Merge origin/master at 834d9dbbce into skill catalog hot refresh

This commit is contained in:
Tianyi Cui
2026-07-29 22:41:57 +08:00
86 changed files with 1554 additions and 194 deletions

View File

@@ -1,6 +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
2026-07-19-gui-layering-and-rpc-protocol.md: 63db4786adcc007d09b7a58824a59f4d1e1e8be1
2026-07-19-gui-layering-and-rpc-protocol.zh.md: b3037ceb8c172925581d2862ea675e53a7f8c54e
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md
2026-07-19-gui-layering-and-rpc-protocol.md: b9718da4725316c64686adef24827e2984d8723d
2026-07-19-gui-layering-and-rpc-protocol.zh.md: 2add148054e8f97c65600cd719fb4f8e0283f52d

View File

@@ -165,7 +165,7 @@ One example row (the table structure is the reading key):
|---|---|---|---|
| `session.list` | `{ cursor?: string }` (cursor is a reserved seat, unimplemented) | `{ items: SessionSummary[] }` | persisted sessions, updatedAt descending; v1 builds no index |
The remaining methods (`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`) are not re-copied here — signatures are the source of truth; see `api/sessions.ts`, `api/host.ts`, and `RpcMethodMap`.
The remaining methods (`session.create`/`session.history`/`session.rename`/`session.prompt`/`session.cancel`/`host.describe`) are not re-copied here — signatures are the source of truth; see `api/sessions.ts`, `api/host.ts`, and `RpcMethodMap`.
### Frames (server→client, named unions)
@@ -187,7 +187,7 @@ The remaining frame types are not re-copied here; the full unions are `MuxFrame`
- **Cold sessions resume implicitly**: when `history`/`prompt` hits an unattached session the impl auto-resumes, deduplicating concurrent triggers with an in-flight table; attachment status is not exposed to clients (`running` already covers it).
- **Approvals/questions**: the requested frame mints a stable rpcId on acceptance; first answer wins, and the host's in-memory pending table (keyed by rpcId) is the only referee; after a mux reopen, still-pending requested frames replay after the subscribed frame (rpcId reused verbatim — refresh recovery). The audit events `approval/asked`/`decided` continue through the durable log — frames = the live control plane, events = the durable audit. **Status**: the contract and frame types are shipped; the host-side pending table/wire answerer is unimplemented (`respond` in `api-proxy.ts` is a stub, always `not-pending`); PendingCard v1 is display-only.
- **No protocol version**: client and host release bound together; `host.describe` has no protocolVersion field; introduce one when an independently released client appears.
- **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`.
- **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`. (`session.rename` graduated from this list: it appends a user-source `session/title` event.)
## The client carrier: the AbstractApiClient class family (`fetch/client.ts`)

View File

