Merge final master into Web transcript
This commit is contained in:
@@ -1042,6 +1042,56 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
const appended = logOf(sessionId).at(-1) as SessionEvent
|
||||
return ok(request, { title: normalized, seq: appended.seq })
|
||||
},
|
||||
fork: (request) => {
|
||||
const { sessionId, atSeq } = request.payload
|
||||
const source = summaryOf(sessionId)
|
||||
if (source === undefined) {
|
||||
return err(request, {
|
||||
code: 'session-not-found',
|
||||
message: `no session ${sessionId}`,
|
||||
details: { sessionId },
|
||||
})
|
||||
}
|
||||
const log = logs.get(sessionId) ?? []
|
||||
const lastSeq = log.at(-1)?.seq ?? -1
|
||||
const anchoredBoundary = atSeq === undefined
|
||||
? undefined
|
||||
: log.find(e => e.type === 'turn/end' && e.seq >= atSeq)
|
||||
const boundary = anchoredBoundary
|
||||
?? (atSeq === undefined || atSeq > lastSeq
|
||||
? log.findLast(e => e.type === 'turn/end')
|
||||
: undefined)
|
||||
if (boundary === undefined) {
|
||||
return err(request, {
|
||||
code: 'fork-unavailable',
|
||||
message: atSeq !== undefined && atSeq <= lastSeq
|
||||
? `session ${sessionId} has not completed the turn containing event ${String(atSeq)}`
|
||||
: `session ${sessionId} has no completed turn`,
|
||||
details: { sessionId },
|
||||
})
|
||||
}
|
||||
let cut = boundary.seq + 1
|
||||
while (cut < log.length && log[cut]?.type !== 'turn/start') cut++
|
||||
const child: SessionSummary = {
|
||||
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false,
|
||||
parentSessionId: sessionId,
|
||||
...source.cwd === undefined ? {} : { cwd: source.cwd },
|
||||
}
|
||||
logs.set(child.sessionId, log.slice(0, cut))
|
||||
sessions.push(child)
|
||||
emitHost({
|
||||
type: 'host/session-added', sessionId: child.sessionId, blank: false,
|
||||
parentSessionId: sessionId,
|
||||
...source.cwd === undefined ? {} : { cwd: source.cwd },
|
||||
})
|
||||
const workspace = workspaces.find(w => w.sessionIds.includes(sessionId))
|
||||
if (workspace !== undefined) {
|
||||
workspace.sessionIds = [child.sessionId, ...workspace.sessionIds]
|
||||
workspace.updatedAt = new Date().toISOString()
|
||||
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
|
||||
}
|
||||
return ok(request, { sessionId: child.sessionId })
|
||||
},
|
||||
history: async (request) => {
|
||||
const log = logs.get(request.payload.sessionId) ?? []
|
||||
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
|
||||
@@ -1591,6 +1641,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'session.models': return this.api.sessions.models(request)
|
||||
case 'session.selectModel': return this.api.sessions.selectModel(request)
|
||||
case 'session.rename': return this.api.sessions.rename(request)
|
||||
case 'session.fork': return this.api.sessions.fork(request)
|
||||
case 'session.prompt': return this.api.sessions.prompt(request)
|
||||
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
|
||||
case 'session.cancel': return this.api.sessions.cancel(request)
|
||||
|
||||
@@ -46,6 +46,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
|
||||
() => Promise.resolve(ok({
|
||||
@@ -99,6 +100,7 @@ export class FakeApiClient implements IApiClient {
|
||||
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
|
||||
@@ -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/runtime/README.md
|
||||
README.md: 63876e2f2c762c5eeff95e065338413017e0a333
|
||||
README.zh.md: efa5e841efad1e5ce0f8c3af63eba00bcae1a363
|
||||
README.md: 199c62dd7358df9707ca8282181fda5c313e8551
|
||||
README.zh.md: dc956aa90d429509b1ef4d9b4139dc8dc9ec2e47
|
||||
|
||||
@@ -34,6 +34,10 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr
|
||||
|
||||
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op.
|
||||
|
||||
## Session forking
|
||||
|
||||
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `(N)` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child.
|
||||
|
||||
## Session model selection
|
||||
|
||||
Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure.
|
||||
|
||||
@@ -34,6 +34,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
|
||||
|
||||
## 会话 fork
|
||||
|
||||
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd,且 `blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端把源会话的持久化标题改名到子会话:尾部 `(N)` 或 `(N)` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。
|
||||
|
||||
## 会话模型选择
|
||||
|
||||
每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle`/`loading`/`ready`/`selecting`/`error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。
|
||||
|
||||
@@ -29,6 +29,17 @@ export interface ISessions {
|
||||
open(id: SessionId): void
|
||||
/** Clear the current selection into the no-session view state. */
|
||||
clear(): void
|
||||
/**
|
||||
* Fork a session from a completed-turn prefix of the source; on resolution
|
||||
* the child is in the list store and `open()` can target it.
|
||||
* @param opts - source session id, the optional event seq anchoring the
|
||||
* cut (the boundary is the first turn/end at or after it; an in-log
|
||||
* anchor in an open turn is unavailable rather than clipped backward),
|
||||
* and whether to increment an inherited durable title before resolving.
|
||||
* @returns the child session id.
|
||||
* @throws when the fork fails, or when a requested child-title rename fails after creation.
|
||||
*/
|
||||
fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId>
|
||||
/**
|
||||
* Register a per-session standard-props provider (hooks become `use<Name>`
|
||||
* selector hooks on the render side; props spread verbatim).
|
||||
|
||||
@@ -289,6 +289,40 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract session.fork; on success merge the child into summaries
|
||||
* immediately (same synchronous-addressability guarantee as create). The
|
||||
* child carries the source's history, so it is never blank; lineage rides
|
||||
* parentSessionId so the list nests it under its source. A child published
|
||||
* before Workspace attachment fails is also reconciled into the list.
|
||||
* @param opts - source session and the optional seq anchoring the cut.
|
||||
* @returns the fork result (the child session id).
|
||||
*/
|
||||
async fork(
|
||||
opts: { sessionId: SessionId; atSeq?: number },
|
||||
): Promise<RpcResult<{ sessionId: SessionId }>> {
|
||||
try {
|
||||
const source = this.summaries.find(s => s.sessionId === opts.sessionId)
|
||||
const { result } = await this.api.sessions.fork({
|
||||
sessionId: opts.sessionId,
|
||||
...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq },
|
||||
})
|
||||
const childId = result.ok
|
||||
? result.value.sessionId
|
||||
: workspaceAttachSessionId(result.error)
|
||||
if (childId !== undefined) {
|
||||
this.recordMutation({ kind: 'upsert', summary: {
|
||||
sessionId: childId, updatedAt: Date.now(), running: false, blank: false,
|
||||
parentSessionId: opts.sessionId,
|
||||
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
|
||||
} })
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
return transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert-or-enrich a locally synthesized summary: a new id prepends; an
|
||||
* existing entry only gains fields it lacks (the session-added frame and the
|
||||
|
||||
@@ -81,6 +81,22 @@ export class SessionCreateError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Structured session-fork failure. */
|
||||
export class SessionForkError extends Error {
|
||||
override readonly name = 'SessionForkError'
|
||||
|
||||
/**
|
||||
* @param rpcError - Host business or folded transport error.
|
||||
* @param sourceSessionId - the session the fork was cut from.
|
||||
*/
|
||||
constructor(
|
||||
readonly rpcError: RpcError,
|
||||
readonly sourceSessionId: SessionId,
|
||||
) {
|
||||
super(`session fork failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
|
||||
export interface SessionBinding {
|
||||
readonly sessionId: SessionId
|
||||
@@ -121,6 +137,24 @@ function displayTitleOf(title: string | undefined, cwd: string | undefined, id:
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment a trailing fork number while preserving its half-width or
|
||||
* full-width parentheses; an unnumbered title starts with ` (1)`.
|
||||
* @param title - source session's durable title.
|
||||
* @returns the title assigned to the fork child.
|
||||
*/
|
||||
function increasedForkTitle(title: string): string {
|
||||
const ascii = /^(.*?)\((\d+)\)$/u.exec(title)
|
||||
if (ascii?.[1] !== undefined && ascii[2] !== undefined) {
|
||||
return `${ascii[1]}(${BigInt(ascii[2]) + 1n})`
|
||||
}
|
||||
const fullWidth = /^(.*?)((\d+))$/u.exec(title)
|
||||
if (fullWidth?.[1] !== undefined && fullWidth[2] !== undefined) {
|
||||
return `${fullWidth[1]}(${BigInt(fullWidth[2]) + 1n})`
|
||||
}
|
||||
return `${title} (1)`
|
||||
}
|
||||
|
||||
interface ScopeRecord {
|
||||
fiber: Fiber
|
||||
ctx: Context
|
||||
@@ -317,6 +351,42 @@ export class SessionsService implements ISessions {
|
||||
return result.value.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork a session from a completed-turn prefix of the source (same
|
||||
* synchronous-addressability guarantee as {@link SessionsService.create}:
|
||||
* on resolution the child is in the list store and open() can target it).
|
||||
* @param opts - source session id, the optional event seq anchoring the
|
||||
* cut (the boundary is the first turn/end at or after it; an in-log
|
||||
* anchor in an open turn is unavailable rather than clipped backward),
|
||||
* and whether to increment an inherited durable title before resolving.
|
||||
* @returns the child session id.
|
||||
* @throws {SessionForkError} with the source id.
|
||||
* @throws {Error} when a requested child-title rename fails after creation.
|
||||
*/
|
||||
async fork(opts: {
|
||||
sessionId: SessionId
|
||||
atSeq?: number
|
||||
increaseTitle?: boolean
|
||||
}): Promise<SessionId> {
|
||||
const sourceTitle = opts.increaseTitle
|
||||
? this.list.getSnapshot().byId[opts.sessionId]?.title
|
||||
: undefined
|
||||
const result = await this.manager.fork({
|
||||
sessionId: opts.sessionId,
|
||||
...(opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }),
|
||||
})
|
||||
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
|
||||
this.projectList()
|
||||
const childId = result.value.sessionId
|
||||
if (sourceTitle !== undefined) {
|
||||
const child = this.binding(childId)?.session
|
||||
if (child === undefined) throw new Error(`fork child "${childId}" is not locally addressable`)
|
||||
const renamed = await child.rename(increasedForkTitle(sourceTitle))
|
||||
if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
|
||||
}
|
||||
return childId
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an Agent-scoped context view (use-and-discard).
|
||||
* @param id - session id (the agent identity — 1:1 same axis).
|
||||
|
||||
@@ -64,6 +64,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
@@ -118,6 +119,7 @@ export class FakeApiClient implements IApiClient {
|
||||
selectModel: (payload: { provider: string; model: string }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
|
||||
@@ -277,6 +277,23 @@ describe('remaining branches', () => {
|
||||
expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
|
||||
})
|
||||
|
||||
it('reconciles a fork child published before workspace attachment fails', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onFork = () => Promise.resolve(err({
|
||||
code: 'workspace-attach-failed',
|
||||
message: 'forked but unattached',
|
||||
details: { sessionId: S2, workspaceId: 'w1' },
|
||||
} as never))
|
||||
const manager = new SessionManager(api)
|
||||
const result = await manager.fork({ sessionId: S1 })
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
|
||||
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
|
||||
sessionId: S2,
|
||||
parentSessionId: S1,
|
||||
blank: false,
|
||||
})])
|
||||
})
|
||||
|
||||
it('reconciles a preallocated id after an ordinary transport failure', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onCreate = () => Promise.reject(new Error('response lost'))
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
|
||||
import { FakeApiClient, deferred, ok } from './fake-api.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
@@ -399,6 +399,69 @@ describe('create', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('fork', () => {
|
||||
it.each([
|
||||
['Roadmap', 'Roadmap (1)'],
|
||||
['Roadmap (1)', 'Roadmap (2)'],
|
||||
['计划(1)', '计划(2)'],
|
||||
['计划 (9)', '计划 (10)'],
|
||||
])('increments the durable title %j after the child is published', async (sourceTitle, childTitle) => {
|
||||
const b = bench()
|
||||
b.svc.handleMuxEnvelope({
|
||||
rpcId: 'source-title' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2 } as never,
|
||||
})
|
||||
await feedList(b, [{ id: 'source', cwd: '/work' }])
|
||||
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
|
||||
b.api.onRename = (payload) => {
|
||||
const { title } = payload as { title: string }
|
||||
return Promise.resolve(ok({ title, seq: 3 }))
|
||||
}
|
||||
|
||||
await expect(b.svc.fork({
|
||||
sessionId: sid('source'), atSeq: 7, increaseTitle: true,
|
||||
})).resolves.toBe('child')
|
||||
|
||||
expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 7 }])
|
||||
expect(b.api.callsOf('session.rename')).toEqual([{ sessionId: 'child', title: childTitle }])
|
||||
await Promise.resolve()
|
||||
expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({
|
||||
title: childTitle,
|
||||
displayTitle: childTitle,
|
||||
parentId: 'source',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not rename without the title policy or a durable source title', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 'source', cwd: '/work' }])
|
||||
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
|
||||
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })).resolves.toBe('child')
|
||||
expect(b.api.callsOf('session.rename')).toEqual([])
|
||||
|
||||
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child-2') }))
|
||||
await expect(b.svc.fork({ sessionId: sid('source') })).resolves.toBe('child-2')
|
||||
expect(b.api.callsOf('session.rename')).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects when child rename fails while keeping the published child addressable', async () => {
|
||||
const b = bench()
|
||||
b.svc.handleMuxEnvelope({
|
||||
rpcId: 'source-title' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2 } as never,
|
||||
})
|
||||
await feedList(b, [{ id: 'source' }])
|
||||
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
|
||||
b.api.onRename = () => Promise.resolve(err({
|
||||
code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') },
|
||||
}))
|
||||
|
||||
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
|
||||
.rejects.toThrow('fork child rename failed: title-invalid: rejected')
|
||||
expect(b.svc.binding(sid('child'))).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => {
|
||||
it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => {
|
||||
const b = bench()
|
||||
|
||||
@@ -169,7 +169,7 @@ export class TestSessions implements ISessions {
|
||||
private readonly channel: SessionProvideChannel
|
||||
|
||||
/** Calls observed on the service-level face (open/clear), newest last. */
|
||||
readonly calls: { method: 'open' | 'clear'; args: unknown[] }[] = []
|
||||
readonly calls: { method: 'open' | 'clear' | 'fork'; args: unknown[] }[] = []
|
||||
|
||||
/**
|
||||
* @param stabilize - the owning runtime's act wrapper.
|
||||
@@ -392,6 +392,17 @@ export class TestSessions implements ISessions {
|
||||
this.list.update((draft) => { draft.current = undefined })
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded fork stub: no child materializes (benches asserting the full
|
||||
* fork flow drive the production service; this face only proves the call).
|
||||
* @param opts - source session id, optional cut anchor, and client title policy.
|
||||
* @returns the source id (no child record is created).
|
||||
*/
|
||||
fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId> {
|
||||
this.calls.push({ method: 'fork', args: [opts] })
|
||||
return Promise.resolve(opts.sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The session face of a fixture (typed view for assertions; fixture
|
||||
* behavior methods are grafted onto it).
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('sessions', () => {
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('records service-face calls; open() moves the selection and clear() empties it', async () => {
|
||||
it('records service-face calls; open() moves selection, clear() empties it, and fork() echoes the source', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
await runtime.sessions.add({ id: 's1' })
|
||||
await runtime.sessions.add({ id: 's2' })
|
||||
@@ -211,9 +211,13 @@ describe('sessions', () => {
|
||||
runtime.sessions.clear()
|
||||
await runtime.flush()
|
||||
expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
|
||||
await expect(runtime.sessions.fork({
|
||||
sessionId: 's1' as SessionId, atSeq: 7, increaseTitle: true,
|
||||
})).resolves.toBe('s1')
|
||||
expect(runtime.sessions.calls).toEqual([
|
||||
{ method: 'open', args: ['s1'] },
|
||||
{ method: 'clear', args: [] },
|
||||
{ method: 'fork', args: [{ sessionId: 's1', atSeq: 7, increaseTitle: true }] },
|
||||
])
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -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: b95d60b64580dce48a94a888db8f5bff74be8d38
|
||||
README.zh.md: b88082c0cb4795fb4f1d0d051b6e79f4c7caab9b
|
||||
README.md: 46eda41d9feb469e75f3dc9f5a8b52b53aea6c1a
|
||||
README.zh.md: b21275f663f65662dcae2e3a843b4980721bb9f5
|
||||
|
||||
@@ -43,7 +43,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced.
|
||||
- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
|
||||
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch remains a chrome stub.
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected.
|
||||
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
||||
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
|
||||
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
|
||||
|
||||
@@ -41,9 +41,9 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。
|
||||
- **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
|
||||
- **统计行的耗时只覆盖窗口内消息流**:LLM(大语言模型)与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
|
||||
- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支仍是 chrome stub。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。
|
||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
||||
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
||||
|
||||
@@ -262,6 +262,13 @@ export function apply(ctx: Context): void {
|
||||
})
|
||||
},
|
||||
loadOlder: () => { void scoped.loadOlder() },
|
||||
forkAt: (seq) => {
|
||||
sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
|
||||
.then((childId) => { sessions.open(childId) })
|
||||
.catch(() => {
|
||||
// Fork or child-rename failure keeps the source view untouched.
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}, ChatView)
|
||||
|
||||
@@ -23,6 +23,10 @@ export interface AssistantMarkdownProps {
|
||||
interrupted?: boolean | undefined
|
||||
/** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */
|
||||
time?: number | undefined
|
||||
/** Event sequence used as the fork boundary; omitted while streaming. */
|
||||
seq?: number | undefined
|
||||
/** Fork the session through the turn containing this finalized message. */
|
||||
onFork?: ((seq: number) => void) | undefined
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
@@ -60,7 +64,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
}
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, time,
|
||||
blocks, streaming, interrupted, time, seq, onFork,
|
||||
}: AssistantMarkdownProps) {
|
||||
const last = blocks.length - 1
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass, so
|
||||
@@ -91,6 +95,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
text={copyText(blocks)}
|
||||
time={time}
|
||||
clock="end"
|
||||
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
|
||||
className={css.actions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -230,7 +230,7 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
* The chat view slot entry: pure component over the composed props (tool rows
|
||||
* render through the declared keyed hole's renderSlot share).
|
||||
*/
|
||||
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
|
||||
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt }: ChatViewSlotProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
// Workspace root off the session list row: path summaries display relative to it.
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
@@ -377,6 +377,8 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
streaming={false}
|
||||
interrupted={node.interrupted}
|
||||
time={node.time}
|
||||
seq={node.seq}
|
||||
onFork={forkAt}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -385,7 +387,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
}
|
||||
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
|
||||
if (node.kind === 'tool-result') return null
|
||||
return <MessageItem key={item.key} node={node} />
|
||||
return <MessageItem key={item.key} node={node} onFork={forkAt} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Shared IconActions chrome for user and assistant messages: copy / branch
|
||||
// live (branch still a stub), date-aware clock, optional edit stub.
|
||||
// Shared IconActions chrome for user and assistant messages: copy live,
|
||||
// branch wired through onBranch, date-aware clock,
|
||||
// optional edit stub.
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
@@ -18,17 +19,19 @@ export interface MessageIconActionsProps {
|
||||
clock: 'start' | 'end'
|
||||
/** When true, append the stub edit control (user bubble). */
|
||||
edit?: boolean | undefined
|
||||
/** Fork the session at this message. */
|
||||
onBranch?: (() => void) | undefined
|
||||
/** Parent layout class composed onto the actions row. */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
|
||||
* @param props - Copy text, event time, clock side, optional edit, className.
|
||||
* @param props - Copy text, event time, clock side, optional edit, branch callback, className.
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, clock, edit, className,
|
||||
text, time, clock, edit, onBranch, className,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const onCopy = useCallback(() => {
|
||||
@@ -48,7 +51,7 @@ export function MessageIconActions({
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="在新对话中分支" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="在新对话中分支">
|
||||
<button type="button" className={css.action} aria-label="在新对话中分支" onClick={onBranch}>
|
||||
<IconBranchOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -17,6 +17,8 @@ import css from './MessageItem.module.css'
|
||||
|
||||
export interface MessageItemProps {
|
||||
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | CompactionSummaryNode | UnknownSurfaceNode
|
||||
/** Fork the session through the turn containing this message (user-bubble branch action). */
|
||||
onFork?: (seq: number) => void
|
||||
}
|
||||
|
||||
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
|
||||
@@ -62,7 +64,7 @@ function projectUserText(text: string): ReactNode {
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
|
||||
export const MessageItem = memo(function MessageItem({ node, onFork }: MessageItemProps) {
|
||||
switch (node.kind) {
|
||||
case 'user': {
|
||||
const { text, rest } = contentText(node.content)
|
||||
@@ -77,6 +79,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
|
||||
time={node.time}
|
||||
clock="start"
|
||||
edit
|
||||
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
|
||||
className={css.actions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -419,6 +419,8 @@ export interface ChatViewInjected {
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
loadOlder: () => void
|
||||
/** Fork the session through the turn containing the message at `seq`, then open the child. */
|
||||
forkAt: (seq: number) => void
|
||||
}
|
||||
|
||||
/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */
|
||||
|
||||
@@ -19,6 +19,13 @@
|
||||
border-radius: 20px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
/* Elevated surface in dark, same as the menus: `.body` inside scrolls once
|
||||
the justification or command passes the cap, so the thumb takes the l2
|
||||
pair. Declared on the card because the elevation belongs to the surface,
|
||||
and the custom properties inherit down to the region that actually
|
||||
scrolls (see ui-theme styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
/* Tinted full-width header band. */
|
||||
@@ -40,11 +47,22 @@
|
||||
background: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
/* Scroll region: an agent's justification and its command are unbounded model
|
||||
text (a one-line `cd` or a 40-line heredoc), and the seat sits in a
|
||||
fixed-height column — uncapped, a long command pushed the action row past
|
||||
the viewport and the approval could not be answered at all. The strip and
|
||||
the action row stay outside, so the buttons are always on screen. */
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 16px 14px;
|
||||
/* border-box so the cap is the region's OUTER height: the composer's draft
|
||||
area counts its padding inside the same number, and the two seats are
|
||||
only interchangeable if they occupy the same box. */
|
||||
box-sizing: border-box;
|
||||
max-height: var(--dsh-composer-text-max-height);
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px 0;
|
||||
}
|
||||
|
||||
/* The model's justification is the panel's message, not a footnote. */
|
||||
@@ -63,11 +81,15 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Card-level row, not body content. Its padding reproduces the metrics the row
|
||||
had inside the body: 14px above (the flex gap of 6 plus the row's 8px top
|
||||
margin, neither of which reaches it out here) and the body's former 14px
|
||||
bottom pad below, so the resting card is unchanged. */
|
||||
.actionRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 14px 16px 14px;
|
||||
}
|
||||
|
||||
.allow,
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
// pending, this panel occupies the composer slot in place of the InputBar:
|
||||
// an amber "Waiting for approval" strip on the card top, the model's
|
||||
// justification as the headline, the paired command in muted code text, and
|
||||
// a right-aligned refuse/allow action row. One-shot: the buttons disable
|
||||
// a right-aligned refuse/allow action row. Justification and command are
|
||||
// unbounded model text, so they scroll inside the card at the shared composer
|
||||
// cap (`data-approval-scroll`) and the action row stays outside it — the
|
||||
// buttons must be reachable no matter how long the command is.
|
||||
// One-shot: the buttons disable
|
||||
// after a click and the panel leaves (the InputBar returns) on the broadcast
|
||||
// resolved frame. The draft's "Always allow this type" is deferred with
|
||||
// grant storage.
|
||||
@@ -53,17 +57,20 @@ function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?
|
||||
<div className={css.root} data-approval-key={pending.key}>
|
||||
<div className={css.card}>
|
||||
<div className={css.strip}><span className={css.dot} />等待审批</div>
|
||||
<div className={css.body}>
|
||||
{/* Tab stop: the region scrolls once the command passes the cap and
|
||||
holds nothing focusable of its own, so without one a keyboard-only
|
||||
user cannot reach the command's tail before answering. */}
|
||||
<div className={css.body} data-approval-scroll="" tabIndex={0} role="group" aria-label="审批详情">
|
||||
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</div>
|
||||
{command !== undefined && <div className={css.command}>{command}</div>}
|
||||
<div className={css.actionRow}>
|
||||
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
|
||||
拒绝
|
||||
</button>
|
||||
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
|
||||
允许一次
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.actionRow}>
|
||||
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
|
||||
拒绝
|
||||
</button>
|
||||
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
|
||||
允许一次
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,6 +143,14 @@
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
/* One cap for every scrolling text region a composer seat can hold: the
|
||||
InputBar draft (figma Input 75:8208 max 14 lines × 24px line) and the
|
||||
takeover panels' bodies top out at the same height, so electing a
|
||||
takeover never grows the footer past the card it replaces. Declared on
|
||||
the seat because it is the chain's only shared ancestor — fallback and
|
||||
elected overlay are siblings — and custom properties inherit down to
|
||||
whichever entry is mounted. */
|
||||
--dsh-composer-text-max-height: 336px;
|
||||
}
|
||||
|
||||
/* Active phase: header is ordinary column chrome above the scrollport (not
|
||||
|
||||
@@ -209,7 +209,9 @@
|
||||
.mirror {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
max-height: 336px;
|
||||
/* 14-line cap, shared with the composer takeovers (declared on
|
||||
ConversationRoot .composerSeat). */
|
||||
max-height: var(--dsh-composer-text-max-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,13 @@ describe('conversation slot inject surface', () => {
|
||||
const chatView = b.chatViewSurface(ROOT)
|
||||
chatView.injected.loadOlder()
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
chatView.injected.forkAt(17)
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
|
||||
})
|
||||
expect(b.runtime.sessions.calls).toContainEqual({
|
||||
method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }],
|
||||
})
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
||||
const openFile = vi.fn<(path: string) => void>()
|
||||
const loadOlder = vi.fn()
|
||||
const forkAt = vi.fn()
|
||||
// Selection rides the REAL chat store (same construction path as
|
||||
// production; the view reads it through the PropsStore useStore share).
|
||||
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
|
||||
@@ -120,9 +121,10 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
openDetails,
|
||||
openFile,
|
||||
loadOlder,
|
||||
forkAt,
|
||||
}
|
||||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, forkAt, setSelection }
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
@@ -194,6 +196,16 @@ describe('ChatView', () => {
|
||||
expect(view.getByText('run a')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('forks from both user and finalized assistant message actions at their event seq', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(buttons).toHaveLength(2)
|
||||
fireEvent.click(buttons[0]!)
|
||||
fireEvent.click(buttons[1]!)
|
||||
expect(h.forkAt.mock.calls).toEqual([[1], [2]])
|
||||
})
|
||||
|
||||
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
|
||||
const markdown = '# Rendered\n\n- **one**\n- `two`'
|
||||
const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })
|
||||
|
||||
@@ -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-question/README.md
|
||||
README.md: 0700375758774610fcd897b9a3e16484206a871d
|
||||
README.zh.md: d9e5eb22cef13e16ab1ce2cebba9e563bd9d08d9
|
||||
README.md: 5ebba2a1da6e6108b82e9deb235b84f987600345
|
||||
README.zh.md: 0aa6428a9b6472fc5b525c11b4716ebc50c378c3
|
||||
|
||||
@@ -6,6 +6,8 @@ Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user`
|
||||
|
||||
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
|
||||
A request whose single question declares a presentation intent renders as that intent's own surface instead. `plan-review` — set by `dsh-plan-mode` on the `exit_plan_mode` review — takes the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, the question text as the card's accessible name, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels (the intent names which label approves, so the verdict never rides option order) and keep the asker's descriptions as tooltips; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. The card claims a request only when it can send every answer that request allows: one question, the intent declared, the plan present as `detail`, the named approve label offered, and a binary single choice (at most one option besides approve, not multi-select). Anything else — no intent, a batch of several questions, a missing plan, an approve label naming no option, a third option, a multi-select decision — stays on the generic flow, which can express it. An intent changes the layout, never which answers are reachable.
|
||||
|
||||
Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
|
||||
|
||||
Composer chrome copy (pager, buttons, placeholders, validation feedback) is bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry its bound translator plus the locale snapshot source through the inject face, so a locale switch re-renders a mounted composer. Question and option text arrives from the model and renders verbatim; carrier failure messages also display untranslated.
|
||||
|
||||
@@ -6,6 +6,8 @@ Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧
|
||||
|
||||
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
|
||||
若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review` —— 由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置 —— 采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形 —— 没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定 —— 都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。
|
||||
|
||||
选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权:HTTP 交付成功不会在本地移除待处理状态。
|
||||
|
||||
编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale` 的 `question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该配置项,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/* Plan-review takeover: the waiting-approval card language (amber strip on a
|
||||
floating capsule, right-aligned actions) applied to a reviewed plan. Kept as
|
||||
its own module rather than shared with ui-conversation's ApprovalPanel: the
|
||||
two takeovers agree on tokens and geometry, not on content — this one's body
|
||||
is scrollable markdown, that one's is a headline plus a command line. Warn
|
||||
semantics ride the alias state tokens; no hardcoded colors. */
|
||||
|
||||
/* Mirrors the question card's frame so the takeover is a content swap. */
|
||||
.frame {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px 24px 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
/* Composer seat sits in a fixed-height conversation column (overflow
|
||||
hidden): cap the card against the viewport and scroll the plan, so the
|
||||
strip and the decision row stay reachable on a long plan. */
|
||||
max-height: min(60vh, 520px);
|
||||
border: 1px solid var(--dsw-alias-state-warn-secondary);
|
||||
border-radius: 20px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
/* Elevated surface in dark: the plan body inside scrolls once the card hits
|
||||
the cap above, so the thumb takes the l2 pair (see ui-theme
|
||||
styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.card,
|
||||
.card * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Tinted full-width header band, as on the approval takeover. */
|
||||
.strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
/* The plan is the panel's message: it takes the whole body and the scroll. */
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 12px 16px 4px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
padding: 8px 16px 12px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
min-height: 16px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.frame {
|
||||
padding: 6px 10px 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 10px 12px 4px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
align-items: flex-end;
|
||||
padding: 8px 12px 10px;
|
||||
}
|
||||
}
|
||||
100
packages/client/ui-question/src/client/PlanReviewPanel.tsx
Normal file
100
packages/client/ui-question/src/client/PlanReviewPanel.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
// PlanReviewPanel: the composer takeover for a question carrying the
|
||||
// `plan-review` presentation intent. A plan under review is one decision over
|
||||
// one body of markdown, so it takes the waiting-approval card shape — tinted
|
||||
// strip, content, right-aligned action row — instead of the generic question
|
||||
// flow's pager, numbered options, skip and custom-answer affordances, which
|
||||
// read as a quiz the user is being graded on.
|
||||
//
|
||||
// The three actions are the whole decision surface: approve and decline answer
|
||||
// the question with the option labels the asker offered (localised copy on the
|
||||
// buttons, the asker's descriptions as their tooltips), while "discuss"
|
||||
// dismisses the request so the composer returns and the user can simply say
|
||||
// what they want. Dismissal is the generic flow's own cancel verb, promoted to
|
||||
// a labelled button because in a two-outcome decision it is the third real
|
||||
// answer, not an escape hatch.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button, IconEditOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PendingQuestion, PlanReview, QuestionComposerProps } from './contract/slots.ts'
|
||||
import css from './PlanReviewPanel.module.css'
|
||||
|
||||
/** The panel's own props: the question domain face, the narrowed review, and the locale seat. */
|
||||
export type PlanReviewPanelProps =
|
||||
{ pending: PendingQuestion; review: PlanReview } & Pick<QuestionComposerProps, 't'>
|
||||
|
||||
/**
|
||||
* Optional-prop spread for a decision button's tooltip: `title` is optional on
|
||||
* the DOM props, and exactOptionalPropertyTypes rejects an explicit undefined.
|
||||
*
|
||||
* @param description - the asker's option description, when it carries one.
|
||||
* @returns The `title` prop to spread, or nothing.
|
||||
*/
|
||||
function tooltip(description: string | undefined): { title?: string } {
|
||||
return description === undefined ? {} : { title: description }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a plan review as a decision card.
|
||||
*
|
||||
* @param props - the question domain face, the narrowed plan review, and `t`.
|
||||
* @returns The plan-review takeover for this request.
|
||||
*/
|
||||
export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
|
||||
// One-shot latch shaped like the approval takeover's: the panel leaves only
|
||||
// when the host's resolved frame lands, so until then a second click must
|
||||
// not re-fire. A failed send (rejected receipt / transport) re-arms it and
|
||||
// shows why, since nothing else would tell the user the click was lost.
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const settle = (send: () => Promise<void>): void => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
void send().catch((cause: unknown) => {
|
||||
setBusy(false)
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
})
|
||||
}
|
||||
const decide = (label: string): void => {
|
||||
settle(() => pending.answer({ answers: [{ id: review.id, selected: [label] }] }))
|
||||
}
|
||||
const decline = review.decline
|
||||
|
||||
return (
|
||||
<div className={css.frame} data-plan-review-key={pending.key}>
|
||||
<section className={css.card} aria-label={review.question}>
|
||||
<div className={css.strip}>
|
||||
<span className={css.dot} />
|
||||
{t('plan.header')}
|
||||
</div>
|
||||
<div className={css.body} data-plan-review-scroll>
|
||||
<MarkdownText text={review.plan} />
|
||||
</div>
|
||||
<div className={css.footer}>
|
||||
<div className={css.feedback} role="status">{error}</div>
|
||||
<div className={css.actions}>
|
||||
<Button
|
||||
size="sm" variant="ghost" icon={<IconEditOutline16 />}
|
||||
disabled={busy} onClick={() => { settle(() => pending.cancel()) }}
|
||||
>
|
||||
{t('plan.discuss')}
|
||||
</Button>
|
||||
{decline !== undefined && (
|
||||
<Button
|
||||
size="sm" variant="outline" {...tooltip(decline.description)}
|
||||
disabled={busy} onClick={() => { decide(decline.label) }}
|
||||
>
|
||||
{t('plan.decline')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm" variant="primary" {...tooltip(review.approve.description)}
|
||||
disabled={busy} onClick={() => { decide(review.approve.label) }}
|
||||
>
|
||||
{t('plan.approve')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
IconCloseOutline16, IconEditOutline16, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
PendingQuestion,
|
||||
PendingQuestion, planReviewOf,
|
||||
type QuestionAnswer, type QuestionComposerProps,
|
||||
} from './contract/slots.ts'
|
||||
import { PlanReviewPanel } from './PlanReviewPanel.tsx'
|
||||
import css from './QuestionComposer.module.css'
|
||||
|
||||
interface DraftAnswer {
|
||||
@@ -46,14 +47,24 @@ function isComposing(event: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement
|
||||
/**
|
||||
* Composer takeover boundary; the carrier key keys local drafts, so a
|
||||
* same-request replay (same key, new carrier object) preserves them.
|
||||
*
|
||||
* One takeover, two shapes: a request that declares a presentation intent this
|
||||
* package renders takes that shape (a plan review is one decision over one
|
||||
* plan, not a question set), and every other request takes the generic flow.
|
||||
* The routing lives here, at the one entry that owns the composer seat, so
|
||||
* neither shape can claim a request the other is already rendering.
|
||||
*
|
||||
* @param props - the selector-matched pending question carrier plus the framework standard kit.
|
||||
* @returns The question flow for this request.
|
||||
* @returns The question flow, or the intent's own surface, for this request.
|
||||
*/
|
||||
export function QuestionComposer(props: QuestionComposerProps) {
|
||||
// Domain-face mint rides the carrier's stable identity (never minted in a
|
||||
// select/render dispatch — per-dispatch minting would churn memo identity).
|
||||
const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
|
||||
return <QuestionFlow key={question.key} pending={question} t={props.t} />
|
||||
const review = useMemo(() => planReviewOf(question.questions), [question])
|
||||
return review === undefined
|
||||
? <QuestionFlow key={question.key} pending={question} t={props.t} />
|
||||
: <PlanReviewPanel key={question.key} pending={question} review={review} t={props.t} />
|
||||
}
|
||||
|
||||
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
|
||||
|
||||
@@ -19,6 +19,71 @@ export type QuestionWait = PendingWait<'question'>
|
||||
/** One structured answer batch covering every question of the request. */
|
||||
export type QuestionAnswer = QuestionResponsePayload['answer']
|
||||
|
||||
/** One question of the request, as the carrier payload carries it. */
|
||||
type QuestionItem = QuestionWait['payload']['questions'][number]
|
||||
|
||||
/** One option the asker offered on a question. */
|
||||
type QuestionOption = NonNullable<QuestionItem['options']>[number]
|
||||
|
||||
/**
|
||||
* A request narrowed to the `plan-review` presentation intent: everything the
|
||||
* decision card renders and answers with, so the panel never re-reads the
|
||||
* request shape. `approve` and `decline` are the asker's own options — an
|
||||
* answer must carry one of those labels verbatim — and `plan` is the markdown
|
||||
* body under review.
|
||||
*/
|
||||
export interface PlanReview {
|
||||
/** The reviewed question's id, echoed in the answer. */
|
||||
id: string
|
||||
/** The question text, kept as the card's accessible name. */
|
||||
question: string
|
||||
/** The plan markdown under review. */
|
||||
plan: string
|
||||
/** The option that approves the plan. */
|
||||
approve: QuestionOption
|
||||
/** The option that declines it; absent when the asker offered no other option. */
|
||||
decline?: QuestionOption
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a request to a renderable plan review, or return undefined to leave it
|
||||
* to the generic question flow.
|
||||
*
|
||||
* The card is one decision over one plan, and it claims a request only when it
|
||||
* can send every answer that request allows — an intent changes the layout,
|
||||
* never which answers are reachable. So the batch must be a single question
|
||||
* that declares the intent, carries the plan as its detail, offers the approve
|
||||
* label the intent names, and is a binary single choice: at most one option
|
||||
* besides approve, and not multi-select. A third option or a multi-select batch
|
||||
* has answers two buttons cannot express, so the generic flow keeps it — as it
|
||||
* keeps any request whose intent the asker's own service would have rejected,
|
||||
* because the client sits downstream of a wire boundary and every request must
|
||||
* stay answerable.
|
||||
*
|
||||
* @param questions - the request's whole question batch.
|
||||
* @returns The narrowed review, or undefined when the generic flow owns it.
|
||||
*/
|
||||
export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | undefined {
|
||||
if (questions.length !== 1) return undefined
|
||||
// Length-checked above; the index read is the narrowing tax, not a guess.
|
||||
const question = questions[0] as QuestionItem
|
||||
const intent = question.intent
|
||||
if (intent?.kind !== 'plan-review' || question.detail === undefined) return undefined
|
||||
if (question.multiSelect === true) return undefined
|
||||
const options = question.options ?? []
|
||||
if (options.length > 2) return undefined
|
||||
const approve = options.find(option => option.label === intent.approve)
|
||||
if (approve === undefined) return undefined
|
||||
const decline = options.find(option => option.label !== intent.approve)
|
||||
return {
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
plan: question.detail,
|
||||
approve,
|
||||
...(decline === undefined ? {} : { decline }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Question domain face over the carrier: render identity and questions
|
||||
* transparently forwarded; answer/cancel own the wire encoding (the ok value
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
* question carrier (matched prop), and the whole behavior surface rides the
|
||||
* carrier (domain encoding in contract/slots.ts PendingQuestion); copy rides
|
||||
* the standard locale seat. Export discipline: packages/client/AGENTS.md.
|
||||
*
|
||||
* One entry, two shapes: the composer renders a request that declares a
|
||||
* presentation intent as that intent's own surface (`plan-review` → the plan
|
||||
* decision card) and every other request as the generic question flow. A
|
||||
* separate chain entry per shape would race the same carrier, so the shape
|
||||
* choice lives inside this entry — see QuestionComposer.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -15,7 +21,9 @@ import { QuestionComposer } from './QuestionComposer.tsx'
|
||||
import { en, zh, type QuestionKey } from './locales.ts'
|
||||
|
||||
export { PendingQuestion } from './contract/slots.ts'
|
||||
export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
|
||||
export type {
|
||||
PlanReview, QuestionAnswer, QuestionComposerProps, QuestionWait,
|
||||
} from './contract/slots.ts'
|
||||
export type { QuestionKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
||||
@@ -11,6 +11,10 @@ export const zh = {
|
||||
'custom.placeholder': '输入你的答案',
|
||||
'action.skip': '跳过本题',
|
||||
'action.next': '下一题',
|
||||
'plan.header': '计划待审',
|
||||
'plan.approve': '确认执行',
|
||||
'plan.decline': '拒绝',
|
||||
'plan.discuss': '去聊天里说',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The question namespace key union. */
|
||||
@@ -27,4 +31,8 @@ export const en = {
|
||||
'custom.placeholder': 'Type your answer',
|
||||
'action.skip': 'Skip this question',
|
||||
'action.next': 'Next',
|
||||
'plan.header': 'Plan review',
|
||||
'plan.approve': 'Approve',
|
||||
'plan.decline': 'Refuse',
|
||||
'plan.discuss': 'Chat about it',
|
||||
} satisfies Record<QuestionKey, string>
|
||||
|
||||
228
packages/client/ui-question/tests/plan-review-panel.spec.tsx
Normal file
228
packages/client/ui-question/tests/plan-review-panel.spec.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
// @vitest-environment jsdom
|
||||
// The plan-review takeover, driven through the composer entry that routes to
|
||||
// it: a request carrying the intent must reach the decision card and answer
|
||||
// with the asker's own option labels, and a request that does not (or cannot)
|
||||
// must keep the generic question flow.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type {
|
||||
ConversationSnapshot, SessionId, SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { planReviewOf, type QuestionComposerProps, type QuestionWait } from '../src/client/contract/slots.ts'
|
||||
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
/** Seat stub over a dictionary pair mirroring the real lookup chain: package dictionary, then common vocabulary, then the key. */
|
||||
const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
|
||||
(key => dict[key] ?? common[key] ?? key)
|
||||
|
||||
/** Framework standard-kit stubs: the panel consumes only the locale seat. */
|
||||
const kit = {
|
||||
sessionId: SID,
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
|
||||
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
|
||||
useProjection: (() => undefined) as never,
|
||||
useInput: (() => { throw new Error('unused') }) as never,
|
||||
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
|
||||
t: seatOver(zh, commonZh),
|
||||
}
|
||||
|
||||
const PLAN = '# Ship the picker\n\n- read the store\n- render the rows\n'
|
||||
|
||||
/** The plan-mode request shape: one question, the plan as detail, approve named. */
|
||||
const questions = (): QuestionWait['payload']['questions'] => [{
|
||||
id: 'plan-review',
|
||||
header: 'Plan review',
|
||||
question: 'Approve this plan and leave plan mode?',
|
||||
detail: PLAN,
|
||||
options: [
|
||||
{ label: 'Approve', description: 'Leave plan mode; the plan is carried out from the next step.' },
|
||||
{ label: 'Keep planning', description: 'Stay in plan mode; feedback goes back to the model.' },
|
||||
],
|
||||
intent: { kind: 'plan-review', approve: 'Approve' },
|
||||
}]
|
||||
|
||||
/** Carrier fixture over a scripted respond carrier. */
|
||||
function wait(
|
||||
payload: QuestionWait['payload'] = { questions: questions() },
|
||||
respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true })),
|
||||
) {
|
||||
return { carrier: new PendingWait('question', RpcId('q-1'), SID, payload, respond), respond }
|
||||
}
|
||||
|
||||
/** The client-response envelope respond must have received for a decision. */
|
||||
function decidedEnvelope(label: string) {
|
||||
return {
|
||||
type: 'client-response', rpcId: RpcId('q-1'),
|
||||
result: { ok: true, value: { sessionId: SID, answer: { answers: [{ id: 'plan-review', selected: [label] }] } } },
|
||||
}
|
||||
}
|
||||
|
||||
describe('planReviewOf', () => {
|
||||
it('narrows a plan-review request to its decision, options included', () => {
|
||||
expect(planReviewOf(questions())).toEqual({
|
||||
id: 'plan-review',
|
||||
question: 'Approve this plan and leave plan mode?',
|
||||
plan: PLAN,
|
||||
approve: { label: 'Approve', description: 'Leave plan mode; the plan is carried out from the next step.' },
|
||||
decline: { label: 'Keep planning', description: 'Stay in plan mode; feedback goes back to the model.' },
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the decline absent when the asker offered approve alone', () => {
|
||||
const [question] = questions()
|
||||
const review = planReviewOf([{ ...question as object, options: [{ label: 'Approve' }] } as never])
|
||||
expect(review?.approve).toEqual({ label: 'Approve' })
|
||||
expect(review === undefined ? true : 'decline' in review).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a batch of more than one question', () => [...questions(), ...questions()]],
|
||||
['no intent at all', () => [{ ...questions()[0] as object, intent: undefined }]],
|
||||
['an intent without the plan as detail', () => [{ ...questions()[0] as object, detail: undefined }]],
|
||||
['an intent whose approve names no option', () => [{
|
||||
...questions()[0] as object, intent: { kind: 'plan-review', approve: 'Ship it' },
|
||||
}]],
|
||||
['an intent with no options at all', () => [{ ...questions()[0] as object, options: undefined }]],
|
||||
// Two buttons cannot send a third label or a combination, and the generic
|
||||
// flow can: an intent never costs the user a reachable answer.
|
||||
['a third option the card could not offer', () => [{
|
||||
...questions()[0] as object,
|
||||
options: [{ label: 'Approve' }, { label: 'Keep planning' }, { label: 'Start over' }],
|
||||
}]],
|
||||
['a multi-select decision', () => [{ ...questions()[0] as object, multiSelect: true }]],
|
||||
])('declines %s, leaving the request to the generic flow', (_case, build) => {
|
||||
expect(planReviewOf(build() as never)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('declines an empty batch, which the generic flow reports as such', () => {
|
||||
expect(planReviewOf([])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlanReviewPanel', () => {
|
||||
it('renders the plan under a review strip, with none of the quiz affordances', () => {
|
||||
const { carrier } = wait()
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
expect(document.querySelector('[data-plan-review-key="q:q-1"]')).toBeTruthy()
|
||||
expect(screen.getByText(zh['plan.header'])).toBeTruthy()
|
||||
// The plan renders as markdown, so its heading is a heading.
|
||||
expect(screen.getByRole('heading', { name: 'Ship the picker' })).toBeTruthy()
|
||||
expect(screen.getByText('render the rows')).toBeTruthy()
|
||||
// The question text stays as the card's accessible name rather than a title
|
||||
// that reads like a test item.
|
||||
expect(screen.getByLabelText('Approve this plan and leave plan mode?')).toBeTruthy()
|
||||
// No pager, no numbered options, no skip, no custom answer.
|
||||
expect(screen.queryByText('1 / 1')).toBeNull()
|
||||
expect(screen.queryByRole('radio')).toBeNull()
|
||||
expect(screen.queryByText(zh['action.skip'])).toBeNull()
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
})
|
||||
|
||||
it('answers with the asker\'s approve label and keeps its description as the tooltip', () => {
|
||||
const { carrier, respond } = wait()
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
const approve = screen.getByRole('button', { name: zh['plan.approve'] })
|
||||
expect(approve.getAttribute('title')).toBe('Leave plan mode; the plan is carried out from the next step.')
|
||||
fireEvent.click(approve)
|
||||
expect(respond).toHaveBeenCalledWith(decidedEnvelope('Approve'))
|
||||
// One-shot: every action locks until the host's resolved frame lands.
|
||||
expect(approve.hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.click(approve)
|
||||
expect(respond).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('answers with the asker\'s decline label', () => {
|
||||
const { carrier, respond } = wait()
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: zh['plan.decline'] }))
|
||||
expect(respond).toHaveBeenCalledWith(decidedEnvelope('Keep planning'))
|
||||
})
|
||||
|
||||
it('dismisses the request so the composer returns for a plain message', () => {
|
||||
const { carrier, respond } = wait()
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
|
||||
expect(respond).toHaveBeenCalledWith({
|
||||
type: 'client-response', rpcId: RpcId('q-1'),
|
||||
result: {
|
||||
ok: false,
|
||||
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the tooltip for an option carrying no description', () => {
|
||||
const { carrier } = wait({ questions: [{
|
||||
...questions()[0] as object,
|
||||
options: [{ label: 'Approve' }, { label: 'Keep planning' }],
|
||||
}] as never })
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('title')).toBe(false)
|
||||
expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('title')).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the decline action when the asker offered approve alone', () => {
|
||||
const { carrier } = wait({ questions: [{
|
||||
...questions()[0] as object, options: [{ label: 'Approve' }],
|
||||
}] as never })
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: zh['plan.decline'] })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: zh['plan.approve'] })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('re-arms the actions and says why when the decision does not land', async () => {
|
||||
const { carrier, respond } = wait(
|
||||
{ questions: questions() },
|
||||
vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: false, reason: 'not-pending' })),
|
||||
)
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
|
||||
const failure = await screen.findByText('question response rejected: not-pending')
|
||||
expect(failure.getAttribute('role')).toBe('status')
|
||||
// Re-armed for the retry: a lost click must not leave a dead card.
|
||||
expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('disabled')).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
|
||||
expect(respond).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports a non-Error transport failure as its stringified value', async () => {
|
||||
// A non-Error rejection is the case under test: a carrier can reject with
|
||||
// anything, and the panel must still show the user something.
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
||||
const { carrier } = wait({ questions: questions() }, vi.fn(() => Promise.reject('socket gone')))
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
|
||||
expect(await screen.findByText('socket gone')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('carries the same decision surface in English', () => {
|
||||
const { carrier } = wait()
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
|
||||
|
||||
expect(screen.getByText('Plan review')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Refuse' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Chat about it' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -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-workspace/README.md
|
||||
README.md: a1b58f4abe0925be3b426d10344777e46caa9ba0
|
||||
README.zh.md: a472507bc45549c8feb55a75d294cbd7b3138cc5
|
||||
README.md: 860c24b8a25a1e9968261f586c16163579131a1c
|
||||
README.zh.md: 5a8e88051fc6f4fd46c5f2f6dcdc185eb4559ac6
|
||||
|
||||
@@ -6,6 +6,8 @@ Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sideba
|
||||
|
||||
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration.
|
||||
|
||||
The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list.
|
||||
|
||||
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
|
||||
|
||||
## Model Experience
|
||||
@@ -18,5 +20,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No Session deletion or fork control** — the Session menu's Fork and Delete rows remain visual-only (Rename is wired); Workspace registration deletion does not delete Sessions.
|
||||
- **No Session deletion control** — the Session menu's Delete row remains visual-only; Workspace registration deletion does not delete Sessions.
|
||||
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。
|
||||
|
||||
Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。
|
||||
|
||||
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
|
||||
|
||||
## 模型体验
|
||||
@@ -18,5 +20,5 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有 Session 删除与 fork 控件**:Session 菜单的 Fork 与 Delete 行仍仅提供视觉效果(Rename 已接线);删除 Workspace 注册记录不会删除 Session。
|
||||
- **没有 Session 删除控件**:Session 菜单的 Delete 行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
|
||||
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。
|
||||
|
||||
@@ -83,7 +83,7 @@ interface DragState {
|
||||
|
||||
type SessionTreeProps = Pick<
|
||||
WorkspaceBrowserProps,
|
||||
'useSessions' | 'startSession' | 'open' | 'insertSessionBefore'
|
||||
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore'
|
||||
> & {
|
||||
workspaces: readonly WorkspaceView[]
|
||||
/** Live search filter owned by the browser root (the query outlives the tree). */
|
||||
@@ -98,13 +98,12 @@ type SessionTreeProps = Pick<
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({
|
||||
useSessions, startSession, open, workspaces, query,
|
||||
useSessions, startSession, open, forkSession, workspaces, query,
|
||||
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore,
|
||||
}: SessionTreeProps) {
|
||||
const list = useSessions(s => s)
|
||||
const current = list.current
|
||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
|
||||
// Transient drag viewing state (never store-bound; order truth stays Host-side).
|
||||
const [drag, setDrag] = useState<DragState | null>(null)
|
||||
const currentGroup = current === undefined
|
||||
@@ -116,8 +115,8 @@ function SessionTree({
|
||||
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
}, [current, currentGroup])
|
||||
const groups = useMemo(
|
||||
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
|
||||
[list, workspaces, expandedProjects, expandedSessions, query],
|
||||
() => deriveGroups(list, workspaces, { expandedProjects, query }),
|
||||
[list, workspaces, expandedProjects, query],
|
||||
)
|
||||
const now = Date.now()
|
||||
|
||||
@@ -128,7 +127,7 @@ function SessionTree({
|
||||
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
|
||||
)}
|
||||
{groups.map(group => (
|
||||
// Group section: header row + expanded session subtree. The
|
||||
// Group section: header row + expanded top-level session rows. The
|
||||
// inter-group breathing room (former flat-list batch separator)
|
||||
// is the section's own margin (WorkspaceBrowser.module.css).
|
||||
<div key={group.key} className={css.groupSection}>
|
||||
@@ -152,7 +151,7 @@ function SessionTree({
|
||||
}}
|
||||
/>
|
||||
{group.sessions.map((node, index) => {
|
||||
// Draggable: real-workspace group roots outside search. The drag
|
||||
// Draggable: real-workspace session rows outside search. The drag
|
||||
// never leaves its group — rows of other groups show no markers
|
||||
// and reject drops (visual movement confined to this section).
|
||||
const draggable = group.workspaceId !== undefined && query === ''
|
||||
@@ -170,15 +169,15 @@ function SessionTree({
|
||||
drop: (half: 'before' | 'after') => {
|
||||
/* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */
|
||||
if (drag === null) return
|
||||
const roots = group.sessions
|
||||
const sessions = group.sessions
|
||||
// Anchor = the row the insert line points at ('after' means
|
||||
// the next root; end-of-list omits the anchor → append).
|
||||
const anchor = half === 'before' ? node.id : roots[index + 1]?.id
|
||||
const anchor = half === 'before' ? node.id : sessions[index + 1]?.id
|
||||
setDrag(null)
|
||||
if (anchor === drag.sessionId) return
|
||||
// No-op when the drop lands back on the source position.
|
||||
const sourceIndex = roots.findIndex(r => r.id === drag.sessionId)
|
||||
const anchorIndex = anchor === undefined ? roots.length : roots.findIndex(r => r.id === anchor)
|
||||
const sourceIndex = sessions.findIndex(r => r.id === drag.sessionId)
|
||||
const anchorIndex = anchor === undefined ? sessions.length : sessions.findIndex(r => r.id === anchor)
|
||||
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
|
||||
insertSessionBefore(drag.workspaceId, drag.sessionId, anchor).catch((reason: unknown) => {
|
||||
console.warn('session reorder rejected:', reason)
|
||||
@@ -190,12 +189,11 @@ function SessionTree({
|
||||
<SessionNodeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
currentId={current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
|
||||
onFork={forkSession}
|
||||
drag={dragProps}
|
||||
/>
|
||||
)
|
||||
@@ -209,7 +207,7 @@ function SessionTree({
|
||||
}
|
||||
|
||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
||||
function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'onSessionRename' | 'query'>) {
|
||||
function FlatList({ useSessions, open, forkSession, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'query'>) {
|
||||
const list = useSessions(s => s)
|
||||
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
|
||||
const now = Date.now()
|
||||
@@ -223,14 +221,11 @@ function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTre
|
||||
<SessionNodeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
currentId={list.current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
|
||||
onToggle={() => {}}
|
||||
flat
|
||||
onFork={forkSession}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -254,6 +249,7 @@ export function WorkspaceBrowser({
|
||||
startSession,
|
||||
open,
|
||||
renameSession,
|
||||
forkSession,
|
||||
renameWorkspace,
|
||||
deleteWorkspace,
|
||||
insertSessionBefore,
|
||||
@@ -462,11 +458,12 @@ export function WorkspaceBrowser({
|
||||
itself is wide-only. */}
|
||||
<div className={css.listArea}>
|
||||
{wide && (groupBy === 'flat'
|
||||
? <FlatList useSessions={useSessions} open={open} onSessionRename={onSessionRename} query={query} />
|
||||
? <FlatList useSessions={useSessions} open={open} forkSession={forkSession} onSessionRename={onSessionRename} query={query} />
|
||||
: (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
onSessionRename={onSessionRename}
|
||||
forkSession={forkSession}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
|
||||
@@ -95,6 +95,8 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
|
||||
open: (sessionId: SessionId) => void
|
||||
/** Rename a Session (explicit user title; resolves on host acceptance). */
|
||||
renameSession: (sessionId: SessionId, title: string) => Promise<void>
|
||||
/** Fork a Session at its last completed turn and open the child. */
|
||||
forkSession: (sessionId: SessionId) => void
|
||||
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
|
||||
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
|
||||
/** Delete only a Host Workspace registration; directory and Session logs remain. */
|
||||
|
||||
@@ -59,6 +59,13 @@ export function apply(ctx: ClientContext): void {
|
||||
const result = await session.rename(title)
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
},
|
||||
forkSession: (sessionId) => {
|
||||
ctx.sessions.fork({ sessionId, increaseTitle: true })
|
||||
.then((childId) => { ctx.sessions.open(childId) })
|
||||
.catch(() => {
|
||||
// Fork or child-rename failure keeps the current selection.
|
||||
})
|
||||
},
|
||||
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
||||
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
|
||||
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
||||
|
||||
@@ -39,9 +39,7 @@
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Session cell (figma): pad 8, adjacent 16px twist + status slots, then a 4px
|
||||
gap to the title — the slots butt together, so the row gap is zeroed and
|
||||
the title carries its own margins. */
|
||||
/* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */
|
||||
.sessionRow {
|
||||
height: 34px;
|
||||
gap: 0;
|
||||
@@ -168,7 +166,7 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Drag reorder insert line (workspace-group roots): 2px accent above or
|
||||
/* Drag reorder insert line (workspace-group session rows): 2px accent above or
|
||||
below the hovered row, drawn with box-shadow so no layout shift. */
|
||||
.sessionRow.dropBefore {
|
||||
box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary);
|
||||
@@ -233,33 +231,9 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Session expand twist occupies the leading 16px slot; keep a spacer when absent
|
||||
so titles align across sibling rows. Duplicates the .iconButton reset instead
|
||||
of `composes:` — the tsdown CSS-modules pipeline drops composes mappings, which
|
||||
left the raw UA button box showing. */
|
||||
.twist {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.twist:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Chevrons and tree twists ride the caption grey (#ADB2B8); the folder glyph
|
||||
stays one step darker (tertiary, #81858C) per the cell spec. Declared last
|
||||
to win over the composed .iconButton color. */
|
||||
.chevron,
|
||||
.twist {
|
||||
/* Chevrons ride the caption grey (#ADB2B8); the folder glyph stays one step
|
||||
darker (tertiary, #81858C) per the cell spec. */
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
|
||||
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
|
||||
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
|
||||
* except workspace Rename/Delete and session Rename; the session and workspace
|
||||
* hover cards are suppressed while a menu is open.
|
||||
* except workspace Rename/Delete and session Rename/Fork; the session and
|
||||
* workspace hover cards are suppressed while a menu is open.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -16,9 +16,6 @@ import type { GroupNode, SessionNode } from '../tree.ts'
|
||||
import { formatRelativeTime } from '../tree.ts'
|
||||
import css from './Rows.module.css'
|
||||
|
||||
/** Indent step per tree level: one 16px slot (figma session cell). */
|
||||
const INDENT_STEP = 16
|
||||
|
||||
const SESSION_MENU_ITEMS = [
|
||||
{ id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> },
|
||||
{ id: 'fork', label: 'Fork session', icon: <IconBranchOutline16 /> },
|
||||
@@ -135,16 +132,12 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
|
||||
}
|
||||
|
||||
/**
|
||||
* One session subtree: the node's own 34px row (indent by depth, expand
|
||||
* twist when it has children, running dot, relative time) plus its visible
|
||||
* children, recursively — the component tree mirrors the derived tree.
|
||||
* One top-level 34px session row with running dot and relative time.
|
||||
* @param props.node - derived session node.
|
||||
* @param props.depth - 0 = directly under the group header.
|
||||
* @param props.currentId - selected session id (row highlight).
|
||||
* @param props.now - epoch ms for relative-time formatting.
|
||||
* @param props.onOpen - open a session by id.
|
||||
* @param props.onToggle - unfold/fold a subtree by id.
|
||||
* @returns the node's row followed by its children.
|
||||
* @returns the session row.
|
||||
*/
|
||||
/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */
|
||||
function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) {
|
||||
@@ -161,7 +154,7 @@ function SessionHoverContent({ node, now }: { node: SessionNode; now: number })
|
||||
}
|
||||
|
||||
/**
|
||||
* Root-row drag wiring supplied by the group owner (workspace groups only).
|
||||
* Session-row drag wiring supplied by the group owner (workspace groups only).
|
||||
* `drop` reports the half of the row the pointer released on: 'before'
|
||||
* inserts above this row, 'after' below it (the owner resolves the anchor).
|
||||
*/
|
||||
@@ -184,26 +177,22 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
|
||||
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onToggle, drag, flat = false }: {
|
||||
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, drag }: {
|
||||
node: SessionNode
|
||||
depth: number
|
||||
currentId: string | undefined
|
||||
now: number
|
||||
onOpen: (id: SessionNode['id']) => void
|
||||
/** Open the browser-owned session rename dialog (row menu action). */
|
||||
onRename: (id: SessionNode['id'], currentTitle: string) => void
|
||||
onToggle: (id: SessionNode['id']) => void
|
||||
/** Present only on draggable rows (workspace-group roots outside search). */
|
||||
/** Fork a session at its last completed turn (row menu action). */
|
||||
onFork: (id: SessionNode['id']) => void
|
||||
/** Present only on draggable rows (workspace-group sessions outside search). */
|
||||
drag?: RowDragProps | undefined
|
||||
/** Flat-list variant: no twist slot (figma flat cell) — titles align on the status slot. */
|
||||
flat?: boolean
|
||||
}) {
|
||||
const row = node
|
||||
const selected = node.id === currentId
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
// Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to
|
||||
// the title): both slots are always reserved so titles align whether or not
|
||||
// the twist/dot is lit. Extra depth rides the left padding.
|
||||
// Figma session cell: pad 8, status slot 16, then a 4px title gap.
|
||||
const ownRow = (
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -212,8 +201,6 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
|
||||
)}
|
||||
role="treeitem"
|
||||
aria-selected={selected}
|
||||
{...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
|
||||
style={{ paddingLeft: 8 + depth * INDENT_STEP }}
|
||||
onClick={() => { onOpen(node.id) }}
|
||||
draggable={drag !== undefined}
|
||||
onDragStart={drag === undefined
|
||||
@@ -239,18 +226,6 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
|
||||
drag.drop(rowHalf(e))
|
||||
}}
|
||||
>
|
||||
{row.hasChildren && !flat
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.twist}
|
||||
aria-label={row.expanded ? 'Collapse' : 'Expand'}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
|
||||
>
|
||||
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
|
||||
<span className={css.title}>{row.title}</span>
|
||||
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
|
||||
@@ -261,7 +236,8 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
|
||||
items={SESSION_MENU_ITEMS}
|
||||
onSelect={(id) => {
|
||||
setMenuOpen(false)
|
||||
if (id === 'rename') onRename(node.id, row.title) // fork/delete stay visual-only.
|
||||
if (id === 'rename') onRename(node.id, row.title)
|
||||
if (id === 'fork') onFork(node.id) // delete stays visual-only.
|
||||
}}
|
||||
portal
|
||||
closeOnPointerLeave
|
||||
@@ -280,24 +256,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<HoverCard
|
||||
anchor={ownRow}
|
||||
content={<SessionHoverContent node={node} now={now} />}
|
||||
disabled={menuOpen || drag?.active === true}
|
||||
/>
|
||||
{node.children.map(child => (
|
||||
<SessionNodeItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
currentId={currentId}
|
||||
now={now}
|
||||
onOpen={onOpen}
|
||||
onRename={onRename}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
<HoverCard
|
||||
anchor={ownRow}
|
||||
content={<SessionHoverContent node={node} now={now} />}
|
||||
disabled={menuOpen || drag?.active === true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,20 +11,15 @@ export const UNGROUPED_KEY = ''
|
||||
/** Display label for the ungrouped bucket row. */
|
||||
export const UNGROUPED_LABEL = 'Ungrouped'
|
||||
|
||||
/** One session node of a group's visible tree (34px row; children render indented one step). */
|
||||
/** One top-level session row in a group or the flat list. */
|
||||
export interface SessionNode {
|
||||
id: SessionId
|
||||
title: string
|
||||
/** Visible children, already expansion/search-filtered (empty when folded). */
|
||||
children: readonly SessionNode[]
|
||||
/** The session HAS children in the data (the twist renders even while folded). */
|
||||
hasChildren: boolean
|
||||
expanded: boolean
|
||||
running: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** One workspace group section: header row facts + the visible session tree. */
|
||||
/** One workspace group section: header row facts + visible top-level session rows. */
|
||||
export interface GroupNode {
|
||||
/** Group key: the workspace id or {@link UNGROUPED_KEY}. */
|
||||
key: string
|
||||
@@ -39,14 +34,13 @@ export interface GroupNode {
|
||||
expanded: boolean
|
||||
/** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */
|
||||
containsCurrent: boolean
|
||||
/** Visible roots (empty while the group is folded). */
|
||||
/** Visible session rows (empty while the group is folded). */
|
||||
sessions: readonly SessionNode[]
|
||||
}
|
||||
|
||||
/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */
|
||||
/** Viewing state consumed by the derivation. */
|
||||
export interface TreeView {
|
||||
expandedProjects: readonly string[]
|
||||
expandedSessions: readonly string[]
|
||||
query: string
|
||||
}
|
||||
|
||||
@@ -56,9 +50,7 @@ interface Group {
|
||||
cwd: string | undefined
|
||||
createdAt: number | undefined
|
||||
label: string
|
||||
summaries: Map<SessionId, SessionSummary>
|
||||
roots: SessionId[]
|
||||
children: Map<SessionId, SessionId[]>
|
||||
sessions: SessionSummary[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,7 +81,7 @@ function sessionTitle(session: SessionSummary): string {
|
||||
return session.blank ? 'New Session' : session.displayTitle
|
||||
}
|
||||
|
||||
/** Build one group's parent/child tree from an ordered member list. */
|
||||
/** Build one group without projecting session lineage into presentation. */
|
||||
function buildGroup(
|
||||
key: string,
|
||||
workspaceId: WorkspaceId | undefined,
|
||||
@@ -99,54 +91,11 @@ function buildGroup(
|
||||
members: readonly SessionSummary[],
|
||||
order: 'account' | 'recency',
|
||||
): Group {
|
||||
const summaries = new Map(members.map(m => [m.id, m]))
|
||||
const children = new Map<SessionId, SessionId[]>()
|
||||
const roots: SessionSummary[] = []
|
||||
for (const m of members) {
|
||||
// A session is a tree child only when its parent lives in the same
|
||||
// group; cross-group or unknown parents degrade to group roots.
|
||||
if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) {
|
||||
const kids = children.get(m.parentId)
|
||||
if (kids === undefined) children.set(m.parentId, [m.id])
|
||||
else kids.push(m.id)
|
||||
} else {
|
||||
roots.push(m)
|
||||
}
|
||||
}
|
||||
// Workspace order is the member iteration order (workspace.sessionIds), so
|
||||
// attached groups keep insertion order; Ungrouped sorts by recency.
|
||||
if (order === 'recency') {
|
||||
roots.sort(byRecency)
|
||||
for (const kids of children.values()) {
|
||||
kids.sort((a, b) => {
|
||||
const sa = summaries.get(a)
|
||||
const sb = summaries.get(b)
|
||||
/* v8 ignore next -- unreachable: kid ids are inserted alongside their summaries. */
|
||||
if (sa === undefined || sb === undefined) return 0
|
||||
return byRecency(sa, sb)
|
||||
})
|
||||
}
|
||||
}
|
||||
const rootIds = roots.map(r => r.id)
|
||||
// parentId cycles (host bug) leave members unreachable from any root;
|
||||
// surface them as extra roots — the flatten walk's visited set stops
|
||||
// loops. Each node sits in at most one kids list and roots have no
|
||||
// in-group parent, so the scan pushes every reachable node exactly once.
|
||||
const reachable = new Set<SessionId>(rootIds)
|
||||
const stack = [...rootIds]
|
||||
while (stack.length > 0) {
|
||||
const top = stack.pop()
|
||||
/* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */
|
||||
if (top === undefined) break
|
||||
for (const kid of children.get(top) ?? []) {
|
||||
reachable.add(kid)
|
||||
stack.push(kid)
|
||||
}
|
||||
}
|
||||
for (const m of members) {
|
||||
if (!reachable.has(m.id)) rootIds.push(m.id)
|
||||
}
|
||||
return { key, workspaceId, cwd, createdAt, label, summaries, roots: rootIds, children }
|
||||
const sessions = [...members]
|
||||
// Workspace order is workspace.sessionIds; only Ungrouped lacks an account
|
||||
// order and therefore falls back to recency.
|
||||
if (order === 'recency') sessions.sort(byRecency)
|
||||
return { key, workspaceId, cwd, createdAt, label, sessions }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,72 +130,24 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
|
||||
return groups
|
||||
}
|
||||
|
||||
function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode {
|
||||
function sessionNode(s: SessionSummary): SessionNode {
|
||||
return {
|
||||
id: s.id,
|
||||
title: sessionTitle(s),
|
||||
children,
|
||||
hasChildren,
|
||||
expanded,
|
||||
running: s.running,
|
||||
updatedAt: s.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function buildVisible(g: Group, expandedSessions: ReadonlySet<string>): SessionNode[] {
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (id: SessionId): SessionNode | null => {
|
||||
if (visited.has(id)) return null
|
||||
visited.add(id)
|
||||
const s = g.summaries.get(id)
|
||||
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
|
||||
if (s === undefined) return null
|
||||
const kids = g.children.get(id) ?? []
|
||||
const expanded = expandedSessions.has(id)
|
||||
const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : []
|
||||
return sessionNode(s, children, kids.length > 0, expanded)
|
||||
}
|
||||
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
}
|
||||
|
||||
/** Matched sessions plus their ancestor chains (forced visible under search). */
|
||||
function searchVisible(g: Group, q: string): Set<SessionId> {
|
||||
const visible = new Set<SessionId>()
|
||||
for (const m of g.summaries.values()) {
|
||||
if (!sessionTitle(m).toLowerCase().includes(q)) continue
|
||||
let cur: SessionSummary | undefined = m
|
||||
while (cur !== undefined && !visible.has(cur.id)) {
|
||||
visible.add(cur.id)
|
||||
cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (id: SessionId): SessionNode | null => {
|
||||
if (visited.has(id) || !visible.has(id)) return null
|
||||
visited.add(id)
|
||||
const s = g.summaries.get(id)
|
||||
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
|
||||
if (s === undefined) return null
|
||||
const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid))
|
||||
const children = kids.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
return sessionNode(s, children, kids.length > 0, kids.length > 0)
|
||||
}
|
||||
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the nested workspace browser group structure.
|
||||
* Derive the workspace browser groups with every session as a top-level row.
|
||||
*
|
||||
* Normal mode: every group shows; sessions populate under expanded groups,
|
||||
* descending only into expanded sessions. Search mode (non-blank query,
|
||||
* preserving Host account order. Search mode (non-blank query,
|
||||
* case-insensitive display-title substring): expansion state is ignored —
|
||||
* matched sessions and their ancestor chains are forced visible, groups
|
||||
* without a display-title or label hit are dropped, and a label-only hit
|
||||
* keeps the bare group header. Blank sessions are excluded everywhere.
|
||||
* matching sessions are forced visible, groups without a display-title or
|
||||
* label hit are dropped, and a label-only hit
|
||||
* keeps the bare group header. Non-current blank sessions are excluded.
|
||||
* @param list - sessions list snapshot (`current` feeds containsCurrent).
|
||||
* @param workspaces - real workspaces in stable Host order.
|
||||
* @param view - local expansion arrays and search query.
|
||||
@@ -259,7 +160,6 @@ export function deriveGroups(
|
||||
): GroupNode[] {
|
||||
const q = view.query.trim().toLowerCase()
|
||||
const expandedProjects = new Set(view.expandedProjects)
|
||||
const expandedSessions = new Set(view.expandedSessions)
|
||||
const currentGroup = list.current === undefined
|
||||
? undefined
|
||||
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
|
||||
@@ -274,24 +174,24 @@ export function deriveGroups(
|
||||
cwd: g.cwd,
|
||||
createdAt: g.createdAt,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size,
|
||||
sessionCount: g.sessions.length,
|
||||
expanded,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: expanded ? buildVisible(g, expandedSessions) : [],
|
||||
sessions: expanded ? g.sessions.map(sessionNode) : [],
|
||||
})
|
||||
} else {
|
||||
const visible = searchVisible(g, q)
|
||||
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue
|
||||
const matches = g.sessions.filter(session => sessionTitle(session).toLowerCase().includes(q))
|
||||
if (matches.length === 0 && !g.label.toLowerCase().includes(q)) continue
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
createdAt: g.createdAt,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size,
|
||||
expanded: visible.size > 0,
|
||||
sessionCount: g.sessions.length,
|
||||
expanded: matches.length > 0,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: buildSearch(g, visible),
|
||||
sessions: matches.map(sessionNode),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -301,9 +201,8 @@ export function deriveGroups(
|
||||
/**
|
||||
* Derive the flat session list ("In one list" mode): every session — fork
|
||||
* children included — as a top-level row, strictly newest-first. No grouping,
|
||||
* no parent/child adjacency; rows reuse SessionNode with children always
|
||||
* empty so the renderer stays branch-free. Search mode filters by
|
||||
* case-insensitive display-title substring.
|
||||
* no parent/child adjacency. Search mode filters by case-insensitive
|
||||
* display-title substring.
|
||||
* @param list - sessions list snapshot.
|
||||
* @param view - the search query (expansion state does not apply).
|
||||
* @returns flat rows in render order.
|
||||
@@ -318,7 +217,7 @@ export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>
|
||||
rows.push(s)
|
||||
}
|
||||
rows.sort(byRecency)
|
||||
return rows.map(s => sessionNode(s, [], false, false))
|
||||
return rows.map(sessionNode)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,11 +19,17 @@ async function bench() {
|
||||
const insertSessionBefore = vi.fn(async () => ({}))
|
||||
const open = vi.fn()
|
||||
const clear = vi.fn()
|
||||
const renameSession = vi.fn(async (title: string) => ({ ok: true, value: { title, seq: 1 } }))
|
||||
const binding = vi.fn(() => ({ session: { rename: renameSession } }))
|
||||
const fork = vi.fn(async () => 'forked' as never)
|
||||
ctx.provide('workspaces', {
|
||||
create, startSession, rename, insertSessionBefore,
|
||||
} as never)
|
||||
ctx.provide('sessions', { open, clear } as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear }
|
||||
ctx.provide('sessions', { open, clear, binding, fork } as never)
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename,
|
||||
insertSessionBefore, open, clear, renameSession, binding, fork,
|
||||
}
|
||||
}
|
||||
|
||||
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
|
||||
@@ -66,6 +72,14 @@ describe('ui-workspace apply', () => {
|
||||
expect(b.startSession).toHaveBeenLastCalledWith(undefined)
|
||||
browser.open('session' as never)
|
||||
expect(b.open).toHaveBeenCalledWith('session')
|
||||
await browser.renameSession('session' as never, 'renamed session')
|
||||
expect(b.binding).toHaveBeenCalledWith('session')
|
||||
expect(b.renameSession).toHaveBeenCalledWith('renamed session')
|
||||
browser.forkSession('session' as never)
|
||||
await vi.waitFor(() => {
|
||||
expect(b.open).toHaveBeenCalledWith('forked')
|
||||
})
|
||||
expect(b.fork).toHaveBeenCalledWith({ sessionId: 'session', increaseTitle: true })
|
||||
await browser.renameWorkspace('ws' as never, 'renamed')
|
||||
expect(b.rename).toHaveBeenCalledWith('ws', 'renamed')
|
||||
await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never)
|
||||
|
||||
@@ -56,46 +56,22 @@ describe('workspace browser rows', () => {
|
||||
expect(onToggle).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders and operates selected, running, recursive Session nodes', () => {
|
||||
const child: SessionNode = {
|
||||
id: sid('child'), title: 'Child', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
const parent: SessionNode = {
|
||||
id: sid('parent'), title: 'Parent', children: [child], hasChildren: true,
|
||||
expanded: true, running: true, updatedAt: 0,
|
||||
it('renders and opens a selected running Session row', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('session'), title: 'Session', running: true, updatedAt: 0,
|
||||
}
|
||||
const onOpen = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
const view = render(
|
||||
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onToggle={onToggle} />,
|
||||
render(
|
||||
<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onFork={vi.fn()} />,
|
||||
)
|
||||
|
||||
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
|
||||
const childRow = screen.getByText('Child').closest('[role="treeitem"]')!
|
||||
expect(parentRow.getAttribute('aria-selected')).toBe('true')
|
||||
expect(parentRow.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(childRow.getAttribute('aria-selected')).toBe('false')
|
||||
expect(childRow.hasAttribute('aria-expanded')).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(onToggle).toHaveBeenCalledWith(parent.id)
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
fireEvent.click(parentRow)
|
||||
fireEvent.click(childRow)
|
||||
expect(onOpen.mock.calls).toEqual([[parent.id], [child.id]])
|
||||
|
||||
view.rerender(
|
||||
<SessionNodeItem
|
||||
node={{ ...parent, children: [], expanded: false, running: false }}
|
||||
depth={1} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onToggle={onToggle}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false')
|
||||
expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px')
|
||||
const row = screen.getByRole('treeitem')
|
||||
expect(row.getAttribute('aria-selected')).toBe('true')
|
||||
expect(row.hasAttribute('aria-expanded')).toBe(false)
|
||||
expect(screen.queryByRole('button', { name: /Expand|Collapse/ })).toBeNull()
|
||||
fireEvent.click(row)
|
||||
expect(onOpen).toHaveBeenCalledWith(node.id)
|
||||
})
|
||||
|
||||
it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
|
||||
@@ -156,15 +132,15 @@ describe('workspace browser rows', () => {
|
||||
expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('session row menu opens without opening the session and dispatches rename', () => {
|
||||
it('session row menu opens without opening the session and dispatches rename and fork', () => {
|
||||
const onOpen = vi.fn()
|
||||
const onRename = vi.fn()
|
||||
const onFork = vi.fn()
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'One', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
id: sid('s1'), title: 'One', running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onToggle={vi.fn()} />)
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onFork={onFork} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
|
||||
@@ -173,9 +149,10 @@ describe('workspace browser rows', () => {
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(onRename).toHaveBeenCalledWith(node.id, 'One')
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
// Fork and Delete stay visual-only.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
|
||||
expect(onFork).toHaveBeenCalledWith(node.id)
|
||||
// Delete stays visual-only.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete session' }))
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
@@ -185,25 +162,14 @@ describe('workspace browser rows', () => {
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('flat variant renders no twist even for a parent and ignores toggling', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} flat />)
|
||||
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the hover card after the dwell and suppresses it while the row menu is open', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
|
||||
expanded: false, running: true, updatedAt: 0,
|
||||
id: sid('s1'), title: 'Hovered', running: true, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} />)
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} />)
|
||||
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
@@ -226,11 +192,10 @@ describe('workspace browser rows', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
id: sid('s1'), title: 'Quiet', running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} />)
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} />)
|
||||
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('Idle')).toBeTruthy()
|
||||
@@ -242,13 +207,12 @@ describe('workspace browser rows', () => {
|
||||
|
||||
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', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
id: sid('s1'), title: 'Drag me', running: false, updatedAt: 0,
|
||||
}
|
||||
const inactive = dragProps()
|
||||
const { rerender } = render(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={inactive} />,
|
||||
)
|
||||
const row = screen.getByRole('treeitem')
|
||||
stubRect(row)
|
||||
@@ -265,8 +229,8 @@ describe('workspace browser rows', () => {
|
||||
|
||||
const active = dragProps({ active: true, marker: 'before' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} drag={active} />,
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={active} />,
|
||||
)
|
||||
stubRect(screen.getByRole('treeitem'))
|
||||
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
|
||||
@@ -279,8 +243,8 @@ describe('workspace browser rows', () => {
|
||||
|
||||
const after = dragProps({ active: true, marker: 'after' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} drag={after} />,
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={after} />,
|
||||
)
|
||||
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ const workspace = (id: string, sessionIds: string[]): WorkspaceView => ({
|
||||
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
const view = (expandedProjects: readonly string[] = [], query = '') => ({
|
||||
expandedProjects, expandedSessions: [] as string[], query,
|
||||
expandedProjects, query,
|
||||
})
|
||||
|
||||
describe('deriveGroups', () => {
|
||||
@@ -74,7 +74,7 @@ describe('deriveGroups', () => {
|
||||
expect(groups[0]!.sessionCount).toBe(1)
|
||||
})
|
||||
|
||||
it('builds, sorts, expands, and cycle-guards an ungrouped session tree', () => {
|
||||
it('ignores fork lineage and sorts every ungrouped session as a top-level row', () => {
|
||||
const parent = summary('parent', 1)
|
||||
const oldChild = { ...summary('old-child', 10), parentId: parent.id }
|
||||
const newChild = { ...summary('new-child', 20), parentId: parent.id }
|
||||
@@ -87,15 +87,13 @@ describe('deriveGroups', () => {
|
||||
const groups = deriveGroups(
|
||||
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
|
||||
[],
|
||||
{ expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id], query: '' },
|
||||
{ expandedProjects: [UNGROUPED_KEY], query: '' },
|
||||
)
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([
|
||||
sid('orphan'), sid('self'), parent.id, sid('cycle-a'),
|
||||
])
|
||||
expect(groups[0]!.sessions[2]!.children.map(node => node.id)).toEqual([
|
||||
newChild.id, tieA.id, tieB.id, oldChild.id,
|
||||
cycleB.id, cycleA.id, orphan.id, self.id, parent.id,
|
||||
])
|
||||
|
||||
// Equal timestamps use ids as a deterministic tiebreak in either input order.
|
||||
@@ -113,7 +111,7 @@ describe('deriveGroups', () => {
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('searches descendants with ancestors and handles cycles, self parents, and label-only hits', () => {
|
||||
it('searches rows independently of lineage and keeps label-only hits', () => {
|
||||
const root = { ...summary('root', 1), displayTitle: 'Ancestor' }
|
||||
const match = { ...summary('match', 2), displayTitle: 'Needle child', parentId: root.id }
|
||||
const sibling = { ...summary('sibling', 3), displayTitle: 'Other child', parentId: root.id }
|
||||
@@ -124,8 +122,8 @@ describe('deriveGroups', () => {
|
||||
const sessions = list(root, match, sibling, self, orphan, cycleA, cycleB)
|
||||
const groups = deriveGroups(sessions, [workspace('project', sessions.ids)], view([], 'needle'))
|
||||
|
||||
expect(groups[0]!.sessions.flatMap(node => [node.id, ...node.children.map(child => child.id)])).toEqual([
|
||||
root.id, match.id, self.id, orphan.id, cycleA.id, cycleB.id,
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([
|
||||
match.id, self.id, orphan.id, cycleA.id, cycleB.id,
|
||||
])
|
||||
|
||||
const labelOnly = deriveGroups(
|
||||
@@ -157,8 +155,6 @@ describe('deriveFlat', () => {
|
||||
const tieA = summary('tie-a', 20)
|
||||
const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' })
|
||||
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
|
||||
// Rows are branch-free: no children, no expansion.
|
||||
expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true)
|
||||
})
|
||||
|
||||
it('search filters by case-insensitive display-title substring', () => {
|
||||
|
||||
@@ -56,6 +56,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
startSession: vi.fn(),
|
||||
open: vi.fn(),
|
||||
renameSession: vi.fn(async () => {}),
|
||||
forkSession: vi.fn(),
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
deleteWorkspace: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
@@ -124,7 +125,7 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(screen.queryByText('alpha-s')).toBeNull()
|
||||
})
|
||||
|
||||
it('unfolds a session subtree through the row twist', () => {
|
||||
it('renders a fork child as a top-level row without a session twist', () => {
|
||||
const parent = summary('parent-s', 2)
|
||||
const child = { ...summary('child-s', 1), parentId: parent.id }
|
||||
mount({
|
||||
@@ -132,11 +133,9 @@ describe('WorkspaceBrowser', () => {
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['parent-s', 'child-s'])])),
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
expect(screen.queryByText('child-s')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('child-s')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(screen.queryByText('child-s')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Expand|Collapse/ })).toBeNull()
|
||||
expect(screen.getByText('child-s').closest('[role="treeitem"]')?.getAttribute('draggable')).toBe('true')
|
||||
})
|
||||
|
||||
it('auto-expands the selected session group and starts a session from the group +', () => {
|
||||
|
||||
Reference in New Issue
Block a user