diff --git a/apps/desktop/src/app/chat/chat-drop-overlay.tsx b/apps/desktop/src/app/chat/chat-drop-overlay.tsx index ff01687aaccb..9620af7a65b0 100644 --- a/apps/desktop/src/app/chat/chat-drop-overlay.tsx +++ b/apps/desktop/src/app/chat/chat-drop-overlay.tsx @@ -1,7 +1,7 @@ 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, DropPill } from '@/components/ui/drop-affordance' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' @@ -38,11 +38,16 @@ export function ChatDropOverlay({ kind }: { kind: DragKind }) { )} data-slot="chat-drop-overlay" > -
-
- +
+ {label} -
+
) } 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-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 1f5df46eb2a4..661c4dbbae4a 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1,21 +1,20 @@ 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 { 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' @@ -23,6 +22,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' @@ -51,6 +51,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' @@ -79,17 +80,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 @@ -197,8 +217,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({ @@ -506,11 +524,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 @@ -531,11 +549,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()) } } @@ -643,7 +661,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, @@ -663,7 +681,8 @@ export function ChatBar({ maxRecordingSeconds, onSubmit, onTranscribeAudio, - sessionId + sessionId, + target: scope.target }) const contextMenu = ( @@ -741,7 +760,7 @@ export function ChatBar({ }} onDragOver={handleInputDragOver} onDrop={handleInputDrop} - onFocus={() => markActiveComposer('main')} + onFocus={() => markActiveComposer(scope.target)} onInput={handleEditorInput} onKeyDown={handleEditorKeyDown} onKeyUp={handleEditorKeyUp} @@ -845,7 +864,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 && ( @@ -970,10 +993,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..b86230de19c3 100644 --- a/apps/desktop/src/app/chat/composer/inline-refs.ts +++ b/apps/desktop/src/app/chat/composer/inline-refs.ts @@ -8,40 +8,14 @@ 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 - } -} - /** 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 { 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..0da2912768af 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,45 +1,34 @@ import { type DragEvent as ReactDragEvent, useCallback, 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 { 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`. */ -export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: FileDropZoneOptions) { +export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) { const [dragKind, setDragKind] = useState(null) const depth = useRef(0) @@ -89,16 +78,14 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: return } + // An outer layer may have already claimed this drop via preventDefault — + // reset the hover state but don't ALSO act on it. + const claimed = event.defaultPrevented + event.preventDefault() reset() - if (kind === 'session') { - const session = readSessionDrag(event.dataTransfer) - - if (session) { - onDropSession?.(session) - } - + if (claimed) { return } @@ -108,7 +95,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/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-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-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)