@khorsheed/dsh-client-session-title-edit 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.2.5(2026-09-30)
4
+
5
+ 无功能变更。加宽 `@deepseek-ai/dsh-*` peer 区间以覆盖宿主 0.2.0(0.2.0 的兼容闸会禁用 peer 区间不覆盖宿主版本的已装插件)。
6
+
7
+ ## 0.2.4(2026-09-28)
8
+
9
+ - **标题定位同时兼容新旧两条宿主 DOM 线**:0.1.7 线改了标题节点结构,旧选择器落空时编辑入口不再消失
10
+
3
11
  ## 0.2.3(2026-09-27)
4
12
 
5
13
  无功能变更。随宿主 0.1.7-rc.2 基线发布波重发:全量构建+测试在 rc.2 基线通过(rc.1→rc.2 无触及本包的宿主变更)。
package/README.en.md CHANGED
@@ -6,7 +6,7 @@ Rename a session whenever you like: click the little pencil by the title, hit En
6
6
 
7
7
  Auto-generated session titles are often off the mark, and finding a session again two days later is pure luck. This plugin puts a pencil next to the title in the chat header: one click and the title becomes an input in place, prefilled and fully selected. Enter saves, Escape cancels, and a draft that's too long gets a warning instead of silent truncation. Renames go through the official `session.rename` RPC, and the title never enters the model context — the model knows nothing about it.
8
8
 
9
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/session-title-edit1.png" width="640" alt="the pencil button beside the title in the chat header, with a rename-session tooltip on hover">
9
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-basic/main/docs/screenshots/session-title-edit1.png" width="640" alt="the pencil button beside the title in the chat header, with a rename-session tooltip on hover">
10
10
 
11
11
  ## Features
12
12
 
@@ -16,7 +16,7 @@ Auto-generated session titles are often off the mark, and finding a session agai
16
16
  - **Budget-aware** — drafts past the host's 80-UTF-8-byte title budget are blocked with a warning, never silently truncated.
17
17
  - **Zero footprint** — rides the official `session.rename` RPC; the title never enters model context (no token or KV-cache effect).
18
18
 
19
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/session-title-edit2.png" width="640" alt="after clicking the pencil the title becomes an input in place — edit and press Enter to save">
19
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-basic/main/docs/screenshots/session-title-edit2.png" width="640" alt="after clicking the pencil the title becomes an input in place — edit and press Enter to save">
20
20
 
21
21
  ## Install
22
22
 
@@ -32,14 +32,15 @@ dsh plugin --profile web remove @khorsheed/dsh-client-session-title-edit
32
32
 
33
33
  ## Compatibility
34
34
 
35
- - npm release line (`@deepseek-ai/dsh@0.1.2-rc.1`): ✅ full — baseline moved to the 0.1.2-rc.1 API surface (single-arm 0.1.2 API consumption; the 0.1.1-rc.2 runtime arm is retired), full build+test green; minHost moves up to 0.1.2-rc.1 — older hosts stay on the previous release line.
36
- - source line (deepseek-harness master): ✅ (verifiedHost: 0.1.2-rc.1)
35
+ - npm release line (host `0.1.7-rc.2`, next release `0.2.4`): ✅ full — the rename rides the official `session.rename`, and **in-place editing works on both the 0.1.5 and 0.1.7 host lines** (one text-based probe covers the 0.1.5/0.1.6 disabled crumb button and the plain-text crumb that replaced it in 0.1.7-alpha.1), proven by the dual-line suite and on both host lines in a live instance; the minHost floor stays at `0.1.2-rc.1`.
36
+ - Published `0.2.3` and earlier: on hosts from 0.1.7-alpha.1 the current crumb is no longer a disabled button, so in-place editing falls back to the inline editor in the actions row (**renaming itself keeps working**, it just no longer overlays the title); `0.2.4` restores it.
37
+ - source line (deepseek-harness master, `0.1.7-rc.2`): ✅ (verifiedHost: `0.1.7-rc.2`)
37
38
 
38
39
  **Version line mapping**: 0.2.0 and up support host `0.1.2-rc.1` and later; hosts on `0.1.0-rc.6` ~ `0.1.1-rc.2` stay on the 0.1.x release line (last release `0.1.0`).
39
40
 
40
41
  ## Known Limitations
41
42
 
42
- - **In-place editing is a DOM-layer stopgap** — the official header exposes no title seat, so the editor overlays the hidden title crumb; if the official DOM changes, it degrades to an inline editor in the actions row.
43
+ - **In-place editing is a DOM-layer stopgap** — the official header still exposes no title seat, so the editor hides the official title node (`data-ste-inplace`) and overlays its input at that node's measured rect. The node is located by the title text (the leaf whose text equals `displayTitle` inside the crumbs nav's last segment), which is what makes one code path serve both host lines: the current crumb is a disabled button up to 0.1.6 and plain `span.crumbCurrent` text from 0.1.7-alpha.1 (upstream `92101e1a5b`, so the title joins the window drag band). When the title is not projected yet, or the official DOM moves again, it degrades to the inline editor in the actions row (renaming is unaffected).
43
44
  - **No optimistic update** — the header title refreshes only when the host projection settles the rename.
44
45
  - **Byte budget is host-owned, client-gated** — the client mirrors the host's 80-byte cap; a host that raises it widens the editor only once the constant follows.
45
46
 
@@ -59,7 +60,7 @@ src/client/slots.ts slot declaration
59
60
 
