Merge branch 'master' into fix/nodeboot-dep
This commit is contained in:
@@ -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/connection/README.md
|
||||
README.md: d2fda9f15125915594259e01e5b153609ceb21bb
|
||||
README.zh.md: 669ae760693b4d98ee873ee5fe323554f58e7ca5
|
||||
README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d
|
||||
README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45
|
||||
|
||||
@@ -10,7 +10,7 @@ The node half guards every request under `/api` before bridging (`src/api-reques
|
||||
|
||||
## Keyless fixture
|
||||
|
||||
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.
|
||||
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust
|
||||
|
||||
## 无密钥 fixture
|
||||
|
||||
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。
|
||||
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token/短语行为,并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Central contract re-export point: every contract import inside
|
||||
// web-runtime goes through this single file.
|
||||
// Types are type-only imports from the apiproxy api/ layer (zero Node deps, browser-safe);
|
||||
// the only runtime values are the RpcId constructor and the AbstractApiClient seam.
|
||||
// Types and runtime protocol helpers/bounds come from the apiproxy api/ layer
|
||||
// (zero Node deps, browser-safe); AbstractApiClient is the client seam.
|
||||
// NEVER import the package root: it drags bootHost/cordis into the browser bundle.
|
||||
// The ./api and ./client subpath exports are the browser-safe channels added for this.
|
||||
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
DirectoryEntry, DirectoryListing,
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
@@ -25,7 +25,11 @@ export type {
|
||||
// transportError moved down to the apiproxy api layer (it belongs beside
|
||||
// RpcResult, its subject); re-exported here so connection consumers keep one
|
||||
// contract entry point.
|
||||
export { RpcId, transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export {
|
||||
RpcId,
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
transportError,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
@@ -26,13 +26,14 @@ import type {
|
||||
// Type-only: the brand constructor is host-side; the fixture casts at its
|
||||
// wire-fabrication boundary (the schema layer's one-cast-point posture).
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import { foldSurface } from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
|
||||
ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
|
||||
ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
|
||||
} from './api.ts'
|
||||
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { AbstractApiClient, RpcId } from './api.ts'
|
||||
import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts'
|
||||
|
||||
/** The fake carrier mints like a real one (business code never mints). */
|
||||
function rpcRequest<P>(payload: P): RpcRequest<P> {
|
||||
@@ -575,6 +576,144 @@ function pageOf(
|
||||
return { events, hasMore: start > 0 }
|
||||
}
|
||||
|
||||
/** Fixture mirror of first-party message extraction used by session-query. */
|
||||
function searchBlockText(block: ContentBlock): string[] {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return [block.text]
|
||||
case 'reasoning':
|
||||
return []
|
||||
case 'tool-call':
|
||||
return [block.name, block.arguments]
|
||||
case 'tool-result':
|
||||
return block.content.flatMap(searchBlockText)
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** One current-surface user/assistant/steering document, if searchable. */
|
||||
function searchEventText(event: SessionEvent): string {
|
||||
const content = event.type === 'user/message'
|
||||
? event.data.content
|
||||
: event.type === 'assistant/message' || event.type === 'steering/message'
|
||||
? event.data.message.content
|
||||
: undefined
|
||||
if (content === undefined) return ''
|
||||
return content.flatMap(searchBlockText).map(part => part.trim()).filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
interface FixtureSearchToken {
|
||||
value: string
|
||||
/** Inclusive code-point offset in the whitespace-normalized display text. */
|
||||
start: number
|
||||
/** Exclusive code-point offset in the whitespace-normalized display text. */
|
||||
end: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-safe approximation of SQLite FTS5 unicode61 token boundaries.
|
||||
* Keeping phrase matching token-based prevents the development fixture from
|
||||
* promising arbitrary within-token substring behavior that production lacks.
|
||||
*/
|
||||
function searchTokenSpans(value: string): { text: string; tokens: FixtureSearchToken[] } {
|
||||
const text = value.replace(/\s+/gu, ' ').trim()
|
||||
const characters = Array.from(text)
|
||||
const tokens: FixtureSearchToken[] = []
|
||||
let start: number | undefined
|
||||
let raw = ''
|
||||
const flush = (end: number): void => {
|
||||
if (start !== undefined) {
|
||||
const folded = raw.normalize('NFD').replace(/\p{M}+/gu, '').toLowerCase()
|
||||
if (folded !== '') tokens.push({ value: folded, start, end })
|
||||
}
|
||||
start = undefined
|
||||
raw = ''
|
||||
}
|
||||
for (let index = 0; index < characters.length; index++) {
|
||||
const character = characters[index] as string
|
||||
const tokenBase = character.normalize('NFD').replace(/\p{M}+/gu, '')
|
||||
if (tokenBase === '') {
|
||||
if (start !== undefined) raw += character
|
||||
continue
|
||||
}
|
||||
if (/^[\p{L}\p{N}\p{Co}]+$/u.test(tokenBase)) {
|
||||
start ??= index
|
||||
raw += character
|
||||
} else {
|
||||
flush(index)
|
||||
}
|
||||
}
|
||||
flush(characters.length)
|
||||
return { text, tokens }
|
||||
}
|
||||
|
||||
interface FixturePhraseMatch {
|
||||
count: number
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/** Count exact contiguous token-phrase occurrences and retain the first display span. */
|
||||
function phraseMatch(document: readonly FixtureSearchToken[], phrase: readonly string[]): FixturePhraseMatch {
|
||||
if (phrase.length === 0 || phrase.length > document.length) return { count: 0, start: 0, end: 0 }
|
||||
let count = 0
|
||||
let firstStart = 0
|
||||
let firstEnd = 0
|
||||
for (let start = 0; start <= document.length - phrase.length; start++) {
|
||||
if (!phrase.every((token, offset) => document[start + offset]?.value === token)) continue
|
||||
count++
|
||||
if (count === 1) {
|
||||
firstStart = document[start]?.start ?? 0
|
||||
firstEnd = document[start + phrase.length - 1]?.end ?? firstStart
|
||||
}
|
||||
}
|
||||
return { count, start: firstStart, end: firstEnd }
|
||||
}
|
||||
|
||||
/** Match-centered fixture excerpt, bounded by Unicode code points for the sidebar. */
|
||||
function searchSnippet(value: string, matchStart: number, matchEnd: number): string {
|
||||
const characters = Array.from(value)
|
||||
if (characters.length <= 120) return value
|
||||
const boundedStart = Math.min(Math.max(0, matchStart), characters.length - 1)
|
||||
const boundedEnd = Math.min(
|
||||
characters.length,
|
||||
Math.max(boundedStart + 1, matchEnd),
|
||||
)
|
||||
const center = Math.floor((boundedStart + boundedEnd) / 2)
|
||||
let start = Math.min(
|
||||
characters.length - 118,
|
||||
Math.max(0, center - Math.floor(118 / 2)),
|
||||
)
|
||||
let end = start + 118
|
||||
if (start === 0) {
|
||||
end = 119
|
||||
} else if (end === characters.length) {
|
||||
start = characters.length - 119
|
||||
}
|
||||
return `${start > 0 ? '…' : ''}${characters.slice(start, end).join('')}${end < characters.length ? '…' : ''}`
|
||||
}
|
||||
|
||||
interface FixtureSearchCandidate {
|
||||
sessionId: SessionId
|
||||
seq: number
|
||||
time: number
|
||||
text: string
|
||||
matchCount: number
|
||||
matchStart: number
|
||||
matchEnd: number
|
||||
documentLength: number
|
||||
}
|
||||
|
||||
/** Mirrors `packages/session-query/session-query-sqlite/src/index.ts`; update both together. */
|
||||
function compareSearchCandidates(a: FixtureSearchCandidate, b: FixtureSearchCandidate): number {
|
||||
if (a.matchCount !== b.matchCount) return b.matchCount - a.matchCount
|
||||
if (a.documentLength !== b.documentLength) return a.documentLength - b.documentLength
|
||||
if (a.time !== b.time) return b.time - a.time
|
||||
if (a.sessionId !== b.sessionId) return a.sessionId < b.sessionId ? -1 : 1
|
||||
return b.seq - a.seq
|
||||
}
|
||||
|
||||
/**
|
||||
* Current plan projection over the full log (host parallel: latest todo/write
|
||||
* with no later turn/start; a new turn retires the previous plan).
|
||||
@@ -987,6 +1126,45 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return {
|
||||
sessions: {
|
||||
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
|
||||
search: (request, signal) => {
|
||||
if (signal.aborted) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'fixture session search was aborted',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
const query = searchTokenSpans(request.payload.query).tokens.map(token => token.value)
|
||||
const matches = sessions.flatMap((summary) => {
|
||||
const log = logs.get(summary.sessionId) ?? []
|
||||
const current = new Set(foldSurface(log).nodes)
|
||||
const best = log.flatMap((event): FixtureSearchCandidate[] => {
|
||||
if (!current.has(event.seq)) return []
|
||||
const eventText = searchEventText(event)
|
||||
const document = searchTokenSpans(eventText)
|
||||
const match = phraseMatch(document.tokens, query)
|
||||
if (match.count === 0) return []
|
||||
return [{
|
||||
sessionId: summary.sessionId,
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
text: document.text,
|
||||
matchCount: match.count,
|
||||
matchStart: match.start,
|
||||
matchEnd: match.end,
|
||||
documentLength: Array.from(eventText).length,
|
||||
}]
|
||||
}).sort(compareSearchCandidates)[0]
|
||||
return best === undefined ? [] : [best]
|
||||
}).sort(compareSearchCandidates)
|
||||
return ok(request, {
|
||||
items: matches.slice(0, SESSION_SEARCH_RESULT_LIMIT).map(match => ({
|
||||
sessionId: match.sessionId,
|
||||
snippet: searchSnippet(match.text, match.matchStart, match.matchEnd),
|
||||
})),
|
||||
hasMore: matches.length > SESSION_SEARCH_RESULT_LIMIT,
|
||||
})
|
||||
},
|
||||
create: async (request) => {
|
||||
const workspace = request.payload.workspaceId === undefined
|
||||
? undefined
|
||||
@@ -1691,20 +1869,30 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
protected override async callUnary<K extends keyof RpcMethodMap>(
|
||||
method: K,
|
||||
payload: RequestPayload<K>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResponse<ResponseValue<K>>> {
|
||||
const request = rpcRequest(payload)
|
||||
const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload }
|
||||
this.onEnvelope(full)
|
||||
const response = await this.dispatch(method, request as RpcRequest<never>) as RpcResponse<ResponseValue<K>>
|
||||
const response = await this.dispatch(
|
||||
method,
|
||||
request as RpcRequest<never>,
|
||||
signal ?? new AbortController().signal,
|
||||
) as RpcResponse<ResponseValue<K>>
|
||||
const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result }
|
||||
this.onEnvelope(fullResponse)
|
||||
return response
|
||||
}
|
||||
|
||||
/** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */
|
||||
private dispatch(method: keyof RpcMethodMap, request: RpcRequest<never>): Promise<RpcResponse<unknown>> {
|
||||
private dispatch(
|
||||
method: keyof RpcMethodMap,
|
||||
request: RpcRequest<never>,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<unknown>> {
|
||||
switch (method) {
|
||||
case 'session.list': return this.api.sessions.list(request)
|
||||
case 'session.search': return this.api.sessions.search(request, signal)
|
||||
case 'session.create': return this.api.sessions.create(request)
|
||||
case 'session.history': return this.api.sessions.history(request)
|
||||
case 'session.models': return this.api.sessions.models(request)
|
||||
@@ -1725,8 +1913,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'workspace.delete': return this.api.workspace.delete(request)
|
||||
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
|
||||
case 'command.list': return this.api.commands.list(request)
|
||||
// The in-memory execute never blocks, so a never-aborting signal is faithful here.
|
||||
case 'command.execute': return this.api.commands.execute(request, new AbortController().signal)
|
||||
case 'command.execute': return this.api.commands.execute(request, signal)
|
||||
case 'skill.list': return this.api.skills.list(request)
|
||||
case 'goal.create': return this.api.goals.create(request)
|
||||
case 'goal.edit': return this.api.goals.edit(request)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { WebApiClient } from './web-api-client.ts'
|
||||
|
||||
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
DirectoryEntry, DirectoryListing,
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
@@ -25,7 +25,11 @@ export type {
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
|
||||
} from './api.ts'
|
||||
export { RpcId, AbstractApiClient, transportError } from './api.ts'
|
||||
export {
|
||||
RpcId,
|
||||
AbstractApiClient,
|
||||
transportError,
|
||||
} from './api.ts'
|
||||
|
||||
// Connection loop types are public through ConnectionHandle.start; the
|
||||
// controller remains package-internal.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
|
||||
@@ -44,6 +44,8 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
// Programmable slots (defaults answer OK-empty); reassign per case.
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ items: [], hasMore: false }))
|
||||
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 }))
|
||||
@@ -87,12 +89,17 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
lastSearchSignal: AbortSignal | undefined
|
||||
|
||||
// Parameter annotations below are local structural types on purpose: the CI
|
||||
// lint lane runs without built artifacts, where IApiClient's wire types
|
||||
// (apiproxy subpath) resolve to any and inferred params trip no-unsafe-argument.
|
||||
readonly sessions: IApiClient['sessions'] = {
|
||||
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
|
||||
search: (payload: unknown, signal?: AbortSignal) => {
|
||||
this.lastSearchSignal = signal
|
||||
return this.record('session.search', payload, this.onSearch(payload))
|
||||
},
|
||||
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
|
||||
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
|
||||
this.record('session.history', payload, this.onHistory(payload)),
|
||||
|
||||
@@ -48,6 +48,59 @@ describe('createFixtureApi', () => {
|
||||
expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material
|
||||
})
|
||||
|
||||
it('searches current message text with literal unicode61-style token phrases', async () => {
|
||||
const api = createFixtureApi()
|
||||
const signal = new AbortController().signal
|
||||
const phrase = await api.sessions.search(req({ query: 'FIXTURE 历史消息' }), signal)
|
||||
expect(phrase.result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 'fx-alpha' }],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
if (!phrase.result.ok) throw new Error('search failed')
|
||||
expect(phrase.result.value.items[0]?.snippet).toContain('fixture 历史消息')
|
||||
|
||||
timing().appendUser(
|
||||
'fx-alpha',
|
||||
`${'leading context '.repeat(20)}late café token${' trailing context'.repeat(20)}`,
|
||||
)
|
||||
const late = await api.sessions.search(req({ query: 'LATE CAFE TOKEN' }), signal)
|
||||
if (!late.result.ok) throw new Error('late search failed')
|
||||
const lateSnippet = late.result.value.items[0]?.snippet ?? ''
|
||||
expect(lateSnippet).toContain('late café token')
|
||||
expect(lateSnippet.startsWith('…')).toBe(true)
|
||||
expect(lateSnippet.endsWith('…')).toBe(true)
|
||||
expect(Array.from(lateSnippet).length).toBeLessThanOrEqual(120)
|
||||
|
||||
timing().appendUser('fx-alpha', 'Greek final sigma: ος')
|
||||
const finalSigma = await api.sessions.search(req({ query: 'ΟΣ' }), signal)
|
||||
if (!finalSigma.result.ok) throw new Error('final sigma search failed')
|
||||
expect(finalSigma.result.value.items[0]?.snippet).toContain('ος')
|
||||
|
||||
const substring = await api.sessions.search(req({ query: 'ixtur' }), signal)
|
||||
expect(substring.result).toEqual({
|
||||
ok: true,
|
||||
value: { items: [], hasMore: false },
|
||||
})
|
||||
const punctuationOnly = await api.sessions.search(req({ query: '*' }), signal)
|
||||
expect(punctuationOnly.result).toEqual({
|
||||
ok: true,
|
||||
value: { items: [], hasMore: false },
|
||||
})
|
||||
const reasoningOnly = await api.sessions.search(req({ query: '思考过程' }), signal)
|
||||
expect(reasoningOnly.result).toEqual({
|
||||
ok: true,
|
||||
value: { items: [], hasMore: false },
|
||||
})
|
||||
|
||||
const aborted = new AbortController()
|
||||
aborted.abort()
|
||||
await expect(api.sessions.search(req({ query: 'fixture' }), aborted.signal))
|
||||
.resolves.toMatchObject({ result: { ok: false, error: { code: 'cancelled' } } })
|
||||
})
|
||||
|
||||
it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => {
|
||||
const api = createFixtureApi()
|
||||
const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))
|
||||
@@ -819,6 +872,10 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
|
||||
it('covers the whole unary dispatch table', async () => {
|
||||
const client = new FixtureApiClient()
|
||||
expect((await client.sessions.search(
|
||||
{ query: 'fixture' },
|
||||
new AbortController().signal,
|
||||
)).result.ok).toBe(true)
|
||||
const created = await client.sessions.create({})
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
const id = created.result.value.sessionId
|
||||
|
||||
@@ -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: a116a5e4ad3070f20e6d90490f2507c1e2369c37
|
||||
README.zh.md: f375811e6f1480d6636fe4eb77746b76d6414b1e
|
||||
README.md: 12023868c577ebcae6898d13358a2456295496c2
|
||||
README.zh.md: 7ef4c93d36b3f0b32c0bfcf8a38892260240c74f
|
||||
|
||||
@@ -12,6 +12,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
|
||||
|
||||
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
|
||||
|
||||
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
|
||||
|
||||
## New Session and the blank mirror
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
|
||||
|
||||
@@ -12,6 +12,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
|
||||
|
||||
## New Session 与 blank 镜像
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
* explicit act of widening what features may do to the sessions domain.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionSearchResultItem } from '../sessions/manager.ts'
|
||||
import type {
|
||||
SessionBinding, SessionListState, SessionProvideDescriptor,
|
||||
} from '../sessions/service.ts'
|
||||
@@ -22,6 +23,12 @@ export interface ISessions {
|
||||
readonly list: ObservableSnapshot<SessionListState>
|
||||
/** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */
|
||||
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
|
||||
/**
|
||||
* The `session.search` result bound the wire schema fixes, exposed to
|
||||
* presentation as injected data. Not per-connection state: every transport
|
||||
* (fixture included) reports the same number.
|
||||
*/
|
||||
readonly searchResultLimit: number
|
||||
/**
|
||||
* Select a session as current.
|
||||
* @param id - session id (must exist in the list; unknown ids fail loud).
|
||||
@@ -29,6 +36,17 @@ export interface ISessions {
|
||||
open(id: SessionId): void
|
||||
/** Clear the current selection into the no-session view state. */
|
||||
clear(): void
|
||||
/**
|
||||
* Search the Host's visible message-content index. Results stay
|
||||
* request-local; the list snapshot remains the metadata authority.
|
||||
* @param query - non-blank literal phrase.
|
||||
* @param signal - cancellation for a superseded search.
|
||||
* @returns bounded results, or a business/transport error.
|
||||
*/
|
||||
search(
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -31,7 +31,7 @@ export type { IWorkspaces } from './contract/workspaces.ts'
|
||||
export type {
|
||||
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
|
||||
} from './sessions/service.ts'
|
||||
export type { SessionListPhase } from './sessions/manager.ts'
|
||||
export type { SessionListPhase, SessionSearchResultItem } from './sessions/manager.ts'
|
||||
export type { WorkspaceListPhase } from './workspaces/manager.ts'
|
||||
export type { WorkspaceListState } from './workspaces/service.ts'
|
||||
export type {
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
|
||||
// List data never enters zustand; React connects via subscribe/getListSnapshot.
|
||||
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
|
||||
SessionSummary, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -27,6 +30,12 @@ import { Session } from './session.ts'
|
||||
*/
|
||||
export type SessionListPhase = 'pending' | 'ready'
|
||||
|
||||
/** Request-local content hit returned to sidebar search consumers. */
|
||||
export interface SessionSearchResultItem {
|
||||
sessionId: SessionId
|
||||
snippet: string
|
||||
}
|
||||
|
||||
/** Immutable session-list snapshot for useSessionList. */
|
||||
export interface SessionListSnapshot {
|
||||
items: readonly SessionListEntry[]
|
||||
@@ -248,6 +257,24 @@ export class SessionManager {
|
||||
return this.listInflight
|
||||
}
|
||||
|
||||
/**
|
||||
* Search visible session message content without adding transient query
|
||||
* state to the list snapshot.
|
||||
* @param query - non-blank literal phrase.
|
||||
* @param signal - cancellation for superseded UI queries.
|
||||
* @returns the Host result or a folded transport error.
|
||||
*/
|
||||
async search(
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
|
||||
try {
|
||||
return (await this.api.sessions.search({ query }, signal)).result
|
||||
} catch (error: unknown) {
|
||||
return transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract session.create; on success merge into summaries immediately (no
|
||||
* wait for the next refresh). A created session is blank by definition
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
* survives frozen (read-only view) until the stage moves on.
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
IApiClient, RpcError, RpcResult, SessionId, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -26,7 +31,7 @@ import type { SessionFace } from '../contract/session.ts'
|
||||
import type { ISessions } from '../contract/sessions.ts'
|
||||
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { SessionListPhase } from './manager.ts'
|
||||
import type { SessionListPhase, SessionSearchResultItem } from './manager.ts'
|
||||
import { SessionProvideChannel } from './provide.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
@@ -189,6 +194,13 @@ export interface SessionProvideDescriptor {
|
||||
|
||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
|
||||
export class SessionsService implements ISessions {
|
||||
/**
|
||||
* The wire schema's own result bound, re-exposed for presentation plugins as
|
||||
* injected data. Not per-connection state: the `session.search` response
|
||||
* schema caps `items` at this constant, so every transport (fixture included)
|
||||
* reports the same number.
|
||||
*/
|
||||
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
|
||||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry. */
|
||||
@@ -228,7 +240,10 @@ export class SessionsService implements ISessions {
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
*/
|
||||
constructor(private readonly rootCtx: Context, api: IApiClient) {
|
||||
constructor(
|
||||
private readonly rootCtx: Context,
|
||||
api: IApiClient,
|
||||
) {
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
@@ -307,6 +322,20 @@ export class SessionsService implements ISessions {
|
||||
return this.manager.refreshList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the Host's visible message-content index. Results stay
|
||||
* request-local; the list snapshot remains the metadata authority.
|
||||
* @param query - non-blank literal phrase.
|
||||
* @param signal - cancellation for a superseded search.
|
||||
* @returns bounded results or a business/transport error.
|
||||
*/
|
||||
search(
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
|
||||
return this.manager.search(query, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a mux stream envelope into the Session object layer.
|
||||
* @param envelope - validated mux stream envelope.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import type { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
@@ -50,6 +51,8 @@ describe('runtime client apply', () => {
|
||||
const workspaces = bench.ctx.get('workspaces')
|
||||
expect(sessions !== undefined).toBe(true)
|
||||
expect(workspaces !== undefined).toBe(true)
|
||||
// The bound the wire schema enforces, not a per-connection negotiation.
|
||||
expect((sessions as SessionsService).searchResultLimit).toBe(SESSION_SEARCH_RESULT_LIMIT)
|
||||
if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply')
|
||||
expect(bench.sinks).toBeDefined()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -61,6 +61,8 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
// Programmable slots (defaults answer OK-empty); reassign per case.
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ items: [], hasMore: false }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
@@ -106,12 +108,17 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
lastSearchSignal: AbortSignal | undefined
|
||||
|
||||
// Parameters carry local structural annotations: the CI lint lane runs
|
||||
// without built lib/, so IApiClient's indexed-access types collapse to any
|
||||
// and inferred parameters would trip no-unsafe-argument.
|
||||
readonly sessions: IApiClient['sessions'] = {
|
||||
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
|
||||
search: (payload: unknown, signal?: AbortSignal) => {
|
||||
this.lastSearchSignal = signal
|
||||
return this.record('session.search', payload, this.onSearch(payload))
|
||||
},
|
||||
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
|
||||
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
|
||||
this.record('session.history', payload, this.onHistory(payload)),
|
||||
|
||||
@@ -206,6 +206,49 @@ describe('list lifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('returns bounded Host results and forwards the caller signal', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onSearch = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
|
||||
hasMore: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await expect(manager.search('exact phrase', signal)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
|
||||
hasMore: true,
|
||||
},
|
||||
})
|
||||
expect(api.callsOf('session.search')).toEqual([{ query: 'exact phrase' }])
|
||||
expect(api.lastSearchSignal).toBe(signal)
|
||||
})
|
||||
|
||||
it('preserves business errors and folds transport failures', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
api.onSearch = () => Promise.resolve(err({
|
||||
code: 'internal',
|
||||
message: 'index unavailable',
|
||||
details: {},
|
||||
}))
|
||||
const signal = new AbortController().signal
|
||||
await expect(manager.search('first', signal)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'index unavailable' },
|
||||
})
|
||||
|
||||
api.onSearch = () => Promise.reject(new Error('wire down'))
|
||||
await expect(manager.search('second', signal)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'wire down' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('host frame routing', () => {
|
||||
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
|
||||
const api = new FakeApiClient()
|
||||
|
||||
@@ -69,6 +69,29 @@ describe('list store projection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('delegates transient content search without changing the list snapshot', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const before = b.svc.list.getSnapshot()
|
||||
b.api.onSearch = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s1'), snippet: 'matching excerpt' }],
|
||||
hasMore: false,
|
||||
}))
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await expect(b.svc.search('needle', signal)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 's1', snippet: 'matching excerpt' }],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
expect(b.api.lastSearchSignal).toBe(signal)
|
||||
expect(b.svc.list.getSnapshot()).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scope tree', () => {
|
||||
it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => {
|
||||
const b = bench()
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0",
|
||||
@@ -37,6 +38,7 @@
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
|
||||
@@ -4,8 +4,11 @@ import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-cl
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
|
||||
SessionListState, SessionProvideDescriptor, SessionSummary, SnapshotStore,
|
||||
SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// The double reports the wire schema's own search bound, like the production
|
||||
// service — a transport-varying limit would be a fiction no client can see.
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { conversationSnapshot } from './fixtures.ts'
|
||||
import type { SessionFixture, Stabilizer } from './fixtures.ts'
|
||||
@@ -151,8 +154,8 @@ export interface TestSessionBinding {
|
||||
*
|
||||
* Implements the same ISessions face features receive as `ctx.sessions`, so
|
||||
* a production face change breaks this double at compile time; the extra
|
||||
* members (add/updateSnapshot/setCurrent/remove/behavior/calls and the
|
||||
* legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
|
||||
* members (add/updateSnapshot/setCurrent/remove/behavior/calls/stubSearch and
|
||||
* the legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
|
||||
*/
|
||||
export class TestSessions implements ISessions {
|
||||
/** The useSessions standard feed (list rows + current selection). */
|
||||
@@ -168,8 +171,14 @@ export class TestSessions implements ISessions {
|
||||
/** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
|
||||
private readonly channel: SessionProvideChannel
|
||||
|
||||
/** Calls observed on the service-level face (open/clear), newest last. */
|
||||
readonly calls: { method: 'open' | 'clear' | 'fork'; args: unknown[] }[] = []
|
||||
/** Calls observed on the service-level face (open/clear/search/fork), newest last. */
|
||||
readonly calls: { method: 'open' | 'clear' | 'search' | 'fork'; args: unknown[] }[] = []
|
||||
|
||||
/** The wire schema's `session.search` result bound (production parity). */
|
||||
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
|
||||
|
||||
/** Replaceable search behavior (see {@link TestSessions.stubSearch}). */
|
||||
private searchStub: ((query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }) | undefined
|
||||
|
||||
/**
|
||||
* @param stabilize - the owning runtime's act wrapper.
|
||||
@@ -392,6 +401,27 @@ export class TestSessions implements ISessions {
|
||||
this.list.update((draft) => { draft.current = undefined })
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the sidebar-search result page (the call is still recorded).
|
||||
* @param impl - hits for a query, as the Host would rank them.
|
||||
*/
|
||||
stubSearch(impl: (query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }): void {
|
||||
this.searchStub = impl
|
||||
}
|
||||
|
||||
/**
|
||||
* Content search over the fixture corpus (recorded). The default answers an
|
||||
* empty page: content ranking is Host behavior, so a scenario that asserts
|
||||
* hits declares them through {@link TestSessions.stubSearch}.
|
||||
* @param query - non-blank literal phrase.
|
||||
* @param signal - cancellation for a superseded search (recorded and forwarded).
|
||||
* @returns the stubbed or empty result page.
|
||||
*/
|
||||
search(query: string, signal: AbortSignal): ReturnType<ISessions['search']> {
|
||||
this.calls.push({ method: 'search', args: [query, signal] })
|
||||
return Promise.resolve({ ok: true, value: this.searchStub?.(query, signal) ?? { items: [], hasMore: false } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded fork stub: no child materializes (benches asserting the full
|
||||
* fork flow drive the production service; this face only proves the call).
|
||||
|
||||
@@ -221,6 +221,28 @@ describe('sessions', () => {
|
||||
])
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('answers search with an empty page until a scenario declares hits, recording every call', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
await runtime.sessions.add({ id: 's1' })
|
||||
const signal = new AbortController().signal
|
||||
expect(runtime.sessions.searchResultLimit).toBeGreaterThan(0)
|
||||
await expect(runtime.sessions.search('marker', signal))
|
||||
.resolves.toEqual({ ok: true, value: { items: [], hasMore: false } })
|
||||
runtime.sessions.stubSearch(query => ({
|
||||
items: [{ sessionId: 's1' as SessionId, snippet: `hit: ${query}` }],
|
||||
hasMore: true,
|
||||
}))
|
||||
await expect(runtime.sessions.search('marker', signal)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { items: [{ sessionId: 's1', snippet: 'hit: marker' }], hasMore: true },
|
||||
})
|
||||
expect(runtime.sessions.calls).toEqual([
|
||||
{ method: 'search', args: ['marker', signal] },
|
||||
{ method: 'search', args: ['marker', signal] },
|
||||
])
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stores', () => {
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -86,6 +86,11 @@ export function apply(ctx: Context): void {
|
||||
// Apply-time construction keeps store identity bound to this fiber.
|
||||
const chatStore = createChatStore()
|
||||
|
||||
// Chat scroll offsets by session, surviving view switches (the chat view
|
||||
// unmounts under the tab ring). Deliberately not persisted: a fresh page
|
||||
// load should keep the open-jump-to-bottom default.
|
||||
const chatScrollTops = new Map<SessionId, number>()
|
||||
|
||||
const viewTabs = (): ViewTab[] => {
|
||||
const tabs: ViewTab[] = []
|
||||
for (const entry of slots.entries('conversation.view')) {
|
||||
@@ -252,6 +257,19 @@ export function apply(ctx: Context): void {
|
||||
})
|
||||
},
|
||||
loadOlder: () => { void scoped.loadOlder() },
|
||||
// Unregistered 'trajectory' id is safe: the tab ring falls back to
|
||||
// the first view, and the untouched inspect target stays inert.
|
||||
inspectCall: (callId) => {
|
||||
actions.setInspect({ callId })
|
||||
actions.setView('trajectory')
|
||||
},
|
||||
chatScroll: {
|
||||
save: (top) => {
|
||||
if (top === null) chatScrollTops.delete(sessionId)
|
||||
else chatScrollTops.set(sessionId, top)
|
||||
},
|
||||
read: () => chatScrollTops.get(sessionId) ?? null,
|
||||
},
|
||||
forkAt: (seq) => {
|
||||
sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
|
||||
.then((childId) => { sessions.open(childId) })
|
||||
|
||||
@@ -64,7 +64,6 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
|
||||
summary={firstLine(text)}
|
||||
body={text}
|
||||
state={running ? 'running' : 'ok'}
|
||||
expandOnRowClick
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ function scrollerOf(from: HTMLElement): HTMLElement {
|
||||
|
||||
type OpenFile = (path: string) => void
|
||||
|
||||
type InspectCall = (callId: string) => void
|
||||
|
||||
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
|
||||
type RenderToolRow = ChatViewSlotProps['renderSlot']
|
||||
|
||||
@@ -57,19 +59,21 @@ type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
|
||||
* top-level call (same registrations, same fallback), nested by the parent.
|
||||
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
|
||||
* renders the running state exactly as a native in-flight row. */
|
||||
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, t }: {
|
||||
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, inspectCall, t }: {
|
||||
renderSlot: RenderToolRow
|
||||
node: CodeSubCall
|
||||
openFile: OpenFile
|
||||
selected: boolean
|
||||
cwd: string | undefined
|
||||
inspectCall: InspectCall
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const settled = 'kind' in node
|
||||
const toolName = settled ? node.call?.name ?? '' : node.name
|
||||
const owner = useMemo(() => ({
|
||||
callId: node.callId, toolName, block: node, openFile, cwd,
|
||||
}), [node, toolName, openFile, cwd])
|
||||
inspect: () => { inspectCall(node.callId) },
|
||||
}), [node, toolName, openFile, cwd, inspectCall])
|
||||
return (
|
||||
<div className={css.callRow} data-selected={selected || undefined}>
|
||||
{renderSlot('conversation.chat.toolview', owner, {
|
||||
@@ -86,7 +90,7 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select
|
||||
* renders its logged sub-dispatches as always-visible indented rows —
|
||||
* each one the same keyed-slot dispatch as a native top-level call. */
|
||||
const CallRow = memo(function CallRow({
|
||||
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, t,
|
||||
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, inspectCall, t,
|
||||
}: {
|
||||
renderSlot: RenderToolRow
|
||||
callId: string
|
||||
@@ -101,11 +105,13 @@ const CallRow = memo(function CallRow({
|
||||
selectedCallId?: string | undefined
|
||||
/** Session workspace root for path-relative summaries. */
|
||||
cwd: string | undefined
|
||||
inspectCall: InspectCall
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const owner = useMemo(() => ({
|
||||
callId, toolName, block, openFile, cwd,
|
||||
}), [callId, toolName, block, openFile, cwd])
|
||||
inspect: () => { inspectCall(callId) },
|
||||
}), [callId, toolName, block, openFile, cwd, inspectCall])
|
||||
return (
|
||||
<div className={css.callRow} data-selected={selected || undefined}>
|
||||
{renderSlot('conversation.chat.toolview', owner, {
|
||||
@@ -122,6 +128,7 @@ const CallRow = memo(function CallRow({
|
||||
openFile={openFile}
|
||||
selected={node.callId === selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
@@ -132,7 +139,7 @@ const CallRow = memo(function CallRow({
|
||||
})
|
||||
|
||||
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, t }: {
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, inspectCall, t }: {
|
||||
renderSlot: RenderToolRow
|
||||
results: readonly ToolResultNode[]
|
||||
openFile: OpenFile
|
||||
@@ -142,6 +149,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
/** Session workspace root for path-relative summaries. */
|
||||
cwd: string | undefined
|
||||
inspectCall: InspectCall
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
return (
|
||||
@@ -158,6 +166,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
|
||||
subCalls={codeDispatches.get(node.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
@@ -237,7 +246,9 @@ function StreamingTail({ useSession, onGrow, t }: {
|
||||
* 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, forkAt, t }: ChatViewSlotProps) {
|
||||
export function ChatView({
|
||||
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
|
||||
}: 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)
|
||||
@@ -284,10 +295,20 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
|
||||
if (local === null) return
|
||||
const el = scrollerOf(local)
|
||||
// Open completed: jump to the bottom once.
|
||||
// Open completed: jump to the bottom once — unless a scroll position
|
||||
// survives from a previous mount (view-tab switch away and back), which
|
||||
// is restored instead of snapping the reader back to the floor.
|
||||
if (openState === 'open' && !openedRef.current) {
|
||||
openedRef.current = true
|
||||
toBottom(el)
|
||||
const saved = chatScroll.read()
|
||||
if (saved === null) {
|
||||
toBottom(el)
|
||||
} else {
|
||||
el.scrollTop = saved
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
||||
atBottomRef.current = isAtBottom
|
||||
setAtBottom(isAtBottom)
|
||||
}
|
||||
firstSeqRef.current = firstSeq
|
||||
lastKeyRef.current = lastKey
|
||||
followSigRef.current = followSig
|
||||
@@ -325,6 +346,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
||||
atBottomRef.current = isAtBottom
|
||||
setAtBottom(isAtBottom)
|
||||
// Continuous save (unmount happens after ref detach, so saving there is
|
||||
// too late); pinned-to-bottom clears so a remount keeps following.
|
||||
chatScroll.save(isAtBottom ? null : el.scrollTop)
|
||||
}
|
||||
|
||||
// Bind scroll to the resolved scrollport (host or local) once per mount.
|
||||
@@ -375,6 +399,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
selectedCallId={inGroup ? selectedCallId : undefined}
|
||||
codeDispatches={codeDispatches}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
@@ -435,6 +460,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
subCalls={codeDispatches.get(call.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface DisclosureRowProps {
|
||||
expandOnRowClick?: boolean | undefined
|
||||
/** Replaces the collapsed icon with a chevron while the row is hovered. */
|
||||
previewChevron?: boolean | undefined
|
||||
/** Keeps `collapsedContent` inline while open (ToolRow's summary stays readable next to the expanded card). */
|
||||
keepContentWhenOpen?: boolean | undefined
|
||||
collapsedContent?: ReactNode
|
||||
children?: ReactNode
|
||||
className?: string | undefined
|
||||
@@ -36,6 +38,7 @@ export function DisclosureRow({
|
||||
onToggle,
|
||||
expandOnRowClick = false,
|
||||
previewChevron = expandable,
|
||||
keepContentWhenOpen = false,
|
||||
collapsedContent,
|
||||
children,
|
||||
className,
|
||||
@@ -93,7 +96,7 @@ export function DisclosureRow({
|
||||
</span>
|
||||
)}
|
||||
<span className={clsx(css.title, titleClassName)}>{title}</span>
|
||||
{!open && collapsedContent}
|
||||
{(keepContentWhenOpen || !open) && collapsedContent}
|
||||
</div>
|
||||
{open && children}
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function GenericCommandCard({ node, t }: GenericCommandCardProps) {
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant="others"
|
||||
icon={<IconApiOutline14 size={16} />}
|
||||
icon={<IconApiOutline14 size={14} />}
|
||||
title={title}
|
||||
summary={summary}
|
||||
// Expandable only when the outcome text overflows a one-line summary.
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
|
||||
@@ -31,9 +31,14 @@ export interface GenericToolCardProps extends ToolRowOwnerProps {
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile, t }: GenericToolCardProps) {
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
// A failing exit status is the terminal card's own error signal (the call
|
||||
// itself settles isError:false), surfaced as the row's red state dot.
|
||||
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
|
||||
? 'error'
|
||||
: model.state
|
||||
const singleFile = model.filePath !== undefined
|
||||
return (
|
||||
<ToolRow
|
||||
@@ -45,12 +50,14 @@ export function GenericToolCard({ toolName, block, cwd, openFile, t }: GenericTo
|
||||
// A terminal presenter's description is the contract's above-card text, so
|
||||
// it outranks the args-derived summary here exactly as it does in BashRow.
|
||||
summary={terminal?.description ?? model.summary}
|
||||
// Single-file tools never expose an args body — the path link is the only action.
|
||||
body={singleFile ? null : model.body}
|
||||
body={model.body}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
terminal={terminal}
|
||||
state={model.state}
|
||||
state={state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={singleFile ? openFile : undefined}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 400;
|
||||
}
|
||||
@@ -103,8 +107,65 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Expanded body: pad-left 22 indented gray text, no border, no fill. */
|
||||
.body {
|
||||
/* Error row's collapsed summary: the failure's first line in the error color. */
|
||||
.errorSummary {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Expanded body + Inspect pill wrapper (sibling of .row: clicks never toggle). */
|
||||
.bodyWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Hover-revealed jump to the trajectory record: a small pill in real flow
|
||||
under the expanded body's bottom-left corner (it reserves its line, so
|
||||
revealing never shifts layout); revealed by hovering anywhere on the tool
|
||||
call — title row included — or by keyboard focus. */
|
||||
.inspectButton {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 4px 0 2px 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
/* Base background, not bg-overlay: the overlay token is a raised dark
|
||||
surface and reads too heavy for a quiet in-flow affordance. */
|
||||
background: var(--dsw-alias-bg-base);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.root:hover .inspectButton,
|
||||
.inspectButton:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Solid hover fill (a translucent token would let content bleed through). */
|
||||
.inspectButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Expanded-body scroll wrapper for the run_code CodeBlock; the IN/OUT card
|
||||
and the terminal card scroll INSIDE their own surface instead, so the
|
||||
scrollbar sits within the rounded card. */
|
||||
.bodyScroll {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Think expanded body: plain indented gray reasoning prose — no IN/OUT card
|
||||
(the reasoning is not an input payload), pre-wrapped at the row's indent.
|
||||
Uncapped: reasoning reads as message prose, so it flows with the page
|
||||
instead of scrolling in a box. */
|
||||
.thinkBody {
|
||||
padding: 4px 0 4px 22px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
@@ -113,6 +174,78 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Expanded input/output card (figma 1249:35657): the code-block surface and
|
||||
radius from the TerminalBlock/CodeBlock family. The card itself is a plain
|
||||
column — the padding and the IN/OUT gutter-label grid live on each section
|
||||
so the divider spans the full card width and each section scrolls alone. */
|
||||
.ioCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 4px 0 4px 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
}
|
||||
|
||||
/* One card section (IN or OUT): the gutter-label grid, capped and scrolling
|
||||
independently so a long input never buries a short output (and vice versa). */
|
||||
.ioSection {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: 14px;
|
||||
align-items: baseline;
|
||||
padding: 12px 16px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Card-internal scrollbar: a 2px transparent border clips the thumb inward so
|
||||
it floats off the rounded card edge instead of hugging it (the terminal
|
||||
card's own output scroller carries the same treatment in TerminalBlock). */
|
||||
.ioSection::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Track end-margins keep the thumb's travel out of the rounded corners. */
|
||||
.ioSection::-webkit-scrollbar-track {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
/* Caption (not tertiary): one step dimmer than the payload text so the
|
||||
gutter labels read as labels, not as part of the content. Sticky against
|
||||
the section's own scroll so the label stays readable while its payload
|
||||
scrolls underneath (top 0 = the section's padding edge inside the
|
||||
scrollport; start-aligned because sticky needs a block-start anchor). */
|
||||
.ioLabel {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* l2 hairline between the IN and OUT sections, spanning the full card width
|
||||
(it sits between the padded sections, not inside their grid). */
|
||||
.ioDivider {
|
||||
flex: none;
|
||||
height: 1px;
|
||||
background: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.ioText {
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* A failed call's OUT text shares the collapsed summary's error color. */
|
||||
.ioText[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* The two block-shaped expanded bodies: the code variant's run_code program
|
||||
through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
|
||||
command output through TerminalBlock. Both are drawn by the shared
|
||||
@@ -121,15 +254,21 @@
|
||||
flow's row rhythm. */
|
||||
.codeBody,
|
||||
.terminalBody {
|
||||
margin: 4px 0 4px 22px;
|
||||
margin: 4px 0 4px 4px;
|
||||
}
|
||||
|
||||
/* Indented to the body's own column so the description reads as the card's
|
||||
heading rather than as another summary row, and sits tight against the card
|
||||
below it. Its own rule: grouping it with a body would put description
|
||||
typography on a `CodeBlock` wrapper and change that body's spacing. */
|
||||
.terminalDescription {
|
||||
margin: 4px 0 0 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
/* In-row code renders at the smaller code size (12/18) via each primitive's
|
||||
rebindable content-font seam; standalone markdown code blocks keep 13/22. */
|
||||
.codeBody {
|
||||
--dsl-code-block-content-font: var(--dsw-font-markdown-code-block-small);
|
||||
}
|
||||
|
||||
/* The terminal card scrolls its OUTPUT inside its own surface (same l1
|
||||
hairline as the IN/OUT card): the banner stays pinned and the scrollbar
|
||||
never rides over it. 224px = the 260px card cap minus the ~36px banner. */
|
||||
.terminalBody {
|
||||
--dsl-terminal-font: var(--dsw-font-markdown-code-block-small);
|
||||
--dsl-terminal-line-height: 18px;
|
||||
--dsl-terminal-output-max-height: 224px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
|
||||
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
|
||||
// separator dot + FILL-truncated summary. The collapsed row is always one
|
||||
// line; the expanded body is indented gray text, the run_code program through
|
||||
// CodeBlock, or — for a call whose render intent is a terminal card — the
|
||||
// command's own output through TerminalBlock, capped at
|
||||
// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is
|
||||
// component-local view state. File-tool summaries are path links that open
|
||||
// through the host; the row itself is not a details-panel control.
|
||||
// separator dot + FILL-truncated summary, drawn through the shared
|
||||
// DisclosureRow chrome with the whole row as the expand toggle (click /
|
||||
// Enter / Space, icon→chevron hover preview). The collapsed row is always
|
||||
// one line; every row with body, output, or terminal material is expandable;
|
||||
// the summary stays inline while open, except Think, whose body opens with
|
||||
// the same first line and would repeat it.
|
||||
// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
|
||||
// text input/output, the run_code program through CodeBlock, or a terminal
|
||||
// card's command output through TerminalBlock — lives in a max-height scroll
|
||||
// container so a long payload scrolls internally instead of taking over the
|
||||
// message flow; Think's prose is the exception and flows uncapped like
|
||||
// message text. Expand state is component-local view state. File-tool
|
||||
// summaries are path links that open through the host (stopPropagation keeps
|
||||
// the two gestures independent); an error row's collapsed summary is the
|
||||
// failure's first line in the error color.
|
||||
|
||||
import { useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { DisclosureRow } from './DisclosureRow.tsx'
|
||||
import css from './ToolRow.module.css'
|
||||
@@ -26,18 +35,20 @@ export interface ToolRowProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
summary: string
|
||||
/** Expanded-body text; null = no text body (`terminal` is the other body source). */
|
||||
/** Expanded-body input text; null = no input section. */
|
||||
body: string | null
|
||||
/** Flattened result text for the expanded Output section; null/absent = no output section. */
|
||||
output?: string | null | undefined
|
||||
/** Error first line shown as the collapsed summary on an error row; null/absent = keep `summary`. */
|
||||
errorSummary?: string | null | undefined
|
||||
/**
|
||||
* Terminal-card material for a call whose render intent is a terminal card
|
||||
* (derived by `terminalCardModel`); it replaces the text body when present.
|
||||
* Null or absent leaves the text body, and a row with neither is not
|
||||
* expandable (its leading slot never toggles).
|
||||
* (derived by `terminalCardModel`); it replaces the text sections when
|
||||
* present. A row with no body, no output, and no terminal material is not
|
||||
* expandable.
|
||||
*/
|
||||
terminal?: TerminalCardModel | null | undefined
|
||||
state: ToolRowState
|
||||
/** Makes the row itself the expand control instead of only its leading icon. */
|
||||
expandOnRowClick?: boolean | undefined
|
||||
/**
|
||||
* Filesystem path from tool args; when set with onOpenFile, the summary
|
||||
* renders as a hover-underline link that opens the host default app.
|
||||
@@ -45,6 +56,21 @@ export interface ToolRowProps {
|
||||
filePath?: string | undefined
|
||||
/** Open the path with the host OS default application (already cwd-resolved). */
|
||||
onOpenFile?: ((path: string) => void) | undefined
|
||||
/**
|
||||
* Jump to this call in the trajectory view: a hover-revealed Inspect pill
|
||||
* over the expanded body. Absent = no affordance (rows without a call
|
||||
* identity, like Think).
|
||||
*/
|
||||
inspect?: (() => void) | undefined
|
||||
}
|
||||
|
||||
/** The Inspect pill's code glyph (user-supplied 16×16), fill follows text color. */
|
||||
function IconInspect() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Leading-slot state substitution: the tool icon yields to the terminal state
|
||||
@@ -66,26 +92,25 @@ export function ToolRow({
|
||||
title,
|
||||
summary,
|
||||
body,
|
||||
output,
|
||||
errorSummary,
|
||||
terminal,
|
||||
state,
|
||||
expandOnRowClick = false,
|
||||
filePath,
|
||||
onOpenFile,
|
||||
inspect,
|
||||
}: ToolRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const terminalBody = terminal ?? null
|
||||
// A row that names a single file keeps one interaction (open that path);
|
||||
// args expand is off whether or not the open callback is wired yet. Terminal
|
||||
// material still expands: only the file variants carry a path, so a terminal
|
||||
// card and a file link never land on the same row.
|
||||
const singleFile = filePath !== undefined
|
||||
const fileLink = singleFile && onOpenFile !== undefined
|
||||
const expandable = (body !== null && !singleFile) || terminalBody !== null
|
||||
// The text arms take the empty string for a null body: a row expandable
|
||||
// only through its terminal material renders the terminal body instead, so
|
||||
// this substitution never shows.
|
||||
const text = body ?? ''
|
||||
const outputText = output ?? null
|
||||
const expandable = body !== null || outputText !== null || terminalBody !== null
|
||||
const open = expanded && expandable
|
||||
// An error row's collapsed summary IS the failure: the first error line in
|
||||
// the error color outranks both the args summary and a terminal description.
|
||||
const failureLine = state === 'error' ? errorSummary ?? null : null
|
||||
const summaryText = failureLine ?? summary
|
||||
// The failure line is error prose, not the path: no open-file affordance.
|
||||
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
|
||||
const toggleExpand = () => {
|
||||
setExpanded(v => !v)
|
||||
}
|
||||
@@ -93,20 +118,33 @@ export function ToolRow({
|
||||
event.stopPropagation()
|
||||
if (filePath !== undefined) onOpenFile?.(filePath)
|
||||
}
|
||||
// Think reasoning is prose, not an input payload: expanded, it renders as
|
||||
// plain indented text (no IN/OUT card) and the inline summary — the body's
|
||||
// own first line — yields to avoid repeating itself.
|
||||
const isThink = variant === 'think'
|
||||
// The code variant's program renders through CodeBlock (shiki), so only its
|
||||
// output joins the IN/OUT card; every other variant's input does too.
|
||||
const cardBody = variant === 'code' ? null : body
|
||||
// The state substitution rides the idle icon slot, so an expandable error
|
||||
// row keeps DisclosureRow's icon→chevron hover preview (its default) instead
|
||||
// of losing it with the icon.
|
||||
return (
|
||||
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
|
||||
<DisclosureRow
|
||||
rowClassName={css.row}
|
||||
leadingClassName={css.leading}
|
||||
titleClassName={css.title}
|
||||
chevronClassName={css.chevron}
|
||||
icon={leadingFor(state, icon)}
|
||||
title={title}
|
||||
open={open}
|
||||
expandable={expandable}
|
||||
expandOnRowClick={expandOnRowClick}
|
||||
previewChevron={expandable && state !== 'error' && state !== 'stopped'}
|
||||
expandOnRowClick
|
||||
keepContentWhenOpen={!isThink}
|
||||
onToggle={toggleExpand}
|
||||
collapsedContent={(
|
||||
collapsedContent={summaryText !== '' && (
|
||||
/* An empty summary drops the separator with it (a row that is only
|
||||
its title shows no trailing dot). */
|
||||
<>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{fileLink ? (
|
||||
@@ -115,31 +153,71 @@ export function ToolRow({
|
||||
className={css.fileLink}
|
||||
onClick={openFile}
|
||||
>
|
||||
{summary}
|
||||
{summaryText}
|
||||
</button>
|
||||
) : (
|
||||
<span className={css.summary}>{summary}</span>
|
||||
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
|
||||
{summaryText}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{/* The terminal presenter's description belongs above the card per
|
||||
the render-intent contract. */}
|
||||
{terminalBody?.description !== undefined && (
|
||||
<div className={css.terminalDescription}>{terminalBody.description}</div>
|
||||
)}
|
||||
{terminalBody !== null
|
||||
? (
|
||||
<TerminalBlock
|
||||
{...terminalBody.card}
|
||||
maxLines={CHAT_TERMINAL_MAX_LINES}
|
||||
labels={terminalBlockLabels(t)}
|
||||
className={css.terminalBody}
|
||||
/>
|
||||
)
|
||||
: variant === 'code'
|
||||
? <CodeBlock code={text} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
||||
: <div className={css.body}>{text}</div>}
|
||||
{/* The wrapper (sibling of the header row, so clicks inside never
|
||||
toggle it) carries the expanded body and the Inspect pill below. */}
|
||||
<div className={css.bodyWrap}>
|
||||
{terminalBody !== null
|
||||
? (
|
||||
<TerminalBlock
|
||||
{...terminalBody.card}
|
||||
maxLines={Infinity}
|
||||
labels={terminalBlockLabels(t)}
|
||||
className={css.terminalBody}
|
||||
/>
|
||||
)
|
||||
: isThink
|
||||
? <div className={css.thinkBody}>{body}</div>
|
||||
: (
|
||||
<>
|
||||
{variant === 'code' && body !== null && (
|
||||
<div className={css.bodyScroll}>
|
||||
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
||||
</div>
|
||||
)}
|
||||
{(cardBody !== null || outputText !== null) && (
|
||||
<div className={css.ioCard}>
|
||||
{cardBody !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>IN</span>
|
||||
<span className={css.ioText}>{cardBody}</span>
|
||||
</div>
|
||||
)}
|
||||
{cardBody !== null && outputText !== null && (
|
||||
<span className={css.ioDivider} aria-hidden />
|
||||
)}
|
||||
{outputText !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>OUT</span>
|
||||
<span className={css.ioText} data-error={state === 'error' || undefined}>
|
||||
{outputText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{inspect !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.inspectButton}
|
||||
onClick={inspect}
|
||||
>
|
||||
<IconInspect />
|
||||
Inspect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -147,13 +147,17 @@ export interface InputZone {
|
||||
}
|
||||
|
||||
/**
|
||||
* View-slot owner share: deliberately empty — ConversationRoot supplies
|
||||
* nothing at its renderSlot site (sessionId and the snapshot hook arrive as
|
||||
* View-slot owner share: the cross-view inspect handoff (otherwise views need
|
||||
* nothing from the render site — sessionId and the snapshot hook arrive as
|
||||
* framework-standard props; tool rows go through each view's own declared
|
||||
* toolview hole). Kept as the named owner seat so a future cross-view
|
||||
* payload has a home.
|
||||
* toolview hole).
|
||||
*/
|
||||
export interface ConvViewOwnerProps {}
|
||||
export interface ConvViewOwnerProps {
|
||||
/** One-shot inspect request from another view (chat's Inspect button); null when idle. */
|
||||
inspect?: { callId: CallId } | null
|
||||
/** Acknowledge the inspect request once applied (clears the store field). */
|
||||
onInspectDone?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner share of a per-view toolview slot: the call material the rendering
|
||||
@@ -176,6 +180,11 @@ export interface ToolRowOwnerProps {
|
||||
* The chat view resolves relative paths against the session cwd.
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
/**
|
||||
* Jump to this call's record in the trajectory view (the expanded row's
|
||||
* hover Inspect affordance). Undefined when no trajectory jump is wired.
|
||||
*/
|
||||
inspect?: (() => void) | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -423,6 +432,19 @@ export interface ChatViewInjected {
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
loadOlder: () => void
|
||||
/** Hand a call off to the trajectory view: write the one-shot inspect target and switch tabs. */
|
||||
inspectCall: (callId: CallId) => void
|
||||
/**
|
||||
* Per-session scroll memory surviving view switches (in-memory, never
|
||||
* persisted): the view saves on every scroll and restores on remount; a
|
||||
* fresh page load starts empty and keeps the open-jump-to-bottom default.
|
||||
*/
|
||||
chatScroll: {
|
||||
/** Record the scroll offset; null clears it (pinned to bottom). */
|
||||
save: (top: number | null) => void
|
||||
/** Last recorded offset, or null when pinned or never recorded. */
|
||||
read: () => number | null
|
||||
}
|
||||
/** Fork the session through the turn containing the message at `seq`, then open the child. */
|
||||
forkAt: (seq: number) => void
|
||||
}
|
||||
|
||||
@@ -37,17 +37,6 @@ export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output lines the chat row's expanded terminal body shows before collapsing
|
||||
* the middle — half the primitive's own default, which the details panel
|
||||
* keeps. A chat row is a summary surface inside the message flow: the flow
|
||||
* must stay scannable across many calls, while the details panel is the
|
||||
* single-call reading surface. A design constant of this UI's row geometry,
|
||||
* not a deployment choice, so it is fixed here rather than a plugin Config
|
||||
* field.
|
||||
*/
|
||||
export const CHAT_TERMINAL_MAX_LINES = 8
|
||||
|
||||
/**
|
||||
* The {@link TerminalBlock} props this derivation owns. Picked off the
|
||||
* primitive's props so the two stay in step; `home` is absent because the web
|
||||
@@ -70,6 +59,20 @@ export interface TerminalCardModel {
|
||||
description: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a settled terminal card reports a failing exit — a non-zero code
|
||||
* or a terminating signal. The bash tool settles a failing command as a
|
||||
* completed call (`isError` stays false: the exit status is result data), so
|
||||
* this is the collapsed row's only failure signal; without it the red exit
|
||||
* pill would be visible only after expanding the card.
|
||||
* @param model - a derived terminal card.
|
||||
* @returns whether the card's exit status is a failure.
|
||||
*/
|
||||
export function terminalFailed(model: TerminalCardModel): boolean {
|
||||
const { exitCode, signal, running } = model.card
|
||||
return running !== true && ((exitCode !== undefined && exitCode !== 0) || signal !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a terminal view's working directory the way the render-intent
|
||||
* contract assigns to the UI bridge: an absolute path is used as-is, a relative
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* Pure row-model derivation for tool summary rows: variant classification,
|
||||
* one-line summary and expanded-body text from the frozen call slice. This
|
||||
* derivation reads the call ARGUMENTS only; a call whose render intent is a
|
||||
* terminal card gets its expanded body from the views instead, through
|
||||
* one-line summary, expanded-body text, and flattened result output from the
|
||||
* frozen call slice. Input material comes from the call ARGUMENTS; output and
|
||||
* error material from the settled result node. A call whose render intent is
|
||||
* a terminal card gets its expanded body from the views instead, through
|
||||
* `terminalCardModel` in terminal-card-model.ts.
|
||||
*/
|
||||
// The block union's defining home is runtime (fold-product types); this
|
||||
// contract only forwards it (type-definition authority stays with the layer
|
||||
// that produces the values).
|
||||
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
@@ -70,11 +71,34 @@ export interface ToolRowModel {
|
||||
* relative values against the session cwd before opening.
|
||||
*/
|
||||
filePath: string | undefined
|
||||
/** Expanded-body text (pretty args); null = row not expandable. */
|
||||
/** Expanded-body input text (pretty args); null = no input section. */
|
||||
body: string | null
|
||||
/** Flattened result text ({@link resultText}); null while running or when the result carries no text. */
|
||||
output: string | null
|
||||
/** First line of the result text on an error row; null for every other state. */
|
||||
errorSummary: string | null
|
||||
state: ToolRowState
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a settled result's content blocks to display text: text blocks
|
||||
* verbatim, other block shapes as pretty JSON. Empty content on a failed call
|
||||
* falls back to the structured error's `name: code` line.
|
||||
* @param node - the settled result node.
|
||||
* @returns the flattened result text (may be empty).
|
||||
*/
|
||||
export function resultText(node: ToolResultNode): string {
|
||||
const parts: string[] = []
|
||||
for (const block of node.content) {
|
||||
if (block.type === 'text') parts.push(block.text)
|
||||
else parts.push(JSON.stringify(block, null, 2))
|
||||
}
|
||||
if (parts.length === 0 && node.error !== undefined) {
|
||||
parts.push(`${node.error.name}: ${node.error.code}`)
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function parseArgs(argsRaw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(argsRaw)
|
||||
@@ -192,12 +216,19 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin
|
||||
const summary = variant === 'others' && toolName !== '' && toolTitle === undefined
|
||||
? `${toolName} · ${base}`
|
||||
: base
|
||||
// The empty string is "no text" for both derived result fields: a settled
|
||||
// call with blank content has nothing to expand, and a blank first line
|
||||
// would erase the collapsed error row's summary slot.
|
||||
const output = done ? (resultText(block) || null) : null
|
||||
const errorSummary = state === 'error' && output !== null ? firstLine(output) : null
|
||||
return {
|
||||
variant,
|
||||
title: toolTitle ?? VARIANT_TITLES[variant],
|
||||
summary,
|
||||
filePath: deriveFilePath(variant, argsRaw),
|
||||
body: deriveBody(variant, argsRaw),
|
||||
output,
|
||||
errorSummary,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,10 @@ export interface ChatStoreState {
|
||||
draft: string
|
||||
/** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */
|
||||
view: string | null
|
||||
/**
|
||||
* One-shot inspect handoff: chat writes the call to reveal, the trajectory
|
||||
* view consumes it and acknowledges by clearing. Read with `?? null` —
|
||||
* persisted snapshots from before this field rehydrate without it.
|
||||
*/
|
||||
inspect: { callId: CallId } | null
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ export function ConversationSession({
|
||||
const blank = useSession(s => s.blank)
|
||||
const inputState = useInput(s => s)
|
||||
const storedDraft = useStore(s => s.draft)
|
||||
// `?? null`: persisted snapshots from before the inspect field rehydrate without it.
|
||||
const inspect = useStore(s => s.inspect ?? null)
|
||||
|
||||
useEffect(() => {
|
||||
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
|
||||
@@ -52,7 +54,10 @@ export function ConversationSession({
|
||||
|
||||
const view: ReactNode = hideChrome ? null : (
|
||||
<div className={css.viewArea}>
|
||||
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
|
||||
{active !== undefined && renderSlot('conversation.view', {
|
||||
inspect,
|
||||
onInspectDone: () => { actions.setInspect(null) },
|
||||
}, { only: active.id })}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DetailsSlotProps } from '../contract/slots.ts'
|
||||
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
import { resultText, type ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
import css from './DetailsPanel.module.css'
|
||||
|
||||
/** Full props composed by reference from the contract (automatic shares & injected share). */
|
||||
@@ -154,20 +154,7 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
|
||||
const result = material.block
|
||||
return (
|
||||
<pre className={css.code} data-error={result.isError || undefined}>
|
||||
{renderResult(result)}
|
||||
{resultText(result)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
/** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */
|
||||
function renderResult(node: ToolResultNode): string {
|
||||
const parts: string[] = []
|
||||
for (const block of node.content) {
|
||||
if (block.type === 'text') parts.push(block.text)
|
||||
else parts.push(JSON.stringify(block, null, 2))
|
||||
}
|
||||
if (parts.length === 0 && node.error !== undefined) {
|
||||
parts.push(`${node.error.name}: ${node.error.code}`)
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* The plugin creates its handle at apply time so identity follows the fiber.
|
||||
*/
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
|
||||
import type { CallId, ChatStoreState, SelectionTarget } from './contract/views.ts'
|
||||
|
||||
/** Declared action shape used to give the exported factory a stable return type. */
|
||||
type ChatActions = {
|
||||
@@ -12,6 +12,7 @@ type ChatActions = {
|
||||
clearDraft: (draft: ChatStoreState) => void
|
||||
restoreDraft: (draft: ChatStoreState, text: string) => void
|
||||
setView: (draft: ChatStoreState, view: string) => void
|
||||
setInspect: (draft: ChatStoreState, target: { callId: CallId } | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,7 +21,7 @@ type ChatActions = {
|
||||
*/
|
||||
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
|
||||
return defineStore({
|
||||
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
|
||||
init: (): ChatStoreState => ({ selection: null, draft: '', view: null, inspect: null }),
|
||||
persist: 'dsh.conversation.chat',
|
||||
actions: {
|
||||
select: (d, target: SelectionTarget | null) => { d.selection = target },
|
||||
@@ -30,6 +31,7 @@ export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions
|
||||
// since the clear (send choreography lives in the inject factory).
|
||||
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
|
||||
setView: (d, view: string) => { d.view = view },
|
||||
setInspect: (d, target: { callId: CallId } | null) => { d.inspect = target },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ask_user_question toolview: question-flavored summary row replacing the
|
||||
// generic "Tool call" card, registered into the keyed
|
||||
// 'conversation.chat.toolview' hole like todo-row. The row composes ToolRow
|
||||
// (chrome, running sweep, leading expansion) and swaps in the interaction
|
||||
// (chrome, running sweep, whole-row expand) and swaps in the interaction
|
||||
// outcome — `waiting` while pending, answered-count once settled, `cancelled`
|
||||
// when the user dismissed the whole set — because the questions themselves
|
||||
// render in the composer takeover.
|
||||
@@ -42,8 +42,9 @@ function answeredSummary(text: string, t: AskQuestionRowProps['t']): string | nu
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type AskQuestionRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/** One-line question-interaction row (leading toggle expands the raw args). */
|
||||
export function AskQuestionRow({ toolName, block, t }: AskQuestionRowProps) {
|
||||
/** One-line question-interaction row (the whole row toggles the call's
|
||||
* Input/Output sections, ToolRow's unified expand). */
|
||||
export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
// Composer verdicts settle the call as specific UserInteractionErrors
|
||||
// (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own
|
||||
@@ -74,7 +75,9 @@ export function AskQuestionRow({ toolName, block, t }: AskQuestionRowProps) {
|
||||
title={t('ask.rowTitle')}
|
||||
summary={summary}
|
||||
body={model.body}
|
||||
output={model.output}
|
||||
state={state}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description),
|
||||
plus the terminal card the row stacks under its summary line. */
|
||||
plus the expand-gated terminal card under the summary line. */
|
||||
|
||||
/* Summary line over the terminal card; the summary row keeps its own 24px
|
||||
height, so the card is a column around it rather than a change to it. */
|
||||
@@ -8,10 +8,23 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
/* Expanded terminal card, matching ToolRow's terminalBody: 4px indent, l1
|
||||
hairline, and the max-height scroll on the card's own OUTPUT (banner stays
|
||||
pinned; 224px = the 260px card cap minus the ~36px banner); the margin
|
||||
replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.terminal {
|
||||
margin: 4px 0 4px 22px;
|
||||
--dsl-terminal-font: var(--dsw-font-markdown-code-block-small);
|
||||
--dsl-terminal-line-height: 18px;
|
||||
--dsl-terminal-output-max-height: 224px;
|
||||
margin: 4px 0 4px 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
/* ToolRow's unified expand interaction, replicated per the registrant
|
||||
posture: pointer on the expandable row (the icon→chevron hover preview is
|
||||
the affordance, no row fill). */
|
||||
.root[data-expandable] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root {
|
||||
@@ -47,6 +60,7 @@
|
||||
}
|
||||
|
||||
.leading {
|
||||
position: relative; /* .chevronHover overlay anchor */
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -57,6 +71,34 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Hover preview on the expandable row: the idle icon crossfades (100ms) into
|
||||
a down chevron before the row is opened — same overlay as ToolRow. */
|
||||
.iconIdle {
|
||||
display: inline-flex;
|
||||
opacity: 1;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.chevronHover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.root:hover .iconIdle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.root:hover .chevronHover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.scopeBadge {
|
||||
flex: none;
|
||||
margin-right: 8px;
|
||||
@@ -95,6 +137,52 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Error row's collapsed summary: the failure's first line in the error color. */
|
||||
.errorSummary {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Hover-revealed Inspect pill under the expanded terminal's bottom-left —
|
||||
ToolRow's .bodyWrap/.inspectButton treatment, replicated per the registrant
|
||||
posture: real flow (it reserves its line), revealed by hovering anywhere on
|
||||
the tool call — title row included — or by keyboard focus. */
|
||||
.bodyWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.inspectButton {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 4px 0 2px 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
/* Base background, not bg-overlay: the overlay token reads too heavy. */
|
||||
background: var(--dsw-alias-bg-base);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.card:hover .inspectButton,
|
||||
.inspectButton:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Solid hover fill: the pill floats over terminal output, so a translucent
|
||||
hover token would let the text underneath bleed through. */
|
||||
.inspectButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
||||
@@ -4,20 +4,24 @@
|
||||
// Child sessions keep a scoped badge so session-dimension differentiation stays
|
||||
// observable inside the component (no parallel registry).
|
||||
//
|
||||
// A bash call declares the terminal render intent, so this row also renders
|
||||
// the command's own output through TerminalBlock. This row has no expand
|
||||
// control and is not a details-panel target either (tool rows stopped being
|
||||
// one), so its terminal body is resident rather than expand-gated as in
|
||||
// ToolRow, and the card's own copy and expand controls are the row's only
|
||||
// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat
|
||||
// flow's tighter cap over the block's own default of 16 — and the block's
|
||||
// internal expander keeps a long output from taking over the message flow.
|
||||
// A bash call declares the terminal render intent, so this row renders the
|
||||
// command's own output through TerminalBlock — expand-gated exactly like
|
||||
// ToolRow's unified interaction: collapsed by default, the whole summary row
|
||||
// is the toggle (click / Enter / Space, icon→chevron hover preview; the
|
||||
// summary stays inline while open),
|
||||
// and the expanded card max-height-scrolls inside its own surface with the
|
||||
// full output (maxLines Infinity — no middle collapse). An error row's
|
||||
// collapsed summary is the failure's first line in the error color.
|
||||
|
||||
import { useState, type KeyboardEvent } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconApiOutline14, IconChevronDownOutline14, StateDot, TerminalBlock,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { terminalBlockLabels, terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import { NS } from '../locales.ts'
|
||||
import css from './bash-sample.module.css'
|
||||
@@ -45,43 +49,89 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the
|
||||
* command's terminal card resident below it. The summary row is not a
|
||||
* details-panel control (tool rows stopped being one), so the card's copy and
|
||||
* expand controls are the row's only interactions.
|
||||
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, the
|
||||
* whole row toggling the command's terminal card (ToolRow's unified
|
||||
* expand interaction, replicated locally per the registrant posture).
|
||||
*/
|
||||
export function BashRow({ toolName, block, sessionId, useSessions, t }: BashRowProps) {
|
||||
export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
// Session workspace root: the terminal view's cwd resolves against it (an
|
||||
// omitted workdir IS the workspace), which the pure presenter cannot do.
|
||||
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
// A failing exit status is the terminal card's own error signal (the call
|
||||
// itself settles isError:false), surfaced as the row's red state dot.
|
||||
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
|
||||
? 'error'
|
||||
: model.state
|
||||
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
||||
const status = stateStatus(model.state, t)
|
||||
const status = stateStatus(state, t)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const expandable = terminal !== null
|
||||
const open = expanded && expandable
|
||||
const failureLine = model.state === 'error' ? model.errorSummary : null
|
||||
const toggleExpand = () => {
|
||||
setExpanded(v => !v)
|
||||
}
|
||||
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
|
||||
event.preventDefault()
|
||||
toggleExpand()
|
||||
}
|
||||
const leading = open
|
||||
? <IconChevronDownOutline14 className={css.chevron} />
|
||||
: expandable
|
||||
? (
|
||||
<>
|
||||
<span className={css.iconIdle}>{leadingFor(state)}</span>
|
||||
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
|
||||
</>
|
||||
)
|
||||
: leadingFor(state)
|
||||
return (
|
||||
<div className={css.card}>
|
||||
<div
|
||||
className={css.root}
|
||||
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
||||
data-variant="bash"
|
||||
data-state={model.state}
|
||||
data-state={state}
|
||||
data-expandable={expandable || undefined}
|
||||
role={expandable ? 'button' : undefined}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
aria-expanded={expandable ? open : undefined}
|
||||
onClick={expandable ? toggleExpand : undefined}
|
||||
onKeyDown={expandable ? toggleFromKeyboard : undefined}
|
||||
>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
<span className={css.leading}>{leading}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
{isChild && <span className={css.scopeBadge}>scoped</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{/* The terminal presenter's description is the contractual
|
||||
above-card summary; it outranks the args-derived one. */}
|
||||
<span className={css.summary}>{terminal?.description ?? model.summary}</span>
|
||||
above-card summary; a failure's first line outranks both. */}
|
||||
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
|
||||
{failureLine ?? terminal?.description ?? model.summary}
|
||||
</span>
|
||||
</div>
|
||||
{terminal !== null && (
|
||||
<TerminalBlock
|
||||
{...terminal.card}
|
||||
maxLines={CHAT_TERMINAL_MAX_LINES}
|
||||
labels={terminalBlockLabels(t)}
|
||||
className={css.terminal}
|
||||
/>
|
||||
{terminal !== null && open && (
|
||||
/* Same hover-Inspect posture as ToolRow's expanded body, replicated
|
||||
locally per the registrant posture. */
|
||||
<div className={css.bodyWrap}>
|
||||
<TerminalBlock
|
||||
{...terminal.card}
|
||||
maxLines={Infinity}
|
||||
labels={terminalBlockLabels(t)}
|
||||
className={css.terminal}
|
||||
/>
|
||||
{inspect !== undefined && (
|
||||
<button type="button" className={css.inspectButton} onClick={inspect}>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
|
||||
</svg>
|
||||
Inspect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// todo_write toolview: plan-flavored summary row replacing the generic
|
||||
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
|
||||
// hole like the bash sample (a product registration, not a sample). The row
|
||||
// composes ToolRow (chrome, running sweep, leading expansion) and swaps in a
|
||||
// composes ToolRow (chrome, running sweep, whole-row expand) and swaps in a
|
||||
// summary of the written list (counts + active item) from the call args; the
|
||||
// durable list itself renders in the TodoPanel above the composer, so the
|
||||
// row stays one line.
|
||||
// row stays one line until expanded.
|
||||
|
||||
import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from 'cordis'
|
||||
@@ -45,10 +45,11 @@ function summarize(argsRaw: string, t: TodoRowProps['t']): string | null {
|
||||
: head
|
||||
}
|
||||
|
||||
/** One-line plan update row (leading toggle expands the raw args). Non-ok
|
||||
* execution states keep the shared row's dot semantics — a cancelled call
|
||||
* wrote no todo/write, so it must not read as a completed update. */
|
||||
export function TodoRow({ toolName, block, t }: TodoRowProps) {
|
||||
/** One-line plan update row (the whole row toggles the call's Input/Output
|
||||
* sections, ToolRow's unified expand). Non-ok execution states keep the
|
||||
* shared row's dot semantics — a cancelled call wrote no todo/write, so it
|
||||
* must not read as a completed update. */
|
||||
export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const summary = summarize(argsRaw, t) ?? model.summary
|
||||
@@ -61,7 +62,10 @@ export function TodoRow({ toolName, block, t }: TodoRowProps) {
|
||||
title={t('todo.rowTitle')}
|
||||
summary={summary}
|
||||
body={model.body}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
state={model.state}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
|
||||
})
|
||||
|
||||
describe('terminal card assembly', () => {
|
||||
it('the keyed bash row carries a resident terminal card; the fallback row reaches one through expand', async () => {
|
||||
it('both the keyed bash row and the fallback row reach the terminal card through the whole-row expand', async () => {
|
||||
const runtime = await bench([
|
||||
bashResult(3, 'c-keyed'),
|
||||
// An unregistered tool with terminal views: GenericToolCard fallback.
|
||||
@@ -144,15 +144,20 @@ describe('terminal card assembly', () => {
|
||||
])
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
// Keyed BashRow renders the card residently (no expand gesture).
|
||||
const keyed = view.container.querySelector('[data-sample="bash-global"]')?.parentElement
|
||||
expect(keyed?.querySelector('[data-terminal]')).not.toBeNull()
|
||||
// Keyed BashRow: collapsed by default, the whole summary row is the toggle.
|
||||
const keyedRow = view.container.querySelector('[data-sample="bash-global"]')
|
||||
const keyed = keyedRow?.parentElement
|
||||
expect(keyed?.querySelector('[data-terminal]')).toBeNull()
|
||||
fireEvent.click(keyedRow!)
|
||||
await waitFor(() => {
|
||||
expect(keyed!.querySelector('[data-terminal]')).not.toBeNull()
|
||||
})
|
||||
|
||||
// Fallback row: card appears only after its expand control.
|
||||
// Fallback row: same unified expand interaction.
|
||||
const fallback = view.container.querySelector('[data-tool="fx-bash"]')
|
||||
expect(fallback).not.toBeNull()
|
||||
expect(fallback!.querySelector('[data-terminal]')).toBeNull()
|
||||
fireEvent.click(fallback!.querySelector('button[aria-expanded]')!)
|
||||
fireEvent.click(fallback!.querySelector('[data-expandable]')!)
|
||||
await waitFor(() => {
|
||||
expect(fallback!.querySelector('[data-terminal]')).not.toBeNull()
|
||||
})
|
||||
|
||||
@@ -205,7 +205,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent)
|
||||
.toContain('Unmount temporary Plugindyn-2')
|
||||
|
||||
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
|
||||
fireEvent.click(mounted!.querySelector('[data-expandable]')!)
|
||||
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
|
||||
})
|
||||
|
||||
@@ -213,8 +213,8 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
const parent = 'call-64'
|
||||
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))
|
||||
const view = mountApp(b.slots)
|
||||
// The code row is expandable via its leading control (body = the program).
|
||||
const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]')
|
||||
// The code row is expandable via the whole summary row (body = the program).
|
||||
const toggle = view.container.querySelector('[data-variant="code"] [data-expandable]')
|
||||
expect(toggle).not.toBeNull()
|
||||
fireEvent.click(toggle!)
|
||||
// Shiki splits the program into token spans inside one <pre class="shiki">:
|
||||
|
||||
@@ -12,7 +12,7 @@ beforeEach(() => {
|
||||
describe('createChatStore', () => {
|
||||
it('init shape: empty selection/draft/view', () => {
|
||||
const store = createChatStore().create()
|
||||
expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
|
||||
expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null })
|
||||
})
|
||||
|
||||
it('actions cover the declared write set', () => {
|
||||
@@ -30,6 +30,11 @@ describe('createChatStore', () => {
|
||||
|
||||
store.actions.setView('chat')
|
||||
expect(store.store.getSnapshot().view).toBe('chat')
|
||||
|
||||
store.actions.setInspect({ callId: 'c1' })
|
||||
expect(store.store.getSnapshot().inspect).toEqual({ callId: 'c1' })
|
||||
store.actions.setInspect(null)
|
||||
expect(store.store.getSnapshot().inspect).toBeNull()
|
||||
})
|
||||
|
||||
it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ afterEach(cleanup)
|
||||
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { classifyTool, resolveToolPath, toolRowModel } from '../src/client/contract/tool-call-model.ts'
|
||||
import { classifyTool, resolveToolPath, resultText, toolRowModel } from '../src/client/contract/tool-call-model.ts'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
|
||||
@@ -107,6 +107,29 @@ describe('tool-call-model', () => {
|
||||
.toBe('{\n "code": ""\n}')
|
||||
})
|
||||
|
||||
it('resultText flattens text blocks verbatim, other shapes as JSON, empty error content to name: code', () => {
|
||||
expect(resultText(result({ content: [{ type: 'text', text: 'a\nb' }] }))).toBe('a\nb')
|
||||
expect(resultText(result({ content: [{ type: 'text', text: 'a' }, { type: 'image', data: 'x' } as never] })))
|
||||
.toBe(`a\n${JSON.stringify({ type: 'image', data: 'x' }, null, 2)}`)
|
||||
expect(resultText(result({ content: [], isError: true, error: { name: 'ToolError', code: 'denied' } })))
|
||||
.toBe('ToolError: denied')
|
||||
expect(resultText(result({ content: [] }))).toBe('')
|
||||
})
|
||||
|
||||
it('derives output from the settled result and null while running or blank', () => {
|
||||
expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'out' }] })).output).toBe('out')
|
||||
expect(toolRowModel('bash', running()).output).toBeNull()
|
||||
expect(toolRowModel('bash', result({ content: [] })).output).toBeNull()
|
||||
})
|
||||
|
||||
it('derives errorSummary as the first output line on error rows only', () => {
|
||||
const failed = result({ content: [{ type: 'text', text: 'boom\ndetail' }], isError: true })
|
||||
expect(toolRowModel('bash', failed).errorSummary).toBe('boom')
|
||||
expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'boom' }] })).errorSummary).toBeNull()
|
||||
expect(toolRowModel('bash', result({ content: [], isError: true })).errorSummary).toBeNull()
|
||||
expect(toolRowModel('bash', running()).errorSummary).toBeNull()
|
||||
})
|
||||
|
||||
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
|
||||
expect(toolRowModel('cordis_inspect', running({
|
||||
name: 'cordis_inspect',
|
||||
@@ -150,14 +173,15 @@ describe('ToolRow', () => {
|
||||
expect(view.container.querySelector('[aria-expanded]')?.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('expanding swaps the leading slot to a chevron, hides summary, shows body', () => {
|
||||
it('row click expands: chevron leading, summary kept inline, body in the scrolling card', () => {
|
||||
const view = render(<ToolRow {...rowProps} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
expect(view.queryByTestId('tool-icon')).toBeNull()
|
||||
expect(view.container.querySelector('svg')).not.toBeNull()
|
||||
expect(view.queryByText('List files')).toBeNull()
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.getByText(/"a": 1/)).toBeTruthy()
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
})
|
||||
@@ -168,16 +192,20 @@ describe('ToolRow', () => {
|
||||
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
const errorView = render(<ToolRow {...rowProps} state="error" />)
|
||||
expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
|
||||
// The dot rides the idle slot, so an expandable error row keeps the
|
||||
// icon→chevron hover preview instead of losing it with the icon.
|
||||
expect(errorView.container.querySelector('[class*="chevronHover"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('non-expandable rows render a passive leading slot', () => {
|
||||
it('non-expandable rows render a passive leading slot and no row button', () => {
|
||||
const view = render(<ToolRow {...rowProps} body={null} />)
|
||||
expect(view.container.querySelector('button')).toBeNull()
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => {
|
||||
const view = render(<ToolRow {...rowProps} expandOnRowClick />)
|
||||
it('the row toggles from Enter and Space, ignoring other keys', () => {
|
||||
const view = render(<ToolRow {...rowProps} />)
|
||||
const row = view.getByRole('button')
|
||||
fireEvent.keyDown(row, { key: 'Tab' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
@@ -187,32 +215,31 @@ describe('ToolRow', () => {
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('a non-expandable expandOnRowClick row exposes no row button', () => {
|
||||
const view = render(<ToolRow {...rowProps} body={null} expandOnRowClick />)
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
|
||||
it('file rows expand from the row while the path link opens without toggling', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(
|
||||
<ToolRow {...rowProps} variant="read" title="Read" summary="src/a.ts" filePath="src/a.ts" onOpenFile={open} />,
|
||||
)
|
||||
const row = view.getByRole('button', { name: /Read/ })
|
||||
// Path click opens the file and leaves the row collapsed.
|
||||
fireEvent.click(view.getByText('src/a.ts'))
|
||||
expect(open).toHaveBeenCalledWith('src/a.ts')
|
||||
// Only the path link is a button — no args-expand affordance on file rows.
|
||||
expect(view.container.querySelectorAll('button')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
expect(view.queryByText(/"a": 1/)).toBeNull()
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
// Row click (outside the link) expands the args body.
|
||||
fireEvent.click(row)
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByText(/"a": 1/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a single-file path disables expand even when onOpenFile is absent', () => {
|
||||
it('a file path without onOpenFile renders a plain summary on an expandable row', () => {
|
||||
const view = render(
|
||||
<ToolRow {...rowProps} variant="write" title="Write" summary="作文.md" filePath="作文.md" />,
|
||||
)
|
||||
expect(view.container.querySelector('button')).toBeNull()
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
fireEvent.click(view.getByText('作文.md'))
|
||||
expect(view.queryByText(/"a": 1/)).toBeNull()
|
||||
const row = view.getByRole('button')
|
||||
fireEvent.click(row)
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByText(/"a": 1/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('non-file rows do not open anything when the summary is clicked', () => {
|
||||
@@ -221,6 +248,75 @@ describe('ToolRow', () => {
|
||||
fireEvent.click(view.getByText('List files'))
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('an error row shows the failure first line in the collapsed summary and the full text expanded', () => {
|
||||
const view = render(
|
||||
<ToolRow {...rowProps} state="error" errorSummary="boom" output={'boom\ndetail'} />,
|
||||
)
|
||||
expect(view.getByText('boom')).toBeTruthy()
|
||||
expect(view.queryByText('List files')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
expect(view.getByText(/detail/)).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-error]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('an error row without an error summary keeps the args summary', () => {
|
||||
const view = render(<ToolRow {...rowProps} state="error" errorSummary={null} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(
|
||||
<ToolRow
|
||||
{...rowProps}
|
||||
variant="write" title="Write" state="error" errorSummary="cannot overwrite"
|
||||
filePath="src/a.ts" onOpenFile={open}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByText('cannot overwrite'))
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
// The failure line renders as plain text, not the underlined link button.
|
||||
expect(view.container.querySelector('[class*="fileLink"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('the expanded body carries a hover Inspect pill that fires the callback', () => {
|
||||
const inspect = vi.fn()
|
||||
const view = render(<ToolRow {...rowProps} inspect={inspect} />)
|
||||
// Collapsed: no pill.
|
||||
expect(view.queryByText('Inspect')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
|
||||
const pill = view.getByText('Inspect')
|
||||
fireEvent.click(pill)
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
// The pill click must not collapse the row (body is a .row sibling).
|
||||
expect(view.getByRole('button', { name: /Bash/ }).getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('no inspect callback, no pill', () => {
|
||||
const view = render(<ToolRow {...rowProps} />)
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
expect(view.queryByText('Inspect')).toBeNull()
|
||||
})
|
||||
|
||||
it('the expanded card gutter-labels each section it carries (IN / OUT)', () => {
|
||||
const both = render(<ToolRow {...rowProps} output="result text" />)
|
||||
fireEvent.click(both.getByRole('button'))
|
||||
expect(both.getByText('IN')).toBeTruthy()
|
||||
expect(both.getByText('OUT')).toBeTruthy()
|
||||
expect(both.getByText('result text')).toBeTruthy()
|
||||
cleanup()
|
||||
const inputOnly = render(<ToolRow {...rowProps} />)
|
||||
fireEvent.click(inputOnly.getByRole('button'))
|
||||
expect(inputOnly.getByText('IN')).toBeTruthy()
|
||||
expect(inputOnly.queryByText('OUT')).toBeNull()
|
||||
cleanup()
|
||||
const outputOnly = render(<ToolRow {...rowProps} body={null} output="only out" />)
|
||||
fireEvent.click(outputOnly.getByRole('button'))
|
||||
expect(outputOnly.queryByText('IN')).toBeNull()
|
||||
expect(outputOnly.getByText('OUT')).toBeTruthy()
|
||||
expect(outputOnly.getByText('only out')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ThinkRow', () => {
|
||||
@@ -241,6 +337,22 @@ describe('ThinkRow', () => {
|
||||
fireEvent.click(view.getByText('Think'))
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
|
||||
streaming={false}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByText('Think'))
|
||||
// The summary (first line) is gone from the row; only the body carries it.
|
||||
expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
|
||||
expect(view.queryByText('IN')).toBeNull()
|
||||
expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
|
||||
expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GenericToolCard', () => {
|
||||
@@ -290,6 +402,14 @@ describe('GenericToolCard', () => {
|
||||
expect(view.container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('passes the owner inspect callback through to the expanded row pill', () => {
|
||||
const inspect = vi.fn()
|
||||
const view = render(<GenericToolCard {...props('bash', result())} inspect={inspect} />)
|
||||
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
|
||||
fireEvent.click(view.getByText('Inspect'))
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('file-path summary click reaches openFile; bash summary does not', () => {
|
||||
const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
|
||||
const fileView = render(<GenericToolCard {...file} />)
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent)
|
||||
.toContain('Unmount temporary Plugindyn-2')
|
||||
|
||||
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
|
||||
fireEvent.click(mounted!.querySelector('[data-expandable]')!)
|
||||
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -97,6 +97,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
||||
const openFile = vi.fn<(path: string) => void>()
|
||||
const loadOlder = vi.fn()
|
||||
const inspectCall = vi.fn<(callId: string) => void>()
|
||||
// In-memory scroll memory matching the apply.ts per-session map contract.
|
||||
let savedScrollTop: number | null = null
|
||||
const chatScroll = {
|
||||
save: (top: number | null) => { savedScrollTop = top },
|
||||
read: () => savedScrollTop,
|
||||
}
|
||||
const forkAt = vi.fn()
|
||||
// Selection rides the REAL chat store (same construction path as
|
||||
// production; the view reads it through the PropsStore useStore share).
|
||||
@@ -124,12 +131,14 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
openDetails,
|
||||
openFile,
|
||||
loadOlder,
|
||||
inspectCall,
|
||||
chatScroll,
|
||||
forkAt,
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
t: makeTranslate(zh, commonZh),
|
||||
}
|
||||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, forkAt, setSelection }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
@@ -218,6 +227,16 @@ describe('ChatView', () => {
|
||||
expect(view.getByText('run a')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [toolResult(3, 'a')],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
|
||||
fireEvent.click(view.getByText('Inspect'))
|
||||
expect(h.inspectCall).toHaveBeenCalledWith('a')
|
||||
})
|
||||
|
||||
it('shows assistant IconActions only on the last content message of each turn', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
@@ -331,11 +350,11 @@ describe('ChatView', () => {
|
||||
expect(rowRenders).toBe(afterMount)
|
||||
})
|
||||
|
||||
it('tool row expands to the args body via the leading slot toggle', () => {
|
||||
it('tool row expands to the args body via the whole-row toggle', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.queryByText(/"command": "cmd-a"/)).toBeNull()
|
||||
fireEvent.click(view.container.querySelector('button[aria-expanded]')!)
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -458,6 +477,55 @@ describe('ChatView', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('a remount restores the saved scroll position instead of re-jumping to the bottom', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
document.body.appendChild(host)
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
// Fresh open (nothing saved): the bottom jump stands.
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
// Reader scrolls up; the position is recorded continuously.
|
||||
host.scrollTop = 100
|
||||
fireEvent.scroll(host)
|
||||
// View-tab switch away and back: the view unmounts, then remounts.
|
||||
view.rerender(<div />)
|
||||
host.scrollTop = 0
|
||||
view.rerender(<h.ChatView {...h.props} />)
|
||||
expect(host.scrollTop).toBe(100)
|
||||
// The restored position is above the floor: follow stays disarmed.
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('a remount while pinned to the bottom keeps the bottom jump', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
document.body.appendChild(host)
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
// At the bottom: the scroll event records the pinned state (null).
|
||||
fireEvent.scroll(host)
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
view.rerender(<div />)
|
||||
host.scrollTop = 0
|
||||
view.rerender(<h.ChatView {...h.props} />)
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('paging button loads older and shows its busy label', () => {
|
||||
const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('selection survives on the store seat', () => {
|
||||
// ...and a re-created same-id session starts from a FRESH instance.
|
||||
const reborn = storeFor(b, 'conversation.session', sid('s1'))
|
||||
expect(reborn).not.toBe(doomed)
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-conne
|
||||
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts'
|
||||
import { terminalCardModel, terminalFailed } from '../src/client/contract/terminal-card-model.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
@@ -95,6 +95,19 @@ describe('terminalCardModel', () => {
|
||||
}))?.card.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('flags a failing exit as terminalFailed; clean exits and running cards are not', () => {
|
||||
// isError stays false on a failing command (the exit status is result
|
||||
// data), so this predicate is the row's only failure signal.
|
||||
expect(terminalFailed(terminalCardModel(settled({
|
||||
resultView: resultTerminal({ exitCode: 2 }),
|
||||
}))!)).toBe(true)
|
||||
expect(terminalFailed(terminalCardModel(settled({
|
||||
resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
|
||||
}))!)).toBe(true)
|
||||
expect(terminalFailed(terminalCardModel(settled())!)).toBe(false)
|
||||
expect(terminalFailed(terminalCardModel(running())!)).toBe(false)
|
||||
})
|
||||
|
||||
it('takes the result view\'s replacement title over the pending one', () => {
|
||||
// The presentation contract defines a result title as REPLACING the pending
|
||||
// title, so a tool that rewrites it at settle time must win here.
|
||||
@@ -229,36 +242,39 @@ describe('chat row terminal body', () => {
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), t,
|
||||
})
|
||||
|
||||
it('the expanded body is the command output, capped tighter than the panel', () => {
|
||||
expect(CHAT_TERMINAL_MAX_LINES).toBeLessThan(16)
|
||||
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
|
||||
const toggleRow = (view: { container: HTMLElement }) => {
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
}
|
||||
|
||||
it('the expanded body is the command output inside the row scroll container', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled())} />)
|
||||
// Collapsed: the one-line summary row only, no output.
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
toggleRow(view)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
// The args JSON body the generic path would have shown is gone.
|
||||
expect(view.queryByText(/"command"/)).toBeNull()
|
||||
})
|
||||
|
||||
it('the cap collapses a long output inside the row, expandable in place', () => {
|
||||
const lines = Array.from({ length: CHAT_TERMINAL_MAX_LINES + 3 }, (_, i) => `line-${i}`)
|
||||
it('a long output renders in full — the scroll container replaces the middle collapse', () => {
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `line-${i}`)
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
resultView: resultTerminal({ output: `${lines.join('\n')}\n` }),
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('… 其余 3 行')).toBeTruthy()
|
||||
expect(view.queryByText('line-5')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: '展开其余 3 行输出' }))
|
||||
toggleRow(view)
|
||||
expect(view.getByText('line-5')).toBeTruthy()
|
||||
expect(view.getByText('line-19')).toBeTruthy()
|
||||
expect(view.queryByText(/其余/)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a multi-line command as one prompt row per line', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: callTerminal({ title: 'ls -la\necho done' }),
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
toggleRow(view)
|
||||
const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
|
||||
expect([...rows].map(row => row.textContent)).toEqual(['$ls -la', '$echo done'])
|
||||
// Still one dot for the call, on the first row.
|
||||
@@ -283,14 +299,14 @@ describe('chat row terminal body', () => {
|
||||
callView: callTerminal({ description: 'Terminal 3' }),
|
||||
}))} />)
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
toggleRow(view)
|
||||
expect(view.container.querySelector('[data-terminal]')).not.toBeNull()
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running terminal call expands to the prompt line with no output yet', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(running())} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
toggleRow(view)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
expect(view.queryByText('复制')).toBeNull()
|
||||
// The card states its own run state: a running command reads as running
|
||||
@@ -302,7 +318,7 @@ describe('chat row terminal body', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: null, resultView: null,
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
toggleRow(view)
|
||||
expect(view.getByText(/"command"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -311,9 +327,16 @@ describe('chat row terminal body', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
call: { name: 'bash', argsRaw: '' },
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
toggleRow(view)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a failing exit status surfaces as the collapsed row\'s error state', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
resultView: resultTerminal({ exitCode: 2 }),
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[data-state]')?.getAttribute('data-state')).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BashRow terminal card', () => {
|
||||
@@ -330,14 +353,17 @@ describe('BashRow terminal card', () => {
|
||||
t,
|
||||
} as unknown as BashRowProps)
|
||||
|
||||
it('renders the command output under the summary row, without an expand gesture', () => {
|
||||
it('collapses to the summary row; the whole row toggles the command output', () => {
|
||||
const view = render(<BashRow {...rowProps(settled())} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
// The card's controls are the row's only interactions: a bash row is not a
|
||||
// path link and no longer a details-panel target, so nothing here navigates.
|
||||
expect(view.container.querySelector('[data-clickable]')).toBeNull()
|
||||
expect(view.getByText('复制')).toBeTruthy()
|
||||
// Collapse back in place: the summary row returns, the card unmounts.
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
})
|
||||
|
||||
// The row's leading StateDot and the card's run-state dot describe the same
|
||||
@@ -346,13 +372,22 @@ describe('BashRow terminal card', () => {
|
||||
it('agrees with the summary row about the run state', () => {
|
||||
const runningView = render(<BashRow {...rowProps(running())} />)
|
||||
expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running')
|
||||
fireEvent.click(runningView.container.querySelector('[data-expandable]')!)
|
||||
expect(runStateOf(runningView.container)).toBe('ongoing')
|
||||
cleanup()
|
||||
const settledView = render(<BashRow {...rowProps(settled())} />)
|
||||
expect(settledView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('ok')
|
||||
fireEvent.click(settledView.container.querySelector('[data-expandable]')!)
|
||||
expect(runStateOf(settledView.container)).toBe('done')
|
||||
})
|
||||
|
||||
it('a failing exit status surfaces as the collapsed row\'s error state', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
resultView: resultTerminal({ exitCode: 2 }),
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('error')
|
||||
})
|
||||
|
||||
it('shows the terminal presenter\'s description instead of the args summary', () => {
|
||||
// `terminal_send`-style presenters author a description the args do not
|
||||
// repeat; the contract puts it above the card, which is this row's summary.
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
.block {
|
||||
--dsl-terminal-radius: 12px;
|
||||
--dsl-terminal-line-height: 22px;
|
||||
/* Rebindable by consumers (CodeBlock's --dsl-code-block-content-font
|
||||
pattern): a surface wanting the smaller code size rebinds this together
|
||||
with --dsl-terminal-line-height on its own container. */
|
||||
--dsl-terminal-font: var(--dsw-font-markdown-code-block);
|
||||
/* The card's own left inset, holding the run-state dot in a column of its own
|
||||
so it never competes with the commands for horizontal space. */
|
||||
--dsl-terminal-gutter: 30px;
|
||||
@@ -22,26 +26,49 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
border-radius: var(--dsl-terminal-radius);
|
||||
/* Clip the banner to the card's own radius: when a consumer adds a border,
|
||||
the banner's equal corner radius no longer nests inside it and leaves a
|
||||
notch at the corner. Nothing inside renders out of the box. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Top-aligned: the status pill and copy control stay on the first prompt row
|
||||
however many command lines the card carries. */
|
||||
/* The status pill and copy control top-align to the FIRST prompt row (their
|
||||
heights are capped to the prompt line, so on a multi-line command they sit
|
||||
with the first command instead of floating mid-banner). */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
/* Pulled back across the card's gutter padding so the banner background and
|
||||
its top-left radius span the FULL surface, then re-inset by the same amount
|
||||
so the prompt text and the dot keep their positions. A plain block child
|
||||
only reaches the content box, which left the gutter column painted in the
|
||||
body color and drew the card's top-left corner in it — invisible in the
|
||||
light theme, where banner and body share a token, and visible in the dark
|
||||
one, where they do not. */
|
||||
/* Pulled back across the card's gutter padding so the banner spans the FULL
|
||||
surface, then re-inset by the same amount so the prompt text and the dot
|
||||
keep their positions. The banner shares the card's own surface (no banner
|
||||
token): the l2 divider below is the section boundary. */
|
||||
margin-left: calc(-1 * var(--dsl-terminal-gutter));
|
||||
padding: 9px 14px 9px var(--dsl-terminal-gutter);
|
||||
background: var(--dsw-alias-markdown-code-block-banner);
|
||||
border-top-left-radius: var(--dsl-terminal-radius);
|
||||
border-top-right-radius: var(--dsl-terminal-radius);
|
||||
/* A long multi-line command scrolls inside the banner (same cap as the
|
||||
IN/OUT card's sections) instead of pushing the output off screen. */
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Banner scrollbar floats off the card edge like the output's. */
|
||||
.header::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.header::-webkit-scrollbar-track {
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
/* Full-width l2 hairline between the command banner and the body — the same
|
||||
divider the IN/OUT card draws between its sections. A running card is
|
||||
banner-only, so it draws none. */
|
||||
.block:not([data-running]) .header {
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* One row per command line. The prompt column is the only element allowed to
|
||||
@@ -51,7 +78,7 @@
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
font: var(--dsl-terminal-font);
|
||||
}
|
||||
|
||||
.promptLine {
|
||||
@@ -100,27 +127,59 @@
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* Capped to the prompt's line height (Pill's own 24px height would exceed a
|
||||
smaller-font prompt row and stretch the banner). Sticky against the
|
||||
banner's own scroll so the pill and the copy control stay in reach while a
|
||||
long command scrolls underneath. */
|
||||
.status {
|
||||
flex: none;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: var(--dsl-terminal-line-height);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
flex: none;
|
||||
background-color: transparent;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
/* Card surface, not transparent: the control is sticky over the banner's
|
||||
own scroll, so scrolled command text must not bleed through it. */
|
||||
background-color: var(--dsw-alias-markdown-code-block);
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xs-13);
|
||||
line-height: var(--dsl-terminal-line-height);
|
||||
}
|
||||
|
||||
/* Vertical scrolling lives on the OUTPUT, not the card root: a root scroller
|
||||
would run its scrollbar over the banner (and the copy control), while here
|
||||
the banner stays pinned and the bar sits inside the output's right padding.
|
||||
Unset, the max-height is none and the auto overflow never engages. */
|
||||
.output {
|
||||
max-height: var(--dsl-terminal-output-max-height, none);
|
||||
padding: 12px 14px 12px 0;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
font: var(--dsl-terminal-font);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Both output scrollbars (vertical cap, horizontal pre overflow) float 2px
|
||||
off the card edge: a transparent border clips the thumb inward so it never
|
||||
hugs the rounded corner. */
|
||||
.output::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Track end-margins keep the thumb's travel out of the card's rounded
|
||||
corners in both directions. */
|
||||
.output::-webkit-scrollbar-track {
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
/* No wrapping, no word-break: alignment is the payload of terminal output. */
|
||||
@@ -147,6 +206,6 @@
|
||||
|
||||
.empty {
|
||||
padding: 12px 14px 12px 0;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
font: var(--dsl-terminal-font);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export interface TerminalBlockProps {
|
||||
signal?: string | undefined
|
||||
/** The command is still running: the block shows the prompt line alone. */
|
||||
running?: boolean | undefined
|
||||
/** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}). */
|
||||
/** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */
|
||||
maxLines?: number | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
|
||||
@@ -139,7 +139,11 @@
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* flex-start, not center: with a wrapped description the indicator must
|
||||
stay on the FIRST line (centering drifts it down the taller copy block).
|
||||
The 8px padding makes a single-line row 40px exactly, so nothing reads
|
||||
as top-heavy; .number/.checkbox re-center against the first line box. */
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
@@ -148,7 +152,7 @@
|
||||
intrinsic height, and centered content then paints outside the row box —
|
||||
over the title and the next row. Overflow belongs to .options. */
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px 6px 8px;
|
||||
padding: 8px 12px 8px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
@@ -180,6 +184,9 @@
|
||||
flex: 0 0 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
/* (24px first-line box − 20px indicator) / 2: centers the indicator against
|
||||
the first text line under the row's flex-start alignment. */
|
||||
margin-top: 2px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-overlay);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
@@ -197,6 +204,8 @@
|
||||
flex: 0 0 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
/* Same first-line centering as .number under flex-start alignment. */
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.checkbox::before {
|
||||
@@ -263,14 +272,16 @@
|
||||
inline text input; focus or a typed draft lifts it to the selected look. */
|
||||
.customRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Same first-line alignment as .option — the indicator seat carries the
|
||||
2px re-centering margin. */
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
/* Same reason as .option: the custom row is scroll content, and shrinking
|
||||
it pushes the inline input past the footer. */
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px 6px 8px;
|
||||
padding: 8px 12px 8px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
@@ -378,9 +389,7 @@
|
||||
|
||||
.option,
|
||||
.customRow {
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
|
||||
@@ -131,6 +131,14 @@ body {
|
||||
--dsw-font-markdown-code-block-font-size: 13px;
|
||||
--dsw-font-markdown-code-block-font-style: normal;
|
||||
|
||||
/* 手工补充(非插件导出):tool row 展开卡片内的小号 code 字体。 */
|
||||
--dsw-font-markdown-code-block-small: 12px/18px var(--ds-font-family-code);
|
||||
--dsw-font-markdown-code-block-small-font-family: var(--ds-font-family-code);
|
||||
--dsw-font-markdown-code-block-small-font-weight: 400;
|
||||
--dsw-font-markdown-code-block-small-line-height: 18px;
|
||||
--dsw-font-markdown-code-block-small-font-size: 12px;
|
||||
--dsw-font-markdown-code-block-small-font-style: normal;
|
||||
|
||||
--dsw-font-xl-24: 600 24px/32px var(--dsw-font-family);
|
||||
--dsw-font-xl-24-font-family: var(--dsw-font-family);
|
||||
--dsw-font-xl-24-font-weight: 600;
|
||||
|
||||
@@ -314,6 +314,10 @@ export interface TrajectoryTableProps {
|
||||
collapsedAssistants: ReadonlySet<number>
|
||||
/** Toggle tool calls under one assistant record. */
|
||||
onToggleAssistant: (index: number) => void
|
||||
/** One-shot cross-view inspect: open and scroll to this call's record. */
|
||||
inspectCallId?: string | null
|
||||
/** Acknowledge a consumed (or unresolvable) inspect request. */
|
||||
onInspectApplied?: (() => void) | undefined
|
||||
}
|
||||
|
||||
/** One request identity paired with its session-global number. */
|
||||
@@ -1497,6 +1501,8 @@ export function TrajectoryTable({
|
||||
onToggleTurn,
|
||||
collapsedAssistants,
|
||||
onToggleAssistant,
|
||||
inspectCallId = null,
|
||||
onInspectApplied,
|
||||
}: TrajectoryTableProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||
const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
|
||||
@@ -1678,8 +1684,37 @@ export function TrajectoryTable({
|
||||
if (target !== undefined) openRecordSummary(target)
|
||||
}
|
||||
|
||||
// Cross-view inspect handoff: resolve the requested call to its record,
|
||||
// open its summary, and remember the row to scroll once the un-collapsed
|
||||
// ledger has rendered. Not-found leaves the request pending (`turns` in the
|
||||
// deps retries as history pages in); the ack clears the store field.
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const pendingScrollIndex = useRef<number | null>(null)
|
||||
const openRecordSummaryRef = useRef(openRecordSummary)
|
||||
openRecordSummaryRef.current = openRecordSummary
|
||||
useEffect(() => {
|
||||
if (inspectCallId === null) return
|
||||
const target = flattenRecords(turns).find(record => record.cell.callId === inspectCallId)
|
||||
if (target === undefined) return
|
||||
openRecordSummaryRef.current(target)
|
||||
pendingScrollIndex.current = target.cell.index
|
||||
onInspectApplied?.()
|
||||
}, [inspectCallId, turns, onInspectApplied])
|
||||
useEffect(() => {
|
||||
const index = pendingScrollIndex.current
|
||||
if (index === null) return
|
||||
const row = rootRef.current
|
||||
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
|
||||
if (row === undefined || row === null) return
|
||||
pendingScrollIndex.current = null
|
||||
/* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */
|
||||
if (typeof row.scrollIntoView === 'function') {
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={css.split} style={splitStyle}>
|
||||
<div ref={rootRef} className={css.split} style={splitStyle}>
|
||||
<div
|
||||
className={css.tablePane}
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -134,7 +134,7 @@ function searchMatches(
|
||||
}
|
||||
|
||||
export function TrajectoryView({
|
||||
useHistory, loadAllHistory,
|
||||
useHistory, loadAllHistory, inspect, onInspectDone,
|
||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
|
||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const [collapsedAssistants, setCollapsedAssistants] =
|
||||
@@ -519,6 +519,8 @@ export function TrajectoryView({
|
||||
onToggleTurn={toggleTurn}
|
||||
collapsedAssistants={collapsedAssistants}
|
||||
onToggleAssistant={toggleAssistant}
|
||||
inspectCallId={inspect?.callId ?? null}
|
||||
onInspectApplied={onInspectDone}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -257,4 +257,50 @@ describe('TrajectoryTable', () => {
|
||||
expect(screen.getByRole('row', { name: /ASSISTANT/ })).toBeTruthy()
|
||||
expect(screen.getByRole('row', { name: /Collapsed turn summary/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
const CALL_TURNS: readonly TrajectoryTurnModel[] = [{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index: 1,
|
||||
kind: 'tool',
|
||||
text: 'bash · {"command":"pwd"}',
|
||||
inputDetail: '{"command":"pwd"}',
|
||||
callId: 'call-1',
|
||||
timeSeconds: 0.1,
|
||||
}],
|
||||
}],
|
||||
}]
|
||||
|
||||
it('an inspect target opens the matching record and acknowledges once', () => {
|
||||
const onInspectApplied = vi.fn()
|
||||
render(
|
||||
<TrajectoryTable
|
||||
turns={CALL_TURNS}
|
||||
{...FOLD_PROPS}
|
||||
inspectCallId="call-1"
|
||||
onInspectApplied={onInspectApplied}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('true')
|
||||
expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
|
||||
expect(onInspectApplied).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('an unmatched inspect target stays pending without acknowledgement', () => {
|
||||
const onInspectApplied = vi.fn()
|
||||
render(
|
||||
<TrajectoryTable
|
||||
turns={CALL_TURNS}
|
||||
{...FOLD_PROPS}
|
||||
inspectCallId="call-missing"
|
||||
onInspectApplied={onInspectApplied}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('false')
|
||||
expect(onInspectApplied).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,9 +44,7 @@ afterEach(cleanup)
|
||||
// The chat store persists under its declared key; clear so one case's active
|
||||
// view cannot rehydrate into the next.
|
||||
beforeEach(() => {
|
||||
// Node 22+ exposes an experimental localStorage global that is undefined
|
||||
// without --localstorage-file; only clear when a real Storage is present.
|
||||
if (typeof localStorage !== 'undefined') localStorage.clear()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
/** Node fixture: user prologue, two turns, one tool result inside turn 1. */
|
||||
|
||||
@@ -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: 860c24b8a25a1e9968261f586c16163579131a1c
|
||||
README.zh.md: 5a8e88051fc6f4fd46c5f2f6dcdc185eb4559ac6
|
||||
README.md: f71bfa09c795bd69e1f49c8f6dffffd5959dbe47
|
||||
README.zh.md: 80b53d85eb210b0e7a7ace1699d6bbfc9a836606
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow.
|
||||
Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and creation flow.
|
||||
|
||||
The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
|
||||
|
||||
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.
|
||||
|
||||
@@ -20,5 +22,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event.
|
||||
- **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.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。
|
||||
共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个表层使用同一套 Workspace 菜单和创建流程。
|
||||
|
||||
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
|
||||
|
||||
该选择器通过全局 `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` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。
|
||||
|
||||
@@ -20,5 +22,6 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。
|
||||
- **没有 Session 删除控件**:Session 菜单的 Delete 行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
|
||||
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。
|
||||
|
||||
@@ -217,6 +217,26 @@
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.list > [role='treeitem'] + [role='treeitem'] {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.searchTree > [role='treeitem'] + [role='treeitem'] {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.searchStatus,
|
||||
.searchWarning {
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.searchWarning {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* One workspace section: header row + expanded session run. Rows inside
|
||||
keep the former flat-list 4px gap as sibling margins; the inter-group
|
||||
breathing room (figma 133:7661 batch separator, 20px after an expanded
|
||||
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
Button, IconCloseFill14, IconPersonalizationOutline16,
|
||||
IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
SessionSearchResultItem, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspaceBrowserProps } from './contract/slots.ts'
|
||||
import type { SessionNode } from './tree.ts'
|
||||
import { deriveFlat, deriveGroups, UNGROUPED_KEY } from './tree.ts'
|
||||
import { ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx'
|
||||
import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts'
|
||||
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx'
|
||||
import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
|
||||
import css from './WorkspaceBrowser.module.css'
|
||||
|
||||
@@ -26,6 +28,21 @@ import css from './WorkspaceBrowser.module.css'
|
||||
* focus() forces a synchronous layout and would jank the slide.
|
||||
*/
|
||||
const EXPAND_SLIDE_MS = 300
|
||||
/** Pause between the latest keystroke and a Host content-search request. */
|
||||
const SEARCH_DEBOUNCE_MS = 250
|
||||
/** `session.search` wire bound, measured in JavaScript UTF-16 code units. */
|
||||
const SEARCH_QUERY_MAX_CODE_UNITS = 500
|
||||
|
||||
/** Keep controlled input and RPC payload inside the session.search wire contract. */
|
||||
function sanitizeSearchQuery(value: string): string {
|
||||
const withoutNul = value.replaceAll('\0', '')
|
||||
if (withoutNul.length <= SEARCH_QUERY_MAX_CODE_UNITS) return withoutNul
|
||||
let end = SEARCH_QUERY_MAX_CODE_UNITS
|
||||
const last = withoutNul.charCodeAt(end - 1)
|
||||
const next = withoutNul.charCodeAt(end)
|
||||
if (last >= 0xD800 && last <= 0xDBFF && next >= 0xDC00 && next <= 0xDFFF) end--
|
||||
return withoutNul.slice(0, end)
|
||||
}
|
||||
|
||||
/** Immutable membership toggle for the local expansion arrays. */
|
||||
function toggled(list: readonly string[], key: string): string[] {
|
||||
@@ -85,8 +102,6 @@ type SessionTreeProps = Pick<
|
||||
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't'
|
||||
> & {
|
||||
workspaces: readonly WorkspaceView[]
|
||||
/** Live search filter owned by the browser root (the query outlives the tree). */
|
||||
query: string
|
||||
/** Open the browser-owned rename dialog for a real Workspace group. */
|
||||
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
|
||||
@@ -97,7 +112,7 @@ type SessionTreeProps = Pick<
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({
|
||||
useSessions, startSession, open, forkSession, workspaces, query,
|
||||
useSessions, startSession, open, forkSession, workspaces,
|
||||
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore, t,
|
||||
}: SessionTreeProps) {
|
||||
const list = useSessions(s => s)
|
||||
@@ -114,8 +129,8 @@ function SessionTree({
|
||||
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
}, [current, currentGroup])
|
||||
const groups = useMemo(
|
||||
() => deriveGroups(list, workspaces, { expandedProjects, query }),
|
||||
[list, workspaces, expandedProjects, query],
|
||||
() => deriveGroups(list, workspaces, { expandedProjects }),
|
||||
[list, workspaces, expandedProjects],
|
||||
)
|
||||
const now = Date.now()
|
||||
|
||||
@@ -123,7 +138,7 @@ function SessionTree({
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
<div className={css.list} role="tree" aria-label={t('section.sessions')}>
|
||||
{groups.length === 0 && (
|
||||
<div className={css.empty}>{query === '' ? t('empty.none') : t('empty.noMatches')}</div>
|
||||
<div className={css.empty}>{t('empty.none')}</div>
|
||||
)}
|
||||
{groups.map(group => (
|
||||
// Group section: header row + expanded top-level session rows. The
|
||||
@@ -151,10 +166,10 @@ function SessionTree({
|
||||
}}
|
||||
/>
|
||||
{group.sessions.map((node, index) => {
|
||||
// Draggable: real-workspace session rows outside search. The drag
|
||||
// Draggable: real-workspace session rows. 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 === ''
|
||||
const draggable = group.workspaceId !== undefined
|
||||
const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
|
||||
const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
|
||||
start: () => {
|
||||
@@ -208,15 +223,15 @@ function SessionTree({
|
||||
}
|
||||
|
||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
||||
function FlatList({ useSessions, open, forkSession, onSessionRename, query, t }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'query' | 't'>) {
|
||||
function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 't'>) {
|
||||
const list = useSessions(s => s)
|
||||
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
|
||||
const rows = useMemo(() => deriveFlat(list), [list])
|
||||
const now = Date.now()
|
||||
return (
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
<div className={css.list} role="tree" aria-label={t('section.sessions')}>
|
||||
{rows.length === 0 && (
|
||||
<div className={css.empty}>{query === '' ? t('empty.none') : t('empty.noMatches')}</div>
|
||||
<div className={css.empty}>{t('empty.none')}</div>
|
||||
)}
|
||||
{rows.map(node => (
|
||||
<SessionNodeItem
|
||||
@@ -236,6 +251,74 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, query, t }:
|
||||
)
|
||||
}
|
||||
|
||||
interface RemoteSearchState {
|
||||
query: string
|
||||
status: 'idle' | 'loading' | 'ready' | 'error'
|
||||
items: readonly SessionSearchResultItem[]
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
/** Flat search body: local metadata matches plus the current Host result page. */
|
||||
function SearchResults({
|
||||
useSessions,
|
||||
open,
|
||||
workspaces,
|
||||
query,
|
||||
remote,
|
||||
resultLimit,
|
||||
t,
|
||||
}: Pick<SessionTreeProps, 'useSessions' | 'open' | 't'> & {
|
||||
workspaces: readonly WorkspaceView[]
|
||||
query: string
|
||||
remote: RemoteSearchState
|
||||
resultLimit: number
|
||||
}) {
|
||||
const list = useSessions(s => s)
|
||||
const currentRemote = remote.query === query
|
||||
? remote
|
||||
: { query, status: 'loading' as const, items: [], hasMore: false }
|
||||
const results = useMemo(
|
||||
() => deriveSearchResults(list, workspaces, query, currentRemote, resultLimit),
|
||||
[list, workspaces, query, currentRemote, resultLimit],
|
||||
)
|
||||
const pending = currentRemote.status === 'loading'
|
||||
const failed = currentRemote.status === 'error'
|
||||
|
||||
return (
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
<div className={css.list}>
|
||||
<div className={css.searchTree} role="tree" aria-label={t('search.results.aria')}>
|
||||
{results.items.map(result => (
|
||||
<SearchResultItem
|
||||
key={result.id}
|
||||
result={result}
|
||||
currentId={list.current}
|
||||
onOpen={open}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{pending && (
|
||||
<div className={css.searchStatus} role="status">{t('search.pending')}</div>
|
||||
)}
|
||||
{failed && (
|
||||
<div className={css.searchWarning} role="status">
|
||||
{t('search.unavailable')}
|
||||
</div>
|
||||
)}
|
||||
{!pending && results.items.length === 0 && (
|
||||
<div className={css.empty}>{t('search.noMatches')}</div>
|
||||
)}
|
||||
{results.hasMore && (
|
||||
<div className={css.searchStatus}>
|
||||
{t('search.hasMore', { n: resultLimit })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={css.fade} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the browsing region.
|
||||
* @param props - composed slot props (shell owner share + store + injected actions).
|
||||
@@ -256,6 +339,8 @@ export function WorkspaceBrowser({
|
||||
deleteWorkspace,
|
||||
insertSessionBefore,
|
||||
createWorkspace,
|
||||
searchSessions,
|
||||
searchResultLimit,
|
||||
useDirectoryFlow,
|
||||
renderSlot,
|
||||
t,
|
||||
@@ -265,6 +350,13 @@ export function WorkspaceBrowser({
|
||||
// The query outlives the tree and the input (both wide-only) so collapsing
|
||||
// does not silently drop an in-progress filter.
|
||||
const [query, setQuery] = useState('')
|
||||
const normalizedQuery = sanitizeSearchQuery(query).trim()
|
||||
const [remoteSearch, setRemoteSearch] = useState<RemoteSearchState>({
|
||||
query: '',
|
||||
status: 'idle',
|
||||
items: [],
|
||||
hasMore: false,
|
||||
})
|
||||
const searchInput = useRef<HTMLInputElement | null>(null)
|
||||
// Section-header + opens the picker menu (same popover in wide and rail
|
||||
// states; the menu anchors on this button).
|
||||
@@ -285,6 +377,43 @@ export function WorkspaceBrowser({
|
||||
}
|
||||
}, [wide, searchOnExpand])
|
||||
|
||||
useEffect(() => {
|
||||
if (normalizedQuery === '') {
|
||||
setRemoteSearch({ query: '', status: 'idle', items: [], hasMore: false })
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
setRemoteSearch({
|
||||
query: normalizedQuery,
|
||||
status: 'loading',
|
||||
items: [],
|
||||
hasMore: false,
|
||||
})
|
||||
const timer = window.setTimeout(() => {
|
||||
searchSessions(normalizedQuery, controller.signal).then((result) => {
|
||||
if (controller.signal.aborted) return
|
||||
setRemoteSearch({
|
||||
query: normalizedQuery,
|
||||
status: 'ready',
|
||||
items: result.items,
|
||||
hasMore: result.hasMore,
|
||||
})
|
||||
}).catch(() => {
|
||||
if (controller.signal.aborted) return
|
||||
setRemoteSearch({
|
||||
query: normalizedQuery,
|
||||
status: 'error',
|
||||
items: [],
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
controller.abort()
|
||||
}
|
||||
}, [normalizedQuery, searchSessions])
|
||||
|
||||
// Rename dialog (browser-owned so it outlives row unmounts during collapse).
|
||||
const [renameTarget, setRenameTarget] = useState<{ workspaceId: WorkspaceId; currentTitle: string } | null>(null)
|
||||
const [renameDraft, setRenameDraft] = useState('')
|
||||
@@ -442,8 +571,9 @@ export function WorkspaceBrowser({
|
||||
className={clsx(css.searchInput, css.wide)}
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
maxLength={SEARCH_QUERY_MAX_CODE_UNITS}
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value) }}
|
||||
onChange={(e) => { setQuery(sanitizeSearchQuery(e.target.value)) }}
|
||||
/>
|
||||
)}
|
||||
{wide && query !== '' && (
|
||||
@@ -461,35 +591,46 @@ export function WorkspaceBrowser({
|
||||
{/* Always-mounted seat keeps the region's flex slot while the list
|
||||
itself is wide-only. */}
|
||||
<div className={css.listArea}>
|
||||
{wide && (groupBy === 'flat'
|
||||
{wide && (normalizedQuery !== ''
|
||||
? (
|
||||
<FlatList
|
||||
useSessions={useSessions} open={open} forkSession={forkSession}
|
||||
onSessionRename={onSessionRename} query={query} t={t}
|
||||
<SearchResults
|
||||
useSessions={useSessions}
|
||||
open={open}
|
||||
workspaces={workspaces}
|
||||
query={normalizedQuery}
|
||||
remote={remoteSearch}
|
||||
resultLimit={searchResultLimit}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
onSessionRename={onSessionRename}
|
||||
forkSession={forkSession}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
query={query}
|
||||
insertSessionBefore={insertSessionBefore}
|
||||
t={t}
|
||||
onRenameRequest={(workspaceId, currentTitle) => {
|
||||
setRenameTarget({ workspaceId, currentTitle })
|
||||
setRenameDraft(currentTitle)
|
||||
setRenameError(null)
|
||||
}}
|
||||
onDeleteRequest={(workspaceId, title) => {
|
||||
setDeleteTarget({ workspaceId, title })
|
||||
setDeleteError(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
: groupBy === 'flat'
|
||||
? (
|
||||
<FlatList
|
||||
useSessions={useSessions} open={open} forkSession={forkSession}
|
||||
onSessionRename={onSessionRename} t={t}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
onSessionRename={onSessionRename}
|
||||
forkSession={forkSession}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
insertSessionBefore={insertSessionBefore}
|
||||
t={t}
|
||||
onRenameRequest={(workspaceId, currentTitle) => {
|
||||
setRenameTarget({ workspaceId, currentTitle })
|
||||
setRenameDraft(currentTitle)
|
||||
setRenameError(null)
|
||||
}}
|
||||
onDeleteRequest={(workspaceId, title) => {
|
||||
setDeleteTarget({ workspaceId, title })
|
||||
setDeleteError(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -24,7 +24,9 @@ import type { HostObservable, PropsLocale, PropsRenderSlots, PropsRuntime, Props
|
||||
// runtime shares below.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
SessionId, SessionSearchResultItem, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { createWorkspaceViewStore } from '../stores.ts'
|
||||
|
||||
/**
|
||||
@@ -93,6 +95,16 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
|
||||
startSession: (workspaceId?: WorkspaceId) => void
|
||||
/** Open a real Session. */
|
||||
open: (sessionId: SessionId) => void
|
||||
/**
|
||||
* Search current visible conversation messages. The Host fixes the result
|
||||
* bound; `hasMore` means the query needs narrowing.
|
||||
*/
|
||||
searchSessions: (
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
) => Promise<{ items: readonly SessionSearchResultItem[]; hasMore: boolean }>
|
||||
/** Maximum number of merged rows rendered for one search. */
|
||||
searchResultLimit: number
|
||||
/** 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. */
|
||||
|
||||
@@ -54,6 +54,12 @@ export const inject = ['slots', 'sessions', 'workspaces', 'locale']
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries')
|
||||
|
||||
const searchSessions: WorkspaceBrowserInjected['searchSessions'] = async (query, signal) => {
|
||||
const result = await ctx.sessions.search(query, signal)
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
return result.value
|
||||
}
|
||||
|
||||
// Stable per-surface occupancy sources (the renderer's hook cache keys by
|
||||
// source identity): true while the surface's directory-flow hole is filled.
|
||||
const flowSource = (hole: 'sidebar.workspaces.directoryFlow' | 'conversation.hero.workspace.directoryFlow'): HostObservable<boolean> => ({
|
||||
@@ -67,6 +73,8 @@ export function apply(ctx: ClientContext): void {
|
||||
// the runtime's shared action (recent-Workspace projection inside).
|
||||
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
|
||||
open: (sessionId) => { ctx.sessions.open(sessionId) },
|
||||
searchSessions,
|
||||
searchResultLimit: ctx.sessions.searchResultLimit,
|
||||
renameSession: async (sessionId, title) => {
|
||||
// Row → session-face hop: rename is a per-session verb (ISession), not
|
||||
// a list-service verb; the binding resolves any listed session.
|
||||
|
||||
@@ -19,6 +19,11 @@ export const zh = {
|
||||
'search.sessions.aria': '搜索会话',
|
||||
'search.placeholder': '搜索名称、关键词…',
|
||||
'search.clear': '清除搜索',
|
||||
'search.results.aria': '搜索结果',
|
||||
'search.pending': '正在搜索会话历史…',
|
||||
'search.unavailable': '内容搜索暂不可用,仅显示名称匹配。',
|
||||
'search.noMatches': '无匹配会话',
|
||||
'search.hasMore': '仅显示前 {n} 条结果,请缩小搜索范围。',
|
||||
'menu.openFolder': '打开本地文件夹…',
|
||||
'menu.createWorkspace': '新建工作区',
|
||||
'picker.loading': '正在加载工作区…',
|
||||
@@ -77,6 +82,11 @@ export const en = {
|
||||
'search.sessions.aria': 'Search sessions',
|
||||
'search.placeholder': 'Search name, keywords...',
|
||||
'search.clear': 'Clear search',
|
||||
'search.results.aria': 'Search results',
|
||||
'search.pending': 'Searching session history…',
|
||||
'search.unavailable': 'Content search is temporarily unavailable. Showing name matches.',
|
||||
'search.noMatches': 'No matching sessions',
|
||||
'search.hasMore': 'Showing the first {n} results. Narrow your search.',
|
||||
'menu.openFolder': 'Open local folder…',
|
||||
'menu.createWorkspace': 'Create a new workspace',
|
||||
'picker.loading': 'Loading workspaces…',
|
||||
|
||||
@@ -24,6 +24,64 @@
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.searchResultRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
min-height: 62px;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 7px 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.searchResultRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.searchResultRow.selected {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.searchResultHeading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.searchResultTitle {
|
||||
min-width: 0;
|
||||
margin-left: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.searchResultWorkspace,
|
||||
.searchResultSnippet {
|
||||
margin-left: 20px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.searchResultWorkspace {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.searchResultSnippet {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Two-line row: the leading slot (folder/chevron), title, and trailing
|
||||
actions all top-align on the 20px first text line (figma cell) — content
|
||||
is 42px (20 + 2 + 20), so 6px vertical padding centers the block. */
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WorkspaceBrowserProps } from '../contract/slots.ts'
|
||||
import type { GroupNode, SessionNode } from '../tree.ts'
|
||||
import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts'
|
||||
import { relativeTime } from '../tree.ts'
|
||||
import css from './Rows.module.css'
|
||||
|
||||
@@ -202,6 +202,41 @@ export interface RowDragProps {
|
||||
end: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* One flat search result: title, Workspace context, and optional content
|
||||
* excerpt. Search navigation opens the session only; it does not address an
|
||||
* event inside the conversation.
|
||||
* @param props.result - merged local/content search row.
|
||||
* @param props.currentId - selected session id.
|
||||
* @param props.onOpen - open the selected session.
|
||||
* @returns the result button.
|
||||
*/
|
||||
export function SearchResultItem({ result, currentId, onOpen }: {
|
||||
result: SearchResultNode
|
||||
currentId: string | undefined
|
||||
onOpen: (id: SearchResultNode['id']) => void
|
||||
}) {
|
||||
const selected = result.id === currentId
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.searchResultRow, selected && css.selected)}
|
||||
role="treeitem"
|
||||
aria-selected={selected}
|
||||
onClick={() => { onOpen(result.id) }}
|
||||
>
|
||||
<span className={css.searchResultHeading}>
|
||||
<span className={css.slot}>{result.running && <StateDot state="ongoing" />}</span>
|
||||
<span className={css.searchResultTitle}>{result.title}</span>
|
||||
</span>
|
||||
<span className={css.searchResultWorkspace}>{result.workspace}</span>
|
||||
{result.snippet !== undefined && (
|
||||
<span className={css.searchResultSnippet}>{result.snippet}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Pointer-position half of a row (insert line above or below). */
|
||||
function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* Unassigned Sessions trail under Ungrouped; only the selected blank Session
|
||||
* remains visible.
|
||||
*/
|
||||
import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSearchResultItem, SessionSummary, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Group key for Sessions outside every Workspace. */
|
||||
export const UNGROUPED_KEY = ''
|
||||
@@ -41,10 +43,24 @@ export interface GroupNode {
|
||||
sessions: readonly SessionNode[]
|
||||
}
|
||||
|
||||
/** One flat search row combining list metadata with an optional content match. */
|
||||
export interface SearchResultNode {
|
||||
id: SessionId
|
||||
title: string
|
||||
workspace: string
|
||||
running: boolean
|
||||
snippet?: string
|
||||
}
|
||||
|
||||
/** Bounded merged search projection plus the refine-query hint bit. */
|
||||
export interface SearchResultSet {
|
||||
items: readonly SearchResultNode[]
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
/** Viewing state consumed by the derivation. */
|
||||
export interface TreeView {
|
||||
expandedProjects: readonly string[]
|
||||
query: string
|
||||
}
|
||||
|
||||
interface Group {
|
||||
@@ -150,16 +166,13 @@ function sessionNode(s: SessionSummary): SessionNode {
|
||||
/**
|
||||
* Derive the workspace browser groups with every session as a top-level row.
|
||||
*
|
||||
* Normal mode: every group shows; sessions populate under expanded groups,
|
||||
* preserving Host account order. Search mode (non-blank query,
|
||||
* case-insensitive display-title substring): expansion state is ignored —
|
||||
* 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
|
||||
* everywhere; blank placeholders never match a search query.
|
||||
* Every group shows; sessions populate under expanded groups, preserving
|
||||
* Host account order. Blank sessions are excluded except for the selected
|
||||
* provisional New Session row. Content search lives outside this derivation
|
||||
* (see {@link deriveSearchResults}).
|
||||
* @param list - sessions list snapshot (`current` feeds containsCurrent).
|
||||
* @param workspaces - real workspaces in stable Host order.
|
||||
* @param view - local expansion arrays and search query.
|
||||
* @param view - local expansion arrays.
|
||||
* @returns group sections in render order.
|
||||
*/
|
||||
export function deriveGroups(
|
||||
@@ -167,7 +180,6 @@ export function deriveGroups(
|
||||
workspaces: readonly WorkspaceView[],
|
||||
view: TreeView,
|
||||
): GroupNode[] {
|
||||
const q = view.query.trim().toLowerCase()
|
||||
const expandedProjects = new Set(view.expandedProjects)
|
||||
const currentGroup = list.current === undefined
|
||||
? undefined
|
||||
@@ -175,34 +187,18 @@ export function deriveGroups(
|
||||
?? UNGROUPED_KEY
|
||||
const groups: GroupNode[] = []
|
||||
for (const g of groupByWorkspace(list, workspaces)) {
|
||||
if (q === '') {
|
||||
const expanded = expandedProjects.has(g.key)
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
createdAt: g.createdAt,
|
||||
label: g.label,
|
||||
sessionCount: g.sessions.length,
|
||||
expanded,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: expanded ? g.sessions.map(sessionNode) : [],
|
||||
})
|
||||
} else {
|
||||
const matches = g.sessions.filter(session => !session.blank && 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.sessions.length,
|
||||
expanded: matches.length > 0,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: matches.map(sessionNode),
|
||||
})
|
||||
}
|
||||
const expanded = expandedProjects.has(g.key)
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
createdAt: g.createdAt,
|
||||
label: g.label,
|
||||
sessionCount: g.sessions.length,
|
||||
expanded,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: expanded ? g.sessions.map(sessionNode) : [],
|
||||
})
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -210,19 +206,16 @@ 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. Search mode filters by case-insensitive
|
||||
* display-title substring.
|
||||
* no parent/child adjacency. Content search lives outside this derivation
|
||||
* (see {@link deriveSearchResults}).
|
||||
* @param list - sessions list snapshot.
|
||||
* @param view - the search query (expansion state does not apply).
|
||||
* @returns flat rows in render order.
|
||||
*/
|
||||
export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>): SessionNode[] {
|
||||
const q = view.query.trim().toLowerCase()
|
||||
export function deriveFlat(list: SessionListState): SessionNode[] {
|
||||
const rows: SessionSummary[] = []
|
||||
for (const id of list.ids) {
|
||||
const s = list.byId[id]
|
||||
if (s === undefined || !sessionVisible(s, list.current)) continue
|
||||
if (q !== '' && (s.blank || !sessionTitle(s).toLowerCase().includes(q))) continue
|
||||
rows.push(s)
|
||||
}
|
||||
rows.sort(byRecency)
|
||||
@@ -238,6 +231,83 @@ export interface RelativeTime {
|
||||
n: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge immediate title/Workspace substring matches with ranked Host content
|
||||
* matches. Local rows lead newest-first, content-only rows retain backend
|
||||
* order, and duplicate sessions receive the backend snippet in place.
|
||||
* @param list - session metadata authority.
|
||||
* @param workspaces - Workspace membership and display labels.
|
||||
* @param query - caller text; surrounding whitespace is ignored.
|
||||
* @param content - ranked Host content-search page.
|
||||
* @param limit - protocol-owned maximum merged row count.
|
||||
* @returns bounded deduplicated flat rows and a refine-query hint bit.
|
||||
*/
|
||||
export function deriveSearchResults(
|
||||
list: SessionListState,
|
||||
workspaces: readonly WorkspaceView[],
|
||||
query: string,
|
||||
content: { items: readonly SessionSearchResultItem[]; hasMore: boolean },
|
||||
limit: number,
|
||||
): SearchResultSet {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (q === '') return { items: [], hasMore: false }
|
||||
|
||||
const workspaceBySession = new Map<SessionId, string>()
|
||||
for (const workspace of workspaces) {
|
||||
for (const sessionId of workspace.sessionIds) {
|
||||
if (!workspaceBySession.has(sessionId)) workspaceBySession.set(sessionId, workspace.title)
|
||||
}
|
||||
}
|
||||
const labelOf = (summary: SessionSummary): string =>
|
||||
workspaceBySession.get(summary.id) ?? projectLabel(summary.cwd)
|
||||
const contentBySession = new Map<SessionId, SessionSearchResultItem>()
|
||||
for (const item of content.items) {
|
||||
if (!contentBySession.has(item.sessionId)) contentBySession.set(item.sessionId, item)
|
||||
}
|
||||
|
||||
const local: SessionSummary[] = []
|
||||
for (const id of list.ids) {
|
||||
const summary = list.byId[id]
|
||||
// Blank placeholders never match a query (their canonical title displays
|
||||
// localized, so matching it would tie search to one language).
|
||||
if (summary === undefined || summary.blank || !sessionVisible(summary, list.current)) continue
|
||||
if (
|
||||
sessionTitle(summary).toLowerCase().includes(q)
|
||||
|| labelOf(summary).toLowerCase().includes(q)
|
||||
) {
|
||||
local.push(summary)
|
||||
}
|
||||
}
|
||||
local.sort(byRecency)
|
||||
|
||||
const ordered: SessionSummary[] = []
|
||||
const included = new Set<SessionId>()
|
||||
const include = (summary: SessionSummary): void => {
|
||||
if (included.has(summary.id)) return
|
||||
included.add(summary.id)
|
||||
ordered.push(summary)
|
||||
}
|
||||
for (const summary of local) include(summary)
|
||||
for (const item of content.items) {
|
||||
const summary = list.byId[item.sessionId]
|
||||
if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current)) include(summary)
|
||||
}
|
||||
|
||||
return {
|
||||
items: ordered.slice(0, limit).map((summary) => {
|
||||
const match = contentBySession.get(summary.id)
|
||||
return {
|
||||
id: summary.id,
|
||||
title: sessionTitle(summary),
|
||||
workspace: labelOf(summary),
|
||||
running: summary.running,
|
||||
...match === undefined ? {} : { snippet: match.snippet },
|
||||
}
|
||||
}),
|
||||
hasMore: content.hasMore || ordered.length > limit,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact relative time for session rows, as a structured bucket the
|
||||
* renderer localizes ("now"/"5min"/"3h"/"2d"/"4mo"/"1y" in en).
|
||||
|
||||
@@ -20,18 +20,22 @@ async function bench() {
|
||||
const insertSessionBefore = vi.fn(async () => ({}))
|
||||
const open = vi.fn()
|
||||
const clear = vi.fn()
|
||||
const search = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false },
|
||||
}))
|
||||
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, binding, fork } as never)
|
||||
ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never)
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotsService, locale, create, startSession, rename,
|
||||
insertSessionBefore, open, clear, renameSession, binding, fork,
|
||||
insertSessionBefore, open, clear, search, renameSession, binding, fork,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +83,13 @@ describe('ui-workspace apply', () => {
|
||||
expect(b.startSession).toHaveBeenLastCalledWith(undefined)
|
||||
browser.open('session' as never)
|
||||
expect(b.open).toHaveBeenCalledWith('session')
|
||||
const signal = new AbortController().signal
|
||||
await expect(browser.searchSessions('match', signal)).resolves.toEqual({
|
||||
items: [{ sessionId: 'session', snippet: 'match' }],
|
||||
hasMore: false,
|
||||
})
|
||||
expect(b.search).toHaveBeenCalledWith('match', signal)
|
||||
expect(browser.searchResultLimit).toBe(20)
|
||||
await browser.renameSession('session' as never, 'renamed session')
|
||||
expect(b.binding).toHaveBeenCalledWith('session')
|
||||
expect(b.renameSession).toHaveBeenCalledWith('renamed session')
|
||||
@@ -124,6 +135,19 @@ describe('ui-workspace apply', () => {
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('rejects the browser search callback on a runtime business error', async () => {
|
||||
const b = await bench()
|
||||
b.search.mockImplementationOnce(async () => ({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'index unavailable', details: {} },
|
||||
}) as never)
|
||||
declare(b.slots, 'sidebar.workspaces')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
|
||||
await expect(browser.searchSessions('needle', new AbortController().signal))
|
||||
.rejects.toThrow('index unavailable')
|
||||
})
|
||||
|
||||
it('unregisters every entry on teardown', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace', 'conversation.empty.workspace')
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/cli
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { RowDragProps } from '../src/client/rows/Rows.tsx'
|
||||
import { ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
|
||||
import type { GroupNode, SessionNode } from '../src/client/tree.ts'
|
||||
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
|
||||
import type { GroupNode, SearchResultNode, SessionNode } from '../src/client/tree.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -44,6 +44,25 @@ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number):
|
||||
}
|
||||
|
||||
describe('workspace browser rows', () => {
|
||||
it('renders a selected content-search row and opens only its session', () => {
|
||||
const onOpen = vi.fn()
|
||||
const result: SearchResultNode = {
|
||||
id: sid('result'),
|
||||
title: 'Result title',
|
||||
workspace: 'Workspace context',
|
||||
running: true,
|
||||
snippet: 'matching message excerpt',
|
||||
}
|
||||
render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} />)
|
||||
const row = screen.getByRole('treeitem')
|
||||
expect(row.getAttribute('aria-selected')).toBe('true')
|
||||
expect(screen.getByText('Workspace context')).toBeTruthy()
|
||||
expect(screen.getByText('matching message excerpt')).toBeTruthy()
|
||||
expect(row.hasAttribute('draggable')).toBe(false)
|
||||
fireEvent.click(row)
|
||||
expect(onOpen).toHaveBeenCalledWith(result.id)
|
||||
})
|
||||
|
||||
it('renders an active Workspace and keeps its create action separate from toggling', () => {
|
||||
const onToggle = vi.fn()
|
||||
const onCreate = vi.fn()
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deriveFlat, deriveGroups, projectLabel, relativeTime, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
|
||||
import {
|
||||
deriveFlat, deriveGroups, deriveSearchResults, projectLabel, relativeTime,
|
||||
UNGROUPED_KEY, UNGROUPED_LABEL,
|
||||
} from '../src/client/tree.ts'
|
||||
import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
@@ -16,12 +19,12 @@ const list = (...items: SessionSummary[]): SessionListState => ({
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
const workspace = (id: string, sessionIds: string[]): WorkspaceView => ({
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title: id,
|
||||
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title,
|
||||
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, query,
|
||||
const view = (expandedProjects: readonly string[] = []) => ({
|
||||
expandedProjects,
|
||||
})
|
||||
|
||||
describe('deriveGroups', () => {
|
||||
@@ -64,25 +67,6 @@ describe('deriveGroups', () => {
|
||||
expect(strayGroups.map(group => group.key)).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('excludes blank sessions from search regardless of the query', () => {
|
||||
const currentBlank = { ...summary('opaque-current', 5), blank: true }
|
||||
const staleBlank = { ...summary('new session stale', 4), blank: true }
|
||||
const real = { ...summary('real', 3), displayTitle: 'new session notes' }
|
||||
const sessions = {
|
||||
...list(currentBlank, staleBlank, real),
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const groups = deriveGroups(
|
||||
sessions,
|
||||
[workspace('first', ['opaque-current', 'new session stale', 'real'])],
|
||||
view([], 'new session'),
|
||||
)
|
||||
// Only the real title hit matches; the current blank's placeholder title
|
||||
// never participates (it displays localized, so matching it would tie
|
||||
// search to one language).
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id])
|
||||
})
|
||||
|
||||
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 }
|
||||
@@ -96,7 +80,7 @@ describe('deriveGroups', () => {
|
||||
const groups = deriveGroups(
|
||||
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
|
||||
[],
|
||||
{ expandedProjects: [UNGROUPED_KEY], query: '' },
|
||||
{ expandedProjects: [UNGROUPED_KEY] },
|
||||
)
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
@@ -120,31 +104,6 @@ describe('deriveGroups', () => {
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
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 }
|
||||
const self = { ...summary('self', 4), displayTitle: 'Needle self', parentId: sid('self') }
|
||||
const orphan = { ...summary('orphan', 5), displayTitle: 'Needle orphan', parentId: sid('absent') }
|
||||
const cycleA = { ...summary('cycle-a', 6), displayTitle: 'Needle cycle A', parentId: sid('cycle-b') }
|
||||
const cycleB = { ...summary('cycle-b', 7), displayTitle: 'Needle cycle B', parentId: sid('cycle-a') }
|
||||
const sessions = list(root, match, sibling, self, orphan, cycleA, cycleB)
|
||||
const groups = deriveGroups(sessions, [workspace('project', sessions.ids)], view([], 'needle'))
|
||||
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([
|
||||
match.id, self.id, orphan.id, cycleA.id, cycleB.id,
|
||||
])
|
||||
|
||||
const labelOnly = deriveGroups(
|
||||
list(summary('hidden', 1)),
|
||||
[workspace('label-hit', ['hidden']), workspace('other', [])],
|
||||
view([], 'label'),
|
||||
)
|
||||
expect(labelOnly).toEqual([
|
||||
expect.objectContaining({ key: 'label-hit', expanded: false, sessions: [], sessionCount: 1 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
|
||||
const owned = summary('owned', 1)
|
||||
const loose = summary('loose', 2)
|
||||
@@ -162,19 +121,13 @@ describe('deriveFlat', () => {
|
||||
const child = { ...summary('child', 30), parentId: parent.id }
|
||||
const tieB = summary('tie-b', 20)
|
||||
const tieA = summary('tie-a', 20)
|
||||
const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' })
|
||||
const rows = deriveFlat(list(parent, child, tieB, tieA))
|
||||
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
|
||||
})
|
||||
|
||||
it('search filters by case-insensitive display-title substring', () => {
|
||||
const hit = { ...summary('hit', 2), displayTitle: 'Needle row' }
|
||||
const miss = { ...summary('miss', 1), displayTitle: 'Other' }
|
||||
expect(deriveFlat(list(hit, miss), { query: ' NEEDLE ' }).map(row => row.id)).toEqual([sid('hit')])
|
||||
})
|
||||
|
||||
it('tolerates ids whose summary has not landed yet', () => {
|
||||
const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
|
||||
expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')])
|
||||
expect(deriveFlat(partial).map(row => row.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('shows only the current blank session and excludes blanks from search', () => {
|
||||
@@ -184,14 +137,120 @@ describe('deriveFlat', () => {
|
||||
...list(summary('real', 1), currentBlank, staleBlank),
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const rows = deriveFlat(sessions, { query: '' })
|
||||
const rows = deriveFlat(sessions)
|
||||
expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
|
||||
expect(rows.map(row => row.title)).toEqual(['New Session', 'real'])
|
||||
expect(rows.map(row => row.blank)).toEqual([true, false])
|
||||
// Blank rows never match a query — not their placeholder title, not their id.
|
||||
expect(deriveFlat(sessions, { query: 'new session' })).toEqual([])
|
||||
expect(deriveFlat(sessions, { query: 'current-blank' })).toEqual([])
|
||||
expect(deriveFlat(sessions, { query: 'stale-blank' })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSearchResults', () => {
|
||||
it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => {
|
||||
const titleHit = summary('title-hit', 30, '/projects/a')
|
||||
titleHit.displayTitle = 'Needle title'
|
||||
const workspaceHit = summary('workspace-hit', 20, '/projects/b')
|
||||
workspaceHit.displayTitle = 'Ordinary title'
|
||||
const contentHit = summary('content-hit', 10, '/projects/c')
|
||||
const sessions = list(titleHit, workspaceHit, contentHit)
|
||||
const result = deriveSearchResults(
|
||||
sessions,
|
||||
[
|
||||
workspace('a', ['title-hit'], 'Alpha'),
|
||||
workspace('b', ['workspace-hit'], 'Needle Workspace'),
|
||||
workspace('duplicate-owner', ['title-hit'], 'Ignored duplicate owner'),
|
||||
],
|
||||
' NEEDLE ',
|
||||
{
|
||||
items: [
|
||||
{ sessionId: contentHit.id, snippet: 'body needle excerpt' },
|
||||
{ sessionId: contentHit.id, snippet: 'ignored duplicate excerpt' },
|
||||
{ sessionId: titleHit.id, snippet: 'title session body excerpt' },
|
||||
{ sessionId: sid('unknown'), snippet: 'not in session.list' },
|
||||
],
|
||||
hasMore: false,
|
||||
},
|
||||
10,
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: titleHit.id,
|
||||
title: 'Needle title',
|
||||
workspace: 'Alpha',
|
||||
running: false,
|
||||
snippet: 'title session body excerpt',
|
||||
},
|
||||
{
|
||||
id: workspaceHit.id,
|
||||
title: 'Ordinary title',
|
||||
workspace: 'Needle Workspace',
|
||||
running: false,
|
||||
},
|
||||
{
|
||||
id: contentHit.id,
|
||||
title: 'content-hit',
|
||||
workspace: 'c',
|
||||
running: false,
|
||||
snippet: 'body needle excerpt',
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('excludes blank sessions from search regardless of query or content hits', () => {
|
||||
const currentBlank = { ...summary('opaque-current', 5), blank: true }
|
||||
const staleBlank = { ...summary('new session stale', 4), blank: true }
|
||||
const sessions = {
|
||||
...list(currentBlank, staleBlank),
|
||||
current: currentBlank.id,
|
||||
}
|
||||
// Blank placeholders never match — not their localized-display title, not
|
||||
// their id, and not even a backend content hit naming them.
|
||||
const result = deriveSearchResults(
|
||||
sessions,
|
||||
[workspace('first', ['opaque-current', 'new session stale'])],
|
||||
'new session',
|
||||
{
|
||||
items: [
|
||||
{ sessionId: staleBlank.id, snippet: 'stale body' },
|
||||
{ sessionId: currentBlank.id, snippet: 'current body' },
|
||||
],
|
||||
hasMore: false,
|
||||
},
|
||||
10,
|
||||
)
|
||||
expect(result.items).toEqual([])
|
||||
})
|
||||
|
||||
it('uses the supplied cap and preserves either local overflow or backend hasMore', () => {
|
||||
const rows = Array.from({ length: 5 }, (_, index) => {
|
||||
const item = summary(`s-${String(index).padStart(2, '0')}`, index)
|
||||
item.displayTitle = `Needle ${String(index)}`
|
||||
return item
|
||||
})
|
||||
const overflow = deriveSearchResults(
|
||||
list(...rows),
|
||||
[],
|
||||
'needle',
|
||||
{ items: [], hasMore: false },
|
||||
3,
|
||||
)
|
||||
expect(overflow.items).toHaveLength(3)
|
||||
expect(overflow.hasMore).toBe(true)
|
||||
|
||||
const backendMore = deriveSearchResults(
|
||||
list(summary('body', 1)),
|
||||
[],
|
||||
'needle',
|
||||
{ items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true },
|
||||
3,
|
||||
)
|
||||
expect(backendMore.items).toHaveLength(1)
|
||||
expect(backendMore.hasMore).toBe(true)
|
||||
expect(deriveSearchResults(list(), [], ' ', { items: [], hasMore: true }, 3))
|
||||
.toEqual({ items: [], hasMore: false })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
actions: store.actions,
|
||||
startSession: vi.fn(),
|
||||
open: vi.fn(),
|
||||
searchSessions: vi.fn(async () => ({ items: [], hasMore: false })),
|
||||
searchResultLimit: 20,
|
||||
renameSession: vi.fn(async () => {}),
|
||||
forkSession: vi.fn(),
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
@@ -219,38 +221,215 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(screen.queryByText('新会话')).toBeNull()
|
||||
})
|
||||
|
||||
it('searches across groups, clears via the clear button, and shows the empty states', () => {
|
||||
const sessions = sessionState([
|
||||
summary('needle-row', 2, { displayTitle: 'Needle row' }),
|
||||
summary('other-row', 1, { displayTitle: 'Other row' }),
|
||||
])
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
|
||||
})
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
|
||||
fireEvent.change(input, { target: { value: 'needle' } })
|
||||
// Search forces matches visible without expansion state.
|
||||
expect(screen.getByText('Needle row')).toBeTruthy()
|
||||
expect(screen.queryByText('Other row')).toBeNull()
|
||||
fireEvent.change(input, { target: { value: 'zzz' } })
|
||||
expect(screen.getByText('无匹配结果')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
|
||||
expect(input.value).toBe('')
|
||||
// Clicking the field row focuses the input (wide mode).
|
||||
fireEvent.click(input.parentElement as HTMLElement)
|
||||
expect(document.activeElement).toBe(input)
|
||||
it('shows local metadata matches immediately, then clears back to the grouped tree', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const sessions = sessionState([
|
||||
summary('needle-row', 2, { displayTitle: 'Needle row' }),
|
||||
summary('other-row', 1, { displayTitle: 'Other row' }),
|
||||
])
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
|
||||
})
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
|
||||
fireEvent.change(input, { target: { value: 'needle' } })
|
||||
const resultTree = screen.getByRole('tree', { name: '搜索结果' })
|
||||
expect(screen.getByText('Needle row')).toBeTruthy()
|
||||
expect(screen.queryByText('Other row')).toBeNull()
|
||||
const status = screen.getByRole('status')
|
||||
expect(status.textContent).toBe('正在搜索会话历史…')
|
||||
expect(resultTree.contains(status)).toBe(false)
|
||||
|
||||
fireEvent.change(input, { target: { value: 'zzz' } })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
expect(screen.getByText('无匹配会话')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
|
||||
expect(input.value).toBe('')
|
||||
expect(screen.getByRole('tree', { name: '会话' })).toBeTruthy()
|
||||
// Clicking the field row focuses the input (wide mode).
|
||||
fireEvent.click(input.parentElement as HTMLElement)
|
||||
expect(document.activeElement).toBe(input)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the no-sessions empty state in both modes', () => {
|
||||
const b = mount()
|
||||
expect(screen.getByText('暂无会话')).toBeTruthy()
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getByText('暂无会话')).toBeTruthy()
|
||||
// Flat search misses show No matches.
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'x' } })
|
||||
expect(screen.getByText('无匹配结果')).toBeTruthy()
|
||||
it('adds Host content hits with context, shows the result bound, and opens without clearing the query', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const open = vi.fn()
|
||||
const searchSessions = vi.fn(async () => ({
|
||||
items: [{ sessionId: sid('body-hit'), snippet: '…the waterfall token appears here…' }],
|
||||
hasMore: true,
|
||||
}))
|
||||
mount({
|
||||
useSessions: hook(sessionState([
|
||||
summary('body-hit', 1, { displayTitle: 'Research notes' }),
|
||||
])),
|
||||
useWorkspaces: hook(workspaceState([
|
||||
workspace('research', ['body-hit'], 'Research Workspace'),
|
||||
])),
|
||||
open,
|
||||
searchSessions,
|
||||
})
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
|
||||
fireEvent.change(input, { target: { value: 'waterfall token' } })
|
||||
expect(screen.getByText('正在搜索会话历史…')).toBeTruthy()
|
||||
expect(screen.queryByText('Research notes')).toBeNull()
|
||||
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
|
||||
expect(searchSessions).toHaveBeenCalledWith('waterfall token', expect.any(AbortSignal))
|
||||
expect(screen.getByText('Research notes')).toBeTruthy()
|
||||
expect(screen.getByText('Research Workspace')).toBeTruthy()
|
||||
expect(screen.getByText('…the waterfall token appears here…')).toBeTruthy()
|
||||
expect(screen.getByText('仅显示前 20 条结果,请缩小搜索范围。')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('treeitem'))
|
||||
expect(open).toHaveBeenCalledWith(sid('body-hit'))
|
||||
expect(input.value).toBe('waterfall token')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds programmatic search input to a schema-valid request without splitting an astral character', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const searchSessions = vi.fn(async () => ({ items: [], hasMore: false }))
|
||||
mount({ searchSessions })
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
|
||||
expect(input.maxLength).toBe(500)
|
||||
fireEvent.change(input, { target: { value: 'y'.repeat(501) } })
|
||||
expect(input.value).toBe('y'.repeat(500))
|
||||
const expected = `prefix${'x'.repeat(493)}`
|
||||
fireEvent.change(input, {
|
||||
target: { value: `prefix\0${'x'.repeat(493)}😀tail` },
|
||||
})
|
||||
|
||||
expect(input.value).toBe(expected)
|
||||
expect(input.value.length).toBe(499)
|
||||
expect(input.value).not.toContain('\0')
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
expect(searchSessions).toHaveBeenCalledOnce()
|
||||
expect(searchSessions).toHaveBeenCalledWith(expected, expect.any(AbortSignal))
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps local matches and shows a lightweight warning when Host search fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const searchSessions = vi.fn(async () => { throw new Error('index unavailable') })
|
||||
mount({
|
||||
useSessions: hook(sessionState([
|
||||
summary('local-hit', 1, { displayTitle: 'Needle title' }),
|
||||
])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['local-hit'])])),
|
||||
searchSessions,
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), {
|
||||
target: { value: 'needle' },
|
||||
})
|
||||
expect(screen.getByText('Needle title')).toBeTruthy()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
expect(screen.getByText('Needle title')).toBeTruthy()
|
||||
expect(screen.getByText('内容搜索暂不可用,仅显示名称匹配。')).toBeTruthy()
|
||||
expect(screen.queryByText('无匹配会话')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('aborts a superseded request and ignores its stale result', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let resolveFirst!: (value: {
|
||||
items: { sessionId: SessionId; snippet: string }[]
|
||||
hasMore: boolean
|
||||
}) => void
|
||||
const first = new Promise<{
|
||||
items: { sessionId: SessionId; snippet: string }[]
|
||||
hasMore: boolean
|
||||
}>((resolve) => { resolveFirst = resolve })
|
||||
const searchSessions = vi.fn((query: string, _signal: AbortSignal) => query === 'first'
|
||||
? first
|
||||
: Promise.resolve({
|
||||
items: [{ sessionId: sid('second-hit'), snippet: 'second excerpt' }],
|
||||
hasMore: false,
|
||||
}))
|
||||
mount({
|
||||
useSessions: hook(sessionState([
|
||||
summary('first-hit', 2, { displayTitle: 'Old result' }),
|
||||
summary('second-hit', 1, { displayTitle: 'Fresh result' }),
|
||||
])),
|
||||
searchSessions,
|
||||
})
|
||||
const input = screen.getByPlaceholderText('搜索名称、关键词…')
|
||||
fireEvent.change(input, { target: { value: 'first' } })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
const firstSignal = searchSessions.mock.calls[0]?.[1] as AbortSignal
|
||||
expect(firstSignal.aborted).toBe(false)
|
||||
|
||||
fireEvent.change(input, { target: { value: 'second' } })
|
||||
expect(firstSignal.aborted).toBe(true)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
expect(screen.getByText('Fresh result')).toBeTruthy()
|
||||
|
||||
await act(async () => {
|
||||
resolveFirst({
|
||||
items: [{ sessionId: sid('first-hit'), snippet: 'stale excerpt' }],
|
||||
hasMore: false,
|
||||
})
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.queryByText('Old result')).toBeNull()
|
||||
expect(screen.getByText('Fresh result')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores a rejected request after it has been superseded', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let rejectFirst!: (reason: Error) => void
|
||||
const first = new Promise<never>((_resolve, reject) => { rejectFirst = reject })
|
||||
const searchSessions = vi.fn((query: string) => query === 'first'
|
||||
? first
|
||||
: Promise.resolve({ items: [], hasMore: false }))
|
||||
mount({ searchSessions })
|
||||
const input = screen.getByPlaceholderText('搜索名称、关键词…')
|
||||
fireEvent.change(input, { target: { value: 'first' } })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
|
||||
fireEvent.change(input, { target: { value: 'second' } })
|
||||
await act(async () => {
|
||||
rejectFirst(new Error('stale failure'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.queryByText('内容搜索暂不可用,仅显示名称匹配。')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the no-sessions empty state in both modes and resolves an empty search', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const b = mount()
|
||||
expect(screen.getByText('暂无会话')).toBeTruthy()
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getByText('暂无会话')).toBeTruthy()
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'x' } })
|
||||
expect(screen.getByText('正在搜索会话历史…')).toBeTruthy()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
expect(screen.getByText('无匹配会话')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rail state renders icon controls that request expansion', () => {
|
||||
@@ -551,6 +730,6 @@ describe('WorkspaceBrowser', () => {
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'needle' } })
|
||||
const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement
|
||||
expect(row.getAttribute('draggable')).toBe('false')
|
||||
expect(row.hasAttribute('draggable')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user