feat(web): make produced-file overflow discoverable
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: 85ff46052ba2f032ee6a95b16c396d45e766d3ba
|
||||
README.zh.md: 89cbb19a984d88e09b7af0890f57ecd15d46d3a5
|
||||
README.md: a82bb55ab65df2732ad16248d2cc9aa15b60e94d
|
||||
README.zh.md: d4c19db8e74e3562ec55b5ef60675aef79b82008
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + observable generation-scoped `hostDescription` + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. Each successful readiness handshake publishes the exact `host.describe` value before `onConnected`; generation loss and explicit stop clear it, so native-capability consumers never retain a disconnected answer. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 可观察且按 generation 生效的 `hostDescription` + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。每次就绪握手成功后,都会在 `onConnected` 之前发布完整的 `host.describe` 值;generation 失效或显式 stop 会清空它,因此原生能力消费者不会保留已经断线的判断。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
|
||||
import type { HostDescription, IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
|
||||
|
||||
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the
|
||||
* future `ctx.connection` plugin's Config). All fields optional; defaults below. */
|
||||
@@ -45,7 +45,7 @@ export interface ConnectionSinks {
|
||||
onMuxEnvelope?: (envelope: RpcRequest<MuxFrame>) => void
|
||||
onHostEnvelope?: (envelope: RpcRequest<HostFrame>) => void
|
||||
/** After each connection generation is established (both streams open + describe succeeded), first connect included. */
|
||||
onConnected?: () => void
|
||||
onConnected?: (description: HostDescription) => void
|
||||
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
|
||||
* span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */
|
||||
onStateChange?: (state: ConnectionState) => void
|
||||
@@ -99,6 +99,11 @@ export class ConnectionController {
|
||||
return this.running
|
||||
}
|
||||
|
||||
/** Re-read both mutable liveness guards after a potentially reentrant sink. */
|
||||
private isGenerationActive(controller: AbortController): boolean {
|
||||
return this.isRunning() && !controller.signal.aborted
|
||||
}
|
||||
|
||||
private async loop(): Promise<void> {
|
||||
while (this.running) {
|
||||
const gen = ++this.generation
|
||||
@@ -143,7 +148,11 @@ export class ConnectionController {
|
||||
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
|
||||
this.attempt = 0
|
||||
this.emitState('connected')
|
||||
this.callSink(this.sinks.onConnected)
|
||||
// A state sink may synchronously stop this controller. Do not publish
|
||||
// a description for a generation that no longer exists afterward.
|
||||
if (this.isGenerationActive(ac)) {
|
||||
this.callSink(() => { this.sinks.onConnected?.(descriptionResult.value) })
|
||||
}
|
||||
} catch {
|
||||
// Transport failure: treat as generation failure, fall through to the shared backoff.
|
||||
if (!ac.signal.aborted) ac.abort()
|
||||
|
||||
@@ -2305,7 +2305,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
interrupt: request => Promise.resolve(ok(request, { accepted: true as const })),
|
||||
},
|
||||
host: {
|
||||
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
|
||||
describe: request => ok(request, {
|
||||
version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, canOpenPath: true,
|
||||
}),
|
||||
// Deterministic native pick: the keyless lanes drive the full
|
||||
// pick-then-adopt path without an OS chooser (design-mock content,
|
||||
// same tree the browse primitives serve).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* controller with its sinks.
|
||||
*/
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { IApiClient } from './api.ts'
|
||||
import type { HostDescription, IApiClient } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
import { WebApiClient } from './web-api-client.ts'
|
||||
@@ -41,6 +41,13 @@ export {
|
||||
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
|
||||
export type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
/** Observable Host description published by each completed connection handshake. */
|
||||
export interface HostDescriptionSource {
|
||||
/** Latest connected-generation description; absent before connect and while reconnecting. */
|
||||
getSnapshot(): HostDescription | undefined
|
||||
/** Subscribe to description replacement and connection loss. */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
|
||||
/** Required services (none — this is the wire root). */
|
||||
export const inject: string[] = []
|
||||
@@ -55,6 +62,8 @@ export interface ConnectionHandle {
|
||||
readonly api: IApiClient
|
||||
/** Whether the current page authority is loopback; non-browser contexts default to true. */
|
||||
readonly isLoopback: boolean
|
||||
/** Generation-scoped Host facts, including native path-open capability. */
|
||||
readonly hostDescription: HostDescriptionSource
|
||||
/** Generic logical RPC channels over the same Connection transport. */
|
||||
readonly rpc: ClientConnectionRpc
|
||||
/**
|
||||
@@ -79,16 +88,56 @@ export function apply(ctx: Context): void {
|
||||
const api: IApiClient = fixtureClient ?? new WebApiClient()
|
||||
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc()
|
||||
let started = false
|
||||
let description: HostDescription | undefined
|
||||
const descriptionListeners = new Set<() => void>()
|
||||
const publishDescription = (next: HostDescription | undefined): void => {
|
||||
if (Object.is(description, next)) return
|
||||
description = next
|
||||
for (const listener of [...descriptionListeners]) {
|
||||
try {
|
||||
listener()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] host-description listener threw:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
|
||||
hostDescription: {
|
||||
getSnapshot: () => description,
|
||||
subscribe: (listener) => {
|
||||
descriptionListeners.add(listener)
|
||||
return () => { descriptionListeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
rpc,
|
||||
start(sinks, config) {
|
||||
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
|
||||
started = true
|
||||
const controller = new ConnectionController(api, sinks, config ?? {})
|
||||
const controller = new ConnectionController(api, {
|
||||
...sinks,
|
||||
onConnected: (next) => {
|
||||
publishDescription(next)
|
||||
// A description subscriber may synchronously stop the loop. In that
|
||||
// case publishDescription(undefined) has already retracted this
|
||||
// generation, so do not leak its stale connected notification to
|
||||
// the consumer sink afterward.
|
||||
if (!Object.is(description, next)) return
|
||||
sinks.onConnected?.(next)
|
||||
},
|
||||
onStateChange: (state) => {
|
||||
if (state === 'reconnecting') publishDescription(undefined)
|
||||
sinks.onStateChange?.(state)
|
||||
},
|
||||
}, config ?? {})
|
||||
controller.start()
|
||||
return { stop: () => { controller.stop() } }
|
||||
return {
|
||||
stop: () => {
|
||||
controller.stop()
|
||||
publishDescription(undefined)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
ctx.provide('connection', handle)
|
||||
|
||||
@@ -87,10 +87,68 @@ describe('connection client apply', () => {
|
||||
it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
|
||||
const handle = await mount()
|
||||
const descriptions: Array<boolean | undefined> = []
|
||||
const stopDescription = handle.hostDescription.subscribe(() => {
|
||||
descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath)
|
||||
})
|
||||
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
|
||||
// config omitted: the `config ?? {}` default arm is part of the surface.
|
||||
const loop = handle.start({})
|
||||
expect(() => handle.start({})).toThrow(/already owned by another consumer/)
|
||||
await vi.waitFor(() => {
|
||||
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
|
||||
})
|
||||
loop.stop() // teardown must not throw; the fixture streams abort quietly
|
||||
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
|
||||
expect(descriptions).toEqual([true, undefined])
|
||||
stopDescription()
|
||||
})
|
||||
|
||||
it('isolates description subscribers so later listeners and the consumer sink still run', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
|
||||
const handle = await mount()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const seen: boolean[] = []
|
||||
const stopThrowing = handle.hostDescription.subscribe(() => { throw new Error('subscriber bug') })
|
||||
const stopRecording = handle.hostDescription.subscribe(() => {
|
||||
const value = handle.hostDescription.getSnapshot()?.canOpenPath
|
||||
if (value !== undefined) seen.push(value)
|
||||
})
|
||||
let connected = 0
|
||||
const loop = handle.start({ onConnected: () => { connected++ } })
|
||||
try {
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
expect(seen).toEqual([true])
|
||||
expect(errorSpy).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
stopThrowing()
|
||||
stopRecording()
|
||||
loop.stop()
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not announce a generation synchronously stopped by a description subscriber', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
|
||||
const handle = await mount()
|
||||
const owner: { loop?: ReturnType<ConnectionHandle['start']> } = {}
|
||||
let sawDescription = false
|
||||
const stopDescription = handle.hostDescription.subscribe(() => {
|
||||
if (handle.hostDescription.getSnapshot() === undefined) return
|
||||
sawDescription = true
|
||||
owner.loop?.stop()
|
||||
})
|
||||
const connected = vi.fn()
|
||||
const loop = handle.start({ onConnected: connected })
|
||||
owner.loop = loop
|
||||
try {
|
||||
await vi.waitFor(() => { expect(sawDescription).toBe(true) })
|
||||
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
|
||||
expect(connected).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
stopDescription()
|
||||
loop.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => {
|
||||
|
||||
@@ -23,10 +23,14 @@ describe('connection lifecycle', () => {
|
||||
it('announces connected after describe + both streams open, then pumps frames to sinks', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const muxSeen: string[] = []
|
||||
const descriptions: boolean[] = []
|
||||
let connected = 0
|
||||
const controller = new ConnectionController(api, {
|
||||
onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
|
||||
onConnected: () => { connected++ },
|
||||
onConnected: (description) => {
|
||||
connected++
|
||||
descriptions.push(description.canOpenPath)
|
||||
},
|
||||
}, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
@@ -34,6 +38,7 @@ describe('connection lifecycle', () => {
|
||||
api.pushMux(subscribedFrame())
|
||||
await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) })
|
||||
expect(api.callsOf('host.describe')).toHaveLength(1)
|
||||
expect(descriptions).toEqual([true])
|
||||
} finally {
|
||||
controller.stop()
|
||||
}
|
||||
@@ -75,7 +80,7 @@ describe('connection lifecycle', () => {
|
||||
try {
|
||||
await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
|
||||
expect(connected).toBe(0) // never announced during the failed generation
|
||||
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
|
||||
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
} finally {
|
||||
controller.stop()
|
||||
@@ -97,7 +102,7 @@ describe('connection lifecycle', () => {
|
||||
},
|
||||
})
|
||||
}
|
||||
return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
|
||||
return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
|
||||
}
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
@@ -210,6 +215,24 @@ describe('connection lifecycle', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not announce a generation stopped synchronously by its connected state sink', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const states: ConnectionState[] = []
|
||||
let connected = 0
|
||||
const controller = new ConnectionController(api, {
|
||||
onConnected: () => { connected++ },
|
||||
onStateChange: (state) => {
|
||||
states.push(state)
|
||||
if (state === 'connected') controller.stop()
|
||||
},
|
||||
}, FAST)
|
||||
|
||||
controller.start()
|
||||
await vi.waitFor(() => { expect(states).toEqual(['connected']) })
|
||||
await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) })
|
||||
expect(connected).toBe(0)
|
||||
})
|
||||
|
||||
it('deduplicates consecutive reconnecting emissions across two straight failures', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
|
||||
@@ -228,7 +251,7 @@ describe('connection lifecycle', () => {
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(describeCalls).toBe(3) })
|
||||
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
|
||||
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
|
||||
} finally {
|
||||
|
||||
@@ -71,8 +71,15 @@ export class FakeApiClient implements IApiClient {
|
||||
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
|
||||
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{
|
||||
version: string
|
||||
cwd: string
|
||||
attachedSessions: number
|
||||
canOpenPath: boolean
|
||||
}>> =
|
||||
() => Promise.resolve(ok({
|
||||
version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true,
|
||||
}))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
() => Promise.resolve(ok({ path: null }))
|
||||
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
|
||||
|
||||
@@ -31,6 +31,10 @@ async function mount(): Promise<Bench> {
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
hostDescription: {
|
||||
getSnapshot: () => undefined,
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
rpc: {
|
||||
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
||||
},
|
||||
@@ -86,7 +90,7 @@ describe('runtime client apply', () => {
|
||||
expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new')
|
||||
// Mux sink and onConnected route without throwing (manager semantics own the behavior).
|
||||
bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
|
||||
bench.sinks?.onConnected?.()
|
||||
bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })
|
||||
})
|
||||
|
||||
it('selects the recent Workspace once when the first baselines have no current session', async () => {
|
||||
@@ -99,7 +103,7 @@ describe('runtime client apply', () => {
|
||||
}))
|
||||
bench.api.onList = () => Promise.resolve(ok({ items: [] }))
|
||||
|
||||
bench.sinks?.onConnected?.()
|
||||
bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })
|
||||
await flushMicrotasks()
|
||||
|
||||
const sessions = bench.ctx.get('sessions') as SessionsService
|
||||
|
||||
@@ -90,8 +90,15 @@ export class FakeApiClient implements IApiClient {
|
||||
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{
|
||||
version: string
|
||||
cwd: string
|
||||
attachedSessions: number
|
||||
canOpenPath: boolean
|
||||
}>> =
|
||||
() => Promise.resolve(ok({
|
||||
version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true,
|
||||
}))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
() => Promise.resolve(ok({ path: null }))
|
||||
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
|
||||
|
||||
@@ -24,6 +24,10 @@ async function mount(): Promise<Bench> {
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
hostDescription: {
|
||||
getSnapshot: () => undefined,
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
rpc: {
|
||||
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
||||
},
|
||||
@@ -83,8 +87,9 @@ describe('wire event bridge', () => {
|
||||
const bench = await mount()
|
||||
let resets = 0
|
||||
bench.ctx.on('connection/reset', () => { resets++ })
|
||||
bench.sinks?.onConnected?.()
|
||||
bench.sinks?.onConnected?.() // second generation after a reconnect
|
||||
const description = { version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }
|
||||
bench.sinks?.onConnected?.(description)
|
||||
bench.sinks?.onConnected?.(description) // second generation after a reconnect
|
||||
expect(resets).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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-deliverables/README.md
|
||||
README.md: 7d03e5faedda3ba8c9cc4cab6ca134d98dc7ec13
|
||||
README.zh.md: dfbbc7a39aa94aab438119a4f23ffb02da2daa3d
|
||||
README.md: 9cfcde9510c5d6814d5b42fd58a0520ea37b8679
|
||||
README.zh.md: 94dba9bb38725ddb3ef39d55a5f8a185a5008d18
|
||||
|
||||
@@ -6,7 +6,7 @@ Produced-files feature owner: registers the deliverables row a finished turn end
|
||||
|
||||
`deliverablesDefinition` folds each Turn's successful mutation calls into engine-published `DeliverablesTurnData`; `producedForClosing` reads that data with the closing Assistant seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per Turn in first-seen order. The Conversation Location index owns Turn membership, so a Turn that mutates and then ends without content text cannot spill into the next Turn's row.
|
||||
|
||||
`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md).
|
||||
`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label and one measured file lane. It shows the largest leading prefix that fits (up to six chips; basename text, full path as the `title`) while reserving the exact localized `+ N files` width, so the remainder stays visible without wrapping or horizontal scrolling. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. When files are hidden, a second-line **Show in folder** action opens the session workspace through that same owner path only while the page is loopback and the current Host handshake reports `canOpenPath`; direct remote Web and headless/container Linux Hosts omit the action by default. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md).
|
||||
|
||||
The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention keeps its code chip and takes the markdown sheet's link language — link-blue at rest, underlined on hover, exactly like URL-promoted inline code — with the full path as its `title`; mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md).
|
||||
|
||||
@@ -21,3 +21,4 @@ None; this package neither assembles nor sends provider requests.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Mention matching is exact path or unique basename only.** A suffix mention (`out/index.html` written as `index.html` resolves; `deep/out/index.html` written as `out/index.html` does not) stays inert; widening the matcher is deferred until a real closing-message shape needs it.
|
||||
- **Native folder handoff targets the Host desktop.** A browser reached through a non-loopback authority omits the action, as does a deployment reporting no native opener. SSH forwarding that makes a remote Host look loopback-local must set the gateway's `nativeOpen: false`; so must a headless macOS/Windows Host, a WSL deployment without working Windows interop, or any Linux desktop whose display/opener probe is a false positive. Identifying the operator-visible desktop remains deployment policy.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
`deliverablesDefinition` 把每个 Turn 中成功的修改调用折叠进引擎发布的 `DeliverablesTurnData`;`producedForClosing` 结合收尾 Assistant 的 seq 读取这份数据。依据的是修改工具自身附带的 `locations`,而不是收尾正文:无论模型是否记得点名,产出文件都会被列出。修改操作按渲染意图而非工具名识别:diff 卡片,或 `kind` 为 `edit` 的通用卡片(即 `str_replace_editor` 的 insert 操作所呈现的形态);因此新的修改工具只需声明自身行为即可加入。读取、删除和失败的调用不贡献任何条目;同一路径在一个 Turn 内按首见顺序只出现一次。Conversation Location 索引拥有 Turn 成员关系,因此一个 Turn 即使先修改文件、随后没有正文内容就结束,也不会溢进下一个 Turn 的行里。
|
||||
|
||||
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个低调的标签、至多六个标签项(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每个标签项经由属主提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。
|
||||
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个低调的标签和一条经过测量的单行文件 lane。它展示能够放下的最大前缀(至多六个标签项;文本为文件名,完整路径作为 `title`),并为本地化后的精确 `+ N 个文件` 宽度预留空间,因此剩余计数始终可见,既不换行也不横向滚动。每个标签项经由属主提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。存在隐藏文件时,第二行的**在文件夹中显示**也经由同一属主路径打开会话 workspace;它只在页面使用 loopback 且当前 Host 握手报告 `canOpenPath` 时出现,直接远程 Web 与 headless/容器 Linux Host 默认均省略该操作。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。
|
||||
|
||||
收尾正文承载同一份词表。本插件提供供 chat 视图按收尾消息查询的 `chatFileMentions` 服务:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享同一 basename 时,文本保持不可点击而不作猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及保留代码标签,并采用 Markdown 样式表的链接样式:静止时为链接蓝色,悬停时显示下划线,与 URL 提升的行内代码完全一致——完整路径作为其 `title`;提及绝不会渲染在链接内部或流式文本中。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。
|
||||
|
||||
@@ -21,3 +21,4 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **提及匹配只认精确路径或唯一 basename。**后缀式提及(`out/index.html` 写作 `index.html` 可解析;`deep/out/index.html` 写作 `out/index.html` 则不行)保持不可点击;等真实的收尾消息形态产生需求后再放宽匹配规则。
|
||||
- **原生文件夹交接以 Host 桌面为目标。**经非 loopback 权威访问的浏览器会省略该操作,报告没有原生打开器的部署也一样。若 SSH 转发让远端 Host 看似处于本机 loopback,部署必须为网关设置 `nativeOpen: false`;无界面的 macOS/Windows Host、Windows interop 不可用的 WSL,或 display/opener 探测误报的 Linux 桌面也必须这样配置。识别操作者实际可见的桌面仍属于部署策略。
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
@@ -48,6 +49,7 @@
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
@@ -56,6 +58,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
/* Turn-tail produced-files row: a quiet label followed by wrapping file chips.
|
||||
Sits between the assistant body and its IconActions footer, so it reads as
|
||||
part of the answer rather than as another tool row. */
|
||||
/* Turn-tail produced-files summary: one measured chip lane plus an optional
|
||||
native-folder action below it. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
column-gap: 8px;
|
||||
row-gap: 6px;
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.label {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.row {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* One produced file. A link by behavior (it opens the file), a chip by shape:
|
||||
full paths are long and several may wrap onto one row. */
|
||||
full paths are long, while the measured lane stays on one row. */
|
||||
.file {
|
||||
flex: 0 0 auto;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -38,7 +53,53 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.file:focus-visible,
|
||||
.showFolder:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
/* Overflow count: the row never silently drops files it did not show. */
|
||||
.more {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.showFolder {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
justify-self: start;
|
||||
margin: 0;
|
||||
padding: 0 2px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: inherit;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.showFolder:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Exact browser-native probes for every candidate shown count. They share the
|
||||
visible styles but never affect layout, accessibility, or scroll width. */
|
||||
.measure {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
.probe {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
@@ -4,46 +4,146 @@
|
||||
// through the same openFile the tool rows use — the Host's own opener, on the
|
||||
// Host machine.
|
||||
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { basename } from './turn-deliverables.ts'
|
||||
import type { NS } from './locales.ts'
|
||||
import css from './ProducedFiles.module.css'
|
||||
|
||||
/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */
|
||||
const SHOWN = 6
|
||||
/** At most six chips compete for the one-line summary; every other path stays counted. */
|
||||
const SHOWN_LIMIT = 6
|
||||
|
||||
/**
|
||||
* Select the largest prefix whose measured chips and exact remainder fit.
|
||||
* @param available - usable width of the one-line file lane.
|
||||
* @param gap - computed flex gap between adjacent visible items.
|
||||
* @param chipWidths - measured widths for the candidate file chips.
|
||||
* @param moreWidthsByShown - exact localized remainder width for each shown count.
|
||||
* @returns Number of leading chips to render.
|
||||
*/
|
||||
export function fitProducedFiles(
|
||||
available: number,
|
||||
gap: number,
|
||||
chipWidths: readonly number[],
|
||||
moreWidthsByShown: readonly (number | undefined)[],
|
||||
): number {
|
||||
if (available <= 0) return chipWidths.length
|
||||
const prefix = [0]
|
||||
for (const width of chipWidths) prefix.push((prefix.at(-1) ?? 0) + width)
|
||||
for (let shown = chipWidths.length; shown >= 0; shown -= 1) {
|
||||
const more = moreWidthsByShown[shown]
|
||||
const items = shown + (more === undefined ? 0 : 1)
|
||||
const needed = (prefix[shown] ?? 0) + (more ?? 0) + Math.max(0, items - 1) * gap
|
||||
if (needed <= available) return shown
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Matched paths plus the opener and locale seats needed to present them. */
|
||||
export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & {
|
||||
matched: readonly string[]
|
||||
/** True only when this loopback deployment exposes a user-visible native opener. */
|
||||
canOpenPath: boolean
|
||||
} & PropsLocale<typeof NS>
|
||||
|
||||
/** Slot-owned props before the connection capability is injected. */
|
||||
export type ProducedFilesSeatProps = Omit<ProducedFilesProps, 'canOpenPath'>
|
||||
|
||||
function moreLabel(t: ProducedFilesProps['t'], count: number): string {
|
||||
return count === 1 ? t('produced.moreOne') : t('produced.more', { count: String(count) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one turn's produced files as openable chips.
|
||||
* @param props - selector-matched paths, the chat view's file opener, and the locale seat.
|
||||
* @returns The produced-files row.
|
||||
*/
|
||||
export function ProducedFiles({ matched: paths, openFile, t }: ProducedFilesProps) {
|
||||
const shown = paths.slice(0, SHOWN)
|
||||
export function ProducedFiles({ matched: paths, openFile, canOpenPath, t }: ProducedFilesProps) {
|
||||
const limit = Math.min(paths.length, SHOWN_LIMIT)
|
||||
const [shownCount, setShownCount] = useState(limit)
|
||||
const rowRef = useRef<HTMLDivElement>(null)
|
||||
const chipProbes = useRef<Array<HTMLButtonElement | null>>([])
|
||||
const moreProbes = useRef<Array<HTMLSpanElement | null>>([])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current
|
||||
if (row === null) return
|
||||
const measure = (): void => {
|
||||
const styles = getComputedStyle(row)
|
||||
const gap = Number.parseFloat(styles.columnGap || styles.gap) || 0
|
||||
const chips = chipProbes.current.slice(0, limit)
|
||||
.map(probe => probe?.getBoundingClientRect().width ?? 0)
|
||||
const more = Array.from({ length: limit + 1 }, (_, candidate) =>
|
||||
paths.length === candidate
|
||||
? undefined
|
||||
: moreProbes.current[candidate]?.getBoundingClientRect().width)
|
||||
setShownCount(fitProducedFiles(row.clientWidth, gap, chips, more))
|
||||
}
|
||||
measure()
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(row)
|
||||
for (const probe of [...chipProbes.current, ...moreProbes.current]) {
|
||||
if (probe !== null) observer.observe(probe)
|
||||
}
|
||||
return () => { observer.disconnect() }
|
||||
}, [limit, paths, t])
|
||||
|
||||
const visibleCount = Math.min(shownCount, limit)
|
||||
const shown = paths.slice(0, visibleCount)
|
||||
const hidden = paths.length - shown.length
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<span className={css.label}>{t('produced.label')}</span>
|
||||
{shown.map(path => (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
className={css.file}
|
||||
// The full path is the disambiguator when two turns produce files
|
||||
// that share a basename; the chip itself stays short.
|
||||
title={path}
|
||||
aria-label={t('produced.open', { name: path })}
|
||||
onClick={() => { openFile(path) }}
|
||||
>
|
||||
{basename(path)}
|
||||
<div ref={rowRef} className={css.row} data-produced-files-row>
|
||||
{shown.map(path => (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
className={css.file}
|
||||
// The full path is the disambiguator when two turns produce files
|
||||
// that share a basename; the chip itself stays short.
|
||||
title={path}
|
||||
aria-label={t('produced.open', { name: path })}
|
||||
onClick={() => { openFile(path) }}
|
||||
>
|
||||
{basename(path)}
|
||||
</button>
|
||||
))}
|
||||
{hidden > 0 && <span className={css.more}>{moreLabel(t, hidden)}</span>}
|
||||
</div>
|
||||
{hidden > 0 && canOpenPath && (
|
||||
<button type="button" className={css.showFolder} onClick={() => { openFile('.') }}>
|
||||
{t('produced.showInFolder')}
|
||||
</button>
|
||||
))}
|
||||
{hidden > 0 && <span className={css.more}>{t('produced.more', { count: String(hidden) })}</span>}
|
||||
)}
|
||||
<div className={css.measure} aria-hidden="true">
|
||||
{paths.slice(0, limit).map((path, index) => (
|
||||
<button
|
||||
key={path}
|
||||
ref={(node) => { chipProbes.current[index] = node }}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={`${css.file} ${css.probe}`}
|
||||
>
|
||||
{basename(path)}
|
||||
</button>
|
||||
))}
|
||||
{Array.from({ length: limit + 1 }, (_, candidate) => {
|
||||
const remaining = paths.length - candidate
|
||||
if (remaining === 0) return null
|
||||
return (
|
||||
<span
|
||||
key={candidate}
|
||||
ref={(node) => { moreProbes.current[candidate] = node }}
|
||||
className={`${css.more} ${css.probe}`}
|
||||
>
|
||||
{moreLabel(t, remaining)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
* composing this plugin out of cordis.yml removes both surfaces entirely;
|
||||
* the owning view renders an empty chain and inert prose at zero cost.
|
||||
*/
|
||||
import { createElement, useSyncExternalStore } from 'react'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { ProducedFiles } from './ProducedFiles.tsx'
|
||||
import {
|
||||
ProducedFiles, type ProducedFilesSeatProps,
|
||||
} from './ProducedFiles.tsx'
|
||||
import { en, NS, zh, type DeliverablesKey } from './locales.ts'
|
||||
import {
|
||||
deliverablesDefinition, producedFileMentions, selectProducedFiles,
|
||||
@@ -27,13 +31,24 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx'
|
||||
export { producedForClosing } from './turn-deliverables.ts'
|
||||
|
||||
/** Required services for the tail-slot registration and its dictionaries. */
|
||||
export const inject = ['slots', 'locale', 'conversationEvents']
|
||||
export const inject = ['slots', 'locale', 'conversationEvents', 'connection']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the dictionaries and the turn-tail entry.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const ProducedFilesSeat = (props: ProducedFilesSeatProps): ReturnType<typeof createElement> => {
|
||||
const description = useSyncExternalStore(
|
||||
listener => connection.hostDescription.subscribe(listener),
|
||||
() => connection.hostDescription.getSnapshot(),
|
||||
)
|
||||
return createElement(ProducedFiles, {
|
||||
...props,
|
||||
canOpenPath: connection.isLoopback && description?.canOpenPath === true,
|
||||
})
|
||||
}
|
||||
ctx.conversationEvents.register(deliverablesDefinition)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
|
||||
ctx.slots.inject(
|
||||
@@ -42,7 +57,7 @@ export function apply(ctx: ClientContext): void {
|
||||
name: 'conversation.chat.turnTail',
|
||||
select: selectProducedFiles,
|
||||
locale: NS,
|
||||
}, ProducedFiles),
|
||||
}, ProducedFilesSeat),
|
||||
)
|
||||
// The prose side of the same vocabulary: the chat view reaches this face
|
||||
// via ctx.get, so its absence — this plugin composed out — is the off state.
|
||||
|
||||
@@ -6,15 +6,19 @@ export const NS = 'deliverables'
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'produced.label': '产物',
|
||||
'produced.more': '还有 {count} 个',
|
||||
'produced.moreOne': '+ 1 个文件',
|
||||
'produced.more': '+ {count} 个文件',
|
||||
'produced.open': '打开 {name}',
|
||||
'produced.showInFolder': '在文件夹中显示',
|
||||
}
|
||||
|
||||
/** English dictionary (same key set). */
|
||||
export const en: Record<DeliverablesKey, string> = {
|
||||
'produced.label': 'Produced',
|
||||
'produced.more': '{count} more',
|
||||
'produced.moreOne': '+ 1 file',
|
||||
'produced.more': '+ {count} files',
|
||||
'produced.open': 'Open {name}',
|
||||
'produced.showInFolder': 'Show in folder',
|
||||
}
|
||||
|
||||
/** Union of this namespace's dictionary keys. */
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* (HMR safety) against the real SlotsService.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type { ComponentType } from 'react'
|
||||
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ConversationEventRegistry, ConversationNodeAssembler, SlotsService,
|
||||
@@ -19,7 +20,7 @@ import type {
|
||||
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
|
||||
import { fitProducedFiles, ProducedFiles, type ProducedFilesSeatProps } from '../src/client/ProducedFiles.tsx'
|
||||
import {
|
||||
basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
|
||||
type DeliverablesTurnData,
|
||||
@@ -27,9 +28,20 @@ import {
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as applyNode } from '../src/index.ts'
|
||||
import { apply as applyInvariant } from '../src/invariant.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
if (originalClientWidth === undefined) {
|
||||
delete (HTMLElement.prototype as { clientWidth?: number }).clientWidth
|
||||
} else {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
|
||||
}
|
||||
})
|
||||
|
||||
class TestTurnDataStore implements ConversationLocationDataStore<ConversationTurnDataMap> {
|
||||
private readonly values = new Map<string, unknown>()
|
||||
@@ -269,21 +281,104 @@ describe('produced-file Turn data', () => {
|
||||
describe('ProducedFiles row', () => {
|
||||
const t = makeTranslate(zh)
|
||||
|
||||
it('renders capped chips with the full path reachable and opens one on click', () => {
|
||||
// Seven files: six chips plus an explicit remainder — the row bounds what
|
||||
// it shows and says so rather than dropping the rest silently.
|
||||
it('selects the largest prefix using the exact remainder width', () => {
|
||||
expect(fitProducedFiles(230, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(2)
|
||||
expect(fitProducedFiles(145, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(1)
|
||||
expect(fitProducedFiles(300, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(3)
|
||||
// A zero-width lane is a pre-layout test/hidden state, not evidence that
|
||||
// every chip overflowed; keep the bounded initial prefix until measured.
|
||||
expect(fitProducedFiles(0, 8, [70, 60], [60, 50, undefined])).toBe(2)
|
||||
// Candidate-specific suffix widths matter at the 10 -> 9 digit boundary.
|
||||
expect(fitProducedFiles(126, 8, [60], [70, 50])).toBe(1)
|
||||
expect(fitProducedFiles(20, 8, [60], [70, 50])).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps one measured line, updates on resize, and opens a file or the workspace folder', () => {
|
||||
const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts']
|
||||
const openFile = vi.fn<(path: string) => void>()
|
||||
const view = render(<ProducedFiles matched={paths} openFile={openFile} t={t} />)
|
||||
let available = 226
|
||||
let resize: ResizeObserverCallback | undefined
|
||||
const disconnect = vi.fn()
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
constructor(callback: ResizeObserverCallback) { resize = callback }
|
||||
observe(): void {}
|
||||
disconnect(): void { disconnect() }
|
||||
})
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) { return this.hasAttribute('data-produced-files-row') ? available : 0 },
|
||||
})
|
||||
const rect = (width: number): DOMRect => ({
|
||||
x: 0, y: 0, width, height: 22, top: 0, right: width, bottom: 22, left: 0,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function getProbeRect(this: HTMLElement) {
|
||||
if (this.closest('[aria-hidden="true"]') === null) return rect(0)
|
||||
if (this.tagName !== 'BUTTON') return rect(60)
|
||||
return rect(this.textContent === 'a.html' || this.textContent === 'b.css' ? 50 : 100)
|
||||
})
|
||||
|
||||
const view = render(
|
||||
<ProducedFiles matched={paths} openFile={openFile} canOpenPath t={t} />,
|
||||
)
|
||||
expect(view.getByText('产物')).toBeTruthy()
|
||||
// Chips carry the basename; the full path stays reachable as the title.
|
||||
const row = view.container.querySelector('[data-produced-files-row]')
|
||||
if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
|
||||
// The third probe is 100px: two chips plus the remainder fit, three do not.
|
||||
expect(within(row).getAllByRole('button')).toHaveLength(2)
|
||||
expect(within(row).getByText('+ 5 个文件')).toBeTruthy()
|
||||
const chip = view.getByRole('button', { name: '打开 deep/a.html' })
|
||||
expect(chip.textContent).toBe('a.html')
|
||||
expect(chip.getAttribute('title')).toBe('deep/a.html')
|
||||
expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull()
|
||||
expect(view.getByText('还有 1 个')).toBeTruthy()
|
||||
fireEvent.click(chip)
|
||||
expect(openFile).toHaveBeenCalledWith('deep/a.html')
|
||||
|
||||
const showFolder = view.getByRole('button', { name: '在文件夹中显示' })
|
||||
fireEvent.click(showFolder)
|
||||
expect(openFile).toHaveBeenLastCalledWith('.')
|
||||
|
||||
available = 150
|
||||
act(() => { resize?.([], {} as ResizeObserver) })
|
||||
expect(within(row).getAllByRole('button')).toHaveLength(1)
|
||||
expect(within(row).getByText('+ 6 个文件')).toBeTruthy()
|
||||
|
||||
view.unmount()
|
||||
expect(disconnect).toHaveBeenCalledOnce()
|
||||
bounds.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps the folder action absent without overflow or a local native opener', () => {
|
||||
const openFile = vi.fn<(path: string) => void>()
|
||||
const noOverflow = render(
|
||||
<ProducedFiles matched={['a.md']} openFile={openFile} canOpenPath t={t} />,
|
||||
)
|
||||
expect(noOverflow.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
|
||||
noOverflow.unmount()
|
||||
const headless = render(
|
||||
<ProducedFiles
|
||||
matched={['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md']}
|
||||
openFile={openFile}
|
||||
canOpenPath={false}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
expect(headless.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
|
||||
})
|
||||
|
||||
it('uses singular English copy when exactly one file is hidden', () => {
|
||||
const view = render(
|
||||
<ProducedFiles
|
||||
matched={['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md']}
|
||||
openFile={() => {}}
|
||||
canOpenPath={false}
|
||||
t={makeTranslate(en)}
|
||||
/>,
|
||||
)
|
||||
const row = view.container.querySelector('[data-produced-files-row]')
|
||||
if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
|
||||
expect(within(row).getByText('+ 1 file')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -340,13 +435,56 @@ describe('plugin registration', () => {
|
||||
name: 'root',
|
||||
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
|
||||
} as never, () => null)
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
const hostDescription = (canOpenPath: boolean) => ({
|
||||
version: 'test', cwd: '/workspace', attachedSessions: 1, canOpenPath,
|
||||
})
|
||||
let description: ReturnType<typeof hostDescription> | undefined = hostDescription(true)
|
||||
const descriptionListeners = new Set<() => void>()
|
||||
const connection = {
|
||||
api: { settings: {} },
|
||||
isLoopback: false,
|
||||
hostDescription: {
|
||||
getSnapshot: () => description,
|
||||
subscribe: (listener: () => void) => {
|
||||
descriptionListeners.add(listener)
|
||||
return () => { descriptionListeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
}
|
||||
ctx.provide('connection', connection as never)
|
||||
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1)
|
||||
|
||||
// The native action needs both independent facts. A Host capability does
|
||||
// not authorize a remote page; loopback without a capable Host does not
|
||||
// render a dead action; reconnect/disconnect retracts the capability.
|
||||
const Entry = ctx.slots.entries('conversation.chat.turnTail')[0]!.component as ComponentType<ProducedFilesSeatProps>
|
||||
const entryProps: ProducedFilesSeatProps = {
|
||||
matched: ['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md'],
|
||||
openFile: () => {},
|
||||
t: makeTranslate(zh),
|
||||
}
|
||||
const surface = render(<Entry {...entryProps} />)
|
||||
expect(surface.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
|
||||
connection.isLoopback = true
|
||||
description = hostDescription(false)
|
||||
surface.rerender(<Entry {...entryProps} />)
|
||||
expect(surface.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
|
||||
act(() => {
|
||||
description = hostDescription(true)
|
||||
for (const listener of descriptionListeners) listener()
|
||||
})
|
||||
expect(surface.getByRole('button', { name: '在文件夹中显示' })).toBeTruthy()
|
||||
act(() => {
|
||||
description = undefined
|
||||
for (const listener of descriptionListeners) listener()
|
||||
})
|
||||
expect(surface.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
|
||||
surface.unmount()
|
||||
|
||||
// The prose face is live while the plugin is: a produced turn yields a
|
||||
// resolver whose matches open through the owner-supplied opener.
|
||||
const opened: string[] = []
|
||||
|
||||
Reference in New Issue
Block a user