@@ -163,7 +163,7 @@ export type ResponseValue<K> =
|---|---|---|---|
| `session.list` | `{ cursor?: string }`cursor 留座不实现) | `{ items: SessionSummary[] }` | 已持久化 sessionupdatedAt 倒序v1 不建索引 |
其余方法(`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即事实源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。
其余方法(`session.create`/`session.history`/`session.rename`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即事实源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。
### 帧server→client具名 union
@@ -185,7 +185,7 @@ export type ResponseValue<K> =
- **冷 session 隐式 resume**`history`/`prompt` 命中未 attach 的 session 时 impl 自动 resume并发触发用在途表去重attach 与否不对客暴露(`running` 已覆盖)。
- **审批/问答**requested 帧受理时 mint 稳定 rpcId先到先赢host 内存 pending 表keyed by rpcId是唯一裁判mux 重开后在 subscribed 帧后重放仍 pending 的 requested 帧rpcId 原样复用,刷新恢复)。审计事件 `approval/asked`/`decided` 照旧走 durable 日志——帧=live 控制面,事件=durable 审计。**现状**:契约与帧类型已 shippedhost 侧 pending 表/wire answerer 未实现(`api-proxy.ts` 的 `respond` 是 stub恒回 `not-pending`PendingCard v1 只展示。
- **不设协议版本**client 与 host 绑定发布,`host.describe` 无 protocolVersion 字段;出现独立发布的 client 时再引入。
- **预留接缝纪律**map 只含已实现方法,未知 method 在信封 parse 即 fail loud`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。
- **预留接缝纪律**map 只含已实现方法,未知 method 在信封 parse 即 fail loud`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。`session.rename` 已从本清单毕业:追加 user 来源的 `session/title` 事件。)
## 客户端载体AbstractApiClient 类体系(`fetch/client.ts`

View 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 .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md
2026-07-29-sticky-composer-conversation-scroll.md: 7ceae95dafffdb756ef49bb5612cd4e711eb59ca
2026-07-29-sticky-composer-conversation-scroll.zh.md: d925d82f94635b5fe67b0be119c041d003def393

View File

@@ -0,0 +1,29 @@
# Agent Note: Fixed header, sticky composer inside the transcript scrollport
Status: implemented
English | [中文](2026-07-29-sticky-composer-conversation-scroll.zh.md)
## Problem
The active conversation column split scrolling: the chat (and trajectory) view owned `overflow-y: auto`, while the composer stack sat as a sibling below that scrollport. A wheel gesture over the stats line or input therefore hit a non-scrolling region and did nothing — the transcript only moved when the pointer was over the message list. Long drafts made it worse: the textarea is itself a scrollport, so wheel over the composer could be trapped there. The session header must occupy the top of the column as ordinary chrome (not `position: sticky` inside the scrollport), while the composer must stick to the bottom of the same scrollport as the transcript so wheel over the footer moves the flow.
## Decision
While a session exists, `ConversationRoot` always supplies a `wrapActiveBody` owner callback that wraps the view ring in a `data-conversation-scroll` body and places a `data-composer-seat` around the whole `'conversation.composer'` chain output (fallback + elected overlay siblings from `overlay: true`). Active CSS sticks that seat with `position: sticky; bottom: 0` so Question/Approval takeovers stay visible when the user is not pinned to the floor; hero CSS centers the fallback stack inside the scroll body. `ConversationSession` keeps a chrome-hidden header + body shell while blank so that tree seat does not change on the first send. The session header remains `flex: none` column chrome above the scrollport when visible. ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set `overflow: visible` and resolve bottom-follow / prepend anchoring through `closest('[data-conversation-scroll]')`.
Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host.
## Alternatives considered
**Sticky header and sticky composer inside one column scrollport.** Rejected for the header: it must occupy the top as fixed layout chrome, not participate in the scrollport's sticky layer.
**Fixed flex-none composer below the scrollport with wheel forwarding.** Rejected: the product requires the composer to stick inside the transcript scrollport so the footer is part of that scroll hit-testing surface, not a sibling that only forwards deltas.
**Portal the composer into ChatView's scroller.** Rejected: the composer is shared across view tabs; the wrap target is the Session body owned by the resident shell.
**Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned.
## Consequences
Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft.

View File

@@ -0,0 +1,29 @@
# Agent Note: 固定标题栏sticky 编辑器位于 transcript 滚动容器内
Status: implemented
[English](2026-07-29-sticky-composer-conversation-scroll.md) | 中文
## Problem
活跃会话列把滚动拆成两段:聊天(以及 trajectory视图自有 `overflow-y: auto`,编辑器栈则作为该滚动容器的兄弟节点坐在下方。指针落在统计行或输入区上时,滚轮打在不可滚动区域上因而毫无效果——只有指针在消息列表上时 transcript 才会移动。草稿变长时更糟textarea 本身也是滚动容器,编辑器上的滚轮可能被截在那里。会话标题栏必须以普通 chrome 占据列顶(不能在滚动容器内 `position: sticky`),而编辑器必须与 transcript 贴在同一滚动容器底部,使页脚上的滚轮能带动内容流动。
## Decision
只要存在会话,`ConversationRoot` 就会始终提供 `wrapActiveBody` owner 回调,将视图环包进 `data-conversation-scroll` 主体,并用 `data-composer-seat` 包住整条 `'conversation.composer'` chain 输出(`overlay: true` 下的 fallback 与选举出的 overlay 兄弟节点)。活跃阶段 CSS 以 `position: sticky; bottom: 0` 钉住该 seat使用户未贴底时 QuestionApproval 接管仍可见hero CSS 在滚动主体内居中 fallback 栈。`ConversationSession` 在 blank 时保留隐藏 chrome 的 header + body 壳,使首次发送时树座位不变。可见时会话标题栏仍是滚动容器之上的 `flex: none` 列 chrome。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller位于宿主下时设为 `overflow: visible`,并通过 `closest('[data-conversation-scroll]')` 解析贴底跟随与前置锚定。
会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。
## Alternatives considered
**标题栏与编辑器都在同一列滚动容器内 sticky。** 标题栏否决:它必须作为固定布局 chrome 占据顶部,而不是参与滚动容器的 sticky 层。
**滚动容器下方 flex-none 固定编辑器并转发滚轮。** 否决:产品要求编辑器 sticky 在 transcript 滚动容器内,使页脚成为该滚动命中面的一部分,而不是仅转发增量的兄弟节点。
**把编辑器 portal 进 ChatView 的 scroller。** 否决:编辑器跨视图标签共享;包装目标是常驻壳拥有的 Session 主体。
**把 StatsLine 留在 ChatView 消息列下方。** 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。
## Consequences
在页脚上滚轮会滚动 transcript可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。hero → active 保持同一 textarea DOM 节点assembled slash-flow 快照)以及 InputHub 草稿。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md
2026-07-21-log-backed-session-titles.md: 1bd58e35ec625fb0b04c0c119ce425ff30a64881
2026-07-21-log-backed-session-titles.zh.md: 37ec95efbca334f71d19d2bc3e18c22d50d9b5fb
2026-07-21-log-backed-session-titles.md: 81ac687c6f55dd0ca1eaeb9d84c811edcfe17b5c
2026-07-21-log-backed-session-titles.zh.md: b0c7e9d76a1b9365fa16dcb223b390b5aec3e174

View File

@@ -36,9 +36,13 @@ Model providers require explicit word, CJK-character, input-byte, output-token,
Automatic provider failures are nonfatal warnings and retain the latest title. Explicit refresh failures reject to the caller. Output must be non-empty text with unique ordered seqs drawn from the fixed request; the service normalizes and byte-limits it before log acceptance.
### Explicit rename
`rename(session, title)` accepts a user title synchronously: it normalizes the text under the accepted-title byte limit, supersedes in-flight automatic work, and appends a `session/title` event with the third source kind, `user`. A user-sourced latest title pins the session: `onUserMessage` schedules no automatic revision while it stands, under either cadence. An explicit `refresh()` remains the deliberate unpin — it appends a provider or fallback event over the pinned one whenever a replacement title is derivable (an underivable fallback, e.g. under a tiny byte cap, leaves the pin standing). The Web host exposes this as the `session.rename` unary method (resuming cold sessions first) and returns the normalized title plus its event seq so the client settles its `title` projection cell before the push frame arrives.
### Forks and consumers
A fork inherits seed title events unchanged, like the rest of its source log. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages.
A fork inherits seed title events unchanged, like the rest of its source log — a pinned (user-sourced) title stays pinned in the child until an explicit refresh. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages.
`ctx.sessionQuery.readTitle()` folds one live-preferred or persisted log without loading titles during `listSessions()`. The TUI uses the latest title as its header subtitle and sets the terminal window title to `<session title> — <configured product title>` after terminal-safe rendering. The Web host folds the same log state into a validated mux control frame after each attached-session subscription baseline and immediately after forwarding a live raw title event. The browser retains only newer title event seqs even when the control frame precedes list or session-instance creation; sidebar labels, search, breadcrumbs, and the browser title then react to the projected revision. `session.list` remains metadata-only, so a cold persisted session uses the cwd basename or id until opening or resuming it attaches the log. The browser title uses `<session title> — <existing HTML title>` only for a selected titled session and otherwise preserves the product title. Consumers reporting agent completion use the core `findLastMessageTurnEnd()` fold, so a later between-turn title record cannot replace the preceding message-triggered outcome.
@@ -59,4 +63,4 @@ A fork inherits seed title events unchanged, like the rest of its source log. Th
- A fallback appears immediately. Each fresh Web session adds one first-message auxiliary call; other compositions choose whether better titles justify model cost and whether later prompts should retitle a session.
- Auxiliary request records and late accepted titles consume event seqs without consuming turn numbers, so persistence exposes both attempted dispatches and accepted updates even though model history and KV-cache identity do not change.
- One provider and monotonic per-session revisions make disposal, supersession, and stale-result rejection explicit, at the cost of leaving multi-strategy precedence to a composite provider.
- Manual rename, deletion, generated-versus-user precedence, search, and list indexing remain outside the capability.
- Deletion (unpinning without an explicit refresh), search, and list indexing remain outside the capability.

View File

@@ -36,9 +36,13 @@ Status: implemented
自动提供方故障只会发出非致命警告,并保留最新标题。显式刷新失败则会向调用方返回拒绝。输出必须是非空文本,并包含来自固定请求、唯一且有序的 seq服务会在日志接受前对其进行规范化并施加字节限制。
### 显式重命名
`rename(session, title)` 同步接受用户标题:按已接受标题的字节上限规范化文本、取代在途自动工作,并追加一条第三种来源 `user``session/title` 事件。最新标题来源为 user 即钉住该会话:只要它还在,`onUserMessage` 在任一节奏下都不再安排自动修订。显式 `refresh()` 仍是有意的解钉手段——只要能推导出替代标题它就在被钉住的标题之上追加提供方或回退事件推导不出回退标题时例如字节上限过小钉住状态保持不变。Web host 将其暴露为 `session.rename` unary 方法(冷会话先恢复),并返回规范化后的标题及其事件 seq使 client 在推送帧到达前就结算自己的 `title` 投影格。
### Fork 与消费方
与源日志的其他部分相同fork 会原样继承作为种子的标题事件。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订,并使用继承的合格消息和新增的合格消息。
与源日志的其他部分相同fork 会原样继承作为种子的标题事件——被钉住user 来源)的标题在子会话中保持钉住,直到显式 refresh。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订,并使用继承的合格消息和新增的合格消息。
`ctx.sessionQuery.readTitle()` 会折叠一份实时优先或已持久化的日志,而不会在 `listSessions()` 期间加载标题。TUI 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 `<session title> — <configured product title>`。Web host 会在每个已附加会话的订阅基线之后,以及转发实时原始标题事件后立即,将同一份日志状态折叠为经过校验的 mux 控制帧。即使控制帧先于列表或会话实例创建抵达,浏览器也只保留标题事件 seq 较新的版本;侧边栏标签、搜索、面包屑和浏览器标题会随投影后的修订更新。`session.list` 仍只包含元数据,因此尚未打开的持久化会话会继续以 cwd 基名或 id 作为回退,直至打开或恢复会话时附加其日志。浏览器仅在选中已有标题的会话时将标题设置为 `<session title> — <existing HTML title>`,否则保留产品标题。报告 agent 完成情况的消费方使用核心的 `findLastMessageTurnEnd()` 折叠逻辑,因此后续的轮次间标题记录无法取代此前由消息触发的结果。
@@ -59,4 +63,4 @@ Status: implemented
- 回退标题会立即出现。每个新建的 Web 会话都会增加一次针对首消息的辅助调用;其他组合可以自行决定更优标题是否值得模型成本,以及后续提示词是否需要重新生成会话标题。
- 辅助请求记录和延迟接受的标题会占用事件 seq但不会占用轮次编号因此持久化会同时呈现尝试发起的调用与已接受的更新尽管模型历史和 KV 缓存标识保持不变。
- 单个提供方和每会话单调递增的修订号让释放、取代和陈旧结果拒绝行为明确可见,但多策略优先级必须由复合提供方负责。
- 手动重命名、删除、生成标题与用户标题的优先级、搜索和列表索引不在此功能范围内。
- 删除(不经显式 refresh 的解钉)、搜索和列表索引不在此功能范围内。

View File

@@ -0,0 +1,130 @@
// @vitest-environment jsdom
// Session row actions in the assembled fixture app: Rename opens the
// browser-owned dialog and settles the title from the unary response.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] },
]
const bundles = new Map(PLUGINS.map(plugin => [
plugin.url,
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
]))
interface FixtureWindow extends Window {
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
__ModuleLoader__?: unknown
}
class ResizeObserverStub {
observe(): void {}
disconnect(): void {}
unobserve(): void {}
}
const win = window as FixtureWindow
let unmount: (() => void) | undefined
beforeEach(() => {
localStorage.clear()
history.replaceState(null, '', '/?fixture')
document.title = 'DeepSeek Harness'
const root = document.createElement('div')
root.id = 'root'
document.body.appendChild(root)
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
setTimeout(() => { callback(0) }, 0) as unknown as number)
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
})
afterEach(() => {
act(() => { unmount?.() })
unmount = undefined
cleanup()
delete win.__DSH_BOOT__
delete win.__ModuleLoader__
delete (globalThis as Record<string, unknown>).__fxTiming
document.body.innerHTML = ''
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
document.title = ''
history.replaceState(null, '', '/')
vi.unstubAllGlobals()
})
async function bootApp(): Promise<void> {
const root = document.querySelector<HTMLElement>('#root')
if (root === null) throw new Error('snapshot root missing')
act(() => {
const entry = new AppWebEntry(root, {
fetchBundle: (url) => {
const code = bundles.get(url)
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
},
executeBundle: (code) => { (0, eval)(code) },
})
void entry.run()
unmount = () => { entry.dispose() }
})
await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
}
/** The session row element carrying the given visible label. */
function rowOf(label: string): HTMLElement {
const tree = screen.getByRole('tree', { name: 'Sessions' })
const row = within(tree).getByText(label).closest<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error(`session row "${label}" missing`)
return row
}
/** Open the row's ... menu and click one action. The anchor button is
* CSS-hover-revealed (real stylesheets are injected in this assembled run,
* so role queries filter it as hidden); target it directly. */
function pickRowAction(label: string, action: string): void {
const anchor = rowOf(label).querySelector<HTMLElement>(`button[aria-label="Session actions for ${label}"]`)
if (anchor === null) throw new Error(`row menu anchor for "${label}" missing`)
fireEvent.click(anchor)
fireEvent.click(screen.getByRole('menuitem', { name: action, hidden: true }))
}
it('renames a session through the row-menu dialog; the row settles from the unary response', async () => {
await bootApp()
const sourceLabel = 'Fixture 历史会话'
await screen.findByText(sourceLabel)
pickRowAction(sourceLabel, 'Rename')
const input = await screen.findByLabelText('Session name')
expect((input as HTMLInputElement).value).toBe(sourceLabel)
fireEvent.change(input, { target: { value: ' 分叉 实验记录 ' } })
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
// Host-side normalization collapses whitespace; the dialog closes on
// acceptance and the row re-labels without any push-frame wait.
const renamed = '分叉 实验记录'
await waitFor(() => { expect(screen.queryByLabelText('Session name')).toBeNull() })
await screen.findByText(renamed)
const tree = screen.getByRole('tree', { name: 'Sessions' })
expect(within(tree).queryByText(sourceLabel)).toBeNull()
const rows = [...tree.querySelectorAll('[role="treeitem"]')].map(row => ({
label: row.textContent?.replace(/\s+/g, ' ').trim() ?? '',
}))
await expect(`${JSON.stringify(rows, null, 2)}\n`)
.toMatchFileSnapshot('./snapshots/session-actions/rename-rows.json')
})

View File

@@ -0,0 +1,14 @@
[
{
"label": "fixture4 sessions"
},
{
"label": "New Sessionnow"
},
{
"label": "分叉 实验记录now"
},
{
"label": "fixture2min"
}
]

View File

@@ -1203,7 +1203,7 @@ export interface Config {
}
```
Source: [`packages/session-title/session-title/src/index.ts:75`](../packages/session-title/session-title/src/index.ts)
Source: [`packages/session-title/session-title/src/index.ts:79`](../packages/session-title/session-title/src/index.ts)
## `@deepseek-ai/dsh-session-title-all-messages-llm`

View File

@@ -1604,6 +1604,19 @@ Log-backed title fold plus asynchronous fallback generation.
*/
get(session: Session): SessionTitleSnapshot | undefined
/**
* Accept an explicit user title. Appends a `session/title` event with the
* `user` source, which pins the title: in-flight automatic generation is
* superseded and later user messages schedule none (an explicit
* {@link SessionTitleService.refresh} remains the deliberate unpin).
* @param session - exact live session to rename.
* @param title - raw user input; normalized before acceptance.
* @returns the accepted title snapshot.
* @throws {SessionTitleInvalidError} when the title normalizes to empty.
* @throws {Error} when the session is not live or the service is disposed.
*/
rename(session: Session, title: string): SessionTitleSnapshot
/**
* Explicitly retry the registered provider, or materialize the built-in
* fallback when no provider is registered.
@@ -1624,7 +1637,7 @@ register(provider: SessionTitleProvider): () => Promise<void>
Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](../core-data-structures/session-title.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md)
Source: [`packages/session-title/session-title/src/index.ts:240`](../../packages/session-title/session-title/src/index.ts)
Source: [`packages/session-title/session-title/src/index.ts:261`](../../packages/session-title/session-title/src/index.ts)
## `ctx.skills` — `SkillService`

View File

@@ -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 docs/core-data-structures/session-title.md
session-title.md: 33efc911c0ca1ae94dc4ded74676e5c32a73bdd5
session-title.zh.md: a4b95a726d2bc89a13d14f1daa2f825cd5aa91b1
session-title.md: fff1aa1f6be45d0cfc4d7f6a9527ccb93561618f
session-title.zh.md: 73821b07c6be40d10d0961dd79b7c06bcadb7d0b

View File

@@ -34,6 +34,10 @@ type SessionTitleSource =
readonly provider: SessionTitleProviderId
readonly model?: SessionTitleModelProvenance
}
| {
/** Explicit user rename: pins the title — automatic generation stops scheduling. */
readonly kind: 'user'
}
```
```ts type-equiv
@@ -41,9 +45,9 @@ type SessionTitleSource =
interface SessionTitleEventData {
/** Normalized non-empty title text. */
readonly title: string
/** Exact human `user/message` seqs used to derive this title. */
/** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */
readonly messageSeqs: number[]
/** Built-in fallback or registered-provider provenance. */
/** Built-in fallback, registered-provider, or explicit-user provenance. */
readonly source: SessionTitleSource
}
```

View File

@@ -34,6 +34,10 @@ type SessionTitleSource =
readonly provider: SessionTitleProviderId
readonly model?: SessionTitleModelProvenance
}
| {
/** Explicit user rename: pins the title — automatic generation stops scheduling. */
readonly kind: 'user'
}
```
```ts type-equiv
@@ -41,9 +45,9 @@ type SessionTitleSource =
interface SessionTitleEventData {
/** Normalized non-empty title text. */
readonly title: string
/** Exact human `user/message` seqs used to derive this title. */
/** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */
readonly messageSeqs: number[]
/** Built-in fallback or registered-provider provenance. */
/** Built-in fallback, registered-provider, or explicit-user provenance. */
readonly source: SessionTitleSource
}
```

View File

@@ -66,7 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| --- | --- | --- |
| `commands/changed` | `runtime` (`emit`) | `ui-command` |
| `connection/reset` | `runtime` (`emit`) | `ui-command` |
| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/plugin` | - | `hmr`, `modules`, `webserver` |
| `internal/status` | - | [`agent`](../packages/core/agent) |
| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` |

View File

@@ -416,7 +416,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
Types: [SessionTitleEventData](core-data-structures/session-title.md)
Source: [`packages/session-title/session-title/src/index.ts:96`](../packages/session-title/session-title/src/index.ts)
Source: [`packages/session-title/session-title/src/index.ts:100`](../packages/session-title/session-title/src/index.ts)
#### `session/title-llm-request` — log-only

View File

