Merge remote-tracking branch 'origin/master' into worktree/dynamic-tool-activation-pr-64796e
This commit is contained in:
commit
85b29bbcea
196 files changed
+5906
-407
No files matched your search
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-08-26-web-rich-text-quotes.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-web-rich-text-quotes.md
|
||||
/agent-note-durable-rich-text-quotes-in-the-web-composer:
|
||||
en: 49522a0a81db6981
|
||||
zh: 92e81872377e96e9
|
||||
/agent-note-durable-rich-text-quotes-in-the-web-composer/problem:
|
||||
en: 7bd295c086ad9905
|
||||
zh: 704dc665acfaa5ec
|
||||
/agent-note-durable-rich-text-quotes-in-the-web-composer/decision:
|
||||
en: d27134e46ab55ea0
|
||||
zh: 3f1da063b661a1b3
|
||||
/agent-note-durable-rich-text-quotes-in-the-web-composer/alternatives-considered:
|
||||
en: 1999da9f900c9be3
|
||||
zh: cf6aad990a180851
|
||||
/agent-note-durable-rich-text-quotes-in-the-web-composer/verification:
|
||||
en: b320c262d9c08ee4
|
||||
zh: 6047f864213a2b4c
|
||||
/agent-note-durable-rich-text-quotes-in-the-web-composer/consequences:
|
||||
en: bb6f4338aad0ec41
|
||||
zh: 93a4396b619492b1
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Durable rich-text quotes in the Web composer
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-26-web-rich-text-quotes.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Selected transcript excerpts need atomic editing and reload-safe drafts without depending on the availability of their source messages.
|
||||
|
||||
## Decision
|
||||
|
||||
`ui-conversation` owns an inline `QuoteNode` beside reference nodes in the existing editor runtime. Its clipboard projection is canonical Markdown, while its detect projection is one atomic character. The existing semantic `DraftSnapshot` persists quote text and clipboard-coordinate spans beside references; raw Lexical JSON and runtime node identities remain outside browser storage. Session switching, Workspace transfer, and failed-send restoration use this same snapshot. Quote controls receive runtime-only locale labels.
|
||||
|
||||
`ui-chat` accepts one nonblank browser selection within displayed user content, including pending steering and local submission echoes or finalized Assistant text and reasoning. Streaming and interrupted Assistant output, tools, injected context, and Markdown controls are excluded. Selection extraction follows visible line and block breaks and inserts through the current Session input actions.
|
||||
|
||||
Text interaction belongs to the shared primitive and the Chat-owned list Slot. Quote registers only gesture admission and insertion. Local absolute placement keeps actions in the content coordinate system; outside presses and Escape close the same area-owned interaction for every consumer. Dismissal and selection clearing are separate operations so a failed insertion preserves the excerpt. Context-menu consumers cancel their originating browser event synchronously; rendering a Slot cannot retroactively cancel it.
|
||||
|
||||
The [text-derived blockquote decision](../simplification/2026-10-13-text-derived-message-quotes.md) owns sent-message recognition and presentation. The [UserText evaluator](../architecture/2026-10-10-user-text-json-nodes.md) renders derived quote nodes through the existing matcher registry.
|
||||
|
||||
Quotes display a compact single-line preview; clipped excerpts expose the complete text in a shared inline HoverCard. Their text has no source-navigation action. The editable draft alone exposes whole-node removal. Only selected text survives message persistence: no source Session, message, or selection coordinates are retained.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Persist Lexical JSON or introduce another draft format.** Editor serialization would become a product storage obligation, while a second draft format would duplicate restoration and transfer ownership. The existing semantic snapshot already carries reference spans and extends to quotes.
|
||||
|
||||
**Retain source navigation.** Availability checks would require source coordinates, subscriptions, history pagination, focus routing, and failure states. A future navigation feature must define those durable coordinates and target-owned actions before adding them.
|
||||
|
||||
**Store UserText nodes or callbacks.** Text nodes are derived presentation, and callbacks are runtime state. Both can change independently of the logged input and semantic draft.
|
||||
|
||||
## Verification
|
||||
|
||||
Composer tests cover atomic removal and undo, semantic storage round trips, Markdown serialization, and failed-send restoration. UserText tests cover text recognition, literal excerpt rendering, literal references, and credential spelling. The keyless Web scenario exercises selection, clipped previews, draft switching and reload, persistence, and ordinary Markdown submission through the shipped composition.
|
||||
|
||||
## Consequences
|
||||
|
||||
The draft and Session retain enough data for atomic editing and replay without retaining source-history dependencies. UserText remains a display projection; its JSON nodes never replace authoritative content. Browser-local storage retains unsent selected text under the existing draft policy.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note:Web composer 中的持久富文本 quote
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-26-web-rich-text-quotes.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
所选 transcript 摘录需要原子编辑与可跨刷新恢复的草稿,不依赖来源消息的可用性。
|
||||
|
||||
## 决策
|
||||
|
||||
`ui-conversation` 在现有编辑器 runtime 中于 reference 节点旁拥有行内 `QuoteNode`。其剪贴板投影是规范 Markdown,检测投影则是一个原子字符。现有语义 `DraftSnapshot` 在 reference 旁持久化 quote 文本与剪贴板坐标范围;原始 Lexical JSON 与 runtime 节点身份不进入浏览器存储。Session 切换、Workspace 转移和发送失败恢复使用同一个 snapshot。Quote 控件接收仅用于 runtime 的 locale 标签。
|
||||
|
||||
`ui-chat` 接受已展示的用户内容(包括待接纳 steering 和本地发送回显)、或最终完成的 Assistant 文本与 reasoning 中的一个非空浏览器选区。流式及中断的 Assistant 输出、tool、注入上下文和 Markdown 控件被排除。选区提取遵循可见的行与 block 换行,通过当前 Session 的 input actions 插入。
|
||||
|
||||
文本交互属于共享基础能力与 Chat 拥有的 list Slot,quote 只注册手势接管与插入操作。本地绝对定位让操作处于内容坐标系;外部按下与 Escape 对所有使用方关闭同一个区域拥有的交互。关闭展示与清除选区是独立操作,因此插入失败时保留摘录。右键菜单使用方同步取消来源浏览器事件;渲染 Slot 无法事后取消事件。
|
||||
|
||||
[文本派生 blockquote 决策](../simplification/2026-10-13-text-derived-message-quotes.zh.md)拥有发送后的消息识别与展示。[UserText 计算器](../architecture/2026-10-10-user-text-json-nodes.zh.md)通过现有 matcher registry 渲染派生的 quote 节点。
|
||||
|
||||
Quote 显示紧凑的单行预览;截断的摘录通过共享行内 HoverCard 展示完整文本。文本不提供来源导航操作,只有可编辑草稿提供整节点删除。消息持久化只保留所选文本:不保留来源 Session、消息或选区坐标。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**持久化 Lexical JSON 或引入另一套草稿格式。** 编辑器序列化会成为产品存储义务,第二套草稿格式则会重复拥有恢复与转移逻辑。现有语义 snapshot 已携带 reference 范围,也可扩展到 quote。
|
||||
|
||||
**保留来源导航。** 可用性检查需要来源坐标、订阅、历史分页、焦点路由和失败状态。未来若添加导航功能,必须先定义其持久坐标与目标拥有的操作。
|
||||
|
||||
**存储 UserText 节点或 callback。** 文本节点是派生的展示,callback 是 runtime 状态;二者都可以独立于记录的输入和语义草稿变化。
|
||||
|
||||
## 验证
|
||||
|
||||
Composer 测试覆盖原子删除与撤销、语义存储往返、Markdown 序列化和发送失败恢复。UserText 测试覆盖文本识别、字面摘录展示、字面 reference 与凭据原文。无密钥 Web 场景通过已发布组合验证选区、截断预览、草稿切换与刷新、持久化和普通 Markdown 提交。
|
||||
|
||||
## 后果
|
||||
|
||||
草稿与 Session 保留足以支持原子编辑和 replay 的数据,不保留对来源历史的依赖。UserText 仍是展示投影;其 JSON 节点不会替代权威 content。浏览器本地存储按现有草稿策略保留未发送的所选文本。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-10-13-text-derived-message-quotes.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-10-13-text-derived-message-quotes.md
|
||||
/agent-note-recognize-message-quotes-from-markdown-text:
|
||||
en: a2abd7f693937d51
|
||||
zh: f9e4bb817536dbbe
|
||||
/agent-note-recognize-message-quotes-from-markdown-text/problem:
|
||||
en: d71ebbacbd9bd8a3
|
||||
zh: 45c1f7fd1d797f61
|
||||
/agent-note-recognize-message-quotes-from-markdown-text/decision:
|
||||
en: 0abb09d5ba396f69
|
||||
zh: dd067b0ca5fd376f
|
||||
/agent-note-recognize-message-quotes-from-markdown-text/alternatives-considered:
|
||||
en: 05c6009796927583
|
||||
zh: 9f08229767c07b31
|
||||
/agent-note-recognize-message-quotes-from-markdown-text/verification:
|
||||
en: 790598fd0f3f0c67
|
||||
zh: 0399e0e322ffca40
|
||||
/agent-note-recognize-message-quotes-from-markdown-text/consequences:
|
||||
en: 21f96c04f6edcc1d
|
||||
zh: 8ea3f42a8e981592
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Recognize message quotes from Markdown text
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-13-text-derived-message-quotes.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Quote-only content indexes duplicate information in Markdown text and require coordinated prompt, source, echo, and queue metadata. The same visible blockquote can acquire different presentation and direct-input behavior depending on how it was created.
|
||||
|
||||
## Decision
|
||||
|
||||
Messages contain ordinary Markdown text. The shared blockquote matcher recognizes consecutive explicitly marked lines for UserText presentation. Hand-written and composer-created blockquotes have identical sent-message behavior. Quote previews display decoded text without running UserText rules over their contents. Session-reference and skill processing applies normally to quoted text; recognized Session labels retain the same presentation as the surrounding quote text.
|
||||
|
||||
The [rich-text quote feature](../feature/2026-08-26-web-rich-text-quotes.md) retains atomic draft nodes and semantic restoration spans. This decision supersedes only its durable quote-index distinction. UserText nodes and recognition ranges remain derived values.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep quote-only indexes.** Indexes preserve a distinction between typed and inserted quotes, but that distinction is unnecessary for sent messages and requires metadata coordination across every producer and renderer.
|
||||
|
||||
**Remove atomic draft spans.** Plain text cannot restore whole-node editing, undo, Session switching, or failed-send restoration. Draft spans remain browser-owned editing state.
|
||||
|
||||
## Verification
|
||||
|
||||
Matcher tests cover original offsets, explicit line markers, blank excerpts, and literal nested quotes. Composer tests assert ordinary text submission. The shipped Web scenario checks text-only persistence and quote display after reload.
|
||||
|
||||
## Consequences
|
||||
|
||||
Messages need no quote fields or content indexes. Typed blockquotes also display as quotes without changing direct-input processing. Recognition deliberately covers explicit quote lines rather than Markdown lazy continuation or fenced-code parsing. Source navigation still requires a separate durable-coordinate design.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 从 Markdown 文本识别消息 quote
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-13-text-derived-message-quotes.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
纯 quote content 索引重复了 Markdown 文本中的信息,并要求 prompt、source、回显与队列协同携带元数据。同样可见的 blockquote 会因创建方式不同而获得不同的展示与直接输入行为。
|
||||
|
||||
## 决策
|
||||
|
||||
消息包含普通 Markdown 文本。共享 blockquote 匹配器识别连续的显式标记行,用于 UserText 展示。手写与输入框创建的 blockquote 在发送后具有相同行为。Quote 预览直接展示还原后的文本,不对正文再次运行 UserText 规则。Session-reference 与 skill 处理照常作用于引用文本;已识别的 Session 标签与周围引用文字保持相同样式。
|
||||
|
||||
[富文本 quote 功能](../feature/2026-08-26-web-rich-text-quotes.zh.md)保留原子草稿节点与语义恢复范围。本决策仅取代其持久 quote 索引区分。UserText 节点与识别范围仍是派生值。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保留纯 quote 索引。** 索引保留手写与插入 quote 的区别,但发送后的消息无需这一区分,且要求每个生产方与渲染器协同处理元数据。
|
||||
|
||||
**移除原子草稿范围。** 纯文本无法恢复整节点编辑、撤销、Session 切换或发送失败恢复。草稿范围仍是浏览器拥有的编辑状态。
|
||||
|
||||
## 验证
|
||||
|
||||
匹配器测试覆盖原始偏移、显式行标记、空白摘录与字面的嵌套 quote。Composer 测试断言普通文本提交。已发布 Web 场景检查仅文本的持久化与刷新后的 quote 展示。
|
||||
|
||||
## 后果
|
||||
|
||||
消息无需 quote 字段或 content 索引。手写 blockquote 也显示为 quote,不改变直接输入处理。识别有意限定为显式引用行,不涵盖 Markdown 懒惰续行或代码围栏解析。来源导航仍需独立设计持久坐标。
|
||||
@@ -72,5 +72,8 @@
|
||||
"@deepseek-ai/dsh-experimental-cot-translation-bundle": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-worktree": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-message-quotes": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,10 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置', exact: true })
|
||||
const settingsMask = settings.locator('..').locator(':scope > [aria-hidden="true"]')
|
||||
const permissionText = settings.getByText('权限', { exact: true }).locator('..')
|
||||
expect(await permissionText.evaluate(el => getComputedStyle(el).paddingRight)).toBe('48px')
|
||||
const cubes = settings.getByRole('button', { name: /浅色|深色|跟随系统/ })
|
||||
expect(await cubes.first().evaluate(el => getComputedStyle(el).flexBasis)).toBe('180px')
|
||||
for (const fullscreen of [false, true, false]) {
|
||||
await page.evaluate(value => document.documentElement.toggleAttribute('data-fullscreen', value), fullscreen)
|
||||
const margin = fullscreen ? 24 : 60
|
||||
@@ -86,8 +90,10 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro
|
||||
}, platform)
|
||||
if (await reopenSidebar.isVisible()) await reopenSidebar.click()
|
||||
await openSettings(page, 'zh')
|
||||
await expect.poll(async () => (await settings.boundingBox())?.y).toBe(platform === 'darwin' ? 68 : 24)
|
||||
await expect.poll(async () => (await settings.boundingBox())?.y).toBe(platform === 'darwin' ? 68 : 0)
|
||||
expect((await settingsMask.boundingBox())?.y).toBe(0)
|
||||
expect(await permissionText.evaluate(el => getComputedStyle(el).paddingRight)).toBe(platform === 'darwin' ? '48px' : '16px')
|
||||
expect(await cubes.first().evaluate(el => getComputedStyle(el).flexBasis)).toBe(platform === 'darwin' ? '180px' : '0px')
|
||||
await page.keyboard.press('Escape')
|
||||
await settings.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "Settings":
|
||||
- text: Settings
|
||||
- navigation:
|
||||
- text: Settings
|
||||
- button "General"
|
||||
- button "Models"
|
||||
- button "Built-in plugins"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "Settings":
|
||||
- text: Settings
|
||||
- navigation:
|
||||
- text: Settings
|
||||
- button "General"
|
||||
- button "Models"
|
||||
- button "Built-in plugins"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "Settings":
|
||||
- text: Settings
|
||||
- navigation:
|
||||
- text: Settings
|
||||
- button "General"
|
||||
- button "Models"
|
||||
- button "Built-in plugins"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- dialog "设置":
|
||||
- text: 设置
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置"
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
/** Keyless real-Web coverage for quote selection, persistence, and uniform reference presentation. */
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
|
||||
import { formatQuoteText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold,
|
||||
parseSeedFixture, renderSeedFixture, seedSession, selectedSessionFixture, watchConsole, webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/rich-text-quotes', import.meta.url))
|
||||
const BORROWED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/rich-text-quotes/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SESSION_ID = 'rich-text-quotes-web-e2e'
|
||||
const OTHER_ID = 'rich-text-quotes-other'
|
||||
const LEGACY_DRAFT_KEY = `dsh.conversation.${SESSION_ID}`
|
||||
const CURRENT_DRAFT_KEY = `dsh.conversation.${SESSION_ID}`
|
||||
const LEGACY_DRAFT = JSON.stringify({ draft: 'Imported plain draft', view: 'chat', viewRequest: null })
|
||||
const SOURCE_TEXT = 'Durable source phrase selected from an earlier message and preserved as structured quote metadata.'
|
||||
const THINKING_TEXT = 'Settled reasoning source'
|
||||
const LITERAL_CREDENTIAL = 'sk-1234567890abcdefghijklmnopqrstuvwxyz'
|
||||
const SESSION_MENTION = formatSessionReferenceMention({ sessionId: SessionId(OTHER_ID), label: 'Quote source' })
|
||||
const DIRECT_SYNTAX_TEXT = [
|
||||
`/quoted-demo ${SESSION_MENTION} ${LITERAL_CREDENTIAL} **literal Markdown**`,
|
||||
...Array.from({ length: 80 }, (_, index) => `Quote line ${String(index + 1)} contains preserved text.`),
|
||||
'End of long quote.',
|
||||
].join('\n')
|
||||
const SELECTED_SYNTAX_TEXT = DIRECT_SYNTAX_TEXT.replaceAll('\n', ' ')
|
||||
const SENT_SYNTAX_TEXT = SELECTED_SYNTAX_TEXT.replace(SESSION_MENTION, '@Quote source')
|
||||
const REPLY = 'Quote received.'
|
||||
|
||||
async function quoteAppearance(quote: Locator): Promise<Record<string, string>> {
|
||||
return quote.evaluate((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
return {
|
||||
alignItems: style.alignItems,
|
||||
color: style.color,
|
||||
display: style.display,
|
||||
fontSize: style.fontSize,
|
||||
gap: style.gap,
|
||||
lineHeight: style.lineHeight,
|
||||
margin: style.margin,
|
||||
padding: style.padding,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const REPLAY: ReplayOverrideDoc = [{
|
||||
kind: 'chunks',
|
||||
chunks: [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: REPLY },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: REPLY } },
|
||||
{ type: 'usage', usage: { inputTokens: 128, outputTokens: 8 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
],
|
||||
}]
|
||||
|
||||
function quoteHistoryFixture(raw: string): string {
|
||||
const { headerLine } = parseSeedFixture(raw)
|
||||
let seq = 0
|
||||
let time = 0
|
||||
const events: Array<Record<string, unknown> & { seq: number; time: number }> = []
|
||||
const at = (event: Record<string, unknown>): number => {
|
||||
const assigned = seq++
|
||||
events.push({ ...event, seq: assigned, time: time++ })
|
||||
return assigned
|
||||
}
|
||||
at({ type: 'turn/start', data: { turn: 1 } })
|
||||
at({
|
||||
type: 'user/message',
|
||||
data: {
|
||||
id: '{{message:1}}',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: SOURCE_TEXT }],
|
||||
source: { kind: 'user', rpcId: '{{rpc:1}}' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
})
|
||||
for (let ordinal = 2; ordinal <= 51; ordinal++) {
|
||||
at({
|
||||
type: 'user/message',
|
||||
data: {
|
||||
id: `{{message:${String(ordinal)}}}`,
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: ordinal === 2 ? formatQuoteText(DIRECT_SYNTAX_TEXT) : `Filler message ${String(ordinal)}`,
|
||||
}],
|
||||
source: { kind: 'user', rpcId: `{{rpc:${String(ordinal)}}}` },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
})
|
||||
}
|
||||
at({
|
||||
type: 'user/message',
|
||||
data: {
|
||||
id: '{{message:52}}',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: `> ${SOURCE_TEXT}` },
|
||||
{ type: 'text', text: '\n\nUse that excerpt.' },
|
||||
],
|
||||
source: {
|
||||
kind: 'user',
|
||||
rpcId: '{{rpc:52}}',
|
||||
},
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
})
|
||||
at({
|
||||
type: 'session/title',
|
||||
data: {
|
||||
title: 'Quote history',
|
||||
messageSeqs: Array.from({ length: 52 }, (_value, index) => index + 1),
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
})
|
||||
at({ type: 'step/start', data: { turn: 1, step: 1 } })
|
||||
at({
|
||||
type: 'assistant/message',
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
id: '{{message:53}}',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'reasoning', text: THINKING_TEXT },
|
||||
{ type: 'text', text: 'Quote fixture ready.' },
|
||||
],
|
||||
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
|
||||
},
|
||||
stream: [
|
||||
{ type: 'chunk', time: 0, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } },
|
||||
{ type: 'reasoning-chunks', time0: 0, index: 0, dt: [], texts: [THINKING_TEXT] },
|
||||
{ type: 'chunk', time: 0, chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: THINKING_TEXT } } },
|
||||
{ type: 'chunk', time: 0, chunk: { type: 'block-start', index: 1, blockType: 'text' } },
|
||||
{ type: 'text-chunks', time0: 0, index: 1, dt: [], texts: ['Quote fixture ready.'] },
|
||||
{ type: 'chunk', time: 0, chunk: { type: 'block-end', index: 1, block: { type: 'text', text: 'Quote fixture ready.' } } },
|
||||
{ type: 'chunk', time: 0, chunk: { type: 'finish', reason: { kind: 'stop' } } },
|
||||
],
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
})
|
||||
at({ type: 'step/end', data: { turn: 1, step: 1 } })
|
||||
at({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } })
|
||||
return renderSeedFixture(headerLine, events)
|
||||
}
|
||||
|
||||
function otherSessionFixture(raw: string): string {
|
||||
const { headerLine } = parseSeedFixture(raw)
|
||||
let seq = 0
|
||||
const events: Array<Record<string, unknown> & { seq: number; time: number }> = []
|
||||
const at = (event: Record<string, unknown>): void => {
|
||||
events.push({ ...event, seq, time: seq })
|
||||
seq += 1
|
||||
}
|
||||
at({ type: 'turn/start', data: { turn: 1 } })
|
||||
at({
|
||||
type: 'user/message', surfaceOp: 'append',
|
||||
data: {
|
||||
id: '{{message:1}}', role: 'user',
|
||||
content: [{ type: 'text', text: 'Other session.' }],
|
||||
source: { kind: 'user', rpcId: '{{rpc:1}}' },
|
||||
},
|
||||
})
|
||||
at({ type: 'session/title', data: { title: 'Other session', messageSeqs: [1], source: { kind: 'fallback' } } })
|
||||
at({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } })
|
||||
return renderSeedFixture(headerLine, events)
|
||||
}
|
||||
|
||||
async function selectAllText(locator: Locator): Promise<void> {
|
||||
await locator.evaluate((element) => {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(element)
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
document.dispatchEvent(new Event('selectionchange'))
|
||||
})
|
||||
}
|
||||
|
||||
async function expectEllipsizedQuote(box: Locator, text: Locator, container: Locator): Promise<number> {
|
||||
const [quoteBounds, containerBounds, presentation] = await Promise.all([
|
||||
box.boundingBox(),
|
||||
container.boundingBox(),
|
||||
text.evaluate((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
return {
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
overflow: style.overflow,
|
||||
textOverflow: style.textOverflow,
|
||||
whiteSpace: style.whiteSpace,
|
||||
}
|
||||
}),
|
||||
])
|
||||
expect(quoteBounds).not.toBeNull()
|
||||
expect(containerBounds).not.toBeNull()
|
||||
const expectedMaximum = Math.min(containerBounds!.width * 0.8, 300)
|
||||
expect(quoteBounds!.width).toBeLessThanOrEqual(expectedMaximum + 1)
|
||||
expect(presentation.overflow).toBe('hidden')
|
||||
expect(presentation.textOverflow).toBe('ellipsis')
|
||||
expect(presentation.whiteSpace).toBe('nowrap')
|
||||
expect(presentation.scrollWidth).toBeGreaterThan(presentation.clientWidth)
|
||||
return quoteBounds!.width
|
||||
}
|
||||
|
||||
async function openQuoteSession(page: Page): Promise<void> {
|
||||
await page.locator(`[data-row-key="session:${SESSION_ID}"]`).click()
|
||||
}
|
||||
|
||||
async function expectScrollableQuote(page: Page, quote: Locator, text: string): Promise<void> {
|
||||
await quote.hover()
|
||||
const content = page.locator('[data-quote-popover]')
|
||||
await expect.poll(() => content.textContent()).toBe(text)
|
||||
const panel = content.locator('..')
|
||||
await expect.poll(async () => {
|
||||
const bounds = await panel.boundingBox()
|
||||
return bounds !== null && bounds.y >= 8 && bounds.y + bounds.height <= page.viewportSize()!.height - 8
|
||||
}).toBe(true)
|
||||
await panel.hover()
|
||||
await page.mouse.wheel(0, 10_000)
|
||||
await expect.poll(() => panel.evaluate(element => ({
|
||||
scrolled: element.scrollTop > 0,
|
||||
atEnd: element.scrollTop + element.clientHeight >= element.scrollHeight - 1,
|
||||
}))).toEqual({ scrolled: true, atEnd: true })
|
||||
expect(await content.evaluate((element) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
||||
let last: Node | null = null
|
||||
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) last = node
|
||||
if (last === null) return false
|
||||
const length = last.textContent?.length ?? 0
|
||||
const range = document.createRange()
|
||||
range.setStart(last, length - 'End of long quote.'.length)
|
||||
range.setEnd(last, length)
|
||||
const end = range.getBoundingClientRect()
|
||||
const panelBounds = element.parentElement!.getBoundingClientRect()
|
||||
return end.top >= panelBounds.top && end.bottom <= panelBounds.bottom
|
||||
})).toBe(true)
|
||||
await page.mouse.move(0, 0)
|
||||
await expect.poll(() => content.count()).toBe(0)
|
||||
}
|
||||
|
||||
async function crossQuoteGap(page: Page, quote: Locator, text: string, placement: 'above' | 'below'): Promise<void> {
|
||||
await quote.hover()
|
||||
const content = page.locator('[data-quote-popover]')
|
||||
await expect.poll(() => content.count()).toBe(1)
|
||||
await expect.poll(() => content.textContent()).toBe(text)
|
||||
const anchor = (await quote.locator('..').boundingBox())!
|
||||
const panel = (await content.locator('..').boundingBox())!
|
||||
expect(panel.width).toBeLessThan(300)
|
||||
const above = panel.y < anchor.y
|
||||
expect(above).toBe(placement === 'above')
|
||||
const x = Math.max(anchor.x, panel.x) + Math.min(anchor.width, panel.width) / 2
|
||||
const y = above ? (panel.y + panel.height + anchor.y) / 2 : (anchor.y + anchor.height + panel.y) / 2
|
||||
expect(await page.evaluate(point =>
|
||||
Boolean(document.elementFromPoint(point.x, point.y)?.closest('[data-hovercard-bridge]')), { x, y })).toBe(true)
|
||||
await page.mouse.move(x, y, { steps: 8 })
|
||||
await page.evaluate(() => new Promise<void>((resolve) => { requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) }))
|
||||
await expect.poll(() => content.count()).toBe(1)
|
||||
expect(await content.textContent()).toBe(text)
|
||||
await content.hover()
|
||||
const body = (await content.boundingBox())!
|
||||
await page.mouse.move(body.x + 12, body.y + 15)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(body.x + 60, body.y + 15, { steps: 5 })
|
||||
await page.mouse.up()
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString().trim())).not.toBe('')
|
||||
expect(await content.count()).toBe(1)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect.poll(() => content.count()).toBe(0)
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: rich-text quotes', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let replayDir: string
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
replayDir = await mkdtemp(join(tmpdir(), 'dsh-rich-text-quotes-replay-'))
|
||||
const replayOverride = join(replayDir, 'replay.override.json')
|
||||
await writeFile(replayOverride, JSON.stringify(REPLAY))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: join(replayDir, 'override-only.jsonl'),
|
||||
replayOverride,
|
||||
})
|
||||
const skillDir = join(scaffold.workspaceCwd, '.agents', 'skills', 'quoted-demo')
|
||||
await mkdir(skillDir, { recursive: true })
|
||||
await writeFile(join(skillDir, 'SKILL.md'), [
|
||||
'---',
|
||||
'name: quoted-demo',
|
||||
'description: Example user-invoked skill',
|
||||
'disable-model-invocation: true',
|
||||
'---',
|
||||
'',
|
||||
'An example skill instruction.',
|
||||
'',
|
||||
].join('\n'))
|
||||
const borrowed = await readFile(await selectedSessionFixture(BORROWED), 'utf8')
|
||||
const createdAt = Date.now() - 60_000
|
||||
await seedSession(scaffold, quoteHistoryFixture(borrowed), SESSION_ID, undefined, { createdAt })
|
||||
await seedSession(scaffold, otherSessionFixture(borrowed), OTHER_ID, undefined, { createdAt: createdAt + 1_000 })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.addInitScript(({ key, value }) => {
|
||||
if (localStorage.getItem(key) === null) localStorage.setItem(key, value)
|
||||
}, { key: LEGACY_DRAFT_KEY, value: LEGACY_DRAFT })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
const group = page.getByRole('treeitem', { name: /Ungrouped/ })
|
||||
await group.waitFor({ timeout: 15_000 })
|
||||
if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
|
||||
await openQuoteSession(page)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (replayDir !== undefined) {
|
||||
await rm(replayDir, { recursive: true, force: true })
|
||||
.catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'rich-text-quotes e2e cleanup failed')
|
||||
})
|
||||
|
||||
it('renders and restores rich quotes with uniform styling after reference recognition', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-rich-text-quotes'))
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await expect.poll(() => input.textContent()).toBe('Imported plain draft')
|
||||
await input.click()
|
||||
await input.press('ControlOrMeta+A')
|
||||
await input.press('Backspace')
|
||||
await expect.poll(() => input.textContent()).toBe('')
|
||||
const sentQuote = page.locator('[data-message-bubble] [data-quote-preview]').filter({ hasText: SOURCE_TEXT }).first()
|
||||
await sentQuote.waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => sentQuote.getAttribute('title')).toBeNull()
|
||||
await expect.poll(() => sentQuote.getAttribute('role')).toBeNull()
|
||||
await expect.poll(() => sentQuote.getAttribute('tabindex')).toBeNull()
|
||||
await expect.poll(() => sentQuote.locator('xpath=ancestor::*[@data-message-bubble][1]').textContent())
|
||||
.toBe(`${SOURCE_TEXT} Use that excerpt.`)
|
||||
const sentText = sentQuote.locator('[data-quote-preview-text]')
|
||||
const sentWidth = await expectEllipsizedQuote(
|
||||
sentQuote,
|
||||
sentText,
|
||||
sentQuote.locator('xpath=ancestor::*[@data-message-bubble][1]'),
|
||||
)
|
||||
const sentAppearance = await quoteAppearance(sentQuote)
|
||||
expect(sentAppearance.gap).toBe('4px')
|
||||
expect(sentAppearance.margin).toBe('0px')
|
||||
await sentQuote.hover()
|
||||
const quotePopover = page.locator('[data-quote-popover]')
|
||||
await expect.poll(() => quotePopover.textContent()).toBe(SOURCE_TEXT)
|
||||
const hoveredSentBounds = await sentQuote.boundingBox()
|
||||
expect(hoveredSentBounds).not.toBeNull()
|
||||
expect(hoveredSentBounds!.width).toBeCloseTo(sentWidth, 0)
|
||||
await quotePopover.hover()
|
||||
// Exceed the pointer grace to prove the panel itself keeps the popover open.
|
||||
await page.waitForTimeout(300)
|
||||
await expect.poll(() => quotePopover.count()).toBe(1)
|
||||
await page.mouse.move(0, 0)
|
||||
await expect.poll(() => quotePopover.count()).toBe(0)
|
||||
|
||||
const sourceBlocks = page.locator('[data-text-interaction-source]').filter({ hasText: SOURCE_TEXT })
|
||||
const olderSource = page.getByRole('button', { name: 'Load earlier' })
|
||||
if (await sourceBlocks.count() < 2 && await olderSource.isVisible()) await olderSource.click()
|
||||
await expect.poll(() => sourceBlocks.count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(2)
|
||||
const source = sourceBlocks.filter({ hasNot: page.locator('[data-quote-preview]') }).first()
|
||||
await source.waitFor()
|
||||
|
||||
const quoteAction = page.getByRole('button', { name: 'Quote text', exact: true })
|
||||
await source.scrollIntoViewIfNeeded()
|
||||
await source.dispatchEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', buttons: 1 })
|
||||
await selectAllText(source)
|
||||
await expect.poll(() => quoteAction.count()).toBe(0)
|
||||
await source.dispatchEvent('pointerup', { pointerId: 1, pointerType: 'mouse', buttons: 0 })
|
||||
await expect.poll(() => quoteAction.count()).toBe(1)
|
||||
expect(await quoteAction.evaluate(element => element.closest('[data-chat-flow]') !== null)).toBe(true)
|
||||
expect(await quoteAction.evaluate(element => getComputedStyle(element.closest('[data-text-interaction-surface]')!).position)).toBe('absolute')
|
||||
await page.keyboard.press('Escape')
|
||||
await expect.poll(() => quoteAction.count()).toBe(0)
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('resize')))
|
||||
await expect.poll(() => quoteAction.count()).toBe(0)
|
||||
await source.dispatchEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 0, buttons: 1 })
|
||||
await selectAllText(source)
|
||||
await source.dispatchEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 0, buttons: 0 })
|
||||
await expect.poll(() => quoteAction.count()).toBe(1)
|
||||
await input.click()
|
||||
await source.scrollIntoViewIfNeeded()
|
||||
await expect.poll(() => quoteAction.count()).toBe(0)
|
||||
await source.dispatchEvent('pointerdown', { pointerId: 1, pointerType: 'mouse', button: 0, buttons: 1 })
|
||||
await selectAllText(source)
|
||||
await source.dispatchEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 0, buttons: 0 })
|
||||
await expect.poll(() => quoteAction.count()).toBe(1)
|
||||
await page.locator('[data-conversation-scroll]').evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight
|
||||
element.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
await expect.poll(() => quoteAction.isVisible()).toBe(false)
|
||||
expect(await page.locator('[data-text-interaction-surface] button').count()).toBe(1)
|
||||
await source.scrollIntoViewIfNeeded()
|
||||
await expect.poll(() => quoteAction.isVisible()).toBe(true)
|
||||
await quoteAction.click()
|
||||
const draftQuote = page.locator('[data-composer-quote]')
|
||||
const draftPreview = draftQuote.locator('[data-quote-preview]')
|
||||
const draftPreviewText = draftPreview.locator('[data-quote-preview-text]')
|
||||
await expect.poll(() => draftQuote.count()).toBe(1)
|
||||
await expect.poll(() => draftQuote.textContent()).toContain(SOURCE_TEXT)
|
||||
const draftText = draftPreviewText
|
||||
await expect.poll(() => draftPreview.getAttribute('role')).toBeNull()
|
||||
await expect.poll(() => draftPreview.getAttribute('tabindex')).toBeNull()
|
||||
const draftWidth = await expectEllipsizedQuote(
|
||||
draftPreview,
|
||||
draftPreviewText,
|
||||
input,
|
||||
)
|
||||
expect(await quoteAppearance(draftPreview)).toEqual(sentAppearance)
|
||||
await draftText.hover()
|
||||
await expect.poll(() => quotePopover.textContent()).toBe(SOURCE_TEXT)
|
||||
const hoveredDraftBounds = await draftPreview.boundingBox()
|
||||
expect(hoveredDraftBounds).not.toBeNull()
|
||||
expect(hoveredDraftBounds!.width).toBeCloseTo(draftWidth, 0)
|
||||
await page.mouse.move(0, 0)
|
||||
await expect.poll(() => quotePopover.count()).toBe(0)
|
||||
await input.press('Backspace')
|
||||
await expect.poll(() => draftQuote.count()).toBe(0)
|
||||
await expect.poll(() => input.textContent()).toBe('')
|
||||
await input.press('ControlOrMeta+z')
|
||||
await expect.poll(() => draftQuote.count()).toBe(1)
|
||||
|
||||
const removeQuote = draftQuote.getByRole('button', { name: 'Remove quote' })
|
||||
const removeIcons = removeQuote.locator('svg')
|
||||
await expect.poll(() => removeIcons.first().evaluate(element => getComputedStyle(element).opacity)).toBe('1')
|
||||
await expect.poll(() => removeIcons.last().evaluate(element => getComputedStyle(element).opacity)).toBe('0')
|
||||
await input.click()
|
||||
await page.keyboard.press('ControlOrMeta+A')
|
||||
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString() ?? '')).toContain(SOURCE_TEXT)
|
||||
await draftQuote.hover()
|
||||
await expect.poll(() => removeIcons.first().evaluate(element => getComputedStyle(element).opacity)).toBe('0')
|
||||
await expect.poll(() => removeIcons.last().evaluate(element => getComputedStyle(element).opacity)).toBe('1')
|
||||
await removeQuote.focus()
|
||||
await expect.poll(() => removeIcons.last().evaluate(element => getComputedStyle(element).opacity)).toBe('1')
|
||||
await expectEllipsizedQuote(draftPreview, draftPreviewText, input)
|
||||
await removeQuote.click()
|
||||
await expect.poll(() => draftQuote.count()).toBe(0)
|
||||
await expect.poll(() => input.textContent()).toBe('')
|
||||
await expect.poll(() => page.evaluate(() => window.getSelection()?.isCollapsed ?? true)).toBe(true)
|
||||
await page.keyboard.press('ControlOrMeta+z')
|
||||
await expect.poll(() => draftQuote.count()).toBe(1)
|
||||
|
||||
await input.click()
|
||||
await page.keyboard.press('ControlOrMeta+A')
|
||||
await page.keyboard.press('ArrowRight')
|
||||
await page.keyboard.press('Backspace')
|
||||
await page.keyboard.press('Backspace')
|
||||
await expect.poll(() => draftQuote.count()).toBe(0)
|
||||
await page.keyboard.press('ControlOrMeta+z')
|
||||
await expect.poll(() => draftQuote.count()).toBe(1)
|
||||
await page.keyboard.press('ControlOrMeta+A')
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
await page.keyboard.press('Delete')
|
||||
await expect.poll(() => draftQuote.count()).toBe(0)
|
||||
await page.keyboard.press('ControlOrMeta+z')
|
||||
await expect.poll(() => draftQuote.count()).toBe(1)
|
||||
|
||||
await page.locator(`[data-row-key="session:${OTHER_ID}"]`).click()
|
||||
await openQuoteSession(page)
|
||||
await expect.poll(() => draftQuote.count()).toBe(1)
|
||||
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
const restoredQuote = page.locator('[data-composer-quote]')
|
||||
await expect.poll(() => restoredQuote.count(), { timeout: 15_000 }).toBe(1)
|
||||
const restoredPreview = restoredQuote.locator('[data-quote-preview]')
|
||||
const restoredText = restoredQuote.locator('[data-quote-preview-text]')
|
||||
await expect.poll(() => restoredPreview.getAttribute('role')).toBeNull()
|
||||
await restoredText.click()
|
||||
|
||||
await restoredQuote.getByRole('button', { name: 'Remove quote' }).click()
|
||||
await expect.poll(() => input.textContent()).toBe('')
|
||||
const process = page.locator('[data-turn-process]').first()
|
||||
if (await process.getAttribute('aria-expanded') === 'false') await process.click()
|
||||
await page.getByRole('button', { name: 'Analysis completed', exact: true }).click()
|
||||
const think = page.getByRole('button', { name: /Think/u })
|
||||
await think.click()
|
||||
const reasoningSource = page.locator('[data-text-interaction-source]').filter({ hasText: THINKING_TEXT })
|
||||
await reasoningSource.waitFor()
|
||||
await selectAllText(reasoningSource)
|
||||
await expect.poll(() => quoteAction.isVisible()).toBe(true)
|
||||
const beforeComposerScroll = await reasoningSource.evaluate((element) => {
|
||||
const scroller = element.closest<HTMLElement>('[data-conversation-scroll]')!
|
||||
const composer = scroller.querySelector<HTMLElement>('[data-composer-seat]')!
|
||||
const previous = scroller.scrollTop
|
||||
scroller.scrollTop -= composer.getBoundingClientRect().top - element.getBoundingClientRect().top + 16
|
||||
return previous
|
||||
})
|
||||
await expect.poll(() => reasoningSource.evaluate((element) => {
|
||||
const composer = element.closest('[data-conversation-scroll]')!.querySelector('[data-composer-seat]')!
|
||||
return element.getBoundingClientRect().top >= composer.getBoundingClientRect().top
|
||||
})).toBe(true)
|
||||
await expect.poll(() => quoteAction.isVisible()).toBe(false)
|
||||
expect(await page.locator('[data-text-interaction-surface] button').count()).toBe(1)
|
||||
await page.locator('[data-conversation-scroll]').evaluate((element, top) => { element.scrollTop = top }, beforeComposerScroll)
|
||||
await expect.poll(() => quoteAction.isVisible()).toBe(true)
|
||||
await quoteAction.click()
|
||||
await expect.poll(() => draftQuote.textContent()).toContain(THINKING_TEXT)
|
||||
await think.click()
|
||||
await expect.poll(() => reasoningSource.isVisible()).toBe(false)
|
||||
const reasoningQuote = draftQuote.locator('[data-quote-preview-text]')
|
||||
await expect.poll(() => draftQuote.locator('[data-quote-preview]').getAttribute('role')).toBeNull()
|
||||
await reasoningQuote.click()
|
||||
await expect.poll(() => reasoningSource.isVisible()).toBe(false)
|
||||
await expect.poll(() => think.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
const backToBottom = page.getByRole('button', { name: 'Back to bottom', exact: true })
|
||||
if (await backToBottom.isVisible()) await backToBottom.click()
|
||||
await expect.poll(() => backToBottom.isVisible()).toBe(false)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
|
||||
await draftQuote.getByRole('button', { name: 'Remove quote' }).click()
|
||||
const loadEarlier = page.getByRole('button', { name: 'Load earlier' })
|
||||
if (await loadEarlier.isVisible()) await loadEarlier.click()
|
||||
await page.getByText('Filler message 3', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
await page.locator('[data-conversation-scroll]').evaluate((element) => {
|
||||
element.scrollTop = 0
|
||||
element.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
const syntaxSource = page.locator('[data-text-interaction-source]').filter({ hasText: DIRECT_SYNTAX_TEXT }).first()
|
||||
await syntaxSource.waitFor({ timeout: 15_000 })
|
||||
await syntaxSource.scrollIntoViewIfNeeded()
|
||||
await selectAllText(syntaxSource)
|
||||
await quoteAction.click()
|
||||
const viewport = page.viewportSize()!
|
||||
try {
|
||||
await expectScrollableQuote(page, draftPreview, SELECTED_SYNTAX_TEXT)
|
||||
await page.setViewportSize({ width: 420, height: 420 })
|
||||
await expectScrollableQuote(page, draftPreview, SELECTED_SYNTAX_TEXT)
|
||||
} finally {
|
||||
await page.setViewportSize(viewport)
|
||||
}
|
||||
const beforeOffset = scaffold.ctx.agents.get(SessionId(SESSION_ID))!.session.seq
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await page.getByRole('button', { name: 'Send message' }).click()
|
||||
const settledSessionId = await settled
|
||||
await page.getByText(REPLY, { exact: true }).waitFor({ timeout: 20_000 })
|
||||
const longSentQuote = page.locator('[data-message-bubble] [data-quote-preview]').filter({ hasText: SENT_SYNTAX_TEXT }).last()
|
||||
try {
|
||||
await expectScrollableQuote(page, longSentQuote, SENT_SYNTAX_TEXT)
|
||||
await page.setViewportSize({ width: 420, height: 420 })
|
||||
await expectScrollableQuote(page, longSentQuote, SENT_SYNTAX_TEXT)
|
||||
} finally {
|
||||
await page.setViewportSize(viewport)
|
||||
}
|
||||
expect(await longSentQuote.textContent()).toContain(LITERAL_CREDENTIAL)
|
||||
const sentSource = longSentQuote.locator('[data-quote-preview-text]')
|
||||
expect(await sentSource.textContent()).toBe(SENT_SYNTAX_TEXT)
|
||||
expect(await sentSource.locator('*').count()).toBe(0)
|
||||
const textAppearance = await quoteAppearance(sentSource)
|
||||
const quoteStyle = await quoteAppearance(longSentQuote)
|
||||
expect(textAppearance.color).toBe(quoteStyle.color)
|
||||
expect(textAppearance.fontSize).toBe(quoteStyle.fontSize)
|
||||
expect(textAppearance.lineHeight).toBe(quoteStyle.lineHeight)
|
||||
expect(await longSentQuote.locator('[data-secret], [data-ref-chip], strong').count()).toBe(0)
|
||||
const events = scaffold.ctx.agents.get(settledSessionId)!.session.snapshotEvents(beforeOffset)
|
||||
expect(events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'skill-invocation')).toBe(true)
|
||||
expect(events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'session-reference')).toBe(true)
|
||||
const submitted = events.find(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user')
|
||||
expect(submitted).toMatchObject({
|
||||
data: {
|
||||
content: [{ type: 'text', text: formatQuoteText(SENT_SYNTAX_TEXT) }],
|
||||
},
|
||||
})
|
||||
expect(submitted).not.toHaveProperty('data.source.quotes')
|
||||
await expect.poll(() => input.textContent()).toBe('')
|
||||
const persisted = await page.evaluate((key): unknown => JSON.parse(localStorage.getItem(key)!), CURRENT_DRAFT_KEY)
|
||||
expect(persisted).toMatchObject({ draft: { text: '', references: [] } })
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.getByText(REPLY, { exact: true }).waitFor({ timeout: 20_000 })
|
||||
await expectScrollableQuote(page, longSentQuote, SENT_SYNTAX_TEXT)
|
||||
const literalQuote = await captureStableAria(
|
||||
page, `[data-message-bubble]:has([data-quote-preview-text]):has-text("${LITERAL_CREDENTIAL}") >> nth=-1`, scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, `${snapshot}\n\n## Literal quote body\n\n${literalQuote}`, MODE)
|
||||
await expect.poll(() => input.textContent()).toBe('')
|
||||
expect(await page.locator('[data-slot-error]').count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
}, 120_000)
|
||||
|
||||
it('keeps adjacent quote previews exclusive while crossing their hover gaps', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-quote-hover-gap'))
|
||||
const texts = ['First excerpt', 'Second excerpt'].map(line => Array.from({ length: 6 }, () => line).join('\n'))
|
||||
const quoted = texts.map(formatQuoteText)
|
||||
const separator = '\nx\n'
|
||||
const draft = {
|
||||
text: quoted[0]! + separator + quoted[1]!, references: [],
|
||||
quotes: texts.map((text, index) => ({
|
||||
text, offset: index === 0 ? 0 : quoted[0]!.length + separator.length, length: quoted[index]!.length,
|
||||
})),
|
||||
}
|
||||
const viewport = page.viewportSize()!
|
||||
try {
|
||||
await page.evaluate(({ key, draft }) => {
|
||||
localStorage.setItem(key, JSON.stringify({ draft, view: 'chat', viewRequest: null }))
|
||||
}, { key: CURRENT_DRAFT_KEY, draft })
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
const draftQuotes = page.locator('[data-composer-quote] [data-quote-preview]')
|
||||
await expect.poll(() => draftQuotes.count()).toBe(2)
|
||||
await crossQuoteGap(page, draftQuotes.last(), texts[1]!, 'above')
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await page.getByRole('button', { name: 'Send message' }).click()
|
||||
await settled
|
||||
await page.setViewportSize({ width: 600, height: 1000 })
|
||||
const sentQuotes = page.locator('[data-message-bubble] [data-quote-preview]').filter({ hasText: 'First excerpt' })
|
||||
await sentQuotes.last().waitFor()
|
||||
await sentQuotes.last().evaluate((element) => { element.scrollIntoView({ block: 'center' }) })
|
||||
await crossQuoteGap(page, sentQuotes.last(), texts[0]!, 'below')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await page.keyboard.press('Escape')
|
||||
await page.setViewportSize(viewport)
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
// UI-only phone geometry for the Automation tasks page: no text field it owns
|
||||
// sits under the 16px touch step, so the page never reads wider than the screen
|
||||
// — iOS Safari zooms the visual viewport for a focused field below it.
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-schedule'
|
||||
import { launchWebScaffold, watchConsole } from './scaffold.ts'
|
||||
|
||||
const PHONE_TASK_SESSION = SessionId('schedule-phone-web-e2e')
|
||||
const PHONE_TASK_TITLE = 'Review overdue deployment'
|
||||
const PHONE_PAGE_WIDTH = 390
|
||||
|
||||
/** The page's box against the screen, plus the font steps of its text fields. */
|
||||
interface ViewFacts {
|
||||
width: number
|
||||
documentWidth: number
|
||||
smallestField: number
|
||||
name: string
|
||||
search: string
|
||||
instruction: string
|
||||
}
|
||||
|
||||
/** Open the Automation tasks page and return its panel. */
|
||||
async function openTaskManager(page: Page, url: string): Promise<Locator> {
|
||||
await page.goto(url, { waitUntil: 'load' })
|
||||
await page.getByRole('button', { name: '自动化任务', exact: true }).click()
|
||||
const panel = page.getByTestId('task-manager-page')
|
||||
await panel.getByRole('heading', { name: '自动化任务', exact: true }).waitFor({ timeout: 30_000 })
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
return panel
|
||||
}
|
||||
|
||||
/** Read the facts above from the open view. */
|
||||
async function viewFacts(page: Page): Promise<ViewFacts> {
|
||||
return page.evaluate(() => {
|
||||
const panel = document.querySelector('[data-testid="task-manager-page"]')
|
||||
const fields = Array.from(panel?.querySelectorAll('input, textarea, select') ?? [])
|
||||
const steps = fields.map(element => Number.parseFloat(getComputedStyle(element).fontSize))
|
||||
const step = (selector: string): string => {
|
||||
const element = document.querySelector(selector)
|
||||
return element === null ? 'absent' : getComputedStyle(element).fontSize
|
||||
}
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
smallestField: steps.length === 0 ? -1 : Math.min(...steps),
|
||||
name: step('[class*="_editName"]'),
|
||||
search: step('[class*="_searchField"] input'),
|
||||
instruction: step('[class*="_instruction"]'),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('keeps the automation page on screen with its text fields at the touch step', async () => {
|
||||
const scaffold = await launchWebScaffold()
|
||||
const url = scaffold.authenticatedUrl
|
||||
try {
|
||||
await scaffold.ctx.schedule.create(PHONE_TASK_SESSION, {
|
||||
prompt: 'Check the deployment log', title: PHONE_TASK_TITLE, at: '2099-08-25T12:08:00.000Z',
|
||||
})
|
||||
const browser: Browser = await chromium.launch()
|
||||
try {
|
||||
// A coarse pointer is the touch device, so the phone context takes the
|
||||
// 16px step.
|
||||
const phone = await browser.newContext({
|
||||
viewport: { width: PHONE_PAGE_WIDTH, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, locale: 'zh-CN',
|
||||
})
|
||||
const phonePage = await phone.newPage()
|
||||
const phoneConsole = watchConsole(phonePage)
|
||||
const phonePanel = await openTaskManager(phonePage, url)
|
||||
await expect.poll(() => viewFacts(phonePage)).toEqual({
|
||||
width: PHONE_PAGE_WIDTH,
|
||||
documentWidth: PHONE_PAGE_WIDTH,
|
||||
smallestField: 16,
|
||||
name: 'absent',
|
||||
search: '16px',
|
||||
instruction: 'absent',
|
||||
})
|
||||
// The detail keeps the floor too, and the name control keeps the 20px step
|
||||
// the floor must not flatten.
|
||||
await phonePanel.getByRole('button', { name: PHONE_TASK_TITLE, exact: true }).click()
|
||||
await phonePanel.locator('[class*="_detailScroll"]').waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => viewFacts(phonePage)).toEqual({
|
||||
width: PHONE_PAGE_WIDTH,
|
||||
documentWidth: PHONE_PAGE_WIDTH,
|
||||
smallestField: 16,
|
||||
name: '20px',
|
||||
search: '16px',
|
||||
instruction: '16px',
|
||||
})
|
||||
await phone.close()
|
||||
|
||||
// A fine pointer keeps the compact steps: the floor is pointer-scoped.
|
||||
const desktop = await browser.newContext({ viewport: { width: 1024, height: 800 }, locale: 'zh-CN' })
|
||||
const desktopPage = await desktop.newPage()
|
||||
const desktopConsole = watchConsole(desktopPage)
|
||||
const desktopPanel = await openTaskManager(desktopPage, url)
|
||||
await expect.poll(() => viewFacts(desktopPage)).toEqual({
|
||||
width: 1024,
|
||||
documentWidth: 1024,
|
||||
smallestField: 14,
|
||||
name: 'absent',
|
||||
search: '14px',
|
||||
instruction: 'absent',
|
||||
})
|
||||
await desktopPanel.getByRole('button', { name: PHONE_TASK_TITLE, exact: true }).click()
|
||||
await desktopPanel.locator('[class*="_detailScroll"]').waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => viewFacts(desktopPage)).toEqual({
|
||||
width: 1024,
|
||||
documentWidth: 1024,
|
||||
smallestField: 14,
|
||||
name: '20px',
|
||||
search: '14px',
|
||||
instruction: '14px',
|
||||
})
|
||||
await desktop.close()
|
||||
|
||||
expect(phoneConsole.pageErrors).toEqual([])
|
||||
expect(phoneConsole.warnings).toEqual([])
|
||||
expect(desktopConsole.pageErrors).toEqual([])
|
||||
expect(desktopConsole.warnings).toEqual([])
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
} finally {
|
||||
await scaffold.close()
|
||||
}
|
||||
}, 180_000)
|
||||
@@ -84,6 +84,17 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
const panelBox = await dialog.boundingBox()
|
||||
const optionsBox = await dialog.locator('[class*="_options"]').boundingBox()
|
||||
const firstSectionBox = await dialog.getByRole('navigation').getByRole('button').first().boundingBox()
|
||||
expect(optionsBox!.y - panelBox!.y).toBe(54)
|
||||
expect(firstSectionBox!.y - panelBox!.y).toBe(64)
|
||||
const lastSection = dialog.getByRole('navigation').getByRole('button').last()
|
||||
await lastSection.focus()
|
||||
await page.keyboard.press('Tab')
|
||||
expect(await dialog.getByRole('button', { name: '打开配置文件' }).evaluate(el => el === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Tab')
|
||||
expect(await dialog.getByRole('button', { name: '关闭', exact: true }).evaluate(el => el === document.activeElement)).toBe(true)
|
||||
// General is active by default; Permission, Language and Appearance are functional.
|
||||
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
|
||||
await dialog.getByRole('button', { name: '工作区内修改' }).waitFor({ timeout: 10_000 })
|
||||
@@ -210,7 +221,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
// Close path 2: the header close button (focus lands there on open).
|
||||
// Close path 2: the header close button.
|
||||
await openSettings(page, 'zh')
|
||||
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '关闭' }).click()
|
||||
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
|
||||
@@ -227,7 +238,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
const dialog = overflowPage.getByRole('dialog', { name: '设置', exact: true })
|
||||
const navigation = dialog.getByRole('navigation')
|
||||
const list = navigation.locator('[class*="_navList"]')
|
||||
const title = navigation.getByText('设置', { exact: true })
|
||||
const title = dialog.getByText('设置', { exact: true })
|
||||
const options = dialog.locator('[class*="_options"]')
|
||||
const last = navigation.getByRole('button').last()
|
||||
await last.waitFor()
|
||||
@@ -261,6 +272,98 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(console.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('fills a phone viewport and turns the rail into the horizontal section strip', async () => {
|
||||
const phone = await browser.newPage({ viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE })
|
||||
onTestFinished(() => phone.close())
|
||||
onTestFailed(() => saveSettingsFailureShot(phone, 'web-e2e-settings-phone'))
|
||||
const console = watchConsole(phone)
|
||||
await phone.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await openSettings(phone, 'zh')
|
||||
const dialog = phone.getByRole('dialog', { name: '设置', exact: true })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '通用设置', exact: true }).waitFor({ timeout: 10_000 })
|
||||
|
||||
// The panel is the viewport: no centered card, no side margins, no rail.
|
||||
await expect.poll(() => dialog.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 })
|
||||
|
||||
// The rail reads left to right on one line instead of stacking.
|
||||
const navigation = dialog.getByRole('navigation')
|
||||
const list = navigation.locator('[class*="_navList"]')
|
||||
const cells = navigation.getByRole('button')
|
||||
const first = await cells.first().boundingBox()
|
||||
const second = await cells.nth(1).boundingBox()
|
||||
expect(first).not.toBeNull()
|
||||
expect(second).not.toBeNull()
|
||||
expect(second!.x).toBeGreaterThanOrEqual(first!.x + first!.width - 1)
|
||||
expect(Math.abs(second!.y - first!.y)).toBeLessThan(1)
|
||||
expect(await list.evaluate(el => getComputedStyle(el).overflowX)).toBe('auto')
|
||||
|
||||
// The shipped sections overflow the strip, which scrolls sideways; the
|
||||
// section column starts below it and keeps the panel's width.
|
||||
const strip = await list.boundingBox()
|
||||
const options = dialog.locator('[class*="_options"]')
|
||||
const optionsBox = await options.boundingBox()
|
||||
expect(strip).not.toBeNull()
|
||||
expect(optionsBox).not.toBeNull()
|
||||
expect(await list.evaluate(el => el.scrollWidth)).toBeGreaterThan(await list.evaluate(el => el.clientWidth))
|
||||
expect(optionsBox!.y).toBeGreaterThanOrEqual(strip!.y + strip!.height - 1)
|
||||
expect(Math.round(optionsBox!.width)).toBe(390)
|
||||
expect(await phone.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
|
||||
|
||||
// Tapping a cell swaps the page, and a General row keeps its label and
|
||||
// control on one line rather than stacking.
|
||||
await cells.nth(1).click()
|
||||
await expect.poll(() => cells.nth(1).getAttribute('aria-current')).toBe('true')
|
||||
await expect.poll(() => dialog.getByText('权限', { exact: true }).count()).toBe(0)
|
||||
await cells.first().click()
|
||||
await expect.poll(() => dialog.getByText('权限', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
||||
const permissionRow = dialog.getByText('权限', { exact: true }).locator('..')
|
||||
const permissionBox = await permissionRow.boundingBox()
|
||||
const controlBox = await dialog.getByRole('button', { name: '工作区内修改' }).boundingBox()
|
||||
expect(controlBox).not.toBeNull()
|
||||
expect(permissionBox).not.toBeNull()
|
||||
expect(controlBox!.y).toBeLessThan(permissionBox!.y + permissionBox!.height)
|
||||
|
||||
// The Appearance cubes share one row instead of wrapping to two.
|
||||
const cubes = dialog.getByRole('button', { name: /浅色|深色|跟随系统/ })
|
||||
const cubeBoxes = await cubes.evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().top))
|
||||
expect(new Set(cubeBoxes.map(top => Math.round(top))).size).toBe(1)
|
||||
|
||||
await cells.first().focus()
|
||||
for (let index = 1; index < await cells.count(); index += 1) {
|
||||
await phone.keyboard.press('Tab')
|
||||
}
|
||||
const last = cells.last()
|
||||
expect(await last.evaluate(el => el === document.activeElement)).toBe(true)
|
||||
const lastBox = await last.boundingBox()
|
||||
// Scroll offsets can round while glyph widths remain fractional.
|
||||
expect(Math.round(lastBox!.x)).toBeGreaterThanOrEqual(0)
|
||||
expect(Math.round(lastBox!.x + lastBox!.width)).toBeLessThanOrEqual(390)
|
||||
await last.click()
|
||||
await expect.poll(() => last.getAttribute('aria-current')).toBe('true')
|
||||
await phone.keyboard.press('Escape')
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
await openSettings(phone, 'zh')
|
||||
const active = dialog.locator('[aria-current="true"]')
|
||||
expect(await active.textContent()).toBe('通用设置')
|
||||
expect(await active.evaluate(el => el === document.activeElement)).toBe(true)
|
||||
|
||||
const close = dialog.getByRole('button', { name: '关闭', exact: true })
|
||||
const closeBox = await close.boundingBox()
|
||||
expect(closeBox!.width).toBe(28)
|
||||
expect(closeBox!.x).toBeGreaterThanOrEqual(0)
|
||||
expect(closeBox!.y).toBeGreaterThanOrEqual(0)
|
||||
expect(closeBox!.x + closeBox!.width).toBeLessThanOrEqual(390)
|
||||
expect(closeBox!.y + closeBox!.height).toBeLessThanOrEqual(844)
|
||||
await close.click()
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
const trigger = phone.getByRole('button', { name: '账号菜单', exact: true })
|
||||
await expect.poll(() => trigger.evaluate(el => el === document.activeElement)).toBe(true)
|
||||
|
||||
expect(console.pageErrors).toEqual([])
|
||||
expect(console.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('stores Permission as the default for future sessions without changing an existing session', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
|
||||
const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
// this client-registered project must not also hold them (one program
|
||||
// cannot see both sides of the cordis Context merges).
|
||||
"exclude": [
|
||||
"tests/rich-text-quotes.e2e.ts",
|
||||
"tests/hover-input.e2e.ts",
|
||||
"tests/collapse-timing.e2e.ts",
|
||||
"tests/notice-wake-tail.e2e.ts",
|
||||
@@ -89,6 +90,7 @@
|
||||
"tests/cua-optin.e2e.ts",
|
||||
"tests/cua-permissions.e2e.ts",
|
||||
"tests/schedule-header.e2e.ts",
|
||||
"tests/schedule-phone.e2e.ts",
|
||||
"tests/worktree-opt-in.e2e.ts",
|
||||
"tests/session-inspector.e2e.ts",
|
||||
"tests/shared-inspector.e2e.ts",
|
||||
|
||||
@@ -4785,6 +4785,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
| `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-lsp` | — | [`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-message-quotes` | — | [`packages/session/message-quotes/src/index.ts`](../packages/session/message-quotes/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-otel` | — | [`packages/telemetry/otel/src/index.ts`](../packages/telemetry/otel/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) |
|
||||
|
||||
@@ -4787,6 +4787,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-lsp` | — | [`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-message-quotes` | — | [`packages/session/message-quotes/src/index.ts`](../packages/session/message-quotes/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-otel` | — | [`packages/telemetry/otel/src/index.ts`](../packages/telemetry/otel/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) |
|
||||
|
||||
@@ -374,6 +374,7 @@ flowchart TD
|
||||
pkg_sdk_protocol["sdk-protocol"]
|
||||
end
|
||||
subgraph group_session["packages/session"]
|
||||
pkg_message_quotes["message-quotes"]
|
||||
pkg_session_checkpoint_policy["session-checkpoint-policy"]
|
||||
pkg_session_format["session-format"]
|
||||
pkg_session_format_catalog["session-format-catalog"]
|
||||
@@ -1563,6 +1564,7 @@ flowchart TD
|
||||
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — |
|
||||
| [`host-open-in-app`](../packages/host/open-in-app) | `host` | — |
|
||||
| [`host-webserver`](../packages/host/webserver) | `host` | — |
|
||||
| [`message-quotes`](../packages/session/message-quotes) | `session` | — |
|
||||
| [`session-format`](../packages/session/session-format) | `session` | — |
|
||||
| [`session-format-v0-to-v1`](../packages/session/session-format-v0-to-v1) | `session` | — |
|
||||
| [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — |
|
||||
|
||||
@@ -376,6 +376,7 @@ flowchart TD
|
||||
pkg_sdk_protocol["sdk-protocol"]
|
||||
end
|
||||
subgraph group_session["packages/session"]
|
||||
pkg_message_quotes["message-quotes"]
|
||||
pkg_session_checkpoint_policy["session-checkpoint-policy"]
|
||||
pkg_session_format["session-format"]
|
||||
pkg_session_format_catalog["session-format-catalog"]
|
||||
@@ -1565,6 +1566,7 @@ flowchart TD
|
||||
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — |
|
||||
| [`host-open-in-app`](../packages/host/open-in-app) | `host` | — |
|
||||
| [`host-webserver`](../packages/host/webserver) | `host` | — |
|
||||
| [`message-quotes`](../packages/session/message-quotes) | `session` | — |
|
||||
| [`session-format`](../packages/session/session-format) | `session` | — |
|
||||
| [`session-format-v0-to-v1`](../packages/session/session-format-v0-to-v1) | `session` | — |
|
||||
| [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — |
|
||||
|
||||
@@ -137,6 +137,7 @@ Factory: conversation.content [ConversationContent]
|
||||
├─ conversation.session [ConversationSession / DefaultConversationViews]
|
||||
│ └─ conversation.view
|
||||
│ ├─ id=chat [ChatView]
|
||||
│ │ ├─ conversation.chat.text-interaction [list]
|
||||
│ │ └─ conversation.chat.flow [ChatFlow]
|
||||
│ │ └─ conversation.chat.node [ChatNodeSeat dispatch]
|
||||
│ │ ├─ key=user,steering [UserMessageNodeView]
|
||||
|
||||
@@ -139,6 +139,7 @@ Factory: conversation.content [ConversationContent]
|
||||
├─ conversation.session [ConversationSession / DefaultConversationViews]
|
||||
│ └─ conversation.view
|
||||
│ ├─ id=chat [ChatView]
|
||||
│ │ ├─ conversation.chat.text-interaction [list]
|
||||
│ │ └─ conversation.chat.flow [ChatFlow]
|
||||
│ │ └─ conversation.chat.node [ChatNodeSeat dispatch]
|
||||
│ │ ├─ key=user,steering [UserMessageNodeView]
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 49a3d0e8e92017d3
|
||||
zh: 91302aa3dd6e9378
|
||||
/session-controller/configuration:
|
||||
en: 5ae2b340c630897f
|
||||
zh: 1ffbd61ecf302ec2
|
||||
en: c92590fbaf75e45e
|
||||
zh: fcac67017bb0e3ab
|
||||
/session-controller/model-experience:
|
||||
en: 30b917bf822bb4df
|
||||
zh: b0b023b5b526c317
|
||||
|
||||
@@ -106,6 +106,8 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
|
||||
|
||||
-----
|
||||
|
||||
Blockquotes travel as ordinary prompt text without quote metadata. Local submission echoes and durable messages use the same text rendering rules; queue edits replace content while retaining prompt correlation.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -106,6 +106,8 @@ GUI 模型选择要求确切提供方/模型对出现在可用目录中;不
|
||||
|
||||
-----
|
||||
|
||||
Blockquote 作为普通 prompt 文本传递,不携带 quote 元数据。本地提交回显与持久消息使用同一文本渲染规则;队列编辑替换 content,同时保留 prompt 关联信息。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ async function commandHarness(
|
||||
inbox: Inbox
|
||||
steer: ReturnType<typeof vi.fn>
|
||||
cancel: ReturnType<typeof vi.fn>
|
||||
resolveAgent: ReturnType<typeof vi.fn>
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -107,7 +108,7 @@ async function commandHarness(
|
||||
saveSelection: () => Promise.resolve(),
|
||||
} as never)
|
||||
const agents = new ApiSessionAgentController(ctx)
|
||||
vi.spyOn(agents, 'resolveAgent').mockImplementation(id => Promise.resolve(id === agent.id
|
||||
const resolveAgent = vi.spyOn(agents, 'resolveAgent').mockImplementation(id => Promise.resolve(id === agent.id
|
||||
? { agent }
|
||||
: { error: new RemoteError('session/not-found', 'missing', { sessionId: id }) }))
|
||||
vi.spyOn(agents, 'composeAgent').mockResolvedValue({ setup: () => {} })
|
||||
@@ -118,6 +119,7 @@ async function commandHarness(
|
||||
inbox,
|
||||
steer,
|
||||
cancel,
|
||||
resolveAgent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +128,19 @@ async function expectFailure(operation: Promise<unknown>, code: string): Promise
|
||||
}
|
||||
|
||||
describe('Session queue commands', () => {
|
||||
it('edits a blockquote into ordinary text without changing prompt correlation', async () => {
|
||||
const { ctx, controller, agent, inbox } = await commandHarness()
|
||||
try {
|
||||
const message = createUserMessage({ content: [{ type: 'text', text: '> selected' }],
|
||||
source: { kind: 'user', rpcId: 'quote-edit' as import('../src/types.ts').SessionRequestId } })
|
||||
inbox.append('next-turn', message)
|
||||
await controller.updateQueue({ sessionId: agent.id, itemId: message.id,
|
||||
action: { kind: 'edit', content: [{ type: 'text', text: 'plain replacement' }] } })
|
||||
expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'plain replacement' }])
|
||||
expect(inbox.nextTurn[0]?.source).toEqual(message.source)
|
||||
} finally { await ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it('preserves the cold Agent resolver rejection', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
@@ -794,6 +794,33 @@ describe('Web session model selection', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('admits Markdown blockquote text without source metadata', async () => {
|
||||
const { ctx, agent, sessionId } = await harness()
|
||||
const followup = vi.fn()
|
||||
Object.assign(agent, { followup })
|
||||
const remote = await createSessionTestRemote(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
|
||||
cwd: '/tmp',
|
||||
})
|
||||
expectValue(await remote.prompt(promptRequest({
|
||||
sessionId,
|
||||
mode: 'queue',
|
||||
content: [
|
||||
{ type: 'text', text: 'Question:\n\n' },
|
||||
{ type: 'text', text: '> quoted source' },
|
||||
],
|
||||
})))
|
||||
const message = followup.mock.calls[0]?.[0] as UserMessage
|
||||
expect(message.content).toEqual([
|
||||
{ type: 'text', text: 'Question:\n\n' },
|
||||
{ type: 'text', text: '> quoted source' },
|
||||
])
|
||||
expect(message.source.kind).toBe('user')
|
||||
expect(message.source).not.toHaveProperty('quotes')
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('serves a session and its catalog when the stored default names a route that is gone', async () => {
|
||||
const { ctx, sessionId } = await harness()
|
||||
await createSessionTestRemote(ctx, {
|
||||
|
||||
@@ -329,6 +329,9 @@
|
||||
- id: ui-approval
|
||||
name: '@deepseek-ai/dsh-client-ui-approval'
|
||||
|
||||
- id: message-quotes
|
||||
name: '@deepseek-ai/dsh-message-quotes'
|
||||
|
||||
- id: ui-chat
|
||||
name: '@deepseek-ai/dsh-client-ui-chat'
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-approval": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-attachment": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-brand-official": "workspace:*",
|
||||
"@deepseek-ai/dsh-message-quotes": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-chat": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-commands": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 7fedfbcbc3ad8cea
|
||||
zh: e29ba5230e0b9ecb
|
||||
/deepseek-ai-dsh-client-locale/use-this-package/choosing-a-language:
|
||||
en: 8527d77583824eff
|
||||
zh: 2ac53ced0a32ba19
|
||||
en: 2b82458e185ba321
|
||||
zh: e55a7e709a3f0158
|
||||
/deepseek-ai-dsh-client-locale/use-this-package/registering-a-dictionary:
|
||||
en: 9b2fcfa8cae8dc4b
|
||||
zh: 293012a4d364ef59
|
||||
|
||||
@@ -29,6 +29,8 @@ Use it wherever the web GUI needs a language switch or translated copy: the ship
|
||||
|
||||
### Choosing a language
|
||||
|
||||
The Language row uses a smaller label gutter in narrow browser settings; Windows and macOS desktop windows retain the standard spacing.
|
||||
|
||||
Open Settings → General and select a registered language. The active locale is applied immediately: the UI copy switches, `<html lang>` points at the external id or built-in document tag, and the choice is written to the durable settings section. A browser without an explicit Host preference selects the first registered language that matches `navigator` by full tag and then primary subtag, falling back to English. A stored external locale waits for its definition to register instead of becoming active while unavailable.
|
||||
|
||||
Native shells may provide `__DSH_LOCALE__` with an asynchronous `read()` and an `onChange(locale)` callback. Initialization supplies the current Host preference and ordered OS languages before the Client mounts. Automatic selection stays provisional; only Settings selections write `locale.preference`. A fresh read on each page load prevents a stale preload preference after reload. Ordinary browsers keep navigator-based detection and their existing settings-scope policy.
|
||||
|
||||
@@ -29,6 +29,8 @@ kind: "package-reference"
|
||||
|
||||
### 选择语言
|
||||
|
||||
窄屏浏览器设置中,语言行缩小标签与控件的间距;Windows 和 macOS 桌面窗口保留标准间距。
|
||||
|
||||
打开“设置 → 常规”并选择一种已注册语言。生效中的 locale 会立即应用:UI 文案切换、`<html lang>` 指向外部 id 或内置语言的文档标签,选择写入持久设置分区。没有显式 Host 偏好的浏览器会按完整标签、再按主语言子标签选择 `navigator` 请求的第一个已注册语言,无法匹配时回退到英文。已存储的外部 locale 会等待其定义注册,不会在不可用时生效。
|
||||
|
||||
原生壳可以提供包含异步 `read()` 和 `onChange(locale)` 回调的 `__DSH_LOCALE__`。初始化在 Client 挂载前提供当前 Host 偏好和有序的系统语言列表。自动选择保持临时状态;只有设置中的选择会写入 `locale.preference`。每次加载页面都重新读取,避免重载后沿用过期的 preload 偏好。普通浏览器继续使用 navigator 检测和原有的设置作用域策略。
|
||||
|
||||
@@ -18,6 +18,14 @@
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
/* Narrow settings panel: the 48px gutter separates the label from its control
|
||||
across a wide row and only squeezes the label here. */
|
||||
@media (max-width: 767px) {
|
||||
:global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .rowText {
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 41dcd70e14ebe9cc
|
||||
zh: ededead572b59037
|
||||
/deepseek-ai-dsh-client-ui-chat/turn-token-usage:
|
||||
en: bb20da372b144302
|
||||
zh: 75a8c5ab7ec8d665
|
||||
en: 059eef868161efe4
|
||||
zh: 52ecc8e682d4c630
|
||||
/deepseek-ai-dsh-client-ui-chat/completed-turn-footer:
|
||||
en: 382821ca3ffa60dd
|
||||
zh: 9840ffa07c13082a
|
||||
@@ -36,8 +36,8 @@
|
||||
en: a14c74ed7de0ef9c
|
||||
zh: d5e618b2ef809150
|
||||
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
|
||||
en: 284737900cb75668
|
||||
zh: 12e0844082ff79ce
|
||||
en: 009ecb4fecd4ee9e
|
||||
zh: ec2714337a1102c5
|
||||
/deepseek-ai-dsh-client-ui-chat/model-experience:
|
||||
en: c17b4dd43a61c569
|
||||
zh: f7414309810c3a08
|
||||
|
||||
@@ -72,6 +72,8 @@ The composer statistics are two `conversation.composer.dock` list entries: `acti
|
||||
|
||||
Explicit selections update every consumer immediately; accepted Host settings reconcile the live value, so a refused or pending write keeps the selected mode until the Host answers.
|
||||
|
||||
In narrow browser settings, preference labels use a smaller gutter before their controls. Windows and macOS desktop windows retain the standard spacing.
|
||||
|
||||
Preference menus restore focus to their trigger without scrolling before publishing a new selection.
|
||||
|
||||
<a id="completed-turn-footer"></a>
|
||||
@@ -172,6 +174,10 @@ Active-Turn highlighting is approximate: `readVisibleTurn` binary-searches the f
|
||||
|
||||
-----
|
||||
|
||||
The Chat view detects selection and context-menu gestures on business nodes marked with `data-text-interaction-source`. Both selection endpoints must resolve to the same source container. Displayed user content and settled Assistant text or reasoning participate; tools, injected context, streaming or interrupted Assistant text, and Markdown controls do not.
|
||||
|
||||
`ctx.uiConversation.interactions` dispatches eligible gestures to plugin registrations and supplies their component to the Chat view. The action surface is absolutely positioned inside the existing Chat column, above the composer seat and its dock. Its wrapper preserves the component's outer stroke and shadow; components own internal scrolling. Scrolling the selection outside that visible region temporarily hides the surface; returning restores it. Chat owns viewport visibility, placement, outside pointer presses, Escape, new selections, source invalidation, and disposal; explicit dismissal preserves browser selection and layout changes do not reopen the surface. [Message quotes](../../session/message-quotes/README.md) owns quotation eligibility and insertion.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,
|
||||
|
||||
明确选择会立即更新所有使用方;Host 接受的设置会校正实时值,因此写入被拒绝或尚未应答时保持所选模式,直到 Host 应答。
|
||||
|
||||
窄屏浏览器设置中,偏好标签与控件的间距缩小;Windows 和 macOS 桌面窗口保留标准间距。
|
||||
|
||||
偏好菜单在发布新选择之前,先将焦点还给触发按钮,且不引起滚动。
|
||||
|
||||
<a id="completed-turn-footer"></a>
|
||||
@@ -172,6 +174,10 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
|
||||
|
||||
-----
|
||||
|
||||
Chat view 在标记了 `data-text-interaction-source` 的业务节点上检测选区和右键手势。选区两端必须定位到同一个来源容器。已展示的用户内容和已完成的 Assistant 文本或 reasoning 参与交互;Tool、注入上下文、流式或中断的 Assistant 文本以及 Markdown 控件不参与。
|
||||
|
||||
`ctx.uiConversation.interactions` 将有效手势分发给插件注册项,并把对应组件交给 Chat view。操作展示位置在已有 Chat 列内绝对定位,限制在 composer seat 及其 dock 上方。外层容器保留组件的外描边与阴影,内部滚动由组件负责。选区滚出该可见范围时暂时隐藏操作,滚回后恢复。Chat 负责视口可见性、定位、外部指针按下、Escape、新选区、来源失效与卸载清理;主动关闭保留浏览器选区,布局变化不会重新打开操作。[Message quotes](../../session/message-quotes/README.zh.md) 负责引用的接管条件和插入。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -205,11 +205,10 @@ export function apply(ctx: Context): void {
|
||||
}, TranscriptViewRow))
|
||||
|
||||
const connectionState = (ctx.get('connection') as ConnectionHandle).state
|
||||
const nodeSources = (binding: SessionBinding): Pick<ChatViewInjected, 'hooks' | 'keyedHooks'> => {
|
||||
const nodeSources = (binding: SessionBinding): Pick<ChatViewInjected, 'keyedHooks'> => {
|
||||
const chat = chatSource(binding)
|
||||
const conversation = ctx.uiConversation.binding(binding)
|
||||
return {
|
||||
hooks: { presentation },
|
||||
keyedHooks: {
|
||||
chatNode: key => chat.getSnapshot().nodes.source(key),
|
||||
chatNodeBottom: key => chat.getSnapshot().nodes.bottomSource(key),
|
||||
@@ -227,8 +226,7 @@ export function apply(ctx: Context): void {
|
||||
inject: (sessionId: SessionId): ChatFlowDataInjected => {
|
||||
const binding = ctx.sessions.binding(sessionId)
|
||||
if (binding === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`)
|
||||
const sources = nodeSources(binding)
|
||||
return { ...sources, hooks: { ...sources.hooks, connectionState } }
|
||||
return { ...nodeSources(binding), hooks: { presentation, connectionState } }
|
||||
},
|
||||
}, ChatFlow))
|
||||
|
||||
@@ -241,6 +239,7 @@ export function apply(ctx: Context): void {
|
||||
locale: NS,
|
||||
children: {
|
||||
'conversation.chat.flow': { kind: 'single', scope: 'session', inject: CHAT_FLOW_INJECT },
|
||||
'conversation.chat.text-interaction': { kind: 'keyed', scope: 'session' },
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId): ChatViewInjected => {
|
||||
@@ -250,6 +249,8 @@ export function apply(ctx: Context): void {
|
||||
const chat = chatSource(binding)
|
||||
return {
|
||||
...nodeSources(binding),
|
||||
hooks: { presentation, interactionIds: ctx.uiConversation.interactions },
|
||||
dispatchInteraction: gesture => ctx.uiConversation.interactions.dispatch(sessionId, gesture),
|
||||
fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId),
|
||||
// Files open in the right Sidebar, not in a desktop application: the
|
||||
// content stays in the product, beside the conversation that produced
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface AssistantMarkdownProps extends
|
||||
useDisclosure: UseDisclosure
|
||||
blocks: readonly AssistantBlock[]
|
||||
streaming: boolean
|
||||
/** Only finalized durable Assistant messages supply selection sources. */
|
||||
textInteractionSource?: boolean
|
||||
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
|
||||
interrupted?: boolean | undefined
|
||||
/** Hide reasoning that belongs to the Turn-level process disclosure. */
|
||||
@@ -54,7 +56,7 @@ export interface AssistantMarkdownProps extends
|
||||
|
||||
/** Renders non-blank reasoning blocks as independent disclosures alongside the response. */
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, loadImage, groupPart, useDisclosure,
|
||||
blocks, streaming, textInteractionSource = false, interrupted, loadImage, groupPart, useDisclosure,
|
||||
reasoningHidden = false, usePresentation, revealProcess, mentions, renderSlot, useGroupAction, t,
|
||||
}: AssistantMarkdownProps) {
|
||||
// Stable per locale revision (t identity changes on switch): a fresh object
|
||||
@@ -88,6 +90,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
labels={labels}
|
||||
fileMentions={mentions}
|
||||
pathImages={pathImages}
|
||||
textInteractionSource={textInteractionSource}
|
||||
/>,
|
||||
)
|
||||
break
|
||||
@@ -101,7 +104,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
useGroupAction={useGroupAction}
|
||||
>
|
||||
<ReasoningRow text={block.text} running={streaming && i === last} usePresentation={usePresentation}
|
||||
useDisclosure={useDisclosure} renderSlot={renderSlot} t={t} />
|
||||
useDisclosure={useDisclosure} renderSlot={renderSlot} textInteractionSource={textInteractionSource} t={t} />
|
||||
</ProcessReasoning>,
|
||||
)
|
||||
break
|
||||
|
||||
@@ -40,6 +40,7 @@ export const AssistantNodeView = memo(function AssistantNodeView({
|
||||
groupPart={groupPart}
|
||||
useDisclosure={useDisclosure}
|
||||
streaming={data.status === 'running'}
|
||||
textInteractionSource={data.status === 'settled' && data.finalNode?.messageId !== undefined}
|
||||
interrupted={data.status === 'interrupted'}
|
||||
loadImage={loadImage}
|
||||
reasoningHidden={reasoningHidden}
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
centered on the same axis as the input box (which caps at chat + 16px); the
|
||||
scroller itself stays full-bleed. */
|
||||
.column {
|
||||
position: relative;
|
||||
max-width: var(--dsh-chat-content-width);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
@@ -66,10 +67,10 @@
|
||||
`hidden="until-found"` retains a searchable zero-height box; hidden and empty
|
||||
Seats do not contribute spacing. The older-history control precedes the anchor;
|
||||
an ancestor :has() here also invalidates the history on unrelated descendant edits. */
|
||||
:is(.column, :global([data-slot="conversation.chat.flow"])) > :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))
|
||||
~ :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))),
|
||||
:is(.column, :global([data-slot="conversation.chat.flow"])) > :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))
|
||||
~ :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))),
|
||||
.column > .older + :global([data-slot="conversation.chat.flow"])
|
||||
> :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))) {
|
||||
> :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))) {
|
||||
margin-top: var(--dsh-chat-flow-gap, 6px);
|
||||
}
|
||||
|
||||
@@ -427,3 +428,13 @@
|
||||
.modalAction {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
/* Local surfaces do not contribute flex spacing or selected message text. */
|
||||
.column > .textInteraction {
|
||||
position: absolute;
|
||||
/* Remain below the sticky composer while scroll placement catches up. */
|
||||
z-index: 6;
|
||||
visibility: hidden;
|
||||
margin: 0;
|
||||
width: max-content;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type { ChatFlowHookContext, ChatViewSlotProps, OpenFileOptions } from '..
|
||||
import type { ChatSnapshot } from '../contract/snapshot.ts'
|
||||
import { TurnNavigator } from './TurnNavigator.tsx'
|
||||
import { mergeTurnRailItems } from './turn-rail-items.ts'
|
||||
import { useTextInteraction } from './text-interaction.ts'
|
||||
import { useChatScroll } from './use-chat-scroll.ts'
|
||||
import { fileMediaUrl, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import css from './ChatView.module.css'
|
||||
@@ -60,7 +61,7 @@ const MOTION_TAIL_MS = 800
|
||||
export function ChatView({
|
||||
useSession, useChat, useConversation, useSessions, renderSlot,
|
||||
sessionId, openFile, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions,
|
||||
usePresentation, useProjection, t,
|
||||
usePresentation, useProjection, useInteractionIds, dispatchInteraction, t,
|
||||
}: ChatViewSlotProps) {
|
||||
const order = useChat(s => s.order)
|
||||
const groupedEntries = useConversation(snapshot => snapshot.views.grouped('chat')?.entries)
|
||||
@@ -192,6 +193,9 @@ export function ChatView({
|
||||
loadedTurns: turnNavigationItems,
|
||||
transcriptView,
|
||||
})
|
||||
const interactionSurfaceRef = useRef<HTMLDivElement>(null)
|
||||
const interactionIds = useInteractionIds(ids => ids)
|
||||
const textInteraction = useTextInteraction(scroll.columnRef, interactionSurfaceRef, dispatchInteraction, interactionIds)
|
||||
const flowContext = useMemo<ChatFlowHookContext>(() => ({ motion: scroll.motion }), [scroll.motion])
|
||||
|
||||
return (
|
||||
@@ -229,6 +233,13 @@ export function ChatView({
|
||||
cwd, openFile: requestOpenFile, inspectCall, forkAt, loadImage, fileMentions,
|
||||
}, { hookContext: flowContext })}
|
||||
</MarkdownDelegateProvider>
|
||||
<div ref={interactionSurfaceRef} className={css.textInteraction} data-text-interaction-surface=""
|
||||
aria-hidden={textInteraction.active === null}>
|
||||
{textInteraction.active !== null && renderSlot('conversation.chat.text-interaction', {
|
||||
trigger: textInteraction.active.trigger, readText: textInteraction.readText,
|
||||
dismiss: textInteraction.dismiss, clearSelection: textInteraction.clearSelection,
|
||||
}, { entryKey: textInteraction.active.id })}
|
||||
</div>
|
||||
{/* No pending placeholders: questions (ui-user-questions) and approvals
|
||||
(ApprovalPanel) both take over the composer, so a flow card would
|
||||
double-render the same wait. */}
|
||||
|
||||
@@ -45,9 +45,12 @@ function latestCompletedParagraphFirstLine(text: string): string {
|
||||
* @param props.t - conversation locale seat for the disclosure and running status.
|
||||
* @returns the reasoning disclosure.
|
||||
*/
|
||||
export const ReasoningRow = memo(function ReasoningRow({ text, running, usePresentation, useDisclosure, renderSlot, t }: {
|
||||
export const ReasoningRow = memo(function ReasoningRow({
|
||||
text, running, textInteractionSource = false, usePresentation, useDisclosure, renderSlot, t,
|
||||
}: {
|
||||
text: string
|
||||
running: boolean
|
||||
textInteractionSource?: boolean
|
||||
useDisclosure: UseDisclosure
|
||||
usePresentation: UsePresentation
|
||||
t: ChatViewSlotProps['t']
|
||||
@@ -68,10 +71,10 @@ export const ReasoningRow = memo(function ReasoningRow({ text, running, usePrese
|
||||
</>
|
||||
), [running, summary])
|
||||
const content = useMemo(() => expanded ? (
|
||||
<div className={css.thinkBody}>
|
||||
<div className={css.thinkBody} data-text-interaction-source={textInteractionSource && !running ? '' : undefined}>
|
||||
{renderSlot('conversation.chat.reasoning.body', { text, running })}
|
||||
</div>
|
||||
) : undefined, [expanded, renderSlot, running, text])
|
||||
) : undefined, [expanded, renderSlot, running, text, textInteractionSource])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -44,7 +44,7 @@ FactoryComponentPropsOf<'conversation.chat.user.content'>) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showBubble && <div className={css.bubble}>
|
||||
{showBubble && <div className={css.bubble} data-message-bubble="" data-text-interaction-source="">
|
||||
{renderFactorySlot('conversation.user.text', { text, facts, interactive, ellipsis })}
|
||||
{extraBlocks.map((block, index) => (
|
||||
<JsonBlock key={index} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/** Area-local text gestures, action ownership, dismissal, and content-relative placement. */
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
import { readTextSelection, selectedText } from './text-selection.ts'
|
||||
import type { TextSelection } from './text-selection.ts'
|
||||
|
||||
import type {
|
||||
TextInteractionGesture, TextInteractionSurface, TextInteractionTrigger,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
/** Current area-owned surface and its actions. */
|
||||
export interface TextInteractionBinding extends Omit<TextInteractionSurface, 'trigger'> {
|
||||
readonly active: { readonly id: string; readonly trigger: TextInteractionTrigger } | null
|
||||
}
|
||||
|
||||
interface Target {
|
||||
readonly source: HTMLElement
|
||||
readonly selection: TextSelection | null
|
||||
readonly text: string | null
|
||||
readonly point: { readonly x: number; readonly y: number } | null
|
||||
}
|
||||
|
||||
interface Bounds { left: number; top: number; right: number; bottom: number }
|
||||
|
||||
function rectBounds(rect: DOMRect): Bounds {
|
||||
return { left: rect.left, top: rect.top, right: rect.left + rect.width, bottom: rect.top + rect.height }
|
||||
}
|
||||
|
||||
function intersects(a: Bounds, b: Bounds): boolean {
|
||||
return a.right > b.left && a.left < b.right && a.bottom > b.top && a.top < b.bottom
|
||||
}
|
||||
|
||||
function sameRange(a: Range | null, b: Range | null): boolean {
|
||||
return a === b || (a !== null && b !== null && a.startContainer === b.startContainer
|
||||
&& a.startOffset === b.startOffset && a.endContainer === b.endContainer && a.endOffset === b.endOffset)
|
||||
}
|
||||
|
||||
function available(root: HTMLElement, target: Target): boolean {
|
||||
if (!root.isConnected || !root.contains(target.source) || !target.source.hasAttribute('data-text-interaction-source')) return false
|
||||
const view = root.ownerDocument.defaultView
|
||||
/* v8 ignore if -- Mounted browser content always has an owner window. */
|
||||
if (view === null) return false
|
||||
for (let node: HTMLElement | null = target.source; node !== null; node = node.parentElement) {
|
||||
const style = view.getComputedStyle(node)
|
||||
if (node.hidden || node.inert || node.hasAttribute('data-text-interaction-exclude')
|
||||
|| style.display === 'none' || style.visibility === 'hidden') return false
|
||||
}
|
||||
return target.selection === null || (target.source.contains(target.selection.range.startContainer)
|
||||
&& target.source.contains(target.selection.range.endContainer) && selectedText(target.selection) === target.text)
|
||||
}
|
||||
|
||||
function composerSeat(root: HTMLElement): HTMLElement | null {
|
||||
return root.closest('[data-conversation-scroll]')?.querySelector<HTMLElement>('[data-composer-seat]') ?? null
|
||||
}
|
||||
|
||||
function visibleBounds(root: HTMLElement, source: HTMLElement, view: Window): Bounds {
|
||||
const rootRect = rectBounds(root.getBoundingClientRect())
|
||||
const bounds: Bounds = {
|
||||
left: Math.max(0, rootRect.left), right: Math.min(view.innerWidth, rootRect.right), top: 0, bottom: view.innerHeight,
|
||||
}
|
||||
for (let node: HTMLElement | null = source; node !== null; node = node.parentElement) {
|
||||
const style = view.getComputedStyle(node)
|
||||
const rect = rectBounds(node.getBoundingClientRect())
|
||||
if (/(auto|scroll|hidden|clip)/u.test(style.overflowX)) {
|
||||
bounds.left = Math.max(bounds.left, rect.left)
|
||||
bounds.right = Math.min(bounds.right, rect.right)
|
||||
}
|
||||
if (/(auto|scroll|hidden|clip)/u.test(style.overflowY)) {
|
||||
bounds.top = Math.max(bounds.top, rect.top)
|
||||
bounds.bottom = Math.min(bounds.bottom, rect.bottom)
|
||||
}
|
||||
}
|
||||
const composer = composerSeat(root)?.getBoundingClientRect()
|
||||
if (composer !== undefined && composer.height > 0
|
||||
&& composer.right > bounds.left && composer.left < bounds.right) {
|
||||
bounds.bottom = Math.min(bounds.bottom, composer.top)
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
/**
|
||||
* Own text interactions for one positioned content area and its local surface.
|
||||
* @param rootRef - existing content element with position: relative.
|
||||
* @param surfaceRef - absolute surface inside that element; only the selected Slot consumer is mounted.
|
||||
* @param dispatchInteraction - framework-injected plugin gesture dispatch.
|
||||
* @param registeredIds - live registered owners used to dismiss removed plugins.
|
||||
* @returns current owner and area-local selection actions.
|
||||
*/
|
||||
export function useTextInteraction(
|
||||
rootRef: RefObject<HTMLElement>, surfaceRef: RefObject<HTMLElement>,
|
||||
dispatchInteraction: (gesture: TextInteractionGesture) => string | null,
|
||||
registeredIds: readonly string[],
|
||||
): TextInteractionBinding {
|
||||
const target = useRef<Target | null>(null)
|
||||
const [active, setActive] = useState<TextInteractionBinding['active']>(null)
|
||||
const dismiss = useCallback(() => {
|
||||
target.current = null
|
||||
if (surfaceRef.current !== null) surfaceRef.current.style.visibility = 'hidden'
|
||||
setActive(null)
|
||||
}, [surfaceRef])
|
||||
useEffect(dismiss, [registeredIds, dispatchInteraction, dismiss])
|
||||
const readText = useCallback(() => {
|
||||
const root = rootRef.current
|
||||
const current = target.current
|
||||
if (root === null || current === null || !available(root, current)) return null
|
||||
return current.selection === null ? null : selectedText(current.selection)
|
||||
}, [rootRef])
|
||||
const clearSelection = useCallback(() => { rootRef.current?.ownerDocument.getSelection()?.removeAllRanges() }, [rootRef])
|
||||
|
||||
const place = useCallback(() => {
|
||||
const root = rootRef.current
|
||||
const surface = surfaceRef.current
|
||||
const current = target.current
|
||||
if (root === null || surface === null || current === null) return
|
||||
if (!available(root, current)) { dismiss(); return }
|
||||
const view = root.ownerDocument.defaultView
|
||||
/* v8 ignore if -- Mounted browser content always has an owner window. */
|
||||
if (view === null) return
|
||||
const visible = visibleBounds(root, current.source, view)
|
||||
const selectionRects = current.selection === null ? [] : [...current.selection.range.getClientRects()]
|
||||
const fallback = current.selection?.range.getBoundingClientRect() ?? current.source.getBoundingClientRect()
|
||||
const rects = selectionRects.length === 0 ? [fallback] : selectionRects
|
||||
const sourceRect = current.source.getBoundingClientRect()
|
||||
const pointX = current.point === null ? 0 : sourceRect.left + current.point.x - current.source.scrollLeft
|
||||
const pointY = current.point === null ? 0 : sourceRect.top + current.point.y - current.source.scrollTop
|
||||
const anchor = current.point === null
|
||||
? rects.map(rectBounds).find(rect => intersects(rect, visible))
|
||||
: { left: pointX, right: pointX + 1, top: pointY, bottom: pointY + 1 }
|
||||
if (anchor === undefined || !intersects(anchor, visible) || visible.right <= visible.left || visible.bottom <= visible.top) {
|
||||
surface.style.visibility = 'hidden'
|
||||
return
|
||||
}
|
||||
const gap = 8
|
||||
surface.style.maxWidth = `${visible.right - visible.left}px`
|
||||
surface.style.maxHeight = `${visible.bottom - visible.top}px`
|
||||
const size = surface.getBoundingClientRect()
|
||||
const x = current.point === null
|
||||
? (Math.max(anchor.left, visible.left) + Math.min(anchor.right, visible.right) - size.width) / 2
|
||||
: anchor.left
|
||||
const above = anchor.top - gap - size.height
|
||||
const below = anchor.bottom + gap
|
||||
const preferredY = current.point === null && above >= visible.top ? above : below
|
||||
const left = Math.max(visible.left, Math.min(x, visible.right - size.width))
|
||||
const top = Math.max(visible.top, Math.min(preferredY, visible.bottom - size.height))
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const scaleX = root.offsetWidth === 0 ? 1 : rootRect.width / root.offsetWidth
|
||||
const scaleY = root.offsetHeight === 0 ? 1 : rootRect.height / root.offsetHeight
|
||||
surface.style.left = `${(left - rootRect.left) / scaleX - root.clientLeft + root.scrollLeft}px`
|
||||
surface.style.top = `${(top - rootRect.top) / scaleY - root.clientTop + root.scrollTop}px`
|
||||
surface.style.visibility = 'visible'
|
||||
}, [dismiss, rootRef, surfaceRef])
|
||||
|
||||
// The surface's measured dimensions are needed after its Slot owner renders, before paint.
|
||||
useLayoutEffect(place, [active, place])
|
||||
useEffect(() => {
|
||||
const root = rootRef.current
|
||||
const surface = surfaceRef.current
|
||||
if (root === null || surface === null) return
|
||||
const document = root.ownerDocument
|
||||
const view = document.defaultView
|
||||
/* v8 ignore if -- Mounted browser content always has an owner window. */
|
||||
if (view === null) return
|
||||
let pointerSelecting = false
|
||||
let keyboardSelecting = false
|
||||
let selectionChangedDuringGesture = false
|
||||
let previousRange: Range | null = null
|
||||
let frame: number | undefined
|
||||
const schedulePlacement = (): void => {
|
||||
if (target.current === null || frame !== undefined) return
|
||||
frame = view.requestAnimationFrame(() => { frame = undefined; place() })
|
||||
}
|
||||
const dispatch = (trigger: TextInteractionTrigger, next: Target, event: Event): void => {
|
||||
if (!available(root, next)) return
|
||||
const id = dispatchInteraction({
|
||||
trigger, hasSelection: next.selection !== null,
|
||||
readText: () => next.text,
|
||||
preventDefault: () => { event.preventDefault() },
|
||||
})
|
||||
if (id === null) return
|
||||
target.current = next
|
||||
setActive({ id, trigger })
|
||||
}
|
||||
const updateSelection = (event: Event): void => {
|
||||
if (pointerSelecting || keyboardSelecting) { selectionChangedDuringGesture = true; return }
|
||||
if (target.current !== null && target.current.point !== null) return
|
||||
if (target.current !== null && surface.contains(document.activeElement)) return
|
||||
const selection = readTextSelection(root, document.getSelection())
|
||||
const range = selection?.range ?? null
|
||||
if (!selectionChangedDuringGesture && sameRange(previousRange, range)) return
|
||||
selectionChangedDuringGesture = false
|
||||
previousRange = range?.cloneRange() ?? null
|
||||
dismiss()
|
||||
if (selection === null) return
|
||||
const text = selectedText(selection)
|
||||
if (text === null) return
|
||||
dispatch('selection', { source: selection.source, selection: { ...selection, range: selection.range.cloneRange() }, text, point: null }, event)
|
||||
}
|
||||
const insideSurface = (event: Event): boolean => event.composedPath().includes(surface)
|
||||
const pointerDown = (event: PointerEvent): void => {
|
||||
if (insideSurface(event)) return
|
||||
const selection = document.getSelection()
|
||||
previousRange = selection?.rangeCount === 1 ? selection.getRangeAt(0).cloneRange() : null
|
||||
dismiss()
|
||||
selectionChangedDuringGesture = false
|
||||
pointerSelecting = event.button === 0 && event.target instanceof Node && root.contains(event.target)
|
||||
}
|
||||
const pointerUp = (event: PointerEvent): void => {
|
||||
if (!pointerSelecting) return
|
||||
pointerSelecting = false
|
||||
updateSelection(event)
|
||||
}
|
||||
const cancel = (): void => { pointerSelecting = false; keyboardSelecting = false; dismiss() }
|
||||
const keyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') { cancel(); return }
|
||||
if (insideSurface(event) || !(event.target instanceof Node) || !root.contains(event.target)) return
|
||||
if (event.key === 'Shift' || event.shiftKey) { keyboardSelecting = true; dismiss() }
|
||||
}
|
||||
const keyUp = (event: KeyboardEvent): void => {
|
||||
if (!keyboardSelecting || (event.key !== 'Shift' && event.shiftKey)) return
|
||||
keyboardSelecting = false
|
||||
updateSelection(event)
|
||||
}
|
||||
const contextMenu = (event: MouseEvent): void => {
|
||||
if (insideSurface(event)) return
|
||||
const element = event.target instanceof Element ? event.target : null
|
||||
const source = element?.closest<HTMLElement>('[data-text-interaction-source]')
|
||||
if (source === undefined || source === null || !root.contains(source)
|
||||
|| element?.closest('[data-text-interaction-exclude], button, input, textarea, [contenteditable="true"]')) return
|
||||
pointerSelecting = false
|
||||
dismiss()
|
||||
const selected = readTextSelection(root, document.getSelection())
|
||||
const keyboard = event.clientX === 0 && event.clientY === 0
|
||||
const inSelection = selected?.source === source && (keyboard || [...selected.range.getClientRects()].some(rect =>
|
||||
event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom))
|
||||
const selection = inSelection ? selected : null
|
||||
const bounds = source.getBoundingClientRect()
|
||||
dispatch('contextmenu', {
|
||||
source, selection: selection === null ? null : { ...selection, range: selection.range.cloneRange() },
|
||||
text: selection === null ? null : selectedText(selection),
|
||||
point: {
|
||||
x: (keyboard ? 0 : event.clientX - bounds.left) + source.scrollLeft,
|
||||
y: (keyboard ? 0 : event.clientY - bounds.top) + source.scrollTop,
|
||||
},
|
||||
}, event)
|
||||
}
|
||||
document.addEventListener('selectionchange', updateSelection)
|
||||
document.addEventListener('pointerdown', pointerDown, true)
|
||||
document.addEventListener('pointerup', pointerUp)
|
||||
document.addEventListener('pointercancel', cancel)
|
||||
document.addEventListener('keydown', keyDown)
|
||||
document.addEventListener('keyup', keyUp)
|
||||
document.addEventListener('scroll', schedulePlacement, true)
|
||||
root.addEventListener('contextmenu', contextMenu)
|
||||
view.addEventListener('resize', schedulePlacement)
|
||||
view.addEventListener('blur', cancel)
|
||||
return () => {
|
||||
document.removeEventListener('selectionchange', updateSelection)
|
||||
document.removeEventListener('pointerdown', pointerDown, true)
|
||||
document.removeEventListener('pointerup', pointerUp)
|
||||
document.removeEventListener('pointercancel', cancel)
|
||||
document.removeEventListener('keydown', keyDown)
|
||||
document.removeEventListener('keyup', keyUp)
|
||||
document.removeEventListener('scroll', schedulePlacement, true)
|
||||
root.removeEventListener('contextmenu', contextMenu)
|
||||
view.removeEventListener('resize', schedulePlacement)
|
||||
view.removeEventListener('blur', cancel)
|
||||
if (frame !== undefined) view.cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [dismiss, place, rootRef, surfaceRef, dispatchInteraction])
|
||||
useEffect(() => {
|
||||
const root = rootRef.current
|
||||
const surface = surfaceRef.current
|
||||
if (active === null || root === null || surface === null) return
|
||||
const view = root.ownerDocument.defaultView
|
||||
/* v8 ignore if -- Mounted browser content always has an owner window. */
|
||||
if (view === null) return
|
||||
let frame: number | undefined
|
||||
const schedule = (): void => {
|
||||
if (frame !== undefined) return
|
||||
frame = view.requestAnimationFrame(() => { frame = undefined; place() })
|
||||
}
|
||||
const resize = new ResizeObserver(schedule)
|
||||
resize.observe(root)
|
||||
resize.observe(surface)
|
||||
const composer = composerSeat(root)
|
||||
if (composer !== null) resize.observe(composer)
|
||||
const mutations = new MutationObserver((records) => {
|
||||
if (records.some(record => !surface.contains(record.target))) schedule()
|
||||
})
|
||||
mutations.observe(root, { childList: true, subtree: true, characterData: true, attributes: true })
|
||||
return () => {
|
||||
resize.disconnect()
|
||||
mutations.disconnect()
|
||||
if (frame !== undefined) view.cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [active, place, rootRef, surfaceRef])
|
||||
return { active, dismiss, readText, clearSelection }
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/** DOM selection validation and visible-text projection, independent of business actions. */
|
||||
|
||||
const PROJECTED_BLOCK_TAGS = new Set([
|
||||
'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'DD', 'DIV', 'DL', 'DT', 'FIGCAPTION',
|
||||
'FIGURE', 'FOOTER', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'LI', 'MAIN',
|
||||
'NAV', 'OL', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'TFOOT', 'THEAD', 'TR', 'UL',
|
||||
])
|
||||
const PROJECTED_CELL_TAGS = new Set(['TD', 'TH'])
|
||||
|
||||
function excluded(element: Element): boolean {
|
||||
if (element.matches('.katex-mathml, [data-text-interaction-exclude], [hidden], .sr-only')) return true
|
||||
if (element.matches('[aria-hidden="true"]') && !element.matches('.katex-html')) return true
|
||||
return element.tagName === 'BUTTON' && element.parentElement?.tagName !== 'CODE'
|
||||
}
|
||||
|
||||
function projectedBoundary(node: Node): '\n' | '\t' | '' {
|
||||
if (!(node instanceof Element)) return ''
|
||||
if (PROJECTED_CELL_TAGS.has(node.tagName)) return '\t'
|
||||
return PROJECTED_BLOCK_TAGS.has(node.tagName) ? '\n' : ''
|
||||
}
|
||||
|
||||
function projectedSeparator(previous: Node, current: Node): '\n' | '\t' | '' {
|
||||
const left = projectedBoundary(previous)
|
||||
const right = projectedBoundary(current)
|
||||
if (left === '\n' || right === '\n') return '\n'
|
||||
return left === '\t' || right === '\t' ? '\t' : ''
|
||||
}
|
||||
|
||||
interface ProjectedText {
|
||||
readonly text: string
|
||||
readonly offsets: readonly number[]
|
||||
}
|
||||
|
||||
function projectedText(node: Text): ProjectedText {
|
||||
const source = node.data
|
||||
const parent = node.parentElement
|
||||
const view = node.ownerDocument.defaultView
|
||||
const whiteSpace = parent === null || view === null ? '' : view.getComputedStyle(parent).whiteSpace
|
||||
const preservesBreaks = whiteSpace === 'pre' || whiteSpace === 'pre-wrap'
|
||||
|| whiteSpace === 'pre-line' || whiteSpace === 'break-spaces'
|
||||
const offsets = new Array<number>(source.length + 1).fill(0)
|
||||
const structuralWhitespace = !preservesBreaks
|
||||
&& /^[\t\n\f\r ]*$/u.test(source)
|
||||
&& [node.previousSibling, node.nextSibling].some(
|
||||
sibling => sibling !== null && projectedBoundary(sibling) !== '',
|
||||
)
|
||||
if (structuralWhitespace) return { text: '', offsets }
|
||||
let text = ''
|
||||
let cursor = 0
|
||||
if (!preservesBreaks && node.previousSibling instanceof HTMLElement
|
||||
&& node.previousSibling.tagName === 'BR' && source.startsWith('\n')) {
|
||||
cursor = 1
|
||||
}
|
||||
while (cursor < source.length) {
|
||||
const softBreak = preservesBreaks ? null : /^[ \t]*[\r\n]+[ \t]*/u.exec(source.slice(cursor))
|
||||
if (softBreak !== null) {
|
||||
const end = cursor + softBreak[0].length
|
||||
for (let index = cursor; index < end; index++) offsets[index] = text.length
|
||||
text += ' '
|
||||
offsets[end] = text.length
|
||||
cursor = end
|
||||
continue
|
||||
}
|
||||
offsets[cursor] = text.length
|
||||
text += source.charAt(cursor)
|
||||
cursor += 1
|
||||
offsets[cursor] = text.length
|
||||
}
|
||||
return { text, offsets }
|
||||
}
|
||||
|
||||
interface ProjectedTextSpan {
|
||||
readonly node: Text
|
||||
readonly start: number
|
||||
readonly offsets: readonly number[]
|
||||
}
|
||||
|
||||
interface ProjectedFragment {
|
||||
readonly text: string
|
||||
readonly spans: readonly ProjectedTextSpan[]
|
||||
readonly boundaries: readonly (readonly [Node, readonly number[]])[]
|
||||
}
|
||||
|
||||
function projectedFragment(node: Node): ProjectedFragment {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const projected = projectedText(node as Text)
|
||||
return {
|
||||
text: projected.text,
|
||||
spans: projected.offsets.length <= 1
|
||||
? []
|
||||
: [{ node: node as Text, start: 0, offsets: projected.offsets }],
|
||||
boundaries: [],
|
||||
}
|
||||
}
|
||||
if (!(node instanceof Element)) {
|
||||
return { text: '', spans: [], boundaries: [] }
|
||||
}
|
||||
if (excluded(node)) return { text: '', spans: [], boundaries: [] }
|
||||
if (node.tagName === 'BR') return { text: '\n', spans: [], boundaries: [] }
|
||||
let text = ''
|
||||
let previous: Node | null = null
|
||||
const spans: ProjectedTextSpan[] = []
|
||||
const boundaries: Array<readonly [Node, readonly number[]]> = []
|
||||
const offsets = new Array<number>(node.childNodes.length + 1).fill(0)
|
||||
for (const [index, child] of [...node.childNodes].entries()) {
|
||||
const fragment = projectedFragment(child)
|
||||
if (fragment.text !== '' && previous !== null) {
|
||||
const separator = projectedSeparator(previous, child)
|
||||
const needsSeparator = separator === '\n'
|
||||
? !text.endsWith('\n') && !fragment.text.startsWith('\n')
|
||||
: separator === '\t' && !/[\t\n]$/u.test(text) && !/^[\t\n]/u.test(fragment.text)
|
||||
if (needsSeparator) text += separator
|
||||
}
|
||||
offsets[index] = text.length
|
||||
const base = text.length
|
||||
text += fragment.text
|
||||
for (const span of fragment.spans) {
|
||||
spans.push({ node: span.node, start: base + span.start, offsets: span.offsets })
|
||||
}
|
||||
for (const [owner, childOffsets] of fragment.boundaries) {
|
||||
boundaries.push([owner, childOffsets.map(offset => base + offset)])
|
||||
}
|
||||
if (fragment.text !== '') previous = child
|
||||
offsets[index + 1] = text.length
|
||||
}
|
||||
boundaries.push([node, offsets])
|
||||
return { text, spans, boundaries }
|
||||
}
|
||||
|
||||
function projectedOffset(
|
||||
projection: ProjectedFragment,
|
||||
container: Node,
|
||||
offset: number,
|
||||
): number | null {
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
const span = projection.spans.find(candidate => candidate.node === container)
|
||||
const projected = span?.offsets[offset]
|
||||
return span === undefined || projected === undefined ? null : span.start + projected
|
||||
}
|
||||
const boundaries = projection.boundaries.find(([owner]) => owner === container)?.[1]
|
||||
return boundaries?.[offset] ?? null
|
||||
}
|
||||
|
||||
/** One browser range contained by an enabled text source. */
|
||||
export interface TextSelection {
|
||||
readonly source: HTMLElement
|
||||
readonly range: Range
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single noncollapsed selection inside one enabled source.
|
||||
* @param root - mounted interaction area.
|
||||
* @param selection - browser selection in that area's document.
|
||||
* @returns the selected source and range, or null across sources and excluded content.
|
||||
*/
|
||||
export function readTextSelection(root: HTMLElement, selection: Selection | null): TextSelection | null {
|
||||
if (selection === null || selection.rangeCount !== 1 || selection.isCollapsed) return null
|
||||
const range = selection.getRangeAt(0)
|
||||
const sourceOf = (node: Node): HTMLElement | null =>
|
||||
(node instanceof Element ? node : node.parentElement)?.closest<HTMLElement>('[data-text-interaction-source]') ?? null
|
||||
const source = sourceOf(range.startContainer)
|
||||
if (source === null || source !== sourceOf(range.endContainer) || !root.contains(source)
|
||||
|| source.closest('[hidden], [inert], [data-text-interaction-exclude]') !== null) return null
|
||||
return { source, range }
|
||||
}
|
||||
|
||||
/**
|
||||
* Project selected DOM text with visible block boundaries and without renderer chrome.
|
||||
* @param selection - range and its source, obtained through readTextSelection.
|
||||
* @returns trimmed visible text; empty or excluded selections return null.
|
||||
*/
|
||||
export function selectedText(selection: TextSelection): string | null {
|
||||
const { source, range } = selection
|
||||
const projection = projectedFragment(source)
|
||||
const start = projectedOffset(projection, range.startContainer, range.startOffset)
|
||||
const end = projectedOffset(projection, range.endContainer, range.endOffset)
|
||||
if (start === null || end === null) return null
|
||||
const text = projection.text.slice(start, end).trim()
|
||||
return text === '' ? null : text
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
|
||||
import type {
|
||||
CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap,
|
||||
TextInteractionGesture, TextInteractionSurface,
|
||||
ConversationGroupData, GroupSnapshot,
|
||||
MessageImageLoader, MessageImageSource, MessageImagesOwnerProps, RenderEntry, TurnLocation, UserTextFacts, UserTextInput,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -297,7 +298,11 @@ export interface PerformanceUsageInjected {
|
||||
|
||||
/** Business callbacks injected into the Chat view. */
|
||||
export interface ChatViewInjected {
|
||||
/** Dispatch one gesture to the Conversation plugin registry. */
|
||||
dispatchInteraction: (gesture: TextInteractionGesture) => string | null
|
||||
hooks: {
|
||||
/** Live plugin IDs; removed plugins dismiss their active surface. */
|
||||
interactionIds: ObservableSnapshot<readonly string[]>
|
||||
/** Live presentation policy derived from the accepted work-details mode. */
|
||||
presentation: ObservableSnapshot<ChatPresentationPolicy>
|
||||
}
|
||||
@@ -328,7 +333,7 @@ export interface ChatViewInjected {
|
||||
|
||||
/** Session-owned node sources shared with the view, plus the flow's connection state. */
|
||||
export type ChatFlowDataInjected = Pick<ChatViewInjected, 'keyedHooks'> & {
|
||||
hooks: ChatViewInjected['hooks'] & {
|
||||
hooks: Pick<ChatViewInjected['hooks'], 'presentation'> & {
|
||||
/** Client connection lifecycle; the running clock pauses while it is not connected. */
|
||||
connectionState: ConnectionStateSource
|
||||
}
|
||||
@@ -355,7 +360,7 @@ export type ChatFlowSlotProps =
|
||||
/** Full Chat view props. */
|
||||
export type ChatViewSlotProps =
|
||||
PropsRuntime<'conversation.view'>
|
||||
& PropsRenderSlots<'conversation.chat.flow'>
|
||||
& PropsRenderSlots<'conversation.chat.flow' | 'conversation.chat.text-interaction'>
|
||||
& PropsStore<ChatStore>
|
||||
& InjectFace<ChatViewInjected>
|
||||
& PropsLocale<'chat'>
|
||||
@@ -392,6 +397,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
|
||||
interface SlotMap {
|
||||
/** Area-local text gestures and surfaces; each consumer registers its own synchronous admission. */
|
||||
'conversation.chat.text-interaction': { kind: 'keyed'; scope: 'session'; owner: TextInteractionSurface }
|
||||
|
||||
/** Ordered Chat rows and local echoes with viewport-bound visibility hooks. */
|
||||
'conversation.chat.flow': {
|
||||
kind: 'single'
|
||||
|
||||
@@ -126,6 +126,7 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
|
||||
}
|
||||
: {
|
||||
kind: 'user',
|
||||
messageId: event.data.id,
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
content: event.data.content,
|
||||
|
||||
@@ -17,6 +17,14 @@
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
/* Narrow settings panel: the 48px gutter separates the label from its control
|
||||
across a wide row and only squeezes the label here. */
|
||||
@media (max-width: 767px) {
|
||||
:global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .rowText {
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
||||
@@ -138,6 +138,50 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true,
|
||||
}
|
||||
|
||||
describe('Chat inject API', () => {
|
||||
it('routes text gestures from each Chat view to the registered consumer with its own Session', async () => {
|
||||
const b = await bench()
|
||||
try {
|
||||
const secondId = await b.runtime.sessions.add({ id: 'second-chat' })
|
||||
using secondReference = b.runtime.sessions.retain(secondId)
|
||||
await secondReference.ready
|
||||
const first = b.chatViewApi(b.rootReference).injected
|
||||
const second = b.chatViewApi(secondReference).injected
|
||||
const received: { sessionId: SessionId; text: string | null }[] = []
|
||||
const dispose = b.runtime.ctx.uiConversation.interactions.register({
|
||||
id: 'selection-menu',
|
||||
onInteraction(request) {
|
||||
received.push({ sessionId: request.sessionId, text: request.readText() })
|
||||
if (request.trigger !== 'contextmenu') return
|
||||
request.preventDefault()
|
||||
request.open()
|
||||
},
|
||||
})
|
||||
expect(first.hooks.interactionIds.getSnapshot()).toEqual(['selection-menu'])
|
||||
expect(second.hooks.interactionIds.getSnapshot()).toEqual(['selection-menu'])
|
||||
const nativeMenu = vi.fn()
|
||||
expect(first.dispatchInteraction({
|
||||
trigger: 'selection', hasSelection: true, readText: () => 'first selection', preventDefault: nativeMenu,
|
||||
})).toBeNull()
|
||||
expect(nativeMenu).not.toHaveBeenCalled()
|
||||
const contextMenu = {
|
||||
trigger: 'contextmenu' as const, hasSelection: true,
|
||||
readText: () => 'second selection', preventDefault: nativeMenu,
|
||||
}
|
||||
expect(second.dispatchInteraction(contextMenu)).toBe('selection-menu')
|
||||
expect(nativeMenu).toHaveBeenCalledOnce()
|
||||
expect(received).toEqual([
|
||||
{ sessionId: ROOT, text: 'first selection' },
|
||||
{ sessionId: secondId, text: 'second selection' },
|
||||
])
|
||||
dispose()
|
||||
expect(first.hooks.interactionIds.getSnapshot()).toEqual([])
|
||||
expect(second.dispatchInteraction(contextMenu)).toBeNull()
|
||||
expect(nativeMenu).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await b.runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('projects independent Settings choices and saves timing to Host settings', async () => {
|
||||
const b = await bench()
|
||||
try {
|
||||
|
||||
@@ -161,8 +161,8 @@ describe('chat flow font-size axis', () => {
|
||||
const empty = declarationsFrom(css, emptySelector)
|
||||
expect(empty).toContain('height: 0')
|
||||
expect(empty).not.toContain('display: none')
|
||||
expect(css).toContain(`:is(.column, :global([data-slot="conversation.chat.flow"])) > :not([hidden]):not(${emptySelector})`)
|
||||
expect(css).toContain(`~ :not([hidden]):not(${emptySelector})`)
|
||||
expect(css).toContain(`:is(.column, :global([data-slot="conversation.chat.flow"])) > :not([hidden]):not(.textInteraction):not(${emptySelector})`)
|
||||
expect(css).toContain(`~ :not([hidden]):not(.textInteraction):not(${emptySelector})`)
|
||||
})
|
||||
|
||||
it('the interrupted-turn tag stays fixed like the dense token variants', () => {
|
||||
|
||||
@@ -260,7 +260,7 @@ function bindKeyedSnapshotSelector<Value>(
|
||||
}
|
||||
|
||||
declare const nodeDispatch: PropsRenderSlots<'conversation.chat.node'>['renderSlot']
|
||||
declare const flowDispatch: ChatViewSlotProps['renderSlot']
|
||||
declare const flowDispatch: PropsRenderSlots<'conversation.chat.flow'>['renderSlot']
|
||||
type NodeRenderer = typeof nodeDispatch<'conversation.chat.node'>
|
||||
type FlowRenderer = typeof flowDispatch<'conversation.chat.flow'>
|
||||
type ImageRenderer = (owner: MessageImagesOwnerProps) => React.ReactNode
|
||||
@@ -453,7 +453,8 @@ function makeHarness(
|
||||
if (name !== 'conversation.chat.user.content') throw new Error(`Unexpected Chat Factory: ${name}`)
|
||||
return renderUserContent(owner as UserContentInput, renderImages, props.t)
|
||||
}
|
||||
const renderSlot: FlowRenderer = (key, owner, opts) => {
|
||||
const renderSlot = (key: string, owner: ChatFlowOwnerProps, opts: Parameters<FlowRenderer>[2]) => {
|
||||
if (key === 'conversation.chat.text-interaction') return null
|
||||
if (key !== 'conversation.chat.flow') throw new Error(`Undeclared ChatView child: ${key}`)
|
||||
if ('renderSlot' in owner || 'useStore' in owner || 'actions' in owner) {
|
||||
throw new Error('Flow owner data must not replace its rendering or store authority')
|
||||
@@ -492,6 +493,7 @@ function makeHarness(
|
||||
},
|
||||
useInput: (() => { throw new Error('unused') }),
|
||||
inputActions: {
|
||||
insertQuote: () => false,
|
||||
captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }),
|
||||
insertText: () => false,
|
||||
setDraft: () => {},
|
||||
@@ -503,6 +505,8 @@ function makeHarness(
|
||||
},
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
useInteractionIds: bindSnapshotSelector(createSnapshotStore<readonly string[]>([])),
|
||||
dispatchInteraction: () => null,
|
||||
usePresentation: bindSnapshotSelector(derivePresentationPolicy(transcriptView, collapseTiming, foldedReplies)),
|
||||
// The fixture implements the concrete flow dispatch behind the generic slot signature.
|
||||
renderSlot: renderSlot as ChatViewSlotProps['renderSlot'],
|
||||
@@ -4575,7 +4579,7 @@ describe('ChatView', () => {
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const column = view.container.querySelector('[data-chat-flow]')!
|
||||
const status = column.querySelector('[data-chat-running]')
|
||||
expect(column.lastElementChild).toBe(status)
|
||||
expect([...column.children].filter(child => !child.hasAttribute('data-text-interaction-surface')).at(-1)).toBe(status)
|
||||
expect(within(status as HTMLElement).getByRole('status').textContent).toBe('深度求索中')
|
||||
expect(status?.lastElementChild?.textContent).toBe('深度求索中')
|
||||
const icon = status?.querySelector('svg')?.parentElement
|
||||
|
||||
@@ -16,6 +16,26 @@ const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('tails', () => {
|
||||
it('enables quoting on the existing Markdown root without replacing streamed content', () => {
|
||||
const body = (textInteractionSource: boolean, streaming: boolean) => (
|
||||
<AssistantMarkdown renderSlot={() => null} useDisclosure={useDisclosure} useGroupAction={useSearchableHidden}
|
||||
usePresentation={useDetailedPresentation} t={t} loadImage={unusedImageLoader}
|
||||
blocks={[{ kind: 'text', text: 'source words' }]} textInteractionSource={textInteractionSource} streaming={streaming} />
|
||||
)
|
||||
const view = render(body(false, true))
|
||||
const paragraph = view.getByText('source words')
|
||||
const markdown = paragraph.parentElement
|
||||
expect(view.container.querySelector('[data-text-interaction-source]')).toBeNull()
|
||||
|
||||
view.rerender(body(true, false))
|
||||
expect(view.container.querySelector('[data-text-interaction-source]')).toBe(markdown)
|
||||
expect(view.getByText('source words')).toBe(paragraph)
|
||||
|
||||
view.rerender(body(false, false))
|
||||
expect(view.container.querySelector('[data-text-interaction-source]')).toBeNull()
|
||||
expect(view.getByText('source words').parentElement).toBe(markdown)
|
||||
})
|
||||
|
||||
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown renderSlot={() => null} useDisclosure={useDisclosure} useGroupAction={useSearchableHidden}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
// @vitest-environment jsdom
|
||||
import { createRef, useCallback, useMemo, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, renderHook } from '@testing-library/react'
|
||||
import { useTextInteraction } from '../src/client/chat/text-interaction.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { TextInteractionBinding } from '../src/client/chat/text-interaction.ts'
|
||||
import type { TextInteractionGesture, TextInteractionRequest } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const rect = (left: number, top: number, width: number, height: number): DOMRect => new DOMRect(left, top, width, height)
|
||||
let selectionRect = rect(100, 120, 40, 20)
|
||||
let resize: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
selectionRect = rect(100, 120, 40, 20)
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
constructor(callback: () => void) { resize = callback }
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
})
|
||||
Object.defineProperty(Range.prototype, 'getBoundingClientRect', { configurable: true, value: () => selectionRect })
|
||||
Object.defineProperty(Range.prototype, 'getClientRects', { configurable: true, value: () => Object.assign([selectionRect], { item: (i: number) => i === 0 ? selectionRect : null }) })
|
||||
vi.stubGlobal('PointerEvent', MouseEvent)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.surface !== undefined) return rect(0, 0, 80, 30)
|
||||
if (this.dataset.viewport !== undefined) return rect(20, 80, 400, 300)
|
||||
return rect(40, 100, 300, 500)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect')
|
||||
Reflect.deleteProperty(Range.prototype, 'getClientRects')
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function Area({ id = 'action', admit, onBinding, enabled = true, composer = false }: {
|
||||
enabled?: boolean
|
||||
composer?: boolean
|
||||
id?: string
|
||||
admit?: (request: TextInteractionRequest) => void
|
||||
onBinding?: (binding: TextInteractionBinding) => void
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null)
|
||||
const surface = useRef<HTMLDivElement>(null)
|
||||
const ids = useMemo(() => enabled ? [id] : [], [enabled, id])
|
||||
const dispatch = useCallback((gesture: TextInteractionGesture): string | null => {
|
||||
if (!enabled) return null
|
||||
let opened = false
|
||||
const request: TextInteractionRequest = { ...gesture, sessionId: SessionId(id), open: () => { opened = true } }
|
||||
if (admit === undefined) { if (request.trigger === 'selection') request.open() }
|
||||
else admit(request)
|
||||
return opened ? id : null
|
||||
}, [id, admit, enabled])
|
||||
const interaction = useTextInteraction(root, surface, dispatch, ids)
|
||||
onBinding?.(interaction)
|
||||
return <div data-viewport="" data-conversation-scroll="" style={{ overflowX: 'auto', overflowY: 'auto' }}>
|
||||
<div ref={root} data-area={id} style={{ position: 'relative' }}>
|
||||
<p data-text-interaction-source="">Hello {id}</p>
|
||||
<p data-text-interaction-source="">Other text</p>
|
||||
<div ref={surface} data-surface="" style={{ position: 'absolute', visibility: 'hidden' }}>
|
||||
{interaction.active?.id === id && <button onPointerDown={(event) => { event.preventDefault() }}
|
||||
onClick={interaction.dismiss}>{id}</button>}
|
||||
</div>
|
||||
</div>
|
||||
{composer && <div data-composer-seat="">Composer and dock</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function select(source: Element) {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(source)
|
||||
const selection = window.getSelection()!
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
fireEvent(document, new Event('selectionchange'))
|
||||
return selection
|
||||
}
|
||||
|
||||
async function layout() {
|
||||
await act(async () => { resize(); await new Promise(resolve => requestAnimationFrame(resolve)) })
|
||||
}
|
||||
|
||||
describe('area text interactions', () => {
|
||||
it('waits for pointer and keyboard selection to finish', () => {
|
||||
const view = render(<Area />)
|
||||
const source = view.container.querySelector('[data-text-interaction-source]')!
|
||||
fireEvent.pointerDown(source, { button: 0 })
|
||||
select(source)
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
fireEvent.pointerUp(document)
|
||||
expect(view.getByRole('button', { name: 'action' })).toBeTruthy()
|
||||
fireEvent.keyDown(source, { key: 'Shift', shiftKey: true })
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
const range = window.getSelection()!.getRangeAt(0)
|
||||
range.setEnd(source.firstChild!, 3)
|
||||
fireEvent(document, new Event('selectionchange'))
|
||||
fireEvent.keyUp(source, { key: 'Shift' })
|
||||
expect(view.getByRole('button')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('closes outside without clearing selection or reopening on scroll and resize', async () => {
|
||||
const view = render(<><Area /><button>Outside</button></>)
|
||||
const selection = select(view.container.querySelector('[data-text-interaction-source]')!)
|
||||
const action = view.getByRole('button', { name: 'action' })
|
||||
fireEvent.pointerDown(action, { button: 0 })
|
||||
expect(view.getByRole('button', { name: 'action' })).toBeTruthy()
|
||||
fireEvent.pointerDown(view.getByRole('button', { name: 'Outside' }), { button: 0 })
|
||||
expect(view.queryByRole('button', { name: 'action' })).toBeNull()
|
||||
expect(selection.toString()).toBe('Hello action')
|
||||
fireEvent.pointerUp(document)
|
||||
fireEvent(document, new Event('selectionchange'))
|
||||
fireEvent.scroll(view.container.querySelector('[data-viewport]')!)
|
||||
await layout()
|
||||
expect(view.queryByRole('button', { name: 'action' })).toBeNull()
|
||||
})
|
||||
|
||||
it('dismisses on Escape, cancellation and blur', () => {
|
||||
const view = render(<Area />)
|
||||
const sources = view.container.querySelectorAll('[data-text-interaction-source]')
|
||||
select(sources[0]!)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
select(sources[1]!)
|
||||
fireEvent.pointerCancel(document)
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
select(sources[0]!)
|
||||
fireEvent.blur(window)
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('places in content coordinates, flips below at the top and restores after scrolling offscreen', async () => {
|
||||
const view = render(<Area />)
|
||||
select(view.container.querySelector('[data-text-interaction-source]')!)
|
||||
const surface = view.container.querySelector<HTMLElement>('[data-surface]')!
|
||||
expect(surface.parentElement?.dataset.area).toBe('action')
|
||||
expect(surface.style.left).toBe('40px')
|
||||
expect(surface.style.top).toBe('-18px')
|
||||
selectionRect = rect(100, 90, 40, 20)
|
||||
await layout()
|
||||
expect(surface.style.top).toBe('18px')
|
||||
selectionRect = rect(100, 10, 40, 20)
|
||||
fireEvent.scroll(view.container.querySelector('[data-viewport]')!)
|
||||
await layout()
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
expect(surface.querySelector('button')).not.toBeNull()
|
||||
selectionRect = rect(100, 120, 40, 20)
|
||||
fireEvent.scroll(view.container.querySelector('[data-viewport]')!)
|
||||
await layout()
|
||||
expect(view.getByRole('button')).toBeTruthy()
|
||||
expect(surface.style.top).toBe('-18px')
|
||||
})
|
||||
|
||||
it('hides behind the composer and its dock, restores on resize, and keeps explicit dismissal final', async () => {
|
||||
const view = render(<Area composer />)
|
||||
const composer = view.container.querySelector<HTMLElement>('[data-composer-seat]')!
|
||||
let composerTop = 260
|
||||
composer.getBoundingClientRect = () => rect(20, composerTop, 400, 380 - composerTop)
|
||||
const source = view.container.querySelector('[data-text-interaction-source]')!
|
||||
const selection = select(source)
|
||||
const surface = view.container.querySelector<HTMLElement>('[data-surface]')!
|
||||
const action = surface.querySelector('button')!
|
||||
selectionRect = rect(100, 280, 40, 20)
|
||||
await layout()
|
||||
expect(surface.style.visibility).toBe('hidden')
|
||||
expect(surface.querySelector('button')).toBe(action)
|
||||
expect(selection.toString()).toBe('Hello action')
|
||||
composerTop = 330
|
||||
await layout()
|
||||
expect(view.getByRole('button')).toBe(action)
|
||||
composerTop = 260
|
||||
await layout()
|
||||
expect(surface.style.visibility).toBe('hidden')
|
||||
fireEvent.pointerDown(composer, { button: 0 })
|
||||
composerTop = 330
|
||||
await layout()
|
||||
expect(surface.querySelector('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('invalidates changed, disabled and removed sources', async () => {
|
||||
const view = render(<Area />)
|
||||
const sources = view.container.querySelectorAll<HTMLElement>('[data-text-interaction-source]')
|
||||
select(sources[0]!)
|
||||
sources[0]!.firstChild!.textContent = 'Changed text'
|
||||
await layout()
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
select(sources[1]!)
|
||||
sources[1]!.removeAttribute('data-text-interaction-source')
|
||||
await layout()
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
select(sources[0]!)
|
||||
sources[0]!.remove()
|
||||
await layout()
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('isolates areas and removes listeners on unmount', () => {
|
||||
const view = render(<><Area id="first" /><Area id="second" /></>)
|
||||
const areas = view.container.querySelectorAll('[data-area]')
|
||||
select(areas[0]!.querySelector('[data-text-interaction-source]')!)
|
||||
expect(view.getByRole('button', { name: 'first' })).toBeTruthy()
|
||||
expect(view.queryByRole('button', { name: 'second' })).toBeNull()
|
||||
select(areas[1]!.querySelector('[data-text-interaction-source]')!)
|
||||
expect(view.queryByRole('button', { name: 'first' })).toBeNull()
|
||||
expect(view.getByRole('button', { name: 'second' })).toBeTruthy()
|
||||
view.unmount()
|
||||
fireEvent(document, new Event('selectionchange'))
|
||||
expect(document.querySelector('[data-surface]')).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves contextmenu defaults to the registrant and never reuses an unrelated selection', () => {
|
||||
const admit = vi.fn((request: TextInteractionRequest) => {
|
||||
if (request.trigger === 'contextmenu') { request.preventDefault(); request.open() }
|
||||
})
|
||||
const view = render(<Area admit={admit} />)
|
||||
const sources = view.container.querySelectorAll('[data-text-interaction-source]')
|
||||
select(sources[0]!)
|
||||
const same = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 110, clientY: 130 })
|
||||
fireEvent(sources[0]!, same)
|
||||
expect(same.defaultPrevented).toBe(true)
|
||||
expect(admit.mock.lastCall![0].readText()).toBe('Hello action')
|
||||
const other = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 110, clientY: 130 })
|
||||
fireEvent(sources[1]!, other)
|
||||
expect(admit.mock.lastCall![0].hasSelection).toBe(false)
|
||||
expect(admit.mock.lastCall![0].readText()).toBeNull()
|
||||
view.rerender(<Area />)
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
const native = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 110, clientY: 130 })
|
||||
fireEvent(sources[0]!, native)
|
||||
expect(native.defaultPrevented).toBe(false)
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('text interaction lifecycle', () => {
|
||||
it('exposes DOM text only while the captured source remains usable', () => {
|
||||
let binding: TextInteractionBinding | undefined
|
||||
const view = render(<Area onBinding={(value) => { binding = value }} />)
|
||||
expect(binding!.readText()).toBeNull()
|
||||
const source = view.container.querySelector<HTMLElement>('[data-text-interaction-source]')!
|
||||
const selection = select(source)
|
||||
expect(binding!.readText()).toBe('Hello action')
|
||||
source.hidden = true
|
||||
expect(binding!.readText()).toBeNull()
|
||||
source.hidden = false
|
||||
source.removeAttribute('data-text-interaction-source')
|
||||
expect(binding!.readText()).toBeNull()
|
||||
source.setAttribute('data-text-interaction-source', '')
|
||||
act(() => { binding!.clearSelection(); binding!.dismiss() })
|
||||
expect(selection.isCollapsed).toBe(true)
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
expect(binding!.readText()).toBeNull()
|
||||
view.unmount()
|
||||
expect(binding!.readText()).toBeNull()
|
||||
binding!.clearSelection()
|
||||
})
|
||||
|
||||
it('supports an unmounted area', () => {
|
||||
const root = createRef<HTMLDivElement>()
|
||||
const surface = createRef<HTMLDivElement>()
|
||||
const hook = renderHook(() => useTextInteraction(root, surface, () => null, []))
|
||||
expect(hook.result.current.readText()).toBeNull()
|
||||
act(() => { hook.result.current.dismiss(); hook.result.current.clearSelection() })
|
||||
})
|
||||
|
||||
it('dismisses a surface when its plugin unregisters', () => {
|
||||
const view = render(<Area />)
|
||||
select(view.container.querySelector('[data-text-interaction-source]')!)
|
||||
expect(view.getByRole('button')).toBeTruthy()
|
||||
view.rerender(<Area enabled={false} />)
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens context menus without cancelling defaults unless the consumer requests it', () => {
|
||||
let binding: TextInteractionBinding | undefined
|
||||
const view = render(<Area onBinding={(value) => { binding = value }} admit={(request) => { request.open() }} />)
|
||||
const source = view.container.querySelector('[data-text-interaction-source]')!
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
|
||||
fireEvent(source, event)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(view.getByRole('button')).toBeTruthy()
|
||||
expect(binding!.readText()).toBeNull()
|
||||
// Menu focus may collapse browser selection without closing the captured interaction.
|
||||
view.getByRole('button').focus()
|
||||
fireEvent(document, new Event('selectionchange'))
|
||||
expect(view.getByRole('button')).toBeTruthy()
|
||||
fireEvent.contextMenu(view.getByRole('button'))
|
||||
expect(view.getByRole('button')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps a focused selection action open and ignores unrelated keys', () => {
|
||||
const view = render(<Area />)
|
||||
const source = view.container.querySelector('[data-text-interaction-source]')!
|
||||
select(source)
|
||||
const button = view.getByRole('button')
|
||||
button.focus()
|
||||
fireEvent(document, new Event('selectionchange'))
|
||||
fireEvent.keyDown(button, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(document, { key: 'Shift' })
|
||||
fireEvent.keyUp(document, { key: 'a' })
|
||||
expect(view.getByRole('button')).toBeTruthy()
|
||||
button.blur()
|
||||
fireEvent.keyDown(source, { key: 'ArrowLeft' })
|
||||
fireEvent.keyDown(source, { key: 'Shift', shiftKey: true })
|
||||
fireEvent.keyUp(source, { key: 'ArrowLeft', shiftKey: true })
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
fireEvent.keyUp(source, { key: 'Shift' })
|
||||
})
|
||||
|
||||
it('does not admit blank or hidden selections, controls, or unmarked right-click targets', () => {
|
||||
const admit = vi.fn((request: TextInteractionRequest) => { request.open() })
|
||||
const view = render(<Area admit={admit} />)
|
||||
const source = view.container.querySelector<HTMLElement>('[data-text-interaction-source]')!
|
||||
source.textContent = ' '
|
||||
select(source)
|
||||
expect(admit).not.toHaveBeenCalled()
|
||||
source.textContent = 'visible words'
|
||||
source.style.display = 'none'
|
||||
select(source)
|
||||
expect(admit).not.toHaveBeenCalled()
|
||||
fireEvent.contextMenu(source)
|
||||
expect(admit).not.toHaveBeenCalled()
|
||||
source.style.display = ''
|
||||
source.innerHTML = '<button>control</button>'
|
||||
fireEvent.contextMenu(source.firstElementChild!)
|
||||
fireEvent.contextMenu(source.firstElementChild!.firstChild!)
|
||||
fireEvent.contextMenu(view.container.querySelector('[data-area]')!)
|
||||
expect(admit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates folded ancestors and handles scaled positioning containers', async () => {
|
||||
const view = render(<Area />)
|
||||
const area = view.container.querySelector<HTMLElement>('[data-area]')!
|
||||
const source = area.querySelector('[data-text-interaction-source]')!
|
||||
Object.defineProperties(area, { offsetWidth: { value: 150 }, offsetHeight: { value: 250 } })
|
||||
select(source)
|
||||
const surface = area.querySelector<HTMLElement>('[data-surface]')!
|
||||
expect(surface.style.left).toBe('20px')
|
||||
expect(surface.style.top).toBe('-9px')
|
||||
area.style.visibility = 'hidden'
|
||||
await layout()
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a context-menu point attached to its source through scrolling and reflow', async () => {
|
||||
const view = render(<Area admit={(request) => { if (request.trigger === 'contextmenu') request.open() }} />)
|
||||
const source = view.container.querySelector<HTMLElement>('[data-text-interaction-source]')!
|
||||
let sourceTop = 100
|
||||
source.getBoundingClientRect = () => rect(40, sourceTop, 300, 500)
|
||||
fireEvent.contextMenu(source, { clientX: 110, clientY: 130 })
|
||||
const surface = view.container.querySelector<HTMLElement>('[data-surface]')!
|
||||
expect(surface.style.top).toBe('39px')
|
||||
sourceTop = 80
|
||||
await layout()
|
||||
expect(surface.style.top).toBe('19px')
|
||||
source.scrollTop = 5
|
||||
fireEvent.scroll(source)
|
||||
await layout()
|
||||
expect(surface.style.top).toBe('14px')
|
||||
})
|
||||
|
||||
it('cancels scheduled placement when an active area unmounts', () => {
|
||||
const view = render(<Area />)
|
||||
select(view.container.querySelector('[data-text-interaction-source]')!)
|
||||
fireEvent.scroll(view.container.querySelector('[data-viewport]')!)
|
||||
resize()
|
||||
view.unmount()
|
||||
expect(document.querySelector('[data-surface]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { readTextSelection, selectedText } from '../src/client/chat/text-selection.ts'
|
||||
|
||||
function readSelection(root: HTMLElement, selection: Selection | null) {
|
||||
const result = readTextSelection(root, selection)
|
||||
return result === null ? null : { text: selectedText(result) }
|
||||
}
|
||||
|
||||
const markdownLabels = {
|
||||
code: { copyLabel: 'Copy', copiedLabel: 'Copied' },
|
||||
footnotes: 'Footnotes',
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('quote selection', () => {
|
||||
it('extracts one source block and omits Markdown control copy', () => {
|
||||
const view = render(
|
||||
<div data-chat-flow="">
|
||||
<div data-text-interaction-source="">
|
||||
<span>Hello </span>
|
||||
<button type="button">Copy</button>
|
||||
<span>world</span>
|
||||
</div>
|
||||
</div>,
|
||||
)
|
||||
const root = view.container.querySelector<HTMLElement>('[data-chat-flow]')!
|
||||
const sourceRoot = view.container.querySelector<HTMLElement>('[data-text-interaction-source]')!
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(sourceRoot)
|
||||
range.getBoundingClientRect = () => ({ left: 10, top: 20, width: 30, height: 10 }) as DOMRect
|
||||
const selection = window.getSelection()!
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
expect(readSelection(root, selection)).toMatchObject({
|
||||
text: 'Hello world',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects cross-block and pending text selections', () => {
|
||||
const view = render(
|
||||
<div data-chat-flow="">
|
||||
<div data-text-interaction-source="">one</div>
|
||||
<span>pending</span>
|
||||
<div data-text-interaction-source="">two</div>
|
||||
</div>,
|
||||
)
|
||||
const root = view.container.querySelector<HTMLElement>('[data-chat-flow]')!
|
||||
const sources = view.container.querySelectorAll('[data-text-interaction-source]')
|
||||
const range = document.createRange()
|
||||
range.setStart(sources[0]!.firstChild!, 0)
|
||||
range.setEnd(sources[1]!.firstChild!, 3)
|
||||
const selection = window.getSelection()!
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
expect(readSelection(root, selection)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps one visible newline for the Markdown hard-break DOM pair', () => {
|
||||
const view = render(
|
||||
<div data-chat-flow="">
|
||||
<div data-text-interaction-source="">
|
||||
<span>first</span><br />{'\n'}<span>second</span>
|
||||
</div>
|
||||
</div>,
|
||||
)
|
||||
const root = view.container.querySelector<HTMLElement>('[data-chat-flow]')!
|
||||
const sourceRoot = view.container.querySelector<HTMLElement>('[data-text-interaction-source]')!
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(sourceRoot)
|
||||
range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect
|
||||
const selection = window.getSelection()!
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
const candidate = readSelection(root, selection)
|
||||
expect(candidate).toMatchObject({
|
||||
text: 'first\nsecond',
|
||||
})
|
||||
})
|
||||
|
||||
it('projects Markdown soft line breaks as visible spaces', () => {
|
||||
const view = render(
|
||||
<div data-chat-flow="">
|
||||
<div data-text-interaction-source=""><p>{'hello\nworld'}</p></div>
|
||||
</div>,
|
||||
)
|
||||
const root = view.container.querySelector<HTMLElement>('[data-chat-flow]')!
|
||||
const sourceRoot = view.container.querySelector<HTMLElement>('[data-text-interaction-source]')!
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(sourceRoot)
|
||||
range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect
|
||||
const selection = window.getSelection()!
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
expect(readSelection(root, selection)).toMatchObject({ text: 'hello world' })
|
||||
|
||||
const text = sourceRoot.querySelector('p')!.firstChild!
|
||||
range.setStart(text, 6)
|
||||
range.setEnd(text, 11)
|
||||
expect(readSelection(root, selection)).toMatchObject({ text: 'world' })
|
||||
})
|
||||
|
||||
it('omits code banners while preserving preformatted line breaks', () => {
|
||||
const view = render(
|
||||
<div data-chat-flow="">
|
||||
<div data-text-interaction-source="">
|
||||
<div data-text-interaction-exclude="">typescript</div>
|
||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{'const x = 1\nreturn x'}</pre>
|
||||
</div>
|
||||
</div>,
|
||||
)
|
||||
const root = view.container.querySelector<HTMLElement>('[data-chat-flow]')!
|
||||
const sourceRoot = view.container.querySelector<HTMLElement>('[data-text-interaction-source]')!
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(sourceRoot)
|
||||
range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect
|
||||
const selection = window.getSelection()!
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
expect(readSelection(root, selection)).toMatchObject({ text: 'const x = 1\nreturn x' })
|
||||
})
|
||||
|
||||
it('uses one synthetic newline when a hard break has no text-node newline', () => {
|
||||
const view = render(
|
||||
<div data-chat-flow="">
|
||||
<div data-text-interaction-source="">
|
||||
<span>first</span><br /><span>second</span>
|
||||
</div>
|
||||
</div>,
|
||||
)
|
||||
const root = view.container.querySelector<HTMLElement>('[data-chat-flow]')!
|
||||
const sourceRoot = view.container.querySelector<HTMLElement>('[data-text-interaction-source]')!
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(sourceRoot)
|
||||
range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect
|
||||
const selection = window.getSelection()!
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
const candidate = readSelection(root, selection)
|
||||
expect(candidate).toMatchObject({
|
||||
text: 'first\nsecond',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves visible boundaries between adjacent Markdown blocks', () => {
|
||||
const view = render(
|
||||
<div data-chat-flow="">
|
||||
<div data-text-interaction-source="">
|
||||
<p>first</p><p>second</p>
|
||||
</div>
|
||||
</div>,
|
||||
)
|
||||
const root = view.container.querySelector<HTMLElement>('[data-chat-flow]')!
|
||||
const sourceRoot = view.container.querySelector<HTMLElement>('[data-text-interaction-source]')!
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(sourceRoot)
|
||||
range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect
|
||||
const selection = window.getSelection()!
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
const candidate = readSelection(root, selection)
|
||||
expect(candidate).toMatchObject({
|
||||
text: 'first\nsecond',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits Markdown renderer whitespace between adjacent blocks', () => {
|
||||
const view = render(
|
||||
<div data-chat-flow="">
|
||||
<div data-text-interaction-source="">
|
||||
<MarkdownText text={'first\n\nsecond'} labels={markdownLabels} />
|
||||
</div>
|
||||
</div>,
|
||||
)
|
||||
const root = view.container.querySelector<HTMLElement>('[data-chat-flow]')!
|
||||
const sourceRoot = view.container.querySelector<HTMLElement>('[data-text-interaction-source]')!
|
||||
const paragraphs = sourceRoot.querySelectorAll('p')
|
||||
expect(paragraphs[0]!.nextSibling?.textContent).toBe('\n')
|
||||
const range = document.createRange()
|
||||
range.setStart(paragraphs[0]!.firstChild!, 0)
|
||||
range.setEnd(paragraphs[1]!.firstChild!, 'second'.length)
|
||||
range.getBoundingClientRect = () => (
|
||||
{ left: 50, top: 60, width: 20, height: 10 } as DOMRect
|
||||
)
|
||||
const selection = window.getSelection()!
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
expect(readSelection(root, selection)).toMatchObject({ text: 'first\nsecond' })
|
||||
})
|
||||
|
||||
it('maps visible KaTeX text while omitting its duplicate MathML branch', () => {
|
||||
const view = render(
|
||||
<div data-chat-flow="">
|
||||
<div data-text-interaction-source="">
|
||||
<span className="katex">
|
||||
<span className="katex-mathml">hidden TeX source</span>
|
||||
<span className="katex-html" aria-hidden="true">x + 1</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>,
|
||||
)
|
||||
const root = view.container.querySelector<HTMLElement>('[data-chat-flow]')!
|
||||
const visible = view.container.querySelector<HTMLElement>('.katex-html')!
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(visible)
|
||||
range.getBoundingClientRect = () => ({ left: 50, top: 60, width: 20, height: 10 }) as DOMRect
|
||||
const selection = window.getSelection()!
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
expect(readSelection(root, selection)).toMatchObject({ text: 'x + 1' })
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('text source boundaries', () => {
|
||||
function source(html: string) {
|
||||
const root = document.createElement('div')
|
||||
const source = document.createElement('div')
|
||||
source.setAttribute('data-text-interaction-source', '')
|
||||
source.innerHTML = html
|
||||
root.append(source)
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(source)
|
||||
return { root, source, range }
|
||||
}
|
||||
|
||||
it('preserves table cells, existing tabs, and inline code reference buttons', () => {
|
||||
const fixture = source('<table><tbody><tr><td>one</td><td>two</td></tr><tr><td style="white-space:pre">three\t</td><td>four</td></tr><tr><td>five</td><td style="white-space:pre">\tsix</td></tr></tbody></table><code><button>reference</button></code>')
|
||||
expect(selectedText(fixture)).toBe('one\ttwo\nthree\tfour\nfive\tsix\nreference')
|
||||
})
|
||||
|
||||
it('omits hidden content, comments, and empty text nodes', () => {
|
||||
const fixture = source('<!-- renderer --><span aria-hidden="true">decoration</span><span hidden>hidden</span><span class="sr-only">spoken</span><span>visible</span>')
|
||||
fixture.source.append(document.createTextNode(''))
|
||||
fixture.range.selectNodeContents(fixture.source)
|
||||
expect(selectedText(fixture)).toBe('visible')
|
||||
const hidden = fixture.source.querySelector('span')!
|
||||
fixture.range.selectNodeContents(hidden)
|
||||
expect(selectedText(fixture)).toBeNull()
|
||||
fixture.range.setStart(hidden.firstChild!, 0)
|
||||
fixture.range.setEnd(hidden.firstChild!, 4)
|
||||
expect(selectedText(fixture)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects whitespace-only and detached sources outside the area', () => {
|
||||
const fixture = source(' ')
|
||||
expect(selectedText(fixture)).toBeNull()
|
||||
const selection = window.getSelection()!
|
||||
selection.removeAllRanges()
|
||||
expect(readTextSelection(fixture.root, null)).toBeNull()
|
||||
expect(readTextSelection(fixture.root, selection)).toBeNull()
|
||||
document.body.append(fixture.root)
|
||||
try {
|
||||
selection.addRange(fixture.range)
|
||||
expect(readTextSelection(document.createElement('div'), selection)).toBeNull()
|
||||
fixture.source.hidden = true
|
||||
expect(readTextSelection(fixture.root, selection)).toBeNull()
|
||||
} finally { fixture.root.remove(); selection.removeAllRanges() }
|
||||
})
|
||||
|
||||
it('projects text in a document without a browsing context', () => {
|
||||
const doc = document.implementation.createHTMLDocument()
|
||||
const source = doc.createElement('div')
|
||||
source.textContent = 'hello\nworld'
|
||||
const range = doc.createRange()
|
||||
range.selectNodeContents(source)
|
||||
expect(selectedText({ source, range })).toBe('hello world')
|
||||
})
|
||||
})
|
||||
@@ -18,11 +18,11 @@
|
||||
en: ef43c4f149195939
|
||||
zh: 49fc6c83fd3a05ce
|
||||
/deepseek-ai-dsh-client-ui-conversation/sent-user-text:
|
||||
en: 9912b286384c41d7
|
||||
zh: 3e994887c1948677
|
||||
en: 7871f9fe6bcc4459
|
||||
zh: 0d51565c600b3958
|
||||
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
|
||||
en: a7a0b235e1c5f2b0
|
||||
zh: ca24e6bf77ad7e1b
|
||||
en: 457e642073206f7d
|
||||
zh: 95ea47c204850314
|
||||
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
|
||||
en: bb1244310f5c0382
|
||||
zh: 824b84077432b559
|
||||
|
||||
@@ -59,6 +59,12 @@ Businesses extend `UserTextDataMap` and `UserTextFacts` on this package's `/clie
|
||||
|
||||
Queue previews enable whitespace collapsing and document-edge trimming, retain emoji, and use CSS ellipsis without truncating the input. Editing and copying retain the original text; native attachments use their separate presentation.
|
||||
|
||||
[Message quotes](../../session/message-quotes/README.md) registers quote matching and rendering through the same UserText extension points. Each user-text container and draft editor owns an independent hover-card group, so its quote previews replace one another immediately.
|
||||
|
||||
The existing semantic `DraftSnapshot` retains quote text and clipboard-coordinate spans beside references. Storage accepts earlier string and reference-only drafts; Session switching, Workspace transfer, and failed-send restoration preserve atomic quotes through the existing editor runtime. `inputActions.insertQuote(text)` inserts a nonblank selection only while the composer is in plain mode. Submission emits ordinary Markdown text; pending echoes, queue rows, steering rows, and durable messages use the same text matcher.
|
||||
|
||||
`UiConversation.interactions` owns plugin registrations for selected-text and context-menu gestures. A registration supplies a synchronous handler whose ID selects its keyed Slot component; plugins use the Slot injection mechanism for rendering. Handlers call `open()` to display and call `preventDefault()` separately when replacing a browser default. Chat supplies DOM selections and local layout; original source data and Markdown offsets are not inferred from DOM positions.
|
||||
|
||||
<a id="shell-and-standard-props"></a>
|
||||
## Shell and standard props
|
||||
|
||||
@@ -98,6 +104,8 @@ The width-handle indicator follows the pointer only during a captured drag; ordi
|
||||
|
||||
The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), the claimed slash command is an atomic leading chip that carries its claim, folder text references carry the folder glyph as an icon prefix, and the draft's text and semantic references are persisted by the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews use the [UserText Factory](#sent-user-text) (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in the ui-primitives `InlineEditor`, so line breaks survive re-editing. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
|
||||
|
||||
In narrow browser settings, the busy-state Enter preference uses a smaller label gutter. Windows and macOS desktop windows retain the standard spacing.
|
||||
|
||||
Plain Enter uses the configured delivery mode, exactly Ctrl+Enter or Cmd+Enter uses its complement, and Shift+Enter inserts a line break. Enter with Alt, AltGraph, both Ctrl and Cmd, or Shift plus Ctrl/Cmd leaves the draft and command menu unchanged and keeps the DOM event available to application shortcuts. The Conversation plugin registers the fixed send, newline, complementary-delivery, command-menu, and reference-menu rows and reserves their bindings until it unloads.
|
||||
|
||||
Clicking unused space inside an editable composer preserves its current caret or selection when focused, or restores its previous caret or selection when unfocused. Text selection, scrollbars, controls, and embedded overlays keep their own pointer behavior.
|
||||
|
||||
@@ -59,6 +59,12 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
|
||||
|
||||
Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CSS 省略而不截断输入。编辑和复制保留原文;原生附件使用独立的展示路径。
|
||||
|
||||
[Message quotes](../../session/message-quotes/README.zh.md) 通过相同的 UserText 扩展点注册引用匹配与渲染。 每个用户文本容器和草稿编辑器各自拥有独立的悬停卡片分组,其中的 quote 原文弹层会立即互相替换。
|
||||
|
||||
现有语义 `DraftSnapshot` 在 references 旁保留 quote 文本及 clipboard 坐标范围。存储接受较早的字符串和仅含 reference 的草稿;Session 切换、Workspace 转移以及发送失败恢复通过现有编辑器 runtime 保留原子 quote。`inputActions.insertQuote(text)` 仅在输入框处于 plain 模式时插入非空选区。提交产出普通 Markdown 文本;待发送回显、队列行、steering 行和持久消息使用同一文本匹配器。
|
||||
|
||||
`UiConversation.interactions` 拥有插件对文本选择和右键手势的注册。每个注册项提供同步处理函数,其 ID 选择对应的 keyed Slot 组件;插件通过 Slot injection 机制渲染。处理函数调用 `open()` 展示,需要替代浏览器默认行为时单独调用 `preventDefault()`。Chat 提供 DOM 选区和区域内布局;机制不从 DOM 位置推断原始来源数据或 Markdown 偏移。
|
||||
|
||||
<a id="shell-and-standard-props"></a>
|
||||
## Shell 与标准 props
|
||||
|
||||
@@ -98,6 +104,8 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
|
||||
|
||||
常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 是携带认领的行首原子 chip,文件夹文本引用以图标前缀携带文件夹图形,草稿文字和引用语义字段由逐 Session Conversation store 持久化。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览使用 [UserText Factory](#sent-user-text)(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在 ui-primitives 的 `InlineEditor` 中展示字面发送文本,因此重新编辑不会丢失换行。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
|
||||
|
||||
窄屏浏览器设置中,繁忙时 Enter 偏好的标签与控件间距缩小;Windows 和 macOS 桌面窗口保留标准间距。
|
||||
|
||||
普通 Enter 使用已配置的投递模式,严格的 Ctrl+Enter 或 Cmd+Enter 使用互补模式,Shift+Enter 插入换行。带 Alt、AltGraph、同时带 Ctrl 与 Cmd,或 Shift 与 Ctrl/Cmd 的 Enter 保持草稿和指令菜单不变,并将 DOM 事件留给应用快捷键。Conversation 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。
|
||||
|
||||
点击可编辑输入框内的留白时,已聚焦的输入框保留当前光标或选区,未聚焦的输入框恢复上次的光标或选区。文本选择、滚动条、控件和内嵌浮层保持各自的指针交互行为。
|
||||
|
||||
@@ -96,7 +96,8 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
"@deepseek-ai/dsh-settings": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*"
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
|
||||
"@deepseek-ai/dsh-message-quotes": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
|
||||
@@ -22,6 +22,14 @@ export interface ReferenceInsert {
|
||||
/** Persisted reference data without the current editor's occurrence identity. */
|
||||
export type DraftReference = Omit<Occurrence, 'occurrenceId'>
|
||||
|
||||
/** One atomic quote in clipboard-text coordinates. */
|
||||
export interface DraftQuote {
|
||||
readonly offset: number
|
||||
readonly length: number
|
||||
/** Selected text before Markdown quote prefixes are added. */
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted command chip opening a draft. The claim itself (its submit
|
||||
* closure) is not persisted; the input rebuilds it from the token through the
|
||||
@@ -41,6 +49,7 @@ export interface DraftSnapshot {
|
||||
readonly text: string
|
||||
readonly references: readonly DraftReference[]
|
||||
readonly command?: DraftCommand
|
||||
readonly quotes?: readonly DraftQuote[]
|
||||
}
|
||||
|
||||
/** Plain initial text or an existing document with explicit reference identities. */
|
||||
|
||||
@@ -182,6 +182,9 @@ export interface InputTarget {
|
||||
|
||||
/** Per-session input facade owned by the conversation wiring layer. */
|
||||
export interface SessionInput extends InputTarget {
|
||||
/** Insert selected transcript text as an atomic quote. @param text - nonblank selection. @returns whether editing accepted it. */
|
||||
insertQuote(text: string): boolean
|
||||
|
||||
/** Replace the whole draft with plain text, removing inline reference chips. */
|
||||
setDraft(text: string): void
|
||||
/** Append ordered browser-owned attachment ids; busy admission phases refuse. */
|
||||
@@ -248,11 +251,13 @@ export type DraftInitializationResult = 'applied' | 'preserved' | 'blocked'
|
||||
|
||||
/**
|
||||
* The public input action face provided to every session-scope slot
|
||||
* component: stable-identity void callbacks, mirroring the
|
||||
* component: stable-identity callbacks, mirroring the
|
||||
* useStore+actions convention. Command-style handles (arbitrate/space/
|
||||
* paste/…) stay InputBar-private and never ride this face.
|
||||
*/
|
||||
export interface InputActions {
|
||||
/** Insert one nonblank transcript quote. @param text - selected text. @returns whether editing accepted it. */
|
||||
insertQuote(text: string): boolean
|
||||
/** @returns a revision-guarded copy of the current editor selection. */
|
||||
captureInsertion(): TokenSpan
|
||||
/**
|
||||
|
||||
@@ -41,6 +41,8 @@ export type AssistantBlock =
|
||||
/** A finalized user message. */
|
||||
export interface UserMessageNode {
|
||||
kind: 'user'
|
||||
/** Durable identity, absent only in synthetic projections. */
|
||||
messageId?: MessageId
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event. */
|
||||
time: number
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/** Conversation text gestures and plugin-owned action surfaces. */
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/** Browser gesture offered to text interaction consumers. */
|
||||
export type TextInteractionTrigger = 'selection' | 'contextmenu'
|
||||
|
||||
/** Synchronous browser gesture; reading text does not imply source-data offsets. */
|
||||
export interface TextInteractionGesture {
|
||||
readonly trigger: TextInteractionTrigger
|
||||
readonly hasSelection: boolean
|
||||
readonly readText: () => string | null
|
||||
/** Cancel the originating browser event during dispatch only. */
|
||||
readonly preventDefault: () => void
|
||||
}
|
||||
|
||||
/** Scoped gesture offered to one registered plugin. */
|
||||
export interface TextInteractionRequest extends TextInteractionGesture {
|
||||
readonly sessionId: SessionId
|
||||
/** Select this plugin's keyed surface for the current gesture. */
|
||||
readonly open: () => void
|
||||
}
|
||||
|
||||
/** One consumer whose ID also names its keyed Slot renderer. */
|
||||
export interface TextInteractionDefinition {
|
||||
readonly id: string
|
||||
readonly onInteraction: (request: TextInteractionRequest) => void
|
||||
}
|
||||
|
||||
/** Area-local actions provided to the currently selected plugin component. */
|
||||
export interface TextInteractionSurface {
|
||||
readonly trigger: TextInteractionTrigger
|
||||
readonly dismiss: () => void
|
||||
readonly readText: () => string | null
|
||||
readonly clearSelection: () => void
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { ConversationEventRegistry } from './event-registry.ts'
|
||||
import { HistoricalImageCache } from './historical-images.ts'
|
||||
import { ConversationViewRegistry } from './view-registry.ts'
|
||||
import { ConversationGroupRegistry } from './group-registry.ts'
|
||||
import { ConversationInteractionRegistry } from './interaction-registry.ts'
|
||||
import { UserTextRegistry } from './user-text-registry.ts'
|
||||
|
||||
/** Observable faces published for one Session's Conversation assembly. */
|
||||
@@ -193,6 +194,8 @@ export class UiConversation extends Service {
|
||||
readonly groups: ConversationGroupRegistry
|
||||
/** Pure text rules and parser snapshots, independent of durable Session assembly. */
|
||||
readonly userText: UserTextRegistry
|
||||
/** Plugin-owned handlers for selection and context-menu gestures. */
|
||||
readonly interactions: ConversationInteractionRegistry
|
||||
private readonly bindings = new WeakMapWithValues<SessionBinding, BindingRecord>()
|
||||
private readonly images: HistoricalImageCache
|
||||
|
||||
@@ -206,6 +209,7 @@ export class UiConversation extends Service {
|
||||
this.views = new ConversationViewRegistry(ctx)
|
||||
this.groups = new ConversationGroupRegistry(ctx, this.views)
|
||||
this.userText = new UserTextRegistry(ctx)
|
||||
this.interactions = new ConversationInteractionRegistry(ctx)
|
||||
this.images = new HistoricalImageCache(ctx, sessions)
|
||||
const rebuild = (): void => {
|
||||
for (const record of this.bindings.values) record.binding.rebuild()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Plugin-owned text gesture handlers, scoped by the dispatching conversation. */
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { TextInteractionDefinition, TextInteractionGesture } from '../contract/text-interaction.ts'
|
||||
import { ConversationDefinitionRegistry } from './definition-registry.ts'
|
||||
|
||||
/** Effect-owned registrations; active surfaces belong to individual Chat views. */
|
||||
export class ConversationInteractionRegistry extends ConversationDefinitionRegistry<TextInteractionDefinition> {
|
||||
private ids: readonly string[] = []
|
||||
|
||||
/**
|
||||
* Register a synchronous handler and its keyed renderer identity.
|
||||
* @param definition - uniquely named plugin handler.
|
||||
* @returns idempotent disposer tied to the registering plugin's lifetime.
|
||||
*/
|
||||
register(definition: TextInteractionDefinition): () => void {
|
||||
if (definition.id === '') throw new Error('text interaction ID must not be empty')
|
||||
return this.registerDefinition(definition.id, definition,
|
||||
`Duplicate text interaction: ${definition.id}`, `uiConversation.interactions.register(${JSON.stringify(definition.id)})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer a gesture until one handler opens its surface; opening never prevents browser defaults.
|
||||
* @param sessionId - conversation where the gesture occurred.
|
||||
* @param gesture - synchronous access to the browser event and DOM text.
|
||||
* @returns selected renderer ID, or null when no plugin opens a surface.
|
||||
*/
|
||||
dispatch(sessionId: SessionId, gesture: TextInteractionGesture): string | null {
|
||||
for (const definition of this.entries()) {
|
||||
const state = { opened: false }
|
||||
let dispatching = true
|
||||
try {
|
||||
definition.onInteraction({
|
||||
...gesture, sessionId,
|
||||
open: () => { if (dispatching) state.opened = true },
|
||||
preventDefault: () => { if (dispatching) gesture.preventDefault() },
|
||||
})
|
||||
} finally { dispatching = false }
|
||||
if (state.opened) return definition.id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe registration changes so Chat views can dismiss stale surfaces.
|
||||
* @returns stable IDs until the registry changes.
|
||||
*/
|
||||
getSnapshot(): readonly string[] {
|
||||
return this.ids
|
||||
}
|
||||
|
||||
protected override refresh(): void {
|
||||
this.ids = [...this.definitions.keys()]
|
||||
super.refresh()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Semantic draft snapshots shared by storage, programmatic input, and editor restoration. */
|
||||
import { z } from 'zod'
|
||||
import type { DraftInput, DraftReference, DraftSnapshot, Occurrence } from './contract/draft-editor.ts'
|
||||
import { formatQuoteText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { DraftInput, DraftQuote, DraftReference, DraftSnapshot, Occurrence } from './contract/draft-editor.ts'
|
||||
|
||||
const referenceSchema = z.object({
|
||||
source: z.string(),
|
||||
@@ -17,10 +18,17 @@ const commandSchema = z.object({
|
||||
token: z.string(),
|
||||
})
|
||||
|
||||
const quoteSchema = z.object({
|
||||
offset: z.number().int().nonnegative(),
|
||||
length: z.number().int().positive(),
|
||||
text: z.string().refine(text => text.trim() !== ''),
|
||||
})
|
||||
|
||||
const snapshotSchema = z.object({
|
||||
text: z.string(),
|
||||
references: z.array(referenceSchema),
|
||||
command: commandSchema.optional(),
|
||||
quotes: z.array(quoteSchema).optional(),
|
||||
}).refine((draft) => {
|
||||
const spelling = draft.command?.token.trimEnd()
|
||||
if (spelling !== undefined && (!/^\/\S/u.test(spelling) || !draft.text.startsWith(spelling))) return false
|
||||
@@ -30,6 +38,16 @@ const snapshotSchema = z.object({
|
||||
end = reference.offset + reference.length
|
||||
if (end > draft.text.length || draft.text.slice(reference.offset, end) !== reference.clipboardText) return false
|
||||
}
|
||||
for (const quote of draft.quotes ?? []) {
|
||||
if (quote.length !== formatQuoteText(quote.text).length
|
||||
|| draft.text.slice(quote.offset, quote.offset + quote.length) !== formatQuoteText(quote.text)) return false
|
||||
}
|
||||
end = spelling?.length ?? 0
|
||||
for (const span of [...draft.references, ...draft.quotes ?? []].sort((a, b) => a.offset - b.offset)) {
|
||||
if (span.offset < end) return false
|
||||
end = span.offset + span.length
|
||||
if (end > draft.text.length) return false
|
||||
}
|
||||
return true
|
||||
}, 'Draft references must identify ordered, non-overlapping text spans after the leading command token')
|
||||
|
||||
@@ -44,6 +62,7 @@ export function resolveDraftInput(input: DraftInput): DraftSnapshot {
|
||||
text: input.text,
|
||||
references: input.references.map(copyReference),
|
||||
...(input.command === undefined ? {} : { command: { token: input.command.token } }),
|
||||
...(input.quotes === undefined ? {} : { quotes: input.quotes.map(quote => ({ ...quote })) }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +77,7 @@ export function parseStoredDraft(value: unknown): DraftSnapshot | undefined {
|
||||
if (!parsed.success) return undefined
|
||||
return {
|
||||
text: parsed.data.text,
|
||||
...parsed.data.quotes === undefined ? {} : { quotes: parsed.data.quotes },
|
||||
references: parsed.data.references.map(({ appearance, invalid, ...reference }) => ({
|
||||
...reference,
|
||||
...(appearance === undefined ? {} : { appearance }),
|
||||
@@ -72,13 +92,17 @@ export function parseStoredDraft(value: unknown): DraftSnapshot | undefined {
|
||||
* @param text - current clipboard projection.
|
||||
* @param occurrences - matching reference projections from the same document revision.
|
||||
* @param commandToken - claim token of the command chip opening the document, if any.
|
||||
* @param quotes - atomic quotes from the same document revision.
|
||||
* @returns an editor-independent snapshot.
|
||||
*/
|
||||
export function snapshotDraft(text: string, occurrences: readonly Occurrence[], commandToken?: string): DraftSnapshot {
|
||||
export function snapshotDraft(
|
||||
text: string, occurrences: readonly Occurrence[], commandToken?: string, quotes: readonly DraftQuote[] = [],
|
||||
): DraftSnapshot {
|
||||
return {
|
||||
text,
|
||||
references: occurrences.map(copyReference),
|
||||
...(commandToken === undefined ? {} : { command: { token: commandToken } }),
|
||||
...(quotes.length === 0 ? {} : { quotes: quotes.map(quote => ({ ...quote })) }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ export type {
|
||||
InputState, InsertReferenceRequest, InsertTextRequest, PickOutcome, SessionInput,
|
||||
SessionInputResolver, SubmitAttachment, SubmitOutcome,
|
||||
} from './contract/input.ts'
|
||||
export type { ArbitrateKey, ArbitrateOutcome, DraftInput, DraftReference, DraftSnapshot, ReferenceInsert, TokenSpan } from './contract/draft-editor.ts'
|
||||
export type { ArbitrateKey, ArbitrateOutcome, DraftInput, DraftQuote, DraftReference, DraftSnapshot, ReferenceInsert, TokenSpan } from './contract/draft-editor.ts'
|
||||
export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
@@ -113,3 +113,6 @@ declare module '@deepseek-ai/cordis' {
|
||||
uiConversation: import('./conversation/assembly.ts').UiConversation
|
||||
}
|
||||
}
|
||||
|
||||
export type { TextInteractionDefinition, TextInteractionGesture, TextInteractionRequest, TextInteractionSurface, TextInteractionTrigger } from './contract/text-interaction.ts'
|
||||
export type { ConversationInteractionRegistry } from './conversation/interaction-registry.ts'
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { CSSProperties, KeyboardEventHandler, ReactNode, RefObject } from 'react'
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import clsx from 'clsx'
|
||||
import { HoverCardGroup } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InputState } from '../../contract/input.ts'
|
||||
import { ComposerContentEditable } from './ComposerContentEditable.tsx'
|
||||
import { DecoratorPortals } from './DecoratorPortals.tsx'
|
||||
@@ -34,7 +35,7 @@ export function DraftEditor({
|
||||
}: DraftEditorProps): ReactNode {
|
||||
return (
|
||||
<div ref={scrollRef} className={css.scroll} data-input-scroll>
|
||||
<div className={css.grow}>
|
||||
<HoverCardGroup className={css.grow}>
|
||||
<ComposerContentEditable
|
||||
editor={workspaceTrigger ? null : editor}
|
||||
editable={editable}
|
||||
@@ -57,7 +58,7 @@ export function DraftEditor({
|
||||
</div>
|
||||
)}
|
||||
<DecoratorPortals editor={workspaceTrigger ? null : editor} />
|
||||
</div>
|
||||
</HoverCardGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/** Atomic composer quote node and whole-node removal command. */
|
||||
|
||||
import type { JSX } from 'react'
|
||||
import { QuotePreview } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { formatQuoteText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
EditorConfig, LexicalEditor, LexicalNode, NodeKey, SerializedLexicalNode, Spread,
|
||||
} from 'lexical'
|
||||
import { createCommand, DecoratorNode } from 'lexical'
|
||||
|
||||
/** Localized non-persisted labels applied by the owning input shell. */
|
||||
export interface QuoteNodeLabels {
|
||||
readonly remove: string
|
||||
}
|
||||
|
||||
const EMPTY_LABELS: QuoteNodeLabels = { remove: '' }
|
||||
|
||||
/** JSON form used only by Lexical's editor-state APIs, not browser persistence. */
|
||||
export type SerializedQuoteNode = Spread<{
|
||||
text: string
|
||||
}, SerializedLexicalNode>
|
||||
|
||||
/** Remove one quote occurrence by its Lexical identity. */
|
||||
export const REMOVE_QUOTE_COMMAND = createCommand<NodeKey>('REMOVE_QUOTE_COMMAND')
|
||||
|
||||
/** Pure React face of one quote node. */
|
||||
export function QuoteNodeView({ editor, nodeKey, text, labels }: {
|
||||
readonly editor: LexicalEditor
|
||||
readonly nodeKey: NodeKey
|
||||
readonly text: string
|
||||
readonly labels: QuoteNodeLabels
|
||||
}) {
|
||||
return (
|
||||
<QuotePreview
|
||||
text={text}
|
||||
removal={{
|
||||
label: labels.remove,
|
||||
onRemove: () => { editor.dispatchCommand(REMOVE_QUOTE_COMMAND, nodeKey) },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** One selected transcript range represented as an atomic inline decorator. */
|
||||
export class QuoteNode extends DecoratorNode<JSX.Element> {
|
||||
__text: string
|
||||
__labels: QuoteNodeLabels
|
||||
|
||||
/** Lexical node registry type tag. */
|
||||
static override getType(): string {
|
||||
return 'quote'
|
||||
}
|
||||
|
||||
/** @param node - node to clone. @returns a copy carrying the same NodeKey. */
|
||||
static override clone(node: QuoteNode): QuoteNode {
|
||||
return new QuoteNode(
|
||||
node.__text,
|
||||
node.__labels,
|
||||
node.__key,
|
||||
)
|
||||
}
|
||||
|
||||
/** @param json - serialized editor node. @returns a fresh node with default runtime labels. */
|
||||
static override importJSON(json: SerializedQuoteNode): QuoteNode {
|
||||
return new QuoteNode(json.text)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param text - selected text.
|
||||
* @param labels - runtime-only localized labels.
|
||||
* @param key - Lexical clone-path key; absent for fresh nodes.
|
||||
*/
|
||||
constructor(
|
||||
text: string,
|
||||
labels: QuoteNodeLabels = EMPTY_LABELS,
|
||||
key?: NodeKey,
|
||||
) {
|
||||
super(key)
|
||||
this.__text = text
|
||||
this.__labels = labels
|
||||
}
|
||||
|
||||
/** Serialize the quote data without its runtime presentation state. */
|
||||
override exportJSON(): SerializedQuoteNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'quote',
|
||||
version: 1,
|
||||
text: this.__text,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount one non-editable inline host. */
|
||||
override createDOM(_config: EditorConfig): HTMLElement {
|
||||
const element = document.createElement('span')
|
||||
element.setAttribute('data-composer-quote', '')
|
||||
element.setAttribute('contenteditable', 'false')
|
||||
return element
|
||||
}
|
||||
|
||||
/** Updates reuse the existing host element. */
|
||||
override updateDOM(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/** Quotes flow with draft text and wrap at their own text boundary. */
|
||||
override isInline(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Arrow movement crosses the quote without entering a NodeSelection. */
|
||||
override isKeyboardSelectable(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/** Clipboard text is the exact model-visible Markdown blockquote. */
|
||||
override getTextContent(): string {
|
||||
return formatQuoteText(this.__text)
|
||||
}
|
||||
|
||||
/** Selected visible quote text. */
|
||||
getQuoteText(): string {
|
||||
return this.getLatest().__text
|
||||
}
|
||||
|
||||
/** Update the localized remove label without changing persisted quote data. */
|
||||
setLabels(labels: QuoteNodeLabels): void {
|
||||
if (this.getLatest().__labels.remove !== labels.remove) this.getWritable().__labels = labels
|
||||
}
|
||||
|
||||
/** Render the quote body and its independent close control. */
|
||||
override decorate(editor: LexicalEditor): JSX.Element {
|
||||
return (
|
||||
<QuoteNodeView
|
||||
editor={editor}
|
||||
nodeKey={this.__key}
|
||||
text={this.__text}
|
||||
labels={this.__labels}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** @param text - selected quote text. @param labels - runtime labels. @returns a fresh node. */
|
||||
export function $createQuoteNode(text: string, labels: QuoteNodeLabels): QuoteNode {
|
||||
return new QuoteNode(text, labels)
|
||||
}
|
||||
|
||||
/** @param node - possible Lexical node. @returns whether it is a QuoteNode. */
|
||||
export function $isQuoteNode(node: LexicalNode | null | undefined): node is QuoteNode {
|
||||
return node instanceof QuoteNode
|
||||
}
|
||||
@@ -16,11 +16,14 @@
|
||||
*/
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import {
|
||||
$getAdjacentNode, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode,
|
||||
KEY_DOWN_COMMAND, MOVE_TO_END,
|
||||
COMMAND_PRIORITY_CRITICAL, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND,
|
||||
KEY_ESCAPE_COMMAND, KEY_SPACE_COMMAND, KEY_TAB_COMMAND, PASTE_COMMAND,
|
||||
} from 'lexical'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import type { ArbitrateKey, ArbitrateOutcome } from '../../contract/draft-editor.ts'
|
||||
import { $isQuoteNode } from './QuoteNode.tsx'
|
||||
|
||||
/** The bar-supplied behavior behind each intercepted gesture. */
|
||||
export interface ComposerKeymapHandlers {
|
||||
@@ -51,6 +54,118 @@ function isComposingEvent(event: KeyboardEvent, recentlyComposing: () => boolean
|
||||
return event.isComposing || event.keyCode === 229 || recentlyComposing()
|
||||
}
|
||||
|
||||
function isApplePlatform(): boolean {
|
||||
return typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform)
|
||||
}
|
||||
|
||||
/** Whether the browser caret touches one side of an atomic quote element. */
|
||||
function domCaretTouches(element: HTMLElement, backward: boolean): boolean {
|
||||
const selection = element.ownerDocument.defaultView?.getSelection()
|
||||
if (selection === null || selection === undefined || !selection.isCollapsed) return false
|
||||
const focus = selection.focusNode
|
||||
if (focus === null) return false
|
||||
const parent = element.parentNode
|
||||
if (parent === null) return false
|
||||
const offset = selection.focusOffset
|
||||
if (focus === parent) {
|
||||
return focus.childNodes.item(backward ? offset - 1 : offset) === element
|
||||
}
|
||||
if (focus.nodeType === Node.TEXT_NODE) {
|
||||
const size = focus.textContent?.length ?? 0
|
||||
if (backward ? offset !== 0 : offset !== size) return false
|
||||
} else if (backward ? offset !== 0 : offset !== focus.childNodes.length) {
|
||||
return false
|
||||
}
|
||||
let boundary = focus
|
||||
while (boundary.parentNode !== null && boundary.parentNode !== parent) {
|
||||
if ((backward ? boundary.previousSibling : boundary.nextSibling) !== null) return false
|
||||
boundary = boundary.parentNode
|
||||
}
|
||||
return boundary.parentNode === parent
|
||||
&& (backward ? boundary.previousSibling : boundary.nextSibling) === element
|
||||
}
|
||||
|
||||
/** Find an atomic quote immediately beside both editor and browser carets. */
|
||||
function adjacentQuote(editor: LexicalEditor, backward: boolean) {
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null
|
||||
const adjacent = $getAdjacentNode(selection.focus, backward)
|
||||
if (!$isQuoteNode(adjacent)) return null
|
||||
const element = editor.getElementByKey(adjacent.getKey())
|
||||
return element !== null && domCaretTouches(element, backward) ? adjacent : null
|
||||
}
|
||||
|
||||
function lineLeadingQuoteBeforeSelection(editor: LexicalEditor) {
|
||||
let quote = adjacentQuote(editor, true)
|
||||
if (quote === null) return null
|
||||
let previous = quote.getPreviousSibling()
|
||||
while (previous !== null) {
|
||||
if ($isLineBreakNode(previous)) return quote
|
||||
if ($isQuoteNode(previous)) {
|
||||
quote = previous
|
||||
previous = previous.getPreviousSibling()
|
||||
continue
|
||||
}
|
||||
if (!$isTextNode(previous)) return null
|
||||
const text = previous.getTextContent()
|
||||
const lastBreak = text.lastIndexOf('\n')
|
||||
if (lastBreak >= 0) return text.slice(lastBreak + 1) === '' ? quote : null
|
||||
if (text.trim() !== '') return null
|
||||
const precedingQuote = previous.getPreviousSibling()
|
||||
if (!$isQuoteNode(precedingQuote)) return null
|
||||
quote = precedingQuote
|
||||
previous = quote.getPreviousSibling()
|
||||
}
|
||||
return quote
|
||||
}
|
||||
|
||||
/** Put the browser caret before an adjacent line-leading quote. */
|
||||
function moveBeforeQuote(editor: LexicalEditor, event: KeyboardEvent): boolean {
|
||||
if (event.shiftKey) return false
|
||||
const quote = lineLeadingQuoteBeforeSelection(editor)
|
||||
if (quote === null) return false
|
||||
const element = editor.getElementByKey(quote.getKey())
|
||||
if (element === null) return false
|
||||
const view = element.ownerDocument.defaultView
|
||||
if (view === null) return false
|
||||
const domSelection = view.getSelection()
|
||||
if (domSelection === null) return false
|
||||
const range = element.ownerDocument.createRange()
|
||||
range.setStartBefore(element)
|
||||
range.collapse(true)
|
||||
domSelection.removeAllRanges()
|
||||
domSelection.addRange(range)
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Move from immediately before a quote to the end of its hard line. */
|
||||
function movePastQuote(editor: LexicalEditor, event: KeyboardEvent): boolean {
|
||||
if (!isApplePlatform() || event.key !== 'ArrowRight' || !event.metaKey
|
||||
|| event.ctrlKey || event.altKey || event.shiftKey) return false
|
||||
const quote = adjacentQuote(editor, false)
|
||||
if (quote === null) return false
|
||||
const block = quote.getParent()
|
||||
if (!$isElementNode(block)) return false
|
||||
let lineEnd = quote.getIndexWithinParent() + 1
|
||||
while (lineEnd < block.getChildrenSize()) {
|
||||
const child = block.getChildAtIndex(lineEnd)
|
||||
if ($isLineBreakNode(child)) break
|
||||
if ($isTextNode(child)) {
|
||||
const hardBreak = child.getTextContent().indexOf('\n')
|
||||
if (hardBreak >= 0) {
|
||||
event.preventDefault()
|
||||
child.select(hardBreak, hardBreak)
|
||||
return true
|
||||
}
|
||||
}
|
||||
lineEnd += 1
|
||||
}
|
||||
event.preventDefault()
|
||||
block.select(lineEnd, lineEnd)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the composer keymap on one editor.
|
||||
* @param editor - the shell-owned editor.
|
||||
@@ -101,6 +216,13 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer
|
||||
root?.addEventListener('compositionend', onCompositionEnd)
|
||||
syncComposition()
|
||||
}),
|
||||
editor.registerCommand(KEY_DOWN_COMMAND, (event) => {
|
||||
if (!isApplePlatform() || !event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return false
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === 'a') return moveBeforeQuote(editor, event)
|
||||
return false
|
||||
}, COMMAND_PRIORITY_CRITICAL),
|
||||
editor.registerCommand(MOVE_TO_END, event => movePastQuote(editor, event), COMMAND_PRIORITY_CRITICAL),
|
||||
editor.registerUpdateListener(syncComposition),
|
||||
editor.registerCommand(KEY_ARROW_UP_COMMAND, arrow('up'), COMMAND_PRIORITY_CRITICAL),
|
||||
editor.registerCommand(KEY_ARROW_DOWN_COMMAND, arrow('down'), COMMAND_PRIORITY_CRITICAL),
|
||||
|
||||
@@ -11,8 +11,9 @@ import type { ElementNode, LexicalNode, NodeKey, Point } from 'lexical'
|
||||
import {
|
||||
$getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode,
|
||||
} from 'lexical'
|
||||
import type { Occurrence } from '../../contract/draft-editor.ts'
|
||||
import type { DraftQuote, Occurrence } from '../../contract/draft-editor.ts'
|
||||
import type { CommandClaim } from '../../contract/input.ts'
|
||||
import { $isQuoteNode } from './QuoteNode.tsx'
|
||||
import { $isReferenceChipNode } from './chip-node.tsx'
|
||||
import { $isCommandChipNode } from './command-chip-node.tsx'
|
||||
|
||||
@@ -81,7 +82,7 @@ export function $composerLayout(): ComposerLayout {
|
||||
const kids = element.getChildren()
|
||||
children.set(element.getKey(), kids.map(kid => kid.getKey()))
|
||||
for (const kid of kids) {
|
||||
if ($isReferenceChipNode(kid) || $isCommandChipNode(kid)) {
|
||||
if ($isReferenceChipNode(kid) || $isCommandChipNode(kid) || $isQuoteNode(kid)) {
|
||||
pushLeaf('chip', kid, ATOMIC_CHAR, kid.getTextContent())
|
||||
} else if ($isTextNode(kid)) {
|
||||
const text = kid.getTextContent()
|
||||
@@ -160,6 +161,8 @@ export interface EditorProjection {
|
||||
readonly clipboardText: string
|
||||
/** InputState-compatible occurrence view (clipboardText coordinates). */
|
||||
readonly occurrences: readonly Occurrence[]
|
||||
/** Atomic quotes in clipboard coordinates. */
|
||||
readonly quotes: readonly DraftQuote[]
|
||||
/** Range selection in detect coordinates (ordered); null while absent or non-range. */
|
||||
readonly selection: { readonly start: number; readonly end: number } | null
|
||||
/** Collapsed caret in detect coordinates; null while the selection is absent or ranged. */
|
||||
@@ -200,8 +203,14 @@ export function $detectOffsetOfPoint(layout: ComposerLayout, point: Point): numb
|
||||
export function $projectComposer(idOf: (key: NodeKey) => number): EditorProjection {
|
||||
const layout = $composerLayout()
|
||||
const occurrences: Occurrence[] = []
|
||||
const quotes: DraftQuote[] = []
|
||||
for (const segment of layout.segments) {
|
||||
if (segment.kind !== 'chip' || !$isReferenceChipNode(segment.node)) continue
|
||||
if (segment.kind !== 'chip') continue
|
||||
if ($isQuoteNode(segment.node)) {
|
||||
quotes.push({ offset: segment.clipboardStart, length: segment.clipboardLength, text: segment.node.getQuoteText() })
|
||||
continue
|
||||
}
|
||||
if (!$isReferenceChipNode(segment.node)) continue
|
||||
const chip = segment.node
|
||||
occurrences.push({
|
||||
occurrenceId: idOf(chip.getKey()),
|
||||
@@ -229,6 +238,7 @@ export function $projectComposer(idOf: (key: NodeKey) => number): EditorProjecti
|
||||
detectText: layout.detectText,
|
||||
clipboardText: layout.clipboardText,
|
||||
occurrences,
|
||||
quotes,
|
||||
selection: range,
|
||||
caret: range !== null && range.start === range.end ? range.start : null,
|
||||
command: $isCommandChipNode(lead) ? lead.getClaim() : undefined,
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { LexicalEditor, NodeKey } from 'lexical'
|
||||
import {
|
||||
$addUpdateTag, $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection,
|
||||
BLUR_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_CRITICAL, createEditor, HISTORY_MERGE_TAG, PASTE_TAG,
|
||||
RootNode, SELECTION_CHANGE_COMMAND, SKIP_DOM_SELECTION_TAG,
|
||||
$addUpdateTag, $createParagraphNode, $createTextNode, $getAdjacentNode, $isTextNode,
|
||||
$getRoot, $getSelection, $getNodeByKey, $isRangeSelection,
|
||||
BLUR_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_BEFORE_CRITICAL, COMMAND_PRIORITY_CRITICAL, createEditor,
|
||||
HISTORY_MERGE_TAG, PASTE_TAG,
|
||||
RootNode, SELECTION_CHANGE_COMMAND, SKIP_DOM_SELECTION_TAG, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, HISTORY_PUSH_TAG,
|
||||
} from 'lexical'
|
||||
import { registerPlainText } from '@lexical/plain-text'
|
||||
import { createEmptyHistoryState, registerHistory } from '@lexical/history'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import type { DraftReference, ReferenceInsert } from '../../contract/draft-editor.ts'
|
||||
import type { DraftQuote, DraftReference, ReferenceInsert } from '../../contract/draft-editor.ts'
|
||||
import type { CommandClaim } from '../../contract/input.ts'
|
||||
import { QuoteNode, $createQuoteNode, $isQuoteNode, REMOVE_QUOTE_COMMAND } from './QuoteNode.tsx'
|
||||
import type { QuoteNodeLabels } from './QuoteNode.tsx'
|
||||
import { registerReferenceActivation } from './reference-activation.ts'
|
||||
import { ReferenceChipNode, $createReferenceChipNode } from './chip-node.tsx'
|
||||
import { CommandChipNode, $createCommandChipNode, registerCommandChipSeat } from './command-chip-node.tsx'
|
||||
@@ -26,6 +30,7 @@ type Lexicon = ReadonlyMap<'/' | '@', readonly string[]>
|
||||
/** Model callbacks read at the same editor registration and update points. */
|
||||
interface DraftEditorRuntimeDeps {
|
||||
readonly onUpdate: () => void
|
||||
readonly quoteLabels: () => QuoteNodeLabels
|
||||
readonly openReference: (source: string | undefined, reference: Pick<ReferenceInsert, 'ref' | 'appearance'>) => boolean
|
||||
readonly lexicon: () => Lexicon
|
||||
readonly resolveLexicon: () => ObservableSnapshot<Lexicon> | undefined
|
||||
@@ -62,7 +67,7 @@ export class DraftEditorRuntime {
|
||||
/** The editor bound by the Composer's contenteditable host. */
|
||||
readonly editor: LexicalEditor
|
||||
private projected: EditorProjection = {
|
||||
detectText: '', clipboardText: '', occurrences: [], selection: null, caret: null, command: undefined,
|
||||
detectText: '', clipboardText: '', occurrences: [], quotes: [], selection: null, caret: null, command: undefined,
|
||||
}
|
||||
/** Stable occurrence ids per chip NodeKey (undo restores keys, so ids survive it too). */
|
||||
private readonly occurrenceIds = new Map<NodeKey, number>()
|
||||
@@ -75,7 +80,7 @@ export class DraftEditorRuntime {
|
||||
constructor(private readonly deps: DraftEditorRuntimeDeps) {
|
||||
this.editor = createEditor({
|
||||
namespace: 'dsh-composer',
|
||||
nodes: [ReferenceChipNode, CommandChipNode, TextRefNode],
|
||||
nodes: [ReferenceChipNode, CommandChipNode, TextRefNode, QuoteNode],
|
||||
onError: (error) => { throw error },
|
||||
})
|
||||
}
|
||||
@@ -95,6 +100,10 @@ export class DraftEditorRuntime {
|
||||
}
|
||||
const unregister = mergeRegister(
|
||||
registerPlainText(this.editor),
|
||||
this.editor.registerNodeTransform(QuoteNode, (node) => { node.setLabels(this.deps.quoteLabels()) }),
|
||||
this.editor.registerCommand(REMOVE_QUOTE_COMMAND, key => this.removeQuote(key), COMMAND_PRIORITY_CRITICAL),
|
||||
this.editor.registerCommand(KEY_BACKSPACE_COMMAND, event => this.deleteAdjacentQuote(event, true), COMMAND_PRIORITY_BEFORE_CRITICAL),
|
||||
this.editor.registerCommand(KEY_DELETE_COMMAND, event => this.deleteAdjacentQuote(event, false), COMMAND_PRIORITY_BEFORE_CRITICAL),
|
||||
this.editor.registerCommand(BLUR_COMMAND, () => {
|
||||
// Finish this batch before an explicit focus can restore its updated selection.
|
||||
this.editor.update(preserveExternalSelection, { discrete: true })
|
||||
@@ -186,7 +195,7 @@ export class DraftEditorRuntime {
|
||||
*/
|
||||
setDraft(text: string): void {
|
||||
const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
|
||||
if (clean === this.projection.clipboardText && this.projection.occurrences.length === 0) return
|
||||
if (clean === this.projection.clipboardText && this.projection.occurrences.length === 0 && this.projection.quotes.length === 0) return
|
||||
this.editor.update(() => {
|
||||
$getRoot().clear()
|
||||
$appendParagraphs(clean)
|
||||
@@ -332,6 +341,60 @@ export class DraftEditorRuntime {
|
||||
return applied
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert one quote after the retained draft selection as an independent undo edit.
|
||||
* @param text - nonblank selected transcript text.
|
||||
* @param labels - current localized editor controls.
|
||||
* @returns whether the insertion position accepted the node.
|
||||
*/
|
||||
insertQuote(text: string, labels: QuoteNodeLabels): boolean {
|
||||
let applied = false
|
||||
this.applyEdit(() => {
|
||||
const projection = $projectComposer(key => this.occurrenceIdOf(key))
|
||||
const at = projection.selection?.end ?? projection.detectText.length
|
||||
applied = $replaceDetectSpanWithNodes({ start: at, end: at }, [$createQuoteNode(text, labels), $createTextNode(' ')])
|
||||
}, PASTE_TAG)
|
||||
return applied
|
||||
}
|
||||
|
||||
/** Remove one quote node and leave the editor caret at its former position. */
|
||||
private removeQuote(key: NodeKey): boolean {
|
||||
const node = $getNodeByKey(key)
|
||||
if (!$isQuoteNode(node)) return false
|
||||
const parent = node.getParent()
|
||||
const offset = node.getIndexWithinParent()
|
||||
const separator = node.getNextSibling()
|
||||
$addUpdateTag(HISTORY_PUSH_TAG)
|
||||
if ($isTextNode(separator) && separator.getTextContent().startsWith(' ')) {
|
||||
const remaining = separator.getTextContent().slice(1)
|
||||
if (remaining === '') separator.remove()
|
||||
else separator.setTextContent(remaining)
|
||||
}
|
||||
node.remove()
|
||||
const caret = Math.min(offset, parent?.getChildrenSize() ?? 0)
|
||||
parent?.select(caret, caret)
|
||||
queueMicrotask(() => { this.editor.getRootElement()?.focus({ preventScroll: true }) })
|
||||
return true
|
||||
}
|
||||
|
||||
/** Delete the QuoteNode immediately beside one collapsed caret. */
|
||||
private deleteAdjacentQuote(event: KeyboardEvent, backward: boolean): boolean {
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
|
||||
let adjacent = $getAdjacentNode(selection.anchor, backward)
|
||||
if (!$isQuoteNode(adjacent) && backward && selection.anchor.type === 'text') {
|
||||
const anchor = selection.anchor.getNode()
|
||||
if ($isTextNode(anchor)
|
||||
&& selection.anchor.offset === 1
|
||||
&& anchor.getTextContent().startsWith(' ')) {
|
||||
adjacent = anchor.getPreviousSibling()
|
||||
}
|
||||
}
|
||||
if (!$isQuoteNode(adjacent)) return false
|
||||
event.preventDefault()
|
||||
return this.removeQuote(adjacent.getKey())
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear committed content using the model's suffix decision inside the editor update.
|
||||
* @param prefixLength - returns the clipboard-prefix length to remove, or null to clear the root.
|
||||
@@ -356,8 +419,10 @@ export class DraftEditorRuntime {
|
||||
* Import semantic content or a model-selected failure snapshot with fresh reference nodes.
|
||||
* @param draft - clipboard text.
|
||||
* @param occurrences - reference occurrences in clipboard order.
|
||||
* @param quotes - atomic quotes in clipboard order.
|
||||
* @param labels - localized quote controls.
|
||||
*/
|
||||
restoreDraft(draft: string, occurrences: readonly DraftReference[]): void {
|
||||
restoreDraft(draft: string, occurrences: readonly DraftReference[], quotes: readonly DraftQuote[] = [], labels: QuoteNodeLabels = { remove: '' }): void {
|
||||
this.editor.update(() => {
|
||||
const root = $getRoot()
|
||||
root.clear()
|
||||
@@ -375,15 +440,26 @@ export class DraftEditorRuntime {
|
||||
}
|
||||
}
|
||||
let cursor = 0
|
||||
for (const occurrence of occurrences) {
|
||||
const spans = [
|
||||
...occurrences.map(reference => ({ kind: 'reference' as const, value: reference })),
|
||||
...quotes.map(quote => ({ kind: 'quote' as const, value: quote })),
|
||||
].sort((a, b) => a.value.offset - b.value.offset)
|
||||
for (const span of spans) {
|
||||
const occurrence = span.value
|
||||
appendText(draft.slice(cursor, occurrence.offset))
|
||||
if (span.kind === 'quote') {
|
||||
paragraph.append($createQuoteNode(span.value.text, labels))
|
||||
cursor = occurrence.offset + occurrence.length
|
||||
continue
|
||||
}
|
||||
const reference = span.value
|
||||
paragraph.append(new ReferenceChipNode({
|
||||
source: occurrence.source,
|
||||
ref: occurrence.ref,
|
||||
label: occurrence.label,
|
||||
...(occurrence.appearance === undefined ? {} : { appearance: occurrence.appearance }),
|
||||
clipboardText: occurrence.clipboardText,
|
||||
}, occurrence.invalid === true))
|
||||
source: reference.source,
|
||||
ref: reference.ref,
|
||||
label: reference.label,
|
||||
...(reference.appearance === undefined ? {} : { appearance: reference.appearance }),
|
||||
clipboardText: reference.clipboardText,
|
||||
}, reference.invalid === true))
|
||||
cursor = occurrence.offset + occurrence.length
|
||||
}
|
||||
appendText(draft.slice(cursor))
|
||||
|
||||
@@ -20,9 +20,10 @@ import type {
|
||||
SessionInput, SubmitAttempt, SubmitAttachment, SubmitOutcome,
|
||||
} from '../contract/input.ts'
|
||||
import type {
|
||||
ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, DraftInput, DraftSnapshot, Occurrence, ReferenceInsert, TokenSpan,
|
||||
ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, DraftQuote, DraftInput, DraftSnapshot, Occurrence, ReferenceInsert, TokenSpan,
|
||||
} from '../contract/draft-editor.ts'
|
||||
import type { InputSubmitMode, MessageSubmission, MessageSubmissionState } from '../contract/composer-submission.ts'
|
||||
import { serializeDraftPrompt } from './prompt.ts'
|
||||
import { SubmitMachine } from './machine.ts'
|
||||
import { resolveDraftInput, snapshotDraft } from '../draft.ts'
|
||||
import { DraftEditorRuntime } from './editor/runtime.ts'
|
||||
@@ -63,6 +64,10 @@ export interface SessionInputDeps {
|
||||
mode: InputSubmitMode,
|
||||
signal: AbortSignal,
|
||||
): Promise<SubmitOutcome>
|
||||
/** Live Session and composer-block policy for transcript insertion. */
|
||||
quoteInsertionAllowed?: () => boolean
|
||||
/** Current localized quote removal copy. */
|
||||
quoteRemoveLabel?: () => string
|
||||
/** Command-plane attachment plumbing (the hub owns the conversation face and the copy). */
|
||||
commandAttachments: {
|
||||
/** Resolve ordered draft ids to wire payloads without sending them; rejects when an id no longer resolves. */
|
||||
@@ -87,6 +92,10 @@ function guardOf(phase: InputState['phase']): 'plain' | 'claimed' | 'frozen' {
|
||||
function projectionContentChanged(prev: EditorProjection, next: EditorProjection): boolean {
|
||||
if (prev.clipboardText !== next.clipboardText || prev.detectText !== next.detectText) return true
|
||||
if (prev.command !== next.command) return true
|
||||
if (prev.quotes.length !== next.quotes.length || prev.quotes.some((quote, i) => {
|
||||
const old = next.quotes[i]
|
||||
return old?.text !== quote.text || old.offset !== quote.offset
|
||||
})) return true
|
||||
if (prev.occurrences.length !== next.occurrences.length) return true
|
||||
return next.occurrences.some((occ, i) => {
|
||||
const old = prev.occurrences[i]
|
||||
@@ -103,6 +112,7 @@ const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
|
||||
interface DetachedDraft {
|
||||
readonly draft: string
|
||||
readonly occurrences: readonly Occurrence[]
|
||||
readonly quotes: readonly DraftQuote[]
|
||||
readonly attachmentIds: readonly DraftAttachmentId[]
|
||||
}
|
||||
|
||||
@@ -122,6 +132,7 @@ export class SessionInputShell implements SessionInput {
|
||||
}
|
||||
/** The public provide-channel action face (one stable identity per session). */
|
||||
readonly actions: InputActions = {
|
||||
insertQuote: text => this.insertQuote(text),
|
||||
captureInsertion: () => ({ ...this.caretSpan(), draftRev: this.rev }),
|
||||
insertText: (text, span) => {
|
||||
if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting' || this.disposed) return false
|
||||
@@ -175,6 +186,7 @@ export class SessionInputShell implements SessionInput {
|
||||
constructor(private readonly deps: SessionInputDeps) {
|
||||
this.draftEditor = new DraftEditorRuntime({
|
||||
onUpdate: () => { this.onEditorUpdate() },
|
||||
quoteLabels: () => ({ remove: this.deps.quoteRemoveLabel?.() ?? '' }),
|
||||
openReference: (source, reference) =>
|
||||
this.deps.inputTriggers?.()?.openReference(source, reference) ?? false,
|
||||
lexicon: () => this.lexicon.getSnapshot(),
|
||||
@@ -221,13 +233,21 @@ export class SessionInputShell implements SessionInput {
|
||||
setDraft(text: DraftInput): void {
|
||||
const draft = resolveDraftInput(text)
|
||||
this.cancelCommandRestore()
|
||||
if (draft.references.length === 0) this.draftEditor.setDraft(draft.text)
|
||||
else this.draftEditor.restoreDraft(draft.text, draft.references)
|
||||
if (draft.references.length === 0 && (draft.quotes?.length ?? 0) === 0) this.draftEditor.setDraft(draft.text)
|
||||
else this.draftEditor.restoreDraft(draft.text, draft.references, draft.quotes, { remove: this.deps.quoteRemoveLabel?.() ?? '' })
|
||||
if (draft.command === undefined) return
|
||||
this.pendingCommand = { token: draft.command.token, rev: this.rev }
|
||||
this.restoreCommand()
|
||||
}
|
||||
|
||||
/** @param text - selected transcript text. @returns whether the editable draft accepted the quote. */
|
||||
insertQuote(text: string): boolean {
|
||||
if (this.disposed || this.snapshot.phase !== 'plain' || this.deps.quoteInsertionAllowed?.() === false || text.trim() === '') return false
|
||||
const inserted = this.draftEditor.insertQuote(text.replace(/\r\n?/gu, '\n'), { remove: this.deps.quoteRemoveLabel?.() ?? '' })
|
||||
if (inserted) this.focus()
|
||||
return inserted
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect optional catalog notifications and resume a pending command
|
||||
* chip restore without waiting for another edit.
|
||||
@@ -272,7 +292,9 @@ export class SessionInputShell implements SessionInput {
|
||||
/** Current semantic document, stable until its content revision changes. */
|
||||
get draftSnapshot(): DraftSnapshot {
|
||||
if (this.draftSnapshotCache?.revision === this.rev) return this.draftSnapshotCache.value
|
||||
const value = snapshotDraft(this.projection.clipboardText, this.projection.occurrences, this.projection.command?.token)
|
||||
const value = snapshotDraft(
|
||||
this.projection.clipboardText, this.projection.occurrences, this.projection.command?.token, this.projection.quotes,
|
||||
)
|
||||
this.draftSnapshotCache = { revision: this.rev, value }
|
||||
return value
|
||||
}
|
||||
@@ -744,45 +766,27 @@ export class SessionInputShell implements SessionInput {
|
||||
const attachmentIds = [...this.attachmentIds]
|
||||
this.attachmentIds = []
|
||||
const occurrences = this.projection.occurrences
|
||||
const record = { draft, occurrences, attachmentIds }
|
||||
const quotes = this.projection.quotes
|
||||
const record = { draft, occurrences, quotes, attachmentIds }
|
||||
this.detachedDrafts.set(attempt.seq, record)
|
||||
if (this.failedRestoreRev === this.rev) {
|
||||
this.failedDetached.clear()
|
||||
this.failedRestoreRev = undefined
|
||||
}
|
||||
if (occurrences.length === 0) {
|
||||
this.settleSink(attempt, this.deps.defaultSink(draft.trim(), attachmentIds, mode, attempt.signal))
|
||||
return
|
||||
}
|
||||
const inputTriggers = this.deps.inputTriggers?.()
|
||||
void Promise.all(occurrences.map(async (o) => {
|
||||
if (inputTriggers === undefined) throw new Error(`no serializer for reference source "${o.source}"`)
|
||||
return {
|
||||
offset: o.offset,
|
||||
length: o.length,
|
||||
text: await inputTriggers.serializeReference(o.source, o.ref, attempt.signal),
|
||||
}
|
||||
})).then(
|
||||
(parts) => {
|
||||
if (this.disposed) return
|
||||
// Splice model forms over their clipboard ranges (offsets are
|
||||
// clipboard-projection; parts arrive offset-sorted since chips walk in
|
||||
// document order).
|
||||
let out = ''
|
||||
let cursor = 0
|
||||
for (const part of parts) {
|
||||
out += draft.slice(cursor, part.offset) + part.text
|
||||
cursor = part.offset + part.length
|
||||
}
|
||||
out += draft.slice(cursor)
|
||||
this.settleSink(attempt, this.deps.defaultSink(out.trim(), attachmentIds, mode, attempt.signal))
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (this.dead(attempt)) return
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
this.settleDetachedFailure(attempt, message)
|
||||
},
|
||||
)
|
||||
const prompt = serializeDraftPrompt(snapshotDraft(draft, occurrences, undefined, quotes), async (source, ref) => {
|
||||
if (inputTriggers === undefined) throw new Error(`no serializer for reference source "${source}"`)
|
||||
return inputTriggers.serializeReference(source, ref, attempt.signal)
|
||||
})
|
||||
const send = (text: string): void => {
|
||||
if (this.dead(attempt)) return
|
||||
this.settleSink(attempt, this.deps.defaultSink(text, attachmentIds, mode, attempt.signal))
|
||||
}
|
||||
if (!(prompt instanceof Promise)) { send(prompt); return }
|
||||
void prompt.then(send, (error: unknown) => {
|
||||
if (this.dead(attempt)) return
|
||||
this.settleDetachedFailure(attempt, error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
}
|
||||
|
||||
private notifySubmission(submission: MessageSubmission | undefined): void {
|
||||
@@ -832,17 +836,19 @@ export class SessionInputShell implements SessionInput {
|
||||
const separator = '\n\n'
|
||||
let draft = ''
|
||||
const occurrences: Occurrence[] = []
|
||||
const quotes: DraftQuote[] = []
|
||||
for (const record of records) {
|
||||
const base = draft.length + (draft === '' ? 0 : separator.length)
|
||||
if (draft !== '') draft += separator
|
||||
draft += record.draft
|
||||
quotes.push(...record.quotes.map(quote => ({ ...quote, offset: base + quote.offset })))
|
||||
for (const occurrence of record.occurrences) {
|
||||
occurrences.push({ ...occurrence, offset: base + occurrence.offset })
|
||||
}
|
||||
}
|
||||
this.restoringFailures = true
|
||||
try {
|
||||
this.draftEditor.restoreDraft(draft, occurrences)
|
||||
this.draftEditor.restoreDraft(draft, occurrences, quotes, { remove: this.deps.quoteRemoveLabel?.() ?? '' })
|
||||
this.draftEditor.clearHistory()
|
||||
this.failedRestoreRev = this.rev
|
||||
} finally {
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
SessionInputResolver, SessionInput, SubmitOutcome,
|
||||
} from '../contract/input.ts'
|
||||
import type { ComposerKeyboard } from '../contract/draft-editor.ts'
|
||||
import type { ComposerBlocks } from '../contract/composer-blocks.ts'
|
||||
import type { InputSubmitMode } from '../contract/composer-submission.ts'
|
||||
import type { PopupDismissFace } from './facade.ts'
|
||||
import { SessionInputShell } from './facade.ts'
|
||||
@@ -40,6 +41,7 @@ interface InputTriggerServiceFace {
|
||||
|
||||
/** Attachment-send face resolved lazily to keep hub/service construction acyclic. */
|
||||
interface ConversationAttachmentFace {
|
||||
readonly blocks: ComposerBlocks
|
||||
sendSession(
|
||||
session: SessionFace,
|
||||
text: string,
|
||||
@@ -123,6 +125,13 @@ export class InputHub implements SessionInputResolver {
|
||||
popup: () => this.popup(actx),
|
||||
inbox: session.projections.faceOf('inbox') as ObservableSnapshot<InboxState | undefined>,
|
||||
defaultSink: (text, attachmentIds, mode, signal) => this.sink(session, text, attachmentIds, mode, signal),
|
||||
quoteRemoveLabel: () => this.t('quote.remove'),
|
||||
quoteInsertionAllowed: () => {
|
||||
const state = session.getSnapshot()
|
||||
return !state.removed && (state.subagent === null
|
||||
|| (state.subagent.address.mode === 'continuable' && state.subagent.parentAvailable === true))
|
||||
&& this.conversation().blocks.storeFor(session.sessionId).getSnapshot() === undefined
|
||||
},
|
||||
steerQueue: () => { void this.steerQueue(session, shell) },
|
||||
commandAttachments: {
|
||||
serialize: async (ids) => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/** Serialize semantic draft spans without turning quoted references into gestures. */
|
||||
import { formatQuoteText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { DraftSnapshot } from '../contract/draft-editor.ts'
|
||||
|
||||
type DraftToken = { readonly kind: 'text'; text: string } | { readonly kind: 'quote'; readonly text: string }
|
||||
interface DraftSpan {
|
||||
readonly offset: number
|
||||
readonly length: number
|
||||
readonly token: DraftToken
|
||||
}
|
||||
|
||||
const QUOTE_SEPARATOR = '\n\n'
|
||||
|
||||
function pushText(tokens: DraftToken[], text: string): void {
|
||||
if (text === '') return
|
||||
const last = tokens.at(-1)
|
||||
if (last?.kind === 'text') last.text += text
|
||||
else tokens.push({ kind: 'text', text })
|
||||
}
|
||||
|
||||
function normalizeQuoteBoundaries(tokens: DraftToken[]): DraftToken[] {
|
||||
const normalized: DraftToken[] = []
|
||||
for (const token of tokens) {
|
||||
if (token.kind === 'text') {
|
||||
pushText(normalized, token.text)
|
||||
continue
|
||||
}
|
||||
const previous = normalized.at(-1)
|
||||
if (previous?.kind === 'text') {
|
||||
previous.text = previous.text.trimEnd()
|
||||
if (previous.text !== '') previous.text += QUOTE_SEPARATOR
|
||||
else normalized.pop()
|
||||
}
|
||||
if (normalized.at(-1)?.kind === 'quote') {
|
||||
normalized.push({ kind: 'text', text: QUOTE_SEPARATOR })
|
||||
}
|
||||
normalized.push(token)
|
||||
}
|
||||
for (let index = 0; index < normalized.length; index++) {
|
||||
const token = normalized[index]
|
||||
if (token?.kind !== 'quote') continue
|
||||
const next = normalized[index + 1]
|
||||
if (next?.kind !== 'text') continue
|
||||
if (next.text === QUOTE_SEPARATOR && normalized[index + 2]?.kind === 'quote') continue
|
||||
next.text = next.text.trimStart()
|
||||
if (next.text !== '') next.text = `${QUOTE_SEPARATOR}${next.text}`
|
||||
}
|
||||
return normalized.filter(token => token.kind === 'quote' || token.text !== '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the captured draft through reference codecs, formatting quotes as Markdown.
|
||||
* @param draft - one semantic draft revision.
|
||||
* @param serializeReference - owner-routed reference serializer.
|
||||
* @returns synchronous content without references, otherwise the resolving promise.
|
||||
*/
|
||||
export function serializeDraftPrompt(
|
||||
draft: DraftSnapshot,
|
||||
serializeReference: (source: string, ref: string) => Promise<string>,
|
||||
): string | Promise<string> {
|
||||
const build = (references: readonly DraftSpan[]): string => {
|
||||
const spans = [
|
||||
...references,
|
||||
...(draft.quotes ?? []).map(quote => ({ offset: quote.offset, length: quote.length,
|
||||
token: { kind: 'quote' as const, text: quote.text } })),
|
||||
].sort((a, b) => a.offset - b.offset)
|
||||
const tokens: DraftToken[] = []
|
||||
let cursor = 0
|
||||
for (const span of spans) {
|
||||
pushText(tokens, draft.text.slice(cursor, span.offset))
|
||||
if (span.token.kind === 'text') pushText(tokens, span.token.text)
|
||||
else tokens.push(span.token)
|
||||
cursor = span.offset + span.length
|
||||
}
|
||||
pushText(tokens, draft.text.slice(cursor))
|
||||
const first = tokens[0]
|
||||
if (first?.kind === 'text') first.text = first.text.trimStart()
|
||||
const last = tokens.at(-1)
|
||||
if (last?.kind === 'text') last.text = last.text.trimEnd()
|
||||
return normalizeQuoteBoundaries(tokens)
|
||||
.map(token => token.kind === 'quote' ? formatQuoteText(token.text) : token.text).join('')
|
||||
}
|
||||
if (draft.references.length === 0) return build([])
|
||||
return Promise.all(draft.references.map(async reference => ({
|
||||
offset: reference.offset, length: reference.length,
|
||||
token: { kind: 'text' as const, text: await serializeReference(reference.source, reference.ref) },
|
||||
}))).then(build)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan'
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'quote.remove': '移除引用',
|
||||
'shortcut.newline': '换行',
|
||||
'shortcut.complementary': '使用互补的 Queue/Steer 方式',
|
||||
'shortcut.slash': '打开命令菜单',
|
||||
@@ -390,6 +391,7 @@ export type ConversationKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'quote.remove': 'Remove quote',
|
||||
'shortcut.newline': 'New line',
|
||||
'shortcut.complementary': 'Use the complementary Queue / Steer action',
|
||||
'shortcut.slash': 'Open command menu',
|
||||
|
||||
@@ -229,7 +229,8 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load
|
||||
)}
|
||||
<span className={css.preview}>{renderFactorySlot('conversation.user.text', {
|
||||
text: row.content.filter(block => block.type === 'text').map(block => block.text).join(''),
|
||||
facts: QUEUE_TEXT_FACTS, interactive: false, ellipsis: true,
|
||||
facts: QUEUE_TEXT_FACTS,
|
||||
interactive: false, ellipsis: true,
|
||||
})}</span>
|
||||
</>
|
||||
)}
|
||||
@@ -345,7 +346,9 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load
|
||||
</span>
|
||||
)}
|
||||
<span className={css.preview}>{renderFactorySlot('conversation.user.text', {
|
||||
text: submission.text, facts: QUEUE_TEXT_FACTS, interactive: false, ellipsis: true,
|
||||
text: submission.text,
|
||||
facts: QUEUE_TEXT_FACTS,
|
||||
interactive: false, ellipsis: true,
|
||||
})}</span>
|
||||
<span className={css.status} role="status">{t('queue.sending')}</span>
|
||||
{queueMutable && <div className={css.actions}>
|
||||
|
||||
@@ -17,6 +17,14 @@
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
/* Narrow settings panel: the 48px gutter separates the label from its control
|
||||
across a wide row and only squeezes the label here. */
|
||||
@media (max-width: 767px) {
|
||||
:global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .rowText {
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** User-text presentation delegates evaluation to its data-layer document and renders JSON business nodes. */
|
||||
import { Fragment, useEffect, useMemo } from 'react'
|
||||
import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { ReferenceChip, UserTextRun } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { HoverCardGroup, ReferenceChip, UserTextRun } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { UserTextNodeProps } from '../contract/slots.ts'
|
||||
import type { UserTextNode } from '../contract/user-text.ts'
|
||||
import css from './UserText.module.css'
|
||||
@@ -42,7 +42,7 @@ export function UserText({
|
||||
})}
|
||||
</Fragment>
|
||||
})}</>, [nodes, text, interactive, renderSlot])
|
||||
return <span className={ellipsis ? css.ellipsis : css.root}>{content}</span>
|
||||
return <HoverCardGroup as="span" className={ellipsis ? css.ellipsis : css.root}>{content}</HoverCardGroup>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client'
|
||||
import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import * as messageQuotes from '@deepseek-ai/dsh-message-quotes/client'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import {
|
||||
SlotTestRuntime, stubConfigForm, usePinnedBrowserLanguages,
|
||||
@@ -23,6 +24,12 @@ import { createConversationStore } from '../src/client/stores.ts'
|
||||
import type { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
'quote-probe': { kind: 'single'; scope: 'session' }
|
||||
}
|
||||
}
|
||||
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
const ROOT = 'root-1' as SessionId
|
||||
@@ -38,7 +45,7 @@ function sessionFakeFor() {
|
||||
} satisfies SessionBehaviorOverrides
|
||||
}
|
||||
|
||||
async function bench() {
|
||||
async function bench(renderQuote = false) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const rootUpload = vi.fn(() => Promise.resolve({
|
||||
ok: true as const,
|
||||
@@ -98,9 +105,15 @@ async function bench() {
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.root.declare({
|
||||
'main': { kind: 'keyed', scope: 'root' },
|
||||
}, (_props: { renderSlot?: unknown }) => null)
|
||||
'quote-probe': { kind: 'single', scope: 'session' },
|
||||
}, ({ renderFactorySlot, SessionProvider }) => renderQuote ? (
|
||||
<SessionProvider session={rootReference}>
|
||||
{renderFactorySlot('conversation.user.text', { text: '> @literal /review', facts: {} })}
|
||||
</SessionProvider>
|
||||
) : null)
|
||||
|
||||
const feature = await runtime.mount({ inject: [...inject], apply })
|
||||
if (renderQuote) await runtime.mount(messageQuotes)
|
||||
runtime.renderRoot()
|
||||
const entryOf = (key: 'main.conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar') =>
|
||||
runtime.slots.entries(key)[0]!
|
||||
@@ -145,6 +158,17 @@ async function bench() {
|
||||
}
|
||||
|
||||
describe('Conversation inject API', () => {
|
||||
it('renders literal quote text through the actual Factory and keyed node registration', async () => {
|
||||
const b = await bench(true)
|
||||
onTestFinished(() => b.runtime.dispose())
|
||||
const errors = vi.fn()
|
||||
const unsubscribe = b.slots.onEntryError(errors)
|
||||
onTestFinished(unsubscribe)
|
||||
await vi.waitFor(() => { expect(document.querySelector('[data-quote-preview-text]')?.textContent).toBe('@literal /review') })
|
||||
expect(document.querySelector('[data-slot-error]')).toBeNull()
|
||||
expect(errors).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('owns the File action, reads its mounted composer availability, and unregisters on disposal', async () => {
|
||||
const b = await bench()
|
||||
onTestFinished(() => b.runtime.dispose())
|
||||
|
||||
@@ -13,6 +13,7 @@ import { parseStoredDraft, resolveDraftInput, snapshotDraft } from '../src/clien
|
||||
import { scanTextRefs } from '../src/client/input/decorations.ts'
|
||||
import { ReferenceChipNode } from '../src/client/input/editor/chip-node.tsx'
|
||||
import { CommandChipNode } from '../src/client/input/editor/command-chip-node.tsx'
|
||||
import { QuoteNode } from '../src/client/input/editor/QuoteNode.tsx'
|
||||
import { TextRefNode } from '../src/client/input/editor/text-ref.ts'
|
||||
import { SessionInputShell, type SessionInputDeps } from '../src/client/input/facade.ts'
|
||||
|
||||
@@ -302,6 +303,33 @@ describe('draft documents in the input shell', () => {
|
||||
await vi.waitFor(() => { expect(claim.submitted).toEqual(['ship']) })
|
||||
})
|
||||
|
||||
it('restores a command and an atomic quote together without changing their text or offsets', async () => {
|
||||
const original = makeShell()
|
||||
original.setDraft('/goal ')
|
||||
expect(original.insertQuote('selected source text')).toBe(true)
|
||||
expect(original.beginCommand(goalClaim(), { start: 0, end: 6, draftRev: original.snapshot.draftRev })).toBe(true)
|
||||
const stored = parseStoredDraft(JSON.parse(JSON.stringify(original.draftSnapshot)))
|
||||
expect(stored).toEqual({
|
||||
text: '/goal > selected source text ', references: [], command: { token: '/goal ' },
|
||||
quotes: [{ offset: 6, length: 22, text: 'selected source text' }],
|
||||
})
|
||||
if (stored === undefined) throw new Error('Expected a stored draft')
|
||||
const claim = goalClaim()
|
||||
const provider: InputTriggerController = {
|
||||
...triggerProvider(createSnapshotStore(new Map())),
|
||||
resolveClaim: () => Promise.resolve(claim),
|
||||
}
|
||||
const restored = makeShell({ inputTriggers: () => provider })
|
||||
restored.setDraft(stored)
|
||||
await vi.waitFor(() => { expect(restored.snapshot.phase).toBe('claimed') })
|
||||
expect(commandChips(restored)).toBe(1)
|
||||
expect(restored.editor.getEditorState().read(() => $nodesOfType(QuoteNode).map(node => node.getQuoteText())))
|
||||
.toEqual(['selected source text'])
|
||||
expect(restored.draftSnapshot).toEqual(stored)
|
||||
restored.submit()
|
||||
await vi.waitFor(() => { expect(claim.submitted).toEqual(['> selected source text ']) })
|
||||
})
|
||||
|
||||
it('waits for the trigger pipeline before resolving a restored command chip', async () => {
|
||||
const stored = storedGoalDraft()
|
||||
const resolveClaim = vi.fn<InputTriggerController['resolveClaim']>(() => Promise.resolve(goalClaim()))
|
||||
|
||||
@@ -37,6 +37,7 @@ function fixture(start = 1, end = 4) {
|
||||
const onUpdate = vi.fn()
|
||||
const runtime: DraftEditorRuntime = new DraftEditorRuntime({
|
||||
onUpdate: () => { runtime.refreshProjection(); onUpdate() },
|
||||
quoteLabels: () => ({ remove: 'Remove quote' }),
|
||||
openReference: () => false,
|
||||
lexicon: () => new Map(), resolveLexicon: () => undefined,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Plugin dispatch is synchronous, scoped, and independent of browser default cancellation. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { TextInteractionRequest } from '../src/client/contract/text-interaction.ts'
|
||||
import { ConversationInteractionRegistry } from '../src/client/conversation/interaction-registry.ts'
|
||||
|
||||
function fixture() {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
return new ConversationInteractionRegistry(ctx)
|
||||
}
|
||||
|
||||
const sessionId = SessionId('interaction')
|
||||
const gesture = () => ({ trigger: 'selection' as const, hasSelection: true, readText: () => 'selected', preventDefault: vi.fn() })
|
||||
|
||||
describe('Conversation interaction registrations', () => {
|
||||
it('passes the session to consumers and stops at the first opened surface', () => {
|
||||
const registry = fixture()
|
||||
const pass = vi.fn()
|
||||
const later = vi.fn()
|
||||
registry.register({ id: 'pass', onInteraction: pass })
|
||||
registry.register({ id: 'quote', onInteraction: (request) => { request.open() } })
|
||||
registry.register({ id: 'later', onInteraction: later })
|
||||
const request = gesture()
|
||||
expect(registry.dispatch(sessionId, request)).toBe('quote')
|
||||
expect(pass).toHaveBeenCalledWith(expect.objectContaining({ sessionId, trigger: 'selection' }))
|
||||
expect(later).not.toHaveBeenCalled()
|
||||
expect(request.preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows cancellation without opening and ignores retained gesture methods', () => {
|
||||
const registry = fixture()
|
||||
let retained: TextInteractionRequest | undefined
|
||||
registry.register({ id: 'menu', onInteraction: (request) => { retained = request; request.preventDefault() } })
|
||||
const request = gesture()
|
||||
expect(registry.dispatch(sessionId, request)).toBeNull()
|
||||
expect(request.preventDefault).toHaveBeenCalledOnce()
|
||||
retained!.open()
|
||||
retained!.preventDefault()
|
||||
expect(request.preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('publishes removal and rejects empty or duplicate IDs', () => {
|
||||
const registry = fixture()
|
||||
const changed = vi.fn()
|
||||
const off = registry.subscribe(changed)
|
||||
expect(() => registry.register({ id: '', onInteraction: () => {} })).toThrow('must not be empty')
|
||||
const dispose = registry.register({ id: 'quote', onInteraction: (request) => { request.open() } })
|
||||
const ids = registry.getSnapshot()
|
||||
expect(ids).toEqual(['quote'])
|
||||
expect(registry.getSnapshot()).toBe(ids)
|
||||
expect(() => registry.register({ id: 'quote', onInteraction: () => {} })).toThrow('Duplicate')
|
||||
dispose()
|
||||
dispose()
|
||||
expect(registry.getSnapshot()).toEqual([])
|
||||
expect(changed).toHaveBeenCalledTimes(2)
|
||||
expect(registry.dispatch(sessionId, gesture())).toBeNull()
|
||||
off()
|
||||
})
|
||||
})
|
||||
@@ -148,6 +148,21 @@ function imageRow(id: string, refId: string, text = ''): UserMessage {
|
||||
}
|
||||
|
||||
describe('QueueDock', () => {
|
||||
it('keeps the same quote preview when a queued echo hands off to Host content', () => {
|
||||
const requestId = 'quote-handoff' as SessionSnapshot['pendingSubmissions'][number]['requestId']
|
||||
const content = [{ type: 'text' as const, text: '> @literal /review' }]
|
||||
const initial = { ...snapshotWith([]), pendingSubmissions: [{ requestId, placement: 'queued' as const,
|
||||
time: 1000, text: content[0]!.text, attachments: [] }] }
|
||||
const source = liveSession(initial)
|
||||
const view = render(<QueueDock {...kitFor(initial)} useSession={source.useSession} useProjection={source.useProjection} />)
|
||||
expect(view.container.querySelector('[data-quote-preview-text]')?.textContent).toBe('@literal /review')
|
||||
const queued: UserMessage = { ...row('accepted-quote', ''), content, source: { kind: 'user', rpcId: requestId } }
|
||||
act(() => { source.push(snapshotWith([queued])) })
|
||||
expect(view.container.querySelectorAll('[data-quote-preview]')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[data-quote-preview-text]')?.textContent).toBe('@literal /review')
|
||||
expect(view.container.querySelector('[data-ref-chip]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['ABC', 'ACB', 'BAC', 'BCA', 'CAB', 'CBA'])(
|
||||
'keeps repeated queued submissions in Dock through acceptance and FIFO claims (%s)', (hostOrder) => {
|
||||
const pending: SessionSnapshot['pendingSubmissions'] = ['A', 'B', 'C'].map(id => ({
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import {
|
||||
KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, REDO_COMMAND, UNDO_COMMAND,
|
||||
$getRoot, $getSelection, $isRangeSelection, $nodesOfType,
|
||||
} from 'lexical'
|
||||
import type { DraftSnapshot } from '../src/client/contract/draft-editor.ts'
|
||||
import type { SubmitOutcome } from '../src/client/contract/input.ts'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import {
|
||||
QuoteNode, QuoteNodeView, REMOVE_QUOTE_COMMAND,
|
||||
} from '../src/client/input/editor/QuoteNode.tsx'
|
||||
import { $selectDetectSpan } from '../src/client/input/editor/span-map.ts'
|
||||
|
||||
const quote = 'selected source text'
|
||||
|
||||
function bench(quoteInsertionAllowed?: () => boolean) {
|
||||
const sink = vi.fn(() => Promise.resolve<SubmitOutcome>({ kind: 'success' }))
|
||||
const shell = new SessionInputShell({
|
||||
actx: {} as Context,
|
||||
defaultSink: sink,
|
||||
commandAttachments: {
|
||||
serialize: () => Promise.resolve([]),
|
||||
release: () => {},
|
||||
unsupportedNotice: () => 'unsupported',
|
||||
},
|
||||
quoteRemoveLabel: () => 'Remove quote',
|
||||
...(quoteInsertionAllowed === undefined ? {} : { quoteInsertionAllowed }),
|
||||
})
|
||||
return { shell, sink }
|
||||
}
|
||||
|
||||
function quoteKeys(shell: SessionInputShell): string[] {
|
||||
return shell.editor.getEditorState().read(() => $nodesOfType(QuoteNode).map(node => node.getKey()))
|
||||
}
|
||||
|
||||
describe('QuoteNode composer lifecycle', () => {
|
||||
it('preserves the draft when its Session cannot accept an inserted quote', () => {
|
||||
let allowed = false
|
||||
const { shell } = bench(() => allowed)
|
||||
shell.setDraft('base ')
|
||||
const before = shell.draftSnapshot
|
||||
expect(shell.insertQuote(quote)).toBe(false)
|
||||
expect(shell.draftSnapshot).toBe(before)
|
||||
allowed = true
|
||||
expect(shell.insertQuote(quote)).toBe(true)
|
||||
})
|
||||
|
||||
it('renders quote text as a non-interactive container and keeps close accessible', () => {
|
||||
const dispatchCommand = vi.fn((_command: unknown, _payload: unknown) => true)
|
||||
const parentKeyDown = vi.fn()
|
||||
const editor = { dispatchCommand } as never
|
||||
const view = render(
|
||||
<div onKeyDown={parentKeyDown}>
|
||||
<QuoteNodeView
|
||||
editor={editor}
|
||||
nodeKey={'quote-key'}
|
||||
text={quote}
|
||||
labels={{ remove: 'Remove quote' }}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
const text = view.container.querySelector<HTMLElement>('[data-quote-preview]')!
|
||||
expect(text.getAttribute('role')).toBeNull()
|
||||
expect(text.getAttribute('tabindex')).toBeNull()
|
||||
expect(text.getAttribute('title')).toBeNull()
|
||||
fireEvent.click(text)
|
||||
expect(dispatchCommand).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(view.getByRole('button', { name: 'Remove quote' }), { key: 'Enter' })
|
||||
expect(parentKeyDown).not.toHaveBeenCalled()
|
||||
fireEvent.click(view.getByRole('button', { name: 'Remove quote' }))
|
||||
expect(dispatchCommand).toHaveBeenCalledWith(REMOVE_QUOTE_COMMAND, 'quote-key')
|
||||
expect(view.queryByRole('link')).toBeNull()
|
||||
})
|
||||
|
||||
it('persists through DraftSnapshot restore as an atomic quote', () => {
|
||||
const first = bench().shell
|
||||
const mirrored: DraftSnapshot[] = []
|
||||
first.bindDraftPersistence((document) => { mirrored.push(document) })
|
||||
expect(first.insertQuote(quote)).toBe(true)
|
||||
const persisted = mirrored.at(-1)
|
||||
expect(persisted).toEqual({ text: '> selected source text ', references: [], quotes: [{ offset: 0, length: 22, text: quote }] })
|
||||
|
||||
const restored = bench().shell
|
||||
restored.setDraft(persisted as DraftSnapshot)
|
||||
restored.editor.getEditorState().read(() => {
|
||||
const [node] = $nodesOfType(QuoteNode)
|
||||
expect(node?.getQuoteText()).toBe(quote)
|
||||
expect(node?.isKeyboardSelectable()).toBe(false)
|
||||
expect(node?.createDOM({ namespace: 'quote', theme: {} }).getAttribute('contenteditable')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('treats insert, close removal, Backspace, undo, and redo as whole-node operations', async () => {
|
||||
const direct = bench().shell
|
||||
direct.insertQuote(quote)
|
||||
expect(direct.editor.dispatchCommand(
|
||||
KEY_BACKSPACE_COMMAND,
|
||||
new KeyboardEvent('keydown', { key: 'Backspace' }),
|
||||
)).toBe(true)
|
||||
await vi.waitFor(() => { expect(quoteKeys(direct)).toHaveLength(0) })
|
||||
expect(direct.snapshot.draft).toBe('')
|
||||
direct.editor.dispatchCommand(UNDO_COMMAND, undefined)
|
||||
await vi.waitFor(() => { expect(quoteKeys(direct)).toHaveLength(1) })
|
||||
|
||||
const { shell } = bench()
|
||||
shell.setDraft('base ')
|
||||
shell.insertQuote(quote)
|
||||
expect(quoteKeys(shell)).toHaveLength(1)
|
||||
await Promise.resolve()
|
||||
shell.editor.dispatchCommand(UNDO_COMMAND, undefined)
|
||||
await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(0) })
|
||||
shell.editor.dispatchCommand(REDO_COMMAND, undefined)
|
||||
await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(1) })
|
||||
|
||||
const [key] = quoteKeys(shell)
|
||||
shell.editor.update(() => {
|
||||
expect($selectDetectSpan({ start: 6, end: 6 })).toBe(true)
|
||||
}, { discrete: true })
|
||||
expect(shell.editor.dispatchCommand(
|
||||
KEY_BACKSPACE_COMMAND,
|
||||
new KeyboardEvent('keydown', { key: 'Backspace' }),
|
||||
)).toBe(true)
|
||||
await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(0) })
|
||||
shell.editor.dispatchCommand(UNDO_COMMAND, undefined)
|
||||
await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(1) })
|
||||
shell.editor.update(() => {
|
||||
expect($selectDetectSpan({ start: 5, end: 5 })).toBe(true)
|
||||
}, { discrete: true })
|
||||
expect(shell.editor.dispatchCommand(
|
||||
KEY_DELETE_COMMAND,
|
||||
new KeyboardEvent('keydown', { key: 'Delete' }),
|
||||
)).toBe(true)
|
||||
await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(0) })
|
||||
shell.editor.dispatchCommand(UNDO_COMMAND, undefined)
|
||||
await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(1) })
|
||||
shell.editor.dispatchCommand(REMOVE_QUOTE_COMMAND, key!)
|
||||
await vi.waitFor(() => { expect(quoteKeys(shell)).toHaveLength(0) })
|
||||
expect(shell.snapshot.draft).toBe('base ')
|
||||
shell.editor.getEditorState().read(() => {
|
||||
const selection = $getSelection()
|
||||
expect($isRangeSelection(selection) && selection.isCollapsed()).toBe(true)
|
||||
})
|
||||
|
||||
const empty = bench().shell
|
||||
empty.insertQuote(quote)
|
||||
const [emptyKey] = quoteKeys(empty)
|
||||
empty.editor.dispatchCommand(REMOVE_QUOTE_COMMAND, emptyKey!)
|
||||
await vi.waitFor(() => { expect(quoteKeys(empty)).toHaveLength(0) })
|
||||
expect(empty.snapshot.draft).toBe('')
|
||||
})
|
||||
|
||||
it('sends selected quotes as ordinary Markdown text', async () => {
|
||||
const { shell, sink } = bench()
|
||||
shell.insertQuote(quote)
|
||||
shell.paste('answer')
|
||||
shell.submit('queue')
|
||||
expect(sink).toHaveBeenCalledWith('> selected source text\n\nanswer', [], 'queue', expect.any(AbortSignal))
|
||||
expect(shell.snapshot.draft).toBe('')
|
||||
shell.editor.getEditorState().read(() => { expect($getRoot().getTextContent()).toBe('') })
|
||||
})
|
||||
|
||||
it('keeps adjacent quotes separated in model text', () => {
|
||||
const { shell, sink } = bench()
|
||||
shell.insertQuote('first')
|
||||
shell.insertQuote('second')
|
||||
shell.submit('queue')
|
||||
expect(sink).toHaveBeenCalledWith('> first\n\n> second', [], 'queue', expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('restores the complete rich document when an optimistic send fails', async () => {
|
||||
let settle!: (outcome: { kind: 'error'; text: string }) => void
|
||||
const { shell, sink } = bench()
|
||||
sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve }))
|
||||
shell.insertQuote(quote)
|
||||
shell.paste('answer')
|
||||
const submitted = shell.draftSnapshot
|
||||
|
||||
shell.submit('queue')
|
||||
expect(shell.snapshot.draft).toBe('')
|
||||
settle({ kind: 'error', text: 'retry' })
|
||||
|
||||
await vi.waitFor(() => { expect(shell.draftSnapshot).toEqual(submitted) })
|
||||
expect(quoteKeys(shell)).toHaveLength(1)
|
||||
expect(shell.notices.getSnapshot()).toMatchObject({ level: 'error', text: 'retry' })
|
||||
})
|
||||
|
||||
it('mirrors optimistic successful-send clearing through the Session lifetime', async () => {
|
||||
let settle!: (outcome: { kind: 'success' }) => void
|
||||
const { shell, sink } = bench()
|
||||
sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve }))
|
||||
const mirrored: DraftSnapshot[] = []
|
||||
shell.bindDraftPersistence((document) => { mirrored.push(document) })
|
||||
shell.insertQuote(quote)
|
||||
shell.submit('queue')
|
||||
expect(mirrored.at(-1)).toEqual({ text: '', references: [] })
|
||||
settle({ kind: 'success' })
|
||||
expect(mirrored.at(-1)).toEqual({ text: '', references: [] })
|
||||
})
|
||||
})
|
||||
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user