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
3 changes: 2 additions & 1 deletion .claude/rules/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ parts of `packages/presentation/ui` (`chat`/`shell`) and `packages/client/workbe
- **Type scale bottoms out at `text-2xs`** (11px — badges, chrome labels); never write ad-hoc pixel sizes like `text-[13px]` — body/secondary/caption are `text-sm`/`text-xs`/`text-2xs`. Below `text-muted-foreground`, dimmer text uses the semantic tiers `text-label-tertiary` (timestamps, weak hints) and `text-label-quaternary` (placeholders, pending edges), never ad-hoc `/NN` opacities (fills like status dots are exempt). Numeric readouts outside `font-mono` take `tabular-nums`.
- **Press feedback on custom controls**: compact independent controls (tabs, icon buttons) get `transition-transform duration-(--motion-fast) active:scale-[0.98]` (icon-only micro buttons: `active:scale-90`); full-width rows mirror their hover colour under `active:` instead of scaling. coss-ui primitives already ship `data-pressed`/`active:` treatments — never restyle those.
- **Long-list rows read the density vars** (`py-(--density-row-py)`; skeletons track the matching `--density-*` height) — the appearance store's `listDensity` flips them via `data-density` on the root. A new list surface opts in with the var, not a fixed `py-2`.
- **Motion reads the token scale**: `duration-(--motion-fast|normal|emphasis)` (150/250/350ms) for CSS transitions, and the exported `SPRING` from `@linkcode/ui` for Motion springs — a new animation picks a tier, never a bare `duration-NNN` or inline spring params; anything slower than `--motion-emphasis` needs a written reason.
- **Motion reads the token scale**: `duration-(--motion-fast|normal|emphasis)` (150/250/350ms) for CSS transitions, and the exported `SPRING` from `@linkcode/ui` for Motion springs — a new animation picks a tier, never a bare `duration-NNN` or inline spring params; anything slower than `--motion-emphasis` needs a written reason. Entrances/exits take `ease-(--motion-ease-out)`, not the built-in `ease-out` (too weak to read at 150ms) and **not** the shell's `cubic-bezier(0.2, 0, 0, 1)` — that one is an emphasized in-out whose zero initial slope belongs on things that morph in place (the pane grid, the composer frame), never on something arriving.
- **Never animate a keyboard-initiated change.** History chords (`⌘[`/`⌘]`) and the palette drive the same state as a click, so a keyed entrance fires on all three; gate it on `useInputModality() === 'pointer'` (`@linkcode/ui`) the way `ThreadTitle` does. CSS animations also need their own `@media (prefers-reduced-motion: reduce)` arm: the app-level `.reduce-motion` class comes from the appearance store, whose default is hard-coded `false` and never reads the OS preference.
- **Routing & layout (webview).** Define routes with `createBrowserRouter` (data router) — no JSX `<Routes>` trees. Build the shell once at the layout level (sidebar/header/content inset); pages render into the outlet and never rebuild chrome. Each page sets its title via the `usePageTitle` hook (the SPA equivalent of Next `metadata`). There is no breadcrumb portal — the current layout has no slot for one; add it (and the corresponding convention here) if a surface needs breadcrumbs.
- **Overlay surfaces (desktop).** A full-page surface layered over the workbench must hide the layer it covers (`invisible` + `inert` on the covered subtree, keeping it mounted): on macOS/Windows both shells are translucent over the native backdrop, so any painted pixels underneath ghost through.
- **Settings mounts differently per app** (mirroring the router split): desktop `settings/settings-view.tsx` is a router-free `fixed inset-0 z-50` overlay rendered **above** the connection gate (reachable with the daemon down to fix a bad URL) — category is `useState`, items `onClick`, closed via the desktop settings store + Escape; webview `routes/settings/settings-layout.tsx` uses react-router (`Link` items + `Outlet`). Both render the shared `SettingsSidebarNav`; its search field is live and app-controlled — the app filters its grouped nav items through `filterSettingsNavGroups` + per-tab keywords from `useSettingsSearchKeywords` (`packages/client/workbench/src/settings/search.ts`, reusing the palette matcher). Don't add react-router to desktop for a new tab; extend the overlay.
Expand Down
19 changes: 5 additions & 14 deletions apps/desktop/src/renderer/src/shell/chrome/chrome.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cn, ShellIconButton } from '@linkcode/ui';
import { cn, ShellIconButton, ThreadTitle } from '@linkcode/ui';
import type { WorkbenchShellHeader, WorkbenchShellNavigation } from '@linkcode/workbench';
import { Popover, PopoverPopup, PopoverTrigger } from 'coss-ui/components/popover';
import { nullthrow } from 'foxact/nullthrow';
Expand All @@ -12,7 +12,7 @@ import {
PanelRightIcon,
Settings2Icon,
} from 'lucide-react';
import { createContext, use, useCallback, useRef, useState, ViewTransition } from 'react';
import { createContext, use, useCallback, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslations } from 'use-intl';
import { useChromeRailInsets } from './use-chrome-rail-insets';
Expand Down Expand Up @@ -546,18 +546,9 @@ function MainChromeTitle({
<span className="mr-1 flex shrink-0 items-center">
{icon ?? <FileTextIcon className="size-4 text-foreground" />}
</span>
{header.sessionId ? (
<ViewTransition
key={header.sessionId}
enter="none"
exit="none"
name={`thread-title-${header.sessionId}`}
>
<span className="min-w-0 flex-1 truncate font-semibold text-sm">{header.title}</span>
</ViewTransition>
) : (
<span className="min-w-0 flex-1 truncate font-semibold text-sm">{header.title}</span>
)}
<ThreadTitle className="min-w-0 flex-1 font-semibold text-sm" sessionId={header.sessionId}>
{header.title}
</ThreadTitle>
{chip}
{menu}
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"@desktop/*": ["./src/*"],
"@renderer/*": ["./src/renderer/src/*"]
},
"types": ["node", "react/canary"],
"types": ["node"],
// The `e2e/*.mts` harnesses run under plain `node`, whose ESM resolver requires the explicit
// file extension on relative imports. Safe here because the base config is `noEmit`.
"allowImportingTsExtensions": true
Expand Down
8 changes: 3 additions & 5 deletions apps/webview/e2e/browser-smoke.e2e.mts
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,9 @@ async function verifyMockEntry(browser: Browser): Promise<void> {
const appErrors: string[] = [];
const page = await browser.newPage();
monitorApplicationErrors(page, server.origin, appErrors);
// This boundary verifies wire prompt/reload recovery, not animation timing. React's
// `<ViewTransition>` still runs `document.startViewTransition` under reduce-motion (the
// preference only collapses the snapshot animations to ~0ms via CSS), so selection is no
// longer synchronous — but near-instant finishes keep a throttled headless tab from holding
// transitions (and the assertions below) open across animation frames.
// This boundary verifies wire prompt/reload recovery, not animation timing: the product's
// reduce-motion fallback collapses the title animations so a throttled headless tab cannot
// hold the assertions below open across animation frames.
await page.addInitScript(() => {
localStorage.setItem(
'linkcode.workbench.appearance:v1',
Expand Down
26 changes: 8 additions & 18 deletions apps/webview/src/shell/web-workbench-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ErrorBadge, ShellFrame, ShellIconButton, TitleStrip } from '@linkcode/ui';
import { ErrorBadge, ShellFrame, ShellIconButton, ThreadTitle, TitleStrip } from '@linkcode/ui';
import type { WorkbenchShellProps } from '@linkcode/workbench';
import {
getResourcesPanelPresentation,
Expand All @@ -12,7 +12,6 @@ import { Card } from 'coss-ui/components/card';
import { Popover, PopoverPopup, PopoverTrigger } from 'coss-ui/components/popover';
import { useMediaQuery } from 'coss-ui/hooks/use-media-query';
import { ChevronLeftIcon, ChevronRightIcon, Settings2Icon, SettingsIcon } from 'lucide-react';
import { ViewTransition } from 'react';
import { Link, useNavigate } from 'react-router';
import { useTranslations } from 'use-intl';

Expand Down Expand Up @@ -78,22 +77,13 @@ export function WebWorkbenchShell({
</ShellIconButton>
<div className="min-w-0">
{/* data-conversation-title is the browser-smoke E2E's header selector. */}
{header.sessionId ? (
<ViewTransition
key={header.sessionId}
enter="none"
exit="none"
name={`thread-title-${header.sessionId}`}
>
<div className="truncate font-medium text-sm" data-conversation-title="">
{header.title}
</div>
</ViewTransition>
) : (
<div className="truncate font-medium text-sm" data-conversation-title="">
{header.title}
</div>
)}
<ThreadTitle
className="font-medium text-sm"
data-conversation-title=""
sessionId={header.sessionId}
>
{header.title}
</ThreadTitle>
{header.subtitle && (
<div className="truncate text-muted-foreground text-xs">{header.subtitle}</div>
)}
Expand Down
3 changes: 1 addition & 2 deletions apps/webview/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
"jsx": "react-jsx",
"paths": {
"@webview/*": ["./src/*"]
},
"types": ["react/canary"]
}
},
"include": ["src"]
}
20 changes: 5 additions & 15 deletions packages/client/workbench/src/surface/shell.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import type { SessionId, TokenUsage } from '@linkcode/schema';
import type { ComposerAttachment, ShellFrameProps } from '@linkcode/ui';
import { ErrorBadge, ShellFrame, TitleStrip } from '@linkcode/ui';
import { ViewTransition } from 'react';
import { ErrorBadge, ShellFrame, ThreadTitle, TitleStrip } from '@linkcode/ui';

