mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
Merge pull request #60638 from NousResearch/bb/contrib-areas
feat(desktop): contribution-driven shell on a layout-tree model
This commit is contained in:
commit
1f89f3102f
174 changed files with 16409 additions and 3696 deletions
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -68,6 +68,17 @@ environments/benchmarks/evals/
|
|||
hermes_cli/web_dist/
|
||||
apps/desktop/build/
|
||||
apps/desktop/dist/
|
||||
|
||||
# tsc-emitted artifacts (a stray `tsc -b` compiles into src/, and vite then
|
||||
# resolves the stale .js OVER the .tsx — never track these)
|
||||
apps/desktop/src/**/*.js
|
||||
apps/desktop/src/**/*.js.map
|
||||
apps/desktop/src/**/*.d.ts
|
||||
!apps/desktop/src/global.d.ts
|
||||
!apps/desktop/src/vite-env.d.ts
|
||||
apps/shared/src/**/*.js
|
||||
apps/shared/src/**/*.js.map
|
||||
apps/shared/src/**/*.d.ts
|
||||
apps/desktop/release/
|
||||
*.tsbuildinfo
|
||||
|
||||
|
|
|
|||
|
|
@ -3597,9 +3597,35 @@ async function ensureRuntime(backend) {
|
|||
return backend
|
||||
}
|
||||
|
||||
// Assemble a single-file multipart/form-data body (FastAPI `UploadFile`
|
||||
// endpoints, e.g. kanban attachments). Hand-rolled because node's http has no
|
||||
// FormData and the payload is one file — a dependency would be overkill.
|
||||
function multipartBody(upload) {
|
||||
const boundary = `----hermes-${crypto.randomBytes(12).toString('hex')}`
|
||||
const filename = String(upload.filename || 'file').replace(/["\r\n]/g, '_')
|
||||
|
||||
const body = Buffer.concat([
|
||||
Buffer.from(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` +
|
||||
`Content-Type: ${upload.contentType || 'application/octet-stream'}\r\n\r\n`
|
||||
),
|
||||
Buffer.from(upload.bytes),
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`)
|
||||
])
|
||||
|
||||
return { body, contentType: `multipart/form-data; boundary=${boundary}` }
|
||||
}
|
||||
|
||||
function fetchJson(url, token, options: any = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
|
||||
const { body, contentType } = options.upload
|
||||
? multipartBody(options.upload)
|
||||
: {
|
||||
body: options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)),
|
||||
contentType: 'application/json'
|
||||
}
|
||||
|
||||
const parsed = new URL(url)
|
||||
const client = parsed.protocol === 'https:' ? https : http
|
||||
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
|
|
@ -3615,7 +3641,7 @@ function fetchJson(url, token, options: any = {}) {
|
|||
{
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Type': contentType,
|
||||
'X-Hermes-Session-Token': token,
|
||||
...(body ? { 'Content-Length': String(body.length) } : {})
|
||||
}
|
||||
|
|
@ -4576,14 +4602,13 @@ function buildApplicationMenu() {
|
|||
submenu: [
|
||||
IS_MAC
|
||||
? {
|
||||
accelerator: 'CommandOrControl+W',
|
||||
click: () => {
|
||||
if (previewShortcutActive) {
|
||||
sendClosePreviewRequested()
|
||||
} else {
|
||||
mainWindow?.close()
|
||||
}
|
||||
},
|
||||
// NO accelerator: on macOS a registered ⌘W is consumed by the OS
|
||||
// menu before the web contents ever sees it (and registerAccelerator
|
||||
// false is a no-op on mac — electron#18295). Leaving it off lets the
|
||||
// `before-input-event` handler below intercept ⌘W and route it to the
|
||||
// renderer's close-active-tab. Clicking the item still closes the tab
|
||||
// (or window) via the same request.
|
||||
click: () => sendClosePreviewRequested(),
|
||||
label: 'Close'
|
||||
}
|
||||
: { role: 'quit' }
|
||||
|
|
@ -4689,9 +4714,12 @@ function installDevToolsShortcut(window) {
|
|||
function installPreviewShortcut(window) {
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
const key = String(input.key || '').toLowerCase()
|
||||
const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
|
||||
const isCloseTabShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
|
||||
|
||||
if (!isPreviewCloseShortcut || !previewShortcutActive) {
|
||||
// Always claim ⌘W here (the File>Close item deliberately has no
|
||||
// accelerator, so nothing else does). The renderer decides tab-vs-window
|
||||
// — no `previewShortcutActive` gate, so it works for every closeable tab.
|
||||
if (!isCloseTabShortcut) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -7870,6 +7898,12 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
|||
// session so the cookie attaches automatically. Token/local modes keep using
|
||||
// the static session-token header.
|
||||
if (connection.authMode === 'oauth') {
|
||||
// The OAuth path rides electron.net with JSON headers; multipart isn't
|
||||
// wired there. Fail loudly rather than corrupting the upload.
|
||||
if (request?.upload) {
|
||||
throw new Error('File uploads are not supported against OAuth-gated remote backends yet.')
|
||||
}
|
||||
|
||||
return fetchJsonViaOauthSession(url, {
|
||||
method: request?.method,
|
||||
body: request?.body,
|
||||
|
|
@ -7880,6 +7914,7 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
|||
return fetchJson(url, connection.token, {
|
||||
method: request?.method,
|
||||
body: request?.body,
|
||||
upload: request?.upload,
|
||||
timeoutMs
|
||||
})
|
||||
})
|
||||
|
|
@ -8380,6 +8415,28 @@ ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
|
|||
}
|
||||
})
|
||||
|
||||
// Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike
|
||||
// `reveal` (which selects an existing item and silently no-ops on a missing
|
||||
// path — the "Open plugins folder" Windows bug), this is for the plugins door,
|
||||
// which often doesn't exist on first use. `shell.openPath` returns '' on
|
||||
// success or an error string; both mkdir + openPath failures are surfaced.
|
||||
ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => {
|
||||
const dir = String(dirPath || '').trim()
|
||||
|
||||
if (!dir) {
|
||||
return { ok: false, error: 'no path' }
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
const error = await shell.openPath(path.normalize(dir))
|
||||
|
||||
return error ? { ok: false, error } : { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
// Rename a file/folder in place. The renderer passes the existing path + a new
|
||||
// base name; the destination is resolved in the SAME parent dir so a rename can
|
||||
// never move the item elsewhere or traverse out. Rejects on a name collision.
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
|
||||
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
|
||||
openDir: dirPath => ipcRenderer.invoke('hermes:fs:openDir', dirPath),
|
||||
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
|
||||
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
|
||||
trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath),
|
||||
|
|
|
|||
|
|
@ -104,6 +104,25 @@ export default [
|
|||
react: { version: 'detect' }
|
||||
}
|
||||
},
|
||||
{
|
||||
// THE PLUGIN FENCE: plugins speak @hermes/plugin-sdk (+ react), never `@/…`
|
||||
// internals — the same isolation a runtime-fetched published plugin gets,
|
||||
// enforced on bundled ones so the SDK surface stays honest and sufficient.
|
||||
files: ['src/plugins/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@/*', '../*', '@hermes/shared'],
|
||||
message: 'Plugins import only @hermes/plugin-sdk (and react). Missing something? Add it to the SDK.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
|
||||
ignores: ['**/node_modules/**', '**/dist/**'],
|
||||
|
|
|
|||
|
|
@ -1,48 +1,48 @@
|
|||
import { useRef } from 'react'
|
||||
|
||||
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS } from '@/components/ui/drop-affordance'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ICONS: Record<'files' | 'session', string> = {
|
||||
files: 'cloud-upload',
|
||||
session: 'comment-discussion'
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-bleed affordance shown while files or a session are dragged over the chat
|
||||
* area. Always `pointer-events-none` so the drop lands on the real element
|
||||
* underneath and the drop-zone handler claims it — the overlay is purely visual.
|
||||
* Copy adapts to whatever is being dragged; the last kind is held through the
|
||||
* fade-out so the label doesn't blank.
|
||||
* The label names the outcome (attach files / link this chat); the last kind is
|
||||
* held through the fade-out so it doesn't blank.
|
||||
*/
|
||||
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
|
||||
const { t } = useI18n()
|
||||
const lastKind = useRef<'files' | 'session'>('files')
|
||||
const lastKind = useRef<DragKind>(kind)
|
||||
|
||||
if (kind) {
|
||||
lastKind.current = kind
|
||||
}
|
||||
|
||||
const resolvedKind = kind ?? lastKind.current
|
||||
const icon = ICONS[resolvedKind]
|
||||
const label = resolvedKind === 'files' ? t.composer.dropFiles : t.composer.dropSession
|
||||
const shown = kind ?? lastKind.current
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out',
|
||||
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center transition-opacity duration-150 ease-out',
|
||||
kind ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
data-slot="chat-drop-overlay"
|
||||
>
|
||||
<div className="absolute inset-2 rounded-2xl border-2 border-dashed border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)] backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]" />
|
||||
<div className="relative flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer">
|
||||
<Codicon className="text-(--ui-accent)" name={icon} size="1rem" />
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
DROP_SHEET_CLASS,
|
||||
DROP_SHEET_BLUR_CLASS,
|
||||
'absolute inset-2 border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)]'
|
||||
)}
|
||||
/>
|
||||
{shown && (
|
||||
<span className="relative text-[11px] font-medium uppercase tracking-wide text-foreground">
|
||||
{shown === 'session' ? t.composer.dropSession : t.composer.dropFiles}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
29
apps/desktop/src/app/chat/close-tab.ts
Normal file
29
apps/desktop/src/app/chat/close-tab.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
|
||||
import { isFocusWithin } from '@/lib/keybinds/combo'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
|
||||
|
||||
/**
|
||||
* ⌘W — close the tab of the context you're in, by precedence:
|
||||
* 1. a focused terminal → its active terminal tab,
|
||||
* 2. an open preview → its active preview tab (unchanged from pre-tiling),
|
||||
* 3. the MAIN zone → its active tab (a session tile stacked into the workspace).
|
||||
* Returns false when nothing closes, so ⌘W is a no-op — it never closes the
|
||||
* window (a bare workspace stays put). Shared by the keyboard path (Win/Linux)
|
||||
* and the macOS menu-accelerator IPC.
|
||||
*/
|
||||
export function closeActiveTab(): boolean {
|
||||
if (isFocusWithin('[data-terminal]')) {
|
||||
closeActiveTerminal()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if ($filePreviewTarget.get() || $previewTarget.get()) {
|
||||
closeActiveRightRailTab()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return closeWorkspaceTab()
|
||||
}
|
||||
|
|
@ -6,6 +6,12 @@ import { setSessionPickerOpen } from '@/store/session'
|
|||
|
||||
export const COMPOSER_STACK_BREAKPOINT_PX = 320
|
||||
|
||||
// Above the stack breakpoint but still cramped: the model pill sheds its label
|
||||
// for its chevron icon (freeing ~120px) so the controls stop crowding the input
|
||||
// before the whole row has to stack. Progressive collapse: full pill → icon
|
||||
// pill → stacked.
|
||||
export const COMPOSER_COMPACT_PILL_PX = 440
|
||||
|
||||
// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem
|
||||
// vertical padding). Anything taller means the text wrapped to a second line,
|
||||
// which is when the composer should expand to the stacked layout.
|
||||
|
|
@ -79,7 +85,5 @@ export function isPendingDraftPersistCurrent(
|
|||
pending: PendingDraftPersist | null,
|
||||
expected: PendingDraftPersist | null
|
||||
): boolean {
|
||||
return (
|
||||
pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text
|
||||
)
|
||||
return pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
|
|||
import { $composerPoppedOut } from '@/store/composer-popout'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
|
||||
import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
|
||||
|
||||
interface UseComposerMetricsArgs {
|
||||
composerRef: RefObject<HTMLFormElement | null>
|
||||
|
|
@ -24,10 +24,13 @@ interface UseComposerMetricsArgs {
|
|||
* Returns `stacked` (the only value the render needs).
|
||||
*/
|
||||
export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }: UseComposerMetricsArgs): {
|
||||
compactPill: boolean
|
||||
stacked: boolean
|
||||
} {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [tight, setTight] = useState(false)
|
||||
// Wider than `tight`: the pill goes icon-only before the row has to stack.
|
||||
const [compactPill, setCompactPill] = useState(false)
|
||||
const narrow = useMediaQuery('(max-width: 30rem)')
|
||||
|
||||
// Edge signals, not the live text: these only re-render when emptiness / the
|
||||
|
|
@ -72,6 +75,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
|
|||
const lastBucketedHeightRef = useRef(0)
|
||||
const lastBucketedSurfaceHeightRef = useRef(0)
|
||||
const lastTightRef = useRef<boolean | null>(null)
|
||||
const lastCompactPillRef = useRef<boolean | null>(null)
|
||||
|
||||
const syncComposerMetrics = useCallback(() => {
|
||||
const composer = composerRef.current
|
||||
|
|
@ -105,6 +109,13 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
|
|||
lastTightRef.current = nextTight
|
||||
setTight(nextTight)
|
||||
}
|
||||
|
||||
const nextCompactPill = width < COMPOSER_COMPACT_PILL_PX
|
||||
|
||||
if (nextCompactPill !== lastCompactPillRef.current) {
|
||||
lastCompactPillRef.current = nextCompactPill
|
||||
setCompactPill(nextCompactPill)
|
||||
}
|
||||
}
|
||||
|
||||
// Expand once the input has actually wrapped past a single line. The
|
||||
|
|
@ -156,5 +167,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
|
|||
}
|
||||
}, [])
|
||||
|
||||
return { stacked: expanded || narrow || tight }
|
||||
// Pill compacts on real width (tile/pane), OR when stacked for any reason
|
||||
// (viewport-narrow / wrapped) so the controls row never over-runs.
|
||||
return { compactPill: compactPill || narrow || tight, stacked: expanded || narrow || tight }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,22 +1,21 @@
|
|||
import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useEffect, useRef } from 'react'
|
||||
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Slot as ContribSlot } from '@/contrib/react/slot'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $composerAttachments } from '@/store/composer'
|
||||
import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history'
|
||||
import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
|
||||
import { removeQueuedPrompt } from '@/store/composer-queue'
|
||||
import { $activeSessionAwaitingInput } from '@/store/prompts'
|
||||
import { toggleReview } from '@/store/review'
|
||||
import { $gatewayState, $messages } from '@/store/session'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { $autoSpeakReplies } from '@/store/voice-prefs'
|
||||
import { useTheme } from '@/themes'
|
||||
|
|
@ -24,6 +23,7 @@ import { useTheme } from '@/themes'
|
|||
import { AttachmentList } from './attachments'
|
||||
import { COMPOSER_FADE_BACKGROUND, type QueueEditState, slashArgStage } from './composer-utils'
|
||||
import { ContextMenu } from './context-menu'
|
||||
import { COMPOSER_AREAS, runComposerMiddleware } from './contrib'
|
||||
import { ComposerControls } from './controls'
|
||||
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance'
|
||||
import { markActiveComposer } from './focus'
|
||||
|
|
@ -52,6 +52,7 @@ import {
|
|||
normalizeComposerEditorDom,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { useComposerScope } from './scope'
|
||||
import { ComposerStatusStack } from './status-stack'
|
||||
import { CodingStatusRow } from './status-stack/coding-row'
|
||||
import { extractClipboardImageBlobs } from './text-utils'
|
||||
|
|
@ -80,17 +81,36 @@ export function ChatBar({
|
|||
onPickImages,
|
||||
onRemoveAttachment,
|
||||
onSteer,
|
||||
onSubmit,
|
||||
onSubmit: onSubmitProp,
|
||||
onTranscribeAudio
|
||||
}: ChatBarProps) {
|
||||
const attachments = useStore($composerAttachments)
|
||||
// Every send (typed, queued, voice) passes through the contributed
|
||||
// middleware chain first — rewrite / pass-through / cancel. Empty chain =
|
||||
// exact pass-through, so surfaces without contributions are byte-identical.
|
||||
const onSubmit = useCallback<ChatBarProps['onSubmit']>(
|
||||
async (value, options) => {
|
||||
const draft = await runComposerMiddleware({ text: value, attachments: options?.attachments })
|
||||
|
||||
if (!draft) {
|
||||
return false
|
||||
}
|
||||
|
||||
return onSubmitProp(draft.text, { ...options, attachments: draft.attachments })
|
||||
},
|
||||
[onSubmitProp]
|
||||
)
|
||||
|
||||
// Which live composer this instance IS (main | tile) — its attachment set,
|
||||
// focus-bus key, and awaiting-input edge. Main scope = the legacy globals.
|
||||
const scope = useComposerScope()
|
||||
const attachments = useStore(scope.attachments.$attachments)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const autoSpeak = useStore($autoSpeakReplies)
|
||||
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
|
||||
// not interrupt it — there's nothing actively running to stop, and stopping
|
||||
// would discard a question the user may want to come back to. The blocking
|
||||
// prompt owns its own dismissal (Skip, Reject, dialog close).
|
||||
const awaitingInput = useStore($activeSessionAwaitingInput)
|
||||
const awaitingInput = useStore(scope.$awaitingInput)
|
||||
const activeQueueSessionKey = queueSessionKey || sessionId || null
|
||||
|
||||
// Status items (subagents, background processes) are keyed by the RUNTIME
|
||||
|
|
@ -189,7 +209,7 @@ export function ChatBar({
|
|||
|
||||
const statusStackVisible = queuedPrompts.length > 0 || statusPresent
|
||||
|
||||
const { stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
|
||||
const { compactPill, stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
|
||||
const hasComposerPayload = hasText || attachments.length > 0
|
||||
const canSubmit = busy || hasComposerPayload
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
|
|
@ -198,8 +218,6 @@ export function ChatBar({
|
|||
// into a tool result) and never for a slash command (those execute inline).
|
||||
const canSteer = busy && !!onSteer && attachments.length === 0 && isSteerableText
|
||||
|
||||
const showHelpHint = isHelpHint
|
||||
|
||||
// The submit engine — the orchestration seam where draft + queue meet. Owns
|
||||
// the submit decision tree, the send-with-restore primitive, and steer.
|
||||
const { steerDraft, submitDraft } = useComposerSubmit({
|
||||
|
|
@ -507,11 +525,11 @@ export function ChatBar({
|
|||
|
||||
// $messages is read imperatively (not subscribed) so the composer
|
||||
// doesn't re-render on every streaming delta flush.
|
||||
const history = deriveUserHistory($messages.get(), chatMessageText)
|
||||
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
|
||||
const entry = browseBackward(sessionId, currentDraft, history)
|
||||
|
||||
if (entry !== null) {
|
||||
loadIntoComposer(entry, $composerAttachments.get())
|
||||
loadIntoComposer(entry, scope.attachments.$attachments.get())
|
||||
}
|
||||
|
||||
return
|
||||
|
|
@ -532,11 +550,11 @@ export function ChatBar({
|
|||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
const history = deriveUserHistory($messages.get(), chatMessageText)
|
||||
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
|
||||
const result = browseForward(sessionId, history)
|
||||
|
||||
if (result !== null) {
|
||||
loadIntoComposer(result.text, $composerAttachments.get())
|
||||
loadIntoComposer(result.text, scope.attachments.$attachments.get())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -644,7 +662,7 @@ export function ChatBar({
|
|||
useComposerBranch({ clearDraft, cwd, draftRef })
|
||||
|
||||
// Global Esc-to-cancel when the chat (not the composer input) has focus.
|
||||
useComposerEscCancel({ awaitingInput, busy, onCancel })
|
||||
useComposerEscCancel({ awaitingInput, busy, onCancel, target: scope.target })
|
||||
|
||||
const {
|
||||
conversation,
|
||||
|
|
@ -664,7 +682,8 @@ export function ChatBar({
|
|||
maxRecordingSeconds,
|
||||
onSubmit,
|
||||
onTranscribeAudio,
|
||||
sessionId
|
||||
sessionId,
|
||||
target: scope.target
|
||||
})
|
||||
|
||||
const contextMenu = (
|
||||
|
|
@ -686,7 +705,7 @@ export function ChatBar({
|
|||
busyAction={busyAction}
|
||||
canSteer={canSteer}
|
||||
canSubmit={canSubmit}
|
||||
compactModelPill={poppedOut}
|
||||
compactModelPill={poppedOut || compactPill}
|
||||
conversation={{
|
||||
active: voiceConversationActive,
|
||||
level: conversation.level,
|
||||
|
|
@ -742,7 +761,7 @@ export function ChatBar({
|
|||
}}
|
||||
onDragOver={handleInputDragOver}
|
||||
onDrop={handleInputDrop}
|
||||
onFocus={() => markActiveComposer('main')}
|
||||
onFocus={() => markActiveComposer(scope.target)}
|
||||
onInput={handleEditorInput}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
onKeyUp={handleEditorKeyUp}
|
||||
|
|
@ -846,7 +865,7 @@ export function ChatBar({
|
|||
: undefined
|
||||
}
|
||||
>
|
||||
{showHelpHint && <HelpHint />}
|
||||
{isHelpHint && <HelpHint />}
|
||||
{trigger && !argStageEmpty && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
|
|
@ -936,6 +955,10 @@ export function ChatBar({
|
|||
)}
|
||||
data-slot="composer-fade"
|
||||
>
|
||||
{/* Contribution seams: banners above, a row below, inline
|
||||
additions beside the "+" menu and before the controls.
|
||||
All four render nothing until something contributes. */}
|
||||
<ContribSlot area={COMPOSER_AREAS.top} />
|
||||
<VoiceActivity state={voiceActivityState} />
|
||||
<VoicePlaybackActivity />
|
||||
{queueEdit && editingQueuedPrompt && (
|
||||
|
|
@ -971,10 +994,17 @@ export function ChatBar({
|
|||
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
|
||||
)}
|
||||
>
|
||||
<div className="flex translate-y-[3px] items-start self-start [grid-area:menu]">{contextMenu}</div>
|
||||
<div className="flex translate-y-[3px] items-start gap-(--composer-control-gap) self-start [grid-area:menu]">
|
||||
{contextMenu}
|
||||
<ContribSlot area={COMPOSER_AREAS.leading} />
|
||||
</div>
|
||||
<div className="min-w-0 [grid-area:input]">{input}</div>
|
||||
<div className="flex items-center justify-end [grid-area:controls]">{controls}</div>
|
||||
<div className="flex items-center justify-end gap-(--composer-control-gap) [grid-area:controls]">
|
||||
<ContribSlot area={COMPOSER_AREAS.actions} />
|
||||
{controls}
|
||||
</div>
|
||||
</div>
|
||||
<ContribSlot area={COMPOSER_AREAS.bottom} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { contextPath } from '@/lib/chat-runtime'
|
||||
|
||||
import type { DroppedFile } from '../hooks/use-composer-actions'
|
||||
|
|
@ -8,44 +9,22 @@ import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipEl
|
|||
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
|
||||
export type InlineRefInput = string | { kind: string; label?: string; value: string }
|
||||
|
||||
/** MIME for an in-app session drag (sidebar row → composer). */
|
||||
export const HERMES_SESSION_MIME = 'application/x-hermes-session'
|
||||
|
||||
/** A dragged sidebar session — carried in-memory by the pointer drag session
|
||||
* (session-drag.ts); sessions never ride native DnD. */
|
||||
export interface SessionDragPayload {
|
||||
id: string
|
||||
profile: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export function writeSessionDrag(transfer: DataTransfer, payload: SessionDragPayload) {
|
||||
transfer.setData(HERMES_SESSION_MIME, JSON.stringify(payload))
|
||||
transfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
export function dragHasSession(transfer: DataTransfer | null) {
|
||||
return Boolean(transfer) && Array.from(transfer!.types || []).includes(HERMES_SESSION_MIME)
|
||||
}
|
||||
|
||||
export function readSessionDrag(transfer: DataTransfer | null): null | SessionDragPayload {
|
||||
const raw = transfer?.getData(HERMES_SESSION_MIME)
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<SessionDragPayload>
|
||||
|
||||
return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
/** A session's friendly display label — its title, or a localized fallback. */
|
||||
export const sessionLabel = ({ id, title }: SessionDragPayload) =>
|
||||
title || translateNow('sidebar.row.untitledChat', id.slice(0, 8))
|
||||
|
||||
/** Build a `@session:<profile>/<id>` chip. Value carries the metadata the agent
|
||||
* needs to resolve the link (session_search); label shows the friendly title. */
|
||||
export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput {
|
||||
return { kind: 'session', label: title || `chat ${id.slice(0, 8)}`, value: `${profile || 'default'}/${id}` }
|
||||
export function sessionInlineRef(payload: SessionDragPayload): InlineRefInput {
|
||||
return { kind: 'session', label: sessionLabel(payload), value: `${payload.profile || 'default'}/${payload.id}` }
|
||||
}
|
||||
|
||||
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {
|
||||
|
|
|
|||
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,53 +1,75 @@
|
|||
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react'
|
||||
import { type DragEvent as ReactDragEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
dragHasAttachments,
|
||||
dragHasSession,
|
||||
readSessionDrag,
|
||||
type SessionDragPayload
|
||||
} from '@/app/chat/composer/inline-refs'
|
||||
import { dragHasAttachments } from '@/app/chat/composer/inline-refs'
|
||||
import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers'
|
||||
|
||||
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from './use-composer-actions'
|
||||
|
||||
/** `'session'` is set by callers from the pointer drag session's store —
|
||||
* native drags only ever resolve to `'files'` here (sessions left native
|
||||
* DnD; see session-drag.ts). */
|
||||
export type DragKind = 'files' | 'session' | null
|
||||
|
||||
const dragKindOf = (event: ReactDragEvent): DragKind => {
|
||||
if (dragHasSession(event.dataTransfer)) {
|
||||
return 'session'
|
||||
}
|
||||
|
||||
if (dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return 'files'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
const dragKindOf = (event: ReactDragEvent): DragKind =>
|
||||
dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME) ? 'files' : null
|
||||
|
||||
interface FileDropZoneOptions {
|
||||
/** When false the zone ignores drags entirely. */
|
||||
enabled?: boolean
|
||||
onDropFiles: (files: DroppedFile[]) => void
|
||||
onDropSession?: (session: SessionDragPayload) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* "Drop anywhere in this region" affordance for files *and* in-app session
|
||||
* links. An enter/leave depth counter keeps nested children from flickering the
|
||||
* "Drop anywhere in this region" affordance for FILE drags — the one drag
|
||||
* kind still on native DnD (Finder/OS drops and the project tree must be).
|
||||
* An enter/leave depth counter keeps nested children from flickering the
|
||||
* active state; `onDropCapture` clears it even when a nested target (the
|
||||
* composer) handles the drop and stops propagation before our bubble-phase
|
||||
* `onDrop` would fire.
|
||||
*
|
||||
* Spread `dropHandlers` onto the container; render an overlay off `dragKind`.
|
||||
* Esc aborts an in-flight drag, matching the sidebar session drag.
|
||||
*/
|
||||
export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: FileDropZoneOptions) {
|
||||
export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) {
|
||||
const [dragKind, setDragKind] = useState<DragKind>(null)
|
||||
const depth = useRef(0)
|
||||
const aborted = useRef(false)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
depth.current = 0
|
||||
setDragKind(null)
|
||||
}, [])
|
||||
|
||||
// Esc aborts a file drag — the same "never mind" a session drag gets. Native
|
||||
// DnD can't be cancelled at the OS level, so we drop the overlay and arm a
|
||||
// guard that swallows the trailing drop instead. Top escape layer + capture
|
||||
// stop so it doesn't also fire a handler behind the drag (see drag-session).
|
||||
useEffect(() => {
|
||||
if (dragKind === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.drag)
|
||||
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
aborted.current = true
|
||||
reset()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey, true)
|
||||
releaseLayer()
|
||||
}
|
||||
}, [dragKind, reset])
|
||||
|
||||
const onDragEnter = useCallback(
|
||||
(event: ReactDragEvent) => {
|
||||
const kind = enabled ? dragKindOf(event) : null
|
||||
|
|
@ -57,6 +79,12 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }:
|
|||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
// A genuinely new drag (not a nested-child re-enter) re-arms after abort.
|
||||
if (depth.current === 0) {
|
||||
aborted.current = false
|
||||
}
|
||||
|
||||
depth.current += 1
|
||||
setDragKind(kind)
|
||||
},
|
||||
|
|
@ -89,16 +117,17 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }:
|
|||
return
|
||||
}
|
||||
|
||||
// Only an Esc abort swallows the drop — NOT `event.defaultPrevented`. The
|
||||
// file tree's app-wide react-dnd HTML5Backend preventDefaults every native
|
||||
// file drop in the capture phase, so that flag is always set here (every
|
||||
// Finder drop would no-op). Genuine nested targets claim via stopPropagation
|
||||
// and never reach this bubble handler anyway.
|
||||
const claimed = aborted.current
|
||||
|
||||
event.preventDefault()
|
||||
reset()
|
||||
|
||||
if (kind === 'session') {
|
||||
const session = readSessionDrag(event.dataTransfer)
|
||||
|
||||
if (session) {
|
||||
onDropSession?.(session)
|
||||
}
|
||||
|
||||
if (claimed) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +137,7 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }:
|
|||
onDropFiles(files)
|
||||
}
|
||||
},
|
||||
[enabled, onDropFiles, onDropSession, reset]
|
||||
[enabled, onDropFiles, reset]
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
121
apps/desktop/src/app/chat/pane-mirror.ts
Normal file
121
apps/desktop/src/app/chat/pane-mirror.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Mirror a reactive list of "tiles" into layout-tree pane contributions:
|
||||
* register a pane per tile, refresh its title in place, and dispose panes whose
|
||||
* tile is gone. This is the shared bookkeeping — a keyed registry, a wanted-set
|
||||
* diff, a one-time pane closer — behind BOTH session tiles and route (page)
|
||||
* tiles; each supplies only what differs (key, title, render, close, edge).
|
||||
*/
|
||||
|
||||
import type { ReadableAtom } from 'nanostores'
|
||||
import type { ReactElement, ReactNode, PointerEvent as ReactPointerEvent } from 'react'
|
||||
|
||||
import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session'
|
||||
import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import type { TileDock } from '@/store/session-states'
|
||||
|
||||
export interface PaneMirror<T> {
|
||||
/** Reactive source list. */
|
||||
source: ReadableAtom<T[]>
|
||||
/** Extra atoms whose changes should re-sync (e.g. titles living elsewhere). */
|
||||
also?: ReadableAtom<unknown>[]
|
||||
/** Stable key + pane-id seed for a tile. */
|
||||
key: (tile: T) => string
|
||||
/** Pane-id namespace — the id is `${prefix}:${key}`. */
|
||||
prefix: string
|
||||
/** Dock on adoption (default right; `center` = stack into anchor's zone). */
|
||||
dir?: (tile: T) => TileDock | undefined
|
||||
/** Pane to dock against (default `workspace`) — a drop's target zone. */
|
||||
anchor?: (tile: T) => string | undefined
|
||||
/** Center docks: the strip slot (stack before this pane id). */
|
||||
before?: (tile: T) => null | string | undefined
|
||||
minWidth: string
|
||||
title: (key: string) => string
|
||||
render: (key: string) => ReactNode
|
||||
/** Wrap the tile's TAB (domain context menu — session verbs). */
|
||||
tabWrap?: (key: string, tab: ReactElement) => ReactNode
|
||||
/** Override the tile's TAB drag (session drop language: stack/split/link).
|
||||
* Returns whether it took the drag (see PaneChrome.tabDrag). */
|
||||
tabDrag?: (
|
||||
key: string,
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
onTap: () => void,
|
||||
double?: DoubleTapContext
|
||||
) => boolean
|
||||
/** Wired as the pane's closer (tab Close). */
|
||||
close: (key: string) => void
|
||||
}
|
||||
|
||||
/** Build a `watch*` fn: syncs once, then re-syncs on every source/also change.
|
||||
* Module-level state lives in the returned closure, so call it once per app. */
|
||||
export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
|
||||
const registered = new Map<string, { dispose: () => void; title: string }>()
|
||||
const paneId = (key: string) => `${cfg.prefix}:${key}`
|
||||
|
||||
const sync = () => {
|
||||
const tiles = cfg.source.get()
|
||||
const wanted = new Set(tiles.map(cfg.key))
|
||||
|
||||
for (const tile of tiles) {
|
||||
const key = cfg.key(tile)
|
||||
const title = cfg.title(key)
|
||||
const current = registered.get(key)
|
||||
|
||||
// register() replaces same-id in place — safe for live title refreshes.
|
||||
if (current && current.title === title) {
|
||||
continue
|
||||
}
|
||||
|
||||
const dispose = registry.register({
|
||||
id: paneId(key),
|
||||
area: 'panes',
|
||||
title,
|
||||
data: {
|
||||
dock: {
|
||||
before: cfg.before?.(tile),
|
||||
pane: cfg.anchor?.(tile) ?? 'workspace',
|
||||
pos: cfg.dir?.(tile) ?? 'right'
|
||||
},
|
||||
minWidth: cfg.minWidth,
|
||||
placement: 'main',
|
||||
tabDrag: cfg.tabDrag
|
||||
? (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) =>
|
||||
cfg.tabDrag!(key, event, onTap, double)
|
||||
: undefined, // returns boolean (handled) — see PaneChrome.tabDrag
|
||||
tabWrap: cfg.tabWrap ? (tab: ReactElement) => cfg.tabWrap!(key, tab) : undefined
|
||||
},
|
||||
render: () => cfg.render(key)
|
||||
})
|
||||
|
||||
registered.set(key, { dispose, title })
|
||||
|
||||
if (!current) {
|
||||
registerPaneCloser(paneId(key), () => cfg.close(key))
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, entry] of registered) {
|
||||
if (!wanted.has(key)) {
|
||||
entry.dispose()
|
||||
registered.delete(key)
|
||||
removeTreePane(paneId(key))
|
||||
}
|
||||
}
|
||||
|
||||
// Prune tree panes the SHARED tree persisted for a tile we never registered
|
||||
// this session and that isn't wanted now — a profile switch reloads with the
|
||||
// other profile's tile panes still stacked in. (`registered` is empty after a
|
||||
// reload, so the loop above can't catch these.)
|
||||
for (const id of treePanesWithPrefix(`${cfg.prefix}:`)) {
|
||||
if (!wanted.has(id.slice(cfg.prefix.length + 1))) {
|
||||
removeTreePane(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
sync()
|
||||
cfg.source.listen(sync)
|
||||
cfg.also?.forEach(atom => atom.listen(sync))
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
193
apps/desktop/src/app/chat/session-drag.ts
Normal file
193
apps/desktop/src/app/chat/session-drag.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* Sidebar session drag — the session RESOLVER over the shared pointer drag
|
||||
* session (pane-shell drag-session.ts). Same machinery as a pane drag
|
||||
* (threshold, rAF moves, snapshots, Esc-as-top-layer with synchronous
|
||||
* teardown), session-specific targeting:
|
||||
*
|
||||
* - a chat zone's TAB STRIP → stack: open the session as a tab at the
|
||||
* divider's slot (the strip caret shows it);
|
||||
* - a chat zone's EDGE band → split: open the session as a tile docked on
|
||||
* that edge (the zone sheet morphs to the half);
|
||||
* - a chat zone's CENTER / the composer → link: insert an `@session` chip
|
||||
* into that surface's composer (ChatDropOverlay owns the visual);
|
||||
* - anything else (sidebar, terminal, gutters) → deny.
|
||||
*
|
||||
* Zones that don't host a chat surface are NOT targets — the overlay never
|
||||
* lights them, so a release there must not commit either (one truth).
|
||||
*
|
||||
* This replaced the native-HTML5 drag + SessionTileDropBridge: riding the
|
||||
* native DnD layer meant macOS's cancel snap-back animation, a `dragend`
|
||||
* held hostage until that animation finished, an Esc the page never even
|
||||
* saw, and window-level armor against react-dnd/dnd-kit. A pointer session
|
||||
* has none of those failure modes. Native DnD remains only at the true OS
|
||||
* boundary (Finder file drops). Known trade: a session can no longer be
|
||||
* dragged into a separate BrowserWindow (native DnD was the only transport
|
||||
* that crossed windows).
|
||||
*/
|
||||
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react'
|
||||
|
||||
import { findGroup } from '@/components/pane-shell/tree/model'
|
||||
import {
|
||||
type DoubleTapContext,
|
||||
rectContains,
|
||||
slotBefore,
|
||||
snapshotStrips,
|
||||
snapshotZones,
|
||||
startDragSession,
|
||||
type StripSnapshot,
|
||||
subZonePosition
|
||||
} from '@/components/pane-shell/tree/renderer/drag-session'
|
||||
import {
|
||||
$layoutTree,
|
||||
$treeDragging,
|
||||
type DropHint,
|
||||
revealTreePane,
|
||||
SESSION_TILE_DRAG
|
||||
} from '@/components/pane-shell/tree/store'
|
||||
import type { EngineZone, ZoneRect } from '@/components/pane-shell/tree/zones-engine'
|
||||
import { openSessionTile, type TileDock } from '@/store/session-states'
|
||||
|
||||
import { requestComposerInsertRefs } from './composer/focus'
|
||||
import { type SessionDragPayload, sessionInlineRef, sessionLabel } from './composer/inline-refs'
|
||||
|
||||
/** A chat surface's drag-start geometry: the anchor pane id it advertises
|
||||
* (`data-session-anchor`) and the composer a link drop routes to
|
||||
* (`data-composer-target`). */
|
||||
interface SurfaceSnapshot {
|
||||
anchor: string
|
||||
composerTarget: string
|
||||
rect: ZoneRect
|
||||
}
|
||||
|
||||
const snapRect = (el: HTMLElement): ZoneRect => {
|
||||
const r = el.getBoundingClientRect()
|
||||
|
||||
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }
|
||||
}
|
||||
|
||||
function snapshotSurfaces(): SurfaceSnapshot[] {
|
||||
return [...document.querySelectorAll<HTMLElement>('[data-session-anchor]')].map(el => ({
|
||||
anchor: el.dataset.sessionAnchor || 'workspace',
|
||||
composerTarget: el.dataset.composerTarget || 'main',
|
||||
rect: snapRect(el)
|
||||
}))
|
||||
}
|
||||
|
||||
/** A session may land in a zone only if it hosts a chat surface — never the
|
||||
* sidebar/terminal zones. Returns the pane a stack anchors to. */
|
||||
function chatZonePane(groupId: string): null | string {
|
||||
const tree = $layoutTree.get()
|
||||
const panes = tree ? (findGroup(tree, groupId)?.panes ?? []) : []
|
||||
|
||||
return panes.find(p => p === 'workspace' || p.startsWith('session-tile:')) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin dragging a session — a sidebar row OR a tile's own tab (same drop
|
||||
* language either way: stack, split, or composer link). Sub-threshold releases
|
||||
* stay ordinary clicks, so `opts.onTap` (activate the tile) and `opts.double`
|
||||
* (hide the tab bar) ride the tab's gestures; Esc aborts instantly. A stack/
|
||||
* split commits through `openSessionTile`, which OPENS a new tile from a sidebar
|
||||
* row and MOVES the existing one when its tab is the drag source.
|
||||
*/
|
||||
export function startSessionDrag(
|
||||
payload: SessionDragPayload,
|
||||
e: ReactPointerEvent<HTMLElement>,
|
||||
opts?: { double?: DoubleTapContext; onTap?: () => void }
|
||||
) {
|
||||
let zones: EngineZone[] = []
|
||||
let strips: StripSnapshot[] = []
|
||||
let surfaces: SurfaceSnapshot[] = []
|
||||
let composers: ZoneRect[] = []
|
||||
let zoneHost = new Map<string, null | string>()
|
||||
|
||||
// Commit intent, updated per resolved move (the machinery flushes the final
|
||||
// move before commit, so these always match the released-at position).
|
||||
let split: { anchor: string; before?: null | string; pos: TileDock } | null = null
|
||||
let link: null | string = null
|
||||
|
||||
// The drag SOURCE (sidebar row or tile tab). Captured synchronously — React
|
||||
// clears `currentTarget` after the pointerdown handler returns, but this runs
|
||||
// inside it. Dimmed while lifted so the source reads as "picked up" — the
|
||||
// same in-place feedback pane-tab drags use, replacing the old cursor chip.
|
||||
const source = e.currentTarget
|
||||
const restoreOpacity = source?.style.opacity ?? ''
|
||||
|
||||
startDragSession(e, {
|
||||
double: opts?.double,
|
||||
ghost: { label: sessionLabel(payload) },
|
||||
onTap: opts?.onTap,
|
||||
|
||||
onEngage() {
|
||||
zones = snapshotZones()
|
||||
strips = snapshotStrips()
|
||||
surfaces = snapshotSurfaces()
|
||||
composers = [...document.querySelectorAll<HTMLElement>('[data-slot="composer-root"]')].map(snapRect)
|
||||
zoneHost = new Map(zones.map(zone => [zone.id, chatZonePane(zone.id)]))
|
||||
source?.style.setProperty('opacity', '0.45')
|
||||
// The same sentinel the zone overlay + chat surfaces key off — the
|
||||
// whole drop language (sheets, pills, caret, link overlay) lights up.
|
||||
$treeDragging.set(SESSION_TILE_DRAG)
|
||||
},
|
||||
|
||||
onEnd() {
|
||||
if (source) {
|
||||
source.style.opacity = restoreOpacity
|
||||
}
|
||||
},
|
||||
|
||||
resolveMove(x, y): DropHint | null {
|
||||
const zone = zones.find(z => rectContains(z.rect, x, y))
|
||||
const host = zone ? zoneHost.get(zone.id) : null
|
||||
|
||||
if (!zone || !host) {
|
||||
split = null
|
||||
link = null
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// The zone's TAB STRIP stacks the session at the divider's slot.
|
||||
const strip = strips.find(s => s.groupId === zone.id && rectContains(s.rect, x, y))
|
||||
|
||||
if (strip) {
|
||||
// Exclude the tile's OWN tab from the slots so re-dropping it in its
|
||||
// home strip reorders cleanly (a no-op for a sidebar-row drag).
|
||||
const stack = slotBefore(strip.slots, x, `session-tile:${payload.id}`)
|
||||
split = { anchor: host, before: stack.before, pos: 'center' }
|
||||
link = null
|
||||
|
||||
return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos: 'center', stack }
|
||||
}
|
||||
|
||||
// The composer (and everything in it) is always the link/attach drop;
|
||||
// elsewhere the shared radial targeting decides center vs edge.
|
||||
const pos = composers.some(rect => rectContains(rect, x, y)) ? 'center' : subZonePosition(zones, zone.id, x, y)
|
||||
const surface = surfaces.find(s => rectContains(s.rect, x, y))
|
||||
|
||||
if (pos === 'center') {
|
||||
split = null
|
||||
link = surface?.composerTarget ?? 'main'
|
||||
} else {
|
||||
split = { anchor: surface?.anchor ?? 'workspace', pos }
|
||||
link = null
|
||||
}
|
||||
|
||||
return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos }
|
||||
},
|
||||
|
||||
onCommit() {
|
||||
if (split) {
|
||||
openSessionTile(payload.id, split.pos, split.anchor, split.before)
|
||||
// A tile for this session may already exist (openSessionTile is
|
||||
// idempotent — e.g. persisted from an earlier run): a drop must never
|
||||
// feel dead, so front/unhide/un-dismiss it either way.
|
||||
revealTreePane(`session-tile:${payload.id}`)
|
||||
} else if (link) {
|
||||
// The "link to chat" drop: an @session chip in that surface's composer.
|
||||
requestComposerInsertRefs([sessionInlineRef(payload)], { target: link })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
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]
|
||||
)
|
||||
}
|
||||
436
apps/desktop/src/app/chat/session-tile.tsx
Normal file
436
apps/desktop/src/app/chat/session-tile.tsx
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
/**
|
||||
* SESSION TILES — a stored session rendered as a layout-tree pane BESIDE the
|
||||
* main thread (multi-session tiling). A tile IS the real chat surface: the
|
||||
* same ChatView/ChatBar/Thread tree the primary session renders, mounted
|
||||
* under a tile `SessionView` (its session's slice of `$sessionStates`) and a
|
||||
* tile `ComposerScope` (own attachment chips, own focus-bus key). Actions
|
||||
* (submit/slash/steer/edit/reload/restore/stop) come from
|
||||
* `useSessionTileActions`, all writing through the wiring cache.
|
||||
*
|
||||
* Lifecycle: `openSessionTile(storedId)` -> `watchSessionTiles` registers a
|
||||
* pane contribution docked right of the main zone -> tree adoption lands it
|
||||
* -> the pane mounts and asks the delegate for a live runtime id. Closing
|
||||
* the pane (tab Close) removes the tile + its zone; tiles persist across
|
||||
* restarts and re-resume on boot.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { atom, computed } from 'nanostores'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
|
||||
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
|
||||
import { $layoutTree, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { transcribeAudio } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { createComposerAttachmentScope } from '@/store/composer'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
|
||||
import { sessionAwaitingInput } from '@/store/prompts'
|
||||
import {
|
||||
$gatewayState,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
sessionMatchesStoredId,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
import {
|
||||
$sessionStates,
|
||||
$sessionTiles,
|
||||
closeSessionTile,
|
||||
discardSessionTile,
|
||||
patchSessionTile,
|
||||
type SessionTile,
|
||||
sessionTileDelegate
|
||||
} from '@/store/session-states'
|
||||
|
||||
import type { SessionDragPayload } from './composer/inline-refs'
|
||||
import { type ComposerScope, ComposerScopeProvider } from './composer/scope'
|
||||
import { useComposerActions } from './hooks/use-composer-actions'
|
||||
import { paneMirror } from './pane-mirror'
|
||||
import { startSessionDrag } from './session-drag'
|
||||
import { useSessionTileActions } from './session-tile-actions'
|
||||
import { type SessionView, SessionViewProvider } from './session-view'
|
||||
import { SessionContextMenu } from './sidebar/session-actions-menu'
|
||||
import { lastVisibleMessageIsUser } from './thread-loading'
|
||||
|
||||
import { ChatView } from '.'
|
||||
|
||||
const NO_MESSAGES: ChatMessage[] = []
|
||||
|
||||
/** The tile's SessionView: the same atom shape the primary chat renders
|
||||
* from, computed from this session's slice of `$sessionStates`. */
|
||||
function buildTileView(storedSessionId: string): SessionView {
|
||||
const $runtimeId = computed(
|
||||
$sessionTiles,
|
||||
tiles => tiles.find(t => t.storedSessionId === storedSessionId)?.runtimeId ?? null
|
||||
)
|
||||
|
||||
const $state = computed([$runtimeId, $sessionStates], (runtimeId, states) =>
|
||||
runtimeId ? states[runtimeId] : undefined
|
||||
)
|
||||
|
||||
const $messages = computed($state, state => state?.messages ?? NO_MESSAGES)
|
||||
|
||||
return {
|
||||
kind: 'tile',
|
||||
$awaitingResponse: computed($state, state => Boolean(state?.awaitingResponse)),
|
||||
$busy: computed($state, state => Boolean(state?.busy)),
|
||||
$cwd: computed($state, state => state?.cwd ?? ''),
|
||||
$lastVisibleIsUser: computed($messages, lastVisibleMessageIsUser),
|
||||
$messages,
|
||||
$messagesEmpty: computed($messages, messages => messages.length === 0),
|
||||
$model: computed($state, state => state?.model ?? ''),
|
||||
$provider: computed($state, state => state?.provider ?? ''),
|
||||
$runtimeId,
|
||||
// Constant for the tile's lifetime — a plain atom, not a computed.
|
||||
$storedId: atom(storedSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
function TileChat({
|
||||
runtimeId,
|
||||
storedSessionId,
|
||||
view
|
||||
}: {
|
||||
runtimeId: string
|
||||
storedSessionId: string
|
||||
view: SessionView
|
||||
}) {
|
||||
const { gatewayRef, requestGateway } = useGatewayRequest()
|
||||
const cwd = useStore(view.$cwd)
|
||||
|
||||
// One attachment set + focus key per tile, stable for the tile's lifetime.
|
||||
const attachments = useRef(createComposerAttachmentScope()).current
|
||||
|
||||
const scope = useMemo<ComposerScope>(
|
||||
() => ({
|
||||
$awaitingInput: sessionAwaitingInput(runtimeId),
|
||||
attachments,
|
||||
popoutAllowed: false,
|
||||
readMessages: () => view.$messages.get(),
|
||||
target: `tile:${storedSessionId}`
|
||||
}),
|
||||
[attachments, runtimeId, storedSessionId, view.$messages]
|
||||
)
|
||||
|
||||
const actions = useSessionTileActions({ runtimeId, scope, storedSessionId })
|
||||
|
||||
// The same attach/pick/paste/drop pipeline the primary composer uses,
|
||||
// pointed at this tile's chips + session.
|
||||
const composer = useComposerActions({
|
||||
activeSessionId: runtimeId,
|
||||
currentCwd: cwd,
|
||||
requestGateway,
|
||||
scope: { add: attachments.add, remove: attachments.remove, target: scope.target }
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionViewProvider value={view}>
|
||||
<ComposerScopeProvider value={scope}>
|
||||
<ChatView
|
||||
gateway={gatewayRef.current}
|
||||
onAddContextRef={composer.addContextRefAttachment}
|
||||
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
|
||||
onAttachDroppedItems={composer.attachDroppedItems}
|
||||
onAttachImageBlob={composer.attachImageBlob}
|
||||
onBranchInNewChat={() => undefined}
|
||||
onCancel={actions.cancelRun}
|
||||
onDeleteSelectedSession={() => undefined}
|
||||
onDismissError={actions.dismissError}
|
||||
onEdit={actions.editMessage}
|
||||
onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)}
|
||||
onPickFiles={() => void composer.pickContextPaths('file')}
|
||||
onPickFolders={() => void composer.pickContextPaths('folder')}
|
||||
onPickImages={() => void composer.pickImages()}
|
||||
onReload={actions.reloadFromMessage}
|
||||
onRemoveAttachment={id => void composer.removeAttachment(id)}
|
||||
onRestoreToMessage={actions.restoreToMessage}
|
||||
onRetryResume={() => patchSessionTile(storedSessionId, { error: undefined })}
|
||||
onSteer={actions.steerPrompt}
|
||||
onSubmit={actions.submitText}
|
||||
onThreadMessagesChange={actions.handleThreadMessagesChange}
|
||||
onToggleSelectedPin={() => undefined}
|
||||
onTranscribeAudio={async audio => (await transcribeAudio(await blobToDataUrl(audio), audio.type)).transcript}
|
||||
/>
|
||||
</ComposerScopeProvider>
|
||||
</SessionViewProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionTilePane({ storedSessionId }: { storedSessionId: string }) {
|
||||
const tiles = useStore($sessionTiles)
|
||||
const tile = tiles.find(t => t.storedSessionId === storedSessionId)
|
||||
const runtimeId = tile?.runtimeId ?? null
|
||||
const gatewayOpen = useStore($gatewayState) === 'open'
|
||||
const resumingRef = useRef(false)
|
||||
const view = useMemo(() => buildTileView(storedSessionId), [storedSessionId])
|
||||
|
||||
// Same gating as the primary's route resume (use-route-resume): never fire
|
||||
// session.resume before the gateway is OPEN. Persisted tiles mount at boot
|
||||
// while it's still connecting — an ungated resume rejected there and
|
||||
// latched every restored tile into the error card.
|
||||
useEffect(() => {
|
||||
if (!gatewayOpen || runtimeId || tile?.error || resumingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const delegate = sessionTileDelegate()
|
||||
|
||||
if (!delegate) {
|
||||
return
|
||||
}
|
||||
|
||||
resumingRef.current = true
|
||||
|
||||
delegate
|
||||
.resumeTile(storedSessionId)
|
||||
.then(id => patchSessionTile(storedSessionId, { error: undefined, runtimeId: id }))
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
// A gone session (404 / "Session not found") is terminal — a stale or
|
||||
// cross-profile persisted tile. Discard it instead of latching an error
|
||||
// that re-retries on every reconnect (the "Session not found" spam).
|
||||
if (/session not found|\b404\b/i.test(message)) {
|
||||
discardSessionTile(storedSessionId)
|
||||
} else {
|
||||
patchSessionTile(storedSessionId, { error: message })
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
resumingRef.current = false
|
||||
})
|
||||
}, [gatewayOpen, runtimeId, storedSessionId, tile?.error])
|
||||
|
||||
// The gateway (re)opening invalidates any latched error — it likely came
|
||||
// from a not-yet-open gateway or the previous connection. Clearing it
|
||||
// retriggers the resume effect: one bounded auto-retry per (re)connect,
|
||||
// mirroring the primary path's became-open resync.
|
||||
useEffect(() => {
|
||||
if (gatewayOpen && tile?.error) {
|
||||
patchSessionTile(storedSessionId, { error: undefined })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gatewayOpen, storedSessionId])
|
||||
|
||||
if (tile?.error) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center p-4">
|
||||
<div className="max-w-[24rem] space-y-2 text-center font-mono text-[11px]">
|
||||
<div className="text-(--ui-danger,#f87171)">Couldn't open this session</div>
|
||||
<div className="break-words text-(--ui-text-quaternary)">{tile.error}</div>
|
||||
<Button onClick={() => patchSessionTile(storedSessionId, { error: undefined })} size="sm" variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!runtimeId) {
|
||||
// The SAME session loader the primary thread shows (Thread's
|
||||
// loading === 'session' branch) — one loading language everywhere.
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<CenteredThreadSpinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <TileChat runtimeId={runtimeId} storedSessionId={storedSessionId} view={view} />
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tile -> pane contribution sync (call once from the app root).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function tileTitle(storedSessionId: string): string {
|
||||
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
|
||||
return stored ? sessionTitle(stored) : 'Session'
|
||||
}
|
||||
|
||||
/** The `@session` link payload for a tile tab drag — id + owning profile + title. */
|
||||
function tileDragPayload(storedSessionId: string): SessionDragPayload {
|
||||
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
|
||||
return { id: storedSessionId, profile: stored?.profile ?? '', title: tileTitle(storedSessionId) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Close confirmation — a BUSY tab (streaming, or blocked on clarify/approval
|
||||
// input) doesn't close silently.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Stored id awaiting close confirmation (null = no dialog). */
|
||||
const $confirmCloseTile = atom<null | string>(null)
|
||||
|
||||
/** The tile closer, gated: a quiet session closes immediately; a busy or
|
||||
* input-blocked one asks first. One state read — the tile's runtime slice. */
|
||||
export function requestCloseSessionTile(storedSessionId: string): void {
|
||||
const runtimeId = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId)?.runtimeId
|
||||
const state = runtimeId ? $sessionStates.get()[runtimeId] : undefined
|
||||
|
||||
if (state?.busy || state?.awaitingResponse || state?.needsInput) {
|
||||
$confirmCloseTile.set(storedSessionId)
|
||||
} else {
|
||||
closeSessionTile(storedSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Mounted once at the shell root: the "Close running tab?" confirmation. */
|
||||
export function SessionTileCloseConfirm() {
|
||||
const { t } = useI18n()
|
||||
const storedSessionId = useStore($confirmCloseTile)
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
confirmLabel={t.zones.closeRunningConfirm}
|
||||
description={t.zones.closeRunningBody}
|
||||
destructive
|
||||
onClose={() => $confirmCloseTile.set(null)}
|
||||
onConfirm={() => {
|
||||
if (storedSessionId) {
|
||||
closeSessionTile(storedSessionId)
|
||||
}
|
||||
}}
|
||||
open={storedSessionId !== null}
|
||||
title={t.zones.closeRunningTitle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Layout reset → every session tile collapses into the MAIN zone as a tab
|
||||
* after the workspace (the primary session stays the first tab), the "smart"
|
||||
* reset: N scattered tiles become one tab bar over the chat instead of
|
||||
* re-docking to their old edges.
|
||||
*
|
||||
* Runs BEFORE generic adoption (see registerLayoutResetHandler) — the tiles
|
||||
* aren't in the fresh tree yet, so each `moveTreePane` ADDS the tile into the
|
||||
* workspace group as a tab (append). The main group id is re-read each pass
|
||||
* because appending returns a new tree. */
|
||||
export function stackSessionTilesIntoMain(): void {
|
||||
for (const tile of $sessionTiles.get()) {
|
||||
const tree = $layoutTree.get()
|
||||
const mainGroup = tree ? findGroupOfPane(tree, 'workspace')?.id : null
|
||||
|
||||
if (mainGroup) {
|
||||
moveTreePane(`session-tile:${tile.storedSessionId}`, { groupId: mainGroup, pos: 'center' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A session TAB's context menu: the full session verb set (pin, copy id, new
|
||||
* window, branch, rename, archive, delete) — the SAME menu a sidebar row
|
||||
* gets, targeted through the tile delegate (whose verbs are generic over
|
||||
* stored ids, primary included). The wrapper stops the contextmenu from also
|
||||
* opening the zone strip's menu. Shared by tile tabs AND the main tab. */
|
||||
export function SessionTabMenu({
|
||||
children,
|
||||
onClose,
|
||||
onHideTabBar,
|
||||
storedSessionId,
|
||||
tabPaneId
|
||||
}: {
|
||||
children: React.ReactElement
|
||||
/** Close this tab (tiles; the main tab passes nothing). */
|
||||
onClose?: () => void
|
||||
/** Hide the zone's tab bar (main tab only — the sticky bar's off switch). */
|
||||
onHideTabBar?: () => void
|
||||
storedSessionId: string
|
||||
/** Layout-tree pane id — powers the Close-others/right/all verbs. */
|
||||
tabPaneId: string
|
||||
}) {
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const stored = sessions.find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
const pinId = stored ? sessionPinId(stored) : storedSessionId
|
||||
const pinned = pinnedSessionIds.includes(pinId)
|
||||
|
||||
return (
|
||||
<span className="contents" onContextMenu={event => event.stopPropagation()}>
|
||||
<SessionContextMenu
|
||||
onArchive={() => void sessionTileDelegate()?.archiveSession(storedSessionId)}
|
||||
onBranch={() => void sessionTileDelegate()?.branchSession(storedSessionId)}
|
||||
onClose={onClose}
|
||||
onDelete={() => void sessionTileDelegate()?.deleteSession(storedSessionId)}
|
||||
onHideTabBar={onHideTabBar}
|
||||
onPin={() => (pinned ? unpinSession(pinId) : pinSession(pinId))}
|
||||
pinned={pinned}
|
||||
profile={stored?.profile}
|
||||
sessionId={storedSessionId}
|
||||
surface="tab"
|
||||
tabPaneId={tabPaneId}
|
||||
title={tileTitle(storedSessionId)}
|
||||
>
|
||||
{children}
|
||||
</SessionContextMenu>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The MAIN tab's menu: the same session verbs targeting the primary's loaded
|
||||
* session, plus the bar's off switch (the bar sticky-shows once a tab is
|
||||
* ever gained; this is the explicit way back). A fresh draft has no session —
|
||||
* no menu. */
|
||||
export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) {
|
||||
const selected = useStore($selectedStoredSessionId)
|
||||
|
||||
const hideTabBar = () => {
|
||||
const tree = $layoutTree.get()
|
||||
const group = tree ? findGroupOfPane(tree, 'workspace') : null
|
||||
|
||||
if (group) {
|
||||
setTreeGroupHeaderHidden(group.id, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
return children
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionTabMenu onHideTabBar={hideTabBar} storedSessionId={selected} tabPaneId="workspace">
|
||||
{children}
|
||||
</SessionTabMenu>
|
||||
)
|
||||
}
|
||||
|
||||
/** Keep pane contributions mirroring `$sessionTiles` (+ titles from
|
||||
* `$sessions`). Tiles dock against main on the chosen edge, flex width. */
|
||||
export const watchSessionTiles = paneMirror<SessionTile>({
|
||||
source: $sessionTiles,
|
||||
also: [$sessions],
|
||||
key: t => t.storedSessionId,
|
||||
prefix: 'session-tile',
|
||||
dir: t => t.dir,
|
||||
anchor: t => t.anchor,
|
||||
before: t => t.before,
|
||||
minWidth: '20rem',
|
||||
title: tileTitle,
|
||||
render: storedSessionId => <SessionTilePane storedSessionId={storedSessionId} />,
|
||||
tabWrap: (storedSessionId, tab) => (
|
||||
<SessionTabMenu
|
||||
onClose={() => requestCloseSessionTile(storedSessionId)}
|
||||
storedSessionId={storedSessionId}
|
||||
tabPaneId={`session-tile:${storedSessionId}`}
|
||||
>
|
||||
{tab}
|
||||
</SessionTabMenu>
|
||||
),
|
||||
// A tile's tab drags like a sidebar row — stack / split / drop-to-link — with
|
||||
// its tap (activate) + double-tap (hide bar) preserved. Always takes the drag.
|
||||
tabDrag: (storedSessionId, event, onTap, double) => {
|
||||
startSessionDrag(tileDragPayload(storedSessionId), event, { double, onTap })
|
||||
|
||||
return true
|
||||
},
|
||||
close: requestCloseSessionTile
|
||||
})
|
||||
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)
|
||||
|
|
@ -3,10 +3,12 @@ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
|
|||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { PlatformAvatar } from '@/app/messaging/platform-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { KbdGroup } from '@/components/ui/kbd'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
|
|
@ -19,6 +21,7 @@ import {
|
|||
SidebarMenuButton,
|
||||
SidebarMenuItem
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { comboTokens } from '@/lib/keybinds/combo'
|
||||
|
|
@ -34,8 +37,6 @@ import {
|
|||
$sidebarAgentsGrouped,
|
||||
$sidebarCronOpen,
|
||||
$sidebarMessagingOpenIds,
|
||||
$sidebarOpen,
|
||||
$sidebarOverlayMounted,
|
||||
$sidebarPinsOpen,
|
||||
$sidebarProjectOrderIds,
|
||||
$sidebarRecentsOpen,
|
||||
|
|
@ -78,6 +79,7 @@ import {
|
|||
refreshWorktrees,
|
||||
scanAndRecordRepos
|
||||
} from '@/store/projects'
|
||||
import { openRouteTile } from '@/store/route-tiles'
|
||||
import {
|
||||
$cronSessions,
|
||||
$currentCwd,
|
||||
|
|
@ -85,7 +87,6 @@ import {
|
|||
$messagingPlatformTotals,
|
||||
$messagingSessions,
|
||||
$messagingTruncated,
|
||||
$selectedStoredSessionId,
|
||||
$sessionProfileTotals,
|
||||
$sessions,
|
||||
$sessionsLoading,
|
||||
|
|
@ -94,8 +95,16 @@ import {
|
|||
sessionPinId,
|
||||
setCurrentCwd
|
||||
} from '@/store/session'
|
||||
import { $focusedStoredSessionId, type SplitDir } from '@/store/session-states'
|
||||
|
||||
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
|
||||
import {
|
||||
type AppView,
|
||||
ARTIFACTS_ROUTE,
|
||||
MESSAGING_ROUTE,
|
||||
SIDEBAR_NAV_AREA,
|
||||
type SidebarNavContribution,
|
||||
SKILLS_ROUTE
|
||||
} from '../../routes'
|
||||
import type { SidebarNavItem } from '../../types'
|
||||
|
||||
import { countLabel } from './chrome'
|
||||
|
|
@ -121,6 +130,7 @@ import {
|
|||
} from './projects'
|
||||
import { SidebarBlankState, SidebarPinnedEmptyState, SidebarSessionSkeletons } from './section-states'
|
||||
import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section'
|
||||
import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu'
|
||||
|
||||
// Non-session groups (messaging platforms) stay compact: show a few rows up
|
||||
// front, reveal more in larger steps on demand. Keeps a busy platform from
|
||||
|
|
@ -209,6 +219,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
|||
onArchiveSession: (sessionId: string) => void
|
||||
onBranchSession: (sessionId: string) => void
|
||||
onNewSessionInWorkspace: (path: null | string) => void
|
||||
/** Create a brand-new session and open it as a tile on `dir`. */
|
||||
onNewSessionSplit: (dir: SplitDir) => void
|
||||
onManageCronJob: (jobId: string) => void
|
||||
onTriggerCronJob: (jobId: string) => void
|
||||
}
|
||||
|
|
@ -224,22 +236,49 @@ export function ChatSidebar({
|
|||
onArchiveSession,
|
||||
onBranchSession,
|
||||
onNewSessionInWorkspace,
|
||||
onNewSessionSplit,
|
||||
onManageCronJob,
|
||||
onTriggerCronJob
|
||||
}: ChatSidebarProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
// Collapsed-but-overlay-mounted → render the full sidebar, not just the nav rail.
|
||||
const overlayMounted = useStore($sidebarOverlayMounted)
|
||||
const contentVisible = sidebarOpen || overlayMounted
|
||||
const { pathname } = useLocation()
|
||||
// Contributed nav rows (plugins pairing a page with a sidebar entry) render
|
||||
// below the built-ins with the same chrome; active = at their route.
|
||||
const navContributions = useContributions(SIDEBAR_NAV_AREA)
|
||||
|
||||
const contributedNav = useMemo<SidebarNavItem[]>(
|
||||
() =>
|
||||
navContributions.flatMap(c => {
|
||||
const data = c.data as Partial<SidebarNavContribution> | undefined
|
||||
|
||||
if (!data?.path?.startsWith('/') || !data.label) {
|
||||
return []
|
||||
}
|
||||
|
||||
const codicon = data.codicon || 'plug'
|
||||
|
||||
return [
|
||||
{
|
||||
id: c.id,
|
||||
label: data.label,
|
||||
icon: (props: { className?: string }) => <Codicon name={codicon} {...props} />,
|
||||
route: data.path
|
||||
}
|
||||
]
|
||||
}),
|
||||
[navContributions]
|
||||
)
|
||||
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const agentsGrouped = useStore($sidebarAgentsGrouped)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const pinsOpen = useStore($sidebarPinsOpen)
|
||||
const agentsOpen = useStore($sidebarRecentsOpen)
|
||||
const cronOpen = useStore($sidebarCronOpen)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
// The sidebar highlight tracks the FOCUSED session — the interacted tile's
|
||||
// tab, else the main selection — so it stays 1:1 with whatever tab is active.
|
||||
const selectedSessionId = useStore($focusedStoredSessionId)
|
||||
const sessions = useStore($sessions)
|
||||
const cronSessions = useStore($cronSessions)
|
||||
const cronJobs = useStore($cronJobs)
|
||||
|
|
@ -1031,15 +1070,12 @@ export function ChatSidebar({
|
|||
return (
|
||||
<Sidebar
|
||||
className={cn(
|
||||
// Visibility is the layout tree's job (a hidden zone is display:none;
|
||||
// the narrow overlay renders the live instance) — the sidebar always
|
||||
// paints itself fully.
|
||||
'relative h-full min-w-0 overflow-hidden border-t-0 border-b-0 text-foreground transition-none',
|
||||
panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0',
|
||||
sidebarOpen
|
||||
? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
|
||||
: 'pointer-events-none border-transparent bg-transparent opacity-0',
|
||||
// While floated by PaneShell's hover-reveal, force visible + interactive
|
||||
// — on hover (group-hover/reveal) or when keyboard-pinned (data-forced).
|
||||
'in-data-[pane-hover-reveal=open]:pointer-events-auto in-data-[pane-hover-reveal=open]:border-(--sidebar-edge-border) in-data-[pane-hover-reveal=open]:bg-(--ui-sidebar-surface-background) in-data-[pane-hover-reveal=open]:opacity-100',
|
||||
'group-hover/reveal:pointer-events-auto group-hover/reveal:border-(--sidebar-edge-border) group-hover/reveal:bg-(--ui-sidebar-surface-background) group-hover/reveal:opacity-100'
|
||||
'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
|
||||
)}
|
||||
collapsible="none"
|
||||
>
|
||||
|
|
@ -1047,62 +1083,85 @@ export function ChatSidebar({
|
|||
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.375rem)]">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="gap-px">
|
||||
{SIDEBAR_NAV.map(item => {
|
||||
{[...SIDEBAR_NAV, ...contributedNav].map(item => {
|
||||
const isInteractive = Boolean(item.action) || Boolean(item.route)
|
||||
|
||||
const active =
|
||||
(item.id === 'skills' && currentView === 'skills') ||
|
||||
(item.id === 'messaging' && currentView === 'messaging') ||
|
||||
(item.id === 'artifacts' && currentView === 'artifacts')
|
||||
(item.id === 'artifacts' && currentView === 'artifacts') ||
|
||||
// Contributed rows light up at their own route.
|
||||
(Boolean(item.route) && pathname === item.route)
|
||||
|
||||
const isNewSession = item.id === 'new-session'
|
||||
|
||||
const button = (
|
||||
<SidebarMenuButton
|
||||
aria-disabled={!isInteractive}
|
||||
className={cn(
|
||||
// no-drag: these rows sit directly under the titlebar's
|
||||
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
|
||||
// 6px of clearance. Drag regions win hit-testing over DOM
|
||||
// (pointer-events can't override), and on Linux/WSLg the
|
||||
// resolved region has been observed to swallow clicks on the
|
||||
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
|
||||
// thread.tsx.
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
|
||||
active &&
|
||||
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
|
||||
!isInteractive &&
|
||||
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
|
||||
)}
|
||||
onClick={() => {
|
||||
// A plain new session lands in whatever profile the live
|
||||
// gateway is on (= the active switcher context). null →
|
||||
// no swap. The switcher header is the single place to
|
||||
// change which profile that is.
|
||||
if (isNewSession) {
|
||||
$newChatProfile.set(null)
|
||||
}
|
||||
|
||||
onNavigate(item)
|
||||
}}
|
||||
tooltip={s.nav[item.id] ?? item.label}
|
||||
type="button"
|
||||
>
|
||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
|
||||
{isNewSession && (
|
||||
<KbdGroup
|
||||
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
|
||||
keys={[...NEW_SESSION_KBD]}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
)
|
||||
|
||||
// New session + route-backed pages can open in a split —
|
||||
// right-click for the directional "Open in split" submenu.
|
||||
return (
|
||||
<SidebarMenuItem key={item.id}>
|
||||
<SidebarMenuButton
|
||||
aria-disabled={!isInteractive}
|
||||
className={cn(
|
||||
// no-drag: these rows sit directly under the titlebar's
|
||||
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
|
||||
// 6px of clearance. Drag regions win hit-testing over DOM
|
||||
// (pointer-events can't override), and on Linux/WSLg the
|
||||
// resolved region has been observed to swallow clicks on the
|
||||
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
|
||||
// thread.tsx.
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
|
||||
active &&
|
||||
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
|
||||
!isInteractive &&
|
||||
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
|
||||
)}
|
||||
onClick={() => {
|
||||
// A plain new session lands in whatever profile the live
|
||||
// gateway is on (= the active switcher context). null →
|
||||
// no swap. The switcher header is the single place to
|
||||
// change which profile that is.
|
||||
if (isNewSession) {
|
||||
$newChatProfile.set(null)
|
||||
}
|
||||
|
||||
onNavigate(item)
|
||||
}}
|
||||
tooltip={s.nav[item.id] ?? item.label}
|
||||
type="button"
|
||||
>
|
||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||
{contentVisible && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
|
||||
{isNewSession && (
|
||||
<KbdGroup
|
||||
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
|
||||
keys={[...NEW_SESSION_KBD]}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
{isNewSession || item.route ? (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{button}</ContextMenuTrigger>
|
||||
<ContextMenuContent aria-label={s.nav[item.id] ?? item.label}>
|
||||
<SplitSubmenu
|
||||
kit={CONTEXT_SPLIT_KIT}
|
||||
label={s.row.openInSplit}
|
||||
onSplit={dir => {
|
||||
if (isNewSession) {
|
||||
onNewSessionSplit(dir)
|
||||
} else if (item.route) {
|
||||
openRouteTile(item.route, dir)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
) : (
|
||||
button
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
|
|
@ -1110,7 +1169,7 @@ export function ChatSidebar({
|
|||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{contentVisible && showSessionSections && (
|
||||
{showSessionSections && (
|
||||
<div className="shrink-0 px-2 pb-1 pt-1">
|
||||
<SearchField
|
||||
aria-label={s.searchAria}
|
||||
|
|
@ -1122,7 +1181,7 @@ export function ChatSidebar({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{contentVisible && showSessionSections && (
|
||||
{showSessionSections && (
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col pb-1.75', SCROLL_Y)}>
|
||||
{trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
|
|
@ -1392,13 +1451,11 @@ export function ChatSidebar({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{contentVisible && !showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
|
||||
{!showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
|
||||
|
||||
{contentVisible && (
|
||||
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
|
||||
<ProfileRail />
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
|
||||
<ProfileRail />
|
||||
</div>
|
||||
</SidebarContent>
|
||||
<ProjectDialog />
|
||||
</Sidebar>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ import { getProfileSoul, updateProfileSoul } from '@/hermes'
|
|||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
|
||||
import {
|
||||
REORDER_DRAG_TRANSITION_CSS,
|
||||
REORDER_RAIL_TRANSITION,
|
||||
reorderCommitHaptic,
|
||||
reorderStepHaptic
|
||||
} from '@/lib/reorder'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import {
|
||||
|
|
@ -67,12 +73,11 @@ const RAIL_GAP = 4 // px — matches gap-1 between squares.
|
|||
// select. Drag-reorder and long-press-recolor live only on the squares path.
|
||||
const PROFILE_DROPDOWN_THRESHOLD = 13
|
||||
|
||||
// easeOutBack — a little overshoot so squares spring into their new slot rather
|
||||
// than sliding in flat. Neighbors reflow on RAIL_TRANSITION; the dragged square
|
||||
// glides between snapped cells on the snappier DRAG_TRANSITION.
|
||||
const SPRING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'
|
||||
const RAIL_TRANSITION = { duration: 300, easing: SPRING }
|
||||
const DRAG_TRANSITION = `transform 200ms ${SPRING}`
|
||||
// Neighbors reflow on RAIL_TRANSITION; the dragged square glides between
|
||||
// snapped cells on the snappier DRAG_TRANSITION. Both come from the SHARED
|
||||
// reorder primitive (lib/reorder.ts) so every reorder strip feels identical.
|
||||
const RAIL_TRANSITION = REORDER_RAIL_TRANSITION
|
||||
const DRAG_TRANSITION = REORDER_DRAG_TRANSITION_CSS
|
||||
|
||||
// The rail is a single horizontal strip of fixed cells. Pin drags to the x-axis
|
||||
// (no cross-axis scrollbar), snap to whole cells so a square steps slot-to-slot
|
||||
|
|
@ -174,7 +179,7 @@ export function ProfileRail() {
|
|||
|
||||
if (id && id !== lastOverRef.current) {
|
||||
lastOverRef.current = id
|
||||
triggerHaptic('selection')
|
||||
reorderStepHaptic()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +196,7 @@ export function ProfileRail() {
|
|||
|
||||
if (from >= 0 && to >= 0) {
|
||||
setProfileOrder(arrayMove(ids, from, to))
|
||||
triggerHaptic('success')
|
||||
reorderCommitHaptic()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ const activeGateway = vi.fn<() => { request: typeof request } | null>(() => ({ r
|
|||
|
||||
vi.mock('@/hermes', () => ({
|
||||
renameSession: (...args: unknown[]) => renameSession(...(args as [])),
|
||||
// profile.ts calls this at import (its $activeGatewayProfile subscribe fires
|
||||
// immediately), pulled in transitively via session-states.
|
||||
setApiRequestProfile: () => {},
|
||||
HermesGateway: class {}
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { closeAllTreeTabs, closeOtherTreeTabs, closeTreeTabsToRight, treeTabCloseTargets } from '@/components/pane-shell/tree/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -13,7 +21,13 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { renameSession } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
|
@ -22,6 +36,7 @@ import { exportSession } from '@/lib/session-export'
|
|||
import { activeGateway } from '@/store/gateway'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeSessionId, $selectedStoredSessionId, setSessions } from '@/store/session'
|
||||
import { $sessionTiles, openSessionTile } from '@/store/session-states'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import type { SessionTitleResponse } from '../../types'
|
||||
|
|
@ -80,10 +95,31 @@ interface SessionActions {
|
|||
onBranch?: () => void
|
||||
onArchive?: () => void
|
||||
onDelete?: () => void
|
||||
/** Close this surface (a tile tab) — omitted where nothing closes (sidebar
|
||||
* rows, the main tab). */
|
||||
onClose?: () => void
|
||||
/** TAB surfaces: the session is already a tab, so "Open in new tab" is
|
||||
* nonsense there — sidebar rows/dropdowns keep it. */
|
||||
surface?: 'row' | 'tab'
|
||||
/** The tab's layout-tree pane id (`session-tile:<id>` or `workspace`) — enables
|
||||
* the Close-others / to-the-right / all tab verbs. Tab surfaces only. */
|
||||
tabPaneId?: string
|
||||
/** The MAIN tab's escape hatch: hide the zone's tab bar (it sticky-shows
|
||||
* once a tab is ever gained; this is the explicit off switch). */
|
||||
onHideTabBar?: () => void
|
||||
}
|
||||
|
||||
type MenuItem = typeof DropdownMenuItem | typeof ContextMenuItem
|
||||
|
||||
/** A menu flavour (dropdown / context) — item + separator components. */
|
||||
interface MenuKit {
|
||||
Item: MenuItem
|
||||
Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator
|
||||
}
|
||||
|
||||
const DROPDOWN_KIT: MenuKit = { Item: DropdownMenuItem, Separator: DropdownMenuSeparator }
|
||||
const CONTEXT_KIT: MenuKit = { Item: ContextMenuItem, Separator: ContextMenuSeparator }
|
||||
|
||||
interface ItemSpec {
|
||||
className?: string
|
||||
disabled: boolean
|
||||
|
|
@ -101,26 +137,45 @@ function useSessionActions({
|
|||
onPin,
|
||||
onBranch,
|
||||
onArchive,
|
||||
onDelete
|
||||
onDelete,
|
||||
onClose,
|
||||
onHideTabBar,
|
||||
surface = 'row',
|
||||
tabPaneId
|
||||
}: SessionActions) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
const tiles = useStore($sessionTiles)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
|
||||
const pinItem: ItemSpec = {
|
||||
disabled: !onPin,
|
||||
icon: 'pin',
|
||||
label: pinned ? r.unpin : r.pin,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}
|
||||
}
|
||||
// Already showing as a tab somewhere (a tile, or loaded in main — main IS
|
||||
// a tab): offering "Open in new tab" again is noise.
|
||||
const alreadyTabbed = sessionId === selectedStoredSessionId || tiles.some(tile => tile.storedSessionId === sessionId)
|
||||
|
||||
const items: ItemSpec[] = [
|
||||
const spec = (partial: Omit<ItemSpec, 'onSelect'> & { onSelect: () => void }): ItemSpec => partial
|
||||
|
||||
// OPEN — where else this session can go. A tab surface IS a tab already,
|
||||
// so it only offers the window hop (and its own Close, below).
|
||||
const openItems: ItemSpec[] = [
|
||||
...(surface === 'row' && !alreadyTabbed
|
||||
? [
|
||||
spec({
|
||||
disabled: !sessionId,
|
||||
icon: 'browser',
|
||||
label: r.openInNewTab,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
// Stack into the MAIN zone as a tab (center dock; the strip
|
||||
// sticky-shows on gain) — the door to the tab bar.
|
||||
openSessionTile(sessionId, 'center')
|
||||
}
|
||||
})
|
||||
]
|
||||
: []),
|
||||
...(canOpenSessionWindow()
|
||||
? [
|
||||
{
|
||||
spec({
|
||||
disabled: !sessionId,
|
||||
icon: 'link-external',
|
||||
label: r.newWindow,
|
||||
|
|
@ -128,28 +183,14 @@ function useSessionActions({
|
|||
triggerHaptic('selection')
|
||||
void openSessionInNewWindow(sessionId)
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
: []),
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'cloud-download',
|
||||
label: r.export,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { profile, title })
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !onBranch,
|
||||
icon: 'git-branch',
|
||||
label: r.branchFrom,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onBranch?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
: [])
|
||||
]
|
||||
|
||||
// IDENTITY — name/mark/reference the session.
|
||||
const identityItems: ItemSpec[] = [
|
||||
spec({
|
||||
disabled: !sessionId,
|
||||
icon: 'edit',
|
||||
label: r.rename,
|
||||
|
|
@ -157,8 +198,96 @@ function useSessionActions({
|
|||
triggerHaptic('selection')
|
||||
setRenameOpen(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
}),
|
||||
spec({
|
||||
disabled: !onPin,
|
||||
icon: 'pin',
|
||||
label: pinned ? r.unpin : r.pin,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
// WORK — derive/extract from the session.
|
||||
const workItems: ItemSpec[] = [
|
||||
spec({
|
||||
disabled: !onBranch,
|
||||
icon: 'git-branch',
|
||||
label: r.branchFrom,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onBranch?.()
|
||||
}
|
||||
}),
|
||||
spec({
|
||||
disabled: !sessionId,
|
||||
icon: 'cloud-download',
|
||||
label: r.export,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { profile, title })
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
// TAB — close verbs that act on the strip (tabs only; a row isn't a tab).
|
||||
const closeTargets = surface === 'tab' && tabPaneId ? treeTabCloseTargets(tabPaneId) : null
|
||||
|
||||
const tabCloseItems: ItemSpec[] =
|
||||
surface === 'tab'
|
||||
? [
|
||||
...(onClose
|
||||
? [
|
||||
spec({
|
||||
disabled: false,
|
||||
icon: 'close',
|
||||
label: t.common.close,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
]
|
||||
: []),
|
||||
...(tabPaneId
|
||||
? [
|
||||
spec({
|
||||
disabled: !closeTargets?.others,
|
||||
icon: 'close-all',
|
||||
label: t.zones.closeOthers,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
closeOtherTreeTabs(tabPaneId)
|
||||
}
|
||||
}),
|
||||
spec({
|
||||
disabled: !closeTargets?.right,
|
||||
icon: 'arrow-right',
|
||||
label: t.zones.closeToRight,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
closeTreeTabsToRight(tabPaneId)
|
||||
}
|
||||
}),
|
||||
spec({
|
||||
disabled: !closeTargets?.all,
|
||||
icon: 'clear-all',
|
||||
label: t.zones.closeAll,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
closeAllTreeTabs(tabPaneId)
|
||||
}
|
||||
})
|
||||
]
|
||||
: [])
|
||||
]
|
||||
: []
|
||||
|
||||
// DANGER — put it away / destroy it (delete stays last, destructive-red).
|
||||
const dangerItems: ItemSpec[] = [
|
||||
spec({
|
||||
disabled: !onArchive,
|
||||
icon: 'archive',
|
||||
label: r.archive,
|
||||
|
|
@ -166,7 +295,7 @@ function useSessionActions({
|
|||
triggerHaptic('selection')
|
||||
onArchive?.()
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
className: 'text-destructive focus:text-destructive',
|
||||
disabled: !onDelete,
|
||||
|
|
@ -187,11 +316,13 @@ function useSessionActions({
|
|||
</Item>
|
||||
)
|
||||
|
||||
const renderItems = (Item: MenuItem) => (
|
||||
const renderItems = (kit: MenuKit) => (
|
||||
<>
|
||||
{renderMenuItem(Item, pinItem)}
|
||||
{openItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
{openItems.length > 0 && <kit.Separator />}
|
||||
{identityItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
<CopyButton
|
||||
appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
|
||||
appearance={kit.Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
|
||||
disabled={!sessionId}
|
||||
errorMessage={r.copyIdFailed}
|
||||
iconClassName="size-3.5 text-current"
|
||||
|
|
@ -200,7 +331,30 @@ function useSessionActions({
|
|||
onCopyError={err => notifyError(err, r.copyIdFailed)}
|
||||
text={sessionId}
|
||||
/>
|
||||
{items.map(spec => renderMenuItem(Item, spec))}
|
||||
<kit.Separator />
|
||||
{workItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
{tabCloseItems.length > 0 && (
|
||||
<>
|
||||
<kit.Separator />
|
||||
{tabCloseItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
</>
|
||||
)}
|
||||
<kit.Separator />
|
||||
{dangerItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
{onHideTabBar && (
|
||||
<>
|
||||
<kit.Separator />
|
||||
{renderMenuItem(kit.Item, {
|
||||
disabled: false,
|
||||
icon: 'eye-closed',
|
||||
label: r.hideTabBar,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onHideTabBar()
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
|
|
@ -225,10 +379,11 @@ interface SessionActionsMenuProps
|
|||
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const { renameDialog, renderItems } = useSessionActions(actions)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
|
|
@ -236,7 +391,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
|
|||
className="w-40"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
{renderItems(DropdownMenuItem)}
|
||||
{renderItems(DROPDOWN_KIT)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{renameDialog}
|
||||
|
|
@ -257,7 +412,7 @@ export function SessionContextMenu({ children, ...actions }: SessionContextMenuP
|
|||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent aria-label={t.sidebar.row.actionsFor(actions.title)} className="w-40">
|
||||
{renderItems(ContextMenuItem)}
|
||||
{renderItems(CONTEXT_KIT)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
{renameDialog}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
|
||||
import { startSessionDrag } from '@/app/chat/session-drag'
|
||||
import { PlatformAvatar } from '@/app/messaging/platform-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
|
|
@ -14,6 +14,7 @@ import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
|||
import { coarseElapsed } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $attentionSessionIds } from '@/store/session'
|
||||
import { openSessionTile } from '@/store/session-states'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
|
||||
|
|
@ -92,7 +93,7 @@ export function SidebarSessionRow({
|
|||
>
|
||||
<SidebarRowShell
|
||||
actions={
|
||||
<div className="relative z-2 grid w-[1.375rem] place-items-center">
|
||||
<div className="relative z-2 grid w-[1.375rem] place-items-center" data-row-actions>
|
||||
{!isWorking && (
|
||||
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{age}
|
||||
|
|
@ -130,21 +131,19 @@ export function SidebarSessionRow({
|
|||
className
|
||||
)}
|
||||
data-working={isWorking ? 'true' : undefined}
|
||||
draggable
|
||||
onDragStart={event => {
|
||||
// Reorder drags belong to dnd-kit (the grab handle) — cancel the
|
||||
// native drag so the two DnD systems don't fight.
|
||||
if ((event.target as HTMLElement).closest('[data-reorder-handle]')) {
|
||||
event.preventDefault()
|
||||
|
||||
onPointerDown={event => {
|
||||
// Reorder drags belong to dnd-kit (the grab handle); the ⋯ actions
|
||||
// cluster keeps its own gestures. Everything else on the row —
|
||||
// including the row-body BUTTON, the natural grab surface — is a
|
||||
// session drag source: a POINTER drag on the shared drag session
|
||||
// (never native HTML5 DnD: no macOS snap-back, Esc aborts
|
||||
// instantly). Sub-threshold releases stay ordinary clicks, so
|
||||
// resume / pin / open-in-window are untouched.
|
||||
if ((event.target as HTMLElement).closest('[data-reorder-handle], [data-row-actions]')) {
|
||||
return
|
||||
}
|
||||
|
||||
writeSessionDrag(event.dataTransfer, {
|
||||
id: session.id,
|
||||
profile: session.profile || 'default',
|
||||
title
|
||||
})
|
||||
startSessionDrag({ id: session.id, profile: session.profile || 'default', title }, event)
|
||||
}}
|
||||
ref={ref}
|
||||
style={style}
|
||||
|
|
@ -153,7 +152,40 @@ export function SidebarSessionRow({
|
|||
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
|
||||
<SidebarRowBody
|
||||
className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')}
|
||||
// Middle-click = open in a new tab (browser muscle memory). Swallow
|
||||
// the mousedown so Chromium doesn't enter autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
openSessionTile(session.id, 'center')
|
||||
}
|
||||
}}
|
||||
onClick={event => {
|
||||
const mod = event.metaKey || event.ctrlKey
|
||||
|
||||
// ⇧⌘-click → pop into its own window (needs standalone windows).
|
||||
if (mod && event.shiftKey && canOpenSessionWindow()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
void openSessionInNewWindow(session.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ⌘/⌃-click → open in a new tab (stack into main).
|
||||
if (mod) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
openSessionTile(session.id, 'center')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ⇧-click → pin.
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
|
@ -163,21 +195,9 @@ export function SidebarSessionRow({
|
|||
return
|
||||
}
|
||||
|
||||
// ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
|
||||
// window — the universal "open in a new window" gesture. Archive
|
||||
// lives in the row's ⋯ and right-click menus. Falls through to a
|
||||
// normal resume when standalone windows aren't available (web embed).
|
||||
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
void openSessionInNewWindow(session.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onResume()
|
||||
}}
|
||||
onMouseDown={event => event.button === 1 && event.preventDefault()}
|
||||
>
|
||||
{reorderable ? (
|
||||
<SidebarRowGrab
|
||||
|
|
|
|||
92
apps/desktop/src/app/chat/sidebar/split-submenu.tsx
Normal file
92
apps/desktop/src/app/chat/sidebar/split-submenu.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import type { SplitDir } from '@/store/session-states'
|
||||
|
||||
/** The leaf + submenu components for one menu flavour, so the split submenu
|
||||
* renders in either the `…` dropdown or a right-click context menu. */
|
||||
export interface SplitMenuKit {
|
||||
Item: typeof DropdownMenuItem | typeof ContextMenuItem
|
||||
Sub: typeof DropdownMenuSub | typeof ContextMenuSub
|
||||
SubContent: typeof DropdownMenuSubContent | typeof ContextMenuSubContent
|
||||
SubTrigger: typeof DropdownMenuSubTrigger | typeof ContextMenuSubTrigger
|
||||
}
|
||||
|
||||
export const DROPDOWN_SPLIT_KIT: SplitMenuKit = {
|
||||
Item: DropdownMenuItem,
|
||||
Sub: DropdownMenuSub,
|
||||
SubContent: DropdownMenuSubContent,
|
||||
SubTrigger: DropdownMenuSubTrigger
|
||||
}
|
||||
|
||||
export const CONTEXT_SPLIT_KIT: SplitMenuKit = {
|
||||
Item: ContextMenuItem,
|
||||
Sub: ContextMenuSub,
|
||||
SubContent: ContextMenuSubContent,
|
||||
SubTrigger: ContextMenuSubTrigger
|
||||
}
|
||||
|
||||
// Ordered so the default (right) sits first, one hop away.
|
||||
const SPLIT_DIRS: { dir: SplitDir; icon: string; label: string }[] = [
|
||||
{ dir: 'right', icon: 'arrow-right', label: 'Right' },
|
||||
{ dir: 'bottom', icon: 'arrow-down', label: 'Down' },
|
||||
{ dir: 'left', icon: 'arrow-left', label: 'Left' },
|
||||
{ dir: 'top', icon: 'arrow-up', label: 'Up' }
|
||||
]
|
||||
|
||||
interface SplitSubmenuProps {
|
||||
kit: SplitMenuKit
|
||||
label: string
|
||||
onSplit: (dir: SplitDir) => void
|
||||
disabled?: boolean
|
||||
/** Dismiss the owning menu after the row's default (right) split — the
|
||||
* dropdown is controlled and can; a context menu can't, so it's a no-op. */
|
||||
close?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* "Open in split ▸": clicking the row splits right (the common case), and the
|
||||
* submenu picks any edge. Shared by session rows and page nav rows.
|
||||
*/
|
||||
export function SplitSubmenu({ close, disabled, kit, label, onSplit }: SplitSubmenuProps) {
|
||||
const { Item, Sub, SubContent, SubTrigger } = kit
|
||||
|
||||
const split = (dir: SplitDir) => {
|
||||
triggerHaptic('selection')
|
||||
onSplit(dir)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sub>
|
||||
<SubTrigger
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
split('right')
|
||||
close?.()
|
||||
}}
|
||||
>
|
||||
<Codicon name="split-horizontal" size="0.875rem" />
|
||||
<span>{label}</span>
|
||||
</SubTrigger>
|
||||
<SubContent>
|
||||
{SPLIT_DIRS.map(({ dir, icon, label: dirLabel }) => (
|
||||
<Item key={dir} onSelect={() => split(dir)}>
|
||||
<Codicon name={icon} size="0.875rem" />
|
||||
<span>{dirLabel}</span>
|
||||
</Item>
|
||||
))}
|
||||
</SubContent>
|
||||
</Sub>
|
||||
)
|
||||
}
|
||||
28
apps/desktop/src/app/command-palette/contrib.ts
Normal file
28
apps/desktop/src/app/command-palette/contrib.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Command-palette contribution surface — `palette` data contributions become
|
||||
* rows in the ⌘K root list, same schema as every other area. Contributions
|
||||
* with an `action` id render that action's live keybind as their hotkey hint.
|
||||
*/
|
||||
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
|
||||
export const PALETTE_AREA = 'palette'
|
||||
|
||||
/** Payload of a `palette` data contribution. */
|
||||
export interface PaletteContribution {
|
||||
id: string
|
||||
label: string
|
||||
/** Keybind action id — its live combo renders as the hotkey hint. */
|
||||
action?: string
|
||||
icon?: IconComponent
|
||||
keywords?: string[]
|
||||
run: () => void
|
||||
}
|
||||
|
||||
/** Contributed palette rows, with stable render keys. */
|
||||
export function usePaletteContributions(): Array<PaletteContribution & { key: string }> {
|
||||
return useContributions(PALETTE_AREA)
|
||||
.map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as PaletteContribution) }))
|
||||
.filter(item => Boolean(item.label && item.run))
|
||||
}
|
||||
|
|
@ -80,6 +80,7 @@ import { FIELD_LABELS, SECTIONS } from '../settings/constants'
|
|||
import { fieldCopyForSchemaKey } from '../settings/field-copy'
|
||||
import { prettyName } from '../settings/helpers'
|
||||
|
||||
import { usePaletteContributions } from './contrib'
|
||||
import { MarketplaceThemePage } from './marketplace-theme-page'
|
||||
import { PetInlineToggle, PetPalettePage } from './pet-palette-page'
|
||||
|
||||
|
|
@ -368,6 +369,8 @@ export function CommandPalette() {
|
|||
[t.settings.fieldLabels]
|
||||
)
|
||||
|
||||
const contributedItems = usePaletteContributions()
|
||||
|
||||
const baseGroups = useMemo<PaletteGroup[]>(() => {
|
||||
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
|
||||
const cc = t.commandCenter
|
||||
|
|
@ -559,9 +562,26 @@ export function CommandPalette() {
|
|||
run: go(settingsTab(entry.tab))
|
||||
}))
|
||||
]
|
||||
}
|
||||
},
|
||||
// Registry-contributed rows (core features + plugins) — one group,
|
||||
// omitted while nothing contributes.
|
||||
...(contributedItems.length > 0
|
||||
? [
|
||||
{
|
||||
heading: cc.commands,
|
||||
items: contributedItems.map(item => ({
|
||||
action: item.action,
|
||||
icon: item.icon ?? Zap,
|
||||
id: item.key,
|
||||
keywords: item.keywords,
|
||||
label: item.label,
|
||||
run: item.run
|
||||
}))
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}, [go, settingsSectionLabel, t, worktrees])
|
||||
}, [contributedItems, go, settingsSectionLabel, t, worktrees])
|
||||
|
||||
// The long, granular lists (settings fields, API keys, MCP servers, archived
|
||||
// chats) only surface once the user types — otherwise they'd bury the
|
||||
|
|
|
|||
36
apps/desktop/src/app/contrib/context.tsx
Normal file
36
apps/desktop/src/app/contrib/context.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { createContext, memo, useContext } from 'react'
|
||||
|
||||
import { DecodeText } from '@/components/ui/decode-text'
|
||||
|
||||
import { StatusbarControls } from '../shell/statusbar-controls'
|
||||
|
||||
import type { WiringApi } from './types'
|
||||
|
||||
/** The controller publishes its wired surfaces here; every registered pane
|
||||
* / chrome slot reads one back through `WiredPane`. */
|
||||
export const ContribWiringContext = createContext<WiringApi | null>(null)
|
||||
|
||||
/** Render a wired surface inside a registered pane / chrome slot.
|
||||
*
|
||||
* Memoized on `part` (its only prop): a zone re-rendering for reasons that
|
||||
* don't touch the wiring — a drag hint sweeping the tree, a sash resize, an
|
||||
* edit-mode toggle — re-renders the group chrome but NOT this component, so
|
||||
* the (expensive) pane body it reads from context is untouched. When the
|
||||
* wiring's `api` genuinely changes, the context read re-renders it as normal. */
|
||||
export const WiredPane = memo(function WiredPane({ part }: { part: keyof WiringApi }) {
|
||||
const api = useContext(ContribWiringContext)
|
||||
|
||||
if (!api) {
|
||||
if (part === 'statusbar') {
|
||||
return <StatusbarControls items={[]} leftItems={[]} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-full place-items-center">
|
||||
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{api[part]}</>
|
||||
})
|
||||
673
apps/desktop/src/app/contrib/controller.tsx
Normal file
673
apps/desktop/src/app/contrib/controller.tsx
Normal file
|
|
@ -0,0 +1,673 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { computed } from 'nanostores'
|
||||
import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } from 'react'
|
||||
|
||||
import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail'
|
||||
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
|
||||
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
|
||||
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
|
||||
import { allPaneIds, group, split } from '@/components/pane-shell/tree/model'
|
||||
import { LayoutTreeRoot } from '@/components/pane-shell/tree/renderer'
|
||||
import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session'
|
||||
import {
|
||||
$layoutTree,
|
||||
bindTreeSideVisibility,
|
||||
declareDefaultTree,
|
||||
dismissTreePane,
|
||||
dockPaneBeside,
|
||||
markCollapsePane,
|
||||
mirrorLayoutTree,
|
||||
paneRootSide,
|
||||
registerLayoutResetHandler,
|
||||
registerPaneCloser,
|
||||
registerPaneOpener,
|
||||
resetLayoutTree,
|
||||
revealTreePane,
|
||||
setPaneCollapsed,
|
||||
setTreePaneHidden,
|
||||
watchContributedPanes
|
||||
} from '@/components/pane-shell/tree/store'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { discoverBundledPlugins } from '@/contrib/plugins'
|
||||
import { Slot } from '@/contrib/react/slot'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
||||
import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
|
||||
import { LayoutDashboard } from '@/lib/icons'
|
||||
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
|
||||
import { Codecs, persistentAtom } from '@/lib/persisted'
|
||||
import { toggleKeybindPanel } from '@/store/keybinds'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$panesFlipped,
|
||||
$sidebarOpen,
|
||||
FILE_BROWSER_DEFAULT_WIDTH,
|
||||
FILE_BROWSER_MAX_WIDTH,
|
||||
FILE_BROWSER_MIN_WIDTH,
|
||||
setFileBrowserOpen,
|
||||
setSidebarOpen,
|
||||
SIDEBAR_DEFAULT_WIDTH,
|
||||
SIDEBAR_MAX_WIDTH
|
||||
} from '@/store/layout'
|
||||
import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
|
||||
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
|
||||
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
|
||||
|
||||
import type { SessionDragPayload } from '../chat/composer/inline-refs'
|
||||
import { watchRouteTiles } from '../chat/route-tile'
|
||||
import { startSessionDrag } from '../chat/session-drag'
|
||||
import {
|
||||
SessionTileCloseConfirm,
|
||||
stackSessionTilesIntoMain,
|
||||
watchSessionTiles,
|
||||
WorkspaceTabMenu
|
||||
} from '../chat/session-tile'
|
||||
import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store'
|
||||
import { $workspaceIsPage } from '../routes'
|
||||
|
||||
import { FilesPane, LogsPane, PreviewRailPane, ReviewPaneContent } from './panes'
|
||||
import { ContribWiring, WiredPane } from './wiring'
|
||||
|
||||
/**
|
||||
* Stripped-down app root (bb/contrib-areas) on the layout TREE model, mounting
|
||||
* the REAL app surfaces. The title bar and status bar sit OUTSIDE the grid
|
||||
* (fixed chrome) but are fully composable: title bar renders `titleBar.left/
|
||||
* right` slots; the status bar consumes `statusBar.left/right` DATA
|
||||
* contributions (payload = StatusbarItem). Core registers its items through
|
||||
* the same calls a plugin would use.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pane contributions. `data.placement` = semantic role for grid presets;
|
||||
// `data.minWidth/maxWidth/minHeight/maxHeight` = the SAME clamps the app's
|
||||
// `Pane` props declare — the layout tree sizes zones by weight (percentage)
|
||||
// but a zone never shrinks/grows past its active pane's clamp.
|
||||
// Headers are contextual (tree-side): a pane alone in a zone shows no
|
||||
// header/tab by default; stacked panes show chips. Double-click a zone
|
||||
// toggles its header either way.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ONE render identity for the workspace pane — syncWorkspaceTitle re-registers
|
||||
// the contribution (new title) and a fresh closure would remount the chat.
|
||||
const renderWorkspacePane = () => <WiredPane part="chatRoutes" />
|
||||
// The main tab carries the same session context menu as tile tabs (targets
|
||||
// the loaded primary session; no menu on a fresh draft).
|
||||
const wrapWorkspaceTab = (tab: ReactElement) => <WorkspaceTabMenu>{tab}</WorkspaceTabMenu>
|
||||
|
||||
/** The `@session` payload for the workspace tab — the loaded primary session,
|
||||
* or null on a fresh draft / full-page view (nothing to link). */
|
||||
const workspaceDragPayload = (): SessionDragPayload | null => {
|
||||
const selected = $selectedStoredSessionId.get()
|
||||
|
||||
if (!selected || $workspaceIsPage.get()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, selected))
|
||||
|
||||
return { id: selected, profile: stored?.profile ?? '', title: stored ? storedSessionTitle(stored) : '' }
|
||||
}
|
||||
|
||||
// The main tab drags like a session tile — drop it on a composer to link the
|
||||
// chat, on a zone/edge to stack/split. Defers (`false`) to the generic pane
|
||||
// move when there's no loaded session to carry.
|
||||
const workspaceTabDrag = (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) => {
|
||||
const payload = workspaceDragPayload()
|
||||
|
||||
if (!payload) {
|
||||
return false
|
||||
}
|
||||
|
||||
startSessionDrag(payload, event, { double, onTap })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
registry.registerMany([
|
||||
{
|
||||
id: 'sessions',
|
||||
area: 'panes',
|
||||
title: 'sessions',
|
||||
// Collapsible: leaves the grid on narrow viewports (edge overlay instead).
|
||||
// dock: where a RE-ADOPTED pane lands (healed from a stale dismissal) —
|
||||
// its default-ish spot beside main, not a random same-placement stack.
|
||||
data: {
|
||||
placement: 'left',
|
||||
collapsible: true,
|
||||
dock: { pane: 'workspace', pos: 'left' },
|
||||
revealAliases: ['chat-sidebar'],
|
||||
width: `${SIDEBAR_DEFAULT_WIDTH}px`,
|
||||
minWidth: `${SIDEBAR_DEFAULT_WIDTH}px`,
|
||||
maxWidth: `${SIDEBAR_MAX_WIDTH}px`
|
||||
},
|
||||
render: () => <WiredPane part="sidebar" />
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
area: 'panes',
|
||||
// Live-retitled to the loaded session by syncWorkspaceTitle below.
|
||||
title: 'New session',
|
||||
data: {
|
||||
placement: 'main',
|
||||
minWidth: '22vw',
|
||||
tabDrag: workspaceTabDrag,
|
||||
tabWrap: wrapWorkspaceTab,
|
||||
uncloseable: true
|
||||
},
|
||||
render: renderWorkspacePane
|
||||
},
|
||||
{
|
||||
id: 'terminal',
|
||||
area: 'panes',
|
||||
title: 'terminal',
|
||||
// revealOnPreset: choosing a layout that places the terminal (e.g.
|
||||
// "Terminal deck") turns takeover on so the zone actually shows, instead of
|
||||
// staying collapsed behind the ⌃` toggle. height sizes the fixed track (a
|
||||
// single-pane zone declaring a height is a fixed track — the preset weight
|
||||
// is moot): a short deck, not a third of the window.
|
||||
data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true },
|
||||
render: () => <WiredPane part="terminal" />
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
area: 'panes',
|
||||
title: 'files',
|
||||
// dock: re-adoption target after a stale dismissal (see sessions).
|
||||
data: {
|
||||
placement: 'right',
|
||||
collapsible: true,
|
||||
dock: { pane: 'workspace', pos: 'right' },
|
||||
revealAliases: ['file-browser'],
|
||||
width: FILE_BROWSER_DEFAULT_WIDTH,
|
||||
minWidth: FILE_BROWSER_MIN_WIDTH,
|
||||
maxWidth: FILE_BROWSER_MAX_WIDTH
|
||||
},
|
||||
render: () => <FilesPane />
|
||||
},
|
||||
{
|
||||
id: 'preview',
|
||||
area: 'panes',
|
||||
title: 'preview',
|
||||
// The rail brings its OWN tab strip (per-target tabs with close buttons).
|
||||
// Exists only while something is previewed — visibility is bound to the
|
||||
// preview targets below, like every other self-managed surface. dock:
|
||||
// adoption seed only — dockPaneBeside re-docks it next to files on every
|
||||
// reveal anyway (position-aware).
|
||||
data: {
|
||||
placement: 'right',
|
||||
dock: { pane: 'files', pos: 'left' },
|
||||
width: 'clamp(18rem, 36vw, 32rem)',
|
||||
minWidth: PREVIEW_RAIL_MIN_WIDTH,
|
||||
maxWidth: PREVIEW_RAIL_MAX_WIDTH
|
||||
},
|
||||
render: () => <PreviewRailPane />
|
||||
},
|
||||
{
|
||||
id: 'review',
|
||||
area: 'panes',
|
||||
title: 'review',
|
||||
// The second right sidebar: hidden until ⌘G ($reviewOpen) — bound below
|
||||
// like the other chrome toggles; its zone collapses while hidden.
|
||||
data: {
|
||||
placement: 'right',
|
||||
collapsible: true,
|
||||
revealAliases: [REVIEW_PANE_ID],
|
||||
width: FILE_BROWSER_DEFAULT_WIDTH,
|
||||
minWidth: FILE_BROWSER_MIN_WIDTH,
|
||||
maxWidth: FILE_BROWSER_MAX_WIDTH
|
||||
},
|
||||
render: () => <ReviewPaneContent />
|
||||
},
|
||||
{
|
||||
// Optional chrome — in NO default layout. Adoption stacks it with the
|
||||
// terminal; $logsOpen (default off, ⌘K "Toggle logs") reveals it.
|
||||
id: 'logs',
|
||||
area: 'panes',
|
||||
title: 'logs',
|
||||
// revealOnPreset: the Quad layout places logs, so applying it turns the
|
||||
// logs pane on (like a ⌘K "Toggle logs") instead of leaving it collapsed.
|
||||
data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true },
|
||||
render: () => <LogsPane />
|
||||
}
|
||||
])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chrome contributions. The title bar and status bar are fixed chrome outside
|
||||
// the grid, composable through these areas. Everything real lives in the real
|
||||
// components (TitlebarControls / useStatusbarItems). Sample PLUGIN
|
||||
// contributions don't live here — they're their own files under `src/plugins/`,
|
||||
// auto-discovered by discoverBundledPlugins() below.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
registry.registerMany([
|
||||
// Titlebar center stays empty on purpose: session title lives in tabs +
|
||||
// sidebar; place/cwd lives in the sidebar project tree. Center is drag
|
||||
// chrome (plugins can still contribute to titleBar.center if needed).
|
||||
// Layout edit mode registers through the SAME declarative surfaces plugins
|
||||
// use: a rebindable keybind (collision-checked in the panel) + a ⌘K row
|
||||
// whose hotkey hint tracks the live binding.
|
||||
{
|
||||
id: 'layout.editMode',
|
||||
area: KEYBINDS_AREA,
|
||||
data: {
|
||||
id: 'layout.editMode',
|
||||
label: 'Toggle layout edit mode',
|
||||
defaults: ['mod+shift+\\'],
|
||||
run: toggleLayoutEditMode
|
||||
} satisfies KeybindContribution
|
||||
},
|
||||
{
|
||||
id: 'layout.editMode',
|
||||
area: PALETTE_AREA,
|
||||
data: {
|
||||
id: 'layout.editMode',
|
||||
label: 'Toggle layout edit mode',
|
||||
action: 'layout.editMode',
|
||||
icon: LayoutDashboard,
|
||||
keywords: ['layout', 'zones', 'panes', 'edit', 'rearrange'],
|
||||
run: toggleLayoutEditMode
|
||||
} satisfies PaletteContribution
|
||||
},
|
||||
// The agent's write -> see loop: rescan <hermes home>/desktop-plugins
|
||||
// without relaunching (same-id reloads dispose the previous incarnation).
|
||||
{
|
||||
id: 'plugins.reload',
|
||||
area: PALETTE_AREA,
|
||||
data: {
|
||||
id: 'plugins.reload',
|
||||
label: 'Reload desktop plugins',
|
||||
keywords: ['plugins', 'reload', 'refresh', 'desktop'],
|
||||
run: () => void discoverRuntimePlugins()
|
||||
} satisfies PaletteContribution
|
||||
},
|
||||
{
|
||||
id: 'layout.reset',
|
||||
area: PALETTE_AREA,
|
||||
data: {
|
||||
id: 'layout.reset',
|
||||
label: 'Reset layout',
|
||||
icon: LayoutDashboard,
|
||||
keywords: ['layout', 'reset', 'default', 'panes'],
|
||||
run: resetLayoutTree
|
||||
} satisfies PaletteContribution
|
||||
},
|
||||
// The keybind panel's non-titlebar door (the keyboard icon is gone).
|
||||
{
|
||||
id: 'keybinds.panel',
|
||||
area: PALETTE_AREA,
|
||||
data: {
|
||||
id: 'keybinds.panel',
|
||||
label: 'Keyboard shortcuts',
|
||||
keywords: ['keybinds', 'shortcuts', 'hotkeys', 'keyboard'],
|
||||
run: toggleKeybindPanel
|
||||
} satisfies PaletteContribution
|
||||
}
|
||||
])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout presets — CHAT (main) always dominates.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The REAL default: sessions left, chat main, and the right sidebars in
|
||||
// column order main | … | review | preview | file-browser (files outermost,
|
||||
// preview DIRECTLY left of the file tree). Each is its OWN zone — main
|
||||
// parity: a file double-click slides the preview open as its own pane beside
|
||||
// the tree, never as a tab stacked into the files sidebar. Preview/review
|
||||
// zones collapse to nothing while their pane is hidden (no target / ⌘G off).
|
||||
// This static spot is just the seed — dockPaneBeside keeps preview adjacent
|
||||
// to files WHEREVER files moves (see the target listeners below).
|
||||
const DEFAULT_TREE = split(
|
||||
'row',
|
||||
[
|
||||
group(['sessions'], { id: 'grp-sessions' }),
|
||||
group(['workspace'], { id: 'grp-main' }),
|
||||
split(
|
||||
'column',
|
||||
[
|
||||
split(
|
||||
'row',
|
||||
[
|
||||
group(['review'], { id: 'grp-review' }),
|
||||
group(['preview'], { id: 'grp-preview' }),
|
||||
group(['files'], { id: 'grp-files' })
|
||||
],
|
||||
[1, 1, 1.2],
|
||||
'spl-rail'
|
||||
),
|
||||
group(['terminal'], { id: 'grp-terminal' })
|
||||
],
|
||||
[1.6, 1],
|
||||
'spl-right'
|
||||
)
|
||||
],
|
||||
[1, 3.4, 1.25],
|
||||
'spl-root'
|
||||
)
|
||||
|
||||
const FOCUS_TREE = split(
|
||||
'row',
|
||||
[group(['sessions']), group(['workspace', 'files', 'preview', 'review', 'terminal'])],
|
||||
[1, 4.6]
|
||||
)
|
||||
|
||||
const TERMINAL_TREE = split(
|
||||
'column',
|
||||
[
|
||||
split('row', [group(['sessions']), group(['workspace']), group(['files', 'preview', 'review'])], [1, 3.2, 1.2]),
|
||||
group(['terminal'])
|
||||
],
|
||||
[3, 1]
|
||||
)
|
||||
|
||||
const QUAD_TREE = split(
|
||||
'column',
|
||||
[
|
||||
split('row', [group(['sessions', 'files']), group(['workspace'])], [1, 3]),
|
||||
split('row', [group(['terminal']), group(['preview', 'review', 'logs'])], [1.4, 1])
|
||||
],
|
||||
[3, 1]
|
||||
)
|
||||
|
||||
registry.registerMany([
|
||||
{ id: 'default', area: 'layouts', title: 'Default', order: 0, data: DEFAULT_TREE },
|
||||
{ id: 'focus', area: 'layouts', title: 'Focus', order: 10, data: FOCUS_TREE },
|
||||
{ id: 'terminal-deck', area: 'layouts', title: 'Terminal deck', order: 20, data: TERMINAL_TREE },
|
||||
{ id: 'quad', area: 'layouts', title: 'Quad', order: 30, data: QUAD_TREE }
|
||||
])
|
||||
|
||||
declareDefaultTree(DEFAULT_TREE)
|
||||
|
||||
// Bundled plugins load AFTER core, so a same-id contribution from a plugin
|
||||
// deliberately overrides the core default (last writer wins). Third-party
|
||||
// runtime plugins will flow through the same discovery seam.
|
||||
discoverBundledPlugins()
|
||||
|
||||
// Plugin panes join the tree by their `placement` hint the moment they
|
||||
// register — incl. runtime plugins arriving seconds after boot.
|
||||
watchContributedPanes()
|
||||
|
||||
// Session + route (page) tiles: persisted splits register panes docked beside
|
||||
// main.
|
||||
watchSessionTiles()
|
||||
watchRouteTiles()
|
||||
|
||||
// The main tab reads as its SESSION (the loaded title, "New session" on a
|
||||
// fresh draft) — a stack of main + tiles is then just a row of session names.
|
||||
// register() replaces same-id in place; the render fn is the shared constant
|
||||
// above, so the pane content never remounts.
|
||||
const syncWorkspaceTitle = () => {
|
||||
const selected = $selectedStoredSessionId.get()
|
||||
const stored = selected ? $sessions.get().find(s => sessionMatchesStoredId(s, selected)) : null
|
||||
|
||||
registry.register({
|
||||
id: 'workspace',
|
||||
area: 'panes',
|
||||
title: stored ? storedSessionTitle(stored) : 'New session',
|
||||
data: {
|
||||
// Pages aren't tab-able: the main zone's bar stands down while one shows.
|
||||
headerVeto: $workspaceIsPage.get(),
|
||||
placement: 'main',
|
||||
minWidth: '22vw',
|
||||
tabDrag: workspaceTabDrag,
|
||||
tabWrap: wrapWorkspaceTab,
|
||||
uncloseable: true
|
||||
},
|
||||
render: renderWorkspacePane
|
||||
})
|
||||
}
|
||||
|
||||
$selectedStoredSessionId.listen(syncWorkspaceTitle)
|
||||
$sessions.listen(syncWorkspaceTitle)
|
||||
$workspaceIsPage.listen(syncWorkspaceTitle)
|
||||
|
||||
// Layout reset collapses every session tile into main as a tab (after the
|
||||
// workspace) instead of re-scattering them — pre-placed before adoption.
|
||||
registerLayoutResetHandler(stackSessionTilesIntoMain)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Titlebar chrome toggles -> tree. The TitlebarControls buttons keep their
|
||||
// store semantics ($sidebarOpen / $fileBrowserOpen / $panesFlipped); the tree
|
||||
// reacts — a hidden pane's zone collapses (content stays mounted), the flip
|
||||
// toggle mirrors the root row.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function bindPaneVisibility(
|
||||
paneId: string,
|
||||
$open: { get(): boolean; listen(fn: (open: boolean) => void): void },
|
||||
close?: () => void,
|
||||
open?: () => void
|
||||
) {
|
||||
setTreePaneHidden(paneId, !$open.get())
|
||||
$open.listen(isOpen => setTreePaneHidden(paneId, !isOpen))
|
||||
|
||||
// The tab menu's Close routes through the owning store (never dismissal),
|
||||
// so the pane's toggle buttons stay truthful.
|
||||
if (close) {
|
||||
registerPaneCloser(paneId, close)
|
||||
}
|
||||
|
||||
// The opener is the mirror: preset application (revealOnPreset) shows the
|
||||
// pane through the same store, so the toggle stays truthful.
|
||||
if (open) {
|
||||
registerPaneOpener(paneId, open)
|
||||
}
|
||||
}
|
||||
|
||||
// TOOL PANELS (terminal, logs): like bindPaneVisibility but the toggle COLLAPSES
|
||||
// the zone to a persistent rail (tab stays) instead of hiding it — the
|
||||
// IntelliJ/VS-Code tool-window model. Restore routes back through `open` (rail
|
||||
// click / chevron) so ⌃`/the button stay truthful; the tab's ✕ removes it.
|
||||
function bindPaneCollapse(
|
||||
paneId: string,
|
||||
$open: { get(): boolean; listen(fn: (open: boolean) => void): void },
|
||||
close: () => void,
|
||||
open: () => void
|
||||
) {
|
||||
markCollapsePane(paneId)
|
||||
setPaneCollapsed(paneId, !$open.get())
|
||||
$open.listen(isOpen => setPaneCollapsed(paneId, !isOpen))
|
||||
registerPaneCloser(paneId, close)
|
||||
registerPaneOpener(paneId, open)
|
||||
}
|
||||
|
||||
// SIDES have one source of truth: the TREE. The legacy $panesFlipped flag is
|
||||
// DERIVED from where the sessions zone actually sits (TitlebarControls maps
|
||||
// its left/right buttons through it), so dragging sessions across — or
|
||||
// applying a mirrored preset — remaps the buttons automatically. The flip
|
||||
// action (⌘\ / titlebar) mirrors the tree only when they disagree.
|
||||
const sessionsOnRight = () => {
|
||||
const tree = $layoutTree.get()
|
||||
|
||||
if (!tree) {
|
||||
return null
|
||||
}
|
||||
|
||||
const order = allPaneIds(tree)
|
||||
const sessions = order.indexOf('sessions')
|
||||
const main = order.indexOf('workspace')
|
||||
|
||||
return sessions >= 0 && main >= 0 ? sessions > main : null
|
||||
}
|
||||
|
||||
$layoutTree.subscribe(() => {
|
||||
const flipped = sessionsOnRight()
|
||||
|
||||
if (flipped !== null && flipped !== $panesFlipped.get()) {
|
||||
$panesFlipped.set(flipped)
|
||||
}
|
||||
})
|
||||
|
||||
$panesFlipped.listen(flipped => {
|
||||
const current = sessionsOnRight()
|
||||
|
||||
if (current !== null && current !== flipped) {
|
||||
mirrorLayoutTree()
|
||||
}
|
||||
})
|
||||
|
||||
// POSITIONAL side toggles (titlebar buttons, ⌘B / ⌘J): $sidebarOpen ≙ the
|
||||
// LEFT side of the main zone, $fileBrowserOpen ≙ the RIGHT — everything on
|
||||
// that side hides together, whatever panes have been rearranged there.
|
||||
bindTreeSideVisibility('left', $sidebarOpen, setSidebarOpen)
|
||||
bindTreeSideVisibility('right', $fileBrowserOpen, setFileBrowserOpen)
|
||||
|
||||
// Workspace-scoped surfaces: the file tree and git diff only mean something
|
||||
// inside a project. A detached chat (no cwd) hides them — their zones
|
||||
// collapse and the chat absorbs the width; picking a project brings them
|
||||
// back. The terminal is NOT workspace-gated: unlike the old shell (where it
|
||||
// rode the rail's row and vanished with it), its zone stands on its own.
|
||||
const $hasWorkspace = computed($currentCwd, cwd => Boolean(cwd.trim()))
|
||||
|
||||
bindPaneVisibility('files', $hasWorkspace)
|
||||
// ⌘G — the review sidebar appears/disappears (and comes to the front).
|
||||
bindPaneVisibility(
|
||||
'review',
|
||||
computed([$reviewOpen, $hasWorkspace], (open, workspace) => open && workspace),
|
||||
closeReview
|
||||
)
|
||||
// ⌃` / statusbar toggle — the terminal COLLAPSES to a rail (tab stays), not
|
||||
// hides; PTYs stay alive while collapsed (see PersistentTerminal).
|
||||
bindPaneCollapse(
|
||||
'terminal',
|
||||
$terminalTakeover,
|
||||
() => setTerminalTakeover(false),
|
||||
() => setTerminalTakeover(true)
|
||||
)
|
||||
|
||||
// Preview EXISTS only while something is previewed (old-shell semantics:
|
||||
// closing the last preview tab closes the pane; a new target opens + fronts
|
||||
// it). Same visibility binding as every other self-managed surface, driven
|
||||
// by the live targets instead of a toggle.
|
||||
const $previewVisible = computed([$previewTarget, $filePreviewTarget], (target, fileTarget) =>
|
||||
Boolean(target || fileTarget)
|
||||
)
|
||||
|
||||
bindPaneVisibility('preview', $previewVisible, closeRightRail)
|
||||
|
||||
// Logs are optional chrome: off by default, toggled from ⌘K, persisted.
|
||||
const $logsOpen = persistentAtom('hermes.desktop.logsOpen', false, Codecs.bool)
|
||||
|
||||
bindPaneCollapse(
|
||||
'logs',
|
||||
$logsOpen,
|
||||
() => $logsOpen.set(false),
|
||||
() => $logsOpen.set(true)
|
||||
)
|
||||
registry.register({
|
||||
id: 'logs.toggle',
|
||||
area: PALETTE_AREA,
|
||||
data: {
|
||||
id: 'logs.toggle',
|
||||
label: 'Toggle logs',
|
||||
keywords: ['logs', 'agent log', 'tail', 'debug'],
|
||||
run: () => $logsOpen.set(!$logsOpen.get())
|
||||
} satisfies PaletteContribution
|
||||
})
|
||||
|
||||
// Sessions/files Close = collapse their SIDE (⌘B/⌘J truthful, titlebar button
|
||||
// flips back) — but only while the pane actually lives in that root side
|
||||
// column. Dragged next to main, a side collapse can't hide it (the collapse
|
||||
// skips main-bearing children), so Close falls back to dismissal there —
|
||||
// otherwise ⌘W/Close silently no-op.
|
||||
registerPaneCloser('sessions', () =>
|
||||
paneRootSide('sessions') === 'left' ? setSidebarOpen(false) : dismissTreePane('sessions')
|
||||
)
|
||||
registerPaneCloser('files', () =>
|
||||
paneRootSide('files') === 'right' ? setFileBrowserOpen(false) : dismissTreePane('files')
|
||||
)
|
||||
|
||||
// A preview target lands NEXT TO the file tree — position-aware: wherever
|
||||
// files currently lives (default rail, ⌘\-flipped, dragged into a stack), the
|
||||
// preview zone docks directly beside it. A user who drags the preview pane
|
||||
// somewhere pins it there instead (until a preset/reset). Then reveal: open
|
||||
// the side, unhide, front — a NEW target while already visible still fronts.
|
||||
const revealPreview = () => {
|
||||
dockPaneBeside('preview', 'files')
|
||||
revealTreePane('preview')
|
||||
}
|
||||
|
||||
$previewTarget.listen(target => target && revealPreview())
|
||||
$filePreviewTarget.listen(target => target && revealPreview())
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ContribController() {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
className="h-screen min-h-0 flex-col bg-background"
|
||||
onOpenChange={setSidebarOpen}
|
||||
open={sidebarOpen}
|
||||
style={{ '--sidebar-width': '100%' } as CSSProperties}
|
||||
>
|
||||
<ContribWiring>
|
||||
<div
|
||||
className="flex h-screen min-h-0 w-screen flex-col bg-(--ui-bg-chrome) text-(--ui-text-primary)"
|
||||
style={{ '--titlebar-height': '0px' } as CSSProperties}
|
||||
>
|
||||
{/* Title bar: fixed chrome outside the grid, composable via slots.
|
||||
Layout contract (no contribution can break it):
|
||||
- a full-bar DRAG BASE underneath (pointer-events-none, like
|
||||
AppShell's drag strips) — everywhere without content drags
|
||||
the window;
|
||||
- each slot region is width-fit, no-drag, pointer-events-auto,
|
||||
so every contribution is clickable by construction;
|
||||
- LEFT/RIGHT slots align to the MAIN PANE's geometry via the
|
||||
tree-published --workspace-left/right vars (pure CSS, no rect
|
||||
threading), clamped to clear the REAL TitlebarControls
|
||||
clusters (fixed, z-70); center is truly window-centered. */}
|
||||
<div className="relative flex h-[34px] shrink-0 items-center border-b border-(--ui-stroke-tertiary) text-xs">
|
||||
{/* Drag strips, AppShell-style: cut to AVOID the fixed control
|
||||
clusters instead of overlapping them — Electron's no-drag
|
||||
carve-out of fixed/transformed elements is unreliable, so a
|
||||
full-bar drag base kills their clicks. In-flow slot content
|
||||
still carves via its own no-drag wrapper (the same pattern as
|
||||
the app's session-title button). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 left-0 w-(--titlebar-controls-left,14px) [-webkit-app-region:drag]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 left-[calc(var(--titlebar-controls-left,14px)+(var(--titlebar-control-size,1.25rem)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,5.5rem)+0.75rem)] [-webkit-app-region:drag]"
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
|
||||
style={{
|
||||
left: 'max(calc(var(--workspace-left, 0px) + 0.5rem), calc(var(--titlebar-controls-left, 14px) + 2 * var(--titlebar-control-size, 1.25rem) + 1rem))'
|
||||
}}
|
||||
>
|
||||
<Slot area="titleBar.left" />
|
||||
</div>
|
||||
<div className="pointer-events-auto absolute left-1/2 top-1/2 z-10 flex w-max -translate-x-1/2 -translate-y-1/2 items-center gap-2 [-webkit-app-region:no-drag]">
|
||||
<Slot area="titleBar.center" />
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
|
||||
style={{
|
||||
right:
|
||||
'max(calc(var(--workspace-right, 0px) + 0.5rem), calc(var(--titlebar-tools-right, 0.75rem) + 4 * (var(--titlebar-control-size, 1.25rem) + 0.25rem) + 0.5rem))'
|
||||
}}
|
||||
>
|
||||
<Slot area="titleBar.right" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LayoutTreeRoot />
|
||||
|
||||
{/* "Close running tab?" — the busy/input-blocked tile close gate. */}
|
||||
<SessionTileCloseConfirm />
|
||||
|
||||
{/* The REAL statusbar (model pill, command center, agents, …) with
|
||||
statusBar.left/right contributions merged in. */}
|
||||
<WiredPane part="statusbar" />
|
||||
</div>
|
||||
</ContribWiring>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Referenced type kept for plugin authors' reference (payload shape of
|
||||
// statusBar.* contributions).
|
||||
export type { StatusbarItem }
|
||||
133
apps/desktop/src/app/contrib/hooks/use-background-sync.ts
Normal file
133
apps/desktop/src/app/contrib/hooks/use-background-sync.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { useEffect } from 'react'
|
||||
|
||||
import { refreshActiveProfile } from '@/store/profile'
|
||||
import { $activeSessionId, $currentCwd, setCurrentCwd } from '@/store/session'
|
||||
|
||||
import type { GatewayRequester } from '../types'
|
||||
|
||||
// Cron sessions are written by a background scheduler tick, messaging turns by
|
||||
// the background gateway (Telegram, WeChat, Discord, …) — neither signals the
|
||||
// desktop websocket, so poll the bounded lists while the app is visible.
|
||||
const CRON_POLL_INTERVAL_MS = 30_000
|
||||
const MESSAGING_POLL_INTERVAL_MS = 10_000
|
||||
const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000
|
||||
|
||||
interface BackgroundSyncParams {
|
||||
activeIsMessaging: boolean
|
||||
activeSessionId: null | string
|
||||
freshDraftReady: boolean
|
||||
gatewayState: string
|
||||
refreshActiveMessagingTranscript: () => Promise<unknown> | unknown
|
||||
refreshCronJobs: () => Promise<unknown> | unknown
|
||||
refreshCurrentModel: (force?: boolean) => Promise<unknown> | unknown
|
||||
refreshHermesConfig: () => Promise<unknown> | unknown
|
||||
refreshMessagingSessions: () => Promise<unknown> | unknown
|
||||
refreshSessions: () => Promise<unknown> | unknown
|
||||
requestGateway: GatewayRequester
|
||||
}
|
||||
|
||||
/** Poll a callback while the tab is visible, on `intervalMs`; re-checks on tab
|
||||
* re-focus. Returns nothing — meant to live inside an effect. */
|
||||
function visiblePoll(intervalMs: number, tick: () => void): () => void {
|
||||
const run = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
tick()
|
||||
}
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(run, intervalMs)
|
||||
document.addEventListener('visibilitychange', run)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId)
|
||||
document.removeEventListener('visibilitychange', run)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps app data live while the gateway is open: an on-connect reseed (model /
|
||||
* profile / sessions + relative-cwd resolution), the cron / messaging /
|
||||
* open-transcript visibility polls, and the fresh-draft model/config reseed.
|
||||
* All the "the desktop websocket won't tell us, so poll" logic in one place.
|
||||
*/
|
||||
export function useBackgroundSync({
|
||||
activeIsMessaging,
|
||||
activeSessionId,
|
||||
freshDraftReady,
|
||||
gatewayState,
|
||||
refreshActiveMessagingTranscript,
|
||||
refreshCronJobs,
|
||||
refreshCurrentModel,
|
||||
refreshHermesConfig,
|
||||
refreshMessagingSessions,
|
||||
refreshSessions,
|
||||
requestGateway
|
||||
}: BackgroundSyncParams): void {
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
void refreshCurrentModel()
|
||||
void refreshActiveProfile()
|
||||
void refreshSessions()
|
||||
|
||||
// A RELATIVE workspace cwd (config `terminal.cwd: .`) renders as "." in the
|
||||
// file tree header — resolve it to the backend's absolute path once.
|
||||
// Session runtime info still overrides later, and never while a session is
|
||||
// active.
|
||||
const cwd = $currentCwd.get().trim()
|
||||
|
||||
if (!$activeSessionId.get() && cwd && !/^(\/|[A-Za-z]:[\\/])/.test(cwd)) {
|
||||
void requestGateway<{ cwd?: string }>('config.get', { key: 'project', cwd })
|
||||
.then(info => {
|
||||
if (info.cwd && !$activeSessionId.get()) {
|
||||
setCurrentCwd(info.cwd)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}, [gatewayState, refreshCurrentModel, refreshSessions, requestGateway])
|
||||
|
||||
// Keep the cron-jobs section live without a user action (scheduler ticks in
|
||||
// the background); re-check on tab re-focus too.
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
return visiblePoll(CRON_POLL_INTERVAL_MS, () => void refreshCronJobs())
|
||||
}, [gatewayState, refreshCronJobs])
|
||||
|
||||
// Keep the messaging-platform session lists live (inbound turns are written
|
||||
// by the gateway, not the desktop websocket).
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
return visiblePoll(MESSAGING_POLL_INTERVAL_MS, () => void refreshMessagingSessions())
|
||||
}, [gatewayState, refreshMessagingSessions])
|
||||
|
||||
// Only the open messaging transcript needs its own poll — local chats are
|
||||
// live over the websocket already.
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open' || !activeIsMessaging) {
|
||||
return
|
||||
}
|
||||
|
||||
const dispose = visiblePoll(ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS, () => void refreshActiveMessagingTranscript())
|
||||
void refreshActiveMessagingTranscript()
|
||||
|
||||
return dispose
|
||||
}, [activeIsMessaging, gatewayState, refreshActiveMessagingTranscript])
|
||||
|
||||
// A fresh new-session draft (gateway open, no active session) re-pulls the
|
||||
// model + config so the composer pill reflects the profile default.
|
||||
useEffect(() => {
|
||||
if (gatewayState === 'open' && !activeSessionId && freshDraftReady) {
|
||||
void refreshCurrentModel()
|
||||
void refreshHermesConfig()
|
||||
}
|
||||
}, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig])
|
||||
}
|
||||
176
apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts
Normal file
176
apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { closeActiveTab } from '@/app/chat/close-tab'
|
||||
import { storedSessionIdForNotification } from '@/lib/session-ids'
|
||||
import { respondToApprovalAction } from '@/store/native-notifications'
|
||||
import {
|
||||
getRememberedRoute,
|
||||
getRememberedSessionId,
|
||||
setRememberedRoute,
|
||||
setRememberedSessionId
|
||||
} from '@/store/session'
|
||||
import { onSessionsChanged } from '@/store/session-sync'
|
||||
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsert } from '../../chat/composer/focus'
|
||||
import { appViewForPath, isOverlayView, NEW_CHAT_ROUTE, sessionRoute } from '../../routes'
|
||||
|
||||
interface DesktopIntegrationsParams {
|
||||
chatOpen: boolean
|
||||
hasPreview: boolean
|
||||
locationPathname: string
|
||||
navigate: (to: string, options?: { replace?: boolean }) => void
|
||||
refreshSessions: () => Promise<unknown> | unknown
|
||||
resumeExhaustedSessionId: null | string
|
||||
routedSessionId: null | string
|
||||
runtimeIdByStoredSessionId: { readonly current: Map<string, string> }
|
||||
}
|
||||
|
||||
/**
|
||||
* All the Electron-main / OS / cross-window integrations the shell listens for:
|
||||
* update polling, the ⌘W close shortcut, deep links, native-notification
|
||||
* navigation, preview-shortcut enablement, remembered-session restore, and
|
||||
* cross-window session-list sync. Kept out of the wiring controller so the
|
||||
* "talks to the desktop shell" surface reads as one unit.
|
||||
*/
|
||||
export function useDesktopIntegrations({
|
||||
locationPathname,
|
||||
navigate,
|
||||
refreshSessions,
|
||||
resumeExhaustedSessionId,
|
||||
routedSessionId,
|
||||
runtimeIdByStoredSessionId
|
||||
}: DesktopIntegrationsParams): void {
|
||||
// Update polling — populates $desktopVersion/$updateStatus, which feed the
|
||||
// statusbar version pill and the update toasts. Also honors the main
|
||||
// process's "open updates" menu request.
|
||||
useEffect(() => {
|
||||
startUpdatePoller()
|
||||
const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow())
|
||||
|
||||
return () => {
|
||||
unsubscribe?.()
|
||||
stopUpdatePoller()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// The renderer OWNS ⌘W: on macOS the native menu accelerator would else
|
||||
// close the window, so claim it unconditionally — the menu then routes ⌘W
|
||||
// to us (close-preview-requested IPC) and we decide tab-vs-window.
|
||||
useEffect(() => {
|
||||
window.hermesDesktop?.setPreviewShortcutActive?.(true)
|
||||
}, [])
|
||||
|
||||
// Remember the open chat (session id for notifications/resume) AND the last
|
||||
// non-overlay route (a page like /skills, or a session route) so a relaunch
|
||||
// lands where you were. Overlays (settings/command-center/…) aren't stored —
|
||||
// you don't want to boot into a modal.
|
||||
useEffect(() => {
|
||||
if (routedSessionId) {
|
||||
setRememberedSessionId(routedSessionId)
|
||||
}
|
||||
|
||||
if (!isOverlayView(appViewForPath(locationPathname))) {
|
||||
setRememberedRoute(locationPathname)
|
||||
}
|
||||
}, [locationPathname, routedSessionId])
|
||||
|
||||
const restoredRef = useRef(false)
|
||||
|
||||
// Restore once on cold start — only when the renderer booted at the default
|
||||
// route (a hidden-then-shown window keeps its own route). Prefer the full
|
||||
// remembered route (covers pages); fall back to the last session id.
|
||||
useEffect(() => {
|
||||
if (restoredRef.current || locationPathname !== NEW_CHAT_ROUTE) {
|
||||
restoredRef.current = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
restoredRef.current = true
|
||||
const route = getRememberedRoute()
|
||||
|
||||
if (route && route !== NEW_CHAT_ROUTE && !isOverlayView(appViewForPath(route))) {
|
||||
navigate(route, { replace: true })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const last = getRememberedSessionId()
|
||||
|
||||
if (last) {
|
||||
navigate(sessionRoute(last), { replace: true })
|
||||
}
|
||||
}, [locationPathname, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) {
|
||||
setRememberedSessionId(null)
|
||||
}
|
||||
}, [resumeExhaustedSessionId])
|
||||
|
||||
// Native-notification click -> jump to the session (runtime id translated to
|
||||
// the stored id the chat route is keyed by); action buttons resolve in place.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => {
|
||||
if (sessionId) {
|
||||
navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionId.current)))
|
||||
}
|
||||
})
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [navigate, runtimeIdByStoredSessionId])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => {
|
||||
void respondToApprovalAction(sessionId ?? null, actionId)
|
||||
})
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
// hermes:// deep links -> a reviewable /blueprint command in the composer.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => {
|
||||
if (!payload || payload.kind !== 'blueprint' || !payload.name) {
|
||||
return
|
||||
}
|
||||
|
||||
const slots = Object.entries(payload.params || {})
|
||||
.map(([k, v]) => {
|
||||
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
|
||||
|
||||
return `${k}=${sval}`
|
||||
})
|
||||
.join(' ')
|
||||
|
||||
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
|
||||
requestComposerInsert(command, { mode: 'block', target: 'main' })
|
||||
requestComposerFocus('main')
|
||||
})
|
||||
|
||||
void window.hermesDesktop?.signalDeepLinkReady?.()
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
// ⌘W via the macOS menu accelerator → close the focused tab; if nothing is
|
||||
// closeable, fall back to closing the window (so ⌘W still works as the
|
||||
// OS-standard window close, esp. secondary windows). The Win/Linux keyboard
|
||||
// path is the `view.closeTab` keybind (use-keybinds), sharing closeActiveTab.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(() => void closeActiveTab())
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
// Another window mutated the shared session list -> re-pull the sidebar.
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
return onSessionsChanged(() => void refreshSessions())
|
||||
}, [refreshSessions])
|
||||
}
|
||||
71
apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts
Normal file
71
apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { setPetActivity } from '@/store/pet'
|
||||
import { setPetScale } from '@/store/pet-gallery'
|
||||
import {
|
||||
setPetOverlayOpenAppHandler,
|
||||
setPetOverlayScaleHandler,
|
||||
setPetOverlaySubmitHandler
|
||||
} from '@/store/pet-overlay'
|
||||
import { $attentionSessionIds, $sessions } from '@/store/session'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import type { GatewayRequester } from '../types'
|
||||
|
||||
interface PetBridgeParams {
|
||||
requestGateway: GatewayRequester
|
||||
resumeSession: (sessionId: string) => Promise<unknown> | unknown
|
||||
submitText: (text: string) => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the popped-out pet overlay back into the app: submit a prompt, resize,
|
||||
* and open the most-recent thread, plus mirroring "a session is awaiting the
|
||||
* user" into the pet's pose. Handlers register ONCE through refs tracking the
|
||||
* latest callbacks — re-registering on identity churn leaves a nulled-handler
|
||||
* window that can drop a submit. Primary window only.
|
||||
*/
|
||||
export function usePetBridge({ requestGateway, resumeSession, submitText }: PetBridgeParams): void {
|
||||
const submitTextRef = useRef(submitText)
|
||||
submitTextRef.current = submitText
|
||||
const resumeSessionRef = useRef(resumeSession)
|
||||
resumeSessionRef.current = resumeSession
|
||||
const requestGatewayRef = useRef(requestGateway)
|
||||
requestGatewayRef.current = requestGateway
|
||||
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
setPetOverlaySubmitHandler(text => void submitTextRef.current(text))
|
||||
// Alt+wheel resize from the popped-out pet — persist through this window's
|
||||
// gateway (the overlay has none) so it survives restart.
|
||||
setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale))
|
||||
// Mail icon: $sessions is most-recent-first; the pet is global, so "most
|
||||
// recent" is the right target.
|
||||
setPetOverlayOpenAppHandler(() => {
|
||||
const recent = $sessions.get()[0]
|
||||
|
||||
if (recent?.id) {
|
||||
void resumeSessionRef.current(recent.id)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
setPetOverlaySubmitHandler(null)
|
||||
setPetOverlayOpenAppHandler(null)
|
||||
setPetOverlayScaleHandler(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Mirror "a session is blocked on the user" (clarify/approval) into the pet's
|
||||
// awaitingInput flag so it shows the `waiting` pose.
|
||||
useEffect(() => {
|
||||
const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 })
|
||||
|
||||
sync()
|
||||
|
||||
return $attentionSessionIds.listen(sync)
|
||||
}, [])
|
||||
}
|
||||
108
apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts
Normal file
108
apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { useEffect } from 'react'
|
||||
|
||||
import { getSessionMessages, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
|
||||
import { toChatMessages } from '@/lib/chat-messages'
|
||||
import { publishSessionState, setSessionTileDelegate } from '@/store/session-states'
|
||||
import type { SessionResumeResponse } from '@/types/hermes'
|
||||
|
||||
import type { usePromptActions } from '../../session/hooks/use-prompt-actions'
|
||||
import type { useSessionStateCache } from '../../session/hooks/use-session-state-cache'
|
||||
import type { GatewayRequester } from '../types'
|
||||
|
||||
type SessionStateCache = ReturnType<typeof useSessionStateCache>
|
||||
|
||||
interface SessionTileDelegateParams {
|
||||
archiveSession: (storedSessionId: string) => Promise<unknown>
|
||||
branchStoredSession: (storedSessionId: string) => Promise<unknown>
|
||||
executeSlashCommand: ReturnType<typeof usePromptActions>['executeSlashCommand']
|
||||
removeSession: (storedSessionId: string) => Promise<unknown>
|
||||
requestGateway: GatewayRequester
|
||||
runtimeIdByStoredSessionIdRef: SessionStateCache['runtimeIdByStoredSessionIdRef']
|
||||
sessionStateByRuntimeIdRef: SessionStateCache['sessionStateByRuntimeIdRef']
|
||||
updateSessionState: SessionStateCache['updateSessionState']
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the session-tile delegate: resume / submit / interrupt / slash for
|
||||
* tiled sessions WITHOUT touching the primary view ($activeSessionId /
|
||||
* $messages stay the main thread's). Resume reuses a live runtime binding when
|
||||
* one exists (incl. the main thread's own session); a cold tile binds +
|
||||
* hydrates the cache, which publishSessionState mirrors to the tile.
|
||||
*/
|
||||
export function useSessionTileDelegate({
|
||||
archiveSession,
|
||||
branchStoredSession,
|
||||
executeSlashCommand,
|
||||
removeSession,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
}: SessionTileDelegateParams): void {
|
||||
useEffect(() => {
|
||||
setSessionTileDelegate({
|
||||
archiveSession: async storedSessionId => {
|
||||
await archiveSession(storedSessionId)
|
||||
},
|
||||
branchSession: async storedSessionId => {
|
||||
await branchStoredSession(storedSessionId)
|
||||
},
|
||||
deleteSession: async storedSessionId => {
|
||||
await removeSession(storedSessionId)
|
||||
},
|
||||
executeSlash: async (rawCommand, sessionId) => {
|
||||
await executeSlashCommand(rawCommand, { sessionId })
|
||||
},
|
||||
interruptSession: async runtimeId => {
|
||||
await requestGateway('session.interrupt', { session_id: runtimeId })
|
||||
},
|
||||
resumeTile: async storedSessionId => {
|
||||
const existing = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
const cached = existing ? sessionStateByRuntimeIdRef.current.get(existing) : undefined
|
||||
|
||||
if (existing && cached?.storedSessionId === storedSessionId) {
|
||||
publishSessionState(existing, cached)
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
const [prefetch, resumed] = await Promise.all([
|
||||
getSessionMessages(storedSessionId).catch(() => null),
|
||||
requestGateway<SessionResumeResponse>('session.resume', { session_id: storedSessionId, cols: 96 })
|
||||
])
|
||||
|
||||
const runtimeId = resumed?.session_id
|
||||
|
||||
if (!runtimeId) {
|
||||
throw new Error('resume returned no session id')
|
||||
}
|
||||
|
||||
updateSessionState(
|
||||
runtimeId,
|
||||
state => ({
|
||||
...state,
|
||||
busy: Boolean(resumed?.info?.running),
|
||||
messages:
|
||||
state.messages.length > 0 ? state.messages : toChatMessages(prefetch?.messages ?? resumed?.messages ?? [])
|
||||
}),
|
||||
storedSessionId
|
||||
)
|
||||
|
||||
return runtimeId
|
||||
},
|
||||
submitToSession: async (runtimeId, text) => {
|
||||
await requestGateway('prompt.submit', { session_id: runtimeId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
|
||||
},
|
||||
updateSession: (runtimeId, updater) => updateSessionState(runtimeId, updater)
|
||||
})
|
||||
}, [
|
||||
archiveSession,
|
||||
branchStoredSession,
|
||||
executeSlashCommand,
|
||||
removeSession,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
])
|
||||
}
|
||||
7
apps/desktop/src/app/contrib/index.ts
Normal file
7
apps/desktop/src/app/contrib/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// The contribution-driven shell package. `controller` registers panes /
|
||||
// layouts / chrome and mounts the app root; `wiring` is the data controller +
|
||||
// memoized pane surfaces; `panes` holds the real-data pane bodies + statusbar
|
||||
// group setters. Only the controller is a public entry (the app root renders
|
||||
// it); the rest are internal to this directory.
|
||||
export { ContribController } from './controller'
|
||||
export { ContribWiring, WiredPane } from './wiring'
|
||||
207
apps/desktop/src/app/contrib/panes.tsx
Normal file
207
apps/desktop/src/app/contrib/panes.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* Real-data panes + composable bar items for the contrib root:
|
||||
*
|
||||
* - `PreviewRailPane` — the REAL ChatPreviewRail; files-pane clicks feed it.
|
||||
* - `FilesPane` — real file browser; activating a file opens it in preview.
|
||||
* - Core statusbar items with LIVE store-backed labels, registered as DATA
|
||||
* contributions (`area: 'statusBar.left' / 'statusBar.right'`, payload =
|
||||
* StatusbarItem) — plugins add theirs through the identical call.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { atom } from 'nanostores'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import { ChatPreviewRail } from '@/app/chat/right-rail/preview'
|
||||
import { RightSidebarPane } from '@/app/right-sidebar'
|
||||
import { ReviewPane } from '@/app/right-sidebar/review'
|
||||
import type { GroupSetter } from '@/app/shell/group-setter'
|
||||
import type { StatusbarItem } from '@/app/shell/statusbar-controls'
|
||||
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
|
||||
import type { TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||
import { DecodeText } from '@/components/ui/decode-text'
|
||||
import { ContribBoundary } from '@/contrib/react/boundary'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { getLogs } from '@/hermes'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $filePreviewTarget, $previewTarget, setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logs — live agent-log tail. OPTIONAL chrome: not in any default layout,
|
||||
// hidden until the ⌘K "Toggle logs" command opens it ($logsOpen).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function LogsPane() {
|
||||
const { data, error } = useQuery({
|
||||
queryKey: ['contrib-logs-tail'],
|
||||
queryFn: () => getLogs({ lines: 300 }),
|
||||
refetchInterval: 5000
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return <div className="p-3 text-xs text-(--ui-text-quaternary)">log unavailable: {String(error)}</div>
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center">
|
||||
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="LOGS" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No chrome of its own — the zone header (when the user summons it) is the
|
||||
// pane's only label. Just the tail.
|
||||
return (
|
||||
<pre className="h-full min-h-0 overflow-auto whitespace-pre-wrap break-words p-2.5 font-mono text-[0.66rem] leading-relaxed text-(--ui-text-secondary)">
|
||||
{data.lines.join('\n')}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preview — the real rail, fed by the files pane
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Preview-server restart handler, provided by the wiring (usePreviewRouting).
|
||||
* Atom-bridged: this module can't import contrib-wiring (it imports us). */
|
||||
export const $restartPreviewServer = atom<((url: string, context?: string) => Promise<string>) | null>(null)
|
||||
|
||||
export function PreviewRailPane() {
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const fileTarget = useStore($filePreviewTarget)
|
||||
const restartPreviewServer = useStore($restartPreviewServer)
|
||||
|
||||
if (!previewTarget && !fileTarget) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center px-4 text-center">
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<DecodeText className="text-(--ui-text-quaternary)" prefix={1} text="PREVIEW" />
|
||||
<span className="text-[0.68rem] text-(--ui-text-quaternary)">click a file in the files pane</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
// The contrib layout zeroes --titlebar-height (content sits BELOW the
|
||||
// titlebar, so the real components' clearance padding must collapse) —
|
||||
// but the rail SIZES its per-file tab strip with that var. Restore the
|
||||
// real value for this subtree so the tabs always render at full height.
|
||||
<div
|
||||
className={cn(ZONE_CONTENT, 'min-h-0 w-full overflow-hidden [&>aside]:pt-0')}
|
||||
style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties}
|
||||
>
|
||||
<ChatPreviewRail onRestartServer={restartPreviewServer ?? undefined} setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Open a file from the tree in the real preview pipeline. */
|
||||
function previewFile(path: string) {
|
||||
void normalizeOrLocalPreviewTarget(path, $currentCwd.get() || undefined)
|
||||
.then(target => {
|
||||
if (target) {
|
||||
setCurrentSessionPreviewTarget(target, 'file-browser', path)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
// Layout fit for wrapped asides. Edge chrome (borders/shadows) is neutralized
|
||||
// GLOBALLY by the tree's seam invariant (see LayoutTreeRoot) — only sizing
|
||||
// and titlebar clearance are per-wrapper concerns.
|
||||
const ZONE_CONTENT = 'h-full [&>aside]:h-full [&>aside]:w-full [&>aside]:pt-0'
|
||||
|
||||
export function FilesPane() {
|
||||
return (
|
||||
<div className={ZONE_CONTENT}>
|
||||
<RightSidebarPane onActivateFile={previewFile} onActivateFolder={previewFile} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Review — the real git diff pane (⌘G / $reviewOpen)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ReviewPaneContent() {
|
||||
const cwd = useStore($currentCwd)
|
||||
|
||||
// Keyed by cwd like DesktopController so switching projects rebuilds the
|
||||
// diff state instead of showing the previous repo's files.
|
||||
return (
|
||||
<div className={cn(ZONE_CONTENT, 'flex min-h-0 flex-col [&>aside]:min-h-0 [&>aside]:flex-1')}>
|
||||
<ReviewPane key={cwd || 'no-cwd'} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Statusbar composability: plugins contribute DATA items into
|
||||
// `statusBar.left` / `statusBar.right`; the wiring feeds them into the REAL
|
||||
// useStatusbarItems as extraLeftItems/extraRightItems. No core filler here —
|
||||
// the real statusbar owns the core items (model pill, terminal toggle, …).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Collect statusbar contributions for one side. A `render()` contribution
|
||||
* becomes a render-item (arbitrary stateful node); otherwise the declarative
|
||||
* `data` payload is the StatusbarItem. */
|
||||
export function useStatusbarContributions(side: 'left' | 'right'): StatusbarItem[] {
|
||||
const items = useContributions(`statusBar.${side}`)
|
||||
|
||||
return items
|
||||
.map(c =>
|
||||
c.render
|
||||
? ({
|
||||
id: c.id,
|
||||
render: () => <ContribBoundary id={c.id} variant="chip">{c.render!()}</ContribBoundary>
|
||||
} satisfies StatusbarItem)
|
||||
: (c.data as StatusbarItem)
|
||||
)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/** Collect TitlebarTool data contributions for one side of the titlebar. */
|
||||
export function useTitlebarToolContributions(side: 'left' | 'right'): TitlebarTool[] {
|
||||
const items = useContributions(`titleBar.tools.${side}`)
|
||||
|
||||
return items.map(c => c.data as TitlebarTool).filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge a page's `GroupSetter` extension point (SkillsView, MessagingView,
|
||||
* ChatPreviewRail, …) into the registry: each call replaces the group's items
|
||||
* as DATA contributions in `<prefix>.<side>`, so page-owned items flow through
|
||||
* the same pipe plugins use. Setting an empty list clears the group.
|
||||
*/
|
||||
export function registryGroupSetter<T>(prefix: string): GroupSetter<T> {
|
||||
const disposers = new Map<string, () => void>()
|
||||
|
||||
return (id, items, side = 'right') => {
|
||||
const key = `${side}:${id}`
|
||||
|
||||
disposers.get(key)?.()
|
||||
disposers.set(
|
||||
key,
|
||||
registry.registerMany(
|
||||
items.map((item, i) => ({
|
||||
id: `${id}-${i}`,
|
||||
area: `${prefix}.${side}`,
|
||||
source: 'core',
|
||||
order: 100 + i,
|
||||
data: item as object
|
||||
}))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's page-facing setters — the same `GroupSetter` shape pages already
|
||||
* take as props, backed by the registry instead of component state. */
|
||||
export const setStatusbarItemGroup = registryGroupSetter<StatusbarItem>('statusBar')
|
||||
export const setTitlebarToolGroup = registryGroupSetter<TitlebarTool>('titleBar.tools')
|
||||
204
apps/desktop/src/app/contrib/surfaces.tsx
Normal file
204
apps/desktop/src/app/contrib/surfaces.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
/**
|
||||
* Wiring surfaces — each pane is its own memoized component. Every surface
|
||||
* reads the reactive state it renders from at the leaf (its own atom
|
||||
* subscriptions) and reaches the controller's callbacks through the stable
|
||||
* `actions` bag, so a state change scoped to one surface (or a bare
|
||||
* wiring-controller tick) never re-renders another. This is what keeps the
|
||||
* layout tree's zones independently rendered — the whole point of the shell.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ComponentProps, lazy, memo, type ReactNode, Suspense, useMemo } from 'react'
|
||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom'
|
||||
|
||||
import { ContribBoundary } from '@/contrib/react/boundary'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { $freshDraftReady, $gatewayState } from '@/store/session'
|
||||
|
||||
import { ChatView } from '../chat'
|
||||
import { ChatSidebar } from '../chat/sidebar'
|
||||
import { TerminalPaneChrome } from '../right-sidebar/terminal/chrome'
|
||||
import { contributedRoutes, NEW_CHAT_ROUTE, ROUTES_AREA, sessionRoute } from '../routes'
|
||||
import { useStatusSnapshot } from '../shell/hooks/use-status-snapshot'
|
||||
import { useStatusbarItems } from '../shell/hooks/use-statusbar-items'
|
||||
import { ModelMenuPanel } from '../shell/model-menu-panel'
|
||||
import { StatusbarControls } from '../shell/statusbar-controls'
|
||||
|
||||
import { setStatusbarItemGroup, useStatusbarContributions } from './panes'
|
||||
import type { SidebarActions, WiringActions } from './types'
|
||||
|
||||
// Same lazy-view split as DesktopController — pages load on demand. The
|
||||
// full-page views the workspace route table mounts live here; overlay views
|
||||
// (agents/settings/…) are the controller's and stay in wiring.tsx.
|
||||
const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView }))
|
||||
const MessagingView = lazy(async () => ({ default: (await import('../messaging')).MessagingView }))
|
||||
const SkillsView = lazy(async () => ({ default: (await import('../skills')).SkillsView }))
|
||||
|
||||
export function LegacySessionRedirect() {
|
||||
const { sessionId } = useParams()
|
||||
|
||||
return <Navigate replace to={sessionId ? sessionRoute(sessionId) : NEW_CHAT_ROUTE} />
|
||||
}
|
||||
|
||||
export const SidebarSurface = memo(function SidebarSurface({
|
||||
actions,
|
||||
currentView
|
||||
}: {
|
||||
actions: SidebarActions
|
||||
currentView: ComponentProps<typeof ChatSidebar>['currentView']
|
||||
}) {
|
||||
return <ChatSidebar currentView={currentView} {...actions} />
|
||||
})
|
||||
|
||||
export const TerminalSurface = memo(function TerminalSurface() {
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-(--ui-editor-surface-background)">
|
||||
<TerminalPaneChrome />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** Owns the statusbar's own data hooks (status snapshot poll, contributed
|
||||
* items) so its 15s refresh — and any statusbar-only churn — re-renders the
|
||||
* bar alone, never the chat/sidebar/terminal. */
|
||||
export const StatusbarSurface = memo(function StatusbarSurface({
|
||||
actions,
|
||||
agentsOpen,
|
||||
chatOpen,
|
||||
commandCenterOpen
|
||||
}: {
|
||||
actions: WiringActions
|
||||
agentsOpen: boolean
|
||||
chatOpen: boolean
|
||||
commandCenterOpen: boolean
|
||||
}) {
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, actions.requestGateway)
|
||||
const extraLeftItems = useStatusbarContributions('left')
|
||||
const extraRightItems = useStatusbarContributions('right')
|
||||
|
||||
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
|
||||
agentsOpen,
|
||||
chatOpen,
|
||||
commandCenterOpen,
|
||||
extraLeftItems,
|
||||
extraRightItems,
|
||||
freshDraftReady,
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
openAgents: actions.openAgents,
|
||||
openCommandCenterSection: actions.openCommandCenterSection,
|
||||
requestGateway: actions.requestGateway,
|
||||
statusSnapshot,
|
||||
toggleCommandCenter: actions.toggleCommandCenter
|
||||
})
|
||||
|
||||
return <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
|
||||
})
|
||||
|
||||
/** The workspace pane: the real route table (chat + full-page views + plugin
|
||||
* routes). Subscribes to `$gatewayState` and ROUTES_AREA itself; the gateway
|
||||
* instance + voice cap arrive as props so a reconnect/config load re-renders
|
||||
* only this surface. ChatView subscribes to its own session atoms, so
|
||||
* streaming never round-trips through the controller. */
|
||||
export const ChatRoutesSurface = memo(function ChatRoutesSurface({
|
||||
actions,
|
||||
maxVoiceRecordingSeconds
|
||||
}: {
|
||||
actions: WiringActions
|
||||
maxVoiceRecordingSeconds?: number
|
||||
}) {
|
||||
const gatewayState = useStore($gatewayState)
|
||||
useContributions(ROUTES_AREA)
|
||||
const routeContributions = contributedRoutes()
|
||||
|
||||
// Recapture the live gateway instance whenever the connection state flips.
|
||||
// getGateway reads a controller ref, so gatewayState is the intentional
|
||||
// re-eval trigger (not a value the computation itself reads).
|
||||
const gateway = useMemo(
|
||||
() => actions.getGateway(),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[actions, gatewayState]
|
||||
)
|
||||
|
||||
const modelMenuContent = useMemo(
|
||||
() =>
|
||||
gatewayState === 'open' ? (
|
||||
<ModelMenuPanel
|
||||
gateway={gateway || undefined}
|
||||
onSelectModel={actions.selectModel}
|
||||
requestGateway={actions.requestGateway}
|
||||
/>
|
||||
) : null,
|
||||
[actions, gateway, gatewayState]
|
||||
)
|
||||
|
||||
const chatView = (
|
||||
<ChatView
|
||||
gateway={gateway}
|
||||
maxVoiceRecordingSeconds={maxVoiceRecordingSeconds}
|
||||
modelMenuContent={modelMenuContent}
|
||||
onAddContextRef={actions.onAddContextRef}
|
||||
onAddUrl={actions.onAddUrl}
|
||||
onAttachDroppedItems={actions.onAttachDroppedItems}
|
||||
onAttachImageBlob={actions.onAttachImageBlob}
|
||||
onBranchInNewChat={actions.onBranchInNewChat}
|
||||
onCancel={actions.onCancel}
|
||||
onDeleteSelectedSession={actions.onDeleteSelectedSession}
|
||||
onDismissError={actions.onDismissError}
|
||||
onEdit={actions.onEdit}
|
||||
onPasteClipboardImage={actions.onPasteClipboardImage}
|
||||
onPickFiles={actions.onPickFiles}
|
||||
onPickFolders={actions.onPickFolders}
|
||||
onPickImages={actions.onPickImages}
|
||||
onReload={actions.onReload}
|
||||
onRemoveAttachment={actions.onRemoveAttachment}
|
||||
onRestoreToMessage={actions.onRestoreToMessage}
|
||||
onRetryResume={actions.onRetryResume}
|
||||
onSteer={actions.onSteer}
|
||||
onSubmit={actions.onSubmit}
|
||||
onThreadMessagesChange={actions.onThreadMessagesChange}
|
||||
onToggleSelectedPin={actions.onToggleSelectedPin}
|
||||
onTranscribeAudio={actions.onTranscribeAudio}
|
||||
/>
|
||||
)
|
||||
|
||||
// FULL-PAGE views (not chat) mark the zone body `data-zone-no-header`: a
|
||||
// page is not a tab-able surface, so the zone's double-click header toggle
|
||||
// stands down while one is showing (see onZoneDoubleClick).
|
||||
const page = (view: ReactNode) => (
|
||||
<div className="contents" data-zone-no-header>
|
||||
<Suspense fallback={null}>{view}</Suspense>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={chatView} index />
|
||||
<Route element={chatView} path=":sessionId" />
|
||||
<Route element={page(<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="skills" />
|
||||
<Route element={page(<MessagingView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="messaging" />
|
||||
<Route element={page(<ArtifactsView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="artifacts" />
|
||||
<Route element={null} path="agents" />
|
||||
<Route element={null} path="command-center" />
|
||||
<Route element={null} path="cron" />
|
||||
<Route element={null} path="profiles" />
|
||||
<Route element={null} path="settings" />
|
||||
<Route element={null} path="starmap" />
|
||||
{/* Registry-contributed pages (core features + plugins) render in the
|
||||
workspace pane like any built-in view — behind the same blast wall
|
||||
as every other contribution mount. */}
|
||||
{routeContributions.map(route => (
|
||||
<Route
|
||||
element={page(<ContribBoundary id={route.key}>{route.render()}</ContribBoundary>)}
|
||||
key={route.key}
|
||||
path={route.path.slice(1)}
|
||||
/>
|
||||
))}
|
||||
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="new" />
|
||||
<Route element={<LegacySessionRedirect />} path="sessions/:sessionId" />
|
||||
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
|
||||
</Routes>
|
||||
)
|
||||
})
|
||||
79
apps/desktop/src/app/contrib/types.ts
Normal file
79
apps/desktop/src/app/contrib/types.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type { ComponentProps, ReactNode } from 'react'
|
||||
|
||||
import type { ChatView } from '../chat'
|
||||
import type { ChatSidebar } from '../chat/sidebar'
|
||||
import type { CommandCenterSection } from '../command-center'
|
||||
import type { useGatewayRequest } from '../gateway/hooks/use-gateway-request'
|
||||
import type { ModelMenuPanel } from '../shell/model-menu-panel'
|
||||
|
||||
export type GatewayRequester = ReturnType<typeof useGatewayRequest>['requestGateway']
|
||||
|
||||
/** The ChatSidebar handlers the controller owns — forwarded verbatim. */
|
||||
export type SidebarActions = Pick<
|
||||
ComponentProps<typeof ChatSidebar>,
|
||||
| 'onArchiveSession'
|
||||
| 'onBranchSession'
|
||||
| 'onDeleteSession'
|
||||
| 'onLoadMoreMessaging'
|
||||
| 'onLoadMoreProfileSessions'
|
||||
| 'onLoadMoreSessions'
|
||||
| 'onManageCronJob'
|
||||
| 'onNavigate'
|
||||
| 'onNewSessionInWorkspace'
|
||||
| 'onNewSessionSplit'
|
||||
| 'onResumeSession'
|
||||
| 'onTriggerCronJob'
|
||||
>
|
||||
|
||||
/** The ChatView handlers the controller owns — forwarded verbatim. */
|
||||
export type ChatActions = Pick<
|
||||
ComponentProps<typeof ChatView>,
|
||||
| 'onAddContextRef'
|
||||
| 'onAddUrl'
|
||||
| 'onAttachDroppedItems'
|
||||
| 'onAttachImageBlob'
|
||||
| 'onBranchInNewChat'
|
||||
| 'onCancel'
|
||||
| 'onDeleteSelectedSession'
|
||||
| 'onDismissError'
|
||||
| 'onEdit'
|
||||
| 'onPasteClipboardImage'
|
||||
| 'onPickFiles'
|
||||
| 'onPickFolders'
|
||||
| 'onPickImages'
|
||||
| 'onReload'
|
||||
| 'onRemoveAttachment'
|
||||
| 'onRestoreToMessage'
|
||||
| 'onRetryResume'
|
||||
| 'onSteer'
|
||||
| 'onSubmit'
|
||||
| 'onThreadMessagesChange'
|
||||
| 'onToggleSelectedPin'
|
||||
| 'onTranscribeAudio'
|
||||
>
|
||||
|
||||
/**
|
||||
* The complete controller-owned callback surface. One object, one stable
|
||||
* identity for the app's life — its fields are mutated in place each render,
|
||||
* so surfaces bound to it never re-render on identity churn but always invoke
|
||||
* the latest closure.
|
||||
*/
|
||||
export interface WiringActions extends SidebarActions, ChatActions {
|
||||
/** The live gateway instance (held in a controller ref). Surfaces recapture
|
||||
* it by subscribing to `$gatewayState`, so no gateway prop needs threading. */
|
||||
getGateway: () => ComponentProps<typeof ChatView>['gateway']
|
||||
openAgents: () => void
|
||||
openCommandCenterSection: (section: CommandCenterSection) => void
|
||||
requestGateway: GatewayRequester
|
||||
selectModel: ComponentProps<typeof ModelMenuPanel>['onSelectModel']
|
||||
toggleCommandCenter: () => void
|
||||
}
|
||||
|
||||
/** The four wired surfaces the controller publishes; `WiredPane` renders one by
|
||||
* key inside a registered pane / chrome slot. */
|
||||
export interface WiringApi {
|
||||
sidebar: ReactNode
|
||||
chatRoutes: ReactNode
|
||||
terminal: ReactNode
|
||||
statusbar: ReactNode
|
||||
}
|
||||
946
apps/desktop/src/app/contrib/wiring.tsx
Normal file
946
apps/desktop/src/app/contrib/wiring.tsx
Normal file
|
|
@ -0,0 +1,946 @@
|
|||
/**
|
||||
* Real-featureset wiring for the contrib (layout tree) root — the minimal
|
||||
* subset of DesktopController's hook chain that makes the REAL surfaces work:
|
||||
* gateway boot -> sessions list -> click-to-resume -> live transcript ->
|
||||
* composer send, plus the real terminal.
|
||||
*
|
||||
* The wired nodes (sidebar / chat routes / terminal) are exposed through
|
||||
* context; registered panes render `<WiredPane part="…"/>` to consume them.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { type CSSProperties, lazy, type ReactNode, Suspense, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
|
||||
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
|
||||
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { DesktopOnboardingOverlay } from '@/components/onboarding'
|
||||
import { FloatingPet } from '@/components/pet/floating-pet'
|
||||
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
|
||||
import { emitGatewayEvent } from '@/contrib/events'
|
||||
import { getSessionMessages, triggerCronJob } from '@/hermes'
|
||||
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
import { sessionMessagesSignature } from '@/lib/session-signatures'
|
||||
import { isMessagingSource } from '@/lib/session-source'
|
||||
import { latestSessionTodos } from '@/lib/todos'
|
||||
import { setCronFocusJobId } from '@/store/cron'
|
||||
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
|
||||
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
|
||||
import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile'
|
||||
import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
$messages,
|
||||
$messagingSessions,
|
||||
$resumeExhaustedSessionId,
|
||||
$resumeFailedSessionId,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
sessionMatchesStoredId,
|
||||
sessionPinId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentModel,
|
||||
setCurrentProvider,
|
||||
setMessages
|
||||
} from '@/store/session'
|
||||
import { focusOpenSession } from '@/store/session-states'
|
||||
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
import { requestComposerInsert } from '../chat/composer/focus'
|
||||
import { useComposerActions } from '../chat/hooks/use-composer-actions'
|
||||
import { CommandPalette } from '../command-palette'
|
||||
import { useGatewayBoot } from '../gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from '../gateway/hooks/use-gateway-request'
|
||||
import { useKeybinds } from '../hooks/use-keybinds'
|
||||
import { ModelPickerOverlay } from '../model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from '../model-visibility-overlay'
|
||||
import { PetGenerateOverlay } from '../pet-generate/pet-generate-overlay'
|
||||
import { FileActionDialogs } from '../right-sidebar/file-actions'
|
||||
import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker'
|
||||
import { PersistentTerminal } from '../right-sidebar/terminal/persistent'
|
||||
import { CRON_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE, syncWorkspaceIsPage } from '../routes'
|
||||
import { SessionPickerOverlay } from '../session-picker-overlay'
|
||||
import { SessionSwitcher } from '../session-switcher'
|
||||
import { useContextSuggestions } from '../session/hooks/use-context-suggestions'
|
||||
import { useCwdActions } from '../session/hooks/use-cwd-actions'
|
||||
import { useHermesConfig } from '../session/hooks/use-hermes-config'
|
||||
import { useMessageStream } from '../session/hooks/use-message-stream'
|
||||
import { useModelControls } from '../session/hooks/use-model-controls'
|
||||
import { usePreviewRouting } from '../session/hooks/use-preview-routing'
|
||||
import { usePromptActions } from '../session/hooks/use-prompt-actions'
|
||||
import { useRouteResume } from '../session/hooks/use-route-resume'
|
||||
import { useSessionActions } from '../session/hooks/use-session-actions'
|
||||
import { useSessionListActions } from '../session/hooks/use-session-list-actions'
|
||||
import { useSessionStateCache } from '../session/hooks/use-session-state-cache'
|
||||
import { useOverlayRouting } from '../shell/hooks/use-overlay-routing'
|
||||
import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width'
|
||||
import { KeybindPanel } from '../shell/keybind-panel'
|
||||
import { titlebarControlsPosition } from '../shell/titlebar'
|
||||
import { TitlebarControls } from '../shell/titlebar-controls'
|
||||
import { UpdatesOverlay } from '../updates-overlay'
|
||||
|
||||
import { ContribWiringContext } from './context'
|
||||
import { useBackgroundSync } from './hooks/use-background-sync'
|
||||
import { useDesktopIntegrations } from './hooks/use-desktop-integrations'
|
||||
import { usePetBridge } from './hooks/use-pet-bridge'
|
||||
import { useSessionTileDelegate } from './hooks/use-session-tile-delegate'
|
||||
import { $restartPreviewServer, useTitlebarToolContributions } from './panes'
|
||||
import { ChatRoutesSurface, SidebarSurface, StatusbarSurface, TerminalSurface } from './surfaces'
|
||||
import type { WiringActions, WiringApi } from './types'
|
||||
|
||||
// Overlay views the controller mounts over the shell — lazy, load on demand.
|
||||
// The workspace-route full-page views (skills/messaging/artifacts) are the
|
||||
// ChatRoutesSurface's and live in ./surfaces.
|
||||
const AgentsView = lazy(async () => ({ default: (await import('../agents')).AgentsView }))
|
||||
const CommandCenterView = lazy(async () => ({ default: (await import('../command-center')).CommandCenterView }))
|
||||
const CronView = lazy(async () => ({ default: (await import('../cron')).CronView }))
|
||||
const ProfilesView = lazy(async () => ({ default: (await import('../profiles')).ProfilesView }))
|
||||
const SettingsView = lazy(async () => ({ default: (await import('../settings')).SettingsView }))
|
||||
const StarmapView = lazy(async () => ({ default: (await import('../starmap')).StarmapView }))
|
||||
|
||||
// Surfaces (the four wired panes), the render context + WiredPane, and the
|
||||
// WiringActions/WiringApi contracts all live in sibling modules — this file is
|
||||
// the controller that assembles them.
|
||||
export { WiredPane } from './context'
|
||||
|
||||
export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const busyRef = useRef(false)
|
||||
const creatingSessionRef = useRef(false)
|
||||
const messagingTranscriptSignatureRef = useRef(new Map<string, string>())
|
||||
// Stable identity for the whole callback surface (see WiringActions). Mutated
|
||||
// in place each render so memoized surfaces never re-render on churn.
|
||||
const actionsRef = useRef<WiringActions | null>(null)
|
||||
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const resumeFailedSessionId = useStore($resumeFailedSessionId)
|
||||
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const messagingSessions = useStore($messagingSessions)
|
||||
const profileScope = useStore($profileScope)
|
||||
|
||||
const routedSessionId = routeSessionId(location.pathname)
|
||||
const routeToken = `${location.pathname}:${location.search}:${location.hash}`
|
||||
const routeTokenRef = useRef(routeToken)
|
||||
routeTokenRef.current = routeToken
|
||||
const getRouteToken = useCallback(() => routeTokenRef.current, [])
|
||||
|
||||
// Mirror "the workspace is showing a full page" into its atom — the
|
||||
// workspace pane contribution re-registers headerVeto from it, so the main
|
||||
// zone's tab bar stands down on pages (and returns with the chat).
|
||||
useEffect(() => {
|
||||
syncWorkspaceIsPage(location.pathname)
|
||||
}, [location.pathname])
|
||||
|
||||
const {
|
||||
agentsOpen,
|
||||
chatOpen,
|
||||
closeOverlayToPreviousRoute,
|
||||
commandCenterInitialSection,
|
||||
commandCenterOpen,
|
||||
cronOpen,
|
||||
currentView,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
openStarmap,
|
||||
profilesOpen,
|
||||
settingsOpen,
|
||||
starmapOpen,
|
||||
toggleCommandCenter
|
||||
} = useOverlayRouting()
|
||||
|
||||
const {
|
||||
activeSessionIdRef,
|
||||
ensureSessionState,
|
||||
resetViewSync,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
syncSessionStateToView,
|
||||
updateSessionState
|
||||
} = useSessionStateCache({
|
||||
activeSessionId,
|
||||
busyRef,
|
||||
selectedStoredSessionId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setMessages
|
||||
})
|
||||
|
||||
const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest()
|
||||
|
||||
const {
|
||||
loadMoreMessagingForPlatform,
|
||||
loadMoreSessions,
|
||||
loadMoreSessionsForProfile,
|
||||
refreshCronJobs,
|
||||
refreshMessagingSessions,
|
||||
refreshSessions
|
||||
} = useSessionListActions({ profileScope })
|
||||
|
||||
const updateActiveSessionRuntimeInfo = useCallback(
|
||||
(info: { branch?: string; cwd?: string }) => {
|
||||
const sessionId = activeSessionIdRef.current
|
||||
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
branch: info.branch ?? state.branch,
|
||||
cwd: info.cwd ?? state.cwd
|
||||
}))
|
||||
},
|
||||
[activeSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
const { refreshProjectBranch } = useCwdActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
onSessionRuntimeInfo: updateActiveSessionRuntimeInfo,
|
||||
requestGateway
|
||||
})
|
||||
|
||||
const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({
|
||||
activeSessionIdRef,
|
||||
refreshProjectBranch
|
||||
})
|
||||
|
||||
const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({
|
||||
activeSessionId,
|
||||
queryClient,
|
||||
requestGateway
|
||||
})
|
||||
|
||||
const openProviderSettings = useCallback(() => navigate(`${SETTINGS_ROUTE}?tab=providers`), [navigate])
|
||||
|
||||
// Post-turn rehydrate from stored history (same behavior as DesktopController,
|
||||
// including finished-todos restoration).
|
||||
const hydrateFromStoredSession = useCallback(
|
||||
async (
|
||||
attempts = 1,
|
||||
storedSessionId = selectedStoredSessionIdRef.current,
|
||||
runtimeSessionId = activeSessionIdRef.current
|
||||
) => {
|
||||
if (!storedSessionId || !runtimeSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const storedProfile = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))?.profile
|
||||
|
||||
for (let index = 0; index < Math.max(1, attempts); index += 1) {
|
||||
try {
|
||||
const latest = await getSessionMessages(storedSessionId, storedProfile)
|
||||
const messages = toChatMessages(latest.messages)
|
||||
updateSessionState(
|
||||
runtimeSessionId,
|
||||
state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }),
|
||||
storedSessionId
|
||||
)
|
||||
|
||||
const restored = todosForHydration(latestSessionTodos(messages))
|
||||
|
||||
if (restored) {
|
||||
setSessionTodos(runtimeSessionId, restored)
|
||||
} else {
|
||||
clearSessionTodos(runtimeSessionId)
|
||||
}
|
||||
|
||||
return
|
||||
} catch {
|
||||
// Best-effort fallback when live stream payloads are empty.
|
||||
}
|
||||
|
||||
if (index < attempts - 1) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 250))
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
// Refresh the open messaging transcript (inbound platform turns arrive via
|
||||
// the background gateway, not the desktop websocket). Signature-gated so a
|
||||
// no-change poll doesn't churn the thread.
|
||||
const refreshActiveMessagingTranscript = useCallback(async () => {
|
||||
const storedSessionId = selectedStoredSessionIdRef.current
|
||||
const runtimeSessionId = activeSessionIdRef.current
|
||||
|
||||
if (!storedSessionId || !runtimeSessionId || busyRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const stored = $messagingSessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
|
||||
if (!stored || !isMessagingSource(stored.source)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const latest = await getSessionMessages(storedSessionId, stored.profile)
|
||||
const signatureKey = `${stored.profile ?? 'default'}:${storedSessionId}`
|
||||
const sig = sessionMessagesSignature(latest.messages)
|
||||
|
||||
if (messagingTranscriptSignatureRef.current.get(signatureKey) === sig) {
|
||||
return
|
||||
}
|
||||
|
||||
messagingTranscriptSignatureRef.current.set(signatureKey, sig)
|
||||
const messages = toChatMessages(latest.messages)
|
||||
|
||||
updateSessionState(
|
||||
runtimeSessionId,
|
||||
state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }),
|
||||
storedSessionId
|
||||
)
|
||||
} catch {
|
||||
// Non-fatal: next poll or manual refresh can hydrate.
|
||||
}
|
||||
}, [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState])
|
||||
|
||||
const { handleGatewayEvent } = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession,
|
||||
queryClient,
|
||||
refreshHermesConfig,
|
||||
refreshSessions,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
// Agent-driven preview routing (agent opens a URL/file -> the preview rail
|
||||
// follows) + the preview server restart handler, layered over the base
|
||||
// gateway event stream exactly like DesktopController.
|
||||
const { handleDesktopGatewayEvent, restartPreviewServer } = usePreviewRouting({
|
||||
activeSessionIdRef,
|
||||
baseHandleGatewayEvent: handleGatewayEvent,
|
||||
currentCwd,
|
||||
currentView,
|
||||
requestGateway,
|
||||
routedSessionId,
|
||||
selectedStoredSessionId
|
||||
})
|
||||
|
||||
// Composer @-mention context suggestions (files/dirs under the cwd).
|
||||
useContextSuggestions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
currentCwd,
|
||||
gatewayState,
|
||||
requestGateway
|
||||
})
|
||||
|
||||
// Expose the restart handler to the preview pane contribution (module
|
||||
// boundary crossed via atom — contrib-panes can't import this file).
|
||||
useEffect(() => {
|
||||
$restartPreviewServer.set(restartPreviewServer)
|
||||
|
||||
return () => $restartPreviewServer.set(null)
|
||||
}, [restartPreviewServer])
|
||||
|
||||
const {
|
||||
archiveSession,
|
||||
branchCurrentSession,
|
||||
branchStoredSession,
|
||||
createBackendSessionForSend,
|
||||
openNewSessionTile,
|
||||
removeSession,
|
||||
resumeSession,
|
||||
selectSidebarItem,
|
||||
startFreshSessionDraft
|
||||
} = useSessionActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
creatingSessionRef,
|
||||
ensureSessionState,
|
||||
getRouteToken,
|
||||
navigate,
|
||||
requestGateway,
|
||||
resetViewSync,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
syncSessionStateToView,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
// A profile switch/create drops to a fresh new-session draft so the
|
||||
// previously open session doesn't bleed across contexts. Skip initial value.
|
||||
const freshSessionRequest = useStore($freshSessionRequest)
|
||||
const lastFreshRef = useRef(freshSessionRequest)
|
||||
|
||||
useEffect(() => {
|
||||
if (freshSessionRequest === lastFreshRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastFreshRef.current = freshSessionRequest
|
||||
startFreshSessionDraft()
|
||||
}, [freshSessionRequest, startFreshSessionDraft])
|
||||
|
||||
// Swapping the live gateway to another profile must re-pull that profile's
|
||||
// global model + active-profile pill (both are nanostores — the blanket
|
||||
// invalidateQueries on swap doesn't touch them).
|
||||
const activeGatewayProfile = useStore($activeGatewayProfile)
|
||||
const lastGatewayProfileRef = useRef(activeGatewayProfile)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeGatewayProfile === lastGatewayProfileRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastGatewayProfileRef.current = activeGatewayProfile
|
||||
// Force: the new profile has its own default, so reseed even if the
|
||||
// composer already shows the previous profile's model.
|
||||
void refreshCurrentModel(true)
|
||||
void refreshActiveProfile()
|
||||
}, [activeGatewayProfile, refreshCurrentModel])
|
||||
|
||||
// New session anchored to a workspace (sidebar "+" on a project/worktree).
|
||||
// Seeds cwd + branch from the clicked workspace; an explicit worktree path
|
||||
// also drills the sidebar into that project so the new lane is visible.
|
||||
const startSessionInWorkspace = useCallback(
|
||||
(path: null | string) => {
|
||||
startFreshSessionDraft()
|
||||
|
||||
// A worktree lane carries its own path; the trunk "+" can be path-less
|
||||
// (the main checkout is implicit), so fall back to the active project's
|
||||
// root instead of no-op'ing on null.
|
||||
const target = path?.trim() || resolveNewSessionCwd()
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentCwd(target)
|
||||
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
|
||||
.then(info => {
|
||||
const resolved = info.cwd || target
|
||||
|
||||
setCurrentCwd(resolved)
|
||||
setCurrentBranch(info.branch || '')
|
||||
|
||||
if (path?.trim()) {
|
||||
restoreWorktree(resolved)
|
||||
void followActiveSessionCwd(resolved)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
},
|
||||
[requestGateway, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
// Composer "branch off into a new worktree": open a fresh session anchored
|
||||
// to the just-created tree, then prefill the task that kicked it off.
|
||||
const startWorkSessionRequest = useStore($startWorkSessionRequest)
|
||||
const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastStartWorkTokenRef.current = startWorkSessionRequest.token
|
||||
startSessionInWorkspace(startWorkSessionRequest.path)
|
||||
|
||||
if (startWorkSessionRequest.draft) {
|
||||
requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' })
|
||||
}
|
||||
}, [startSessionInWorkspace, startWorkSessionRequest])
|
||||
|
||||
const composer = useComposerActions({ activeSessionId, currentCwd, requestGateway })
|
||||
|
||||
const branchInNewChat = useCallback(
|
||||
async (messageId?: string) => {
|
||||
const branched = await branchCurrentSession(messageId)
|
||||
|
||||
if (branched) {
|
||||
await refreshSessions().catch(() => undefined)
|
||||
}
|
||||
|
||||
return branched
|
||||
},
|
||||
[branchCurrentSession, refreshSessions]
|
||||
)
|
||||
|
||||
const handleSkinCommand = useSkinCommand()
|
||||
|
||||
const {
|
||||
cancelRun,
|
||||
editMessage,
|
||||
executeSlashCommand,
|
||||
handleThreadMessagesChange,
|
||||
reloadFromMessage,
|
||||
restoreToMessage,
|
||||
steerPrompt,
|
||||
submitText,
|
||||
transcribeVoiceAudio
|
||||
} = usePromptActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
branchCurrentSession: branchInNewChat,
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
getRouteToken,
|
||||
handleSkinCommand,
|
||||
openMemoryGraph: openStarmap,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession: resumeSession,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
sttEnabled,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
// Session-tile delegate (resume/submit/interrupt/slash + the session verbs
|
||||
// the tile TAB menu needs, without touching the primary view).
|
||||
useSessionTileDelegate({
|
||||
archiveSession,
|
||||
branchStoredSession,
|
||||
executeSlashCommand,
|
||||
removeSession,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
// The popped-out pet overlay's bridge back into the app.
|
||||
usePetBridge({ requestGateway, resumeSession, submitText })
|
||||
|
||||
// Clear a failed turn's red error banner. Errors are renderer-local (never
|
||||
// persisted): a bare error placeholder is dropped entirely; a partial-output
|
||||
// failure keeps its content and sheds the error. Both the runtime cache AND
|
||||
// the live $messages view must be updated — preserveLocalAssistantErrors
|
||||
// re-grafts any still-errored view message on the next session.info flush.
|
||||
const dismissError = useCallback(
|
||||
(messageId: string) => {
|
||||
const runtimeSessionId = activeSessionIdRef.current
|
||||
|
||||
if (!runtimeSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] =>
|
||||
messages.flatMap(message => {
|
||||
if (message.id !== messageId || !message.error) {
|
||||
return [message]
|
||||
}
|
||||
|
||||
if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{ ...message, error: undefined, pending: false }]
|
||||
})
|
||||
|
||||
// View first: the cache update below triggers a re-sync that reads
|
||||
// $messages as the error-preservation baseline.
|
||||
setMessages(clearErrorIn($messages.get()))
|
||||
|
||||
updateSessionState(runtimeSessionId, state => ({
|
||||
...state,
|
||||
messages: clearErrorIn(state.messages)
|
||||
}))
|
||||
},
|
||||
[activeSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
useRouteResume({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
creatingSessionRef,
|
||||
currentView,
|
||||
freshDraftReady,
|
||||
gatewayState,
|
||||
locationPathname: location.pathname,
|
||||
resumeSession,
|
||||
resumeFailedSessionId,
|
||||
resumeExhaustedSessionId,
|
||||
routedSessionId,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft
|
||||
})
|
||||
|
||||
// Plugins hear the stream FIRST (isolated fan-out in contrib/events), then
|
||||
// the app dispatches as before — a plugin listener can't affect app flow.
|
||||
const handleGatewayEventWithPlugins = useCallback(
|
||||
(event: Parameters<typeof handleDesktopGatewayEvent>[0]) => {
|
||||
emitGatewayEvent(event)
|
||||
handleDesktopGatewayEvent(event)
|
||||
},
|
||||
[handleDesktopGatewayEvent]
|
||||
)
|
||||
|
||||
useGatewayBoot({
|
||||
handleGatewayEvent: handleGatewayEventWithPlugins,
|
||||
onConnectionReady: c => {
|
||||
connectionRef.current = c
|
||||
},
|
||||
onGatewayReady: g => {
|
||||
gatewayRef.current = g
|
||||
},
|
||||
refreshHermesConfig,
|
||||
refreshSessions
|
||||
})
|
||||
|
||||
// Only the open messaging transcript needs its own poll — local chats are
|
||||
// live over the websocket already.
|
||||
const activeIsMessaging =
|
||||
!!selectedStoredSessionId &&
|
||||
isMessagingSource(messagingSessions.find(s => sessionMatchesStoredId(s, selectedStoredSessionId))?.source)
|
||||
|
||||
// Keep app data live while the gateway is open (on-connect reseed + the
|
||||
// cron / messaging / transcript visibility polls + fresh-draft reseed).
|
||||
useBackgroundSync({
|
||||
activeIsMessaging,
|
||||
activeSessionId,
|
||||
freshDraftReady,
|
||||
gatewayState,
|
||||
refreshActiveMessagingTranscript,
|
||||
refreshCronJobs,
|
||||
refreshCurrentModel,
|
||||
refreshHermesConfig,
|
||||
refreshMessagingSessions,
|
||||
refreshSessions,
|
||||
requestGateway
|
||||
})
|
||||
|
||||
// Electron-main / OS / cross-window integrations: update polling, ⌘W close,
|
||||
// deep links, native-notification nav, preview-shortcut enablement,
|
||||
// remembered-session restore, and cross-window session-list sync.
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const filePreviewTarget = useStore($filePreviewTarget)
|
||||
|
||||
useDesktopIntegrations({
|
||||
chatOpen,
|
||||
hasPreview: Boolean(filePreviewTarget || previewTarget),
|
||||
locationPathname: location.pathname,
|
||||
navigate,
|
||||
refreshSessions,
|
||||
resumeExhaustedSessionId,
|
||||
routedSessionId,
|
||||
runtimeIdByStoredSessionId: runtimeIdByStoredSessionIdRef
|
||||
})
|
||||
|
||||
// Pin/unpin the selected session (statusbar keybind + chat header) — pinned
|
||||
// on the durable lineage-root id so it survives auto-compression.
|
||||
const toggleSelectedPin = useCallback(() => {
|
||||
const sessionId = $selectedStoredSessionId.get()
|
||||
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const session = $sessions.get().find(s => sessionMatchesStoredId(s, sessionId))
|
||||
const pinId = session ? sessionPinId(session) : sessionId
|
||||
|
||||
if ($pinnedSessionIds.get().includes(pinId)) {
|
||||
unpinSession(pinId)
|
||||
} else {
|
||||
pinSession(pinId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Single global listener for every rebindable hotkey plus the on-screen
|
||||
// keybind editor's capture mode (same as DesktopController).
|
||||
useKeybinds({
|
||||
openNewSessionTab: () => void openNewSessionTile('center'),
|
||||
startFreshSession: startFreshSessionDraft,
|
||||
toggleCommandCenter,
|
||||
toggleSelectedPin
|
||||
})
|
||||
|
||||
// The controller's entire callback surface, gathered into the stable
|
||||
// `actions` bag. `nextActions` is TS-checked against WiringActions each
|
||||
// render; its fields are copied into the ref object so `actions` keeps one
|
||||
// identity for the app's life (memoized surfaces don't re-render on churn)
|
||||
// while every handler still closes over the latest values.
|
||||
const nextActions: WiringActions = {
|
||||
onAddContextRef: composer.addContextRefAttachment,
|
||||
onAddUrl: url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url),
|
||||
onArchiveSession: sessionId => void archiveSession(sessionId),
|
||||
onAttachDroppedItems: composer.attachDroppedItems,
|
||||
onAttachImageBlob: composer.attachImageBlob,
|
||||
onBranchInNewChat: messageId => void branchInNewChat(messageId),
|
||||
onBranchSession: sessionId => void branchStoredSession(sessionId),
|
||||
onCancel: cancelRun,
|
||||
onDeleteSelectedSession: () => {
|
||||
const id = $selectedStoredSessionId.get()
|
||||
|
||||
if (id) {
|
||||
void removeSession(id)
|
||||
}
|
||||
},
|
||||
onDeleteSession: sessionId => void removeSession(sessionId),
|
||||
onDismissError: dismissError,
|
||||
onEdit: editMessage,
|
||||
onLoadMoreMessaging: loadMoreMessagingForPlatform,
|
||||
onLoadMoreProfileSessions: loadMoreSessionsForProfile,
|
||||
onLoadMoreSessions: loadMoreSessions,
|
||||
onManageCronJob: jobId => {
|
||||
setCronFocusJobId(jobId)
|
||||
navigate(CRON_ROUTE)
|
||||
},
|
||||
onNavigate: selectSidebarItem,
|
||||
onNewSessionInWorkspace: startSessionInWorkspace,
|
||||
onNewSessionSplit: dir => void openNewSessionTile(dir),
|
||||
onPasteClipboardImage: opts => composer.pasteClipboardImage(opts),
|
||||
onPickFiles: () => void composer.pickContextPaths('file'),
|
||||
onPickFolders: () => void composer.pickContextPaths('folder'),
|
||||
onPickImages: () => void composer.pickImages(),
|
||||
onReload: reloadFromMessage,
|
||||
onRemoveAttachment: id => void composer.removeAttachment(id),
|
||||
onRestoreToMessage: restoreToMessage,
|
||||
// Already on screen (open tile, or the main session)? Jump to its tab;
|
||||
// otherwise load it into main.
|
||||
onResumeSession: sessionId => {
|
||||
if (!focusOpenSession(sessionId)) {
|
||||
navigate(sessionRoute(sessionId))
|
||||
}
|
||||
},
|
||||
onRetryResume: sessionId => void resumeSession(sessionId, true),
|
||||
onSteer: steerPrompt,
|
||||
onSubmit: submitText,
|
||||
onThreadMessagesChange: handleThreadMessagesChange,
|
||||
onToggleSelectedPin: toggleSelectedPin,
|
||||
onTranscribeAudio: transcribeVoiceAudio,
|
||||
onTriggerCronJob: jobId => {
|
||||
void triggerCronJob(jobId)
|
||||
.then(() => refreshCronJobs())
|
||||
.catch(() => undefined)
|
||||
},
|
||||
getGateway: () => gatewayRef.current,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
requestGateway,
|
||||
selectModel,
|
||||
toggleCommandCenter
|
||||
}
|
||||
|
||||
if (actionsRef.current) {
|
||||
Object.assign(actionsRef.current, nextActions)
|
||||
} else {
|
||||
actionsRef.current = nextActions
|
||||
}
|
||||
|
||||
const actions = actionsRef.current
|
||||
|
||||
// Each pane node is memoized on ONLY the reactive inputs it truly consumes;
|
||||
// everything else reaches its surface through `actions` (stable) or the
|
||||
// surface's own atom subscriptions. A wiring tick that doesn't touch a
|
||||
// node's keys leaves its element reference intact, so `WiredPane` (memoized)
|
||||
// bails on that pane subtree — panes render independently of one another.
|
||||
const sidebarNode = useMemo(
|
||||
() => <SidebarSurface actions={actions} currentView={currentView} />,
|
||||
[actions, currentView]
|
||||
)
|
||||
|
||||
const terminalNode = useMemo(() => <TerminalSurface />, [])
|
||||
|
||||
const statusbarNode = useMemo(
|
||||
() => (
|
||||
<StatusbarSurface
|
||||
actions={actions}
|
||||
agentsOpen={agentsOpen}
|
||||
chatOpen={chatOpen}
|
||||
commandCenterOpen={commandCenterOpen}
|
||||
/>
|
||||
),
|
||||
[actions, agentsOpen, chatOpen, commandCenterOpen]
|
||||
)
|
||||
|
||||
// The voice cap changes only on config load; the gateway instance + all
|
||||
// chat reactivity are subscribed inside ChatRoutesSurface / ChatView.
|
||||
const chatRoutesNode = useMemo(
|
||||
() => <ChatRoutesSurface actions={actions} maxVoiceRecordingSeconds={voiceMaxRecordingSeconds} />,
|
||||
[actions, voiceMaxRecordingSeconds]
|
||||
)
|
||||
|
||||
const api = useMemo<WiringApi>(
|
||||
() => ({
|
||||
chatRoutes: chatRoutesNode,
|
||||
sidebar: sidebarNode,
|
||||
statusbar: statusbarNode,
|
||||
terminal: terminalNode
|
||||
}),
|
||||
[chatRoutesNode, sidebarNode, statusbarNode, terminalNode]
|
||||
)
|
||||
|
||||
// The REAL titlebar tool clusters (sidebar/flip toggles, haptics, keybinds,
|
||||
// settings gear) — fixed chrome positioned via the same CSS vars AppShell
|
||||
// sets, computed here from the live connection. Page-registered tools
|
||||
// (preview's monitor/devtools cluster, …) arrive as registry contributions.
|
||||
const leftTitlebarTools = useTitlebarToolContributions('left')
|
||||
const rightTitlebarTools = useTitlebarToolContributions('right')
|
||||
const connection = useStore($connection)
|
||||
const controlsPos = titlebarControlsPosition(connection?.windowButtonPosition, Boolean(connection?.isFullscreen))
|
||||
// Exact vertical centering: titlebarControlsPosition() returns
|
||||
// (TITLEBAR_HEIGHT - TITLEBAR_CONTROL_HEIGHT) / 2, but TitlebarControls
|
||||
// also applies a hard translate-y-0.5 (+2px) to its clusters. Cancel that
|
||||
// constant so cluster center == bar center — measured, not eyeballed.
|
||||
const controlsTranslateY = 2
|
||||
// Windows/WSLg reserve native min/max/close on the right (AppShell parity:
|
||||
// prefer the live WCO measurement, fall back to the static reservation).
|
||||
const measuredOverlayWidth = useWindowControlsOverlayWidth()
|
||||
const nativeOverlayWidth = measuredOverlayWidth ?? connection?.nativeOverlayWidth ?? 0
|
||||
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
|
||||
// Pane-registered tools (preview's monitor/devtools cluster) anchor flush
|
||||
// against the static system cluster — in the tree layout the titlebar band
|
||||
// sits ABOVE the grid, so AppShell's pane-width anchoring doesn't apply.
|
||||
const SYSTEM_TOOL_COUNT = 4
|
||||
const paneToolCount = rightTitlebarTools.filter(tool => !tool.hidden).length
|
||||
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))`
|
||||
|
||||
const titlebarToolsWidth =
|
||||
paneToolCount > 0
|
||||
? `calc(${systemToolsWidth} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))`
|
||||
: systemToolsWidth
|
||||
|
||||
return (
|
||||
<ContribWiringContext.Provider value={api}>
|
||||
<div
|
||||
className="contents"
|
||||
style={
|
||||
{
|
||||
'--titlebar-controls-left': `${controlsPos.left}px`,
|
||||
'--titlebar-controls-top': `${controlsPos.top - controlsTranslateY}px`,
|
||||
'--titlebar-tools-right': titlebarToolsRight,
|
||||
'--titlebar-tools-width': titlebarToolsWidth,
|
||||
'--shell-preview-toolbar-gap': systemToolsWidth
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<TitlebarControls
|
||||
leftTools={leftTitlebarTools}
|
||||
onOpenSettings={() => navigate(SETTINGS_ROUTE)}
|
||||
tools={rightTitlebarTools}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* The full real overlay set (mirrors DesktopController's `overlays`). */}
|
||||
<RemoteDisplayBanner />
|
||||
{!isSecondaryWindow() && <DesktopInstallOverlay />}
|
||||
{!isSecondaryWindow() && (
|
||||
<DesktopOnboardingOverlay
|
||||
enabled={gatewayState === 'open'}
|
||||
onCompleted={() => {
|
||||
void refreshHermesConfig()
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)}
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
<SessionPickerOverlay onResume={resumeSession} />
|
||||
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
|
||||
<UpdatesOverlay />
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
<CommandPalette />
|
||||
<PetGenerateOverlay />
|
||||
<SessionSwitcher />
|
||||
<FileActionDialogs />
|
||||
<RemoteFolderPicker />
|
||||
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<SettingsView
|
||||
gateway={gatewayRef.current}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onConfigSaved={() => {
|
||||
void refreshHermesConfig()
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
onMainModelChanged={(provider, model) => {
|
||||
setCurrentProvider(provider)
|
||||
setCurrentModel(model)
|
||||
updateModelOptionsCache(provider, model, true)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{commandCenterOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<CommandCenterView
|
||||
initialSection={commandCenterInitialSection}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onDeleteSession={removeSession}
|
||||
onNavigateRoute={path => navigate(path)}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{agentsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<AgentsView onClose={closeOverlayToPreviousRoute} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{cronOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<CronView
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{profilesOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<ProfilesView onClose={closeOverlayToPreviousRoute} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{starmapOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<StarmapView onClose={closeOverlayToPreviousRoute} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* The full hotkey map (⌘/ and the titlebar keyboard button). */}
|
||||
<KeybindPanel />
|
||||
|
||||
{/* Toasts above everything. */}
|
||||
<NotificationStack />
|
||||
|
||||
{/* Petdex floating mascot — renders nothing unless installed + enabled. */}
|
||||
<FloatingPet />
|
||||
|
||||
{/* Single persistent xterm host chasing the terminal pane's slot rect. */}
|
||||
<PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
|
||||
</ContribWiringContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -54,8 +54,8 @@ import {
|
|||
} from '../overlays/panel'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { jobState, jobTitle, STATE_DOT } from './job-state'
|
||||
import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model'
|
||||
import { jobState, jobTitle, STATE_DOT } from './job-state'
|
||||
|
||||
const DEFAULT_DELIVER = 'local'
|
||||
|
||||
|
|
@ -398,6 +398,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
|||
notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) })
|
||||
} else if (editor.mode === 'edit') {
|
||||
const scriptOnlyJob = jobIsScriptOnly(editor.job)
|
||||
|
||||
const updated = await updateCronJob(
|
||||
editor.job.id,
|
||||
cronEditorUpdates(values, { scriptOnlyJob })
|
||||
|
|
@ -756,6 +757,7 @@ function CronEditorDialog({
|
|||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
const validationError = validateCronEditor({
|
||||
prompt,
|
||||
schedule,
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
import type { SessionInfo } from '@/hermes'
|
||||
|
||||
// Cheap signature compare so a poll only swaps the atom (and re-renders the
|
||||
// sidebar) when the visible rows actually changed.
|
||||
export function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return a.every((session, i) => {
|
||||
const other = b[i]
|
||||
|
||||
return (
|
||||
other != null &&
|
||||
session.id === other.id &&
|
||||
session._lineage_root_id === other._lineage_root_id &&
|
||||
session.title === other.title &&
|
||||
session.source === other.source &&
|
||||
session.profile === other.profile &&
|
||||
session.preview === other.preview &&
|
||||
session.message_count === other.message_count &&
|
||||
session.last_active === other.last_active &&
|
||||
session.ended_at === other.ended_at
|
||||
)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -39,6 +39,7 @@ import {
|
|||
setCurrentCwd,
|
||||
setSessionsLoading
|
||||
} from '@/store/session'
|
||||
import { resetTileRuntimeBindings } from '@/store/session-states'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)
|
||||
|
|
@ -167,6 +168,9 @@ export function useGatewayBoot({
|
|||
}
|
||||
|
||||
reconnectAttempt = 0
|
||||
// A respawned backend re-mints (recycles) runtime ids, so any tile's
|
||||
// bound runtime id is now stale — drop them so each tile re-resumes.
|
||||
resetTileRuntimeBindings()
|
||||
// Resync state that may have moved on the backend while we were asleep.
|
||||
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
|
||||
await callbacksRef.current.refreshSessions().catch(() => undefined)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { closeActiveTab } from '@/app/chat/close-tab'
|
||||
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
|
||||
import { matchesQuery } from '@/hooks/use-media-query'
|
||||
import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
import { activateTreeTabSlot, cycleTreeTabInFocusedZone, layoutHasRootSide } from '@/components/pane-shell/tree/store'
|
||||
import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
|
||||
import { $repoStatus } from '@/store/coding-status'
|
||||
import { toggleCommandPalette } from '@/store/command-palette'
|
||||
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
|
||||
import {
|
||||
CHAT_SIDEBAR_PANE_ID,
|
||||
FILE_BROWSER_PANE_ID,
|
||||
requestSessionSearchFocus,
|
||||
setFileBrowserOpen,
|
||||
toggleFileBrowserOpen,
|
||||
|
|
@ -30,6 +28,7 @@ import {
|
|||
import { requestNewWorktree } from '@/store/projects'
|
||||
import { toggleReview } from '@/store/review'
|
||||
import { setModelPickerOpen } from '@/store/session'
|
||||
import { reopenLastClosedTile } from '@/store/session-states'
|
||||
import {
|
||||
$switcherOpen,
|
||||
closeSwitcher,
|
||||
|
|
@ -45,7 +44,6 @@ import { openNewSessionInNewWindow } from '@/store/windows'
|
|||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus'
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||
import {
|
||||
AGENTS_ROUTE,
|
||||
ARTIFACTS_ROUTE,
|
||||
|
|
@ -62,6 +60,8 @@ export interface KeybindRuntimeDeps {
|
|||
toggleCommandCenter: () => void
|
||||
/** Drop to a fresh new-session draft. */
|
||||
startFreshSession: () => void
|
||||
/** Open a fresh session as a tab in the main zone (⌘T), leaving the primary. */
|
||||
openNewSessionTab: () => void
|
||||
/** Pin/unpin the active session. */
|
||||
toggleSelectedPin: () => void
|
||||
}
|
||||
|
|
@ -82,7 +82,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
const profileSwitchHandlers: HandlerMap = {}
|
||||
|
||||
for (let slot = 1; slot <= PROFILE_SLOT_COUNT; slot += 1) {
|
||||
profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot)
|
||||
// ⌘1…⌘9 switch the FOCUSED zone's tab when it's a real tab strip; only a
|
||||
// single-pane (or unfocused) layout falls through to the profile switch.
|
||||
profileSwitchHandlers[`profile.switch.${slot}`] = () => {
|
||||
if (!activateTreeTabSlot(slot)) {
|
||||
switchProfileToSlot(slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const goToSession = (sessionId: null | string) => {
|
||||
|
|
@ -138,9 +144,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
deps.startFreshSession()
|
||||
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
||||
},
|
||||
'session.newTab': () => deps.openNewSessionTab(),
|
||||
'session.newWindow': () => void openNewSessionInNewWindow(),
|
||||
'session.next': () => stepSession(1),
|
||||
'session.prev': () => stepSession(-1),
|
||||
// ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus
|
||||
// falls through to the recent-session switcher.
|
||||
'session.next': () => void (cycleTreeTabInFocusedZone(1) || stepSession(1)),
|
||||
'session.prev': () => void (cycleTreeTabInFocusedZone(-1) || stepSession(-1)),
|
||||
...sessionSlotHandlers,
|
||||
'session.focusSearch': requestSessionSearchFocus,
|
||||
'session.togglePin': deps.toggleSelectedPin,
|
||||
|
|
@ -148,20 +157,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
// through instead of silently doing nothing).
|
||||
'workspace.newWorktree': () => $repoStatus.get() && requestNewWorktree(),
|
||||
|
||||
'view.toggleSidebar': () => {
|
||||
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
|
||||
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: CHAT_SIDEBAR_PANE_ID } }))
|
||||
} else {
|
||||
toggleSidebarOpen()
|
||||
}
|
||||
},
|
||||
'view.toggleRightSidebar': () => {
|
||||
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
|
||||
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: FILE_BROWSER_PANE_ID } }))
|
||||
} else {
|
||||
toggleFileBrowserOpen()
|
||||
}
|
||||
},
|
||||
// Narrow-viewport reveal is handled inside the store toggles now.
|
||||
'view.toggleSidebar': toggleSidebarOpen,
|
||||
// ⌘J toggles the right sidebar — but a layout with no right side (e.g.
|
||||
// terminal-on-bottom) would leave it a dead key, so it falls back to the
|
||||
// terminal there. The single "secondary panel" toggle.
|
||||
'view.toggleRightSidebar': () =>
|
||||
layoutHasRootSide('right') ? toggleFileBrowserOpen() : setTerminalTakeover(!$terminalTakeover.get()),
|
||||
'view.toggleReview': toggleReview,
|
||||
'view.showFiles': showFiles,
|
||||
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
|
||||
|
|
@ -177,6 +179,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
'view.prevTerminal': () => $terminalTakeover.get() && cycleTerminal(-1),
|
||||
'view.closeTerminal': () => $terminalTakeover.get() && closeActiveTerminal(),
|
||||
'view.flipPanes': togglePanesFlipped,
|
||||
// ⌘W: close the focused tab (terminal / preview target / zone tree tab).
|
||||
// On macOS the menu accelerator owns ⌘W and routes through the same
|
||||
// closeActiveTab via IPC (see use-desktop-integrations); this binding is
|
||||
// the Win/Linux path where ⌘W reaches the renderer directly.
|
||||
'view.closeTab': () => void closeActiveTab(),
|
||||
'view.reopenTab': reopenLastClosedTile,
|
||||
|
||||
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
|
||||
|
||||
|
|
@ -242,7 +250,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
return
|
||||
}
|
||||
|
||||
const handler = handlersRef.current[actionId]
|
||||
// Built-in handlers first (they carry React context); contributed
|
||||
// actions bring their own `run` through the registry.
|
||||
const handler = handlersRef.current[actionId] ?? contributedKeybindHandler(actionId)
|
||||
|
||||
if (!handler) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1 +1,6 @@
|
|||
export { DesktopController as default } from './desktop-controller'
|
||||
// The app root is the contribution-driven shell: panes, titlebar/statusbar
|
||||
// items, keybinds, palette commands, routes, and themes all register through
|
||||
// the contribution registry (src/contrib) — core surfaces use the same calls
|
||||
// plugins do. Everything lives under ./contrib: the wiring (gateway boot,
|
||||
// sessions, streams) + pane surfaces, and the pane/layout registration.
|
||||
export { ContribController as default } from './contrib'
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { type ReactNode, useEffect } from 'react'
|
||||
import { type CSSProperties, type ReactNode, useEffect } from 'react'
|
||||
|
||||
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -32,8 +34,10 @@ export function OverlayView({
|
|||
// stop propagation themselves, so opening (e.g.) the model picker inside
|
||||
// Settings still closes the picker first instead of the underlying overlay.
|
||||
useEffect(() => {
|
||||
const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.overlay)
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.overlay)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -44,7 +48,10 @@ export function OverlayView({
|
|||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
releaseLayer()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
|
|
@ -64,6 +71,12 @@ export function OverlayView({
|
|||
}
|
||||
}}
|
||||
role="presentation"
|
||||
// Window-level chrome: overlays always clear the real titlebar. The
|
||||
// contrib shell zeroes --titlebar-height for CONTENT areas (panes sit
|
||||
// below its in-flow title bar), and CSS vars inherit through the DOM —
|
||||
// so a fixed overlay mounted inside a zone would read 0 and bleed to
|
||||
// the edges. Re-pin the real height at the overlay root.
|
||||
style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -84,7 +84,9 @@ export function ReviewPane() {
|
|||
{(loading || isRepo) && (
|
||||
<RightSidebarSectionHeader data-suppress-pane-reveal-side="">
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<SidebarPanelLabel>{c.review}</SidebarPanelLabel>
|
||||
{/* Pure self-naming label — redundant under a zone tab that already
|
||||
says "review", so the zone header hides it (styles.css). */}
|
||||
<SidebarPanelLabel data-pane-self-label="">{c.review}</SidebarPanelLabel>
|
||||
</div>
|
||||
<Tip label={treeMode === 'tree' ? c.viewAsList : c.viewAsTree}>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
import { atom } from 'nanostores'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { registry } from '@/contrib/registry'
|
||||
|
||||
export const SESSION_ROUTE_PREFIX = '/'
|
||||
export const NEW_CHAT_ROUTE = '/'
|
||||
export const SETTINGS_ROUTE = '/settings'
|
||||
|
|
@ -16,6 +21,11 @@ export type AppView =
|
|||
| 'chat'
|
||||
| 'command-center'
|
||||
| 'cron'
|
||||
// A contributed (plugin) full page at its own route — NOT chat. Without this
|
||||
// distinction contributed paths fell through appViewForPath's 'chat' default,
|
||||
// so the sidebar kept a session highlighted and the titlebar kept the
|
||||
// session-title dropdown while a plugin page was showing.
|
||||
| 'extension'
|
||||
| 'messaging'
|
||||
| 'profiles'
|
||||
| 'settings'
|
||||
|
|
@ -56,6 +66,52 @@ export const APP_ROUTES = [
|
|||
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
|
||||
const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => route.path))
|
||||
|
||||
// ── Contributed routes — the `routes` registry area ─────────────────────────
|
||||
// A contribution mounts a FULL PAGE in the workspace pane at `data.path`
|
||||
// (`render` on the contribution itself, like every other area). Contributed
|
||||
// paths are reserved exactly like APP_ROUTES so the session-id parser never
|
||||
// mistakes them for a session route. Navigate with `host.navigate(path)`.
|
||||
|
||||
export const ROUTES_AREA = 'routes'
|
||||
|
||||
/** Payload of a `routes` contribution's `data`. */
|
||||
export interface RouteContribution {
|
||||
/** Absolute path, e.g. `/kanban`. One segment; no params. */
|
||||
path: string
|
||||
}
|
||||
|
||||
export function contributedRoutes(): Array<{ key: string; path: string; title?: string; render: () => ReactNode }> {
|
||||
return registry
|
||||
.getArea(ROUTES_AREA)
|
||||
.map(c => ({
|
||||
key: `${c.source ?? 'core'}:${c.id}`,
|
||||
path: (c.data as RouteContribution | undefined)?.path ?? '',
|
||||
title: c.title,
|
||||
render: c.render!
|
||||
}))
|
||||
.filter(route => Boolean(route.path.startsWith('/') && route.render) && !RESERVED_PATHS.has(route.path))
|
||||
}
|
||||
|
||||
function isContributedPath(pathname: string): boolean {
|
||||
return contributedRoutes().some(route => route.path === pathname)
|
||||
}
|
||||
|
||||
// ── Contributed sidebar nav — the `sidebar.nav` registry area ────────────────
|
||||
// A DATA contribution adds a row to the sidebar's top nav (below Artifacts).
|
||||
// Pair with a ROUTES_AREA page: the row navigates to `path` and lights up
|
||||
// while the app is there.
|
||||
|
||||
export const SIDEBAR_NAV_AREA = 'sidebar.nav'
|
||||
|
||||
/** Payload of a `sidebar.nav` data contribution. */
|
||||
export interface SidebarNavContribution {
|
||||
/** Codicon name, e.g. `'project'`. */
|
||||
codicon: string
|
||||
label: string
|
||||
/** Route to navigate to (usually a contributed page's path). */
|
||||
path: string
|
||||
}
|
||||
|
||||
// Views that render as a full-screen modal card (OverlayView) over the shell.
|
||||
// While one is open the app's titlebar control clusters must hide so they don't
|
||||
// bleed over the overlay (they sit at a higher z-index than the overlay card).
|
||||
|
|
@ -77,7 +133,7 @@ export function isNewChatRoute(pathname: string): boolean {
|
|||
}
|
||||
|
||||
export function routeSessionId(pathname: string): string | null {
|
||||
if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname)) {
|
||||
if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname) || isContributedPath(pathname)) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -95,5 +151,25 @@ export function appViewForPath(pathname: string): AppView {
|
|||
return 'chat'
|
||||
}
|
||||
|
||||
if (isContributedPath(pathname)) {
|
||||
return 'extension'
|
||||
}
|
||||
|
||||
return APP_VIEW_BY_PATH.get(pathname) ?? 'chat'
|
||||
}
|
||||
|
||||
/** True while the workspace pane shows a FULL PAGE (skills/messaging/
|
||||
* artifacts/plugin routes) instead of the chat. Published by the wiring
|
||||
* (which owns the router location); the workspace pane contribution mirrors
|
||||
* it as `headerVeto` so the zone tab bar stands down on pages. Overlays
|
||||
* (settings/…) don't count — the chat stays beneath them. */
|
||||
export const $workspaceIsPage = atom(false)
|
||||
|
||||
export function syncWorkspaceIsPage(pathname: string): void {
|
||||
const view = appViewForPath(pathname)
|
||||
const isPage = view !== 'chat' && !isOverlayView(view)
|
||||
|
||||
if (isPage !== $workspaceIsPage.get()) {
|
||||
$workspaceIsPage.set(isPage)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { followActiveSessionCwd } from '@/store/projects'
|
|||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
import {
|
||||
$currentCwd,
|
||||
sessionMatchesStoredId,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentFastMode,
|
||||
|
|
@ -405,7 +406,17 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
}
|
||||
|
||||
if (payload?.usage) {
|
||||
setCurrentUsage(current => ({ ...current, ...payload.usage }))
|
||||
// Per-session twin FIRST (the statusbar reads it for focused tiles);
|
||||
// the primary-only global mirrors the ACTIVE session — ungated it
|
||||
// let a background tile's turn overwrite the primary's count.
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
usage: { calls: 0, input: 0, output: 0, total: 0, ...state.usage, ...payload.usage }
|
||||
}))
|
||||
|
||||
if (isActiveEvent) {
|
||||
setCurrentUsage(current => ({ ...current, ...payload.usage }))
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'session.title') {
|
||||
// Live auto-title push (titler runs async, after the turn's refresh).
|
||||
|
|
@ -413,9 +424,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
const nextTitle = typeof payload?.title === 'string' ? payload.title.trim() : ''
|
||||
|
||||
if (storedId && nextTitle) {
|
||||
setSessions(prev =>
|
||||
prev.map(s => (s.id === storedId || s._lineage_root_id === storedId ? { ...s, title: nextTitle } : s))
|
||||
)
|
||||
setSessions(prev => prev.map(s => (sessionMatchesStoredId(s, storedId) ? { ...s, title: nextTitle } : s)))
|
||||
}
|
||||
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
|
||||
if (!sessionId) {
|
||||
|
|
|
|||
|
|
@ -1453,6 +1453,7 @@ describe('usePromptActions submit session-context isolation (#54527)', () => {
|
|||
it('aborts recovery submit when the user switches sessions during timeout resume', async () => {
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
let submitAttempts = 0
|
||||
|
||||
let releaseResume: () => void = () => {}
|
||||
|
||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED_SESSION_A }
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
|||
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS, transcribeAudio } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { stripAnsi } from '@/lib/ansi'
|
||||
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
||||
import { type ChatMessage, textPart } from '@/lib/chat-messages'
|
||||
import { pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { normalize } from '@/lib/text'
|
||||
|
|
@ -44,23 +44,28 @@ import type {
|
|||
SessionSteerResponse
|
||||
} from '../../../types'
|
||||
|
||||
import {
|
||||
applyBranchVisibility,
|
||||
applyReloadOptimistic,
|
||||
applyRewindOptimistic,
|
||||
finalizeInterruptedMessages,
|
||||
planEdit,
|
||||
planReload,
|
||||
planRestore,
|
||||
runRewindSubmit
|
||||
} from './rewind'
|
||||
import { useSlashCommand } from './slash'
|
||||
import { useSubmitPrompt } from './submit'
|
||||
import {
|
||||
appendText,
|
||||
blobToDataUrl,
|
||||
delay,
|
||||
friendlyRemoteAttachError,
|
||||
type GatewayRequest,
|
||||
inlineErrorMessage,
|
||||
isSessionBusyError,
|
||||
isSessionNotFoundError,
|
||||
readFileDataUrlForAttach,
|
||||
readImageForRemoteAttach,
|
||||
type SubmitTextOptions,
|
||||
visibleUserIndexAtOrdinal,
|
||||
visibleUserOrdinal,
|
||||
withSessionBusyRetry
|
||||
type SubmitTextOptions
|
||||
} from './utils'
|
||||
|
||||
interface HandoffResult {
|
||||
|
|
@ -403,6 +408,13 @@ export function usePromptActions({
|
|||
return { error: inlineErrorMessage(err, copy.handoff.failed(target)), ok: false }
|
||||
}
|
||||
|
||||
const markCompleted = (): HandoffResult => {
|
||||
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
|
||||
notify({ kind: 'success', message: copy.handoff.success(target) })
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 60_000
|
||||
let lastState = 'pending'
|
||||
|
||||
|
|
@ -425,10 +437,7 @@ export function usePromptActions({
|
|||
}
|
||||
|
||||
if (state === 'completed') {
|
||||
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
|
||||
notify({ kind: 'success', message: copy.handoff.success(target) })
|
||||
|
||||
return { ok: true }
|
||||
return markCompleted()
|
||||
}
|
||||
|
||||
if (state === 'failed') {
|
||||
|
|
@ -442,10 +451,7 @@ export function usePromptActions({
|
|||
}).catch(() => null)
|
||||
|
||||
if (cleanup?.state === 'completed') {
|
||||
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
|
||||
notify({ kind: 'success', message: copy.handoff.success(target) })
|
||||
|
||||
return { ok: true }
|
||||
return markCompleted()
|
||||
}
|
||||
|
||||
return { error: copy.handoff.timedOut, ok: false }
|
||||
|
|
@ -512,21 +518,16 @@ export function usePromptActions({
|
|||
setAwaitingResponse(false)
|
||||
setTurnStartedAt(null)
|
||||
|
||||
const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
|
||||
messages
|
||||
.filter(message => !((message.pending || message.id === streamId) && !chatMessageText(message).trim()))
|
||||
.map(message => (message.pending || message.id === streamId ? { ...message, pending: false } : message))
|
||||
|
||||
if (!sessionId) {
|
||||
releaseBusy()
|
||||
setMessages(finalizeMessages($messages.get()))
|
||||
setMessages(finalizeInterruptedMessages($messages.get()))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(sessionId, state => {
|
||||
const streamId = state.streamId
|
||||
const messages = finalizeMessages(state.messages, streamId)
|
||||
const messages = finalizeInterruptedMessages(state.messages, streamId)
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
|
@ -631,66 +632,19 @@ export function usePromptActions({
|
|||
return
|
||||
}
|
||||
|
||||
const messages = $messages.get()
|
||||
const parentIndex = parentId ? messages.findIndex(message => message.id === parentId) : messages.length - 1
|
||||
const plan = planReload($messages.get(), parentId)
|
||||
|
||||
const userIndex =
|
||||
parentIndex >= 0
|
||||
? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(message => message.role === 'user')
|
||||
: -1
|
||||
|
||||
if (userIndex < 0) {
|
||||
if (!plan) {
|
||||
return
|
||||
}
|
||||
|
||||
const absoluteUserIndex = parentIndex - userIndex
|
||||
const userMessage = messages[absoluteUserIndex]
|
||||
const userText = userMessage ? chatMessageText(userMessage).trim() : ''
|
||||
|
||||
if (!userText) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetAssistant =
|
||||
parentId && messages[parentIndex]?.role === 'assistant'
|
||||
? messages[parentIndex]
|
||||
: messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant')
|
||||
|
||||
const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage)
|
||||
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, absoluteUserIndex)
|
||||
|
||||
clearNotifications()
|
||||
updateSessionState(activeSessionId, state => {
|
||||
const nextUserIndex = state.messages.findIndex(
|
||||
(message, index) => index > absoluteUserIndex && message.role === 'user'
|
||||
)
|
||||
|
||||
const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex
|
||||
|
||||
return {
|
||||
...state,
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: branchGroupId,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false,
|
||||
messages: [
|
||||
...state.messages.slice(0, absoluteUserIndex + 1),
|
||||
...state.messages
|
||||
.slice(absoluteUserIndex + 1, end)
|
||||
.map(message => (message.role === 'assistant' ? { ...message, branchGroupId, hidden: true } : message))
|
||||
]
|
||||
}
|
||||
})
|
||||
updateSessionState(activeSessionId, state => applyReloadOptimistic(state, plan))
|
||||
|
||||
try {
|
||||
await requestGateway(
|
||||
'prompt.submit',
|
||||
{
|
||||
session_id: activeSessionId,
|
||||
text: userText,
|
||||
truncate_before_user_ordinal: truncateBeforeUserOrdinal
|
||||
},
|
||||
{ session_id: activeSessionId, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
|
||||
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
} catch (err) {
|
||||
|
|
@ -715,41 +669,8 @@ export function usePromptActions({
|
|||
// fresh turn. Live/stuck turns interrupt first, and a raced "session busy"
|
||||
// response interrupts + retries through the shared busy gate.
|
||||
const submitRewindPrompt = useCallback(
|
||||
async (sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => {
|
||||
const interrupt = async () => {
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
} catch {
|
||||
// Best-effort. The submit path still gates on the gateway state.
|
||||
}
|
||||
}
|
||||
|
||||
const submit = () =>
|
||||
requestGateway(
|
||||
'prompt.submit',
|
||||
{
|
||||
session_id: sessionId,
|
||||
text,
|
||||
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
|
||||
},
|
||||
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
|
||||
if (interruptFirst) {
|
||||
await interrupt()
|
||||
}
|
||||
|
||||
try {
|
||||
await submit()
|
||||
} catch (err) {
|
||||
if (!isSessionBusyError(err)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
await interrupt()
|
||||
await withSessionBusyRetry(submit)
|
||||
}
|
||||
},
|
||||
(sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) =>
|
||||
runRewindSubmit(requestGateway, sessionId, text, truncateOrdinal, interruptFirst),
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
|
|
@ -762,30 +683,7 @@ export function usePromptActions({
|
|||
}
|
||||
|
||||
const messages = $messages.get()
|
||||
const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user')
|
||||
|
||||
const fallbackIndex =
|
||||
target?.userOrdinal === null || target?.userOrdinal === undefined
|
||||
? -1
|
||||
: visibleUserIndexAtOrdinal(messages, target.userOrdinal)
|
||||
|
||||
const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex
|
||||
const source = messages[sourceIndex]
|
||||
|
||||
if (!source || source.role !== 'user') {
|
||||
throw new Error('Could not find the message to restore.')
|
||||
}
|
||||
|
||||
const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim()
|
||||
|
||||
if (!text) {
|
||||
throw new Error('Cannot restore an empty message.')
|
||||
}
|
||||
|
||||
const truncateBeforeUserOrdinal =
|
||||
target?.userOrdinal === null || target?.userOrdinal === undefined
|
||||
? visibleUserOrdinal(messages, sourceIndex)
|
||||
: target.userOrdinal
|
||||
const plan = planRestore(messages, messageId, target)
|
||||
|
||||
// The turns we're discarding may have spawned todos and background
|
||||
// processes; they belong to the abandoned timeline, so wipe their status
|
||||
|
|
@ -798,18 +696,10 @@ export function usePromptActions({
|
|||
setMutableRef(busyRef, true)
|
||||
setBusy(true)
|
||||
setAwaitingResponse(true)
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false,
|
||||
messages: state.messages.slice(0, sourceIndex + 1)
|
||||
}))
|
||||
updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex))
|
||||
|
||||
try {
|
||||
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, busyRef.current || $busy.get())
|
||||
await submitRewindPrompt(sessionId, plan.text, plan.truncateOrdinal, busyRef.current || $busy.get())
|
||||
} catch (err) {
|
||||
// The rewind never landed (e.g. the gateway stayed busy past the retry
|
||||
// deadline). Roll the optimistic truncation back to the full original
|
||||
|
|
@ -833,34 +723,17 @@ export function usePromptActions({
|
|||
const editMessage = useCallback(
|
||||
async (edited: AppendMessage) => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
const sourceId = edited.sourceId || edited.parentId
|
||||
const text = appendText(edited)
|
||||
|
||||
if (!sessionId || !sourceId || !text || edited.role !== 'user') {
|
||||
return
|
||||
}
|
||||
|
||||
const messages = $messages.get()
|
||||
const sourceIndex = messages.findIndex(m => m.id === sourceId)
|
||||
const source = messages[sourceIndex]
|
||||
const plan = sessionId ? planEdit(messages, edited) : null
|
||||
|
||||
if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) {
|
||||
if (!sessionId || !plan) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sending an edit is a revert: rewind to this prompt and re-run with the
|
||||
// new text. It can fire mid-turn; submitRewindPrompt always interrupts
|
||||
// first, so a live turn is wound down before the resubmit.
|
||||
|
||||
// Failed turn: optimistic user msg never reached the gateway, so truncating
|
||||
// by ordinal would 422. Submit as a plain resend instead.
|
||||
const nextMessage = messages[sourceIndex + 1]
|
||||
const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error)
|
||||
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
|
||||
|
||||
// Editing rewinds the conversation to this prompt — same as restore — so
|
||||
// drop the abandoned timeline's todos/background rows (and kill the live
|
||||
// processes) before the re-run repopulates them.
|
||||
// new text (submitRewindPrompt interrupts a live turn first). Same as
|
||||
// restore, so drop the abandoned timeline's todos/background rows before
|
||||
// the re-run repopulates them.
|
||||
clearSessionTodos(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
clearPreviewArtifacts(sessionId)
|
||||
|
|
@ -869,33 +742,20 @@ export function usePromptActions({
|
|||
setMutableRef(busyRef, true)
|
||||
setBusy(true)
|
||||
setAwaitingResponse(true)
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false,
|
||||
messages: [...state.messages.slice(0, sourceIndex), editedMessage]
|
||||
}))
|
||||
updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage))
|
||||
|
||||
const isStaleTargetError = (err: unknown) =>
|
||||
/no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err))
|
||||
|
||||
try {
|
||||
await submitRewindPrompt(
|
||||
sessionId,
|
||||
text,
|
||||
isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex),
|
||||
busyRef.current || $busy.get()
|
||||
)
|
||||
await submitRewindPrompt(sessionId, plan.text, plan.truncateOrdinal, busyRef.current || $busy.get())
|
||||
} catch (err) {
|
||||
let surfaced = err
|
||||
|
||||
if (!isFailedTurn && isStaleTargetError(err)) {
|
||||
if (!plan.isFailedTurn && isStaleTargetError(err)) {
|
||||
try {
|
||||
// Already interrupted on the first attempt — submit as a plain resend.
|
||||
await submitRewindPrompt(sessionId, text, undefined, false)
|
||||
await submitRewindPrompt(sessionId, plan.text, undefined, false)
|
||||
|
||||
return
|
||||
} catch (retryErr) {
|
||||
|
|
@ -918,34 +778,13 @@ export function usePromptActions({
|
|||
|
||||
const handleThreadMessagesChange = useCallback(
|
||||
(nextMessages: readonly ThreadMessage[]) => {
|
||||
const visibleIds = new Set(nextMessages.map(m => m.id))
|
||||
const sessionId = activeSessionIdRef.current
|
||||
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(sessionId, state => {
|
||||
let changed = false
|
||||
|
||||
const messages = state.messages.map(message => {
|
||||
if (message.role !== 'assistant' || !message.branchGroupId) {
|
||||
return message
|
||||
}
|
||||
|
||||
const hidden = !visibleIds.has(message.id)
|
||||
|
||||
if (message.hidden === hidden) {
|
||||
return message
|
||||
}
|
||||
|
||||
changed = true
|
||||
|
||||
return { ...message, hidden }
|
||||
})
|
||||
|
||||
return changed ? { ...state, messages } : state
|
||||
})
|
||||
updateSessionState(sessionId, state => applyBranchVisibility(state, nextMessages))
|
||||
},
|
||||
[activeSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
|
@ -953,6 +792,9 @@ export function usePromptActions({
|
|||
return {
|
||||
cancelRun,
|
||||
editMessage,
|
||||
// Session tiles route their slash input here (targets THEIR session via
|
||||
// options.sessionId; app-level effects — branch, handoff — act on main).
|
||||
executeSlashCommand,
|
||||
handleThreadMessagesChange,
|
||||
handoffSession,
|
||||
reloadFromMessage,
|
||||
|
|
|
|||
271
apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts
Normal file
271
apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
/**
|
||||
* Shared rewind/interrupt core for the prompt verbs — the ONE implementation
|
||||
* of the submit primitive + the pure message math behind cancel / reload /
|
||||
* restore / edit / branch-visibility. Both the primary chat (`index.ts`) and
|
||||
* session tiles (`session-tile-actions.ts`) build on these so the two surfaces
|
||||
* can't silently diverge (the tile's "sends only once" busy-ref bug was exactly
|
||||
* that class of drift). The functions here are PURE — planners compute from a
|
||||
* `ChatMessage[]`, optimistic transforms map a `ClientSessionState` to the next
|
||||
* — so each caller keeps its own state-write + error-handling wiring.
|
||||
*/
|
||||
|
||||
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
|
||||
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
|
||||
import { appendText, isSessionBusyError, visibleUserIndexAtOrdinal, visibleUserOrdinal, withSessionBusyRetry } from './utils'
|
||||
|
||||
type RequestGateway = <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
|
||||
|
||||
/**
|
||||
* Rewind a turn: `prompt.submit` with an optional `truncate_before_user_ordinal`
|
||||
* (drops that user turn + everything after). Idle rewinds submit directly
|
||||
* (interrupting an idle agent can leave a stale interrupt flag that cancels the
|
||||
* fresh turn); live/stuck turns interrupt first, and a raced "session busy"
|
||||
* response interrupts + retries through the shared busy gate.
|
||||
*/
|
||||
export async function runRewindSubmit(
|
||||
requestGateway: RequestGateway,
|
||||
sessionId: string,
|
||||
text: string,
|
||||
truncateOrdinal: number | undefined,
|
||||
interruptFirst: boolean
|
||||
): Promise<void> {
|
||||
const interrupt = async () => {
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
} catch {
|
||||
// Best-effort. The submit path still gates on the gateway state.
|
||||
}
|
||||
}
|
||||
|
||||
const submit = () =>
|
||||
requestGateway(
|
||||
'prompt.submit',
|
||||
{ session_id: sessionId, text, ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) },
|
||||
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
|
||||
if (interruptFirst) {
|
||||
await interrupt()
|
||||
}
|
||||
|
||||
try {
|
||||
await submit()
|
||||
} catch (err) {
|
||||
if (!isSessionBusyError(err)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
await interrupt()
|
||||
await withSessionBusyRetry(submit)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel/stop finalize: drop empty pending/stream placeholders, un-pend the rest. */
|
||||
export function finalizeInterruptedMessages(messages: ChatMessage[], streamId?: null | string): ChatMessage[] {
|
||||
return messages
|
||||
.filter(message => !((message.pending || message.id === streamId) && !chatMessageText(message).trim()))
|
||||
.map(message => (message.pending || message.id === streamId ? { ...message, pending: false } : message))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reload (regenerate)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ReloadPlan {
|
||||
branchGroupId: string
|
||||
text: string
|
||||
truncateOrdinal: number
|
||||
userIndex: number
|
||||
}
|
||||
|
||||
/** The user turn to re-run for a reload from `parentId` (or the last turn). */
|
||||
export function planReload(messages: ChatMessage[], parentId: null | string): null | ReloadPlan {
|
||||
const parentIndex = parentId ? messages.findIndex(m => m.id === parentId) : messages.length - 1
|
||||
|
||||
const userBack =
|
||||
parentIndex >= 0 ? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(m => m.role === 'user') : -1
|
||||
|
||||
if (userBack < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const userIndex = parentIndex - userBack
|
||||
const userMessage = messages[userIndex]
|
||||
const text = userMessage ? chatMessageText(userMessage).trim() : ''
|
||||
|
||||
if (!userMessage || !text) {
|
||||
return null
|
||||
}
|
||||
|
||||
const targetAssistant =
|
||||
parentId && messages[parentIndex]?.role === 'assistant'
|
||||
? messages[parentIndex]
|
||||
: messages.slice(userIndex + 1).find(m => m.role === 'assistant')
|
||||
|
||||
return {
|
||||
branchGroupId: targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage),
|
||||
text,
|
||||
truncateOrdinal: visibleUserOrdinal(messages, userIndex),
|
||||
userIndex
|
||||
}
|
||||
}
|
||||
|
||||
/** Optimistic reload state: keep the user turn, hide the branch's assistants. */
|
||||
export function applyReloadOptimistic(state: ClientSessionState, plan: ReloadPlan): ClientSessionState {
|
||||
const nextUserIndex = state.messages.findIndex((m, i) => i > plan.userIndex && m.role === 'user')
|
||||
const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex
|
||||
|
||||
return {
|
||||
...state,
|
||||
awaitingResponse: true,
|
||||
busy: true,
|
||||
interrupted: false,
|
||||
messages: [
|
||||
...state.messages.slice(0, plan.userIndex + 1),
|
||||
...state.messages
|
||||
.slice(plan.userIndex + 1, end)
|
||||
.map(m => (m.role === 'assistant' ? { ...m, branchGroupId: plan.branchGroupId, hidden: true } : m))
|
||||
],
|
||||
pendingBranchGroup: plan.branchGroupId,
|
||||
sawAssistantPayload: false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Restore (rewind checkpoint)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RestoreTarget {
|
||||
text?: string
|
||||
userOrdinal?: null | number
|
||||
}
|
||||
|
||||
export interface RestorePlan {
|
||||
sourceIndex: number
|
||||
text: string
|
||||
truncateOrdinal: number
|
||||
}
|
||||
|
||||
/** Resolve the user turn to rewind to; throws with a user-facing reason. */
|
||||
export function planRestore(messages: ChatMessage[], messageId: string, target?: RestoreTarget): RestorePlan {
|
||||
const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user')
|
||||
|
||||
const fallbackIndex =
|
||||
target?.userOrdinal === null || target?.userOrdinal === undefined
|
||||
? -1
|
||||
: visibleUserIndexAtOrdinal(messages, target.userOrdinal)
|
||||
|
||||
const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex
|
||||
const source = messages[sourceIndex]
|
||||
|
||||
if (!source || source.role !== 'user') {
|
||||
throw new Error('Could not find the message to restore.')
|
||||
}
|
||||
|
||||
const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim()
|
||||
|
||||
if (!text) {
|
||||
throw new Error('Cannot restore an empty message.')
|
||||
}
|
||||
|
||||
const truncateOrdinal =
|
||||
target?.userOrdinal === null || target?.userOrdinal === undefined
|
||||
? visibleUserOrdinal(messages, sourceIndex)
|
||||
: target.userOrdinal
|
||||
|
||||
return { sourceIndex, text, truncateOrdinal }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit (revert + resubmit with new text)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EditPlan {
|
||||
editedMessage: ChatMessage
|
||||
isFailedTurn: boolean
|
||||
sourceIndex: number
|
||||
text: string
|
||||
truncateOrdinal: number | undefined
|
||||
}
|
||||
|
||||
/** Resolve the edited user turn, or null when nothing changed / invalid. */
|
||||
export function planEdit(messages: ChatMessage[], edited: AppendMessage): EditPlan | null {
|
||||
const sourceId = edited.sourceId || edited.parentId
|
||||
const text = appendText(edited)
|
||||
|
||||
if (!sourceId || !text || edited.role !== 'user') {
|
||||
return null
|
||||
}
|
||||
|
||||
const sourceIndex = messages.findIndex(m => m.id === sourceId)
|
||||
const source = messages[sourceIndex]
|
||||
|
||||
if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Failed turn: the optimistic user msg never reached the gateway, so a
|
||||
// truncate-by-ordinal would 422 — resubmit plainly instead.
|
||||
const nextMessage = messages[sourceIndex + 1]
|
||||
const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error)
|
||||
|
||||
return {
|
||||
editedMessage: { ...source, parts: [textPart(text)] },
|
||||
isFailedTurn,
|
||||
sourceIndex,
|
||||
text,
|
||||
truncateOrdinal: isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex)
|
||||
}
|
||||
}
|
||||
|
||||
/** Optimistic rewind-to state for restore/edit: drop everything after the
|
||||
* source turn (edit swaps in the edited message; restore keeps the original). */
|
||||
export function applyRewindOptimistic(
|
||||
state: ClientSessionState,
|
||||
sourceIndex: number,
|
||||
editedMessage?: ChatMessage
|
||||
): ClientSessionState {
|
||||
return {
|
||||
...state,
|
||||
awaitingResponse: true,
|
||||
busy: true,
|
||||
interrupted: false,
|
||||
messages: editedMessage
|
||||
? [...state.messages.slice(0, sourceIndex), editedMessage]
|
||||
: state.messages.slice(0, sourceIndex + 1),
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Branch visibility (assistant-ui hides non-active branches)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Sync each assistant branch message's `hidden` to what the thread renders. */
|
||||
export function applyBranchVisibility(state: ClientSessionState, next: readonly ThreadMessage[]): ClientSessionState {
|
||||
const visibleIds = new Set(next.map(m => m.id))
|
||||
let changed = false
|
||||
|
||||
const messages = state.messages.map(message => {
|
||||
if (message.role !== 'assistant' || !message.branchGroupId) {
|
||||
return message
|
||||
}
|
||||
|
||||
const hidden = !visibleIds.has(message.id)
|
||||
|
||||
if (message.hidden === hidden) {
|
||||
return message
|
||||
}
|
||||
|
||||
changed = true
|
||||
|
||||
return { ...message, hidden }
|
||||
})
|
||||
|
||||
return changed ? { ...state, messages } : state
|
||||
}
|
||||
|
|
@ -3,8 +3,8 @@ import { type MutableRefObject, useCallback } from 'react'
|
|||
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
|
||||
import type { Translations } from '@/i18n'
|
||||
import { type ChatMessage, textPart } from '@/lib/chat-messages'
|
||||
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
||||
import { optimisticAttachmentRef } from '@/lib/chat-runtime'
|
||||
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import {
|
||||
$composerAttachments,
|
||||
|
|
@ -49,6 +49,26 @@ interface SubmitPromptDeps {
|
|||
updater: (state: ClientSessionState) => ClientSessionState,
|
||||
storedSessionId?: string | null
|
||||
) => ClientSessionState
|
||||
/** Composer-scope seams: the main chat runs on the module-level globals
|
||||
* (defaults); a session tile injects its own so a tile submit never writes
|
||||
* the primary view's $busy/$messages or clears the main attachment chips. */
|
||||
scope?: {
|
||||
clearAttachments: () => void
|
||||
readAttachments: () => ComposerAttachment[]
|
||||
setAwaitingResponse: (awaiting: boolean) => void
|
||||
setBusy: (busy: boolean) => void
|
||||
setMessages: (updater: (current: ChatMessage[]) => ChatMessage[]) => void
|
||||
}
|
||||
}
|
||||
|
||||
// Stable identity — a fresh default object per render would churn the
|
||||
// useCallback below on every render.
|
||||
const MAIN_SUBMIT_SCOPE: NonNullable<SubmitPromptDeps['scope']> = {
|
||||
clearAttachments: clearComposerAttachments,
|
||||
readAttachments: () => $composerAttachments.get(),
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setMessages
|
||||
}
|
||||
|
||||
/** The prompt submit pipeline, extracted from usePromptActions. */
|
||||
|
|
@ -63,7 +83,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
syncAttachmentsForSubmit,
|
||||
updateSessionState
|
||||
updateSessionState,
|
||||
scope = MAIN_SUBMIT_SCOPE
|
||||
} = deps
|
||||
|
||||
return useCallback(
|
||||
|
|
@ -76,7 +97,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
// this, the sibling iterations below (a.kind / a.label / a.refText, and the
|
||||
// sync step) throw "Cannot read properties of undefined (reading 'refText')"
|
||||
// and break the chat surface.
|
||||
const attachments = (options?.attachments ?? $composerAttachments.get()).filter((a): a is ComposerAttachment =>
|
||||
const attachments = (options?.attachments ?? scope.readAttachments()).filter((a): a is ComposerAttachment =>
|
||||
Boolean(a)
|
||||
)
|
||||
|
||||
|
|
@ -159,8 +180,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
const releaseBusy = () => {
|
||||
releaseSubmitLock()
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
scope.setBusy(false)
|
||||
scope.setAwaitingResponse(false)
|
||||
}
|
||||
|
||||
// Idempotent optimistic insert — re-running with the resolved sessionId
|
||||
|
|
@ -199,7 +220,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
|
||||
const dropOptimistic = (sid: null | string) => {
|
||||
if (!sid) {
|
||||
setMessages(current => current.filter(m => m.id !== optimisticId))
|
||||
scope.setMessages(current => current.filter(m => m.id !== optimisticId))
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -225,8 +246,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
}
|
||||
|
||||
setMutableRef(busyRef, true)
|
||||
setBusy(true)
|
||||
setAwaitingResponse(true)
|
||||
scope.setBusy(true)
|
||||
scope.setAwaitingResponse(true)
|
||||
clearNotifications()
|
||||
|
||||
let sessionId: null | string = activeSessionId
|
||||
|
|
@ -234,7 +255,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
if (sessionId) {
|
||||
seedOptimistic(sessionId)
|
||||
} else {
|
||||
setMessages(current => [...current, buildUserMessage()])
|
||||
scope.setMessages(current => [...current, buildUserMessage()])
|
||||
}
|
||||
|
||||
if (!sessionId && startingStoredSessionId) {
|
||||
|
|
@ -383,7 +404,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
}
|
||||
|
||||
if (usingComposerAttachments) {
|
||||
clearComposerAttachments()
|
||||
scope.clearAttachments()
|
||||
}
|
||||
|
||||
// Submit landed — the turn now runs (busy stays true), but the submit
|
||||
|
|
@ -440,6 +461,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
createBackendSessionForSend,
|
||||
getRouteToken,
|
||||
requestGateway,
|
||||
scope,
|
||||
selectedStoredSessionIdRef,
|
||||
syncAttachmentsForSubmit,
|
||||
updateSessionState
|
||||
|
|
|
|||
|
|
@ -426,6 +426,7 @@ describe('resumeSession failure recovery', () => {
|
|||
storedSessionId: 'stored-1',
|
||||
streamId: null,
|
||||
turnStartedAt: null,
|
||||
usage: null,
|
||||
yolo: false
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { MutableRefObject } from 'react'
|
|||
import { useCallback, useRef } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { revealTreePane } from '@/components/pane-shell/tree/store'
|
||||
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
|
|
@ -44,6 +45,7 @@ import {
|
|||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { closeSessionTile, dropSessionState, openSessionTile, patchSessionTile, publishSessionState, type TileDock } from '@/store/session-states'
|
||||
import { broadcastSessionsChanged } from '@/store/session-sync'
|
||||
import { isWatchWindow } from '@/store/windows'
|
||||
import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
|
|
@ -88,6 +90,54 @@ interface SessionActionsOptions {
|
|||
) => ClientSessionState
|
||||
}
|
||||
|
||||
// Stored ids created in THIS renderer run. A brand-new session lives only in the
|
||||
// gateway's in-memory map until its first turn persists a state.db row — so if a
|
||||
// respawning/flapping backend drops it, both resume RPC and the REST transcript
|
||||
// 404 even though the user just made it. We must NOT treat that as "gone" (which
|
||||
// yanks them to a fresh draft — the "new sessions clear themselves" bug); the
|
||||
// bounded retry rebinds it when the backend returns. Boot-into-a-stale-last-id
|
||||
// (NOT in this set) still legitimately drops to a draft.
|
||||
const createdThisRun = new Set<string>()
|
||||
|
||||
// Reflect a stored row's persisted token counts into the live usage atom
|
||||
// (total is derived, so callers can't drift it out of sync with input/output).
|
||||
function applyStoredUsage(stored: { input_tokens?: number | null; output_tokens?: number | null }) {
|
||||
const input = stored.input_tokens || 0
|
||||
const output = stored.output_tokens || 0
|
||||
|
||||
setCurrentUsage(current => ({ ...current, input, output, total: input + output }))
|
||||
}
|
||||
|
||||
// `session.create` params from the current profile + sticky-UI model/effort/fast,
|
||||
// ensuring the gateway is on that profile first. Shared by the primary send path
|
||||
// and the "open in split" tile path; `cwd` is the one thing that differs (the
|
||||
// live composer cwd for a send, the resolved new-session cwd for a fresh tile).
|
||||
//
|
||||
// Resolving null profile to the active gateway's is load-bearing: in global-remote
|
||||
// mode one backend serves every profile, so an omitted profile silently lands the
|
||||
// chat on the launch (default) profile — the "rubberbands back to default" bug.
|
||||
// A no-op for single-profile/local-pooled users (a backend resolves its own launch
|
||||
// profile to None). The sticky UI model/effort/fast ride as per-session overrides,
|
||||
// never the profile default (that lives in Settings → Model).
|
||||
async function desktopSessionCreateParams(cwd: string): Promise<Record<string, unknown>> {
|
||||
const profile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
|
||||
await ensureGatewayProfile(profile)
|
||||
|
||||
const model = $currentModel.get().trim()
|
||||
const provider = $currentProvider.get().trim()
|
||||
const effort = $currentReasoningEffort.get().trim()
|
||||
|
||||
return {
|
||||
cols: 96,
|
||||
source: 'desktop',
|
||||
...(cwd && { cwd }),
|
||||
...(profile ? { profile } : {}),
|
||||
...(model ? { model, ...(provider ? { provider } : {}) } : {}),
|
||||
...(effort ? { reasoning_effort: effort } : {}),
|
||||
...($currentFastMode.get() ? { fast: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
interface FreshSessionDraftOptions {
|
||||
replaceRoute?: boolean
|
||||
workspaceTarget?: NewChatWorkspaceTarget
|
||||
|
|
@ -186,19 +236,9 @@ export function useSessionActions({
|
|||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
// A plain new session (top "New Session", /new, keybind) leaves
|
||||
// $newChatProfile null to mean "use the live context"; the per-profile
|
||||
// "+" sets it explicitly. Resolve null to the active gateway profile so
|
||||
// session.create always carries it: in global-remote mode one backend
|
||||
// serves every profile, so an omitted profile param silently lands the
|
||||
// chat on the launch (default) profile — the "rubberbands back to
|
||||
// default" bug. This is a no-op for single-profile/local-pooled users:
|
||||
// a backend resolves its own launch profile to None (_profile_home).
|
||||
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
|
||||
await ensureGatewayProfile(newChatProfile)
|
||||
// An explicit one-shot workspace target (null → detached, string → that
|
||||
// folder) wins; otherwise fall through to the live cwd, then the
|
||||
// project-aware default (resolveNewSessionCwd).
|
||||
// folder) wins; otherwise the live cwd, then the project-aware default
|
||||
// (resolveNewSessionCwd — a project's new session keeps its repo cwd).
|
||||
const workspaceTarget = $newChatWorkspaceTarget.get()
|
||||
|
||||
const cwd =
|
||||
|
|
@ -208,26 +248,8 @@ export function useSessionActions({
|
|||
? workspaceTarget.trim()
|
||||
: $currentCwd.get().trim() || resolveNewSessionCwd()
|
||||
|
||||
// The composer's model/effort/fast is sticky UI state ($currentModel,
|
||||
// $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it
|
||||
// with every session.create so the new chat opens on whatever the picker
|
||||
// shows — applied as per-session overrides, never written to the profile
|
||||
// default (that lives in Settings → Model).
|
||||
const uiModel = $currentModel.get().trim()
|
||||
const uiProvider = $currentProvider.get().trim()
|
||||
const uiEffort = $currentReasoningEffort.get().trim()
|
||||
const uiFast = $currentFastMode.get()
|
||||
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
source: 'desktop',
|
||||
...(cwd && { cwd }),
|
||||
...(newChatProfile ? { profile: newChatProfile } : {}),
|
||||
...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}),
|
||||
...(uiEffort ? { reasoning_effort: uiEffort } : {}),
|
||||
...(uiFast ? { fast: true } : {})
|
||||
})
|
||||
|
||||
const params = await desktopSessionCreateParams(cwd)
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', params)
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
if (
|
||||
|
|
@ -246,6 +268,7 @@ export function useSessionActions({
|
|||
ensureSessionState(created.session_id, stored)
|
||||
|
||||
if (stored) {
|
||||
createdThisRun.add(stored)
|
||||
// Seed the sidebar preview with the user's first message so the row
|
||||
// reads meaningfully while the turn is in flight, instead of flashing
|
||||
// "Untitled session" until the turn persists and auto-title runs. The
|
||||
|
|
@ -310,6 +333,44 @@ export function useSessionActions({
|
|||
[navigate, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
/** Create a fresh session and open it as a tile — leaves the primary chat alone.
|
||||
* Used by the New session row's "Open in split" menu (and any future
|
||||
* "new chat beside" affordance). */
|
||||
const openNewSessionTile = useCallback(
|
||||
async (dir: TileDock = 'right') => {
|
||||
try {
|
||||
// Fresh tile → the resolved new-session cwd (project/default), not the
|
||||
// primary composer's live cwd.
|
||||
const params = await desktopSessionCreateParams(resolveNewSessionCwd().trim())
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', params)
|
||||
const stored = created.stored_session_id
|
||||
|
||||
if (!stored) {
|
||||
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
|
||||
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
createdThisRun.add(stored)
|
||||
// Seed the sidebar + per-runtime cache, but DON'T steal the primary
|
||||
// selection — this session lives in the tile. Prime it with the create
|
||||
// runtime so the tile skips a redundant resume.
|
||||
upsertOptimisticSession(created, stored, null, null)
|
||||
const runtimeInfo = applyRuntimeInfo(created.info)
|
||||
updateSessionState(created.session_id, state => (runtimeInfo ? { ...state, ...runtimeInfo } : state), stored)
|
||||
|
||||
openSessionTile(stored, dir)
|
||||
patchSessionTile(stored, { runtimeId: created.session_id })
|
||||
revealTreePane(`session-tile:${stored}`)
|
||||
broadcastSessionsChanged()
|
||||
} catch (error) {
|
||||
notifyError(error, copy.createSessionFailed)
|
||||
}
|
||||
},
|
||||
[copy, requestGateway, updateSessionState]
|
||||
)
|
||||
|
||||
const openSettings = useCallback(() => {
|
||||
navigate(SETTINGS_ROUTE)
|
||||
}, [navigate])
|
||||
|
|
@ -375,6 +436,7 @@ export function useSessionActions({
|
|||
if (state.storedSessionId !== storedSessionId) {
|
||||
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
|
||||
sessionStateByRuntimeIdRef.current.delete(runtimeId)
|
||||
dropSessionState(runtimeId)
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -423,11 +485,13 @@ export function useSessionActions({
|
|||
|
||||
if (cachedViewState !== cachedState) {
|
||||
sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState)
|
||||
publishSessionState(cachedRuntimeId, cachedViewState)
|
||||
}
|
||||
|
||||
if (sessionShouldHaveTranscript(stored) && cachedViewState.messages.length === 0) {
|
||||
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
|
||||
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
|
||||
dropSessionState(cachedRuntimeId)
|
||||
} else {
|
||||
setFreshDraftReady(false)
|
||||
clearNotifications()
|
||||
|
|
@ -464,6 +528,7 @@ export function useSessionActions({
|
|||
|
||||
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
|
||||
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
|
||||
dropSessionState(cachedRuntimeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -471,6 +536,16 @@ export function useSessionActions({
|
|||
setFreshDraftReady(false)
|
||||
setActiveSessionId(null)
|
||||
activeSessionIdRef.current = null
|
||||
// A warm-cache hit at entry skipped the cold-path transcript clear, but the
|
||||
// warm path can still bail down to here — an empty-transcript drop, or the
|
||||
// cache getting purged during the profile-swap await — so the PREVIOUS
|
||||
// session's transcript would leak into this cold resume ("switching
|
||||
// sessions shows the same messages"). Clear it so the loader/prefetch
|
||||
// paints fresh; guarded so the normal cold path (already cleared) no-ops.
|
||||
if ($messages.get().length > 0) {
|
||||
setMessages([])
|
||||
}
|
||||
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
setAwaitingResponse(false)
|
||||
|
|
@ -485,12 +560,7 @@ export function useSessionActions({
|
|||
applyStoredSessionPreviewRuntimeInfo(stored)
|
||||
|
||||
if (stored) {
|
||||
setCurrentUsage(current => ({
|
||||
...current,
|
||||
input: stored.input_tokens || 0,
|
||||
output: stored.output_tokens || 0,
|
||||
total: (stored.input_tokens || 0) + (stored.output_tokens || 0)
|
||||
}))
|
||||
applyStoredUsage(stored)
|
||||
}
|
||||
|
||||
let resumedRunning = false
|
||||
|
|
@ -642,6 +712,16 @@ export function useSessionActions({
|
|||
// permanently-dead id. (Booting straight into a no-longer-existent
|
||||
// last-session id is the common trigger.)
|
||||
if ($messages.get().length === 0 && isSessionGoneError(fallbackError)) {
|
||||
// A session created THIS run isn't gone — its backend just flapped
|
||||
// before the turn-less session persisted. Keep the empty view and arm
|
||||
// the bounded retry to rebind, rather than yanking to a fresh draft.
|
||||
// Only a stale id from a PRIOR run drops to a draft.
|
||||
if (createdThisRun.has(storedSessionId)) {
|
||||
setResumeFailedSessionId(storedSessionId)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
startFreshSessionDraft(true)
|
||||
|
||||
return
|
||||
|
|
@ -876,6 +956,18 @@ export function useSessionActions({
|
|||
if (closingRuntimeId) {
|
||||
clearQueuedPrompts(closingRuntimeId)
|
||||
}
|
||||
|
||||
// A tiled copy of this session must not outlive it: collapse the pane
|
||||
// and evict its mirrored runtime state so nothing submits to (or renders)
|
||||
// a deleted session.
|
||||
const tiledRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
closeSessionTile(storedSessionId)
|
||||
|
||||
if (tiledRuntimeId) {
|
||||
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
|
||||
sessionStateByRuntimeIdRef.current.delete(tiledRuntimeId)
|
||||
dropSessionState(tiledRuntimeId)
|
||||
}
|
||||
} catch (err) {
|
||||
if (removed) {
|
||||
setSessions(prev => [removed, ...prev])
|
||||
|
|
@ -892,12 +984,7 @@ export function useSessionActions({
|
|||
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
|
||||
if (stored) {
|
||||
setCurrentUsage(current => ({
|
||||
...current,
|
||||
input: stored.input_tokens || 0,
|
||||
output: stored.output_tokens || 0,
|
||||
total: (stored.input_tokens || 0) + (stored.output_tokens || 0)
|
||||
}))
|
||||
applyStoredUsage(stored)
|
||||
}
|
||||
|
||||
setMessages(previousMessages)
|
||||
|
|
@ -918,8 +1005,10 @@ export function useSessionActions({
|
|||
copy,
|
||||
navigate,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
startFreshSessionDraft
|
||||
]
|
||||
)
|
||||
|
|
@ -956,6 +1045,16 @@ export function useSessionActions({
|
|||
// not appear to do nothing until the next full refresh.
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
|
||||
// An archived session is hidden from the sidebar; its tile must go too.
|
||||
const tiledRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
closeSessionTile(storedSessionId)
|
||||
|
||||
if (tiledRuntimeId) {
|
||||
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
|
||||
sessionStateByRuntimeIdRef.current.delete(tiledRuntimeId)
|
||||
dropSessionState(tiledRuntimeId)
|
||||
}
|
||||
|
||||
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
|
|
@ -968,7 +1067,13 @@ export function useSessionActions({
|
|||
notifyError(err, copy.archiveFailed)
|
||||
}
|
||||
},
|
||||
[copy, selectedStoredSessionId, startFreshSessionDraft]
|
||||
[
|
||||
copy,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
sessionStateByRuntimeIdRef,
|
||||
startFreshSessionDraft
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
@ -977,6 +1082,7 @@ export function useSessionActions({
|
|||
branchStoredSession,
|
||||
closeSettings,
|
||||
createBackendSessionForSend,
|
||||
openNewSessionTile,
|
||||
openSettings,
|
||||
removeSession,
|
||||
resumeSession,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/p
|
|||
import {
|
||||
$currentCwd,
|
||||
$sessions,
|
||||
sessionMatchesStoredId,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentFastMode,
|
||||
|
|
@ -20,6 +21,10 @@ import {
|
|||
setSessions,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
|
||||
// Re-exported for the many session-actions/tile call sites that already import
|
||||
// it from here; the canonical definition lives in @/store/session.
|
||||
export { sessionMatchesStoredId }
|
||||
import { reportBackendContract, reportInstallMethodWarning } from '@/store/updates'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes'
|
||||
|
||||
|
|
@ -183,10 +188,6 @@ export function patchSessionWorkspace(sessionId: string, cwd: string | undefined
|
|||
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
|
||||
}
|
||||
|
||||
export function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean {
|
||||
return session.id === storedSessionId || session._lineage_root_id === storedSessionId
|
||||
}
|
||||
|
||||
export function sessionShouldHaveTranscript(session: SessionInfo | undefined): boolean {
|
||||
return (session?.message_count ?? 0) > 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useRef } from 'react'
|
||||
|
||||
import { getCronJobs, listAllProfileSessions, type SessionInfo } from '@/hermes'
|
||||
import { sameCronSignature } from '@/lib/session-signatures'
|
||||
import {
|
||||
isMessagingSource,
|
||||
LOCAL_SESSION_SOURCE_IDS,
|
||||
|
|
@ -29,8 +30,6 @@ import {
|
|||
setSessionsTotal
|
||||
} from '@/store/session'
|
||||
|
||||
import { sameCronSignature } from '../../desktop-controller-utils'
|
||||
|
||||
// The recents list is local-only: cron rows have their own section, and each
|
||||
// messaging platform (telegram, discord, …) is fetched separately into its own
|
||||
// self-managed sidebar section (refreshMessagingSessions). Excluding both here
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { publishSessionState } from '@/store/session-states'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
|
|
@ -263,6 +264,10 @@ export function useSessionStateCache({
|
|||
const previous = ensureSessionState(sessionId, storedSessionId)
|
||||
const next = updater({ ...previous, messages: previous.messages })
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
// Mirror into the reactive multi-session store — session tiles (and any
|
||||
// other non-primary surface) subscribe per runtime id there instead of
|
||||
// through the single active $messages view.
|
||||
publishSessionState(sessionId, next)
|
||||
|
||||
if (previous.storedSessionId !== next.storedSessionId || !next.busy) {
|
||||
setSessionWorking(previous.storedSessionId, false)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Tip } from '@/components/ui/tooltip'
|
|||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Bell, Download, Globe, Info, KeyRound, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
|
||||
import { Archive, Bell, Download, Globe, Info, KeyRound, Package, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
|
|
@ -22,6 +22,7 @@ import { SECTIONS } from './constants'
|
|||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
|
||||
import { NotificationsSettings } from './notifications-settings'
|
||||
import { PluginsSettings } from './plugins-settings'
|
||||
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
|
||||
|
|
@ -32,6 +33,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
|||
'gateway',
|
||||
'keys',
|
||||
'notifications',
|
||||
'plugins',
|
||||
'sessions',
|
||||
'about'
|
||||
]
|
||||
|
|
@ -186,6 +188,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||
label: t.settings.nav.apiKeys,
|
||||
onSelect: () => setActiveView('keys')
|
||||
},
|
||||
{
|
||||
active: activeView === 'plugins',
|
||||
icon: Package,
|
||||
id: 'plugins',
|
||||
label: t.settings.nav.plugins,
|
||||
onSelect: () => setActiveView('plugins')
|
||||
},
|
||||
{
|
||||
active: activeView === 'sessions',
|
||||
icon: Archive,
|
||||
|
|
@ -259,6 +268,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||
<KeysSettings view={keysView} />
|
||||
) : activeView === 'notifications' ? (
|
||||
<NotificationsSettings />
|
||||
) : activeView === 'plugins' ? (
|
||||
<PluginsSettings />
|
||||
) : (
|
||||
<SessionsSettings />
|
||||
)}
|
||||
|
|
|
|||
124
apps/desktop/src/app/settings/plugins-settings.tsx
Normal file
124
apps/desktop/src/app/settings/plugins-settings.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
|
||||
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
||||
import { getStatus } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Package } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { EmptyState, ListRow, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
const KIND_ORDER: Record<PluginRecord['kind'], number> = { disk: 0, runtime: 1, bundled: 2 }
|
||||
|
||||
function reveal(file: string) {
|
||||
void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined)
|
||||
}
|
||||
|
||||
async function revealPluginsDir() {
|
||||
try {
|
||||
const { hermes_home } = await getStatus()
|
||||
// openDir (not reveal): the door often doesn't exist on first use, and
|
||||
// showItemInFolder on a missing path silently no-ops (esp. Windows).
|
||||
const result = await window.hermesDesktop?.openDir?.(`${hermes_home}/desktop-plugins`)
|
||||
|
||||
if (result && !result.ok) {
|
||||
notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder')
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not resolve the plugins folder')
|
||||
}
|
||||
}
|
||||
|
||||
function PluginRow({ record }: { record: PluginRecord }) {
|
||||
const { t } = useI18n()
|
||||
const p = t.settings.plugins
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{record.file && (
|
||||
<Tip label={p.reveal}>
|
||||
<Button onClick={() => reveal(record.file!)} size="icon" variant="ghost">
|
||||
<Codicon name="folder-opened" size="0.85rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
<Switch
|
||||
aria-label={`${record.status === 'disabled' ? p.enable : p.disable} ${record.name}`}
|
||||
checked={record.status !== 'disabled'}
|
||||
onCheckedChange={on => {
|
||||
triggerHaptic('selection')
|
||||
void setPluginEnabled(record.id, on)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
record.status === 'error' ? (
|
||||
<span className="text-(--ui-danger,#f87171)">{record.error}</span>
|
||||
) : (
|
||||
record.file ?? record.id
|
||||
)
|
||||
}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
{record.name}
|
||||
<Pill>{p.kinds[record.kind]}</Pill>
|
||||
{record.status === 'error' && <Pill tone="primary">{p.failed}</Pill>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginsSettings() {
|
||||
const { t } = useI18n()
|
||||
const p = t.settings.plugins
|
||||
const records = useStore($pluginRecords)
|
||||
|
||||
const rows = Object.values(records).sort(
|
||||
(a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.name.localeCompare(b.name)
|
||||
)
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<SectionHeading icon={Package} meta={p.count(rows.length)} title={p.title} />
|
||||
<p className="mb-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{p.blurb}</p>
|
||||
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Button onClick={() => void revealPluginsDir()} size="sm" variant="outline">
|
||||
<Codicon name="folder-opened" size="0.8rem" />
|
||||
{p.openFolder}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
void discoverRuntimePlugins()
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8rem" />
|
||||
{p.rescan}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState title={p.empty} />
|
||||
) : (
|
||||
<div className="divide-y divide-(--ui-stroke-tertiary)">
|
||||
{rows.map(record => (
|
||||
<PluginRow key={record.id} record={record} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ export type SettingsView =
|
|||
| 'gateway'
|
||||
| 'keys'
|
||||
| 'notifications'
|
||||
| 'plugins'
|
||||
| 'providers'
|
||||
| 'sessions'
|
||||
| `config:${string}`
|
||||
|
|
|
|||
|
|
@ -1,237 +0,0 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { PaneShell } from '@/components/pane-shell'
|
||||
import { FloatingPet } from '@/components/pet/floating-pet'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$panesFlipped,
|
||||
$sidebarOpen,
|
||||
FILE_BROWSER_DEFAULT_WIDTH,
|
||||
FILE_BROWSER_PANE_ID,
|
||||
setSidebarOpen
|
||||
} from '@/store/layout'
|
||||
import { $paneWidthOverride } from '@/store/panes'
|
||||
import { $connection } from '@/store/session'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||
|
||||
import { useWindowControlsOverlayWidth } from './hooks/use-window-controls-overlay-width'
|
||||
import { KeybindPanel } from './keybind-panel'
|
||||
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
|
||||
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
|
||||
import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode
|
||||
leftStatusbarItems?: readonly StatusbarItem[]
|
||||
leftTitlebarTools?: readonly TitlebarTool[]
|
||||
// Fixed-position overlays that must share <main>'s stacking context so pane
|
||||
// resize handles (z-20) paint above them. The persistent terminal lives here:
|
||||
// hoisting it to the root `overlays` layer (sibling of <main>, z above z-3)
|
||||
// would cover every pane's drag handle.
|
||||
mainOverlays?: ReactNode
|
||||
onOpenSettings: () => void
|
||||
overlays?: ReactNode
|
||||
// Rails that sit at the window's left edge in the flipped layout but never
|
||||
// force-collapse to hover-reveal overlays — so they cover the top-left traffic
|
||||
// lights (and zero the titlebar inset) even below the collapse breakpoint.
|
||||
previewPaneOpen?: boolean
|
||||
statusbarItems?: readonly StatusbarItem[]
|
||||
terminalPaneOpen?: boolean
|
||||
titlebarTools?: readonly TitlebarTool[]
|
||||
}
|
||||
|
||||
// Renderer-side fallback so layout snaps even when the main-process fullscreen event
|
||||
// hasn't landed yet (e.g. dev reloads, before the IPC bridge is wired).
|
||||
function subscribeWindowSize(cb: () => void) {
|
||||
window.addEventListener('resize', cb)
|
||||
window.addEventListener('fullscreenchange', cb)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', cb)
|
||||
window.removeEventListener('fullscreenchange', cb)
|
||||
}
|
||||
}
|
||||
|
||||
const viewportIsFullscreen = () =>
|
||||
window.innerWidth >= window.screen.width && window.innerHeight >= window.screen.height
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
leftStatusbarItems,
|
||||
leftTitlebarTools,
|
||||
mainOverlays,
|
||||
onOpenSettings,
|
||||
overlays,
|
||||
previewPaneOpen = false,
|
||||
statusbarItems,
|
||||
terminalPaneOpen = false,
|
||||
titlebarTools
|
||||
}: AppShellProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)
|
||||
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
|
||||
const connection = useStore($connection)
|
||||
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
||||
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
|
||||
// Every secondary window (new-session scratch, subagent watch, cmd-click
|
||||
// pop-out) is a compact side panel — none of them carry the full titlebar
|
||||
// tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag.
|
||||
const hideTitlebarControls = isSecondaryWindow()
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
|
||||
// Width Windows/WSLg reserve for the native min/max/close overlay (zero on
|
||||
// macOS, where window controls sit on the left and are reported via
|
||||
// windowButtonPosition instead). The right tool cluster has to clear them.
|
||||
// Prefer the EXACT width measured from the live Window Controls Overlay
|
||||
// (precise + self-correcting across DPI/host themes); fall back to the static
|
||||
// reservation the main process sends when the WCO API isn't available.
|
||||
const measuredOverlayWidth = useWindowControlsOverlayWidth()
|
||||
const staticOverlayWidth = connection?.nativeOverlayWidth ?? 0
|
||||
const nativeOverlayWidth = measuredOverlayWidth ?? staticOverlayWidth
|
||||
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
|
||||
|
||||
// When the native window controls overlay our titlebar band — Windows and
|
||||
// WSLg both paint Electron's Window Controls Overlay and report
|
||||
// nativeOverlayWidth > 0 — the right rail's editor-style tab strip (which
|
||||
// normally lives IN that band) would render at y=0 under the fixed titlebar
|
||||
// tool cluster and collide with it. Drop the right rail one titlebar-height so
|
||||
// it opens BELOW the band. macOS / plain Linux paint no overlay → 0 inset,
|
||||
// layout byte-for-byte unchanged. Consumed as --right-rail-top-inset.
|
||||
const rightRailTopInset = nativeOverlayWidth > 0 ? 'var(--titlebar-height)' : '0px'
|
||||
|
||||
// The inset clears the top-left titlebar buttons when nothing covers the
|
||||
// window's left edge. Default layout: the sessions sidebar sits there.
|
||||
// Flipped layout: the file browser does instead. Both force-collapse to a
|
||||
// hover-reveal overlay (0px track) below the collapse breakpoint, so the edge
|
||||
// is uncovered there regardless of their stored open state. A standalone
|
||||
// session window renders no sidebar at all, so its edge is always uncovered.
|
||||
const collapsibleLeftPaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
|
||||
// The terminal + preview rails never force-collapse, so when they're the
|
||||
// leftmost open pane (flipped layout) they cover the edge even when narrow.
|
||||
const persistentLeftPaneOpen = panesFlipped && (terminalPaneOpen || previewPaneOpen)
|
||||
|
||||
const leftEdgePaneOpen =
|
||||
!isSecondaryWindow() && ((!narrowViewport && collapsibleLeftPaneOpen) || persistentLeftPaneOpen)
|
||||
|
||||
const titlebarContentInset = leftEdgePaneOpen
|
||||
? 0
|
||||
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
|
||||
|
||||
// The static system cluster (haptics, profiles, settings, right-sidebar) is
|
||||
// hardcoded in TitlebarControls. Pane-supplied tools (preview's group) render
|
||||
// in a separate cluster anchored further left.
|
||||
//
|
||||
// Width math has to include the `gap-x-1` (0.25rem) between buttons:
|
||||
// N buttons + (N - 1) inner gaps, plus one extra 0.25rem of breathing room
|
||||
// between the pane-tool cluster and the system cluster so they don't sit
|
||||
// flush against each other. Modeled as N gaps (N - 1 inner + 1 trailing)
|
||||
// to keep the formula generic for any pane-tool count.
|
||||
const SYSTEM_TOOL_COUNT = 4
|
||||
const paneToolCount = titlebarTools?.filter(tool => !tool.hidden).length ?? 0
|
||||
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))`
|
||||
|
||||
const fileBrowserWidth =
|
||||
fileBrowserWidthOverride !== undefined ? `${fileBrowserWidthOverride}px` : FILE_BROWSER_DEFAULT_WIDTH
|
||||
|
||||
// Where the pane-tool cluster's right edge sits, measured from the inner
|
||||
// titlebar padding (--titlebar-tools-right). Two anchors:
|
||||
// - file-browser closed → flush against static cluster's left edge
|
||||
// - file-browser open → flush against the file-browser pane's left edge
|
||||
// (= preview pane's right edge)
|
||||
const previewToolbarGap = fileBrowserOpen ? fileBrowserWidth : systemToolsWidth
|
||||
|
||||
// Used by the drag region to know where the rightmost interactive element
|
||||
// ends. When pane tools are present, that's `gap + paneCount * controlSize
|
||||
// + paneCount * 0.25rem` (the leftmost button is at `tools-right + gap +
|
||||
// paneCount * (size + gap-x-1)`). Otherwise the static cluster's footprint
|
||||
// is enough.
|
||||
const titlebarToolsWidth =
|
||||
paneToolCount > 0
|
||||
? `calc(${previewToolbarGap} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))`
|
||||
: systemToolsWidth
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
className="h-screen min-h-0 flex-col bg-background"
|
||||
onOpenChange={setSidebarOpen}
|
||||
open={sidebarOpen}
|
||||
style={
|
||||
{
|
||||
// Alias for shadcn <Sidebar> descendants. Resolves to the chat-sidebar
|
||||
// pane track via PaneShell's emitted --pane-chat-sidebar-width.
|
||||
'--sidebar-width': 'var(--pane-chat-sidebar-width)',
|
||||
'--titlebar-height': `${TITLEBAR_HEIGHT}px`,
|
||||
'--titlebar-content-inset': `${titlebarContentInset}px`,
|
||||
'--titlebar-controls-left': `${titlebarControls.left}px`,
|
||||
'--titlebar-controls-top': `${titlebarControls.top}px`,
|
||||
'--titlebar-tools-right': titlebarToolsRight,
|
||||
'--titlebar-tools-width': titlebarToolsWidth,
|
||||
// Drops the right rail below the titlebar band when the OS/host paints
|
||||
// window controls over it (Windows/WSLg); 0px elsewhere.
|
||||
'--right-rail-top-inset': rightRailTopInset,
|
||||
// Anchor for the pane-tool cluster's right edge in TitlebarControls.
|
||||
// Sourced from the layout store rather than the PaneShell-emitted
|
||||
// --pane-*-width vars because the titlebar is a sibling of PaneShell
|
||||
// and CSS variables resolve at the consumer's scope.
|
||||
'--shell-preview-toolbar-gap': previewToolbarGap
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{!hideTitlebarControls && (
|
||||
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
||||
)}
|
||||
|
||||
{nativeOverlayWidth > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed right-0 top-0 z-[4] h-(--titlebar-height) w-(--titlebar-tools-right) border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background)"
|
||||
/>
|
||||
)}
|
||||
|
||||
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
|
||||
<PaneShell className="min-h-0 flex-1">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
|
||||
/>
|
||||
|
||||
{children}
|
||||
</PaneShell>
|
||||
|
||||
{/* Fixed overlays scoped to main's stacking context (terminal). Rendered
|
||||
after PaneShell so it paints over pane content, but its z stays under
|
||||
the panes' z-20 resize handles, keeping every pane resizable. */}
|
||||
{mainOverlays}
|
||||
|
||||
{/* The compact pop-out drops the statusbar — it's a scratch window, not
|
||||
the full shell. */}
|
||||
{!isSecondaryWindow() && <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />}
|
||||
</main>
|
||||
|
||||
{overlays}
|
||||
|
||||
{/* Keybind map dialog (titlebar ⌨ button / ⌘/). */}
|
||||
<KeybindPanel />
|
||||
|
||||
{/* Mounted at the shell root (after overlays) so success/error toasts
|
||||
surface above every route and overlay — not just the chat view. */}
|
||||
<NotificationStack />
|
||||
|
||||
{/* Petdex floating mascot — in-window, always-on-top, reactive to agent
|
||||
activity. Renders nothing unless a pet is installed + enabled. */}
|
||||
<FloatingPet />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -36,21 +36,27 @@ function useGatewayLogTail(): string[] {
|
|||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = () =>
|
||||
getLogs({ file: 'gui', lines: LOG_TAIL })
|
||||
.then(res => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
// async: getLogs THROWS (not rejects) when the desktop bridge is missing
|
||||
// (plain-browser mode) — a sync throw here would take down the root
|
||||
// error boundary before the .catch even attaches.
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await getLogs({ file: 'gui', lines: LOG_TAIL })
|
||||
|
||||
setLines(
|
||||
res.lines
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !LOG_NOISE_RE.test(line))
|
||||
.slice(-LOG_VISIBLE)
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setLines(
|
||||
res.lines
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !LOG_NOISE_RE.test(line))
|
||||
.slice(-LOG_VISIBLE)
|
||||
)
|
||||
} catch {
|
||||
// Bridge/gateway unavailable — keep the last tail.
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
const timer = window.setInterval(load, LOG_POLL_MS)
|
||||
|
|
|
|||
6
apps/desktop/src/app/shell/group-setter.ts
Normal file
6
apps/desktop/src/app/shell/group-setter.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// The `GroupSetter` shape pages take as an extension-point prop (SkillsView,
|
||||
// MessagingView, ChatPreviewRail, …). The live implementation is the
|
||||
// registry-backed `registryGroupSetter` in app/contrib/panes.tsx.
|
||||
type Side = 'left' | 'right'
|
||||
|
||||
export type GroupSetter<T> = (id: string, items: readonly T[], side?: Side) => void
|
||||
|
|
@ -22,9 +22,13 @@ import {
|
|||
$connection,
|
||||
$currentCwd,
|
||||
$currentUsage,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$sessionStartedAt,
|
||||
$turnStartedAt,
|
||||
sessionMatchesStoredId
|
||||
} from '@/store/session'
|
||||
import { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } from '@/store/session-states'
|
||||
import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents'
|
||||
import { $gatewayRestarting } from '@/store/system-actions'
|
||||
import {
|
||||
|
|
@ -40,6 +44,8 @@ import type { StatusResponse } from '@/types/hermes'
|
|||
import { CRON_ROUTE } from '../../routes'
|
||||
import type { StatusbarItem } from '../statusbar-controls'
|
||||
|
||||
const EMPTY_USAGE = { calls: 0, input: 0, output: 0, total: 0 } as const
|
||||
|
||||
function workspaceLabel(cwd: string): string {
|
||||
const normalized = cwd.replace(/[\\/]+$/, '')
|
||||
const leaf = normalized.split(/[\\/]/).filter(Boolean).pop()
|
||||
|
|
@ -80,15 +86,15 @@ export function useStatusbarItems({
|
|||
const { t } = useI18n()
|
||||
const copy = t.shell.statusbar
|
||||
const fileMenu = t.fileMenu
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const primaryActiveSessionId = useStore($activeSessionId)
|
||||
const activeGatewayProfile = useStore($activeGatewayProfile)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const busy = useStore($busy)
|
||||
const primaryBusy = useStore($busy)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const currentUsage = useStore($currentUsage)
|
||||
const primaryUsage = useStore($currentUsage)
|
||||
const gatewayRestarting = useStore($gatewayRestarting)
|
||||
const sessionStartedAt = useStore($sessionStartedAt)
|
||||
const turnStartedAt = useStore($turnStartedAt)
|
||||
const primarySessionStartedAt = useStore($sessionStartedAt)
|
||||
const primaryTurnStartedAt = useStore($turnStartedAt)
|
||||
const subagentsBySession = useStore($subagentsBySession)
|
||||
const updateStatus = useStore($updateStatus)
|
||||
const updateApply = useStore($updateApply)
|
||||
|
|
@ -97,11 +103,42 @@ export function useStatusbarItems({
|
|||
const desktopVersion = useStore($desktopVersion)
|
||||
const connection = useStore($connection)
|
||||
|
||||
// The FOCUSED session (interacted tile, else the primary — the same
|
||||
// derivation the titlebar title follows): every session-scoped readout
|
||||
// below (context count, timers, busy pulse) tracks it, so clicking into a
|
||||
// tile makes the statusbar describe THAT session.
|
||||
const focusedStoredSessionId = useStore($focusedStoredSessionId)
|
||||
const focusedRuntimeId = useStore($focusedRuntimeId)
|
||||
const focusedState = useStore($focusedSessionState)
|
||||
const sessions = useStore($sessions)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const primaryFocused = !focusedStoredSessionId || focusedStoredSessionId === selectedStoredSessionId
|
||||
|
||||
const activeSessionId = primaryFocused ? primaryActiveSessionId : (focusedRuntimeId ?? null)
|
||||
const busy = primaryFocused ? primaryBusy : Boolean(focusedState?.busy)
|
||||
|
||||
// EMPTY_USAGE (module constant) keeps the fallback referentially stable —
|
||||
// a fresh `{...}` each render would bust the usage-label memos below.
|
||||
const currentUsage = primaryFocused ? primaryUsage : (focusedState?.usage ?? EMPTY_USAGE)
|
||||
|
||||
const turnStartedAt = primaryFocused ? primaryTurnStartedAt : (focusedState?.turnStartedAt ?? null)
|
||||
|
||||
// A tile's session-start comes from its stored row (the cache only knows
|
||||
// runtime state); seconds → ms.
|
||||
const focusedRow = focusedStoredSessionId
|
||||
? sessions.find(s => sessionMatchesStoredId(s, focusedStoredSessionId))
|
||||
: null
|
||||
|
||||
const sessionStartedAt = primaryFocused
|
||||
? primarySessionStartedAt
|
||||
: focusedRow?.started_at
|
||||
? focusedRow.started_at * 1000
|
||||
: null
|
||||
|
||||
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
|
||||
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
|
||||
const approvalModeItem = useApprovalModeStatusbarItem(activeGatewayProfile, requestGateway)
|
||||
|
||||
|
||||
const gatewayMenuContent = useMemo(
|
||||
() => (close: () => void) => (
|
||||
<GatewayMenuPanel
|
||||
|
|
|
|||
|
|
@ -6,14 +6,16 @@ import { Button } from '@/components/ui/button'
|
|||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Kbd, KbdCombo } from '@/components/ui/kbd'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
KEYBIND_ACTIONS,
|
||||
allKeybindActions,
|
||||
KEYBIND_CATEGORIES,
|
||||
KEYBIND_PANEL_ACTION,
|
||||
KEYBIND_READONLY,
|
||||
type KeybindActionMeta,
|
||||
type KeybindReadonly
|
||||
type KeybindReadonly,
|
||||
KEYBINDS_AREA
|
||||
} from '@/lib/keybinds/actions'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { arraysEqual } from '@/lib/storage'
|
||||
|
|
@ -22,6 +24,7 @@ import {
|
|||
$capture,
|
||||
$keybindPanelOpen,
|
||||
beginCapture,
|
||||
bindingsFor,
|
||||
closeKeybindPanel,
|
||||
conflictsFor,
|
||||
endCapture,
|
||||
|
|
@ -36,6 +39,9 @@ export function KeybindPanel() {
|
|||
const bindings = useStore($bindings)
|
||||
const k = t.keybinds
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
|
||||
// Subscribe so contributed actions appear/disappear live in the map.
|
||||
useContributions(KEYBINDS_AREA)
|
||||
const actionList = allKeybindActions()
|
||||
|
||||
const openCombo = bindings[KEYBIND_PANEL_ACTION]?.[0]
|
||||
|
||||
|
|
@ -74,7 +80,7 @@ export function KeybindPanel() {
|
|||
{/* Body */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5">
|
||||
{KEYBIND_CATEGORIES.map(category => {
|
||||
const actions = KEYBIND_ACTIONS.filter(
|
||||
const actions = actionList.filter(
|
||||
action => action.category === category && action.id !== KEYBIND_PANEL_ACTION
|
||||
)
|
||||
|
||||
|
|
@ -139,9 +145,12 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) {
|
|||
const bindings = useStore($bindings)
|
||||
const capture = useStore($capture)
|
||||
|
||||
const combos = bindings[action.id] ?? []
|
||||
// bindingsFor resolves stored overrides for late-registered (contributed)
|
||||
// actions too — $bindings only carries built-ins, so a raw lookup would show
|
||||
// the default instead of the user's rebinding for a plugin/contrib action.
|
||||
const combos = bindingsFor(action.id, bindings)
|
||||
const capturing = capture === action.id
|
||||
const label = k.actions[action.id] ?? action.id
|
||||
const label = k.actions[action.id] ?? action.label ?? action.id
|
||||
const isDefault = arraysEqual(combos, [...action.defaults])
|
||||
|
||||
const conflict = combos
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ export interface StatusbarMenuItem {
|
|||
|
||||
export interface StatusbarItem {
|
||||
id: string
|
||||
/** Escape hatch: render an arbitrary node into the bar (own state, tooltip,
|
||||
* events). When set, it OWNS the slot — label/variant/onSelect are ignored.
|
||||
* This is how a plugin drops a full stateful React component into the bar. */
|
||||
render?: () => ReactNode
|
||||
label?: ReactNode
|
||||
detail?: ReactNode
|
||||
icon?: ReactNode
|
||||
|
|
@ -92,6 +96,11 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
|
|||
function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType<typeof useNavigate> }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
// Render escape hatch: the contribution owns its own chrome/state/tooltip.
|
||||
if (item.render) {
|
||||
return <>{item.render()}</>
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{item.icon}
|
||||
|
|
@ -100,7 +109,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
|||
</>
|
||||
)
|
||||
|
||||
if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) {
|
||||
if (item.variant === 'menu' && (item.menuContent || !!item.menuItems?.length)) {
|
||||
// The `Tip` helper can't wrap a menu: its TooltipTrigger needs a DOM child,
|
||||
// but DropdownMenu's Root renders no element, so the hover listeners never
|
||||
// land on the button and the tooltip silently never shows. Compose the two
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { type ComponentProps, type MouseEvent, type ReactNode, useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
|
||||
import { resetLayoutTree } from '@/components/pane-shell/tree/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
|
|
@ -9,10 +11,8 @@ import { useI18n } from '@/i18n'
|
|||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
import { toggleKeybindPanel } from '@/store/keybinds'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$panesFlipped,
|
||||
$sidebarOpen,
|
||||
toggleFileBrowserOpen,
|
||||
togglePanesFlipped,
|
||||
|
|
@ -32,7 +32,7 @@ export interface TitlebarTool {
|
|||
hidden?: boolean
|
||||
href?: string
|
||||
icon: ReactNode
|
||||
onSelect?: () => void
|
||||
onSelect?: (event?: MouseEvent) => void
|
||||
title?: string
|
||||
to?: string
|
||||
}
|
||||
|
|
@ -46,14 +46,60 @@ interface TitlebarControlsProps extends ComponentProps<'div'> {
|
|||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The layout button's glyph. Morphs into its composite reset form — the
|
||||
* layout icon wearing a small counter-clockwise arrow badge ("layout, back
|
||||
* to how it was") — ONLY while the pointer is on the button AND ⌘/Ctrl is
|
||||
* held: hover gates via CSS (`group/tool` on the button), the modifier via
|
||||
* the window listener. Pressing the modifier elsewhere changes nothing.
|
||||
*/
|
||||
function LayoutGlyph({ modHeld }: { modHeld: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<span className={cn('inline-flex', modHeld && 'group-hover/tool:hidden')}>
|
||||
<Codicon name="layout" />
|
||||
</span>
|
||||
<span className={cn('relative hidden', modHeld && 'group-hover/tool:inline-flex')}>
|
||||
<Codicon name="layout" />
|
||||
<span className="absolute -bottom-1 -right-1.5 grid place-items-center rounded-full bg-(--ui-bg-chrome) p-px">
|
||||
<Codicon className="-scale-x-100" name="refresh" size="0.5625rem" />
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Live ⌘/Ctrl tracking — mod-click affordances telegraph themselves (the
|
||||
* layout button morphs into its reset form while the modifier is down). */
|
||||
function useModifierHeld(): boolean {
|
||||
const [held, setHeld] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const sync = (event: KeyboardEvent) => setHeld(event.metaKey || event.ctrlKey)
|
||||
const clear = () => setHeld(false)
|
||||
|
||||
window.addEventListener('keydown', sync)
|
||||
window.addEventListener('keyup', sync)
|
||||
window.addEventListener('blur', clear)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', sync)
|
||||
window.removeEventListener('keyup', sync)
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return held
|
||||
}
|
||||
|
||||
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const modHeld = useModifierHeld()
|
||||
const hapticsMuted = useStore($hapticsMuted)
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
|
||||
const toggleHaptics = () => {
|
||||
if (!hapticsMuted) {
|
||||
|
|
@ -67,14 +113,13 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
}
|
||||
}
|
||||
|
||||
// Each titlebar button controls the pane physically on its side, so a flip
|
||||
// swaps which pane each one toggles. Default: sessions left, file browser
|
||||
// right. Flipped: file browser left, sessions right. Sidebar toggles never
|
||||
// carry an active highlight — they're plain show/hide affordances.
|
||||
const fileBrowserEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
|
||||
const sessionsEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
|
||||
const leftEdge = panesFlipped ? fileBrowserEdge : sessionsEdge
|
||||
const rightEdge = panesFlipped ? sessionsEdge : fileBrowserEdge
|
||||
// POSITIONAL toggles: each button shows/hides everything on its physical
|
||||
// side of the main zone (the layout tree collapses the whole side), so they
|
||||
// stay correct through flips and rearranges. $sidebarOpen ≙ left side,
|
||||
// $fileBrowserOpen ≙ right side. Never an active highlight — plain
|
||||
// show/hide affordances.
|
||||
const leftEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
|
||||
const rightEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
|
||||
|
||||
const leftToolbarTools: TitlebarTool[] = [
|
||||
{
|
||||
|
|
@ -111,6 +156,26 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
|
||||
// Static system tools — always pinned to the screen's right edge.
|
||||
const systemTools: TitlebarTool[] = [
|
||||
{
|
||||
className: 'group/tool',
|
||||
// Hover + held ⌘/Ctrl morphs the glyph into its reset form (see
|
||||
// LayoutGlyph) — the mod-click telegraphs itself before it happens.
|
||||
icon: <LayoutGlyph modHeld={modHeld} />,
|
||||
id: 'layout',
|
||||
label: t.titlebar.layoutEditor,
|
||||
onSelect: event => {
|
||||
if (event?.metaKey || event?.ctrlKey) {
|
||||
triggerHaptic('warning')
|
||||
resetLayoutTree()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('open')
|
||||
toggleLayoutEditMode()
|
||||
},
|
||||
title: t.titlebar.layoutEditorTitle
|
||||
},
|
||||
{
|
||||
active: hapticsMuted,
|
||||
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
|
||||
|
|
@ -118,15 +183,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
|
||||
onSelect: toggleHaptics
|
||||
},
|
||||
{
|
||||
icon: <Codicon name="keyboard" />,
|
||||
id: 'keybinds',
|
||||
label: t.titlebar.openKeybinds,
|
||||
onSelect: () => {
|
||||
triggerHaptic('open')
|
||||
toggleKeybindPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: <Codicon name="settings-gear" />,
|
||||
id: 'settings',
|
||||
|
|
@ -147,8 +203,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
}
|
||||
|
||||
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
|
||||
const settingsTool = visibleSystemTools.find(tool => tool.id === 'settings')
|
||||
const visibleSystemToolsBeforeSettings = visibleSystemTools.filter(tool => tool.id !== 'settings')
|
||||
const visiblePaneTools = tools.filter(tool => !tool.hidden)
|
||||
|
||||
return (
|
||||
|
|
@ -187,10 +241,9 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
aria-label={t.shell.appControls}
|
||||
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{visibleSystemToolsBeforeSettings.map(tool => (
|
||||
{visibleSystemTools.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
))}
|
||||
{settingsTool && <TitlebarToolButton navigate={navigate} tool={settingsTool} />}
|
||||
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -228,12 +281,12 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
|
|||
aria-pressed={tool.active ?? undefined}
|
||||
className={className}
|
||||
disabled={tool.disabled}
|
||||
onClick={() => {
|
||||
onClick={event => {
|
||||
if (tool.to) {
|
||||
navigate(tool.to)
|
||||
}
|
||||
|
||||
tool.onSelect?.()
|
||||
tool.onSelect?.(event)
|
||||
}}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
size="icon-titlebar"
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
type Side = 'left' | 'right'
|
||||
type Groups<T> = Record<Side, Record<string, readonly T[]>>
|
||||
|
||||
export type GroupSetter<T> = (id: string, items: readonly T[], side?: Side) => void
|
||||
|
||||
interface GroupRegistry<T> {
|
||||
flat: { left: T[]; right: T[] }
|
||||
set: GroupSetter<T>
|
||||
}
|
||||
|
||||
export function useGroupRegistry<T>(): GroupRegistry<T> {
|
||||
const [groups, setGroups] = useState<Groups<T>>({ left: {}, right: {} })
|
||||
|
||||
const set = useCallback<GroupSetter<T>>((id, items, side = 'right') => {
|
||||
setGroups(current => {
|
||||
const next = { ...current, [side]: { ...current[side] } }
|
||||
|
||||
if (items.length === 0) {
|
||||
delete next[side][id]
|
||||
} else {
|
||||
next[side][id] = items
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const flat = useMemo(
|
||||
() => ({
|
||||
left: Object.values(groups.left).flat(),
|
||||
right: Object.values(groups.right).flat()
|
||||
}),
|
||||
[groups]
|
||||
)
|
||||
|
||||
return { flat, set }
|
||||
}
|
||||
|
|
@ -461,11 +461,36 @@ export function McpTab({ gateway }: { gateway: HermesGateway | null }) {
|
|||
const draftSeeded = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (config && !draftSeeded.current) {
|
||||
// profilePending: config still holds the PREVIOUS profile's record right
|
||||
// after a switch — seeding from it would latch the wrong profile's doc.
|
||||
if (!config || profilePending) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!draftSeeded.current) {
|
||||
draftSeeded.current = true
|
||||
resetDraft(getServers(config))
|
||||
|
||||
return
|
||||
}
|
||||
}, [config])
|
||||
|
||||
if (dirty || names.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Heal the early-boot race: the first config snapshot can land before the
|
||||
// backend has mcp_servers assembled, seeding (and latching) an empty doc
|
||||
// while later refetches fill the list — saving would then wipe the real
|
||||
// servers. A PRISTINE empty draft reseeds when servers arrive; any user
|
||||
// edit (dirty) still always wins.
|
||||
try {
|
||||
if (Object.keys(parseServersDoc(draft)).length === 0) {
|
||||
resetDraft(servers)
|
||||
}
|
||||
} catch {
|
||||
// Mid-edit / invalid JSON — the user's text wins.
|
||||
}
|
||||
}, [config, dirty, draft, names, profilePending, servers])
|
||||
|
||||
// Bumped on every profile switch. Async probe/auth completions capture the
|
||||
// epoch at call time and bail if it changed, so a slow profile-A request can't
|
||||
|
|
@ -698,15 +723,17 @@ export function McpTab({ gateway }: { gateway: HermesGateway | null }) {
|
|||
return
|
||||
}
|
||||
|
||||
const next = withEnabled(servers[serverName], enabled)
|
||||
|
||||
try {
|
||||
if (!(await persist({ ...servers, [serverName]: withEnabled(servers[serverName], enabled) }))) {
|
||||
if (!(await persist({ ...servers, [serverName]: next }))) {
|
||||
return
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
patchDraft(doc => (doc[serverName] ? { ...doc, [serverName]: withEnabled(doc[serverName], enabled) } : doc))
|
||||
} else {
|
||||
resetDraft({ ...servers, [serverName]: withEnabled(servers[serverName], enabled) })
|
||||
resetDraft({ ...servers, [serverName]: next })
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type * as React from 'react'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import type { UsageStats } from '@/types/hermes'
|
||||
|
||||
export interface ContextSuggestion {
|
||||
text: string
|
||||
|
|
@ -125,7 +126,8 @@ export type CommandDispatchResponse =
|
|||
export type SidebarNavId = 'artifacts' | 'command-center' | 'messaging' | 'new-session' | 'settings' | 'skills'
|
||||
|
||||
export interface SidebarNavItem {
|
||||
id: SidebarNavId
|
||||
/** Built-in view id, or a contributed row's namespaced contribution id. */
|
||||
id: SidebarNavId | (string & {})
|
||||
label: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
route?: string
|
||||
|
|
@ -158,4 +160,8 @@ export interface ClientSessionState {
|
|||
* focused, and switching sessions doesn't zero a still-running turn's clock.
|
||||
* The global $turnStartedAt mirrors whichever session is currently viewed. */
|
||||
turnStartedAt: number | null
|
||||
/** Cumulative token usage, updated per completed turn. Per-session twin of
|
||||
* the primary-only $currentUsage — the statusbar reads it for a focused
|
||||
* tile's context count. Null until the first turn reports. */
|
||||
usage: null | UsageStats
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
useState
|
||||
} from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool/fallback'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
|
|
@ -21,7 +22,7 @@ import { useI18n } from '@/i18n'
|
|||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
|
||||
import { clearClarifyRequest, sessionClarifyRequest } from '@/store/clarify'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
|
|
@ -184,7 +185,11 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {
|
|||
function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.clarify
|
||||
const request = useStore($clarifyRequest)
|
||||
// The tool row is in whichever session's transcript rendered it — read THAT
|
||||
// session's clarify (primary or tile), not the globally-active one.
|
||||
const sessionId = useStore(useSessionView().$runtimeId)
|
||||
const $request = useMemo(() => sessionClarifyRequest(sessionId), [sessionId])
|
||||
const request = useStore($request)
|
||||
const gateway = useStore($gateway)
|
||||
const fromArgs = useMemo(() => readClarifyArgs(args), [args])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
'use client'
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FC, useCallback, useEffect, useState } from 'react'
|
||||
import { type FC, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -20,11 +21,11 @@ import { cn } from '@/lib/utils'
|
|||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$approvalInlineVisible,
|
||||
$approvalRequest,
|
||||
type ApprovalRequest,
|
||||
clearApprovalRequest,
|
||||
registerApprovalInlineAnchor
|
||||
registerApprovalInlineAnchor,
|
||||
sessionApprovalInlineVisible,
|
||||
sessionApprovalRequest
|
||||
} from '@/store/prompts'
|
||||
|
||||
import type { ToolPart } from './fallback-model'
|
||||
|
|
@ -48,7 +49,11 @@ export const APPROVAL_TOOLS = new Set(['terminal', 'execute_code'])
|
|||
type ApprovalChoice = 'once' | 'session' | 'always' | 'deny'
|
||||
|
||||
export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
|
||||
const request = useStore($approvalRequest)
|
||||
// The tool row lives in whichever session's transcript rendered it — read
|
||||
// THAT session's approval (works for the primary and every tile).
|
||||
const sessionId = useStore(useSessionView().$runtimeId)
|
||||
const $request = useMemo(() => sessionApprovalRequest(sessionId), [sessionId])
|
||||
const request = useStore($request)
|
||||
|
||||
if (!request || !APPROVAL_TOOLS.has(part.toolName)) {
|
||||
return null
|
||||
|
|
@ -58,15 +63,18 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
|
|||
}
|
||||
|
||||
const InlineApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
useEffect(() => registerApprovalInlineAnchor(), [])
|
||||
useEffect(() => registerApprovalInlineAnchor(request.sessionId), [request.sessionId])
|
||||
|
||||
return <ApprovalBar request={request} surface="inline" />
|
||||
}
|
||||
|
||||
export const PendingApprovalFallback: FC = () => {
|
||||
const { t } = useI18n()
|
||||
const request = useStore($approvalRequest)
|
||||
const inlineVisible = useStore($approvalInlineVisible)
|
||||
const sessionId = useStore(useSessionView().$runtimeId)
|
||||
const $request = useMemo(() => sessionApprovalRequest(sessionId), [sessionId])
|
||||
const $inlineVisible = useMemo(() => sessionApprovalInlineVisible(sessionId), [sessionId])
|
||||
const request = useStore($request)
|
||||
const inlineVisible = useStore($inlineVisible)
|
||||
|
||||
if (!request || inlineVisible) {
|
||||
return null
|
||||
|
|
@ -119,8 +127,9 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline'
|
|||
const respond = useCallback(
|
||||
async (choice: ApprovalChoice) => {
|
||||
// Another bar (or the keyboard path) may have already resolved this
|
||||
// approval; the atom is the single source of truth, so bail if it's gone.
|
||||
if (busy || !$approvalRequest.get()) {
|
||||
// approval; the map is the single source of truth, so bail if this
|
||||
// session's request is gone.
|
||||
if (busy || !sessionApprovalRequest(request.sessionId).get()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,15 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { DecodeText } from '@/components/ui/decode-text'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { $gatewaySwitching } from '@/store/gateway-switch'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at
|
||||
// the render level means no timer logic (even a stale HMR one) can ever
|
||||
// scramble "CONN".
|
||||
const PREFIX = 'CONN'
|
||||
const TAIL = 'ECTING'
|
||||
// Even-weight mono ascii so cycling glyphs don't jump width (matches the
|
||||
// nousnet-web download-button decode effect).
|
||||
const SCRAMBLE_CHARS = '/\\|-_=+<>~:*'
|
||||
const TICK_MS = 45
|
||||
// Decode mechanics live in the shared <DecodeText> primitive
|
||||
// (components/ui/decode-text.tsx). "CONN" stays legible via prefix={4}.
|
||||
const TEXT = 'CONNECTING'
|
||||
|
||||
// Exit choreography (ms): text fades down + out, hold, then the overlay fades.
|
||||
const TEXT_OUT_MS = 360
|
||||
|
|
@ -40,18 +35,11 @@ function forcedPreview(): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function scrambledTail(resolvedCount: number): string {
|
||||
return Array.from(TAIL, (ch, i) =>
|
||||
i < resolvedCount ? ch : SCRAMBLE_CHARS[(Math.random() * SCRAMBLE_CHARS.length) | 0]
|
||||
).join('')
|
||||
}
|
||||
|
||||
export function GatewayConnectingOverlay() {
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const boot = useStore($desktopBoot)
|
||||
const gatewaySwitching = useStore($gatewaySwitching)
|
||||
const [previewing] = useState(forcedPreview)
|
||||
const [tail, setTail] = useState(TAIL)
|
||||
const [phase, setPhase] = useState<Phase>('live')
|
||||
// Once cold boot has completed once, never resurrect the fullscreen overlay
|
||||
// — soft gateway switches keep the shell and reskeleton the sidebar instead.
|
||||
|
|
@ -67,12 +55,14 @@ export function GatewayConnectingOverlay() {
|
|||
// the chat then — users should still be able to type drafts, open settings,
|
||||
// and recover instead of staring at a modal CONNECTING screen.
|
||||
const initialBootActive = boot.visible || boot.running || boot.progress < 100
|
||||
|
||||
const connecting =
|
||||
!coldBootDoneRef.current &&
|
||||
!gatewaySwitching &&
|
||||
gatewayState !== 'open' &&
|
||||
!boot.error &&
|
||||
initialBootActive
|
||||
|
||||
// Latches once we've actually shown the overlay, so the brief frame where
|
||||
// gatewayState flips to "open" (connecting -> false) before the exit phase
|
||||
// kicks in doesn't unmount us and cause a flash.
|
||||
|
|
@ -82,36 +72,6 @@ export function GatewayConnectingOverlay() {
|
|||
shownRef.current = true
|
||||
}
|
||||
|
||||
// Decode loop — only while live (freeze the resolved word during the exit).
|
||||
useEffect(() => {
|
||||
if (phase !== 'live' || (!previewing && !connecting)) {
|
||||
return
|
||||
}
|
||||
|
||||
let resolved = 0
|
||||
let hold = 0
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
if (resolved >= TAIL.length) {
|
||||
hold += 1
|
||||
|
||||
if (hold > 16) {
|
||||
resolved = 0
|
||||
hold = 0
|
||||
}
|
||||
|
||||
setTail(TAIL)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resolved += 0.5
|
||||
setTail(scrambledTail(Math.floor(resolved)))
|
||||
}, TICK_MS)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [phase, previewing, connecting])
|
||||
|
||||
// Kick off the exit when connected: real connect, or a faked timer in preview.
|
||||
useEffect(() => {
|
||||
if (phase !== 'live') {
|
||||
|
|
@ -119,16 +79,12 @@ export function GatewayConnectingOverlay() {
|
|||
}
|
||||
|
||||
if (previewing) {
|
||||
const id = window.setTimeout(() => {
|
||||
setTail(TAIL)
|
||||
setPhase('text-out')
|
||||
}, PREVIEW_CONNECT_MS)
|
||||
const id = window.setTimeout(() => setPhase('text-out'), PREVIEW_CONNECT_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
|
||||
if (gatewayState === 'open' && shownRef.current) {
|
||||
setTail(TAIL)
|
||||
setPhase('text-out')
|
||||
}
|
||||
}, [phase, previewing, gatewayState])
|
||||
|
|
@ -149,10 +105,7 @@ export function GatewayConnectingOverlay() {
|
|||
|
||||
// Preview replays so we can keep watching the transition.
|
||||
if (phase === 'gone' && previewing) {
|
||||
const id = window.setTimeout(() => {
|
||||
setTail(TAIL)
|
||||
setPhase('live')
|
||||
}, PREVIEW_REPLAY_MS)
|
||||
const id = window.setTimeout(() => setPhase('live'), PREVIEW_REPLAY_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
|
|
@ -183,21 +136,16 @@ export function GatewayConnectingOverlay() {
|
|||
overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<style>{'@keyframes gco-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>
|
||||
<span
|
||||
<DecodeText
|
||||
active={phase === 'live' && (previewing || connecting)}
|
||||
className={cn(
|
||||
'inline-flex items-center pl-[0.4em] font-mono text-[0.64rem] font-semibold uppercase tracking-[0.4em] tabular-nums text-(--theme-primary) transition duration-300 ease-out',
|
||||
'pl-[0.4em] text-(--theme-primary) transition duration-300 ease-out',
|
||||
leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'
|
||||
)}
|
||||
>
|
||||
{PREFIX}
|
||||
{tail}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="dither ml-0.5 inline-block size-2 shrink-0 -translate-y-px rounded-[1px]"
|
||||
style={{ animation: 'gco-cursor 1s step-end infinite' }}
|
||||
/>
|
||||
</span>
|
||||
cursor
|
||||
prefix={4}
|
||||
text={TEXT}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -410,10 +410,12 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
|||
// Which key-form option to preselect when we flip to 'apikey' mode. The
|
||||
// OpenRouter row selects its key; the generic link lands on the first option.
|
||||
const [apiKeyInitialEnv, setApiKeyInitialEnv] = useState<string | undefined>(undefined)
|
||||
|
||||
const openKeyForm = (envKey?: string) => {
|
||||
setApiKeyInitialEnv(envKey)
|
||||
setOnboardingMode('apikey')
|
||||
}
|
||||
|
||||
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
|
||||
const hasOauth = ordered.length > 0
|
||||
const apiKeyOptions = useApiKeyCatalog()
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
import { createContext } from 'react'
|
||||
|
||||
export interface PaneSlot {
|
||||
side: 'left' | 'right'
|
||||
open: boolean
|
||||
/** Resolved CSS `grid-column` value (e.g. "3 / 4", or a full-side span for a bottom-row pane). */
|
||||
gridColumn: string
|
||||
/** Resolved CSS `grid-row` value ("1 / -1" full-height, "1 / 2" above a bottom row, "2 / 3" the row itself). */
|
||||
gridRow: string
|
||||
/** True when this pane lays out as a horizontal row beneath its rail instead of a vertical column. */
|
||||
bottomRow: boolean
|
||||
}
|
||||
|
||||
export interface PaneShellContextValue {
|
||||
paneById: Map<string, PaneSlot>
|
||||
mainColumn: number
|
||||
}
|
||||
|
||||
export const PaneShellContext = createContext<PaneShellContextValue | null>(null)
|
||||
59
apps/desktop/src/components/pane-shell/edit-mode.tsx
Normal file
59
apps/desktop/src/components/pane-shell/edit-mode.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Layout edit mode — the shared toggle for the tree renderer's FancyZones-style
|
||||
* rearrangement (see tree/renderer.tsx). The toggle hotkey is a `keybinds`
|
||||
* contribution (`layout.editMode`, default ⌘⇧\ — the sibling of ⌘\ = flip
|
||||
* panes), so it's rebindable and collision-checked like every other action.
|
||||
* This hook only owns Escape-to-exit.
|
||||
*/
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers'
|
||||
|
||||
export const $layoutEditMode = atom(false)
|
||||
|
||||
export function toggleLayoutEditMode() {
|
||||
$layoutEditMode.set(!$layoutEditMode.get())
|
||||
}
|
||||
|
||||
/** Escape exits edit mode. Registered once by the layout root. */
|
||||
export function useLayoutEditHotkey(enabled: boolean) {
|
||||
useEffect(() => {
|
||||
if (!enabled || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
// Own an Escape layer only WHILE edit mode is on, so it doesn't outrank the
|
||||
// narrow-pane reveal the rest of the time.
|
||||
let releaseLayer: (() => void) | null = null
|
||||
|
||||
const unsub = $layoutEditMode.subscribe(on => {
|
||||
if (on && !releaseLayer) {
|
||||
releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.layoutEdit)
|
||||
} else if (!on && releaseLayer) {
|
||||
releaseLayer()
|
||||
releaseLayer = null
|
||||
}
|
||||
})
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || e.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.layoutEdit)) {
|
||||
return
|
||||
}
|
||||
|
||||
if ($layoutEditMode.get()) {
|
||||
e.preventDefault()
|
||||
$layoutEditMode.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
unsub()
|
||||
releaseLayer?.()
|
||||
}
|
||||
}, [enabled])
|
||||
}
|
||||
270
apps/desktop/src/components/pane-shell/geometry.ts
Normal file
270
apps/desktop/src/components/pane-shell/geometry.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
/**
|
||||
* Pane geometry — AABB INTERSECTION → WINDOW-CONTROL AWARENESS. The native
|
||||
* window controls (macOS traffic lights top-left / Windows-WSLg overlay
|
||||
* top-right) are a rectangle in viewport pixels. Any region whose rect
|
||||
* intersects it must reserve that space and expose a drag strip. One
|
||||
* `intersect()` call replaces per-layout inset special cases.
|
||||
*
|
||||
* Also publishes the WORKSPACE zone's edges as CSS vars (see
|
||||
* publishWorkspaceGeometry) — chrome that aligns to the main pane (titlebar
|
||||
* title et al) consumes `var(--workspace-left/right)` in plain CSS instead of
|
||||
* threading rects through React.
|
||||
*/
|
||||
|
||||
import { type RefObject, useLayoutEffect, useState, useSyncExternalStore } from 'react'
|
||||
|
||||
import { $layoutTree } from '@/components/pane-shell/tree/store'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Rect {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/** AABB intersection. Returns null when the rects don't overlap. */
|
||||
export function intersect(a: Rect, b: Rect): Rect | null {
|
||||
const x = Math.max(a.x, b.x)
|
||||
const y = Math.max(a.y, b.y)
|
||||
const right = Math.min(a.x + a.width, b.x + b.width)
|
||||
const bottom = Math.min(a.y + a.height, b.y + b.height)
|
||||
|
||||
if (right <= x || bottom <= y) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { x, y, width: right - x, height: bottom - y }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native window controls rect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Height of the band the native controls live in. */
|
||||
const CONTROLS_BAND_HEIGHT = 34
|
||||
/** Width of the macOS traffic-light cluster measured from the buttons' x. */
|
||||
const MACOS_LIGHTS_WIDTH = 58
|
||||
const MACOS_FALLBACK_BUTTON_X = 24
|
||||
|
||||
interface ConnectionLike {
|
||||
windowButtonPosition?: { x: number; y: number } | null
|
||||
nativeOverlayWidth?: number | null
|
||||
isFullscreen?: boolean | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The native window-control rectangle in viewport pixels, or null when there
|
||||
* is nothing to dodge (fullscreen, plain browser, secondary windows with
|
||||
* hidden controls).
|
||||
*/
|
||||
export function windowControlsRect(connection: ConnectionLike | null, viewportWidth: number): Rect | null {
|
||||
const inElectron = typeof window !== 'undefined' && 'hermesDesktop' in window
|
||||
|
||||
if (!inElectron) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (connection?.isFullscreen) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Windows / WSLg: native overlay on the top-right.
|
||||
const overlayWidth = connection?.nativeOverlayWidth ?? 0
|
||||
|
||||
if (overlayWidth > 0) {
|
||||
return { x: viewportWidth - overlayWidth, y: 0, width: overlayWidth, height: CONTROLS_BAND_HEIGHT }
|
||||
}
|
||||
|
||||
// macOS: traffic lights on the top-left. windowButtonPosition === null means
|
||||
// the platform has no left-side controls at all (Windows/Linux w/o overlay).
|
||||
const pos = connection?.windowButtonPosition
|
||||
|
||||
if (pos === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform)
|
||||
|
||||
if (!pos && !isMac) {
|
||||
return null
|
||||
}
|
||||
|
||||
const x = pos?.x ?? MACOS_FALLBACK_BUTTON_X
|
||||
|
||||
return { x: 0, y: 0, width: x + MACOS_LIGHTS_WIDTH, height: CONTROLS_BAND_HEIGHT }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let cachedRect: Rect | null = null
|
||||
let cachedKey = ''
|
||||
|
||||
function rectKey(r: Rect | null) {
|
||||
return r ? `${r.x},${r.y},${r.width},${r.height}` : ''
|
||||
}
|
||||
|
||||
function readControlsRect(): Rect | null {
|
||||
const next = windowControlsRect($connection.get(), typeof window === 'undefined' ? 0 : window.innerWidth)
|
||||
const key = rectKey(next)
|
||||
|
||||
// Referentially stable snapshot for useSyncExternalStore.
|
||||
if (key !== cachedKey) {
|
||||
cachedKey = key
|
||||
cachedRect = next
|
||||
}
|
||||
|
||||
return cachedRect
|
||||
}
|
||||
|
||||
function subscribeControlsRect(cb: () => void) {
|
||||
const unsubConnection = $connection.subscribe(() => cb())
|
||||
window.addEventListener('resize', cb)
|
||||
|
||||
return () => {
|
||||
unsubConnection()
|
||||
window.removeEventListener('resize', cb)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reactive native window-controls rect (connection + viewport aware). */
|
||||
export function useWindowControlsRect(): Rect | null {
|
||||
return useSyncExternalStore(subscribeControlsRect, readControlsRect, () => null)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-element overlap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sameRect(a: Rect | null, b: Rect | null) {
|
||||
if (a === b) {return true}
|
||||
|
||||
if (!a || !b) {return false}
|
||||
|
||||
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace-edge CSS vars
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Publish the workspace zone's viewport edges as root CSS vars:
|
||||
* --workspace-left : px from the viewport's left to the main zone
|
||||
* --workspace-right : px from the main zone's right to the viewport's right
|
||||
*
|
||||
* Measured once per relevant change (zone resize via ResizeObserver, layout
|
||||
* mutations via $layoutTree, window resize) and consumed in plain CSS — the
|
||||
* measured-var idiom (--composer-measured-height), not per-component JS
|
||||
* geometry. Call once from the tree root; returns the disposer.
|
||||
*/
|
||||
export function publishWorkspaceGeometry(): () => void {
|
||||
const root = document.documentElement
|
||||
let el: HTMLElement | null = null
|
||||
let lastLeft = NaN
|
||||
let lastRight = NaN
|
||||
|
||||
const ro = new ResizeObserver(() => measure())
|
||||
|
||||
const measure = () => {
|
||||
const next = document.querySelector<HTMLElement>('[data-session-anchor="workspace"]')
|
||||
|
||||
if (next !== el) {
|
||||
if (el) {
|
||||
ro.unobserve(el)
|
||||
}
|
||||
|
||||
el = next
|
||||
|
||||
if (el) {
|
||||
ro.observe(el)
|
||||
}
|
||||
}
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const r = el.getBoundingClientRect()
|
||||
const left = Math.round(r.left)
|
||||
const right = Math.round(window.innerWidth - r.right)
|
||||
|
||||
// Skip unchanged writes: a sash drag fires the RO every frame, and each
|
||||
// :root custom-property set dirties style for everything that reads them.
|
||||
if (left !== lastLeft) {
|
||||
lastLeft = left
|
||||
root.style.setProperty('--workspace-left', `${left}px`)
|
||||
}
|
||||
|
||||
if (right !== lastRight) {
|
||||
lastRight = right
|
||||
root.style.setProperty('--workspace-right', `${right}px`)
|
||||
}
|
||||
}
|
||||
|
||||
// Tree mutations move zones without resizing them (⌘\ flip) — re-measure a
|
||||
// frame later, after the DOM committed. RO covers width changes (sash drags,
|
||||
// side collapses); window resize covers the rest.
|
||||
const unsubTree = $layoutTree.listen(() => requestAnimationFrame(measure))
|
||||
window.addEventListener('resize', measure)
|
||||
measure()
|
||||
|
||||
return () => {
|
||||
unsubTree()
|
||||
window.removeEventListener('resize', measure)
|
||||
ro.disconnect()
|
||||
root.style.removeProperty('--workspace-left')
|
||||
root.style.removeProperty('--workspace-right')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intersects an element's live viewport rect with the native window-controls
|
||||
* rect. Returns the overlap in ELEMENT-LOCAL coordinates (null when clear), so
|
||||
* the consumer can reserve the space and paint a drag strip without knowing
|
||||
* anything about platform, fullscreen state, or layout position.
|
||||
*/
|
||||
export function useWindowControlsOverlap(ref: RefObject<HTMLElement | null>, enabled = true): Rect | null {
|
||||
const controls = useWindowControlsRect()
|
||||
const [overlap, setOverlap] = useState<Rect | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current
|
||||
|
||||
if (!enabled || !controls || !el) {
|
||||
setOverlap(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const r = el.getBoundingClientRect()
|
||||
const hit = intersect(controls, { x: r.x, y: r.y, width: r.width, height: r.height })
|
||||
const local = hit ? { x: hit.x - r.x, y: hit.y - r.y, width: hit.width, height: hit.height } : null
|
||||
|
||||
setOverlap(prev => (sameRect(prev, local) ? prev : local))
|
||||
}
|
||||
|
||||
update()
|
||||
|
||||
// Size changes fire the observer; cross-window moves fire `resize`. A pane
|
||||
// shifted only by a sibling's resize re-measures on its own grid reflow
|
||||
// (its track width changes), so this covers the shell's real cases.
|
||||
const ro = new ResizeObserver(update)
|
||||
ro.observe(el)
|
||||
window.addEventListener('resize', update)
|
||||
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
window.removeEventListener('resize', update)
|
||||
}
|
||||
}, [controls, enabled, ref])
|
||||
|
||||
return overlap
|
||||
}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
export type { PaneShellContextValue, PaneSlot } from './context'
|
||||
export { PaneShellContext } from './context'
|
||||
export { Pane, PANE_TOGGLE_REVEAL_EVENT, PaneMain, PaneShell } from './pane-shell'
|
||||
export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell'
|
||||
/**
|
||||
* Cross-surface event: toggle-reveal a collapsed pane. Dispatched by the
|
||||
* keybinds (⌘B / ⌘G / titlebar toggles on narrow viewports) with the pane id
|
||||
* in `detail`; the layout tree's narrow overlays (tree/renderer.tsx) listen
|
||||
* and slide the pane over the grid.
|
||||
*/
|
||||
export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal'
|
||||
|
|
|
|||
|
|
@ -1,333 +0,0 @@
|
|||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $paneStates, setPaneOpen, setPaneWidthOverride } from '@/store/panes'
|
||||
|
||||
import { Pane, PaneMain, PaneShell } from './pane-shell'
|
||||
|
||||
function gridContainer(rendered: ReturnType<typeof render>): HTMLElement {
|
||||
const root = rendered.container.firstElementChild
|
||||
|
||||
if (!(root instanceof HTMLElement)) {
|
||||
throw new Error('PaneShell did not render a root element')
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
function getColumnTemplate(container: HTMLElement): string[] {
|
||||
return (container.style.gridTemplateColumns ?? '').split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
function mockWidth(element: HTMLElement, width: number) {
|
||||
Object.defineProperty(element, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: width,
|
||||
top: 0,
|
||||
width,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('PaneShell composition', () => {
|
||||
beforeEach(() => {
|
||||
$paneStates.set({})
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$paneStates.set({})
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('builds a 2-column grid for one left pane + main', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const tracks = getColumnTemplate(gridContainer(rendered))
|
||||
|
||||
expect(tracks).toEqual(['240px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('orders panes left-to-right by side, preserving source order within a side', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<Pane id="sessions" side="left" width="200px">
|
||||
sessions
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
<Pane id="preview" side="right" width="320px">
|
||||
preview
|
||||
</Pane>
|
||||
<Pane id="inspector" side="right" width="280px">
|
||||
inspector
|
||||
</Pane>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const tracks = getColumnTemplate(gridContainer(rendered))
|
||||
|
||||
expect(tracks).toEqual(['240px', '200px', 'minmax(0,1fr)', '320px', '280px'])
|
||||
})
|
||||
|
||||
it('collapses a closed pane to 0px', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane defaultOpen={false} id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const tracks = getColumnTemplate(gridContainer(rendered))
|
||||
|
||||
expect(tracks).toEqual(['0px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('reads open state from the panes store', () => {
|
||||
setPaneOpen('files', false)
|
||||
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('disabled forces the track to 0px even when the store says open', () => {
|
||||
setPaneOpen('files', true)
|
||||
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane disabled={true} id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('disabled does NOT mutate the store-persisted open state', () => {
|
||||
setPaneOpen('files', true)
|
||||
|
||||
render(
|
||||
<PaneShell>
|
||||
<Pane disabled={true} id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect($paneStates.get().files?.open).toBe(true)
|
||||
})
|
||||
|
||||
it('uses widthOverride from the store when set', () => {
|
||||
setPaneOpen('files', true)
|
||||
setPaneWidthOverride('files', 320)
|
||||
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" resizable side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['320px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('preserves CSS-string widths verbatim (clamp, var, etc.)', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="inspector" side="right" width="clamp(13.5rem,21vw,20rem)">
|
||||
inspector
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const template = gridContainer(rendered).style.gridTemplateColumns
|
||||
|
||||
expect(template).toContain('clamp(13.5rem,21vw,20rem)')
|
||||
})
|
||||
|
||||
it('coerces numeric widths to px', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width={224}>
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['224px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('emits per-pane width as a CSS variable', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const root = gridContainer(rendered)
|
||||
|
||||
expect(root.style.getPropertyValue('--pane-files-width').trim()).toBe('240px')
|
||||
})
|
||||
|
||||
it('places a Pane in the correct grid column via inline style', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
<span data-testid="files-content">files</span>
|
||||
</Pane>
|
||||
<PaneMain>
|
||||
<span data-testid="main-content">main</span>
|
||||
</PaneMain>
|
||||
<Pane id="preview" side="right" width="320px">
|
||||
<span data-testid="preview-content">preview</span>
|
||||
</Pane>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const filesCell = rendered.getByTestId('files-content').parentElement!
|
||||
const mainCell = rendered.getByTestId('main-content').parentElement!
|
||||
const previewCell = rendered.getByTestId('preview-content').parentElement!
|
||||
|
||||
expect(filesCell.style.gridColumn).toBe('1 / 2')
|
||||
expect(mainCell.style.gridColumn).toBe('2 / 3')
|
||||
expect(previewCell.style.gridColumn).toBe('3 / 4')
|
||||
})
|
||||
|
||||
it('marks closed panes aria-hidden', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane defaultOpen={false} id="files" side="left" width="240px">
|
||||
<span data-testid="files-content">files</span>
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const cell = rendered.getByTestId('files-content').parentElement!
|
||||
|
||||
expect(cell.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(cell.getAttribute('data-pane-open')).toBe('false')
|
||||
})
|
||||
|
||||
it('passes through arbitrary non-Pane children for self-placement', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
<div data-testid="floating-overlay" style={{ position: 'absolute' }}>
|
||||
overlay
|
||||
</div>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(rendered.getByTestId('floating-overlay')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows a resize handle only when resizable', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<Pane id="preview" resizable side="right" width="320px">
|
||||
preview
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(rendered.queryByLabelText('Resize files')).toBeNull()
|
||||
expect(rendered.getByLabelText('Resize preview')).toBeDefined()
|
||||
})
|
||||
|
||||
it('dragging a left-pane separator stores a wider width override', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" maxWidth={360} minWidth={200} resizable side="left" width="240px">
|
||||
<span data-testid="files-content">files</span>
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const paneCell = rendered.getByTestId('files-content').parentElement
|
||||
|
||||
if (!(paneCell instanceof HTMLElement)) {
|
||||
throw new Error('Expected pane cell element')
|
||||
}
|
||||
|
||||
mockWidth(paneCell, 240)
|
||||
const separator = rendered.getByLabelText('Resize files')
|
||||
|
||||
fireEvent.pointerDown(separator, { clientX: 240, pointerId: 1 })
|
||||
fireEvent.pointerMove(window, { clientX: 300 })
|
||||
fireEvent.pointerUp(window, { clientX: 300 })
|
||||
|
||||
expect($paneStates.get().files?.widthOverride).toBe(300)
|
||||
})
|
||||
|
||||
it('dragging a right-pane separator clamps to max width', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<PaneMain>main</PaneMain>
|
||||
<Pane id="preview" maxWidth={340} minWidth={220} resizable side="right" width="320px">
|
||||
<span data-testid="preview-content">preview</span>
|
||||
</Pane>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const paneCell = rendered.getByTestId('preview-content').parentElement
|
||||
|
||||
if (!(paneCell instanceof HTMLElement)) {
|
||||
throw new Error('Expected pane cell element')
|
||||
}
|
||||
|
||||
mockWidth(paneCell, 320)
|
||||
const separator = rendered.getByLabelText('Resize preview')
|
||||
|
||||
fireEvent.pointerDown(separator, { clientX: 900, pointerId: 1 })
|
||||
fireEvent.pointerMove(window, { clientX: 760 })
|
||||
fireEvent.pointerUp(window, { clientX: 760 })
|
||||
|
||||
expect($paneStates.get().preview?.widthOverride).toBe(340)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,598 +0,0 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import {
|
||||
Children,
|
||||
type CSSProperties,
|
||||
isValidElement,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $paneStates, ensurePaneRegistered, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes'
|
||||
|
||||
import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context'
|
||||
|
||||
type PaneSide = 'left' | 'right'
|
||||
type WidthValue = string | number
|
||||
|
||||
interface PaneRoleMarker {
|
||||
__paneShellRole?: 'pane' | 'main'
|
||||
}
|
||||
|
||||
export interface PaneProps {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
defaultOpen?: boolean
|
||||
/** Paints a persistent hairline on the resize edge (not just the hover sash) so the pane boundary is always visible. */
|
||||
divider?: boolean
|
||||
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
|
||||
disabled?: boolean
|
||||
/** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */
|
||||
forceCollapsed?: boolean
|
||||
/** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */
|
||||
hoverReveal?: boolean
|
||||
/**
|
||||
* Lay the pane out as a horizontal row beneath its rail (spanning every column on
|
||||
* its `side`) instead of as a vertical column. The pane then resizes on the Y axis.
|
||||
* Used to drop the terminal under a crowded rail rather than squeezing another column in.
|
||||
*/
|
||||
bottomRow?: boolean
|
||||
/** Default height of a `bottomRow` pane. */
|
||||
height?: WidthValue
|
||||
/** Min/max height clamps for a `bottomRow` pane's vertical resize. */
|
||||
maxHeight?: WidthValue
|
||||
minHeight?: WidthValue
|
||||
/** Width of the collapsed-overlay panel. Defaults to the docked width (or its resize override); set this to render a narrower overlay than the docked pane (e.g. min width on mobile). */
|
||||
overlayWidth?: WidthValue
|
||||
/** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */
|
||||
onOverlayActiveChange?: (overlayActive: boolean) => void
|
||||
id: string
|
||||
maxWidth?: WidthValue
|
||||
minWidth?: WidthValue
|
||||
resizable?: boolean
|
||||
side: PaneSide
|
||||
width?: WidthValue
|
||||
}
|
||||
|
||||
export interface PaneMainProps {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface PaneShellProps {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
interface CollectedPane {
|
||||
bottomRow: boolean
|
||||
defaultOpen: boolean
|
||||
disabled: boolean
|
||||
forceCollapsed: boolean
|
||||
height: string
|
||||
id: string
|
||||
resizable: boolean
|
||||
side: PaneSide
|
||||
width: string
|
||||
}
|
||||
|
||||
const DEFAULT_WIDTH = '16rem'
|
||||
const DEFAULT_HEIGHT = '18rem'
|
||||
const DEFAULT_RESIZE_MIN_WIDTH = 160
|
||||
const DEFAULT_RESIZE_MIN_HEIGHT = 120
|
||||
|
||||
// Resize-sash geometry per axis: `x` is a vertical bar on the inner edge of a
|
||||
// column; `y` is a horizontal bar on the top edge of a bottom row.
|
||||
const SASH = {
|
||||
x: {
|
||||
orientation: 'vertical',
|
||||
bar: 'bottom-0 top-0 w-1 cursor-col-resize',
|
||||
line: 'inset-y-0 left-1/2 w-px -translate-x-1/2',
|
||||
hover: 'inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2'
|
||||
},
|
||||
y: {
|
||||
orientation: 'horizontal',
|
||||
bar: 'inset-x-0 top-0 h-1 -translate-y-1/2 cursor-row-resize',
|
||||
line: 'inset-x-0 top-1/2 h-px -translate-y-1/2',
|
||||
hover: 'inset-x-0 top-1/2 h-(--vscode-sash-hover-size,0.25rem) -translate-y-1/2'
|
||||
}
|
||||
} as const
|
||||
|
||||
// Hover-reveal slide. The enter delay is a pure-CSS hover-intent gate: a fast
|
||||
// pass-by doesn't dwell on the trigger long enough for the delay to elapse.
|
||||
const HOVER_REVEAL_SLIDE_MS = 220
|
||||
const HOVER_REVEAL_ENTER_DELAY_MS = 130
|
||||
const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)'
|
||||
// Offset shadow lifting the revealed panel off the content (same both sides;
|
||||
// the mirror axis is offset-x, which is 0). Same color on light + dark.
|
||||
const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012'
|
||||
// Edge trigger strip, inset past the OS window-resize grab area AND the
|
||||
// adjacent pane's scrollbar (0.5rem, .scrollbar-dt) — the strip overlays the
|
||||
// neighboring scroller's edge, so any overlap makes the scrollbar reveal the
|
||||
// pane on hover and swallow its clicks (#44140).
|
||||
const HOVER_REVEAL_TRIGGER_WIDTH = 14
|
||||
const HOVER_REVEAL_EDGE_GUTTER = 'calc(0.5rem + 2px)'
|
||||
|
||||
// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal
|
||||
// from the keyboard, since its store-open toggle is a no-op while collapsed.
|
||||
export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal'
|
||||
|
||||
const widthToCss = (value: WidthValue | undefined, fallback: string) =>
|
||||
value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value
|
||||
|
||||
const remPx = () =>
|
||||
typeof window === 'undefined'
|
||||
? 16
|
||||
: Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
|
||||
|
||||
const viewportPx = () => (typeof window === 'undefined' ? 1280 : window.innerWidth)
|
||||
const viewportHeightPx = () => (typeof window === 'undefined' ? 800 : window.innerHeight)
|
||||
|
||||
// Resolves PaneProps min/max (number | "Npx" | "Nrem" | "Nvw" | "Nvh" | "N%") to
|
||||
// pixels for drag clamping. vw/% resolve against window width, vh against height.
|
||||
function widthToPx(value: WidthValue | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem|vw|vh|%)?$/)
|
||||
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const n = Number.parseFloat(match[1])
|
||||
|
||||
switch (match[2]) {
|
||||
case 'rem':
|
||||
return n * remPx()
|
||||
|
||||
case 'vh':
|
||||
return (n * viewportHeightPx()) / 100
|
||||
|
||||
case 'vw':
|
||||
|
||||
case '%':
|
||||
return (n * viewportPx()) / 100
|
||||
|
||||
default:
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement {
|
||||
return isValidElement(child) && (child.type as PaneRoleMarker)?.__paneShellRole === role
|
||||
}
|
||||
|
||||
function collectPanes(children: ReactNode) {
|
||||
const left: CollectedPane[] = []
|
||||
const right: CollectedPane[] = []
|
||||
let mainCount = 0
|
||||
|
||||
Children.forEach(children, child => {
|
||||
if (isRole(child, 'main')) {
|
||||
mainCount++
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!isRole(child, 'pane')) {
|
||||
return
|
||||
}
|
||||
|
||||
const props = child.props as PaneProps
|
||||
|
||||
const entry: CollectedPane = {
|
||||
bottomRow: props.bottomRow ?? false,
|
||||
defaultOpen: props.defaultOpen ?? true,
|
||||
disabled: props.disabled ?? false,
|
||||
forceCollapsed: props.forceCollapsed ?? false,
|
||||
height: widthToCss(props.height, DEFAULT_HEIGHT),
|
||||
id: props.id,
|
||||
resizable: props.resizable ?? false,
|
||||
side: props.side,
|
||||
width: widthToCss(props.width, DEFAULT_WIDTH)
|
||||
}
|
||||
|
||||
;(props.side === 'left' ? left : right).push(entry)
|
||||
})
|
||||
|
||||
return { left, mainCount, right }
|
||||
}
|
||||
|
||||
type PaneStoreState = Record<string, { open: boolean; widthOverride?: number; heightOverride?: number }>
|
||||
|
||||
function paneIsOpen(pane: CollectedPane, states: PaneStoreState) {
|
||||
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
|
||||
|
||||
return !pane.disabled && !pane.forceCollapsed && stateOpen
|
||||
}
|
||||
|
||||
function trackForPane(pane: CollectedPane, states: PaneStoreState) {
|
||||
const open = paneIsOpen(pane, states)
|
||||
|
||||
if (!open) {
|
||||
return { open: false, track: '0px' }
|
||||
}
|
||||
|
||||
const override = pane.resizable ? states[pane.id]?.widthOverride : undefined
|
||||
|
||||
return { open: true, track: override !== undefined ? `${override}px` : pane.width }
|
||||
}
|
||||
|
||||
function heightTrackForPane(pane: CollectedPane, states: PaneStoreState) {
|
||||
const override = pane.resizable ? states[pane.id]?.heightOverride : undefined
|
||||
|
||||
return override !== undefined ? `${override}px` : pane.height
|
||||
}
|
||||
|
||||
export function PaneShell({ children, className, style }: PaneShellProps) {
|
||||
const paneStates = useStore($paneStates)
|
||||
const { left, mainCount, right } = useMemo(() => collectPanes(children), [children])
|
||||
|
||||
if (import.meta.env.DEV && mainCount > 1) {
|
||||
console.warn('[PaneShell] expected at most one <PaneMain>, got', mainCount)
|
||||
}
|
||||
|
||||
const ctxValue = useMemo(() => {
|
||||
const paneById = new Map<string, PaneSlot>()
|
||||
const tracks: string[] = []
|
||||
const cssVars: Record<string, string> = {}
|
||||
let column = 1
|
||||
|
||||
// A bottom-row pane drops out of its rail's column flow and instead spans
|
||||
// every column on its side as a new row below them. The first open one wins
|
||||
// and decides which rail gets split into two rows.
|
||||
const leftCols = left.filter(pane => !pane.bottomRow)
|
||||
const rightCols = right.filter(pane => !pane.bottomRow)
|
||||
const bottomRowPanes = [...left, ...right].filter(pane => pane.bottomRow)
|
||||
const activeBottomRow = bottomRowPanes.find(pane => paneIsOpen(pane, paneStates)) ?? null
|
||||
const bottomRailSide = activeBottomRow?.side ?? null
|
||||
|
||||
// Open column panes on the bottom row's side shrink to the top row; everything
|
||||
// else (main, the other rail, closed / hover-reveal panes) stays full height.
|
||||
const addColumn = (pane: CollectedPane, paneSide: PaneSide) => {
|
||||
const { open, track } = trackForPane(pane, paneStates)
|
||||
tracks.push(track)
|
||||
cssVars[`--pane-${pane.id}-width`] = track
|
||||
const gridRow = open && paneSide === bottomRailSide ? '1 / 2' : '1 / -1'
|
||||
paneById.set(pane.id, {
|
||||
open,
|
||||
side: paneSide,
|
||||
gridColumn: `${column} / ${column + 1}`,
|
||||
gridRow,
|
||||
bottomRow: false
|
||||
})
|
||||
column++
|
||||
}
|
||||
|
||||
for (const pane of leftCols) {
|
||||
addColumn(pane, 'left')
|
||||
}
|
||||
|
||||
tracks.push('minmax(0,1fr)')
|
||||
const mainColumn = column++
|
||||
|
||||
for (const pane of rightCols) {
|
||||
addColumn(pane, 'right')
|
||||
}
|
||||
|
||||
// Place every bottom-row pane: span its rail's columns on the second row.
|
||||
for (const pane of bottomRowPanes) {
|
||||
const gridColumn = pane.side === 'left' ? `1 / ${mainColumn}` : `${mainColumn + 1} / -1`
|
||||
paneById.set(pane.id, {
|
||||
open: pane === activeBottomRow,
|
||||
side: pane.side,
|
||||
gridColumn,
|
||||
gridRow: '2 / 3',
|
||||
bottomRow: true
|
||||
})
|
||||
}
|
||||
|
||||
// Always emit explicit rows so `grid-row: 1 / -1` (full-height) resolves
|
||||
// against a known last line. With a bottom row active there are two tracks;
|
||||
// otherwise a single 1fr track behaves exactly like the old single-row grid.
|
||||
const gridTemplateRows = activeBottomRow
|
||||
? `minmax(0,1fr) ${heightTrackForPane(activeBottomRow, paneStates)}`
|
||||
: 'minmax(0,1fr)'
|
||||
|
||||
return {
|
||||
cssVars,
|
||||
gridTemplate: tracks.join(' '),
|
||||
gridTemplateRows,
|
||||
mainColumn,
|
||||
paneById
|
||||
} satisfies PaneShellContextValue & {
|
||||
cssVars: Record<string, string>
|
||||
gridTemplate: string
|
||||
gridTemplateRows: string
|
||||
}
|
||||
}, [left, paneStates, right])
|
||||
|
||||
const composedStyle = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
...ctxValue.cssVars,
|
||||
...style,
|
||||
gridTemplateColumns: ctxValue.gridTemplate,
|
||||
gridTemplateRows: ctxValue.gridTemplateRows
|
||||
}),
|
||||
[ctxValue.cssVars, ctxValue.gridTemplate, ctxValue.gridTemplateRows, style]
|
||||
)
|
||||
|
||||
return (
|
||||
<PaneShellContext.Provider value={{ mainColumn: ctxValue.mainColumn, paneById: ctxValue.paneById }}>
|
||||
<div className={cn('relative grid h-full min-h-0', className)} data-pane-shell="" style={composedStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</PaneShellContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Pane({
|
||||
children,
|
||||
className,
|
||||
defaultOpen = true,
|
||||
divider = false,
|
||||
disabled = false,
|
||||
hoverReveal = false,
|
||||
maxHeight,
|
||||
minHeight,
|
||||
overlayWidth: overlayWidthProp,
|
||||
id,
|
||||
maxWidth,
|
||||
minWidth,
|
||||
onOverlayActiveChange,
|
||||
resizable = false,
|
||||
width
|
||||
}: PaneProps) {
|
||||
const ctx = useContext(PaneShellContext)
|
||||
const paneStates = useStore($paneStates)
|
||||
const registered = useRef(false)
|
||||
const paneRef = useRef<HTMLDivElement | null>(null)
|
||||
// Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS.
|
||||
const [forced, setForced] = useState(false)
|
||||
|
||||
const slot = ctx?.paneById.get(id)
|
||||
const open = Boolean(slot?.open && !disabled)
|
||||
const side = slot?.side ?? 'left'
|
||||
// Collapsed + hoverReveal: float the pane contents over the main column on
|
||||
// hover/focus instead of hiding them. Honors any persisted resize width.
|
||||
const overlayActive = !open && hoverReveal && !disabled
|
||||
const override = resizable ? paneStates[id]?.widthOverride : undefined
|
||||
|
||||
// Overlay width: an explicit `overlayWidth` (e.g. min width on mobile) wins,
|
||||
// else the persisted resize override, else the docked width.
|
||||
const overlayWidth =
|
||||
overlayWidthProp !== undefined
|
||||
? widthToCss(overlayWidthProp, DEFAULT_WIDTH)
|
||||
: override !== undefined
|
||||
? `${override}px`
|
||||
: widthToCss(width, DEFAULT_WIDTH)
|
||||
|
||||
useEffect(() => {
|
||||
if (registered.current) {
|
||||
return
|
||||
}
|
||||
|
||||
registered.current = true
|
||||
ensurePaneRegistered(id, { open: defaultOpen })
|
||||
}, [defaultOpen, id])
|
||||
|
||||
// Keyboard toggle pins/unpins the reveal while collapsed; clear when no longer
|
||||
// a collapsed overlay (reopened / widened).
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !overlayActive) {
|
||||
setForced(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const onToggle = (e: Event) => {
|
||||
if ((e as CustomEvent<{ id: string }>).detail?.id === id) {
|
||||
setForced(v => !v)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
|
||||
|
||||
return () => window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
|
||||
}, [id, overlayActive])
|
||||
|
||||
// Keep contents mounted while collapsed so reveal is a pure CSS transform.
|
||||
useEffect(() => {
|
||||
onOverlayActiveChange?.(overlayActive)
|
||||
}, [onOverlayActiveChange, overlayActive])
|
||||
|
||||
const isBottomRow = Boolean(slot?.bottomRow)
|
||||
const axis = isBottomRow ? 'y' : 'x'
|
||||
const sash = SASH[axis]
|
||||
const canResize = open && resizable
|
||||
const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH
|
||||
const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY
|
||||
const loH = widthToPx(minHeight) ?? DEFAULT_RESIZE_MIN_HEIGHT
|
||||
const hiH = widthToPx(maxHeight) ?? Number.POSITIVE_INFINITY
|
||||
|
||||
// One pointer-drag for both axes. Columns grow toward the main column (left
|
||||
// rail → right, right rail → left); the bottom row grows up from its top edge.
|
||||
const startResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>, axis: 'x' | 'y') => {
|
||||
const rect = paneRef.current?.getBoundingClientRect()
|
||||
const base = (axis === 'x' ? rect?.width : rect?.height) ?? 0
|
||||
|
||||
if (!canResize || base <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const handle = event.currentTarget
|
||||
const { pointerId } = event
|
||||
const start = axis === 'x' ? event.clientX : event.clientY
|
||||
const dir = axis === 'x' ? (side === 'left' ? 1 : -1) : -1
|
||||
const [min, max] = axis === 'x' ? [lo, hi] : [loH, hiH]
|
||||
const apply = axis === 'x' ? setPaneWidthOverride : setPaneHeightOverride
|
||||
const restoreCursor = document.body.style.cursor
|
||||
const restoreSelect = document.body.style.userSelect
|
||||
|
||||
handle.setPointerCapture?.(pointerId)
|
||||
document.body.style.cursor = axis === 'x' ? 'col-resize' : 'row-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const next = base + ((axis === 'x' ? e.clientX : e.clientY) - start) * dir
|
||||
apply(id, Math.round(Math.min(max, Math.max(min, next))))
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
document.body.style.cursor = restoreCursor
|
||||
document.body.style.userSelect = restoreSelect
|
||||
handle.releasePointerCapture?.(pointerId)
|
||||
window.removeEventListener('pointermove', onMove, true)
|
||||
window.removeEventListener('pointerup', cleanup, true)
|
||||
window.removeEventListener('pointercancel', cleanup, true)
|
||||
window.removeEventListener('blur', cleanup)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove, true)
|
||||
window.addEventListener('pointerup', cleanup, true)
|
||||
window.addEventListener('pointercancel', cleanup, true)
|
||||
window.addEventListener('blur', cleanup)
|
||||
},
|
||||
[canResize, hi, hiH, id, lo, loH, side]
|
||||
)
|
||||
|
||||
if (!ctx) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`[Pane:${id}] must be rendered inside <PaneShell>`)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (!slot) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Collapsed hover-reveal track: a 0px, pointer-transparent grid cell holding a
|
||||
// thin edge trigger + the floating panel (both absolute, escaping the zero
|
||||
// box). group-hover (or data-forced from the keyboard) drives the slide; the
|
||||
// enter-delay is the hover-intent gate. No JS pointer math.
|
||||
if (overlayActive) {
|
||||
const edge = side === 'left' ? 'left' : 'right'
|
||||
const offscreen = side === 'left' ? '-translate-x-[calc(100%+1rem)]' : 'translate-x-[calc(100%+1rem)]'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('group/reveal pointer-events-none relative min-w-0', className)}
|
||||
data-forced={forced ? '' : undefined}
|
||||
data-pane-hover-reveal={forced ? 'open' : 'closed'}
|
||||
data-pane-id={id}
|
||||
data-pane-open="false"
|
||||
data-pane-side={side}
|
||||
ref={paneRef}
|
||||
style={{ gridColumn: slot.gridColumn, gridRow: slot.gridRow }}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
|
||||
data-pane-reveal-trigger=""
|
||||
style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }}
|
||||
/>
|
||||
|
||||
{/* Keyed on side so flipping panes remounts off-screen on the new edge
|
||||
instead of transitioning the transform across the viewport. */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0',
|
||||
offscreen,
|
||||
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
|
||||
'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]'
|
||||
)}
|
||||
key={edge}
|
||||
style={
|
||||
{
|
||||
[edge]: 0,
|
||||
width: overlayWidth,
|
||||
'--reveal-shadow': HOVER_REVEAL_SHADOW,
|
||||
transitionDuration: `${HOVER_REVEAL_SLIDE_MS}ms`,
|
||||
transitionTimingFunction: HOVER_REVEAL_EASE,
|
||||
'--reveal-enter-delay': `${HOVER_REVEAL_ENTER_DELAY_MS}ms`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!open}
|
||||
className={cn('relative min-h-0 min-w-0 overflow-hidden', !open && 'pointer-events-none', className)}
|
||||
data-pane-id={id}
|
||||
data-pane-open={open ? 'true' : 'false'}
|
||||
data-pane-side={slot.side}
|
||||
ref={paneRef}
|
||||
style={{ gridColumn: slot.gridColumn, gridRow: slot.gridRow }}
|
||||
>
|
||||
{canResize && (
|
||||
<div
|
||||
aria-label={`Resize ${id}`}
|
||||
aria-orientation={sash.orientation}
|
||||
className={cn(
|
||||
'group absolute z-20 [-webkit-app-region:no-drag]',
|
||||
sash.bar,
|
||||
!isBottomRow && (slot.side === 'left' ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2')
|
||||
)}
|
||||
onPointerDown={e => startResize(e, axis)}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
>
|
||||
{divider && <span className={cn('absolute bg-(--ui-stroke-secondary)', sash.line)} />}
|
||||
<span
|
||||
className={cn(
|
||||
'absolute bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100',
|
||||
sash.hover
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
;(Pane as unknown as PaneRoleMarker).__paneShellRole = 'pane'
|
||||
|
||||
export function PaneMain({ children, className }: PaneMainProps) {
|
||||
const ctx = useContext(PaneShellContext)
|
||||
|
||||
if (!ctx) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[PaneMain] must be rendered inside <PaneShell>')
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex min-h-0 min-w-0 flex-col overflow-hidden', className)}
|
||||
data-pane-main="true"
|
||||
style={{ gridColumn: `${ctx.mainColumn} / ${ctx.mainColumn + 1}`, gridRow: '1 / -1' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
;(PaneMain as unknown as PaneRoleMarker).__paneShellRole = 'main'
|
||||
621
apps/desktop/src/components/pane-shell/tree/grid-model.ts
Normal file
621
apps/desktop/src/components/pane-shell/tree/grid-model.ts
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
/**
|
||||
* FancyZones grid model — a faithful port of PowerToys'
|
||||
* `FancyZonesEditor/Models/GridLayoutModel.cs` + `FancyZonesEditor/GridData.cs`
|
||||
* (microsoft/PowerToys, MIT). Function/field names, algorithms, and invariants
|
||||
* follow the C# sources so behavior matches the original editor:
|
||||
*
|
||||
* - A layout is rows x columns with percent tracks summing to MULTIPLIER
|
||||
* (10000) and a cellChildMap assigning each cell to a zone; a zone spanning
|
||||
* multiple cells appears as the same index in adjacent cells.
|
||||
* - Zones are rectangles in the 0..10000 coordinate space (prefix sums).
|
||||
* - Resizers are the shared edges between zones, derived from cellChildMap
|
||||
* discontinuities; dragging one moves every zone touching that edge.
|
||||
* - Merging computes the rectangular CLOSURE of the selection (extending it
|
||||
* until no zone is partially cut) — the signature FancyZones merge feel.
|
||||
*/
|
||||
|
||||
// The sum of row/column percents should be equal to this number.
|
||||
export const MULTIPLIER = 10000
|
||||
|
||||
/** Minimum zone extent in model units (editor ergonomics; C# uses 1). */
|
||||
export const MIN_ZONE_SIZE = 500
|
||||
|
||||
export interface GridLayout {
|
||||
rows: number
|
||||
columns: number
|
||||
rowPercents: number[]
|
||||
columnPercents: number[]
|
||||
/** cellChildMap[row][col] = zone index; spans = same index in adjacent cells. */
|
||||
cellChildMap: number[][]
|
||||
}
|
||||
|
||||
export interface GridZone {
|
||||
index: number
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
}
|
||||
|
||||
export interface GridResizer {
|
||||
orientation: 'horizontal' | 'vertical'
|
||||
/** All zones to the left/up, in order. */
|
||||
negativeSideIndices: number[]
|
||||
/** All zones to the right/down, in order. */
|
||||
positiveSideIndices: number[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GridData helpers (verbatim)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** result[k] is the sum of the first k elements of the given list. */
|
||||
export function prefixSum(list: number[]): number[] {
|
||||
const result: number[] = [0]
|
||||
let sum = 0
|
||||
|
||||
for (const value of list) {
|
||||
sum += value
|
||||
result.push(sum)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** Opposite of prefixSum: differences of consecutive elements. */
|
||||
function adjacentDifference(list: number[]): number[] {
|
||||
if (list.length <= 1) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result: number[] = []
|
||||
|
||||
for (let i = 0; i < list.length - 1; i++) {
|
||||
result.push(list[i + 1] - list[i])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** Contiguous-segment unique (order-preserving), as in GridData.Unique. */
|
||||
function unique(list: number[]): number[] {
|
||||
const result: number[] = []
|
||||
|
||||
if (list.length === 0) {
|
||||
return result
|
||||
}
|
||||
|
||||
let last = list[0]
|
||||
result.push(last)
|
||||
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
if (list[i] !== last) {
|
||||
last = list[i]
|
||||
result.push(last)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model -> zones / resizers (GridData.ModelToZones / ModelToResizers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function modelToZones(model: GridLayout): GridZone[] | null {
|
||||
const { rows, columns: cols, cellChildMap } = model
|
||||
|
||||
let zoneCount = 0
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
zoneCount = Math.max(zoneCount, cellChildMap[row][col])
|
||||
}
|
||||
}
|
||||
|
||||
zoneCount++
|
||||
|
||||
if (zoneCount > rows * cols) {
|
||||
return null
|
||||
}
|
||||
|
||||
const indexCount = new Array<number>(zoneCount).fill(0)
|
||||
const indexRowLow = new Array<number>(zoneCount).fill(Number.MAX_SAFE_INTEGER)
|
||||
const indexRowHigh = new Array<number>(zoneCount).fill(0)
|
||||
const indexColLow = new Array<number>(zoneCount).fill(Number.MAX_SAFE_INTEGER)
|
||||
const indexColHigh = new Array<number>(zoneCount).fill(0)
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const index = cellChildMap[row][col]
|
||||
indexCount[index]++
|
||||
indexRowLow[index] = Math.min(indexRowLow[index], row)
|
||||
indexColLow[index] = Math.min(indexColLow[index], col)
|
||||
indexRowHigh[index] = Math.max(indexRowHigh[index], row)
|
||||
indexColHigh[index] = Math.max(indexColHigh[index], col)
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < zoneCount; index++) {
|
||||
if (indexCount[index] === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Each zone must occupy a full rectangle of cells.
|
||||
if (indexCount[index] !== (indexRowHigh[index] - indexRowLow[index] + 1) * (indexColHigh[index] - indexColLow[index] + 1)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
model.rowPercents.length !== rows ||
|
||||
model.columnPercents.length !== cols ||
|
||||
model.rowPercents.some(x => x < 1) ||
|
||||
model.columnPercents.some(x => x < 1)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rowPrefixSum = prefixSum(model.rowPercents)
|
||||
const colPrefixSum = prefixSum(model.columnPercents)
|
||||
|
||||
if (rowPrefixSum[rows] !== MULTIPLIER || colPrefixSum[cols] !== MULTIPLIER) {
|
||||
return null
|
||||
}
|
||||
|
||||
const zones: GridZone[] = []
|
||||
|
||||
for (let index = 0; index < zoneCount; index++) {
|
||||
zones.push({
|
||||
index,
|
||||
left: colPrefixSum[indexColLow[index]],
|
||||
right: colPrefixSum[indexColHigh[index] + 1],
|
||||
top: rowPrefixSum[indexRowLow[index]],
|
||||
bottom: rowPrefixSum[indexRowHigh[index] + 1]
|
||||
})
|
||||
}
|
||||
|
||||
return zones
|
||||
}
|
||||
|
||||
export function modelToResizers(model: GridLayout): GridResizer[] {
|
||||
const grid = model.cellChildMap
|
||||
const { rows, columns: cols } = model
|
||||
const resizers: GridResizer[] = []
|
||||
|
||||
// Horizontal
|
||||
for (let row = 1; row < rows; row++) {
|
||||
for (let startCol = 0; startCol < cols; ) {
|
||||
if (grid[row - 1][startCol] !== grid[row][startCol]) {
|
||||
let endCol = startCol
|
||||
|
||||
while (endCol + 1 < cols && grid[row - 1][endCol + 1] !== grid[row][endCol + 1]) {
|
||||
endCol++
|
||||
}
|
||||
|
||||
const positive: number[] = []
|
||||
const negative: number[] = []
|
||||
|
||||
for (let col = startCol; col <= endCol; col++) {
|
||||
negative.push(grid[row - 1][col])
|
||||
positive.push(grid[row][col])
|
||||
}
|
||||
|
||||
resizers.push({
|
||||
orientation: 'horizontal',
|
||||
positiveSideIndices: unique(positive),
|
||||
negativeSideIndices: unique(negative)
|
||||
})
|
||||
|
||||
startCol = endCol + 1
|
||||
} else {
|
||||
startCol++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical
|
||||
for (let col = 1; col < cols; col++) {
|
||||
for (let startRow = 0; startRow < rows; ) {
|
||||
if (grid[startRow][col - 1] !== grid[startRow][col]) {
|
||||
let endRow = startRow
|
||||
|
||||
while (endRow + 1 < rows && grid[endRow + 1][col - 1] !== grid[endRow + 1][col]) {
|
||||
endRow++
|
||||
}
|
||||
|
||||
const positive: number[] = []
|
||||
const negative: number[] = []
|
||||
|
||||
for (let row = startRow; row <= endRow; row++) {
|
||||
negative.push(grid[row][col - 1])
|
||||
positive.push(grid[row][col])
|
||||
}
|
||||
|
||||
resizers.push({
|
||||
orientation: 'vertical',
|
||||
positiveSideIndices: unique(positive),
|
||||
negativeSideIndices: unique(negative)
|
||||
})
|
||||
|
||||
startRow = endRow + 1
|
||||
} else {
|
||||
startRow++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resizers
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zones -> model (GridData.ZonesToModel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function zonesToModel(zones: GridZone[]): GridLayout {
|
||||
const xCoords = [...new Set(zones.flatMap(z => [z.left, z.right]))].sort((a, b) => a - b)
|
||||
const yCoords = [...new Set(zones.flatMap(z => [z.top, z.bottom]))].sort((a, b) => a - b)
|
||||
|
||||
const model: GridLayout = {
|
||||
rows: yCoords.length - 1,
|
||||
columns: xCoords.length - 1,
|
||||
rowPercents: adjacentDifference(yCoords),
|
||||
columnPercents: adjacentDifference(xCoords),
|
||||
cellChildMap: Array.from({ length: yCoords.length - 1 }, () => new Array<number>(xCoords.length - 1).fill(0))
|
||||
}
|
||||
|
||||
for (let index = 0; index < zones.length; index++) {
|
||||
const zone = zones[index]
|
||||
const startRow = yCoords.indexOf(zone.top)
|
||||
const endRow = yCoords.indexOf(zone.bottom)
|
||||
const startCol = xCoords.indexOf(zone.left)
|
||||
const endCol = xCoords.indexOf(zone.right)
|
||||
|
||||
for (let row = startRow; row < endRow; row++) {
|
||||
for (let col = startCol; col < endCol; col++) {
|
||||
model.cellChildMap[row][col] = index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Closure + merge (GridData.ComputeClosure / DoMerge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function computeClosure(zones: GridZone[], indices: number[]): { indices: number[]; zone: GridZone } {
|
||||
let left = Number.MAX_SAFE_INTEGER
|
||||
let right = Number.MIN_SAFE_INTEGER
|
||||
let top = Number.MAX_SAFE_INTEGER
|
||||
let bottom = Number.MIN_SAFE_INTEGER
|
||||
|
||||
if (indices.length === 0) {
|
||||
return { indices: [], zone: { index: -1, left, right, top, bottom } }
|
||||
}
|
||||
|
||||
const extend = (zone: GridZone) => {
|
||||
left = Math.min(left, zone.left)
|
||||
right = Math.max(right, zone.right)
|
||||
top = Math.min(top, zone.top)
|
||||
bottom = Math.max(bottom, zone.bottom)
|
||||
}
|
||||
|
||||
for (const index of indices) {
|
||||
extend(zones[index])
|
||||
}
|
||||
|
||||
let possiblyBroken = true
|
||||
|
||||
while (possiblyBroken) {
|
||||
possiblyBroken = false
|
||||
|
||||
for (const zone of zones) {
|
||||
const area = (zone.bottom - zone.top) * (zone.right - zone.left)
|
||||
|
||||
const cutLeft = Math.max(left, zone.left)
|
||||
const cutRight = Math.min(right, zone.right)
|
||||
const cutTop = Math.max(top, zone.top)
|
||||
const cutBottom = Math.min(bottom, zone.bottom)
|
||||
|
||||
const newArea = Math.max(0, cutBottom - cutTop) * Math.max(0, cutRight - cutLeft)
|
||||
|
||||
if (newArea !== 0 && newArea !== area) {
|
||||
// Bad intersection found, extend.
|
||||
extend(zone)
|
||||
possiblyBroken = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resultIndices = zones
|
||||
.filter(zone => left <= zone.left && zone.right <= right && top <= zone.top && zone.bottom <= bottom)
|
||||
.map(zone => zone.index)
|
||||
|
||||
return { indices: resultIndices, zone: { index: -1, left, right, top, bottom } }
|
||||
}
|
||||
|
||||
export function mergeClosureIndices(model: GridLayout, indices: number[]): number[] {
|
||||
const zones = modelToZones(model)
|
||||
|
||||
return zones ? computeClosure(zones, indices).indices : []
|
||||
}
|
||||
|
||||
export function doMerge(model: GridLayout, indices: number[]): GridLayout {
|
||||
if (indices.length === 0) {
|
||||
return model
|
||||
}
|
||||
|
||||
const zones = modelToZones(model)
|
||||
|
||||
if (!zones) {
|
||||
return model
|
||||
}
|
||||
|
||||
const lowestIndex = Math.min(...indices)
|
||||
const closure = computeClosure(zones, indices)
|
||||
const closureIndices = new Set(closure.indices)
|
||||
|
||||
const remaining = zones.filter(zone => !closureIndices.has(zone.index))
|
||||
remaining.splice(lowestIndex, 0, closure.zone)
|
||||
|
||||
return zonesToModel(remaining)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Split (GridData.CanSplit / Split)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function canSplit(
|
||||
model: GridLayout,
|
||||
zoneIndex: number,
|
||||
position: number,
|
||||
orientation: 'horizontal' | 'vertical'
|
||||
): boolean {
|
||||
const zones = modelToZones(model)
|
||||
|
||||
if (!zones || !zones[zoneIndex]) {
|
||||
return false
|
||||
}
|
||||
|
||||
const zone = zones[zoneIndex]
|
||||
|
||||
if (orientation === 'horizontal') {
|
||||
return zone.top + MIN_ZONE_SIZE <= position && position <= zone.bottom - MIN_ZONE_SIZE
|
||||
}
|
||||
|
||||
return zone.left + MIN_ZONE_SIZE <= position && position <= zone.right - MIN_ZONE_SIZE
|
||||
}
|
||||
|
||||
export function splitZone(
|
||||
model: GridLayout,
|
||||
zoneIndex: number,
|
||||
position: number,
|
||||
orientation: 'horizontal' | 'vertical'
|
||||
): GridLayout {
|
||||
if (!canSplit(model, zoneIndex, position, orientation)) {
|
||||
return model
|
||||
}
|
||||
|
||||
const zones = modelToZones(model)!
|
||||
const zone = zones[zoneIndex]
|
||||
const zone1 = { ...zone }
|
||||
const zone2 = { ...zone }
|
||||
|
||||
zones.splice(zoneIndex, 1)
|
||||
|
||||
if (orientation === 'horizontal') {
|
||||
zone1.bottom = position
|
||||
zone2.top = position
|
||||
} else {
|
||||
zone1.right = position
|
||||
zone2.left = position
|
||||
}
|
||||
|
||||
zones.splice(zoneIndex, 0, zone1)
|
||||
zones.splice(zoneIndex + 1, 0, zone2)
|
||||
|
||||
return zonesToModel(zones)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resizer drag (GridData.CanDrag / Drag)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function canDrag(model: GridLayout, resizerIndex: number, delta: number): boolean {
|
||||
const zones = modelToZones(model)
|
||||
const resizers = modelToResizers(model)
|
||||
const resizer = resizers[resizerIndex]
|
||||
|
||||
if (!zones || !resizer) {
|
||||
return false
|
||||
}
|
||||
|
||||
const getSize = (zoneIndex: number) => {
|
||||
const zone = zones[zoneIndex]
|
||||
|
||||
return resizer.orientation === 'vertical' ? zone.right - zone.left : zone.bottom - zone.top
|
||||
}
|
||||
|
||||
for (const zoneIndex of resizer.positiveSideIndices) {
|
||||
if (getSize(zoneIndex) - delta < MIN_ZONE_SIZE) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for (const zoneIndex of resizer.negativeSideIndices) {
|
||||
if (getSize(zoneIndex) + delta < MIN_ZONE_SIZE) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function dragResizer(model: GridLayout, resizerIndex: number, delta: number): GridLayout {
|
||||
if (!canDrag(model, resizerIndex, delta)) {
|
||||
return model
|
||||
}
|
||||
|
||||
const zones = modelToZones(model)!
|
||||
const resizer = modelToResizers(model)[resizerIndex]
|
||||
|
||||
for (const zoneIndex of resizer.positiveSideIndices) {
|
||||
const zone = zones[zoneIndex]
|
||||
|
||||
if (resizer.orientation === 'horizontal') {
|
||||
zone.top += delta
|
||||
} else {
|
||||
zone.left += delta
|
||||
}
|
||||
}
|
||||
|
||||
for (const zoneIndex of resizer.negativeSideIndices) {
|
||||
const zone = zones[zoneIndex]
|
||||
|
||||
if (resizer.orientation === 'horizontal') {
|
||||
zone.bottom += delta
|
||||
} else {
|
||||
zone.right += delta
|
||||
}
|
||||
}
|
||||
|
||||
return zonesToModel(zones)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Templates + validation (GridLayoutModel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Even track sizes that sum EXACTLY to MULTIPLIER (GridLayoutModel.InitRows note). */
|
||||
function evenPercents(count: number): number[] {
|
||||
const out: number[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
out.push(Math.floor((MULTIPLIER * (i + 1)) / count) - Math.floor((MULTIPLIER * i) / count))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function initColumns(count: number): GridLayout {
|
||||
return {
|
||||
rows: 1,
|
||||
columns: count,
|
||||
rowPercents: [MULTIPLIER],
|
||||
columnPercents: evenPercents(count),
|
||||
cellChildMap: [Array.from({ length: count }, (_, i) => i)]
|
||||
}
|
||||
}
|
||||
|
||||
export function initRows(count: number): GridLayout {
|
||||
return {
|
||||
rows: count,
|
||||
columns: 1,
|
||||
rowPercents: evenPercents(count),
|
||||
columnPercents: [MULTIPLIER],
|
||||
cellChildMap: Array.from({ length: count }, (_, i) => [i])
|
||||
}
|
||||
}
|
||||
|
||||
export function initGrid(zoneCount: number): GridLayout {
|
||||
let rows = 1
|
||||
|
||||
while (Math.floor(zoneCount / rows) >= rows) {
|
||||
rows++
|
||||
}
|
||||
|
||||
rows--
|
||||
let cols = Math.floor(zoneCount / rows)
|
||||
|
||||
if (zoneCount % rows !== 0) {
|
||||
cols++
|
||||
}
|
||||
|
||||
const model: GridLayout = {
|
||||
rows,
|
||||
columns: cols,
|
||||
rowPercents: evenPercents(rows),
|
||||
columnPercents: evenPercents(cols),
|
||||
cellChildMap: Array.from({ length: rows }, () => new Array<number>(cols).fill(0))
|
||||
}
|
||||
|
||||
let index = 0
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
model.cellChildMap[row][col] = index++
|
||||
|
||||
if (index === zoneCount) {
|
||||
index--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Priority Grid" template (GridLayoutModel._priorityData, decoded from
|
||||
* its byte format: percents are `hi * 256 + lo`). Falls back to initGrid for
|
||||
* counts beyond the table, as in the original.
|
||||
*/
|
||||
export function initPriorityGrid(zoneCount: number): GridLayout {
|
||||
if (zoneCount === 2) {
|
||||
return { rows: 1, columns: 2, rowPercents: [MULTIPLIER], columnPercents: [6667, 3333], cellChildMap: [[0, 1]] }
|
||||
}
|
||||
|
||||
if (zoneCount === 3) {
|
||||
return {
|
||||
rows: 1,
|
||||
columns: 3,
|
||||
rowPercents: [MULTIPLIER],
|
||||
columnPercents: [2500, 5000, 2500],
|
||||
cellChildMap: [[0, 1, 2]]
|
||||
}
|
||||
}
|
||||
|
||||
return initGrid(zoneCount)
|
||||
}
|
||||
|
||||
/** GridLayoutModel.IsModelValid, extended with the rectangular-span check. */
|
||||
export function isGridValid(model: unknown): model is GridLayout {
|
||||
if (!model || typeof model !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const m = model as GridLayout
|
||||
|
||||
if (typeof m.rows !== 'number' || typeof m.columns !== 'number' || m.rows <= 0 || m.columns <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(m.rowPercents) ||
|
||||
!Array.isArray(m.columnPercents) ||
|
||||
m.rowPercents.length !== m.rows ||
|
||||
m.columnPercents.length !== m.columns ||
|
||||
m.rowPercents.some(x => typeof x !== 'number' || x < 1) ||
|
||||
m.columnPercents.some(x => typeof x !== 'number' || x < 1)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(m.cellChildMap) ||
|
||||
m.cellChildMap.length !== m.rows ||
|
||||
m.cellChildMap.some(r => !Array.isArray(r) || r.length !== m.columns || r.some(c => typeof c !== 'number'))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const rowPrefix = prefixSum(m.rowPercents)
|
||||
const colPrefix = prefixSum(m.columnPercents)
|
||||
|
||||
if (rowPrefix[m.rows] !== MULTIPLIER || colPrefix[m.columns] !== MULTIPLIER) {
|
||||
return false
|
||||
}
|
||||
|
||||
return modelToZones(m) !== null
|
||||
}
|
||||
198
apps/desktop/src/components/pane-shell/tree/grid-to-tree.ts
Normal file
198
apps/desktop/src/components/pane-shell/tree/grid-to-tree.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
/**
|
||||
* Grid -> tree bridge. A FancyZones grid whose zones can be produced by
|
||||
* recursive guillotine cuts (every FancyZones template, and almost every
|
||||
* practical layout) converts exactly to our runtime `LayoutNode` tree:
|
||||
* full-length cut lines become splits with weights from the cut positions.
|
||||
* Non-guillotine arrangements (interlocking pinwheels) return null and the
|
||||
* editor disables Save with an explanation.
|
||||
*/
|
||||
|
||||
import type { GridLayout, GridZone } from './grid-model'
|
||||
import { modelToZones } from './grid-model'
|
||||
import { group, type LayoutNode, normalize, split } from './model'
|
||||
|
||||
function cutCandidates(zones: GridZone[], axis: 'x' | 'y'): number[] {
|
||||
const coords = new Set(zones.flatMap(z => (axis === 'x' ? [z.left, z.right] : [z.top, z.bottom])))
|
||||
|
||||
// Interior lines only.
|
||||
return [...coords].sort((a, b) => a - b).slice(1, -1)
|
||||
}
|
||||
|
||||
function isValidCut(zones: GridZone[], axis: 'x' | 'y', at: number): boolean {
|
||||
return zones.every(zone => (axis === 'x' ? zone.right <= at || zone.left >= at : zone.bottom <= at || zone.top >= at))
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively cut the zone set. Collects ALL valid cuts on the chosen axis at
|
||||
* once so "three columns" becomes one flat 3-child split, not nested pairs.
|
||||
*/
|
||||
function cut(zones: GridZone[], assignPane: (zoneIndex: number) => string[]): LayoutNode | null {
|
||||
if (zones.length === 1) {
|
||||
// Zones without panes become EMPTY groups — editor-authored drop targets
|
||||
// that live until the first structural op prunes them (normalize).
|
||||
return group(assignPane(zones[0].index))
|
||||
}
|
||||
|
||||
for (const axis of ['x', 'y'] as const) {
|
||||
const cuts = cutCandidates(zones, axis).filter(at => isValidCut(zones, axis, at))
|
||||
|
||||
if (cuts.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const start = Math.min(...zones.map(z => (axis === 'x' ? z.left : z.top)))
|
||||
const end = Math.max(...zones.map(z => (axis === 'x' ? z.right : z.bottom)))
|
||||
const lines = [start, ...cuts, end]
|
||||
const children: LayoutNode[] = []
|
||||
const weights: number[] = []
|
||||
|
||||
for (let i = 0; i < lines.length - 1; i++) {
|
||||
const lo = lines[i]
|
||||
const hi = lines[i + 1]
|
||||
|
||||
const slice = zones.filter(zone =>
|
||||
axis === 'x' ? zone.left >= lo && zone.right <= hi : zone.top >= lo && zone.bottom <= hi
|
||||
)
|
||||
|
||||
if (slice.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const child = cut(slice, assignPane)
|
||||
|
||||
if (child) {
|
||||
children.push(child)
|
||||
weights.push(hi - lo)
|
||||
}
|
||||
}
|
||||
|
||||
if (children.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (children.length === 1) {
|
||||
return children[0]
|
||||
}
|
||||
|
||||
return split(axis === 'x' ? 'row' : 'column', children, weights)
|
||||
}
|
||||
|
||||
// No full-length cut exists on either axis: non-guillotine (pinwheel).
|
||||
return null
|
||||
}
|
||||
|
||||
export type PanePlacementHint = 'main' | 'left' | 'right' | 'top' | 'bottom'
|
||||
|
||||
export interface PlacedPane {
|
||||
id: string
|
||||
placement?: PanePlacementHint
|
||||
}
|
||||
|
||||
const CENTER = 5000 // MULTIPLIER / 2
|
||||
|
||||
interface ZoneGeo {
|
||||
index: number
|
||||
area: number
|
||||
cx: number
|
||||
cy: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic zone assignment: panes claim zones by ROLE, matched on geometry —
|
||||
* `main` takes the largest zone, `left`/`right`/`top`/`bottom` take zones whose
|
||||
* centroid actually sits on that side (with a small size tiebreak). A hinted
|
||||
* pane with no acceptable zone left STACKS with its role-mates (or main)
|
||||
* instead of squatting in some random cell; unhinted panes fill what remains
|
||||
* biggest-first; extra zones stay empty.
|
||||
*/
|
||||
function assignZones(zones: GridZone[], panes: PlacedPane[]): Map<number, string[]> {
|
||||
const geo: ZoneGeo[] = zones.map(z => ({
|
||||
index: z.index,
|
||||
area: (z.right - z.left) * (z.bottom - z.top),
|
||||
cx: (z.left + z.right) / 2,
|
||||
cy: (z.top + z.bottom) / 2
|
||||
}))
|
||||
|
||||
const remaining = new Map(geo.map(g => [g.index, g]))
|
||||
const assignments = new Map<number, string[]>()
|
||||
const zoneForRole = new Map<string, number>()
|
||||
|
||||
// Score = fit for the role; accept = the zone genuinely sits on that side.
|
||||
const roles: Record<PanePlacementHint, { accept: (g: ZoneGeo) => boolean; score: (g: ZoneGeo) => number }> = {
|
||||
main: { accept: () => true, score: g => g.area },
|
||||
left: { accept: g => g.cx < CENTER, score: g => CENTER - g.cx + g.area / 1e8 },
|
||||
right: { accept: g => g.cx > CENTER, score: g => g.cx - CENTER + g.area / 1e8 },
|
||||
top: { accept: g => g.cy < CENTER, score: g => CENTER - g.cy + g.area / 1e8 },
|
||||
bottom: { accept: g => g.cy > CENTER, score: g => g.cy - CENTER + g.area / 1e8 }
|
||||
}
|
||||
|
||||
const claim = (pane: PlacedPane, role: PanePlacementHint | '_') => {
|
||||
const spec = role === '_' ? roles.main : roles[role]
|
||||
let best: ZoneGeo | null = null
|
||||
|
||||
for (const g of remaining.values()) {
|
||||
if (spec.accept(g) && (!best || spec.score(g) > spec.score(best))) {
|
||||
best = g
|
||||
}
|
||||
}
|
||||
|
||||
if (best) {
|
||||
remaining.delete(best.index)
|
||||
assignments.set(best.index, [pane.id])
|
||||
zoneForRole.set(role, best.index)
|
||||
|
||||
if (role === 'main' || !zoneForRole.has('main')) {
|
||||
zoneForRole.set('main', zoneForRole.get('main') ?? best.index)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// No acceptable zone left: stack with role-mates, else with main, else last.
|
||||
const home = zoneForRole.get(role) ?? zoneForRole.get('main') ?? [...assignments.keys()].pop()
|
||||
|
||||
if (home !== undefined) {
|
||||
assignments.get(home)?.push(pane.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Placement priority: main anchors first, then sided panes, then the rest.
|
||||
const rank = (p: PlacedPane) => (p.placement === 'main' ? 0 : p.placement ? 1 : 2)
|
||||
|
||||
for (const pane of [...panes].sort((a, b) => rank(a) - rank(b))) {
|
||||
claim(pane, pane.placement ?? '_')
|
||||
}
|
||||
|
||||
return assignments
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a grid to a tree. Panes carry placement hints (from their
|
||||
* contribution's `data.placement`) and land in geometrically fitting zones;
|
||||
* zones beyond the pane count stay as EMPTY zones.
|
||||
*/
|
||||
export function gridToTree(gridModel: GridLayout, panes: PlacedPane[]): LayoutNode | null {
|
||||
const zones = modelToZones(gridModel)
|
||||
|
||||
if (!zones || zones.length === 0 || panes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const assignments = assignZones(zones, panes)
|
||||
const result = cut(zones, zoneIndex => assignments.get(zoneIndex) ?? [])
|
||||
|
||||
return result ? normalize(result) : null
|
||||
}
|
||||
|
||||
/** True when the grid is expressible as a tree (guillotine-cuttable). */
|
||||
export function gridIsTreeExpressible(gridModel: GridLayout): boolean {
|
||||
const zones = modelToZones(gridModel)
|
||||
|
||||
if (!zones) {
|
||||
return false
|
||||
}
|
||||
|
||||
const probe = cut(zones, () => ['probe'])
|
||||
|
||||
return probe !== null
|
||||
}
|
||||
617
apps/desktop/src/components/pane-shell/tree/model.ts
Normal file
617
apps/desktop/src/components/pane-shell/tree/model.ts
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
/**
|
||||
* Layout tree model — the Dockview-style structure that replaces the
|
||||
* rails/bands grammar. Two node kinds:
|
||||
*
|
||||
* - `split`: children laid out along an orientation with fractional weights.
|
||||
* - `group`: a stack of panes (tabs) with one active; may be minimized to
|
||||
* its header strip (DetailPane semantics).
|
||||
*
|
||||
* Everything the old grammar special-cased is just tree shape here: a "top row
|
||||
* spanning the right rail" is a column split; "a cell inside a column" is a
|
||||
* stacked group; spans fall out of tree position. All operations are pure and
|
||||
* return new trees; `normalize` keeps the structure canonical (no empty
|
||||
* groups, no single-child or same-orientation nested splits).
|
||||
*/
|
||||
|
||||
export type Orientation = 'row' | 'column'
|
||||
|
||||
export interface SplitNode {
|
||||
type: 'split'
|
||||
id: string
|
||||
orientation: Orientation
|
||||
children: LayoutNode[]
|
||||
/** Parallel to children; relative flex weights. */
|
||||
weights: number[]
|
||||
}
|
||||
|
||||
export interface GroupNode {
|
||||
type: 'group'
|
||||
id: string
|
||||
/** Pane ids stacked in this group (rendered as tabs when > 1). */
|
||||
panes: string[]
|
||||
/** The visible pane. */
|
||||
active: string
|
||||
/** Collapsed to header strip (chevron restores). */
|
||||
minimized?: boolean
|
||||
/**
|
||||
* Header hidden entirely (double-click the header to hide, double-click the
|
||||
* zone's top edge to bring it back). Minimize always shows the header —
|
||||
* a minimized group IS its header.
|
||||
*/
|
||||
headerHidden?: boolean
|
||||
}
|
||||
|
||||
export type LayoutNode = SplitNode | GroupNode
|
||||
|
||||
/** Where a dragged pane lands relative to a target group. */
|
||||
export type DropPosition = 'center' | 'left' | 'right' | 'top' | 'bottom'
|
||||
|
||||
export type RootEdge = 'left' | 'right' | 'top' | 'bottom'
|
||||
|
||||
let seq = 0
|
||||
export const nodeId = (kind: string) => `${kind}-${Date.now().toString(36)}-${(seq++).toString(36)}`
|
||||
|
||||
export const group = (panes: string[], options?: Partial<Omit<GroupNode, 'type' | 'panes'>>): GroupNode => ({
|
||||
type: 'group',
|
||||
id: options?.id ?? nodeId('g'),
|
||||
panes,
|
||||
active: options?.active ?? panes[0] ?? '',
|
||||
minimized: options?.minimized,
|
||||
headerHidden: options?.headerHidden
|
||||
})
|
||||
|
||||
export const split = (
|
||||
orientation: Orientation,
|
||||
children: LayoutNode[],
|
||||
weights?: number[],
|
||||
id?: string
|
||||
): SplitNode => ({
|
||||
type: 'split',
|
||||
id: id ?? nodeId('s'),
|
||||
orientation,
|
||||
children,
|
||||
weights: weights ?? children.map(() => 1)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function findGroup(node: LayoutNode, groupId: string): GroupNode | null {
|
||||
if (node.type === 'group') {
|
||||
return node.id === groupId ? node : null
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
const hit = findGroup(child, groupId)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function findGroupOfPane(node: LayoutNode, paneId: string): GroupNode | null {
|
||||
if (node.type === 'group') {
|
||||
return node.panes.includes(paneId) ? node : null
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
const hit = findGroupOfPane(child, paneId)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function allPaneIds(node: LayoutNode): string[] {
|
||||
return node.type === 'group' ? [...node.panes] : node.children.flatMap(allPaneIds)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structural edits (pure)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Canonical form: unwrap single-child splits, flatten same-orientation
|
||||
* nesting (weights scaled into the parent's slot), and PRUNE EMPTY GROUPS —
|
||||
* dragging the last pane out of a zone closes the zone and its siblings
|
||||
* absorb the space (VS Code semantics). Keeping empties as "stable regions"
|
||||
* (the original FancyZones rule) let invisible residue accumulate into
|
||||
* corrupt-feeling structure (`row([]|[])` eating half a slot); authored
|
||||
* empty zones still exist inside the zone editor's own grid model, and an
|
||||
* editor-applied tree keeps them until the first structural op.
|
||||
*/
|
||||
export function normalize(node: LayoutNode): LayoutNode | null {
|
||||
if (node.type === 'group') {
|
||||
if (node.panes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const active = node.panes.includes(node.active) ? node.active : node.panes[0]
|
||||
// A zone down to one pane clears a redundant HIDDEN override (the lone-pane
|
||||
// default is already headerless) but KEEPS an explicit SHOWN override —
|
||||
// once a zone has ever had a tab bar, closing back to one tab leaves it
|
||||
// shown (sticky bar; the off switch is "Hide tab bar"). `false` survives.
|
||||
const headerHidden = node.panes.length <= 1 && node.headerHidden !== false ? undefined : node.headerHidden
|
||||
|
||||
if (active === node.active && headerHidden === node.headerHidden) {
|
||||
return node
|
||||
}
|
||||
|
||||
return { ...node, active, headerHidden }
|
||||
}
|
||||
|
||||
const children: LayoutNode[] = []
|
||||
const weights: number[] = []
|
||||
|
||||
node.children.forEach((child, i) => {
|
||||
const kept = normalize(child)
|
||||
|
||||
if (!kept) {
|
||||
return
|
||||
}
|
||||
|
||||
if (kept.type === 'split' && kept.orientation === node.orientation) {
|
||||
// Flatten: distribute this slot's weight across the flattened children
|
||||
// proportionally to their internal weights.
|
||||
const total = kept.weights.reduce((a, b) => a + b, 0) || 1
|
||||
kept.children.forEach((grandchild, j) => {
|
||||
children.push(grandchild)
|
||||
weights.push((node.weights[i] ?? 1) * ((kept.weights[j] ?? 1) / total))
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
children.push(kept)
|
||||
weights.push(node.weights[i] ?? 1)
|
||||
})
|
||||
|
||||
if (children.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (children.length === 1) {
|
||||
return children[0]
|
||||
}
|
||||
|
||||
return { ...node, children, weights }
|
||||
}
|
||||
|
||||
/** Remove a pane wherever it lives. Closing the ACTIVE tab activates its
|
||||
* previous neighbor (the next one when it was first) — browser-tab feel,
|
||||
* never a jump to the strip's start. */
|
||||
export function removePane(node: LayoutNode, paneId: string): LayoutNode | null {
|
||||
const walk = (n: LayoutNode): LayoutNode => {
|
||||
if (n.type === 'group') {
|
||||
const at = n.panes.indexOf(paneId)
|
||||
|
||||
if (at === -1) {
|
||||
return n
|
||||
}
|
||||
|
||||
const panes = n.panes.filter(p => p !== paneId)
|
||||
|
||||
return { ...n, panes, active: n.active === paneId ? panes[Math.max(0, at - 1)] : n.active }
|
||||
}
|
||||
|
||||
return { ...n, children: n.children.map(walk) }
|
||||
}
|
||||
|
||||
return normalize(walk(node))
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert `paneId` at `target` group: `center` joins the stack (as a tab);
|
||||
* an edge splits the group in that direction. If the neighboring split
|
||||
* already runs in that orientation the new group is spliced in beside the
|
||||
* target instead of nesting (normalize would flatten it anyway).
|
||||
*/
|
||||
export function insertAtGroup(
|
||||
node: LayoutNode,
|
||||
targetGroupId: string,
|
||||
paneId: string,
|
||||
pos: DropPosition,
|
||||
/** Center drops only: stack BEFORE this pane id (`null`/omitted = append) —
|
||||
* the tab-strip insertion divider's slot. */
|
||||
before?: null | string,
|
||||
/** Front the inserted pane — TRUE for a gesture (drop/reveal), FALSE for silent
|
||||
* adoption (logs stacking into the terminal zone must not steal its tab). */
|
||||
activate: boolean = true
|
||||
): LayoutNode | null {
|
||||
const walk = (n: LayoutNode): LayoutNode => {
|
||||
if (n.type === 'group') {
|
||||
if (n.id !== targetGroupId) {
|
||||
return n
|
||||
}
|
||||
|
||||
if (pos === 'center') {
|
||||
const at = before ? n.panes.indexOf(before) : -1
|
||||
const panes = at >= 0 ? [...n.panes.slice(0, at), paneId, ...n.panes.slice(at)] : [...n.panes, paneId]
|
||||
|
||||
// Gaining a pane pins the header EXPLICITLY shown (not just cleared):
|
||||
// a stack you can't see is a trap, and once a zone has ever stacked
|
||||
// the bar STAYS when it drops back to one tab — the auto-hide flicker
|
||||
// while dragging tabs around felt broken. Hiding is the user's call
|
||||
// (double-click / zone menu). Active moves only on a gesture; an empty
|
||||
// target has no prior tab, so the newcomer takes it regardless.
|
||||
const active = activate || n.panes.length === 0 ? paneId : n.active
|
||||
|
||||
return { ...n, panes, active, headerHidden: false }
|
||||
}
|
||||
|
||||
const orientation: Orientation = pos === 'left' || pos === 'right' ? 'row' : 'column'
|
||||
const leading = pos === 'left' || pos === 'top'
|
||||
const added = group([paneId])
|
||||
const children = leading ? [added, n] : [n, added]
|
||||
|
||||
return split(orientation, children, [1, 1])
|
||||
}
|
||||
|
||||
return { ...n, children: n.children.map(walk) }
|
||||
}
|
||||
|
||||
return normalize(walk(node))
|
||||
}
|
||||
|
||||
/**
|
||||
* The tree's VISIBLE shape: pane stacks + split orientations, with empty
|
||||
* groups skipped (editor-session trees may still hold them) and single-child
|
||||
* runs unwrapped. Two trees with equal signatures are indistinguishable on
|
||||
* screen regardless of node ids.
|
||||
*/
|
||||
function shapeSignature(node: LayoutNode): string {
|
||||
if (node.type === 'group') {
|
||||
return node.panes.length > 0 ? `[${node.panes.join(',')}]` : ''
|
||||
}
|
||||
|
||||
const children = node.children.map(shapeSignature).filter(Boolean)
|
||||
|
||||
if (children.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return children.length === 1 ? children[0] : `${node.orientation}(${children.join('|')})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Move = remove + insert. If the target group vanished during removal (the
|
||||
* pane was its only occupant), the move is a no-op. A move whose result
|
||||
* LOOKS identical to the current layout is also a no-op — e.g. a "split
|
||||
* bottom" drop onto the zone the pane already sits alone below would only
|
||||
* rebuild the same arrangement under a fresh zone id.
|
||||
*/
|
||||
export function movePane(
|
||||
root: LayoutNode,
|
||||
paneId: string,
|
||||
target: { groupId: string; pos: DropPosition; before?: null | string }
|
||||
): LayoutNode {
|
||||
const from = findGroupOfPane(root, paneId)
|
||||
|
||||
// No-op guards: dropping a pane onto its own single-pane group.
|
||||
if (from && from.id === target.groupId && from.panes.length === 1) {
|
||||
return root
|
||||
}
|
||||
|
||||
const without = removePane(root, paneId)
|
||||
|
||||
if (!without) {
|
||||
// The pane was the only thing in the tree.
|
||||
return root
|
||||
}
|
||||
|
||||
if (!findGroup(without, target.groupId)) {
|
||||
return root
|
||||
}
|
||||
|
||||
const next = insertAtGroup(without, target.groupId, paneId, target.pos, target.before) ?? root
|
||||
|
||||
return shapeSignature(next) === shapeSignature(root) ? root : next
|
||||
}
|
||||
|
||||
/** Group ids of every leaf under a node, in tree order. */
|
||||
export function groupLeafIds(node: LayoutNode): string[] {
|
||||
return node.type === 'group' ? [node.id] : node.children.flatMap(groupLeafIds)
|
||||
}
|
||||
|
||||
function pathToGroup(node: LayoutNode, groupId: string): LayoutNode[] | null {
|
||||
if (node.type === 'group') {
|
||||
return node.id === groupId ? [node] : null
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
const sub = pathToGroup(child, groupId)
|
||||
|
||||
if (sub) {
|
||||
return [node, ...sub]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const OPPOSITE_EDGE: Record<RootEdge, RootEdge> = { bottom: 'top', left: 'right', right: 'left', top: 'bottom' }
|
||||
|
||||
/** The viable group touching `edge` of this subtree. Along the edge's axis
|
||||
* children are scanned edge-first — a non-viable zone is display:none, so the
|
||||
* next sibling IS the visual edge; across it, every child touches the edge. */
|
||||
function edgeGroup(node: LayoutNode, edge: RootEdge, viable: (g: GroupNode) => boolean): GroupNode | null {
|
||||
if (node.type === 'group') {
|
||||
return viable(node) ? node : null
|
||||
}
|
||||
|
||||
const along = (node.orientation === 'row') === (edge === 'left' || edge === 'right')
|
||||
const children = along && (edge === 'right' || edge === 'bottom') ? [...node.children].reverse() : node.children
|
||||
|
||||
for (const child of children) {
|
||||
const hit = edgeGroup(child, edge, viable)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The viable zone VISUALLY adjacent to `groupId` on `side` (the target of the
|
||||
* zone menu's "Move left/right/up/down"). Walks up to the nearest ancestor
|
||||
* split running along that axis with a sibling on that side, then descends to
|
||||
* the sibling's closest viable leaf; subtrees whose every zone fails `viable`
|
||||
* (all panes hidden) are skipped, matching their collapsed rendering.
|
||||
*/
|
||||
export function adjacentGroup(
|
||||
root: LayoutNode,
|
||||
groupId: string,
|
||||
side: RootEdge,
|
||||
viable: (g: GroupNode) => boolean
|
||||
): GroupNode | null {
|
||||
const path = pathToGroup(root, groupId)
|
||||
|
||||
if (!path) {
|
||||
return null
|
||||
}
|
||||
|
||||
const orientation: Orientation = side === 'left' || side === 'right' ? 'row' : 'column'
|
||||
const forward = side === 'right' || side === 'bottom'
|
||||
|
||||
for (let i = path.length - 2; i >= 0; i--) {
|
||||
const parent = path[i]
|
||||
|
||||
if (parent.type !== 'split' || parent.orientation !== orientation) {
|
||||
continue
|
||||
}
|
||||
|
||||
const index = parent.children.indexOf(path[i + 1])
|
||||
|
||||
const siblings = forward ? parent.children.slice(index + 1) : parent.children.slice(0, index).reverse()
|
||||
|
||||
for (const sibling of siblings) {
|
||||
const hit = edgeGroup(sibling, OPPOSITE_EDGE[side], viable)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function sameSet(ids: string[], set: Set<string>): boolean {
|
||||
return ids.length === set.size && ids.every(id => set.has(id))
|
||||
}
|
||||
|
||||
/** The node whose complete leaf set equals `set` (a rectangular region in a
|
||||
* guillotine tree is always exactly one subtree), or null. */
|
||||
function findCover(node: LayoutNode, set: Set<string>): LayoutNode | null {
|
||||
if (sameSet(groupLeafIds(node), set)) {
|
||||
return node
|
||||
}
|
||||
|
||||
if (node.type === 'split') {
|
||||
for (const child of node.children) {
|
||||
const hit = findCover(child, set)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* FancyZones span: merge the highlighted zones into ONE group holding
|
||||
* `paneId`, absorbing any panes that lived in those zones as tabs. Only works
|
||||
* when the highlighted set forms a rectangular subtree (it always does for a
|
||||
* combined zone range on a guillotine tree); returns null otherwise so the
|
||||
* caller can fall back to a single-zone drop.
|
||||
*/
|
||||
export function mergeZonesWithPane(root: LayoutNode, groupIds: string[], paneId: string): LayoutNode | null {
|
||||
const set = new Set(groupIds)
|
||||
|
||||
if (set.size <= 1 || !findCover(root, set)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Panes from the merged zones (tree order), minus the dragged one.
|
||||
const panesInSet: string[] = []
|
||||
|
||||
const collect = (n: LayoutNode) => {
|
||||
if (n.type === 'group') {
|
||||
if (set.has(n.id)) {
|
||||
panesInSet.push(...n.panes.filter(p => p !== paneId))
|
||||
}
|
||||
} else {
|
||||
n.children.forEach(collect)
|
||||
}
|
||||
}
|
||||
|
||||
collect(root)
|
||||
|
||||
// If the dragged pane lives OUTSIDE the merged set, pull it from its origin
|
||||
// first (leaving that origin an empty zone). Inside the set it's absorbed.
|
||||
const origin = findGroupOfPane(root, paneId)
|
||||
let working = root
|
||||
|
||||
if (origin && !set.has(origin.id)) {
|
||||
working = removePane(root, paneId) ?? root
|
||||
}
|
||||
|
||||
const merged = group([paneId, ...panesInSet])
|
||||
|
||||
const replace = (n: LayoutNode): LayoutNode => {
|
||||
if (sameSet(groupLeafIds(n), set)) {
|
||||
return merged
|
||||
}
|
||||
|
||||
return n.type === 'split' ? { ...n, children: n.children.map(replace) } : n
|
||||
}
|
||||
|
||||
return normalize(replace(working))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attribute edits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mapGroups(node: LayoutNode, fn: (g: GroupNode) => GroupNode): LayoutNode {
|
||||
return node.type === 'group' ? fn(node) : { ...node, children: node.children.map(c => mapGroups(c, fn)) }
|
||||
}
|
||||
|
||||
export function setActivePane(root: LayoutNode, groupId: string, paneId: string): LayoutNode {
|
||||
return mapGroups(root, g => (g.id === groupId && g.panes.includes(paneId) ? { ...g, active: paneId } : g))
|
||||
}
|
||||
|
||||
/** Reorder a pane within its group's tab stack (browser-tab drag semantics). */
|
||||
export function reorderPaneInGroup(root: LayoutNode, groupId: string, paneId: string, toIndex: number): LayoutNode {
|
||||
return mapGroups(root, g => {
|
||||
if (g.id !== groupId || !g.panes.includes(paneId)) {
|
||||
return g
|
||||
}
|
||||
|
||||
const without = g.panes.filter(p => p !== paneId)
|
||||
const index = Math.max(0, Math.min(without.length, toIndex))
|
||||
const panes = [...without.slice(0, index), paneId, ...without.slice(index)]
|
||||
|
||||
return { ...g, panes }
|
||||
})
|
||||
}
|
||||
|
||||
export function setGroupMinimized(root: LayoutNode, groupId: string, minimized: boolean): LayoutNode {
|
||||
return mapGroups(root, g => (g.id === groupId ? { ...g, minimized } : g))
|
||||
}
|
||||
|
||||
export function setGroupHeaderHidden(root: LayoutNode, groupId: string, headerHidden: boolean): LayoutNode {
|
||||
return mapGroups(root, g => (g.id === groupId ? { ...g, headerHidden } : g))
|
||||
}
|
||||
|
||||
function replaceNode(node: LayoutNode, id: string, make: (g: GroupNode) => LayoutNode): LayoutNode {
|
||||
if (node.type === 'group') {
|
||||
return node.id === id ? make(node) : node
|
||||
}
|
||||
|
||||
return { ...node, children: node.children.map(c => replaceNode(c, id, make)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a zone: `movePaneId` (one of SEVERAL panes in the group) moves into
|
||||
* the new zone on `side` — VS Code "split right", split and move in one
|
||||
* gesture. A lone pane can't split away from itself: no-op (normalize prunes
|
||||
* the empty zone the split would have minted).
|
||||
*/
|
||||
export function splitGroupZone(root: LayoutNode, groupId: string, side: RootEdge, movePaneId: string): LayoutNode {
|
||||
const orientation: Orientation = side === 'left' || side === 'right' ? 'row' : 'column'
|
||||
const before = side === 'left' || side === 'top'
|
||||
|
||||
return (
|
||||
normalize(
|
||||
replaceNode(root, groupId, g => {
|
||||
if (g.panes.length < 2 || !g.panes.includes(movePaneId)) {
|
||||
return g
|
||||
}
|
||||
|
||||
const added = group([movePaneId])
|
||||
const remaining = { ...g, panes: g.panes.filter(p => p !== movePaneId) }
|
||||
|
||||
return split(orientation, before ? [added, remaining] : [remaining, added], [1, 1])
|
||||
})
|
||||
) ?? root
|
||||
)
|
||||
}
|
||||
|
||||
/** Mirror the layout HORIZONTALLY (the titlebar flip toggle / ⌘\): reverse
|
||||
* every ROW split's child order at EVERY depth, so left↔right flips
|
||||
* everywhere. A right rail lands on the left with its OWN internal order
|
||||
* mirrored too — so preview stays directly beside the file tree instead of
|
||||
* jumping to the far edge (a shallow root-only reverse left nested rails in
|
||||
* place). COLUMN splits keep their top↔bottom order (the terminal stays at
|
||||
* the bottom). Its own involution: flipping twice is the identity. */
|
||||
export function mirrorTreeHorizontal(root: LayoutNode): LayoutNode {
|
||||
if (root.type === 'group') {
|
||||
return root
|
||||
}
|
||||
|
||||
const children = root.children.map(mirrorTreeHorizontal)
|
||||
|
||||
return root.orientation === 'row'
|
||||
? { ...root, children: children.reverse(), weights: [...root.weights].reverse() }
|
||||
: { ...root, children }
|
||||
}
|
||||
|
||||
export function setSplitWeights(root: LayoutNode, splitId: string, weights: number[]): LayoutNode {
|
||||
if (root.type === 'split') {
|
||||
if (root.id === splitId) {
|
||||
return { ...root, weights }
|
||||
}
|
||||
|
||||
return { ...root, children: root.children.map(c => setSplitWeights(c, splitId, weights)) }
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation (persisted trees are untrusted)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isLayoutNode(value: unknown): value is LayoutNode {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const n = value as Record<string, unknown>
|
||||
|
||||
if (n.type === 'group') {
|
||||
return (
|
||||
typeof n.id === 'string' &&
|
||||
Array.isArray(n.panes) &&
|
||||
n.panes.every(p => typeof p === 'string') &&
|
||||
typeof n.active === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
if (n.type === 'split') {
|
||||
return (
|
||||
typeof n.id === 'string' &&
|
||||
(n.orientation === 'row' || n.orientation === 'column') &&
|
||||
Array.isArray(n.children) &&
|
||||
n.children.length > 0 &&
|
||||
n.children.every(isLayoutNode) &&
|
||||
Array.isArray(n.weights) &&
|
||||
n.weights.length === n.children.length &&
|
||||
n.weights.every(w => typeof w === 'number' && Number.isFinite(w) && w > 0)
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue