feat(session-export): add command and Header action
This commit is contained in:
@@ -65,6 +65,10 @@
|
||||
config:
|
||||
maxNoteBytes: 8192
|
||||
|
||||
# Browser Session export: `/export` command plus the shared download dialog.
|
||||
- id: session-export
|
||||
name: '@deepseek-ai/dsh-session-export'
|
||||
|
||||
- id: workspace
|
||||
name: '@deepseek-ai/dsh-workspace'
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-message-feedback": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-export": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-json": "workspace:^",
|
||||
|
||||
@@ -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-command/README.md
|
||||
README.md: 60b70cfbc3784dd5b138f8857270c4bbdc0fd634
|
||||
README.zh.md: 639b7f997e967527232bb88116558c5457b4d497
|
||||
README.md: 1fa5d6cd38a18303857f4132ea0839822183f76b
|
||||
README.zh.md: 1af0a17a0a14cf135f7b8b70089d610a1ab71d96
|
||||
|
||||
@@ -8,6 +8,8 @@ Client command API (`ctx.command`): the session-keyed command-directory cache, t
|
||||
|
||||
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the forwarded `commands/change` owner event (old snapshots serve while the repull flies) and by forwarded `agent-preset/selected` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
|
||||
|
||||
After `command.execute` returns a matched command result, this browser emits local `command/executed(sessionId, name, result)`. Other clients receive the durable command nodes through the Host event stream but never this acknowledgment, so a browser-only side effect can select successful results from the client that submitted the command without treating Session replay as an action request.
|
||||
|
||||
Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md).
|
||||
|
||||
`PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`.
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由转发的 owner 事件 `commands/change` 软失效(重拉在途期间旧快照继续服务),也由转发的 `agent-preset/selected` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
|
||||
|
||||
`command.execute` 返回已匹配的命令结果后,当前浏览器会发布本地 `command/executed(sessionId, name, result)`。其他客户端只会通过 Host 事件流收到持久命令节点,不会收到这条确认,因此浏览器专属副作用可以筛选由实际提交命令的客户端收到的成功结果,而不会把 Session 回放当成操作请求。
|
||||
|
||||
菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。
|
||||
|
||||
`PopupSelectController`(`src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`(SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边)。壳是打开期间持有焦点的瞬态层;onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS,回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
|
||||
// (`commands/change` rides the allowlist) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { CommandResult } from '@deepseek-ai/dsh-commands'
|
||||
import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
|
||||
@@ -23,6 +24,28 @@ import { CommandDirectory } from './directory.ts'
|
||||
import { PopupSelectController } from './popup.ts'
|
||||
import type { TokenSegment } from './popup.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* This browser client completed one admitted Host command execution.
|
||||
* Other clients receive the durable command nodes but never this local
|
||||
* submission acknowledgment.
|
||||
* @param sessionId - Session addressed by the local submission.
|
||||
* @param name - Executed command name without the leading slash.
|
||||
* @param result - Host command result returned to this browser.
|
||||
* @mode emit
|
||||
*/
|
||||
'command/executed'(sessionId: SessionId, name: string, result: CommandResult): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Recover the command name from a line the Host confirmed as executed. */
|
||||
function submittedCommandName(line: string): string {
|
||||
const trimmed = line.trim()
|
||||
const separator = trimmed.search(/\s/u)
|
||||
return (separator === -1 ? trimmed : trimmed.slice(0, separator)).slice(1)
|
||||
}
|
||||
|
||||
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
|
||||
interface LiveState {
|
||||
readonly contributions: Map<string, CommandContribution>
|
||||
@@ -351,6 +374,7 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
const result = await this.ctx.remote.commands.execute(session.sessionId, line)
|
||||
if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
|
||||
if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` }
|
||||
this.ctx.emit('command/executed', session.sessionId, submittedCommandName(line), result.value.result)
|
||||
return { kind: 'success' }
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { CommandResult } from '@deepseek-ai/dsh-commands'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
@@ -120,6 +121,10 @@ async function bench(opts: BenchOptions = {}) {
|
||||
},
|
||||
})
|
||||
ctx.provide('remote.commands', commandsRemote)
|
||||
const executions: Array<{ sessionId: SessionId; name: string; result: CommandResult }> = []
|
||||
ctx.on('command/executed', (sessionId, name, result) => {
|
||||
executions.push({ sessionId, name, result })
|
||||
})
|
||||
/** Notices the fake conversation face collected (runDetached routing). */
|
||||
const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
|
||||
ctx.provide('conversation', {
|
||||
@@ -145,7 +150,7 @@ async function bench(opts: BenchOptions = {}) {
|
||||
const warm = async (session: ClientSessionContext) => {
|
||||
await source.candidates(session, { query: '', position: 'leading', signal: new AbortController().signal })
|
||||
}
|
||||
return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, registered, notices }
|
||||
return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices }
|
||||
}
|
||||
|
||||
function menuPick(source: SlashSource, name: string, session: ClientSessionContext, end?: number) {
|
||||
@@ -367,7 +372,7 @@ describe('dispatch (menu column)', () => {
|
||||
})
|
||||
|
||||
it('host bare → consume-token span guard on the session scope + detached execute', async () => {
|
||||
const { source, mint, warm, executeCalls } = await bench()
|
||||
const { source, mint, warm, executeCalls, executions } = await bench()
|
||||
const scope = mint('s1')
|
||||
const consumes: ConsumeTokenRequest[] = []
|
||||
scope.ctx.on('slash/input-consume-token', (r) => {
|
||||
@@ -377,8 +382,14 @@ describe('dispatch (menu column)', () => {
|
||||
await warm(proj('s1'))
|
||||
expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled')
|
||||
expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }])
|
||||
await Promise.resolve()
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
||||
await vi.waitFor(() => {
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
||||
expect(executions).toEqual([{
|
||||
sessionId: sid('s1'),
|
||||
name: 'plan',
|
||||
result: { kind: 'success' },
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
it('a name the directory no longer serves → undefined (snapshot swapped between menu and pick)', async () => {
|
||||
@@ -496,7 +507,7 @@ describe('matchEnter (enter column)', () => {
|
||||
|
||||
describe('execute payload', () => {
|
||||
it('claim.submit addresses the session; admitted outcomes stay off the composer (flow card owns them)', async () => {
|
||||
const { source, warm, executeCalls } = await bench({
|
||||
const { source, warm, executeCalls, executions } = await bench({
|
||||
execute: () => Promise.resolve({ matched: true }),
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
@@ -507,6 +518,11 @@ describe('execute payload', () => {
|
||||
// Pure admission: no outcome text ever rides the submit result — the
|
||||
// durable command lifecycle events render the outcome in the flow.
|
||||
expect(settled).toEqual({ kind: 'success' })
|
||||
expect(executions).toEqual([{
|
||||
sessionId: sid('s1'),
|
||||
name: 'goal',
|
||||
result: { kind: 'success' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('maps matched:false to an error outcome and a matched bare result to success', async () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: e2db456f92151c3602fce0bf40a86e4019cda1cb
|
||||
README.zh.md: 399228c619d50a7fc1909db81350b4078803f8f7
|
||||
README.md: b6a265c9f0a67d31ebeaa88bd59082c4be465983
|
||||
README.zh.md: be06e988500e0c3e6fc69409552459b8cfff75e9
|
||||
|
||||
@@ -16,7 +16,7 @@ Chat business rows are independent registry contributions rather than a closed b
|
||||
|
||||
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
|
||||
|
||||
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
|
||||
The session header renders the session-scoped `'conversation.session.header.actions'` list beside the title and the independent `'conversation.session.header.utilities'` list at the right edge. Session context and lineage controls remain in `actions`; optional Session utilities cannot reorder or move them. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
|
||||
|
||||
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。Client 插件通过 declaration merging 增加类型化 `ChatNodeDataMap` key,在 `ctx.conversationEvents` 上注册 `ConversationNodeDefinition`,再向 `conversation.chat.node` 注册匹配的 keyed renderer;它无须修改 Session fold 或中央 renderer switch。稳定事件 id、append/prepend 回放、Location data 与 renderer 约束见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.md)。
|
||||
|
||||
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
|
||||
会话页头会在标题旁渲染 Session scope 的 `'conversation.session.header.actions'` 列表,并在最右侧渲染独立的 `'conversation.session.header.utilities'` 列表。Session 上下文和谱系控件保留在 `actions` 中,可选的 Session 工具不会改变它们的顺序或位置。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
|
||||
|
||||
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。
|
||||
|
||||
|
||||
@@ -259,6 +259,7 @@ export function apply(ctx: Context): void {
|
||||
locale: NS,
|
||||
children: {
|
||||
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
|
||||
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (): ConversationSessionHeaderInjected => ({
|
||||
|
||||
@@ -45,6 +45,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* context that precedes interactive actions.
|
||||
*/
|
||||
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
|
||||
/**
|
||||
* Right-aligned Session utilities kept outside the title-adjacent action
|
||||
* group, so an optional utility cannot reorder session context or lineage.
|
||||
*/
|
||||
'conversation.session.header.utilities': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
|
||||
/**
|
||||
* The conversation view ring: one list entry per view tab (chat here;
|
||||
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
|
||||
@@ -525,7 +530,7 @@ export type ConversationSessionSlotProps =
|
||||
/** Full strict-session header props: shared store, tabs/actions render shares, navigation, and locale. */
|
||||
export type ConversationSessionHeaderSlotProps =
|
||||
PropsRuntime<'conversation.session.header'>
|
||||
& PropsRenderSlots<'conversation.session.header.actions'>
|
||||
& PropsRenderSlots<'conversation.session.header.actions' | 'conversation.session.header.utilities'>
|
||||
& PropsStore<ChatStore>
|
||||
& ConversationSessionHeaderInjected
|
||||
& PropsLocale<'conversation'>
|
||||
|
||||
@@ -53,9 +53,18 @@
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 0;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.titleCluster {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -111,6 +120,18 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.headerUtilities {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.headerUtilities:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
|
||||
.tabs {
|
||||
position: relative;
|
||||
|
||||
@@ -69,27 +69,32 @@ export function ConversationSessionHeader({
|
||||
{!hideChrome && (
|
||||
<>
|
||||
<div className={css.titleRow}>
|
||||
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
|
||||
{ancestry.map((summary, index) => {
|
||||
const last = index === ancestry.length - 1
|
||||
return (
|
||||
<span key={summary.id} className={css.crumbSeg}>
|
||||
{index > 0 && <span className={css.crumbSep}>/</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.crumb, last && css.crumbCurrent)}
|
||||
disabled={last}
|
||||
onClick={() => { open(summary.id) }}
|
||||
>
|
||||
{summary.displayTitle}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
|
||||
</nav>
|
||||
<div className={css.headerActions}>
|
||||
{renderSlot('conversation.session.header.actions', {})}
|
||||
<div className={css.titleCluster}>
|
||||
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
|
||||
{ancestry.map((summary, index) => {
|
||||
const last = index === ancestry.length - 1
|
||||
return (
|
||||
<span key={summary.id} className={css.crumbSeg}>
|
||||
{index > 0 && <span className={css.crumbSep}>/</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.crumb, last && css.crumbCurrent)}
|
||||
disabled={last}
|
||||
onClick={() => { open(summary.id) }}
|
||||
>
|
||||
{summary.displayTitle}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
|
||||
</nav>
|
||||
<div className={css.headerActions}>
|
||||
{renderSlot('conversation.session.header.actions', {})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.headerUtilities}>
|
||||
{renderSlot('conversation.session.header.utilities', {})}
|
||||
</div>
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
|
||||
@@ -337,6 +337,8 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(host?.contains(header)).toBe(false)
|
||||
expect(host?.contains(seat)).toBe(true)
|
||||
expect(seat?.contains(textarea)).toBe(true)
|
||||
expect(b.slotCalls).toContain('conversation.session.header.actions')
|
||||
expect(b.slotCalls).toContain('conversation.session.header.utilities')
|
||||
})
|
||||
|
||||
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
|
||||
|
||||
@@ -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-trajectory/README.md
|
||||
README.md: f4b3bd223c2872f0341d49bdaa102440d73b4f29
|
||||
README.zh.md: 9bcb3b6ad98d672cc524c168f2024be9ba56b577
|
||||
README.md: d3786b6460c5df7eaa6d24e68c80025e7fb29ae4
|
||||
README.zh.md: 5eb1451b9a3a9896d5486fcf5c8d9cf30d6159a0
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. The toolbar's Export button hands the session log — the root plus every subagent descendant — directly to the browser download manager as a ZIP streamed by the host (`GET /api/session.export`), so JavaScript never buffers the response: every file is the session's stored artifact text verbatim (`session.jsonl` at the root, `subagents/<id>/session.jsonl` for descendants; no manifest, byte-identical to the backend's durable artifact), and every image any included log references sits under `media/<attachmentId>.<ext>`. Fixture mode (no host) answers 404 for the export. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。工具栏的 “Export” 按钮会将会话日志——根会话及其全部子代理——作为宿主流式返回的 ZIP(`GET /api/session.export`)直接交给浏览器下载管理器,因此 JavaScript 不会缓冲响应:每个文件都是会话存储工件的逐字原文(根为 `session.jsonl`,子代理为 `subagents/<id>/session.jsonl`;无清单,与后端持久化工件逐字节一致),每个被包含日志引用的图片则放在 `media/<attachmentId>.<ext>` 下。fixture 模式(无宿主)对导出应答 404。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -164,46 +164,6 @@
|
||||
font: 14px/14px var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
.export {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.export:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.export:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.export:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.exportIcon {
|
||||
flex: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.25;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
flex: 0 1 164px;
|
||||
|
||||
@@ -26,12 +26,6 @@ export interface TrajectoryToolbarProps {
|
||||
searchQuery: string
|
||||
/** Update the live ledger search query. */
|
||||
onSearchQueryChange: (query: string) => void
|
||||
/** Whether the session-log export is in flight. */
|
||||
exporting: boolean
|
||||
/** Trigger the session-log export download. */
|
||||
onExport: () => void
|
||||
/** Export failure message, shown while set; null while idle or successful. */
|
||||
exportError: string | null
|
||||
/** Translate a toolbar dictionary key. */
|
||||
t: TranslateNS<typeof NS>
|
||||
}
|
||||
@@ -52,9 +46,6 @@ export function TrajectoryToolbar({
|
||||
onToggleAllAssistants,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
exporting,
|
||||
onExport,
|
||||
exportError,
|
||||
t,
|
||||
}: TrajectoryToolbarProps) {
|
||||
return (
|
||||
@@ -119,20 +110,6 @@ export function TrajectoryToolbar({
|
||||
</span>
|
||||
{t('toolbar.calls')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.export}
|
||||
aria-label={t('toolbar.exportAria')}
|
||||
title={exportError ?? (exporting ? t('toolbar.exporting') : t('toolbar.exportTitle'))}
|
||||
disabled={exporting}
|
||||
onClick={onExport}
|
||||
>
|
||||
<svg className={css.exportIcon} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M8 3v7m0 0 3-3m-3 3L5 7" />
|
||||
<path d="M3 11.5V13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1.5" />
|
||||
</svg>
|
||||
{t('toolbar.export')}
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.search}>
|
||||
<IconSearchOutline16 size={11} className={css.searchIcon} />
|
||||
|
||||
@@ -71,8 +71,6 @@ export interface TrajectoryViewInjected {
|
||||
}
|
||||
loadOlder: () => Promise<boolean>
|
||||
setActualDuration: (actualDuration: boolean) => void
|
||||
/** Download the session log (including subagent logs) as a ZIP archive; rejects on failure. */
|
||||
exportLog: () => Promise<void>
|
||||
}
|
||||
|
||||
interface UsageLike {
|
||||
@@ -120,7 +118,7 @@ function addUsage(
|
||||
}
|
||||
|
||||
export function TrajectoryView({
|
||||
useSession, useDuration, loadOlder, setActualDuration, exportLog,
|
||||
useSession, useDuration, loadOlder, setActualDuration,
|
||||
inspect, onInspectDone, t,
|
||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected> & PropsLocale<'trajectory'>) {
|
||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
|
||||
@@ -130,8 +128,6 @@ export function TrajectoryView({
|
||||
const actualDuration = useDuration(value => value)
|
||||
const [actualTime, setActualTime] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [exportError, setExportError] = useState<string | null>(null)
|
||||
const [searchIndex] = useState(() => new TrajectorySearchIndex())
|
||||
const [searchIndexRevision, setSearchIndexRevision] = useState(0)
|
||||
const searchIndexTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -447,19 +443,6 @@ export function TrajectoryView({
|
||||
return loadOlder()
|
||||
}, [loadOlder])
|
||||
|
||||
const onExport = useCallback(() => {
|
||||
if (exporting) return
|
||||
setExporting(true)
|
||||
setExportError(null)
|
||||
void exportLog().then(
|
||||
() => { setExporting(false) },
|
||||
(error: unknown) => {
|
||||
setExportError(error instanceof Error ? error.message : String(error))
|
||||
setExporting(false)
|
||||
},
|
||||
)
|
||||
}, [exportLog, exporting])
|
||||
|
||||
return (
|
||||
<div className={css.root} data-conversation-composer-overlay="">
|
||||
<TrajectoryToolbar
|
||||
@@ -479,16 +462,8 @@ export function TrajectoryView({
|
||||
onToggleAllAssistants={toggleAllAssistants}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
exporting={exporting}
|
||||
onExport={onExport}
|
||||
exportError={exportError}
|
||||
t={t}
|
||||
/>
|
||||
{exportError !== null && (
|
||||
<div className={css.exportError} role="alert">
|
||||
{exportError}
|
||||
</div>
|
||||
)}
|
||||
<TrajectoryTimeline
|
||||
turns={timelineTurns}
|
||||
mode={timelineMode}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Session log export delivery. The host streams the archive from
|
||||
* `GET /api/session.export`; this module owns the browser-native download
|
||||
* handoff so the browser can stream the response directly to its download
|
||||
* manager instead of buffering the ZIP in JavaScript.
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collapse an untrusted session id into one safe path/filename segment.
|
||||
* Distinct ids may collapse onto one segment (impossible for the host-minted
|
||||
* UUIDs, so no uniqueness suffix is kept).
|
||||
* @param id - the raw session id.
|
||||
* @returns a filesystem-safe single segment.
|
||||
*/
|
||||
function safeSessionIdSegment(id: string): string {
|
||||
return id.replace(/[^A-Za-z0-9_-]/g, '_')
|
||||
}
|
||||
|
||||
/**
|
||||
* The export archive filename for one session (same convention the host's
|
||||
* Content-Disposition uses).
|
||||
* @param sessionId - the root session id.
|
||||
* @returns the download filename.
|
||||
*/
|
||||
export function sessionLogZipFilename(sessionId: string): string {
|
||||
return `dsh-session-${safeSessionIdSegment(sessionId)}.zip`
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand one host-streamed session archive to the browser download manager.
|
||||
* The operation resolves after dispatching the native download; HTTP delivery
|
||||
* continues outside JavaScript and is reported by the browser itself.
|
||||
* @param sessionId - the root session id to export with all descendants.
|
||||
* @returns a promise that rejects if the browser handoff itself fails.
|
||||
*/
|
||||
export function downloadSessionLog(sessionId: string): Promise<void> {
|
||||
return Promise.resolve().then(() => {
|
||||
const query = new URLSearchParams({ sessionId, includeDescendants: 'true' })
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = `/api/session.export?${query.toString()}`
|
||||
anchor.download = sessionLogZipFilename(sessionId)
|
||||
anchor.click()
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// owning package) must be in the program for the register calls to type.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { createTrajectoryDurationStore } from './duration-store.ts'
|
||||
import { downloadSessionLog } from './export-log.ts'
|
||||
import { en, NS, zh } from './locales.ts'
|
||||
import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts'
|
||||
import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts'
|
||||
@@ -60,7 +59,6 @@ export function apply(ctx: Context): void {
|
||||
return session.getSnapshot().views.get('trajectory') !== before
|
||||
},
|
||||
setActualDuration: (value) => { duration.set(value) },
|
||||
exportLog: () => downloadSessionLog(sessionId),
|
||||
}
|
||||
},
|
||||
}, TrajectoryView))
|
||||
|
||||
@@ -17,10 +17,6 @@ export type TrajectoryKey =
|
||||
| 'toolbar.calls'
|
||||
| 'toolbar.expandCalls'
|
||||
| 'toolbar.collapseCalls'
|
||||
| 'toolbar.export'
|
||||
| 'toolbar.exportAria'
|
||||
| 'toolbar.exporting'
|
||||
| 'toolbar.exportTitle'
|
||||
| 'toolbar.search'
|
||||
| 'toolbar.searchPlaceholder'
|
||||
|
||||
@@ -45,10 +41,6 @@ export const zh: Record<TrajectoryKey, string> = {
|
||||
'toolbar.calls': 'Calls',
|
||||
'toolbar.expandCalls': 'Expand calls',
|
||||
'toolbar.collapseCalls': 'Collapse calls',
|
||||
'toolbar.export': 'Export',
|
||||
'toolbar.exportAria': 'Export session log',
|
||||
'toolbar.exporting': 'Exporting…',
|
||||
'toolbar.exportTitle': 'Export session log (ZIP, includes subagents)',
|
||||
'toolbar.search': '搜索轨迹',
|
||||
'toolbar.searchPlaceholder': '搜索',
|
||||
}
|
||||
@@ -67,10 +59,6 @@ export const en: Record<TrajectoryKey, string> = {
|
||||
'toolbar.calls': 'Calls',
|
||||
'toolbar.expandCalls': 'Expand calls',
|
||||
'toolbar.collapseCalls': 'Collapse calls',
|
||||
'toolbar.export': 'Export',
|
||||
'toolbar.exportAria': 'Export session log',
|
||||
'toolbar.exporting': 'Exporting…',
|
||||
'toolbar.exportTitle': 'Export session log (ZIP, includes subagents)',
|
||||
'toolbar.search': 'Search trajectory',
|
||||
'toolbar.searchPlaceholder': 'Search',
|
||||
}
|
||||
|
||||
@@ -13,18 +13,6 @@
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.exportError {
|
||||
box-sizing: border-box;
|
||||
flex: none;
|
||||
width: 100%;
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
color: var(--dsw-alias-label-danger, var(--dsw-alias-label-primary));
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ledger {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Session-log export browser delivery: safe filename derivation and a native
|
||||
* download handoff that leaves the streamed response outside JavaScript.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { downloadSessionLog, sessionLogZipFilename } from '../src/client/export-log.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('sessionLogZipFilename', () => {
|
||||
it('keeps safe session ids verbatim', () => {
|
||||
expect(sessionLogZipFilename('session-abc_1-2')).toBe('dsh-session-session-abc_1-2.zip')
|
||||
})
|
||||
|
||||
it('neutralizes unsafe id characters that could shape the filename', () => {
|
||||
expect(sessionLogZipFilename('../evil')).toBe('dsh-session-___evil.zip')
|
||||
expect(sessionLogZipFilename('a/b')).toBe('dsh-session-a_b.zip')
|
||||
})
|
||||
|
||||
it('strips dots so a dot-only id cannot shape a dot segment', () => {
|
||||
expect(sessionLogZipFilename('..')).toBe('dsh-session-__.zip')
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadSessionLog', () => {
|
||||
it('hands the descendant-inclusive endpoint directly to the browser', async () => {
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await downloadSessionLog('session/with spaces')
|
||||
|
||||
expect(click).toHaveBeenCalledOnce()
|
||||
const anchor = click.mock.contexts[0] as HTMLAnchorElement
|
||||
const url = new URL(anchor.href)
|
||||
expect(url.pathname).toBe('/api/session.export')
|
||||
expect(url.searchParams.get('sessionId')).toBe('session/with spaces')
|
||||
expect(url.searchParams.get('includeDescendants')).toBe('true')
|
||||
expect(anchor.download).toBe('dsh-session-session_with_spaces.zip')
|
||||
})
|
||||
|
||||
it('rejects when the browser download handoff fails', async () => {
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {
|
||||
throw new Error('download denied')
|
||||
})
|
||||
|
||||
await expect(downloadSessionLog('session-root')).rejects.toThrow('download denied')
|
||||
})
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Trajectory toolbar export button: click dispatch, in-flight disable, and error surfacing. */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { TrajectoryToolbar, type TrajectoryToolbarProps } from '../src/client/TrajectoryToolbar.tsx'
|
||||
import { zh, type TrajectoryKey } from '../src/client/locales.ts'
|
||||
|
||||
/** Test translator pinned to the Simplified Chinese dictionary. */
|
||||
const zhT = (key: LocaleKeysOf<'trajectory'>): string => zh[key as TrajectoryKey] ?? key
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function baseProps(overrides: Partial<TrajectoryToolbarProps> = {}): TrajectoryToolbarProps {
|
||||
return {
|
||||
actualDuration: false,
|
||||
onActualDurationChange: vi.fn(),
|
||||
actualTime: false,
|
||||
onActualTimeChange: vi.fn(),
|
||||
allTurnsCollapsed: false,
|
||||
onToggleAllTurns: vi.fn(),
|
||||
allAssistantsCollapsed: false,
|
||||
onToggleAllAssistants: vi.fn(),
|
||||
searchQuery: '',
|
||||
onSearchQueryChange: vi.fn(),
|
||||
exporting: false,
|
||||
onExport: vi.fn(),
|
||||
exportError: null,
|
||||
t: zhT,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('TrajectoryToolbar export', () => {
|
||||
it('renders the export button and dispatches the export callback on click', () => {
|
||||
const onExport = vi.fn()
|
||||
render(<TrajectoryToolbar {...baseProps({ onExport })} />)
|
||||
const button = screen.getByRole('button', { name: 'Export session log' })
|
||||
fireEvent.click(button)
|
||||
expect(onExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('disables the button while an export is in flight and blocks dispatch', () => {
|
||||
const onExport = vi.fn()
|
||||
render(<TrajectoryToolbar {...baseProps({ exporting: true, onExport })} />)
|
||||
const button = screen.getByRole('button', { name: 'Export session log' }) as HTMLButtonElement
|
||||
expect(button.disabled).toBe(true)
|
||||
fireEvent.click(button)
|
||||
expect(onExport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces an export failure as the button title', () => {
|
||||
render(<TrajectoryToolbar {...baseProps({ exportError: 'Export failed: internal boom' })} />)
|
||||
const button = screen.getByRole('button', { name: 'Export session log' })
|
||||
expect(button.title).toBe('Export failed: internal boom')
|
||||
})
|
||||
})
|
||||
@@ -136,12 +136,6 @@ function standaloneDuration(): Pick<
|
||||
}
|
||||
}
|
||||
|
||||
function standaloneExport(
|
||||
onExport: () => Promise<void> = vi.fn(() => Promise.resolve()),
|
||||
): Pick<ComponentProps<typeof TrajectoryView>, 'exportLog'> {
|
||||
return { exportLog: onExport }
|
||||
}
|
||||
|
||||
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
const store = createSnapshotStore(historySnapshot(nodes))
|
||||
return { store, useSession: bindSnapshotSelector(store) }
|
||||
@@ -253,7 +247,6 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
return {
|
||||
loadOlder: trajectory.loadOlder,
|
||||
setActualDuration: trajectory.setActualDuration,
|
||||
exportLog: trajectory.exportLog,
|
||||
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
|
||||
t: (key: TrajectoryKey) => zh[key],
|
||||
}
|
||||
@@ -1134,7 +1127,6 @@ describe('timeline projection', () => {
|
||||
...standaloneProps([]),
|
||||
...standaloneHistory(historySnapshot([])),
|
||||
...standaloneDuration(),
|
||||
...standaloneExport(),
|
||||
},
|
||||
))
|
||||
expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy()
|
||||
@@ -1142,41 +1134,6 @@ describe('timeline projection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('session log export', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(HTMLAnchorElement.prototype, 'click')
|
||||
})
|
||||
|
||||
it('downloads the host-streamed ZIP with descendants on click', async () => {
|
||||
const clickAnchor = vi.fn()
|
||||
HTMLAnchorElement.prototype.click = clickAnchor
|
||||
const b = await bench(historySnapshot(NODES))
|
||||
mount(b.slots)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Export session log' }))
|
||||
await vi.waitFor(() => { expect(clickAnchor).toHaveBeenCalledOnce() })
|
||||
const anchor = clickAnchor.mock.contexts[0] as HTMLAnchorElement
|
||||
const url = new URL(anchor.href)
|
||||
expect(url.pathname).toBe('/api/session.export')
|
||||
expect(url.searchParams.get('sessionId')).toBe(SID)
|
||||
expect(url.searchParams.get('includeDescendants')).toBe('true')
|
||||
})
|
||||
|
||||
it('surfaces a browser handoff failure in the visible alert bar', async () => {
|
||||
HTMLAnchorElement.prototype.click = vi.fn(() => { throw new Error('download denied') })
|
||||
const b = await bench(historySnapshot(NODES))
|
||||
mount(b.slots)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Export session log' }))
|
||||
await vi.waitFor(() => {
|
||||
const alert = screen.queryByRole('alert')
|
||||
expect(alert).not.toBeNull()
|
||||
expect(alert!.textContent).toContain('download denied')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('TrajectoryView state', () => {
|
||||
it('persists the duration preference through the runtime snapshot-store seam', () => {
|
||||
const firstDuration = createTrajectoryDurationStore()
|
||||
@@ -1187,7 +1144,6 @@ describe('TrajectoryView state', () => {
|
||||
const first = render(
|
||||
<TrajectoryView
|
||||
{...commonProps}
|
||||
{...standaloneExport()}
|
||||
useDuration={bindSnapshotSelector(firstDuration)}
|
||||
setActualDuration={(value) => { firstDuration.set(value) }}
|
||||
/>,
|
||||
@@ -1203,7 +1159,6 @@ describe('TrajectoryView state', () => {
|
||||
render(
|
||||
<TrajectoryView
|
||||
{...commonProps}
|
||||
{...standaloneExport()}
|
||||
useDuration={bindSnapshotSelector(restoredDuration)}
|
||||
setActualDuration={(value) => { restoredDuration.set(value) }}
|
||||
/>,
|
||||
@@ -1228,7 +1183,6 @@ describe('TrajectoryView state', () => {
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
{...standaloneDuration()}
|
||||
{...standaloneExport()}
|
||||
useSession={bindSnapshotSelector(store)}
|
||||
loadOlder={vi.fn(() => Promise.resolve(false))}
|
||||
/>,
|
||||
|
||||
@@ -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/session-query/README.md
|
||||
README.md: 3d5db8da74cb825c701fe50cf518505e9e6bcad9
|
||||
README.zh.md: fc430cbdc4130f77eb8857c4d88a2435ad0cc97f
|
||||
README.md: 90b105d44a2affd8d487db20dc51357425446f8b
|
||||
README.zh.md: 305223e77385f7ee640b54e1d03e8659967da43e
|
||||
|
||||
@@ -8,6 +8,7 @@ This family provides authorized retrieval over live and durable session logs, in
|
||||
|---|---|---|
|
||||
| [`session-query/`](session-query/README.md) | Defines trusted reads, relationship queries, and search operations | `ctx.sessionQuery` |
|
||||
| [`session-query-sqlite/`](session-query-sqlite/README.md) | Implements session queries with SQLite full-text search | `ctx.sessionQuery` |
|
||||
| [`session-export/`](session-export/README.md) | Adds the Web `/export` command, shared browser download state, and result modal over the Host ZIP endpoint | `ctx.sessionExport` |
|
||||
| [`tool-session-query/`](tool-session-query/README.md) | Exposes workspace-authorized session queries to the model | registers on `ctx.tools` |
|
||||
|
||||
The subsystem reference — logical records, bounded reads, traces, filters, result pages — is [docs/subsystems/session-query.md](../../docs/subsystems/session-query.md).
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|---|---|---|
|
||||
| [`session-query/`](session-query/README.md) | 定义可信读取、关系查询和搜索操作 | `ctx.sessionQuery` |
|
||||
| [`session-query-sqlite/`](session-query-sqlite/README.md) | 使用 SQLite 全文搜索实现会话查询 | `ctx.sessionQuery` |
|
||||
| [`session-export/`](session-export/README.md) | 在 Host ZIP 端点之上增加 Web `/export` 命令、共享浏览器下载状态和结果弹窗 | `ctx.sessionExport` |
|
||||
| [`tool-session-query/`](tool-session-query/README.md) | 向模型公开经过工作区授权的会话查询 | 注册到 `ctx.tools` |
|
||||
|
||||
子系统参考——逻辑记录、有界读取、追踪、筛选器、结果页——见 [docs/subsystems/session-query.md](../../docs/subsystems/session-query.md)。
|
||||
|
||||
6
packages/session-query/session-export/README.i18n.yaml
Normal file
6
packages/session-query/session-export/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session-query/session-export/README.md
|
||||
README.md: 3df11c1132715dc590d50b53588f8875015f237b
|
||||
README.zh.md: 008cf433df1104c9f7e45cd04c0e0f256e3682fb
|
||||
48
packages/session-query/session-export/README.md
Normal file
48
packages/session-query/session-export/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# @deepseek-ai/dsh-session-export
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Web Session-log download control over the host-streamed ZIP endpoint owned by `dsh-host-apiproxy`. The Host half registers `/export`; the browser half owns a 111×32 `Session log` action in the Session Header, one download controller, and one modal shared by that button and the slash command. ZIP generation, raw JSONL/zstd reads, descendants, attachments, backpressure, and HTTP error semantics remain owned by the [ApiProxy download implementation](../../host/apiproxy/README.md).
|
||||
|
||||
## Command contract
|
||||
|
||||
| Input | Result |
|
||||
|---|---|
|
||||
| `/export` | Record a human-command lifecycle; the submitting browser receives the local execution acknowledgment and downloads `GET /api/session.export?sessionId=<id>&includeDescendants=true`. |
|
||||
| `/export <path>` | Return an error. Browser downloads choose their destination through the browser's ordinary download behavior. |
|
||||
|
||||
The command is mounted only by the Web bundle. The local `command/executed` acknowledgment triggers the slash download only after a successful `/export` result in the browser that submitted it; other tabs still render the durable command row without repeating the browser side effect. The Header button calls the same controller directly, so both entry paths share in-flight collapsing, cancellation on plugin disposal, HTTP error handling, browser save behavior, and the same Modal.
|
||||
|
||||
The Host download endpoint flushes a live root Session before `readRaw`, so a slash-triggered ZIP includes the `command/run` and `command/done` pair whose acknowledgment started the download. Cold persisted Sessions require no flush.
|
||||
|
||||
The modal reports preparation, download start, or failure. Closing it does not cancel an in-flight download and does not reopen it when that operation later settles. One Session admits one active download at a time; repeated gestures share that operation.
|
||||
|
||||
## Composition
|
||||
|
||||
```yaml
|
||||
- id: session-export
|
||||
name: '@deepseek-ai/dsh-session-export'
|
||||
```
|
||||
|
||||
The Web bundle mounts the package beside `dsh-host-apiproxy`, `dsh-commands`, `dsh-client-ui-command`, and `dsh-client-ui-conversation`. The package contributes its button and modal to the right-aligned `conversation.session.header.utilities` list, independently of the title-adjacent mode, Subagent, and Task entries in `conversation.session.header.actions`; Trajectory carries no export control.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Human `/export` control
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Nothing. `/export` stays on the human-command plane, and the ZIP download does not enter model history.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero. The command creates no model turn.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None. The log-only command lifecycle and browser download do not change the derived request prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The download endpoint requires a persistence backend with a per-Session raw artifact. The shipped JSONL backend supports plaintext and zstd artifacts; SQLite export is not included in this change.
|
||||
- This is a browser download, not a Host-path writer. The browser chooses the local destination; no Host path or native folder action is returned.
|
||||
48
packages/session-query/session-export/README.zh.md
Normal file
48
packages/session-query/session-export/README.zh.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# @deepseek-ai/dsh-session-export
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Web Session 日志下载控制,使用 `dsh-host-apiproxy` 拥有的 Host 流式 ZIP 端点。Host 半包注册 `/export`;浏览器半包在 Session Header 中提供 111×32 的 `Session log` 操作,以及一个供该按钮与斜杠命令共用的下载控制器和弹窗。ZIP 生成、原始 JSONL/zstd 读取、子 Session、附件、背压和 HTTP 错误语义仍由 [ApiProxy 下载实现](../../host/apiproxy/README.md)负责。
|
||||
|
||||
## 命令约定
|
||||
|
||||
| 输入 | 结果 |
|
||||
|---|---|
|
||||
| `/export` | 记录一组用户命令生命周期;提交命令的浏览器收到本地执行确认后,下载 `GET /api/session.export?sessionId=<id>&includeDescendants=true`。 |
|
||||
| `/export <path>` | 返回错误。浏览器下载通过浏览器的普通下载行为选择目标位置。 |
|
||||
|
||||
该命令只由 Web bundle 挂载。只有 `/export` 返回成功时,本地 `command/executed` 确认才会在提交命令的浏览器中触发斜杠下载;其他标签页仍会渲染持久命令行,但不会重复执行浏览器副作用。Header 按钮直接调用同一个控制器,因此两种入口共用并发折叠、插件释放时取消、HTTP 错误处理、浏览器保存行为和同一个 Modal。
|
||||
|
||||
Host 下载端点会在 `readRaw` 前 flush 活动的根 Session,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对。冷持久化 Session 不需要 flush。
|
||||
|
||||
弹窗报告准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载;该操作随后完成时也不会重新打开弹窗。每个 Session 同时只允许一项下载,重复操作会共用该任务。
|
||||
|
||||
## 组合
|
||||
|
||||
```yaml
|
||||
- id: session-export
|
||||
name: '@deepseek-ai/dsh-session-export'
|
||||
```
|
||||
|
||||
Web bundle 将本包与 `dsh-host-apiproxy`、`dsh-commands`、`dsh-client-ui-command` 和 `dsh-client-ui-conversation` 一起挂载。本包把按钮和弹窗贡献到最右侧的 `conversation.session.header.utilities` 列表,与标题旁 `conversation.session.header.actions` 中的模式、Subagent 和 Task 配置项相互独立;Trajectory 不包含导出入口。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 用户 `/export` 控制
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
无。`/export` 留在用户命令平面,ZIP 下载不会进入模型历史。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
为零。该命令不创建模型轮次。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无。仅日志命令生命周期和浏览器下载不会改变派生请求前缀。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- 下载端点要求持久化后端具有逐 Session 原始工件。随附 JSONL 后端支持明文和 zstd 工件;本次改动不包含 SQLite 导出。
|
||||
- 这是浏览器下载,不是 Host 路径写入。目标位置由浏览器选择,不会返回 Host 路径或原生文件夹操作。
|
||||
63
packages/session-query/session-export/package.json
Normal file
63
packages/session-query/session-export/package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-export",
|
||||
"description": "Web Session-log export command and shared download dialog",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": { "access": "restricted" },
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/session-query/session-export"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
|
||||
"./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" },
|
||||
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" },
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["lib/index.js", "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts"],
|
||||
"scripts": { "bundle": "tsdown", "watch": "tsdown --watch" },
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
}
|
||||
}
|
||||
49
packages/session-query/session-export/src/client/Dialog.tsx
Normal file
49
packages/session-query/session-export/src/client/Dialog.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ObservableSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionExportDownloadState } from './controller.ts'
|
||||
import { NS } from './locales.ts'
|
||||
|
||||
/** Browser operations and state injected into the Session Header contribution. */
|
||||
export interface SessionExportDialogInjected {
|
||||
hooks: { sessionExport: ObservableSnapshot<SessionExportDownloadState> }
|
||||
request: (sessionId: SessionId) => Promise<void>
|
||||
dismiss: (sessionId: SessionId) => void
|
||||
}
|
||||
|
||||
export type SessionExportDialogProps =
|
||||
PropsRuntime<'conversation.session.header.actions'>
|
||||
& PropsLocale<typeof NS>
|
||||
& InjectFace<SessionExportDialogInjected>
|
||||
|
||||
/**
|
||||
* Modal shared by the Session Header button and this browser's `/export` command.
|
||||
* @param props - Session runtime, bound controller state, actions, and localized copy.
|
||||
* @returns the modal portal contribution.
|
||||
*/
|
||||
export function SessionExportDialog({
|
||||
sessionId, useSessionExport, dismiss, t,
|
||||
}: SessionExportDialogProps) {
|
||||
const entry = useSessionExport(state => state.bySession[String(sessionId)])
|
||||
|
||||
const status = entry?.status
|
||||
const open = entry?.open === true
|
||||
const error = status === 'error' ? entry?.error || t('dialog.commandFailed') : null
|
||||
const title = status === 'downloading'
|
||||
? t('dialog.preparingTitle')
|
||||
: status === 'success' ? t('dialog.successTitle') : t('dialog.errorTitle')
|
||||
const description = status === 'downloading'
|
||||
? t('dialog.preparingDescription')
|
||||
: status === 'success' ? t('dialog.successDescription') : error ?? t('dialog.commandFailed')
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => { dismiss(sessionId) }}
|
||||
title={title}
|
||||
description={description}
|
||||
closeLabel={t('dialog.close')}
|
||||
footer={<Button variant="primary" onClick={() => { dismiss(sessionId) }}>{t('dialog.close')}</Button>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/* The 111 px design width is a floor so translated labels do not clip. */
|
||||
.sessionLogButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 111px;
|
||||
height: 32px;
|
||||
padding: 6px 12px;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 18px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: transparent;
|
||||
font-family: var(--dsw-font-family);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sessionLogButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.sessionLogButton:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.sessionLogButton span,
|
||||
.sessionLogButton svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sessionLogButton span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { SessionExportDialog, type SessionExportDialogProps } from './Dialog.tsx'
|
||||
import css from './HeaderAction.module.css'
|
||||
|
||||
/**
|
||||
* Render the Session Header export capsule and its shared result dialog.
|
||||
* @param props - Session runtime, download controller, and localized dialog copy.
|
||||
* @returns the persistent Header action and Session-scoped dialog.
|
||||
*/
|
||||
export function SessionExportHeader(props: SessionExportDialogProps): ReactNode {
|
||||
const { sessionId, useSessionExport, request } = props
|
||||
const entry = useSessionExport(state => state.bySession[String(sessionId)])
|
||||
const busy = entry?.status === 'downloading'
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={css.sessionLogButton}
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
onClick={() => { void request(sessionId) }}
|
||||
>
|
||||
<span>Session log</span>
|
||||
<IconDownloadOutline16 size={12} />
|
||||
</button>
|
||||
<SessionExportDialog {...props} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
148
packages/session-query/session-export/src/client/controller.ts
Normal file
148
packages/session-query/session-export/src/client/controller.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/** Browser download state shared by the Session Header button and `/export`. */
|
||||
|
||||
import { createSnapshotStore, type SessionId, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Download phases presented by the shared modal. */
|
||||
export type SessionExportDownloadStatus = 'downloading' | 'success' | 'error'
|
||||
|
||||
/** One Session's current download-dialog state. */
|
||||
export interface SessionExportDownloadEntry {
|
||||
readonly open: boolean
|
||||
readonly status: SessionExportDownloadStatus
|
||||
readonly error: string | null
|
||||
}
|
||||
|
||||
/** Download states keyed by the Session whose Header owns the dialog. */
|
||||
export interface SessionExportDownloadState {
|
||||
bySession: Record<string, SessionExportDownloadEntry | undefined>
|
||||
}
|
||||
|
||||
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>
|
||||
type Save = (blob: Blob, filename: string) => void
|
||||
|
||||
const INITIAL: SessionExportDownloadState = { bySession: {} }
|
||||
|
||||
/**
|
||||
* Collapse an untrusted Session id into the filename convention owned by the host endpoint.
|
||||
* @param sessionId - Session whose archive is downloaded.
|
||||
* @returns one safe browser download filename.
|
||||
*/
|
||||
export function sessionLogZipFilename(sessionId: SessionId): string {
|
||||
return `dsh-session-${String(sessionId).replace(/[^A-Za-z0-9_-]/g, '_')}.zip`
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser save without copying the response blob.
|
||||
* @param blob - complete ZIP response body.
|
||||
* @param filename - browser download filename.
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
setTimeout(() => { URL.revokeObjectURL(url) }, 0)
|
||||
}
|
||||
|
||||
/** Resolve the browser's Host base with the connection carrier's null-origin fallback. */
|
||||
function hostBase(): string {
|
||||
const origin = (globalThis as { location?: { origin?: string } }).location?.origin
|
||||
return origin !== undefined && origin !== 'null' ? origin : 'http://dsh.internal'
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/** Owns one in-flight browser download per Session and publishes modal state. */
|
||||
export class SessionExportDownloadController {
|
||||
/** uSES-safe state source shared by every Session-scoped modal contribution. */
|
||||
readonly store: SnapshotStore<SessionExportDownloadState> = createSnapshotStore(INITIAL)
|
||||
|
||||
private readonly active = new Map<SessionId, { readonly abort: AbortController; readonly done: Promise<void> }>()
|
||||
private disposed = false
|
||||
|
||||
/**
|
||||
* @param fetcher - HTTP carrier used to read the host-streamed ZIP.
|
||||
* @param save - browser save operation.
|
||||
*/
|
||||
constructor(
|
||||
private readonly fetcher: Fetch = (input, init) => fetch(input, init),
|
||||
private readonly save: Save = downloadBlob,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Download one Session tree; concurrent gestures for the same Session share one operation.
|
||||
* @param sessionId - root Session whose ZIP includes descendants and attachments.
|
||||
* @returns after the browser save starts, an error state is published, or a late post-disposal request is ignored.
|
||||
*/
|
||||
download(sessionId: SessionId): Promise<void> {
|
||||
const existing = this.active.get(sessionId)
|
||||
if (existing !== undefined) return existing.done
|
||||
if (this.disposed) return Promise.resolve()
|
||||
const abort = new AbortController()
|
||||
const done = this.run(sessionId, abort.signal).finally(() => {
|
||||
this.active.delete(sessionId)
|
||||
})
|
||||
this.active.set(sessionId, { abort, done })
|
||||
return done
|
||||
}
|
||||
|
||||
/**
|
||||
* Present a command failure without issuing an HTTP request.
|
||||
* @param sessionId - Session whose modal reports the failure.
|
||||
* @param error - stable command failure text.
|
||||
*/
|
||||
fail(sessionId: SessionId, error: string): void {
|
||||
this.publish(sessionId, { open: true, status: 'error', error })
|
||||
}
|
||||
|
||||
/**
|
||||
* Close one Session's dialog without cancelling an in-flight browser download.
|
||||
* @param sessionId - Session whose modal closes.
|
||||
*/
|
||||
dismiss(sessionId: SessionId): void {
|
||||
const current = this.store.getSnapshot().bySession[String(sessionId)]
|
||||
if (current === undefined || !current.open) return
|
||||
this.publish(sessionId, { ...current, open: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort active fetches and reach quiescence.
|
||||
* @returns after every active operation settles.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
const active = [...this.active.values()]
|
||||
for (const operation of active) operation.abort.abort()
|
||||
await Promise.allSettled(active.map(operation => operation.done))
|
||||
}
|
||||
|
||||
private async run(sessionId: SessionId, signal: AbortSignal): Promise<void> {
|
||||
this.publish(sessionId, { open: true, status: 'downloading', error: null })
|
||||
try {
|
||||
const url = new URL('/api/session.export', hostBase())
|
||||
url.searchParams.set('sessionId', sessionId)
|
||||
url.searchParams.set('includeDescendants', 'true')
|
||||
const response = await this.fetcher(url, { signal })
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`)
|
||||
}
|
||||
this.save(await response.blob(), sessionLogZipFilename(sessionId))
|
||||
const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true
|
||||
this.publish(sessionId, { open, status: 'success', error: null })
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) return
|
||||
const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true
|
||||
this.publish(sessionId, { open, status: 'error', error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
private publish(sessionId: SessionId, entry: SessionExportDownloadEntry): void {
|
||||
this.store.update((state) => {
|
||||
state.bySession = { ...state.bySession, [String(sessionId)]: entry }
|
||||
})
|
||||
}
|
||||
}
|
||||
52
packages/session-query/session-export/src/client/index.ts
Normal file
52
packages/session-query/session-export/src/client/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/** Browser plugin owning Session export download state and its shared modal. */
|
||||
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { SessionExportDownloadController } from './controller.ts'
|
||||
import type { SessionExportDialogInjected } from './Dialog.tsx'
|
||||
import { SessionExportHeader } from './HeaderAction.tsx'
|
||||
import { en, NS, zh, type SessionExportKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
sessionExport: SessionExportDownloadController
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
'session-export': SessionExportKey
|
||||
}
|
||||
}
|
||||
|
||||
export type { SessionExportDownloadEntry, SessionExportDownloadState } from './controller.ts'
|
||||
|
||||
export const inject = ['slots', 'locale']
|
||||
|
||||
/**
|
||||
* Provide the download controller and mount its modal into the Session Header.
|
||||
* @param ctx - browser context carrying slots and locale services.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const controller = new SessionExportDownloadController()
|
||||
ctx.provide('sessionExport', controller)
|
||||
ctx.effect(() => async () => { await controller.dispose() }, 'session-export: browser download lifecycle')
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'session-export: browser dictionaries')
|
||||
ctx.on('command/executed', (sessionId, commandName, result) => {
|
||||
if (commandName === 'export' && result.kind === 'success') void controller.download(sessionId)
|
||||
})
|
||||
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
|
||||
name: 'conversation.session.header.utilities',
|
||||
id: 'session-export',
|
||||
locale: NS,
|
||||
inject: (): SessionExportDialogInjected => ({
|
||||
hooks: { sessionExport: controller.store },
|
||||
request: (sessionId: SessionId) => controller.download(sessionId),
|
||||
dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) },
|
||||
}),
|
||||
}, SessionExportHeader))
|
||||
}
|
||||
|
||||
export type { SessionExportDialogInjected, SessionExportDialogProps } from './Dialog.tsx'
|
||||
27
packages/session-query/session-export/src/client/locales.ts
Normal file
27
packages/session-query/session-export/src/client/locales.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** Locale namespace owned by Session export browser feedback. */
|
||||
export const NS = 'session-export'
|
||||
|
||||
/** Simplified-Chinese Session export strings. */
|
||||
export const zh = {
|
||||
'dialog.preparingTitle': '正在导出 Session',
|
||||
'dialog.preparingDescription': '正在准备包含当前 Session、子 Session 和附件的 ZIP 文件。',
|
||||
'dialog.successTitle': 'Session 导出已开始下载',
|
||||
'dialog.successDescription': '浏览器正在下载 Session ZIP 文件。',
|
||||
'dialog.errorTitle': 'Session 导出失败',
|
||||
'dialog.close': '关闭',
|
||||
'dialog.commandFailed': '无法启动 Session 导出。',
|
||||
} as const
|
||||
|
||||
/** English Session export strings. */
|
||||
export const en: Record<keyof typeof zh, string> = {
|
||||
'dialog.preparingTitle': 'Exporting Session',
|
||||
'dialog.preparingDescription': 'Preparing a ZIP containing this Session, its sub-Sessions, and attachments.',
|
||||
'dialog.successTitle': 'Session download started',
|
||||
'dialog.successDescription': 'The browser is downloading the Session ZIP.',
|
||||
'dialog.errorTitle': 'Session export failed',
|
||||
'dialog.close': 'Close',
|
||||
'dialog.commandFailed': 'Could not start the Session export.',
|
||||
}
|
||||
|
||||
/** Stable locale keys consumed by the shared modal. */
|
||||
export type SessionExportKey = keyof typeof zh
|
||||
6
packages/session-query/session-export/src/css-modules.d.ts
vendored
Normal file
6
packages/session-query/session-export/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
26
packages/session-query/session-export/src/index.ts
Normal file
26
packages/session-query/session-export/src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** Web Session-log download command over the host endpoint owned by ApiProxy. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { CommandResult } from '@deepseek-ai/dsh-commands'
|
||||
|
||||
export const name = 'session-export'
|
||||
export const inject = ['commands']
|
||||
|
||||
const REQUESTED: CommandResult = {
|
||||
kind: 'success',
|
||||
text: 'Session log download requested.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Web-only `/export` command that the browser download plugin observes.
|
||||
* @param ctx - Host context carrying the human-command registry.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.effect(() => ctx.commands.register({
|
||||
name: 'export',
|
||||
description: 'Download this Session log as a ZIP archive',
|
||||
handler: invocation => Promise.resolve(invocation.rawInput.trim() === ''
|
||||
? REQUESTED
|
||||
: { kind: 'error', text: 'The Web /export command does not accept a path.' }),
|
||||
}), 'session-export: command')
|
||||
}
|
||||
22
packages/session-query/session-export/src/invariant.ts
Normal file
22
packages/session-query/session-export/src/invariant.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Package invariant companion for `@deepseek-ai/dsh-session-export`. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-export'
|
||||
|
||||
export const name = 'session-export-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: the command registry owns lifecycle pairing and ApiProxy owns ZIP integrity. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Host context carrying the invariant registry.
|
||||
* @returns the registration disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { SessionExportHeader } from '../src/client/HeaderAction.tsx'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
const SID = 'session-export-apply' as SessionId
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
function declare(slots: SlotsService): () => void {
|
||||
return slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
|
||||
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
const declaration = declare(slots)
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, declaration, fiber }
|
||||
}
|
||||
|
||||
describe('session-export browser plugin', () => {
|
||||
it('provides one controller and removes its Header contribution on disposal', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 500 })))
|
||||
const b = await bench()
|
||||
expect(inject).toEqual(['slots', 'locale'])
|
||||
expect(b.ctx.sessionExport).toBeDefined()
|
||||
expect(b.slots.entries('conversation.session.header.actions')).toHaveLength(0)
|
||||
const entry = b.slots.entries('conversation.session.header.utilities')[0]
|
||||
expect(entry?.component).toBe(SessionExportHeader)
|
||||
expect(entry?.options).toMatchObject({ id: 'session-export' })
|
||||
const injected = (entry?.inject as unknown as () => import('../src/client/Dialog.tsx').SessionExportDialogInjected)()
|
||||
b.ctx.sessionExport.fail(SID, 'failed')
|
||||
expect(b.ctx.sessionExport.store.getSnapshot().bySession[SID]?.status).toBe('error')
|
||||
injected.dismiss(SID)
|
||||
expect(b.ctx.sessionExport.store.getSnapshot().bySession[SID]?.open).toBe(false)
|
||||
await injected.request(SID)
|
||||
expect(b.ctx.sessionExport.store.getSnapshot().bySession[SID]?.status).toBe('error')
|
||||
|
||||
await b.fiber.dispose()
|
||||
expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('downloads only for an export execution acknowledged by this browser client', async () => {
|
||||
const fetcher = vi.fn(async () => new Response('', { status: 500 }))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const first = await bench()
|
||||
const second = await bench()
|
||||
|
||||
first.ctx.emit('command/executed', SID, 'plan', { kind: 'success' })
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
first.ctx.emit('command/executed', SID, 'export', { kind: 'error', text: 'bad path' })
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
first.ctx.emit('command/executed', SID, 'export', { kind: 'success' })
|
||||
await vi.waitFor(() => {
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(first.ctx.sessionExport.store.getSnapshot().bySession[SID]?.status).toBe('error')
|
||||
})
|
||||
expect(second.ctx.sessionExport.store.getSnapshot().bySession[SID]).toBeUndefined()
|
||||
|
||||
await first.fiber.dispose()
|
||||
await second.fiber.dispose()
|
||||
})
|
||||
|
||||
it('re-registers after the declaring Header slot collapses and returns', async () => {
|
||||
const b = await bench()
|
||||
b.declaration()
|
||||
expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0)
|
||||
const redeclare = declare(b.slots)
|
||||
await Promise.resolve()
|
||||
expect(b.slots.entries('conversation.session.header.utilities')[0]?.component).toBe(SessionExportHeader)
|
||||
redeclare()
|
||||
await b.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { CommandDefinition, CommandInvocation } from '@deepseek-ai/dsh-commands'
|
||||
import * as SessionExport from '../src/index.ts'
|
||||
|
||||
describe('/export Web download command', () => {
|
||||
it('registers one pathless command and removes it with the plugin fiber', async () => {
|
||||
let descriptor: CommandDefinition | undefined
|
||||
const ctx = new Context()
|
||||
ctx.provide('commands', {
|
||||
register(next: CommandDefinition) {
|
||||
descriptor = next
|
||||
return () => { descriptor = undefined }
|
||||
},
|
||||
} as never)
|
||||
const fiber = await ctx.plugin(SessionExport)
|
||||
|
||||
expect(descriptor).toMatchObject({
|
||||
name: 'export',
|
||||
description: 'Download this Session log as a ZIP archive',
|
||||
})
|
||||
const invoke = (rawInput: string) => descriptor?.handler({ rawInput } as CommandInvocation)
|
||||
await expect(invoke('')).resolves.toEqual({
|
||||
kind: 'success', text: 'Session log download requested.',
|
||||
})
|
||||
await expect(invoke(' output.zip')).resolves.toEqual({
|
||||
kind: 'error', text: 'The Web /export command does not accept a path.',
|
||||
})
|
||||
|
||||
await fiber.dispose()
|
||||
expect(descriptor).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
downloadBlob, SessionExportDownloadController, sessionLogZipFilename,
|
||||
} from '../src/client/controller.ts'
|
||||
|
||||
const SID = 'session-export-controller' as SessionId
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('SessionExportDownloadController', () => {
|
||||
it('downloads the host ZIP and publishes one shared success state', async () => {
|
||||
const fetcher = vi.fn(async () => new Response('zip', { status: 200 }))
|
||||
const save = vi.fn()
|
||||
const controller = new SessionExportDownloadController(fetcher, save)
|
||||
|
||||
await controller.download(SID)
|
||||
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit]
|
||||
expect(url.pathname).toBe('/api/session.export')
|
||||
expect(url.searchParams.get('sessionId')).toBe(SID)
|
||||
expect(url.searchParams.get('includeDescendants')).toBe('true')
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal)
|
||||
expect(save).toHaveBeenCalledWith(expect.any(Object), 'dsh-session-session-export-controller.zip')
|
||||
expect((save.mock.calls[0]?.[0] as Blob).size).toBe(3)
|
||||
expect(controller.store.getSnapshot().bySession[SID]).toEqual({
|
||||
open: true, status: 'success', error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses concurrent gestures and preserves a dismissed dialog', async () => {
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const fetcher = vi.fn(() => response.promise)
|
||||
const controller = new SessionExportDownloadController(fetcher, vi.fn())
|
||||
|
||||
const first = controller.download(SID)
|
||||
const second = controller.download(SID)
|
||||
expect(first).toBe(second)
|
||||
controller.dismiss(SID)
|
||||
response.resolve(new Response('zip', { status: 200 }))
|
||||
await first
|
||||
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(controller.store.getSnapshot().bySession[SID]?.open).toBe(false)
|
||||
controller.dismiss(SID)
|
||||
})
|
||||
|
||||
it('publishes HTTP, transport, and command failures without leaking rejections', async () => {
|
||||
const http = new SessionExportDownloadController(
|
||||
async () => new Response('backend unavailable', { status: 500 }), vi.fn(),
|
||||
)
|
||||
await http.download(SID)
|
||||
expect(http.store.getSnapshot().bySession[SID]).toEqual({
|
||||
open: true,
|
||||
status: 'error',
|
||||
error: 'Export failed: HTTP 500 backend unavailable',
|
||||
})
|
||||
|
||||
const transport = new SessionExportDownloadController(async () => { throw 'offline' }, vi.fn())
|
||||
await transport.download(SID)
|
||||
expect(transport.store.getSnapshot().bySession[SID]?.error).toBe('offline')
|
||||
|
||||
transport.fail(SID, 'command failed')
|
||||
expect(transport.store.getSnapshot().bySession[SID]?.error).toBe('command failed')
|
||||
transport.dismiss('absent' as SessionId)
|
||||
|
||||
const emptyDetail = new SessionExportDownloadController(
|
||||
async () => ({
|
||||
ok: false, status: 503, text: async () => { throw new Error('body unavailable') },
|
||||
}) as unknown as Response,
|
||||
vi.fn(),
|
||||
)
|
||||
await emptyDetail.download(SID)
|
||||
expect(emptyDetail.store.getSnapshot().bySession[SID]?.error).toBe('Export failed: HTTP 503')
|
||||
})
|
||||
|
||||
it('aborts active fetches on disposal and rejects later requests', async () => {
|
||||
let signal: AbortSignal | undefined
|
||||
const fetcher = vi.fn((_input: string | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
|
||||
signal = init?.signal ?? undefined
|
||||
signal?.addEventListener('abort', () => {
|
||||
reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'))
|
||||
}, { once: true })
|
||||
}))
|
||||
const controller = new SessionExportDownloadController(fetcher, vi.fn())
|
||||
const pending = controller.download(SID)
|
||||
|
||||
await controller.dispose()
|
||||
|
||||
await expect(pending).resolves.toBeUndefined()
|
||||
expect(signal?.aborted).toBe(true)
|
||||
await expect(controller.download(SID)).resolves.toBeUndefined()
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
it('uses the null-origin fallback and default browser operations', async () => {
|
||||
vi.stubGlobal('location', { origin: 'null' })
|
||||
const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('zip'))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:default')
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
const controller = new SessionExportDownloadController()
|
||||
|
||||
await controller.download(SID)
|
||||
|
||||
expect((fetcher.mock.calls[0]?.[0] as URL).origin).toBe('http://dsh.internal')
|
||||
})
|
||||
|
||||
it('defaults dialog openness when state is externally cleared before settlement', async () => {
|
||||
const success = Promise.withResolvers<Response>()
|
||||
const successful = new SessionExportDownloadController(() => success.promise, vi.fn())
|
||||
const successRun = successful.download(SID)
|
||||
successful.store.set({ bySession: {} })
|
||||
success.resolve(new Response('zip'))
|
||||
await successRun
|
||||
expect(successful.store.getSnapshot().bySession[SID]?.open).toBe(true)
|
||||
|
||||
const failure = Promise.withResolvers<Response>()
|
||||
const failing = new SessionExportDownloadController(() => failure.promise, vi.fn())
|
||||
const failureRun = failing.download(SID)
|
||||
failing.store.set({ bySession: {} })
|
||||
failure.reject(new Error('failed after clear'))
|
||||
await failureRun
|
||||
expect(failing.store.getSnapshot().bySession[SID]?.open).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('browser download helpers', () => {
|
||||
it('sanitizes the archive filename and revokes the object URL after the click', () => {
|
||||
vi.useFakeTimers()
|
||||
const create = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:session')
|
||||
const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
expect(sessionLogZipFilename('a/b' as SessionId)).toBe('dsh-session-a_b.zip')
|
||||
downloadBlob(new Blob(['zip']), 'archive.zip')
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
expect(click).toHaveBeenCalledOnce()
|
||||
expect(revoke).not.toHaveBeenCalled()
|
||||
vi.runAllTimers()
|
||||
expect(revoke).toHaveBeenCalledWith('blob:session')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SessionExportDownloadController } from '../src/client/controller.ts'
|
||||
import { SessionExportDialog } from '../src/client/Dialog.tsx'
|
||||
import type { SessionExportDialogProps } from '../src/client/Dialog.tsx'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
const SID = 'session-export-dialog' as SessionId
|
||||
|
||||
function bench(
|
||||
controller = new SessionExportDownloadController(
|
||||
async () => new Response('zip', { status: 200 }), vi.fn(),
|
||||
),
|
||||
) {
|
||||
const dismiss = vi.fn((sessionId: SessionId) => { controller.dismiss(sessionId) })
|
||||
function useSessionExport<T>(selector: (state: ReturnType<typeof controller.store.getSnapshot>) => T): T {
|
||||
return useSyncExternalStore(
|
||||
listener => controller.store.subscribe(listener),
|
||||
() => selector(controller.store.getSnapshot()),
|
||||
)
|
||||
}
|
||||
const t = (key: keyof typeof en): string => en[key]
|
||||
const props = { sessionId: SID, useSessionExport, dismiss, t } as unknown as SessionExportDialogProps
|
||||
const view = render(<SessionExportDialog {...props} />)
|
||||
return { controller, dismiss, view }
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('SessionExportDialog', () => {
|
||||
it('shows a controller failure and closes it without reading Session history', async () => {
|
||||
const b = bench()
|
||||
act(() => { b.controller.fail(SID, 'toolbar failed') })
|
||||
const dialog = await b.view.findByRole('dialog', { name: 'Session export failed' })
|
||||
expect(dialog.textContent).toContain('toolbar failed')
|
||||
const close = b.view.getAllByRole('button', { name: 'Close' })[0]
|
||||
if (close === undefined) throw new Error('Session export dialog has no close button')
|
||||
fireEvent.click(close)
|
||||
await waitFor(() => { expect(b.dismiss).toHaveBeenCalledWith(SID) })
|
||||
})
|
||||
|
||||
it('renders the in-flight state and the settled browser download state', async () => {
|
||||
let release!: (response: Response) => void
|
||||
const pending = new Promise<Response>((resolve) => { release = resolve })
|
||||
const controller = new SessionExportDownloadController(() => pending, vi.fn())
|
||||
const b = bench(controller)
|
||||
|
||||
const download = controller.download(SID)
|
||||
expect(await b.view.findByRole('dialog', { name: 'Exporting Session' })).toBeTruthy()
|
||||
release(new Response('zip', { status: 200 }))
|
||||
await download
|
||||
expect(await b.view.findByRole('dialog', { name: 'Session download started' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('uses fallback copy when a failure has no detail', async () => {
|
||||
const b = bench()
|
||||
act(() => { b.controller.fail(SID, '') })
|
||||
const dialog = await b.view.findByRole('dialog', { name: 'Session export failed' })
|
||||
expect(dialog.textContent).toContain('Could not start the Session export.')
|
||||
const close = b.view.getAllByRole('button', { name: 'Close' }).at(-1)
|
||||
if (close === undefined) throw new Error('Session export dialog has no footer action')
|
||||
fireEvent.click(close)
|
||||
await waitFor(() => { expect(b.dismiss).toHaveBeenCalledWith(SID) })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SessionExportDownloadController } from '../src/client/controller.ts'
|
||||
import { SessionExportHeader } from '../src/client/HeaderAction.tsx'
|
||||
import type { SessionExportDialogProps } from '../src/client/Dialog.tsx'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
const SID = 'session-export-header' as SessionId
|
||||
|
||||
function bindSessionExport(controller: SessionExportDownloadController) {
|
||||
return function useSessionExport<T>(selector: (state: ReturnType<typeof controller.store.getSnapshot>) => T): T {
|
||||
return useSyncExternalStore(
|
||||
listener => controller.store.subscribe(listener),
|
||||
() => selector(controller.store.getSnapshot()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function bench() {
|
||||
const controller = new SessionExportDownloadController(async () => new Response('zip'), vi.fn())
|
||||
const request = vi.fn((sessionId: SessionId) => controller.download(sessionId))
|
||||
const dismiss = vi.fn((sessionId: SessionId) => { controller.dismiss(sessionId) })
|
||||
const useSessionExport = bindSessionExport(controller)
|
||||
const props = {
|
||||
sessionId: SID,
|
||||
useSessionExport,
|
||||
request,
|
||||
dismiss,
|
||||
t: (key: keyof typeof en): string => en[key],
|
||||
} as unknown as SessionExportDialogProps
|
||||
const view = render(<SessionExportHeader {...props} />)
|
||||
return { controller, request, view }
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Session export Header action', () => {
|
||||
it('renders the 111×32 text capsule and downloads through the shared controller', async () => {
|
||||
const b = bench()
|
||||
const button = b.view.getByRole('button', { name: 'Session log' })
|
||||
expect(button.querySelector('svg')).not.toBeNull()
|
||||
fireEvent.click(button)
|
||||
await waitFor(() => { expect(b.request).toHaveBeenCalledWith(SID) })
|
||||
expect(await b.view.findByRole('dialog', { name: 'Session download started' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disables the capsule while either entry path downloads this Session', async () => {
|
||||
const b = bench()
|
||||
let release!: (response: Response) => void
|
||||
const pending = new Promise<Response>((resolve) => { release = resolve })
|
||||
const controller = new SessionExportDownloadController(() => pending, vi.fn())
|
||||
const useSessionExport = bindSessionExport(controller)
|
||||
b.view.rerender(<SessionExportHeader {...({
|
||||
sessionId: SID,
|
||||
useSessionExport,
|
||||
request: (sessionId: SessionId) => controller.download(sessionId),
|
||||
dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) },
|
||||
t: (key: keyof typeof en): string => en[key],
|
||||
} as unknown as SessionExportDialogProps)} />)
|
||||
|
||||
const download = controller.download(SID)
|
||||
const button = b.view.getByRole('button', { name: 'Session log' })
|
||||
await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('true') })
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true)
|
||||
release(new Response('zip'))
|
||||
await download
|
||||
await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('false') })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { apply, inject, name } from '../src/invariant.ts'
|
||||
|
||||
describe('@deepseek-ai/dsh-session-export/invariant', () => {
|
||||
it('registers the package-owned empty companion', async () => {
|
||||
const register = vi.fn(() => vi.fn())
|
||||
const ctx = new Context()
|
||||
ctx.provide('invariants', { register })
|
||||
const dispose = await apply(ctx)
|
||||
expect(name).toBe('session-export-invariant')
|
||||
expect(inject).toEqual(['invariants'])
|
||||
expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-session-export', expect.any(Function))
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as SessionExport from '@deepseek-ai/dsh-session-export'
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.fiber.dispose()
|
||||
context = undefined
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
describe('session-export real Loader composition', () => {
|
||||
it('discovers and executes /export through the assembled command plane', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-session-export-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-session'",
|
||||
"- name: '@deepseek-ai/dsh-commands'",
|
||||
"- name: '@deepseek-ai/dsh-session-export'",
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-session', SessionStore],
|
||||
['@deepseek-ai/dsh-commands', CommandService],
|
||||
['@deepseek-ai/dsh-session-export', SessionExport],
|
||||
])
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
await context.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(configPath).href },
|
||||
})
|
||||
await context.loader.await()
|
||||
|
||||
const session = context.sessions.create(SessionId('loader-session-export'), { meta: { createdAt: 1 } })
|
||||
const agent = { session, status: 'idle', options: {} } as unknown as Agent
|
||||
expect(context.commands.list(agent)).toContainEqual({
|
||||
name: 'export', description: 'Download this Session log as a ZIP archive',
|
||||
})
|
||||
const execution = await context.commands.execute(agent, '/export', new AbortController().signal)
|
||||
expect(execution?.result).toEqual({ kind: 'success', text: 'Session log download requested.' })
|
||||
expect(session.events.map(event => event.type)).toEqual(['command/run', 'command/done'])
|
||||
expect(session.deriveMessages()).toEqual([])
|
||||
})
|
||||
})
|
||||
22
packages/session-query/session-export/tsconfig.client.json
Normal file
22
packages/session-query/session-export/tsconfig.client.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"src/client",
|
||||
"src/css-modules.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../interaction/commands" },
|
||||
{ "path": "../../client/locale" },
|
||||
{ "path": "../../client/runtime" },
|
||||
{ "path": "../../client/ui-command" },
|
||||
{ "path": "../../client/ui-conversation" },
|
||||
{ "path": "../../client/ui-primitives" },
|
||||
{ "path": "../../client/ui-slots" }
|
||||
]
|
||||
}
|
||||
17
packages/session-query/session-export/tsconfig.host.json
Normal file
17
packages/session-query/session-export/tsconfig.host.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/index.ts",
|
||||
"src/invariant.ts"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../interaction/commands" },
|
||||
{ "path": "../../support/invariants" }
|
||||
]
|
||||
}
|
||||
7
packages/session-query/session-export/tsconfig.json
Normal file
7
packages/session-query/session-export/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.host.json" },
|
||||
{ "path": "./tsconfig.client.json" }
|
||||
]
|
||||
}
|
||||
3
packages/session-query/session-export/tsdown.config.ts
Normal file
3
packages/session-query/session-export/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-session-export', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user