@@ -1021,6 +1021,27 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication')
return ok(request, { sessionId: created.sessionId })
},
rename: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
const { sessionId, title } = request.payload
const normalized = title.trim().replace(/\s+/g, ' ')
if (normalized.length === 0) {
return err(request, {
code: 'title-invalid',
message: 'session title must contain visible characters',
details: { sessionId },
})
}
// The append emits the session/event and its session/projection frame
// (host parallel); the unary response settles the caller first.
append(sessionId, {
type: 'session/title',
data: { title: normalized, messageSeqs: [], source: { kind: 'user' } },
})
const appended = logOf(sessionId).at(-1) as SessionEvent
return ok(request, { title: normalized, seq: appended.seq })
},
history: async (request) => {
const log = logs.get(request.payload.sessionId) ?? []
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
@@ -1564,6 +1585,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.history': return this.api.sessions.history(request)
case 'session.models': return this.api.sessions.models(request)
case 'session.selectModel': return this.api.sessions.selectModel(request)
case 'session.rename': return this.api.sessions.rename(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'host.describe': return this.api.host.describe(request)

View File

@@ -45,6 +45,7 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
() => Promise.resolve(ok({
@@ -96,6 +97,7 @@ export class FakeApiClient implements IApiClient {
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}

View File

@@ -464,6 +464,47 @@ describe('createFixtureApi', () => {
expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed'])
})
it('session.rename covers not-found, blank title, and the accepted append + title frame', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const framesPromise = (async () => {
const frames: MuxFrame[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
frames.push(envelope.payload)
if (frames.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === '重命名')) abort.abort()
}
return frames
})()
await new Promise(resolve => setTimeout(resolve, 10))
const missing = await api.sessions.rename(req({ sessionId: sid('fx-void'), title: 'x' }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'fx-void' } } })
const blank = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: ' ' }))
expect(blank.result).toMatchObject({ ok: false, error: { code: 'title-invalid', details: { sessionId: 'fx-alpha' } } })
const renamed = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: ' 重命名 ' }))
if (!renamed.result.ok) throw new Error('rename failed')
expect(renamed.result.value.title).toBe('重命名')
const acceptedSeq = renamed.result.value.seq
// The response seq addresses the appended title event (the client plane
// has no session/title in its event union — titles ride the projection —
// so the event is located by seq and its payload checked structurally).
const history = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 100 }))
if (!history.result.ok) throw new Error('history failed')
const appended = history.result.value.events.find(entry => entry.event.seq === acceptedSeq)
expect(appended?.event).toMatchObject({
type: 'session/title',
data: { title: '重命名', messageSeqs: [], source: { kind: 'user' } },
})
// Beyond the subscribe-time baseline replay, the append emitted exactly
// one title projection frame carrying the new value at the response seq.
const frames = await framesPromise
const titleFrames = frames.filter(f => f.type === 'session/projection' && f.key === 'title' && f.sessionId === sid('fx-alpha') && f.value === '重命名')
expect(titleFrames).toHaveLength(1)
expect(titleFrames[0]).toMatchObject({ seq: acceptedSeq })
})
it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => {
const api = createFixtureApi()
const wsid = 'fx-ws-fixture' as WorkspaceId

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: d283cf19572f4888d17884472ea0d2272109de7f
README.zh.md: b2d479e1ba277738390de2122c295ce44c77b1e0
README.md: b51cc0276d8635ea9faa506e30246a107c1c1418
README.zh.md: 4b2248d875ae37f1b848c51a0009d2497c6b3e61

View File

@@ -22,7 +22,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## Session title projection
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title.
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op.
## Session model selection

View File

@@ -22,7 +22,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## Session 标题投影
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值直到打开或恢复会话促使主机折叠并投影由日志支撑的标题。
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值直到打开或恢复会话促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
## 会话模型选择

View File

@@ -41,6 +41,13 @@ export interface ISession {
* @returns acceptance, or the business error.
*/
cancel(): Promise<RpcResult<{ accepted: true }>>
/**
* Rename this session (explicit user title; pins it against automatic
* regeneration).
* @param title - raw title text (the host normalizes acceptance).
* @returns the normalized accepted title and its event seq, or the business error.
*/
rename(title: string): Promise<RpcResult<{ title: string; seq: number }>>
/**
* Extend the history window backwards (older messages pagination).
* @returns completion; failures land in snapshot.openState/loadingOlder.

View File

@@ -252,6 +252,25 @@ export class Session implements SessionFace {
return result
}
/**
* Rename: contract session.rename 1:1. On success settle the 'title'
* projection cell from the response's `{title, seq}` under the store's
* higher-seq-wins rule (the push frame arriving later is a no-op replay),
* so the list row and any useProjection('title') reader update without
* waiting for the mux frame.
* @param title - raw title text (the host normalizes acceptance).
* @returns the rename result (normalized accepted title + title event seq).
*/
async rename(title: string): Promise<RpcResult<{ title: string; seq: number }>> {
try {
const { result } = await this.api.sessions.rename({ sessionId: this.sessionId, title })
if (result.ok) this.projections.apply('title', result.value.title, result.value.seq)
return result
} catch (error) {
return transportError(error)
}
}
/**
* Execute one slash-command line against this session's agent — pure
* admission semantics (the host executor durably logs the lifecycle;

View File

@@ -63,6 +63,7 @@ export class FakeApiClient implements IApiClient {
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
@@ -115,6 +116,7 @@ export class FakeApiClient implements IApiClient {
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
selectModel: (payload: { provider: string; model: string }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}

View File

@@ -301,6 +301,32 @@ describe('prompt and cancel errors', () => {
})
})
describe('rename', () => {
it('settles the title projection cell from the unary response (higher-seq-wins vs the push frame)', async () => {
const { api, session } = makeSession()
api.onRename = () => Promise.resolve(ok({ title: '正名', seq: 7 }))
const result = await session.rename(' 正名 ')
expect(result).toMatchObject({ ok: true, value: { title: '正名', seq: 7 } })
expect(api.callsOf('session.rename')).toMatchObject([{ sessionId: SID, title: ' 正名 ' }])
expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
// A stale lower-seq apply (the push-frame path routes into this same
// store) must not roll the settled value back.
session.projections.apply('title', '旧名', 3)
expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
})
it('returns the business error untouched and folds a transport throw to internal', async () => {
const { api, session } = makeSession()
api.onRename = () => Promise.resolve(err({ code: 'title-invalid', message: 'empty', details: { sessionId: SID } }))
const rejected = await session.rename(' ')
expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } })
expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined()
api.onRename = () => Promise.reject(new Error('rename transport down'))
const folded = await session.rename('x')
expect(folded).toMatchObject({ ok: false, error: { code: 'internal' } })
})
})
describe('pending interactions', () => {
it('adds approval/question on requested and removes them on resolved', async () => {
const { session } = makeSession()

View File

@@ -108,6 +108,13 @@ export class FixtureSession implements SessionFace {
throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `rename` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
rename(): never {
throw new Error(`test session "${this.sessionId}": rename is not stubbed — supply it on the fixture's session face`)
}
}
/** One live test session: fixture-derived stores plus its minted scope state. */

View File

@@ -470,6 +470,7 @@ describe('fixture session face', () => {
expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
expect(() => bare.command()).toThrow(/command is not stubbed/)
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
expect(() => bare.rename()).toThrow(/rename is not stubbed/)
await runtime.dispose()
})

View File

@@ -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: 275c8097ebbc2a0fd1356c77e027064d3454f8d6
README.zh.md: 13f54215d21f72b17bf08313d67e5f3a95cd1c33
README.md: 8a8002ef75299579d7df1eb562a7e8bfaf2aeb2a
README.zh.md: 27e6ab94d4d1cee7fcb7943e0f07c4de2a2f7503

View File

@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant and the todo row), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store.
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、输入区 dock队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约api-contracts v3 §7 加 slot 终端设计store seatprops share
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock与输入区一同 sticky 的会话统计行)、输入区 dock队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约api-contracts v3 §7 加 slot 终端设计store seatprops share
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`投影而来。聊天视图是该包自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。

View File

@@ -17,6 +17,7 @@ import type { IConversation } from './service.ts'
import { InputHub } from './input/hub.ts'
import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { StatsLine } from './chat/StatsLine.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
@@ -238,6 +239,9 @@ export function apply(ctx: Context): void {
},
}, ChatView)
// Session stats stick with the composer (composer.dock = stats-line family).
slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0 }, StatsLine)
// Class-plugin mount (packages/AGENTS.md service form): the service
// registers itself as `conversation` and lives on its own child fiber.
// Mounted AFTER the chat entry register above — construction guarantee for

View File

@@ -1,6 +1,8 @@
/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool
runs) via the column gap and between consecutive tool rows via the group
gap. Input padding cap rides the skeleton. */
gap. Input padding cap rides the skeleton. Under
`[data-conversation-scroll]` the column host owns overflow and this view
is ordinary flow (see ConversationRoot active-phase rules). */
.root {
position: relative;
@@ -17,6 +19,18 @@
padding: 16px 24px;
}
:global([data-conversation-scroll]) .root {
flex: 0 0 auto;
min-height: auto;
height: auto;
}
:global([data-conversation-scroll]) .scroll {
overflow: visible;
flex: 0 0 auto;
min-height: auto;
}
/* Message column: 736px fixed width, centered on the same axis as the
input box; the scroller itself stays full-bleed. */
.column {
@@ -113,16 +127,34 @@
opacity: 0.6;
}
/* Back-to-bottom: 34px circular icon button at the column's right edge. */
.toBottom {
position: absolute;
right: max(24px, calc((100% - 736px) / 2));
/* Back-to-bottom: zero-height sticky slot so the control does not extend
scrollHeight; the button translates up into the viewport. Under the
conversation host, clearance sits above the sticky composer stack. */
.toBottomSlot {
position: sticky;
bottom: 16px;
width: 34px;
height: 34px;
/* Above the sticky composer (z-index 7) so the control stays clickable and
visible over the input card. */
z-index: 8;
height: 0;
display: flex;
justify-content: flex-end;
padding-right: max(0px, calc((100% - 736px) / 2));
pointer-events: none;
}
:global([data-conversation-scroll]) .toBottomSlot {
/* Clears the sticky composer stack (stats + docks + input card). */
bottom: 168px;
}
.toBottom {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
margin-top: -34px;
padding: 0;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 100px;
@@ -130,6 +162,7 @@
background: var(--dsw-alias-button-floating-fill);
box-shadow: var(--dsw-shadow-lv2);
cursor: pointer;
pointer-events: auto;
}
.toBottom:hover {

View File

@@ -1,11 +1,16 @@
// ChatView: the default conversation view — message flow with user bubbles,
// assistant narration, tool summary rows grouped into step runs, pending
// cards, paging, bottom-follow, and the session stats line under the flow
// (chrome dissolved into the view: the footer is part of what a chat view
// IS, not registration metadata). Pure component registered directly; its
// registration declares the keyed 'conversation.chat.toolview' hole, so tool
// rows render through the props renderSlot share (entryKey = tool name,
// GenericToolCard as the render-site fallback).
// cards, paging, and bottom-follow. Session stats live on
// 'conversation.composer.dock' (sticky with the composer). Pure component
// registered directly; its registration declares the keyed
// 'conversation.chat.toolview' hole, so tool rows render through the props
// renderSlot share (entryKey = tool name, GenericToolCard as the render-site
// fallback).
//
// Scroll: when nested under `[data-conversation-scroll]` (active conversation
// column), that host is the scrollport and this view is flow content; when
// mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and
// prepend anchoring always target the resolved scrollport.
//
// Render economics (architecture RFC performance model): the list parent
// subscribes to snapshot segments that do NOT change per streaming chunk
@@ -17,7 +22,7 @@
// memoized rows never churns them.
import {
memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react'
import type {
CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
@@ -30,11 +35,15 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem } from './MessageItem.tsx'
import { StatsLine } from './StatsLine.tsx'
import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24
/** Active column host when present; otherwise the view-local scroller. */
function scrollerOf(from: HTMLElement): HTMLElement {
return (from.closest('[data-conversation-scroll]')) ?? from
}
type OpenFile = (path: string) => void
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
@@ -244,26 +253,34 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const firstSeqRef = useRef<number | null>(null)
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(null)
/** Flow tip signature — follow-scroll only when this moves, never on a
* scroll-driven at-bottom chrome re-render (that was snapping inertial
* scrolls the rest of the way to the floor). */
const followSigRef = useRef<string | null>(null)
const firstSeq = nodes[0]?.seq ?? null
const lastItem = items[items.length - 1]
const lastKey = lastItem?.key ?? null
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}`
const toBottom = (el: HTMLDivElement): void => {
const toBottom = (el: HTMLElement): void => {
el.scrollTop = el.scrollHeight
atBottomRef.current = true
setAtBottom(true)
}
useLayoutEffect(() => {
const el = listRef.current
const local = listRef.current
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
if (el === null) return
if (local === null) return
const el = scrollerOf(local)
// Open completed: jump to the bottom once.
if (openState === 'open' && !openedRef.current) {
openedRef.current = true
toBottom(el)
firstSeqRef.current = firstSeq
lastKeyRef.current = lastItem?.key ?? null
lastKeyRef.current = lastKey
followSigRef.current = followSig
return
}
// Prepend (head seq decreased): compensate by the height delta.
@@ -272,42 +289,65 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
anchorRef.current = null
firstSeqRef.current = firstSeq
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastItem?.key ?? null
lastKeyRef.current = lastKey
followSigRef.current = followSig
return
}
firstSeqRef.current = firstSeq
// Own words must be visible: a new trailing user node force-scrolls
// (send lives in the composer, so arrival is detected here, not armed there).
const lastKey = lastItem?.key ?? null
const appendedUser = lastKey !== lastKeyRef.current
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
const tipMoved = followSigRef.current !== followSig
lastKeyRef.current = lastKey
if (appendedUser || atBottomRef.current) toBottom(el)
followSigRef.current = followSig
// Follow new flow content while pinned; do NOT re-pin on every render
// merely because atBottomRef is true (scroll threshold → setState → snap).
if (appendedUser || (tipMoved && atBottomRef.current)) toBottom(el)
})
const onScroll = (): void => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */
if (el === null) return
const onScrollRef = useRef(() => {})
onScrollRef.current = () => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
if (local === null) return
const el = scrollerOf(local)
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
}
// Bind scroll to the resolved scrollport (host or local) once per mount.
useEffect(() => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
if (local === null) return
const el = scrollerOf(local)
const onScroll = (): void => { onScrollRef.current() }
el.addEventListener('scroll', onScroll, { passive: true })
return () => { el.removeEventListener('scroll', onScroll) }
}, [])
// Follow streaming growth the parent never re-renders for (stable ref).
// The ref starts null and is assigned every render, so the placeholder
// initializer a function initial value would need never exists.
const followRef = useRef<(() => void) | null>(null)
followRef.current = () => {
const el = listRef.current
if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight
const local = listRef.current
if (local !== null && atBottomRef.current) {
const el = scrollerOf(local)
el.scrollTop = el.scrollHeight
}
}
const onGrow = useRef(() => followRef.current?.()).current
const loadOlderAnchored = (): void => {
const el = listRef.current
const local = listRef.current
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
if (local !== null) {
const el = scrollerOf(local)
anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
}
loadOlder()
}
@@ -350,7 +390,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
return (
<div className={css.root}>
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
<div ref={listRef} className={css.scroll}>
<div className={css.column}>
{openState === 'loading' && <div className={css.hint}></div>}
{openState === 'error' && <div className={css.openError}>{openErrorMessage}</div>}
@@ -388,22 +428,23 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnDots />}
</div>
{!atBottom && (
<div className={css.toBottomSlot}>
<button
type="button"
className={css.toBottom}
aria-label="回到底部"
onClick={() => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
if (local !== null) toBottom(scrollerOf(local))
}}
>
<IconChevronDownOutline14 />
</button>
</div>
)}
</div>
<StatsLine useSession={useSession} />
{!atBottom && (
<button
type="button"
className={css.toBottom}
aria-label="回到底部"
onClick={() => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
if (el !== null) toBottom(el)
}}
>
<IconChevronDownOutline14 />
</button>
)}
</div>
)
}

