@khorsheed/dsh-client-session-title-edit 0.1.0
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 +11 -0
- package/LICENSE +21 -0
- package/README.en.md +70 -0
- package/README.i18n.yaml +6 -0
- package/README.md +70 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +413 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +11 -0
- package/lib/invariant.js +26 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/lib/types/client/TitleEditAction.d.ts +8 -0
- package/lib/types/client/TitleEditAction.d.ts.map +1 -0
- package/lib/types/client/TitleEditAction.js +201 -0
- package/lib/types/client/index.d.ts +21 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js +36 -0
- package/lib/types/client/locales.d.ts +34 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/locales.js +25 -0
- package/lib/types/client/slots.d.ts +31 -0
- package/lib/types/client/slots.d.ts.map +1 -0
- package/lib/types/client/slots.js +8 -0
- package/lib/types/client/title-length.d.ts +39 -0
- package/lib/types/client/title-length.d.ts.map +1 -0
- package/lib/types/client/title-length.js +60 -0
- package/lib/types/index.d.ts +9 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +8 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/invariant.js +24 -0
- package/package.json +79 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-plugins contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @khorsheed/dsh-client-session-title-edit
|
|
2
|
+
|
|
3
|
+
English | [中文](README.md)
|
|
4
|
+
|
|
5
|
+
Rename a session right in the dsh web GUI chat header: click the pencil next to the title and the title itself becomes an inline editor. Enter commits, Escape cancels, over-long drafts are blocked with a localized warning — and the model never sees any of it.
|
|
6
|
+
|
|
7
|
+
<img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/session-title-edit1.png" width="480" alt="inline session title editor in the chat header">
|
|
8
|
+
|
|
9
|
+
<img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/session-title-edit2.png" width="480" alt="click the pencil and the title becomes an input — Enter saves">
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Pencil in the header** — an entry right of the session title swaps in an inline editor, prefilled and fully selected.
|
|
14
|
+
- **Predictable keys** — Enter commits, Escape cancels, empty drafts disable save, host rejections show inline.
|
|
15
|
+
- **Auto-fitting input** — the field widens with the draft up to the official 220px cap.
|
|
16
|
+
- **Budget-aware** — drafts past the host's 80-UTF-8-byte title budget are blocked with a warning, never silently truncated.
|
|
17
|
+
- **Zero footprint** — rides the official `session.rename` RPC; the title never enters model context (no token or KV-cache effect).
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
dsh plugin --profile web add @khorsheed/dsh-client-session-title-edit
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Restart the web instance after install; uninstall removes every surface the plugin adds.
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
dsh plugin --profile web remove @khorsheed/dsh-client-session-title-edit
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Compatibility
|
|
32
|
+
|
|
33
|
+
- npm release line (`@deepseek-ai/dsh@0.1.1-rc.2`): ✅ full — the rc.8→0.1.1-rc.1 API audit (2026-08-21) confirms every surface this plugin consumes is unchanged or additive (the ProjectionDefinition restructure, cacheHitPercent return-type change, and the credentials/updated event rename do not touch this package); no source change was needed; re-audited for rc.2 (2026-08-22): consumed surface unchanged, full build+test green.
|
|
34
|
+
- source line (deepseek-harness master): ✅
|
|
35
|
+
|
|
36
|
+
## Known Limitations
|
|
37
|
+
|
|
38
|
+
- **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.
|
|
39
|
+
- **No optimistic update** — the header title refreshes only when the host projection settles the rename.
|
|
40
|
+
- **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.
|
|
41
|
+
|
|
42
|
+
## How it works
|
|
43
|
+
|
|
44
|
+
<details>
|
|
45
|
+
<summary>Internals (click to expand)</summary>
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
src/index.ts exports (apply/inject + TitleEditActionProps)
|
|
49
|
+
src/client/index.ts plugin body; contributes the header-actions entry
|
|
50
|
+
src/client/TitleEditAction.tsx pencil entry, inline editor, overlay + width fitting
|
|
51
|
+
src/client/title-length.ts MAX_TITLE_BYTES mirror + host normalization
|
|
52
|
+
src/client/locales.ts localized warnings/errors
|
|
53
|
+
src/client/slots.ts slot declaration
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
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.
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
|
|
62
|
+
</details>
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
Part of the [dsh-plugins](https://github.com/Khorsheed/dsh-plugins) monorepo (`packages/session-title-edit`). Issues and contributions welcome there.
|
|
67
|
+
|
|
68
|
+
## Changelog
|
|
69
|
+
|
|
70
|
+
See [CHANGELOG.md](CHANGELOG.md).
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record: the git blob hash of each side as of the
|
|
2
|
+
# last confirmed-consistent state. Both languages carry equal authority; after
|
|
3
|
+
# editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/session-title-edit/README.en.md
|
|
5
|
+
packages/session-title-edit/README.en.md: 9b0bc61b534c9b2221996afbb1f0da10f011526d
|
|
6
|
+
packages/session-title-edit/README.md: 1117e60c88c6ca7ec99dfb659d59ae98e1287865
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @khorsheed/dsh-client-session-title-edit
|
|
2
|
+
|
|
3
|
+
[English](README.en.md) | 中文
|
|
4
|
+
|
|
5
|
+
在 dsh web GUI 聊天区头部直接重命名会话:点击标题旁的铅笔,标题本身变成内联编辑器。Enter 提交、Escape 取消,超长草稿会被本地化警告拦下——模型对此完全无感。
|
|
6
|
+
|
|
7
|
+
<img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/session-title-edit1.png" width="480" alt="聊天头部的内联会话标题编辑器">
|
|
8
|
+
|
|
9
|
+
<img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/session-title-edit2.png" width="480" alt="点击铅笔后标题变成输入框,回车即保存">
|
|
10
|
+
|
|
11
|
+
## 特性
|
|
12
|
+
|
|
13
|
+
- **头部铅笔**——会话标题右侧的条目切换出内联编辑器,预填当前标题并全选。
|
|
14
|
+
- **可预期的按键**——Enter 提交、Escape 取消、空草稿禁用保存、宿主拒绝时内联提示。
|
|
15
|
+
- **宽度自适应**——输入框随草稿变宽,上限为官方 220px。
|
|
16
|
+
- **预算把关**——超过宿主 80 UTF-8 字节标题预算的草稿被警告拦下,绝不静默截断。
|
|
17
|
+
- **零足迹**——走官方 `session.rename` RPC;标题永远不会进入模型上下文(无 token 或 KV 缓存影响)。
|
|
18
|
+
|
|
19
|
+
## 安装
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
dsh plugin --profile web add @khorsheed/dsh-client-session-title-edit
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
安装后重启 web 实例;卸载即移除本插件添加的全部界面。
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
dsh plugin --profile web remove @khorsheed/dsh-client-session-title-edit
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Compatibility
|
|
32
|
+
|
|
33
|
+
- npm 发布线(`@deepseek-ai/dsh@0.1.1-rc.2`):✅ 完整——rc.8→0.1.1-rc.1 API 审计(2026-08-21)确认本插件消费的所有面无变化或纯增量(ProjectionDefinition 重构、cacheHitPercent 返回值变更、credentials/updated 事件改名均不涉及本包),无需改动源码;rc.1→rc.2 复核(2026-08-22):消费面无变化,全量构建测试通过。
|
|
34
|
+
- 源码线(deepseek-harness master):✅
|
|
35
|
+
|
|
36
|
+
## 已知限制
|
|
37
|
+
|
|
38
|
+
- **原位编辑是 DOM 层过渡方案**——官方 header 没有暴露标题槽位,编辑器通过隐藏官方标题 crumb 并原位覆盖实现;官方 DOM 变化时退回 actions 行内编辑器。
|
|
39
|
+
- **无乐观更新**——header 标题只在宿主投影结算重命名后刷新。
|
|
40
|
+
- **字节预算归宿主所有,由客户端把关**——客户端镜像宿主的 80 字节上限;宿主提高上限后,需常量同步跟进编辑器才会放宽。
|
|
41
|
+
|
|
42
|
+
## 实现原理
|
|
43
|
+
|
|
44
|
+
<details>
|
|
45
|
+
<summary>内部结构(点击展开)</summary>
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
src/index.ts 导出(apply/inject + TitleEditActionProps)
|
|
49
|
+
src/client/index.ts 插件主体;贡献 header-actions 条目
|
|
50
|
+
src/client/TitleEditAction.tsx 铅笔条目、内联编辑器、覆盖与宽度适配
|
|
51
|
+
src/client/title-length.ts MAX_TITLE_BYTES 镜像 + 宿主归一化
|
|
52
|
+
src/client/locales.ts 本地化警告与错误
|
|
53
|
+
src/client/slots.ts 槽位声明
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
纯浏览器侧插件:重命名走官方 `session.rename` RPC(`session.rename` → `sessions.rename` → `ctx.sessionTitle.rename`),因此无需宿主半边、无需新增 RPC、无需改动任何官方包。
|
|
57
|
+
|
|
58
|
+
编辑是原位的:点击铅笔后,通过 `data-ste-inplace` 隐藏官方标题 crumb,并在其测量矩形上覆盖本插件的输入框。输入框宽度借助隐藏的、与输入框同字体的镜像 span 按草稿适配,下限为 crumb 原宽度、上限为 crumb 的 220px 上限。
|
|
59
|
+
|
|
60
|
+
对模型没有任何变化:标题是仅投影的会话属性,永不进入模型上下文;重命名只是追加一条带用户来源的 `session/title` 事件——无 token 或 KV 缓存影响,投影落地后 header 标题随之刷新。另外,`@deepseek-ai/dsh-session-title` 限制接受的标题长度(`maxTitleBytes`,生产默认 80 UTF-8 字节);编辑器镜像该上限以及宿主截断前的归一化(剥离转义/控制/方向序列、折叠空白、去首尾空白),把关恰好在宿主会截断时触发。
|
|
61
|
+
|
|
62
|
+
</details>
|
|
63
|
+
|
|
64
|
+
## 开发
|
|
65
|
+
|
|
66
|
+
隶属 [dsh-plugins](https://github.com/Khorsheed/dsh-plugins) monorepo(`packages/session-title-edit`)。问题与贡献请移步该仓库。
|
|
67
|
+
|
|
68
|
+
## 变更记录
|
|
69
|
+
|
|
70
|
+
见 [CHANGELOG.md](CHANGELOG.md)。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# session-title-edit bundle patch: inserts the plugin row into the composing
|
|
2
|
+
# profile. The client half is discovered through the package.json dsh.client
|
|
3
|
+
# block; there is no host half, so this row only activates the browser entry.
|
|
4
|
+
- insert:
|
|
5
|
+
- id: session-title-edit
|
|
6
|
+
name: '@khorsheed/dsh-client-session-title-edit'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@khorsheed/dsh-client-session-title-edit",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
9
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
10
|
+
//#region src/client/locales.ts
|
|
11
|
+
/** `session-title-edit` namespace dictionaries. */
|
|
12
|
+
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
13
|
+
const zh = {
|
|
14
|
+
"action.rename": "重命名会话",
|
|
15
|
+
"editor.aria": "会话标题",
|
|
16
|
+
"editor.placeholder": "输入新标题…",
|
|
17
|
+
"editor.save": "保存",
|
|
18
|
+
"cancel": "取消",
|
|
19
|
+
"error.invalid": "标题不能为空",
|
|
20
|
+
"error.rename": "重命名失败,请重试",
|
|
21
|
+
"hint.tooLong": "标题过长:最多 {max} 字节(约 {chars} 个汉字),当前 {bytes} 字节"
|
|
22
|
+
};
|
|
23
|
+
/** The dictionary namespace owned by this plugin. */
|
|
24
|
+
const NS = "session-title-edit";
|
|
25
|
+
/** English dictionary, checked complete against the zh key set. */
|
|
26
|
+
const en = {
|
|
27
|
+
"action.rename": "Rename session",
|
|
28
|
+
"editor.aria": "Session title",
|
|
29
|
+
"editor.placeholder": "Enter a new title…",
|
|
30
|
+
"editor.save": "Save",
|
|
31
|
+
"cancel": "Cancel",
|
|
32
|
+
"error.invalid": "The title cannot be empty",
|
|
33
|
+
"error.rename": "Rename failed, please try again",
|
|
34
|
+
"hint.tooLong": "Title too long: max {max} bytes (about {chars} CJK characters), currently {bytes}"
|
|
35
|
+
};
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region src/client/slots.ts
|
|
38
|
+
/**
|
|
39
|
+
* Narrow an unknown thrown value to a {@link RenameFailure}.
|
|
40
|
+
* @param error - the value caught from a rename attempt.
|
|
41
|
+
* @returns true when the value is an Error carrying a string `code`.
|
|
42
|
+
*/
|
|
43
|
+
function isRenameFailure(error) {
|
|
44
|
+
return error instanceof Error && typeof error.code === "string";
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region src/client/title-length.ts
|
|
48
|
+
/**
|
|
49
|
+
* Client-side mirror of the host session-title byte budget and the
|
|
50
|
+
* normalization the host applies before capping, so the editor can gate
|
|
51
|
+
* against the exact title the host would accept instead of discovering a
|
|
52
|
+
* silent truncation after commit.
|
|
53
|
+
*
|
|
54
|
+
* The host contract: `@deepseek-ai/dsh-session-title`'s
|
|
55
|
+
* `normalizeSessionTitle(input, maxTitleBytes)` cleans the text (ESC/CSI/
|
|
56
|
+
* control/directional sequences stripped, whitespace collapsed to one space,
|
|
57
|
+
* trimmed) and truncates to a UTF-8 byte budget. The production default is
|
|
58
|
+
* `maxTitleBytes: 80` (deepseek-harness `packages/bundle/base/cordis.patch.yml`).
|
|
59
|
+
* The clean step and the constant below mirror that behavior; update them
|
|
60
|
+
* together if the host changes either.
|
|
61
|
+
*/
|
|
62
|
+
/** Strip operating-system-command escape sequences, including unterminated tails. */
|
|
63
|
+
const OSC_SEQUENCE = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu;
|
|
64
|
+
/** Strip control-sequence-introducer escapes such as SGR color codes. */
|
|
65
|
+
const CSI_SEQUENCE = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu;
|
|
66
|
+
/** Strip remaining two-byte ESC control sequences. */
|
|
67
|
+
const ESC_SEQUENCE = /\u001B[@-_]/gu;
|
|
68
|
+
/** Strip non-whitespace C0/C1 control characters. */
|
|
69
|
+
const CONTROL_CHARACTER = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/gu;
|
|
70
|
+
/** Strip directional and invisible controls that can make a displayed title deceptive. */
|
|
71
|
+
const DIRECTIONAL_CONTROL = /[\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF]/gu;
|
|
72
|
+
/**
|
|
73
|
+
* UTF-8 byte length of a string (the host caps titles by encoded size, not
|
|
74
|
+
* character count).
|
|
75
|
+
* @param input - arbitrary text.
|
|
76
|
+
* @returns the number of UTF-8 bytes `input` encodes to.
|
|
77
|
+
*/
|
|
78
|
+
function utf8ByteLength(input) {
|
|
79
|
+
return new TextEncoder().encode(input).length;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Byte length of a draft after the same cleaning the host applies before
|
|
83
|
+
* capping: escape/control/directional sequences stripped, whitespace
|
|
84
|
+
* collapsed to single spaces, trimmed. Gating on this value means the editor
|
|
85
|
+
* fires exactly when the host would truncate — whitespace-heavy or control-
|
|
86
|
+
* laced drafts are not falsely flagged, and drafts at the boundary pass
|
|
87
|
+
* through unchanged.
|
|
88
|
+
* @param input - raw editor draft.
|
|
89
|
+
* @returns UTF-8 bytes of the cleaned text (0 when nothing visible remains).
|
|
90
|
+
*/
|
|
91
|
+
function normalizedTitleByteLength(input) {
|
|
92
|
+
return utf8ByteLength(input.replace(OSC_SEQUENCE, "").replace(CSI_SEQUENCE, "").replace(ESC_SEQUENCE, "").replace(CONTROL_CHARACTER, "").replace(DIRECTIONAL_CONTROL, "").replace(/\s+/gu, " ").trim());
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region \0dsh-css:/Users/zhuyudan/code/dsh-plugins/packages/session-title-edit/src/client/TitleEditAction.module.css.mjs
|
|
96
|
+
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:-8px;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 button[data-ste-inplace]{visibility:hidden!important}";
|
|
97
|
+
const tagId = "@khorsheed/dsh-client-session-title-edit/TitleEditAction.module.css";
|
|
98
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
99
|
+
const tag = document.createElement("style");
|
|
100
|
+
tag.dataset.plugin = "@khorsheed/dsh-client-session-title-edit";
|
|
101
|
+
tag.dataset.pluginCss = tagId;
|
|
102
|
+
tag.textContent = css;
|
|
103
|
+
document.head.appendChild(tag);
|
|
104
|
+
}
|
|
105
|
+
var TitleEditAction_module_css_default = {
|
|
106
|
+
"editorInput": "okd51a_editorInput",
|
|
107
|
+
"renameButton": "okd51a_renameButton",
|
|
108
|
+
"inPlaceError": "okd51a_inPlaceError",
|
|
109
|
+
"editor": "okd51a_editor",
|
|
110
|
+
"editorError": "okd51a_editorError",
|
|
111
|
+
"editorHint": "okd51a_editorHint",
|
|
112
|
+
"inPlaceMirror": "okd51a_inPlaceMirror",
|
|
113
|
+
"inPlaceHint": "okd51a_inPlaceHint",
|
|
114
|
+
"inPlaceInput": "okd51a_inPlaceInput"
|
|
115
|
+
};
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/client/TitleEditAction.tsx
|
|
118
|
+
/**
|
|
119
|
+
* Session-header title-edit entry (`conversation.session.header.actions`): a
|
|
120
|
+
* pencil control immediately right of the title. Editing is IN PLACE: the
|
|
121
|
+
* official current-title crumb is hidden via a `data-ste-inplace` attribute
|
|
122
|
+
* (DOM layer — the official header renders the title and exposes no title
|
|
123
|
+
* seat) and this entry overlays its own input at the crumb's measured rect,
|
|
124
|
+
* so the title itself reads as the input. When the crumb cannot be located
|
|
125
|
+
* (the official DOM changed), the entry degrades to an inline editor in the
|
|
126
|
+
* actions row. Enter commits, Escape cancels, a blurred in-place input
|
|
127
|
+
* cancels, and a trimmed-empty draft never commits; host rejection keeps the
|
|
128
|
+
* editor open with a localized error.
|
|
129
|
+
*
|
|
130
|
+
* TODO(session-title-edit): deprecate the DOM-layer overlay when the official
|
|
131
|
+
* header opens a title slot (or makes the crumb editable) — this entry then
|
|
132
|
+
* becomes a pure slot consumer and the probe/overlay logic is removed.
|
|
133
|
+
*/
|
|
134
|
+
/** Attribute hiding the official current-title crumb while editing in place. */
|
|
135
|
+
const IN_PLACE_ATTR = "data-ste-inplace";
|
|
136
|
+
/** Extra room so the caret and a trailing glyph are never clipped. */
|
|
137
|
+
const INPUT_CARET_BUFFER = 4;
|
|
138
|
+
/** Outer width cap of the fitted input, matching the official crumb's `max-width: 220px`. */
|
|
139
|
+
const MAX_INPUT_WIDTH = 220;
|
|
140
|
+
/**
|
|
141
|
+
* Fit the in-place input to its text between the original crumb width (the
|
|
142
|
+
* floor, so the box never collapses while deleting) and the official 220px
|
|
143
|
+
* crumb cap (the ceiling, so a long draft clips exactly where the crumb's
|
|
144
|
+
* ellipsis would).
|
|
145
|
+
* @param textWidth - measured rendered width of the draft text.
|
|
146
|
+
* @param minWidth - the crumb's measured width at open.
|
|
147
|
+
* @returns the outer input width in px.
|
|
148
|
+
*/
|
|
149
|
+
function fitInputWidth(textWidth, minWidth) {
|
|
150
|
+
const target = textWidth + 18 + INPUT_CARET_BUFFER;
|
|
151
|
+
return Math.round(Math.min(Math.max(target, minWidth), MAX_INPUT_WIDTH));
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Locate the official current-title crumb: the header's only disabled crumb
|
|
155
|
+
* button (the current session's crumb is `disabled`, ancestors navigate).
|
|
156
|
+
* @returns the crumb element, or null when the official DOM is unrecognizable.
|
|
157
|
+
*/
|
|
158
|
+
function locateCrumb() {
|
|
159
|
+
return document.querySelector("header nav button:disabled");
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Render the title-edit control.
|
|
163
|
+
* @param props - session kit, rename verb, and locale seat.
|
|
164
|
+
* @returns the pencil button, or the in-place / inline editor while editing.
|
|
165
|
+
*/
|
|
166
|
+
function TitleEditAction({ sessionId, useSessions, renameSession, t }) {
|
|
167
|
+
const displayTitle = useSessions((state) => state.byId[sessionId]?.displayTitle);
|
|
168
|
+
const [editing, setEditing] = (0, react.useState)(false);
|
|
169
|
+
const [draft, setDraft] = (0, react.useState)("");
|
|
170
|
+
const [busy, setBusy] = (0, react.useState)(false);
|
|
171
|
+
const [error, setError] = (0, react.useState)(null);
|
|
172
|
+
/** Placement when editing in place (over the hidden crumb); null = row editor. */
|
|
173
|
+
const [placement, setPlacement] = (0, react.useState)(null);
|
|
174
|
+
/** Outer width the in-place input is fitted to (draft text vs the crumb floor and 220px cap). */
|
|
175
|
+
const [inputWidth, setInputWidth] = (0, react.useState)(0);
|
|
176
|
+
const inputRef = (0, react.useRef)(null);
|
|
177
|
+
const crumbRef = (0, react.useRef)(null);
|
|
178
|
+
/** Hidden mirror of the draft, measured to fit the input to its text. */
|
|
179
|
+
const measureRef = (0, react.useRef)(null);
|
|
180
|
+
(0, react.useEffect)(() => {
|
|
181
|
+
if (!editing) return;
|
|
182
|
+
/* v8 ignore next -- the input is mounted while editing, so the ref is set */
|
|
183
|
+
inputRef.current?.select();
|
|
184
|
+
}, [editing]);
|
|
185
|
+
const inPlaceActive = placement !== null;
|
|
186
|
+
(0, react.useEffect)(() => {
|
|
187
|
+
if (!inPlaceActive) return;
|
|
188
|
+
const remeasure = () => {
|
|
189
|
+
const crumb = crumbRef.current;
|
|
190
|
+
/* v8 ignore next -- closeEditor nulls the ref and the placement in the same batch, so a resize cannot fire between them */
|
|
191
|
+
if (crumb === null) return;
|
|
192
|
+
const rect = crumb.getBoundingClientRect();
|
|
193
|
+
setPlacement({
|
|
194
|
+
left: rect.left,
|
|
195
|
+
top: rect.top,
|
|
196
|
+
width: rect.width,
|
|
197
|
+
height: rect.height
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
window.addEventListener("resize", remeasure);
|
|
201
|
+
return () => {
|
|
202
|
+
window.removeEventListener("resize", remeasure);
|
|
203
|
+
};
|
|
204
|
+
}, [inPlaceActive]);
|
|
205
|
+
(0, react.useEffect)(() => () => {
|
|
206
|
+
if (crumbRef.current !== null) crumbRef.current.removeAttribute(IN_PLACE_ATTR);
|
|
207
|
+
}, []);
|
|
208
|
+
(0, react.useLayoutEffect)(() => {
|
|
209
|
+
if (!inPlaceActive) return;
|
|
210
|
+
const textWidth = measureRef.current?.offsetWidth ?? 0;
|
|
211
|
+
setInputWidth(fitInputWidth(textWidth, placement.width));
|
|
212
|
+
}, [
|
|
213
|
+
draft,
|
|
214
|
+
placement,
|
|
215
|
+
inPlaceActive
|
|
216
|
+
]);
|
|
217
|
+
const closeEditor = () => {
|
|
218
|
+
setEditing(false);
|
|
219
|
+
setError(null);
|
|
220
|
+
if (crumbRef.current !== null) {
|
|
221
|
+
crumbRef.current.removeAttribute(IN_PLACE_ATTR);
|
|
222
|
+
crumbRef.current = null;
|
|
223
|
+
}
|
|
224
|
+
setPlacement(null);
|
|
225
|
+
};
|
|
226
|
+
const begin = () => {
|
|
227
|
+
setDraft(displayTitle ?? "");
|
|
228
|
+
setError(null);
|
|
229
|
+
const crumb = locateCrumb();
|
|
230
|
+
crumbRef.current = crumb;
|
|
231
|
+
if (crumb !== null) {
|
|
232
|
+
const rect = crumb.getBoundingClientRect();
|
|
233
|
+
crumb.setAttribute(IN_PLACE_ATTR, "");
|
|
234
|
+
setPlacement({
|
|
235
|
+
left: rect.left,
|
|
236
|
+
top: rect.top,
|
|
237
|
+
width: rect.width,
|
|
238
|
+
height: rect.height
|
|
239
|
+
});
|
|
240
|
+
setInputWidth(rect.width);
|
|
241
|
+
}
|
|
242
|
+
setEditing(true);
|
|
243
|
+
};
|
|
244
|
+
const cancel = () => {
|
|
245
|
+
/* v8 ignore next -- the cancel button and the input are disabled while busy, so this arm is unreachable through the UI */
|
|
246
|
+
if (busy) return;
|
|
247
|
+
closeEditor();
|
|
248
|
+
};
|
|
249
|
+
const commit = async () => {
|
|
250
|
+
/* v8 ignore next -- the save button and the input are disabled while busy, so this arm is unreachable through the UI */
|
|
251
|
+
if (busy) return;
|
|
252
|
+
const title = draft.trim();
|
|
253
|
+
if (title === "" || overLimit) return;
|
|
254
|
+
setBusy(true);
|
|
255
|
+
setError(null);
|
|
256
|
+
try {
|
|
257
|
+
await renameSession(title);
|
|
258
|
+
closeEditor();
|
|
259
|
+
} catch (cause) {
|
|
260
|
+
setError(isRenameFailure(cause) && cause.code === "title-invalid" ? t("error.invalid") : t("error.rename"));
|
|
261
|
+
} finally {
|
|
262
|
+
setBusy(false);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
const onKeyDown = (event) => {
|
|
266
|
+
if (event.key === "Enter") commit();
|
|
267
|
+
else if (event.key === "Escape") cancel();
|
|
268
|
+
};
|
|
269
|
+
const onBlur = () => {
|
|
270
|
+
if (busy) return;
|
|
271
|
+
closeEditor();
|
|
272
|
+
};
|
|
273
|
+
const trimmed = draft.trim();
|
|
274
|
+
const draftBytes = normalizedTitleByteLength(draft);
|
|
275
|
+
const overLimit = draftBytes > 80;
|
|
276
|
+
const overLimitHint = t("hint.tooLong", {
|
|
277
|
+
max: 80,
|
|
278
|
+
chars: Math.floor(80 / 3),
|
|
279
|
+
bytes: draftBytes
|
|
280
|
+
});
|
|
281
|
+
if (!editing) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
282
|
+
type: "button",
|
|
283
|
+
className: TitleEditAction_module_css_default.renameButton,
|
|
284
|
+
"aria-label": t("action.rename"),
|
|
285
|
+
title: t("action.rename"),
|
|
286
|
+
onClick: begin,
|
|
287
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconEditOutline16, { size: 14 })
|
|
288
|
+
});
|
|
289
|
+
if (placement !== null) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
290
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
291
|
+
ref: measureRef,
|
|
292
|
+
className: TitleEditAction_module_css_default.inPlaceMirror,
|
|
293
|
+
"aria-hidden": "true",
|
|
294
|
+
children: draft === "" ? " " : draft
|
|
295
|
+
}),
|
|
296
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
297
|
+
ref: inputRef,
|
|
298
|
+
className: TitleEditAction_module_css_default.inPlaceInput,
|
|
299
|
+
type: "text",
|
|
300
|
+
value: draft,
|
|
301
|
+
"aria-label": t("editor.aria"),
|
|
302
|
+
placeholder: t("editor.placeholder"),
|
|
303
|
+
disabled: busy,
|
|
304
|
+
autoFocus: true,
|
|
305
|
+
style: {
|
|
306
|
+
left: placement.left,
|
|
307
|
+
top: placement.top,
|
|
308
|
+
width: inputWidth,
|
|
309
|
+
height: placement.height
|
|
310
|
+
},
|
|
311
|
+
onChange: (event) => {
|
|
312
|
+
setDraft(event.currentTarget.value);
|
|
313
|
+
},
|
|
314
|
+
onKeyDown,
|
|
315
|
+
onBlur
|
|
316
|
+
}),
|
|
317
|
+
error !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
318
|
+
className: TitleEditAction_module_css_default.inPlaceError,
|
|
319
|
+
role: "alert",
|
|
320
|
+
children: error
|
|
321
|
+
}) : overLimit && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
322
|
+
className: TitleEditAction_module_css_default.inPlaceHint,
|
|
323
|
+
role: "alert",
|
|
324
|
+
children: overLimitHint
|
|
325
|
+
})
|
|
326
|
+
] });
|
|
327
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
328
|
+
className: TitleEditAction_module_css_default.editor,
|
|
329
|
+
role: "group",
|
|
330
|
+
"aria-label": t("editor.aria"),
|
|
331
|
+
children: [
|
|
332
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
333
|
+
ref: inputRef,
|
|
334
|
+
className: TitleEditAction_module_css_default.editorInput,
|
|
335
|
+
type: "text",
|
|
336
|
+
value: draft,
|
|
337
|
+
"aria-label": t("editor.aria"),
|
|
338
|
+
placeholder: t("editor.placeholder"),
|
|
339
|
+
disabled: busy,
|
|
340
|
+
autoFocus: true,
|
|
341
|
+
onChange: (event) => {
|
|
342
|
+
setDraft(event.currentTarget.value);
|
|
343
|
+
},
|
|
344
|
+
onKeyDown
|
|
345
|
+
}),
|
|
346
|
+
error !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
347
|
+
className: TitleEditAction_module_css_default.editorError,
|
|
348
|
+
role: "alert",
|
|
349
|
+
children: error
|
|
350
|
+
}) : overLimit && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
351
|
+
className: TitleEditAction_module_css_default.editorHint,
|
|
352
|
+
role: "alert",
|
|
353
|
+
children: overLimitHint
|
|
354
|
+
}),
|
|
355
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
356
|
+
variant: "outline",
|
|
357
|
+
disabled: busy,
|
|
358
|
+
onClick: cancel,
|
|
359
|
+
children: t("cancel")
|
|
360
|
+
}),
|
|
361
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
362
|
+
variant: "primary",
|
|
363
|
+
disabled: busy || trimmed === "" || overLimit,
|
|
364
|
+
onClick: () => {
|
|
365
|
+
commit();
|
|
366
|
+
},
|
|
367
|
+
children: t("editor.save")
|
|
368
|
+
})
|
|
369
|
+
]
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
//#endregion
|
|
373
|
+
//#region src/client/index.ts
|
|
374
|
+
/** Required services: slot contribution, session binding, and the copy. */
|
|
375
|
+
const inject = [
|
|
376
|
+
"slots",
|
|
377
|
+
"sessions",
|
|
378
|
+
"locale"
|
|
379
|
+
];
|
|
380
|
+
/**
|
|
381
|
+
* Client plugin body: register the dictionaries and the header action.
|
|
382
|
+
* @param ctx - client root context.
|
|
383
|
+
*/
|
|
384
|
+
function apply(ctx) {
|
|
385
|
+
ctx.effect(() => ctx.locale.register(NS, {
|
|
386
|
+
zh,
|
|
387
|
+
en
|
|
388
|
+
}), "session-title-edit: dictionaries");
|
|
389
|
+
ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
|
|
390
|
+
name: "conversation.session.header.actions",
|
|
391
|
+
id: "session-title-edit",
|
|
392
|
+
order: -20,
|
|
393
|
+
locale: NS,
|
|
394
|
+
inject: (sessionId) => ({ renameSession: async (title) => {
|
|
395
|
+
const session = ctx.sessions.binding(sessionId)?.session;
|
|
396
|
+
if (session === void 0) throw new Error(`session-title-edit: unknown session "${sessionId}"`);
|
|
397
|
+
const result = await session.rename(title);
|
|
398
|
+
if (!result.ok) {
|
|
399
|
+
const failure = new Error(result.error.message);
|
|
400
|
+
failure.code = result.error.code;
|
|
401
|
+
throw failure;
|
|
402
|
+
}
|
|
403
|
+
} })
|
|
404
|
+
}, TitleEditAction));
|
|
405
|
+
}
|
|
406
|
+
//#endregion
|
|
407
|
+
exports.apply = apply;
|
|
408
|
+
exports.inject = inject;
|
|
409
|
+
return module.exports;
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
//# sourceMappingURL=client.js.map
|