diff --git a/.gitignore b/.gitignore index 4d25e5425b6b..6f1b3be6d92b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index e89e369bacee..de2e7c68ac1a 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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. diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 6c1ebc5bf642..952fdfdc8dad 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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), diff --git a/apps/desktop/eslint.config.mjs b/apps/desktop/eslint.config.mjs index 2a5a925e8ff9..6cec34d289a6 100644 --- a/apps/desktop/eslint.config.mjs +++ b/apps/desktop/eslint.config.mjs @@ -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/**'], diff --git a/apps/desktop/src/app/chat/chat-drop-overlay.tsx b/apps/desktop/src/app/chat/chat-drop-overlay.tsx index ff01687aaccb..37d8172eb1e5 100644 --- a/apps/desktop/src/app/chat/chat-drop-overlay.tsx +++ b/apps/desktop/src/app/chat/chat-drop-overlay.tsx @@ -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(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 (
-
-
- - {label} -
+
+ {shown && ( + + {shown === 'session' ? t.composer.dropSession : t.composer.dropFiles} + + )}
) } diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts new file mode 100644 index 000000000000..100e14365852 --- /dev/null +++ b/apps/desktop/src/app/chat/close-tab.ts @@ -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() +} diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts index 66f438f62708..7939be35b6b6 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -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 } diff --git a/apps/desktop/src/app/chat/composer/context-menu.tsx b/apps/desktop/src/app/chat/composer/context-menu.tsx index 57c34ebde38c..e0c12803733c 100644 --- a/apps/desktop/src/app/chat/composer/context-menu.tsx +++ b/apps/desktop/src/app/chat/composer/context-menu.tsx @@ -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} + {attachmentProviders.length > 0 && } + {attachmentProviders.map(provider => ( + void provider.run({ insertText: onInsertText })} + > + + {provider.label} + + ))} +
diff --git a/apps/desktop/src/app/chat/composer/contrib.test.ts b/apps/desktop/src/app/chat/composer/contrib.test.ts new file mode 100644 index 000000000000..d90229a8c896 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/contrib.test.ts @@ -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' }) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/contrib.ts b/apps/desktop/src/app/chat/composer/contrib.ts new file mode 100644 index 000000000000..893f5174c200 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/contrib.ts @@ -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 +} + +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 +} + +/** + * 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 { + 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 { + return useContributions(COMPOSER_AREAS.attachments) + .map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as ComposerAttachmentProvider) })) + .filter(p => Boolean(p.label && p.run)) +} diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index d3969b700200..238642f54e49 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -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:'`. */ +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(FOCUS_EVENT, { target: resolve(target) }) @@ -129,12 +135,14 @@ export const requestComposerSubmit = ( export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) => subscribe(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. diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts index a8e5c65888cd..60a5255eec67 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts @@ -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] diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index 52cb605f5413..b9fa789ad51d 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -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 diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts index 37b3625a4f16..ff017edcf96e 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts @@ -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)) { diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts index da66ddd843aa..318802bbab80 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts @@ -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 @@ -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(null) + const lastCompactPillRef = useRef(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 } } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts index 3fb0d0372f88..518aa3658a56 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts @@ -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) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts index c40d56a4826b..761d6830a1bd 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts @@ -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. diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts index 2dc0ef8047f1..adf44e34a8d7 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -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) } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 2cff7a4084c7..5ce92171d3f1 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -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). diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index a24344a2e7b8..41e1c813309b 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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( + 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 && } + {isHelpHint && } {trigger && !argStageEmpty && ( + {/* Contribution seams: banners above, a row below, inline + additions beside the "+" menu and before the controls. + All four render nothing until something contributes. */} + {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"]' )} > -
{contextMenu}
+
+ {contextMenu} + +
{input}
-
{controls}
+
+ + {controls} +
+
diff --git a/apps/desktop/src/app/chat/composer/inline-refs.ts b/apps/desktop/src/app/chat/composer/inline-refs.ts index ac04bfacbc6f..5fd62f4cc94a 100644 --- a/apps/desktop/src/app/chat/composer/inline-refs.ts +++ b/apps/desktop/src/app/chat/composer/inline-refs.ts @@ -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 - - 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:/` 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) { diff --git a/apps/desktop/src/app/chat/composer/scope.tsx b/apps/desktop/src/app/chat/composer/scope.tsx new file mode 100644 index 000000000000..67581a39e30d --- /dev/null +++ b/apps/desktop/src/app/chat/composer/scope.tsx @@ -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 + 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:'`). */ + target: ComposerTarget +} + +export const MAIN_COMPOSER_SCOPE: ComposerScope = { + $awaitingInput: $activeSessionAwaitingInput, + attachments: mainComposerScope, + popoutAllowed: true, + readMessages: () => $messages.get(), + target: 'main' +} + +const ComposerScopeContext = createContext(MAIN_COMPOSER_SCOPE) + +export const ComposerScopeProvider = ComposerScopeContext.Provider + +export const useComposerScope = (): ComposerScope => useContext(ComposerScopeContext) diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index d510c59f45b4..2f9a924a0a92 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -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: (method: string, params?: Record) => Promise + 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 { diff --git a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts index 10b3cfe40a90..42e4554b992e 100644 --- a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts +++ b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts @@ -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(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 { diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index fb8175102b08..4b417404bedd 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -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} > - + {title} @@ -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()) - const toolMergeCacheRef = useRef(createToolMergeCache()) - - const runtimeMessageRepository = useMemo(() => { - const items: { message: ThreadMessage; parentId: string | null }[] = [] - const branchParentByGroup = new Map() - 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({ 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 (
- + {/* Tiles get their chrome from the layout zone (chip strip); the modal + prompt overlays stay active-session-scoped in the primary surface. */} + {isPrimary && ( + + )} - + {/* 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. */} + )} - + {/* A session drag hovering an EDGE hands the visual to the zone + target; the link overlay shows only for the center region. */} +
{/* Composer renders OUTSIDE the contain:[layout paint] wrapper above: diff --git a/apps/desktop/src/app/chat/pane-mirror.ts b/apps/desktop/src/app/chat/pane-mirror.ts new file mode 100644 index 000000000000..8e8a96836702 --- /dev/null +++ b/apps/desktop/src/app/chat/pane-mirror.ts @@ -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 { + /** Reactive source list. */ + source: ReadableAtom + /** Extra atoms whose changes should re-sync (e.g. titles living elsewhere). */ + also?: ReadableAtom[] + /** 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, + 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(cfg: PaneMirror): () => void { + const registered = new Map 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, 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)) + } +} diff --git a/apps/desktop/src/app/chat/right-rail/index.ts b/apps/desktop/src/app/chat/right-rail/index.ts index 8bb73a68a891..c79955718d44 100644 --- a/apps/desktop/src/app/chat/right-rail/index.ts +++ b/apps/desktop/src/app/chat/right-rail/index.ts @@ -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' diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index ca4c65f2e998..46afb0ede38f 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -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 }) diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx index 2b77007a7307..31fc5f6d6a74 100644 --- a/apps/desktop/src/app/chat/right-rail/preview.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview.tsx @@ -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 . 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 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)' }} > -
+
-
{ - if (event.button !== 1) { - return - } - - event.preventDefault() - closeRightRailTab(tab.id) - }} - onMouseDown={event => { - if (event.button === 1) { - event.preventDefault() - } - }} - > - {active && ( -
+
closeRightRailTab(tab.id)}> diff --git a/apps/desktop/src/app/chat/route-tile.tsx b/apps/desktop/src/app/chat/route-tile.tsx new file mode 100644 index 000000000000..b807f733bf62 --- /dev/null +++ b/apps/desktop/src/app/chat/route-tile.tsx @@ -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 ReactNode; title: string }> = { + [ARTIFACTS_ROUTE]: { render: () => , title: 'Artifacts' }, + [MESSAGING_ROUTE]: { render: () => , title: 'Messaging' }, + [SKILLS_ROUTE]: { render: () => , 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 ( + + {builtin.render()} + + ) + } + + if (contrib) { + return {contrib.render()} + } + + return ( +
+ no page at {path} +
+ ) +} + +// --------------------------------------------------------------------------- +// 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({ + source: $routeTiles, + key: t => t.path, + prefix: 'route-tile', + dir: t => t.dir, + minWidth: '22rem', + title: routeTitle, + render: path => , + close: closeRouteTile +}) diff --git a/apps/desktop/src/app/chat/runtime-repository.ts b/apps/desktop/src/app/chat/runtime-repository.ts new file mode 100644 index 000000000000..9dc94d42114a --- /dev/null +++ b/apps/desktop/src/app/chat/runtime-repository.ts @@ -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()) + const toolMergeCacheRef = useRef(createToolMergeCache()) + + return useMemo(() => { + const items: { message: ThreadMessage; parentId: string | null }[] = [] + const branchParentByGroup = new Map() + 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]) +} diff --git a/apps/desktop/src/app/chat/session-drag.ts b/apps/desktop/src/app/chat/session-drag.ts new file mode 100644 index 000000000000..f99bfa47325d --- /dev/null +++ b/apps/desktop/src/app/chat/session-drag.ts @@ -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('[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, + opts?: { double?: DoubleTapContext; onTap?: () => void } +) { + let zones: EngineZone[] = [] + let strips: StripSnapshot[] = [] + let surfaces: SurfaceSnapshot[] = [] + let composers: ZoneRect[] = [] + let zoneHost = new Map() + + // 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('[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 }) + } + } + }) +} diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts new file mode 100644 index 000000000000..f75ff6bf4dac --- /dev/null +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -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 => { + 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 => { + 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] + ) +} diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx new file mode 100644 index 000000000000..04ddaf1cc630 --- /dev/null +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -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( + () => ({ + $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 ( + + + 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} + /> + + + ) +} + +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 ( +
+
+
Couldn't open this session
+
{tile.error}
+ +
+
+ ) + } + + if (!runtimeId) { + // The SAME session loader the primary thread shows (Thread's + // loading === 'session' branch) — one loading language everywhere. + return ( +
+ +
+ ) + } + + return +} + +// --------------------------------------------------------------------------- +// 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) + +/** 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 ( + $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 ( + event.stopPropagation()}> + 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} + + + ) +} + +/** 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 ( + + {children} + + ) +} + +/** Keep pane contributions mirroring `$sessionTiles` (+ titles from + * `$sessions`). Tiles dock against main on the chosen edge, flex width. */ +export const watchSessionTiles = paneMirror({ + 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 => , + tabWrap: (storedSessionId, tab) => ( + requestCloseSessionTile(storedSessionId)} + storedSessionId={storedSessionId} + tabPaneId={`session-tile:${storedSessionId}`} + > + {tab} + + ), + // 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 +}) diff --git a/apps/desktop/src/app/chat/session-view.tsx b/apps/desktop/src/app/chat/session-view.tsx new file mode 100644 index 000000000000..af72d0ceb41e --- /dev/null +++ b/apps/desktop/src/app/chat/session-view.tsx @@ -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 + $storedId: ReadableAtom + $messages: ReadableAtom + $busy: ReadableAtom + $awaitingResponse: ReadableAtom + $messagesEmpty: ReadableAtom + $lastVisibleIsUser: ReadableAtom + $cwd: ReadableAtom + $model: ReadableAtom + $provider: ReadableAtom +} + +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(PRIMARY_SESSION_VIEW) + +export const SessionViewProvider = SessionViewContext.Provider + +export const useSessionView = (): SessionView => useContext(SessionViewContext) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 416483dde427..9a3fbe3a2748 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -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 { 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( + () => + navContributions.flatMap(c => { + const data = c.data as Partial | undefined + + if (!data?.path?.startsWith('/') || !data.label) { + return [] + } + + const codicon = data.codicon || 'plug' + + return [ + { + id: c.id, + label: data.label, + icon: (props: { className?: string }) => , + 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 (