View File

@@ -1,4 +1,6 @@
// Settled-node identity prevents stream-delta updates from rerendering this row.
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
import { memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
@@ -49,7 +51,7 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
}
}
/** Props: the conversation-snapshot selector hook (handed down by ChatView). */
/** Props: the conversation-snapshot selector (dock registration or unit mount). */
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {

View File

@@ -117,6 +117,18 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Owner share of the strict session content seat. */
export interface ConversationSessionOwnerProps {
/**
* Wrap the view ring in the transcript scrollport that also hosts the
* sticky composer seat (whole `'conversation.composer'` chain output).
* Supplied for every real session (hero/settling/active) so the composer
* keeps one tree seat across the blank → active flip; the header stays
* outside that wrapper as ordinary column chrome (`flex: none`), while
* active CSS sticks the seat to the bottom of the same scrollport so wheel
* over the footer scrolls the flow.
* @param view - the session view-ring content (null while blank chrome is hidden).
* @returns the scrollport containing `view` and the sticky composer seat.
*/
wrapActiveBody?: (view: ReactNode) => ReactNode
}
/**

View File

@@ -17,6 +17,12 @@
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
/* Blank hero/settling: keep the header node mounted (stable Session tree for
the wrapActiveBody composer) without taking column space. */
.headerHidden {
display: none;
}
.crumbRow {
display: flex;
align-items: center;
@@ -127,6 +133,46 @@
flex-direction: column;
}
/* Common seat for the composer chain (fallback + elected overlay siblings). */
.composerSeat {
display: flex;
flex: none;
flex-direction: column;
}
/* Active phase: header is ordinary column chrome above the scrollport (not
sticky). The scroll body holds the transcript and the sticky composer seat
so wheel over the footer moves the flow. */
.root[data-phase='active'] {
overflow: hidden;
}
.root[data-phase='active'] .header {
flex: none;
}
.scrollBody {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
.root[data-phase='active'] .viewArea {
flex: 1 0 auto;
min-height: auto;
}
.root[data-phase='active'] .composerSeat {
position: sticky;
bottom: 0;
/* Above markdown CodeBlock sticky banners (z-index 6) so the footer never
paints under a sticking code header while scrolling. */
z-index: 7;
background: var(--dsw-alias-bg-base);
}
/* Hero phase: the composer stack (hero chrome + workspace row + card) is
flex-centered in the column; composer phase docks it at the bottom. Flex,
NOT absolute+transform: a transform would make this box the containing
@@ -165,12 +211,15 @@
padding-left: 8px;
}
.root[data-phase='hero'] {
/* Hero: the composer sits inside the session scroll body; center there so
the tree seat matches active (sticky footer) without a Root remount. */
.root[data-phase='hero'] .scrollBody {
justify-content: center;
overflow-y: auto;
}
/* Settling (session replaying, hero/docked unknown): keep the composer
/* Settling (session replaying, hero/docked unknown): keep the composer seat
mounted but invisible so no wrong layout flashes before the phase lands. */
.root[data-phase='settling'] .composerStack {
.root[data-phase='settling'] .composerSeat {
visibility: hidden;
}

View File

@@ -2,7 +2,7 @@
// chain stay mounted across no-session/session transitions. Only the inert
// input body swaps for the strict session InputBar.
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
@@ -113,24 +113,53 @@ export function ConversationRoot({
{hero && <HeroGlow className={css.heroGlow} />}
{hero && <HeroShell />}
{hero && heroWorkspaceRow}
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
{/* Stats band above the input-dock strips so the prior ChatView footer
order (stats → todo/queue → card) is preserved under the sticky stack. */}
{!hero && zone !== undefined && renderSlot('conversation.composer.dock', zone)}
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
{inputBar}
</div>
)
const phase = settling ? 'settling' : hero ? 'hero' : 'active'
const composer = renderSlotChain(
'conversation.composer',
{ interactions: pending },
{ fallback: composerBar, overlay: true },
)
// Sticky wraps the whole chain output (fallback + elected overlay), not
// only `.composerStack`: overlay:true renders those as siblings, and sticky
// on the fallback alone would leave Question/Approval panels at the content
// end off-screen when the user is not pinned to the floor.
const composerSeat = (
<div className={css.composerSeat} data-composer-seat="">
{composer}
</div>
)
// Header stays column chrome above this scrollport; the sticky composer
// seat lives inside it with the transcript. Always wrap while a session
// exists (hero/settling/active) so the composer keeps one tree seat across
// the blank → active flip — relocating it only in active remounted the textarea.
const wrapActiveBody = (view: ReactNode): ReactNode => (
<div className={css.scrollBody} data-conversation-scroll="">
{view}
{composerSeat}
</div>
)
return (
<div className={css.root} data-phase={settling ? 'settling' : hero ? 'hero' : 'active'}>
<div className={css.root} data-phase={phase}>
{/* Mounted for every real session, hero included: ConversationSession
renders no chrome while blank but owns the draft-persistence mirror
bind — unmounting it in the hero would lose pre-first-send text on
a refresh or scope rebuild. */}
{sessionId !== undefined && renderSlot('conversation.session', {})}
{renderSlotChain(
'conversation.composer',
{ interactions: pending },
{ fallback: composerBar, overlay: true },
keeps a chrome-hidden shell while blank and owns the draft-
persistence mirror bind — unmounting it in the hero would lose
pre-first-send text on a refresh or scope rebuild. */}
{sessionId !== undefined && renderSlot(
'conversation.session',
{ wrapActiveBody },
)}
{sessionId === undefined ? composerSeat : null}
</div>
)
}

View File

@@ -1,6 +1,6 @@
/** Strict per-session conversation content: header, view ring, and chat store bindings. */
import { useEffect, useSyncExternalStore } from 'react'
import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
import clsx from 'clsx'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
@@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
export function ConversationSession({
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror, open,
renderSlot, views, bindDraftMirror, open, wrapActiveBody,
}: ConversationSessionProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
@@ -44,52 +44,67 @@ export function ConversationSession({
// the machine mirror, not this seed effect.
}, [inputActions])
if (blank && composerPhase === 'blank') return null
// Blank hero/settling: keep the same header + body tree shape so a
// wrapActiveBody-hosted composer keeps its DOM identity across the first
// send (hero → active). Chrome is hidden; the draft-persistence mirror
// still runs because this component stays mounted.
const hideChrome = blank && composerPhase === 'blank'
const view: ReactNode = hideChrome ? null : (
<div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>
)
return (
<>
<header className={css.header}>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label="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>}
<header
className={clsx(css.header, hideChrome && css.headerHidden)}
aria-hidden={hideChrome || undefined}
>
{!hideChrome && (
<>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label="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>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{tabs.map(viewTab => (
<button
key={viewTab.id}
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
role="tab"
aria-selected={viewTab.id === active?.id}
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
onClick={() => { actions.setView(viewTab.id) }}
>
{summary.displayTitle}
{viewTab.label}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
</div>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{tabs.map(view => (
<button
key={view.id}
type="button"
role="tab"
aria-selected={view.id === active?.id}
className={clsx(css.tab, view.id === active?.id && css.tabActive)}
onClick={() => { actions.setView(view.id) }}
>
{view.label}
</button>
))}
</div>
))}
</div>
)}
</>
)}
</header>
<div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>
{wrapActiveBody !== undefined ? wrapActiveBody(view) : view}
</>
)
}

View File

@@ -115,9 +115,9 @@ export function HeroShell({ children }: HeroShellProps) {
Let&apos;s start building
</div>
<div className={css.body}>
{/* The resident composer (rendered by ConversationRoot at its stable
tree position; the workspace row rides its accessory hole) is
CSS-positioned into this gap during the hero phase — see
{/* The resident composer (ConversationRoot wrapActiveBody seat; the
workspace row rides the stack above the card) is CSS-centered in
the session scroll body during hero — see
ConversationRoot.module.css [data-phase='hero']. */}
</div>
</div>

View File

@@ -79,6 +79,27 @@ export function InputBar({
if (!locked) inputRef.current?.focus()
}, [locked])
// Active conversation scrollport: chain the wheel. While the textarea (capped
// at 14 lines with overflow-y:auto) can still move in this direction, keep
// the native scroll; only at its own edge forward delta to the host so a
// short draft never traps the gesture and a long draft stays scrollable.
// Hero mounts have no host and keep native wheel scrolling.
useEffect(() => {
const el = inputRef.current
if (el === null) return
const onWheel = (e: WheelEvent): void => {
const host = el.closest('[data-conversation-scroll]')
if (!(host instanceof HTMLElement) || e.deltaY === 0) return
const atTop = el.scrollTop <= 0
const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 1
if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atEnd)) return
e.preventDefault()
host.scrollTop += e.deltaY
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => { el.removeEventListener('wheel', onWheel) }
}, [])
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
// IME guard so a composition-closing Shift+Enter still breaks the line.

View File

@@ -86,6 +86,8 @@ describe('apply wiring', () => {
// service being present implies the chat entry declared the hole first.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
// Stats stick with the composer (not inside ChatView).
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
await b.runtime.dispose()
})

View File

@@ -1,5 +1,5 @@
// @vitest-environment jsdom
// StatsLine (rendered inside the chat view body): totals derivation + the RFC
// StatsLine (composer.dock entry): totals derivation + the RFC
// hard acceptance — zero renders during streaming. Bash sample row: the
// canonical sub-agent differential decided INSIDE the component off the
// standard useSessions kit (no registry predicates — tool ring dissolved).

