Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b2e5b1d
refactor(desktop): convert Bot Mode from hand-written jsx() calls to TSX
OutThisLife Aug 27, 2026
5afa487
refactor(desktop): rebuild Bot Mode on the app's design system
OutThisLife Aug 27, 2026
4fcd16e
feat(desktop): widen the plugin SDK to what Bot Mode had to reinvent
OutThisLife Aug 27, 2026
35b53af
fix(desktop): stop the main zone vanishing behind Bot Mode
OutThisLife Aug 27, 2026
67854b5
fix(desktop): bot chats share core's unread and drop the branch rail
OutThisLife Aug 27, 2026
01a3e9a
fix(cli): give a profile created without a clone a usable model block
OutThisLife Aug 27, 2026
4517621
feat(desktop): let a plugin title an empty chat it owns
OutThisLife Aug 28, 2026
e4bd1a0
feat(desktop): give a bot's empty chat its own face and name
OutThisLife Aug 28, 2026
d3df1a3
test(desktop): port the bot-mode suite off the .mjs vm harness
OutThisLife Aug 28, 2026
32ca343
fix(desktop): /new inside a bot chat compared against a property that…
OutThisLife Aug 28, 2026
0fececa
chore(desktop): lint the bot-mode modules clean
OutThisLife Aug 28, 2026
610d1ed
refactor(desktop): give the sidebar row geometry and a bounded cache …
OutThisLife Aug 28, 2026
ff5c5b4
refactor(desktop): compose the shared lead cell instead of copying it
OutThisLife Aug 28, 2026
7165645
fix(desktop): bound the two bot-rail caches that grew for the window'…
OutThisLife Aug 28, 2026
fa236b3
i18n(desktop): localize the strings the bot rail still hardcoded
OutThisLife Aug 28, 2026
99cae5b
refactor(desktop): put the bot dialogs' one-offs on the shared primit…
OutThisLife Aug 28, 2026
d7f6ef8
fix(desktop): three latent bugs in the bot rail, and the dead declara…
OutThisLife Aug 28, 2026
355be02
style(desktop): prettier over this branch's own eslint --fix output
OutThisLife Aug 28, 2026
531a8cd
revert(lint): keep the shared eslint configs out of this branch
OutThisLife Aug 28, 2026
bbcf569
Merge origin/main into bb/bot-mode-design-system
OutThisLife Aug 28, 2026
6559448
Merge origin/main into bb/bot-mode-design-system
OutThisLife Aug 28, 2026
008bc18
fix(desktop): a bot row click returns to its open tabs instead of re-…
OutThisLife Aug 28, 2026
8ab34a7
fix(desktop): staleness-probe the adopt-on-conflict canonical open
OutThisLife Aug 28, 2026
19ff8e6
test(desktop): port the bot-meta v1 -> v2 migration suite
OutThisLife Aug 28, 2026
0f4d4d4
fix(desktop): subscribe the bot-chat flag to every store it reads
OutThisLife Aug 28, 2026
832ec82
fix(desktop): mount every chat.empty contributor, not just the first
OutThisLife Aug 28, 2026
71afc81
docs(desktop): record why the Bots-home new-chat refusal is gone
OutThisLife Aug 28, 2026
387c73b
i18n(desktop): translate Bot Mode into ja, zh, and zh-hant (#96878)
OutThisLife Aug 28, 2026
d22e8e4
fix(bots): restore the #97008 session contracts on the rebuilt modules
teknium1 Aug 28, 2026
a7db531
i18n(desktop): move the Bot Mode kickoff and the residual owned strin…
OutThisLife Aug 28, 2026
ebb2091
fix(bots): scope a freshly created bot chat to the bots workspace
OutThisLife Aug 28, 2026
7584260
docs(bots): name the room budget as the seam the limits PRs hook
OutThisLife Aug 28, 2026
21cc469
Merge remote-tracking branch 'origin/main' into bb/bot-mode-design-sy…
OutThisLife Aug 28, 2026
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: 1 addition & 2 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 14 additions & 1 deletion apps/desktop/src/app/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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 } =
Expand Down Expand Up @@ -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}
/>
<div
className={cn(
Expand Down
101 changes: 0 additions & 101 deletions apps/desktop/src/app/chat/pane-mirror.test.ts

This file was deleted.

30 changes: 4 additions & 26 deletions apps/desktop/src/app/chat/pane-mirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T, V> = V | ((tile: T) => V | undefined)

const workspaceValue = <T, V>(value: WorkspaceValue<T, V> | undefined, tile: T): V | undefined =>
typeof value === 'function' ? (value as (tile: T) => V | undefined)(tile) : value

export interface PaneMirror<T> {
/** Reactive source list. */
source: ReadableAtom<T[]>
/** Extra atoms whose changes should re-sync (e.g. titles living elsewhere). */
also?: ReadableAtom<unknown>[]
/** Workspace surface this tile belongs to. Omit for a global pane. */
workspaceMode?: WorkspaceValue<T, WorkspaceMode>
/** Exact opaque owner inside Bot Mode. Omit outside an owner-scoped pane. */
workspaceOwnerKey?: WorkspaceValue<T, string>
/** Stable key + pane-id seed for a tile. */
key: (tile: T) => string
/** Pane-id namespace — the id is `${prefix}:${key}`. */
Expand Down Expand Up @@ -67,10 +57,7 @@ export interface PaneMirror<T> {
/** 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<T>(cfg: PaneMirror<T>): () => void {
const registered = new Map<
string,
{ dispose: () => void; title: string; workspaceMode?: WorkspaceMode; workspaceOwnerKey?: string }
>()
const registered = new Map<string, { dispose: () => void; title: string }>()

const paneId = (key: string) => `${cfg.prefix}:${key}`

Expand All @@ -81,17 +68,10 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => 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
}

Expand Down Expand Up @@ -119,12 +99,10 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => 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))
Expand Down
23 changes: 0 additions & 23 deletions apps/desktop/src/app/chat/preview-tile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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

Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/app/chat/route-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ function RouteTilePane({ path }: { path: string }) {
/** Keep pane contributions mirroring `$routeTiles`. Call once from the root. */
export const watchRouteTiles = paneMirror<RouteTile>({
source: $routeTiles,
workspaceMode: 'sessions',
key: t => t.path,
prefix: 'route-tile',
dir: t => t.dir,
Expand Down
2 changes: 0 additions & 2 deletions apps/desktop/src/app/chat/session-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionTile>({
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.)
Expand Down
62 changes: 23 additions & 39 deletions apps/desktop/src/app/chat/sidebar/chrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -18,39 +26,10 @@ export function SidebarSectionMeta({ children }: { children: React.ReactNode })
return <span className="shrink-0 text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
}

// ── 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'>) {
Expand Down Expand Up @@ -100,7 +79,12 @@ export function SidebarRowShell({
}: React.ComponentProps<'div'> & { actions?: React.ReactNode; actionsClassName?: string }) {
return (
<div
className={cn(rowMinH, rowPadTrail, 'grid grid-cols-[minmax(0,1fr)_auto] items-stretch rounded-md', className)}
className={cn(
SIDEBAR_ROW_MIN_H,
SIDEBAR_ROW_PAD_TRAIL,
'grid grid-cols-[minmax(0,1fr)_auto] items-stretch rounded-md',
className
)}
{...props}
>
{children}
Expand All @@ -115,12 +99,12 @@ export function SidebarRowShell({

/** Multi-control left cluster (project rows). */
export function SidebarRowCluster({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn(rowInset, className)} {...props} />
return <div className={cn(SIDEBAR_ROW_INSET, className)} {...props} />
}

/** Session row main tap target. */
export function SidebarRowBody({ className, ...props }: React.ComponentProps<'button'>) {
return <RowButton className={cn(rowInset, 'bg-transparent text-left', className)} {...props} />
return <RowButton className={cn(SIDEBAR_ROW_INSET, 'bg-transparent text-left', className)} {...props} />
}

/** Tappable label — underline/truncate live on the inner span, not the button. */
Expand All @@ -132,19 +116,19 @@ export function SidebarRowLink({
}: React.ComponentProps<'button'> & { labelClassName?: string }) {
return (
<RowButton className={cn('min-w-0 shrink bg-transparent p-0 text-left', className)} {...props}>
<span className={cn(rowLabel, labelClassName)}>{children}</span>
<span className={cn(SIDEBAR_ROW_LABEL, labelClassName)}>{children}</span>
</RowButton>
)
}

/** Fixed leading column (dot, icon, drag handle). */
export function SidebarRowLead({ className, ...props }: React.ComponentProps<'span'>) {
return <span className={cn(rowLead, className)} {...props} />
return <span className={cn(SIDEBAR_ROW_LEAD, className)} {...props} />
}

/** Standard row label typography. */
export function SidebarRowLabel({ className, ...props }: React.ComponentProps<'span'>) {
return <span className={cn(rowLabel, className)} {...props} />
return <span className={cn(SIDEBAR_ROW_LABEL, className)} {...props} />
}

/** What a group's sessions add up to, for the Show options that count something. */
Expand Down
Loading
Loading