From ffdcafb45f6c1ef0b5fb2add63f9e193a1e2e4ac Mon Sep 17 00:00:00 2001 From: GeeeekExplorer <2651904866@qq.com> Date: Wed, 5 Aug 2026 16:42:51 +0800 Subject: [PATCH 1/5] feat(web): done dot on sessions that finished while unviewed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session that stops running while it is not the selected session arms a green 'done' reminder dot on its sidebar row, so the operator notices a finished background session and returns to it; opening the session clears the dot, and a re-run re-arms it on completion. SessionManager owns the reminder set (a sibling of the waiting-approval bit): a running->idle edge of a non-selected session arms it, select() consumes it, removal prunes it, and it survives connection generations. The bit rides SessionListEntry/SessionSummary into the workspace browser rows, which render the existing StateDot done state (running keeps the spinner) and label the hover card '已完成/Completed'. --- .../runtime/src/client/sessions/lineage.ts | 5 + .../runtime/src/client/sessions/manager.ts | 67 +++++++++- .../runtime/src/client/sessions/service.ts | 3 + packages/client/runtime/tests/lineage.spec.ts | 7 + packages/client/runtime/tests/manager.spec.ts | 125 ++++++++++++++++++ .../client/ui-workspace/src/client/locales.ts | 2 + .../ui-workspace/src/client/rows/Rows.tsx | 12 +- .../client/ui-workspace/src/client/tree.ts | 6 + .../client/ui-workspace/tests/rows.spec.tsx | 66 +++++++-- .../client/ui-workspace/tests/tree.spec.ts | 19 +++ 10 files changed, 297 insertions(+), 15 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 115370488f..69094f2964 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -29,6 +29,8 @@ export interface SessionListEntry { projectionValues?: Readonly> /** User interaction currently blocking this session, derived from live mux frames. */ pendingInteraction?: PendingInteractionStatus + /** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */ + completed: boolean /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ depth: number } @@ -39,11 +41,13 @@ export interface SessionListEntry { * hydrated list from mutable timestamps. * @param summaries - the host's session.list items. * @param pendingInteractions - current manager-owned interaction status by session. + * @param completed - sessions with a pending completion reminder (manager-owned live fact; absent = false). * @returns display rows in render order. */ export function flattenLineage( summaries: readonly TitledSessionSummary[], pendingInteractions?: ReadonlyMap, + completed?: ReadonlySet, ): SessionListEntry[] { const byId = new Map() for (const s of summaries) byId.set(s.sessionId, s) @@ -72,6 +76,7 @@ export function flattenLineage( out.push({ ...s, ...(pendingInteraction === undefined ? {} : { pendingInteraction }), + completed: completed?.has(s.sessionId) ?? false, depth, }) const kids = children.get(s.sessionId) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index c9961592ba..64199c4812 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -109,6 +109,14 @@ export class SessionManager { * sessions never instantiated. Cleared per connection generation — the reopen replay re-adds * still-pending requests — and on session-removed. */ private readonly pendingInteractions = new Map>() + /** + * Sessions that finished running while not selected — the sidebar's green + * "done" reminder (manager-owned, survives connection generations; cleared + * on select and session-removed, re-armed by the next completion). + */ + private readonly completedNotifications = new Set() + /** Last-observed running bits per session; the true→false edge here arms {@link completedNotifications}. */ + private readonly prevRunning = new Map() /** Per-session projection value stores, retained independently of instance arrival (the * title-snapshot precedent, generalized): push frames land here whether or not the Session * is instantiated (list rows read the 'title' key), and an instantiated Session adopts the @@ -175,6 +183,8 @@ export class SessionManager { : this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false, ) this.selected = sessionId + // Looking at the session consumes its completion reminder (dot clears). + this.completedNotifications.delete(sessionId) void this.refreshSubagents(sessionId) this.notifier.notifyNow() } @@ -192,6 +202,7 @@ export class SessionManager { this.addresses.set(address.childSessionId, address) this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false) this.selected = address.childSessionId + this.completedNotifications.delete(address.childSessionId) void this.refreshSubagents(address.childSessionId) this.notifier.notifyNow() } @@ -414,13 +425,28 @@ export class SessionManager { try { const { result } = await this.api.sessions.list({}) if (result.ok) { - let summaries = this.listPhase === 'pending' + const baseline = this.listPhase === 'pending' ? result.value.items : mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId) - for (const mutation of mutations) summaries = applyMutation(summaries, mutation) + // Seed first observations from the pull-time baseline BEFORE replaying + // in-flight mutations, then reconcile the reminders after EVERY + // replayed mutation: an edge that happens entirely between mutations + // (baseline idle → running → idle) must still arm, which a single + // sync on the folded result would collapse away. + for (const s of baseline) { + if (!this.prevRunning.has(s.sessionId)) this.prevRunning.set(s.sessionId, s.running) + } + let summaries = baseline + for (const mutation of mutations) { + summaries = applyMutation(summaries, mutation) + this.summaries = summaries + this.syncCompletedNotifications() + } this.summaries = summaries this.listState = 'idle' this.listPhase = 'ready' + // Covers the empty-mutations pull (a plain baseline carries no edge). + this.syncCompletedNotifications() // Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source). for (const s of this.summaries) { const session = this.sessions.get(s.sessionId) @@ -566,6 +592,8 @@ export class SessionManager { private recordMutation(mutation: SessionListMutation): void { this.listMutations?.push(mutation) this.summaries = applyMutation(this.summaries, mutation) + // Eager edge reconciliation — a snapshot-build-time pass would miss consecutive status frames. + this.syncCompletedNotifications() this.notifier.markDirty() } @@ -893,6 +921,38 @@ export class SessionManager { }) } + /** + * Reconcile completion reminders against the latest summaries, eagerly after + * every mutation and pull (a snapshot-build-time pass would collapse + * consecutive status frames into one observation). A running→idle edge of a + * non-selected session arms its reminder; running disarms it; removal drops + * it. First observation only records the running bit — sessions already + * idle at load get no reminder. + */ + private syncCompletedNotifications(): void { + const seen = new Set() + for (const s of this.summaries) { + seen.add(s.sessionId) + const prev = this.prevRunning.get(s.sessionId) + if (prev === undefined) { + this.prevRunning.set(s.sessionId, s.running) + continue + } + if (prev && !s.running) { + if (s.sessionId !== this.selected) this.completedNotifications.add(s.sessionId) + } else if (s.running) { + this.completedNotifications.delete(s.sessionId) + } + this.prevRunning.set(s.sessionId, s.running) + } + for (const id of this.prevRunning.keys()) { + if (!seen.has(id)) this.prevRunning.delete(id) + } + for (const id of this.completedNotifications) { + if (!seen.has(id)) this.completedNotifications.delete(id) + } + } + private buildListSnapshot(): SessionListSnapshot { const merged: TitledSessionSummary[] = this.summaries.map((summary) => { // List rows read the generic 'title' projection key (host-computed unit @@ -914,7 +974,7 @@ export class SessionManager { const status = statuses.find(candidate => candidate !== 'approval') ?? statuses[0] if (status !== undefined) pendingInteractions.set(sessionId, status) } - const fresh = flattenLineage(merged, pendingInteractions) + const fresh = flattenLineage(merged, pendingInteractions, this.completedNotifications) const items = fresh.map((entry) => { const prev = this.entryCache.get(entry.sessionId) if ( @@ -924,6 +984,7 @@ export class SessionManager { && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth && prev.pendingInteraction === entry.pendingInteraction && prev.projectionValues === entry.projectionValues + && prev.completed === entry.completed ) return prev this.entryCache.set(entry.sessionId, entry) return entry diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 9399f594d3..b1b271e702 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -51,6 +51,8 @@ export interface SessionSummary { running: boolean /** User interaction currently blocking this session (sidebar amber-dot state). */ pendingInteraction?: PendingInteractionStatus + /** Finished while not selected and not yet opened — the sidebar's green "done" reminder. Absent = false. */ + completed?: boolean /** * Empty-log bit (host summary derivation mirror). New Session reuses a blank * one targeting the same workspace. Filtering stays with the consumer: the @@ -614,6 +616,7 @@ export class SessionsService implements ISessions { id: entry.sessionId, displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), running: entry.running, + ...(entry.completed ? { completed: true } : {}), blank: entry.blank, updatedAt: entry.updatedAt, ...(entry.pendingInteraction === undefined diff --git a/packages/client/runtime/tests/lineage.spec.ts b/packages/client/runtime/tests/lineage.spec.ts index c616c19462..7d3c948f3e 100644 --- a/packages/client/runtime/tests/lineage.spec.ts +++ b/packages/client/runtime/tests/lineage.spec.ts @@ -52,4 +52,11 @@ describe('flattenLineage', () => { warnSpy.mockRestore() } }) + + it('projects the completion-reminder set into rows (absent = false)', () => { + const out = flattenLineage([s('a', 10), s('b', 20)], undefined, new Set(['b' as SessionId])) + expect(out.find(e => e.sessionId === 'a')?.completed).toBe(false) + expect(out.find(e => e.sessionId === 'b')?.completed).toBe(true) + expect(flattenLineage([s('a', 10)])[0]?.completed).toBe(false) + }) }) diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 909a293b3e..e203e49dd9 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -985,3 +985,128 @@ describe('pending-interaction list status', () => { expect(session.getSnapshot().pending).toEqual([]) }) }) + +describe('completed reminder', () => { + const status = (rpcId: string, sessionId: SessionId, running: boolean) => ({ + rpcId: rpcId as never, + payload: { type: 'host/session-status' as const, sessionId, running }, + }) + const added = (rpcId: string, sessionId: SessionId) => ({ + rpcId: rpcId as never, + payload: { type: 'host/session-added' as const, sessionId, blank: false }, + }) + const entry = (manager: SessionManager, sessionId: SessionId) => + manager.getListSnapshot().items.find(item => item.sessionId === sessionId) + + it('arms on a running→idle flip of a non-selected session and clears on select', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope(added('h1', S1)) + manager.handleHostEnvelope(added('h2', S2)) + manager.select(S1) + expect(entry(manager, S2)?.completed).toBe(false) + manager.handleHostEnvelope(status('s1', S2, true)) + manager.handleHostEnvelope(status('s2', S2, false)) + expect(entry(manager, S2)?.completed).toBe(true) + // Opening the session consumes the reminder. + manager.select(S2) + expect(entry(manager, S2)?.completed).toBe(false) + }) + + it('never arms for the session being watched and re-arms after a switch-away re-run', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope(added('h1', S1)) + manager.handleHostEnvelope(added('h2', S2)) + manager.select(S2) + manager.handleHostEnvelope(status('s1', S2, true)) + manager.handleHostEnvelope(status('s2', S2, false)) + expect(entry(manager, S2)?.completed).toBe(false) // watched to completion: no reminder + // Switch away; a fresh run completing again arms the reminder. + manager.select(S1) + manager.handleHostEnvelope(status('s3', S2, true)) + manager.handleHostEnvelope(status('s4', S2, false)) + expect(entry(manager, S2)?.completed).toBe(true) + }) + + it('a re-run disarms the reminder while running and re-arms on its completion', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope(added('h1', S1)) + manager.handleHostEnvelope(added('h2', S2)) + manager.select(S1) + manager.handleHostEnvelope(status('s1', S2, true)) + manager.handleHostEnvelope(status('s2', S2, false)) + expect(entry(manager, S2)?.completed).toBe(true) + // The user starts a new run without opening the session: running wins. + manager.handleHostEnvelope(status('s3', S2, true)) + expect(entry(manager, S2)?.completed).toBe(false) + manager.handleHostEnvelope(status('s4', S2, false)) + expect(entry(manager, S2)?.completed).toBe(true) + }) + + it('session-removed drops the reminder and a re-add starts clean', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope(added('h1', S1)) + manager.handleHostEnvelope(added('h2', S2)) + manager.select(S1) + manager.handleHostEnvelope(status('s1', S2, true)) + manager.handleHostEnvelope(status('s2', S2, false)) + expect(entry(manager, S2)?.completed).toBe(true) + manager.handleHostEnvelope({ rpcId: 'rm' as never, payload: { type: 'host/session-removed', sessionId: S2 } }) + expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toBeUndefined() + manager.handleHostEnvelope(added('h3', S2)) + expect(entry(manager, S2)?.completed).toBe(false) + }) + + it('a list refresh carrying the running→idle transition arms the reminder', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] })) + const manager = new SessionManager(api) + await manager.refreshList() + manager.select(S1) + expect(entry(manager, S2)?.completed).toBe(false) + api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: false })] as never[] })) + await manager.refreshList() + expect(entry(manager, S2)?.completed).toBe(true) + }) + + it('never arms for sessions already idle at first observation', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) + const manager = new SessionManager(api) + await manager.refreshList() + manager.select(S1) + expect(entry(manager, S2)?.completed).toBe(false) + api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 201 })] as never[] })) + await manager.refreshList() + expect(entry(manager, S2)?.completed).toBe(false) + }) + + it('arms a completion that happened during an in-flight first pull (baseline running, replayed idle)', async () => { + const api = new FakeApiClient() + const gate = deferred>>() + api.onList = () => gate.promise + const manager = new SessionManager(api) + const refresh = manager.refreshList() + // The session finishes while the first pull is still in flight; the pull + // response recorded it as running at pull time. + manager.handleHostEnvelope(status('s-mid', S2, false)) + gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] })) + await refresh + expect(entry(manager, S2)?.completed).toBe(true) + }) + + it('arms when a session ran and completed entirely between in-flight mutations (baseline idle)', async () => { + const api = new FakeApiClient() + const gate = deferred>>() + api.onList = () => gate.promise + const manager = new SessionManager(api) + const refresh = manager.refreshList() + // The unknown session starts and finishes while the first pull is in + // flight; the pull-time baseline recorded it idle, so the running→idle + // edge lives entirely inside the replayed mutations. + manager.handleHostEnvelope(status('s-start', S2, true)) + manager.handleHostEnvelope(status('s-finish', S2, false)) + gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) + await refresh + expect(entry(manager, S2)?.completed).toBe(true) + }) +}) diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index b9e06a6ae2..d9c70de729 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -49,6 +49,7 @@ export const zh = { 'status.waitingApproval': '等待审批', 'status.planReview': '计划待审', 'status.waitingAnswer': '等待回答', + 'status.completed': '已完成', 'hover.created': '创建于 {time}', 'hover.copied': '已复制', 'date.ymd': '{y}年{m}月{d}日', @@ -109,6 +110,7 @@ export const en = { 'status.waitingApproval': 'Waiting for approval', 'status.planReview': 'Plan awaiting review', 'status.waitingAnswer': 'Waiting for answer', + 'status.completed': 'Completed', 'hover.created': 'Created {time}', 'hover.copied': 'Copied', 'date.ymd': '{y}-{m}-{d}', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 836325076b..fb64a0be42 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -173,7 +173,7 @@ function assertNever(value: never): never { /** Session status presentation; pending user interaction outranks the running state. */ function sessionStatus( - node: Pick, + node: Pick, t: RowTranslate, ): { state: StateDotState; label: string } { switch (node.pendingInteraction) { @@ -185,10 +185,11 @@ function sessionStatus( default: return assertNever(node.pendingInteraction) } if (node.running) return { state: 'ongoing', label: t('status.running') } + if (node.completed) return { state: 'done', label: t('status.completed') } return { state: 'done', label: t('status.idle') } } -/** Hover-card body: full title, relative time, and interaction/running/idle status. */ +/** Hover-card body: full title, relative time, and interaction/running/completed/idle status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { const status = sessionStatus(node, t) return ( @@ -251,7 +252,7 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { > - {status.state !== 'done' && ( + {(status.state !== 'done' || result.completed) && ( <> {status.label} @@ -351,8 +352,11 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork drag.drop(rowHalf(e)) }} > + {/* Pending interactions and running outrank the idle state; a + finished-but-unviewed session shows the green done reminder dot + (cleared by opening the session). */} - {status.state !== 'done' && ( + {(status.state !== 'done' || row.completed) && ( <> {status.label} diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 1a9f42504c..90153211ea 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -24,6 +24,8 @@ export interface SessionNode { /** The runtime Session list reports an interaction awaiting this user. */ pendingInteraction?: PendingInteractionStatus running: boolean + /** Finished running while not selected and not yet opened (the green "done" reminder dot). */ + completed: boolean updatedAt: number } @@ -54,6 +56,8 @@ export interface SearchResultNode { /** The runtime Session list reports an interaction awaiting this user. */ pendingInteraction?: PendingInteractionStatus running: boolean + /** Finished running while not selected and not yet opened (the green "done" reminder dot). */ + completed: boolean snippet?: string } @@ -175,6 +179,7 @@ function sessionNode(s: SessionSummary): SessionNode { title: sessionTitle(s), blank: s.blank, running: s.running, + completed: s.completed === true, updatedAt: s.updatedAt, ...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }), } @@ -330,6 +335,7 @@ export function deriveSearchResults( ...(summary.pendingInteraction === undefined ? {} : { pendingInteraction: summary.pendingInteraction }), + completed: summary.completed === true, ...match === undefined ? {} : { snippet: match.snippet }, } }), diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 1f5387cf43..1c8fd1f703 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -64,6 +64,7 @@ describe('workspace browser rows', () => { title: 'Result title', workspace: 'Workspace context', running: true, + completed: false, snippet: 'matching message excerpt', } render() @@ -85,7 +86,7 @@ describe('workspace browser rows', () => { ] as const)('shows %s ahead of running in search results', (pendingInteraction, label) => { const result: SearchResultNode = { id: sid(pendingInteraction), title: 'Needs input', workspace: 'Project', - pendingInteraction, running: true, + pendingInteraction, running: true, completed: false, } render() const row = screen.getByRole('treeitem') @@ -114,7 +115,7 @@ describe('workspace browser rows', () => { it('renders and opens a selected running Session row', () => { const node: SessionNode = { - id: sid('session'), title: 'Session', blank: false, running: true, updatedAt: 0, + id: sid('session'), title: 'Session', blank: false, running: true, completed: false, updatedAt: 0, } const onOpen = vi.fn() render( @@ -130,6 +131,38 @@ describe('workspace browser rows', () => { expect(onOpen).toHaveBeenCalledWith(node.id) }) + it('shows the green done dot only on a finished, unviewed session (running wins the slot)', () => { + const renderRow = (over: Partial) => render( + , + ) + const stateDot = (view: ReturnType) => + view.container.querySelector('[data-state]') + // No completion reminder, not running: no state dot at all. + const plain = renderRow({}) + expect(stateDot(plain)).toBeNull() + plain.unmount() + // Completed while unviewed: the green done dot. + const done = renderRow({ completed: true }) + expect(done.container.querySelector('[data-state="done"]')).not.toBeNull() + done.unmount() + // Running wins the slot: the animated ongoing dot, no done dot. + const running = renderRow({ completed: true, running: true }) + expect(running.container.querySelector('[data-state="ongoing"]')).not.toBeNull() + expect(running.container.querySelector('[data-state="done"]')).toBeNull() + }) + + it('shows the green done dot on a finished search result row', () => { + render() + expect(screen.getByRole('treeitem').querySelector('[data-state="done"]')).not.toBeNull() + }) + it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => { const onRename = vi.fn() const onDelete = vi.fn() @@ -198,7 +231,7 @@ describe('workspace browser rows', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0, + id: sid('s-blank'), title: 'ignored', blank: true, running: false, completed: false, updatedAt: 0, } render() @@ -224,7 +257,7 @@ describe('workspace browser rows', () => { const onFork = vi.fn() const onArchive = vi.fn() const node: SessionNode = { - id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'One', blank: false, running: false, completed: false, updatedAt: 0, } render() @@ -257,7 +290,7 @@ describe('workspace browser rows', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0, + id: sid('s1'), title: 'Hovered', blank: false, running: true, completed: false, updatedAt: 0, } render() @@ -288,7 +321,7 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid(pendingInteraction), title: 'Needs input', blank: false, - pendingInteraction, running: true, updatedAt: 0, + pendingInteraction, running: true, completed: false, updatedAt: 0, } const view = render() @@ -314,7 +347,7 @@ describe('workspace browser rows', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'Quiet', blank: false, running: false, completed: false, updatedAt: 0, } render() @@ -327,9 +360,26 @@ describe('workspace browser rows', () => { } }) + it('completed hover card shows the Completed status line', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s1'), title: 'Done', blank: false, running: false, completed: true, updatedAt: 0, + } + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + // Row's visually-hidden reminder label plus the hover card's status line. + expect(screen.getAllByText('已完成')).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { const node: SessionNode = { - id: sid('s1'), title: 'Drag me', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'Drag me', blank: false, running: false, completed: false, updatedAt: 0, } const inactive = dragProps() const { rerender } = render( diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index a15fffa3d8..fed1c03eec 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -77,6 +77,22 @@ describe('deriveGroups', () => { expect(strayGroups.map(group => group.key)).toEqual(['first']) }) + it('projects the completion reminder into session and search rows (absent = false)', () => { + const done = { ...summary('done', 3), completed: true } + const plain = summary('plain', 2) + const sessions = list(done, plain) + const groups = deriveGroups( + sessions, [workspace('first', ['done', 'plain'])], noArchive, view(['first']), + ) + const doneNode = groups[0]!.sessions.find(session => session.id === done.id)! + const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)! + expect(doneNode.completed).toBe(true) + expect(plainNode.completed).toBe(false) + expect(deriveFlat(sessions, noArchive).find(node => node.id === done.id)!.completed).toBe(true) + const search = deriveSearchResults(sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive, { items: [], hasMore: false }, 10) + expect(search.items[0]?.completed).toBe(true) + }) + it('hides subagent-origin sessions without hiding ordinary forks', () => { const parent = summary('parent', 1) const fork = { ...summary('fork', 2), parentId: parent.id } @@ -259,6 +275,7 @@ describe('deriveSearchResults', () => { workspace: 'Alpha', running: false, pendingInteraction: 'plan-review', + completed: false, snippet: 'title session body excerpt', }, { @@ -266,12 +283,14 @@ describe('deriveSearchResults', () => { title: 'Ordinary title', workspace: 'Needle Workspace', running: false, + completed: false, }, { id: contentHit.id, title: 'content-hit', workspace: 'c', running: false, + completed: false, snippet: 'body needle excerpt', }, ], From 7313be1d2dcd76b2d7e2abdfa2cbfe5b3c02aa91 Mon Sep 17 00:00:00 2001 From: GeeeekExplorer <2651904866@qq.com> Date: Thu, 6 Aug 2026 00:28:58 +0800 Subject: [PATCH 2/5] docs: agent note for the session completion dot --- ...08-06-session-completed-done-dot.i18n.yaml | 6 +++++ .../2026-08-06-session-completed-done-dot.md | 25 +++++++++++++++++++ ...026-08-06-session-completed-done-dot.zh.md | 25 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 .agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.i18n.yaml new file mode 100644 index 0000000000..eb0a37d991 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.md +2026-08-06-session-completed-done-dot.md: bd6911ce137f1272090c86c029710c9f4054ee6d +2026-08-06-session-completed-done-dot.zh.md: 9ec2199a29d1307c3ebd0238e84d5f90d36fe21c diff --git a/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.md b/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.md new file mode 100644 index 0000000000..bd6911ce13 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.md @@ -0,0 +1,25 @@ +# Agent Note: Session completion dot in the sidebar + +Status: implemented + +English | [中文](2026-08-06-session-completed-done-dot.zh.md) + +## Problem + +A session the operator delegated work to and then left (switched to another conversation) gives no signal when it finishes. Its running indicator stops, but the row then looks identical to any idle session, so the operator must poll the list or discover the finished work late. The pending-interaction amber dot covers sessions that need input, not sessions whose work is simply done. + +## Decision + +`SessionManager` owns a client-side completion-reminder set, a sibling of the pending-interaction bit: a running→idle edge of a session that is not the selected one arms its reminder; `select()`/`selectSubagent()` consume it; starting a new run disarms it and its completion re-arms it; removal prunes it. The bit rides `SessionListEntry` → `SessionSummary` (optional, absent = no reminder) into the workspace browser, whose session and search rows render the existing `StateDot` `done` state — running keeps the ongoing spinner, an idle session without a reminder shows nothing — and whose hover card labels the reminder 已完成 / Completed. + +The reminder is in-memory and per browser. It survives connection generations — a transport blip does not invalidate "you have not looked yet" — but not a page reload. + +## Consequences + +The sidebar row states become three disjoint signals: green = finished and unviewed, amber = awaiting the operator's input, blue = running. No wire, on-disk, or configuration format changes: `SessionSummary.completed` is optional, so existing consumers and test fixtures stay valid, and only the workspace browser reads it. The completion edge is detected eagerly at every list mutation and pull (a snapshot-build-time-only pass would collapse two consecutive status frames into one observation and miss the completion). + +## Alternatives considered + +- **Component-local UI state.** Rejected because the sidebar unmounts on collapse and multiple surfaces (grouped tree, flat list, search) need the same bit; the manager already owns the running transitions and the selection, so a manager-owned set is the one source all surfaces can project. +- **Event-driven arming from status frames only.** Rejected because a list pull can also carry a running→idle transition (a session finished while the refresh was in flight); the reminder is reconciled against every mutation and pull. +- **Persisting the reminder.** Rejected because the reminder means "you have not looked at this session yet" in this browser; reload restores the selection and the user is looking at the list again, so a durable bit would only go stale. diff --git a/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.zh.md b/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.zh.md new file mode 100644 index 0000000000..9ec2199a29 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-session-completed-done-dot.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 侧边栏会话完成提醒点 + +Status: implemented + +[English](2026-08-06-session-completed-done-dot.md) | 中文 + +## Problem + +操作者派发任务后切换到其他会话,原会话完成时没有任何信号。运行指示停止后,该行与普通空闲会话看起来完全一样,操作者只能反复查看列表或很晚才发现工作已完成。等待交互的琥珀点只覆盖需要操作者输入的会话,不覆盖"只是干完了活"的会话。 + +## Decision + +`SessionManager` 持有客户端侧的完成提醒集合,与待交互位并列:非当前会话发生 running→idle 边沿时点亮其提醒;`select()`/`selectSubagent()` 消费掉提醒;重新开始一轮运行会熄灭提醒并在再次完成时重新点亮;会话被移除时清理提醒。该位经 `SessionListEntry` → `SessionSummary`(可选字段,缺省 = 无提醒)进入工作区浏览区,其会话行与搜索结果行渲染现有的 `StateDot` `done` 状态——运行中仍显示转圈,无提醒的空闲会话不显示任何点——悬停卡片将该提醒标注为"已完成 / Completed"。 + +提醒仅存在于内存中且按浏览器实例隔离。它跨连接代存活——传输抖动不会使"你还没回来看"失效——但页面刷新后重置。 + +## Consequences + +侧边栏行状态成为三个互斥信号:绿 = 已完成且未查看,琥珀 = 等待操作者输入,蓝 = 运行中。无 wire、磁盘或配置格式变更:`SessionSummary.completed` 为可选字段,现有消费者与测试 fixture 保持有效,只有工作区浏览区读取它。完成边沿在每次列表变更与拉取时即时检测(仅在建快照时检测会把连续两个状态帧折叠为一次观察,从而漏掉完成事件)。 + +## Alternatives considered + +- **组件本地 UI 状态。** 已拒绝:侧边栏折叠时会卸载,且多个界面(分组树、单列表、搜索)需要同一状态位;manager 本就持有运行状态迁移与选中状态,manager 持有的集合是所有界面都能投影的唯一事实源。 +- **仅从状态帧做事件驱动点亮。** 已拒绝:列表拉取本身也可能携带 running→idle 迁移(刷新在途时会话已完成);提醒需对每次变更与拉取做对账。 +- **持久化提醒。** 已拒绝:提醒的含义是"此浏览器里你还没查看该会话";刷新会恢复选中状态且用户正看着列表,持久化位只会过期。 From cdf4a18b6846e6a64fa74f004caee6400d11bf6c Mon Sep 17 00:00:00 2001 From: GeeeekExplorer <2651904866@qq.com> Date: Thu, 6 Aug 2026 14:10:50 +0800 Subject: [PATCH 3/5] test(web): align stale markdown goldens with the stats-line clock spacing The two CJK/inline-code markdown goldens recorded the stats line without the space after the clock token ({{clock}}Ran for), while every other golden and the current rendering emit {{clock}} Ran for. The mismatch surfaced on the merge tree as the only diff in the web browser snapshot lane; align the two stragglers with the rest. --- apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md | 2 +- .../tests/snapshots/markdown-inline-code-links/ui.expected.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md index 68a4df5603..187ab25e8c 100644 --- a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -40,7 +40,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md index 059849223c..19efa06238 100644 --- a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md @@ -31,7 +31,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img From ed3450b3374a964ad9d46375134d917e761ee236 Mon Sep 17 00:00:00 2001 From: GeeeekExplorer <2651904866@qq.com> Date: Thu, 6 Aug 2026 16:52:24 +0800 Subject: [PATCH 4/5] chore: refresh PR merge ref after branch rewrites From f9f72e2f0908ebb7557ed6e8e36e2905fbbf8abf Mon Sep 17 00:00:00 2001 From: imccyu Date: Thu, 6 Aug 2026 17:44:06 +0800 Subject: [PATCH 5/5] fix(ui): preserve Hero tree when selecting a Workspace --- ...ession-scope-and-provide-channel.i18n.yaml | 4 +- ...lient-session-scope-and-provide-channel.md | 2 +- ...nt-session-scope-and-provide-channel.zh.md | 2 +- ...input-machine-and-slash-pipeline.i18n.yaml | 4 +- ...25-web-input-machine-and-slash-pipeline.md | 11 +- ...web-input-machine-and-slash-pipeline.zh.md | 11 +- ...cky-composer-conversation-scroll.i18n.yaml | 4 +- ...-29-sticky-composer-conversation-scroll.md | 6 +- ...-sticky-composer-conversation-scroll.zh.md | 6 +- ...isible-while-blank-session-opens.i18n.yaml | 4 +- ...-hero-visible-while-blank-session-opens.md | 4 +- ...ro-visible-while-blank-session-opens.zh.md | 4 +- apps/web/tests/startup-auto-selection.e2e.ts | 48 +++++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../ui-conversation/src/client/apply.ts | 39 +++-- .../src/client/contract/slots.ts | 59 ++++--- .../ui-conversation/src/client/index.ts | 3 +- .../skeleton/ConversationRoot.module.css | 4 +- .../src/client/skeleton/ConversationRoot.tsx | 32 +--- .../client/skeleton/ConversationSession.tsx | 161 ++++++++++-------- .../src/client/skeleton/EmptyHero.tsx | 6 +- .../tests/apply-inject.spec.tsx | 14 +- .../tests/assembly-surfaces.spec.tsx | 56 +++++- .../ui-conversation/tests/chat-apply.spec.tsx | 4 +- .../tests/selection-survival.spec.tsx | 9 +- .../ui-conversation/tests/skeleton.spec.tsx | 34 +++- .../client/ui-trajectory/tests/views.spec.tsx | 75 +++++--- 29 files changed, 395 insertions(+), 223 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 37eccb0c95..9c8308be74 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: aeefbe22a397e3d7ffb9f6427a3c70c8c8e8b940 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 056d50d45cef891e0e635d8bb4f2e73064ccdb87 +2026-07-25-web-client-session-scope-and-provide-channel.md: 3c51f06fca23a495f0fbc0cc4f1c289edea07b3b +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 06fc1005785d9d11b52839f91c3bb4b99cad7d63 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index aeefbe22a3..3c51f06fca 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -93,7 +93,7 @@ Slot scope is the closed set `root | session-maybe | session`: - `session-maybe` follows the current session with ADOPTION identity (the only behavior — there is no hold-identity-forever mode): an incarnation born session-less keeps its React instance across the arrival of the FIRST session (the blank shell adopts it — no remount, the DOM survives), and from then on behaves exactly like a strict session entry — switching to a different session remounts, and dropping back to no-session remounts into a fresh blank incarnation that will adopt again. Component-local per-session state therefore clears by construction; state that must survive a switch belongs in session-bound sources (machine, store, hooks). With no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session; the per-entry adoption bookkeeping (incarnation-counter key) lives in the renderer's `SessionMaybeEntry`. - `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store. -`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session it renders inert (machine faces absent, `disabled` owner prop), and the same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. +`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session it renders inert (machine faces absent, `disabled` owner prop), and the same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. - The runtime's first built-in entry: the `'session'` hook — `useSession` itself rides the same mechanism, no special-casing. - Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 056d50d45c..06fc100578 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -93,7 +93,7 @@ slot scope 是闭集 `root | session-maybe | session`: - `session-maybe` 以**收养(adoption)身份语义**跟随 current session(唯一行为——不存在「永久保持实例」模式):空态出生的化身在**第一个** session 到来时保持 React 实例(空壳收养它——不重挂,DOM 存活);此后行为与严格 session entry 完全一致——切到不同 session 重挂,跌回无 session 也重挂为崭新的空态化身(之后再次收养)。因此组件本地的 per-session 状态**由构造保证**随切换清零;需要活过切换的状态必须住 session 绑定的源(machine、store、hooks)。无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动和提供方名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的钩子/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整钩子/prop 形状;逐 entry 的收养记账(化身计数 key)住在 renderer 的 `SessionMaybeEntry`。 - `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。 -`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/view。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时以惰性态渲染(machine face 缺席、`disabled` owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 +`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 session → blank session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时以惰性态渲染(machine face 缺席、`disabled` owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 - 运行时内建第一条:`'session'` 钩子——`useSession` 本身走同一机制,无特判。 - Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index a52995c855..f22f2340ac 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 977df6508e1a1cd54cf1ddb469a6bfb835f60071 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: f70065c8b356b2ed5ca6ab317fbdeb5177f058fa +2026-07-25-web-input-machine-and-slash-pipeline.md: 39ef214a94fcd019f535fb60136d5dcc09b54e60 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 9b0ca0cadbc5e0212048b165f0d60d567a5639ad diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 977df6508e..39ef214a94 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -69,9 +69,9 @@ A trigger/menu/pick pipeline with zero knowledge of "commands": ### hub / facade: the resident shell and the strict-session input body - The hub (trigger/decoration registries + send orchestration) takes the slash/command services as optional `ctx.get()` dependencies: without ui-slash or the command surfaces, input still sends and receives normally — graceful degradation. -- Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. +- Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. It always owns the same scrollport and composer seat; separate strict-session header and body outlets fill those fixed regions after a Session appears. - The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the textarea DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout. -- ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted. +- ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`: a summary-proven blank Session remains Hero in every open state, while an unproven Session settles during loading. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted. - Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt` with `mode:'queue'` (the Web UI has no steer entry; host-wire `mode:'steer'` remains outside this machine); backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists. - When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current. - The Notifier's two-bit contract: `dirty` (snapshot freshness, clearable by an `ensureFresh` pull) and `notifyPending` (notification debt, cleared only by a flush) are mutually independent — a pull must not swallow a push, and object-layer push subscribers (watchTransaction) depend on this guarantee. @@ -93,9 +93,10 @@ skill/@subagent references skip the placeholder + occurrence identity chain — ### The slot system -`conversation` is itself session-maybe; its session content and the composer input slots are strict session, while the Hero Workspace picker stays root. The child slots are all declared by ui-conversation's conversation registration: +`conversation` is itself session-maybe; its session content and the composer input slots are strict session, while the Hero Workspace picker stays root. The root registration renders the header outlet above its resident scrollport and the body outlet inside it, before the resident composer seat. The child slots are all declared by ui-conversation's conversation registration: -- `conversation.session` (single) — the strict-session header, view ring, and chat store; rebuilt when the session id switches. +- `conversation.session.header` (single) — strict-session breadcrumb, view tabs, and header actions above the resident scrollport. +- `conversation.session` (single) — the strict-session view ring and draft mirror inside the resident scrollport. Header and body share the same session-scoped chat store; each is rebuilt when the session id switches. - `conversation.composer.bar` (single) — the slot for the InputBar itself: the InputBar is a true slot entry (self-registered into its own slot) and the content of the composer chain's fallback; it is not a chain entry — the chain's single election would unmount it on a takeover, breaking textarea DOM survival. - `conversation.input.overlay` — the floating-overlay anchor inside the input card; registrants' inject resolves each one's own per-session controller by the slot sessionId. - `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`. @@ -128,7 +129,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ ## Consequences -- One resident conversation shell carries no-session/blank/active: no session → blank guarantees only the outer frame's React identity, allowing the disabled textarea to be replaced by the strict InputBar; the same blank session → engaging/active keeps the InputBar and the textarea. EmptyState and the controlled intent chain (`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`) are deleted along with their last consumer. +- One resident conversation shell carries no-session/blank/active: no session → blank preserves ConversationRoot, Hero, the root-scoped Workspace picker, scrollport, composer seat, InputBar, and textarea; only the strict header and body outlets gain content. The same blank session → engaging/active also keeps the InputBar and textarea. EmptyState and the controlled intent chain (`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`) are deleted along with their last consumer. - The input surface's zero knowledge of commands plus optional dependencies: pure input works without the command packages; `@` references and skill references get free reuse of the same menu/pick pipeline. The cost is that space/enter adjudication is a per-source polling protocol whose answer semantics (sync/async, the meaning of undefined) are a frozen contract. - Transactionalized submission (attempt seq + the drift guard) makes the three defect classes — stale-result backwash, session switching, concurrent replay — structurally impossible, pinned by the matrix tests. - Known gaps: chip fidelity across refresh (paste matching is reusable for it) has no workstream yet; the subagent reference's model representation awaits its business workstream. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index f70065c8b3..9b0ca0cadb 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -69,9 +69,9 @@ occurrence 表与 chip 三投影: ### hub / facade:常驻外壳与严格 session 输入体 - hub(trigger/decoration 注册表 + 发送编排)对 slash/command 服务是可选 `ctx.get()` 依赖:无 ui-slash/命令面时输入正常收发,优雅降级。 -- 每个实体 Session 只有一个 `SessionInputShell`(facade),随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。 +- 每个实体 Session 只有一个 `SessionInputShell`(facade),随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。它始终拥有同一个 scrollport 与 composer seat;Session 出现后,彼此独立的严格 session header 和 body outlet 只填入这些固定区域。 - composer bar 是一个无条件渲染的 `session-maybe` slot entry:无 session 时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank session 后同一实例转为 live——textarea DOM 在无 session → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。 -- ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在 prompt 成功受理后翻 false。 +- ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`:summary 已证实为空的 Session 在任何 open state 下都保持 Hero,未经证实的 Session 则在 loading 期间进入 settling。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在 prompt 成功受理后翻 false。 - 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt` 且固定 `mode:'queue'`(Web UI 无 steer 入口;host 线缆上的 `mode:'steer'` 不经此 machine);失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。 - blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标 session 不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank session 留存但不再 current。 - Notifier 双位契约:`dirty`(快照新鲜度,`ensureFresh` 拉取可清)与 `notifyPending`(通知欠账,只有 flush 清)各自独立——拉取不得吞推送,对象层推订阅者(watchTransaction)依赖这一保证。 @@ -93,9 +93,10 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 ### slot 体系 -`conversation` 本身是 session-maybe;其会话内容与 composer 输入 slot 严格限定为 session,Hero Workspace picker 保持 root。子 slot 均由 ui-conversation 的 conversation 注册声明: +`conversation` 本身是 session-maybe;其会话内容与 composer 输入 slot 严格限定为 session,Hero Workspace picker 保持 root。root 注册把 header outlet 渲染在常驻 scrollport 上方,把 body outlet 渲染在其内部、常驻 composer seat 之前。子 slot 均由 ui-conversation 的 conversation 注册声明: -- `conversation.session`(single)——严格 session 的 header、view ring 与 chat store;session id 切换时重建。 +- `conversation.session.header`(single)——常驻 scrollport 上方严格 session 的 breadcrumb、view tab 与 header action。 +- `conversation.session`(single)——常驻 scrollport 内严格 session 的 view ring 与 draft mirror。header 和 body 共享同一个 session scope chat store;session id 切换时各自重建。 - `conversation.composer.bar`(single)——InputBar 本体的 slot:InputBar 是真 slot entry(自有 slot 自注册),composer chain fallback 的内容;不做 chain entry——chain 单选举会在 takeover 时卸载它,破坏 textarea DOM 存活。 - `conversation.input.overlay`——输入卡内浮层锚点;注册者 inject 按 slot sessionId 解析各自 per-session controller。 - `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。 @@ -128,7 +129,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 ## 后果 -- 一个常驻 conversation 外壳承接 no-session/blank/active:无 session → blank 只保证大框架 React identity,允许 disabled textarea 替换为严格 InputBar;同一 blank session → engaging/active 保持 InputBar 与 textarea。EmptyState 与受控 intent 链(`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`)随最后消费者一并删除。 +- 一个常驻 conversation 外壳承接 no-session/blank/active:无 session → blank 保持 ConversationRoot、Hero、root scope Workspace picker、scrollport、composer seat、InputBar 与 textarea;只有严格 session header 和 body outlet 开始承载内容。同一 blank session → engaging/active 也保持 InputBar 与 textarea。EmptyState 与受控 intent 链(`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`)随最后消费者一并删除。 - 输入面对命令零知识 + 可选依赖:无命令包时纯输入可用;`@` 引用与 skill 引用免费复用同一菜单/pick 管线。代价是空格/回车裁决是逐 source 轮询协议,其应答语义(同步/异步、undefined 含义)为冻结契约。 - 提交事务化(attempt seq + 漂移守卫)使晚到结果回灌、会话切换、concurrent 重放三类缺陷结构性不可能,由矩阵测试钉住。 - 已知欠账:chip 跨刷新保真(可复用粘贴匹配)未立项;subagent 引用的模型表示待业务立项。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml index 9c5e373acb..b849211296 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md -2026-07-29-sticky-composer-conversation-scroll.md: 69d46894a53b0113f3e4f0fe871bbf3f9697969b -2026-07-29-sticky-composer-conversation-scroll.zh.md: c0d5a0640468207282316ecd2fa1f209708df7b5 +2026-07-29-sticky-composer-conversation-scroll.md: d3fed7a9d0b1f39f9551fbd85e0f83515b1a2690 +2026-07-29-sticky-composer-conversation-scroll.zh.md: 2beee34d3bb68832d14b7607b43aa11e1425d53d diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md index 69d46894a5..d3fed7a9d0 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md @@ -10,7 +10,7 @@ The active conversation column split scrolling: the chat (and trajectory) view o ## Decision -While a session exists, `ConversationRoot` always supplies a `wrapActiveBody` owner callback that wraps the view ring in a `data-conversation-scroll` body and places a `data-composer-seat` around the whole `'conversation.composer'` chain output (fallback + elected overlay siblings from `overlay: true`). Active CSS sticks that seat with `position: sticky; bottom: 0` so Question/Approval takeovers stay visible when the user is not pinned to the floor; hero CSS centers the fallback stack inside the scroll body. `ConversationSession` keeps a chrome-hidden header + body shell while blank so that tree seat does not change on the first send. The session header remains `flex: none` column chrome above the scrollport when visible. ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set `overflow: visible` and resolve bottom-follow / prepend anchoring through `closest('[data-conversation-scroll]')`. +`ConversationRoot` always owns one `data-conversation-scroll` body, with the strict `conversation.session` view outlet before a `data-composer-seat` around the whole `'conversation.composer'` chain output (fallback + elected overlay siblings from `overlay: true`). The separate strict `conversation.session.header` outlet remains `flex: none` column chrome above that scrollport and hides while the Session is blank. This fixed parent tree keeps the scroll body and composer seat mounted from no session through the blank Hero and active conversation. Active CSS sticks that seat with `position: sticky; bottom: 0` so Question/Approval takeovers stay visible when the user is not pinned to the floor; Hero CSS centers the fallback stack inside the scroll body. ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set `overflow: visible` and resolve bottom-follow / prepend anchoring through `closest('[data-conversation-scroll]')`. Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host. @@ -22,7 +22,7 @@ Chat history prepend follows reader intent through stable rendered node/call ide **Fixed flex-none composer below the scrollport with wheel forwarding.** Rejected: the product requires the composer to stick inside the transcript scrollport so the footer is part of that scroll hit-testing surface, not a sibling that only forwards deltas. -**Portal the composer into ChatView's scroller.** Rejected: the composer is shared across view tabs; the wrap target is the Session body owned by the resident shell. +**Portal the composer into ChatView's scroller.** Rejected: the composer is shared across view tabs; its target is the root-owned scrollport in the resident shell. **Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned. @@ -30,4 +30,4 @@ Chat history prepend follows reader intent through stable rendered node/call ide ## Consequences -Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve wheel/trackpad scroll decisions, including Chromium's compositor-first delivery and stream-finalization clamp/regrow. Other browser scroll inputs do not change follow ownership under this narrow provenance rule. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft. +Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve wheel/trackpad scroll decisions, including Chromium's compositor-first delivery and stream-finalization clamp/regrow. Other browser scroll inputs do not change follow ownership under this narrow provenance rule. No session → blank Hero and Hero → active both keep the same textarea DOM node and InputHub draft. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md index c0d5a06404..2beee34d3b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -只要存在会话,`ConversationRoot` 就会始终提供 `wrapActiveBody` owner 回调,将视图环包进 `data-conversation-scroll` 主体,并用 `data-composer-seat` 包住整条 `'conversation.composer'` chain 输出(`overlay: true` 下的 fallback 与选举出的 overlay 兄弟节点)。活跃阶段 CSS 以 `position: sticky; bottom: 0` 钉住该 seat,使用户未贴底时 Question/Approval 接管仍可见;hero CSS 在滚动主体内居中 fallback 栈。`ConversationSession` 在 blank 时保留隐藏 chrome 的 header + body 壳,使首次发送时树座位不变。可见时会话标题栏仍是滚动容器之上的 `flex: none` 列 chrome。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller;位于宿主下时设为 `overflow: visible`,并通过 `closest('[data-conversation-scroll]')` 解析贴底跟随与前置锚定。 +`ConversationRoot` 始终拥有同一个 `data-conversation-scroll` 主体,其中严格 `conversation.session` view outlet 位于 `data-composer-seat` 之前;该 seat 包住整条 `'conversation.composer'` chain 输出(`overlay: true` 下的 fallback 与选举出的 overlay 兄弟节点)。独立的严格 `conversation.session.header` outlet 作为 `flex: none` 列 chrome 位于滚动容器上方,并在 Session 仍为 blank 时隐藏。固定的父级树让滚动主体与 composer seat 从无 session、blank Hero 到活跃对话始终保持挂载。活跃阶段 CSS 以 `position: sticky; bottom: 0` 钉住该 seat,使用户未贴底时 Question/Approval 接管仍可见;Hero CSS 在滚动主体内居中 fallback 栈。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller;位于宿主下时设为 `overflow: visible`,并通过 `closest('[data-conversation-scroll]')` 解析贴底跟随与前置锚定。 会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。 @@ -22,7 +22,7 @@ Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图 **滚动容器下方 flex-none 固定编辑器并转发滚轮。** 否决:产品要求编辑器 sticky 在 transcript 滚动容器内,使页脚成为该滚动命中面的一部分,而不是仅转发增量的兄弟节点。 -**把编辑器 portal 进 ChatView 的 scroller。** 否决:编辑器跨视图标签共享;包装目标是常驻壳拥有的 Session 主体。 +**把编辑器 portal 进 ChatView 的 scroller。** 否决:编辑器跨视图标签共享;其目标是常驻壳中由 root 持有的滚动容器。 **把 StatsLine 留在 ChatView 消息列下方。** 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。 @@ -30,4 +30,4 @@ Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图 ## Consequences -在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留滚轮/触控板的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段滚动位置受钳制后滚动容器重新增长的情况。在这条窄范围的输入来源规则下,其他浏览器滚动输入不会改变贴底跟随所有权。hero → active 保持同一 textarea DOM 节点(assembled slash-flow 快照)以及 InputHub 草稿。 +在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留滚轮/触控板的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段滚动位置受钳制后滚动容器重新增长的情况。在这条窄范围的输入来源规则下,其他浏览器滚动输入不会改变贴底跟随所有权。无 session → blank Hero 与 Hero → active 都保持同一 textarea DOM 节点以及 InputHub 草稿。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml index 12b3982d54..202b86ce3b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md -2026-07-31-hero-visible-while-blank-session-opens.md: b39963beffa403ef6fa44735aa88395a99139751 -2026-07-31-hero-visible-while-blank-session-opens.zh.md: b451d7c00ec7eb8d736134e738d72e5e07fd1b04 +2026-07-31-hero-visible-while-blank-session-opens.md: 6afa5d0ee2b695d6805d20f54e82073db8028df7 +2026-07-31-hero-visible-while-blank-session-opens.zh.md: f21e549b5811d81094374b1363186a1d18fbadaf diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md index b39963beff..6afa5d0ee2 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md @@ -24,12 +24,10 @@ The summary flag and the snapshot's own `blank` are distinct sources: the snapsh ## Deferred -The no-session→session tree relocation in `ConversationRoot` (the hero/composer subtree moves into the `conversation.session` outlet) still rebuilds the composer DOM on the same transition; removing it means moving `conversation.session` to `session-maybe` scope, a slot-contract change that needs its own proposal. - Object-layer reference churn found while diagnosing this — no-op projections minting fresh snapshots, the create path projecting twice, `select()` using `notifyNow` from async continuations — is real but independent of the visible flash. ## Consequences Startup auto-selection renders the hero immediately and keeps the composer seat and header visible through the history round-trip, so launching into a recent workspace no longer looks like a page reload. Sessions whose summary does not prove them blank keep the previous settling behavior, so the guard still covers the case it was written for. Skeleton tests pin all three summary shapes: a row reporting `blank: false` settles, an absent row settles, and a summary-proven blank session opening under `loading` renders hero chrome with a live textarea. -The assembled coverage is `apps/web/tests/startup-auto-selection.e2e.ts` (keyless web browser lane): it registers a workspace, holds the `session.history` response open at the browser's network boundary, and asserts the visible frame while the auto-selected open is in flight — hero phase, hero title, painted composer — plus a recorded phase timeline of exactly `['hero']` for the whole load. Holding the round-trip is what makes it a regression test rather than a race: against a loopback host the open settles too fast to sample, and with the exemption reverted the held window is precisely when the root reports `settling`. +The assembled coverage is `apps/web/tests/startup-auto-selection.e2e.ts` (keyless web browser lane). Its first Workspace connection asserts that the Hero root, Workspace chip, scroll body, composer seat, and textarea remain the same DOM nodes when the blank Session appears. It then holds the `session.history` response open at the browser's network boundary and asserts the visible frame while the auto-selected open is in flight — hero phase, hero title, painted composer — plus a recorded phase timeline of exactly `['hero']` for the whole load. Holding the round-trip is what makes the second case a regression test rather than a race: against a loopback host the open settles too fast to sample, and with the exemption reverted the held window is precisely when the root reports `settling`. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md index b451d7c00e..f21e549b58 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md @@ -24,12 +24,10 @@ Status: implemented ## 推迟事项 -`ConversationRoot` 中"无会话→有会话"的树位置迁移(hero/composer 子树移入 `conversation.session` 出口)仍会在同一次转换中重建 composer 的 DOM;消除它意味着把 `conversation.session` 移到 `session-maybe` 作用域,这是一次插槽契约变更,需要单独立项。 - 诊断期间发现的对象层引用抖动——空操作投影铸造出新的快照、创建路径重复投影一次、`select()` 在异步续体中使用 `notifyNow`——确实存在,但与这次可见闪烁相互独立。 ## 影响 启动自动选择会立即渲染 hero,并在整个历史往返期间保持 composer 座位与 header 可见,因此启动进入最近工作区不再像页面重载。摘要未证明为空白的会话保持原有的 settling 行为,这道防护仍覆盖它当初针对的场景。骨架测试固定了摘要的三种形态:报告 `blank: false` 的行进入 settling;根本没有该行同样进入 settling;摘要已证明为空白的会话在 `loading` 期间渲染 hero 外壳与可用的文本框。 -组装级覆盖是 `apps/web/tests/startup-auto-selection.e2e.ts`(无密钥的 Web 浏览器泳道):它注册一个工作区,在浏览器网络边界上扣住 `session.history` 的响应,并在自动选择的打开仍在飞行途中断言可见画面——hero 阶段、hero 标题、已绘制的 composer——外加整次加载记录到的阶段时间线恰好为 `['hero']`。扣住这次往返正是它成为回归测试而非竞态的原因:对着回环主机,打开会快到无从采样;而一旦回退这条豁免,被扣住的这段窗口恰恰就是根节点报告 `settling` 的时刻。 +组装级覆盖是 `apps/web/tests/startup-auto-selection.e2e.ts`(无密钥的 Web 浏览器泳道)。首次连接 Workspace 时,它断言 blank Session 出现前后 Hero root、Workspace chip、滚动主体、composer seat 与 textarea 都是同一 DOM 节点。随后它在浏览器网络边界上扣住 `session.history` 的响应,并在自动选择的打开仍在飞行途中断言可见画面——hero 阶段、hero 标题、已绘制的 composer——外加整次加载记录到的阶段时间线恰好为 `['hero']`。扣住这次往返正是第二个用例成为回归测试而非竞态的原因:对着回环主机,打开会快到无从采样;而一旦回退这条豁免,被扣住的这段窗口恰恰就是根节点报告 `settling` 的时刻。 diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index f3a953c1e6..fea5217b73 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -12,6 +12,9 @@ // assembled application can show is that the path a user actually takes // reaches it: the real selection service, the real client session opening over // the real /api transport, and a real browser deciding what is painted. +// The initial Workspace pick also records the resident Hero/composer nodes and +// proves that opening the first blank Session fills the strict outlets without +// replacing those nodes. // // The round-trip against a loopback host is far too fast to observe, so this // scenario HOLDS the `session.history` response open at the browser's network @@ -55,9 +58,6 @@ describe('web e2e: startup auto-selection', () => { tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - // A registered workspace is the precondition for auto-selection: the first - // load has nothing to select, so the reload below is the path under test. - await connectFreshWorkspace(page, scaffold.workspaceCwd, 'startup-auto-selection') }, 180_000) afterAll(async () => { @@ -65,6 +65,48 @@ describe('web e2e: startup auto-selection', () => { await scaffold?.close() }) + it('keeps the resident Hero and composer nodes when the first Workspace session appears', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-first-workspace-stable-tree')) + await page.locator(`${ROOT_PHASE}[data-phase="hero"]`).waitFor({ timeout: 15_000 }) + await page.evaluate(() => { + const refs = { + root: document.querySelector('div[data-phase="hero"]'), + workspaceChip: document.querySelector('[aria-label="Choose workspace"]'), + scrollBody: document.querySelector('[data-conversation-scroll]'), + composerSeat: document.querySelector('[data-composer-seat]'), + textarea: document.querySelector('textarea'), + } + if (Object.values(refs).some(node => node === null)) throw new Error('incomplete initial Hero tree') + ;(window as unknown as { __heroTree: typeof refs }).__heroTree = refs + }) + + // A registered Workspace is the precondition for the reload case below; + // this first connection is also the no-Workspace → Workspace path. + await connectFreshWorkspace(page, scaffold.workspaceCwd, 'startup-auto-selection') + + expect(await page.evaluate(() => { + const before = (window as unknown as { __heroTree: Record }).__heroTree + return { + phase: document.querySelector('div[data-phase]')?.getAttribute('data-phase'), + root: document.querySelector('div[data-phase="hero"]') === before.root, + workspaceChip: document.querySelector('[aria-label="Choose workspace"]') === before.workspaceChip, + scrollBody: document.querySelector('[data-conversation-scroll]') === before.scrollBody, + composerSeat: document.querySelector('[data-composer-seat]') === before.composerSeat, + textarea: document.querySelector('textarea') === before.textarea, + textareaEnabled: !(document.querySelector('textarea') as HTMLTextAreaElement).disabled, + } + })).toEqual({ + phase: 'hero', + root: true, + workspaceChip: true, + scrollBody: true, + composerSeat: true, + textarea: true, + textareaEnabled: true, + }) + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + it('keeps the hero and the composer on screen while the auto-selected blank session opens', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-startup-auto-selection')) // Runs before any page script on the reload below, so the first phase the diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 0df2b4b4df..be53206dde 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 7bd0d551fc41967326dd9860f5c31a99ea3c254a -README.zh.md: d339f6423d9a9f77c02d86ad0b8e57bd0baba52b +README.md: bbd115eac0eb914914dc11e504639633c801abdd +README.zh.md: 843b49e311fbf1a9157413c42a0ef3e9828284bc diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7bd0d551fc..bbd115eac0 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -6,9 +6,9 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders. -The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. +The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. -The view ring is a slot: the conversation registration declares the session-scoped `'conversation.view'` list in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome. +The view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: `), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome. Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d339f6423d..843b49e311 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -6,9 +6,9 @@ 压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 -视图环是一个 slot:会话注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `),视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。 +视图环是一个 slot:严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: `);视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index b796ce84fc..6bc9068cfc 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type { ViewTab } from './contract/views.ts' import type { ApprovalWait, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected, - ConversationSessionInjected, DetailsInjected, + ConversationSessionHeaderInjected, ConversationSessionInjected, DetailsInjected, } from './contract/slots.ts' import type { InputNotice } from './input/contract.ts' import { resolveToolPath } from './contract/tool-call-model.ts' @@ -33,7 +33,7 @@ import { askQuestionToolview } from './toolviews/ask-question-row.tsx' import { todoDockEntry } from './skeleton/TodoPanel.tsx' import { queueDockEntry } from './queue/QueueDock.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' -import { ConversationSession } from './skeleton/ConversationSession.tsx' +import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { en, NS, zh, type ConversationKey } from './locales.ts' @@ -123,6 +123,11 @@ export function apply(ctx: Context): void { } return tabs } + const views = { + list: viewTabs, + subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn), + version: () => slots.getVersion('conversation.view'), + } // The per-session input machine registry (InputService face; published as // ctx.conversation.input by the service below sharing this one instance). @@ -151,6 +156,7 @@ export function apply(ctx: Context): void { locale: NS, children: { 'conversation.session': { kind: 'single', scope: 'session' }, + 'conversation.session.header': { kind: 'single', scope: 'session' }, 'conversation.composer': { kind: 'chain', scope: 'session' }, 'conversation.composer.bar': { kind: 'single', scope: 'session-maybe' }, 'conversation.input.overlay': { kind: 'list', scope: 'session' }, @@ -176,27 +182,36 @@ export function apply(ctx: Context): void { }), }, ConversationRoot) - // The strict session subtree owns only per-session store and view content; - // the resident parent keeps Hero and composer layout identity stable. + // The strict session body fills the resident scrollport without owning it; + // the Hero/composer path therefore stays fixed while the first blank + // session appears after a Workspace pick. slots.register({ name: 'conversation.session', - locale: NS, children: { 'conversation.view': { kind: 'list', scope: 'session' }, - 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, }, store: chatStore, inject: (sessionId: SessionId, _actions: BoundActions): ConversationSessionInjected => ({ - views: { - list: viewTabs, - subscribe: fn => slots.subscribe('conversation.view', fn), - version: () => slots.getVersion('conversation.view'), - }, + views, bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write), - open: (id) => { sessions.open(id) }, }), }, ConversationSession) + // Header chrome sits above the resident scrollport but shares the same + // per-session chat store (active view) as its body and view entries. + slots.register({ + name: 'conversation.session.header', + locale: NS, + children: { + 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, + }, + store: chatStore, + inject: (): ConversationSessionHeaderInjected => ({ + views, + open: (id) => { sessions.open(id) }, + }), + }, ConversationSessionHeader) + // The default composer body: its own single slot inside the composer // chain's fallback (decision 20). Public machine surface arrives via the // provide channel above; the keyboard command face and the stop/retry diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 6abfb592f5..a84b4a3bf0 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -13,19 +13,20 @@ import type { CallId, SelectionTarget, ViewTab } from './views.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { /** - * Strict-session content inside the resident conversation shell. This - * subtree owns the per-session chat store, header, and view ring and is - * remounted when the current session id changes. + * Strict-session body inside the resident conversation scrollport. It + * owns the per-session draft mirror and active view ring. */ - 'conversation.session': { kind: 'single'; scope: 'session'; owner: ConversationSessionOwnerProps } + 'conversation.session': { kind: 'single'; scope: 'session' } + /** Strict-session header above the resident conversation scrollport. */ + 'conversation.session.header': { kind: 'single'; scope: 'session' } /** Session-header actions contributed by feature plugins. */ 'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps } /** * The conversation view ring: one list entry per view tab (chat here; * trajectory/waterfall from ui-trajectory), rendered one-at-a-time by - * ConversationRoot via `only: `. Declared by this package's - * 'conversation' entry (declaring is claiming). Session scope: views read - * the conversation snapshot through the standard kit. + * the session body via `only: `. Declared by this package's + * body entry (declaring is claiming). Session scope: views read the + * conversation snapshot through the standard kit. */ 'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps } /** @@ -122,22 +123,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } -/** Owner share of the strict session content seat. */ -export interface ConversationSessionOwnerProps { - /** - * Wrap the view ring in the transcript scrollport that also hosts the - * sticky composer seat (whole `'conversation.composer'` chain output). - * Supplied for every real session (hero/settling/active) so the composer - * keeps one tree seat across the blank → active flip; the header stays - * outside that wrapper as ordinary column chrome (`flex: none`), while - * active CSS sticks the seat to the bottom of the same scrollport so wheel - * over the footer scrolls the flow. - * @param view - the session view-ring content (null while blank chrome is hidden). - * @returns the scrollport containing `view` and the sticky composer seat. - */ - wrapActiveBody?: (view: ReactNode) => ReactNode -} - /** Header actions derive their state from the standard session/global kit. */ export interface ConversationHeaderActionOwnerProps {} @@ -228,7 +213,7 @@ export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'> */ export type ConvViewProps = PropsRuntime<'conversation.view'> -/** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */ +/** The shared chat store handle type declared by the Session header/body, details, and chat-view registrations. */ export type ChatStore = ReturnType /** Business callbacks injected into the conversation slot. */ @@ -240,7 +225,7 @@ export interface ConversationInjected { selectWorkspace: (workspaceId: WorkspaceId) => Promise } -/** Business callbacks injected into the strict session content seat. */ +/** Business callbacks injected into the strict Session body seat. */ export interface ConversationSessionInjected { /** Views projected from the `conversation.view` slot ledger. */ views: { @@ -250,6 +235,16 @@ export interface ConversationSessionInjected { } /** Bind the input machine's draft persistence mirror to the session store. */ bindDraftMirror: (write: (text: string) => void) => () => void +} + +/** Business callbacks injected into the strict session header seat. */ +export interface ConversationSessionHeaderInjected { + /** Views projected from the `conversation.view` slot ledger. */ + views: { + list: () => readonly ViewTab[] + subscribe: (fn: () => void) => () => void + version: () => number + } /** Select a real Session through the runtime navigation owner. */ open: (sessionId: SessionId) => void } @@ -354,7 +349,8 @@ export interface ComposerChainProps { */ export type ConversationSlotProps = PropsRuntime<'conversation'> & PropsRenderSlots< - | 'conversation.session' | 'conversation.composer' | 'conversation.composer.bar' + | 'conversation.session' | 'conversation.session.header' + | 'conversation.composer' | 'conversation.composer.bar' | 'conversation.input.overlay' | 'conversation.input.dock' | 'conversation.composer.dock' | 'conversation.input.left' | 'conversation.input.right' @@ -363,12 +359,19 @@ export type ConversationSlotProps = & ConversationInjected & PropsLocale<'conversation'> -/** Full strict-session content props: per-session store, view ring, callbacks, and the locale seat. */ +/** Full strict-session body props: per-session store, view ring, and draft mirror. */ export type ConversationSessionSlotProps = PropsRuntime<'conversation.session'> - & PropsRenderSlots<'conversation.view' | 'conversation.session.header.actions'> + & PropsRenderSlots<'conversation.view'> & PropsStore & ConversationSessionInjected + +/** Full strict-session header props: shared store, tabs/actions render shares, navigation, and locale. */ +export type ConversationSessionHeaderSlotProps = + PropsRuntime<'conversation.session.header'> + & PropsRenderSlots<'conversation.session.header.actions'> + & PropsStore + & ConversationSessionHeaderInjected & PropsLocale<'conversation'> /** The pending approval carrier the owner dispatches into the composer chain. */ diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index d04f2473e1..ac5f6574c8 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -15,7 +15,8 @@ export type { ConversationKey } from './locales.ts' export type { ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected, ComposerChainProps, ConversationInjected, - ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, + ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, + ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index b1c5f51451..4c9d2f8627 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -31,8 +31,8 @@ border-bottom: 1px solid var(--dsw-alias-border-l2); } -/* Blank hero/settling: keep the header node mounted (stable Session tree for - the wrapActiveBody composer) without taking column space. */ +/* Blank hero/settling: keep the strict Session header mounted without taking + column space; the root-owned scrollport and composer remain below it. */ .headerHidden { display: none; } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index dce9416831..bc5f7bdc01 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -2,7 +2,7 @@ // chain, AND the composer bar (session-maybe slot) stay mounted across // no-session/session transitions — the bar renders inert via owner props. -import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' @@ -31,9 +31,8 @@ export function ConversationRoot({ // Publishes the seat's live height as --dsh-composer-height on the scroll // body so floating controls (ChatView back-to-bottom) clear the composer as - // it grows. Callback ref, not an effect: the seat remounts when the tree - // moves between the no-session and session paths. Stable identity so React - // reattaches only on those remounts, not on every render. + // it grows. Callback ref, not an effect; stable identity prevents observer + // churn while the first blank session fills the resident body outlet. const seatObserver = useRef(null) const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => { seatObserver.current?.disconnect() @@ -167,28 +166,13 @@ export function ConversationRoot({ ) - // Header stays column chrome above this scrollport; the sticky composer - // seat lives inside it with the transcript. Always wrap while a session - // exists (hero/settling/active) so the composer keeps one tree seat across - // the blank → active flip — relocating it only in active remounted the textarea. - const wrapActiveBody = (view: ReactNode): ReactNode => ( -
- {view} - {composerSeat} -
- ) - return (
- {/* Mounted for every real session, hero included: ConversationSession - keeps a chrome-hidden shell while blank and owns the draft- - persistence mirror bind — unmounting it in the hero would lose - pre-first-send text on a refresh or scope rebuild. */} - {sessionId !== undefined && renderSlot( - 'conversation.session', - { wrapActiveBody }, - )} - {sessionId === undefined ? wrapActiveBody(null) : null} + {renderSlot('conversation.session.header', {})} +
+ {renderSlot('conversation.session', {})} + {composerSeat} +
) } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index d6a597e54f..35bdab481b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -1,14 +1,19 @@ -/** Strict per-session conversation content: header, view ring, and chat store bindings. */ +/** Strict per-session header/body content inserted into the resident conversation layout. */ -import { useEffect, useSyncExternalStore, type ReactNode } from 'react' +import { useEffect, useSyncExternalStore } from 'react' import clsx from 'clsx' import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' -import type { ConversationSessionSlotProps } from '../contract/slots.ts' +import type { + ConversationSessionHeaderSlotProps, ConversationSessionSlotProps, +} from '../contract/slots.ts' import css from './ConversationRoot.module.css' -/** Full props composed from the strict session slot contract. */ +/** Full props composed from the strict session body contract. */ export type ConversationSessionProps = ConversationSessionSlotProps +/** Full props composed from the strict session header contract. */ +export type ConversationSessionHeaderProps = ConversationSessionHeaderSlotProps + interface Breadcrumb { readonly id: SessionId readonly displayTitle: string @@ -38,10 +43,15 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum }) } -export function ConversationSession({ - sessionId, useSession, useSessions, useInput, inputActions, useStore, actions, - renderSlot, views, bindDraftMirror, open, wrapActiveBody, t, -}: ConversationSessionProps) { +/** + * Renders Session header chrome above the resident conversation scrollport. + * @param props - Strict Session store, view ledger, navigation, render, and locale shares. + * @returns the hidden blank-session header or visible title and tabs. + */ +export function ConversationSessionHeader({ + sessionId, useSession, useSessions, useStore, actions, + renderSlot, views, open, t, +}: ConversationSessionHeaderProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() const activeId = useStore(s => s.view) ?? 'chat' @@ -49,6 +59,77 @@ export function ConversationSession({ const ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs) const composerPhase = useSession(s => s.composerPhase) const blank = useSession(s => s.blank) + const hideChrome = blank && composerPhase === 'blank' + + return ( +
+ {!hideChrome && ( + <> +
+ +
+ {renderSlot('conversation.session.header.actions', {})} +
+
+ {tabs.length > 1 && ( +
+ {tabs.map(viewTab => ( + + ))} +
+ )} + + )} +
+ ) +} + +/** + * Renders the active Session view inside the resident scrollport and keeps + * the input draft mirrored while blank Hero chrome is visible. + * @param props - Strict Session input/store, view ledger, and render shares. + * @returns the active view area, or null while the Session remains blank. + */ +export function ConversationSession({ + useSession, useInput, inputActions, useStore, actions, + renderSlot, views, bindDraftMirror, +}: ConversationSessionProps) { + useSyncExternalStore(views.subscribe, views.version) + const tabs = views.list() + const activeId = useStore(s => s.view) ?? 'chat' + const active = tabs.find(view => view.id === activeId) ?? tabs[0] + const composerPhase = useSession(s => s.composerPhase) + const blank = useSession(s => s.blank) const inputState = useInput(s => s) const storedDraft = useStore(s => s.draft) // `?? null`: persisted snapshots from before the inspect field rehydrate without it. @@ -62,13 +143,8 @@ export function ConversationSession({ // the machine mirror, not this seed effect. }, [inputActions]) - // Blank hero/settling: keep the same header + body tree shape so a - // wrapActiveBody-hosted composer keeps its DOM identity across the first - // send (hero → active). Chrome is hidden; the draft-persistence mirror - // still runs because this component stays mounted. - const hideChrome = blank && composerPhase === 'blank' - - const view: ReactNode = hideChrome ? null : ( + if (blank && composerPhase === 'blank') return null + return (
{active !== undefined && renderSlot('conversation.view', { inspect, @@ -76,59 +152,4 @@ export function ConversationSession({ }, { only: active.id })}
) - - return ( - <> -
- {!hideChrome && ( - <> -
- -
- {renderSlot('conversation.session.header.actions', {})} -
-
- {tabs.length > 1 && ( -
- {tabs.map(viewTab => ( - - ))} -
- )} - - )} -
- {wrapActiveBody !== undefined ? wrapActiveBody(view) : view} - - ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 4b491e1f7f..62ebdd93b4 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -123,9 +123,9 @@ export function HeroShell({ t, children }: HeroShellProps) { {t('hero.preview')}
- {/* The resident composer (ConversationRoot wrapActiveBody seat; the - workspace row rides the stack above the card) is CSS-centered in - the session scroll body during hero — see + {/* The resident composer (ConversationRoot's root-owned scrollport; + the workspace row rides the stack above the card) is CSS-centered + in that scroll body during hero — see ConversationRoot.module.css [data-phase='hero']. */}
diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index cfbb0fdcbe..49682fea52 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -21,7 +21,8 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { - ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected, + ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected, + ConversationSessionInjected, DetailsInjected, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { createChatStore } from '../src/client/stores.ts' @@ -70,7 +71,7 @@ async function bench() { // The host face (store resolution) exists only inside the installed // renderer, so materialize it the way the shell does. runtime.renderRoot() - const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') => + const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar' | 'conversation.view' | 'details') => runtime.slots.entries(key)[0]! /** Resolve store instance + call the inject the way the outlet would. */ const conversationSurface = (id: SessionId) => { @@ -80,6 +81,13 @@ async function bench() { id, instance.actions) return { instance, injected } } + const conversationHeaderSurface = (id: SessionId) => { + const entry = entryOf('conversation.session.header') + const instance = runtime.storeOf('conversation.session.header', id) as ChatInstance + const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionHeaderInjected)( + id, instance.actions) + return { instance, injected } + } const residentSurface = (id: SessionId | undefined) => { const entry = entryOf('conversation') return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id) @@ -111,7 +119,7 @@ async function bench() { } return { runtime, feature, slots: runtime.slots, entryOf, - conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface, + conversationSurface, conversationHeaderSurface, residentSurface, composerSurface, chatViewSurface, inputSurface, sessionFake, layoutFake, } } diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 3a1a968bea..16163065eb 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -21,11 +21,12 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, waitFor, within } from '@testing-library/react' +import { useState } from 'react' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' -import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' // The service reads its initial locale from the browser; these specs assert // the shipped Chinese copy, so they state the browser they assume. @@ -83,6 +84,16 @@ const LAYOUT_CHILDREN = { 'details': { kind: 'single', scope: 'session' }, } as const +/** Stateful occupant proving the root-scoped Hero workspace outlet is not rebuilt. */ +function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { + const [count, setCount] = useState(0) + return ( + + ) +} + async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) { const runtime = await SlotTestRuntime.create() runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) @@ -188,6 +199,49 @@ describe('resident composer', () => { await runtime.dispose() }) + it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => { + const runtime = await SlotTestRuntime.create() + runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + const locale = new LocaleService(runtime.ctx) + runtime.provide('locale', locale) + runtime.slots.installLocale(locale) + await runtime.workspaces.update((draft) => { + draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never + }) + await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) + await runtime.mount({ inject: [...inject], apply }) + runtime.slots.register({ name: 'conversation.hero.workspace' }, WorkspaceProbe) + const view = runtime.renderRoot() + + const root = view.container.querySelector('[data-phase="hero"]')! + const scrollBody = view.container.querySelector('[data-conversation-scroll]')! + const composerSeat = view.container.querySelector('[data-composer-seat]')! + const textarea = view.container.querySelector('textarea')! + const workspaceChip = view.getByRole('button', { name: '选择工作区' }) + const workspaceProbe = view.getByTestId('workspace-probe') + expect(textarea.disabled).toBe(true) + + fireEvent.click(workspaceChip) + fireEvent.click(workspaceProbe) + expect(workspaceProbe.textContent).toBe('true:1') + + await runtime.sessions.add({ + id: SID, + summary: { title: 'S', displayTitle: 'S', cwd: '/proj', blank: true }, + snapshot: { blank: true, composerPhase: 'blank' }, + }) + + expect(view.container.querySelector('[data-phase="hero"]')).toBe(root) + expect(view.container.querySelector('[data-conversation-scroll]')).toBe(scrollBody) + expect(view.container.querySelector('[data-composer-seat]')).toBe(composerSeat) + expect(view.container.querySelector('textarea')).toBe(textarea) + expect(view.getByRole('button', { name: '选择工作区' })).toBe(workspaceChip) + expect(view.getByTestId('workspace-probe')).toBe(workspaceProbe) + expect(workspaceProbe.textContent).toBe('true:1') + expect(textarea.disabled).toBe(false) + await runtime.dispose() + }) + it('the textarea survives the blank→active conversion as the same DOM node', async () => { const runtime = await bench([], { blank: true }) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 1415d4edb0..df8fff6719 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -45,7 +45,7 @@ async function bench() { } /** First stored entry for a key (inject/store live directly on StoredEntry). */ -function renderEntryOf(slots: Awaited>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') { +function renderEntryOf(slots: Awaited>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') { return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown } } @@ -73,6 +73,7 @@ describe('apply wiring', () => { const b = await bench() const conversation = renderEntryOf(b.slots, 'conversation') const conversationSession = renderEntryOf(b.slots, 'conversation.session') + const conversationHeader = renderEntryOf(b.slots, 'conversation.session.header') const chatView = renderEntryOf(b.slots, 'conversation.view') const details = renderEntryOf(b.slots, 'details') expect(conversation?.inject).toBeTypeOf('function') @@ -81,6 +82,7 @@ describe('apply wiring', () => { // The shared handle: one apply-built store value on ALL session entries // (the session-maybe 'conversation' shell carries no store by design). expect(conversationSession?.store).toBeDefined() + expect(conversationHeader?.store).toBe(conversationSession?.store) expect(details?.store).toBe(conversationSession?.store) expect(chatView?.store).toBe(conversationSession?.store) // The hero workspace picker hole rides the conversation entry's children diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.tsx b/packages/client/ui-conversation/tests/selection-survival.spec.tsx index 4559618a8a..07e9ad0c79 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.tsx +++ b/packages/client/ui-conversation/tests/selection-survival.spec.tsx @@ -15,16 +15,17 @@ type ChatInstance = ReturnType['create']> async function bench() { const runtime = await SlotTestRuntime.create() const chat = createChatStore() - // The apply.ts shape: one shared handle across both strict-session slot - // registrations ('conversation.session'/'details'); the session-maybe - // 'conversation' shell carries no store by design. The slots must first - // exist in the ledger — the test root declares them (the AppFrame role). + // The apply.ts shape: one shared handle across the strict Session header, + // body, and details registrations; the session-maybe 'conversation' shell + // carries no store by design. The slots must first exist in the ledger. await runtime.root.declare({ 'conversation': { kind: 'single', scope: 'session-maybe' }, 'conversation.session': { kind: 'single', scope: 'session' }, + 'conversation.session.header': { kind: 'single', scope: 'session' }, 'details': { kind: 'single', scope: 'session' }, }, (_p: { renderSlot?: unknown }) => null) runtime.slots.register({ name: 'conversation.session', store: chat }, () => null) + runtime.slots.register({ name: 'conversation.session.header', store: chat }, () => null) runtime.slots.register({ name: 'details', store: chat }, () => null) runtime.renderRoot() // materializes the host face storeOf resolves through return { runtime, chat } diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index b2828bcc80..ba2e75f5f7 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -18,7 +18,7 @@ import { createChatStore } from '../src/client/stores.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import { en, zh } from '../src/client/locales.ts' import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx' -import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx' +import { ConversationSession, ConversationSessionHeader } from '../src/client/skeleton/ConversationSession.tsx' import { HeroShell } from '../src/client/skeleton/EmptyHero.tsx' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' @@ -122,6 +122,33 @@ function mount( const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => { slotCalls.push(key) if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null } + if (key === 'conversation.session.header') { + return ( + children(SID)} + useSession={useSession} + useSessions={props.useSessions} + useWorkspaces={props.useWorkspaces} + useProjection={(() => undefined)} + useInput={useInput} + inputActions={inputActions} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + renderSlot={renderSlot as never} + views={{ + list: () => [ + { id: 'chat', label: 'Chat' }, + { id: 'trajectory', label: 'Trajectory' }, + ], + subscribe: () => () => {}, + version: () => 1, + }} + open={open} + t={t} + /> + ) + } if (key === 'conversation.session') { return ( 1, }} bindDraftMirror={write => wiring.bindMirror(write)} - open={open} - t={t} - {...owner} /> ) } @@ -340,7 +364,7 @@ describe('ConversationRoot resident composer', () => { const before = b.view.getByRole('textbox') fireEvent.change(before, { target: { value: 'kept across flip' } }) // First message landed: content exists, phase leaves blank. Composer - // already sat in the Session scrollport during hero, so the textarea + // already sat in the resident scrollport during hero, so the textarea // node and InputHub draft both survive. b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false })) b.rerender() diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 548f51aceb..56a9ee90cf 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -21,7 +21,10 @@ import type { SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx' +import { + ConversationSession, ConversationSessionHeader, + type ConversationSessionHeaderProps, type ConversationSessionProps, +} from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' @@ -35,12 +38,9 @@ import { createTrajectoryDurationStore } from '../src/client/duration-store.ts' import { deriveTrajectoryTimeline } from '../src/client/timeline.ts' const SID = 's1' as SessionId - -// Stub of the conversation package's standard locale seat (this spec mounts -// its ConversationSession chrome); answers from the zh dictionary and falls -// back to the key like the real chain. -const tConversation: ConversationSessionProps['t'] = +const tConversation: ConversationSessionHeaderProps['t'] = key => (conversationZh as Record)[key] ?? key + afterEach(cleanup) // The chat store persists under its declared key; clear so one case's active // view cannot rehydrate into the next. @@ -180,7 +180,7 @@ function tabsOf(slots: SlotsService): ViewTab[] { .map(e => ({ id: e.options.id!, label: resolveSlotLabel(e.options.label) ?? e.options.id! })) } -/** Mount the strict session content over the ring ledger with an outlet-faithful renderSlot. */ +/** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) { const sessionSnapshot = createSnapshotStore({ running: false, removed: false, promptError: null, nodes, @@ -190,6 +190,13 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES }) const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession const chat = createChatStore().create() + const views = { + list: () => tabsOf(slots), + subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn), + version: () => slots.getVersion('conversation.view'), + } + const useInput = bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never + const inputActions = { setDraft: vi.fn(), submit: vi.fn() } // Minimal outlet twin: resolve the ring entry by the `only` filter and // render it with the session standard kit (what SlotOutlet does for a // list-kind session slot, minus machinery). @@ -222,27 +229,39 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES ) }) as unknown as ConversationSessionProps['renderSlot'] return render( - children(SID)} - useSession={useSession} - useSessions={emptySessions()} - useWorkspaces={emptyWorkspaces()} - useProjection={(() => undefined)} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} - renderSlot={renderSlot} - views={{ - list: () => tabsOf(slots), - subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn), - version: () => slots.getVersion('conversation.view'), - }} - useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never} - inputActions={{ setDraft: vi.fn(), submit: vi.fn() }} - bindDraftMirror={() => () => {}} - open={vi.fn()} - />, + <> + children(SID)} + useSession={useSession} + useSessions={emptySessions()} + useWorkspaces={emptyWorkspaces()} + useProjection={(() => undefined)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + renderSlot={() => null} + views={views} + useInput={useInput} + inputActions={inputActions} + open={vi.fn()} + t={tConversation} + /> + children(SID)} + useSession={useSession} + useSessions={emptySessions()} + useWorkspaces={emptyWorkspaces()} + useProjection={(() => undefined)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + renderSlot={renderSlot} + views={views} + useInput={useInput} + inputActions={inputActions} + bindDraftMirror={() => () => {}} + /> + , ) }