diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d3f836973158..b6514cc59f8a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -69,9 +69,8 @@ "check:test:ui": "npm run test:ui", "check:test:desktop:platforms": "npm run test:desktop:platforms", "check:test:desktop:all": "npm run test:desktop:all", - "check:test:plugins": "node --test src/plugins/*/tests/*.test.mjs", "check:lint": "npm run typecheck && npm run lint", - "check": "npm run check:lint && npm run test:ui && npm run test:desktop:platforms && npm run test:desktop:all && npm run check:test:plugins", + "check": "npm run check:lint && npm run test:ui && npm run test:desktop:platforms && npm run test:desktop:all", "test:e2e": "npm run build && playwright test e2e/", "test:e2e:visual": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list", "test:e2e:update-snapshots": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots", diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 35e9fcfb3739..3764633db6d3 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -13,6 +13,7 @@ import { PR_COMMENT_URL_RE } from '@/lib/chat-runtime' import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' +import { useStoresSelector } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' import { interceptsTypedVoiceStop } from '@/lib/voice-stop-word' import { sessionCompacting } from '@/store/compaction' @@ -23,6 +24,7 @@ import { $hudMode } from '@/store/hud' import { sessionBlockingPrompt } from '@/store/prompts' import { toggleReview } from '@/store/review' import { $gatewayState } from '@/store/session' +import { $botChatSessionIds, $sessionStates, $sessionTiles, isBotChatSession } from '@/store/session-states' import { $threadScrolledUp } from '@/store/thread-scroll' import { $autoSpeakReplies } from '@/store/voice-prefs' import { useTheme } from '@/themes' @@ -945,6 +947,14 @@ export function ChatBar({ handleInputDrop } = useComposerDrop({ cwd, insertInlineRefs, onAttachDroppedItems, requestMainFocus }) + // A bot chat is a companion conversation, not a working session, so it has no + // repo to speak of — see the blank repoPath handed to CodingStatusRow below. + // Three stores: the scope set records the answer, and resolving this runtime + // id to the stored one it is filed under reads the other two. + const botChat = useStoresSelector([$botChatSessionIds, $sessionStates, $sessionTiles], () => + isBotChatSession(sessionId) + ) + // Branch / worktree hand-offs (CodingStatusRow). Owns the worktree open + // branch-off/convert/list/switch actions; draft travels into the new session. const { handleBranchOff, handleConvertBranch, handleListBranches, handleSwitchBranch, openInWorktree } = @@ -1316,7 +1326,10 @@ export function ChatBar({ onOpen={() => toggleReview(scope.target === 'main' ? null : (cwd ?? null), scope.target)} onOpenWorktree={openInWorktree} onSwitchBranch={handleSwitchBranch} - repoPath={cwd} + // Blank in a bot chat: the row hides itself without a repo, + // and stops probing git / GitHub for a surface that has no + // branch to show. Cheaper than a second composer. + repoPath={botChat ? undefined : cwd} />
>> = [] -let sequence = 0 - -function setup(options: { - workspaceMode?: 'sessions' | 'bots' | ((tile: Tile) => 'sessions' | 'bots' | undefined) - workspaceOwnerKey?: string | ((tile: Tile) => string | undefined) -}) { - const source = atom([]) - const prefix = `pane-mirror-scope-${sequence++}` - cleanupSources.push(source) - - paneMirror({ - source, - key: tile => tile.id, - prefix, - minWidth: '10rem', - title: key => key, - render: () => null, - close: () => undefined, - ...options - })() - - return { - source, - contribution: (id: string) => registry.getArea('panes').find(entry => entry.id === `${prefix}:${id}`) - } -} - -afterEach(() => { - for (const source of cleanupSources.splice(0)) { - source.set([]) - } -}) - -describe('paneMirror workspace scope', () => { - it('forwards a static workspace mode', () => { - const mirror = setup({ workspaceMode: 'sessions' }) - mirror.source.set([{ id: 'one' }]) - - expect(mirror.contribution('one')).toMatchObject({ - workspaceMode: 'sessions', - workspaceOwnerKey: undefined - }) - }) - - it('resolves owner callbacks per tile and refreshes an unchanged title', () => { - const mirror = setup({ - workspaceMode: 'bots', - workspaceOwnerKey: tile => tile.owner - }) - - mirror.source.set([{ id: 'one', owner: 'connection-a::default' }]) - expect(mirror.contribution('one')?.workspaceOwnerKey).toBe('connection-a::default') - - mirror.source.set([{ id: 'one', owner: 'connection-b::default' }]) - expect(mirror.contribution('one')?.workspaceOwnerKey).toBe('connection-b::default') - }) - - it('leaves existing callers unscoped when options are omitted', () => { - const mirror = setup({}) - mirror.source.set([{ id: 'one' }]) - - expect(mirror.contribution('one')).toMatchObject({ - workspaceMode: undefined, - workspaceOwnerKey: undefined - }) - }) - - it('keeps an unscoped Browser tile visible in Bot Mode', () => { - const mirror = setup({}) - mirror.source.set([{ id: 'url:browser' }]) - - const pane = mirror.contribution('url:browser') - - expect(contributesToWorkspace(pane, 'sessions')).toBe(true) - expect(contributesToWorkspace(pane, 'bots', 'bot:connection-a::default')).toBe(true) - }) - - it('hides a Sessions-only Browser tile from Bot Mode', () => { - const mirror = setup({ workspaceMode: 'sessions' }) - mirror.source.set([{ id: 'url:browser' }]) - - const pane = mirror.contribution('url:browser') - - expect(contributesToWorkspace(pane, 'sessions')).toBe(true) - expect(contributesToWorkspace(pane, 'bots', 'bot:connection-a::default')).toBe(false) - }) -}) diff --git a/apps/desktop/src/app/chat/pane-mirror.ts b/apps/desktop/src/app/chat/pane-mirror.ts index c0886ff77113..96657794ee34 100644 --- a/apps/desktop/src/app/chat/pane-mirror.ts +++ b/apps/desktop/src/app/chat/pane-mirror.ts @@ -12,23 +12,13 @@ import type { ReactElement, ReactNode, PointerEvent as ReactPointerEvent } from import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store' import type { MenuKit } from '@/components/ui/actions-menu' import { registry } from '@/contrib/registry' -import type { WorkspaceMode } from '@/contrib/types' import type { TileDock } from '@/store/session-states' -type WorkspaceValue = V | ((tile: T) => V | undefined) - -const workspaceValue = (value: WorkspaceValue | undefined, tile: T): V | undefined => - typeof value === 'function' ? (value as (tile: T) => V | undefined)(tile) : value - export interface PaneMirror { /** Reactive source list. */ source: ReadableAtom /** Extra atoms whose changes should re-sync (e.g. titles living elsewhere). */ also?: ReadableAtom[] - /** Workspace surface this tile belongs to. Omit for a global pane. */ - workspaceMode?: WorkspaceValue - /** Exact opaque owner inside Bot Mode. Omit outside an owner-scoped pane. */ - workspaceOwnerKey?: WorkspaceValue /** Stable key + pane-id seed for a tile. */ key: (tile: T) => string /** Pane-id namespace — the id is `${prefix}:${key}`. */ @@ -67,10 +57,7 @@ export interface PaneMirror { /** Build a `watch*` fn: syncs once, then re-syncs on every source/also change. * Module-level state lives in the returned closure, so call it once per app. */ export function paneMirror(cfg: PaneMirror): () => void { - const registered = new Map< - string, - { dispose: () => void; title: string; workspaceMode?: WorkspaceMode; workspaceOwnerKey?: string } - >() + const registered = new Map void; title: string }>() const paneId = (key: string) => `${cfg.prefix}:${key}` @@ -81,17 +68,10 @@ export function paneMirror(cfg: PaneMirror): () => void { for (const tile of tiles) { const key = cfg.key(tile) const title = cfg.title(key) - const workspaceMode = workspaceValue(cfg.workspaceMode, tile) - const workspaceOwnerKey = workspaceValue(cfg.workspaceOwnerKey, tile) const current = registered.get(key) // register() replaces same-id in place — safe for live title refreshes. - if ( - current && - current.title === title && - current.workspaceMode === workspaceMode && - current.workspaceOwnerKey === workspaceOwnerKey - ) { + if (current && current.title === title) { continue } @@ -119,12 +99,10 @@ export function paneMirror(cfg: PaneMirror): () => void { tabMenuPrefix: cfg.tabMenuPrefix?.(key), tabWrap: cfg.tabWrap ? (tab: ReactElement) => cfg.tabWrap!(key, tab) : undefined }, - render: () => cfg.render(key), - workspaceMode, - workspaceOwnerKey + render: () => cfg.render(key) }) - registered.set(key, { dispose, title, workspaceMode, workspaceOwnerKey }) + registered.set(key, { dispose, title }) if (!current) { registerPaneCloser(paneId(key), () => cfg.close(key)) diff --git a/apps/desktop/src/app/chat/preview-tile.test.ts b/apps/desktop/src/app/chat/preview-tile.test.ts index 51073def256e..d5cea44709f5 100644 --- a/apps/desktop/src/app/chat/preview-tile.test.ts +++ b/apps/desktop/src/app/chat/preview-tile.test.ts @@ -8,7 +8,6 @@ vi.mock('./right-rail/preview-console-store', () => ({ forgetPreviewConsole: () => undefined })) -import { contributesToWorkspace } from '@/components/pane-shell/workspace-scope' import { registry } from '@/contrib/registry' import { $previewTabs, closeRightRail, noteBrowserPage, openPreview } from '@/store/preview' @@ -22,28 +21,6 @@ afterEach(() => { closeRightRail() }) -// By prefix, not by a literal id: a Browser tab's id is minted per tab now -// that there can be more than one of them. -function browserPane() { - return registry.getArea('panes').find(entry => entry.id.startsWith('preview-tile:url:')) -} - -describe('preview tiles in Bot Mode', () => { - it('registers the in-app Browser as a global pane so Bot Mode can show it', () => { - openPreview( - { kind: 'url', label: 'example.com', source: 'https://example.com', url: 'https://example.com' }, - 'explicit-link' - ) - - const pane = browserPane() - - expect(pane).toBeTruthy() - expect(pane?.workspaceMode).toBeUndefined() - expect(contributesToWorkspace(pane, 'sessions')).toBe(true) - expect(contributesToWorkspace(pane, 'bots', 'bot:connection-a::default')).toBe(true) - }) -}) - describe('browserTabLabel', () => { const target = { kind: 'url', label: 'Browser', source: 'about:blank', url: 'about:blank' } as const diff --git a/apps/desktop/src/app/chat/route-tile.tsx b/apps/desktop/src/app/chat/route-tile.tsx index f2af03527a24..1388f48c0425 100644 --- a/apps/desktop/src/app/chat/route-tile.tsx +++ b/apps/desktop/src/app/chat/route-tile.tsx @@ -86,7 +86,6 @@ function RouteTilePane({ path }: { path: string }) { /** Keep pane contributions mirroring `$routeTiles`. Call once from the root. */ export const watchRouteTiles = paneMirror({ source: $routeTiles, - workspaceMode: 'sessions', key: t => t.path, prefix: 'route-tile', dir: t => t.dir, diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 7678cd13b9d3..d58c4584fe96 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -646,8 +646,6 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) * `$sessions`). Tiles dock against main on the chosen edge, flex width. */ export const watchSessionTiles = paneMirror({ source: $sessionTiles, - workspaceMode: tile => tile.workspaceMode ?? 'sessions', - workspaceOwnerKey: tile => tile.workspaceOwnerKey, // $projectTree: a tile whose session is older than the recents page resolves // its title through the tree, which loads after the tiles register. (The tab's // status dot subscribes to color/state itself, so it needs no `also` entry.) diff --git a/apps/desktop/src/app/chat/sidebar/chrome.tsx b/apps/desktop/src/app/chat/sidebar/chrome.tsx index 56887360046e..3b6d90c9bb9e 100644 --- a/apps/desktop/src/app/chat/sidebar/chrome.tsx +++ b/apps/desktop/src/app/chat/sidebar/chrome.tsx @@ -9,6 +9,14 @@ import { compactNumber } from '@/lib/format' import { cn } from '@/lib/utils' import { $sidebarRowMeta } from '@/store/layout' +import { + SIDEBAR_ROW_INSET, + SIDEBAR_ROW_LABEL, + SIDEBAR_ROW_LEAD, + SIDEBAR_ROW_MIN_H, + SIDEBAR_ROW_PAD_TRAIL +} from './row-geometry' + // Shared, content-agnostic sidebar chrome — used by both the flat session // sections and the project/workspace tree, so it lives outside either to keep // imports one-directional (no index <-> projects cycle). @@ -18,39 +26,10 @@ export function SidebarSectionMeta({ children }: { children: React.ReactNode }) return {children} } -// ── Row geometry (session row is canonical — everything composes these) ───── -// -// Height lives ONLY on SidebarRowShell (min-h-[1.625rem]). Inset children -// stretch to fill the cell and center content internally — never items-center -// on the shell grid, or short clusters (projects) float 1–2px off sessions. -// -// `rowPadX` is the BODY's padding: the lead's inset, plus the gap the label -// keeps from the actions column, both inside the row's click target. -// `rowPadTrail` is the row's own trailing inset and belongs to the SHELL — the -// only box containing both the actions column AND the card's in-body cluster, -// so one class insets every trailing thing a row can render. Owned anywhere -// else, the age / chips / kebab sit flush on the border box, which is exactly -// where a working row paints its arc (`.arc-row` has zero standoff) — the ring -// ran through the text. - -const rowMinH = 'min-h-[1.625rem]' -const rowPadX = 'pl-2 pr-2' -const rowPadTrail = 'pr-2' -const rowGap = 'gap-1.5' -const rowLead = 'grid size-3.5 shrink-0 place-items-center' -const rowInset = cn(rowPadX, rowGap, 'flex h-full min-w-0 items-center self-stretch py-0.5') -// `truncate` is overflow:hidden. `leading-none` (line-height: 1) makes the -// line box equal the em-square, so glyph ink that sticks out — Segoe UI on -// Windows is ~1.33em — gets shaved. 1.35 leaves room; the shell still owns -// row height, so the extra leading just centers. -export const SIDEBAR_TRUNCATED_LEADING = 'leading-[1.35]' as const -const rowLabel = cn('min-w-0 truncate text-[0.8125rem] text-(--ui-text-secondary)', SIDEBAR_TRUNCATED_LEADING) - -/** Inbox-style card (workspace + age, title + preview, model + size). */ -export const SIDEBAR_ROW_CARD_MIN_H = 'min-h-[3.375rem]' as const - -/** Codicon size in sidebar row leads — matches the file tree (`tree.tsx`). */ -export const SIDEBAR_LEAD_ICON_SIZE = '0.875rem' as const +// Row geometry lives in `row-geometry.ts` — see that file for why each class +// belongs to the box it belongs to. Re-exported here because this module is +// where callers already look for row chrome. +export { SIDEBAR_LEAD_ICON_SIZE, SIDEBAR_ROW_CARD_MIN_H, SIDEBAR_TRUNCATED_LEADING } from './row-geometry' /** Vertical stack of rows (gap-px, single column). */ export function SidebarRowStack({ className, ...props }: React.ComponentProps<'div'>) { @@ -100,7 +79,12 @@ export function SidebarRowShell({ }: React.ComponentProps<'div'> & { actions?: React.ReactNode; actionsClassName?: string }) { return (
{children} @@ -115,12 +99,12 @@ export function SidebarRowShell({ /** Multi-control left cluster (project rows). */ export function SidebarRowCluster({ className, ...props }: React.ComponentProps<'div'>) { - return
+ return
} /** Session row main tap target. */ export function SidebarRowBody({ className, ...props }: React.ComponentProps<'button'>) { - return + return } /** Tappable label — underline/truncate live on the inner span, not the button. */ @@ -132,19 +116,19 @@ export function SidebarRowLink({ }: React.ComponentProps<'button'> & { labelClassName?: string }) { return ( - {children} + {children} ) } /** Fixed leading column (dot, icon, drag handle). */ export function SidebarRowLead({ className, ...props }: React.ComponentProps<'span'>) { - return + return } /** Standard row label typography. */ export function SidebarRowLabel({ className, ...props }: React.ComponentProps<'span'>) { - return + return } /** What a group's sessions add up to, for the Show options that count something. */ diff --git a/apps/desktop/src/app/chat/sidebar/connection-glyph.tsx b/apps/desktop/src/app/chat/sidebar/connection-glyph.tsx index f553d7f6f09a..4fd338145ae5 100644 --- a/apps/desktop/src/app/chat/sidebar/connection-glyph.tsx +++ b/apps/desktop/src/app/chat/sidebar/connection-glyph.tsx @@ -1,11 +1,21 @@ import type { DesktopRegistryConnection } from '@/global' import { Cloud, Monitor, Network, Terminal } from '@/lib/icons' +import { cn } from '@/lib/utils' + +import { SIDEBAR_ROW_LEAD } from './row-geometry' // One glyph per connection kind — device, cloud, network, terminal — shared by -// the statusbar switcher, its menu, and the fleet profile rail so a gateway -// looks the same wherever it is named. Dependency-free on purpose (icons and -// a type only) so light components can use it without pulling in stores. -export function ConnectionGlyph({ connection }: { connection: Pick }) { +// the statusbar switcher, its menu, the fleet profile rail and the Bots rail so +// a gateway looks the same wherever it is named. Dependency-free on purpose +// (icons, a type and class strings) so light components can use it without +// pulling in stores. +export function ConnectionGlyph({ + className, + connection +}: { + className?: string + connection: Pick +}) { const Icon = connection.kind === 'local' ? Monitor @@ -18,7 +28,7 @@ export function ConnectionGlyph({ connection }: { connection: Pick