View File

@@ -371,6 +371,42 @@ describe('ChatView', () => {
expect(view.queryByLabelText('回到底部')).toBeNull()
})
it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
// Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
// re-render from setAtBottom must not force scrollTop to scrollHeight.
scroller.scrollTop = 690 // distance-to-bottom = 10
fireEvent.scroll(scroller)
expect(view.queryByLabelText('回到底部')).toBeNull()
expect(scroller.scrollTop).toBe(690)
})
it('under data-conversation-scroll, bottom-follow targets the host scrollport', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
document.body.appendChild(host)
try {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />, { container: host })
// Open jump uses the host, not the local .scroll node.
expect(host.scrollTop).toBe(2000)
host.scrollTop = 100
fireEvent.scroll(host)
expect(view.getByLabelText('回到底部')).toBeTruthy()
fireEvent.click(view.getByLabelText('回到底部'))
expect(host.scrollTop).toBe(2000)
} finally {
host.remove()
}
})
it('paging button loads older and shows its busy label', () => {
const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)

View File

@@ -233,6 +233,56 @@ describe('running and lock semantics (queue cut 1)', () => {
expect((textarea).value).toBe('typed')
})
it('wheel over a non-overflowing textarea forwards to the conversation host', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
const { view, textarea } = bench()
host.appendChild(view.container)
document.body.appendChild(host)
try {
const wheeled = fireEvent.wheel(textarea, { deltaY: 30 })
expect(wheeled).toBe(false) // preventDefault
expect(host.scrollTop).toBe(70)
} finally {
host.remove()
}
})
it('wheel chains: long drafts scroll inside the textarea until each edge, then the host', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
const { view, textarea } = bench()
host.appendChild(view.container)
document.body.appendChild(host)
Object.defineProperty(textarea, 'clientHeight', { value: 100, configurable: true })
Object.defineProperty(textarea, 'scrollHeight', { value: 400, configurable: true })
let scrollTop = 150
Object.defineProperty(textarea, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => { scrollTop = value },
})
try {
// Mid-draft: both directions stay local — host must not move.
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(true)
expect(fireEvent.wheel(textarea, { deltaY: -30 })).toBe(true)
expect(host.scrollTop).toBe(40)
// At the bottom edge, further down-scroll forwards to the host.
scrollTop = 300
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(false)
expect(host.scrollTop).toBe(70)
// At the top edge, further up-scroll forwards to the host.
scrollTop = 0
host.scrollTop = 70
expect(fireEvent.wheel(textarea, { deltaY: -20 })).toBe(false)
expect(host.scrollTop).toBe(50)
} finally {
host.remove()
}
})
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
const { textarea } = bench({ disabled: true })
expect(textarea.placeholder).toBe('Session unavailable')

View File

@@ -61,6 +61,8 @@ function mount(
snapshot: ConversationSnapshot,
workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
/** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
overlayTakeover = false,
) {
const root = sid('root')
const sessions = createSnapshotStore<SessionListState>({
@@ -111,6 +113,7 @@ function mount(
}}
bindDraftMirror={write => wiring.bindMirror(write)}
open={open}
{...owner}
/>
)
}
@@ -141,7 +144,18 @@ function mount(
}
return <div data-testid={`view-${opts?.only ?? key}`} />
}) as ConversationRootProps['renderSlot']
const renderSlotChain = ((_key, _owner, opts) => opts?.fallback ?? null) as ConversationRootProps['renderSlotChain']
const renderSlotChain = ((_key, _owner, opts) => (
overlayTakeover
? (
<>
<div data-chain-overlay-fallback="conversation.composer" style={{ display: 'none' }}>
{opts?.fallback ?? null}
</div>
<div data-testid="composer-takeover">TAKEOVER</div>
</>
)
: (opts?.fallback ?? null)
)) as ConversationRootProps['renderSlotChain']
const props: ConversationRootProps = {
sessionId: SID,
SessionProvider: ({ children }) => children(SID),
@@ -176,6 +190,30 @@ describe('ConversationRoot resident composer', () => {
expect(b.open).toHaveBeenCalledWith(sid('root'))
})
it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => {
const b = mount(conversationSnapshot())
const host = b.view.container.querySelector('[data-conversation-scroll]')
const seat = b.view.container.querySelector('[data-composer-seat]')
const header = b.view.container.querySelector('header')
const textarea = b.view.container.querySelector('textarea')
expect(host).not.toBeNull()
expect(seat).not.toBeNull()
expect(header).not.toBeNull()
// Header is column chrome above the scrollport; the seat sticks inside it.
expect(host?.contains(header)).toBe(false)
expect(host?.contains(seat)).toBe(true)
expect(seat?.contains(textarea)).toBe(true)
})
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
const b = mount(conversationSnapshot(), undefined, undefined, true)
const seat = b.view.container.querySelector('[data-composer-seat]')
const takeover = b.view.getByTestId('composer-takeover')
const fallback = b.view.container.querySelector('[data-chain-overlay-fallback="conversation.composer"]')
expect(seat?.contains(takeover)).toBe(true)
expect(seat?.contains(fallback)).toBe(true)
})
it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
const b = mount(
conversationSnapshot({ composerPhase: 'blank', blank: true }),
@@ -184,13 +222,19 @@ describe('ConversationRoot resident composer', () => {
{ ...workspace('second'), title: 'Selected Folder' },
],
)
// Hero chrome present, view ring absent.
// Hero chrome present, view ring absent; scroll host already wraps the
// resident composer so the blank → active flip does not remount it.
const host = b.view.container.querySelector('[data-conversation-scroll]')
const header = b.view.container.querySelector('header')
expect(host).not.toBeNull()
expect(header?.getAttribute('aria-hidden')).toBe('true')
expect(b.view.getByText("Let's start building")).toBeTruthy()
expect(b.view.queryByTestId('view-chat')).toBeNull()
// The same machine-backed textarea is live in the hero, and the
// persistence mirror stays bound (ConversationSession mounts chrome-less
// persistence mirror stays bound (ConversationSession mounts chrome-hidden
// for blank sessions): hero typing reaches the chat store.
const box = b.view.getByRole('textbox')
expect(host?.contains(box)).toBe(true)
fireEvent.change(box, { target: { value: 'draft in hero' } })
expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
// Picker: open through the chip; a pick switches to the other
@@ -203,16 +247,20 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.getByText('Selected Folder')).toBeTruthy()
})
it('textarea DOM identity survives the hero → active flip', () => {
it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
const before = b.view.getByRole('textbox')
fireEvent.change(before, { target: { value: 'kept across flip' } })
// First message landed: content exists, phase leaves blank.
// First message landed: content exists, phase leaves blank. Composer
// already sat in the Session scrollport during hero, so the textarea
// node and InputHub draft both survive.
b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
b.rerender()
const after = b.view.getByRole('textbox')
const after = b.view.getByRole('textbox') as HTMLTextAreaElement
expect(after).toBe(before)
expect((after as HTMLTextAreaElement).value).toBe('kept across flip')
expect(after.value).toBe('kept across flip')
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
expect(b.view.queryByText("Let's start building")).toBeNull()
expect(b.view.getByTestId('view-chat')).toBeTruthy()
})

View File

@@ -74,6 +74,10 @@
white-space: pre-wrap;
word-break: break-all;
background: var(--dsw-alias-markdown-code-block);
/* Bottom radii live on <pre>: overflow:hidden on .block would kill the
sticky banner, and this opaque fill otherwise squares off the wrapper. */
border-bottom-left-radius: var(--dsl-code-block-border-radius);
border-bottom-right-radius: var(--dsl-code-block-border-radius);
}
/* Shiki inlines its theme background var; route it to the repo token. */

View File

@@ -13,6 +13,13 @@
background: var(--dsw-alias-bg-layer-1);
}
/* Under the active conversation column (`[data-conversation-scroll]`) the
* parent owns overflow so the sticky composer stays in the same scrollport. */
:global([data-conversation-scroll]) .root {
overflow: visible;
height: auto;
}
.ledger {
display: flex;
flex: 1;

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
README.md: 8acf819121b46512d38b39ff858bb2bf797cfe96
README.zh.md: e97d93f7e38d00af91b43f0df9fb0e3b17ae8ed5
README.md: a1b58f4abe0925be3b426d10344777e46caa9ba0
README.zh.md: a472507bc45549c8feb55a75d294cbd7b3138cc5

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
@@ -18,5 +18,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions.
- **No Session deletion or fork control** — the Session menu's Fork and Delete rows remain visual-only (Rename is wired); Workspace registration deletion does not delete Sessions.
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.

View File

@@ -4,7 +4,7 @@
共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot因此两个表层使用同一菜单和创建流程。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**`single` kind`conversation.hero.workspace.directoryFlow``sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染每次菜单渲染读取占用状态洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`每次打开上报一个所选路径owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示错误落入可重试的文件夹对话框**重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**`single` kind`conversation.hero.workspace.directoryFlow``sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染每次菜单渲染读取占用状态洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`每次打开上报一个所选路径owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示错误落入可重试的文件夹对话框**重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框并以该行的显示标题预填客户端不设名称冲突规则host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
@@ -18,5 +18,5 @@
## 已知限制与暂缓事项
- **没有 Session 删除控件**现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
- **没有 Session 删除与 fork 控件**Session 菜单的 Fork 与 Delete 行仍仅提供视觉效果Rename 已接线);删除 Workspace 注册记录不会删除 Session。
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture测试前置数据的部署或远程浏览器部署无法打开本地操作系统对话框模态框会显示平台故障并允许重试。可远程的选取是 `-browse` 组合的应用内流程。

View File

