mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(desktop): chat view — drop overlays, composer scoping, tile integration
This commit is contained in:
parent
eae1d7d147
commit
860a3f67bb
25 changed files with 1066 additions and 302 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
54
apps/desktop/src/app/chat/composer/contrib.test.ts
Normal file
54
apps/desktop/src/app/chat/composer/contrib.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
94
apps/desktop/src/app/chat/composer/contrib.ts
Normal file
94
apps/desktop/src/app/chat/composer/contrib.ts
Normal 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))
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
47
apps/desktop/src/app/chat/composer/scope.tsx
Normal file
47
apps/desktop/src/app/chat/composer/scope.tsx
Normal 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)
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
90
apps/desktop/src/app/chat/route-tile.tsx
Normal file
90
apps/desktop/src/app/chat/route-tile.tsx
Normal 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
|
||||
})
|
||||
51
apps/desktop/src/app/chat/runtime-repository.ts
Normal file
51
apps/desktop/src/app/chat/runtime-repository.ts
Normal 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])
|
||||
}
|
||||
359
apps/desktop/src/app/chat/session-tile-actions.ts
Normal file
359
apps/desktop/src/app/chat/session-tile-actions.ts
Normal 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]
|
||||
)
|
||||
}
|
||||
61
apps/desktop/src/app/chat/session-view.tsx
Normal file
61
apps/desktop/src/app/chat/session-view.tsx
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue