feat(desktop): chat view — drop overlays, composer scoping, tile integration

This commit is contained in:
Brooklyn Nicholson 2026-07-13 17:28:32 -04:00
parent eae1d7d147
commit 860a3f67bb
25 changed files with 1066 additions and 302 deletions

View file

@ -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"
>
<div className="absolute inset-2 rounded-2xl border-2 border-dashed border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)] backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]" />
<div className="relative flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer">
<Codicon className="text-(--ui-accent)" name={icon} size="1rem" />
<div
className={cn(
DROP_SHEET_CLASS,
DROP_SHEET_BLUR_CLASS,
'absolute inset-2 border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)]'
)}
/>
<DropPill className="relative" icon={icon}>
{label}
</div>
</DropPill>
</div>
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,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<ChatBarProps['onSubmit']>(
async (value, options) => {
const draft = await runComposerMiddleware({ text: value, attachments: options?.attachments })
if (!draft) {
return false
}
return onSubmitProp(draft.text, { ...options, attachments: draft.attachments })
},
[onSubmitProp]
)
// Which live composer this instance IS (main | tile) — its attachment set,
// focus-bus key, and awaiting-input edge. Main scope = the legacy globals.
const scope = useComposerScope()
const attachments = useStore(scope.attachments.$attachments)
const scrolledUp = useStore($threadScrolledUp)
const autoSpeak = useStore($autoSpeakReplies)
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
// not interrupt it — there's nothing actively running to stop, and stopping
// would discard a question the user may want to come back to. The blocking
// prompt owns its own dismissal (Skip, Reject, dialog close).
const awaitingInput = useStore($activeSessionAwaitingInput)
const awaitingInput = useStore(scope.$awaitingInput)
const activeQueueSessionKey = queueSessionKey || sessionId || null
// Status items (subagents, background processes) are keyed by the RUNTIME
@ -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 && <HelpHint />}
{isHelpHint && <HelpHint />}
{trigger && !argStageEmpty && (
<ComposerTriggerPopover
activeIndex={triggerActive}
@ -935,6 +954,10 @@ export function ChatBar({
)}
data-slot="composer-fade"
>
{/* Contribution seams: banners above, a row below, inline
additions beside the "+" menu and before the controls.
All four render nothing until something contributes. */}
<ContribSlot area={COMPOSER_AREAS.top} />
<VoiceActivity state={voiceActivityState} />
<VoicePlaybackActivity />
{queueEdit && editingQueuedPrompt && (
@ -970,10 +993,17 @@ export function ChatBar({
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
)}
>
<div className="flex translate-y-[3px] items-start self-start [grid-area:menu]">{contextMenu}</div>
<div className="flex translate-y-[3px] items-start gap-(--composer-control-gap) self-start [grid-area:menu]">
{contextMenu}
<ContribSlot area={COMPOSER_AREAS.leading} />
</div>
<div className="min-w-0 [grid-area:input]">{input}</div>
<div className="flex items-center justify-end [grid-area:controls]">{controls}</div>
<div className="flex items-center justify-end gap-(--composer-control-gap) [grid-area:controls]">
<ContribSlot area={COMPOSER_AREAS.actions} />
{controls}
</div>
</div>
<ContribSlot area={COMPOSER_AREAS.bottom} />
</div>
</div>
</div>

View file

@ -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<SessionDragPayload>
return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null
} catch {
return null
}
}
/** Build a `@session:<profile>/<id>` chip. Value carries the metadata the agent
* needs to resolve the link (session_search); label shows the friendly title. */
export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput {

View file

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

View file

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

View file

@ -1,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<DragKind>(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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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