Merge pull request #8008 from deepseek-harness/fix/forge-file-menu-and-comment-echo

fix(forge): center the selected file in the file menu and shorten comment quotes
This commit is contained in:
Yichen Jiang authored and GitHub committed 2026-11-06 14:22:57 +08:00
commit 6a315e6aea
31 files changed
+377 -51

No files matched your search

@@ -1,6 +1,6 @@
- textbox "发消息或创建任务, / 调用指令, @ 文件或对话":
- paragraph:
- button "移除引用"
- text: "## User Comment 2 File: src/extra.ts Side: R Lines: 1 Source: Local (branch) Base: origin/main Diff hunk: ```diff @@ -0,0 +1,1 @@ +export const extra = 1 ``` Comment: 请检查这项修改"
- text: src/extra.ts R1 请检查这项修改
- button "移除引用"
- text: "## User Comment 1 File: src/app.ts Side: R Lines: 1 Source: Local (branch) Base: origin/main Diff hunk: ```diff @@ -0,0 +1,1 @@ +export const app = 1 ``` Comment: 请解释这行代码"
- text: src/app.ts R1 请解释这行代码
@@ -18,8 +18,8 @@
en: ef43c4f149195939
zh: 49fc6c83fd3a05ce
/deepseek-ai-dsh-client-ui-conversation/sent-user-text:
en: cc4dafd1aae02468
zh: be6b67724b7ac4a3
en: 4a094cd97426ad18
zh: 5faadddb7e2b0a43
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: 7c45c9c9767b3184
zh: 290037aa8a71b1df
+1 -1
View File
@@ -61,7 +61,7 @@ Queue previews enable whitespace collapsing and document-edge trimming, retain e
[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 and Workspace transfer preserve atomic quotes through the existing editor runtime, and a failed send leaves them in the editor. `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.
The existing semantic `DraftSnapshot` retains quote text and clipboard-coordinate spans beside references. Storage accepts earlier string and reference-only drafts; Session switching and Workspace transfer preserve atomic quotes through the existing editor runtime, and a failed send leaves them in the editor. `inputActions.insertQuote(text, summary?)` inserts a nonblank selection only while the composer is in plain mode; an optional `QuoteSummary` replaces the text in the composer display and is stored with the draft, while the sent message still carries the text. 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.
+1 -1
View File
@@ -61,7 +61,7 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS
[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 行和持久消息使用同一文本匹配器。
现有语义 `DraftSnapshot` 在 references 旁保留 quote 文本及 clipboard 坐标范围。存储接受较早的字符串和仅含 reference 的草稿;Session 切换和 Workspace 转移通过现有编辑器 runtime 保留原子 quote,发送失败时它们留在编辑器中。`inputActions.insertQuote(text, summary?)` 仅在输入框处于 plain 模式时插入非空选区;可选的 `QuoteSummary` 在输入框中替代文本显示并随草稿保存,发送的消息仍携带该文本。提交产出普通 Markdown 文本;待发送回显、队列行、steering 行和持久消息使用同一文本匹配器。
`UiConversation.interactions` 拥有插件对文本选择和右键手势的注册。每个注册项提供同步处理函数,其 ID 选择对应的 keyed Slot 组件;插件通过 Slot injection 机制渲染。处理函数调用 `open()` 展示,需要替代浏览器默认行为时单独调用 `preventDefault()`。Chat 提供 DOM 选区和区域内布局;机制不从 DOM 位置推断原始来源数据或 Markdown 偏移。
@@ -1,5 +1,6 @@
/** Editor-facing ranges, reference projections, and the composer keyboard interface. */
import type { LexicalEditor } from 'lexical'
import type { QuoteSummary } from '@deepseek-ai/dsh-client-ui-primitives'
import type { InputState } from './input.ts'
import type { InputSubmitMode } from './composer-submission.ts'
@@ -28,6 +29,8 @@ export interface DraftQuote {
readonly length: number
/** Selected text before Markdown quote prefixes are added. */
readonly text: string
/** Readable display the inserting owner supplied; the draft text and the sent message still carry `text`. */
readonly summary?: QuoteSummary | undefined
}
/**
@@ -11,6 +11,7 @@ import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/cli
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { QuoteSummary } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ArbitrateKey, ArbitrateOutcome, DraftQuote, Occurrence, ReferenceInsert, TokenSpan } from './draft-editor.ts'
import type { InputSubmitMode, MessageSubmission } from './composer-submission.ts'
@@ -182,8 +183,13 @@ 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
/**
* Insert text as an atomic quote.
* @param text - nonblank selection; the sent message carries it as a Markdown blockquote.
* @param summary - readable display shown in the composer instead of `text`.
* @returns whether editing accepted it.
*/
insertQuote(text: string, summary?: QuoteSummary): boolean
/** Replace the whole draft with plain text, removing inline reference chips. */
setDraft(text: string): void
@@ -256,8 +262,13 @@ export type DraftInitializationResult = 'applied' | 'preserved' | 'blocked'
* 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
/**
* Insert one nonblank quote.
* @param text - selected text; the sent message carries it as a Markdown blockquote.
* @param summary - readable display shown in the composer instead of `text`.
* @returns whether editing accepted it.
*/
insertQuote(text: string, summary?: QuoteSummary): boolean
/** @returns a revision-guarded copy of the current editor selection. */
captureInsertion(): TokenSpan
/**
@@ -1,6 +1,7 @@
/** Semantic draft snapshots shared by storage, programmatic input, and editor restoration. */
import { z } from 'zod'
import { formatQuoteText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { QuoteSummary } from '@deepseek-ai/dsh-client-ui-primitives'
import type { DraftInput, DraftQuote, DraftReference, DraftSnapshot, Occurrence } from './contract/draft-editor.ts'
const referenceSchema = z.object({
@@ -18,10 +19,16 @@ const commandSchema = z.object({
token: z.string(),
})
// Lists every QuoteSummary field: parsing strips unknown keys, so a field missing here is dropped from restored drafts.
const quoteSummarySchema: z.ZodType<QuoteSummary> = z.object({
label: z.string(), title: z.string(), detail: z.string().optional(), body: z.string(),
})
const quoteSchema = z.object({
offset: z.number().int().nonnegative(),
length: z.number().int().positive(),
text: z.string().refine(text => text.trim() !== ''),
summary: quoteSummarySchema.optional(),
})
const snapshotSchema = z.object({
@@ -3,6 +3,7 @@
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 { QuoteSummary } from '@deepseek-ai/dsh-client-ui-primitives'
import type {
EditorConfig, LexicalEditor, LexicalNode, NodeKey, SerializedLexicalNode, Spread,
} from 'lexical'
@@ -18,21 +19,24 @@ const EMPTY_LABELS: QuoteNodeLabels = { remove: '' }
/** JSON form used only by Lexical's editor-state APIs, not browser persistence. */
export type SerializedQuoteNode = Spread<{
text: string
summary?: QuoteSummary
}, 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 }: {
export function QuoteNodeView({ editor, nodeKey, text, summary, labels }: {
readonly editor: LexicalEditor
readonly nodeKey: NodeKey
readonly text: string
readonly summary?: QuoteSummary | undefined
readonly labels: QuoteNodeLabels
}) {
return (
<QuotePreview
text={text}
summary={summary}
removal={{
label: labels.remove,
onRemove: () => { editor.dispatchCommand(REMOVE_QUOTE_COMMAND, nodeKey) },
@@ -44,6 +48,7 @@ export function QuoteNodeView({ editor, nodeKey, text, labels }: {
/** One selected transcript range represented as an atomic inline decorator. */
export class QuoteNode extends DecoratorNode<JSX.Element> {
__text: string
__summary: QuoteSummary | undefined
__labels: QuoteNodeLabels
/** Lexical node registry type tag. */
@@ -56,27 +61,31 @@ export class QuoteNode extends DecoratorNode<JSX.Element> {
return new QuoteNode(
node.__text,
node.__labels,
node.__summary,
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)
return new QuoteNode(json.text, EMPTY_LABELS, json.summary)
}
/**
* @param text - selected text.
* @param labels - runtime-only localized labels.
* @param summary - readable display shown instead of the text.
* @param key - Lexical clone-path key; absent for fresh nodes.
*/
constructor(
text: string,
labels: QuoteNodeLabels = EMPTY_LABELS,
summary?: QuoteSummary,
key?: NodeKey,
) {
super(key)
this.__text = text
this.__summary = summary
this.__labels = labels
}
@@ -87,6 +96,7 @@ export class QuoteNode extends DecoratorNode<JSX.Element> {
type: 'quote',
version: 1,
text: this.__text,
...(this.__summary === undefined ? {} : { summary: this.__summary }),
}
}
@@ -123,6 +133,11 @@ export class QuoteNode extends DecoratorNode<JSX.Element> {
return this.getLatest().__text
}
/** Readable display the inserting owner supplied, if any. */
getQuoteSummary(): QuoteSummary | undefined {
return this.getLatest().__summary
}
/** 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
@@ -135,15 +150,21 @@ export class QuoteNode extends DecoratorNode<JSX.Element> {
editor={editor}
nodeKey={this.__key}
text={this.__text}
summary={this.__summary}
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 text - selected quote text.
* @param labels - runtime labels.
* @param summary - readable display shown instead of the text.
* @returns a fresh node.
*/
export function $createQuoteNode(text: string, labels: QuoteNodeLabels, summary?: QuoteSummary): QuoteNode {
return new QuoteNode(text, labels, summary)
}
/** @param node - possible Lexical node. @returns whether it is a QuoteNode. */
@@ -207,7 +207,11 @@ export function $projectComposer(idOf: (key: NodeKey) => number): EditorProjecti
for (const segment of layout.segments) {
if (segment.kind !== 'chip') continue
if ($isQuoteNode(segment.node)) {
quotes.push({ offset: segment.clipboardStart, length: segment.clipboardLength, text: segment.node.getQuoteText() })
const summary = segment.node.getQuoteSummary()
quotes.push({
offset: segment.clipboardStart, length: segment.clipboardLength, text: segment.node.getQuoteText(),
...(summary === undefined ? {} : { summary }),
})
continue
}
if (!$isReferenceChipNode(segment.node)) continue
@@ -1,6 +1,7 @@
/** The Composer model's private Lexical editor, projections, and node operations. */
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { LexicalEditor, NodeKey } from 'lexical'
import type { QuoteSummary } from '@deepseek-ai/dsh-client-ui-primitives'
import {
$addUpdateTag, $createParagraphNode, $createTextNode, $getAdjacentNode, $isTextNode,
$getRoot, $getSelection, $getNodeByKey, $isRangeSelection,
@@ -348,14 +349,15 @@ export class DraftEditorRuntime {
* 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.
* @param summary - readable display shown instead of the text.
* @returns whether the insertion position accepted the node.
*/
insertQuote(text: string, labels: QuoteNodeLabels): boolean {
insertQuote(text: string, labels: QuoteNodeLabels, summary?: QuoteSummary): 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(' ')])
applied = $replaceDetectSpanWithNodes({ start: at, end: at }, [$createQuoteNode(text, labels, summary), $createTextNode(' ')])
}, PASTE_TAG)
return applied
}
@@ -451,7 +453,7 @@ export class DraftEditorRuntime {
const occurrence = span.value
appendText(draft.slice(cursor, occurrence.offset))
if (span.kind === 'quote') {
paragraph.append($createQuoteNode(span.value.text, labels))
paragraph.append($createQuoteNode(span.value.text, labels, span.value.summary))
cursor = occurrence.offset + occurrence.length
continue
}
@@ -14,6 +14,7 @@ import {
createSnapshotStore, type ObservableSnapshot, type SnapshotStore,
} from '@deepseek-ai/dsh-client-store'
import type { LexicalEditor } from 'lexical'
import type { QuoteSummary } from '@deepseek-ai/dsh-client-ui-primitives'
import type {
CommandClaim, ConsumeTokenRequest, DraftAttachmentId, DraftInitializationOptions, DraftInitializationResult,
InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome,
@@ -100,12 +101,17 @@ function frozen(phase: InputState['phase']): boolean {
}
/** Whether two projections differ in content (selection and caret excluded). */
function sameSummary(left: QuoteSummary | undefined, right: QuoteSummary | undefined): boolean {
return left === right || (left !== undefined && right !== undefined && left.label === right.label
&& left.title === right.title && left.detail === right.detail && left.body === right.body)
}
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 old?.text !== quote.text || old.offset !== quote.offset || !sameSummary(old.summary, quote.summary)
})) return true
if (prev.occurrences.length !== next.occurrences.length) return true
return next.occurrences.some((occ, i) => {
@@ -140,7 +146,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),
insertQuote: (text, summary) => this.insertQuote(text, summary),
captureInsertion: () => ({ ...this.caretSpan(), draftRev: this.rev }),
insertText: (text, span) => {
if (frozen(this.snapshot.phase) || this.disposed) return false
@@ -230,10 +236,14 @@ export class SessionInputShell implements SessionInput {
this.restoreCommand()
}
/** @param text - selected transcript text. @returns whether the editable draft accepted the quote. */
insertQuote(text: string): boolean {
/**
* @param text - selected transcript text.
* @param summary - readable composer display of the quote.
* @returns whether the editable draft accepted the quote.
*/
insertQuote(text: string, summary?: QuoteSummary): 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?.() ?? '' })
const inserted = this.draftEditor.insertQuote(text.replace(/\r\n?/gu, '\n'), { remove: this.deps.quoteRemoveLabel?.() ?? '' }, summary)
if (inserted) this.focus()
return inserted
}
@@ -93,6 +93,54 @@ describe('QuoteNode composer lifecycle', () => {
})
})
it('shows an owner summary in place of the text while drafts, editor state, and the sent message keep the text', async () => {
const summary = { label: 'index.ts L78', title: 'src/index.ts', detail: 'L78', body: 'rename this' }
const { shell, sink } = bench()
const mirrored: DraftSnapshot[] = []
shell.bindDraftPersistence((document) => { mirrored.push(document) })
expect(shell.actions.insertQuote(quote, summary)).toBe(true)
const persisted = mirrored.at(-1)
expect(persisted).toEqual({ text: '> selected source text ', references: [], quotes: [{ offset: 0, length: 22, text: quote, summary }] })
const view = render(<QuoteNodeView editor={shell.editor} nodeKey="quote-key" text={quote} summary={summary} labels={{ remove: 'Remove quote' }} />)
expect(view.container.querySelector('[data-quote-preview-text]')?.textContent).toBe('index.ts L78src/index.ts L78 rename this')
view.unmount()
const restored = bench().shell
restored.setDraft(persisted as DraftSnapshot)
expect(restored.draftSnapshot).toEqual(persisted)
const reparsed = restored.editor.parseEditorState(restored.editor.getEditorState().toJSON())
expect(reparsed.read(() => $nodesOfType(QuoteNode).map(node => [node.getQuoteText(), node.getQuoteSummary()])))
.toEqual([[quote, summary]])
const plain = bench().shell
plain.insertQuote(quote)
expect(plain.editor.parseEditorState(plain.editor.getEditorState().toJSON())
.read(() => $nodesOfType(QuoteNode).map(node => node.getQuoteSummary()))).toEqual([undefined])
shell.submit('queue')
expect(sink).toHaveBeenCalledWith('> selected source text', [], 'queue', expect.any(AbortSignal), expect.any(Function))
await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('') })
})
it('treats a summary added or changed at the same text and offset as a draft change', () => {
const summary = { label: 'index.ts L78', title: 'src/index.ts', detail: 'L78', body: 'rename this' }
const { shell } = bench()
const mirrored: DraftSnapshot[] = []
shell.bindDraftPersistence((document) => { mirrored.push(document) })
shell.insertQuote(quote)
const plain = shell.draftSnapshot
const revision = shell.snapshot.draftRev
shell.setDraft({ ...plain, quotes: [{ offset: 0, length: 22, text: quote, summary }] })
expect(shell.snapshot.draftRev).toBeGreaterThan(revision)
expect(shell.draftSnapshot.quotes).toEqual([{ offset: 0, length: 22, text: quote, summary }])
expect(mirrored.at(-1)?.quotes).toEqual([{ offset: 0, length: 22, text: quote, summary }])
shell.setDraft({ ...plain, quotes: [{ offset: 0, length: 22, text: quote, summary: { ...summary, body: 'keep this' } }] })
expect(shell.draftSnapshot.quotes?.[0]?.summary?.body).toBe('keep this')
shell.setDraft({ ...plain, quotes: [{ offset: 0, length: 22, text: quote, summary: { ...summary, body: 'keep this' } }] })
expect(shell.draftSnapshot.quotes?.[0]?.summary?.body).toBe('keep this')
shell.setDraft(plain)
expect(shell.draftSnapshot.quotes).toEqual([{ offset: 0, length: 22, text: quote }])
})
it('treats insert, close removal, Backspace, undo, and redo as whole-node operations', async () => {
const direct = bench().shell
direct.insertQuote(quote)
@@ -15,6 +15,17 @@ describe('quote draft spans', () => {
expect(parseStoredDraft({ ...draft, quotes: [quote, quote] })).toBeUndefined()
})
it('restores a stored quote summary and leaves summary-less drafts without one', () => {
const summary = { label: 'a.ts R1', title: 'src/a.ts', detail: 'R1', body: 'why' }
const draft = { text: '> quote', references: [], quotes: [{ offset: 0, length: 7, text: 'quote', summary }] }
expect(parseStoredDraft(JSON.parse(JSON.stringify(draft)))).toEqual(draft)
const { detail: _detail, ...withoutDetail } = summary
const brief = { ...draft, quotes: [{ offset: 0, length: 7, text: 'quote', summary: withoutDetail }] }
expect(parseStoredDraft(JSON.parse(JSON.stringify(brief)))).toEqual(brief)
expect(parseStoredDraft({ text: '> quote', references: [], quotes: [{ offset: 0, length: 7, text: 'quote' }] })?.quotes?.[0]?.summary).toBeUndefined()
expect(parseStoredDraft({ ...draft, quotes: [{ offset: 0, length: 7, text: 'quote', summary: { label: 'a.ts' } }] })).toBeUndefined()
})
it('serializes reference owners outside quotes into ordinary Markdown text', async () => {
const serialize = vi.fn(() => Promise.resolve('@[Session](dsh-session:id)'))
const quote = '/review @literal'
@@ -45,8 +45,8 @@
en: 45f4cd1ef96bb6d2
zh: f264d25b780ad1dc
/deepseek-ai-dsh-client-ui-primitives/further-exploration:
en: daa908e173136285
zh: 5f8492ce8a4c4e48
en: 19f1407f92cb8264
zh: 16205d2db9c91ad1
/deepseek-ai-dsh-client-ui-primitives/model-experience:
en: 3120df665299c634
zh: 1d9f365d091d0d2a
+1 -1
View File
@@ -209,7 +209,7 @@ These pages place the atoms in the client stack and the design system.
-----
`QuotePreview` shares the compact quote excerpt and clipped-only inline HoverCard between composers and sent messages. The popover sizes to its text up to 300px and stays within the viewport. Both surfaces display the exact text; editable drafts may supply a localized whole-quote removal action. Quotes opt into the nearest `HoverCardGroup` for exclusive preview display. The primitive performs no recognition or navigation.
`QuotePreview` shares the compact quote excerpt and clipped-only inline HoverCard between composers and sent messages. The popover sizes to its text up to 300px and stays within the viewport. Both surfaces display the exact text unless the owner supplies a `QuoteSummary`: its label and body then replace the inline excerpt, assistive technology reads the title and optional detail in place of the label, and the popover shows its title, optional detail, and body on every hover. Editable drafts may supply a localized whole-quote removal action. Quotes opt into the nearest `HoverCardGroup` for exclusive preview display. The primitive performs no recognition or navigation.
`MarkdownDelegate.getTriggerProps` receives explicit file, URL, or original-image targets and returns gesture props. File paths and line numbers are carried by callbacks. File mentions can supply `contextMenuProps`; `ImageLightbox.contextMenuProps` delegates the original image gesture. An image menu owns Escape and Tab while it holds focus, returning to the preview before closing it.
+1 -1
View File
@@ -209,7 +209,7 @@ kind: "package-library"
-----
`QuotePreview` 在 composer 与已发送消息之间共享紧凑 quote 摘录和仅截断时显示的行内 HoverCard。原文弹层按文本内容确定宽度,最大为 300px,并限制在视口内。两处均展示原文;可编辑草稿可以提供本地化的整条 quote 删除操作。quote 加入最近的 `HoverCardGroup`,使原文弹层互斥显示。该 primitive 不负责识别或导航。
`QuotePreview` 在 composer 与已发送消息之间共享紧凑 quote 摘录和仅截断时显示的行内 HoverCard。原文弹层按文本内容确定宽度,最大为 300px,并限制在视口内。两处均展示原文,除非所有者提供 `QuoteSummary`:此时行内摘录改为显示其 label 和 body,辅助技术以 title 和可选的 detail 代替 label 朗读,每次悬停时弹层显示其 title、可选的 detail 和 body。可编辑草稿可以提供本地化的整条 quote 删除操作。quote 加入最近的 `HoverCardGroup`,使原文弹层互斥显示。该 primitive 不负责识别或导航。
`MarkdownDelegate.getTriggerProps` 接收明确的文件、链接或原图目标并返回手势 props。文件路径与行号通过回调传递。文件引用可提供 `contextMenuProps`;`ImageLightbox.contextMenuProps` 转发原图手势。图片菜单持有焦点时负责 Escape 与 Tab,先返回预览再关闭预览。
@@ -22,6 +22,43 @@
white-space: pre-wrap;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.inlineBody {
color: var(--dsw-alias-label-secondary);
}
.summary {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
font-size: 14px;
line-height: 20px;
}
.summaryTitle {
color: var(--dsw-alias-state-business-primary);
overflow-wrap: anywhere;
}
.summaryDetail {
color: var(--dsw-alias-label-secondary);
}
.summaryBody {
color: var(--dsw-alias-label-primary);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.source {
min-width: 0;
padding: 0;
@@ -25,10 +25,24 @@ export interface QuotePreviewRemoval {
readonly onRemove: () => void
}
/** Owner-supplied readable form of a quote whose exact text is long structured content. */
export interface QuoteSummary {
/** Short inline label shown before `body` in place of the exact text; assistive technology reads `title` and `detail` instead. */
readonly label: string
/** First popover line, such as a file path; also the label's accessible text. */
readonly title: string
/** Secondary popover line, such as a line range. */
readonly detail?: string | undefined
/** Main text, such as a user's comment, shown inline after `label` and in full in the popover. */
readonly body: string
}
/** Presentation inputs for one compact quote excerpt. */
export interface QuotePreviewProps {
/** Exact selected text rendered inline and exposed in a popover while clipped. */
/** Exact selected text rendered inline and exposed in a popover while clipped, unless `summary` replaces it. */
readonly text: string
/** Readable replacement for `text`: its label renders inline and its popover opens on every hover. */
readonly summary?: QuoteSummary | undefined
/** Composer-only removal action; omit for inert sent quotes. */
readonly removal?: QuotePreviewRemoval | undefined
}
@@ -36,10 +50,10 @@ export interface QuotePreviewProps {
/**
* Render one width-capped quote preview shared by editable drafts and sent messages.
* Clipped excerpts open a content-sized, scrollable popover that fits within the viewport.
* @param props - selected text and optional localized removal action.
* @returns a non-navigating quote preview with a full-text popover only while clipped.
* @param props - selected text, optional summary, and optional localized removal action.
* @returns a non-navigating quote preview with a full-text popover only while clipped, or a summary popover.
*/
export function QuotePreview({ text, removal }: QuotePreviewProps): JSX.Element {
export function QuotePreview({ text, summary, removal }: QuotePreviewProps): JSX.Element {
const sourceRef = useRef<HTMLSpanElement | null>(null)
const [truncated, setTruncated] = useState(false)
useLayoutEffect(() => {
@@ -86,7 +100,15 @@ export function QuotePreview({ text, removal }: QuotePreviewProps): JSX.Element
<IconCloseOutlineRegular size={12} className={css.closeMark} />
</button>
)}
<span ref={sourceRef} className={css.source} data-quote-preview-text="">{text}</span>
<span ref={sourceRef} className={css.source} data-quote-preview-text="">
{summary === undefined ? text : (
<>
<span aria-hidden="true">{summary.label}</span>
<span className={css.visuallyHidden}>{summary.detail === undefined ? summary.title : `${summary.title} ${summary.detail}`}</span>
{' '}<span className={css.inlineBody}>{summary.body}</span>
</>
)}
</span>
</span>
)
return (
@@ -95,9 +117,17 @@ export function QuotePreview({ text, removal }: QuotePreviewProps): JSX.Element
grouped
fitContent
openDelayMs={0}
disabled={!truncated}
disabled={summary === undefined && !truncated}
anchor={preview}
content={<span className={css.popoverText} data-quote-popover="" aria-hidden="true">{text}</span>}
content={summary === undefined
? <span className={css.popoverText} data-quote-popover="" aria-hidden="true">{text}</span>
: (
<span className={css.summary} data-quote-popover="" aria-hidden="true">
<span className={css.summaryTitle}>{summary.title}</span>
{summary.detail !== undefined && <span className={css.summaryDetail}>{summary.detail}</span>}
<span className={css.summaryBody}>{summary.body}</span>
</span>
)}
/>
)
}
+1 -1
View File
@@ -136,7 +136,7 @@ export { ImageLightbox } from './ImageLightbox.tsx'
export type { ImageLightboxLabels } from './ImageLightbox.tsx'
export { QuotePreview } from './QuotePreview.tsx'
export type { QuotePreviewProps, QuotePreviewRemoval } from './QuotePreview.tsx'
export type { QuotePreviewProps, QuotePreviewRemoval, QuoteSummary } from './QuotePreview.tsx'
export { formatQuoteText } from './markdown/quote-text.ts'
export type { MarkdownMenuTarget } from './markdown/MarkdownDelegate.tsx'
@@ -112,6 +112,29 @@ describe('QuotePreview', () => {
}
})
it('shows an owner summary inline and in a popover that opens without clipping', async () => {
const text = '## User Comment 1\nFile: src/index.ts\nComment:\nrename this'
const view = render(<QuotePreview text={text} summary={{ label: 'index.ts L78', title: 'src/index.ts', detail: 'L78', body: 'rename this' }} />)
const quote = view.container.querySelector<HTMLElement>('[data-quote-preview]')!
const inline = quote.querySelector('[data-quote-preview-text]')!
expect([...inline.children].map(part => [part.textContent, part.getAttribute('aria-hidden')]))
.toEqual([['index.ts L78', 'true'], ['src/index.ts L78', null], ['rename this', null]])
expect(view.queryByText(text)).toBeNull()
fireEvent.pointerEnter(quote)
await waitFor(() => {
expect([...document.querySelector('[data-quote-popover]')!.children].map(line => line.textContent))
.toEqual(['src/index.ts', 'L78', 'rename this'])
})
view.unmount()
render(<QuotePreview text={text} summary={{ label: 'index.ts', title: 'src/index.ts', body: 'rename this' }} />)
expect(document.querySelector('[data-quote-preview-text]')?.children[1]?.textContent).toBe('src/index.ts')
fireEvent.pointerEnter(document.querySelector<HTMLElement>('[data-quote-preview]')!)
await waitFor(() => {
expect([...document.querySelector('[data-quote-popover]')!.children].map(line => line.textContent)).toEqual(['src/index.ts', 'rename this'])
})
})
it('contains keyboard and pointer removal events inside the editable preview', () => {
const onRemove = vi.fn()
const onPointerDown = vi.fn()
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 2f8a621b991260a1
/deepseek-ai-dsh-experimental-client-ui-forge/use-this-package:
en: a33264258ae8b2f6
zh: 0d250e4610050228
en: 1101418b5621ba9d
zh: 4d66052d4708d02c
/deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation:
en: 0c2dd86b5f6b489d
zh: 2f0fce0affa48f59
@@ -46,7 +46,7 @@ The diff follows the file tree’s depth-first order: directories before files,
`gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, `worktreePrefix`, `maxDiffBytes`, `maxUntrackedFiles`, and `maxCachedDiffBytes` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge).
All files opens a searchable directory tree. Single-child directories share a row, and renamed files also match their old path. A wide changes pane shows the same tree beside the diff; its File tree toolbar button cycles through left, right, and hidden, starting on the left. Its accessible name describes the next action, and focus stays on the button when it moves or hides the tree. The search, folds, selection, and width survive moving or hiding the tree and switching tabs or Sessions. Drag the divider or use its arrow keys to resize the tree. Files and folders reserve the same disclosure column so each child is indented from its parent. Local changes and each PR retain separate tabs. Select code text or use line numbers to select a range within one displayed hunk and side; text selection and copying stay on the starting side. The plus button opens the plain-text comment editor. With a mouse, hovering a line also shows a plus beside its line number, which stays clickable, on the hovered side in two columns and on the line's own side in one column; pressing it selects that line, dragging extends the range on that side, and releasing opens the editor. It accepts newlines and IME composition; only its action buttons submit the comment. Drag its lower edge to adjust the height. Add to input inserts a removable quote into the main composer; multiple quotes are sent together only when the user sends that composer. The GitHub split button starts or adds to a pending review; its menu publishes a single comment immediately. Comments remain beside their code after quoting, queueing, or publishing. Pending comments are edited or removed there. Request changes requires a review summary; the dialog explains this beside the summary field and announces the requirement when the decision changes. Submit review in the PR title bar opens a centered dialog showing only the pending count, summary, and Comment, Approve, or Request changes. Added and deleted files show only their populated line-number column. Pending reviews and comment records are stored in this browser per Session and tab, including the original PR head commit. Switching Sessions or hiding the pane retains them; closing the tab clears its comments, summary, and decision, and reopening starts an empty review. Published GitHub comments and references already added to the main composer are unaffected. Records whose code changed remain visible below their file, or after the diff when the file is absent. A changed head requires fresh line selections. While the tab remains open, an uncertain write stays blocked across reloads until the user checks GitHub and explicitly permits another submission.
All files opens a searchable directory tree with the selected file scrolled to its middle. Single-child directories share a row, and renamed files also match their old path. A wide changes pane shows the same tree beside the diff; its File tree toolbar button cycles through left, right, and hidden, starting on the left. Its accessible name describes the next action, and focus stays on the button when it moves or hides the tree. The search, folds, selection, and width survive moving or hiding the tree and switching tabs or Sessions. Drag the divider or use its arrow keys to resize the tree. Files and folders reserve the same disclosure column so each child is indented from its parent. Local changes and each PR retain separate tabs. Select code text or use line numbers to select a range within one displayed hunk and side; text selection and copying stay on the starting side. The plus button opens the plain-text comment editor. With a mouse, hovering a line also shows a plus beside its line number, which stays clickable, on the hovered side in two columns and on the line's own side in one column; pressing it selects that line, dragging extends the range on that side, and releasing opens the editor. It accepts newlines and IME composition; only its action buttons submit the comment. Drag its lower edge to adjust the height. Add to input inserts a removable quote into the main composer that shows the file name, side and lines, and comment, and on hover the file path; multiple quotes are sent together only when the user sends that composer. The GitHub split button starts or adds to a pending review; its menu publishes a single comment immediately. Comments remain beside their code after quoting, queueing, or publishing. Pending comments are edited or removed there. Request changes requires a review summary; the dialog explains this beside the summary field and announces the requirement when the decision changes. Submit review in the PR title bar opens a centered dialog showing only the pending count, summary, and Comment, Approve, or Request changes. Added and deleted files show only their populated line-number column. Pending reviews and comment records are stored in this browser per Session and tab, including the original PR head commit. Switching Sessions or hiding the pane retains them; closing the tab clears its comments, summary, and decision, and reopening starts an empty review. Published GitHub comments and references already added to the main composer are unaffected. Records whose code changed remain visible below their file, or after the diff when the file is absent. A changed head requires fresh line selections. While the tab remains open, an uncertain write stays blocked across reloads until the user checks GitHub and explicitly permits another submission.
-----
@@ -46,7 +46,7 @@ diff 按文件树的深度优先顺序排列:同层目录在前、文件在后
`gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches`、`worktreePrefix`、`maxDiffBytes`、`maxUntrackedFiles` 与 `maxCachedDiffBytes` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。
“全部文件”打开可搜索的目录树;只有一个子目录的路径会合并显示,重命名文件也可以按旧路径搜索。较宽的变更面板会在 diff 旁显示同一棵树,工具栏的“文件树”按钮按“左侧 → 右侧 → 隐藏”循环切换,默认显示在左侧。按钮的无障碍名称说明下一步操作,移动或隐藏文件树时焦点仍留在按钮上。移动或隐藏文件树,以及切换标签页与 Session 时保留搜索、折叠状态、选中文件和宽度。拖动分隔线,或聚焦分隔线后使用方向键,可以调整文件树宽度。文件与文件夹预留相同的展开箭头列,子项相对父目录向内缩进。本地变更与每个 PR 保留独立标签页。划选代码文字或通过行号选择同一已显示 hunk、同一侧的连续行,文字划选与复制都限定在起始侧。点击“+”打开纯文本评论框。使用鼠标时,悬停在某一行上也会在其行号旁显示“+”,行号仍可点击:两栏时显示在悬停的一侧,单栏时显示在该行所属的一侧;按下即选中这一行,拖动可在同一侧扩展范围,松开即打开评论框。评论框支持换行和输入法组合输入,只有点击操作按钮才提交。可拖动底边调整高度。“添加到输入框”将评论作为可移除的引用加入主输入框,多条引用只会在用户发送主输入框时一起发送。GitHub 分段按钮用于开始或加入待提交评审,下拉菜单可以立即发布单条评论。评论在引用、暂存或发布后保留在对应代码旁,暂存评论可在那里编辑或删除。“请求更改”需要填写评审总结,弹窗在总结输入框旁说明此要求,并在切换评审决定时播报。PR 标题栏中的“提交评审”打开居中弹窗,只显示待提交数量、总结以及“评论”“批准”“请求更改”选项。新建和删除文件只显示有内容的一侧行号。待提交评审与评论记录按 Session 和页签保存在当前浏览器,包括最初评审的 head。切换 Session 或隐藏面板时保留;关闭页签会清空该页签的评论、总结与评审决定,重新打开从空评审开始。已发布到 GitHub 的评论与已加入主输入框的引用不受影响。head 变化后需要重新选择评论行。代码变化后的记录保留在文件末尾;文件已不在 diff 中时,记录显示在 diff 之后。页签保持打开时,结果不明的提交在重新加载后仍会阻止重复发送,直到用户检查 GitHub 并明确允许再次提交。
“全部文件”打开可搜索的目录树,并把选中文件滚动到中间;只有一个子目录的路径会合并显示,重命名文件也可以按旧路径搜索。较宽的变更面板会在 diff 旁显示同一棵树,工具栏的“文件树”按钮按“左侧 → 右侧 → 隐藏”循环切换,默认显示在左侧。按钮的无障碍名称说明下一步操作,移动或隐藏文件树时焦点仍留在按钮上。移动或隐藏文件树,以及切换标签页与 Session 时保留搜索、折叠状态、选中文件和宽度。拖动分隔线,或聚焦分隔线后使用方向键,可以调整文件树宽度。文件与文件夹预留相同的展开箭头列,子项相对父目录向内缩进。本地变更与每个 PR 保留独立标签页。划选代码文字或通过行号选择同一已显示 hunk、同一侧的连续行,文字划选与复制都限定在起始侧。点击“+”打开纯文本评论框。使用鼠标时,悬停在某一行上也会在其行号旁显示“+”,行号仍可点击:两栏时显示在悬停的一侧,单栏时显示在该行所属的一侧;按下即选中这一行,拖动可在同一侧扩展范围,松开即打开评论框。评论框支持换行和输入法组合输入,只有点击操作按钮才提交。可拖动底边调整高度。“添加到输入框”将评论作为可移除的引用加入主输入框,引用显示文件名、侧与行号以及评论,悬停时显示文件路径;多条引用只会在用户发送主输入框时一起发送。GitHub 分段按钮用于开始或加入待提交评审,下拉菜单可以立即发布单条评论。评论在引用、暂存或发布后保留在对应代码旁,暂存评论可在那里编辑或删除。“请求更改”需要填写评审总结,弹窗在总结输入框旁说明此要求,并在切换评审决定时播报。PR 标题栏中的“提交评审”打开居中弹窗,只显示待提交数量、总结以及“评论”“批准”“请求更改”选项。新建和删除文件只显示有内容的一侧行号。待提交评审与评论记录按 Session 和页签保存在当前浏览器,包括最初评审的 head。切换 Session 或隐藏面板时保留;关闭页签会清空该页签的评论、总结与评审决定,重新打开从空评审开始。已发布到 GitHub 的评论与已加入主输入框的引用不受影响。head 变化后需要重新选择评论行。代码变化后的记录保留在文件末尾;文件已不在 diff 中时,记录显示在 diff 之后。页签保持打开时,结果不明的提交在重新加载后仍会阻止重复发送,直到用户检查 GitHub 并明确允许再次提交。
-----
@@ -1,11 +1,11 @@
/** Changed-file navigation shared by the toolbar menu and wide diff pane, with a disclosure column on every row. */
import { useMemo, useState, type ReactNode } from 'react'
import { useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { FileTypeIcon, IconChevronDownOutlineRegular, IconFolderCloseRegular, Input, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ForgeDiffFile } from '../types.ts'
import { Counts } from './DiffFile.tsx'
import { changedFileTree, filterFileTree, type ChangedFileNode } from './file-tree.ts'
import { changedFileTree, filesInTreeOrder, filterFileTree, type ChangedFileNode } from './file-tree.ts'
import type { NS } from './locales.ts'
import css from './ChangedFiles.module.css'
@@ -62,6 +62,23 @@ function Rows({ nodes, depth, props }: {
})
}
/**
* Scroll only the row container to show a row that is not wholly visible; a visible row stays where it is.
* `scrollIntoView` would also scroll every scrollable ancestor, moving the menu card or the page behind it, and
* its `container` option is not yet supported widely enough, so the offset is measured here.
* @param container - the scrolling rows.
* @param row - the row to show.
* @param align - `center` places the row mid-view with its neighbors; `nearest` scrolls by the least distance.
*/
function revealRow(container: HTMLElement, row: HTMLElement, align: 'center' | 'nearest'): void {
const top = container.getBoundingClientRect().top + container.clientTop
const bottom = top + container.clientHeight
const box = row.getBoundingClientRect()
if (box.top >= top && box.bottom <= bottom) return
if (align === 'center') container.scrollTop += (box.top + box.bottom - top - bottom) / 2
else container.scrollTop += box.top < top ? box.top - top : box.bottom - bottom
}
/**
* Render searchable directory navigation without reading file contents.
* @param props - changed files, per-tab choices, navigation actions, and localized copy.
@@ -70,11 +87,36 @@ function Rows({ nodes, depth, props }: {
export function ChangedFiles(props: ChangedFilesProps): ReactNode {
const tree = useMemo(() => changedFileTree(props.files), [props.files])
const nodes = useMemo(() => filterFileTree(tree, props.state.query), [tree, props.state.query])
const order = useMemo(() => filesInTreeOrder(tree), [tree])
const selectedIndex = order.findIndex(file => file.path === props.state.selected)
const rows = useRef<HTMLDivElement>(null)
const query = useRef(props.state.query)
// A changed filter shows its results from the top.
useLayoutEffect(() => {
if (query.current === props.state.query) return
query.current = props.state.query
const container = rows.current
/* v8 ignore next -- the ref is attached before layout effects run. */
if (container === null) return
container.scrollTop = 0
}, [props.state.query])
const mounted = useRef(false)
// The toolbar menu mounts these rows at the top on every opening, so mounting centers the selected file. Later, a
// file step in the diff, or files arriving or reordering around a selection, scrolls by the least distance; a
// refresh that keeps the selected file's position leaves the reader's scroll alone.
useLayoutEffect(() => {
const align = mounted.current ? 'nearest' : 'center'
mounted.current = true
const container = rows.current
const row = container?.querySelector<HTMLElement>('[aria-current="true"]')
if (container == null || row == null) return
revealRow(container, row, align)
}, [props.state.selected, selectedIndex])
return <div className={css.root} data-forge-file-tree="">
<div className={css.search}><Input className={clsx(css.searchInput)} aria-label={props.t('diff.filterFiles')} placeholder={props.t('diff.filterFiles')}
value={props.state.query} onChange={(event) => { props.filter(event.target.value) }}
onKeyDown={(event) => { if (['Home', 'End', 'ArrowLeft', 'ArrowRight'].includes(event.key)) event.stopPropagation() }} /></div>
<div className={css.rows}>
<div ref={rows} className={css.rows}>
{nodes.length === 0 ? <div className={css.empty}>{props.t('diff.noMatchingFiles')}</div> : <Rows nodes={nodes} depth={0} props={props} />}
</div>
</div>
@@ -5,7 +5,7 @@ import clsx from 'clsx'
import { Button, IconChevronDownOutlineRegular, Menu, MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ReviewQueue } from './review-queue.ts'
import type { ReviewSelection, ReviewSource } from './review.ts'
import { reviewRange, type ReviewSelection, type ReviewSource } from './review.ts'
import type { NS } from './locales.ts'
import css from './ReviewComposer.module.css'
@@ -72,7 +72,7 @@ export function ReviewComposer({ selection, actions, close, visible = true, spli
const input = useRef<HTMLTextAreaElement>(null)
useEffect(() => { input.current?.focus() }, [])
const comment = (): ReviewSelection => ({ ...selection, comment: { ...selection.comment, body: body.current } })
const range = `${selection.comment.side}${selection.comment.startLine}${selection.comment.endLine === selection.comment.startLine ? '' : `–${selection.comment.endLine}`}`
const range = reviewRange(selection.comment)
const publish = async (): Promise<void> => {
if (running.current || actions.publish === undefined) return
running.current = true
@@ -5,6 +5,7 @@ import { Button, Tag } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ReviewActions } from './ReviewComposer.tsx'
import { EMPTY_FILE_REVIEW, type FileReviewState, type ReviewThread as Thread } from './review-queue.ts'
import { reviewRange } from './review.ts'
import type { NS } from './locales.ts'
import css from './ReviewComposer.module.css'
@@ -39,7 +40,7 @@ export function ReviewThread({ thread, actions, locked, outdated, split, t }: Pr
const [error, setError] = useState<string | null>(null)
const comment = thread.selection.comment
const quoted = editing && thread.kind === 'pending' ? { ...thread.selection, comment: { ...comment, body } } : thread.selection
const range = `${comment.side}${comment.startLine}${comment.endLine === comment.startLine ? '' : `–${comment.endLine}`}`
const range = reviewRange(comment)
const status = thread.kind === 'pending' ? 'review.pending' : thread.kind === 'published' ? 'review.published' : 'review.quoted'
const save = (): boolean => actions.records?.editThread(thread.key, body) === true
return <section className={css.thread} data-forge-review-thread={thread.kind} data-side={comment.side}
@@ -18,7 +18,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RemoteResult, TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
import type { ForgeChangeRequestListState, ForgeChangeRequestStatesFrame, ForgePanelChangeRequest } from '../types.ts'
import { ReviewQueue } from './review-queue.ts'
import { formatReviewComment, type ReviewSelection } from './review.ts'
import { formatReviewComment, reviewQuoteSummary, type ReviewSelection } from './review.ts'
import { ActionToast } from './ActionToast.tsx'
import { CHANGES_ID, changesDefinition } from './changes-address.ts'
import { createChangesStore } from './changes-store.ts'
@@ -148,7 +148,7 @@ function registerUi(ctx: Context): void {
if (scope === undefined || conversation === undefined) return false
const input = conversation.input.for(scope)
const numbers = Array.from(input.state.getSnapshot().draft.matchAll(/(?:^|\n)(?:> )?## User Comment (\d+)/g), match => Number(match[1]))
if (!input.insertQuote(formatReviewComment(selection, Math.max(0, ...numbers) + 1))) return false
if (!input.insertQuote(formatReviewComment(selection, Math.max(0, ...numbers) + 1), reviewQuoteSummary(selection.comment))) return false
input.focus()
return true
}
@@ -1,5 +1,6 @@
/** Frozen line selections shared by the diff UI, composer references, and review submission. */
import type { QuoteSummary } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ForgeDiffFile } from '../types.ts'
/** A comment on consecutive lines of one side of one displayed hunk. */
@@ -58,6 +59,25 @@ export function selectReviewLines(file: ForgeDiffFile, side: 'L' | 'R', from: nu
}
}
/**
* Name a comment's side and lines, such as `R12` or `L3–5`.
* @param comment - the commented range.
* @returns the range label shown beside comments and in composer quotes.
*/
export function reviewRange(comment: ReviewComment): string {
return `${comment.side}${comment.startLine}${comment.endLine === comment.startLine ? '' : `–${comment.endLine}`}`
}
/**
* Describe a composer quote of a comment by its file, range, and text instead of the whole message reference.
* @param comment - the quoted comment.
* @returns the composer's display of the quote.
*/
export function reviewQuoteSummary(comment: ReviewComment): QuoteSummary {
const range = reviewRange(comment)
return { label: `${comment.path.slice(comment.path.lastIndexOf('/') + 1)} ${range}`, title: comment.path, detail: range, body: comment.body }
}
/**
* Format a frozen reference for the ordinary, logged user message.
* @param selection - source, range, displayed patch, and user comment.
@@ -5,7 +5,7 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { ForgeDiffFile } from '../src/types.ts'
import { ChangedFiles, type ChangedFilesState } from '../src/client/ChangedFiles.tsx'
import { ChangedFiles, ChangedFilesMenu, type ChangedFilesState } from '../src/client/ChangedFiles.tsx'
import { changedFileTree, filesInTreeOrder, filterFileTree } from '../src/client/file-tree.ts'
import { en } from '../src/client/locales.ts'
import { FileTreePane } from '../src/client/FileTreePane.tsx'
@@ -79,6 +79,55 @@ it('keeps text-caret navigation in the filter while other keys bubble to the men
expect(view.getByRole('button').getAttribute('aria-current')).toBe('true')
})
it('centers the selected file on each menu opening and then follows selection steps by the least scroll', () => {
const files = ['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts', 'h.ts', 'i.ts', 'j.ts'].map(path => file(path))
// Layout: the rows show three 30px files from y = 100; each file moves up by the rows' scroll offset.
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(90)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
const index = files.findIndex(item => item.path === this.getAttribute('aria-label'))
const scrolled = this.parentElement?.parentElement?.scrollTop ?? 0
return index < 0 ? new DOMRect(0, 100, 200, 90) : new DOMRect(0, 100 + index * 30 - scrolled, 200, 30)
})
const rows = (): HTMLElement => document.querySelector<HTMLElement>('[data-forge-file-tree] > :last-child')!
const props = { files, t: makeTranslate(en), filter: vi.fn(), toggle: vi.fn(), jump: vi.fn() }
const menu = render(<ChangedFilesMenu {...props} state={{ query: '', collapsed: {}, selected: 'f.ts' }} visible />)
const trigger = menu.getByRole('button', { name: 'All 10 files' })
for (const opening of [1, 2]) {
fireEvent.click(trigger)
// f.ts spans 250–280 and the view 100–190, so its middle moves to the view's middle.
expect(rows().scrollTop, `opening ${opening}`).toBe(120)
fireEvent.click(trigger)
expect(document.querySelector('[data-forge-file-tree]')).toBeNull()
}
menu.rerender(<ChangedFilesMenu {...props} state={{ query: '', collapsed: {}, selected: 'b.ts' }} visible />)
fireEvent.click(trigger)
expect(rows().scrollTop).toBe(0)
menu.unmount()
const pane = render(<ChangedFiles {...props} state={{ query: '', collapsed: {}, selected: null }} />)
expect(rows().scrollTop).toBe(0)
const select = (selected: string): void => { pane.rerender(<ChangedFiles {...props} state={{ query: '', collapsed: {}, selected }} />) }
select('h.ts')
expect(rows().scrollTop).toBe(150)
select('a.ts')
expect(rows().scrollTop).toBe(0)
select('c.ts')
expect(rows().scrollTop).toBe(0)
select('h.ts')
expect(rows().scrollTop).toBe(150)
pane.rerender(<ChangedFiles {...props} state={{ query: 'ts', collapsed: {}, selected: 'h.ts' }} />)
expect(rows().scrollTop).toBe(0)
pane.unmount()
// A navigation selects its file before the diff arrives; the rows then reveal it once they exist.
const early = render(<ChangedFiles {...props} files={[]} state={{ query: '', collapsed: {}, selected: 'h.ts' }} />)
expect(rows().scrollTop).toBe(0)
early.rerender(<ChangedFiles {...props} state={{ query: '', collapsed: {}, selected: 'h.ts' }} />)
expect(rows().scrollTop).toBe(150)
rows().scrollTop = 0
early.rerender(<ChangedFiles {...props} files={[...files]} state={{ query: '', collapsed: {}, selected: 'h.ts' }} />)
expect(rows().scrollTop).toBe(0)
})
it('bounds pointer and keyboard resizing, retains hidden width, and stops cancelled drags', () => {
let containerWidth = 1000
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => containerWidth)
@@ -8,6 +8,7 @@ import { Context, Service } from '@deepseek-ai/cordis'
import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime'
import type { QuoteSummary } from '@deepseek-ai/dsh-client-ui-primitives'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { SidebarRightTabDefinition, TabId } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import type { ChangeRequestSnapshot, ForgeReviewRequest, ForgeReviewResult } from '@deepseek-ai/dsh-experimental-forge/types'
@@ -729,7 +730,7 @@ describe('mountForgePanel changes view', () => {
const scope = f.sessions.scope(S1)!
let draft = ''
const focus = vi.fn(), send = vi.fn()
const insertQuote = vi.fn((text: string) => { draft += `\n> ${text}`; return true })
const insertQuote = vi.fn((text: string, _summary?: QuoteSummary) => { draft += `\n> ${text}`; return true })
const input = { state: { getSnapshot: () => ({ draft }) }, insertQuote, focus }
const resolve = vi.fn(() => input)
// Only the input methods used by this contribution are supplied by the Session fixture.
@@ -738,6 +739,7 @@ describe('mountForgePanel changes view', () => {
expect(view.addReviewToInput(selection)).toBe(true)
expect(view.addReviewToInput(selection)).toBe(true)
expect(insertQuote.mock.calls.map(([text]) => text.split('\n')[0])).toEqual(['## User Comment 1', '## User Comment 2'])
expect(insertQuote.mock.calls[0]?.[1]).toEqual({ label: 'a.ts R1', title: 'a.ts', detail: 'R1', body: 'Check this' })
expect(resolve).toHaveBeenCalledWith(scope)
expect(focus).toHaveBeenCalledTimes(2)
insertQuote.mockReturnValueOnce(false)
@@ -5,7 +5,7 @@ import { Profiler } from 'react'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { ForgeDiffFile } from '../src/types.ts'
import { formatReviewComment, selectReviewLines, reviewMatches, type ReviewSelection } from '../src/client/review.ts'
import { formatReviewComment, reviewQuoteSummary, selectReviewLines, reviewMatches, type ReviewSelection } from '../src/client/review.ts'
import { ReviewQueue } from '../src/client/review-queue.ts'
import { ReviewThread } from '../src/client/ReviewThread.tsx'
import { SubmitReview } from '../src/client/SubmitReview.tsx'
@@ -109,6 +109,11 @@ describe('review references', () => {
expect(formatReviewComment({ source: { kind: 'local', scope: 'branch', base: 'origin/release/2.4', baseRef: 'refs/remotes/origin/release/2.4' }, comment: selection.comment }, 3))
.toContain('Lines: 3-4\nSource: Local (branch)\nBase: origin/release/2.4\nDiff hunk:\n')
})
it('summarizes a composer quote by file name, side and lines, and comment', () => {
expect(reviewQuoteSummary({ ...selection.comment, path: 'src/client/a.ts' }))
.toEqual({ label: 'a.ts R3–4', title: 'src/client/a.ts', detail: 'R3–4', body: '为什么新增这两行?' })
expect(reviewQuoteSummary({ ...selection.comment, side: 'L', endLine: 3 })).toEqual({ label: 'a.ts L3', title: 'a.ts', detail: 'L3', body: '为什么新增这两行?' })
})
it('adds a quote without posting or closing when the input refuses it', () => {
const addToInput = vi.fn<(selection: ReviewSelection) => boolean>().mockReturnValue(false)
const close = vi.fn()