@khorsheed/dsh-client-session-title-edit 0.2.2 → 0.2.4
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 +8 -0
- package/README.en.md +7 -6
- package/README.i18n.yaml +2 -2
- package/README.md +7 -6
- package/lib/client.js +84 -36
- package/lib/types/client/TitleEditAction.js +91 -34
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# 变更记录
|
|
2
2
|
|
|
3
|
+
## 0.2.4(2026-09-28)
|
|
4
|
+
|
|
5
|
+
- **标题定位同时兼容新旧两条宿主 DOM 线**:0.1.7 线改了标题节点结构,旧选择器落空时编辑入口不再消失
|
|
6
|
+
|
|
7
|
+
## 0.2.3(2026-09-27)
|
|
8
|
+
|
|
9
|
+
无功能变更。随宿主 0.1.7-rc.2 基线发布波重发:全量构建+测试在 rc.2 基线通过(rc.1→rc.2 无触及本包的宿主变更)。
|
|
10
|
+
|
|
3
11
|
## 0.2.2(2026-09-26)
|
|
4
12
|
|
|
5
13
|
适配宿主 rc.1 线并实证 0.1.5/0.1.7 双线可用(0.1.5-rc.1 全量 boot 实证,2026-09-25)。
|
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-
|
|
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-
|
|
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 (
|
|
36
|
-
-
|
|
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
|
|
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
|
|
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:
|
|
6
|
-
packages/session-title-edit/README.md:
|
|
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-
|
|
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-
|
|
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
|
|
36
|
-
-
|
|
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
|
|
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`
|
|
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 = ".
|
|
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
|
-
"
|
|
126
|
-
"
|
|
127
|
-
"
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
"
|
|
125
|
+
"inPlaceMirror": "okd51a_inPlaceMirror",
|
|
126
|
+
"renameButton": "okd51a_renameButton",
|
|
127
|
+
"inPlaceInput": "okd51a_inPlaceInput",
|
|
128
|
+
"editorHint": "okd51a_editorHint",
|
|
129
|
+
"editor": "okd51a_editor",
|
|
130
|
+
"editorInput": "okd51a_editorInput",
|
|
131
|
+
"inPlaceError": "okd51a_inPlaceError",
|
|
132
|
+
"inPlaceHint": "okd51a_inPlaceHint",
|
|
133
|
+
"editorError": "okd51a_editorError"
|
|
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
|
|
141
|
-
* (DOM layer — the official header renders the
|
|
142
|
-
* seat) and this entry overlays its own input at
|
|
143
|
-
* so the title itself reads as the input.
|
|
144
|
-
*
|
|
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
|
|
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
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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
|
|
178
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
211
|
-
const rect =
|
|
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 (
|
|
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 (
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
249
|
-
|
|
250
|
-
if (
|
|
251
|
-
const rect =
|
|
252
|
-
|
|
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
|
|
6
|
-
* (DOM layer — the official header renders the
|
|
7
|
-
* seat) and this entry overlays its own input at
|
|
8
|
-
* so the title itself reads as the input.
|
|
9
|
-
*
|
|
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
|
|
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
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
|
53
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
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 (
|
|
147
|
+
if (title === null)
|
|
91
148
|
return;
|
|
92
|
-
const rect =
|
|
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
|
|
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 (
|
|
102
|
-
|
|
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
|
|
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 (
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
// Measure FIRST: the
|
|
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 =
|
|
131
|
-
|
|
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
|
|
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.
|
|
11
|
+
"version": "0.2.4",
|
|
12
12
|
"type": "module",
|
|
13
13
|
"main": "lib/index.js",
|
|
14
14
|
"types": "lib/types/index.d.ts",
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
},
|
|
44
44
|
"compat": {
|
|
45
45
|
"minHost": "0.1.2-rc.1",
|
|
46
|
-
"verifiedHost": "0.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",
|