export interface WorkbenchShellHeader {
title: string;
subtitle?: string;
usage?: TokenUsage | null;
/** The open conversation, when one is; keys the title's matched-geometry boundary. */
/** The open conversation, when one is; keys the header title so a switch replays its animation. */
sessionId?: SessionId | null;
}

Expand Down Expand Up @@ -73,18 +72,9 @@ function DefaultTitleStrip({
return (
<TitleStrip className="border-border border-b">
<div className="min-w-0">
{header.sessionId ? (
<ViewTransition
key={header.sessionId}
enter="none"
exit="none"
name={`thread-title-${header.sessionId}`}
>
<div className="truncate font-medium text-sm">{header.title}</div>
</ViewTransition>
) : (
<div className="truncate font-medium text-sm">{header.title}</div>
)}
<ThreadTitle className="font-medium text-sm" sessionId={header.sessionId}>
{header.title}
</ThreadTitle>
{header.subtitle && (
<div className="truncate text-muted-foreground text-xs">{header.subtitle}</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,8 @@ export function useWorkbenchSessions(onError: (err: unknown) => void): Workbench
// Session page: every thread stays one click away in the sidebar, so we skip straight to the
// new-thread draft instead of auto-opening an arbitrary recent session (an all-automation list
// has nothing to open either; an explicit automation selection resolves against the full list).
// Deferred for the render path only: zustand updates ride useSyncExternalStore, which never
// enters a transition lane, so this bridge is what activates the `<ViewTransition>` boundaries
// (row title → header) on a switch. Mutations keep using the live store values.
// Deferred for the render path only: the outgoing thread stays painted while the incoming tree
// renders, instead of flashing through an empty surface. Mutations use the live store values.
const deferredSelectedId = useDeferredValue(selectedId);
const draft = explicitDraft ?? (deferredSelectedId === null ? LANDING_DRAFT : null);

Expand Down
3 changes: 1 addition & 2 deletions packages/client/workbench/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"types": ["react/canary"]
"jsx": "react-jsx"
},
"include": ["src"]
}
1 change: 1 addition & 0 deletions packages/presentation/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
export * from './brand/animated-mark';
export * from './chat';
export * from './code-themes';
export * from './input-modality';
export * from './keyboard';
export { cn } from './lib/cn';
export * from './motion';
Expand Down
43 changes: 43 additions & 0 deletions packages/presentation/ui/src/input-modality.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useSyncExternalStore } from 'react';

/** Which device drove the most recent interaction — the signal `:focus-visible` keeps to itself,
* exposed so motion can stay off keyboard-driven changes (a chord repeats far too often to animate). */
export type InputModality = 'pointer' | 'keyboard';

let modality: InputModality | null = null;
const subscribers = new Set<() => void>();

function record(next: InputModality): void {
if (modality === next) return;
modality = next;
for (const notify of subscribers) notify();
}

const onPointerDown = (): void => record('pointer');
const onKeyDown = (): void => record('keyboard');

function subscribe(onStoreChange: () => void): () => void {
if (subscribers.size === 0) {
// Capture: a handler that stops propagation must not hide the modality from us.
document.addEventListener('pointerdown', onPointerDown, { capture: true });
document.addEventListener('keydown', onKeyDown, { capture: true });
}
subscribers.add(onStoreChange);
return () => {
subscribers.delete(onStoreChange);
if (subscribers.size === 0) {
document.removeEventListener('pointerdown', onPointerDown, { capture: true });
document.removeEventListener('keydown', onKeyDown, { capture: true });
}
};
}

function getSnapshot(): InputModality | null {
return modality;
}

/** `null` until the first interaction — callers must read unknown as "not pointer" so nothing
* animates on the first paint. */
export function useInputModality(): InputModality | null {
return useSyncExternalStore(subscribe, getSnapshot);
}
65 changes: 65 additions & 0 deletions packages/presentation/ui/src/shell/__tests__/thread-title.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// @vitest-environment jsdom

import type { SessionId } from '@linkcode/schema';
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { ThreadTitle } from '../shell-control';

const ENTER_CLASS = 'thread-title-enter';

function renderTitle(sessionId: string, title: string) {
return render(
<ThreadTitle data-testid="title" sessionId={sessionId as SessionId}>
{title}
</ThreadTitle>,
);
}

function titleOf(container: HTMLElement): HTMLElement {
const element = container.querySelector('[data-testid="title"]');
if (!(element instanceof HTMLElement)) throw new Error('title not rendered');
return element;
}

afterEach(cleanup);

describe('ThreadTitle', () => {
it('remounts the element on a switch so the entrance can replay', () => {
const { container, rerender } = renderTitle('a', 'first');
fireEvent.pointerDown(document);
const first = titleOf(container);

rerender(
<ThreadTitle data-testid="title" sessionId={'b' as SessionId}>
second
</ThreadTitle>,
);

expect(titleOf(container)).not.toBe(first);
expect(titleOf(container).textContent).toBe('second');
});

it('animates a pointer-driven switch', () => {
const { container } = renderTitle('a', 'first');
fireEvent.pointerDown(document);

expect(titleOf(container).classList.contains(ENTER_CLASS)).toBe(true);
});

it('stays static for a keyboard-driven switch — a history chord repeats too often to animate', () => {
const { container } = renderTitle('a', 'first');
fireEvent.pointerDown(document);
fireEvent.keyDown(document, { key: '[', metaKey: true });

expect(titleOf(container).classList.contains(ENTER_CLASS)).toBe(false);
});

it('re-arms on the next pointer interaction', () => {
const { container } = renderTitle('a', 'first');
fireEvent.keyDown(document, { key: '[', metaKey: true });
expect(titleOf(container).classList.contains(ENTER_CLASS)).toBe(false);

fireEvent.pointerDown(document);
expect(titleOf(container).classList.contains(ENTER_CLASS)).toBe(true);
});
});
22 changes: 22 additions & 0 deletions packages/presentation/ui/src/shell/shell-control.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { SessionId } from '@linkcode/schema';
import { Button } from 'coss-ui/components/button';
import { Kbd } from 'coss-ui/components/kbd';
import { Tooltip, TooltipContent, TooltipTrigger } from 'coss-ui/components/tooltip';
import { useInputModality } from '../input-modality';
import { cn } from '../lib/cn';

export type ShellIconButtonProps = React.ComponentProps<typeof Button> & {
Expand Down Expand Up @@ -71,6 +73,26 @@ export function PanelControlButton({
);
}

export type ThreadTitleProps = React.ComponentProps<'div'> & {
/** The open thread; keys the element internally so a switch remounts it and replays the
* entrance. A rename inside the same thread keeps the key and stays put. */
sessionId?: SessionId | null;
};

/** The header's thread title. The entrance plays only for pointer-driven switches — the history
* chords and the palette repeat far too often to animate. */
export function ThreadTitle({ sessionId, className, ...props }: ThreadTitleProps): React.ReactNode {
const pointerDriven = useInputModality() === 'pointer';

return (
<div
key={sessionId ?? 'none'}
className={cn('truncate', pointerDriven && 'thread-title-enter', className)}
{...props}
/>
);
}

export function TitleStrip({
className,
children,
Expand Down
17 changes: 3 additions & 14 deletions packages/presentation/ui/src/shell/sidebar/thread-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'coss-ui/
import { PreviewCard, PreviewCardTrigger } from 'coss-ui/components/preview-card';
import { SidebarMenuButton, SidebarMenuItem } from 'coss-ui/components/sidebar';
import { ClockIcon, EllipsisIcon, FolderIcon, GitBranchIcon, PinIcon, XIcon } from 'lucide-react';
import { ViewTransition } from 'react';
import { useTranslations } from 'use-intl';
import { AGENT_LABELS, AgentIcon } from '../../chat/agent-icon';
import { cn } from '../../lib/cn';
Expand Down Expand Up @@ -96,19 +95,9 @@ export function ThreadRow({
/>
</span>
{/* data-thread-title is the browser-smoke E2E's row selector, not product styling. */}
{active ? (
// No boundary on the active row: pairing is mount/unmount-based, so this boundary
// unmounting (plain span taking over) is what lets the entering header adopt it.
<span className="min-w-0 flex-1 truncate" data-thread-title={session.sessionId}>
{title}
</span>
) : (
<ViewTransition enter="none" exit="none" name={`thread-title-${session.sessionId}`}>
<span className="min-w-0 flex-1 truncate" data-thread-title={session.sessionId}>
{title}
</span>
</ViewTransition>
)}
<span className="min-w-0 flex-1 truncate" data-thread-title={session.sessionId}>
{title}
</span>
</PreviewCardTrigger>
<SidebarPreviewCardPopup>
<div className="flex min-w-0 flex-1 flex-col gap-2">
Expand Down
Loading