@@ -92,12 +92,14 @@ type SessionTreeProps = Pick<
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
/** Open the browser-owned session rename dialog. */
onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
}
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({
useSessions, startSession, open, workspaces, query,
onRenameRequest, onDeleteRequest, insertSessionBefore,
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore,
}: SessionTreeProps) {
const list = useSessions(s => s)
const current = list.current
@@ -192,6 +194,7 @@ function SessionTree({
currentId={current}
now={now}
onOpen={open}
onRename={onSessionRename}
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
drag={dragProps}
/>
@@ -206,7 +209,7 @@ function SessionTree({
}
/** The flat "In one list" body: every session a top-level row, newest-first. */
function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) {
function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'onSessionRename' | 'query'>) {
const list = useSessions(s => s)
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
const now = Date.now()
@@ -224,6 +227,7 @@ function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessi
currentId={list.current}
now={now}
onOpen={open}
onRename={onSessionRename}
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
onToggle={() => {}}
flat
@@ -249,6 +253,7 @@ export function WorkspaceBrowser({
actions,
startSession,
open,
renameSession,
renameWorkspace,
deleteWorkspace,
insertSessionBefore,
@@ -309,6 +314,39 @@ export function WorkspaceBrowser({
})
}
// Session rename dialog (same browser-owned pattern as workspace rename;
// sessions have no client-side name-conflict rule — the host normalizes).
// Unlike workspace rename, an unchanged title is NOT blocked: confirming
// the current automatic title is the gesture that pins it.
const [sessionRenameTarget, setSessionRenameTarget] = useState<{ sessionId: SessionNode['id']; currentTitle: string } | null>(null)
const [sessionRenameDraft, setSessionRenameDraft] = useState('')
const [sessionRenaming, setSessionRenaming] = useState(false)
const [sessionRenameError, setSessionRenameError] = useState<string | null>(null)
const sessionRenameTrimmed = sessionRenameDraft.trim()
const sessionRenameBlocked = sessionRenaming || sessionRenameTrimmed === '' || sessionRenameTarget === null
const closeSessionRename = () => {
if (sessionRenaming) return
setSessionRenameTarget(null)
setSessionRenameError(null)
}
const confirmSessionRename = () => {
if (sessionRenameBlocked) return
setSessionRenaming(true)
setSessionRenameError(null)
renameSession(sessionRenameTarget.sessionId, sessionRenameTrimmed).then(() => {
setSessionRenaming(false)
setSessionRenameTarget(null)
}).catch((reason: unknown) => {
setSessionRenaming(false)
setSessionRenameError(reason instanceof Error ? reason.message : String(reason))
})
}
const onSessionRename = (sessionId: SessionNode['id'], currentTitle: string) => {
setSessionRenameTarget({ sessionId, currentTitle })
setSessionRenameDraft(currentTitle)
setSessionRenameError(null)
}
// Delete dialog is separate from the row so a successful removal can
// unmount that row without tearing down the in-flight confirmation state.
const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
@@ -424,10 +462,11 @@ export function WorkspaceBrowser({
itself is wide-only. */}
<div className={css.listArea}>
{wide && (groupBy === 'flat'
? <FlatList useSessions={useSessions} open={open} query={query} />
? <FlatList useSessions={useSessions} open={open} onSessionRename={onSessionRename} query={query} />
: (
<SessionTree
useSessions={useSessions}
onSessionRename={onSessionRename}
workspaces={workspaces}
startSession={startSession}
open={open}
@@ -479,6 +518,37 @@ export function WorkspaceBrowser({
)}
{renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
</Modal>
<Modal
open={sessionRenameTarget !== null}
onClose={closeSessionRename}
title="Rename session"
footer={(
<>
<Button variant="outline" disabled={sessionRenaming} onClick={closeSessionRename}>Cancel</Button>
<Button variant="primary" disabled={sessionRenameBlocked} onClick={confirmSessionRename}>Rename</Button>
</>
)}
>
<input
className={css.renameInput}
value={sessionRenameDraft}
aria-label="Session name"
autoFocus
disabled={sessionRenaming}
onFocus={(e) => { e.target.select() }}
onChange={(e) => { setSessionRenameDraft(e.target.value); setSessionRenameError(null) }}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={(e) => {
if (e.key === 'Enter' && !composingRef.current) {
e.preventDefault()
confirmSessionRename()
}
}}
/>
{sessionRenameError !== null && <div className={css.renameError} role="alert">{sessionRenameError}</div>}
</Modal>
<Modal
open={deleteTarget !== null}
onClose={closeDelete}

View File

@@ -93,6 +93,8 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
startSession: (workspaceId?: WorkspaceId) => void
/** Open a real Session. */
open: (sessionId: SessionId) => void
/** Rename a Session (explicit user title; resolves on host acceptance). */
renameSession: (sessionId: SessionId, title: string) => Promise<void>
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
/** Delete only a Host Workspace registration; directory and Session logs remain. */

View File

@@ -51,6 +51,14 @@ export function apply(ctx: ClientContext): void {
// the runtime's shared action (recent-Workspace projection inside).
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
open: (sessionId) => { ctx.sessions.open(sessionId) },
renameSession: async (sessionId, title) => {
// Row → session-face hop: rename is a per-session verb (ISession), not
// a list-service verb; the binding resolves any listed session.
const session = ctx.sessions.binding(sessionId)?.session
if (session === undefined) throw new Error(`unknown session "${sessionId}"`)
const result = await session.rename(title)
if (!result.ok) throw new Error(result.error.message)
},
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {

View File

@@ -2,8 +2,8 @@
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
* except workspace Rename; the session hover card is suppressed while a menu
* is open. Workspace Rename/Delete are wired; session actions remain visual-only.
* except workspace Rename/Delete and session Rename; the session hover card is
* suppressed while a menu is open.
*/
import { useState } from 'react'
import clsx from 'clsx'
@@ -159,12 +159,14 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle, drag, flat = false }: {
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onToggle, drag, flat = false }: {
node: SessionNode
depth: number
currentId: string | undefined
now: number
onOpen: (id: SessionNode['id']) => void
/** Open the browser-owned session rename dialog (row menu action). */
onRename: (id: SessionNode['id'], currentTitle: string) => void
onToggle: (id: SessionNode['id']) => void
/** Present only on draggable rows (workspace-group roots outside search). */
drag?: RowDragProps | undefined
@@ -232,7 +234,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={SESSION_MENU_ITEMS}
onSelect={() => { setMenuOpen(false) }} // Visual-only for now.
onSelect={(id) => {
setMenuOpen(false)
if (id === 'rename') onRename(node.id, row.title) // fork/delete stay visual-only.
}}
portal
closeOnPointerLeave
anchor={(
@@ -264,6 +269,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
currentId={currentId}
now={now}
onOpen={onOpen}
onRename={onRename}
onToggle={onToggle}
/>
))}

View File

@@ -68,7 +68,8 @@ describe('workspace browser rows', () => {
const onOpen = vi.fn()
const onToggle = vi.fn()
const view = render(
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen} onToggle={onToggle} />,
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
onRename={vi.fn()} onToggle={onToggle} />,
)
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
@@ -88,7 +89,8 @@ describe('workspace browser rows', () => {
view.rerender(
<SessionNodeItem
node={{ ...parent, children: [], expanded: false, running: false }}
depth={1} currentId={undefined} now={0} onOpen={onOpen} onToggle={onToggle}
depth={1} currentId={undefined} now={0} onOpen={onOpen}
onRename={vi.fn()} onToggle={onToggle}
/>,
)
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
@@ -135,19 +137,29 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
})
it('session row menu opens without opening the session and closes on selection', () => {
it('session row menu opens without opening the session and dispatches rename', () => {
const onOpen = vi.fn()
const onRename = vi.fn()
const node: SessionNode = {
id: sid('s1'), title: 'One', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen} onToggle={vi.fn()} />)
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onToggle={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
expect(onOpen).not.toHaveBeenCalled()
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
// Rename dispatches with the current display title (dialog prefill).
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(onRename).toHaveBeenCalledWith(node.id, 'One')
expect(onOpen).not.toHaveBeenCalled()
// Fork and Delete stay visual-only.
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete session' }))
expect(onRename).toHaveBeenCalledOnce()
// Escape closes without selecting (Menu onClose path).
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
fireEvent.keyDown(document, { key: 'Escape' })
@@ -159,7 +171,8 @@ describe('workspace browser rows', () => {
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
expanded: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} flat />)
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} flat />)
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
})
@@ -170,7 +183,8 @@ describe('workspace browser rows', () => {
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
expanded: false, running: true, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()} onToggle={vi.fn()} />)
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} />)
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
@@ -196,7 +210,8 @@ describe('workspace browser rows', () => {
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} />)
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} />)
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('Idle')).toBeTruthy()
@@ -213,7 +228,8 @@ describe('workspace browser rows', () => {
}
const inactive = dragProps()
const { rerender } = render(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
)
const row = screen.getByRole('treeitem')
stubRect(row)
@@ -230,7 +246,8 @@ describe('workspace browser rows', () => {
const active = dragProps({ active: true, marker: 'before' })
rerender(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={active} />,
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} drag={active} />,
)
stubRect(screen.getByRole('treeitem'))
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
@@ -243,7 +260,8 @@ describe('workspace browser rows', () => {
const after = dragProps({ active: true, marker: 'after' })
rerender(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={after} />,
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} drag={after} />,
)
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
})

View File

@@ -55,6 +55,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
actions: store.actions,
startSession: vi.fn(),
open: vi.fn(),
renameSession: vi.fn(async () => {}),
renameWorkspace: vi.fn(async () => {}),
deleteWorkspace: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),

View File

@@ -734,6 +734,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'get(session: Session): SessionTitleSnapshot | undefined',
jsDoc: '/**\n * Read the latest folded title from one live or replayed session.\n * @param session - session whose log is the title source of truth.\n * @returns latest title snapshot, or `undefined` before eligible input.\n */',
},
{
signature: 'rename(session: Session, title: string): SessionTitleSnapshot',
jsDoc: '/**\n * Accept an explicit user title. Appends a `session/title` event with the\n * `user` source, which pins the title: in-flight automatic generation is\n * superseded and later user messages schedule none (an explicit\n * {@link SessionTitleService.refresh} remains the deliberate unpin).\n * @param session - exact live session to rename.\n * @param title - raw user input; normalized before acceptance.\n * @returns the accepted title snapshot.\n * @throws {SessionTitleInvalidError} when the title normalizes to empty.\n * @throws {Error} when the session is not live or the service is disposed.\n */',
},
{
signature: 'async refresh(session: Session, signal?: AbortSignal): Promise<SessionTitleSnapshot | undefined>',
jsDoc: '/**\n * Explicitly retry the registered provider, or materialize the built-in\n * fallback when no provider is registered.\n * @param session - exact live session to refresh.\n * @param signal - optional caller cancellation.\n * @returns latest accepted title, or `undefined` when no eligible text exists.\n */',
@@ -2336,7 +2340,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionTitleSource',
declaration: 'export type SessionTitleSource = {\n readonly kind: \'fallback\';\n} | {\n readonly kind: \'provider\';\n readonly provider: SessionTitleProviderId;\n readonly model?: SessionTitleModelProvenance;\n};',
declaration: 'export type SessionTitleSource = {\n readonly kind: \'fallback\';\n} | {\n readonly kind: \'provider\';\n readonly provider: SessionTitleProviderId;\n readonly model?: SessionTitleModelProvenance;\n} | {\n readonly kind: \'user\';\n};',
},
{
name: 'SessionTitleUserMessage',

View File

@@ -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/host/apiproxy/README.md
README.md: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74
README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9
README.md: b5f80dcb3a077a411db3b721737a9c16b56fcecf
README.zh.md: 1c3c7486f7d500f8c2d36028d47f29b112d9b5ae

View File

@@ -12,7 +12,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.

View File

@@ -12,7 +12,7 @@
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections``@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema协议 schema 对 `values`/`value` 保持宽松loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方模型推理reasoning目标以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`

View File

@@ -51,6 +51,7 @@
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",

View File

@@ -38,6 +38,8 @@ import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
import type {} from '@deepseek-ai/dsh-commands'
import type {} from '@deepseek-ai/dsh-skill'
// Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`.
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
// Side-effect type import: resolves the `approval/request` waterfall and
@@ -1049,6 +1051,36 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
},
async rename(request) {
const { sessionId, title } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const titles = ctx.get('sessionTitle')
if (titles === undefined) {
return err(request, { code: 'internal', message: 'renaming is unavailable: this deployment mounts no session-title service', details: {} })
}
try {
const accepted = titles.rename(found.agent.session, title)
return ok(request, { title: accepted.title, seq: accepted.eventSeq })
} catch (error: unknown) {
// Only the input's fault maps to title-invalid (the message is
// product-user-visible in the rename dialog); liveness and disposal
// races are deployment trouble, not a bad title.
if (error instanceof SessionTitleInvalidError) {
return err(request, {
code: 'title-invalid',
message: error.message,
details: { sessionId },
})
}
return err(request, {
code: 'internal',
message: `failed to rename session "${sessionId}": ${String(error)}`,
details: {},
})
}
},
async prompt(request) {
const { sessionId, mode, content } = request.payload
const found = await agentFor(sessionId)

View File

@@ -23,6 +23,7 @@ export interface RpcMethodMap {
'session.history': SessionsApi['history']
'session.models': SessionsApi['models']
'session.selectModel': SessionsApi['selectModel']
'session.rename': SessionsApi['rename']
'session.prompt': SessionsApi['prompt']
'session.cancel': SessionsApi['cancel']
'host.describe': HostApi['describe']

View File

@@ -49,6 +49,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>

View File

@@ -48,6 +48,7 @@ export interface RpcErrorDetailsMap {
'command-error': {}
/** A leading-/ prompt named no registered command; the message names the token. */
'unknown-command': {}
'title-invalid': { sessionId: SessionId }
'internal': {}
}

View File

@@ -73,6 +73,18 @@ export const sessionCreateValueSchema = z.object({
sessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'session.create'>>>
/** session.rename request payload (raw title; host-side normalization decides acceptance). */
export const sessionRenameRequestSchema = z.object({
sessionId: sessionIdSchema,
title: z.string(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.rename'>>>
/** session.rename response value (the normalized accepted title and its event seq). */
export const sessionRenameValueSchema = z.object({
title: z.string().min(1),
seq: z.number().int().nonnegative(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.rename'>>>
/** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */
export const sessionHistoryRequestSchema = z.object({
sessionId: sessionIdSchema,

View File

@@ -208,6 +208,16 @@ export interface SessionsApi {
}>):
Promise<RpcResponse<{ selected: ModelTarget }>>
/**
* Renames a session: appends a `session/title` event with the `user`
* source, which pins the title against automatic regeneration. The
* normalized accepted title and the title event's seq return so the caller
* can settle its projection cell without waiting for the push frame. A
* title that normalizes to empty fails with `title-invalid`.
*/
rename(request: RpcRequest<{ sessionId: SessionId; title: string }>):
Promise<RpcResponse<{ title: string; seq: number }>>
/**
* Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer.
* A prompt whose content is exactly one text block starting with '/' is a slash command: the host

View File

@@ -24,6 +24,7 @@ import {
sessionListValueSchema,
sessionModelsValueSchema,
sessionPromptValueSchema,
sessionRenameValueSchema,
sessionSelectModelValueSchema,
} from '../api/sessions.schema.ts'
import {
@@ -66,6 +67,7 @@ export interface IApiClient {
history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
}
@@ -116,6 +118,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.history': sessionHistoryValueSchema,
'session.models': sessionModelsValueSchema,
'session.selectModel': sessionSelectModelValueSchema,
'session.rename': sessionRenameValueSchema,
'session.prompt': sessionPromptValueSchema,
'session.cancel': sessionCancelValueSchema,
'host.describe': hostDescribeValueSchema,
@@ -327,6 +330,7 @@ export abstract class AbstractApiClient implements IApiClient {
history: (payload, signal) => this.callUnary('session.history', payload, signal),
models: (payload, signal) => this.callUnary('session.models', payload, signal),
selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
rename: (payload, signal) => this.callUnary('session.rename', payload, signal),
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
}

View File

@@ -21,6 +21,7 @@ import {
sessionListRequestSchema,
sessionModelsRequestSchema,
sessionPromptRequestSchema,
sessionRenameRequestSchema,
sessionSelectModelRequestSchema,
} from '../api/sessions.schema.ts'
import {
@@ -68,6 +69,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },

View File

@@ -0,0 +1,129 @@
/**
* sessions.rename delegation through the composed SessionTitleService. The
* agent factory is a structural stub whose createAgent forwards seed/meta into
* the real SessionStore, and whose resume never runs (every source here is
* already attached). Cold-session resolution is the shared `agentFor` path —
* api-proxy-cold.spec.ts owns the resume evidence for every unary that rides
* it, rename included.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionTitleService from '@deepseek-ai/dsh-session-title'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
const sid = (id: string): SessionId => id as SessionId
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`fr-${String(nextRpc++)}`), payload }
}
async function composed(withTitles = true): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
if (withTitles) {
await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 40 })
}
// Store-backed structural factory: create builds the session with the
// forwarded seed/meta (the store validates the balanced prefix) and
// registers an idle agent stub over it.
ctx.agents.setFactory({
createAgent: (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
const session = ctx.sessions.create(options.sessionId, {
...options.seed === undefined ? {} : { seed: [...options.seed] },
...options.meta === undefined ? {} : { meta: options.meta },
})
const agent = { id: session.id, session, status: 'idle', ctx: ownerCtx } as Agent
ctx.agents.register(agent)
return Promise.resolve({ agent, dispose: () => Promise.resolve() })
},
resume: () => Promise.reject(new Error('resume must not run: every source is attached')),
})
return ctx
}
/** Register one live agent whose log holds `turns` completed turns. */
function liveAgent(ctx: Context, id: string, turns: number): Session {
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
for (let turn = 1; turn <= turns; turn++) {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
return session
}
const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
describe('sessions.rename', () => {
it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-rename', 1)
const renamed = await api(ctx).sessions.rename(request({ sessionId: source.id, title: ' new name ' }))
expect(renamed.result.ok).toBe(true)
if (!renamed.result.ok) return
expect(renamed.result.value.title).toBe('new name')
const event = source.events.findLast(item => item.type === 'session/title')
expect(event?.seq).toBe(renamed.result.value.seq)
expect(event?.data).toMatchObject({ title: 'new name', source: { kind: 'user' } })
})
it('maps only an empty-normalizing title to title-invalid, with a presentable message', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-rename-bad', 1)
// U+200B passes a client-side trim gate but normalizes to empty host-side.
const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: ' ' }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) {
expect(response.result.error).toMatchObject({
code: 'title-invalid',
details: { sessionId: source.id },
})
// The message renders verbatim in the rename dialog's alert.
expect(response.result.error.message).toBe('session title must contain visible characters')
}
})
it('maps a non-validation rename failure (stale session object) to internal, not title-invalid', async () => {
const ctx = await composed()
// The registered agent holds a session object from another store: the
// title service's liveness check throws a plain Error, which must not
// read as the user's fault.
const foreign = await composed(false)
const stale = liveAgent(foreign, 'session-rename-stale', 1)
ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent)
const response = await api(ctx).sessions.rename(request({ sessionId: stale.id, title: 'name' }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('internal')
})
it('answers internal when the composition mounts no session-title service', async () => {
const ctx = await composed(false)
const source = liveAgent(ctx, 'session-no-titles', 1)
const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: 'name' }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) {
expect(response.result.error.code).toBe('internal')
expect(response.result.error.message).toMatch(/mounts no session-title service/)
}
})
})

View File

@@ -46,6 +46,7 @@ function scriptedApi(overrides: {
selectModel: r => ok(r, {
selected: { provider: r.payload.provider, model: r.payload.model },
}),
rename: r => ok(r, { title: 'renamed', seq: 0 }),
prompt: r => ok(r, { accepted: true as const }),
cancel: r => ok(r, { accepted: true as const }),
...overrides.sessions,

View File

@@ -67,6 +67,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
},
}
},
async rename(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { title: request.payload.title, seq: 0 } } }
},
async prompt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
@@ -224,6 +227,8 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
},
},
})
const renamed = await c.sessions.rename({ sessionId: 's' as never, title: 'named' })
expect(renamed.result).toMatchObject({ ok: true, value: { title: 'named', seq: 0 } })
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
expect((await c.host.describe({})).result.ok).toBe(true)

View File

@@ -70,11 +70,13 @@ describe('rpcErrorSchema', () => {
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid')
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
})
it('rejects a known code with missing details', () => {
expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
expect(() => rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: {} })).toThrow()
expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow()
expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
})

View File

@@ -41,6 +41,9 @@
{
"path": "../../session-projection/session-projection-cache"
},
{
"path": "../../session-title/session-title"
},
{
"path": "../../skill/skill"
},

View File

@@ -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-title/session-title/README.md
README.md: 1939d00f7e78834ec19e2d6b4590cf12af297a30
README.zh.md: f842db9ca5d0215ecf6589978983952678a19897
README.md: 9a5ec27c36f3411add37ebe231262eb5d205bc9e
README.zh.md: 38fc9f82e3fcd94233cad31e291b8258c97d0975

View File

@@ -10,6 +10,7 @@ Only text blocks from human `user/message` events are eligible. The first eligib
- `get(session)` folds the latest accepted title from a live or replayed log.
- `refresh(session, signal?)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages. Provider errors and caller cancellation reject; cancellation does not roll back an already accepted fallback event.
- `rename(session, title)` accepts an explicit user title synchronously: it normalizes the text, supersedes in-flight automatic work, and appends a `session/title` event with the `user` source. A user-sourced latest title pins the session — later user messages schedule no automatic revision; an explicit `refresh` remains the deliberate unpin.
- `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register.
Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion appends a standalone log-only event directly through `Session` without opening a turn. Persistence observes that event eagerly and drains on ordinary lifecycle checkpoints; title publication itself does not force a flush. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their revision before provider work, while overlapping automatic and explicit fallback requests share one session-local in-flight append. The service and bundled model provider each append their own literal event type, so no generic title-write marker, cast, or settlement queue is needed. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes.
@@ -50,5 +51,5 @@ None for the main request; title events do not change its reconstructed content
## Known Limitations and Deferred Work
- Manual rename, title deletion, generated-versus-user precedence, search, and list indexing are outside this service.
- Title deletion (unpinning back to automatic titles without an explicit `refresh`), search, and list indexing are outside this service.
- The provider registry deliberately accepts at most one implementation, so a deployment cannot compose competing title strategies without writing one provider that owns their precedence.

View File

@@ -10,6 +10,7 @@
- `get(session)` 从活跃或回放日志折叠最新已接受标题。
- `refresh(session, signal?)` 在需要时物化回退,然后显式运行已注册提供方,处理当前符合条件的消息。提供方错误或调用方取消都会导致返回的 Promise 被拒绝;取消不会回滚已接受的回退事件。
- `rename(session, title)` 同步接受用户显式标题:规范化文本、取代在途自动工作,并追加一条 `user` 来源的 `session/title` 事件。最新标题来源为 user 即钉住该会话——后续用户消息不再安排自动 revision显式 `refresh` 仍是有意的解钉手段。
- `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出对提供方执行 dispose资源释放会中止待处理和活跃调用等待其结算之后才允许注册另一个提供方。
自动工作绝不会延迟主 agent智能体响应。只有当带标记、由循环构建的请求其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使请求头未变而无需新快照,也适用此规则。延迟完成会直接通过 `Session` 追加一个独立的纯日志事件,而不打开轮次。持久化会立即观察到该事件,并在常规生命周期检查点完成刷写;标题发布本身不会强制刷写。自动失败会发出警告并保留最新标题。新的全消息修订、提供方 dispose、会话 dispose 和显式刷新都会中止旧工作,陈旧的完成结果无法追加。并发显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地正在进行的追加操作。服务与内置模型提供方各自追加自己的字面事件类型,因此不需要通用标题写入标记、类型断言或结算队列。服务拆卸会取消排队工作,并在卸载完成前等待不响应取消的调用结算完成。
@@ -50,5 +51,5 @@ Fork 出的会话会原样继承种子中的标题事件。首消息节奏不会
## 已知限制与暂缓事项
- 手动重命名、删除标题、生成标题与用户标题的优先级、搜索和列表索引不属于此服务。
- 删除标题(不经显式 `refresh` 就解钉回自动标题)、搜索和列表索引不属于此服务。
- 提供方注册表有意最多接受一个实现,因此部署若要组合相互竞争的标题策略,必须编写一个自行负责优先级的提供方。

View File

@@ -7,7 +7,7 @@ import { Context, FiberState, Service, type Fiber } from 'cordis'
import z from 'schemastery'
import { z as zod } from 'zod'
import type { Branded } from '@deepseek-ai/dsh-brand'
import { deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import { assertNever, deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import type {
Session,
@@ -52,14 +52,18 @@ export type SessionTitleSource =
readonly provider: SessionTitleProviderId
readonly model?: SessionTitleModelProvenance
}
| {
/** Explicit user rename: pins the title — automatic generation stops scheduling. */
readonly kind: 'user'
}
/** Payload of the log-only `session/title` event. */
export interface SessionTitleEventData {
/** Normalized non-empty title text. */
readonly title: string
/** Exact human `user/message` seqs used to derive this title. */
/** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */
readonly messageSeqs: number[]
/** Built-in fallback or registered-provider provenance. */
/** Built-in fallback, registered-provider, or explicit-user provenance. */
readonly source: SessionTitleSource
}
@@ -97,6 +101,16 @@ declare module '@deepseek-ai/dsh-session' {
}
}
/**
* Rejection of an explicit user title whose text normalizes to empty — the
* one {@link SessionTitleService.rename} failure that blames the input.
* Callers translating rename failures onto a wire (`title-invalid`) narrow on
* this class; liveness and disposal failures stay plain `Error`s.
*/
export class SessionTitleInvalidError extends Error {
override readonly name = 'SessionTitleInvalidError'
}
/** One eligible human text message exposed to title providers. */
export interface SessionTitleUserMessage {
/** Source `user/message` event seq. */
@@ -180,20 +194,27 @@ export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleS
return deepFreeze({
title: event.data.title,
messageSeqs: [...event.data.messageSeqs],
source: event.data.source.kind === 'fallback'
? { kind: 'fallback' }
: {
kind: 'provider',
provider: event.data.source.provider,
...(event.data.source.model === undefined
? {}
: { model: { ...event.data.source.model } }),
},
source: copySessionTitleSource(event.data.source),
eventSeq: event.seq,
updatedAt: event.time,
})
}
/** Defensive copy of a logged title source (the snapshot must not alias log-owned objects). */
function copySessionTitleSource(source: SessionTitleSource): SessionTitleSource {
switch (source.kind) {
case 'fallback': return { kind: 'fallback' }
case 'provider': return {
kind: 'provider',
provider: source.provider,
...(source.model === undefined ? {} : { model: { ...source.model } }),
}
case 'user': return { kind: 'user' }
/* v8 ignore next -- closed-union exhaustiveness guard */
default: return assertNever(source, 'SessionTitleSource')
}
}
/** Service-owned resolved limits. */
interface ResolvedConfig {
readonly fallbackMaxWords: number
@@ -328,6 +349,39 @@ export class SessionTitleService extends Service {
return foldSessionTitle(session.events)
}
/**
* Accept an explicit user title. Appends a `session/title` event with the
* `user` source, which pins the title: in-flight automatic generation is
* superseded and later user messages schedule none (an explicit
* {@link SessionTitleService.refresh} remains the deliberate unpin).
* @param session - exact live session to rename.
* @param title - raw user input; normalized before acceptance.
* @returns the accepted title snapshot.
* @throws {SessionTitleInvalidError} when the title normalizes to empty.
* @throws {Error} when the session is not live or the service is disposed.
*/
rename(session: Session, title: string): SessionTitleSnapshot {
this.assertServiceActive()
if (this.ctx.sessions.get(session.id) !== session) {
throw new Error(`session "${session.id}" is not live in this store`)
}
const normalized = normalizeSessionTitle(title, this.config.maxTitleBytes)
if (normalized.length === 0) {
throw new SessionTitleInvalidError('session title must contain visible characters')
}
const state = this.stateFor(session)
this.supersede(state, 'user rename superseded automatic title generation')
session.append('session/title', {
title: normalized,
messageSeqs: [],
source: { kind: 'user' },
})
const snapshot = this.get(session)
/* v8 ignore next -- unreachable: the append above just committed a session/title event. */
if (snapshot === undefined) throw new Error('renamed title failed to fold')
return snapshot
}
/**
* Explicitly retry the registered provider, or materialize the built-in
* fallback when no provider is registered.
@@ -345,6 +399,16 @@ export class SessionTitleService extends Service {
const messages = collectSessionTitleMessages(session.events)
const latest = messages.at(-1)
if (registration === undefined || registration.closing || latest === undefined) {
// Explicit refresh is the unpin even without a provider: a standing
// user title must not short-circuit ensureFallback into a no-op, so
// re-derive and append the fallback over it when one is derivable.
const current = this.get(session)
const [first] = messages
if (current?.source.kind === 'user' && first !== undefined) {
this.appendFallback(session, first)
signal?.throwIfAborted()
return this.get(session)
}
const fallback = await this.ensureFallback(session)
signal?.throwIfAborted()
return fallback
@@ -398,6 +462,8 @@ export class SessionTitleService extends Service {
private onUserMessage(session: Session, event: Extract<SessionEvent, { type: 'user/message' }>): void {
if (!this.serviceActive()) return
if (event.data.source.kind !== 'user' || collectSessionTitleMessages([event]).length === 0) return
// A user rename pins the title: no automatic revision may override it.
if (this.get(session)?.source.kind === 'user') return
const registration = this.registration
if (registration !== undefined && !registration.closing) {
const messages = collectSessionTitleMessages(session.events, event.seq)
@@ -668,6 +734,23 @@ export class SessionTitleService extends Service {
}
}
/**
* Derive and append the deterministic fallback title over whatever stands
* (the refresh unpin path: overwriting a pinned user title is the point).
* Synchronous on purpose — no await may separate derivation from append, so
* it needs neither ensureFallback's in-flight dedup nor its liveness
* re-check. An underivable fallback (empty after the caps) appends nothing.
*/
private appendFallback(session: Session, first: SessionTitleUserMessage): void {
const title = fallbackSessionTitle(first.text, this.config.fallbackMaxWords, this.config.fallbackMaxBytes)
if (title.length === 0) return
session.append('session/title', {
title,
messageSeqs: [first.seq],
source: { kind: 'fallback' },
})
}
/** Create the first deterministic fallback if the session still lacks a title. */
private async ensureFallback(session: Session): Promise<SessionTitleSnapshot | undefined> {
this.assertServiceActive()

View File

@@ -5,7 +5,8 @@
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-title'
@@ -15,11 +16,26 @@ export const name = 'session-title-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the service validates provider revisions before their
* title append, and its remaining lifecycle state is process-local and covered
* by package tests.
* Durable title-provenance invariant: an automatic title always cites at
* least one human `user/message` seq, and an explicit user rename cites none
* — `messageSeqs` is empty iff `source.kind` is `user`. Provider revisions
* are validated by the service before their append; this checks the durable
* relationship every appended `session/title` event must keep, whichever
* writer produced it.
*/
const install: InvariantInstaller = () => {}
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
// internal/dispatch interception rejects the append before publication
// (the session/event listener would only observe the already-committed log).
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [, event] = args as [unknown, SessionEvent]
if (event.type !== 'session/title') return
const { source, messageSeqs } = event.data
if ((messageSeqs.length === 0) !== (source.kind === 'user')) {
fail(`session/title event ${String(event.seq)} breaks provenance: source "${source.kind}" with ${String(messageSeqs.length)} cited message seq(s)`)
}
}, { global: true })
}, { inject: ['sessions'] })
/**
* Register this package's invariant companion.

View File

@@ -0,0 +1,44 @@
// Title-provenance invariant: messageSeqs is empty iff source.kind is 'user'
// — the durable relationship every appended session/title event must keep.
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import * as SessionTitleInvariantCompanion from '@deepseek-ai/dsh-session-title/invariant'
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(SessionTitleInvariantCompanion)
return ctx
}
describe('session-title provenance invariant', () => {
it('accepts cited automatic titles and citation-free user renames', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('title-invariant-valid'))
expect(() => {
session.append('session/title', { title: 'auto', messageSeqs: [1], source: { kind: 'fallback' } })
session.append('session/title', { title: 'named', messageSeqs: [], source: { kind: 'user' } })
}).not.toThrow()
})
it('rejects a citation-free automatic title and a user rename that cites messages', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('title-invariant-invalid'))
expect(() => {
session.append('session/title', { title: 'auto', messageSeqs: [], source: { kind: 'fallback' } })
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-session-title',
}))
expect(() => {
session.append('session/title', { title: 'named', messageSeqs: [1], source: { kind: 'user' } })
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-session-title',
}))
expect(session.seq).toBe(0)
})
})

View File

@@ -0,0 +1,181 @@
// SessionTitleService.rename: user-source acceptance, normalization/rejection
// boundaries, and the pin (a user-sourced latest title schedules no automatic
// revision; explicit refresh stays the unpin).
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService, {
SessionTitleProviderId,
foldSessionTitle,
type SessionTitleProviderRequest,
} from '@deepseek-ai/dsh-session-title'
const CONFIG = {
fallbackMaxWords: 5,
fallbackMaxBytes: 40,
maxTitleBytes: 40,
} as const
async function settle(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
}
function appendHumanPrompt(session: ReturnType<Context['sessions']['create']>, text: string) {
return session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
describe('SessionTitleService.rename', () => {
it('appends a normalized user-source title', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const session = ctx.sessions.create(SessionId('rename-accept'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
appendHumanPrompt(session, 'Original prompt text')
await settle()
const accepted = ctx.sessionTitle.rename(session, ' Hand\tpicked name ')
expect(accepted).toMatchObject({
title: 'Hand picked name',
messageSeqs: [],
source: { kind: 'user' },
})
const event = session.events.findLast(item => item.type === 'session/title')
expect(event?.data).toEqual({
title: 'Hand picked name',
messageSeqs: [],
source: { kind: 'user' },
})
// foldSessionTitle round-trips the third source kind.
expect(foldSessionTitle(session.events)?.source).toEqual({ kind: 'user' })
})
it('rejects titles that normalize to empty and dead sessions', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const session = ctx.sessions.create(SessionId('rename-reject'))
expect(() => ctx.sessionTitle.rename(session, '  ')).toThrow(/visible characters/)
expect(() => ctx.sessionTitle.rename(new Session(SessionId('detached')), 'name'))
.toThrow(/not live in this store/)
})
it('pins the title: later user messages schedule no automatic revision; refresh unpins', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const generate = vi.fn(async (request: SessionTitleProviderRequest) => ({
title: 'Provider title',
messageSeqs: request.messages.map(message => message.seq),
}))
ctx.sessionTitle.register({
id: SessionTitleProviderId('pin-provider'),
automatic: 'all-user-messages',
generate,
})
const session = ctx.sessions.create(SessionId('rename-pin'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
appendHumanPrompt(session, 'First prompt')
await settle()
ctx.sessionTitle.rename(session, 'Pinned by hand')
// A later eligible prompt must schedule nothing while the pin stands.
appendHumanPrompt(session, 'Second prompt after the pin')
await settle()
session.append('request/header', {
header: { config: { provider: 'main-route', model: 'chat-model' } },
reason: 'change',
})
await settle()
expect(generate).not.toHaveBeenCalled()
expect(ctx.sessionTitle.get(session)?.title).toBe('Pinned by hand')
// Explicit refresh remains the deliberate unpin.
const refreshed = await ctx.sessionTitle.refresh(session)
expect(generate).toHaveBeenCalledOnce()
expect(refreshed?.title).toBe('Provider title')
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('provider')
})
it('fallback-only refresh also unpins: the user title yields to a re-derived fallback', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const session = ctx.sessions.create(SessionId('rename-unpin-fallback'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
appendHumanPrompt(session, 'Derivable prompt words')
await settle()
ctx.sessionTitle.rename(session, 'Pinned without provider')
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('user')
const refreshed = await ctx.sessionTitle.refresh(session)
expect(refreshed).toMatchObject({
title: 'Derivable prompt words',
source: { kind: 'fallback' },
})
// The pin is gone: the latest title is fallback-sourced, so the
// onUserMessage pin check no longer skips scheduling.
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
})
it('supersedes in-flight automatic generation: a late provider result cannot override the user title', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
// The provider parks on a test-held deferred so rename lands while its
// generation is ACTIVE (not merely scheduled).
let releaseProvider: (() => void) | undefined
const gate = new Promise<void>((resolve) => { releaseProvider = resolve })
let aborted = false
const generate = vi.fn(async (request: SessionTitleProviderRequest) => {
request.signal.addEventListener('abort', () => { aborted = true })
await gate
return { title: 'Late provider title', messageSeqs: request.messages.map(message => message.seq) }
})
ctx.sessionTitle.register({
id: SessionTitleProviderId('deferred-provider'),
automatic: 'all-user-messages',
generate,
})
const session = ctx.sessions.create(SessionId('rename-supersede'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
appendHumanPrompt(session, 'Prompt that triggers generation')
session.append('request/header', {
header: { config: { provider: 'main-route', model: 'chat-model' } },
reason: 'change',
})
await settle()
expect(generate).toHaveBeenCalledOnce()
ctx.sessionTitle.rename(session, 'User wins')
expect(aborted).toBe(true)
releaseProvider?.()
await settle()
// The released provider result must not append over the user title, and
// the swallowed abort must not surface as an unhandled rejection.
const latest = session.events.findLast(item => item.type === 'session/title')
expect(latest?.data).toMatchObject({ title: 'User wins', source: { kind: 'user' } })
})
it('fallback-only refresh keeps the user title when no fallback is derivable', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
// A 3-byte fallback cap cannot hold the 4-byte emoji prompt: the
// re-derived fallback is empty, so the pinned title survives the refresh.
await ctx.plugin(SessionTitleService, { ...CONFIG, fallbackMaxBytes: 3 })
const session = ctx.sessions.create(SessionId('rename-unpin-empty'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
appendHumanPrompt(session, '😀😀')
await settle()
ctx.sessionTitle.rename(session, 'Sticky emoji pin')
const refreshed = await ctx.sessionTitle.refresh(session)
expect(refreshed?.title).toBe('Sticky emoji pin')
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('user')
})
})

3
pnpm-lock.yaml generated
View File

@@ -2952,6 +2952,9 @@ importers:
'@deepseek-ai/dsh-session-projection-cache':
specifier: workspace:^
version: link:../../session-projection/session-projection-cache
'@deepseek-ai/dsh-session-title':
specifier: workspace:^
version: link:../../session-title/session-title
'@deepseek-ai/dsh-skill':
specifier: workspace:^
version: link:../../skill/skill

View File

@@ -86,6 +86,7 @@
"./packages/hooks/*/src/invariant.ts",
"./packages/session-persistence/*/src/invariant.ts",
"./packages/session-projection/*/src/invariant.ts",
"./packages/session-title/*/src/invariant.ts",
"./packages/session-query/*/src/invariant.ts",
"./packages/telemetry/*/src/invariant.ts",
"./packages/acp/*/src/invariant.ts",