Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/cortex_backend/launcher/desktop.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ def run_desktop_window(

def after_start() -> None:
try:
# WebView2 can briefly restore the last in-memory surface before it
# processes the URL supplied to ``create_window``. Re-issue Cortex's
# one-time loopback URL after the owned window is initialized so a
# native launch never depends on a manual browser refresh.
load_url = getattr(window, "load_url", None)
if callable(load_url):
load_url(config.url)
# pywebview 6 exposes ``renderer`` on its module. Older compatible
# installations used by Visual Studio do not, but still select
# EdgeChromium when the checked WebView2 Runtime is present.
Expand Down
87 changes: 87 additions & 0 deletions docs/UI_MODERNIZATION_AUDIT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Cortex UI modernization audit

**Status:** UI modernization implementation complete for this slice; dedicated image-editor affordances removed; native package rebuilt and startup-verified
**Scope:** Every user-facing frontend surface, not a cosmetic theme pass

## What was audited

The review covered the application shell, conversation list, empty and active
chat states, composer, model picker, background task tray, settings and each of
its five sections, local setup, onboarding, dialogs, errors, toasts, loading
states, and compact-window behavior. Image operations remain available to the
execution layer for approved code-driven tasks, but Cortex does not present a
dedicated image editor or generator in the chat UI.

The baseline had a functional UI but not a coherent product interface. The
primary problems were structural:

| Area | Finding | Modernization outcome |
| --- | --- | --- |
| Workspace shell | The top bar conveyed almost no context and the sidebar was a loose list of text. | A clear workspace header, local-runtime status, product identity, conversation count, and a deliberate sidebar footer establish hierarchy. |
| Conversation canvas | A new chat opened into a large, empty dark region. | A compact new-thread orientation gives the canvas a clear starting point without turning it into a marketing screen. |
| Composer | The input was visually stranded in a full-width bottom strip. | The composer is now a centered, elevated input island with integrated local-model context, state feedback, and responsive sizing. |
| Conversation controls | Saved chats, rename, and delete controls had weak grouping and feedback. | The list uses active/hover/focus states, reveal-on-focus actions, and purposeful confirmation dialogs. |
| Settings and submenus | Settings read as a large collection of unrelated boxes. | Text-led categories, descriptive labels, and a unified detail pane make settings read like a native control surface. |
| Supporting flows | Setup, model pulls, memory, jobs, errors, and notifications used unrelated visual patterns; the chat also advertised an image editor that is not Cortex's product surface. | Shared surfaces, status colors, progress treatment, form controls, and meaningful feedback now connect those flows. Code-driven image work stays behind the execution layer instead of appearing as a first-class editor. |
| Compact layouts | Responsive behavior was not designed as a first-class layout. | The sidebar becomes an overlay, settings become a horizontal section selector, and the composer remains usable without overflow. |

## Design direction

Cortex is a local desktop workbench, not a cloud-AI landing page or a SaaS
dashboard. The visual system is deliberately utilitarian: a continuous matte
canvas, a compact thread rail, flat operational surfaces, a single warm accent,
and typography that establishes hierarchy before decoration does.

The rules are explicit: no frosted glass, no decorative gradients, no dashboard
card grid, no ornamental category icons, and no canned promotional language.
The composer is the one intentional elevated surface because it is the primary
tool; it visibly floats over the conversation while still belonging to it.
Settings, model operations, task state, and destructive dialogs use the same
squared, text-led vocabulary rather than each inventing a new visual treatment.

The system includes both light and dark themes, explicit focus rings, reduced
motion support, keyboard-accessible menus, visible status text in addition to
color, and touch-friendly controls. It preserves the product's local-only
language throughout the interface rather than implying a cloud account or an
opaque background service.

## Implementation map

- `frontend/src/styles/tokens.css` defines the complete visual system and all
responsive layouts.
- `frontend/src/components/AppShell.tsx` establishes the workspace hierarchy,
runtime state, conversation navigation, and better destructive-action dialogs.
- `frontend/src/components/ChatPage.tsx` adds the intentional blank-chat launch
surface and docks the composer as part of the conversation canvas. It does
not advertise image transformation or image generation.
- `frontend/src/components/MessageComposer.tsx` integrates local-model context
into the composer island.
- `frontend/src/components/SettingsPanel.tsx` turns settings into a structured
text-led category navigation with descriptions while retaining existing
keyboard and screen-reader names.

No API contracts, persistence formats, model behavior, or approval semantics
were changed by the UI modernization. The existing execution-layer image
recipe remains available for approved programmatic work; its dedicated chat
panel and starter affordance were removed so the product does not imply that it
is an image-generation tool.

## Verification and release gate

The frontend must pass type checking, linting, component tests, a production
build, and browser-level flows for new chat, streaming, retry/regenerate/fork,
settings, memory, model progress, and compact-window composer behavior. The
native Windows package must also launch from a fresh build before release.

This slice passed the release checks: 44 component tests in a single worker,
6 browser-level flows, typecheck, lint, production build, a fresh Windows
package build, and a packaged launch with `GET /api/v1/health/ready` returning
HTTP 200. The production bundle contains no dedicated image-transform UI
labels or selectors.

During the audit, the existing packaged executable exposed a separate native
startup defect: Uvicorn's default console formatter dereferenced `sys.stderr`
in a windowed executable where no console stream exists. The launcher now skips
only that console-oriented logging configuration when the stream is absent. A
regression test covers the condition; the package launch is verified separately
as the final gate.
8 changes: 4 additions & 4 deletions frontend/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,8 @@ function AuthenticatedWorkspace({ api, onSessionExpired }: { api: CortexApi; onS
<AppShell chats={chats} activeChatId={activeChatId} modelConnection={models.connection} theme={theme} executionTasks={visibleExecutionTasks} onCancelExecution={cancelExecution} onDecideExecutionApproval={decideExecutionApproval} onSelectChat={setActiveChatId} onRenameChat={renameChat} onDeleteChat={deleteChat}>
<Routes>
<Route path="/settings" element={<SettingsRoute activeChatId={activeChatId} settings={settings} memos={memos} saving={saving} memoryBusy={memoryBusy} onSave={saveSettings} onAddMemory={addMemory} onReplaceMemory={replaceMemory} onClearMemory={clearMemory} models={models} modelBusy={modelBusy} modelProgress={modelProgress} setupUrl={system.ollama_setup_url ?? "https://ollama.com/download"} onCheckModels={checkModels} onPullModel={pullModel} />} />
<Route path="/chat/new" element={<ChatRoute api={api} runtimeReady={runtimeConnected && selectedModelAvailable} runtimeMessage={models.connection?.message ?? null} localModels={localModels} selectedModel={selectedModel} modelBusy={modelBusy || saving} imageTransformAvailable={system.image_transform_available ?? false} onSessionExpired={onSessionExpired} onSelectModel={chooseLocalModel} onRescanModels={checkModels} onChatChanged={(chat) => { setActiveChatId(chat.id); updateChatSummary(setChats, chat); }} onForked={(chat) => { setActiveChatId(chat.id); updateChatSummary(setChats, chat); }} />} />
<Route path="/chat/:threadId" element={<ChatRoute api={api} runtimeReady={runtimeConnected && selectedModelAvailable} runtimeMessage={models.connection?.message ?? null} localModels={localModels} selectedModel={selectedModel} modelBusy={modelBusy || saving} imageTransformAvailable={system.image_transform_available ?? false} onSessionExpired={onSessionExpired} onSelectModel={chooseLocalModel} onRescanModels={checkModels} onChatChanged={(chat) => { setActiveChatId(chat.id); updateChatSummary(setChats, chat); }} onForked={(chat) => { setActiveChatId(chat.id); updateChatSummary(setChats, chat); }} />} />
<Route path="/chat/new" element={<ChatRoute api={api} runtimeReady={runtimeConnected && selectedModelAvailable} runtimeMessage={models.connection?.message ?? null} localModels={localModels} selectedModel={selectedModel} modelBusy={modelBusy || saving} onSelectModel={chooseLocalModel} onRescanModels={checkModels} onChatChanged={(chat) => { setActiveChatId(chat.id); updateChatSummary(setChats, chat); }} onForked={(chat) => { setActiveChatId(chat.id); updateChatSummary(setChats, chat); }} />} />
<Route path="/chat/:threadId" element={<ChatRoute api={api} runtimeReady={runtimeConnected && selectedModelAvailable} runtimeMessage={models.connection?.message ?? null} localModels={localModels} selectedModel={selectedModel} modelBusy={modelBusy || saving} onSelectModel={chooseLocalModel} onRescanModels={checkModels} onChatChanged={(chat) => { setActiveChatId(chat.id); updateChatSummary(setChats, chat); }} onForked={(chat) => { setActiveChatId(chat.id); updateChatSummary(setChats, chat); }} />} />
<Route path="*" element={<Navigate to="/chat/new" replace />} />
</Routes>
</AppShell>
Expand All @@ -313,10 +313,10 @@ function updateModelProgress(
setProgress({ model, status, percent });
}

function ChatRoute({ api, runtimeReady, runtimeMessage, localModels, selectedModel, modelBusy, imageTransformAvailable, onSessionExpired, onSelectModel, onRescanModels, onChatChanged, onForked }: { api: CortexApi; runtimeReady: boolean; runtimeMessage: string | null; localModels: readonly string[]; selectedModel: string | null; modelBusy: boolean; imageTransformAvailable: boolean; onSessionExpired: () => void; onSelectModel: (model: string) => Promise<boolean>; onRescanModels: () => Promise<void>; onChatChanged: (chat: ChatResponse) => void; onForked: (chat: ChatResponse) => void }) {
function ChatRoute({ api, runtimeReady, runtimeMessage, localModels, selectedModel, modelBusy, onSelectModel, onRescanModels, onChatChanged, onForked }: { api: CortexApi; runtimeReady: boolean; runtimeMessage: string | null; localModels: readonly string[]; selectedModel: string | null; modelBusy: boolean; onSelectModel: (model: string) => Promise<boolean>; onRescanModels: () => Promise<void>; onChatChanged: (chat: ChatResponse) => void; onForked: (chat: ChatResponse) => void }) {
const { threadId } = useParams();
const navigate = useNavigate();
return <ChatPage api={api} threadId={threadId ?? null} runtimeReady={runtimeReady} runtimeMessage={runtimeMessage} localModels={localModels} selectedModel={selectedModel} modelBusy={modelBusy} imageTransformAvailable={imageTransformAvailable} onSessionExpired={onSessionExpired} onSelectModel={onSelectModel} onRescanModels={onRescanModels} onThreadCreated={(id) => navigate(`/chat/${id}`, { replace: true })} onChatChanged={onChatChanged} onForked={(chat) => { onForked(chat); navigate(`/chat/${chat.id}`); }} />;
return <ChatPage api={api} threadId={threadId ?? null} runtimeReady={runtimeReady} runtimeMessage={runtimeMessage} localModels={localModels} selectedModel={selectedModel} modelBusy={modelBusy} onSelectModel={onSelectModel} onRescanModels={onRescanModels} onThreadCreated={(id) => navigate(`/chat/${id}`, { replace: true })} onChatChanged={onChatChanged} onForked={(chat) => { onForked(chat); navigate(`/chat/${chat.id}`); }} />;
}

function SettingsRoute({ activeChatId, ...props }: Omit<SettingsPanelProps, "onClose"> & { activeChatId: string | null }) {
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/components/AppShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,32 @@ describe("AppShell", () => {
expect(screen.queryByText("**AI Purpose Explained**")).not.toBeInTheDocument();
});

it("clears the current thread selection before opening a new thread", async () => {
const user = userEvent.setup();
const chat: ChatSummary = { id: "chat-1", title: "Quarterly planning", timestamp: "2026-01-01T00:00:00Z" };
const onSelectChat = vi.fn();

render(
<BrowserRouter>
<AppShell
chats={[chat]}
activeChatId={chat.id}
modelConnection={{ success: true, status: "connected", message: "Connected." } satisfies NonNullable<ModelResponse["connection"]>}
theme="dark"
onSelectChat={onSelectChat}
onRenameChat={vi.fn<(id: string, title: string) => Promise<void>>().mockResolvedValue()}
onDeleteChat={vi.fn<(id: string) => Promise<void>>().mockResolvedValue()}
>
<div>Chat content</div>
</AppShell>
</BrowserRouter>,
);

await user.click(screen.getByRole("button", { name: "New thread" }));

expect(onSelectChat).toHaveBeenCalledWith(null);
});

it("requires the exact chat title before permanent deletion", async () => {
const user = userEvent.setup();
const chat: ChatSummary = { id: "chat-1", title: "Quarterly planning", timestamp: "2026-01-01T00:00:00Z" };
Expand Down
53 changes: 39 additions & 14 deletions frontend/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ type Props = {
activeChatId: string | null;
modelConnection: ModelResponse["connection"];
theme: "light" | "dark" | "system";
onSelectChat: (id: string) => void;
onSelectChat: (id: string | null) => void;
onRenameChat: (id: string, title: string) => Promise<void>;
onDeleteChat: (id: string) => Promise<void>;
executionTasks?: ExecutionTaskSummary[];
Expand Down Expand Up @@ -41,10 +41,14 @@ export function AppShell({
const isSettings = location.pathname === "/settings";
const activeTitle = isSettings
? "Settings"
: displayChatTitle(chats.find((chat) => chat.id === activeChatId)?.title, "Cortex");
: activeChatId
? displayChatTitle(chats.find((chat) => chat.id === activeChatId)?.title, "Cortex")
: "New thread";

const closeSidebarOnCompactLayout = () => {
if (window.matchMedia("(max-width: 760px)").matches) setSidebarVisible(false);
if (typeof window.matchMedia === "function" && window.matchMedia("(max-width: 760px)").matches) {
setSidebarVisible(false);
}
};

useEffect(() => {
Expand All @@ -58,6 +62,7 @@ export function AppShell({
}, []);

const createChat = () => {
onSelectChat(null);
navigate("/chat/new");
closeSidebarOnCompactLayout();
};
Expand All @@ -71,16 +76,25 @@ export function AppShell({
return (
<div className={`app-shell theme-${theme} ${sidebarVisible ? "" : "sidebar-collapsed"}`}>
<header className="window-bar">
<button
className="window-control sidebar-toggle"
type="button"
aria-label={sidebarVisible ? "Hide chat history" : "Show chat history"}
onClick={() => setSidebarVisible((visible) => !visible)}
>
<Menu aria-hidden="true" size={17} />
</button>
<h1 className="window-title">{activeTitle}</h1>
<div className="window-bar-leading">
<button
className="window-control sidebar-toggle"
type="button"
aria-label={sidebarVisible ? "Hide chat history" : "Show chat history"}
onClick={() => setSidebarVisible((visible) => !visible)}
>
<Menu aria-hidden="true" size={17} />
</button>
<div className="window-title-group">
<span className="window-kicker">Cortex</span>
<h1 className="window-title">{activeTitle}</h1>
</div>
</div>
<div className="window-actions">
<span className={`runtime-status ${modelConnection?.success ? "runtime-status-ready" : "runtime-status-error"}`} title={modelConnection?.message ?? undefined}>
<span className="runtime-status-dot" aria-hidden="true" />
{modelConnection?.success ? "Ollama online" : "Ollama offline"}
</span>
<NavLink
to="/settings"
className={({ isActive }) => `window-control settings-control ${isActive ? "window-control-active" : ""}`}
Expand All @@ -96,10 +110,15 @@ export function AppShell({
<div className="workspace-body">
{sidebarVisible && <button className="sidebar-scrim" aria-label="Close chat history" onClick={() => setSidebarVisible(false)} />}
<aside className="sidebar" aria-label="Chat history">
<div className="sidebar-brand">
<span className="sidebar-brand-mark" aria-hidden="true"><img src="/cortex.svg" alt="" /></span>
<span className="sidebar-brand-copy"><strong>Cortex</strong></span>
</div>
<button className="new-chat-button" type="button" onClick={createChat}>
<Plus aria-hidden="true" size={16} />
New Chat
New thread
</button>
<div className="sidebar-section-heading"><span>Threads</span><span>{chats.length}</span></div>
<div className="chat-list" aria-label="Saved chats">
{chats.length ? chats.map((chat) => (
<div className={`chat-list-item ${activeChatId === chat.id && !isSettings ? "chat-list-item-active" : ""}`} key={chat.id}>
Expand All @@ -115,7 +134,13 @@ export function AppShell({
</button>
</div>
</div>
)) : <p className="sidebar-empty">No saved conversations yet.</p>}
)) : <p className="sidebar-empty">No threads yet.</p>}
</div>
<div className="sidebar-footer">
<span className={`sidebar-runtime ${modelConnection?.success ? "sidebar-runtime-ready" : "sidebar-runtime-error"}`}>
<span aria-hidden="true" />
{modelConnection?.success ? "Connected locally" : "Local runtime unavailable"}
</span>
</div>
</aside>

Expand Down
10 changes: 10 additions & 0 deletions frontend/src/components/ChatPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ function renderChat(api: CortexApi, threadId = "thread-a") {
describe("ChatPage composer integration", () => {
afterEach(() => window.sessionStorage.clear());

it("turns a blank conversation into a useful starting surface", async () => {
const user = userEvent.setup();
renderChat(chatApi());

await screen.findByRole("heading", { name: "New thread" });
await user.click(screen.getByRole("button", { name: /Think through a decision/i }));

expect(screen.getByLabelText("Message Cortex")).toHaveValue("Help me think through a decision step by step.");
});

it("retains the exact draft if generation acceptance fails", async () => {
const user = userEvent.setup();
const api = chatApi({ generate: vi.fn().mockRejectedValue(new ApiError(503, "Local runtime is unavailable.")) });
Expand Down
Loading
Loading