60
61
  Pure browser-side plugin: the rename rides the official `session.rename` RPC (`session.rename` → `sessions.rename` → `ctx.sessionTitle.rename`), so it needs no host half, no new RPC, and no edits to core packages.
61
62
 
62
- Editing is in place: clicking the pencil hides the official title crumb (`data-ste-inplace`) and overlays the plugin's input at the crumb's measured rect. The input width is fitted to the draft via a hidden mirror span at the input's font, between the crumb's original width and its 220px cap.
63
+ Editing is in place: clicking the pencil hides the official title node (`data-ste-inplace`) and overlays the plugin's input at that node's measured rect. The node is located by the current title text (the leaf whose text equals `displayTitle` inside the crumbs nav's last segment), so the 0.1.5/0.1.6 disabled crumb button and the 0.1.7-alpha.1 `span.crumbCurrent` share one path; ancestor crumbs and the official lineage slot's content are never hidden by mistake. The input width is fitted to the draft via a hidden mirror span at the input's font, between the title node's original width and the official crumb's 220px cap.
63
64
 
64
65
  Nothing changes for the model: the title is a projection-only property, never in model context, and renaming appends only a user-sourced `session/title` event — no token or KV-cache effect. The header title flips when the host projection lands. Separately, `@deepseek-ai/dsh-session-title` caps accepted titles (`maxTitleBytes`, production default 80 UTF-8 bytes); the editor mirrors that cap plus the host's pre-cap normalization (escape/control/directional stripping, whitespace collapsing, trimming), so gating fires exactly when the host would truncate.
65
66
 
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # last confirmed-consistent state. Both languages carry equal authority; after
3
3
  # editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/session-title-edit/README.en.md
5
- packages/session-title-edit/README.en.md: cbbe2ec82fea49b19470530a0320c4f8ce430bca
6
- packages/session-title-edit/README.md: 824eeb723447f7fe5b0337f43d903e39dcb7fbff
5
+ packages/session-title-edit/README.en.md: 0a8cc7956e7b923a32640451eb6bf8b4c95d1f7b
6
+ packages/session-title-edit/README.md: fce91b839aedaea17db2646de9090f6e2f49dda1
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  自动起的会话标题常常词不达意,过两天想找回某个会话全靠运气。这个插件在聊天区顶部的标题旁放了一支铅笔:点一下,标题就地变成输入框,预填好当前名字并全选;Enter 保存、Escape 取消,名字起得太长会先被警告拦下,绝不悄悄截断。改名走的是官方 `session.rename` 接口,标题永远不会进入模型上下文,模型对此一无所知。
8
8
 
9
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/session-title-edit1.png" width="640" alt="聊天区头部标题旁的铅笔按钮,悬停显示「重命名会话」">
9
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-basic/main/docs/screenshots/session-title-edit1.png" width="640" alt="聊天区头部标题旁的铅笔按钮,悬停显示「重命名会话」">
10
10
 
11
11
  ## 特性
12
12
 
@@ -16,7 +16,7 @@
16
16
  - **预算把关**——超过宿主 80 UTF-8 字节标题预算的草稿被警告拦下,绝不静默截断。
17
17
  - **零足迹**——走官方 `session.rename` RPC;标题永远不会进入模型上下文(无 token 或 KV 缓存影响)。
18
18
 
19
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/session-title-edit2.png" width="640" alt="点击铅笔后标题就地变成输入框,直接修改后回车保存">
19
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-basic/main/docs/screenshots/session-title-edit2.png" width="640" alt="点击铅笔后标题就地变成输入框,直接修改后回车保存">
20
20
 
21
21
  ## 安装
22
22
 
@@ -32,14 +32,15 @@ dsh plugin --profile web remove @khorsheed/dsh-client-session-title-edit
32
32
 
33
33
  ## Compatibility
34
34
 
35
- - npm 发布线(`@deepseek-ai/dsh@0.1.2-rc.1`):✅ 完整——基线迁移至 0.1.2-rc.1 API 面(单臂消费 0.1.2 API,0.1.1-rc.2 运行臂已退役),全量构建测试通过;minHost 前移至 0.1.2-rc.1,旧宿主请停留在旧发布线。
36
- - 源码线(deepseek-harness master):✅(verifiedHost: 0.1.2-rc.1)
35
+ - npm 发布线(宿主 `0.1.7-rc.2`,下一版 `0.2.4`):✅ 完整——改名走官方 `session.rename`;**就地覆盖在 0.1.5 与 0.1.7 两条宿主线上都可用**(同一套文本定位同时覆盖 0.1.5/0.1.6 的 disabled crumb 按钮与 0.1.7-alpha.1 起的纯文本 crumb),双线单测 + 两条宿主线实机验收通过;minHost 地板保持 `0.1.2-rc.1` 不动。
36
+ - 已发布版本 `0.2.3` 及更早:在 0.1.7-alpha.1 起的宿主上,标题 crumb 不再是 disabled 按钮,就地覆盖会退回 actions 行内编辑器(**改名本身仍可用**,只是不再盖在标题上);升到 `0.2.4` 即恢复。
37
+ - 源码线(deepseek-harness master,`0.1.7-rc.2`):✅(verifiedHost: `0.1.7-rc.2`)
37
38
 
38
39
  **版本线对照**:0.2.0 起支持宿主 `0.1.2-rc.1` 及以后;宿主 `0.1.0-rc.6` ~ `0.1.1-rc.2` 的用户请停留在 0.1.x 发布线(末版 `0.1.0`)。
39
40
 
40
41
  ## 已知限制
41
42
 
42
- - **原位编辑是 DOM 层过渡方案**——官方 header 没有暴露标题槽位,编辑器通过隐藏官方标题 crumb 并原位覆盖实现;官方 DOM 变化时退回 actions 行内编辑器。
43
+ - **原位编辑是 DOM 层过渡方案**——官方 header 仍无标题槽位,编辑器通过 `data-ste-inplace` 隐藏官方标题节点、并在其测量矩形上覆盖输入框。定位按「crumbs nav 最后一段里文本等于当前标题的节点」完成,因此两条宿主线共用一套代码:0.1.5/0.1.6 的当前 crumb 是 disabled 按钮,0.1.7-alpha.1 起(上游 `92101e1a5b`,为让标题进入窗口拖拽条)改成了纯文本 `span.crumbCurrent`。标题尚未投影、或官方 DOM 再次变化时,退回 actions 行内编辑器(改名不受影响)。
43
44
  - **无乐观更新**——header 标题只在宿主投影结算重命名后刷新。
44
45
  - **字节预算归宿主所有,由客户端把关**——客户端镜像宿主的 80 字节上限;宿主提高上限后,需常量同步跟进编辑器才会放宽。
45
46
 
@@ -59,7 +60,7 @@ src/client/slots.ts 槽位声明
59
60
 
60
61
  纯浏览器侧插件:重命名走官方 `session.rename` RPC(`session.rename` → `sessions.rename` → `ctx.sessionTitle.rename`),因此无需宿主半边、无需新增 RPC、无需改动任何官方包。
61
62
 
62
- 编辑是原位的:点击铅笔后,通过 `data-ste-inplace` 隐藏官方标题 crumb,并在其测量矩形上覆盖本插件的输入框。输入框宽度借助隐藏的、与输入框同字体的镜像 span 按草稿适配,下限为 crumb 原宽度、上限为 crumb 的 220px 上限。
63
+ 编辑是原位的:点击铅笔后,通过 `data-ste-inplace` 隐藏官方标题节点,并在其测量矩形上覆盖本插件的输入框。标题节点由「当前标题文本」定位(crumbs nav 最后一段里文本等于 `displayTitle` 的叶子节点),因此 0.1.5/0.1.6 的 disabled crumb 按钮与 0.1.7-alpha.1 起的 `span.crumbCurrent` 共用同一条路径;祖先 crumb 与官方 lineage 槽的内容都不会被误隐藏。输入框宽度借助隐藏的、与输入框同字体的镜像 span 按草稿适配,下限为标题节点原宽度、上限为官方 crumb 的 220px 上限。
63
64
 
64
65
  对模型没有任何变化:标题是仅投影的会话属性,永不进入模型上下文;重命名只是追加一条带用户来源的 `session/title` 事件——无 token 或 KV 缓存影响,投影落地后 header 标题随之刷新。另外,`@deepseek-ai/dsh-session-title` 限制接受的标题长度(`maxTitleBytes`,生产默认 80 UTF-8 字节);编辑器镜像该上限以及宿主截断前的归一化(剥离转义/控制/方向序列、折叠空白、去首尾空白),把关恰好在宿主会截断时触发。
65
66
 
package/lib/client.js CHANGED
@@ -112,7 +112,7 @@ window.__ModuleLoader__.load({
112
112
  }
113
113
  //#endregion
114
114
  //#region \0dsh-css:src/client/TitleEditAction.module.css.mjs
115
- const css = ".KqdipW_renameButton{width:22px;height:22px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:0;border-radius:6px;justify-content:center;align-items:center;margin-left:-12px;padding:0;display:inline-flex}.KqdipW_renameButton:hover,.KqdipW_renameButton:focus-visible{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}.KqdipW_editor{align-items:center;gap:6px;display:inline-flex}.KqdipW_editorInput{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);width:220px;height:28px;color:var(--dsw-alias-label-primary);background:0 0;border-radius:6px;outline:none;padding:3px 8px;font-size:12px;line-height:18px}.KqdipW_editorInput:focus{border-color:var(--dsw-alias-brand-primary)}.KqdipW_editorInput:disabled{color:var(--dsw-alias-label-dimmed)}.KqdipW_editorError{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}.KqdipW_editorHint{color:var(--dsw-alias-state-warn-primary);font-size:12px;line-height:18px}.KqdipW_inPlaceInput{box-sizing:border-box;z-index:40;color:var(--dsw-alias-label-primary);background:0 0;border:1px solid #0000;border-radius:12px;outline:none;padding:4px 8px;font-size:14px;font-weight:500;line-height:20px;position:fixed}.KqdipW_inPlaceInput:focus{border-color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-base)}.KqdipW_inPlaceInput:disabled{color:var(--dsw-alias-label-dimmed)}.KqdipW_inPlaceMirror{visibility:hidden;white-space:pre;pointer-events:none;font-family:inherit;font-size:14px;font-weight:500;position:absolute;top:-9999px;left:-9999px}.KqdipW_inPlaceError{color:var(--dsw-alias-state-error-primary);margin-left:6px;font-size:12px;line-height:18px}.KqdipW_inPlaceHint{color:var(--dsw-alias-state-warn-primary);margin-left:6px;font-size:12px;line-height:18px}header nav button[data-ste-inplace]{visibility:hidden!important}";
115
+ const css = ".okd51a_renameButton{width:22px;height:22px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:0;border-radius:6px;justify-content:center;align-items:center;margin-left:-12px;padding:0;display:inline-flex}.okd51a_renameButton:hover,.okd51a_renameButton:focus-visible{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}.okd51a_editor{align-items:center;gap:6px;display:inline-flex}.okd51a_editorInput{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);width:220px;height:28px;color:var(--dsw-alias-label-primary);background:0 0;border-radius:6px;outline:none;padding:3px 8px;font-size:12px;line-height:18px}.okd51a_editorInput:focus{border-color:var(--dsw-alias-brand-primary)}.okd51a_editorInput:disabled{color:var(--dsw-alias-label-dimmed)}.okd51a_editorError{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}.okd51a_editorHint{color:var(--dsw-alias-state-warn-primary);font-size:12px;line-height:18px}.okd51a_inPlaceInput{box-sizing:border-box;z-index:40;color:var(--dsw-alias-label-primary);background:0 0;border:1px solid #0000;border-radius:12px;outline:none;padding:4px 8px;font-size:14px;font-weight:500;line-height:20px;position:fixed}.okd51a_inPlaceInput:focus{border-color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-base)}.okd51a_inPlaceInput:disabled{color:var(--dsw-alias-label-dimmed)}.okd51a_inPlaceMirror{visibility:hidden;white-space:pre;pointer-events:none;font-family:inherit;font-size:14px;font-weight:500;position:absolute;top:-9999px;left:-9999px}.okd51a_inPlaceError{color:var(--dsw-alias-state-error-primary);margin-left:6px;font-size:12px;line-height:18px}.okd51a_inPlaceHint{color:var(--dsw-alias-state-warn-primary);margin-left:6px;font-size:12px;line-height:18px}header nav [data-ste-inplace]{visibility:hidden!important}";
116
116
  const tagId = "@khorsheed/dsh-client-session-title-edit/TitleEditAction.module.css";
117
117
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
118
118
  const tag = document.createElement("style");
@@ -122,26 +122,30 @@ window.__ModuleLoader__.load({
122
122
  document.head.appendChild(tag);
123
123
  }
124
124
  var TitleEditAction_module_css_default = {
125
- "editorHint": "KqdipW_editorHint",
126
- "editor": "KqdipW_editor",
127
- "renameButton": "KqdipW_renameButton",
128
- "inPlaceHint": "KqdipW_inPlaceHint",
129
- "inPlaceMirror": "KqdipW_inPlaceMirror",
130
- "inPlaceError": "KqdipW_inPlaceError",
131
- "editorInput": "KqdipW_editorInput",
132
- "editorError": "KqdipW_editorError",
133
- "inPlaceInput": "KqdipW_inPlaceInput"
125
+ "inPlaceHint": "okd51a_inPlaceHint",
126
+ "renameButton": "okd51a_renameButton",
127
+ "inPlaceMirror": "okd51a_inPlaceMirror",
128
+ "inPlaceError": "okd51a_inPlaceError",
129
+ "editorInput": "okd51a_editorInput",
130
+ "editorError": "okd51a_editorError",
131
+ "editor": "okd51a_editor",
132
+ "editorHint": "okd51a_editorHint",
133
+ "inPlaceInput": "okd51a_inPlaceInput"
134
134
  };
135
135
  //#endregion
136
136
  //#region src/client/TitleEditAction.tsx
137
137
  /**
138
138
  * Session-header title-edit entry (`conversation.session.header.actions`): a
139
139
  * pencil control immediately right of the title. Editing is IN PLACE: the
140
- * official current-title crumb is hidden via a `data-ste-inplace` attribute
141
- * (DOM layer — the official header renders the title and exposes no title
142
- * seat) and this entry overlays its own input at the crumb's measured rect,
143
- * so the title itself reads as the input. When the crumb cannot be located
144
- * (the official DOM changed), the entry degrades to an inline editor in the
140
+ * official node showing the current session's title is hidden via a
141
+ * `data-ste-inplace` attribute (DOM layer — the official header renders the
142
+ * title and exposes no title seat) and this entry overlays its own input at
143
+ * that node's measured rect, so the title itself reads as the input. Both
144
+ * supported host lines are located by the title text the store already gave
145
+ * us: up to 0.1.6 the current crumb is the header's only disabled button,
146
+ * from 0.1.7-alpha.1 it is plain text (`span.crumbCurrent`), and in both the
147
+ * title node is the text child of the header's last crumb segment. When the
148
+ * node cannot be located, the entry degrades to an inline editor in the
145
149
  * actions row. Enter commits, Escape cancels, a blurred in-place input
146
150
  * cancels, and a trimmed-empty draft never commits; host rejection keeps the
147
151
  * editor open with a localized error.
@@ -150,8 +154,10 @@ window.__ModuleLoader__.load({
150
154
  * header opens a title slot (or makes the crumb editable) — this entry then
151
155
  * becomes a pure slot consumer and the probe/overlay logic is removed.
152
156
  */
153
- /** Attribute hiding the official current-title crumb while editing in place. */
157
+ /** Attribute hiding the official current-title node while editing in place. */
154
158
  const IN_PLACE_ATTR = "data-ste-inplace";
159
+ /** The breadcrumb separator's rendered text: never mistaken for a title. */
160
+ const CRUMB_SEPARATOR = "/";
155
161
  /** Extra room so the caret and a trailing glyph are never clipped. */
156
162
  const INPUT_CARET_BUFFER = 4;
157
163
  /** Outer width cap of the fitted input, matching the official crumb's `max-width: 220px`. */
@@ -170,12 +176,50 @@ window.__ModuleLoader__.load({
170
176
  return Math.round(Math.min(Math.max(target, minWidth), MAX_INPUT_WIDTH));
171
177
  }
172
178
  /**
173
- * Locate the official current-title crumb: the header's only disabled crumb
174
- * button (the current session's crumb is `disabled`, ancestors navigate).
175
- * @returns the crumb element, or null when the official DOM is unrecognizable.
179
+ * Locate the header's breadcrumb list. The host renders it as the sibling
180
+ * immediately before the actions row this entry is mounted in (both supported
181
+ * lines), with the row's own slot anchor (`[data-slot=…]`, `display: contents`)
182
+ * between the two — so walk up our own subtree until an ancestor's previous
183
+ * sibling is that nav. The enclosing header's nav is the fallback when the
184
+ * shape is gone.
185
+ * @param anchor - this entry's own rendered control, which pins which header we sit in.
186
+ * @returns the crumbs nav, or null when the official DOM is unrecognizable.
176
187
  */
177
- function locateCrumb() {
178
- return document.querySelector("header nav button:disabled");
188
+ function locateCrumbsNav(anchor) {
189
+ for (let node = anchor; node !== null; node = node.parentElement) {
190
+ const sibling = node.previousElementSibling;
191
+ if (sibling instanceof HTMLElement && sibling.tagName === "NAV") return sibling;
192
+ if (node.tagName === "HEADER") break;
193
+ }
194
+ return (anchor?.closest("header") ?? document.querySelector("header"))?.querySelector("nav") ?? null;
195
+ }
196
+ /**
197
+ * Locate the official node that renders the current session's title. Up to
198
+ * host 0.1.6 the current crumb is the header's only disabled button; from
199
+ * 0.1.7-alpha.1 (upstream 92101e1a5b — the disabled button was subtracted from
200
+ * the darwin window-drag band, so the title became plain text) it is a
201
+ * `span.crumbCurrent`. Both draw the title as the text child of the header's
202
+ * LAST crumb segment, so the rendered title text — which the session store
203
+ * already gave us — is the line-agnostic anchor; a leaf match is preferred over
204
+ * the segment wrapper (which also holds the separator and any lineage slot
205
+ * content). The disabled-button query survives as the fallback for a session
206
+ * whose title the store has not projected yet.
207
+ * @param anchor - this entry's own rendered control.
208
+ * @param displayTitle - the current session title, as the crumb renders it.
209
+ * @returns the current-title element, or null when the official DOM is unrecognizable.
210
+ */
211
+ function locateCurrentTitle(anchor, displayTitle) {
212
+ const nav = locateCrumbsNav(anchor);
213
+ if (nav === null) return null;
214
+ const segment = nav.lastElementChild;
215
+ if (!(segment instanceof HTMLElement)) return null;
216
+ const wanted = displayTitle?.trim() ?? "";
217
+ if (wanted !== "") {
218
+ const matches = [segment, ...Array.from(segment.children).filter((element) => element instanceof HTMLElement)].filter((element) => (element.textContent ?? "").trim() !== CRUMB_SEPARATOR).filter((element) => (element.textContent ?? "").trim() === wanted);
219
+ const titled = matches.find((element) => element.children.length === 0) ?? matches.at(0);
220
+ if (titled !== void 0) return titled;
221
+ }
222
+ return segment.querySelector("button:disabled");
179
223
  }
180
224
  /**
181
225
  * Render the title-edit control.
@@ -188,12 +232,15 @@ window.__ModuleLoader__.load({
188
232
  const [draft, setDraft] = (0, react.useState)("");
189
233
  const [busy, setBusy] = (0, react.useState)(false);
190
234
  const [error, setError] = (0, react.useState)(null);
191
- /** Placement when editing in place (over the hidden crumb); null = row editor. */
235
+ /** Placement when editing in place (over the hidden title node); null = row editor. */
192
236
  const [placement, setPlacement] = (0, react.useState)(null);
193
- /** Outer width the in-place input is fitted to (draft text vs the crumb floor and 220px cap). */
237
+ /** Outer width the in-place input is fitted to (draft text vs the title floor and 220px cap). */
194
238
  const [inputWidth, setInputWidth] = (0, react.useState)(0);
195
239
  const inputRef = (0, react.useRef)(null);
196
- const crumbRef = (0, react.useRef)(null);
240
+ /** This entry's own control: the DOM anchor that proves which header we sit in. */
241
+ const buttonRef = (0, react.useRef)(null);
242
+ /** The official current-title node, hidden while the overlay is open. */
243
+ const titleRef = (0, react.useRef)(null);
197
244
  /** Hidden mirror of the draft, measured to fit the input to its text. */
198
245
  const measureRef = (0, react.useRef)(null);
199
246
  (0, react.useEffect)(() => {
@@ -205,10 +252,10 @@ window.__ModuleLoader__.load({
205
252
  (0, react.useEffect)(() => {
206
253
  if (!inPlaceActive) return;
207
254
  const remeasure = () => {
208
- const crumb = crumbRef.current;
255
+ const title = titleRef.current;
209
256
  /* v8 ignore next -- closeEditor nulls the ref and the placement in the same batch, so a resize cannot fire between them */
210
- if (crumb === null) return;
211
- const rect = crumb.getBoundingClientRect();
257
+ if (title === null) return;
258
+ const rect = title.getBoundingClientRect();
212
259
  setPlacement({
213
260
  left: rect.left,
214
261
  top: rect.top,
@@ -222,7 +269,7 @@ window.__ModuleLoader__.load({
222
269
  };
223
270
  }, [inPlaceActive]);
224
271
  (0, react.useEffect)(() => () => {
225
- if (crumbRef.current !== null) crumbRef.current.removeAttribute(IN_PLACE_ATTR);
272
+ if (titleRef.current !== null) titleRef.current.removeAttribute(IN_PLACE_ATTR);
226
273
  }, []);
227
274
  (0, react.useLayoutEffect)(() => {
228
275
  if (!inPlaceActive) return;
@@ -236,20 +283,20 @@ window.__ModuleLoader__.load({
236
283
  const closeEditor = () => {
237
284
  setEditing(false);
238
285
  setError(null);
239
- if (crumbRef.current !== null) {
240
- crumbRef.current.removeAttribute(IN_PLACE_ATTR);
241
- crumbRef.current = null;
286
+ if (titleRef.current !== null) {
287
+ titleRef.current.removeAttribute(IN_PLACE_ATTR);
288
+ titleRef.current = null;
242
289
  }
243
290
  setPlacement(null);
244
291
  };
245
292
  const begin = () => {
246
293
  setDraft(displayTitle ?? "");
247
294
  setError(null);
248
- const crumb = locateCrumb();
249
- crumbRef.current = crumb;
250
- if (crumb !== null) {
251
- const rect = crumb.getBoundingClientRect();
252
- crumb.setAttribute(IN_PLACE_ATTR, "");
295
+ const title = locateCurrentTitle(buttonRef.current, displayTitle);
296
+ titleRef.current = title;
297
+ if (title !== null) {
298
+ const rect = title.getBoundingClientRect();
299
+ title.setAttribute(IN_PLACE_ATTR, "");
253
300
  setPlacement({
254
301
  left: rect.left,
255
302
  top: rect.top,
@@ -298,6 +345,7 @@ window.__ModuleLoader__.load({
298
345
  bytes: draftBytes
299
346
  });
300
347
  if (!editing) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
348
+ ref: buttonRef,
301
349
  type: "button",
302
350
  className: TitleEditAction_module_css_default.renameButton,
303
351
  "aria-label": t("action.rename"),
@@ -2,11 +2,15 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  /**
3
3
  * Session-header title-edit entry (`conversation.session.header.actions`): a
4
4
  * pencil control immediately right of the title. Editing is IN PLACE: the
5
- * official current-title crumb is hidden via a `data-ste-inplace` attribute
6
- * (DOM layer — the official header renders the title and exposes no title
7
- * seat) and this entry overlays its own input at the crumb's measured rect,
8
- * so the title itself reads as the input. When the crumb cannot be located
9
- * (the official DOM changed), the entry degrades to an inline editor in the
5
+ * official node showing the current session's title is hidden via a
6
+ * `data-ste-inplace` attribute (DOM layer — the official header renders the
7
+ * title and exposes no title seat) and this entry overlays its own input at
8
+ * that node's measured rect, so the title itself reads as the input. Both
9
+ * supported host lines are located by the title text the store already gave
10
+ * us: up to 0.1.6 the current crumb is the header's only disabled button,
11
+ * from 0.1.7-alpha.1 it is plain text (`span.crumbCurrent`), and in both the
12
+ * title node is the text child of the header's last crumb segment. When the
13
+ * node cannot be located, the entry degrades to an inline editor in the
10
14
  * actions row. Enter commits, Escape cancels, a blurred in-place input
11
15
  * cancels, and a trimmed-empty draft never commits; host rejection keeps the
12
16
  * editor open with a localized error.
@@ -21,8 +25,10 @@ import { IconEditOutlineMedium } from './icons.js';
21
25
  import { isRenameFailure } from './slots.js';
22
26
  import { MAX_TITLE_BYTES, normalizedTitleByteLength } from './title-length.js';
23
27
  import css from './TitleEditAction.module.css';
24
- /** Attribute hiding the official current-title crumb while editing in place. */
28
+ /** Attribute hiding the official current-title node while editing in place. */
25
29
  const IN_PLACE_ATTR = 'data-ste-inplace';
30
+ /** The breadcrumb separator's rendered text: never mistaken for a title. */
31
+ const CRUMB_SEPARATOR = '/';
26
32
  /** Horizontal padding of the in-place input (`padding: 4px 8px`). */
27
33
  const INPUT_H_PADDING = 8;
28
34
  /** Border width of the in-place input (`1px solid transparent`). */
@@ -45,12 +51,60 @@ function fitInputWidth(textWidth, minWidth) {
45
51
  return Math.round(Math.min(Math.max(target, minWidth), MAX_INPUT_WIDTH));
46
52
  }
47
53
  /**
48
- * Locate the official current-title crumb: the header's only disabled crumb
49
- * button (the current session's crumb is `disabled`, ancestors navigate).
50
- * @returns the crumb element, or null when the official DOM is unrecognizable.
54
+ * Locate the header's breadcrumb list. The host renders it as the sibling
55
+ * immediately before the actions row this entry is mounted in (both supported
56
+ * lines), with the row's own slot anchor (`[data-slot=…]`, `display: contents`)
57
+ * between the two — so walk up our own subtree until an ancestor's previous
58
+ * sibling is that nav. The enclosing header's nav is the fallback when the
59
+ * shape is gone.
60
+ * @param anchor - this entry's own rendered control, which pins which header we sit in.
61
+ * @returns the crumbs nav, or null when the official DOM is unrecognizable.
51
62
  */
52
- function locateCrumb() {
53
- return document.querySelector('header nav button:disabled');
63
+ function locateCrumbsNav(anchor) {
64
+ for (let node = anchor; node !== null; node = node.parentElement) {
65
+ const sibling = node.previousElementSibling;
66
+ if (sibling instanceof HTMLElement && sibling.tagName === 'NAV')
67
+ return sibling;
68
+ if (node.tagName === 'HEADER')
69
+ break;
70
+ }
71
+ return (anchor?.closest('header') ?? document.querySelector('header'))?.querySelector('nav') ?? null;
72
+ }
73
+ /**
74
+ * Locate the official node that renders the current session's title. Up to
75
+ * host 0.1.6 the current crumb is the header's only disabled button; from
76
+ * 0.1.7-alpha.1 (upstream 92101e1a5b — the disabled button was subtracted from
77
+ * the darwin window-drag band, so the title became plain text) it is a
78
+ * `span.crumbCurrent`. Both draw the title as the text child of the header's
79
+ * LAST crumb segment, so the rendered title text — which the session store
80
+ * already gave us — is the line-agnostic anchor; a leaf match is preferred over
81
+ * the segment wrapper (which also holds the separator and any lineage slot
82
+ * content). The disabled-button query survives as the fallback for a session
83
+ * whose title the store has not projected yet.
84
+ * @param anchor - this entry's own rendered control.
85
+ * @param displayTitle - the current session title, as the crumb renders it.
86
+ * @returns the current-title element, or null when the official DOM is unrecognizable.
87
+ */
88
+ function locateCurrentTitle(anchor, displayTitle) {
89
+ const nav = locateCrumbsNav(anchor);
90
+ if (nav === null)
91
+ return null;
92
+ const segment = nav.lastElementChild;
93
+ if (!(segment instanceof HTMLElement))
94
+ return null;
95
+ const wanted = displayTitle?.trim() ?? '';
96
+ if (wanted !== '') {
97
+ const inSegment = Array.from(segment.children)
98
+ .filter((element) => element instanceof HTMLElement);
99
+ const candidates = [segment, ...inSegment]
100
+ .filter(element => (element.textContent ?? '').trim() !== CRUMB_SEPARATOR);
101
+ const matches = candidates.filter(element => (element.textContent ?? '').trim() === wanted);
102
+ const titled = matches.find(element => element.children.length === 0) ?? matches.at(0);
103
+ if (titled !== undefined)
104
+ return titled;
105
+ }
106
+ // ≤0.1.6 shape: the current crumb is the header's only disabled crumb button.
107
+ return segment.querySelector('button:disabled');
54
108
  }
55
109
  /**
56
110
  * Render the title-edit control.
@@ -63,12 +117,15 @@ export function TitleEditAction({ sessionId, useSessions, renameSession, t }) {
63
117
  const [draft, setDraft] = useState('');
64
118
  const [busy, setBusy] = useState(false);
65
119
  const [error, setError] = useState(null);
66
- /** Placement when editing in place (over the hidden crumb); null = row editor. */
120
+ /** Placement when editing in place (over the hidden title node); null = row editor. */
67
121
  const [placement, setPlacement] = useState(null);
68
- /** Outer width the in-place input is fitted to (draft text vs the crumb floor and 220px cap). */
122
+ /** Outer width the in-place input is fitted to (draft text vs the title floor and 220px cap). */
69
123
  const [inputWidth, setInputWidth] = useState(0);
70
124
  const inputRef = useRef(null);
71
- const crumbRef = useRef(null);
125
+ /** This entry's own control: the DOM anchor that proves which header we sit in. */
126
+ const buttonRef = useRef(null);
127
+ /** The official current-title node, hidden while the overlay is open. */
128
+ const titleRef = useRef(null);
72
129
  /** Hidden mirror of the draft, measured to fit the input to its text. */
73
130
  const measureRef = useRef(null);
74
131
  // Select the whole current title on open so typing replaces it outright.
@@ -79,31 +136,31 @@ export function TitleEditAction({ sessionId, useSessions, renameSession, t }) {
79
136
  inputRef.current?.select();
80
137
  }, [editing]);
81
138
  // Re-measure while editing in place so a window resize keeps the overlay on
82
- // the (hidden) crumb's position.
139
+ // the (hidden) title node's position.
83
140
  const inPlaceActive = placement !== null;
84
141
  useEffect(() => {
85
142
  if (!inPlaceActive)
86
143
  return;
87
144
  const remeasure = () => {
88
- const crumb = crumbRef.current;
145
+ const title = titleRef.current;
89
146
  /* v8 ignore next -- closeEditor nulls the ref and the placement in the same batch, so a resize cannot fire between them */
90
- if (crumb === null)
147
+ if (title === null)
91
148
  return;
92
- const rect = crumb.getBoundingClientRect();
149
+ const rect = title.getBoundingClientRect();
93
150
  setPlacement({ left: rect.left, top: rect.top, width: rect.width, height: rect.height });
94
151
  };
95
152
  window.addEventListener('resize', remeasure);
96
153
  return () => { window.removeEventListener('resize', remeasure); };
97
154
  }, [inPlaceActive]);
98
- // Never leave the official crumb hidden: restore it if this entry unmounts
155
+ // Never leave the official title hidden: restore it if this entry unmounts
99
156
  // mid-edit (programmatic session switch, HMR reload, plugin disposal).
100
157
  useEffect(() => () => {
101
- if (crumbRef.current !== null)
102
- crumbRef.current.removeAttribute(IN_PLACE_ATTR);
158
+ if (titleRef.current !== null)
159
+ titleRef.current.removeAttribute(IN_PLACE_ATTR);
103
160
  }, []);
104
161
  // Fit the in-place input to its text: measure the mirror span (which carries
105
162
  // the exact draft at the input's font) after every draft or placement
106
- // change, then clamp between the crumb's original width and the 220px cap.
163
+ // change, then clamp between the title node's original width and the 220px cap.
107
164
  useLayoutEffect(() => {
108
165
  if (!inPlaceActive)
109
166
  return;
@@ -113,25 +170,25 @@ export function TitleEditAction({ sessionId, useSessions, renameSession, t }) {
113
170
  const closeEditor = () => {
114
171
  setEditing(false);
115
172
  setError(null);
116
- if (crumbRef.current !== null) {
117
- crumbRef.current.removeAttribute(IN_PLACE_ATTR);
118
- crumbRef.current = null;
173
+ if (titleRef.current !== null) {
174
+ titleRef.current.removeAttribute(IN_PLACE_ATTR);
175
+ titleRef.current = null;
119
176
  }
120
177
  setPlacement(null);
121
178
  };
122
179
  const begin = () => {
123
180
  setDraft(displayTitle ?? '');
124
181
  setError(null);
125
- const crumb = locateCrumb();
126
- crumbRef.current = crumb;
127
- if (crumb !== null) {
128
- // Measure FIRST: the crumb is hidden via visibility (layout box kept),
182
+ const title = locateCurrentTitle(buttonRef.current, displayTitle);
183
+ titleRef.current = title;
184
+ if (title !== null) {
185
+ // Measure FIRST: the node is hidden via visibility (layout box kept),
129
186
  // so its rect stays measurable both before and after hiding.
130
- const rect = crumb.getBoundingClientRect();
131
- crumb.setAttribute(IN_PLACE_ATTR, '');
187
+ const rect = title.getBoundingClientRect();
188
+ title.setAttribute(IN_PLACE_ATTR, '');
132
189
  setPlacement({ left: rect.left, top: rect.top, width: rect.width, height: rect.height });
133
- // Floor the fitted width at the crumb's original width so the open box
134
- // matches today's look; the fit effect widens it as the draft grows.
190
+ // Floor the fitted width at the title node's original width so the open
191
+ // box matches today's look; the fit effect widens it as the draft grows.
135
192
  setInputWidth(rect.width);
136
193
  }
137
194
  setEditing(true);
@@ -189,7 +246,7 @@ export function TitleEditAction({ sessionId, useSessions, renameSession, t }) {
189
246
  bytes: draftBytes,
190
247
  });
191
248
  if (!editing) {
192
- return (_jsx("button", { type: "button", className: css.renameButton, "aria-label": t('action.rename'), title: t('action.rename'), onClick: begin, children: _jsx(IconEditOutlineMedium, { size: 14 }) }));
249
+ return (_jsx("button", { ref: buttonRef, type: "button", className: css.renameButton, "aria-label": t('action.rename'), title: t('action.rename'), onClick: begin, children: _jsx(IconEditOutlineMedium, { size: 14 }) }));
193
250
  }
194
251
  if (placement !== null) {
195
252
  return (_jsxs(_Fragment, { children: [_jsx("span", { ref: measureRef, className: css.inPlaceMirror, "aria-hidden": "true", children: draft === '' ? ' ' : draft }), _jsx("input", { ref: inputRef, className: css.inPlaceInput, type: "text", value: draft, "aria-label": t('editor.aria'), placeholder: t('editor.placeholder'), disabled: busy, autoFocus: true, style: { left: placement.left, top: placement.top, width: inputWidth, height: placement.height }, onChange: (event) => { setDraft(event.currentTarget.value); }, onKeyDown: onKeyDown, onBlur: onBlur }), error !== null
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "session",
9
9
  "title"
10
10
  ],
11
- "version": "0.2.3",
11
+ "version": "0.2.5",
12
12
  "type": "module",
13
13
  "main": "lib/index.js",
14
14
  "types": "lib/types/index.d.ts",
@@ -43,17 +43,18 @@
43
43
  },
44
44
  "compat": {
45
45
  "minHost": "0.1.2-rc.1",
46
- "verifiedHost": "0.1.2-rc.1"
46
+ "verifiedHost": "0.1.7-rc.2",
47
+ "notes": "就地编辑是 DOM 层过渡方案(官方 header 无标题槽位),定位按「crumbs nav 最后一段里文本等于当前 displayTitle 的叶子节点」做:0.1.5/0.1.6 的当前 crumb 是 disabled 按钮,0.1.7-alpha.1 起(上游 92101e1a5b,让标题进入窗口拖拽条)是纯文本 span.crumbCurrent,两条宿主线因此共用一套定位;标题尚未投影或官方 DOM 再次变化时按设计退回 actions 行的行内编辑器(改名始终走官方 session.rename,不受影响)。已发布的 0.2.3 及更早版本在 0.1.7-alpha.1 起的宿主上只会走退回臂。"
47
48
  }
48
49
  },
49
50
  "license": "MIT",
50
51
  "peerDependencies": {
51
52
  "@deepseek-ai/cordis": "^4.0.1",
52
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
53
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
54
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
55
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
56
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
53
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6 || ^0.2.0-rc.1",
54
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6 || ^0.2.0-rc.1",
55
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6 || ^0.2.0-rc.1",
56
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6 || ^0.2.0-rc.1",
57
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6 || ^0.2.0-rc.1",
57
58
  "react": "^18.2.0"
58
59
  },
59
60
  "files": [