Merge pull request #60638 from NousResearch/bb/contrib-areas

feat(desktop): contribution-driven shell on a layout-tree model
This commit is contained in:
brooklyn! 2026-07-15 13:28:38 -04:00 committed by GitHub
commit 1f89f3102f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
174 changed files with 16409 additions and 3696 deletions

11
.gitignore vendored
View file

@ -68,6 +68,17 @@ environments/benchmarks/evals/
hermes_cli/web_dist/
apps/desktop/build/
apps/desktop/dist/
# tsc-emitted artifacts (a stray `tsc -b` compiles into src/, and vite then
# resolves the stale .js OVER the .tsx — never track these)
apps/desktop/src/**/*.js
apps/desktop/src/**/*.js.map
apps/desktop/src/**/*.d.ts
!apps/desktop/src/global.d.ts
!apps/desktop/src/vite-env.d.ts
apps/shared/src/**/*.js
apps/shared/src/**/*.js.map
apps/shared/src/**/*.d.ts
apps/desktop/release/
*.tsbuildinfo

View file

@ -3597,9 +3597,35 @@ async function ensureRuntime(backend) {
return backend
}
// Assemble a single-file multipart/form-data body (FastAPI `UploadFile`
// endpoints, e.g. kanban attachments). Hand-rolled because node's http has no
// FormData and the payload is one file — a dependency would be overkill.
function multipartBody(upload) {
const boundary = `----hermes-${crypto.randomBytes(12).toString('hex')}`
const filename = String(upload.filename || 'file').replace(/["\r\n]/g, '_')
const body = Buffer.concat([
Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` +
`Content-Type: ${upload.contentType || 'application/octet-stream'}\r\n\r\n`
),
Buffer.from(upload.bytes),
Buffer.from(`\r\n--${boundary}--\r\n`)
])
return { body, contentType: `multipart/form-data; boundary=${boundary}` }
}
function fetchJson(url, token, options: any = {}) {
return new Promise((resolve, reject) => {
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
const { body, contentType } = options.upload
? multipartBody(options.upload)
: {
body: options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)),
contentType: 'application/json'
}
const parsed = new URL(url)
const client = parsed.protocol === 'https:' ? https : http
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
@ -3615,7 +3641,7 @@ function fetchJson(url, token, options: any = {}) {
{
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
'Content-Type': contentType,
'X-Hermes-Session-Token': token,
...(body ? { 'Content-Length': String(body.length) } : {})
}
@ -4576,14 +4602,13 @@ function buildApplicationMenu() {
submenu: [
IS_MAC
? {
accelerator: 'CommandOrControl+W',
click: () => {
if (previewShortcutActive) {
sendClosePreviewRequested()
} else {
mainWindow?.close()
}
},
// NO accelerator: on macOS a registered ⌘W is consumed by the OS
// menu before the web contents ever sees it (and registerAccelerator
// false is a no-op on mac — electron#18295). Leaving it off lets the
// `before-input-event` handler below intercept ⌘W and route it to the
// renderer's close-active-tab. Clicking the item still closes the tab
// (or window) via the same request.
click: () => sendClosePreviewRequested(),
label: 'Close'
}
: { role: 'quit' }
@ -4689,9 +4714,12 @@ function installDevToolsShortcut(window) {
function installPreviewShortcut(window) {
window.webContents.on('before-input-event', (event, input) => {
const key = String(input.key || '').toLowerCase()
const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
const isCloseTabShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
if (!isPreviewCloseShortcut || !previewShortcutActive) {
// Always claim ⌘W here (the File>Close item deliberately has no
// accelerator, so nothing else does). The renderer decides tab-vs-window
// — no `previewShortcutActive` gate, so it works for every closeable tab.
if (!isCloseTabShortcut) {
return
}
@ -7870,6 +7898,12 @@ ipcMain.handle('hermes:api', async (_event, request) => {
// session so the cookie attaches automatically. Token/local modes keep using
// the static session-token header.
if (connection.authMode === 'oauth') {
// The OAuth path rides electron.net with JSON headers; multipart isn't
// wired there. Fail loudly rather than corrupting the upload.
if (request?.upload) {
throw new Error('File uploads are not supported against OAuth-gated remote backends yet.')
}
return fetchJsonViaOauthSession(url, {
method: request?.method,
body: request?.body,
@ -7880,6 +7914,7 @@ ipcMain.handle('hermes:api', async (_event, request) => {
return fetchJson(url, connection.token, {
method: request?.method,
body: request?.body,
upload: request?.upload,
timeoutMs
})
})
@ -8380,6 +8415,28 @@ ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
}
})
// Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike
// `reveal` (which selects an existing item and silently no-ops on a missing
// path — the "Open plugins folder" Windows bug), this is for the plugins door,
// which often doesn't exist on first use. `shell.openPath` returns '' on
// success or an error string; both mkdir + openPath failures are surfaced.
ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => {
const dir = String(dirPath || '').trim()
if (!dir) {
return { ok: false, error: 'no path' }
}
try {
await fs.promises.mkdir(dir, { recursive: true })
const error = await shell.openPath(path.normalize(dir))
return error ? { ok: false, error } : { ok: true }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
})
// Rename a file/folder in place. The renderer passes the existing path + a new
// base name; the destination is resolved in the SAME parent dir so a rename can
// never move the item elsewhere or traverse out. Rejects on a name collision.

View file

@ -107,6 +107,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
openDir: dirPath => ipcRenderer.invoke('hermes:fs:openDir', dirPath),
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath),

View file

@ -104,6 +104,25 @@ export default [
react: { version: 'detect' }
}
},
{
// THE PLUGIN FENCE: plugins speak @hermes/plugin-sdk (+ react), never `@/…`
// internals — the same isolation a runtime-fetched published plugin gets,
// enforced on bundled ones so the SDK surface stays honest and sufficient.
files: ['src/plugins/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@/*', '../*', '@hermes/shared'],
message: 'Plugins import only @hermes/plugin-sdk (and react). Missing something? Add it to the SDK.'
}
]
}
]
}
},
{
files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
ignores: ['**/node_modules/**', '**/dist/**'],

View file

@ -1,48 +1,48 @@
import { useRef } from 'react'
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
import { Codicon } from '@/components/ui/codicon'
import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS } from '@/components/ui/drop-affordance'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
const ICONS: Record<'files' | 'session', string> = {
files: 'cloud-upload',
session: 'comment-discussion'
}
/**
* Full-bleed affordance shown while files or a session are dragged over the chat
* area. Always `pointer-events-none` so the drop lands on the real element
* underneath and the drop-zone handler claims it the overlay is purely visual.
* Copy adapts to whatever is being dragged; the last kind is held through the
* fade-out so the label doesn't blank.
* The label names the outcome (attach files / link this chat); the last kind is
* held through the fade-out so it doesn't blank.
*/
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
const { t } = useI18n()
const lastKind = useRef<'files' | 'session'>('files')
const lastKind = useRef<DragKind>(kind)
if (kind) {
lastKind.current = kind
}
const resolvedKind = kind ?? lastKind.current
const icon = ICONS[resolvedKind]
const label = resolvedKind === 'files' ? t.composer.dropFiles : t.composer.dropSession
const shown = kind ?? lastKind.current
return (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out',
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center transition-opacity duration-150 ease-out',
kind ? 'opacity-100' : 'opacity-0'
)}
data-slot="chat-drop-overlay"
>
<div className="absolute inset-2 rounded-2xl border-2 border-dashed border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)] backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]" />
<div className="relative flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer">
<Codicon className="text-(--ui-accent)" name={icon} size="1rem" />
{label}
</div>
<div
className={cn(
DROP_SHEET_CLASS,
DROP_SHEET_BLUR_CLASS,
'absolute inset-2 border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)]'
)}
/>
{shown && (
<span className="relative text-[11px] font-medium uppercase tracking-wide text-foreground">
{shown === 'session' ? t.composer.dropSession : t.composer.dropFiles}
</span>
)}
</div>
)
}

View file

@ -0,0 +1,29 @@
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
import { isFocusWithin } from '@/lib/keybinds/combo'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
/**
* W close the tab of the context you're in, by precedence:
* 1. a focused terminal its active terminal tab,
* 2. an open preview its active preview tab (unchanged from pre-tiling),
* 3. the MAIN zone its active tab (a session tile stacked into the workspace).
* Returns false when nothing closes, so W is a no-op it never closes the
* window (a bare workspace stays put). Shared by the keyboard path (Win/Linux)
* and the macOS menu-accelerator IPC.
*/
export function closeActiveTab(): boolean {
if (isFocusWithin('[data-terminal]')) {
closeActiveTerminal()
return true
}
if ($filePreviewTarget.get() || $previewTarget.get()) {
closeActiveRightRailTab()
return true
}
return closeWorkspaceTab()
}

View file

@ -6,6 +6,12 @@ import { setSessionPickerOpen } from '@/store/session'
export const COMPOSER_STACK_BREAKPOINT_PX = 320
// Above the stack breakpoint but still cramped: the model pill sheds its label
// for its chevron icon (freeing ~120px) so the controls stop crowding the input
// before the whole row has to stack. Progressive collapse: full pill → icon
// pill → stacked.
export const COMPOSER_COMPACT_PILL_PX = 440
// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem
// vertical padding). Anything taller means the text wrapped to a second line,
// which is when the composer should expand to the stacked layout.
@ -79,7 +85,5 @@ export function isPendingDraftPersistCurrent(
pending: PendingDraftPersist | null,
expected: PendingDraftPersist | null
): boolean {
return (
pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text
)
return pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text
}

View file

@ -18,6 +18,7 @@ import { useI18n } from '@/i18n'
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { useComposerAttachmentProviders } from './contrib'
import { GHOST_ICON_BTN } from './controls'
import type { ChatBarState } from './types'
@ -39,6 +40,9 @@ export function ContextMenu({
// window (composer "+" anchor), so we promoted it to a real Dialog —
// easier to grow with search / descriptions, and no positioning math.
const [snippetsOpen, setSnippetsOpen] = useState(false)
// `composer.attachments` contributions — plugin/core-registered rows that
// extend this menu through the same registry as every other surface.
const attachmentProviders = useComposerAttachmentProviders()
return (
<>
@ -90,6 +94,18 @@ export function ContextMenu({
{c.promptSnippets}
</ContextMenuItem>
{attachmentProviders.length > 0 && <DropdownMenuSeparator />}
{attachmentProviders.map(provider => (
<DropdownMenuItem
className="text-[length:var(--conversation-tool-font-size)] focus:bg-(--ui-bg-tertiary)"
key={provider.key}
onSelect={() => void provider.run({ insertText: onInsertText })}
>
<Codicon name={provider.icon ?? 'plug'} size="0.875rem" />
<span>{provider.label}</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">

View file

@ -0,0 +1,54 @@
import { afterEach, describe, expect, it } from 'vitest'
import { registry } from '@/contrib/registry'
import { COMPOSER_AREAS, type ComposerMiddleware, runComposerMiddleware } from './contrib'
const disposers: Array<() => void> = []
function addMiddleware(id: string, handler: ComposerMiddleware['handler'], order?: number) {
disposers.push(
registry.register({ id, area: COMPOSER_AREAS.middleware, order, data: { handler } satisfies ComposerMiddleware })
)
}
afterEach(() => {
disposers.splice(0).forEach(d => d())
})
describe('runComposerMiddleware', () => {
it('passes the draft through untouched when nothing is registered', async () => {
const draft = { text: 'hello' }
expect(await runComposerMiddleware(draft)).toBe(draft)
})
it('chains rewrites in registry order', async () => {
addMiddleware('b', d => ({ ...d, text: `${d.text}b` }), 20)
addMiddleware('a', d => ({ ...d, text: `${d.text}a` }), 10)
expect(await runComposerMiddleware({ text: 'x' })).toEqual({ text: 'xab' })
})
it('cancels the send when a handler returns null', async () => {
addMiddleware('gate', () => null)
addMiddleware('later', d => ({ ...d, text: 'never' }), 99)
expect(await runComposerMiddleware({ text: 'x' })).toBeNull()
})
it('treats a throwing handler as pass-through', async () => {
addMiddleware('boom', () => {
throw new Error('broken plugin')
})
addMiddleware('after', d => ({ ...d, text: `${d.text}!` }), 99)
expect(await runComposerMiddleware({ text: 'x' })).toEqual({ text: 'x!' })
})
it('supports async handlers', async () => {
addMiddleware('async', async d => ({ ...d, text: d.text.toUpperCase() }))
expect(await runComposerMiddleware({ text: 'quiet' })).toEqual({ text: 'QUIET' })
})
})

View file

@ -0,0 +1,94 @@
/**
* Composer contribution surface every seam of the composer is hook-into-able
* through the SAME registry schema as every other surface (statusbar, titlebar,
* panes, layouts):
*
* render areas (`render`): composer.top banner strip above the input
* composer.bottom row below the input grid
* composer.leading inline after the "+" menu
* composer.actions inline before the model pill
*
* data kinds (`data`): composer.middleware (ComposerMiddleware)
* composer.attachments (ComposerAttachmentProvider)
*
* Core keeps ownership of the transcript, input, and submit engine these
* seams AUGMENT the composer, they never replace it. Middleware runs as an
* ordered async chain around the app's onSubmit: each handler may rewrite the
* draft, pass it through, or cancel the send by returning null.
*/
import { useContributions } from '@/contrib/react/use-contributions'
import { registry } from '@/contrib/registry'
import type { ComposerAttachment } from '@/store/composer'
export const COMPOSER_AREAS = {
top: 'composer.top',
bottom: 'composer.bottom',
leading: 'composer.leading',
actions: 'composer.actions',
middleware: 'composer.middleware',
attachments: 'composer.attachments'
} as const
export interface ComposerDraft {
text: string
attachments?: ComposerAttachment[]
}
/** Payload of a `composer.middleware` data contribution. */
export interface ComposerMiddleware {
/** Rewrite (return a draft), pass through (same draft), or cancel (null). */
handler: (draft: ComposerDraft) => ComposerDraft | null | Promise<ComposerDraft | null>
}
export interface ComposerAttachmentContext {
insertText: (text: string) => void
}
/** Payload of a `composer.attachments` data contribution an entry in the
* composer's "+" attach menu. */
export interface ComposerAttachmentProvider {
label: string
/** Codicon name for the menu row. Defaults to `plug`. */
icon?: string
run: (ctx: ComposerAttachmentContext) => void | Promise<void>
}
/**
* Run the ordered middleware chain over a draft. Contributions execute in
* registry order (`order`, then registration order); the first `null` wins
* and cancels the send. A throwing handler is treated as pass-through so a
* broken plugin can't eat messages.
*/
export async function runComposerMiddleware(draft: ComposerDraft): Promise<ComposerDraft | null> {
let current = draft
for (const contribution of registry.getArea(COMPOSER_AREAS.middleware)) {
const middleware = contribution.data as ComposerMiddleware | undefined
if (!middleware?.handler) {
continue
}
try {
const next = await middleware.handler(current)
if (next === null) {
return null
}
current = next
} catch {
// Pass-through: a faulty middleware must never swallow the message.
}
}
return current
}
/** Attach-menu entries contributed by plugins/core, with stable render keys. */
export function useComposerAttachmentProviders(): Array<ComposerAttachmentProvider & { key: string }> {
return useContributions(COMPOSER_AREAS.attachments)
.map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as ComposerAttachmentProvider) }))
.filter(p => Boolean(p.label && p.run))
}

View file

@ -13,7 +13,9 @@
import type { InlineRefInput } from './inline-refs'
import { RICH_INPUT_SLOT } from './rich-editor'
export type ComposerTarget = 'edit' | 'main'
/** Composer routing key. The main chat is `'main'`, the edit composer
* `'edit'`; scoped composers (session tiles) use `'tile:<id>'`. */
export type ComposerTarget = 'edit' | 'main' | (string & {})
export type ComposerInsertMode = 'block' | 'inline'
interface FocusDetail {
@ -76,6 +78,10 @@ export const markActiveComposer = (target: ComposerTarget) => {
activeTarget = target
}
/** The composer that last held focus the target `'active'` resolves to.
* Used by broadcast listeners (voice, Esc-to-stop) to act on exactly one. */
export const getActiveComposer = (): ComposerTarget => activeTarget
export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') =>
dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target) })
@ -129,12 +135,14 @@ export const requestComposerSubmit = (
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
subscribe<SubmitDetail>(SUBMIT_EVENT, handler)
/** Toggle the active composer's voice conversation the `composer.voice`
* hotkey (Ctrl+B) reaching into the composer that owns the voice state. */
export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() })
/** Toggle ONE composer's voice conversation the `composer.voice` hotkey
* (Ctrl+B) reaches the composer that owns voice. Defaults to the active
* composer so N tiles don't all flip together. */
export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active') =>
dispatch<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, { target: resolve(target) })
export const onComposerVoiceToggleRequest = (handler: () => void) =>
subscribe<{ at: number }>(VOICE_TOGGLE_EVENT, () => handler())
export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target))
/**
* Focus a composer input across React commit + browser focus restore.

View file

@ -1,8 +1,9 @@
import { type MutableRefObject, useCallback } from 'react'
import { clearComposerAttachments } from '@/store/composer'
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
import { useComposerScope } from '../scope'
interface UseComposerBranchOptions {
clearDraft: () => void
cwd: null | string | undefined
@ -17,6 +18,8 @@ interface UseComposerBranchOptions {
* projects store) is the only dependency; nothing about ChatBar's render.
*/
export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBranchOptions) {
const scope = useComposerScope()
// Hand a worktree off to the controller: open a fresh session anchored there,
// carrying the composer draft as its first turn. Clearing here means the draft
// travels to the new session instead of getting stashed under this one.
@ -24,7 +27,7 @@ export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBran
(path: string) => {
const text = draftRef.current
clearDraft()
clearComposerAttachments()
scope.attachments.clear()
requestStartWorkSession(path, text)
},
[clearDraft, draftRef]

View file

@ -2,7 +2,7 @@ import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react'
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { $composerAttachments, type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
import { type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
import { isBrowsingHistory } from '@/store/composer-input-history'
import {
@ -21,6 +21,7 @@ import {
} from '../focus'
import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs'
import { composerPlainText, placeCaretEnd, renderComposerContents } from '../rich-editor'
import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
interface UseComposerDraftArgs {
@ -50,6 +51,8 @@ export function useComposerDraft({
}: UseComposerDraftArgs) {
const aui = useAui()
const composerRuntime = useComposerRuntime()
// Which composer this is on the focus bus + which attachment set it owns.
const { attachments: attachmentScope, target } = useComposerScope()
// Coarse edges only — these flip rarely (empty↔non-empty, the `?` help sigil,
// steerable-vs-slash), so typing within a line costs no render.
@ -98,8 +101,8 @@ export function useComposerDraft({
const focusInput = useCallback(() => {
focusComposerInput(editorRef.current)
markActiveComposer('main')
}, [])
markActiveComposer(target)
}, [target])
const requestMainFocus = useCallback(() => {
setFocusRequestId(id => id + 1)
@ -155,14 +158,14 @@ export function useComposerDraft({
return undefined
}
const offFocus = onComposerFocusRequest(target => {
if (target === 'main') {
const offFocus = onComposerFocusRequest(requested => {
if (requested === target) {
setFocusRequestId(id => id + 1)
}
})
const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
if (target === 'main') {
const offInsert = onComposerInsertRequest(({ mode, target: requested, text }) => {
if (requested === target) {
appendExternalText(text, mode)
}
})
@ -171,13 +174,13 @@ export function useComposerDraft({
offFocus()
offInsert()
}
}, [appendExternalText, inputDisabled])
}, [appendExternalText, inputDisabled, target])
const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) =>
const stashAt = (scope: string | null, text = draftRef.current, attachments = attachmentScope.$attachments.get()) =>
stashSessionDraft(scope, text, attachments)
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
$composerAttachments.set(cloneAttachments(attachments))
attachmentScope.$attachments.set(cloneAttachments(attachments))
paintDraft(text, false)
}
@ -296,12 +299,12 @@ export function useComposerDraft({
insertInlineRefsRef.current = insertInlineRefs
useEffect(() => {
return onComposerInsertRefsRequest(({ refs, target }) => {
if (target === 'main') {
return onComposerInsertRefsRequest(({ refs, target: requested }) => {
if (requested === target) {
insertInlineRefsRef.current(refs)
}
})
}, [])
}, [target])
// Per-thread draft swap — the composer's only session coupling. Lifecycle
// never clears composer state; this effect alone stashes on leave, restores

View file

@ -2,10 +2,15 @@ import { useEffect, useRef } from 'react'
import { triggerHaptic } from '@/lib/haptics'
import { type ComposerTarget, getActiveComposer } from '../focus'
interface UseComposerEscCancelOptions {
awaitingInput: boolean
busy: boolean
onCancel: () => unknown
/** This composer's focus-bus key. With N composers mounted (main + tiles),
* only the active one's Esc cancels otherwise every busy tile stops. */
target: ComposerTarget
}
/**
@ -15,7 +20,7 @@ interface UseComposerEscCancelOptions {
* the window listener registered exactly once while still reading fresh
* busy/awaitingInput/onCancel each press.
*/
export function useComposerEscCancel({ awaitingInput, busy, onCancel }: UseComposerEscCancelOptions) {
export function useComposerEscCancel({ awaitingInput, busy, onCancel, target }: UseComposerEscCancelOptions) {
// Intentional only: we bail if (a) the composer/another field already handled
// Esc (defaultPrevented), (b) focus is in any input/textarea/contenteditable
// (you're typing, not stopping), or (c) a dialog/popover is open — Esc must
@ -30,6 +35,12 @@ export function useComposerEscCancel({ awaitingInput, busy, onCancel }: UseCompo
return
}
// Only the focused composer cancels — otherwise every mounted busy tile
// stops at once (and the winner would be mount-order arbitrary).
if (getActiveComposer() !== target) {
return
}
const active = document.activeElement as HTMLElement | null
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {

View file

@ -6,7 +6,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
import { $composerPoppedOut } from '@/store/composer-popout'
import { isSecondaryWindow } from '@/store/windows'
import { COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
interface UseComposerMetricsArgs {
composerRef: RefObject<HTMLFormElement | null>
@ -24,10 +24,13 @@ interface UseComposerMetricsArgs {
* Returns `stacked` (the only value the render needs).
*/
export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }: UseComposerMetricsArgs): {
compactPill: boolean
stacked: boolean
} {
const [expanded, setExpanded] = useState(false)
const [tight, setTight] = useState(false)
// Wider than `tight`: the pill goes icon-only before the row has to stack.
const [compactPill, setCompactPill] = useState(false)
const narrow = useMediaQuery('(max-width: 30rem)')
// Edge signals, not the live text: these only re-render when emptiness / the
@ -72,6 +75,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
const lastBucketedHeightRef = useRef(0)
const lastBucketedSurfaceHeightRef = useRef(0)
const lastTightRef = useRef<boolean | null>(null)
const lastCompactPillRef = useRef<boolean | null>(null)
const syncComposerMetrics = useCallback(() => {
const composer = composerRef.current
@ -105,6 +109,13 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
lastTightRef.current = nextTight
setTight(nextTight)
}
const nextCompactPill = width < COMPOSER_COMPACT_PILL_PX
if (nextCompactPill !== lastCompactPillRef.current) {
lastCompactPillRef.current = nextCompactPill
setCompactPill(nextCompactPill)
}
}
// Expand once the input has actually wrapped past a single line. The
@ -156,5 +167,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
}
}, [])
return { stacked: expanded || narrow || tight }
// Pill compacts on real width (tile/pane), OR when stacked for any reason
// (viewport-narrow / wrapped) so the controls row never over-runs.
return { compactPill: compactPill || narrow || tight, stacked: expanded || narrow || tight }
}

View file

@ -11,6 +11,8 @@ import {
} from '@/store/composer-popout'
import { isSecondaryWindow } from '@/store/windows'
import { useComposerScope } from '../scope'
import { useComposerPopoutGestures } from './use-popout-drag'
interface UseComposerPopoutOptions {
@ -25,7 +27,10 @@ interface UseComposerPopoutOptions {
* window's composer out via the shared atom.
*/
export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) {
const popoutAllowed = !isSecondaryWindow()
// The floating composer is a window-level singleton: only the main scope
// (not tiles) in a primary window may pop out.
const scope = useComposerScope()
const popoutAllowed = !isSecondaryWindow() && scope.popoutAllowed
const poppedOut = useStore($composerPoppedOut) && popoutAllowed
const popoutPosition = useStore($composerPopoutPosition)

View file

@ -3,7 +3,7 @@ import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { useSessionSlice } from '@/lib/use-session-slice'
import { clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
import { type ComposerAttachment } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import {
$queuedPromptsBySession,
@ -19,6 +19,7 @@ import {
import { notify } from '@/store/notifications'
import { cloneAttachments, type QueueEditState } from '../composer-utils'
import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
interface UseComposerQueueArgs {
@ -60,6 +61,7 @@ export function useComposerQueue({
sessionId
}: UseComposerQueueArgs) {
const { t } = useI18n()
const scope = useComposerScope()
// Per-session slice (edge): re-renders only when THIS session's queue changes,
// not on cross-session queue churn (the plain atom's map ref changes on every
@ -173,11 +175,11 @@ export function useComposerQueue({
}
clearDraft()
clearComposerAttachments()
scope.attachments.clear()
triggerHaptic('selection')
return true
}, [activeQueueSessionKey, attachments, clearDraft, draftRef])
}, [activeQueueSessionKey, attachments, clearDraft, draftRef, scope.attachments])
// All queue drain paths share one lock + send-then-remove sequence.
// `pickEntry` lets each caller choose head, by-id, or skip-edited.

View file

@ -2,13 +2,14 @@ import { type RefObject, useEffect, useRef } from 'react'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { clearComposerAttachments, clearSessionDraft, type ComposerAttachment } from '@/store/composer'
import { clearSessionDraft, type ComposerAttachment } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue'
import { cloneAttachments, type QueueEditState } from '../composer-utils'
import { onComposerSubmitRequest } from '../focus'
import { composerPlainText } from '../rich-editor'
import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
interface UseComposerSubmitArgs {
@ -71,6 +72,8 @@ export function useComposerSubmit({
setComposerText,
stashAt
}: UseComposerSubmitArgs) {
const scope = useComposerScope()
// Shared send primitive: fire onSubmit, and if the gateway rejects (accepted
// === false) or throws, re-load + re-stash the draft so the words survive.
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
@ -163,7 +166,7 @@ export function useComposerSubmit({
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
clearComposerAttachments()
scope.attachments.clear()
dispatchSubmit(text, submittedAttachments)
}

View file

@ -8,6 +8,7 @@ import { notifyError } from '@/store/notifications'
import { $messages } from '@/store/session'
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
import type { ComposerTarget } from '../focus'
import { onComposerVoiceToggleRequest } from '../focus'
import type { ChatBarProps } from '../types'
@ -25,6 +26,9 @@ interface UseComposerVoiceArgs {
onSubmit: ChatBarProps['onSubmit']
onTranscribeAudio: ChatBarProps['onTranscribeAudio']
sessionId: string | null | undefined
/** This composer's focus-bus key voice toggles targeting another
* composer (or the active one, when not us) are ignored. */
target: ComposerTarget
}
/**
@ -42,7 +46,8 @@ export function useComposerVoice({
maxRecordingSeconds,
onSubmit,
onTranscribeAudio,
sessionId
sessionId,
target
}: UseComposerVoiceArgs) {
const { t } = useI18n()
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
@ -122,7 +127,10 @@ export function useComposerVoice({
}
}, [conversation, disabled, voiceConversationActive])
useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation])
useEffect(
() => onComposerVoiceToggleRequest(toggled => toggled === target && toggleVoiceConversation()),
[target, toggleVoiceConversation]
)
// Explicit start/end for the on-screen conversation controls (the hotkey uses
// the gated toggle above).

View file

@ -1,22 +1,21 @@
import { ComposerPrimitive } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useEffect, useRef } from 'react'
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react'
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
import { Button } from '@/components/ui/button'
import { Slot as ContribSlot } from '@/contrib/react/slot'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $composerAttachments } from '@/store/composer'
import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history'
import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
import { removeQueuedPrompt } from '@/store/composer-queue'
import { $activeSessionAwaitingInput } from '@/store/prompts'
import { toggleReview } from '@/store/review'
import { $gatewayState, $messages } from '@/store/session'
import { $gatewayState } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { $autoSpeakReplies } from '@/store/voice-prefs'
import { useTheme } from '@/themes'
@ -24,6 +23,7 @@ import { useTheme } from '@/themes'
import { AttachmentList } from './attachments'
import { COMPOSER_FADE_BACKGROUND, type QueueEditState, slashArgStage } from './composer-utils'
import { ContextMenu } from './context-menu'
import { COMPOSER_AREAS, runComposerMiddleware } from './contrib'
import { ComposerControls } from './controls'
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance'
import { markActiveComposer } from './focus'
@ -52,6 +52,7 @@ import {
normalizeComposerEditorDom,
RICH_INPUT_SLOT
} from './rich-editor'
import { useComposerScope } from './scope'
import { ComposerStatusStack } from './status-stack'
import { CodingStatusRow } from './status-stack/coding-row'
import { extractClipboardImageBlobs } from './text-utils'
@ -80,17 +81,36 @@ export function ChatBar({
onPickImages,
onRemoveAttachment,
onSteer,
onSubmit,
onSubmit: onSubmitProp,
onTranscribeAudio
}: ChatBarProps) {
const attachments = useStore($composerAttachments)
// Every send (typed, queued, voice) passes through the contributed
// middleware chain first — rewrite / pass-through / cancel. Empty chain =
// exact pass-through, so surfaces without contributions are byte-identical.
const onSubmit = useCallback<ChatBarProps['onSubmit']>(
async (value, options) => {
const draft = await runComposerMiddleware({ text: value, attachments: options?.attachments })
if (!draft) {
return false
}
return onSubmitProp(draft.text, { ...options, attachments: draft.attachments })
},
[onSubmitProp]
)
// Which live composer this instance IS (main | tile) — its attachment set,
// focus-bus key, and awaiting-input edge. Main scope = the legacy globals.
const scope = useComposerScope()
const attachments = useStore(scope.attachments.$attachments)
const scrolledUp = useStore($threadScrolledUp)
const autoSpeak = useStore($autoSpeakReplies)
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
// not interrupt it — there's nothing actively running to stop, and stopping
// would discard a question the user may want to come back to. The blocking
// prompt owns its own dismissal (Skip, Reject, dialog close).
const awaitingInput = useStore($activeSessionAwaitingInput)
const awaitingInput = useStore(scope.$awaitingInput)
const activeQueueSessionKey = queueSessionKey || sessionId || null
// Status items (subagents, background processes) are keyed by the RUNTIME
@ -189,7 +209,7 @@ export function ChatBar({
const statusStackVisible = queuedPrompts.length > 0 || statusPresent
const { stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
const { compactPill, stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
const hasComposerPayload = hasText || attachments.length > 0
const canSubmit = busy || hasComposerPayload
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
@ -198,8 +218,6 @@ export function ChatBar({
// into a tool result) and never for a slash command (those execute inline).
const canSteer = busy && !!onSteer && attachments.length === 0 && isSteerableText
const showHelpHint = isHelpHint
// The submit engine — the orchestration seam where draft + queue meet. Owns
// the submit decision tree, the send-with-restore primitive, and steer.
const { steerDraft, submitDraft } = useComposerSubmit({
@ -507,11 +525,11 @@ export function ChatBar({
// $messages is read imperatively (not subscribed) so the composer
// doesn't re-render on every streaming delta flush.
const history = deriveUserHistory($messages.get(), chatMessageText)
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
const entry = browseBackward(sessionId, currentDraft, history)
if (entry !== null) {
loadIntoComposer(entry, $composerAttachments.get())
loadIntoComposer(entry, scope.attachments.$attachments.get())
}
return
@ -532,11 +550,11 @@ export function ChatBar({
event.preventDefault()
triggerKeyConsumedRef.current = true
const history = deriveUserHistory($messages.get(), chatMessageText)
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
const result = browseForward(sessionId, history)
if (result !== null) {
loadIntoComposer(result.text, $composerAttachments.get())
loadIntoComposer(result.text, scope.attachments.$attachments.get())
}
}
@ -644,7 +662,7 @@ export function ChatBar({
useComposerBranch({ clearDraft, cwd, draftRef })
// Global Esc-to-cancel when the chat (not the composer input) has focus.
useComposerEscCancel({ awaitingInput, busy, onCancel })
useComposerEscCancel({ awaitingInput, busy, onCancel, target: scope.target })
const {
conversation,
@ -664,7 +682,8 @@ export function ChatBar({
maxRecordingSeconds,
onSubmit,
onTranscribeAudio,
sessionId
sessionId,
target: scope.target
})
const contextMenu = (
@ -686,7 +705,7 @@ export function ChatBar({
busyAction={busyAction}
canSteer={canSteer}
canSubmit={canSubmit}
compactModelPill={poppedOut}
compactModelPill={poppedOut || compactPill}
conversation={{
active: voiceConversationActive,
level: conversation.level,
@ -742,7 +761,7 @@ export function ChatBar({
}}
onDragOver={handleInputDragOver}
onDrop={handleInputDrop}
onFocus={() => markActiveComposer('main')}
onFocus={() => markActiveComposer(scope.target)}
onInput={handleEditorInput}
onKeyDown={handleEditorKeyDown}
onKeyUp={handleEditorKeyUp}
@ -846,7 +865,7 @@ export function ChatBar({
: undefined
}
>
{showHelpHint && <HelpHint />}
{isHelpHint && <HelpHint />}
{trigger && !argStageEmpty && (
<ComposerTriggerPopover
activeIndex={triggerActive}
@ -936,6 +955,10 @@ export function ChatBar({
)}
data-slot="composer-fade"
>
{/* Contribution seams: banners above, a row below, inline
additions beside the "+" menu and before the controls.
All four render nothing until something contributes. */}
<ContribSlot area={COMPOSER_AREAS.top} />
<VoiceActivity state={voiceActivityState} />
<VoicePlaybackActivity />
{queueEdit && editingQueuedPrompt && (
@ -971,10 +994,17 @@ export function ChatBar({
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
)}
>
<div className="flex translate-y-[3px] items-start self-start [grid-area:menu]">{contextMenu}</div>
<div className="flex translate-y-[3px] items-start gap-(--composer-control-gap) self-start [grid-area:menu]">
{contextMenu}
<ContribSlot area={COMPOSER_AREAS.leading} />
</div>
<div className="min-w-0 [grid-area:input]">{input}</div>
<div className="flex items-center justify-end [grid-area:controls]">{controls}</div>
<div className="flex items-center justify-end gap-(--composer-control-gap) [grid-area:controls]">
<ContribSlot area={COMPOSER_AREAS.actions} />
{controls}
</div>
</div>
<ContribSlot area={COMPOSER_AREAS.bottom} />
</div>
</div>
</div>

View file

@ -1,4 +1,5 @@
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { translateNow } from '@/i18n'
import { contextPath } from '@/lib/chat-runtime'
import type { DroppedFile } from '../hooks/use-composer-actions'
@ -8,44 +9,22 @@ import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipEl
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
export type InlineRefInput = string | { kind: string; label?: string; value: string }
/** MIME for an in-app session drag (sidebar row → composer). */
export const HERMES_SESSION_MIME = 'application/x-hermes-session'
/** A dragged sidebar session carried in-memory by the pointer drag session
* (session-drag.ts); sessions never ride native DnD. */
export interface SessionDragPayload {
id: string
profile: string
title: string
}
export function writeSessionDrag(transfer: DataTransfer, payload: SessionDragPayload) {
transfer.setData(HERMES_SESSION_MIME, JSON.stringify(payload))
transfer.effectAllowed = 'copy'
}
export function dragHasSession(transfer: DataTransfer | null) {
return Boolean(transfer) && Array.from(transfer!.types || []).includes(HERMES_SESSION_MIME)
}
export function readSessionDrag(transfer: DataTransfer | null): null | SessionDragPayload {
const raw = transfer?.getData(HERMES_SESSION_MIME)
if (!raw) {
return null
}
try {
const parsed = JSON.parse(raw) as Partial<SessionDragPayload>
return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null
} catch {
return null
}
}
/** A session's friendly display label — its title, or a localized fallback. */
export const sessionLabel = ({ id, title }: SessionDragPayload) =>
title || translateNow('sidebar.row.untitledChat', id.slice(0, 8))
/** Build a `@session:<profile>/<id>` chip. Value carries the metadata the agent
* needs to resolve the link (session_search); label shows the friendly title. */
export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput {
return { kind: 'session', label: title || `chat ${id.slice(0, 8)}`, value: `${profile || 'default'}/${id}` }
export function sessionInlineRef(payload: SessionDragPayload): InlineRefInput {
return { kind: 'session', label: sessionLabel(payload), value: `${payload.profile || 'default'}/${payload.id}` }
}
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {

View file

@ -0,0 +1,47 @@
import type { ReadableAtom } from 'nanostores'
import { createContext, useContext } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { type ComposerAttachmentScope, mainComposerScope } from '@/store/composer'
import { $activeSessionAwaitingInput } from '@/store/prompts'
import { $messages } from '@/store/session'
import type { ComposerTarget } from './focus'
/**
* COMPOSER SCOPE which live composer a ChatBar instance IS. The main chat's
* ChatBar runs in the default scope (module-level attachment atom, focus-bus
* target 'main', the active session's awaiting-input edge). A session tile
* mounts its ChatBar under its own scope, so N composers coexist: separate
* attachment chips, separate focus/insert routing, separate Esc semantics.
*
* Draft TEXT needs no scoping it lives in each ChatBar's contentEditable +
* draftRef and stashes per session key (`stashSessionDraft`), which already
* differs per surface.
*/
export interface ComposerScope {
/** This scope's "turn parked on user input" edge — gates Esc-to-stop. */
$awaitingInput: ReadableAtom<boolean>
attachments: ComposerAttachmentScope
/** Only the main scope may pop out (the floating composer is a singleton). */
popoutAllowed: boolean
/** Imperative read of this scope's transcript (input-history browse)
* never subscribed, so streaming stays out of the composer's renders. */
readMessages: () => ChatMessage[]
/** Focus-bus routing key (`'main'` | `'tile:<id>'`). */
target: ComposerTarget
}
export const MAIN_COMPOSER_SCOPE: ComposerScope = {
$awaitingInput: $activeSessionAwaitingInput,
attachments: mainComposerScope,
popoutAllowed: true,
readMessages: () => $messages.get(),
target: 'main'
}
const ComposerScopeContext = createContext<ComposerScope>(MAIN_COMPOSER_SCOPE)
export const ComposerScopeProvider = ComposerScopeContext.Provider
export const useComposerScope = (): ComposerScope => useContext(ComposerScopeContext)

View file

@ -255,22 +255,46 @@ export function partitionDroppedFiles(candidates: DroppedFile[]): {
return { osDrops, inAppRefs }
}
/** The composer these actions feed. Defaults to the main chat's scope;
* session tiles pass their own so picks/drops/pastes land in THEIR chips. */
interface ComposerActionsScope {
add: (attachment: ComposerAttachment) => void
remove: (id: string) => ComposerAttachment | null
target: string
}
const MAIN_ACTIONS_SCOPE: ComposerActionsScope = {
add: addComposerAttachment,
remove: removeComposerAttachment,
target: 'main'
}
interface ComposerActionsOptions {
activeSessionId: string | null
currentCwd: string
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
scope?: ComposerActionsScope
}
/** Add to the main composer and focus it. All sidebar/picker/drop attach paths funnel through here. */
const attachToMain = (attachment: ComposerAttachment) => {
addComposerAttachment(attachment)
requestComposerFocus('main')
}
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
export function useComposerActions({
activeSessionId,
currentCwd,
requestGateway,
scope = MAIN_ACTIONS_SCOPE
}: ComposerActionsOptions) {
const { t } = useI18n()
const copy = t.desktop
/** Add to this scope's composer and focus it. All sidebar/picker/drop
* attach paths funnel through here. */
const attachToMain = useCallback(
(attachment: ComposerAttachment) => {
scope.add(attachment)
requestComposerFocus(scope.target)
},
[scope]
)
const addTextToDraft = useCallback((text: string) => {
requestComposerInsert(text, { mode: 'block' })
}, [])
@ -302,7 +326,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
detail,
refText
})
}, [])
}, [attachToMain])
const pickContextPaths = useCallback(
async (kind: 'file' | 'folder') => {
@ -329,7 +353,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
})
}
},
[currentCwd]
[attachToMain, currentCwd]
)
const insertContextPathInlineRef = useCallback(
@ -344,12 +368,12 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
return false
}
requestComposerInsertRefs([ref])
requestComposerFocus('main')
requestComposerInsertRefs([ref], { target: scope.target })
requestComposerFocus(scope.target)
return true
},
[currentCwd]
[currentCwd, scope.target]
)
const attachContextFilePath = useCallback(
@ -371,7 +395,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
return true
},
[currentCwd]
[attachToMain, currentCwd]
)
const attachImagePath = useCallback(
@ -394,7 +418,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
const previewUrl = await attachmentPreviewDataUrl(filePath)
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
scope.add({ ...baseAttachment, previewUrl })
}
return true
@ -404,7 +428,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
return true
}
},
[copy.imagePreviewFailed]
[attachToMain, copy.imagePreviewFailed, scope]
)
const attachImageBlob = useCallback(
@ -509,7 +533,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
return true
},
[currentCwd]
[attachToMain, currentCwd]
)
const attachDroppedItems = useCallback(
@ -599,7 +623,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
const removeAttachment = useCallback(
async (id: string) => {
const removed = removeComposerAttachment(id)
const removed = scope.remove(id)
if (
removed?.kind === 'image' &&
@ -614,7 +638,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
}).catch(() => undefined)
}
},
[activeSessionId, requestGateway]
[activeSessionId, requestGateway, scope]
)
return {

View file

@ -1,53 +1,75 @@
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react'
import { type DragEvent as ReactDragEvent, useCallback, useEffect, useRef, useState } from 'react'
import {
dragHasAttachments,
dragHasSession,
readSessionDrag,
type SessionDragPayload
} from '@/app/chat/composer/inline-refs'
import { dragHasAttachments } from '@/app/chat/composer/inline-refs'
import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers'
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from './use-composer-actions'
/** `'session'` is set by callers from the pointer drag session's store
* native drags only ever resolve to `'files'` here (sessions left native
* DnD; see session-drag.ts). */
export type DragKind = 'files' | 'session' | null
const dragKindOf = (event: ReactDragEvent): DragKind => {
if (dragHasSession(event.dataTransfer)) {
return 'session'
}
if (dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return 'files'
}
return null
}
const dragKindOf = (event: ReactDragEvent): DragKind =>
dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME) ? 'files' : null
interface FileDropZoneOptions {
/** When false the zone ignores drags entirely. */
enabled?: boolean
onDropFiles: (files: DroppedFile[]) => void
onDropSession?: (session: SessionDragPayload) => void
}
/**
* "Drop anywhere in this region" affordance for files *and* in-app session
* links. An enter/leave depth counter keeps nested children from flickering the
* "Drop anywhere in this region" affordance for FILE drags the one drag
* kind still on native DnD (Finder/OS drops and the project tree must be).
* An enter/leave depth counter keeps nested children from flickering the
* active state; `onDropCapture` clears it even when a nested target (the
* composer) handles the drop and stops propagation before our bubble-phase
* `onDrop` would fire.
*
* Spread `dropHandlers` onto the container; render an overlay off `dragKind`.
* Esc aborts an in-flight drag, matching the sidebar session drag.
*/
export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: FileDropZoneOptions) {
export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) {
const [dragKind, setDragKind] = useState<DragKind>(null)
const depth = useRef(0)
const aborted = useRef(false)
const reset = useCallback(() => {
depth.current = 0
setDragKind(null)
}, [])
// Esc aborts a file drag — the same "never mind" a session drag gets. Native
// DnD can't be cancelled at the OS level, so we drop the overlay and arm a
// guard that swallows the trailing drop instead. Top escape layer + capture
// stop so it doesn't also fire a handler behind the drag (see drag-session).
useEffect(() => {
if (dragKind === null) {
return
}
const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.drag)
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
return
}
event.preventDefault()
event.stopPropagation()
aborted.current = true
reset()
}
window.addEventListener('keydown', onKey, true)
return () => {
window.removeEventListener('keydown', onKey, true)
releaseLayer()
}
}, [dragKind, reset])
const onDragEnter = useCallback(
(event: ReactDragEvent) => {
const kind = enabled ? dragKindOf(event) : null
@ -57,6 +79,12 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }:
}
event.preventDefault()
// A genuinely new drag (not a nested-child re-enter) re-arms after abort.
if (depth.current === 0) {
aborted.current = false
}
depth.current += 1
setDragKind(kind)
},
@ -89,16 +117,17 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }:
return
}
// Only an Esc abort swallows the drop — NOT `event.defaultPrevented`. The
// file tree's app-wide react-dnd HTML5Backend preventDefaults every native
// file drop in the capture phase, so that flag is always set here (every
// Finder drop would no-op). Genuine nested targets claim via stopPropagation
// and never reach this bubble handler anyway.
const claimed = aborted.current
event.preventDefault()
reset()
if (kind === 'session') {
const session = readSessionDrag(event.dataTransfer)
if (session) {
onDropSession?.(session)
}
if (claimed) {
return
}
@ -108,7 +137,7 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }:
onDropFiles(files)
}
},
[enabled, onDropFiles, onDropSession, reset]
[enabled, onDropFiles, reset]
)
return {

View file

@ -1,32 +1,22 @@
import {
type AppendMessage,
AssistantRuntimeProvider,
ExportedMessageRepository,
type ThreadMessage
} from '@assistant-ui/react'
import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import type * as React from 'react'
import { Suspense, useCallback, useMemo, useRef } from 'react'
import { Suspense, useCallback, useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import { Thread } from '@/components/assistant-ui/thread'
import { Backdrop } from '@/components/Backdrop'
import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts'
import { $sessionTileDragging, $sessionTileEdgeHover } from '@/components/pane-shell/tree/store'
import { PromptOverlays } from '@/components/prompt-overlays'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ErrorState } from '@/components/ui/error-state'
import { TitleMenuTrigger } from '@/components/ui/title-menu-trigger'
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import type { ChatMessage } from '@/lib/chat-messages'
import {
coalesceToolOnlyAssistants,
createToolMergeCache,
quickModelOptions,
sessionTitle,
toRuntimeMessage
} from '@/lib/chat-runtime'
import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
@ -35,23 +25,14 @@ import { $petActive } from '@/store/pet'
import { $petOverlayActive } from '@/store/pet-overlay'
import { $gatewaySwapTarget } from '@/store/profile'
import {
$activeSessionId,
$awaitingResponse,
$busy,
$contextSuggestions,
$currentCwd,
$currentModel,
$currentProvider,
$freshDraftReady,
$gatewayState,
$introPersonality,
$introSeed,
$lastVisibleMessageIsUser,
$messages,
$messagesEmpty,
$resumeExhaustedSessionId,
$selectedStoredSessionId,
$sessions,
sessionMatchesStoredId,
sessionPinId
} from '@/store/session'
import { isSecondaryWindow, isWatchWindow } from '@/store/windows'
@ -63,12 +44,15 @@ import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitle
import { ChatDropOverlay } from './chat-drop-overlay'
import { ChatSwapOverlay } from './chat-swap-overlay'
import { ChatBar, ChatBarFallback } from './composer'
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
import { requestComposerInsert } from './composer/focus'
import { droppedFileInlineRefs } from './composer/inline-refs'
import { useComposerScope } from './composer/scope'
import type { ChatBarState } from './composer/types'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { useFileDropZone } from './hooks/use-file-drop-zone'
import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone'
import { useRuntimeMessageRepository } from './runtime-repository'
import { ScrollToBottomButton } from './scroll-to-bottom-button'
import { useSessionView } from './session-view'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
import { threadLoadingState } from './thread-loading'
@ -122,7 +106,7 @@ function ChatHeader({
const pinnedSessionIds = useStore($pinnedSessionIds)
const activeStoredSession =
sessions.find(session => session.id === selectedSessionId || session._lineage_root_id === selectedSessionId) || null
(selectedSessionId && sessions.find(session => sessionMatchesStoredId(session, selectedSessionId))) || null
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
@ -160,14 +144,7 @@ function ChatHeader({
sideOffset={8}
title={title}
>
<Button
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 overflow-hidden border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
<h2 className="min-w-0 flex-1 truncate text-[0.75rem] font-medium leading-none">{title}</h2>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" />
</Button>
<TitleMenuTrigger>{title}</TitleMenuTrigger>
</SessionActionsMenu>
</div>
</header>
@ -207,45 +184,9 @@ function ChatRuntimeBoundary({
onThreadMessagesChange,
suppressMessages
}: ChatRuntimeBoundaryProps) {
const storeMessages = useStore($messages)
const storeMessages = useStore(useSessionView().$messages)
const messages = suppressMessages ? NO_MESSAGES : storeMessages
const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
const toolMergeCacheRef = useRef(createToolMergeCache())
const runtimeMessageRepository = useMemo(() => {
const items: { message: ThreadMessage; parentId: string | null }[] = []
const branchParentByGroup = new Map<string, string | null>()
let visibleParentId: string | null = null
let headId: string | null = null
for (const message of coalesceToolOnlyAssistants(messages, toolMergeCacheRef.current)) {
let parentId = visibleParentId
if (message.role === 'assistant' && message.branchGroupId) {
if (!branchParentByGroup.has(message.branchGroupId)) {
branchParentByGroup.set(message.branchGroupId, visibleParentId)
}
parentId = branchParentByGroup.get(message.branchGroupId) ?? null
}
const cachedMessage = runtimeMessageCacheRef.current.get(message)
const runtimeMessage = cachedMessage ?? toRuntimeMessage(message)
if (!cachedMessage) {
runtimeMessageCacheRef.current.set(message, runtimeMessage)
}
items.push({ message: runtimeMessage, parentId })
if (!message.hidden) {
visibleParentId = message.id
headId = message.id
}
}
return ExportedMessageRepository.fromBranchableArray(items, { headId })
}, [messages])
const runtimeMessageRepository = useRuntimeMessageRepository(messages)
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: runtimeMessageRepository,
@ -293,13 +234,24 @@ export function ChatView({
}: ChatViewProps) {
const location = useLocation()
const { t } = useI18n()
const activeSessionId = useStore($activeSessionId)
const awaitingResponse = useStore($awaitingResponse)
const busy = useStore($busy)
// The view this surface renders: the primary route-driven session (global
// atoms) or a tile's session slice — same component either way.
const view = useSessionView()
const composerScope = useComposerScope()
const isPrimary = view.kind === 'primary'
const activeSessionId = useStore(view.$runtimeId)
const storedId = useStore(view.$storedId)
// Dock anchor for a session drop onto this surface: the workspace pane for the
// primary, this tile's pane id for a tile. Read by the session-drop bridge.
const sessionAnchor = isPrimary ? 'workspace' : `session-tile:${storedId ?? ''}`
const awaitingResponse = useStore(view.$awaitingResponse)
const busy = useStore(view.$busy)
const contextSuggestions = useStore($contextSuggestions)
const currentCwd = useStore($currentCwd)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
// Per-session (SessionView) reads — a tile IS its session, so these come
// from the view slice, not the global atoms (which track the primary only).
const currentCwd = useStore(view.$cwd)
const currentModel = useStore(view.$model)
const currentProvider = useStore(view.$provider)
// A pet anywhere (in-window or popped out) owns the hearts; composer only when none.
const petActive = useStore($petActive)
const petOverlayActive = useStore($petOverlayActive)
@ -310,16 +262,18 @@ export function ChatView({
const gatewayOpen = gatewayState === 'open'
const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed)
// PERF: ChatView must not subscribe to $messages — the atom is replaced on
// every streaming delta flush (~30×/s) and a subscription here re-renders
// the entire chat shell (header, chat bar, thread wrapper) per token. The
// runtime that DOES need the messages lives in ChatRuntimeBoundary below;
// this component only needs streaming-stable derivations.
const messagesEmpty = useStore($messagesEmpty)
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
const selectedSessionId = useStore($selectedStoredSessionId)
// PERF: ChatView must not subscribe to the view's $messages — the atom is
// replaced on every streaming delta flush (~30×/s) and a subscription here
// re-renders the entire chat shell (header, chat bar, thread wrapper) per
// token. The runtime that DOES need the messages lives in
// ChatRuntimeBoundary below; this component only needs streaming-stable
// derivations.
const messagesEmpty = useStore(view.$messagesEmpty)
const lastVisibleIsUser = useStore(view.$lastVisibleIsUser)
const selectedSessionId = useStore(view.$storedId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
const routedSessionId = routeSessionId(location.pathname)
// A tile IS its session — no route involved, never "mismatched".
const routedSessionId = isPrimary ? routeSessionId(location.pathname) : selectedSessionId
const isRoutedSessionView = Boolean(routedSessionId)
// The URL points at a session the store hasn't loaded yet (sidebar / cmd-K /
@ -331,6 +285,7 @@ export function ChatView({
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
// scratch window, not the full-height empty state.
const showIntro =
isPrimary &&
!isSecondaryWindow() &&
freshDraftReady &&
!isRoutedSessionView &&
@ -349,7 +304,7 @@ export function ChatView({
// Suppress the loader and show an explicit error + manual Retry instead of
// spinning forever. Gated on the route matching so a stale latch from another
// session can't blank the current one.
const resumeExhausted = isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
const resumeExhausted = isPrimary && isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
const loadingSession =
!resumeExhausted && isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
@ -420,23 +375,33 @@ export function ChatView({
const refs = droppedFileInlineRefs(inAppRefs, currentCwd)
if (refs.length) {
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
requestComposerInsert(refs.join(' '), { mode: 'inline', target: composerScope.target })
}
if (osDrops.length) {
void onAttachDroppedItems(osDrops)
}
},
[currentCwd, onAttachDroppedItems]
[composerScope.target, currentCwd, onAttachDroppedItems]
)
// Dropping a sidebar session inserts an @session link the agent can resolve
// via session_search (carries the source profile, so cross-profile works).
const onDropSession = useCallback((session: SessionDragPayload) => {
requestComposerInsertRefs([sessionInlineRef(session)], { target: 'main' })
}, [])
// Session drags are POINTER drags (session-drag.ts) — never native DnD.
// The drop zone below only handles files; session drops commit through the
// drag session itself, which routes a center/link drop to this surface's
// composer via `data-composer-target`.
const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles })
const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles, onDropSession })
// While a session drag targets one of this surface's EDGES or a tab strip,
// the zone overlay/caret owns the visual — the link overlay stands down.
// It shows for the whole drag on every chat surface otherwise (the drag
// session's global sentinel, not a per-surface hover chain).
// COMPUTED booleans, never the raw `$dropHint`: the hint churns on every
// pointer-crossing of every drag (pane drags included), and a re-render
// here is the WHOLE surface — thread, composer, header — per mounted tile.
const sessionDragging = useStore($sessionTileDragging)
const sessionEdgeHover = useStore($sessionTileEdgeHover)
const overlayKind: DragKind = dragKind === 'files' ? 'files' : sessionDragging && !sessionEdgeHover ? 'session' : null
return (
<div
@ -444,17 +409,26 @@ export function ChatView({
'relative isolate flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)',
className
)}
data-composer-target={composerScope.target}
data-session-anchor={sessionAnchor}
>
<Backdrop />
<ChatHeader
activeSessionId={activeSessionId}
isRoutedSessionView={isRoutedSessionView}
onDeleteSelectedSession={onDeleteSelectedSession}
onToggleSelectedPin={onToggleSelectedPin}
selectedSessionId={selectedSessionId}
/>
{/* Tiles get their chrome from the layout zone (chip strip); the modal
prompt overlays stay active-session-scoped in the primary surface. */}
{isPrimary && (
<ChatHeader
activeSessionId={activeSessionId}
isRoutedSessionView={isRoutedSessionView}
onDeleteSelectedSession={onDeleteSelectedSession}
onToggleSelectedPin={onToggleSelectedPin}
selectedSessionId={selectedSessionId}
/>
)}
<PromptOverlays />
{/* Mounted for the primary AND every tile, each scoped to its own session
so a tiled/background session's blocking prompt surfaces instead of
stalling to timeout. */}
<PromptOverlays sessionId={activeSessionId} />
<ChatRuntimeBoundary
busy={busy}
@ -510,7 +484,9 @@ export function ChatView({
}}
/>
)}
<ChatDropOverlay kind={dragKind} />
{/* A session drag hovering an EDGE hands the visual to the zone
target; the link overlay shows only for the center region. */}
<ChatDropOverlay kind={overlayKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />
</div>
{/* Composer renders OUTSIDE the contain:[layout paint] wrapper above:

View file

@ -0,0 +1,121 @@
/**
* Mirror a reactive list of "tiles" into layout-tree pane contributions:
* register a pane per tile, refresh its title in place, and dispose panes whose
* tile is gone. This is the shared bookkeeping a keyed registry, a wanted-set
* diff, a one-time pane closer behind BOTH session tiles and route (page)
* tiles; each supplies only what differs (key, title, render, close, edge).
*/
import type { ReadableAtom } from 'nanostores'
import type { ReactElement, ReactNode, PointerEvent as ReactPointerEvent } from 'react'
import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session'
import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store'
import { registry } from '@/contrib/registry'
import type { TileDock } from '@/store/session-states'
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>[]
/** Stable key + pane-id seed for a tile. */
key: (tile: T) => string
/** Pane-id namespace — the id is `${prefix}:${key}`. */
prefix: string
/** Dock on adoption (default right; `center` = stack into anchor's zone). */
dir?: (tile: T) => TileDock | undefined
/** Pane to dock against (default `workspace`) — a drop's target zone. */
anchor?: (tile: T) => string | undefined
/** Center docks: the strip slot (stack before this pane id). */
before?: (tile: T) => null | string | undefined
minWidth: string
title: (key: string) => string
render: (key: string) => ReactNode
/** Wrap the tile's TAB (domain context menu — session verbs). */
tabWrap?: (key: string, tab: ReactElement) => ReactNode
/** Override the tile's TAB drag (session drop language: stack/split/link).
* Returns whether it took the drag (see PaneChrome.tabDrag). */
tabDrag?: (
key: string,
event: ReactPointerEvent<HTMLElement>,
onTap: () => void,
double?: DoubleTapContext
) => boolean
/** Wired as the pane's closer (tab Close). */
close: (key: string) => void
}
/** 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 }>()
const paneId = (key: string) => `${cfg.prefix}:${key}`
const sync = () => {
const tiles = cfg.source.get()
const wanted = new Set(tiles.map(cfg.key))
for (const tile of tiles) {
const key = cfg.key(tile)
const title = cfg.title(key)
const current = registered.get(key)
// register() replaces same-id in place — safe for live title refreshes.
if (current && current.title === title) {
continue
}
const dispose = registry.register({
id: paneId(key),
area: 'panes',
title,
data: {
dock: {
before: cfg.before?.(tile),
pane: cfg.anchor?.(tile) ?? 'workspace',
pos: cfg.dir?.(tile) ?? 'right'
},
minWidth: cfg.minWidth,
placement: 'main',
tabDrag: cfg.tabDrag
? (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) =>
cfg.tabDrag!(key, event, onTap, double)
: undefined, // returns boolean (handled) — see PaneChrome.tabDrag
tabWrap: cfg.tabWrap ? (tab: ReactElement) => cfg.tabWrap!(key, tab) : undefined
},
render: () => cfg.render(key)
})
registered.set(key, { dispose, title })
if (!current) {
registerPaneCloser(paneId(key), () => cfg.close(key))
}
}
for (const [key, entry] of registered) {
if (!wanted.has(key)) {
entry.dispose()
registered.delete(key)
removeTreePane(paneId(key))
}
}
// Prune tree panes the SHARED tree persisted for a tile we never registered
// this session and that isn't wanted now — a profile switch reloads with the
// other profile's tile panes still stacked in. (`registered` is empty after a
// reload, so the loop above can't catch these.)
for (const id of treePanesWithPrefix(`${cfg.prefix}:`)) {
if (!wanted.has(id.slice(cfg.prefix.length + 1))) {
removeTreePane(id)
}
}
}
return () => {
sync()
cfg.source.listen(sync)
cfg.also?.forEach(atom => atom.listen(sync))
}
}

View file

@ -1 +1 @@
export { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH, PREVIEW_RAIL_PANE_WIDTH } from './preview'
export { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from './preview'

View file

@ -498,8 +498,10 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
event.preventDefault()
event.stopPropagation()
// Insert into and focus the SAME composer — 'active' — so a tile that owns
// focus keeps it instead of the ref landing in a tile but main stealing focus.
requestComposerInsertRefs([ref])
requestComposerFocus('main')
requestComposerFocus('active')
}
window.addEventListener('keydown', onKeyDown, { capture: true })

View file

@ -10,6 +10,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { PANE_TAB_STRIP_LINE, PaneTab, PaneTabLabel } from '@/components/ui/pane-tab'
import { Tip } from '@/components/ui/tooltip'
import { translateNow, useI18n } from '@/i18n'
import { formatCombo } from '@/lib/keybinds/combo'
@ -38,14 +39,6 @@ import { PreviewPane } from './preview-pane'
export const PREVIEW_RAIL_MIN_WIDTH = '18rem'
export const PREVIEW_RAIL_MAX_WIDTH = '38rem'
const INTRINSIC = `clamp(${PREVIEW_RAIL_MIN_WIDTH}, 36vw, 32rem)`
// Track for <Pane id="preview">. Folds the intrinsic clamp with a min-floor
// against --chat-min-width so the chat surface never gets squeezed below it.
// Subtracts the project browser width so preview yields rather than crushing
// the chat when both right-side panes are open.
export const PREVIEW_RAIL_PANE_WIDTH = `min(${INTRINSIC}, max(0rem, calc(100vw - var(--pane-chat-sidebar-width) - var(--pane-file-browser-width, 0rem) - var(--chat-min-width))))`
interface ChatPreviewRailProps {
onRestartServer?: (url: string, context?: string) => Promise<string>
setTitlebarToolGroup?: SetTitlebarToolGroup
@ -110,7 +103,12 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
// titlebar-height so it opens below the band. 0px elsewhere → unchanged.
style={{ paddingTop: 'var(--right-rail-top-inset, 0px)' }}
>
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)">
<div
className={cn(
'group/rail-tabs flex h-(--titlebar-height) shrink-0 bg-(--ui-sidebar-surface-background)',
PANE_TAB_STRIP_LINE
)}
>
<div
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
@ -124,67 +122,20 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
return (
<ContextMenu key={tab.id}>
<ContextMenuTrigger asChild>
<div
className={cn(
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
active
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
// Middle-click closes the tab, matching browser/IDE muscle
// memory. `onMouseDown` swallows the middle-button press so
// Chromium doesn't switch into autoscroll mode.
onAuxClick={event => {
if (event.button !== 1) {
return
}
event.preventDefault()
closeRightRailTab(tab.id)
}}
onMouseDown={event => {
if (event.button === 1) {
event.preventDefault()
}
}}
>
{active && (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
)}
<PaneTab active={active} dirty={dirty} onClose={() => closeRightRailTab(tab.id)}>
<Tip label={tab.target.path || tab.target.url || tab.label}>
<button
<PaneTabLabel
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
as="button"
className="normal-case tracking-normal"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
type="button"
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
{tab.label}
</PaneTabLabel>
</Tip>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
/>
{dirty && (
<span
aria-hidden="true"
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center opacity-100 transition-opacity group-hover/tab:opacity-0 group-focus-within/tab:opacity-0"
>
{/* Amber (our warn color); a tab-bg ring + soft drop keeps it
legible where it overlaps the filename. */}
<span className="size-2 rounded-full bg-amber-500 shadow-[0_0_0_2px_var(--tab-bg),0_1px_2px_rgba(0,0,0,0.45)] dark:bg-amber-400" />
</span>
)}
<button
aria-label={t.preview.closeTab(tab.label)}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
onClick={() => closeRightRailTab(tab.id)}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
</div>
</PaneTab>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => closeRightRailTab(tab.id)}>

View file

@ -0,0 +1,90 @@
/**
* ROUTE (PAGE) TILES a full-page view rendered as a layout-tree pane BESIDE
* the main thread, the page analog of session tiles. Built-in pages
* (Capabilities / Messaging / Artifacts) render their view; plugin pages render
* their `ROUTES_AREA` contribution. Lifecycle mirrors session tiles:
* `openRouteTile(path)` -> `watchRouteTiles` registers a pane docked beside
* main -> tree adoption lands it on the chosen edge; closing removes it.
*/
import { lazy, type ReactNode, Suspense } from 'react'
import { ContribBoundary } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { $routeTiles, closeRouteTile, type RouteTile } from '@/store/route-tiles'
import { ARTIFACTS_ROUTE, contributedRoutes, MESSAGING_ROUTE, ROUTES_AREA, SKILLS_ROUTE } from '../routes'
import { paneMirror } from './pane-mirror'
const SkillsView = lazy(async () => ({ default: (await import('../skills')).SkillsView }))
const MessagingView = lazy(async () => ({ default: (await import('../messaging')).MessagingView }))
const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView }))
// Built-in page views + their pane titles, keyed by route.
const BUILTIN_PAGES: Record<string, { render: () => ReactNode; title: string }> = {
[ARTIFACTS_ROUTE]: { render: () => <ArtifactsView />, title: 'Artifacts' },
[MESSAGING_ROUTE]: { render: () => <MessagingView />, title: 'Messaging' },
[SKILLS_ROUTE]: { render: () => <SkillsView />, title: 'Capabilities' }
}
/** Humanize a route path into a tab title: `/my-atlas` → `My Atlas`. */
const humanizePath = (path: string): string =>
path
.replace(/^\/+/, '')
.split(/[/-]/)
.filter(Boolean)
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ') || path
/** Title for a route tile: the built-in name, the contribution's own `title`,
* else a humanized path never the internal `${source}:${id}` key. */
function routeTitle(path: string): string {
if (BUILTIN_PAGES[path]) {
return BUILTIN_PAGES[path].title
}
return contributedRoutes().find(r => r.path === path)?.title ?? humanizePath(path)
}
function RouteTilePane({ path }: { path: string }) {
const builtin = BUILTIN_PAGES[path]
// Subscribe so a plugin page tile appears the moment its route registers.
useContributions(ROUTES_AREA)
const contrib = builtin ? null : contributedRoutes().find(r => r.path === path)
if (builtin) {
return (
<ContribBoundary id={path}>
<Suspense fallback={null}>{builtin.render()}</Suspense>
</ContribBoundary>
)
}
if (contrib) {
return <ContribBoundary id={path}>{contrib.render()}</ContribBoundary>
}
return (
<div className="grid h-full place-items-center font-mono text-[11px] text-(--ui-text-quaternary)">
no page at {path}
</div>
)
}
// ---------------------------------------------------------------------------
// Route tile -> pane contribution sync (call once from the app root).
// ---------------------------------------------------------------------------
/** Keep pane contributions mirroring `$routeTiles`. Call once from the root. */
export const watchRouteTiles = paneMirror<RouteTile>({
source: $routeTiles,
key: t => t.path,
prefix: 'route-tile',
dir: t => t.dir,
minWidth: '22rem',
title: routeTitle,
render: path => <RouteTilePane path={path} />,
close: closeRouteTile
})

View file

@ -0,0 +1,51 @@
import { ExportedMessageRepository, type ThreadMessage } from '@assistant-ui/react'
import { useMemo, useRef } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { coalesceToolOnlyAssistants, createToolMergeCache, toRuntimeMessage } from '@/lib/chat-runtime'
/**
* ChatMessage[] -> assistant-ui message repository, with a WeakMap identity
* cache so unchanged messages convert once (and a tool-merge cache that folds
* tool-only assistant turns into their neighbour). Shared by the main chat's
* runtime boundary and session tiles one transcript pipeline, N surfaces.
*/
export function useRuntimeMessageRepository(messages: ChatMessage[]): ExportedMessageRepository {
const cacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
const toolMergeCacheRef = useRef(createToolMergeCache())
return useMemo(() => {
const items: { message: ThreadMessage; parentId: string | null }[] = []
const branchParentByGroup = new Map<string, string | null>()
let visibleParentId: string | null = null
let headId: string | null = null
for (const message of coalesceToolOnlyAssistants(messages, toolMergeCacheRef.current)) {
let parentId = visibleParentId
if (message.role === 'assistant' && message.branchGroupId) {
if (!branchParentByGroup.has(message.branchGroupId)) {
branchParentByGroup.set(message.branchGroupId, visibleParentId)
}
parentId = branchParentByGroup.get(message.branchGroupId) ?? null
}
const cachedMessage = cacheRef.current.get(message)
const runtimeMessage = cachedMessage ?? toRuntimeMessage(message)
if (!cachedMessage) {
cacheRef.current.set(message, runtimeMessage)
}
items.push({ message: runtimeMessage, parentId })
if (!message.hidden) {
visibleParentId = message.id
headId = message.id
}
}
return ExportedMessageRepository.fromBranchableArray(items, { headId })
}, [messages])
}

View file

@ -0,0 +1,193 @@
/**
* Sidebar session drag the session RESOLVER over the shared pointer drag
* session (pane-shell drag-session.ts). Same machinery as a pane drag
* (threshold, rAF moves, snapshots, Esc-as-top-layer with synchronous
* teardown), session-specific targeting:
*
* - a chat zone's TAB STRIP stack: open the session as a tab at the
* divider's slot (the strip caret shows it);
* - a chat zone's EDGE band split: open the session as a tile docked on
* that edge (the zone sheet morphs to the half);
* - a chat zone's CENTER / the composer link: insert an `@session` chip
* into that surface's composer (ChatDropOverlay owns the visual);
* - anything else (sidebar, terminal, gutters) deny.
*
* Zones that don't host a chat surface are NOT targets the overlay never
* lights them, so a release there must not commit either (one truth).
*
* This replaced the native-HTML5 drag + SessionTileDropBridge: riding the
* native DnD layer meant macOS's cancel snap-back animation, a `dragend`
* held hostage until that animation finished, an Esc the page never even
* saw, and window-level armor against react-dnd/dnd-kit. A pointer session
* has none of those failure modes. Native DnD remains only at the true OS
* boundary (Finder file drops). Known trade: a session can no longer be
* dragged into a separate BrowserWindow (native DnD was the only transport
* that crossed windows).
*/
import type { PointerEvent as ReactPointerEvent } from 'react'
import { findGroup } from '@/components/pane-shell/tree/model'
import {
type DoubleTapContext,
rectContains,
slotBefore,
snapshotStrips,
snapshotZones,
startDragSession,
type StripSnapshot,
subZonePosition
} from '@/components/pane-shell/tree/renderer/drag-session'
import {
$layoutTree,
$treeDragging,
type DropHint,
revealTreePane,
SESSION_TILE_DRAG
} from '@/components/pane-shell/tree/store'
import type { EngineZone, ZoneRect } from '@/components/pane-shell/tree/zones-engine'
import { openSessionTile, type TileDock } from '@/store/session-states'
import { requestComposerInsertRefs } from './composer/focus'
import { type SessionDragPayload, sessionInlineRef, sessionLabel } from './composer/inline-refs'
/** A chat surface's drag-start geometry: the anchor pane id it advertises
* (`data-session-anchor`) and the composer a link drop routes to
* (`data-composer-target`). */
interface SurfaceSnapshot {
anchor: string
composerTarget: string
rect: ZoneRect
}
const snapRect = (el: HTMLElement): ZoneRect => {
const r = el.getBoundingClientRect()
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }
}
function snapshotSurfaces(): SurfaceSnapshot[] {
return [...document.querySelectorAll<HTMLElement>('[data-session-anchor]')].map(el => ({
anchor: el.dataset.sessionAnchor || 'workspace',
composerTarget: el.dataset.composerTarget || 'main',
rect: snapRect(el)
}))
}
/** A session may land in a zone only if it hosts a chat surface never the
* sidebar/terminal zones. Returns the pane a stack anchors to. */
function chatZonePane(groupId: string): null | string {
const tree = $layoutTree.get()
const panes = tree ? (findGroup(tree, groupId)?.panes ?? []) : []
return panes.find(p => p === 'workspace' || p.startsWith('session-tile:')) ?? null
}
/**
* Begin dragging a session a sidebar row OR a tile's own tab (same drop
* language either way: stack, split, or composer link). Sub-threshold releases
* stay ordinary clicks, so `opts.onTap` (activate the tile) and `opts.double`
* (hide the tab bar) ride the tab's gestures; Esc aborts instantly. A stack/
* split commits through `openSessionTile`, which OPENS a new tile from a sidebar
* row and MOVES the existing one when its tab is the drag source.
*/
export function startSessionDrag(
payload: SessionDragPayload,
e: ReactPointerEvent<HTMLElement>,
opts?: { double?: DoubleTapContext; onTap?: () => void }
) {
let zones: EngineZone[] = []
let strips: StripSnapshot[] = []
let surfaces: SurfaceSnapshot[] = []
let composers: ZoneRect[] = []
let zoneHost = new Map<string, null | string>()
// Commit intent, updated per resolved move (the machinery flushes the final
// move before commit, so these always match the released-at position).
let split: { anchor: string; before?: null | string; pos: TileDock } | null = null
let link: null | string = null
// The drag SOURCE (sidebar row or tile tab). Captured synchronously — React
// clears `currentTarget` after the pointerdown handler returns, but this runs
// inside it. Dimmed while lifted so the source reads as "picked up" — the
// same in-place feedback pane-tab drags use, replacing the old cursor chip.
const source = e.currentTarget
const restoreOpacity = source?.style.opacity ?? ''
startDragSession(e, {
double: opts?.double,
ghost: { label: sessionLabel(payload) },
onTap: opts?.onTap,
onEngage() {
zones = snapshotZones()
strips = snapshotStrips()
surfaces = snapshotSurfaces()
composers = [...document.querySelectorAll<HTMLElement>('[data-slot="composer-root"]')].map(snapRect)
zoneHost = new Map(zones.map(zone => [zone.id, chatZonePane(zone.id)]))
source?.style.setProperty('opacity', '0.45')
// The same sentinel the zone overlay + chat surfaces key off — the
// whole drop language (sheets, pills, caret, link overlay) lights up.
$treeDragging.set(SESSION_TILE_DRAG)
},
onEnd() {
if (source) {
source.style.opacity = restoreOpacity
}
},
resolveMove(x, y): DropHint | null {
const zone = zones.find(z => rectContains(z.rect, x, y))
const host = zone ? zoneHost.get(zone.id) : null
if (!zone || !host) {
split = null
link = null
return null
}
// The zone's TAB STRIP stacks the session at the divider's slot.
const strip = strips.find(s => s.groupId === zone.id && rectContains(s.rect, x, y))
if (strip) {
// Exclude the tile's OWN tab from the slots so re-dropping it in its
// home strip reorders cleanly (a no-op for a sidebar-row drag).
const stack = slotBefore(strip.slots, x, `session-tile:${payload.id}`)
split = { anchor: host, before: stack.before, pos: 'center' }
link = null
return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos: 'center', stack }
}
// The composer (and everything in it) is always the link/attach drop;
// elsewhere the shared radial targeting decides center vs edge.
const pos = composers.some(rect => rectContains(rect, x, y)) ? 'center' : subZonePosition(zones, zone.id, x, y)
const surface = surfaces.find(s => rectContains(s.rect, x, y))
if (pos === 'center') {
split = null
link = surface?.composerTarget ?? 'main'
} else {
split = { anchor: surface?.anchor ?? 'workspace', pos }
link = null
}
return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos }
},
onCommit() {
if (split) {
openSessionTile(payload.id, split.pos, split.anchor, split.before)
// A tile for this session may already exist (openSessionTile is
// idempotent — e.g. persisted from an earlier run): a drop must never
// feel dead, so front/unhide/un-dismiss it either way.
revealTreePane(`session-tile:${payload.id}`)
} else if (link) {
// The "link to chat" drop: an @session chip in that surface's composer.
requestComposerInsertRefs([sessionInlineRef(payload)], { target: link })
}
}
})
}

View file

@ -0,0 +1,359 @@
/**
* Prompt actions for a SESSION TILE the same verbs the primary chat wires
* (submit incl. slash, cancel, steer, edit, reload, restore, branch-hide
* sync), targeted at the tile's session instead of the active one. State
* writes go through the delegate's `updateSession` (the wiring cache), so
* the cache, the primary view, and every tile mirror stay one truth; view
* concerns (busy pill, transcript) reach the tile via its `$sessionStates`
* slice never the global `$busy`/`$messages`.
*/
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import { useCallback, useMemo, useRef } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import type { ClientSessionState } from '@/app/types'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import { useI18n } from '@/i18n'
import { textPart } from '@/lib/chat-messages'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { clearClarifyRequest } from '@/store/clarify'
import type { ComposerAttachment } from '@/store/composer'
import { resetSessionBackground } from '@/store/composer-status'
import { notifyError } from '@/store/notifications'
import { clearPreviewArtifacts } from '@/store/preview-status'
import { clearAllPrompts } from '@/store/prompts'
import { $connection } from '@/store/session'
import { $sessionStates, sessionTileDelegate } from '@/store/session-states'
import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'
import { uploadComposerAttachment } from '../session/hooks/use-prompt-actions'
import {
applyBranchVisibility,
applyReloadOptimistic,
applyRewindOptimistic,
finalizeInterruptedMessages,
planEdit,
planReload,
planRestore,
runRewindSubmit
} from '../session/hooks/use-prompt-actions/rewind'
import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit'
import { type SubmitTextOptions } from '../session/hooks/use-prompt-actions/utils'
import type { ComposerScope } from './composer/scope'
interface SessionTileActionsArgs {
runtimeId: string
scope: ComposerScope
storedSessionId: string
}
export function useSessionTileActions({ runtimeId, scope, storedSessionId }: SessionTileActionsArgs) {
const { t } = useI18n()
const copy = t.desktop
const { requestGateway } = useGatewayRequest()
const runtimeIdRef = useRef(runtimeId)
runtimeIdRef.current = runtimeId
const storedIdRef = useRef(storedSessionId)
storedIdRef.current = storedSessionId
// Tile busy tracks the SESSION state, never the global $busy — and it must
// read LIVE. A render-time snapshot goes stale (this hook's host doesn't
// re-render on busy edges), and a stale `true` silently blocks every
// subsequent submit ("tile only sends one message"). The setter is a no-op:
// session state owns busy; submit's optimistic writes flow through
// updateSession.
const busyRef = useMemo(
() =>
({
get current() {
return $sessionStates.get()[runtimeIdRef.current]?.busy ?? false
},
set current(_value: boolean) {
// Owned by session state.
}
}) as { current: boolean },
[]
)
const update = useCallback(
(updater: (state: ClientSessionState) => ClientSessionState) =>
sessionTileDelegate()?.updateSession(runtimeIdRef.current, updater),
[]
)
const readState = useCallback(() => $sessionStates.get()[runtimeIdRef.current], [])
const readMessages = useCallback(() => readState()?.messages ?? [], [readState])
// Tile-side attachment staging: same upload rules as the primary submit
// (skip synced/pathless, byte-upload files+images), against the tile scope.
const syncAttachmentsForSubmit = useCallback(
async (
sessionId: string,
attachments: ComposerAttachment[],
options: { updateComposerAttachments?: boolean } = {}
): Promise<ComposerAttachment[]> => {
const remote = $connection.get()?.mode === 'remote'
const synced: ComposerAttachment[] = []
for (const attachment of attachments) {
if (!attachment.path || attachment.attachedSessionId === sessionId) {
synced.push(attachment)
continue
}
if (attachment.kind === 'image' || attachment.kind === 'file') {
const next = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
if (options.updateComposerAttachments ?? true) {
scope.attachments.update(next)
}
synced.push(next)
continue
}
synced.push(attachment)
}
return synced
},
[requestGateway, scope.attachments]
)
// The REAL submit pipeline with tile seams: session always exists, and the
// scope's writers replace the global view/attachment writes.
const submitPromptText = useSubmitPrompt({
activeSessionId: runtimeId,
activeSessionIdRef: runtimeIdRef,
busyRef,
copy,
createBackendSessionForSend: async () => runtimeIdRef.current,
// A tile IS its session — no route to abandon, so the create-abort guard's
// token is a stable constant (the guard never trips for a tile).
getRouteToken: () => runtimeId,
requestGateway,
selectedStoredSessionIdRef: storedIdRef,
syncAttachmentsForSubmit,
updateSessionState: (sessionId, updater) => sessionTileDelegate()!.updateSession(sessionId, updater),
scope: {
clearAttachments: scope.attachments.clear,
readAttachments: () => scope.attachments.$attachments.get(),
// Busy/messages flow through updateSession -> the tile's state slice;
// the primary view atoms must never see a tile turn.
setAwaitingResponse: () => undefined,
setBusy: () => undefined,
setMessages: () => undefined
}
})
const submitText = useCallback(
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
const attachments = options?.attachments ?? scope.attachments.$attachments.get()
if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) {
triggerHaptic('selection')
await sessionTileDelegate()?.executeSlash(visibleText, runtimeIdRef.current)
return true
}
return await submitPromptText(rawText, options)
},
[scope.attachments.$attachments, submitPromptText]
)
const appendSystemNote = useCallback(
(text: string) => {
update(state => ({
...state,
messages: [
...state.messages,
{ id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, role: 'system', parts: [textPart(text)] }
]
}))
},
[update]
)
const cancelRun = useCallback(async () => {
const sessionId = runtimeIdRef.current
update(state => ({
...state,
messages: finalizeInterruptedMessages(state.messages, state.streamId),
busy: false,
awaitingResponse: false,
streamId: null,
pendingBranchGroup: null,
needsInput: false,
interrupted: true
}))
clearSessionTodos(sessionId)
clearSessionSubagents(sessionId)
resetSessionBackground(sessionId)
clearAllPrompts(sessionId)
clearClarifyRequest(undefined, sessionId)
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch (err) {
notifyError(err, copy.stopFailed)
}
}, [copy.stopFailed, requestGateway, update])
const steerPrompt = useCallback(
async (rawText: string): Promise<boolean> => {
const text = rawText.trim()
if (!text) {
return false
}
try {
const result = await requestGateway<{ status?: string }>('session.steer', {
session_id: runtimeIdRef.current,
text
})
if (result?.status === 'queued') {
triggerHaptic('submit')
appendSystemNote(`steer:${text}`)
return true
}
} catch {
// Swallow — the caller queues the text so nothing is lost.
}
return false
},
[appendSystemNote, requestGateway]
)
// Rewind primitive (interrupt-first for live turns, busy-retry) — shared with
// the primary chat so the two can't diverge.
const submitRewind = useCallback(
(text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) =>
runRewindSubmit(requestGateway, runtimeIdRef.current, text, truncateOrdinal, interruptFirst),
[requestGateway]
)
const reloadFromMessage = useCallback(
async (parentId: string | null) => {
const state = readState()
if (!state || state.busy) {
return
}
const plan = planReload(state.messages, parentId)
if (!plan) {
return
}
update(current => applyReloadOptimistic(current, plan))
try {
await requestGateway(
'prompt.submit',
{ session_id: runtimeIdRef.current, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
} catch (err) {
update(current => ({ ...current, busy: false, awaitingResponse: false }))
notifyError(err, copy.regenerateFailed)
}
},
[copy.regenerateFailed, readState, requestGateway, update]
)
const restoreToMessage = useCallback(
async (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => {
const sessionId = runtimeIdRef.current
const messages = readMessages()
const plan = planRestore(messages, messageId, target)
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearPreviewArtifacts(sessionId)
const wasBusy = readState()?.busy ?? false
update(state => applyRewindOptimistic(state, plan.sourceIndex))
try {
await submitRewind(plan.text, plan.truncateOrdinal, wasBusy)
} catch (err) {
update(state => ({ ...state, busy: false, awaitingResponse: false, messages }))
throw err
}
},
[readMessages, readState, submitRewind, update]
)
const editMessage = useCallback(
async (edited: AppendMessage) => {
const messages = readMessages()
const plan = planEdit(messages, edited)
if (!plan) {
return
}
const sessionId = runtimeIdRef.current
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearPreviewArtifacts(sessionId)
const wasBusy = readState()?.busy ?? false
update(state => applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage))
try {
await submitRewind(plan.text, plan.truncateOrdinal, wasBusy)
} catch (err) {
update(state => ({ ...state, busy: false, awaitingResponse: false, messages }))
notifyError(err, copy.editFailed)
}
},
[copy.editFailed, readMessages, readState, submitRewind, update]
)
// Branch-visibility sync (assistant-ui hides non-active branches).
const handleThreadMessagesChange = useCallback(
(nextMessages: readonly ThreadMessage[]) => update(state => applyBranchVisibility(state, nextMessages)),
[update]
)
const dismissError = useCallback(
(messageId: string) => {
update(state => ({ ...state, messages: state.messages.filter(m => m.id !== messageId) }))
},
[update]
)
return useMemo(
() => ({
cancelRun,
dismissError,
editMessage,
handleThreadMessagesChange,
reloadFromMessage,
restoreToMessage,
steerPrompt,
submitText
}),
[cancelRun, dismissError, editMessage, handleThreadMessagesChange, reloadFromMessage, restoreToMessage, steerPrompt, submitText]
)
}

View file

@ -0,0 +1,436 @@
/**
* SESSION TILES a stored session rendered as a layout-tree pane BESIDE the
* main thread (multi-session tiling). A tile IS the real chat surface: the
* same ChatView/ChatBar/Thread tree the primary session renders, mounted
* under a tile `SessionView` (its session's slice of `$sessionStates`) and a
* tile `ComposerScope` (own attachment chips, own focus-bus key). Actions
* (submit/slash/steer/edit/reload/restore/stop) come from
* `useSessionTileActions`, all writing through the wiring cache.
*
* Lifecycle: `openSessionTile(storedId)` -> `watchSessionTiles` registers a
* pane contribution docked right of the main zone -> tree adoption lands it
* -> the pane mounts and asks the delegate for a live runtime id. Closing
* the pane (tab Close) removes the tile + its zone; tiles persist across
* restarts and re-resume on boot.
*/
import { useStore } from '@nanostores/react'
import { atom, computed } from 'nanostores'
import { useEffect, useMemo, useRef } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
import { $layoutTree, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { transcribeAudio } from '@/hermes'
import { useI18n } from '@/i18n'
import type { ChatMessage } from '@/lib/chat-messages'
import { sessionTitle } from '@/lib/chat-runtime'
import { createComposerAttachmentScope } from '@/store/composer'
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
import { sessionAwaitingInput } from '@/store/prompts'
import {
$gatewayState,
$selectedStoredSessionId,
$sessions,
sessionMatchesStoredId,
sessionPinId
} from '@/store/session'
import {
$sessionStates,
$sessionTiles,
closeSessionTile,
discardSessionTile,
patchSessionTile,
type SessionTile,
sessionTileDelegate
} from '@/store/session-states'
import type { SessionDragPayload } from './composer/inline-refs'
import { type ComposerScope, ComposerScopeProvider } from './composer/scope'
import { useComposerActions } from './hooks/use-composer-actions'
import { paneMirror } from './pane-mirror'
import { startSessionDrag } from './session-drag'
import { useSessionTileActions } from './session-tile-actions'
import { type SessionView, SessionViewProvider } from './session-view'
import { SessionContextMenu } from './sidebar/session-actions-menu'
import { lastVisibleMessageIsUser } from './thread-loading'
import { ChatView } from '.'
const NO_MESSAGES: ChatMessage[] = []
/** The tile's SessionView: the same atom shape the primary chat renders
* from, computed from this session's slice of `$sessionStates`. */
function buildTileView(storedSessionId: string): SessionView {
const $runtimeId = computed(
$sessionTiles,
tiles => tiles.find(t => t.storedSessionId === storedSessionId)?.runtimeId ?? null
)
const $state = computed([$runtimeId, $sessionStates], (runtimeId, states) =>
runtimeId ? states[runtimeId] : undefined
)
const $messages = computed($state, state => state?.messages ?? NO_MESSAGES)
return {
kind: 'tile',
$awaitingResponse: computed($state, state => Boolean(state?.awaitingResponse)),
$busy: computed($state, state => Boolean(state?.busy)),
$cwd: computed($state, state => state?.cwd ?? ''),
$lastVisibleIsUser: computed($messages, lastVisibleMessageIsUser),
$messages,
$messagesEmpty: computed($messages, messages => messages.length === 0),
$model: computed($state, state => state?.model ?? ''),
$provider: computed($state, state => state?.provider ?? ''),
$runtimeId,
// Constant for the tile's lifetime — a plain atom, not a computed.
$storedId: atom(storedSessionId)
}
}
function TileChat({
runtimeId,
storedSessionId,
view
}: {
runtimeId: string
storedSessionId: string
view: SessionView
}) {
const { gatewayRef, requestGateway } = useGatewayRequest()
const cwd = useStore(view.$cwd)
// One attachment set + focus key per tile, stable for the tile's lifetime.
const attachments = useRef(createComposerAttachmentScope()).current
const scope = useMemo<ComposerScope>(
() => ({
$awaitingInput: sessionAwaitingInput(runtimeId),
attachments,
popoutAllowed: false,
readMessages: () => view.$messages.get(),
target: `tile:${storedSessionId}`
}),
[attachments, runtimeId, storedSessionId, view.$messages]
)
const actions = useSessionTileActions({ runtimeId, scope, storedSessionId })
// The same attach/pick/paste/drop pipeline the primary composer uses,
// pointed at this tile's chips + session.
const composer = useComposerActions({
activeSessionId: runtimeId,
currentCwd: cwd,
requestGateway,
scope: { add: attachments.add, remove: attachments.remove, target: scope.target }
})
return (
<SessionViewProvider value={view}>
<ComposerScopeProvider value={scope}>
<ChatView
gateway={gatewayRef.current}
onAddContextRef={composer.addContextRefAttachment}
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
onAttachDroppedItems={composer.attachDroppedItems}
onAttachImageBlob={composer.attachImageBlob}
onBranchInNewChat={() => undefined}
onCancel={actions.cancelRun}
onDeleteSelectedSession={() => undefined}
onDismissError={actions.dismissError}
onEdit={actions.editMessage}
onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)}
onPickFiles={() => void composer.pickContextPaths('file')}
onPickFolders={() => void composer.pickContextPaths('folder')}
onPickImages={() => void composer.pickImages()}
onReload={actions.reloadFromMessage}
onRemoveAttachment={id => void composer.removeAttachment(id)}
onRestoreToMessage={actions.restoreToMessage}
onRetryResume={() => patchSessionTile(storedSessionId, { error: undefined })}
onSteer={actions.steerPrompt}
onSubmit={actions.submitText}
onThreadMessagesChange={actions.handleThreadMessagesChange}
onToggleSelectedPin={() => undefined}
onTranscribeAudio={async audio => (await transcribeAudio(await blobToDataUrl(audio), audio.type)).transcript}
/>
</ComposerScopeProvider>
</SessionViewProvider>
)
}
export function SessionTilePane({ storedSessionId }: { storedSessionId: string }) {
const tiles = useStore($sessionTiles)
const tile = tiles.find(t => t.storedSessionId === storedSessionId)
const runtimeId = tile?.runtimeId ?? null
const gatewayOpen = useStore($gatewayState) === 'open'
const resumingRef = useRef(false)
const view = useMemo(() => buildTileView(storedSessionId), [storedSessionId])
// Same gating as the primary's route resume (use-route-resume): never fire
// session.resume before the gateway is OPEN. Persisted tiles mount at boot
// while it's still connecting — an ungated resume rejected there and
// latched every restored tile into the error card.
useEffect(() => {
if (!gatewayOpen || runtimeId || tile?.error || resumingRef.current) {
return
}
const delegate = sessionTileDelegate()
if (!delegate) {
return
}
resumingRef.current = true
delegate
.resumeTile(storedSessionId)
.then(id => patchSessionTile(storedSessionId, { error: undefined, runtimeId: id }))
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
// A gone session (404 / "Session not found") is terminal — a stale or
// cross-profile persisted tile. Discard it instead of latching an error
// that re-retries on every reconnect (the "Session not found" spam).
if (/session not found|\b404\b/i.test(message)) {
discardSessionTile(storedSessionId)
} else {
patchSessionTile(storedSessionId, { error: message })
}
})
.finally(() => {
resumingRef.current = false
})
}, [gatewayOpen, runtimeId, storedSessionId, tile?.error])
// The gateway (re)opening invalidates any latched error — it likely came
// from a not-yet-open gateway or the previous connection. Clearing it
// retriggers the resume effect: one bounded auto-retry per (re)connect,
// mirroring the primary path's became-open resync.
useEffect(() => {
if (gatewayOpen && tile?.error) {
patchSessionTile(storedSessionId, { error: undefined })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gatewayOpen, storedSessionId])
if (tile?.error) {
return (
<div className="grid h-full place-items-center p-4">
<div className="max-w-[24rem] space-y-2 text-center font-mono text-[11px]">
<div className="text-(--ui-danger,#f87171)">Couldn't open this session</div>
<div className="break-words text-(--ui-text-quaternary)">{tile.error}</div>
<Button onClick={() => patchSessionTile(storedSessionId, { error: undefined })} size="sm" variant="outline">
Retry
</Button>
</div>
</div>
)
}
if (!runtimeId) {
// The SAME session loader the primary thread shows (Thread's
// loading === 'session' branch) — one loading language everywhere.
return (
<div className="relative h-full">
<CenteredThreadSpinner />
</div>
)
}
return <TileChat runtimeId={runtimeId} storedSessionId={storedSessionId} view={view} />
}
// ---------------------------------------------------------------------------
// Tile -> pane contribution sync (call once from the app root).
// ---------------------------------------------------------------------------
function tileTitle(storedSessionId: string): string {
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
return stored ? sessionTitle(stored) : 'Session'
}
/** The `@session` link payload for a tile tab drag — id + owning profile + title. */
function tileDragPayload(storedSessionId: string): SessionDragPayload {
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
return { id: storedSessionId, profile: stored?.profile ?? '', title: tileTitle(storedSessionId) }
}
// ---------------------------------------------------------------------------
// Close confirmation — a BUSY tab (streaming, or blocked on clarify/approval
// input) doesn't close silently.
// ---------------------------------------------------------------------------
/** Stored id awaiting close confirmation (null = no dialog). */
const $confirmCloseTile = atom<null | string>(null)
/** The tile closer, gated: a quiet session closes immediately; a busy or
* input-blocked one asks first. One state read the tile's runtime slice. */
export function requestCloseSessionTile(storedSessionId: string): void {
const runtimeId = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId)?.runtimeId
const state = runtimeId ? $sessionStates.get()[runtimeId] : undefined
if (state?.busy || state?.awaitingResponse || state?.needsInput) {
$confirmCloseTile.set(storedSessionId)
} else {
closeSessionTile(storedSessionId)
}
}
/** Mounted once at the shell root: the "Close running tab?" confirmation. */
export function SessionTileCloseConfirm() {
const { t } = useI18n()
const storedSessionId = useStore($confirmCloseTile)
return (
<ConfirmDialog
confirmLabel={t.zones.closeRunningConfirm}
description={t.zones.closeRunningBody}
destructive
onClose={() => $confirmCloseTile.set(null)}
onConfirm={() => {
if (storedSessionId) {
closeSessionTile(storedSessionId)
}
}}
open={storedSessionId !== null}
title={t.zones.closeRunningTitle}
/>
)
}
/** Layout reset every session tile collapses into the MAIN zone as a tab
* after the workspace (the primary session stays the first tab), the "smart"
* reset: N scattered tiles become one tab bar over the chat instead of
* re-docking to their old edges.
*
* Runs BEFORE generic adoption (see registerLayoutResetHandler) the tiles
* aren't in the fresh tree yet, so each `moveTreePane` ADDS the tile into the
* workspace group as a tab (append). The main group id is re-read each pass
* because appending returns a new tree. */
export function stackSessionTilesIntoMain(): void {
for (const tile of $sessionTiles.get()) {
const tree = $layoutTree.get()
const mainGroup = tree ? findGroupOfPane(tree, 'workspace')?.id : null
if (mainGroup) {
moveTreePane(`session-tile:${tile.storedSessionId}`, { groupId: mainGroup, pos: 'center' })
}
}
}
/** A session TAB's context menu: the full session verb set (pin, copy id, new
* window, branch, rename, archive, delete) the SAME menu a sidebar row
* gets, targeted through the tile delegate (whose verbs are generic over
* stored ids, primary included). The wrapper stops the contextmenu from also
* opening the zone strip's menu. Shared by tile tabs AND the main tab. */
export function SessionTabMenu({
children,
onClose,
onHideTabBar,
storedSessionId,
tabPaneId
}: {
children: React.ReactElement
/** Close this tab (tiles; the main tab passes nothing). */
onClose?: () => void
/** Hide the zone's tab bar (main tab only — the sticky bar's off switch). */
onHideTabBar?: () => void
storedSessionId: string
/** Layout-tree pane id — powers the Close-others/right/all verbs. */
tabPaneId: string
}) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const stored = sessions.find(s => sessionMatchesStoredId(s, storedSessionId))
const pinId = stored ? sessionPinId(stored) : storedSessionId
const pinned = pinnedSessionIds.includes(pinId)
return (
<span className="contents" onContextMenu={event => event.stopPropagation()}>
<SessionContextMenu
onArchive={() => void sessionTileDelegate()?.archiveSession(storedSessionId)}
onBranch={() => void sessionTileDelegate()?.branchSession(storedSessionId)}
onClose={onClose}
onDelete={() => void sessionTileDelegate()?.deleteSession(storedSessionId)}
onHideTabBar={onHideTabBar}
onPin={() => (pinned ? unpinSession(pinId) : pinSession(pinId))}
pinned={pinned}
profile={stored?.profile}
sessionId={storedSessionId}
surface="tab"
tabPaneId={tabPaneId}
title={tileTitle(storedSessionId)}
>
{children}
</SessionContextMenu>
</span>
)
}
/** The MAIN tab's menu: the same session verbs targeting the primary's loaded
* session, plus the bar's off switch (the bar sticky-shows once a tab is
* ever gained; this is the explicit way back). A fresh draft has no session
* no menu. */
export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) {
const selected = useStore($selectedStoredSessionId)
const hideTabBar = () => {
const tree = $layoutTree.get()
const group = tree ? findGroupOfPane(tree, 'workspace') : null
if (group) {
setTreeGroupHeaderHidden(group.id, true)
}
}
if (!selected) {
return children
}
return (
<SessionTabMenu onHideTabBar={hideTabBar} storedSessionId={selected} tabPaneId="workspace">
{children}
</SessionTabMenu>
)
}
/** Keep pane contributions mirroring `$sessionTiles` (+ titles from
* `$sessions`). Tiles dock against main on the chosen edge, flex width. */
export const watchSessionTiles = paneMirror<SessionTile>({
source: $sessionTiles,
also: [$sessions],
key: t => t.storedSessionId,
prefix: 'session-tile',
dir: t => t.dir,
anchor: t => t.anchor,
before: t => t.before,
minWidth: '20rem',
title: tileTitle,
render: storedSessionId => <SessionTilePane storedSessionId={storedSessionId} />,
tabWrap: (storedSessionId, tab) => (
<SessionTabMenu
onClose={() => requestCloseSessionTile(storedSessionId)}
storedSessionId={storedSessionId}
tabPaneId={`session-tile:${storedSessionId}`}
>
{tab}
</SessionTabMenu>
),
// A tile's tab drags like a sidebar row — stack / split / drop-to-link — with
// its tap (activate) + double-tap (hide bar) preserved. Always takes the drag.
tabDrag: (storedSessionId, event, onTap, double) => {
startSessionDrag(tileDragPayload(storedSessionId), event, { double, onTap })
return true
},
close: requestCloseSessionTile
})

View file

@ -0,0 +1,61 @@
import type { ReadableAtom } from 'nanostores'
import { createContext, useContext } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import {
$activeSessionId,
$awaitingResponse,
$busy,
$currentCwd,
$currentModel,
$currentProvider,
$lastVisibleMessageIsUser,
$messages,
$messagesEmpty,
$selectedStoredSessionId
} from '@/store/session'
/**
* SESSION VIEW the store surface a ChatView renders from. The PRIMARY view
* is the app's classic global atoms (route-driven active session, untouched
* fast path). A session TILE provides the same shape computed from its
* session's slice of `$sessionStates`, so the identical ChatView tree renders
* either one chat surface, N sessions on screen.
*
* Everything is atoms (not values) so subscription granularity survives:
* ChatView subscribes only to the coarse edges; `$messages` stays boundary-
* only exactly like the primary view's perf contract.
*/
export interface SessionView {
kind: 'primary' | 'tile'
$runtimeId: ReadableAtom<string | null>
$storedId: ReadableAtom<string | null>
$messages: ReadableAtom<ChatMessage[]>
$busy: ReadableAtom<boolean>
$awaitingResponse: ReadableAtom<boolean>
$messagesEmpty: ReadableAtom<boolean>
$lastVisibleIsUser: ReadableAtom<boolean>
$cwd: ReadableAtom<string>
$model: ReadableAtom<string>
$provider: ReadableAtom<string>
}
export const PRIMARY_SESSION_VIEW: SessionView = {
kind: 'primary',
$awaitingResponse,
$busy,
$cwd: $currentCwd,
$lastVisibleIsUser: $lastVisibleMessageIsUser,
$messages,
$messagesEmpty,
$model: $currentModel,
$provider: $currentProvider,
$runtimeId: $activeSessionId,
$storedId: $selectedStoredSessionId
}
const SessionViewContext = createContext<SessionView>(PRIMARY_SESSION_VIEW)
export const SessionViewProvider = SessionViewContext.Provider
export const useSessionView = (): SessionView => useContext(SessionViewContext)

View file

@ -3,10 +3,12 @@ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { KbdGroup } from '@/components/ui/kbd'
import { SearchField } from '@/components/ui/search-field'
@ -19,6 +21,7 @@ import {
SidebarMenuButton,
SidebarMenuItem
} from '@/components/ui/sidebar'
import { useContributions } from '@/contrib/react/use-contributions'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useI18n } from '@/i18n'
import { comboTokens } from '@/lib/keybinds/combo'
@ -34,8 +37,6 @@ import {
$sidebarAgentsGrouped,
$sidebarCronOpen,
$sidebarMessagingOpenIds,
$sidebarOpen,
$sidebarOverlayMounted,
$sidebarPinsOpen,
$sidebarProjectOrderIds,
$sidebarRecentsOpen,
@ -78,6 +79,7 @@ import {
refreshWorktrees,
scanAndRecordRepos
} from '@/store/projects'
import { openRouteTile } from '@/store/route-tiles'
import {
$cronSessions,
$currentCwd,
@ -85,7 +87,6 @@ import {
$messagingPlatformTotals,
$messagingSessions,
$messagingTruncated,
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
$sessionsLoading,
@ -94,8 +95,16 @@ import {
sessionPinId,
setCurrentCwd
} from '@/store/session'
import { $focusedStoredSessionId, type SplitDir } from '@/store/session-states'
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
import {
type AppView,
ARTIFACTS_ROUTE,
MESSAGING_ROUTE,
SIDEBAR_NAV_AREA,
type SidebarNavContribution,
SKILLS_ROUTE
} from '../../routes'
import type { SidebarNavItem } from '../../types'
import { countLabel } from './chrome'
@ -121,6 +130,7 @@ import {
} from './projects'
import { SidebarBlankState, SidebarPinnedEmptyState, SidebarSessionSkeletons } from './section-states'
import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section'
import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu'
// Non-session groups (messaging platforms) stay compact: show a few rows up
// front, reveal more in larger steps on demand. Keeps a busy platform from
@ -209,6 +219,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
onArchiveSession: (sessionId: string) => void
onBranchSession: (sessionId: string) => void
onNewSessionInWorkspace: (path: null | string) => void
/** Create a brand-new session and open it as a tile on `dir`. */
onNewSessionSplit: (dir: SplitDir) => void
onManageCronJob: (jobId: string) => void
onTriggerCronJob: (jobId: string) => void
}
@ -224,22 +236,49 @@ export function ChatSidebar({
onArchiveSession,
onBranchSession,
onNewSessionInWorkspace,
onNewSessionSplit,
onManageCronJob,
onTriggerCronJob
}: ChatSidebarProps) {
const { t } = useI18n()
const s = t.sidebar
const sidebarOpen = useStore($sidebarOpen)
// Collapsed-but-overlay-mounted → render the full sidebar, not just the nav rail.
const overlayMounted = useStore($sidebarOverlayMounted)
const contentVisible = sidebarOpen || overlayMounted
const { pathname } = useLocation()
// Contributed nav rows (plugins pairing a page with a sidebar entry) render
// below the built-ins with the same chrome; active = at their route.
const navContributions = useContributions(SIDEBAR_NAV_AREA)
const contributedNav = useMemo<SidebarNavItem[]>(
() =>
navContributions.flatMap(c => {
const data = c.data as Partial<SidebarNavContribution> | undefined
if (!data?.path?.startsWith('/') || !data.label) {
return []
}
const codicon = data.codicon || 'plug'
return [
{
id: c.id,
label: data.label,
icon: (props: { className?: string }) => <Codicon name={codicon} {...props} />,
route: data.path
}
]
}),
[navContributions]
)
const panesFlipped = useStore($panesFlipped)
const agentsGrouped = useStore($sidebarAgentsGrouped)
const pinnedSessionIds = useStore($pinnedSessionIds)
const pinsOpen = useStore($sidebarPinsOpen)
const agentsOpen = useStore($sidebarRecentsOpen)
const cronOpen = useStore($sidebarCronOpen)
const selectedSessionId = useStore($selectedStoredSessionId)
// The sidebar highlight tracks the FOCUSED session — the interacted tile's
// tab, else the main selection — so it stays 1:1 with whatever tab is active.
const selectedSessionId = useStore($focusedStoredSessionId)
const sessions = useStore($sessions)
const cronSessions = useStore($cronSessions)
const cronJobs = useStore($cronJobs)
@ -1031,15 +1070,12 @@ export function ChatSidebar({
return (
<Sidebar
className={cn(
// Visibility is the layout tree's job (a hidden zone is display:none;
// the narrow overlay renders the live instance) — the sidebar always
// paints itself fully.
'relative h-full min-w-0 overflow-hidden border-t-0 border-b-0 text-foreground transition-none',
panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0',
sidebarOpen
? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
: 'pointer-events-none border-transparent bg-transparent opacity-0',
// While floated by PaneShell's hover-reveal, force visible + interactive
// — on hover (group-hover/reveal) or when keyboard-pinned (data-forced).
'in-data-[pane-hover-reveal=open]:pointer-events-auto in-data-[pane-hover-reveal=open]:border-(--sidebar-edge-border) in-data-[pane-hover-reveal=open]:bg-(--ui-sidebar-surface-background) in-data-[pane-hover-reveal=open]:opacity-100',
'group-hover/reveal:pointer-events-auto group-hover/reveal:border-(--sidebar-edge-border) group-hover/reveal:bg-(--ui-sidebar-surface-background) group-hover/reveal:opacity-100'
'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
)}
collapsible="none"
>
@ -1047,62 +1083,85 @@ export function ChatSidebar({
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.375rem)]">
<SidebarGroupContent>
<SidebarMenu className="gap-px">
{SIDEBAR_NAV.map(item => {
{[...SIDEBAR_NAV, ...contributedNav].map(item => {
const isInteractive = Boolean(item.action) || Boolean(item.route)
const active =
(item.id === 'skills' && currentView === 'skills') ||
(item.id === 'messaging' && currentView === 'messaging') ||
(item.id === 'artifacts' && currentView === 'artifacts')
(item.id === 'artifacts' && currentView === 'artifacts') ||
// Contributed rows light up at their own route.
(Boolean(item.route) && pathname === item.route)
const isNewSession = item.id === 'new-session'
const button = (
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
// no-drag: these rows sit directly under the titlebar's
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
// 6px of clearance. Drag regions win hit-testing over DOM
// (pointer-events can't override), and on Linux/WSLg the
// resolved region has been observed to swallow clicks on the
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
// thread.tsx.
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
active &&
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
)}
onClick={() => {
// A plain new session lands in whatever profile the live
// gateway is on (= the active switcher context). null →
// no swap. The switcher header is the single place to
// change which profile that is.
if (isNewSession) {
$newChatProfile.set(null)
}
onNavigate(item)
}}
tooltip={s.nav[item.id] ?? item.label}
type="button"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
size="sm"
/>
)}
</SidebarMenuButton>
)
// New session + route-backed pages can open in a split —
// right-click for the directional "Open in split" submenu.
return (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
// no-drag: these rows sit directly under the titlebar's
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
// 6px of clearance. Drag regions win hit-testing over DOM
// (pointer-events can't override), and on Linux/WSLg the
// resolved region has been observed to swallow clicks on the
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
// thread.tsx.
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
active &&
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
)}
onClick={() => {
// A plain new session lands in whatever profile the live
// gateway is on (= the active switcher context). null →
// no swap. The switcher header is the single place to
// change which profile that is.
if (isNewSession) {
$newChatProfile.set(null)
}
onNavigate(item)
}}
tooltip={s.nav[item.id] ?? item.label}
type="button"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
{contentVisible && (
<>
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
size="sm"
/>
)}
</>
)}
</SidebarMenuButton>
{isNewSession || item.route ? (
<ContextMenu>
<ContextMenuTrigger asChild>{button}</ContextMenuTrigger>
<ContextMenuContent aria-label={s.nav[item.id] ?? item.label}>
<SplitSubmenu
kit={CONTEXT_SPLIT_KIT}
label={s.row.openInSplit}
onSplit={dir => {
if (isNewSession) {
onNewSessionSplit(dir)
} else if (item.route) {
openRouteTile(item.route, dir)
}
}}
/>
</ContextMenuContent>
</ContextMenu>
) : (
button
)}
</SidebarMenuItem>
)
})}
@ -1110,7 +1169,7 @@ export function ChatSidebar({
</SidebarGroupContent>
</SidebarGroup>
{contentVisible && showSessionSections && (
{showSessionSections && (
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField
aria-label={s.searchAria}
@ -1122,7 +1181,7 @@ export function ChatSidebar({
</div>
)}
{contentVisible && showSessionSections && (
{showSessionSections && (
<div className={cn('flex min-h-0 flex-1 flex-col pb-1.75', SCROLL_Y)}>
{trimmedQuery && (
<SidebarSessionsSection
@ -1392,13 +1451,11 @@ export function ChatSidebar({
</div>
)}
{contentVisible && !showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
{!showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
{contentVisible && (
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
<ProfileRail />
</div>
)}
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
<ProfileRail />
</div>
</SidebarContent>
<ProjectDialog />
</Sidebar>

View file

@ -35,6 +35,12 @@ import { getProfileSoul, updateProfileSoul } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import {
REORDER_DRAG_TRANSITION_CSS,
REORDER_RAIL_TRANSITION,
reorderCommitHaptic,
reorderStepHaptic
} from '@/lib/reorder'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import {
@ -67,12 +73,11 @@ const RAIL_GAP = 4 // px — matches gap-1 between squares.
// select. Drag-reorder and long-press-recolor live only on the squares path.
const PROFILE_DROPDOWN_THRESHOLD = 13
// easeOutBack — a little overshoot so squares spring into their new slot rather
// than sliding in flat. Neighbors reflow on RAIL_TRANSITION; the dragged square
// glides between snapped cells on the snappier DRAG_TRANSITION.
const SPRING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'
const RAIL_TRANSITION = { duration: 300, easing: SPRING }
const DRAG_TRANSITION = `transform 200ms ${SPRING}`
// Neighbors reflow on RAIL_TRANSITION; the dragged square glides between
// snapped cells on the snappier DRAG_TRANSITION. Both come from the SHARED
// reorder primitive (lib/reorder.ts) so every reorder strip feels identical.
const RAIL_TRANSITION = REORDER_RAIL_TRANSITION
const DRAG_TRANSITION = REORDER_DRAG_TRANSITION_CSS
// The rail is a single horizontal strip of fixed cells. Pin drags to the x-axis
// (no cross-axis scrollbar), snap to whole cells so a square steps slot-to-slot
@ -174,7 +179,7 @@ export function ProfileRail() {
if (id && id !== lastOverRef.current) {
lastOverRef.current = id
triggerHaptic('selection')
reorderStepHaptic()
}
}
@ -191,7 +196,7 @@ export function ProfileRail() {
if (from >= 0 && to >= 0) {
setProfileOrder(arrayMove(ids, from, to))
triggerHaptic('success')
reorderCommitHaptic()
}
}

View file

@ -16,6 +16,9 @@ const activeGateway = vi.fn<() => { request: typeof request } | null>(() => ({ r
vi.mock('@/hermes', () => ({
renameSession: (...args: unknown[]) => renameSession(...(args as [])),
// profile.ts calls this at import (its $activeGatewayProfile subscribe fires
// immediately), pulled in transitively via session-states.
setApiRequestProfile: () => {},
HermesGateway: class {}
}))

View file

@ -1,9 +1,17 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { closeAllTreeTabs, closeOtherTreeTabs, closeTreeTabsToRight, treeTabCloseTargets } from '@/components/pane-shell/tree/store'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { CopyButton } from '@/components/ui/copy-button'
import {
Dialog,
@ -13,7 +21,13 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { renameSession } from '@/hermes'
import { useI18n } from '@/i18n'
@ -22,6 +36,7 @@ import { exportSession } from '@/lib/session-export'
import { activeGateway } from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
import { $activeSessionId, $selectedStoredSessionId, setSessions } from '@/store/session'
import { $sessionTiles, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import type { SessionTitleResponse } from '../../types'
@ -80,10 +95,31 @@ interface SessionActions {
onBranch?: () => void
onArchive?: () => void
onDelete?: () => void
/** Close this surface (a tile tab) omitted where nothing closes (sidebar
* rows, the main tab). */
onClose?: () => void
/** TAB surfaces: the session is already a tab, so "Open in new tab" is
* nonsense there sidebar rows/dropdowns keep it. */
surface?: 'row' | 'tab'
/** The tab's layout-tree pane id (`session-tile:<id>` or `workspace`) enables
* the Close-others / to-the-right / all tab verbs. Tab surfaces only. */
tabPaneId?: string
/** The MAIN tab's escape hatch: hide the zone's tab bar (it sticky-shows
* once a tab is ever gained; this is the explicit off switch). */
onHideTabBar?: () => void
}
type MenuItem = typeof DropdownMenuItem | typeof ContextMenuItem
/** A menu flavour (dropdown / context) — item + separator components. */
interface MenuKit {
Item: MenuItem
Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator
}
const DROPDOWN_KIT: MenuKit = { Item: DropdownMenuItem, Separator: DropdownMenuSeparator }
const CONTEXT_KIT: MenuKit = { Item: ContextMenuItem, Separator: ContextMenuSeparator }
interface ItemSpec {
className?: string
disabled: boolean
@ -101,26 +137,45 @@ function useSessionActions({
onPin,
onBranch,
onArchive,
onDelete
onDelete,
onClose,
onHideTabBar,
surface = 'row',
tabPaneId
}: SessionActions) {
const { t } = useI18n()
const r = t.sidebar.row
const [renameOpen, setRenameOpen] = useState(false)
const tiles = useStore($sessionTiles)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const pinItem: ItemSpec = {
disabled: !onPin,
icon: 'pin',
label: pinned ? r.unpin : r.pin,
onSelect: () => {
triggerHaptic('selection')
onPin?.()
}
}
// Already showing as a tab somewhere (a tile, or loaded in main — main IS
// a tab): offering "Open in new tab" again is noise.
const alreadyTabbed = sessionId === selectedStoredSessionId || tiles.some(tile => tile.storedSessionId === sessionId)
const items: ItemSpec[] = [
const spec = (partial: Omit<ItemSpec, 'onSelect'> & { onSelect: () => void }): ItemSpec => partial
// OPEN — where else this session can go. A tab surface IS a tab already,
// so it only offers the window hop (and its own Close, below).
const openItems: ItemSpec[] = [
...(surface === 'row' && !alreadyTabbed
? [
spec({
disabled: !sessionId,
icon: 'browser',
label: r.openInNewTab,
onSelect: () => {
triggerHaptic('selection')
// Stack into the MAIN zone as a tab (center dock; the strip
// sticky-shows on gain) — the door to the tab bar.
openSessionTile(sessionId, 'center')
}
})
]
: []),
...(canOpenSessionWindow()
? [
{
spec({
disabled: !sessionId,
icon: 'link-external',
label: r.newWindow,
@ -128,28 +183,14 @@ function useSessionActions({
triggerHaptic('selection')
void openSessionInNewWindow(sessionId)
}
}
})
]
: []),
{
disabled: !sessionId,
icon: 'cloud-download',
label: r.export,
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { profile, title })
}
},
{
disabled: !onBranch,
icon: 'git-branch',
label: r.branchFrom,
onSelect: () => {
triggerHaptic('selection')
onBranch?.()
}
},
{
: [])
]
// IDENTITY — name/mark/reference the session.
const identityItems: ItemSpec[] = [
spec({
disabled: !sessionId,
icon: 'edit',
label: r.rename,
@ -157,8 +198,96 @@ function useSessionActions({
triggerHaptic('selection')
setRenameOpen(true)
}
},
{
}),
spec({
disabled: !onPin,
icon: 'pin',
label: pinned ? r.unpin : r.pin,
onSelect: () => {
triggerHaptic('selection')
onPin?.()
}
})
]
// WORK — derive/extract from the session.
const workItems: ItemSpec[] = [
spec({
disabled: !onBranch,
icon: 'git-branch',
label: r.branchFrom,
onSelect: () => {
triggerHaptic('selection')
onBranch?.()
}
}),
spec({
disabled: !sessionId,
icon: 'cloud-download',
label: r.export,
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { profile, title })
}
})
]
// TAB — close verbs that act on the strip (tabs only; a row isn't a tab).
const closeTargets = surface === 'tab' && tabPaneId ? treeTabCloseTargets(tabPaneId) : null
const tabCloseItems: ItemSpec[] =
surface === 'tab'
? [
...(onClose
? [
spec({
disabled: false,
icon: 'close',
label: t.common.close,
onSelect: () => {
triggerHaptic('selection')
onClose()
}
})
]
: []),
...(tabPaneId
? [
spec({
disabled: !closeTargets?.others,
icon: 'close-all',
label: t.zones.closeOthers,
onSelect: () => {
triggerHaptic('selection')
closeOtherTreeTabs(tabPaneId)
}
}),
spec({
disabled: !closeTargets?.right,
icon: 'arrow-right',
label: t.zones.closeToRight,
onSelect: () => {
triggerHaptic('selection')
closeTreeTabsToRight(tabPaneId)
}
}),
spec({
disabled: !closeTargets?.all,
icon: 'clear-all',
label: t.zones.closeAll,
onSelect: () => {
triggerHaptic('selection')
closeAllTreeTabs(tabPaneId)
}
})
]
: [])
]
: []
// DANGER — put it away / destroy it (delete stays last, destructive-red).
const dangerItems: ItemSpec[] = [
spec({
disabled: !onArchive,
icon: 'archive',
label: r.archive,
@ -166,7 +295,7 @@ function useSessionActions({
triggerHaptic('selection')
onArchive?.()
}
},
}),
{
className: 'text-destructive focus:text-destructive',
disabled: !onDelete,
@ -187,11 +316,13 @@ function useSessionActions({
</Item>
)
const renderItems = (Item: MenuItem) => (
const renderItems = (kit: MenuKit) => (
<>
{renderMenuItem(Item, pinItem)}
{openItems.map(item => renderMenuItem(kit.Item, item))}
{openItems.length > 0 && <kit.Separator />}
{identityItems.map(item => renderMenuItem(kit.Item, item))}
<CopyButton
appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
appearance={kit.Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
disabled={!sessionId}
errorMessage={r.copyIdFailed}
iconClassName="size-3.5 text-current"
@ -200,7 +331,30 @@ function useSessionActions({
onCopyError={err => notifyError(err, r.copyIdFailed)}
text={sessionId}
/>
{items.map(spec => renderMenuItem(Item, spec))}
<kit.Separator />
{workItems.map(item => renderMenuItem(kit.Item, item))}
{tabCloseItems.length > 0 && (
<>
<kit.Separator />
{tabCloseItems.map(item => renderMenuItem(kit.Item, item))}
</>
)}
<kit.Separator />
{dangerItems.map(item => renderMenuItem(kit.Item, item))}
{onHideTabBar && (
<>
<kit.Separator />
{renderMenuItem(kit.Item, {
disabled: false,
icon: 'eye-closed',
label: r.hideTabBar,
onSelect: () => {
triggerHaptic('selection')
onHideTabBar()
}
})}
</>
)}
</>
)
@ -225,10 +379,11 @@ interface SessionActionsMenuProps
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
const { t } = useI18n()
const { renameDialog, renderItems } = useSessionActions(actions)
const [open, setOpen] = useState(false)
return (
<>
<DropdownMenu>
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
@ -236,7 +391,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
className="w-40"
sideOffset={sideOffset}
>
{renderItems(DropdownMenuItem)}
{renderItems(DROPDOWN_KIT)}
</DropdownMenuContent>
</DropdownMenu>
{renameDialog}
@ -257,7 +412,7 @@ export function SessionContextMenu({ children, ...actions }: SessionContextMenuP
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent aria-label={t.sidebar.row.actionsFor(actions.title)} className="w-40">
{renderItems(ContextMenuItem)}
{renderItems(CONTEXT_KIT)}
</ContextMenuContent>
</ContextMenu>
{renameDialog}

View file

@ -1,7 +1,7 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
import { startSessionDrag } from '@/app/chat/session-drag'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@ -14,6 +14,7 @@ import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { coarseElapsed } from '@/lib/time'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
import { openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
@ -92,7 +93,7 @@ export function SidebarSessionRow({
>
<SidebarRowShell
actions={
<div className="relative z-2 grid w-[1.375rem] place-items-center">
<div className="relative z-2 grid w-[1.375rem] place-items-center" data-row-actions>
{!isWorking && (
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
{age}
@ -130,21 +131,19 @@ export function SidebarSessionRow({
className
)}
data-working={isWorking ? 'true' : undefined}
draggable
onDragStart={event => {
// Reorder drags belong to dnd-kit (the grab handle) — cancel the
// native drag so the two DnD systems don't fight.
if ((event.target as HTMLElement).closest('[data-reorder-handle]')) {
event.preventDefault()
onPointerDown={event => {
// Reorder drags belong to dnd-kit (the grab handle); the ⋯ actions
// cluster keeps its own gestures. Everything else on the row —
// including the row-body BUTTON, the natural grab surface — is a
// session drag source: a POINTER drag on the shared drag session
// (never native HTML5 DnD: no macOS snap-back, Esc aborts
// instantly). Sub-threshold releases stay ordinary clicks, so
// resume / pin / open-in-window are untouched.
if ((event.target as HTMLElement).closest('[data-reorder-handle], [data-row-actions]')) {
return
}
writeSessionDrag(event.dataTransfer, {
id: session.id,
profile: session.profile || 'default',
title
})
startSessionDrag({ id: session.id, profile: session.profile || 'default', title }, event)
}}
ref={ref}
style={style}
@ -153,7 +152,40 @@ export function SidebarSessionRow({
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
<SidebarRowBody
className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')}
// Middle-click = open in a new tab (browser muscle memory). Swallow
// the mousedown so Chromium doesn't enter autoscroll mode.
onAuxClick={event => {
if (event.button === 1) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
openSessionTile(session.id, 'center')
}
}}
onClick={event => {
const mod = event.metaKey || event.ctrlKey
// ⇧⌘-click → pop into its own window (needs standalone windows).
if (mod && event.shiftKey && canOpenSessionWindow()) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
void openSessionInNewWindow(session.id)
return
}
// ⌘/⌃-click → open in a new tab (stack into main).
if (mod) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
openSessionTile(session.id, 'center')
return
}
// ⇧-click → pin.
if (event.shiftKey) {
event.preventDefault()
event.stopPropagation()
@ -163,21 +195,9 @@ export function SidebarSessionRow({
return
}
// ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
// window — the universal "open in a new window" gesture. Archive
// lives in the row's ⋯ and right-click menus. Falls through to a
// normal resume when standalone windows aren't available (web embed).
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
void openSessionInNewWindow(session.id)
return
}
onResume()
}}
onMouseDown={event => event.button === 1 && event.preventDefault()}
>
{reorderable ? (
<SidebarRowGrab

View file

@ -0,0 +1,92 @@
import { Codicon } from '@/components/ui/codicon'
import {
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger
} from '@/components/ui/context-menu'
import {
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
import type { SplitDir } from '@/store/session-states'
/** The leaf + submenu components for one menu flavour, so the split submenu
* renders in either the `` dropdown or a right-click context menu. */
export interface SplitMenuKit {
Item: typeof DropdownMenuItem | typeof ContextMenuItem
Sub: typeof DropdownMenuSub | typeof ContextMenuSub
SubContent: typeof DropdownMenuSubContent | typeof ContextMenuSubContent
SubTrigger: typeof DropdownMenuSubTrigger | typeof ContextMenuSubTrigger
}
export const DROPDOWN_SPLIT_KIT: SplitMenuKit = {
Item: DropdownMenuItem,
Sub: DropdownMenuSub,
SubContent: DropdownMenuSubContent,
SubTrigger: DropdownMenuSubTrigger
}
export const CONTEXT_SPLIT_KIT: SplitMenuKit = {
Item: ContextMenuItem,
Sub: ContextMenuSub,
SubContent: ContextMenuSubContent,
SubTrigger: ContextMenuSubTrigger
}
// Ordered so the default (right) sits first, one hop away.
const SPLIT_DIRS: { dir: SplitDir; icon: string; label: string }[] = [
{ dir: 'right', icon: 'arrow-right', label: 'Right' },
{ dir: 'bottom', icon: 'arrow-down', label: 'Down' },
{ dir: 'left', icon: 'arrow-left', label: 'Left' },
{ dir: 'top', icon: 'arrow-up', label: 'Up' }
]
interface SplitSubmenuProps {
kit: SplitMenuKit
label: string
onSplit: (dir: SplitDir) => void
disabled?: boolean
/** Dismiss the owning menu after the row's default (right) split the
* dropdown is controlled and can; a context menu can't, so it's a no-op. */
close?: () => void
}
/**
* "Open in split ▸": clicking the row splits right (the common case), and the
* submenu picks any edge. Shared by session rows and page nav rows.
*/
export function SplitSubmenu({ close, disabled, kit, label, onSplit }: SplitSubmenuProps) {
const { Item, Sub, SubContent, SubTrigger } = kit
const split = (dir: SplitDir) => {
triggerHaptic('selection')
onSplit(dir)
}
return (
<Sub>
<SubTrigger
disabled={disabled}
onClick={() => {
split('right')
close?.()
}}
>
<Codicon name="split-horizontal" size="0.875rem" />
<span>{label}</span>
</SubTrigger>
<SubContent>
{SPLIT_DIRS.map(({ dir, icon, label: dirLabel }) => (
<Item key={dir} onSelect={() => split(dir)}>
<Codicon name={icon} size="0.875rem" />
<span>{dirLabel}</span>
</Item>
))}
</SubContent>
</Sub>
)
}

View file

@ -0,0 +1,28 @@
/**
* Command-palette contribution surface `palette` data contributions become
* rows in the K root list, same schema as every other area. Contributions
* with an `action` id render that action's live keybind as their hotkey hint.
*/
import { useContributions } from '@/contrib/react/use-contributions'
import type { IconComponent } from '@/lib/icons'
export const PALETTE_AREA = 'palette'
/** Payload of a `palette` data contribution. */
export interface PaletteContribution {
id: string
label: string
/** Keybind action id — its live combo renders as the hotkey hint. */
action?: string
icon?: IconComponent
keywords?: string[]
run: () => void
}
/** Contributed palette rows, with stable render keys. */
export function usePaletteContributions(): Array<PaletteContribution & { key: string }> {
return useContributions(PALETTE_AREA)
.map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as PaletteContribution) }))
.filter(item => Boolean(item.label && item.run))
}

View file

@ -80,6 +80,7 @@ import { FIELD_LABELS, SECTIONS } from '../settings/constants'
import { fieldCopyForSchemaKey } from '../settings/field-copy'
import { prettyName } from '../settings/helpers'
import { usePaletteContributions } from './contrib'
import { MarketplaceThemePage } from './marketplace-theme-page'
import { PetInlineToggle, PetPalettePage } from './pet-palette-page'
@ -368,6 +369,8 @@ export function CommandPalette() {
[t.settings.fieldLabels]
)
const contributedItems = usePaletteContributions()
const baseGroups = useMemo<PaletteGroup[]>(() => {
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
const cc = t.commandCenter
@ -559,9 +562,26 @@ export function CommandPalette() {
run: go(settingsTab(entry.tab))
}))
]
}
},
// Registry-contributed rows (core features + plugins) — one group,
// omitted while nothing contributes.
...(contributedItems.length > 0
? [
{
heading: cc.commands,
items: contributedItems.map(item => ({
action: item.action,
icon: item.icon ?? Zap,
id: item.key,
keywords: item.keywords,
label: item.label,
run: item.run
}))
}
]
: [])
]
}, [go, settingsSectionLabel, t, worktrees])
}, [contributedItems, go, settingsSectionLabel, t, worktrees])
// The long, granular lists (settings fields, API keys, MCP servers, archived
// chats) only surface once the user types — otherwise they'd bury the

View file

@ -0,0 +1,36 @@
import { createContext, memo, useContext } from 'react'
import { DecodeText } from '@/components/ui/decode-text'
import { StatusbarControls } from '../shell/statusbar-controls'
import type { WiringApi } from './types'
/** The controller publishes its wired surfaces here; every registered pane
* / chrome slot reads one back through `WiredPane`. */
export const ContribWiringContext = createContext<WiringApi | null>(null)
/** Render a wired surface inside a registered pane / chrome slot.
*
* Memoized on `part` (its only prop): a zone re-rendering for reasons that
* don't touch the wiring a drag hint sweeping the tree, a sash resize, an
* edit-mode toggle re-renders the group chrome but NOT this component, so
* the (expensive) pane body it reads from context is untouched. When the
* wiring's `api` genuinely changes, the context read re-renders it as normal. */
export const WiredPane = memo(function WiredPane({ part }: { part: keyof WiringApi }) {
const api = useContext(ContribWiringContext)
if (!api) {
if (part === 'statusbar') {
return <StatusbarControls items={[]} leftItems={[]} />
}
return (
<div className="grid h-full place-items-center">
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" />
</div>
)
}
return <>{api[part]}</>
})

View file

@ -0,0 +1,673 @@
import { useStore } from '@nanostores/react'
import { computed } from 'nanostores'
import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } from 'react'
import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail'
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { allPaneIds, group, split } from '@/components/pane-shell/tree/model'
import { LayoutTreeRoot } from '@/components/pane-shell/tree/renderer'
import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session'
import {
$layoutTree,
bindTreeSideVisibility,
declareDefaultTree,
dismissTreePane,
dockPaneBeside,
markCollapsePane,
mirrorLayoutTree,
paneRootSide,
registerLayoutResetHandler,
registerPaneCloser,
registerPaneOpener,
resetLayoutTree,
revealTreePane,
setPaneCollapsed,
setTreePaneHidden,
watchContributedPanes
} from '@/components/pane-shell/tree/store'
import { SidebarProvider } from '@/components/ui/sidebar'
import { discoverBundledPlugins } from '@/contrib/plugins'
import { Slot } from '@/contrib/react/slot'
import { registry } from '@/contrib/registry'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
import { LayoutDashboard } from '@/lib/icons'
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
import { Codecs, persistentAtom } from '@/lib/persisted'
import { toggleKeybindPanel } from '@/store/keybinds'
import {
$fileBrowserOpen,
$panesFlipped,
$sidebarOpen,
FILE_BROWSER_DEFAULT_WIDTH,
FILE_BROWSER_MAX_WIDTH,
FILE_BROWSER_MIN_WIDTH,
setFileBrowserOpen,
setSidebarOpen,
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH
} from '@/store/layout'
import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
import type { SessionDragPayload } from '../chat/composer/inline-refs'
import { watchRouteTiles } from '../chat/route-tile'
import { startSessionDrag } from '../chat/session-drag'
import {
SessionTileCloseConfirm,
stackSessionTilesIntoMain,
watchSessionTiles,
WorkspaceTabMenu
} from '../chat/session-tile'
import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store'
import { $workspaceIsPage } from '../routes'
import { FilesPane, LogsPane, PreviewRailPane, ReviewPaneContent } from './panes'
import { ContribWiring, WiredPane } from './wiring'
/**
* Stripped-down app root (bb/contrib-areas) on the layout TREE model, mounting
* the REAL app surfaces. The title bar and status bar sit OUTSIDE the grid
* (fixed chrome) but are fully composable: title bar renders `titleBar.left/
* right` slots; the status bar consumes `statusBar.left/right` DATA
* contributions (payload = StatusbarItem). Core registers its items through
* the same calls a plugin would use.
*/
// ---------------------------------------------------------------------------
// Pane contributions. `data.placement` = semantic role for grid presets;
// `data.minWidth/maxWidth/minHeight/maxHeight` = the SAME clamps the app's
// `Pane` props declare — the layout tree sizes zones by weight (percentage)
// but a zone never shrinks/grows past its active pane's clamp.
// Headers are contextual (tree-side): a pane alone in a zone shows no
// header/tab by default; stacked panes show chips. Double-click a zone
// toggles its header either way.
// ---------------------------------------------------------------------------
// ONE render identity for the workspace pane — syncWorkspaceTitle re-registers
// the contribution (new title) and a fresh closure would remount the chat.
const renderWorkspacePane = () => <WiredPane part="chatRoutes" />
// The main tab carries the same session context menu as tile tabs (targets
// the loaded primary session; no menu on a fresh draft).
const wrapWorkspaceTab = (tab: ReactElement) => <WorkspaceTabMenu>{tab}</WorkspaceTabMenu>
/** The `@session` payload for the workspace tab the loaded primary session,
* or null on a fresh draft / full-page view (nothing to link). */
const workspaceDragPayload = (): SessionDragPayload | null => {
const selected = $selectedStoredSessionId.get()
if (!selected || $workspaceIsPage.get()) {
return null
}
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, selected))
return { id: selected, profile: stored?.profile ?? '', title: stored ? storedSessionTitle(stored) : '' }
}
// The main tab drags like a session tile — drop it on a composer to link the
// chat, on a zone/edge to stack/split. Defers (`false`) to the generic pane
// move when there's no loaded session to carry.
const workspaceTabDrag = (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) => {
const payload = workspaceDragPayload()
if (!payload) {
return false
}
startSessionDrag(payload, event, { double, onTap })
return true
}
registry.registerMany([
{
id: 'sessions',
area: 'panes',
title: 'sessions',
// Collapsible: leaves the grid on narrow viewports (edge overlay instead).
// dock: where a RE-ADOPTED pane lands (healed from a stale dismissal) —
// its default-ish spot beside main, not a random same-placement stack.
data: {
placement: 'left',
collapsible: true,
dock: { pane: 'workspace', pos: 'left' },
revealAliases: ['chat-sidebar'],
width: `${SIDEBAR_DEFAULT_WIDTH}px`,
minWidth: `${SIDEBAR_DEFAULT_WIDTH}px`,
maxWidth: `${SIDEBAR_MAX_WIDTH}px`
},
render: () => <WiredPane part="sidebar" />
},
{
id: 'workspace',
area: 'panes',
// Live-retitled to the loaded session by syncWorkspaceTitle below.
title: 'New session',
data: {
placement: 'main',
minWidth: '22vw',
tabDrag: workspaceTabDrag,
tabWrap: wrapWorkspaceTab,
uncloseable: true
},
render: renderWorkspacePane
},
{
id: 'terminal',
area: 'panes',
title: 'terminal',
// revealOnPreset: choosing a layout that places the terminal (e.g.
// "Terminal deck") turns takeover on so the zone actually shows, instead of
// staying collapsed behind the ⌃` toggle. height sizes the fixed track (a
// single-pane zone declaring a height is a fixed track — the preset weight
// is moot): a short deck, not a third of the window.
data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true },
render: () => <WiredPane part="terminal" />
},
{
id: 'files',
area: 'panes',
title: 'files',
// dock: re-adoption target after a stale dismissal (see sessions).
data: {
placement: 'right',
collapsible: true,
dock: { pane: 'workspace', pos: 'right' },
revealAliases: ['file-browser'],
width: FILE_BROWSER_DEFAULT_WIDTH,
minWidth: FILE_BROWSER_MIN_WIDTH,
maxWidth: FILE_BROWSER_MAX_WIDTH
},
render: () => <FilesPane />
},
{
id: 'preview',
area: 'panes',
title: 'preview',
// The rail brings its OWN tab strip (per-target tabs with close buttons).
// Exists only while something is previewed — visibility is bound to the
// preview targets below, like every other self-managed surface. dock:
// adoption seed only — dockPaneBeside re-docks it next to files on every
// reveal anyway (position-aware).
data: {
placement: 'right',
dock: { pane: 'files', pos: 'left' },
width: 'clamp(18rem, 36vw, 32rem)',
minWidth: PREVIEW_RAIL_MIN_WIDTH,
maxWidth: PREVIEW_RAIL_MAX_WIDTH
},
render: () => <PreviewRailPane />
},
{
id: 'review',
area: 'panes',
title: 'review',
// The second right sidebar: hidden until ⌘G ($reviewOpen) — bound below
// like the other chrome toggles; its zone collapses while hidden.
data: {
placement: 'right',
collapsible: true,
revealAliases: [REVIEW_PANE_ID],
width: FILE_BROWSER_DEFAULT_WIDTH,
minWidth: FILE_BROWSER_MIN_WIDTH,
maxWidth: FILE_BROWSER_MAX_WIDTH
},
render: () => <ReviewPaneContent />
},
{
// Optional chrome — in NO default layout. Adoption stacks it with the
// terminal; $logsOpen (default off, ⌘K "Toggle logs") reveals it.
id: 'logs',
area: 'panes',
title: 'logs',
// revealOnPreset: the Quad layout places logs, so applying it turns the
// logs pane on (like a ⌘K "Toggle logs") instead of leaving it collapsed.
data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true },
render: () => <LogsPane />
}
])
// ---------------------------------------------------------------------------
// Chrome contributions. The title bar and status bar are fixed chrome outside
// the grid, composable through these areas. Everything real lives in the real
// components (TitlebarControls / useStatusbarItems). Sample PLUGIN
// contributions don't live here — they're their own files under `src/plugins/`,
// auto-discovered by discoverBundledPlugins() below.
// ---------------------------------------------------------------------------
registry.registerMany([
// Titlebar center stays empty on purpose: session title lives in tabs +
// sidebar; place/cwd lives in the sidebar project tree. Center is drag
// chrome (plugins can still contribute to titleBar.center if needed).
// Layout edit mode registers through the SAME declarative surfaces plugins
// use: a rebindable keybind (collision-checked in the panel) + a ⌘K row
// whose hotkey hint tracks the live binding.
{
id: 'layout.editMode',
area: KEYBINDS_AREA,
data: {
id: 'layout.editMode',
label: 'Toggle layout edit mode',
defaults: ['mod+shift+\\'],
run: toggleLayoutEditMode
} satisfies KeybindContribution
},
{
id: 'layout.editMode',
area: PALETTE_AREA,
data: {
id: 'layout.editMode',
label: 'Toggle layout edit mode',
action: 'layout.editMode',
icon: LayoutDashboard,
keywords: ['layout', 'zones', 'panes', 'edit', 'rearrange'],
run: toggleLayoutEditMode
} satisfies PaletteContribution
},
// The agent's write -> see loop: rescan <hermes home>/desktop-plugins
// without relaunching (same-id reloads dispose the previous incarnation).
{
id: 'plugins.reload',
area: PALETTE_AREA,
data: {
id: 'plugins.reload',
label: 'Reload desktop plugins',
keywords: ['plugins', 'reload', 'refresh', 'desktop'],
run: () => void discoverRuntimePlugins()
} satisfies PaletteContribution
},
{
id: 'layout.reset',
area: PALETTE_AREA,
data: {
id: 'layout.reset',
label: 'Reset layout',
icon: LayoutDashboard,
keywords: ['layout', 'reset', 'default', 'panes'],
run: resetLayoutTree
} satisfies PaletteContribution
},
// The keybind panel's non-titlebar door (the keyboard icon is gone).
{
id: 'keybinds.panel',
area: PALETTE_AREA,
data: {
id: 'keybinds.panel',
label: 'Keyboard shortcuts',
keywords: ['keybinds', 'shortcuts', 'hotkeys', 'keyboard'],
run: toggleKeybindPanel
} satisfies PaletteContribution
}
])
// ---------------------------------------------------------------------------
// Layout presets — CHAT (main) always dominates.
// ---------------------------------------------------------------------------
// The REAL default: sessions left, chat main, and the right sidebars in
// column order main | … | review | preview | file-browser (files outermost,
// preview DIRECTLY left of the file tree). Each is its OWN zone — main
// parity: a file double-click slides the preview open as its own pane beside
// the tree, never as a tab stacked into the files sidebar. Preview/review
// zones collapse to nothing while their pane is hidden (no target / ⌘G off).
// This static spot is just the seed — dockPaneBeside keeps preview adjacent
// to files WHEREVER files moves (see the target listeners below).
const DEFAULT_TREE = split(
'row',
[
group(['sessions'], { id: 'grp-sessions' }),
group(['workspace'], { id: 'grp-main' }),
split(
'column',
[
split(
'row',
[
group(['review'], { id: 'grp-review' }),
group(['preview'], { id: 'grp-preview' }),
group(['files'], { id: 'grp-files' })
],
[1, 1, 1.2],
'spl-rail'
),
group(['terminal'], { id: 'grp-terminal' })
],
[1.6, 1],
'spl-right'
)
],
[1, 3.4, 1.25],
'spl-root'
)
const FOCUS_TREE = split(
'row',
[group(['sessions']), group(['workspace', 'files', 'preview', 'review', 'terminal'])],
[1, 4.6]
)
const TERMINAL_TREE = split(
'column',
[
split('row', [group(['sessions']), group(['workspace']), group(['files', 'preview', 'review'])], [1, 3.2, 1.2]),
group(['terminal'])
],
[3, 1]
)
const QUAD_TREE = split(
'column',
[
split('row', [group(['sessions', 'files']), group(['workspace'])], [1, 3]),
split('row', [group(['terminal']), group(['preview', 'review', 'logs'])], [1.4, 1])
],
[3, 1]
)
registry.registerMany([
{ id: 'default', area: 'layouts', title: 'Default', order: 0, data: DEFAULT_TREE },
{ id: 'focus', area: 'layouts', title: 'Focus', order: 10, data: FOCUS_TREE },
{ id: 'terminal-deck', area: 'layouts', title: 'Terminal deck', order: 20, data: TERMINAL_TREE },
{ id: 'quad', area: 'layouts', title: 'Quad', order: 30, data: QUAD_TREE }
])
declareDefaultTree(DEFAULT_TREE)
// Bundled plugins load AFTER core, so a same-id contribution from a plugin
// deliberately overrides the core default (last writer wins). Third-party
// runtime plugins will flow through the same discovery seam.
discoverBundledPlugins()
// Plugin panes join the tree by their `placement` hint the moment they
// register — incl. runtime plugins arriving seconds after boot.
watchContributedPanes()
// Session + route (page) tiles: persisted splits register panes docked beside
// main.
watchSessionTiles()
watchRouteTiles()
// The main tab reads as its SESSION (the loaded title, "New session" on a
// fresh draft) — a stack of main + tiles is then just a row of session names.
// register() replaces same-id in place; the render fn is the shared constant
// above, so the pane content never remounts.
const syncWorkspaceTitle = () => {
const selected = $selectedStoredSessionId.get()
const stored = selected ? $sessions.get().find(s => sessionMatchesStoredId(s, selected)) : null
registry.register({
id: 'workspace',
area: 'panes',
title: stored ? storedSessionTitle(stored) : 'New session',
data: {
// Pages aren't tab-able: the main zone's bar stands down while one shows.
headerVeto: $workspaceIsPage.get(),
placement: 'main',
minWidth: '22vw',
tabDrag: workspaceTabDrag,
tabWrap: wrapWorkspaceTab,
uncloseable: true
},
render: renderWorkspacePane
})
}
$selectedStoredSessionId.listen(syncWorkspaceTitle)
$sessions.listen(syncWorkspaceTitle)
$workspaceIsPage.listen(syncWorkspaceTitle)
// Layout reset collapses every session tile into main as a tab (after the
// workspace) instead of re-scattering them — pre-placed before adoption.
registerLayoutResetHandler(stackSessionTilesIntoMain)
// ---------------------------------------------------------------------------
// Titlebar chrome toggles -> tree. The TitlebarControls buttons keep their
// store semantics ($sidebarOpen / $fileBrowserOpen / $panesFlipped); the tree
// reacts — a hidden pane's zone collapses (content stays mounted), the flip
// toggle mirrors the root row.
// ---------------------------------------------------------------------------
function bindPaneVisibility(
paneId: string,
$open: { get(): boolean; listen(fn: (open: boolean) => void): void },
close?: () => void,
open?: () => void
) {
setTreePaneHidden(paneId, !$open.get())
$open.listen(isOpen => setTreePaneHidden(paneId, !isOpen))
// The tab menu's Close routes through the owning store (never dismissal),
// so the pane's toggle buttons stay truthful.
if (close) {
registerPaneCloser(paneId, close)
}
// The opener is the mirror: preset application (revealOnPreset) shows the
// pane through the same store, so the toggle stays truthful.
if (open) {
registerPaneOpener(paneId, open)
}
}
// TOOL PANELS (terminal, logs): like bindPaneVisibility but the toggle COLLAPSES
// the zone to a persistent rail (tab stays) instead of hiding it — the
// IntelliJ/VS-Code tool-window model. Restore routes back through `open` (rail
// click / chevron) so ⌃`/the button stay truthful; the tab's ✕ removes it.
function bindPaneCollapse(
paneId: string,
$open: { get(): boolean; listen(fn: (open: boolean) => void): void },
close: () => void,
open: () => void
) {
markCollapsePane(paneId)
setPaneCollapsed(paneId, !$open.get())
$open.listen(isOpen => setPaneCollapsed(paneId, !isOpen))
registerPaneCloser(paneId, close)
registerPaneOpener(paneId, open)
}
// SIDES have one source of truth: the TREE. The legacy $panesFlipped flag is
// DERIVED from where the sessions zone actually sits (TitlebarControls maps
// its left/right buttons through it), so dragging sessions across — or
// applying a mirrored preset — remaps the buttons automatically. The flip
// action (⌘\ / titlebar) mirrors the tree only when they disagree.
const sessionsOnRight = () => {
const tree = $layoutTree.get()
if (!tree) {
return null
}
const order = allPaneIds(tree)
const sessions = order.indexOf('sessions')
const main = order.indexOf('workspace')
return sessions >= 0 && main >= 0 ? sessions > main : null
}
$layoutTree.subscribe(() => {
const flipped = sessionsOnRight()
if (flipped !== null && flipped !== $panesFlipped.get()) {
$panesFlipped.set(flipped)
}
})
$panesFlipped.listen(flipped => {
const current = sessionsOnRight()
if (current !== null && current !== flipped) {
mirrorLayoutTree()
}
})
// POSITIONAL side toggles (titlebar buttons, ⌘B / ⌘J): $sidebarOpen ≙ the
// LEFT side of the main zone, $fileBrowserOpen ≙ the RIGHT — everything on
// that side hides together, whatever panes have been rearranged there.
bindTreeSideVisibility('left', $sidebarOpen, setSidebarOpen)
bindTreeSideVisibility('right', $fileBrowserOpen, setFileBrowserOpen)
// Workspace-scoped surfaces: the file tree and git diff only mean something
// inside a project. A detached chat (no cwd) hides them — their zones
// collapse and the chat absorbs the width; picking a project brings them
// back. The terminal is NOT workspace-gated: unlike the old shell (where it
// rode the rail's row and vanished with it), its zone stands on its own.
const $hasWorkspace = computed($currentCwd, cwd => Boolean(cwd.trim()))
bindPaneVisibility('files', $hasWorkspace)
// ⌘G — the review sidebar appears/disappears (and comes to the front).
bindPaneVisibility(
'review',
computed([$reviewOpen, $hasWorkspace], (open, workspace) => open && workspace),
closeReview
)
// ⌃` / statusbar toggle — the terminal COLLAPSES to a rail (tab stays), not
// hides; PTYs stay alive while collapsed (see PersistentTerminal).
bindPaneCollapse(
'terminal',
$terminalTakeover,
() => setTerminalTakeover(false),
() => setTerminalTakeover(true)
)
// Preview EXISTS only while something is previewed (old-shell semantics:
// closing the last preview tab closes the pane; a new target opens + fronts
// it). Same visibility binding as every other self-managed surface, driven
// by the live targets instead of a toggle.
const $previewVisible = computed([$previewTarget, $filePreviewTarget], (target, fileTarget) =>
Boolean(target || fileTarget)
)
bindPaneVisibility('preview', $previewVisible, closeRightRail)
// Logs are optional chrome: off by default, toggled from ⌘K, persisted.
const $logsOpen = persistentAtom('hermes.desktop.logsOpen', false, Codecs.bool)
bindPaneCollapse(
'logs',
$logsOpen,
() => $logsOpen.set(false),
() => $logsOpen.set(true)
)
registry.register({
id: 'logs.toggle',
area: PALETTE_AREA,
data: {
id: 'logs.toggle',
label: 'Toggle logs',
keywords: ['logs', 'agent log', 'tail', 'debug'],
run: () => $logsOpen.set(!$logsOpen.get())
} satisfies PaletteContribution
})
// Sessions/files Close = collapse their SIDE (⌘B/⌘J truthful, titlebar button
// flips back) — but only while the pane actually lives in that root side
// column. Dragged next to main, a side collapse can't hide it (the collapse
// skips main-bearing children), so Close falls back to dismissal there —
// otherwise ⌘W/Close silently no-op.
registerPaneCloser('sessions', () =>
paneRootSide('sessions') === 'left' ? setSidebarOpen(false) : dismissTreePane('sessions')
)
registerPaneCloser('files', () =>
paneRootSide('files') === 'right' ? setFileBrowserOpen(false) : dismissTreePane('files')
)
// A preview target lands NEXT TO the file tree — position-aware: wherever
// files currently lives (default rail, ⌘\-flipped, dragged into a stack), the
// preview zone docks directly beside it. A user who drags the preview pane
// somewhere pins it there instead (until a preset/reset). Then reveal: open
// the side, unhide, front — a NEW target while already visible still fronts.
const revealPreview = () => {
dockPaneBeside('preview', 'files')
revealTreePane('preview')
}
$previewTarget.listen(target => target && revealPreview())
$filePreviewTarget.listen(target => target && revealPreview())
// ---------------------------------------------------------------------------
export function ContribController() {
const sidebarOpen = useStore($sidebarOpen)
return (
<SidebarProvider
className="h-screen min-h-0 flex-col bg-background"
onOpenChange={setSidebarOpen}
open={sidebarOpen}
style={{ '--sidebar-width': '100%' } as CSSProperties}
>
<ContribWiring>
<div
className="flex h-screen min-h-0 w-screen flex-col bg-(--ui-bg-chrome) text-(--ui-text-primary)"
style={{ '--titlebar-height': '0px' } as CSSProperties}
>
{/* Title bar: fixed chrome outside the grid, composable via slots.
Layout contract (no contribution can break it):
- a full-bar DRAG BASE underneath (pointer-events-none, like
AppShell's drag strips) everywhere without content drags
the window;
- each slot region is width-fit, no-drag, pointer-events-auto,
so every contribution is clickable by construction;
- LEFT/RIGHT slots align to the MAIN PANE's geometry via the
tree-published --workspace-left/right vars (pure CSS, no rect
threading), clamped to clear the REAL TitlebarControls
clusters (fixed, z-70); center is truly window-centered. */}
<div className="relative flex h-[34px] shrink-0 items-center border-b border-(--ui-stroke-tertiary) text-xs">
{/* Drag strips, AppShell-style: cut to AVOID the fixed control
clusters instead of overlapping them Electron's no-drag
carve-out of fixed/transformed elements is unreliable, so a
full-bar drag base kills their clicks. In-flow slot content
still carves via its own no-drag wrapper (the same pattern as
the app's session-title button). */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-0 w-(--titlebar-controls-left,14px) [-webkit-app-region:drag]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[calc(var(--titlebar-controls-left,14px)+(var(--titlebar-control-size,1.25rem)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,5.5rem)+0.75rem)] [-webkit-app-region:drag]"
/>
<div
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
left: 'max(calc(var(--workspace-left, 0px) + 0.5rem), calc(var(--titlebar-controls-left, 14px) + 2 * var(--titlebar-control-size, 1.25rem) + 1rem))'
}}
>
<Slot area="titleBar.left" />
</div>
<div className="pointer-events-auto absolute left-1/2 top-1/2 z-10 flex w-max -translate-x-1/2 -translate-y-1/2 items-center gap-2 [-webkit-app-region:no-drag]">
<Slot area="titleBar.center" />
</div>
<div
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
right:
'max(calc(var(--workspace-right, 0px) + 0.5rem), calc(var(--titlebar-tools-right, 0.75rem) + 4 * (var(--titlebar-control-size, 1.25rem) + 0.25rem) + 0.5rem))'
}}
>
<Slot area="titleBar.right" />
</div>
</div>
<LayoutTreeRoot />
{/* "Close running tab?" — the busy/input-blocked tile close gate. */}
<SessionTileCloseConfirm />
{/* The REAL statusbar (model pill, command center, agents, ) with
statusBar.left/right contributions merged in. */}
<WiredPane part="statusbar" />
</div>
</ContribWiring>
</SidebarProvider>
)
}
// Referenced type kept for plugin authors' reference (payload shape of
// statusBar.* contributions).
export type { StatusbarItem }

View file

@ -0,0 +1,133 @@
import { useEffect } from 'react'
import { refreshActiveProfile } from '@/store/profile'
import { $activeSessionId, $currentCwd, setCurrentCwd } from '@/store/session'
import type { GatewayRequester } from '../types'
// Cron sessions are written by a background scheduler tick, messaging turns by
// the background gateway (Telegram, WeChat, Discord, …) — neither signals the
// desktop websocket, so poll the bounded lists while the app is visible.
const CRON_POLL_INTERVAL_MS = 30_000
const MESSAGING_POLL_INTERVAL_MS = 10_000
const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000
interface BackgroundSyncParams {
activeIsMessaging: boolean
activeSessionId: null | string
freshDraftReady: boolean
gatewayState: string
refreshActiveMessagingTranscript: () => Promise<unknown> | unknown
refreshCronJobs: () => Promise<unknown> | unknown
refreshCurrentModel: (force?: boolean) => Promise<unknown> | unknown
refreshHermesConfig: () => Promise<unknown> | unknown
refreshMessagingSessions: () => Promise<unknown> | unknown
refreshSessions: () => Promise<unknown> | unknown
requestGateway: GatewayRequester
}
/** Poll a callback while the tab is visible, on `intervalMs`; re-checks on tab
* re-focus. Returns nothing meant to live inside an effect. */
function visiblePoll(intervalMs: number, tick: () => void): () => void {
const run = () => {
if (document.visibilityState === 'visible') {
tick()
}
}
const intervalId = window.setInterval(run, intervalMs)
document.addEventListener('visibilitychange', run)
return () => {
window.clearInterval(intervalId)
document.removeEventListener('visibilitychange', run)
}
}
/**
* Keeps app data live while the gateway is open: an on-connect reseed (model /
* profile / sessions + relative-cwd resolution), the cron / messaging /
* open-transcript visibility polls, and the fresh-draft model/config reseed.
* All the "the desktop websocket won't tell us, so poll" logic in one place.
*/
export function useBackgroundSync({
activeIsMessaging,
activeSessionId,
freshDraftReady,
gatewayState,
refreshActiveMessagingTranscript,
refreshCronJobs,
refreshCurrentModel,
refreshHermesConfig,
refreshMessagingSessions,
refreshSessions,
requestGateway
}: BackgroundSyncParams): void {
useEffect(() => {
if (gatewayState !== 'open') {
return
}
void refreshCurrentModel()
void refreshActiveProfile()
void refreshSessions()
// A RELATIVE workspace cwd (config `terminal.cwd: .`) renders as "." in the
// file tree header — resolve it to the backend's absolute path once.
// Session runtime info still overrides later, and never while a session is
// active.
const cwd = $currentCwd.get().trim()
if (!$activeSessionId.get() && cwd && !/^(\/|[A-Za-z]:[\\/])/.test(cwd)) {
void requestGateway<{ cwd?: string }>('config.get', { key: 'project', cwd })
.then(info => {
if (info.cwd && !$activeSessionId.get()) {
setCurrentCwd(info.cwd)
}
})
.catch(() => undefined)
}
}, [gatewayState, refreshCurrentModel, refreshSessions, requestGateway])
// Keep the cron-jobs section live without a user action (scheduler ticks in
// the background); re-check on tab re-focus too.
useEffect(() => {
if (gatewayState !== 'open') {
return
}
return visiblePoll(CRON_POLL_INTERVAL_MS, () => void refreshCronJobs())
}, [gatewayState, refreshCronJobs])
// Keep the messaging-platform session lists live (inbound turns are written
// by the gateway, not the desktop websocket).
useEffect(() => {
if (gatewayState !== 'open') {
return
}
return visiblePoll(MESSAGING_POLL_INTERVAL_MS, () => void refreshMessagingSessions())
}, [gatewayState, refreshMessagingSessions])
// Only the open messaging transcript needs its own poll — local chats are
// live over the websocket already.
useEffect(() => {
if (gatewayState !== 'open' || !activeIsMessaging) {
return
}
const dispose = visiblePoll(ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS, () => void refreshActiveMessagingTranscript())
void refreshActiveMessagingTranscript()
return dispose
}, [activeIsMessaging, gatewayState, refreshActiveMessagingTranscript])
// A fresh new-session draft (gateway open, no active session) re-pulls the
// model + config so the composer pill reflects the profile default.
useEffect(() => {
if (gatewayState === 'open' && !activeSessionId && freshDraftReady) {
void refreshCurrentModel()
void refreshHermesConfig()
}
}, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig])
}

View file

@ -0,0 +1,176 @@
import { useEffect, useRef } from 'react'
import { closeActiveTab } from '@/app/chat/close-tab'
import { storedSessionIdForNotification } from '@/lib/session-ids'
import { respondToApprovalAction } from '@/store/native-notifications'
import {
getRememberedRoute,
getRememberedSessionId,
setRememberedRoute,
setRememberedSessionId
} from '@/store/session'
import { onSessionsChanged } from '@/store/session-sync'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates'
import { isSecondaryWindow } from '@/store/windows'
import { requestComposerFocus, requestComposerInsert } from '../../chat/composer/focus'
import { appViewForPath, isOverlayView, NEW_CHAT_ROUTE, sessionRoute } from '../../routes'
interface DesktopIntegrationsParams {
chatOpen: boolean
hasPreview: boolean
locationPathname: string
navigate: (to: string, options?: { replace?: boolean }) => void
refreshSessions: () => Promise<unknown> | unknown
resumeExhaustedSessionId: null | string
routedSessionId: null | string
runtimeIdByStoredSessionId: { readonly current: Map<string, string> }
}
/**
* All the Electron-main / OS / cross-window integrations the shell listens for:
* update polling, the W close shortcut, deep links, native-notification
* navigation, preview-shortcut enablement, remembered-session restore, and
* cross-window session-list sync. Kept out of the wiring controller so the
* "talks to the desktop shell" surface reads as one unit.
*/
export function useDesktopIntegrations({
locationPathname,
navigate,
refreshSessions,
resumeExhaustedSessionId,
routedSessionId,
runtimeIdByStoredSessionId
}: DesktopIntegrationsParams): void {
// Update polling — populates $desktopVersion/$updateStatus, which feed the
// statusbar version pill and the update toasts. Also honors the main
// process's "open updates" menu request.
useEffect(() => {
startUpdatePoller()
const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow())
return () => {
unsubscribe?.()
stopUpdatePoller()
}
}, [])
// The renderer OWNS ⌘W: on macOS the native menu accelerator would else
// close the window, so claim it unconditionally — the menu then routes ⌘W
// to us (close-preview-requested IPC) and we decide tab-vs-window.
useEffect(() => {
window.hermesDesktop?.setPreviewShortcutActive?.(true)
}, [])
// Remember the open chat (session id for notifications/resume) AND the last
// non-overlay route (a page like /skills, or a session route) so a relaunch
// lands where you were. Overlays (settings/command-center/…) aren't stored —
// you don't want to boot into a modal.
useEffect(() => {
if (routedSessionId) {
setRememberedSessionId(routedSessionId)
}
if (!isOverlayView(appViewForPath(locationPathname))) {
setRememberedRoute(locationPathname)
}
}, [locationPathname, routedSessionId])
const restoredRef = useRef(false)
// Restore once on cold start — only when the renderer booted at the default
// route (a hidden-then-shown window keeps its own route). Prefer the full
// remembered route (covers pages); fall back to the last session id.
useEffect(() => {
if (restoredRef.current || locationPathname !== NEW_CHAT_ROUTE) {
restoredRef.current = true
return
}
restoredRef.current = true
const route = getRememberedRoute()
if (route && route !== NEW_CHAT_ROUTE && !isOverlayView(appViewForPath(route))) {
navigate(route, { replace: true })
return
}
const last = getRememberedSessionId()
if (last) {
navigate(sessionRoute(last), { replace: true })
}
}, [locationPathname, navigate])
useEffect(() => {
if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) {
setRememberedSessionId(null)
}
}, [resumeExhaustedSessionId])
// Native-notification click -> jump to the session (runtime id translated to
// the stored id the chat route is keyed by); action buttons resolve in place.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => {
if (sessionId) {
navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionId.current)))
}
})
return () => unsubscribe?.()
}, [navigate, runtimeIdByStoredSessionId])
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => {
void respondToApprovalAction(sessionId ?? null, actionId)
})
return () => unsubscribe?.()
}, [])
// hermes:// deep links -> a reviewable /blueprint command in the composer.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => {
if (!payload || payload.kind !== 'blueprint' || !payload.name) {
return
}
const slots = Object.entries(payload.params || {})
.map(([k, v]) => {
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
return `${k}=${sval}`
})
.join(' ')
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
requestComposerInsert(command, { mode: 'block', target: 'main' })
requestComposerFocus('main')
})
void window.hermesDesktop?.signalDeepLinkReady?.()
return () => unsubscribe?.()
}, [])
// ⌘W via the macOS menu accelerator → close the focused tab; if nothing is
// closeable, fall back to closing the window (so ⌘W still works as the
// OS-standard window close, esp. secondary windows). The Win/Linux keyboard
// path is the `view.closeTab` keybind (use-keybinds), sharing closeActiveTab.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(() => void closeActiveTab())
return () => unsubscribe?.()
}, [])
// Another window mutated the shared session list -> re-pull the sidebar.
useEffect(() => {
if (isSecondaryWindow()) {
return
}
return onSessionsChanged(() => void refreshSessions())
}, [refreshSessions])
}

View file

@ -0,0 +1,71 @@
import { useEffect, useRef } from 'react'
import { setPetActivity } from '@/store/pet'
import { setPetScale } from '@/store/pet-gallery'
import {
setPetOverlayOpenAppHandler,
setPetOverlayScaleHandler,
setPetOverlaySubmitHandler
} from '@/store/pet-overlay'
import { $attentionSessionIds, $sessions } from '@/store/session'
import { isSecondaryWindow } from '@/store/windows'
import type { GatewayRequester } from '../types'
interface PetBridgeParams {
requestGateway: GatewayRequester
resumeSession: (sessionId: string) => Promise<unknown> | unknown
submitText: (text: string) => Promise<unknown> | unknown
}
/**
* Wires the popped-out pet overlay back into the app: submit a prompt, resize,
* and open the most-recent thread, plus mirroring "a session is awaiting the
* user" into the pet's pose. Handlers register ONCE through refs tracking the
* latest callbacks re-registering on identity churn leaves a nulled-handler
* window that can drop a submit. Primary window only.
*/
export function usePetBridge({ requestGateway, resumeSession, submitText }: PetBridgeParams): void {
const submitTextRef = useRef(submitText)
submitTextRef.current = submitText
const resumeSessionRef = useRef(resumeSession)
resumeSessionRef.current = resumeSession
const requestGatewayRef = useRef(requestGateway)
requestGatewayRef.current = requestGateway
useEffect(() => {
if (isSecondaryWindow()) {
return
}
setPetOverlaySubmitHandler(text => void submitTextRef.current(text))
// Alt+wheel resize from the popped-out pet — persist through this window's
// gateway (the overlay has none) so it survives restart.
setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale))
// Mail icon: $sessions is most-recent-first; the pet is global, so "most
// recent" is the right target.
setPetOverlayOpenAppHandler(() => {
const recent = $sessions.get()[0]
if (recent?.id) {
void resumeSessionRef.current(recent.id)
}
})
return () => {
setPetOverlaySubmitHandler(null)
setPetOverlayOpenAppHandler(null)
setPetOverlayScaleHandler(null)
}
}, [])
// Mirror "a session is blocked on the user" (clarify/approval) into the pet's
// awaitingInput flag so it shows the `waiting` pose.
useEffect(() => {
const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 })
sync()
return $attentionSessionIds.listen(sync)
}, [])
}

View file

@ -0,0 +1,108 @@
import { useEffect } from 'react'
import { getSessionMessages, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import { toChatMessages } from '@/lib/chat-messages'
import { publishSessionState, setSessionTileDelegate } from '@/store/session-states'
import type { SessionResumeResponse } from '@/types/hermes'
import type { usePromptActions } from '../../session/hooks/use-prompt-actions'
import type { useSessionStateCache } from '../../session/hooks/use-session-state-cache'
import type { GatewayRequester } from '../types'
type SessionStateCache = ReturnType<typeof useSessionStateCache>
interface SessionTileDelegateParams {
archiveSession: (storedSessionId: string) => Promise<unknown>
branchStoredSession: (storedSessionId: string) => Promise<unknown>
executeSlashCommand: ReturnType<typeof usePromptActions>['executeSlashCommand']
removeSession: (storedSessionId: string) => Promise<unknown>
requestGateway: GatewayRequester
runtimeIdByStoredSessionIdRef: SessionStateCache['runtimeIdByStoredSessionIdRef']
sessionStateByRuntimeIdRef: SessionStateCache['sessionStateByRuntimeIdRef']
updateSessionState: SessionStateCache['updateSessionState']
}
/**
* Publishes the session-tile delegate: resume / submit / interrupt / slash for
* tiled sessions WITHOUT touching the primary view ($activeSessionId /
* $messages stay the main thread's). Resume reuses a live runtime binding when
* one exists (incl. the main thread's own session); a cold tile binds +
* hydrates the cache, which publishSessionState mirrors to the tile.
*/
export function useSessionTileDelegate({
archiveSession,
branchStoredSession,
executeSlashCommand,
removeSession,
requestGateway,
runtimeIdByStoredSessionIdRef,
sessionStateByRuntimeIdRef,
updateSessionState
}: SessionTileDelegateParams): void {
useEffect(() => {
setSessionTileDelegate({
archiveSession: async storedSessionId => {
await archiveSession(storedSessionId)
},
branchSession: async storedSessionId => {
await branchStoredSession(storedSessionId)
},
deleteSession: async storedSessionId => {
await removeSession(storedSessionId)
},
executeSlash: async (rawCommand, sessionId) => {
await executeSlashCommand(rawCommand, { sessionId })
},
interruptSession: async runtimeId => {
await requestGateway('session.interrupt', { session_id: runtimeId })
},
resumeTile: async storedSessionId => {
const existing = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const cached = existing ? sessionStateByRuntimeIdRef.current.get(existing) : undefined
if (existing && cached?.storedSessionId === storedSessionId) {
publishSessionState(existing, cached)
return existing
}
const [prefetch, resumed] = await Promise.all([
getSessionMessages(storedSessionId).catch(() => null),
requestGateway<SessionResumeResponse>('session.resume', { session_id: storedSessionId, cols: 96 })
])
const runtimeId = resumed?.session_id
if (!runtimeId) {
throw new Error('resume returned no session id')
}
updateSessionState(
runtimeId,
state => ({
...state,
busy: Boolean(resumed?.info?.running),
messages:
state.messages.length > 0 ? state.messages : toChatMessages(prefetch?.messages ?? resumed?.messages ?? [])
}),
storedSessionId
)
return runtimeId
},
submitToSession: async (runtimeId, text) => {
await requestGateway('prompt.submit', { session_id: runtimeId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
},
updateSession: (runtimeId, updater) => updateSessionState(runtimeId, updater)
})
}, [
archiveSession,
branchStoredSession,
executeSlashCommand,
removeSession,
requestGateway,
runtimeIdByStoredSessionIdRef,
sessionStateByRuntimeIdRef,
updateSessionState
])
}

View file

@ -0,0 +1,7 @@
// The contribution-driven shell package. `controller` registers panes /
// layouts / chrome and mounts the app root; `wiring` is the data controller +
// memoized pane surfaces; `panes` holds the real-data pane bodies + statusbar
// group setters. Only the controller is a public entry (the app root renders
// it); the rest are internal to this directory.
export { ContribController } from './controller'
export { ContribWiring, WiredPane } from './wiring'

View file

@ -0,0 +1,207 @@
/**
* Real-data panes + composable bar items for the contrib root:
*
* - `PreviewRailPane` the REAL ChatPreviewRail; files-pane clicks feed it.
* - `FilesPane` real file browser; activating a file opens it in preview.
* - Core statusbar items with LIVE store-backed labels, registered as DATA
* contributions (`area: 'statusBar.left' / 'statusBar.right'`, payload =
* StatusbarItem) plugins add theirs through the identical call.
*/
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { atom } from 'nanostores'
import type { CSSProperties } from 'react'
import { ChatPreviewRail } from '@/app/chat/right-rail/preview'
import { RightSidebarPane } from '@/app/right-sidebar'
import { ReviewPane } from '@/app/right-sidebar/review'
import type { GroupSetter } from '@/app/shell/group-setter'
import type { StatusbarItem } from '@/app/shell/statusbar-controls'
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
import type { TitlebarTool } from '@/app/shell/titlebar-controls'
import { DecodeText } from '@/components/ui/decode-text'
import { ContribBoundary } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { registry } from '@/contrib/registry'
import { getLogs } from '@/hermes'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $filePreviewTarget, $previewTarget, setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
// ---------------------------------------------------------------------------
// Logs — live agent-log tail. OPTIONAL chrome: not in any default layout,
// hidden until the ⌘K "Toggle logs" command opens it ($logsOpen).
// ---------------------------------------------------------------------------
export function LogsPane() {
const { data, error } = useQuery({
queryKey: ['contrib-logs-tail'],
queryFn: () => getLogs({ lines: 300 }),
refetchInterval: 5000
})
if (error) {
return <div className="p-3 text-xs text-(--ui-text-quaternary)">log unavailable: {String(error)}</div>
}
if (!data) {
return (
<div className="grid h-full place-items-center">
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="LOGS" />
</div>
)
}
// No chrome of its own — the zone header (when the user summons it) is the
// pane's only label. Just the tail.
return (
<pre className="h-full min-h-0 overflow-auto whitespace-pre-wrap break-words p-2.5 font-mono text-[0.66rem] leading-relaxed text-(--ui-text-secondary)">
{data.lines.join('\n')}
</pre>
)
}
// ---------------------------------------------------------------------------
// Preview — the real rail, fed by the files pane
// ---------------------------------------------------------------------------
/** Preview-server restart handler, provided by the wiring (usePreviewRouting).
* Atom-bridged: this module can't import contrib-wiring (it imports us). */
export const $restartPreviewServer = atom<((url: string, context?: string) => Promise<string>) | null>(null)
export function PreviewRailPane() {
const previewTarget = useStore($previewTarget)
const fileTarget = useStore($filePreviewTarget)
const restartPreviewServer = useStore($restartPreviewServer)
if (!previewTarget && !fileTarget) {
return (
<div className="grid h-full place-items-center px-4 text-center">
<div className="flex flex-col items-center gap-1.5">
<DecodeText className="text-(--ui-text-quaternary)" prefix={1} text="PREVIEW" />
<span className="text-[0.68rem] text-(--ui-text-quaternary)">click a file in the files pane</span>
</div>
</div>
)
}
return (
// The contrib layout zeroes --titlebar-height (content sits BELOW the
// titlebar, so the real components' clearance padding must collapse) —
// but the rail SIZES its per-file tab strip with that var. Restore the
// real value for this subtree so the tabs always render at full height.
<div
className={cn(ZONE_CONTENT, 'min-h-0 w-full overflow-hidden [&>aside]:pt-0')}
style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties}
>
<ChatPreviewRail onRestartServer={restartPreviewServer ?? undefined} setTitlebarToolGroup={setTitlebarToolGroup} />
</div>
)
}
/** Open a file from the tree in the real preview pipeline. */
function previewFile(path: string) {
void normalizeOrLocalPreviewTarget(path, $currentCwd.get() || undefined)
.then(target => {
if (target) {
setCurrentSessionPreviewTarget(target, 'file-browser', path)
}
})
.catch(() => undefined)
}
// Layout fit for wrapped asides. Edge chrome (borders/shadows) is neutralized
// GLOBALLY by the tree's seam invariant (see LayoutTreeRoot) — only sizing
// and titlebar clearance are per-wrapper concerns.
const ZONE_CONTENT = 'h-full [&>aside]:h-full [&>aside]:w-full [&>aside]:pt-0'
export function FilesPane() {
return (
<div className={ZONE_CONTENT}>
<RightSidebarPane onActivateFile={previewFile} onActivateFolder={previewFile} />
</div>
)
}
// ---------------------------------------------------------------------------
// Review — the real git diff pane (⌘G / $reviewOpen)
// ---------------------------------------------------------------------------
export function ReviewPaneContent() {
const cwd = useStore($currentCwd)
// Keyed by cwd like DesktopController so switching projects rebuilds the
// diff state instead of showing the previous repo's files.
return (
<div className={cn(ZONE_CONTENT, 'flex min-h-0 flex-col [&>aside]:min-h-0 [&>aside]:flex-1')}>
<ReviewPane key={cwd || 'no-cwd'} />
</div>
)
}
// ---------------------------------------------------------------------------
// Statusbar composability: plugins contribute DATA items into
// `statusBar.left` / `statusBar.right`; the wiring feeds them into the REAL
// useStatusbarItems as extraLeftItems/extraRightItems. No core filler here —
// the real statusbar owns the core items (model pill, terminal toggle, …).
// ---------------------------------------------------------------------------
/** Collect statusbar contributions for one side. A `render()` contribution
* becomes a render-item (arbitrary stateful node); otherwise the declarative
* `data` payload is the StatusbarItem. */
export function useStatusbarContributions(side: 'left' | 'right'): StatusbarItem[] {
const items = useContributions(`statusBar.${side}`)
return items
.map(c =>
c.render
? ({
id: c.id,
render: () => <ContribBoundary id={c.id} variant="chip">{c.render!()}</ContribBoundary>
} satisfies StatusbarItem)
: (c.data as StatusbarItem)
)
.filter(Boolean)
}
/** Collect TitlebarTool data contributions for one side of the titlebar. */
export function useTitlebarToolContributions(side: 'left' | 'right'): TitlebarTool[] {
const items = useContributions(`titleBar.tools.${side}`)
return items.map(c => c.data as TitlebarTool).filter(Boolean)
}
/**
* Bridge a page's `GroupSetter` extension point (SkillsView, MessagingView,
* ChatPreviewRail, ) into the registry: each call replaces the group's items
* as DATA contributions in `<prefix>.<side>`, so page-owned items flow through
* the same pipe plugins use. Setting an empty list clears the group.
*/
export function registryGroupSetter<T>(prefix: string): GroupSetter<T> {
const disposers = new Map<string, () => void>()
return (id, items, side = 'right') => {
const key = `${side}:${id}`
disposers.get(key)?.()
disposers.set(
key,
registry.registerMany(
items.map((item, i) => ({
id: `${id}-${i}`,
area: `${prefix}.${side}`,
source: 'core',
order: 100 + i,
data: item as object
}))
)
)
}
}
/** The app's page-facing setters the same `GroupSetter` shape pages already
* take as props, backed by the registry instead of component state. */
export const setStatusbarItemGroup = registryGroupSetter<StatusbarItem>('statusBar')
export const setTitlebarToolGroup = registryGroupSetter<TitlebarTool>('titleBar.tools')

View file

@ -0,0 +1,204 @@
/**
* Wiring surfaces each pane is its own memoized component. Every surface
* reads the reactive state it renders from at the leaf (its own atom
* subscriptions) and reaches the controller's callbacks through the stable
* `actions` bag, so a state change scoped to one surface (or a bare
* wiring-controller tick) never re-renders another. This is what keeps the
* layout tree's zones independently rendered the whole point of the shell.
*/
import { useStore } from '@nanostores/react'
import { type ComponentProps, lazy, memo, type ReactNode, Suspense, useMemo } from 'react'
import { Navigate, Route, Routes, useParams } from 'react-router-dom'
import { ContribBoundary } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { $freshDraftReady, $gatewayState } from '@/store/session'
import { ChatView } from '../chat'
import { ChatSidebar } from '../chat/sidebar'
import { TerminalPaneChrome } from '../right-sidebar/terminal/chrome'
import { contributedRoutes, NEW_CHAT_ROUTE, ROUTES_AREA, sessionRoute } from '../routes'
import { useStatusSnapshot } from '../shell/hooks/use-status-snapshot'
import { useStatusbarItems } from '../shell/hooks/use-statusbar-items'
import { ModelMenuPanel } from '../shell/model-menu-panel'
import { StatusbarControls } from '../shell/statusbar-controls'
import { setStatusbarItemGroup, useStatusbarContributions } from './panes'
import type { SidebarActions, WiringActions } from './types'
// Same lazy-view split as DesktopController — pages load on demand. The
// full-page views the workspace route table mounts live here; overlay views
// (agents/settings/…) are the controller's and stay in wiring.tsx.
const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView }))
const MessagingView = lazy(async () => ({ default: (await import('../messaging')).MessagingView }))
const SkillsView = lazy(async () => ({ default: (await import('../skills')).SkillsView }))
export function LegacySessionRedirect() {
const { sessionId } = useParams()
return <Navigate replace to={sessionId ? sessionRoute(sessionId) : NEW_CHAT_ROUTE} />
}
export const SidebarSurface = memo(function SidebarSurface({
actions,
currentView
}: {
actions: SidebarActions
currentView: ComponentProps<typeof ChatSidebar>['currentView']
}) {
return <ChatSidebar currentView={currentView} {...actions} />
})
export const TerminalSurface = memo(function TerminalSurface() {
return (
<div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-(--ui-editor-surface-background)">
<TerminalPaneChrome />
</div>
)
})
/** Owns the statusbar's own data hooks (status snapshot poll, contributed
* items) so its 15s refresh and any statusbar-only churn re-renders the
* bar alone, never the chat/sidebar/terminal. */
export const StatusbarSurface = memo(function StatusbarSurface({
actions,
agentsOpen,
chatOpen,
commandCenterOpen
}: {
actions: WiringActions
agentsOpen: boolean
chatOpen: boolean
commandCenterOpen: boolean
}) {
const gatewayState = useStore($gatewayState)
const freshDraftReady = useStore($freshDraftReady)
const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, actions.requestGateway)
const extraLeftItems = useStatusbarContributions('left')
const extraRightItems = useStatusbarContributions('right')
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
agentsOpen,
chatOpen,
commandCenterOpen,
extraLeftItems,
extraRightItems,
freshDraftReady,
gatewayState,
inferenceStatus,
openAgents: actions.openAgents,
openCommandCenterSection: actions.openCommandCenterSection,
requestGateway: actions.requestGateway,
statusSnapshot,
toggleCommandCenter: actions.toggleCommandCenter
})
return <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
})
/** The workspace pane: the real route table (chat + full-page views + plugin
* routes). Subscribes to `$gatewayState` and ROUTES_AREA itself; the gateway
* instance + voice cap arrive as props so a reconnect/config load re-renders
* only this surface. ChatView subscribes to its own session atoms, so
* streaming never round-trips through the controller. */
export const ChatRoutesSurface = memo(function ChatRoutesSurface({
actions,
maxVoiceRecordingSeconds
}: {
actions: WiringActions
maxVoiceRecordingSeconds?: number
}) {
const gatewayState = useStore($gatewayState)
useContributions(ROUTES_AREA)
const routeContributions = contributedRoutes()
// Recapture the live gateway instance whenever the connection state flips.
// getGateway reads a controller ref, so gatewayState is the intentional
// re-eval trigger (not a value the computation itself reads).
const gateway = useMemo(
() => actions.getGateway(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[actions, gatewayState]
)
const modelMenuContent = useMemo(
() =>
gatewayState === 'open' ? (
<ModelMenuPanel
gateway={gateway || undefined}
onSelectModel={actions.selectModel}
requestGateway={actions.requestGateway}
/>
) : null,
[actions, gateway, gatewayState]
)
const chatView = (
<ChatView
gateway={gateway}
maxVoiceRecordingSeconds={maxVoiceRecordingSeconds}
modelMenuContent={modelMenuContent}
onAddContextRef={actions.onAddContextRef}
onAddUrl={actions.onAddUrl}
onAttachDroppedItems={actions.onAttachDroppedItems}
onAttachImageBlob={actions.onAttachImageBlob}
onBranchInNewChat={actions.onBranchInNewChat}
onCancel={actions.onCancel}
onDeleteSelectedSession={actions.onDeleteSelectedSession}
onDismissError={actions.onDismissError}
onEdit={actions.onEdit}
onPasteClipboardImage={actions.onPasteClipboardImage}
onPickFiles={actions.onPickFiles}
onPickFolders={actions.onPickFolders}
onPickImages={actions.onPickImages}
onReload={actions.onReload}
onRemoveAttachment={actions.onRemoveAttachment}
onRestoreToMessage={actions.onRestoreToMessage}
onRetryResume={actions.onRetryResume}
onSteer={actions.onSteer}
onSubmit={actions.onSubmit}
onThreadMessagesChange={actions.onThreadMessagesChange}
onToggleSelectedPin={actions.onToggleSelectedPin}
onTranscribeAudio={actions.onTranscribeAudio}
/>
)
// FULL-PAGE views (not chat) mark the zone body `data-zone-no-header`: a
// page is not a tab-able surface, so the zone's double-click header toggle
// stands down while one is showing (see onZoneDoubleClick).
const page = (view: ReactNode) => (
<div className="contents" data-zone-no-header>
<Suspense fallback={null}>{view}</Suspense>
</div>
)
return (
<Routes>
<Route element={chatView} index />
<Route element={chatView} path=":sessionId" />
<Route element={page(<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="skills" />
<Route element={page(<MessagingView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="messaging" />
<Route element={page(<ArtifactsView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="artifacts" />
<Route element={null} path="agents" />
<Route element={null} path="command-center" />
<Route element={null} path="cron" />
<Route element={null} path="profiles" />
<Route element={null} path="settings" />
<Route element={null} path="starmap" />
{/* Registry-contributed pages (core features + plugins) render in the
workspace pane like any built-in view behind the same blast wall
as every other contribution mount. */}
{routeContributions.map(route => (
<Route
element={page(<ContribBoundary id={route.key}>{route.render()}</ContribBoundary>)}
key={route.key}
path={route.path.slice(1)}
/>
))}
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="new" />
<Route element={<LegacySessionRedirect />} path="sessions/:sessionId" />
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
</Routes>
)
})

View file

@ -0,0 +1,79 @@
import type { ComponentProps, ReactNode } from 'react'
import type { ChatView } from '../chat'
import type { ChatSidebar } from '../chat/sidebar'
import type { CommandCenterSection } from '../command-center'
import type { useGatewayRequest } from '../gateway/hooks/use-gateway-request'
import type { ModelMenuPanel } from '../shell/model-menu-panel'
export type GatewayRequester = ReturnType<typeof useGatewayRequest>['requestGateway']
/** The ChatSidebar handlers the controller owns — forwarded verbatim. */
export type SidebarActions = Pick<
ComponentProps<typeof ChatSidebar>,
| 'onArchiveSession'
| 'onBranchSession'
| 'onDeleteSession'
| 'onLoadMoreMessaging'
| 'onLoadMoreProfileSessions'
| 'onLoadMoreSessions'
| 'onManageCronJob'
| 'onNavigate'
| 'onNewSessionInWorkspace'
| 'onNewSessionSplit'
| 'onResumeSession'
| 'onTriggerCronJob'
>
/** The ChatView handlers the controller owns — forwarded verbatim. */
export type ChatActions = Pick<
ComponentProps<typeof ChatView>,
| 'onAddContextRef'
| 'onAddUrl'
| 'onAttachDroppedItems'
| 'onAttachImageBlob'
| 'onBranchInNewChat'
| 'onCancel'
| 'onDeleteSelectedSession'
| 'onDismissError'
| 'onEdit'
| 'onPasteClipboardImage'
| 'onPickFiles'
| 'onPickFolders'
| 'onPickImages'
| 'onReload'
| 'onRemoveAttachment'
| 'onRestoreToMessage'
| 'onRetryResume'
| 'onSteer'
| 'onSubmit'
| 'onThreadMessagesChange'
| 'onToggleSelectedPin'
| 'onTranscribeAudio'
>
/**
* The complete controller-owned callback surface. One object, one stable
* identity for the app's life its fields are mutated in place each render,
* so surfaces bound to it never re-render on identity churn but always invoke
* the latest closure.
*/
export interface WiringActions extends SidebarActions, ChatActions {
/** The live gateway instance (held in a controller ref). Surfaces recapture
* it by subscribing to `$gatewayState`, so no gateway prop needs threading. */
getGateway: () => ComponentProps<typeof ChatView>['gateway']
openAgents: () => void
openCommandCenterSection: (section: CommandCenterSection) => void
requestGateway: GatewayRequester
selectModel: ComponentProps<typeof ModelMenuPanel>['onSelectModel']
toggleCommandCenter: () => void
}
/** The four wired surfaces the controller publishes; `WiredPane` renders one by
* key inside a registered pane / chrome slot. */
export interface WiringApi {
sidebar: ReactNode
chatRoutes: ReactNode
terminal: ReactNode
statusbar: ReactNode
}

View file

@ -0,0 +1,946 @@
/**
* Real-featureset wiring for the contrib (layout tree) root the minimal
* subset of DesktopController's hook chain that makes the REAL surfaces work:
* gateway boot -> sessions list -> click-to-resume -> live transcript ->
* composer send, plus the real terminal.
*
* The wired nodes (sidebar / chat routes / terminal) are exposed through
* context; registered panes render `<WiredPane part="…"/>` to consume them.
*/
import { useStore } from '@nanostores/react'
import { useQueryClient } from '@tanstack/react-query'
import { type CSSProperties, lazy, type ReactNode, Suspense, useCallback, useEffect, useMemo, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
import { NotificationStack } from '@/components/notifications'
import { DesktopOnboardingOverlay } from '@/components/onboarding'
import { FloatingPet } from '@/components/pet/floating-pet'
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
import { emitGatewayEvent } from '@/contrib/events'
import { getSessionMessages, triggerCronJob } from '@/hermes'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { sessionMessagesSignature } from '@/lib/session-signatures'
import { isMessagingSource } from '@/lib/session-source'
import { latestSessionTodos } from '@/lib/todos'
import { setCronFocusJobId } from '@/store/cron'
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile'
import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects'
import {
$activeSessionId,
$connection,
$currentCwd,
$freshDraftReady,
$gatewayState,
$messages,
$messagingSessions,
$resumeExhaustedSessionId,
$resumeFailedSessionId,
$selectedStoredSessionId,
$sessions,
sessionMatchesStoredId,
sessionPinId,
setAwaitingResponse,
setBusy,
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
setCurrentProvider,
setMessages
} from '@/store/session'
import { focusOpenSession } from '@/store/session-states'
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
import { isSecondaryWindow } from '@/store/windows'
import { useSkinCommand } from '@/themes/use-skin-command'
import { requestComposerInsert } from '../chat/composer/focus'
import { useComposerActions } from '../chat/hooks/use-composer-actions'
import { CommandPalette } from '../command-palette'
import { useGatewayBoot } from '../gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from '../gateway/hooks/use-gateway-request'
import { useKeybinds } from '../hooks/use-keybinds'
import { ModelPickerOverlay } from '../model-picker-overlay'
import { ModelVisibilityOverlay } from '../model-visibility-overlay'
import { PetGenerateOverlay } from '../pet-generate/pet-generate-overlay'
import { FileActionDialogs } from '../right-sidebar/file-actions'
import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker'
import { PersistentTerminal } from '../right-sidebar/terminal/persistent'
import { CRON_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE, syncWorkspaceIsPage } from '../routes'
import { SessionPickerOverlay } from '../session-picker-overlay'
import { SessionSwitcher } from '../session-switcher'
import { useContextSuggestions } from '../session/hooks/use-context-suggestions'
import { useCwdActions } from '../session/hooks/use-cwd-actions'
import { useHermesConfig } from '../session/hooks/use-hermes-config'
import { useMessageStream } from '../session/hooks/use-message-stream'
import { useModelControls } from '../session/hooks/use-model-controls'
import { usePreviewRouting } from '../session/hooks/use-preview-routing'
import { usePromptActions } from '../session/hooks/use-prompt-actions'
import { useRouteResume } from '../session/hooks/use-route-resume'
import { useSessionActions } from '../session/hooks/use-session-actions'
import { useSessionListActions } from '../session/hooks/use-session-list-actions'
import { useSessionStateCache } from '../session/hooks/use-session-state-cache'
import { useOverlayRouting } from '../shell/hooks/use-overlay-routing'
import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width'
import { KeybindPanel } from '../shell/keybind-panel'
import { titlebarControlsPosition } from '../shell/titlebar'
import { TitlebarControls } from '../shell/titlebar-controls'
import { UpdatesOverlay } from '../updates-overlay'
import { ContribWiringContext } from './context'
import { useBackgroundSync } from './hooks/use-background-sync'
import { useDesktopIntegrations } from './hooks/use-desktop-integrations'
import { usePetBridge } from './hooks/use-pet-bridge'
import { useSessionTileDelegate } from './hooks/use-session-tile-delegate'
import { $restartPreviewServer, useTitlebarToolContributions } from './panes'
import { ChatRoutesSurface, SidebarSurface, StatusbarSurface, TerminalSurface } from './surfaces'
import type { WiringActions, WiringApi } from './types'
// Overlay views the controller mounts over the shell — lazy, load on demand.
// The workspace-route full-page views (skills/messaging/artifacts) are the
// ChatRoutesSurface's and live in ./surfaces.
const AgentsView = lazy(async () => ({ default: (await import('../agents')).AgentsView }))
const CommandCenterView = lazy(async () => ({ default: (await import('../command-center')).CommandCenterView }))
const CronView = lazy(async () => ({ default: (await import('../cron')).CronView }))
const ProfilesView = lazy(async () => ({ default: (await import('../profiles')).ProfilesView }))
const SettingsView = lazy(async () => ({ default: (await import('../settings')).SettingsView }))
const StarmapView = lazy(async () => ({ default: (await import('../starmap')).StarmapView }))
// Surfaces (the four wired panes), the render context + WiredPane, and the
// WiringActions/WiringApi contracts all live in sibling modules — this file is
// the controller that assembles them.
export { WiredPane } from './context'
export function ContribWiring({ children }: { children: ReactNode }) {
const queryClient = useQueryClient()
const location = useLocation()
const navigate = useNavigate()
const busyRef = useRef(false)
const creatingSessionRef = useRef(false)
const messagingTranscriptSignatureRef = useRef(new Map<string, string>())
// Stable identity for the whole callback surface (see WiringActions). Mutated
// in place each render so memoized surfaces never re-render on churn.
const actionsRef = useRef<WiringActions | null>(null)
const gatewayState = useStore($gatewayState)
const activeSessionId = useStore($activeSessionId)
const currentCwd = useStore($currentCwd)
const freshDraftReady = useStore($freshDraftReady)
const resumeFailedSessionId = useStore($resumeFailedSessionId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const messagingSessions = useStore($messagingSessions)
const profileScope = useStore($profileScope)
const routedSessionId = routeSessionId(location.pathname)
const routeToken = `${location.pathname}:${location.search}:${location.hash}`
const routeTokenRef = useRef(routeToken)
routeTokenRef.current = routeToken
const getRouteToken = useCallback(() => routeTokenRef.current, [])
// Mirror "the workspace is showing a full page" into its atom — the
// workspace pane contribution re-registers headerVeto from it, so the main
// zone's tab bar stands down on pages (and returns with the chat).
useEffect(() => {
syncWorkspaceIsPage(location.pathname)
}, [location.pathname])
const {
agentsOpen,
chatOpen,
closeOverlayToPreviousRoute,
commandCenterInitialSection,
commandCenterOpen,
cronOpen,
currentView,
openAgents,
openCommandCenterSection,
openStarmap,
profilesOpen,
settingsOpen,
starmapOpen,
toggleCommandCenter
} = useOverlayRouting()
const {
activeSessionIdRef,
ensureSessionState,
resetViewSync,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
syncSessionStateToView,
updateSessionState
} = useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId,
setAwaitingResponse,
setBusy,
setMessages
})
const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest()
const {
loadMoreMessagingForPlatform,
loadMoreSessions,
loadMoreSessionsForProfile,
refreshCronJobs,
refreshMessagingSessions,
refreshSessions
} = useSessionListActions({ profileScope })
const updateActiveSessionRuntimeInfo = useCallback(
(info: { branch?: string; cwd?: string }) => {
const sessionId = activeSessionIdRef.current
if (!sessionId) {
return
}
updateSessionState(sessionId, state => ({
...state,
branch: info.branch ?? state.branch,
cwd: info.cwd ?? state.cwd
}))
},
[activeSessionIdRef, updateSessionState]
)
const { refreshProjectBranch } = useCwdActions({
activeSessionId,
activeSessionIdRef,
onSessionRuntimeInfo: updateActiveSessionRuntimeInfo,
requestGateway
})
const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({
activeSessionIdRef,
refreshProjectBranch
})
const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({
activeSessionId,
queryClient,
requestGateway
})
const openProviderSettings = useCallback(() => navigate(`${SETTINGS_ROUTE}?tab=providers`), [navigate])
// Post-turn rehydrate from stored history (same behavior as DesktopController,
// including finished-todos restoration).
const hydrateFromStoredSession = useCallback(
async (
attempts = 1,
storedSessionId = selectedStoredSessionIdRef.current,
runtimeSessionId = activeSessionIdRef.current
) => {
if (!storedSessionId || !runtimeSessionId) {
return
}
const storedProfile = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))?.profile
for (let index = 0; index < Math.max(1, attempts); index += 1) {
try {
const latest = await getSessionMessages(storedSessionId, storedProfile)
const messages = toChatMessages(latest.messages)
updateSessionState(
runtimeSessionId,
state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }),
storedSessionId
)
const restored = todosForHydration(latestSessionTodos(messages))
if (restored) {
setSessionTodos(runtimeSessionId, restored)
} else {
clearSessionTodos(runtimeSessionId)
}
return
} catch {
// Best-effort fallback when live stream payloads are empty.
}
if (index < attempts - 1) {
await new Promise(resolve => window.setTimeout(resolve, 250))
}
}
},
[activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState]
)
// Refresh the open messaging transcript (inbound platform turns arrive via
// the background gateway, not the desktop websocket). Signature-gated so a
// no-change poll doesn't churn the thread.
const refreshActiveMessagingTranscript = useCallback(async () => {
const storedSessionId = selectedStoredSessionIdRef.current
const runtimeSessionId = activeSessionIdRef.current
if (!storedSessionId || !runtimeSessionId || busyRef.current) {
return
}
const stored = $messagingSessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
if (!stored || !isMessagingSource(stored.source)) {
return
}
try {
const latest = await getSessionMessages(storedSessionId, stored.profile)
const signatureKey = `${stored.profile ?? 'default'}:${storedSessionId}`
const sig = sessionMessagesSignature(latest.messages)
if (messagingTranscriptSignatureRef.current.get(signatureKey) === sig) {
return
}
messagingTranscriptSignatureRef.current.set(signatureKey, sig)
const messages = toChatMessages(latest.messages)
updateSessionState(
runtimeSessionId,
state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }),
storedSessionId
)
} catch {
// Non-fatal: next poll or manual refresh can hydrate.
}
}, [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState])
const { handleGatewayEvent } = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession,
queryClient,
refreshHermesConfig,
refreshSessions,
sessionStateByRuntimeIdRef,
updateSessionState
})
// Agent-driven preview routing (agent opens a URL/file -> the preview rail
// follows) + the preview server restart handler, layered over the base
// gateway event stream exactly like DesktopController.
const { handleDesktopGatewayEvent, restartPreviewServer } = usePreviewRouting({
activeSessionIdRef,
baseHandleGatewayEvent: handleGatewayEvent,
currentCwd,
currentView,
requestGateway,
routedSessionId,
selectedStoredSessionId
})
// Composer @-mention context suggestions (files/dirs under the cwd).
useContextSuggestions({
activeSessionId,
activeSessionIdRef,
currentCwd,
gatewayState,
requestGateway
})
// Expose the restart handler to the preview pane contribution (module
// boundary crossed via atom — contrib-panes can't import this file).
useEffect(() => {
$restartPreviewServer.set(restartPreviewServer)
return () => $restartPreviewServer.set(null)
}, [restartPreviewServer])
const {
archiveSession,
branchCurrentSession,
branchStoredSession,
createBackendSessionForSend,
openNewSessionTile,
removeSession,
resumeSession,
selectSidebarItem,
startFreshSessionDraft
} = useSessionActions({
activeSessionId,
activeSessionIdRef,
busyRef,
creatingSessionRef,
ensureSessionState,
getRouteToken,
navigate,
requestGateway,
resetViewSync,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
syncSessionStateToView,
updateSessionState
})
// A profile switch/create drops to a fresh new-session draft so the
// previously open session doesn't bleed across contexts. Skip initial value.
const freshSessionRequest = useStore($freshSessionRequest)
const lastFreshRef = useRef(freshSessionRequest)
useEffect(() => {
if (freshSessionRequest === lastFreshRef.current) {
return
}
lastFreshRef.current = freshSessionRequest
startFreshSessionDraft()
}, [freshSessionRequest, startFreshSessionDraft])
// Swapping the live gateway to another profile must re-pull that profile's
// global model + active-profile pill (both are nanostores — the blanket
// invalidateQueries on swap doesn't touch them).
const activeGatewayProfile = useStore($activeGatewayProfile)
const lastGatewayProfileRef = useRef(activeGatewayProfile)
useEffect(() => {
if (activeGatewayProfile === lastGatewayProfileRef.current) {
return
}
lastGatewayProfileRef.current = activeGatewayProfile
// Force: the new profile has its own default, so reseed even if the
// composer already shows the previous profile's model.
void refreshCurrentModel(true)
void refreshActiveProfile()
}, [activeGatewayProfile, refreshCurrentModel])
// New session anchored to a workspace (sidebar "+" on a project/worktree).
// Seeds cwd + branch from the clicked workspace; an explicit worktree path
// also drills the sidebar into that project so the new lane is visible.
const startSessionInWorkspace = useCallback(
(path: null | string) => {
startFreshSessionDraft()
// A worktree lane carries its own path; the trunk "+" can be path-less
// (the main checkout is implicit), so fall back to the active project's
// root instead of no-op'ing on null.
const target = path?.trim() || resolveNewSessionCwd()
if (!target) {
return
}
setCurrentCwd(target)
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
.then(info => {
const resolved = info.cwd || target
setCurrentCwd(resolved)
setCurrentBranch(info.branch || '')
if (path?.trim()) {
restoreWorktree(resolved)
void followActiveSessionCwd(resolved)
}
})
.catch(() => undefined)
},
[requestGateway, startFreshSessionDraft]
)
// Composer "branch off into a new worktree": open a fresh session anchored
// to the just-created tree, then prefill the task that kicked it off.
const startWorkSessionRequest = useStore($startWorkSessionRequest)
const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0)
useEffect(() => {
if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) {
return
}
lastStartWorkTokenRef.current = startWorkSessionRequest.token
startSessionInWorkspace(startWorkSessionRequest.path)
if (startWorkSessionRequest.draft) {
requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' })
}
}, [startSessionInWorkspace, startWorkSessionRequest])
const composer = useComposerActions({ activeSessionId, currentCwd, requestGateway })
const branchInNewChat = useCallback(
async (messageId?: string) => {
const branched = await branchCurrentSession(messageId)
if (branched) {
await refreshSessions().catch(() => undefined)
}
return branched
},
[branchCurrentSession, refreshSessions]
)
const handleSkinCommand = useSkinCommand()
const {
cancelRun,
editMessage,
executeSlashCommand,
handleThreadMessagesChange,
reloadFromMessage,
restoreToMessage,
steerPrompt,
submitText,
transcribeVoiceAudio
} = usePromptActions({
activeSessionId,
activeSessionIdRef,
branchCurrentSession: branchInNewChat,
busyRef,
createBackendSessionForSend,
getRouteToken,
handleSkinCommand,
openMemoryGraph: openStarmap,
refreshSessions,
requestGateway,
resumeStoredSession: resumeSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
updateSessionState
})
// Session-tile delegate (resume/submit/interrupt/slash + the session verbs
// the tile TAB menu needs, without touching the primary view).
useSessionTileDelegate({
archiveSession,
branchStoredSession,
executeSlashCommand,
removeSession,
requestGateway,
runtimeIdByStoredSessionIdRef,
sessionStateByRuntimeIdRef,
updateSessionState
})
// The popped-out pet overlay's bridge back into the app.
usePetBridge({ requestGateway, resumeSession, submitText })
// Clear a failed turn's red error banner. Errors are renderer-local (never
// persisted): a bare error placeholder is dropped entirely; a partial-output
// failure keeps its content and sheds the error. Both the runtime cache AND
// the live $messages view must be updated — preserveLocalAssistantErrors
// re-grafts any still-errored view message on the next session.info flush.
const dismissError = useCallback(
(messageId: string) => {
const runtimeSessionId = activeSessionIdRef.current
if (!runtimeSessionId) {
return
}
const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] =>
messages.flatMap(message => {
if (message.id !== messageId || !message.error) {
return [message]
}
if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) {
return []
}
return [{ ...message, error: undefined, pending: false }]
})
// View first: the cache update below triggers a re-sync that reads
// $messages as the error-preservation baseline.
setMessages(clearErrorIn($messages.get()))
updateSessionState(runtimeSessionId, state => ({
...state,
messages: clearErrorIn(state.messages)
}))
},
[activeSessionIdRef, updateSessionState]
)
useRouteResume({
activeSessionId,
activeSessionIdRef,
creatingSessionRef,
currentView,
freshDraftReady,
gatewayState,
locationPathname: location.pathname,
resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
startFreshSessionDraft
})
// Plugins hear the stream FIRST (isolated fan-out in contrib/events), then
// the app dispatches as before — a plugin listener can't affect app flow.
const handleGatewayEventWithPlugins = useCallback(
(event: Parameters<typeof handleDesktopGatewayEvent>[0]) => {
emitGatewayEvent(event)
handleDesktopGatewayEvent(event)
},
[handleDesktopGatewayEvent]
)
useGatewayBoot({
handleGatewayEvent: handleGatewayEventWithPlugins,
onConnectionReady: c => {
connectionRef.current = c
},
onGatewayReady: g => {
gatewayRef.current = g
},
refreshHermesConfig,
refreshSessions
})
// Only the open messaging transcript needs its own poll — local chats are
// live over the websocket already.
const activeIsMessaging =
!!selectedStoredSessionId &&
isMessagingSource(messagingSessions.find(s => sessionMatchesStoredId(s, selectedStoredSessionId))?.source)
// Keep app data live while the gateway is open (on-connect reseed + the
// cron / messaging / transcript visibility polls + fresh-draft reseed).
useBackgroundSync({
activeIsMessaging,
activeSessionId,
freshDraftReady,
gatewayState,
refreshActiveMessagingTranscript,
refreshCronJobs,
refreshCurrentModel,
refreshHermesConfig,
refreshMessagingSessions,
refreshSessions,
requestGateway
})
// Electron-main / OS / cross-window integrations: update polling, ⌘W close,
// deep links, native-notification nav, preview-shortcut enablement,
// remembered-session restore, and cross-window session-list sync.
const previewTarget = useStore($previewTarget)
const filePreviewTarget = useStore($filePreviewTarget)
useDesktopIntegrations({
chatOpen,
hasPreview: Boolean(filePreviewTarget || previewTarget),
locationPathname: location.pathname,
navigate,
refreshSessions,
resumeExhaustedSessionId,
routedSessionId,
runtimeIdByStoredSessionId: runtimeIdByStoredSessionIdRef
})
// Pin/unpin the selected session (statusbar keybind + chat header) — pinned
// on the durable lineage-root id so it survives auto-compression.
const toggleSelectedPin = useCallback(() => {
const sessionId = $selectedStoredSessionId.get()
if (!sessionId) {
return
}
const session = $sessions.get().find(s => sessionMatchesStoredId(s, sessionId))
const pinId = session ? sessionPinId(session) : sessionId
if ($pinnedSessionIds.get().includes(pinId)) {
unpinSession(pinId)
} else {
pinSession(pinId)
}
}, [])
// Single global listener for every rebindable hotkey plus the on-screen
// keybind editor's capture mode (same as DesktopController).
useKeybinds({
openNewSessionTab: () => void openNewSessionTile('center'),
startFreshSession: startFreshSessionDraft,
toggleCommandCenter,
toggleSelectedPin
})
// The controller's entire callback surface, gathered into the stable
// `actions` bag. `nextActions` is TS-checked against WiringActions each
// render; its fields are copied into the ref object so `actions` keeps one
// identity for the app's life (memoized surfaces don't re-render on churn)
// while every handler still closes over the latest values.
const nextActions: WiringActions = {
onAddContextRef: composer.addContextRefAttachment,
onAddUrl: url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url),
onArchiveSession: sessionId => void archiveSession(sessionId),
onAttachDroppedItems: composer.attachDroppedItems,
onAttachImageBlob: composer.attachImageBlob,
onBranchInNewChat: messageId => void branchInNewChat(messageId),
onBranchSession: sessionId => void branchStoredSession(sessionId),
onCancel: cancelRun,
onDeleteSelectedSession: () => {
const id = $selectedStoredSessionId.get()
if (id) {
void removeSession(id)
}
},
onDeleteSession: sessionId => void removeSession(sessionId),
onDismissError: dismissError,
onEdit: editMessage,
onLoadMoreMessaging: loadMoreMessagingForPlatform,
onLoadMoreProfileSessions: loadMoreSessionsForProfile,
onLoadMoreSessions: loadMoreSessions,
onManageCronJob: jobId => {
setCronFocusJobId(jobId)
navigate(CRON_ROUTE)
},
onNavigate: selectSidebarItem,
onNewSessionInWorkspace: startSessionInWorkspace,
onNewSessionSplit: dir => void openNewSessionTile(dir),
onPasteClipboardImage: opts => composer.pasteClipboardImage(opts),
onPickFiles: () => void composer.pickContextPaths('file'),
onPickFolders: () => void composer.pickContextPaths('folder'),
onPickImages: () => void composer.pickImages(),
onReload: reloadFromMessage,
onRemoveAttachment: id => void composer.removeAttachment(id),
onRestoreToMessage: restoreToMessage,
// Already on screen (open tile, or the main session)? Jump to its tab;
// otherwise load it into main.
onResumeSession: sessionId => {
if (!focusOpenSession(sessionId)) {
navigate(sessionRoute(sessionId))
}
},
onRetryResume: sessionId => void resumeSession(sessionId, true),
onSteer: steerPrompt,
onSubmit: submitText,
onThreadMessagesChange: handleThreadMessagesChange,
onToggleSelectedPin: toggleSelectedPin,
onTranscribeAudio: transcribeVoiceAudio,
onTriggerCronJob: jobId => {
void triggerCronJob(jobId)
.then(() => refreshCronJobs())
.catch(() => undefined)
},
getGateway: () => gatewayRef.current,
openAgents,
openCommandCenterSection,
requestGateway,
selectModel,
toggleCommandCenter
}
if (actionsRef.current) {
Object.assign(actionsRef.current, nextActions)
} else {
actionsRef.current = nextActions
}
const actions = actionsRef.current
// Each pane node is memoized on ONLY the reactive inputs it truly consumes;
// everything else reaches its surface through `actions` (stable) or the
// surface's own atom subscriptions. A wiring tick that doesn't touch a
// node's keys leaves its element reference intact, so `WiredPane` (memoized)
// bails on that pane subtree — panes render independently of one another.
const sidebarNode = useMemo(
() => <SidebarSurface actions={actions} currentView={currentView} />,
[actions, currentView]
)
const terminalNode = useMemo(() => <TerminalSurface />, [])
const statusbarNode = useMemo(
() => (
<StatusbarSurface
actions={actions}
agentsOpen={agentsOpen}
chatOpen={chatOpen}
commandCenterOpen={commandCenterOpen}
/>
),
[actions, agentsOpen, chatOpen, commandCenterOpen]
)
// The voice cap changes only on config load; the gateway instance + all
// chat reactivity are subscribed inside ChatRoutesSurface / ChatView.
const chatRoutesNode = useMemo(
() => <ChatRoutesSurface actions={actions} maxVoiceRecordingSeconds={voiceMaxRecordingSeconds} />,
[actions, voiceMaxRecordingSeconds]
)
const api = useMemo<WiringApi>(
() => ({
chatRoutes: chatRoutesNode,
sidebar: sidebarNode,
statusbar: statusbarNode,
terminal: terminalNode
}),
[chatRoutesNode, sidebarNode, statusbarNode, terminalNode]
)
// The REAL titlebar tool clusters (sidebar/flip toggles, haptics, keybinds,
// settings gear) — fixed chrome positioned via the same CSS vars AppShell
// sets, computed here from the live connection. Page-registered tools
// (preview's monitor/devtools cluster, …) arrive as registry contributions.
const leftTitlebarTools = useTitlebarToolContributions('left')
const rightTitlebarTools = useTitlebarToolContributions('right')
const connection = useStore($connection)
const controlsPos = titlebarControlsPosition(connection?.windowButtonPosition, Boolean(connection?.isFullscreen))
// Exact vertical centering: titlebarControlsPosition() returns
// (TITLEBAR_HEIGHT - TITLEBAR_CONTROL_HEIGHT) / 2, but TitlebarControls
// also applies a hard translate-y-0.5 (+2px) to its clusters. Cancel that
// constant so cluster center == bar center — measured, not eyeballed.
const controlsTranslateY = 2
// Windows/WSLg reserve native min/max/close on the right (AppShell parity:
// prefer the live WCO measurement, fall back to the static reservation).
const measuredOverlayWidth = useWindowControlsOverlayWidth()
const nativeOverlayWidth = measuredOverlayWidth ?? connection?.nativeOverlayWidth ?? 0
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
// Pane-registered tools (preview's monitor/devtools cluster) anchor flush
// against the static system cluster — in the tree layout the titlebar band
// sits ABOVE the grid, so AppShell's pane-width anchoring doesn't apply.
const SYSTEM_TOOL_COUNT = 4
const paneToolCount = rightTitlebarTools.filter(tool => !tool.hidden).length
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))`
const titlebarToolsWidth =
paneToolCount > 0
? `calc(${systemToolsWidth} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))`
: systemToolsWidth
return (
<ContribWiringContext.Provider value={api}>
<div
className="contents"
style={
{
'--titlebar-controls-left': `${controlsPos.left}px`,
'--titlebar-controls-top': `${controlsPos.top - controlsTranslateY}px`,
'--titlebar-tools-right': titlebarToolsRight,
'--titlebar-tools-width': titlebarToolsWidth,
'--shell-preview-toolbar-gap': systemToolsWidth
} as CSSProperties
}
>
<TitlebarControls
leftTools={leftTitlebarTools}
onOpenSettings={() => navigate(SETTINGS_ROUTE)}
tools={rightTitlebarTools}
/>
{children}
</div>
{/* The full real overlay set (mirrors DesktopController's `overlays`). */}
<RemoteDisplayBanner />
{!isSecondaryWindow() && <DesktopInstallOverlay />}
{!isSecondaryWindow() && (
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
onCompleted={() => {
void refreshHermesConfig()
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
requestGateway={requestGateway}
/>
)}
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
<SessionPickerOverlay onResume={resumeSession} />
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
<UpdatesOverlay />
<GatewayConnectingOverlay />
<BootFailureOverlay />
<CommandPalette />
<PetGenerateOverlay />
<SessionSwitcher />
<FileActionDialogs />
<RemoteFolderPicker />
{settingsOpen && (
<Suspense fallback={null}>
<SettingsView
gateway={gatewayRef.current}
onClose={closeOverlayToPreviousRoute}
onConfigSaved={() => {
void refreshHermesConfig()
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
onMainModelChanged={(provider, model) => {
setCurrentProvider(provider)
setCurrentModel(model)
updateModelOptionsCache(provider, model, true)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
/>
</Suspense>
)}
{commandCenterOpen && (
<Suspense fallback={null}>
<CommandCenterView
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
</Suspense>
)}
{agentsOpen && (
<Suspense fallback={null}>
<AgentsView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{cronOpen && (
<Suspense fallback={null}>
<CronView
onClose={closeOverlayToPreviousRoute}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
</Suspense>
)}
{profilesOpen && (
<Suspense fallback={null}>
<ProfilesView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{starmapOpen && (
<Suspense fallback={null}>
<StarmapView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{/* The full hotkey map (⌘/ and the titlebar keyboard button). */}
<KeybindPanel />
{/* Toasts above everything. */}
<NotificationStack />
{/* Petdex floating mascot — renders nothing unless installed + enabled. */}
<FloatingPet />
{/* Single persistent xterm host chasing the terminal pane's slot rect. */}
<PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
</ContribWiringContext.Provider>
)
}

View file

@ -54,8 +54,8 @@ import {
} from '../overlays/panel'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { jobState, jobTitle, STATE_DOT } from './job-state'
import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model'
import { jobState, jobTitle, STATE_DOT } from './job-state'
const DEFAULT_DELIVER = 'local'
@ -398,6 +398,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) })
} else if (editor.mode === 'edit') {
const scriptOnlyJob = jobIsScriptOnly(editor.job)
const updated = await updateCronJob(
editor.job.id,
cronEditorUpdates(values, { scriptOnlyJob })
@ -756,6 +757,7 @@ function CronEditorDialog({
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const validationError = validateCronEditor({
prompt,
schedule,

View file

@ -1,26 +0,0 @@
import type { SessionInfo } from '@/hermes'
// Cheap signature compare so a poll only swaps the atom (and re-renders the
// sidebar) when the visible rows actually changed.
export function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
if (a.length !== b.length) {
return false
}
return a.every((session, i) => {
const other = b[i]
return (
other != null &&
session.id === other.id &&
session._lineage_root_id === other._lineage_root_id &&
session.title === other.title &&
session.source === other.source &&
session.profile === other.profile &&
session.preview === other.preview &&
session.message_count === other.message_count &&
session.last_active === other.last_active &&
session.ended_at === other.ended_at
)
})
}

File diff suppressed because it is too large Load diff

View file

@ -39,6 +39,7 @@ import {
setCurrentCwd,
setSessionsLoading
} from '@/store/session'
import { resetTileRuntimeBindings } from '@/store/session-states'
import type { RpcEvent } from '@/types/hermes'
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)
@ -167,6 +168,9 @@ export function useGatewayBoot({
}
reconnectAttempt = 0
// A respawned backend re-mints (recycles) runtime ids, so any tile's
// bound runtime id is now stale — drop them so each tile re-resumes.
resetTileRuntimeBindings()
// Resync state that may have moved on the backend while we were asleep.
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
await callbacksRef.current.refreshSessions().catch(() => undefined)

View file

@ -1,18 +1,16 @@
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { closeActiveTab } from '@/app/chat/close-tab'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals'
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
import { matchesQuery } from '@/hooks/use-media-query'
import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
import { activateTreeTabSlot, cycleTreeTabInFocusedZone, layoutHasRootSide } from '@/components/pane-shell/tree/store'
import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
import { $repoStatus } from '@/store/coding-status'
import { toggleCommandPalette } from '@/store/command-palette'
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
import {
CHAT_SIDEBAR_PANE_ID,
FILE_BROWSER_PANE_ID,
requestSessionSearchFocus,
setFileBrowserOpen,
toggleFileBrowserOpen,
@ -30,6 +28,7 @@ import {
import { requestNewWorktree } from '@/store/projects'
import { toggleReview } from '@/store/review'
import { setModelPickerOpen } from '@/store/session'
import { reopenLastClosedTile } from '@/store/session-states'
import {
$switcherOpen,
closeSwitcher,
@ -45,7 +44,6 @@ import { openNewSessionInNewWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
import {
AGENTS_ROUTE,
ARTIFACTS_ROUTE,
@ -62,6 +60,8 @@ export interface KeybindRuntimeDeps {
toggleCommandCenter: () => void
/** Drop to a fresh new-session draft. */
startFreshSession: () => void
/** Open a fresh session as a tab in the main zone (⌘T), leaving the primary. */
openNewSessionTab: () => void
/** Pin/unpin the active session. */
toggleSelectedPin: () => void
}
@ -82,7 +82,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
const profileSwitchHandlers: HandlerMap = {}
for (let slot = 1; slot <= PROFILE_SLOT_COUNT; slot += 1) {
profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot)
// ⌘1…⌘9 switch the FOCUSED zone's tab when it's a real tab strip; only a
// single-pane (or unfocused) layout falls through to the profile switch.
profileSwitchHandlers[`profile.switch.${slot}`] = () => {
if (!activateTreeTabSlot(slot)) {
switchProfileToSlot(slot)
}
}
}
const goToSession = (sessionId: null | string) => {
@ -138,9 +144,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
deps.startFreshSession()
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
},
'session.newTab': () => deps.openNewSessionTab(),
'session.newWindow': () => void openNewSessionInNewWindow(),
'session.next': () => stepSession(1),
'session.prev': () => stepSession(-1),
// ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus
// falls through to the recent-session switcher.
'session.next': () => void (cycleTreeTabInFocusedZone(1) || stepSession(1)),
'session.prev': () => void (cycleTreeTabInFocusedZone(-1) || stepSession(-1)),
...sessionSlotHandlers,
'session.focusSearch': requestSessionSearchFocus,
'session.togglePin': deps.toggleSelectedPin,
@ -148,20 +157,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
// through instead of silently doing nothing).
'workspace.newWorktree': () => $repoStatus.get() && requestNewWorktree(),
'view.toggleSidebar': () => {
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: CHAT_SIDEBAR_PANE_ID } }))
} else {
toggleSidebarOpen()
}
},
'view.toggleRightSidebar': () => {
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: FILE_BROWSER_PANE_ID } }))
} else {
toggleFileBrowserOpen()
}
},
// Narrow-viewport reveal is handled inside the store toggles now.
'view.toggleSidebar': toggleSidebarOpen,
// ⌘J toggles the right sidebar — but a layout with no right side (e.g.
// terminal-on-bottom) would leave it a dead key, so it falls back to the
// terminal there. The single "secondary panel" toggle.
'view.toggleRightSidebar': () =>
layoutHasRootSide('right') ? toggleFileBrowserOpen() : setTerminalTakeover(!$terminalTakeover.get()),
'view.toggleReview': toggleReview,
'view.showFiles': showFiles,
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
@ -177,6 +179,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'view.prevTerminal': () => $terminalTakeover.get() && cycleTerminal(-1),
'view.closeTerminal': () => $terminalTakeover.get() && closeActiveTerminal(),
'view.flipPanes': togglePanesFlipped,
// ⌘W: close the focused tab (terminal / preview target / zone tree tab).
// On macOS the menu accelerator owns ⌘W and routes through the same
// closeActiveTab via IPC (see use-desktop-integrations); this binding is
// the Win/Linux path where ⌘W reaches the renderer directly.
'view.closeTab': () => void closeActiveTab(),
'view.reopenTab': reopenLastClosedTile,
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
@ -242,7 +250,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
return
}
const handler = handlersRef.current[actionId]
// Built-in handlers first (they carry React context); contributed
// actions bring their own `run` through the registry.
const handler = handlersRef.current[actionId] ?? contributedKeybindHandler(actionId)
if (!handler) {
return

View file

@ -1 +1,6 @@
export { DesktopController as default } from './desktop-controller'
// The app root is the contribution-driven shell: panes, titlebar/statusbar
// items, keybinds, palette commands, routes, and themes all register through
// the contribution registry (src/contrib) — core surfaces use the same calls
// plugins do. Everything lives under ./contrib: the wiring (gateway boot,
// sessions, streams) + pane surfaces, and the pane/layout registration.
export { ContribController as default } from './contrib'

View file

@ -1,8 +1,10 @@
import { type ReactNode, useEffect } from 'react'
import { type CSSProperties, type ReactNode, useEffect } from 'react'
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { translateNow } from '@/i18n'
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@ -32,8 +34,10 @@ export function OverlayView({
// stop propagation themselves, so opening (e.g.) the model picker inside
// Settings still closes the picker first instead of the underlying overlay.
useEffect(() => {
const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.overlay)
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) {
if (event.key !== 'Escape' || event.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.overlay)) {
return
}
@ -44,7 +48,10 @@ export function OverlayView({
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
releaseLayer()
}
}, [onClose])
return (
@ -64,6 +71,12 @@ export function OverlayView({
}
}}
role="presentation"
// Window-level chrome: overlays always clear the real titlebar. The
// contrib shell zeroes --titlebar-height for CONTENT areas (panes sit
// below its in-flow title bar), and CSS vars inherit through the DOM —
// so a fixed overlay mounted inside a zone would read 0 and bleed to
// the edges. Re-pin the real height at the overlay root.
style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties}
>
<div
className={cn(

View file

@ -84,7 +84,9 @@ export function ReviewPane() {
{(loading || isRepo) && (
<RightSidebarSectionHeader data-suppress-pane-reveal-side="">
<div className="flex min-w-0 flex-1">
<SidebarPanelLabel>{c.review}</SidebarPanelLabel>
{/* Pure self-naming label redundant under a zone tab that already
says "review", so the zone header hides it (styles.css). */}
<SidebarPanelLabel data-pane-self-label="">{c.review}</SidebarPanelLabel>
</div>
<Tip label={treeMode === 'tree' ? c.viewAsList : c.viewAsTree}>
<Button

View file

@ -1,3 +1,8 @@
import { atom } from 'nanostores'
import type { ReactNode } from 'react'
import { registry } from '@/contrib/registry'
export const SESSION_ROUTE_PREFIX = '/'
export const NEW_CHAT_ROUTE = '/'
export const SETTINGS_ROUTE = '/settings'
@ -16,6 +21,11 @@ export type AppView =
| 'chat'
| 'command-center'
| 'cron'
// A contributed (plugin) full page at its own route — NOT chat. Without this
// distinction contributed paths fell through appViewForPath's 'chat' default,
// so the sidebar kept a session highlighted and the titlebar kept the
// session-title dropdown while a plugin page was showing.
| 'extension'
| 'messaging'
| 'profiles'
| 'settings'
@ -56,6 +66,52 @@ export const APP_ROUTES = [
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => route.path))
// ── Contributed routes — the `routes` registry area ─────────────────────────
// A contribution mounts a FULL PAGE in the workspace pane at `data.path`
// (`render` on the contribution itself, like every other area). Contributed
// paths are reserved exactly like APP_ROUTES so the session-id parser never
// mistakes them for a session route. Navigate with `host.navigate(path)`.
export const ROUTES_AREA = 'routes'
/** Payload of a `routes` contribution's `data`. */
export interface RouteContribution {
/** Absolute path, e.g. `/kanban`. One segment; no params. */
path: string
}
export function contributedRoutes(): Array<{ key: string; path: string; title?: string; render: () => ReactNode }> {
return registry
.getArea(ROUTES_AREA)
.map(c => ({
key: `${c.source ?? 'core'}:${c.id}`,
path: (c.data as RouteContribution | undefined)?.path ?? '',
title: c.title,
render: c.render!
}))
.filter(route => Boolean(route.path.startsWith('/') && route.render) && !RESERVED_PATHS.has(route.path))
}
function isContributedPath(pathname: string): boolean {
return contributedRoutes().some(route => route.path === pathname)
}
// ── Contributed sidebar nav — the `sidebar.nav` registry area ────────────────
// A DATA contribution adds a row to the sidebar's top nav (below Artifacts).
// Pair with a ROUTES_AREA page: the row navigates to `path` and lights up
// while the app is there.
export const SIDEBAR_NAV_AREA = 'sidebar.nav'
/** Payload of a `sidebar.nav` data contribution. */
export interface SidebarNavContribution {
/** Codicon name, e.g. `'project'`. */
codicon: string
label: string
/** Route to navigate to (usually a contributed page's path). */
path: string
}
// Views that render as a full-screen modal card (OverlayView) over the shell.
// While one is open the app's titlebar control clusters must hide so they don't
// bleed over the overlay (they sit at a higher z-index than the overlay card).
@ -77,7 +133,7 @@ export function isNewChatRoute(pathname: string): boolean {
}
export function routeSessionId(pathname: string): string | null {
if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname)) {
if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname) || isContributedPath(pathname)) {
return null
}
@ -95,5 +151,25 @@ export function appViewForPath(pathname: string): AppView {
return 'chat'
}
if (isContributedPath(pathname)) {
return 'extension'
}
return APP_VIEW_BY_PATH.get(pathname) ?? 'chat'
}
/** True while the workspace pane shows a FULL PAGE (skills/messaging/
* artifacts/plugin routes) instead of the chat. Published by the wiring
* (which owns the router location); the workspace pane contribution mirrors
* it as `headerVeto` so the zone tab bar stands down on pages. Overlays
* (settings/) don't count the chat stays beneath them. */
export const $workspaceIsPage = atom(false)
export function syncWorkspaceIsPage(pathname: string): void {
const view = appViewForPath(pathname)
const isPage = view !== 'chat' && !isOverlayView(view)
if (isPage !== $workspaceIsPage.get()) {
$workspaceIsPage.set(isPage)
}
}

View file

@ -26,6 +26,7 @@ import { followActiveSessionCwd } from '@/store/projects'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import {
$currentCwd,
sessionMatchesStoredId,
setCurrentBranch,
setCurrentCwd,
setCurrentFastMode,
@ -405,7 +406,17 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
}
if (payload?.usage) {
setCurrentUsage(current => ({ ...current, ...payload.usage }))
// Per-session twin FIRST (the statusbar reads it for focused tiles);
// the primary-only global mirrors the ACTIVE session — ungated it
// let a background tile's turn overwrite the primary's count.
updateSessionState(sessionId, state => ({
...state,
usage: { calls: 0, input: 0, output: 0, total: 0, ...state.usage, ...payload.usage }
}))
if (isActiveEvent) {
setCurrentUsage(current => ({ ...current, ...payload.usage }))
}
}
} else if (event.type === 'session.title') {
// Live auto-title push (titler runs async, after the turn's refresh).
@ -413,9 +424,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
const nextTitle = typeof payload?.title === 'string' ? payload.title.trim() : ''
if (storedId && nextTitle) {
setSessions(prev =>
prev.map(s => (s.id === storedId || s._lineage_root_id === storedId ? { ...s, title: nextTitle } : s))
)
setSessions(prev => prev.map(s => (sessionMatchesStoredId(s, storedId) ? { ...s, title: nextTitle } : s)))
}
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
if (!sessionId) {

View file

@ -1453,6 +1453,7 @@ describe('usePromptActions submit session-context isolation (#54527)', () => {
it('aborts recovery submit when the user switches sessions during timeout resume', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
let releaseResume: () => void = () => {}
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED_SESSION_A }

View file

@ -5,9 +5,9 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS, transcribeAudio } from '@/hermes'
import { useI18n } from '@/i18n'
import { stripAnsi } from '@/lib/ansi'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { type ChatMessage, textPart } from '@/lib/chat-messages'
import { pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
import { normalize } from '@/lib/text'
@ -44,23 +44,28 @@ import type {
SessionSteerResponse
} from '../../../types'
import {
applyBranchVisibility,
applyReloadOptimistic,
applyRewindOptimistic,
finalizeInterruptedMessages,
planEdit,
planReload,
planRestore,
runRewindSubmit
} from './rewind'
import { useSlashCommand } from './slash'
import { useSubmitPrompt } from './submit'
import {
appendText,
blobToDataUrl,
delay,
friendlyRemoteAttachError,
type GatewayRequest,
inlineErrorMessage,
isSessionBusyError,
isSessionNotFoundError,
readFileDataUrlForAttach,
readImageForRemoteAttach,
type SubmitTextOptions,
visibleUserIndexAtOrdinal,
visibleUserOrdinal,
withSessionBusyRetry
type SubmitTextOptions
} from './utils'
interface HandoffResult {
@ -403,6 +408,13 @@ export function usePromptActions({
return { error: inlineErrorMessage(err, copy.handoff.failed(target)), ok: false }
}
const markCompleted = (): HandoffResult => {
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
notify({ kind: 'success', message: copy.handoff.success(target) })
return { ok: true }
}
const deadline = Date.now() + 60_000
let lastState = 'pending'
@ -425,10 +437,7 @@ export function usePromptActions({
}
if (state === 'completed') {
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
notify({ kind: 'success', message: copy.handoff.success(target) })
return { ok: true }
return markCompleted()
}
if (state === 'failed') {
@ -442,10 +451,7 @@ export function usePromptActions({
}).catch(() => null)
if (cleanup?.state === 'completed') {
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
notify({ kind: 'success', message: copy.handoff.success(target) })
return { ok: true }
return markCompleted()
}
return { error: copy.handoff.timedOut, ok: false }
@ -512,21 +518,16 @@ export function usePromptActions({
setAwaitingResponse(false)
setTurnStartedAt(null)
const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
messages
.filter(message => !((message.pending || message.id === streamId) && !chatMessageText(message).trim()))
.map(message => (message.pending || message.id === streamId ? { ...message, pending: false } : message))
if (!sessionId) {
releaseBusy()
setMessages(finalizeMessages($messages.get()))
setMessages(finalizeInterruptedMessages($messages.get()))
return
}
updateSessionState(sessionId, state => {
const streamId = state.streamId
const messages = finalizeMessages(state.messages, streamId)
const messages = finalizeInterruptedMessages(state.messages, streamId)
return {
...state,
@ -631,66 +632,19 @@ export function usePromptActions({
return
}
const messages = $messages.get()
const parentIndex = parentId ? messages.findIndex(message => message.id === parentId) : messages.length - 1
const plan = planReload($messages.get(), parentId)
const userIndex =
parentIndex >= 0
? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(message => message.role === 'user')
: -1
if (userIndex < 0) {
if (!plan) {
return
}
const absoluteUserIndex = parentIndex - userIndex
const userMessage = messages[absoluteUserIndex]
const userText = userMessage ? chatMessageText(userMessage).trim() : ''
if (!userText) {
return
}
const targetAssistant =
parentId && messages[parentIndex]?.role === 'assistant'
? messages[parentIndex]
: messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant')
const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage)
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, absoluteUserIndex)
clearNotifications()
updateSessionState(activeSessionId, state => {
const nextUserIndex = state.messages.findIndex(
(message, index) => index > absoluteUserIndex && message.role === 'user'
)
const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex
return {
...state,
busy: true,
awaitingResponse: true,
pendingBranchGroup: branchGroupId,
sawAssistantPayload: false,
interrupted: false,
messages: [
...state.messages.slice(0, absoluteUserIndex + 1),
...state.messages
.slice(absoluteUserIndex + 1, end)
.map(message => (message.role === 'assistant' ? { ...message, branchGroupId, hidden: true } : message))
]
}
})
updateSessionState(activeSessionId, state => applyReloadOptimistic(state, plan))
try {
await requestGateway(
'prompt.submit',
{
session_id: activeSessionId,
text: userText,
truncate_before_user_ordinal: truncateBeforeUserOrdinal
},
{ session_id: activeSessionId, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
} catch (err) {
@ -715,41 +669,8 @@ export function usePromptActions({
// fresh turn. Live/stuck turns interrupt first, and a raced "session busy"
// response interrupts + retries through the shared busy gate.
const submitRewindPrompt = useCallback(
async (sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => {
const interrupt = async () => {
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch {
// Best-effort. The submit path still gates on the gateway state.
}
}
const submit = () =>
requestGateway(
'prompt.submit',
{
session_id: sessionId,
text,
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
},
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
if (interruptFirst) {
await interrupt()
}
try {
await submit()
} catch (err) {
if (!isSessionBusyError(err)) {
throw err
}
await interrupt()
await withSessionBusyRetry(submit)
}
},
(sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) =>
runRewindSubmit(requestGateway, sessionId, text, truncateOrdinal, interruptFirst),
[requestGateway]
)
@ -762,30 +683,7 @@ export function usePromptActions({
}
const messages = $messages.get()
const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user')
const fallbackIndex =
target?.userOrdinal === null || target?.userOrdinal === undefined
? -1
: visibleUserIndexAtOrdinal(messages, target.userOrdinal)
const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex
const source = messages[sourceIndex]
if (!source || source.role !== 'user') {
throw new Error('Could not find the message to restore.')
}
const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim()
if (!text) {
throw new Error('Cannot restore an empty message.')
}
const truncateBeforeUserOrdinal =
target?.userOrdinal === null || target?.userOrdinal === undefined
? visibleUserOrdinal(messages, sourceIndex)
: target.userOrdinal
const plan = planRestore(messages, messageId, target)
// The turns we're discarding may have spawned todos and background
// processes; they belong to the abandoned timeline, so wipe their status
@ -798,18 +696,10 @@ export function usePromptActions({
setMutableRef(busyRef, true)
setBusy(true)
setAwaitingResponse(true)
updateSessionState(sessionId, state => ({
...state,
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: false,
messages: state.messages.slice(0, sourceIndex + 1)
}))
updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex))
try {
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, busyRef.current || $busy.get())
await submitRewindPrompt(sessionId, plan.text, plan.truncateOrdinal, busyRef.current || $busy.get())
} catch (err) {
// The rewind never landed (e.g. the gateway stayed busy past the retry
// deadline). Roll the optimistic truncation back to the full original
@ -833,34 +723,17 @@ export function usePromptActions({
const editMessage = useCallback(
async (edited: AppendMessage) => {
const sessionId = activeSessionId || activeSessionIdRef.current
const sourceId = edited.sourceId || edited.parentId
const text = appendText(edited)
if (!sessionId || !sourceId || !text || edited.role !== 'user') {
return
}
const messages = $messages.get()
const sourceIndex = messages.findIndex(m => m.id === sourceId)
const source = messages[sourceIndex]
const plan = sessionId ? planEdit(messages, edited) : null
if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) {
if (!sessionId || !plan) {
return
}
// Sending an edit is a revert: rewind to this prompt and re-run with the
// new text. It can fire mid-turn; submitRewindPrompt always interrupts
// first, so a live turn is wound down before the resubmit.
// Failed turn: optimistic user msg never reached the gateway, so truncating
// by ordinal would 422. Submit as a plain resend instead.
const nextMessage = messages[sourceIndex + 1]
const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error)
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
// Editing rewinds the conversation to this prompt — same as restore — so
// drop the abandoned timeline's todos/background rows (and kill the live
// processes) before the re-run repopulates them.
// new text (submitRewindPrompt interrupts a live turn first). Same as
// restore, so drop the abandoned timeline's todos/background rows before
// the re-run repopulates them.
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearPreviewArtifacts(sessionId)
@ -869,33 +742,20 @@ export function usePromptActions({
setMutableRef(busyRef, true)
setBusy(true)
setAwaitingResponse(true)
updateSessionState(sessionId, state => ({
...state,
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: false,
messages: [...state.messages.slice(0, sourceIndex), editedMessage]
}))
updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage))
const isStaleTargetError = (err: unknown) =>
/no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err))
try {
await submitRewindPrompt(
sessionId,
text,
isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex),
busyRef.current || $busy.get()
)
await submitRewindPrompt(sessionId, plan.text, plan.truncateOrdinal, busyRef.current || $busy.get())
} catch (err) {
let surfaced = err
if (!isFailedTurn && isStaleTargetError(err)) {
if (!plan.isFailedTurn && isStaleTargetError(err)) {
try {
// Already interrupted on the first attempt — submit as a plain resend.
await submitRewindPrompt(sessionId, text, undefined, false)
await submitRewindPrompt(sessionId, plan.text, undefined, false)
return
} catch (retryErr) {
@ -918,34 +778,13 @@ export function usePromptActions({
const handleThreadMessagesChange = useCallback(
(nextMessages: readonly ThreadMessage[]) => {
const visibleIds = new Set(nextMessages.map(m => m.id))
const sessionId = activeSessionIdRef.current
if (!sessionId) {
return
}
updateSessionState(sessionId, state => {
let changed = false
const messages = state.messages.map(message => {
if (message.role !== 'assistant' || !message.branchGroupId) {
return message
}
const hidden = !visibleIds.has(message.id)
if (message.hidden === hidden) {
return message
}
changed = true
return { ...message, hidden }
})
return changed ? { ...state, messages } : state
})
updateSessionState(sessionId, state => applyBranchVisibility(state, nextMessages))
},
[activeSessionIdRef, updateSessionState]
)
@ -953,6 +792,9 @@ export function usePromptActions({
return {
cancelRun,
editMessage,
// Session tiles route their slash input here (targets THEIR session via
// options.sessionId; app-level effects — branch, handoff — act on main).
executeSlashCommand,
handleThreadMessagesChange,
handoffSession,
reloadFromMessage,

View file

@ -0,0 +1,271 @@
/**
* Shared rewind/interrupt core for the prompt verbs the ONE implementation
* of the submit primitive + the pure message math behind cancel / reload /
* restore / edit / branch-visibility. Both the primary chat (`index.ts`) and
* session tiles (`session-tile-actions.ts`) build on these so the two surfaces
* can't silently diverge (the tile's "sends only once" busy-ref bug was exactly
* that class of drift). The functions here are PURE planners compute from a
* `ChatMessage[]`, optimistic transforms map a `ClientSessionState` to the next
* so each caller keeps its own state-write + error-handling wiring.
*/
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import type { ClientSessionState } from '@/app/types'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { appendText, isSessionBusyError, visibleUserIndexAtOrdinal, visibleUserOrdinal, withSessionBusyRetry } from './utils'
type RequestGateway = <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
/**
* Rewind a turn: `prompt.submit` with an optional `truncate_before_user_ordinal`
* (drops that user turn + everything after). Idle rewinds submit directly
* (interrupting an idle agent can leave a stale interrupt flag that cancels the
* fresh turn); live/stuck turns interrupt first, and a raced "session busy"
* response interrupts + retries through the shared busy gate.
*/
export async function runRewindSubmit(
requestGateway: RequestGateway,
sessionId: string,
text: string,
truncateOrdinal: number | undefined,
interruptFirst: boolean
): Promise<void> {
const interrupt = async () => {
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch {
// Best-effort. The submit path still gates on the gateway state.
}
}
const submit = () =>
requestGateway(
'prompt.submit',
{ session_id: sessionId, text, ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) },
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
if (interruptFirst) {
await interrupt()
}
try {
await submit()
} catch (err) {
if (!isSessionBusyError(err)) {
throw err
}
await interrupt()
await withSessionBusyRetry(submit)
}
}
/** Cancel/stop finalize: drop empty pending/stream placeholders, un-pend the rest. */
export function finalizeInterruptedMessages(messages: ChatMessage[], streamId?: null | string): ChatMessage[] {
return messages
.filter(message => !((message.pending || message.id === streamId) && !chatMessageText(message).trim()))
.map(message => (message.pending || message.id === streamId ? { ...message, pending: false } : message))
}
// ---------------------------------------------------------------------------
// Reload (regenerate)
// ---------------------------------------------------------------------------
export interface ReloadPlan {
branchGroupId: string
text: string
truncateOrdinal: number
userIndex: number
}
/** The user turn to re-run for a reload from `parentId` (or the last turn). */
export function planReload(messages: ChatMessage[], parentId: null | string): null | ReloadPlan {
const parentIndex = parentId ? messages.findIndex(m => m.id === parentId) : messages.length - 1
const userBack =
parentIndex >= 0 ? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(m => m.role === 'user') : -1
if (userBack < 0) {
return null
}
const userIndex = parentIndex - userBack
const userMessage = messages[userIndex]
const text = userMessage ? chatMessageText(userMessage).trim() : ''
if (!userMessage || !text) {
return null
}
const targetAssistant =
parentId && messages[parentIndex]?.role === 'assistant'
? messages[parentIndex]
: messages.slice(userIndex + 1).find(m => m.role === 'assistant')
return {
branchGroupId: targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage),
text,
truncateOrdinal: visibleUserOrdinal(messages, userIndex),
userIndex
}
}
/** Optimistic reload state: keep the user turn, hide the branch's assistants. */
export function applyReloadOptimistic(state: ClientSessionState, plan: ReloadPlan): ClientSessionState {
const nextUserIndex = state.messages.findIndex((m, i) => i > plan.userIndex && m.role === 'user')
const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex
return {
...state,
awaitingResponse: true,
busy: true,
interrupted: false,
messages: [
...state.messages.slice(0, plan.userIndex + 1),
...state.messages
.slice(plan.userIndex + 1, end)
.map(m => (m.role === 'assistant' ? { ...m, branchGroupId: plan.branchGroupId, hidden: true } : m))
],
pendingBranchGroup: plan.branchGroupId,
sawAssistantPayload: false
}
}
// ---------------------------------------------------------------------------
// Restore (rewind checkpoint)
// ---------------------------------------------------------------------------
export interface RestoreTarget {
text?: string
userOrdinal?: null | number
}
export interface RestorePlan {
sourceIndex: number
text: string
truncateOrdinal: number
}
/** Resolve the user turn to rewind to; throws with a user-facing reason. */
export function planRestore(messages: ChatMessage[], messageId: string, target?: RestoreTarget): RestorePlan {
const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user')
const fallbackIndex =
target?.userOrdinal === null || target?.userOrdinal === undefined
? -1
: visibleUserIndexAtOrdinal(messages, target.userOrdinal)
const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex
const source = messages[sourceIndex]
if (!source || source.role !== 'user') {
throw new Error('Could not find the message to restore.')
}
const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim()
if (!text) {
throw new Error('Cannot restore an empty message.')
}
const truncateOrdinal =
target?.userOrdinal === null || target?.userOrdinal === undefined
? visibleUserOrdinal(messages, sourceIndex)
: target.userOrdinal
return { sourceIndex, text, truncateOrdinal }
}
// ---------------------------------------------------------------------------
// Edit (revert + resubmit with new text)
// ---------------------------------------------------------------------------
export interface EditPlan {
editedMessage: ChatMessage
isFailedTurn: boolean
sourceIndex: number
text: string
truncateOrdinal: number | undefined
}
/** Resolve the edited user turn, or null when nothing changed / invalid. */
export function planEdit(messages: ChatMessage[], edited: AppendMessage): EditPlan | null {
const sourceId = edited.sourceId || edited.parentId
const text = appendText(edited)
if (!sourceId || !text || edited.role !== 'user') {
return null
}
const sourceIndex = messages.findIndex(m => m.id === sourceId)
const source = messages[sourceIndex]
if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) {
return null
}
// Failed turn: the optimistic user msg never reached the gateway, so a
// truncate-by-ordinal would 422 — resubmit plainly instead.
const nextMessage = messages[sourceIndex + 1]
const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error)
return {
editedMessage: { ...source, parts: [textPart(text)] },
isFailedTurn,
sourceIndex,
text,
truncateOrdinal: isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex)
}
}
/** Optimistic rewind-to state for restore/edit: drop everything after the
* source turn (edit swaps in the edited message; restore keeps the original). */
export function applyRewindOptimistic(
state: ClientSessionState,
sourceIndex: number,
editedMessage?: ChatMessage
): ClientSessionState {
return {
...state,
awaitingResponse: true,
busy: true,
interrupted: false,
messages: editedMessage
? [...state.messages.slice(0, sourceIndex), editedMessage]
: state.messages.slice(0, sourceIndex + 1),
pendingBranchGroup: null,
sawAssistantPayload: false
}
}
// ---------------------------------------------------------------------------
// Branch visibility (assistant-ui hides non-active branches)
// ---------------------------------------------------------------------------
/** Sync each assistant branch message's `hidden` to what the thread renders. */
export function applyBranchVisibility(state: ClientSessionState, next: readonly ThreadMessage[]): ClientSessionState {
const visibleIds = new Set(next.map(m => m.id))
let changed = false
const messages = state.messages.map(message => {
if (message.role !== 'assistant' || !message.branchGroupId) {
return message
}
const hidden = !visibleIds.has(message.id)
if (message.hidden === hidden) {
return message
}
changed = true
return { ...message, hidden }
})
return changed ? { ...state, messages } : state
}

View file

@ -3,8 +3,8 @@ import { type MutableRefObject, useCallback } from 'react'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import type { Translations } from '@/i18n'
import { type ChatMessage, textPart } from '@/lib/chat-messages'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { optimisticAttachmentRef } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { setMutableRef } from '@/lib/mutable-ref'
import {
$composerAttachments,
@ -49,6 +49,26 @@ interface SubmitPromptDeps {
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
/** Composer-scope seams: the main chat runs on the module-level globals
* (defaults); a session tile injects its own so a tile submit never writes
* the primary view's $busy/$messages or clears the main attachment chips. */
scope?: {
clearAttachments: () => void
readAttachments: () => ComposerAttachment[]
setAwaitingResponse: (awaiting: boolean) => void
setBusy: (busy: boolean) => void
setMessages: (updater: (current: ChatMessage[]) => ChatMessage[]) => void
}
}
// Stable identity — a fresh default object per render would churn the
// useCallback below on every render.
const MAIN_SUBMIT_SCOPE: NonNullable<SubmitPromptDeps['scope']> = {
clearAttachments: clearComposerAttachments,
readAttachments: () => $composerAttachments.get(),
setAwaitingResponse,
setBusy,
setMessages
}
/** The prompt submit pipeline, extracted from usePromptActions. */
@ -63,7 +83,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
requestGateway,
selectedStoredSessionIdRef,
syncAttachmentsForSubmit,
updateSessionState
updateSessionState,
scope = MAIN_SUBMIT_SCOPE
} = deps
return useCallback(
@ -76,7 +97,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
// this, the sibling iterations below (a.kind / a.label / a.refText, and the
// sync step) throw "Cannot read properties of undefined (reading 'refText')"
// and break the chat surface.
const attachments = (options?.attachments ?? $composerAttachments.get()).filter((a): a is ComposerAttachment =>
const attachments = (options?.attachments ?? scope.readAttachments()).filter((a): a is ComposerAttachment =>
Boolean(a)
)
@ -159,8 +180,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
const releaseBusy = () => {
releaseSubmitLock()
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
scope.setBusy(false)
scope.setAwaitingResponse(false)
}
// Idempotent optimistic insert — re-running with the resolved sessionId
@ -199,7 +220,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
const dropOptimistic = (sid: null | string) => {
if (!sid) {
setMessages(current => current.filter(m => m.id !== optimisticId))
scope.setMessages(current => current.filter(m => m.id !== optimisticId))
return
}
@ -225,8 +246,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
}
setMutableRef(busyRef, true)
setBusy(true)
setAwaitingResponse(true)
scope.setBusy(true)
scope.setAwaitingResponse(true)
clearNotifications()
let sessionId: null | string = activeSessionId
@ -234,7 +255,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
if (sessionId) {
seedOptimistic(sessionId)
} else {
setMessages(current => [...current, buildUserMessage()])
scope.setMessages(current => [...current, buildUserMessage()])
}
if (!sessionId && startingStoredSessionId) {
@ -383,7 +404,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
}
if (usingComposerAttachments) {
clearComposerAttachments()
scope.clearAttachments()
}
// Submit landed — the turn now runs (busy stays true), but the submit
@ -440,6 +461,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
createBackendSessionForSend,
getRouteToken,
requestGateway,
scope,
selectedStoredSessionIdRef,
syncAttachmentsForSubmit,
updateSessionState

View file

@ -426,6 +426,7 @@ describe('resumeSession failure recovery', () => {
storedSessionId: 'stored-1',
streamId: null,
turnStartedAt: null,
usage: null,
yolo: false
}
]

View file

@ -2,6 +2,7 @@ import type { MutableRefObject } from 'react'
import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { revealTreePane } from '@/components/pane-shell/tree/store'
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
@ -44,6 +45,7 @@ import {
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { closeSessionTile, dropSessionState, openSessionTile, patchSessionTile, publishSessionState, type TileDock } from '@/store/session-states'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { isWatchWindow } from '@/store/windows'
import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes'
@ -88,6 +90,54 @@ interface SessionActionsOptions {
) => ClientSessionState
}
// Stored ids created in THIS renderer run. A brand-new session lives only in the
// gateway's in-memory map until its first turn persists a state.db row — so if a
// respawning/flapping backend drops it, both resume RPC and the REST transcript
// 404 even though the user just made it. We must NOT treat that as "gone" (which
// yanks them to a fresh draft — the "new sessions clear themselves" bug); the
// bounded retry rebinds it when the backend returns. Boot-into-a-stale-last-id
// (NOT in this set) still legitimately drops to a draft.
const createdThisRun = new Set<string>()
// Reflect a stored row's persisted token counts into the live usage atom
// (total is derived, so callers can't drift it out of sync with input/output).
function applyStoredUsage(stored: { input_tokens?: number | null; output_tokens?: number | null }) {
const input = stored.input_tokens || 0
const output = stored.output_tokens || 0
setCurrentUsage(current => ({ ...current, input, output, total: input + output }))
}
// `session.create` params from the current profile + sticky-UI model/effort/fast,
// ensuring the gateway is on that profile first. Shared by the primary send path
// and the "open in split" tile path; `cwd` is the one thing that differs (the
// live composer cwd for a send, the resolved new-session cwd for a fresh tile).
//
// Resolving null profile to the active gateway's is load-bearing: in global-remote
// mode one backend serves every profile, so an omitted profile silently lands the
// chat on the launch (default) profile — the "rubberbands back to default" bug.
// A no-op for single-profile/local-pooled users (a backend resolves its own launch
// profile to None). The sticky UI model/effort/fast ride as per-session overrides,
// never the profile default (that lives in Settings → Model).
async function desktopSessionCreateParams(cwd: string): Promise<Record<string, unknown>> {
const profile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
await ensureGatewayProfile(profile)
const model = $currentModel.get().trim()
const provider = $currentProvider.get().trim()
const effort = $currentReasoningEffort.get().trim()
return {
cols: 96,
source: 'desktop',
...(cwd && { cwd }),
...(profile ? { profile } : {}),
...(model ? { model, ...(provider ? { provider } : {}) } : {}),
...(effort ? { reasoning_effort: effort } : {}),
...($currentFastMode.get() ? { fast: true } : {})
}
}
interface FreshSessionDraftOptions {
replaceRoute?: boolean
workspaceTarget?: NewChatWorkspaceTarget
@ -186,19 +236,9 @@ export function useSessionActions({
creatingSessionRef.current = true
try {
// A plain new session (top "New Session", /new, keybind) leaves
// $newChatProfile null to mean "use the live context"; the per-profile
// "+" sets it explicitly. Resolve null to the active gateway profile so
// session.create always carries it: in global-remote mode one backend
// serves every profile, so an omitted profile param silently lands the
// chat on the launch (default) profile — the "rubberbands back to
// default" bug. This is a no-op for single-profile/local-pooled users:
// a backend resolves its own launch profile to None (_profile_home).
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
await ensureGatewayProfile(newChatProfile)
// An explicit one-shot workspace target (null → detached, string → that
// folder) wins; otherwise fall through to the live cwd, then the
// project-aware default (resolveNewSessionCwd).
// folder) wins; otherwise the live cwd, then the project-aware default
// (resolveNewSessionCwd — a project's new session keeps its repo cwd).
const workspaceTarget = $newChatWorkspaceTarget.get()
const cwd =
@ -208,26 +248,8 @@ export function useSessionActions({
? workspaceTarget.trim()
: $currentCwd.get().trim() || resolveNewSessionCwd()
// The composer's model/effort/fast is sticky UI state ($currentModel,
// $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it
// with every session.create so the new chat opens on whatever the picker
// shows — applied as per-session overrides, never written to the profile
// default (that lives in Settings → Model).
const uiModel = $currentModel.get().trim()
const uiProvider = $currentProvider.get().trim()
const uiEffort = $currentReasoningEffort.get().trim()
const uiFast = $currentFastMode.get()
const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
source: 'desktop',
...(cwd && { cwd }),
...(newChatProfile ? { profile: newChatProfile } : {}),
...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}),
...(uiEffort ? { reasoning_effort: uiEffort } : {}),
...(uiFast ? { fast: true } : {})
})
const params = await desktopSessionCreateParams(cwd)
const created = await requestGateway<SessionCreateResponse>('session.create', params)
const stored = created.stored_session_id ?? null
if (
@ -246,6 +268,7 @@ export function useSessionActions({
ensureSessionState(created.session_id, stored)
if (stored) {
createdThisRun.add(stored)
// Seed the sidebar preview with the user's first message so the row
// reads meaningfully while the turn is in flight, instead of flashing
// "Untitled session" until the turn persists and auto-title runs. The
@ -310,6 +333,44 @@ export function useSessionActions({
[navigate, startFreshSessionDraft]
)
/** Create a fresh session and open it as a tile leaves the primary chat alone.
* Used by the New session row's "Open in split" menu (and any future
* "new chat beside" affordance). */
const openNewSessionTile = useCallback(
async (dir: TileDock = 'right') => {
try {
// Fresh tile → the resolved new-session cwd (project/default), not the
// primary composer's live cwd.
const params = await desktopSessionCreateParams(resolveNewSessionCwd().trim())
const created = await requestGateway<SessionCreateResponse>('session.create', params)
const stored = created.stored_session_id
if (!stored) {
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
return
}
createdThisRun.add(stored)
// Seed the sidebar + per-runtime cache, but DON'T steal the primary
// selection — this session lives in the tile. Prime it with the create
// runtime so the tile skips a redundant resume.
upsertOptimisticSession(created, stored, null, null)
const runtimeInfo = applyRuntimeInfo(created.info)
updateSessionState(created.session_id, state => (runtimeInfo ? { ...state, ...runtimeInfo } : state), stored)
openSessionTile(stored, dir)
patchSessionTile(stored, { runtimeId: created.session_id })
revealTreePane(`session-tile:${stored}`)
broadcastSessionsChanged()
} catch (error) {
notifyError(error, copy.createSessionFailed)
}
},
[copy, requestGateway, updateSessionState]
)
const openSettings = useCallback(() => {
navigate(SETTINGS_ROUTE)
}, [navigate])
@ -375,6 +436,7 @@ export function useSessionActions({
if (state.storedSessionId !== storedSessionId) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(runtimeId)
dropSessionState(runtimeId)
return null
}
@ -423,11 +485,13 @@ export function useSessionActions({
if (cachedViewState !== cachedState) {
sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState)
publishSessionState(cachedRuntimeId, cachedViewState)
}
if (sessionShouldHaveTranscript(stored) && cachedViewState.messages.length === 0) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
dropSessionState(cachedRuntimeId)
} else {
setFreshDraftReady(false)
clearNotifications()
@ -464,6 +528,7 @@ export function useSessionActions({
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
dropSessionState(cachedRuntimeId)
}
}
}
@ -471,6 +536,16 @@ export function useSessionActions({
setFreshDraftReady(false)
setActiveSessionId(null)
activeSessionIdRef.current = null
// A warm-cache hit at entry skipped the cold-path transcript clear, but the
// warm path can still bail down to here — an empty-transcript drop, or the
// cache getting purged during the profile-swap await — so the PREVIOUS
// session's transcript would leak into this cold resume ("switching
// sessions shows the same messages"). Clear it so the loader/prefetch
// paints fresh; guarded so the normal cold path (already cleared) no-ops.
if ($messages.get().length > 0) {
setMessages([])
}
busyRef.current = true
setBusy(true)
setAwaitingResponse(false)
@ -485,12 +560,7 @@ export function useSessionActions({
applyStoredSessionPreviewRuntimeInfo(stored)
if (stored) {
setCurrentUsage(current => ({
...current,
input: stored.input_tokens || 0,
output: stored.output_tokens || 0,
total: (stored.input_tokens || 0) + (stored.output_tokens || 0)
}))
applyStoredUsage(stored)
}
let resumedRunning = false
@ -642,6 +712,16 @@ export function useSessionActions({
// permanently-dead id. (Booting straight into a no-longer-existent
// last-session id is the common trigger.)
if ($messages.get().length === 0 && isSessionGoneError(fallbackError)) {
// A session created THIS run isn't gone — its backend just flapped
// before the turn-less session persisted. Keep the empty view and arm
// the bounded retry to rebind, rather than yanking to a fresh draft.
// Only a stale id from a PRIOR run drops to a draft.
if (createdThisRun.has(storedSessionId)) {
setResumeFailedSessionId(storedSessionId)
return
}
startFreshSessionDraft(true)
return
@ -876,6 +956,18 @@ export function useSessionActions({
if (closingRuntimeId) {
clearQueuedPrompts(closingRuntimeId)
}
// A tiled copy of this session must not outlive it: collapse the pane
// and evict its mirrored runtime state so nothing submits to (or renders)
// a deleted session.
const tiledRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
closeSessionTile(storedSessionId)
if (tiledRuntimeId) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(tiledRuntimeId)
dropSessionState(tiledRuntimeId)
}
} catch (err) {
if (removed) {
setSessions(prev => [removed, ...prev])
@ -892,12 +984,7 @@ export function useSessionActions({
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
if (stored) {
setCurrentUsage(current => ({
...current,
input: stored.input_tokens || 0,
output: stored.output_tokens || 0,
total: (stored.input_tokens || 0) + (stored.output_tokens || 0)
}))
applyStoredUsage(stored)
}
setMessages(previousMessages)
@ -918,8 +1005,10 @@ export function useSessionActions({
copy,
navigate,
requestGateway,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
startFreshSessionDraft
]
)
@ -956,6 +1045,16 @@ export function useSessionActions({
// not appear to do nothing until the next full refresh.
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
// An archived session is hidden from the sidebar; its tile must go too.
const tiledRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
closeSessionTile(storedSessionId)
if (tiledRuntimeId) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(tiledRuntimeId)
dropSessionState(tiledRuntimeId)
}
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
} catch (err) {
if (archived) {
@ -968,7 +1067,13 @@ export function useSessionActions({
notifyError(err, copy.archiveFailed)
}
},
[copy, selectedStoredSessionId, startFreshSessionDraft]
[
copy,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
sessionStateByRuntimeIdRef,
startFreshSessionDraft
]
)
return {
@ -977,6 +1082,7 @@ export function useSessionActions({
branchStoredSession,
closeSettings,
createBackendSessionForSend,
openNewSessionTile,
openSettings,
removeSession,
resumeSession,

View file

@ -8,6 +8,7 @@ import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/p
import {
$currentCwd,
$sessions,
sessionMatchesStoredId,
setCurrentBranch,
setCurrentCwd,
setCurrentFastMode,
@ -20,6 +21,10 @@ import {
setSessions,
setYoloActive
} from '@/store/session'
// Re-exported for the many session-actions/tile call sites that already import
// it from here; the canonical definition lives in @/store/session.
export { sessionMatchesStoredId }
import { reportBackendContract, reportInstallMethodWarning } from '@/store/updates'
import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes'
@ -183,10 +188,6 @@ export function patchSessionWorkspace(sessionId: string, cwd: string | undefined
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
}
export function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean {
return session.id === storedSessionId || session._lineage_root_id === storedSessionId
}
export function sessionShouldHaveTranscript(session: SessionInfo | undefined): boolean {
return (session?.message_count ?? 0) > 0
}

View file

@ -1,6 +1,7 @@
import { useCallback, useRef } from 'react'
import { getCronJobs, listAllProfileSessions, type SessionInfo } from '@/hermes'
import { sameCronSignature } from '@/lib/session-signatures'
import {
isMessagingSource,
LOCAL_SESSION_SOURCE_IDS,
@ -29,8 +30,6 @@ import {
setSessionsTotal
} from '@/store/session'
import { sameCronSignature } from '../../desktop-controller-utils'
// The recents list is local-only: cron rows have their own section, and each
// messaging platform (telegram, discord, …) is fetched separately into its own
// self-managed sidebar section (refreshMessagingSessions). Excluding both here

View file

@ -21,6 +21,7 @@ import {
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { publishSessionState } from '@/store/session-states'
import type { ClientSessionState } from '../../types'
@ -263,6 +264,10 @@ export function useSessionStateCache({
const previous = ensureSessionState(sessionId, storedSessionId)
const next = updater({ ...previous, messages: previous.messages })
sessionStateByRuntimeIdRef.current.set(sessionId, next)
// Mirror into the reactive multi-session store — session tiles (and any
// other non-primary surface) subscribe per runtime id there instead of
// through the single active $messages view.
publishSessionState(sessionId, next)
if (previous.storedSessionId !== next.storedSessionId || !next.busy) {
setSessionWorking(previous.storedSessionId, false)

View file

@ -6,7 +6,7 @@ import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Bell, Download, Globe, Info, KeyRound, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
import { Archive, Bell, Download, Globe, Info, KeyRound, Package, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@ -22,6 +22,7 @@ import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
import { NotificationsSettings } from './notifications-settings'
import { PluginsSettings } from './plugins-settings'
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
@ -32,6 +33,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
'gateway',
'keys',
'notifications',
'plugins',
'sessions',
'about'
]
@ -186,6 +188,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
label: t.settings.nav.apiKeys,
onSelect: () => setActiveView('keys')
},
{
active: activeView === 'plugins',
icon: Package,
id: 'plugins',
label: t.settings.nav.plugins,
onSelect: () => setActiveView('plugins')
},
{
active: activeView === 'sessions',
icon: Archive,
@ -259,6 +268,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
<KeysSettings view={keysView} />
) : activeView === 'notifications' ? (
<NotificationsSettings />
) : activeView === 'plugins' ? (
<PluginsSettings />
) : (
<SessionsSettings />
)}

View file

@ -0,0 +1,124 @@
import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Switch } from '@/components/ui/switch'
import { Tip } from '@/components/ui/tooltip'
import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { getStatus } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Package } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { EmptyState, ListRow, Pill, SectionHeading, SettingsContent } from './primitives'
const KIND_ORDER: Record<PluginRecord['kind'], number> = { disk: 0, runtime: 1, bundled: 2 }
function reveal(file: string) {
void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined)
}
async function revealPluginsDir() {
try {
const { hermes_home } = await getStatus()
// openDir (not reveal): the door often doesn't exist on first use, and
// showItemInFolder on a missing path silently no-ops (esp. Windows).
const result = await window.hermesDesktop?.openDir?.(`${hermes_home}/desktop-plugins`)
if (result && !result.ok) {
notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder')
}
} catch (err) {
notifyError(err, 'Could not resolve the plugins folder')
}
}
function PluginRow({ record }: { record: PluginRecord }) {
const { t } = useI18n()
const p = t.settings.plugins
return (
<ListRow
action={
<div className="flex items-center justify-end gap-2">
{record.file && (
<Tip label={p.reveal}>
<Button onClick={() => reveal(record.file!)} size="icon" variant="ghost">
<Codicon name="folder-opened" size="0.85rem" />
</Button>
</Tip>
)}
<Switch
aria-label={`${record.status === 'disabled' ? p.enable : p.disable} ${record.name}`}
checked={record.status !== 'disabled'}
onCheckedChange={on => {
triggerHaptic('selection')
void setPluginEnabled(record.id, on)
}}
/>
</div>
}
description={
record.status === 'error' ? (
<span className="text-(--ui-danger,#f87171)">{record.error}</span>
) : (
record.file ?? record.id
)
}
title={
<span className="flex items-center gap-2">
{record.name}
<Pill>{p.kinds[record.kind]}</Pill>
{record.status === 'error' && <Pill tone="primary">{p.failed}</Pill>}
</span>
}
/>
)
}
export function PluginsSettings() {
const { t } = useI18n()
const p = t.settings.plugins
const records = useStore($pluginRecords)
const rows = Object.values(records).sort(
(a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.name.localeCompare(b.name)
)
return (
<SettingsContent>
<SectionHeading icon={Package} meta={p.count(rows.length)} title={p.title} />
<p className="mb-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{p.blurb}</p>
<div className="mb-4 flex items-center gap-2">
<Button onClick={() => void revealPluginsDir()} size="sm" variant="outline">
<Codicon name="folder-opened" size="0.8rem" />
{p.openFolder}
</Button>
<Button
onClick={() => {
triggerHaptic('selection')
void discoverRuntimePlugins()
}}
size="sm"
variant="outline"
>
<Codicon name="refresh" size="0.8rem" />
{p.rescan}
</Button>
</div>
{rows.length === 0 ? (
<EmptyState title={p.empty} />
) : (
<div className="divide-y divide-(--ui-stroke-tertiary)">
{rows.map(record => (
<PluginRow key={record.id} record={record} />
))}
</div>
)}
</SettingsContent>
)
}

View file

@ -9,6 +9,7 @@ export type SettingsView =
| 'gateway'
| 'keys'
| 'notifications'
| 'plugins'
| 'providers'
| 'sessions'
| `config:${string}`

View file

@ -1,237 +0,0 @@
import { useStore } from '@nanostores/react'
import type { CSSProperties, ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import { NotificationStack } from '@/components/notifications'
import { PaneShell } from '@/components/pane-shell'
import { FloatingPet } from '@/components/pet/floating-pet'
import { SidebarProvider } from '@/components/ui/sidebar'
import { useMediaQuery } from '@/hooks/use-media-query'
import {
$fileBrowserOpen,
$panesFlipped,
$sidebarOpen,
FILE_BROWSER_DEFAULT_WIDTH,
FILE_BROWSER_PANE_ID,
setSidebarOpen
} from '@/store/layout'
import { $paneWidthOverride } from '@/store/panes'
import { $connection } from '@/store/session'
import { isSecondaryWindow } from '@/store/windows'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
import { useWindowControlsOverlayWidth } from './hooks/use-window-controls-overlay-width'
import { KeybindPanel } from './keybind-panel'
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
interface AppShellProps {
children: ReactNode
leftStatusbarItems?: readonly StatusbarItem[]
leftTitlebarTools?: readonly TitlebarTool[]
// Fixed-position overlays that must share <main>'s stacking context so pane
// resize handles (z-20) paint above them. The persistent terminal lives here:
// hoisting it to the root `overlays` layer (sibling of <main>, z above z-3)
// would cover every pane's drag handle.
mainOverlays?: ReactNode
onOpenSettings: () => void
overlays?: ReactNode
// Rails that sit at the window's left edge in the flipped layout but never
// force-collapse to hover-reveal overlays — so they cover the top-left traffic
// lights (and zero the titlebar inset) even below the collapse breakpoint.
previewPaneOpen?: boolean
statusbarItems?: readonly StatusbarItem[]
terminalPaneOpen?: boolean
titlebarTools?: readonly TitlebarTool[]
}
// Renderer-side fallback so layout snaps even when the main-process fullscreen event
// hasn't landed yet (e.g. dev reloads, before the IPC bridge is wired).
function subscribeWindowSize(cb: () => void) {
window.addEventListener('resize', cb)
window.addEventListener('fullscreenchange', cb)
return () => {
window.removeEventListener('resize', cb)
window.removeEventListener('fullscreenchange', cb)
}
}
const viewportIsFullscreen = () =>
window.innerWidth >= window.screen.width && window.innerHeight >= window.screen.height
export function AppShell({
children,
leftStatusbarItems,
leftTitlebarTools,
mainOverlays,
onOpenSettings,
overlays,
previewPaneOpen = false,
statusbarItems,
terminalPaneOpen = false,
titlebarTools
}: AppShellProps) {
const sidebarOpen = useStore($sidebarOpen)
const fileBrowserOpen = useStore($fileBrowserOpen)
const panesFlipped = useStore($panesFlipped)
const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
const connection = useStore($connection)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
// Every secondary window (new-session scratch, subagent watch, cmd-click
// pop-out) is a compact side panel — none of them carry the full titlebar
// tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag.
const hideTitlebarControls = isSecondaryWindow()
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
// Width Windows/WSLg reserve for the native min/max/close overlay (zero on
// macOS, where window controls sit on the left and are reported via
// windowButtonPosition instead). The right tool cluster has to clear them.
// Prefer the EXACT width measured from the live Window Controls Overlay
// (precise + self-correcting across DPI/host themes); fall back to the static
// reservation the main process sends when the WCO API isn't available.
const measuredOverlayWidth = useWindowControlsOverlayWidth()
const staticOverlayWidth = connection?.nativeOverlayWidth ?? 0
const nativeOverlayWidth = measuredOverlayWidth ?? staticOverlayWidth
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
// When the native window controls overlay our titlebar band — Windows and
// WSLg both paint Electron's Window Controls Overlay and report
// nativeOverlayWidth > 0 — the right rail's editor-style tab strip (which
// normally lives IN that band) would render at y=0 under the fixed titlebar
// tool cluster and collide with it. Drop the right rail one titlebar-height so
// it opens BELOW the band. macOS / plain Linux paint no overlay → 0 inset,
// layout byte-for-byte unchanged. Consumed as --right-rail-top-inset.
const rightRailTopInset = nativeOverlayWidth > 0 ? 'var(--titlebar-height)' : '0px'
// The inset clears the top-left titlebar buttons when nothing covers the
// window's left edge. Default layout: the sessions sidebar sits there.
// Flipped layout: the file browser does instead. Both force-collapse to a
// hover-reveal overlay (0px track) below the collapse breakpoint, so the edge
// is uncovered there regardless of their stored open state. A standalone
// session window renders no sidebar at all, so its edge is always uncovered.
const collapsibleLeftPaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
// The terminal + preview rails never force-collapse, so when they're the
// leftmost open pane (flipped layout) they cover the edge even when narrow.
const persistentLeftPaneOpen = panesFlipped && (terminalPaneOpen || previewPaneOpen)
const leftEdgePaneOpen =
!isSecondaryWindow() && ((!narrowViewport && collapsibleLeftPaneOpen) || persistentLeftPaneOpen)
const titlebarContentInset = leftEdgePaneOpen
? 0
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
// The static system cluster (haptics, profiles, settings, right-sidebar) is
// hardcoded in TitlebarControls. Pane-supplied tools (preview's group) render
// in a separate cluster anchored further left.
//
// Width math has to include the `gap-x-1` (0.25rem) between buttons:
// N buttons + (N - 1) inner gaps, plus one extra 0.25rem of breathing room
// between the pane-tool cluster and the system cluster so they don't sit
// flush against each other. Modeled as N gaps (N - 1 inner + 1 trailing)
// to keep the formula generic for any pane-tool count.
const SYSTEM_TOOL_COUNT = 4
const paneToolCount = titlebarTools?.filter(tool => !tool.hidden).length ?? 0
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))`
const fileBrowserWidth =
fileBrowserWidthOverride !== undefined ? `${fileBrowserWidthOverride}px` : FILE_BROWSER_DEFAULT_WIDTH
// Where the pane-tool cluster's right edge sits, measured from the inner
// titlebar padding (--titlebar-tools-right). Two anchors:
// - file-browser closed → flush against static cluster's left edge
// - file-browser open → flush against the file-browser pane's left edge
// (= preview pane's right edge)
const previewToolbarGap = fileBrowserOpen ? fileBrowserWidth : systemToolsWidth
// Used by the drag region to know where the rightmost interactive element
// ends. When pane tools are present, that's `gap + paneCount * controlSize
// + paneCount * 0.25rem` (the leftmost button is at `tools-right + gap +
// paneCount * (size + gap-x-1)`). Otherwise the static cluster's footprint
// is enough.
const titlebarToolsWidth =
paneToolCount > 0
? `calc(${previewToolbarGap} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))`
: systemToolsWidth
return (
<SidebarProvider
className="h-screen min-h-0 flex-col bg-background"
onOpenChange={setSidebarOpen}
open={sidebarOpen}
style={
{
// Alias for shadcn <Sidebar> descendants. Resolves to the chat-sidebar
// pane track via PaneShell's emitted --pane-chat-sidebar-width.
'--sidebar-width': 'var(--pane-chat-sidebar-width)',
'--titlebar-height': `${TITLEBAR_HEIGHT}px`,
'--titlebar-content-inset': `${titlebarContentInset}px`,
'--titlebar-controls-left': `${titlebarControls.left}px`,
'--titlebar-controls-top': `${titlebarControls.top}px`,
'--titlebar-tools-right': titlebarToolsRight,
'--titlebar-tools-width': titlebarToolsWidth,
// Drops the right rail below the titlebar band when the OS/host paints
// window controls over it (Windows/WSLg); 0px elsewhere.
'--right-rail-top-inset': rightRailTopInset,
// Anchor for the pane-tool cluster's right edge in TitlebarControls.
// Sourced from the layout store rather than the PaneShell-emitted
// --pane-*-width vars because the titlebar is a sibling of PaneShell
// and CSS variables resolve at the consumer's scope.
'--shell-preview-toolbar-gap': previewToolbarGap
} as CSSProperties
}
>
{!hideTitlebarControls && (
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
)}
{nativeOverlayWidth > 0 && (
<div
aria-hidden
className="pointer-events-none fixed right-0 top-0 z-[4] h-(--titlebar-height) w-(--titlebar-tools-right) border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background)"
/>
)}
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
<PaneShell className="min-h-0 flex-1">
<div
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
/>
{children}
</PaneShell>
{/* Fixed overlays scoped to main's stacking context (terminal). Rendered
after PaneShell so it paints over pane content, but its z stays under
the panes' z-20 resize handles, keeping every pane resizable. */}
{mainOverlays}
{/* The compact pop-out drops the statusbar it's a scratch window, not
the full shell. */}
{!isSecondaryWindow() && <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />}
</main>
{overlays}
{/* Keybind map dialog (titlebar ⌨ button / ⌘/). */}
<KeybindPanel />
{/* Mounted at the shell root (after overlays) so success/error toasts
surface above every route and overlay not just the chat view. */}
<NotificationStack />
{/* Petdex floating mascot in-window, always-on-top, reactive to agent
activity. Renders nothing unless a pet is installed + enabled. */}
<FloatingPet />
</SidebarProvider>
)
}

View file

@ -36,21 +36,27 @@ function useGatewayLogTail(): string[] {
useEffect(() => {
let cancelled = false
const load = () =>
getLogs({ file: 'gui', lines: LOG_TAIL })
.then(res => {
if (cancelled) {
return
}
// async: getLogs THROWS (not rejects) when the desktop bridge is missing
// (plain-browser mode) — a sync throw here would take down the root
// error boundary before the .catch even attaches.
const load = async () => {
try {
const res = await getLogs({ file: 'gui', lines: LOG_TAIL })
setLines(
res.lines
.map(line => line.trim())
.filter(line => line && !LOG_NOISE_RE.test(line))
.slice(-LOG_VISIBLE)
)
})
.catch(() => {})
if (cancelled) {
return
}
setLines(
res.lines
.map(line => line.trim())
.filter(line => line && !LOG_NOISE_RE.test(line))
.slice(-LOG_VISIBLE)
)
} catch {
// Bridge/gateway unavailable — keep the last tail.
}
}
void load()
const timer = window.setInterval(load, LOG_POLL_MS)

View file

@ -0,0 +1,6 @@
// The `GroupSetter` shape pages take as an extension-point prop (SkillsView,
// MessagingView, ChatPreviewRail, …). The live implementation is the
// registry-backed `registryGroupSetter` in app/contrib/panes.tsx.
type Side = 'left' | 'right'
export type GroupSetter<T> = (id: string, items: readonly T[], side?: Side) => void

View file

@ -22,9 +22,13 @@ import {
$connection,
$currentCwd,
$currentUsage,
$selectedStoredSessionId,
$sessions,
$sessionStartedAt,
$turnStartedAt,
sessionMatchesStoredId
} from '@/store/session'
import { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } from '@/store/session-states'
import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents'
import { $gatewayRestarting } from '@/store/system-actions'
import {
@ -40,6 +44,8 @@ import type { StatusResponse } from '@/types/hermes'
import { CRON_ROUTE } from '../../routes'
import type { StatusbarItem } from '../statusbar-controls'
const EMPTY_USAGE = { calls: 0, input: 0, output: 0, total: 0 } as const
function workspaceLabel(cwd: string): string {
const normalized = cwd.replace(/[\\/]+$/, '')
const leaf = normalized.split(/[\\/]/).filter(Boolean).pop()
@ -80,15 +86,15 @@ export function useStatusbarItems({
const { t } = useI18n()
const copy = t.shell.statusbar
const fileMenu = t.fileMenu
const activeSessionId = useStore($activeSessionId)
const primaryActiveSessionId = useStore($activeSessionId)
const activeGatewayProfile = useStore($activeGatewayProfile)
const terminalTakeover = useStore($terminalTakeover)
const busy = useStore($busy)
const primaryBusy = useStore($busy)
const currentCwd = useStore($currentCwd)
const currentUsage = useStore($currentUsage)
const primaryUsage = useStore($currentUsage)
const gatewayRestarting = useStore($gatewayRestarting)
const sessionStartedAt = useStore($sessionStartedAt)
const turnStartedAt = useStore($turnStartedAt)
const primarySessionStartedAt = useStore($sessionStartedAt)
const primaryTurnStartedAt = useStore($turnStartedAt)
const subagentsBySession = useStore($subagentsBySession)
const updateStatus = useStore($updateStatus)
const updateApply = useStore($updateApply)
@ -97,11 +103,42 @@ export function useStatusbarItems({
const desktopVersion = useStore($desktopVersion)
const connection = useStore($connection)
// The FOCUSED session (interacted tile, else the primary — the same
// derivation the titlebar title follows): every session-scoped readout
// below (context count, timers, busy pulse) tracks it, so clicking into a
// tile makes the statusbar describe THAT session.
const focusedStoredSessionId = useStore($focusedStoredSessionId)
const focusedRuntimeId = useStore($focusedRuntimeId)
const focusedState = useStore($focusedSessionState)
const sessions = useStore($sessions)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const primaryFocused = !focusedStoredSessionId || focusedStoredSessionId === selectedStoredSessionId
const activeSessionId = primaryFocused ? primaryActiveSessionId : (focusedRuntimeId ?? null)
const busy = primaryFocused ? primaryBusy : Boolean(focusedState?.busy)
// EMPTY_USAGE (module constant) keeps the fallback referentially stable —
// a fresh `{...}` each render would bust the usage-label memos below.
const currentUsage = primaryFocused ? primaryUsage : (focusedState?.usage ?? EMPTY_USAGE)
const turnStartedAt = primaryFocused ? primaryTurnStartedAt : (focusedState?.turnStartedAt ?? null)
// A tile's session-start comes from its stored row (the cache only knows
// runtime state); seconds → ms.
const focusedRow = focusedStoredSessionId
? sessions.find(s => sessionMatchesStoredId(s, focusedStoredSessionId))
: null
const sessionStartedAt = primaryFocused
? primarySessionStartedAt
: focusedRow?.started_at
? focusedRow.started_at * 1000
: null
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
const approvalModeItem = useApprovalModeStatusbarItem(activeGatewayProfile, requestGateway)
const gatewayMenuContent = useMemo(
() => (close: () => void) => (
<GatewayMenuPanel

View file

@ -6,14 +6,16 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Kbd, KbdCombo } from '@/components/ui/kbd'
import { useContributions } from '@/contrib/react/use-contributions'
import { useI18n } from '@/i18n'
import {
KEYBIND_ACTIONS,
allKeybindActions,
KEYBIND_CATEGORIES,
KEYBIND_PANEL_ACTION,
KEYBIND_READONLY,
type KeybindActionMeta,
type KeybindReadonly
type KeybindReadonly,
KEYBINDS_AREA
} from '@/lib/keybinds/actions'
import { formatCombo } from '@/lib/keybinds/combo'
import { arraysEqual } from '@/lib/storage'
@ -22,6 +24,7 @@ import {
$capture,
$keybindPanelOpen,
beginCapture,
bindingsFor,
closeKeybindPanel,
conflictsFor,
endCapture,
@ -36,6 +39,9 @@ export function KeybindPanel() {
const bindings = useStore($bindings)
const k = t.keybinds
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
// Subscribe so contributed actions appear/disappear live in the map.
useContributions(KEYBINDS_AREA)
const actionList = allKeybindActions()
const openCombo = bindings[KEYBIND_PANEL_ACTION]?.[0]
@ -74,7 +80,7 @@ export function KeybindPanel() {
{/* Body */}
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5">
{KEYBIND_CATEGORIES.map(category => {
const actions = KEYBIND_ACTIONS.filter(
const actions = actionList.filter(
action => action.category === category && action.id !== KEYBIND_PANEL_ACTION
)
@ -139,9 +145,12 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) {
const bindings = useStore($bindings)
const capture = useStore($capture)
const combos = bindings[action.id] ?? []
// bindingsFor resolves stored overrides for late-registered (contributed)
// actions too — $bindings only carries built-ins, so a raw lookup would show
// the default instead of the user's rebinding for a plugin/contrib action.
const combos = bindingsFor(action.id, bindings)
const capturing = capture === action.id
const label = k.actions[action.id] ?? action.id
const label = k.actions[action.id] ?? action.label ?? action.id
const isDefault = arraysEqual(combos, [...action.defaults])
const conflict = combos

View file

@ -25,6 +25,10 @@ export interface StatusbarMenuItem {
export interface StatusbarItem {
id: string
/** Escape hatch: render an arbitrary node into the bar (own state, tooltip,
* events). When set, it OWNS the slot label/variant/onSelect are ignored.
* This is how a plugin drops a full stateful React component into the bar. */
render?: () => ReactNode
label?: ReactNode
detail?: ReactNode
icon?: ReactNode
@ -92,6 +96,11 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType<typeof useNavigate> }) {
const [menuOpen, setMenuOpen] = useState(false)
// Render escape hatch: the contribution owns its own chrome/state/tooltip.
if (item.render) {
return <>{item.render()}</>
}
const content = (
<>
{item.icon}
@ -100,7 +109,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
</>
)
if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) {
if (item.variant === 'menu' && (item.menuContent || !!item.menuItems?.length)) {
// The `Tip` helper can't wrap a menu: its TooltipTrigger needs a DOM child,
// but DropdownMenu's Root renders no element, so the hover listeners never
// land on the button and the tooltip silently never shows. Compose the two

View file

@ -1,7 +1,9 @@
import { useStore } from '@nanostores/react'
import type { ComponentProps, ReactNode } from 'react'
import { type ComponentProps, type MouseEvent, type ReactNode, useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { resetLayoutTree } from '@/components/pane-shell/tree/store'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
@ -9,10 +11,8 @@ import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
import { toggleKeybindPanel } from '@/store/keybinds'
import {
$fileBrowserOpen,
$panesFlipped,
$sidebarOpen,
toggleFileBrowserOpen,
togglePanesFlipped,
@ -32,7 +32,7 @@ export interface TitlebarTool {
hidden?: boolean
href?: string
icon: ReactNode
onSelect?: () => void
onSelect?: (event?: MouseEvent) => void
title?: string
to?: string
}
@ -46,14 +46,60 @@ interface TitlebarControlsProps extends ComponentProps<'div'> {
onOpenSettings: () => void
}
/**
* The layout button's glyph. Morphs into its composite reset form the
* layout icon wearing a small counter-clockwise arrow badge ("layout, back
* to how it was") ONLY while the pointer is on the button AND /Ctrl is
* held: hover gates via CSS (`group/tool` on the button), the modifier via
* the window listener. Pressing the modifier elsewhere changes nothing.
*/
function LayoutGlyph({ modHeld }: { modHeld: boolean }) {
return (
<>
<span className={cn('inline-flex', modHeld && 'group-hover/tool:hidden')}>
<Codicon name="layout" />
</span>
<span className={cn('relative hidden', modHeld && 'group-hover/tool:inline-flex')}>
<Codicon name="layout" />
<span className="absolute -bottom-1 -right-1.5 grid place-items-center rounded-full bg-(--ui-bg-chrome) p-px">
<Codicon className="-scale-x-100" name="refresh" size="0.5625rem" />
</span>
</span>
</>
)
}
/** Live /Ctrl tracking mod-click affordances telegraph themselves (the
* layout button morphs into its reset form while the modifier is down). */
function useModifierHeld(): boolean {
const [held, setHeld] = useState(false)
useEffect(() => {
const sync = (event: KeyboardEvent) => setHeld(event.metaKey || event.ctrlKey)
const clear = () => setHeld(false)
window.addEventListener('keydown', sync)
window.addEventListener('keyup', sync)
window.addEventListener('blur', clear)
return () => {
window.removeEventListener('keydown', sync)
window.removeEventListener('keyup', sync)
window.removeEventListener('blur', clear)
}
}, [])
return held
}
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
const { t } = useI18n()
const navigate = useNavigate()
const location = useLocation()
const modHeld = useModifierHeld()
const hapticsMuted = useStore($hapticsMuted)
const fileBrowserOpen = useStore($fileBrowserOpen)
const sidebarOpen = useStore($sidebarOpen)
const panesFlipped = useStore($panesFlipped)
const toggleHaptics = () => {
if (!hapticsMuted) {
@ -67,14 +113,13 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
}
}
// Each titlebar button controls the pane physically on its side, so a flip
// swaps which pane each one toggles. Default: sessions left, file browser
// right. Flipped: file browser left, sessions right. Sidebar toggles never
// carry an active highlight — they're plain show/hide affordances.
const fileBrowserEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
const sessionsEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
const leftEdge = panesFlipped ? fileBrowserEdge : sessionsEdge
const rightEdge = panesFlipped ? sessionsEdge : fileBrowserEdge
// POSITIONAL toggles: each button shows/hides everything on its physical
// side of the main zone (the layout tree collapses the whole side), so they
// stay correct through flips and rearranges. $sidebarOpen ≙ left side,
// $fileBrowserOpen ≙ right side. Never an active highlight — plain
// show/hide affordances.
const leftEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
const rightEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
const leftToolbarTools: TitlebarTool[] = [
{
@ -111,6 +156,26 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
// Static system tools — always pinned to the screen's right edge.
const systemTools: TitlebarTool[] = [
{
className: 'group/tool',
// Hover + held ⌘/Ctrl morphs the glyph into its reset form (see
// LayoutGlyph) — the mod-click telegraphs itself before it happens.
icon: <LayoutGlyph modHeld={modHeld} />,
id: 'layout',
label: t.titlebar.layoutEditor,
onSelect: event => {
if (event?.metaKey || event?.ctrlKey) {
triggerHaptic('warning')
resetLayoutTree()
return
}
triggerHaptic('open')
toggleLayoutEditMode()
},
title: t.titlebar.layoutEditorTitle
},
{
active: hapticsMuted,
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
@ -118,15 +183,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
onSelect: toggleHaptics
},
{
icon: <Codicon name="keyboard" />,
id: 'keybinds',
label: t.titlebar.openKeybinds,
onSelect: () => {
triggerHaptic('open')
toggleKeybindPanel()
}
},
{
icon: <Codicon name="settings-gear" />,
id: 'settings',
@ -147,8 +203,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
}
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
const settingsTool = visibleSystemTools.find(tool => tool.id === 'settings')
const visibleSystemToolsBeforeSettings = visibleSystemTools.filter(tool => tool.id !== 'settings')
const visiblePaneTools = tools.filter(tool => !tool.hidden)
return (
@ -187,10 +241,9 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
aria-label={t.shell.appControls}
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
>
{visibleSystemToolsBeforeSettings.map(tool => (
{visibleSystemTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
{settingsTool && <TitlebarToolButton navigate={navigate} tool={settingsTool} />}
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
</div>
</>
@ -228,12 +281,12 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
aria-pressed={tool.active ?? undefined}
className={className}
disabled={tool.disabled}
onClick={() => {
onClick={event => {
if (tool.to) {
navigate(tool.to)
}
tool.onSelect?.()
tool.onSelect?.(event)
}}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"

View file

@ -1,39 +0,0 @@
import { useCallback, useMemo, useState } from 'react'
type Side = 'left' | 'right'
type Groups<T> = Record<Side, Record<string, readonly T[]>>
export type GroupSetter<T> = (id: string, items: readonly T[], side?: Side) => void
interface GroupRegistry<T> {
flat: { left: T[]; right: T[] }
set: GroupSetter<T>
}
export function useGroupRegistry<T>(): GroupRegistry<T> {
const [groups, setGroups] = useState<Groups<T>>({ left: {}, right: {} })
const set = useCallback<GroupSetter<T>>((id, items, side = 'right') => {
setGroups(current => {
const next = { ...current, [side]: { ...current[side] } }
if (items.length === 0) {
delete next[side][id]
} else {
next[side][id] = items
}
return next
})
}, [])
const flat = useMemo(
() => ({
left: Object.values(groups.left).flat(),
right: Object.values(groups.right).flat()
}),
[groups]
)
return { flat, set }
}

View file

@ -461,11 +461,36 @@ export function McpTab({ gateway }: { gateway: HermesGateway | null }) {
const draftSeeded = useRef(false)
useEffect(() => {
if (config && !draftSeeded.current) {
// profilePending: config still holds the PREVIOUS profile's record right
// after a switch — seeding from it would latch the wrong profile's doc.
if (!config || profilePending) {
return
}
if (!draftSeeded.current) {
draftSeeded.current = true
resetDraft(getServers(config))
return
}
}, [config])
if (dirty || names.length === 0) {
return
}
// Heal the early-boot race: the first config snapshot can land before the
// backend has mcp_servers assembled, seeding (and latching) an empty doc
// while later refetches fill the list — saving would then wipe the real
// servers. A PRISTINE empty draft reseeds when servers arrive; any user
// edit (dirty) still always wins.
try {
if (Object.keys(parseServersDoc(draft)).length === 0) {
resetDraft(servers)
}
} catch {
// Mid-edit / invalid JSON — the user's text wins.
}
}, [config, dirty, draft, names, profilePending, servers])
// Bumped on every profile switch. Async probe/auth completions capture the
// epoch at call time and bail if it changed, so a slow profile-A request can't
@ -698,15 +723,17 @@ export function McpTab({ gateway }: { gateway: HermesGateway | null }) {
return
}
const next = withEnabled(servers[serverName], enabled)
try {
if (!(await persist({ ...servers, [serverName]: withEnabled(servers[serverName], enabled) }))) {
if (!(await persist({ ...servers, [serverName]: next }))) {
return
}
if (dirty) {
patchDraft(doc => (doc[serverName] ? { ...doc, [serverName]: withEnabled(doc[serverName], enabled) } : doc))
} else {
resetDraft({ ...servers, [serverName]: withEnabled(servers[serverName], enabled) })
resetDraft({ ...servers, [serverName]: next })
}
if (enabled) {

View file

@ -1,6 +1,7 @@
import type * as React from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import type { UsageStats } from '@/types/hermes'
export interface ContextSuggestion {
text: string
@ -125,7 +126,8 @@ export type CommandDispatchResponse =
export type SidebarNavId = 'artifacts' | 'command-center' | 'messaging' | 'new-session' | 'settings' | 'skills'
export interface SidebarNavItem {
id: SidebarNavId
/** Built-in view id, or a contributed row's namespaced contribution id. */
id: SidebarNavId | (string & {})
label: string
icon: React.ComponentType<{ className?: string }>
route?: string
@ -158,4 +160,8 @@ export interface ClientSessionState {
* focused, and switching sessions doesn't zero a still-running turn's clock.
* The global $turnStartedAt mirrors whichever session is currently viewed. */
turnStartedAt: number | null
/** Cumulative token usage, updated per completed turn. Per-session twin of
* the primary-only $currentUsage the statusbar reads it for a focused
* tile's context count. Null until the first turn reports. */
usage: null | UsageStats
}

View file

@ -1,5 +1,5 @@
import { cleanup, render, screen } from '@testing-library/react'
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
import { cleanup, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'

View file

@ -13,6 +13,7 @@ import {
useState
} from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { ToolFallback } from '@/components/assistant-ui/tool/fallback'
import { Button } from '@/components/ui/button'
import { Kbd } from '@/components/ui/kbd'
@ -21,7 +22,7 @@ import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
import { clearClarifyRequest, sessionClarifyRequest } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
@ -184,7 +185,11 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {
function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const { t } = useI18n()
const copy = t.assistant.clarify
const request = useStore($clarifyRequest)
// The tool row is in whichever session's transcript rendered it — read THAT
// session's clarify (primary or tile), not the globally-active one.
const sessionId = useStore(useSessionView().$runtimeId)
const $request = useMemo(() => sessionClarifyRequest(sessionId), [sessionId])
const request = useStore($request)
const gateway = useStore($gateway)
const fromArgs = useMemo(() => readClarifyArgs(args), [args])

View file

@ -1,8 +1,9 @@
'use client'
import { useStore } from '@nanostores/react'
import { type FC, useCallback, useEffect, useState } from 'react'
import { type FC, useCallback, useEffect, useMemo, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { Button } from '@/components/ui/button'
import {
Dialog,
@ -20,11 +21,11 @@ import { cn } from '@/lib/utils'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import {
$approvalInlineVisible,
$approvalRequest,
type ApprovalRequest,
clearApprovalRequest,
registerApprovalInlineAnchor
registerApprovalInlineAnchor,
sessionApprovalInlineVisible,
sessionApprovalRequest
} from '@/store/prompts'
import type { ToolPart } from './fallback-model'
@ -48,7 +49,11 @@ export const APPROVAL_TOOLS = new Set(['terminal', 'execute_code'])
type ApprovalChoice = 'once' | 'session' | 'always' | 'deny'
export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
const request = useStore($approvalRequest)
// The tool row lives in whichever session's transcript rendered it — read
// THAT session's approval (works for the primary and every tile).
const sessionId = useStore(useSessionView().$runtimeId)
const $request = useMemo(() => sessionApprovalRequest(sessionId), [sessionId])
const request = useStore($request)
if (!request || !APPROVAL_TOOLS.has(part.toolName)) {
return null
@ -58,15 +63,18 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
}
const InlineApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
useEffect(() => registerApprovalInlineAnchor(), [])
useEffect(() => registerApprovalInlineAnchor(request.sessionId), [request.sessionId])
return <ApprovalBar request={request} surface="inline" />
}
export const PendingApprovalFallback: FC = () => {
const { t } = useI18n()
const request = useStore($approvalRequest)
const inlineVisible = useStore($approvalInlineVisible)
const sessionId = useStore(useSessionView().$runtimeId)
const $request = useMemo(() => sessionApprovalRequest(sessionId), [sessionId])
const $inlineVisible = useMemo(() => sessionApprovalInlineVisible(sessionId), [sessionId])
const request = useStore($request)
const inlineVisible = useStore($inlineVisible)
if (!request || inlineVisible) {
return null
@ -119,8 +127,9 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline'
const respond = useCallback(
async (choice: ApprovalChoice) => {
// Another bar (or the keyboard path) may have already resolved this
// approval; the atom is the single source of truth, so bail if it's gone.
if (busy || !$approvalRequest.get()) {
// approval; the map is the single source of truth, so bail if this
// session's request is gone.
if (busy || !sessionApprovalRequest(request.sessionId).get()) {
return
}

View file

@ -1,20 +1,15 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { DecodeText } from '@/components/ui/decode-text'
import { cn } from '@/lib/utils'
import { $desktopBoot } from '@/store/boot'
import { $gatewaySwitching } from '@/store/gateway-switch'
import { $gatewayState } from '@/store/session'
// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at
// the render level means no timer logic (even a stale HMR one) can ever
// scramble "CONN".
const PREFIX = 'CONN'
const TAIL = 'ECTING'
// Even-weight mono ascii so cycling glyphs don't jump width (matches the
// nousnet-web download-button decode effect).
const SCRAMBLE_CHARS = '/\\|-_=+<>~:*'
const TICK_MS = 45
// Decode mechanics live in the shared <DecodeText> primitive
// (components/ui/decode-text.tsx). "CONN" stays legible via prefix={4}.
const TEXT = 'CONNECTING'
// Exit choreography (ms): text fades down + out, hold, then the overlay fades.
const TEXT_OUT_MS = 360
@ -40,18 +35,11 @@ function forcedPreview(): boolean {
}
}
function scrambledTail(resolvedCount: number): string {
return Array.from(TAIL, (ch, i) =>
i < resolvedCount ? ch : SCRAMBLE_CHARS[(Math.random() * SCRAMBLE_CHARS.length) | 0]
).join('')
}
export function GatewayConnectingOverlay() {
const gatewayState = useStore($gatewayState)
const boot = useStore($desktopBoot)
const gatewaySwitching = useStore($gatewaySwitching)
const [previewing] = useState(forcedPreview)
const [tail, setTail] = useState(TAIL)
const [phase, setPhase] = useState<Phase>('live')
// Once cold boot has completed once, never resurrect the fullscreen overlay
// — soft gateway switches keep the shell and reskeleton the sidebar instead.
@ -67,12 +55,14 @@ export function GatewayConnectingOverlay() {
// the chat then — users should still be able to type drafts, open settings,
// and recover instead of staring at a modal CONNECTING screen.
const initialBootActive = boot.visible || boot.running || boot.progress < 100
const connecting =
!coldBootDoneRef.current &&
!gatewaySwitching &&
gatewayState !== 'open' &&
!boot.error &&
initialBootActive
// Latches once we've actually shown the overlay, so the brief frame where
// gatewayState flips to "open" (connecting -> false) before the exit phase
// kicks in doesn't unmount us and cause a flash.
@ -82,36 +72,6 @@ export function GatewayConnectingOverlay() {
shownRef.current = true
}
// Decode loop — only while live (freeze the resolved word during the exit).
useEffect(() => {
if (phase !== 'live' || (!previewing && !connecting)) {
return
}
let resolved = 0
let hold = 0
const id = window.setInterval(() => {
if (resolved >= TAIL.length) {
hold += 1
if (hold > 16) {
resolved = 0
hold = 0
}
setTail(TAIL)
return
}
resolved += 0.5
setTail(scrambledTail(Math.floor(resolved)))
}, TICK_MS)
return () => window.clearInterval(id)
}, [phase, previewing, connecting])
// Kick off the exit when connected: real connect, or a faked timer in preview.
useEffect(() => {
if (phase !== 'live') {
@ -119,16 +79,12 @@ export function GatewayConnectingOverlay() {
}
if (previewing) {
const id = window.setTimeout(() => {
setTail(TAIL)
setPhase('text-out')
}, PREVIEW_CONNECT_MS)
const id = window.setTimeout(() => setPhase('text-out'), PREVIEW_CONNECT_MS)
return () => window.clearTimeout(id)
}
if (gatewayState === 'open' && shownRef.current) {
setTail(TAIL)
setPhase('text-out')
}
}, [phase, previewing, gatewayState])
@ -149,10 +105,7 @@ export function GatewayConnectingOverlay() {
// Preview replays so we can keep watching the transition.
if (phase === 'gone' && previewing) {
const id = window.setTimeout(() => {
setTail(TAIL)
setPhase('live')
}, PREVIEW_REPLAY_MS)
const id = window.setTimeout(() => setPhase('live'), PREVIEW_REPLAY_MS)
return () => window.clearTimeout(id)
}
@ -183,21 +136,16 @@ export function GatewayConnectingOverlay() {
overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100'
)}
>
<style>{'@keyframes gco-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>
<span
<DecodeText
active={phase === 'live' && (previewing || connecting)}
className={cn(
'inline-flex items-center pl-[0.4em] font-mono text-[0.64rem] font-semibold uppercase tracking-[0.4em] tabular-nums text-(--theme-primary) transition duration-300 ease-out',
'pl-[0.4em] text-(--theme-primary) transition duration-300 ease-out',
leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'
)}
>
{PREFIX}
{tail}
<span
aria-hidden="true"
className="dither ml-0.5 inline-block size-2 shrink-0 -translate-y-px rounded-[1px]"
style={{ animation: 'gco-cursor 1s step-end infinite' }}
/>
</span>
cursor
prefix={4}
text={TEXT}
/>
</div>
)
}

View file

@ -410,10 +410,12 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
// Which key-form option to preselect when we flip to 'apikey' mode. The
// OpenRouter row selects its key; the generic link lands on the first option.
const [apiKeyInitialEnv, setApiKeyInitialEnv] = useState<string | undefined>(undefined)
const openKeyForm = (envKey?: string) => {
setApiKeyInitialEnv(envKey)
setOnboardingMode('apikey')
}
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
const hasOauth = ordered.length > 0
const apiKeyOptions = useApiKeyCatalog()

View file

@ -1,19 +0,0 @@
import { createContext } from 'react'
export interface PaneSlot {
side: 'left' | 'right'
open: boolean
/** Resolved CSS `grid-column` value (e.g. "3 / 4", or a full-side span for a bottom-row pane). */
gridColumn: string
/** Resolved CSS `grid-row` value ("1 / -1" full-height, "1 / 2" above a bottom row, "2 / 3" the row itself). */
gridRow: string
/** True when this pane lays out as a horizontal row beneath its rail instead of a vertical column. */
bottomRow: boolean
}
export interface PaneShellContextValue {
paneById: Map<string, PaneSlot>
mainColumn: number
}
export const PaneShellContext = createContext<PaneShellContextValue | null>(null)

View file

@ -0,0 +1,59 @@
/**
* Layout edit mode the shared toggle for the tree renderer's FancyZones-style
* rearrangement (see tree/renderer.tsx). The toggle hotkey is a `keybinds`
* contribution (`layout.editMode`, default \ the sibling of \ = flip
* panes), so it's rebindable and collision-checked like every other action.
* This hook only owns Escape-to-exit.
*/
import { atom } from 'nanostores'
import { useEffect } from 'react'
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers'
export const $layoutEditMode = atom(false)
export function toggleLayoutEditMode() {
$layoutEditMode.set(!$layoutEditMode.get())
}
/** Escape exits edit mode. Registered once by the layout root. */
export function useLayoutEditHotkey(enabled: boolean) {
useEffect(() => {
if (!enabled || typeof window === 'undefined') {
return
}
// Own an Escape layer only WHILE edit mode is on, so it doesn't outrank the
// narrow-pane reveal the rest of the time.
let releaseLayer: (() => void) | null = null
const unsub = $layoutEditMode.subscribe(on => {
if (on && !releaseLayer) {
releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.layoutEdit)
} else if (!on && releaseLayer) {
releaseLayer()
releaseLayer = null
}
})
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape' || e.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.layoutEdit)) {
return
}
if ($layoutEditMode.get()) {
e.preventDefault()
$layoutEditMode.set(false)
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
unsub()
releaseLayer?.()
}
}, [enabled])
}

View file

@ -0,0 +1,270 @@
/**
* Pane geometry AABB INTERSECTION WINDOW-CONTROL AWARENESS. The native
* window controls (macOS traffic lights top-left / Windows-WSLg overlay
* top-right) are a rectangle in viewport pixels. Any region whose rect
* intersects it must reserve that space and expose a drag strip. One
* `intersect()` call replaces per-layout inset special cases.
*
* Also publishes the WORKSPACE zone's edges as CSS vars (see
* publishWorkspaceGeometry) chrome that aligns to the main pane (titlebar
* title et al) consumes `var(--workspace-left/right)` in plain CSS instead of
* threading rects through React.
*/
import { type RefObject, useLayoutEffect, useState, useSyncExternalStore } from 'react'
import { $layoutTree } from '@/components/pane-shell/tree/store'
import { $connection } from '@/store/session'
// ---------------------------------------------------------------------------
// Rects
// ---------------------------------------------------------------------------
export interface Rect {
x: number
y: number
width: number
height: number
}
/** AABB intersection. Returns null when the rects don't overlap. */
export function intersect(a: Rect, b: Rect): Rect | null {
const x = Math.max(a.x, b.x)
const y = Math.max(a.y, b.y)
const right = Math.min(a.x + a.width, b.x + b.width)
const bottom = Math.min(a.y + a.height, b.y + b.height)
if (right <= x || bottom <= y) {
return null
}
return { x, y, width: right - x, height: bottom - y }
}
// ---------------------------------------------------------------------------
// Native window controls rect
// ---------------------------------------------------------------------------
/** Height of the band the native controls live in. */
const CONTROLS_BAND_HEIGHT = 34
/** Width of the macOS traffic-light cluster measured from the buttons' x. */
const MACOS_LIGHTS_WIDTH = 58
const MACOS_FALLBACK_BUTTON_X = 24
interface ConnectionLike {
windowButtonPosition?: { x: number; y: number } | null
nativeOverlayWidth?: number | null
isFullscreen?: boolean | null
}
/**
* The native window-control rectangle in viewport pixels, or null when there
* is nothing to dodge (fullscreen, plain browser, secondary windows with
* hidden controls).
*/
export function windowControlsRect(connection: ConnectionLike | null, viewportWidth: number): Rect | null {
const inElectron = typeof window !== 'undefined' && 'hermesDesktop' in window
if (!inElectron) {
return null
}
if (connection?.isFullscreen) {
return null
}
// Windows / WSLg: native overlay on the top-right.
const overlayWidth = connection?.nativeOverlayWidth ?? 0
if (overlayWidth > 0) {
return { x: viewportWidth - overlayWidth, y: 0, width: overlayWidth, height: CONTROLS_BAND_HEIGHT }
}
// macOS: traffic lights on the top-left. windowButtonPosition === null means
// the platform has no left-side controls at all (Windows/Linux w/o overlay).
const pos = connection?.windowButtonPosition
if (pos === null) {
return null
}
const isMac = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform)
if (!pos && !isMac) {
return null
}
const x = pos?.x ?? MACOS_FALLBACK_BUTTON_X
return { x: 0, y: 0, width: x + MACOS_LIGHTS_WIDTH, height: CONTROLS_BAND_HEIGHT }
}
// ---------------------------------------------------------------------------
// Live hook
// ---------------------------------------------------------------------------
let cachedRect: Rect | null = null
let cachedKey = ''
function rectKey(r: Rect | null) {
return r ? `${r.x},${r.y},${r.width},${r.height}` : ''
}
function readControlsRect(): Rect | null {
const next = windowControlsRect($connection.get(), typeof window === 'undefined' ? 0 : window.innerWidth)
const key = rectKey(next)
// Referentially stable snapshot for useSyncExternalStore.
if (key !== cachedKey) {
cachedKey = key
cachedRect = next
}
return cachedRect
}
function subscribeControlsRect(cb: () => void) {
const unsubConnection = $connection.subscribe(() => cb())
window.addEventListener('resize', cb)
return () => {
unsubConnection()
window.removeEventListener('resize', cb)
}
}
/** Reactive native window-controls rect (connection + viewport aware). */
export function useWindowControlsRect(): Rect | null {
return useSyncExternalStore(subscribeControlsRect, readControlsRect, () => null)
}
// ---------------------------------------------------------------------------
// Per-element overlap
// ---------------------------------------------------------------------------
function sameRect(a: Rect | null, b: Rect | null) {
if (a === b) {return true}
if (!a || !b) {return false}
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
}
// ---------------------------------------------------------------------------
// Workspace-edge CSS vars
// ---------------------------------------------------------------------------
/**
* Publish the workspace zone's viewport edges as root CSS vars:
* --workspace-left : px from the viewport's left to the main zone
* --workspace-right : px from the main zone's right to the viewport's right
*
* Measured once per relevant change (zone resize via ResizeObserver, layout
* mutations via $layoutTree, window resize) and consumed in plain CSS the
* measured-var idiom (--composer-measured-height), not per-component JS
* geometry. Call once from the tree root; returns the disposer.
*/
export function publishWorkspaceGeometry(): () => void {
const root = document.documentElement
let el: HTMLElement | null = null
let lastLeft = NaN
let lastRight = NaN
const ro = new ResizeObserver(() => measure())
const measure = () => {
const next = document.querySelector<HTMLElement>('[data-session-anchor="workspace"]')
if (next !== el) {
if (el) {
ro.unobserve(el)
}
el = next
if (el) {
ro.observe(el)
}
}
if (!el) {
return
}
const r = el.getBoundingClientRect()
const left = Math.round(r.left)
const right = Math.round(window.innerWidth - r.right)
// Skip unchanged writes: a sash drag fires the RO every frame, and each
// :root custom-property set dirties style for everything that reads them.
if (left !== lastLeft) {
lastLeft = left
root.style.setProperty('--workspace-left', `${left}px`)
}
if (right !== lastRight) {
lastRight = right
root.style.setProperty('--workspace-right', `${right}px`)
}
}
// Tree mutations move zones without resizing them (⌘\ flip) — re-measure a
// frame later, after the DOM committed. RO covers width changes (sash drags,
// side collapses); window resize covers the rest.
const unsubTree = $layoutTree.listen(() => requestAnimationFrame(measure))
window.addEventListener('resize', measure)
measure()
return () => {
unsubTree()
window.removeEventListener('resize', measure)
ro.disconnect()
root.style.removeProperty('--workspace-left')
root.style.removeProperty('--workspace-right')
}
}
/**
* Intersects an element's live viewport rect with the native window-controls
* rect. Returns the overlap in ELEMENT-LOCAL coordinates (null when clear), so
* the consumer can reserve the space and paint a drag strip without knowing
* anything about platform, fullscreen state, or layout position.
*/
export function useWindowControlsOverlap(ref: RefObject<HTMLElement | null>, enabled = true): Rect | null {
const controls = useWindowControlsRect()
const [overlap, setOverlap] = useState<Rect | null>(null)
useLayoutEffect(() => {
const el = ref.current
if (!enabled || !controls || !el) {
setOverlap(null)
return
}
const update = () => {
const r = el.getBoundingClientRect()
const hit = intersect(controls, { x: r.x, y: r.y, width: r.width, height: r.height })
const local = hit ? { x: hit.x - r.x, y: hit.y - r.y, width: hit.width, height: hit.height } : null
setOverlap(prev => (sameRect(prev, local) ? prev : local))
}
update()
// Size changes fire the observer; cross-window moves fire `resize`. A pane
// shifted only by a sibling's resize re-measures on its own grid reflow
// (its track width changes), so this covers the shell's real cases.
const ro = new ResizeObserver(update)
ro.observe(el)
window.addEventListener('resize', update)
return () => {
ro.disconnect()
window.removeEventListener('resize', update)
}
}, [controls, enabled, ref])
return overlap
}

View file

@ -1,4 +1,7 @@
export type { PaneShellContextValue, PaneSlot } from './context'
export { PaneShellContext } from './context'
export { Pane, PANE_TOGGLE_REVEAL_EVENT, PaneMain, PaneShell } from './pane-shell'
export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell'
/**
* Cross-surface event: toggle-reveal a collapsed pane. Dispatched by the
* keybinds (B / G / titlebar toggles on narrow viewports) with the pane id
* in `detail`; the layout tree's narrow overlays (tree/renderer.tsx) listen
* and slide the pane over the grid.
*/
export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal'

View file

@ -1,333 +0,0 @@
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $paneStates, setPaneOpen, setPaneWidthOverride } from '@/store/panes'
import { Pane, PaneMain, PaneShell } from './pane-shell'
function gridContainer(rendered: ReturnType<typeof render>): HTMLElement {
const root = rendered.container.firstElementChild
if (!(root instanceof HTMLElement)) {
throw new Error('PaneShell did not render a root element')
}
return root
}
function getColumnTemplate(container: HTMLElement): string[] {
return (container.style.gridTemplateColumns ?? '').split(/\s+/).filter(Boolean)
}
function mockWidth(element: HTMLElement, width: number) {
Object.defineProperty(element, 'getBoundingClientRect', {
configurable: true,
value: () => ({
bottom: 0,
height: 0,
left: 0,
right: width,
top: 0,
width,
x: 0,
y: 0,
toJSON: () => ({})
})
})
}
describe('PaneShell composition', () => {
beforeEach(() => {
$paneStates.set({})
window.localStorage.clear()
})
afterEach(() => {
cleanup()
$paneStates.set({})
window.localStorage.clear()
})
it('builds a 2-column grid for one left pane + main', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const tracks = getColumnTemplate(gridContainer(rendered))
expect(tracks).toEqual(['240px', 'minmax(0,1fr)'])
})
it('orders panes left-to-right by side, preserving source order within a side', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<Pane id="sessions" side="left" width="200px">
sessions
</Pane>
<PaneMain>main</PaneMain>
<Pane id="preview" side="right" width="320px">
preview
</Pane>
<Pane id="inspector" side="right" width="280px">
inspector
</Pane>
</PaneShell>
)
const tracks = getColumnTemplate(gridContainer(rendered))
expect(tracks).toEqual(['240px', '200px', 'minmax(0,1fr)', '320px', '280px'])
})
it('collapses a closed pane to 0px', () => {
const rendered = render(
<PaneShell>
<Pane defaultOpen={false} id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const tracks = getColumnTemplate(gridContainer(rendered))
expect(tracks).toEqual(['0px', 'minmax(0,1fr)'])
})
it('reads open state from the panes store', () => {
setPaneOpen('files', false)
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)'])
})
it('disabled forces the track to 0px even when the store says open', () => {
setPaneOpen('files', true)
const rendered = render(
<PaneShell>
<Pane disabled={true} id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)'])
})
it('disabled does NOT mutate the store-persisted open state', () => {
setPaneOpen('files', true)
render(
<PaneShell>
<Pane disabled={true} id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect($paneStates.get().files?.open).toBe(true)
})
it('uses widthOverride from the store when set', () => {
setPaneOpen('files', true)
setPaneWidthOverride('files', 320)
const rendered = render(
<PaneShell>
<Pane id="files" resizable side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['320px', 'minmax(0,1fr)'])
})
it('preserves CSS-string widths verbatim (clamp, var, etc.)', () => {
const rendered = render(
<PaneShell>
<Pane id="inspector" side="right" width="clamp(13.5rem,21vw,20rem)">
inspector
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const template = gridContainer(rendered).style.gridTemplateColumns
expect(template).toContain('clamp(13.5rem,21vw,20rem)')
})
it('coerces numeric widths to px', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width={224}>
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['224px', 'minmax(0,1fr)'])
})
it('emits per-pane width as a CSS variable', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const root = gridContainer(rendered)
expect(root.style.getPropertyValue('--pane-files-width').trim()).toBe('240px')
})
it('places a Pane in the correct grid column via inline style', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
<span data-testid="files-content">files</span>
</Pane>
<PaneMain>
<span data-testid="main-content">main</span>
</PaneMain>
<Pane id="preview" side="right" width="320px">
<span data-testid="preview-content">preview</span>
</Pane>
</PaneShell>
)
const filesCell = rendered.getByTestId('files-content').parentElement!
const mainCell = rendered.getByTestId('main-content').parentElement!
const previewCell = rendered.getByTestId('preview-content').parentElement!
expect(filesCell.style.gridColumn).toBe('1 / 2')
expect(mainCell.style.gridColumn).toBe('2 / 3')
expect(previewCell.style.gridColumn).toBe('3 / 4')
})
it('marks closed panes aria-hidden', () => {
const rendered = render(
<PaneShell>
<Pane defaultOpen={false} id="files" side="left" width="240px">
<span data-testid="files-content">files</span>
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const cell = rendered.getByTestId('files-content').parentElement!
expect(cell.getAttribute('aria-hidden')).toBe('true')
expect(cell.getAttribute('data-pane-open')).toBe('false')
})
it('passes through arbitrary non-Pane children for self-placement', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
<div data-testid="floating-overlay" style={{ position: 'absolute' }}>
overlay
</div>
</PaneShell>
)
expect(rendered.getByTestId('floating-overlay')).toBeDefined()
})
it('shows a resize handle only when resizable', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<Pane id="preview" resizable side="right" width="320px">
preview
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect(rendered.queryByLabelText('Resize files')).toBeNull()
expect(rendered.getByLabelText('Resize preview')).toBeDefined()
})
it('dragging a left-pane separator stores a wider width override', () => {
const rendered = render(
<PaneShell>
<Pane id="files" maxWidth={360} minWidth={200} resizable side="left" width="240px">
<span data-testid="files-content">files</span>
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const paneCell = rendered.getByTestId('files-content').parentElement
if (!(paneCell instanceof HTMLElement)) {
throw new Error('Expected pane cell element')
}
mockWidth(paneCell, 240)
const separator = rendered.getByLabelText('Resize files')
fireEvent.pointerDown(separator, { clientX: 240, pointerId: 1 })
fireEvent.pointerMove(window, { clientX: 300 })
fireEvent.pointerUp(window, { clientX: 300 })
expect($paneStates.get().files?.widthOverride).toBe(300)
})
it('dragging a right-pane separator clamps to max width', () => {
const rendered = render(
<PaneShell>
<PaneMain>main</PaneMain>
<Pane id="preview" maxWidth={340} minWidth={220} resizable side="right" width="320px">
<span data-testid="preview-content">preview</span>
</Pane>
</PaneShell>
)
const paneCell = rendered.getByTestId('preview-content').parentElement
if (!(paneCell instanceof HTMLElement)) {
throw new Error('Expected pane cell element')
}
mockWidth(paneCell, 320)
const separator = rendered.getByLabelText('Resize preview')
fireEvent.pointerDown(separator, { clientX: 900, pointerId: 1 })
fireEvent.pointerMove(window, { clientX: 760 })
fireEvent.pointerUp(window, { clientX: 760 })
expect($paneStates.get().preview?.widthOverride).toBe(340)
})
})

View file

@ -1,598 +0,0 @@
import { useStore } from '@nanostores/react'
import {
Children,
type CSSProperties,
isValidElement,
type ReactElement,
type ReactNode,
type PointerEvent as ReactPointerEvent,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { cn } from '@/lib/utils'
import { $paneStates, ensurePaneRegistered, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes'
import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context'
type PaneSide = 'left' | 'right'
type WidthValue = string | number
interface PaneRoleMarker {
__paneShellRole?: 'pane' | 'main'
}
export interface PaneProps {
children?: ReactNode
className?: string
defaultOpen?: boolean
/** Paints a persistent hairline on the resize edge (not just the hover sash) so the pane boundary is always visible. */
divider?: boolean
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
disabled?: boolean
/** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */
forceCollapsed?: boolean
/** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */
hoverReveal?: boolean
/**
* Lay the pane out as a horizontal row beneath its rail (spanning every column on
* its `side`) instead of as a vertical column. The pane then resizes on the Y axis.
* Used to drop the terminal under a crowded rail rather than squeezing another column in.
*/
bottomRow?: boolean
/** Default height of a `bottomRow` pane. */
height?: WidthValue
/** Min/max height clamps for a `bottomRow` pane's vertical resize. */
maxHeight?: WidthValue
minHeight?: WidthValue
/** Width of the collapsed-overlay panel. Defaults to the docked width (or its resize override); set this to render a narrower overlay than the docked pane (e.g. min width on mobile). */
overlayWidth?: WidthValue
/** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */
onOverlayActiveChange?: (overlayActive: boolean) => void
id: string
maxWidth?: WidthValue
minWidth?: WidthValue
resizable?: boolean
side: PaneSide
width?: WidthValue
}
export interface PaneMainProps {
children?: ReactNode
className?: string
}
export interface PaneShellProps {
children?: ReactNode
className?: string
style?: CSSProperties
}
interface CollectedPane {
bottomRow: boolean
defaultOpen: boolean
disabled: boolean
forceCollapsed: boolean
height: string
id: string
resizable: boolean
side: PaneSide
width: string
}
const DEFAULT_WIDTH = '16rem'
const DEFAULT_HEIGHT = '18rem'
const DEFAULT_RESIZE_MIN_WIDTH = 160
const DEFAULT_RESIZE_MIN_HEIGHT = 120
// Resize-sash geometry per axis: `x` is a vertical bar on the inner edge of a
// column; `y` is a horizontal bar on the top edge of a bottom row.
const SASH = {
x: {
orientation: 'vertical',
bar: 'bottom-0 top-0 w-1 cursor-col-resize',
line: 'inset-y-0 left-1/2 w-px -translate-x-1/2',
hover: 'inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2'
},
y: {
orientation: 'horizontal',
bar: 'inset-x-0 top-0 h-1 -translate-y-1/2 cursor-row-resize',
line: 'inset-x-0 top-1/2 h-px -translate-y-1/2',
hover: 'inset-x-0 top-1/2 h-(--vscode-sash-hover-size,0.25rem) -translate-y-1/2'
}
} as const
// Hover-reveal slide. The enter delay is a pure-CSS hover-intent gate: a fast
// pass-by doesn't dwell on the trigger long enough for the delay to elapse.
const HOVER_REVEAL_SLIDE_MS = 220
const HOVER_REVEAL_ENTER_DELAY_MS = 130
const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)'
// Offset shadow lifting the revealed panel off the content (same both sides;
// the mirror axis is offset-x, which is 0). Same color on light + dark.
const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012'
// Edge trigger strip, inset past the OS window-resize grab area AND the
// adjacent pane's scrollbar (0.5rem, .scrollbar-dt) — the strip overlays the
// neighboring scroller's edge, so any overlap makes the scrollbar reveal the
// pane on hover and swallow its clicks (#44140).
const HOVER_REVEAL_TRIGGER_WIDTH = 14
const HOVER_REVEAL_EDGE_GUTTER = 'calc(0.5rem + 2px)'
// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal
// from the keyboard, since its store-open toggle is a no-op while collapsed.
export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal'
const widthToCss = (value: WidthValue | undefined, fallback: string) =>
value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value
const remPx = () =>
typeof window === 'undefined'
? 16
: Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
const viewportPx = () => (typeof window === 'undefined' ? 1280 : window.innerWidth)
const viewportHeightPx = () => (typeof window === 'undefined' ? 800 : window.innerHeight)
// Resolves PaneProps min/max (number | "Npx" | "Nrem" | "Nvw" | "Nvh" | "N%") to
// pixels for drag clamping. vw/% resolve against window width, vh against height.
function widthToPx(value: WidthValue | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined
}
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem|vw|vh|%)?$/)
if (!match) {
return undefined
}
const n = Number.parseFloat(match[1])
switch (match[2]) {
case 'rem':
return n * remPx()
case 'vh':
return (n * viewportHeightPx()) / 100
case 'vw':
case '%':
return (n * viewportPx()) / 100
default:
return n
}
}
function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement {
return isValidElement(child) && (child.type as PaneRoleMarker)?.__paneShellRole === role
}
function collectPanes(children: ReactNode) {
const left: CollectedPane[] = []
const right: CollectedPane[] = []
let mainCount = 0
Children.forEach(children, child => {
if (isRole(child, 'main')) {
mainCount++
return
}
if (!isRole(child, 'pane')) {
return
}
const props = child.props as PaneProps
const entry: CollectedPane = {
bottomRow: props.bottomRow ?? false,
defaultOpen: props.defaultOpen ?? true,
disabled: props.disabled ?? false,
forceCollapsed: props.forceCollapsed ?? false,
height: widthToCss(props.height, DEFAULT_HEIGHT),
id: props.id,
resizable: props.resizable ?? false,
side: props.side,
width: widthToCss(props.width, DEFAULT_WIDTH)
}
;(props.side === 'left' ? left : right).push(entry)
})
return { left, mainCount, right }
}
type PaneStoreState = Record<string, { open: boolean; widthOverride?: number; heightOverride?: number }>
function paneIsOpen(pane: CollectedPane, states: PaneStoreState) {
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
return !pane.disabled && !pane.forceCollapsed && stateOpen
}
function trackForPane(pane: CollectedPane, states: PaneStoreState) {
const open = paneIsOpen(pane, states)
if (!open) {
return { open: false, track: '0px' }
}
const override = pane.resizable ? states[pane.id]?.widthOverride : undefined
return { open: true, track: override !== undefined ? `${override}px` : pane.width }
}
function heightTrackForPane(pane: CollectedPane, states: PaneStoreState) {
const override = pane.resizable ? states[pane.id]?.heightOverride : undefined
return override !== undefined ? `${override}px` : pane.height
}
export function PaneShell({ children, className, style }: PaneShellProps) {
const paneStates = useStore($paneStates)
const { left, mainCount, right } = useMemo(() => collectPanes(children), [children])
if (import.meta.env.DEV && mainCount > 1) {
console.warn('[PaneShell] expected at most one <PaneMain>, got', mainCount)
}
const ctxValue = useMemo(() => {
const paneById = new Map<string, PaneSlot>()
const tracks: string[] = []
const cssVars: Record<string, string> = {}
let column = 1
// A bottom-row pane drops out of its rail's column flow and instead spans
// every column on its side as a new row below them. The first open one wins
// and decides which rail gets split into two rows.
const leftCols = left.filter(pane => !pane.bottomRow)
const rightCols = right.filter(pane => !pane.bottomRow)
const bottomRowPanes = [...left, ...right].filter(pane => pane.bottomRow)
const activeBottomRow = bottomRowPanes.find(pane => paneIsOpen(pane, paneStates)) ?? null
const bottomRailSide = activeBottomRow?.side ?? null
// Open column panes on the bottom row's side shrink to the top row; everything
// else (main, the other rail, closed / hover-reveal panes) stays full height.
const addColumn = (pane: CollectedPane, paneSide: PaneSide) => {
const { open, track } = trackForPane(pane, paneStates)
tracks.push(track)
cssVars[`--pane-${pane.id}-width`] = track
const gridRow = open && paneSide === bottomRailSide ? '1 / 2' : '1 / -1'
paneById.set(pane.id, {
open,
side: paneSide,
gridColumn: `${column} / ${column + 1}`,
gridRow,
bottomRow: false
})
column++
}
for (const pane of leftCols) {
addColumn(pane, 'left')
}
tracks.push('minmax(0,1fr)')
const mainColumn = column++
for (const pane of rightCols) {
addColumn(pane, 'right')
}
// Place every bottom-row pane: span its rail's columns on the second row.
for (const pane of bottomRowPanes) {
const gridColumn = pane.side === 'left' ? `1 / ${mainColumn}` : `${mainColumn + 1} / -1`
paneById.set(pane.id, {
open: pane === activeBottomRow,
side: pane.side,
gridColumn,
gridRow: '2 / 3',
bottomRow: true
})
}
// Always emit explicit rows so `grid-row: 1 / -1` (full-height) resolves
// against a known last line. With a bottom row active there are two tracks;
// otherwise a single 1fr track behaves exactly like the old single-row grid.
const gridTemplateRows = activeBottomRow
? `minmax(0,1fr) ${heightTrackForPane(activeBottomRow, paneStates)}`
: 'minmax(0,1fr)'
return {
cssVars,
gridTemplate: tracks.join(' '),
gridTemplateRows,
mainColumn,
paneById
} satisfies PaneShellContextValue & {
cssVars: Record<string, string>
gridTemplate: string
gridTemplateRows: string
}
}, [left, paneStates, right])
const composedStyle = useMemo<CSSProperties>(
() => ({
...ctxValue.cssVars,
...style,
gridTemplateColumns: ctxValue.gridTemplate,
gridTemplateRows: ctxValue.gridTemplateRows
}),
[ctxValue.cssVars, ctxValue.gridTemplate, ctxValue.gridTemplateRows, style]
)
return (
<PaneShellContext.Provider value={{ mainColumn: ctxValue.mainColumn, paneById: ctxValue.paneById }}>
<div className={cn('relative grid h-full min-h-0', className)} data-pane-shell="" style={composedStyle}>
{children}
</div>
</PaneShellContext.Provider>
)
}
export function Pane({
children,
className,
defaultOpen = true,
divider = false,
disabled = false,
hoverReveal = false,
maxHeight,
minHeight,
overlayWidth: overlayWidthProp,
id,
maxWidth,
minWidth,
onOverlayActiveChange,
resizable = false,
width
}: PaneProps) {
const ctx = useContext(PaneShellContext)
const paneStates = useStore($paneStates)
const registered = useRef(false)
const paneRef = useRef<HTMLDivElement | null>(null)
// Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS.
const [forced, setForced] = useState(false)
const slot = ctx?.paneById.get(id)
const open = Boolean(slot?.open && !disabled)
const side = slot?.side ?? 'left'
// Collapsed + hoverReveal: float the pane contents over the main column on
// hover/focus instead of hiding them. Honors any persisted resize width.
const overlayActive = !open && hoverReveal && !disabled
const override = resizable ? paneStates[id]?.widthOverride : undefined
// Overlay width: an explicit `overlayWidth` (e.g. min width on mobile) wins,
// else the persisted resize override, else the docked width.
const overlayWidth =
overlayWidthProp !== undefined
? widthToCss(overlayWidthProp, DEFAULT_WIDTH)
: override !== undefined
? `${override}px`
: widthToCss(width, DEFAULT_WIDTH)
useEffect(() => {
if (registered.current) {
return
}
registered.current = true
ensurePaneRegistered(id, { open: defaultOpen })
}, [defaultOpen, id])
// Keyboard toggle pins/unpins the reveal while collapsed; clear when no longer
// a collapsed overlay (reopened / widened).
useEffect(() => {
if (typeof window === 'undefined' || !overlayActive) {
setForced(false)
return
}
const onToggle = (e: Event) => {
if ((e as CustomEvent<{ id: string }>).detail?.id === id) {
setForced(v => !v)
}
}
window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
return () => window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
}, [id, overlayActive])
// Keep contents mounted while collapsed so reveal is a pure CSS transform.
useEffect(() => {
onOverlayActiveChange?.(overlayActive)
}, [onOverlayActiveChange, overlayActive])
const isBottomRow = Boolean(slot?.bottomRow)
const axis = isBottomRow ? 'y' : 'x'
const sash = SASH[axis]
const canResize = open && resizable
const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH
const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY
const loH = widthToPx(minHeight) ?? DEFAULT_RESIZE_MIN_HEIGHT
const hiH = widthToPx(maxHeight) ?? Number.POSITIVE_INFINITY
// One pointer-drag for both axes. Columns grow toward the main column (left
// rail → right, right rail → left); the bottom row grows up from its top edge.
const startResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>, axis: 'x' | 'y') => {
const rect = paneRef.current?.getBoundingClientRect()
const base = (axis === 'x' ? rect?.width : rect?.height) ?? 0
if (!canResize || base <= 0) {
return
}
event.preventDefault()
const handle = event.currentTarget
const { pointerId } = event
const start = axis === 'x' ? event.clientX : event.clientY
const dir = axis === 'x' ? (side === 'left' ? 1 : -1) : -1
const [min, max] = axis === 'x' ? [lo, hi] : [loH, hiH]
const apply = axis === 'x' ? setPaneWidthOverride : setPaneHeightOverride
const restoreCursor = document.body.style.cursor
const restoreSelect = document.body.style.userSelect
handle.setPointerCapture?.(pointerId)
document.body.style.cursor = axis === 'x' ? 'col-resize' : 'row-resize'
document.body.style.userSelect = 'none'
const onMove = (e: PointerEvent) => {
const next = base + ((axis === 'x' ? e.clientX : e.clientY) - start) * dir
apply(id, Math.round(Math.min(max, Math.max(min, next))))
}
const cleanup = () => {
document.body.style.cursor = restoreCursor
document.body.style.userSelect = restoreSelect
handle.releasePointerCapture?.(pointerId)
window.removeEventListener('pointermove', onMove, true)
window.removeEventListener('pointerup', cleanup, true)
window.removeEventListener('pointercancel', cleanup, true)
window.removeEventListener('blur', cleanup)
}
window.addEventListener('pointermove', onMove, true)
window.addEventListener('pointerup', cleanup, true)
window.addEventListener('pointercancel', cleanup, true)
window.addEventListener('blur', cleanup)
},
[canResize, hi, hiH, id, lo, loH, side]
)
if (!ctx) {
if (import.meta.env.DEV) {
console.warn(`[Pane:${id}] must be rendered inside <PaneShell>`)
}
return null
}
if (!slot) {
return null
}
// Collapsed hover-reveal track: a 0px, pointer-transparent grid cell holding a
// thin edge trigger + the floating panel (both absolute, escaping the zero
// box). group-hover (or data-forced from the keyboard) drives the slide; the
// enter-delay is the hover-intent gate. No JS pointer math.
if (overlayActive) {
const edge = side === 'left' ? 'left' : 'right'
const offscreen = side === 'left' ? '-translate-x-[calc(100%+1rem)]' : 'translate-x-[calc(100%+1rem)]'
return (
<div
className={cn('group/reveal pointer-events-none relative min-w-0', className)}
data-forced={forced ? '' : undefined}
data-pane-hover-reveal={forced ? 'open' : 'closed'}
data-pane-id={id}
data-pane-open="false"
data-pane-side={side}
ref={paneRef}
style={{ gridColumn: slot.gridColumn, gridRow: slot.gridRow }}
>
<div
aria-hidden="true"
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
data-pane-reveal-trigger=""
style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }}
/>
{/* Keyed on side so flipping panes remounts off-screen on the new edge
instead of transitioning the transform across the viewport. */}
<div
className={cn(
'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0',
offscreen,
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]'
)}
key={edge}
style={
{
[edge]: 0,
width: overlayWidth,
'--reveal-shadow': HOVER_REVEAL_SHADOW,
transitionDuration: `${HOVER_REVEAL_SLIDE_MS}ms`,
transitionTimingFunction: HOVER_REVEAL_EASE,
'--reveal-enter-delay': `${HOVER_REVEAL_ENTER_DELAY_MS}ms`
} as CSSProperties
}
>
<div className="flex h-full w-full flex-col">{children}</div>
</div>
</div>
)
}
return (
<div
aria-hidden={!open}
className={cn('relative min-h-0 min-w-0 overflow-hidden', !open && 'pointer-events-none', className)}
data-pane-id={id}
data-pane-open={open ? 'true' : 'false'}
data-pane-side={slot.side}
ref={paneRef}
style={{ gridColumn: slot.gridColumn, gridRow: slot.gridRow }}
>
{canResize && (
<div
aria-label={`Resize ${id}`}
aria-orientation={sash.orientation}
className={cn(
'group absolute z-20 [-webkit-app-region:no-drag]',
sash.bar,
!isBottomRow && (slot.side === 'left' ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2')
)}
onPointerDown={e => startResize(e, axis)}
role="separator"
tabIndex={0}
>
{divider && <span className={cn('absolute bg-(--ui-stroke-secondary)', sash.line)} />}
<span
className={cn(
'absolute bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100',
sash.hover
)}
/>
</div>
)}
{children}
</div>
)
}
;(Pane as unknown as PaneRoleMarker).__paneShellRole = 'pane'
export function PaneMain({ children, className }: PaneMainProps) {
const ctx = useContext(PaneShellContext)
if (!ctx) {
if (import.meta.env.DEV) {
console.warn('[PaneMain] must be rendered inside <PaneShell>')
}
return null
}
return (
<div
className={cn('flex min-h-0 min-w-0 flex-col overflow-hidden', className)}
data-pane-main="true"
style={{ gridColumn: `${ctx.mainColumn} / ${ctx.mainColumn + 1}`, gridRow: '1 / -1' }}
>
{children}
</div>
)
}
;(PaneMain as unknown as PaneRoleMarker).__paneShellRole = 'main'

View file

@ -0,0 +1,621 @@
/**
* FancyZones grid model a faithful port of PowerToys'
* `FancyZonesEditor/Models/GridLayoutModel.cs` + `FancyZonesEditor/GridData.cs`
* (microsoft/PowerToys, MIT). Function/field names, algorithms, and invariants
* follow the C# sources so behavior matches the original editor:
*
* - A layout is rows x columns with percent tracks summing to MULTIPLIER
* (10000) and a cellChildMap assigning each cell to a zone; a zone spanning
* multiple cells appears as the same index in adjacent cells.
* - Zones are rectangles in the 0..10000 coordinate space (prefix sums).
* - Resizers are the shared edges between zones, derived from cellChildMap
* discontinuities; dragging one moves every zone touching that edge.
* - Merging computes the rectangular CLOSURE of the selection (extending it
* until no zone is partially cut) the signature FancyZones merge feel.
*/
// The sum of row/column percents should be equal to this number.
export const MULTIPLIER = 10000
/** Minimum zone extent in model units (editor ergonomics; C# uses 1). */
export const MIN_ZONE_SIZE = 500
export interface GridLayout {
rows: number
columns: number
rowPercents: number[]
columnPercents: number[]
/** cellChildMap[row][col] = zone index; spans = same index in adjacent cells. */
cellChildMap: number[][]
}
export interface GridZone {
index: number
left: number
top: number
right: number
bottom: number
}
export interface GridResizer {
orientation: 'horizontal' | 'vertical'
/** All zones to the left/up, in order. */
negativeSideIndices: number[]
/** All zones to the right/down, in order. */
positiveSideIndices: number[]
}
// ---------------------------------------------------------------------------
// GridData helpers (verbatim)
// ---------------------------------------------------------------------------
/** result[k] is the sum of the first k elements of the given list. */
export function prefixSum(list: number[]): number[] {
const result: number[] = [0]
let sum = 0
for (const value of list) {
sum += value
result.push(sum)
}
return result
}
/** Opposite of prefixSum: differences of consecutive elements. */
function adjacentDifference(list: number[]): number[] {
if (list.length <= 1) {
return []
}
const result: number[] = []
for (let i = 0; i < list.length - 1; i++) {
result.push(list[i + 1] - list[i])
}
return result
}
/** Contiguous-segment unique (order-preserving), as in GridData.Unique. */
function unique(list: number[]): number[] {
const result: number[] = []
if (list.length === 0) {
return result
}
let last = list[0]
result.push(last)
for (let i = 1; i < list.length; i++) {
if (list[i] !== last) {
last = list[i]
result.push(last)
}
}
return result
}
// ---------------------------------------------------------------------------
// Model -> zones / resizers (GridData.ModelToZones / ModelToResizers)
// ---------------------------------------------------------------------------
export function modelToZones(model: GridLayout): GridZone[] | null {
const { rows, columns: cols, cellChildMap } = model
let zoneCount = 0
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
zoneCount = Math.max(zoneCount, cellChildMap[row][col])
}
}
zoneCount++
if (zoneCount > rows * cols) {
return null
}
const indexCount = new Array<number>(zoneCount).fill(0)
const indexRowLow = new Array<number>(zoneCount).fill(Number.MAX_SAFE_INTEGER)
const indexRowHigh = new Array<number>(zoneCount).fill(0)
const indexColLow = new Array<number>(zoneCount).fill(Number.MAX_SAFE_INTEGER)
const indexColHigh = new Array<number>(zoneCount).fill(0)
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const index = cellChildMap[row][col]
indexCount[index]++
indexRowLow[index] = Math.min(indexRowLow[index], row)
indexColLow[index] = Math.min(indexColLow[index], col)
indexRowHigh[index] = Math.max(indexRowHigh[index], row)
indexColHigh[index] = Math.max(indexColHigh[index], col)
}
}
for (let index = 0; index < zoneCount; index++) {
if (indexCount[index] === 0) {
return null
}
// Each zone must occupy a full rectangle of cells.
if (indexCount[index] !== (indexRowHigh[index] - indexRowLow[index] + 1) * (indexColHigh[index] - indexColLow[index] + 1)) {
return null
}
}
if (
model.rowPercents.length !== rows ||
model.columnPercents.length !== cols ||
model.rowPercents.some(x => x < 1) ||
model.columnPercents.some(x => x < 1)
) {
return null
}
const rowPrefixSum = prefixSum(model.rowPercents)
const colPrefixSum = prefixSum(model.columnPercents)
if (rowPrefixSum[rows] !== MULTIPLIER || colPrefixSum[cols] !== MULTIPLIER) {
return null
}
const zones: GridZone[] = []
for (let index = 0; index < zoneCount; index++) {
zones.push({
index,
left: colPrefixSum[indexColLow[index]],
right: colPrefixSum[indexColHigh[index] + 1],
top: rowPrefixSum[indexRowLow[index]],
bottom: rowPrefixSum[indexRowHigh[index] + 1]
})
}
return zones
}
export function modelToResizers(model: GridLayout): GridResizer[] {
const grid = model.cellChildMap
const { rows, columns: cols } = model
const resizers: GridResizer[] = []
// Horizontal
for (let row = 1; row < rows; row++) {
for (let startCol = 0; startCol < cols; ) {
if (grid[row - 1][startCol] !== grid[row][startCol]) {
let endCol = startCol
while (endCol + 1 < cols && grid[row - 1][endCol + 1] !== grid[row][endCol + 1]) {
endCol++
}
const positive: number[] = []
const negative: number[] = []
for (let col = startCol; col <= endCol; col++) {
negative.push(grid[row - 1][col])
positive.push(grid[row][col])
}
resizers.push({
orientation: 'horizontal',
positiveSideIndices: unique(positive),
negativeSideIndices: unique(negative)
})
startCol = endCol + 1
} else {
startCol++
}
}
}
// Vertical
for (let col = 1; col < cols; col++) {
for (let startRow = 0; startRow < rows; ) {
if (grid[startRow][col - 1] !== grid[startRow][col]) {
let endRow = startRow
while (endRow + 1 < rows && grid[endRow + 1][col - 1] !== grid[endRow + 1][col]) {
endRow++
}
const positive: number[] = []
const negative: number[] = []
for (let row = startRow; row <= endRow; row++) {
negative.push(grid[row][col - 1])
positive.push(grid[row][col])
}
resizers.push({
orientation: 'vertical',
positiveSideIndices: unique(positive),
negativeSideIndices: unique(negative)
})
startRow = endRow + 1
} else {
startRow++
}
}
}
return resizers
}
// ---------------------------------------------------------------------------
// Zones -> model (GridData.ZonesToModel)
// ---------------------------------------------------------------------------
export function zonesToModel(zones: GridZone[]): GridLayout {
const xCoords = [...new Set(zones.flatMap(z => [z.left, z.right]))].sort((a, b) => a - b)
const yCoords = [...new Set(zones.flatMap(z => [z.top, z.bottom]))].sort((a, b) => a - b)
const model: GridLayout = {
rows: yCoords.length - 1,
columns: xCoords.length - 1,
rowPercents: adjacentDifference(yCoords),
columnPercents: adjacentDifference(xCoords),
cellChildMap: Array.from({ length: yCoords.length - 1 }, () => new Array<number>(xCoords.length - 1).fill(0))
}
for (let index = 0; index < zones.length; index++) {
const zone = zones[index]
const startRow = yCoords.indexOf(zone.top)
const endRow = yCoords.indexOf(zone.bottom)
const startCol = xCoords.indexOf(zone.left)
const endCol = xCoords.indexOf(zone.right)
for (let row = startRow; row < endRow; row++) {
for (let col = startCol; col < endCol; col++) {
model.cellChildMap[row][col] = index
}
}
}
return model
}
// ---------------------------------------------------------------------------
// Closure + merge (GridData.ComputeClosure / DoMerge)
// ---------------------------------------------------------------------------
function computeClosure(zones: GridZone[], indices: number[]): { indices: number[]; zone: GridZone } {
let left = Number.MAX_SAFE_INTEGER
let right = Number.MIN_SAFE_INTEGER
let top = Number.MAX_SAFE_INTEGER
let bottom = Number.MIN_SAFE_INTEGER
if (indices.length === 0) {
return { indices: [], zone: { index: -1, left, right, top, bottom } }
}
const extend = (zone: GridZone) => {
left = Math.min(left, zone.left)
right = Math.max(right, zone.right)
top = Math.min(top, zone.top)
bottom = Math.max(bottom, zone.bottom)
}
for (const index of indices) {
extend(zones[index])
}
let possiblyBroken = true
while (possiblyBroken) {
possiblyBroken = false
for (const zone of zones) {
const area = (zone.bottom - zone.top) * (zone.right - zone.left)
const cutLeft = Math.max(left, zone.left)
const cutRight = Math.min(right, zone.right)
const cutTop = Math.max(top, zone.top)
const cutBottom = Math.min(bottom, zone.bottom)
const newArea = Math.max(0, cutBottom - cutTop) * Math.max(0, cutRight - cutLeft)
if (newArea !== 0 && newArea !== area) {
// Bad intersection found, extend.
extend(zone)
possiblyBroken = true
}
}
}
const resultIndices = zones
.filter(zone => left <= zone.left && zone.right <= right && top <= zone.top && zone.bottom <= bottom)
.map(zone => zone.index)
return { indices: resultIndices, zone: { index: -1, left, right, top, bottom } }
}
export function mergeClosureIndices(model: GridLayout, indices: number[]): number[] {
const zones = modelToZones(model)
return zones ? computeClosure(zones, indices).indices : []
}
export function doMerge(model: GridLayout, indices: number[]): GridLayout {
if (indices.length === 0) {
return model
}
const zones = modelToZones(model)
if (!zones) {
return model
}
const lowestIndex = Math.min(...indices)
const closure = computeClosure(zones, indices)
const closureIndices = new Set(closure.indices)
const remaining = zones.filter(zone => !closureIndices.has(zone.index))
remaining.splice(lowestIndex, 0, closure.zone)
return zonesToModel(remaining)
}
// ---------------------------------------------------------------------------
// Split (GridData.CanSplit / Split)
// ---------------------------------------------------------------------------
export function canSplit(
model: GridLayout,
zoneIndex: number,
position: number,
orientation: 'horizontal' | 'vertical'
): boolean {
const zones = modelToZones(model)
if (!zones || !zones[zoneIndex]) {
return false
}
const zone = zones[zoneIndex]
if (orientation === 'horizontal') {
return zone.top + MIN_ZONE_SIZE <= position && position <= zone.bottom - MIN_ZONE_SIZE
}
return zone.left + MIN_ZONE_SIZE <= position && position <= zone.right - MIN_ZONE_SIZE
}
export function splitZone(
model: GridLayout,
zoneIndex: number,
position: number,
orientation: 'horizontal' | 'vertical'
): GridLayout {
if (!canSplit(model, zoneIndex, position, orientation)) {
return model
}
const zones = modelToZones(model)!
const zone = zones[zoneIndex]
const zone1 = { ...zone }
const zone2 = { ...zone }
zones.splice(zoneIndex, 1)
if (orientation === 'horizontal') {
zone1.bottom = position
zone2.top = position
} else {
zone1.right = position
zone2.left = position
}
zones.splice(zoneIndex, 0, zone1)
zones.splice(zoneIndex + 1, 0, zone2)
return zonesToModel(zones)
}
// ---------------------------------------------------------------------------
// Resizer drag (GridData.CanDrag / Drag)
// ---------------------------------------------------------------------------
export function canDrag(model: GridLayout, resizerIndex: number, delta: number): boolean {
const zones = modelToZones(model)
const resizers = modelToResizers(model)
const resizer = resizers[resizerIndex]
if (!zones || !resizer) {
return false
}
const getSize = (zoneIndex: number) => {
const zone = zones[zoneIndex]
return resizer.orientation === 'vertical' ? zone.right - zone.left : zone.bottom - zone.top
}
for (const zoneIndex of resizer.positiveSideIndices) {
if (getSize(zoneIndex) - delta < MIN_ZONE_SIZE) {
return false
}
}
for (const zoneIndex of resizer.negativeSideIndices) {
if (getSize(zoneIndex) + delta < MIN_ZONE_SIZE) {
return false
}
}
return true
}
export function dragResizer(model: GridLayout, resizerIndex: number, delta: number): GridLayout {
if (!canDrag(model, resizerIndex, delta)) {
return model
}
const zones = modelToZones(model)!
const resizer = modelToResizers(model)[resizerIndex]
for (const zoneIndex of resizer.positiveSideIndices) {
const zone = zones[zoneIndex]
if (resizer.orientation === 'horizontal') {
zone.top += delta
} else {
zone.left += delta
}
}
for (const zoneIndex of resizer.negativeSideIndices) {
const zone = zones[zoneIndex]
if (resizer.orientation === 'horizontal') {
zone.bottom += delta
} else {
zone.right += delta
}
}
return zonesToModel(zones)
}
// ---------------------------------------------------------------------------
// Templates + validation (GridLayoutModel)
// ---------------------------------------------------------------------------
/** Even track sizes that sum EXACTLY to MULTIPLIER (GridLayoutModel.InitRows note). */
function evenPercents(count: number): number[] {
const out: number[] = []
for (let i = 0; i < count; i++) {
out.push(Math.floor((MULTIPLIER * (i + 1)) / count) - Math.floor((MULTIPLIER * i) / count))
}
return out
}
export function initColumns(count: number): GridLayout {
return {
rows: 1,
columns: count,
rowPercents: [MULTIPLIER],
columnPercents: evenPercents(count),
cellChildMap: [Array.from({ length: count }, (_, i) => i)]
}
}
export function initRows(count: number): GridLayout {
return {
rows: count,
columns: 1,
rowPercents: evenPercents(count),
columnPercents: [MULTIPLIER],
cellChildMap: Array.from({ length: count }, (_, i) => [i])
}
}
export function initGrid(zoneCount: number): GridLayout {
let rows = 1
while (Math.floor(zoneCount / rows) >= rows) {
rows++
}
rows--
let cols = Math.floor(zoneCount / rows)
if (zoneCount % rows !== 0) {
cols++
}
const model: GridLayout = {
rows,
columns: cols,
rowPercents: evenPercents(rows),
columnPercents: evenPercents(cols),
cellChildMap: Array.from({ length: rows }, () => new Array<number>(cols).fill(0))
}
let index = 0
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
model.cellChildMap[row][col] = index++
if (index === zoneCount) {
index--
}
}
}
return model
}
/**
* The "Priority Grid" template (GridLayoutModel._priorityData, decoded from
* its byte format: percents are `hi * 256 + lo`). Falls back to initGrid for
* counts beyond the table, as in the original.
*/
export function initPriorityGrid(zoneCount: number): GridLayout {
if (zoneCount === 2) {
return { rows: 1, columns: 2, rowPercents: [MULTIPLIER], columnPercents: [6667, 3333], cellChildMap: [[0, 1]] }
}
if (zoneCount === 3) {
return {
rows: 1,
columns: 3,
rowPercents: [MULTIPLIER],
columnPercents: [2500, 5000, 2500],
cellChildMap: [[0, 1, 2]]
}
}
return initGrid(zoneCount)
}
/** GridLayoutModel.IsModelValid, extended with the rectangular-span check. */
export function isGridValid(model: unknown): model is GridLayout {
if (!model || typeof model !== 'object') {
return false
}
const m = model as GridLayout
if (typeof m.rows !== 'number' || typeof m.columns !== 'number' || m.rows <= 0 || m.columns <= 0) {
return false
}
if (
!Array.isArray(m.rowPercents) ||
!Array.isArray(m.columnPercents) ||
m.rowPercents.length !== m.rows ||
m.columnPercents.length !== m.columns ||
m.rowPercents.some(x => typeof x !== 'number' || x < 1) ||
m.columnPercents.some(x => typeof x !== 'number' || x < 1)
) {
return false
}
if (
!Array.isArray(m.cellChildMap) ||
m.cellChildMap.length !== m.rows ||
m.cellChildMap.some(r => !Array.isArray(r) || r.length !== m.columns || r.some(c => typeof c !== 'number'))
) {
return false
}
const rowPrefix = prefixSum(m.rowPercents)
const colPrefix = prefixSum(m.columnPercents)
if (rowPrefix[m.rows] !== MULTIPLIER || colPrefix[m.columns] !== MULTIPLIER) {
return false
}
return modelToZones(m) !== null
}

View file

@ -0,0 +1,198 @@
/**
* Grid -> tree bridge. A FancyZones grid whose zones can be produced by
* recursive guillotine cuts (every FancyZones template, and almost every
* practical layout) converts exactly to our runtime `LayoutNode` tree:
* full-length cut lines become splits with weights from the cut positions.
* Non-guillotine arrangements (interlocking pinwheels) return null and the
* editor disables Save with an explanation.
*/
import type { GridLayout, GridZone } from './grid-model'
import { modelToZones } from './grid-model'
import { group, type LayoutNode, normalize, split } from './model'
function cutCandidates(zones: GridZone[], axis: 'x' | 'y'): number[] {
const coords = new Set(zones.flatMap(z => (axis === 'x' ? [z.left, z.right] : [z.top, z.bottom])))
// Interior lines only.
return [...coords].sort((a, b) => a - b).slice(1, -1)
}
function isValidCut(zones: GridZone[], axis: 'x' | 'y', at: number): boolean {
return zones.every(zone => (axis === 'x' ? zone.right <= at || zone.left >= at : zone.bottom <= at || zone.top >= at))
}
/**
* Recursively cut the zone set. Collects ALL valid cuts on the chosen axis at
* once so "three columns" becomes one flat 3-child split, not nested pairs.
*/
function cut(zones: GridZone[], assignPane: (zoneIndex: number) => string[]): LayoutNode | null {
if (zones.length === 1) {
// Zones without panes become EMPTY groups — editor-authored drop targets
// that live until the first structural op prunes them (normalize).
return group(assignPane(zones[0].index))
}
for (const axis of ['x', 'y'] as const) {
const cuts = cutCandidates(zones, axis).filter(at => isValidCut(zones, axis, at))
if (cuts.length === 0) {
continue
}
const start = Math.min(...zones.map(z => (axis === 'x' ? z.left : z.top)))
const end = Math.max(...zones.map(z => (axis === 'x' ? z.right : z.bottom)))
const lines = [start, ...cuts, end]
const children: LayoutNode[] = []
const weights: number[] = []
for (let i = 0; i < lines.length - 1; i++) {
const lo = lines[i]
const hi = lines[i + 1]
const slice = zones.filter(zone =>
axis === 'x' ? zone.left >= lo && zone.right <= hi : zone.top >= lo && zone.bottom <= hi
)
if (slice.length === 0) {
continue
}
const child = cut(slice, assignPane)
if (child) {
children.push(child)
weights.push(hi - lo)
}
}
if (children.length === 0) {
return null
}
if (children.length === 1) {
return children[0]
}
return split(axis === 'x' ? 'row' : 'column', children, weights)
}
// No full-length cut exists on either axis: non-guillotine (pinwheel).
return null
}
export type PanePlacementHint = 'main' | 'left' | 'right' | 'top' | 'bottom'
export interface PlacedPane {
id: string
placement?: PanePlacementHint
}
const CENTER = 5000 // MULTIPLIER / 2
interface ZoneGeo {
index: number
area: number
cx: number
cy: number
}
/**
* Semantic zone assignment: panes claim zones by ROLE, matched on geometry
* `main` takes the largest zone, `left`/`right`/`top`/`bottom` take zones whose
* centroid actually sits on that side (with a small size tiebreak). A hinted
* pane with no acceptable zone left STACKS with its role-mates (or main)
* instead of squatting in some random cell; unhinted panes fill what remains
* biggest-first; extra zones stay empty.
*/
function assignZones(zones: GridZone[], panes: PlacedPane[]): Map<number, string[]> {
const geo: ZoneGeo[] = zones.map(z => ({
index: z.index,
area: (z.right - z.left) * (z.bottom - z.top),
cx: (z.left + z.right) / 2,
cy: (z.top + z.bottom) / 2
}))
const remaining = new Map(geo.map(g => [g.index, g]))
const assignments = new Map<number, string[]>()
const zoneForRole = new Map<string, number>()
// Score = fit for the role; accept = the zone genuinely sits on that side.
const roles: Record<PanePlacementHint, { accept: (g: ZoneGeo) => boolean; score: (g: ZoneGeo) => number }> = {
main: { accept: () => true, score: g => g.area },
left: { accept: g => g.cx < CENTER, score: g => CENTER - g.cx + g.area / 1e8 },
right: { accept: g => g.cx > CENTER, score: g => g.cx - CENTER + g.area / 1e8 },
top: { accept: g => g.cy < CENTER, score: g => CENTER - g.cy + g.area / 1e8 },
bottom: { accept: g => g.cy > CENTER, score: g => g.cy - CENTER + g.area / 1e8 }
}
const claim = (pane: PlacedPane, role: PanePlacementHint | '_') => {
const spec = role === '_' ? roles.main : roles[role]
let best: ZoneGeo | null = null
for (const g of remaining.values()) {
if (spec.accept(g) && (!best || spec.score(g) > spec.score(best))) {
best = g
}
}
if (best) {
remaining.delete(best.index)
assignments.set(best.index, [pane.id])
zoneForRole.set(role, best.index)
if (role === 'main' || !zoneForRole.has('main')) {
zoneForRole.set('main', zoneForRole.get('main') ?? best.index)
}
return
}
// No acceptable zone left: stack with role-mates, else with main, else last.
const home = zoneForRole.get(role) ?? zoneForRole.get('main') ?? [...assignments.keys()].pop()
if (home !== undefined) {
assignments.get(home)?.push(pane.id)
}
}
// Placement priority: main anchors first, then sided panes, then the rest.
const rank = (p: PlacedPane) => (p.placement === 'main' ? 0 : p.placement ? 1 : 2)
for (const pane of [...panes].sort((a, b) => rank(a) - rank(b))) {
claim(pane, pane.placement ?? '_')
}
return assignments
}
/**
* Convert a grid to a tree. Panes carry placement hints (from their
* contribution's `data.placement`) and land in geometrically fitting zones;
* zones beyond the pane count stay as EMPTY zones.
*/
export function gridToTree(gridModel: GridLayout, panes: PlacedPane[]): LayoutNode | null {
const zones = modelToZones(gridModel)
if (!zones || zones.length === 0 || panes.length === 0) {
return null
}
const assignments = assignZones(zones, panes)
const result = cut(zones, zoneIndex => assignments.get(zoneIndex) ?? [])
return result ? normalize(result) : null
}
/** True when the grid is expressible as a tree (guillotine-cuttable). */
export function gridIsTreeExpressible(gridModel: GridLayout): boolean {
const zones = modelToZones(gridModel)
if (!zones) {
return false
}
const probe = cut(zones, () => ['probe'])
return probe !== null
}

View file

@ -0,0 +1,617 @@
/**
* Layout tree model the Dockview-style structure that replaces the
* rails/bands grammar. Two node kinds:
*
* - `split`: children laid out along an orientation with fractional weights.
* - `group`: a stack of panes (tabs) with one active; may be minimized to
* its header strip (DetailPane semantics).
*
* Everything the old grammar special-cased is just tree shape here: a "top row
* spanning the right rail" is a column split; "a cell inside a column" is a
* stacked group; spans fall out of tree position. All operations are pure and
* return new trees; `normalize` keeps the structure canonical (no empty
* groups, no single-child or same-orientation nested splits).
*/
export type Orientation = 'row' | 'column'
export interface SplitNode {
type: 'split'
id: string
orientation: Orientation
children: LayoutNode[]
/** Parallel to children; relative flex weights. */
weights: number[]
}
export interface GroupNode {
type: 'group'
id: string
/** Pane ids stacked in this group (rendered as tabs when > 1). */
panes: string[]
/** The visible pane. */
active: string
/** Collapsed to header strip (chevron restores). */
minimized?: boolean
/**
* Header hidden entirely (double-click the header to hide, double-click the
* zone's top edge to bring it back). Minimize always shows the header
* a minimized group IS its header.
*/
headerHidden?: boolean
}
export type LayoutNode = SplitNode | GroupNode
/** Where a dragged pane lands relative to a target group. */
export type DropPosition = 'center' | 'left' | 'right' | 'top' | 'bottom'
export type RootEdge = 'left' | 'right' | 'top' | 'bottom'
let seq = 0
export const nodeId = (kind: string) => `${kind}-${Date.now().toString(36)}-${(seq++).toString(36)}`
export const group = (panes: string[], options?: Partial<Omit<GroupNode, 'type' | 'panes'>>): GroupNode => ({
type: 'group',
id: options?.id ?? nodeId('g'),
panes,
active: options?.active ?? panes[0] ?? '',
minimized: options?.minimized,
headerHidden: options?.headerHidden
})
export const split = (
orientation: Orientation,
children: LayoutNode[],
weights?: number[],
id?: string
): SplitNode => ({
type: 'split',
id: id ?? nodeId('s'),
orientation,
children,
weights: weights ?? children.map(() => 1)
})
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
export function findGroup(node: LayoutNode, groupId: string): GroupNode | null {
if (node.type === 'group') {
return node.id === groupId ? node : null
}
for (const child of node.children) {
const hit = findGroup(child, groupId)
if (hit) {
return hit
}
}
return null
}
export function findGroupOfPane(node: LayoutNode, paneId: string): GroupNode | null {
if (node.type === 'group') {
return node.panes.includes(paneId) ? node : null
}
for (const child of node.children) {
const hit = findGroupOfPane(child, paneId)
if (hit) {
return hit
}
}
return null
}
export function allPaneIds(node: LayoutNode): string[] {
return node.type === 'group' ? [...node.panes] : node.children.flatMap(allPaneIds)
}
// ---------------------------------------------------------------------------
// Structural edits (pure)
// ---------------------------------------------------------------------------
/**
* Canonical form: unwrap single-child splits, flatten same-orientation
* nesting (weights scaled into the parent's slot), and PRUNE EMPTY GROUPS
* dragging the last pane out of a zone closes the zone and its siblings
* absorb the space (VS Code semantics). Keeping empties as "stable regions"
* (the original FancyZones rule) let invisible residue accumulate into
* corrupt-feeling structure (`row([]|[])` eating half a slot); authored
* empty zones still exist inside the zone editor's own grid model, and an
* editor-applied tree keeps them until the first structural op.
*/
export function normalize(node: LayoutNode): LayoutNode | null {
if (node.type === 'group') {
if (node.panes.length === 0) {
return null
}
const active = node.panes.includes(node.active) ? node.active : node.panes[0]
// A zone down to one pane clears a redundant HIDDEN override (the lone-pane
// default is already headerless) but KEEPS an explicit SHOWN override —
// once a zone has ever had a tab bar, closing back to one tab leaves it
// shown (sticky bar; the off switch is "Hide tab bar"). `false` survives.
const headerHidden = node.panes.length <= 1 && node.headerHidden !== false ? undefined : node.headerHidden
if (active === node.active && headerHidden === node.headerHidden) {
return node
}
return { ...node, active, headerHidden }
}
const children: LayoutNode[] = []
const weights: number[] = []
node.children.forEach((child, i) => {
const kept = normalize(child)
if (!kept) {
return
}
if (kept.type === 'split' && kept.orientation === node.orientation) {
// Flatten: distribute this slot's weight across the flattened children
// proportionally to their internal weights.
const total = kept.weights.reduce((a, b) => a + b, 0) || 1
kept.children.forEach((grandchild, j) => {
children.push(grandchild)
weights.push((node.weights[i] ?? 1) * ((kept.weights[j] ?? 1) / total))
})
return
}
children.push(kept)
weights.push(node.weights[i] ?? 1)
})
if (children.length === 0) {
return null
}
if (children.length === 1) {
return children[0]
}
return { ...node, children, weights }
}
/** Remove a pane wherever it lives. Closing the ACTIVE tab activates its
* previous neighbor (the next one when it was first) browser-tab feel,
* never a jump to the strip's start. */
export function removePane(node: LayoutNode, paneId: string): LayoutNode | null {
const walk = (n: LayoutNode): LayoutNode => {
if (n.type === 'group') {
const at = n.panes.indexOf(paneId)
if (at === -1) {
return n
}
const panes = n.panes.filter(p => p !== paneId)
return { ...n, panes, active: n.active === paneId ? panes[Math.max(0, at - 1)] : n.active }
}
return { ...n, children: n.children.map(walk) }
}
return normalize(walk(node))
}
/**
* Insert `paneId` at `target` group: `center` joins the stack (as a tab);
* an edge splits the group in that direction. If the neighboring split
* already runs in that orientation the new group is spliced in beside the
* target instead of nesting (normalize would flatten it anyway).
*/
export function insertAtGroup(
node: LayoutNode,
targetGroupId: string,
paneId: string,
pos: DropPosition,
/** Center drops only: stack BEFORE this pane id (`null`/omitted = append)
* the tab-strip insertion divider's slot. */
before?: null | string,
/** Front the inserted pane TRUE for a gesture (drop/reveal), FALSE for silent
* adoption (logs stacking into the terminal zone must not steal its tab). */
activate: boolean = true
): LayoutNode | null {
const walk = (n: LayoutNode): LayoutNode => {
if (n.type === 'group') {
if (n.id !== targetGroupId) {
return n
}
if (pos === 'center') {
const at = before ? n.panes.indexOf(before) : -1
const panes = at >= 0 ? [...n.panes.slice(0, at), paneId, ...n.panes.slice(at)] : [...n.panes, paneId]
// Gaining a pane pins the header EXPLICITLY shown (not just cleared):
// a stack you can't see is a trap, and once a zone has ever stacked
// the bar STAYS when it drops back to one tab — the auto-hide flicker
// while dragging tabs around felt broken. Hiding is the user's call
// (double-click / zone menu). Active moves only on a gesture; an empty
// target has no prior tab, so the newcomer takes it regardless.
const active = activate || n.panes.length === 0 ? paneId : n.active
return { ...n, panes, active, headerHidden: false }
}
const orientation: Orientation = pos === 'left' || pos === 'right' ? 'row' : 'column'
const leading = pos === 'left' || pos === 'top'
const added = group([paneId])
const children = leading ? [added, n] : [n, added]
return split(orientation, children, [1, 1])
}
return { ...n, children: n.children.map(walk) }
}
return normalize(walk(node))
}
/**
* The tree's VISIBLE shape: pane stacks + split orientations, with empty
* groups skipped (editor-session trees may still hold them) and single-child
* runs unwrapped. Two trees with equal signatures are indistinguishable on
* screen regardless of node ids.
*/
function shapeSignature(node: LayoutNode): string {
if (node.type === 'group') {
return node.panes.length > 0 ? `[${node.panes.join(',')}]` : ''
}
const children = node.children.map(shapeSignature).filter(Boolean)
if (children.length === 0) {
return ''
}
return children.length === 1 ? children[0] : `${node.orientation}(${children.join('|')})`
}
/**
* Move = remove + insert. If the target group vanished during removal (the
* pane was its only occupant), the move is a no-op. A move whose result
* LOOKS identical to the current layout is also a no-op e.g. a "split
* bottom" drop onto the zone the pane already sits alone below would only
* rebuild the same arrangement under a fresh zone id.
*/
export function movePane(
root: LayoutNode,
paneId: string,
target: { groupId: string; pos: DropPosition; before?: null | string }
): LayoutNode {
const from = findGroupOfPane(root, paneId)
// No-op guards: dropping a pane onto its own single-pane group.
if (from && from.id === target.groupId && from.panes.length === 1) {
return root
}
const without = removePane(root, paneId)
if (!without) {
// The pane was the only thing in the tree.
return root
}
if (!findGroup(without, target.groupId)) {
return root
}
const next = insertAtGroup(without, target.groupId, paneId, target.pos, target.before) ?? root
return shapeSignature(next) === shapeSignature(root) ? root : next
}
/** Group ids of every leaf under a node, in tree order. */
export function groupLeafIds(node: LayoutNode): string[] {
return node.type === 'group' ? [node.id] : node.children.flatMap(groupLeafIds)
}
function pathToGroup(node: LayoutNode, groupId: string): LayoutNode[] | null {
if (node.type === 'group') {
return node.id === groupId ? [node] : null
}
for (const child of node.children) {
const sub = pathToGroup(child, groupId)
if (sub) {
return [node, ...sub]
}
}
return null
}
const OPPOSITE_EDGE: Record<RootEdge, RootEdge> = { bottom: 'top', left: 'right', right: 'left', top: 'bottom' }
/** The viable group touching `edge` of this subtree. Along the edge's axis
* children are scanned edge-first a non-viable zone is display:none, so the
* next sibling IS the visual edge; across it, every child touches the edge. */
function edgeGroup(node: LayoutNode, edge: RootEdge, viable: (g: GroupNode) => boolean): GroupNode | null {
if (node.type === 'group') {
return viable(node) ? node : null
}
const along = (node.orientation === 'row') === (edge === 'left' || edge === 'right')
const children = along && (edge === 'right' || edge === 'bottom') ? [...node.children].reverse() : node.children
for (const child of children) {
const hit = edgeGroup(child, edge, viable)
if (hit) {
return hit
}
}
return null
}
/**
* The viable zone VISUALLY adjacent to `groupId` on `side` (the target of the
* zone menu's "Move left/right/up/down"). Walks up to the nearest ancestor
* split running along that axis with a sibling on that side, then descends to
* the sibling's closest viable leaf; subtrees whose every zone fails `viable`
* (all panes hidden) are skipped, matching their collapsed rendering.
*/
export function adjacentGroup(
root: LayoutNode,
groupId: string,
side: RootEdge,
viable: (g: GroupNode) => boolean
): GroupNode | null {
const path = pathToGroup(root, groupId)
if (!path) {
return null
}
const orientation: Orientation = side === 'left' || side === 'right' ? 'row' : 'column'
const forward = side === 'right' || side === 'bottom'
for (let i = path.length - 2; i >= 0; i--) {
const parent = path[i]
if (parent.type !== 'split' || parent.orientation !== orientation) {
continue
}
const index = parent.children.indexOf(path[i + 1])
const siblings = forward ? parent.children.slice(index + 1) : parent.children.slice(0, index).reverse()
for (const sibling of siblings) {
const hit = edgeGroup(sibling, OPPOSITE_EDGE[side], viable)
if (hit) {
return hit
}
}
}
return null
}
function sameSet(ids: string[], set: Set<string>): boolean {
return ids.length === set.size && ids.every(id => set.has(id))
}
/** The node whose complete leaf set equals `set` (a rectangular region in a
* guillotine tree is always exactly one subtree), or null. */
function findCover(node: LayoutNode, set: Set<string>): LayoutNode | null {
if (sameSet(groupLeafIds(node), set)) {
return node
}
if (node.type === 'split') {
for (const child of node.children) {
const hit = findCover(child, set)
if (hit) {
return hit
}
}
}
return null
}
/**
* FancyZones span: merge the highlighted zones into ONE group holding
* `paneId`, absorbing any panes that lived in those zones as tabs. Only works
* when the highlighted set forms a rectangular subtree (it always does for a
* combined zone range on a guillotine tree); returns null otherwise so the
* caller can fall back to a single-zone drop.
*/
export function mergeZonesWithPane(root: LayoutNode, groupIds: string[], paneId: string): LayoutNode | null {
const set = new Set(groupIds)
if (set.size <= 1 || !findCover(root, set)) {
return null
}
// Panes from the merged zones (tree order), minus the dragged one.
const panesInSet: string[] = []
const collect = (n: LayoutNode) => {
if (n.type === 'group') {
if (set.has(n.id)) {
panesInSet.push(...n.panes.filter(p => p !== paneId))
}
} else {
n.children.forEach(collect)
}
}
collect(root)
// If the dragged pane lives OUTSIDE the merged set, pull it from its origin
// first (leaving that origin an empty zone). Inside the set it's absorbed.
const origin = findGroupOfPane(root, paneId)
let working = root
if (origin && !set.has(origin.id)) {
working = removePane(root, paneId) ?? root
}
const merged = group([paneId, ...panesInSet])
const replace = (n: LayoutNode): LayoutNode => {
if (sameSet(groupLeafIds(n), set)) {
return merged
}
return n.type === 'split' ? { ...n, children: n.children.map(replace) } : n
}
return normalize(replace(working))
}
// ---------------------------------------------------------------------------
// Attribute edits
// ---------------------------------------------------------------------------
function mapGroups(node: LayoutNode, fn: (g: GroupNode) => GroupNode): LayoutNode {
return node.type === 'group' ? fn(node) : { ...node, children: node.children.map(c => mapGroups(c, fn)) }
}
export function setActivePane(root: LayoutNode, groupId: string, paneId: string): LayoutNode {
return mapGroups(root, g => (g.id === groupId && g.panes.includes(paneId) ? { ...g, active: paneId } : g))
}
/** Reorder a pane within its group's tab stack (browser-tab drag semantics). */
export function reorderPaneInGroup(root: LayoutNode, groupId: string, paneId: string, toIndex: number): LayoutNode {
return mapGroups(root, g => {
if (g.id !== groupId || !g.panes.includes(paneId)) {
return g
}
const without = g.panes.filter(p => p !== paneId)
const index = Math.max(0, Math.min(without.length, toIndex))
const panes = [...without.slice(0, index), paneId, ...without.slice(index)]
return { ...g, panes }
})
}
export function setGroupMinimized(root: LayoutNode, groupId: string, minimized: boolean): LayoutNode {
return mapGroups(root, g => (g.id === groupId ? { ...g, minimized } : g))
}
export function setGroupHeaderHidden(root: LayoutNode, groupId: string, headerHidden: boolean): LayoutNode {
return mapGroups(root, g => (g.id === groupId ? { ...g, headerHidden } : g))
}
function replaceNode(node: LayoutNode, id: string, make: (g: GroupNode) => LayoutNode): LayoutNode {
if (node.type === 'group') {
return node.id === id ? make(node) : node
}
return { ...node, children: node.children.map(c => replaceNode(c, id, make)) }
}
/**
* Split a zone: `movePaneId` (one of SEVERAL panes in the group) moves into
* the new zone on `side` VS Code "split right", split and move in one
* gesture. A lone pane can't split away from itself: no-op (normalize prunes
* the empty zone the split would have minted).
*/
export function splitGroupZone(root: LayoutNode, groupId: string, side: RootEdge, movePaneId: string): LayoutNode {
const orientation: Orientation = side === 'left' || side === 'right' ? 'row' : 'column'
const before = side === 'left' || side === 'top'
return (
normalize(
replaceNode(root, groupId, g => {
if (g.panes.length < 2 || !g.panes.includes(movePaneId)) {
return g
}
const added = group([movePaneId])
const remaining = { ...g, panes: g.panes.filter(p => p !== movePaneId) }
return split(orientation, before ? [added, remaining] : [remaining, added], [1, 1])
})
) ?? root
)
}
/** Mirror the layout HORIZONTALLY (the titlebar flip toggle / \): reverse
* every ROW split's child order at EVERY depth, so leftright flips
* everywhere. A right rail lands on the left with its OWN internal order
* mirrored too so preview stays directly beside the file tree instead of
* jumping to the far edge (a shallow root-only reverse left nested rails in
* place). COLUMN splits keep their topbottom order (the terminal stays at
* the bottom). Its own involution: flipping twice is the identity. */
export function mirrorTreeHorizontal(root: LayoutNode): LayoutNode {
if (root.type === 'group') {
return root
}
const children = root.children.map(mirrorTreeHorizontal)
return root.orientation === 'row'
? { ...root, children: children.reverse(), weights: [...root.weights].reverse() }
: { ...root, children }
}
export function setSplitWeights(root: LayoutNode, splitId: string, weights: number[]): LayoutNode {
if (root.type === 'split') {
if (root.id === splitId) {
return { ...root, weights }
}
return { ...root, children: root.children.map(c => setSplitWeights(c, splitId, weights)) }
}
return root
}
// ---------------------------------------------------------------------------
// Validation (persisted trees are untrusted)
// ---------------------------------------------------------------------------
export function isLayoutNode(value: unknown): value is LayoutNode {
if (!value || typeof value !== 'object') {
return false
}
const n = value as Record<string, unknown>
if (n.type === 'group') {
return (
typeof n.id === 'string' &&
Array.isArray(n.panes) &&
n.panes.every(p => typeof p === 'string') &&
typeof n.active === 'string'
)
}
if (n.type === 'split') {
return (
typeof n.id === 'string' &&
(n.orientation === 'row' || n.orientation === 'column') &&
Array.isArray(n.children) &&
n.children.length > 0 &&
n.children.every(isLayoutNode) &&
Array.isArray(n.weights) &&
n.weights.length === n.children.length &&
n.weights.every(w => typeof w === 'number' && Number.isFinite(w) && w > 0)
)
}
return false
}

Some files were not shown because too many files have changed in this diff Show more