fix(web): decorate slash tokens in bubbles from logged skill and command facts
This commit is contained in:
1 parent
46d20f8bee
commit
d0da8ccf40
30 files changed
+342
-70
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.md: 200761cc9e648eea80bdae9d7b363246c816e5d1
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 673d0ee4bd0916b20ee74226f50240e3904fe06c
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.md: 500f0acee97f225b0e1a05107f12a5c3ee814bfe
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: cfe0781b945e4570f8f8ca5a6b71a95a2eafadcf
|
||||
+2
-2
@@ -67,8 +67,8 @@ skill/@subagent references skip the placeholder + occurrence identity chain —
|
||||
|
||||
- PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes.
|
||||
- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast.
|
||||
- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text.
|
||||
- Sending is the literal text (no more `<skill>` serialization); on the bubble side MessageItem decorates both shapes (the legacy `<skill>` tag + plain-text tokens).
|
||||
- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits; a `/name` token also ends at whitespace, the draft end, or trailing sentence punctuation — the whitespace-bounded shape of the host skill gesture, so `/nfs-hg/xxx` is a path; the sent-text projection `projectUserText` in ui-primitives applies the same shape) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text.
|
||||
- Sending is the literal text (no more `<skill>` serialization); on the bubble side `projectUserText` decorates a plain-text `/name` token only when the same step logged a `skill-invocation` injection for that name — ui-chat's `SkillNameProjector` attaches the step's injected names to the direct message Node, the way the recall projector attaches session labels — so `/123` or a stray `/word` stays plain; a command-input bubble (ui-goal) names its executed command the same way and renders the token as a `command` chip; `@name` tokens still decorate by shape.
|
||||
- Decoration reactivity: the shell subscribes to the controller's lexicon store and re-scans the document on each roll change, so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render.
|
||||
|
||||
### Per-session provide contributions and the private keyboard surface
|
||||
|
||||
+2
-2
@@ -67,8 +67,8 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引
|
||||
|
||||
- PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同约定:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。
|
||||
- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` 快照 store(每次 source 通知重拉);scope 出生后才注册的 source 由服务广播给活 controller,补 warm 并并入名录。
|
||||
- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。
|
||||
- 发送即原文(不再 `<skill>` 序列化);气泡侧 MessageItem 双形状装饰(legacy `<skill>` 标签 + 纯文本 token)。
|
||||
- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中;`/name` token 还必须止于空白、draft 末尾或句尾标点——与宿主 skill gesture 同样以空白为界,因此 `/nfs-hg/xxx` 是路径;ui-primitives 中已发送文本的投影 `projectUserText` 采用同一形状)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。
|
||||
- 发送即原文(不再 `<skill>` 序列化);气泡侧 `projectUserText` 只在同一步骤记录了该名字的 `skill-invocation` 注入时才装饰纯文本 `/name` token——ui-chat 的 `SkillNameProjector` 把该步骤注入的 skill 名挂到直接消息节点上,与 recall 投影挂会话标签的方式相同——因此 `/123` 或随手敲的 `/词` 保持普通文本;指令输入气泡(ui-goal)以同样方式指明其已执行的指令,把 token 渲染为 `command` chip;`@name` token 仍按形状装饰。
|
||||
- 装饰响应性:shell 订阅 controller 的 lexicon store,每次名录变化重扫全文档,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。
|
||||
|
||||
### 每会话供数贡献与键盘私面
|
||||
|
||||
@@ -148,7 +148,7 @@ function TurnMaxTokensItem({ t }: {
|
||||
|
||||
/** Right-aligned bubble shared by user and steering rows. */
|
||||
function UserStyleBubble({
|
||||
content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t,
|
||||
content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], skillNames = [], previewImages, t,
|
||||
}: {
|
||||
content: readonly unknown[]
|
||||
renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
|
||||
@@ -160,6 +160,8 @@ function UserStyleBubble({
|
||||
echo?: boolean
|
||||
/** Exact session mention labels associated by the adjacent recall node. */
|
||||
referenceLabels?: readonly string[]
|
||||
/** Skill names the step's `skill-invocation` injections loaded for this message. */
|
||||
skillNames?: readonly string[]
|
||||
/** Local submission-echo previews replacing the content-derived image group. */
|
||||
previewImages?: readonly MessageImageSource[]
|
||||
t: ChatViewSlotProps['t']
|
||||
@@ -177,7 +179,7 @@ function UserStyleBubble({
|
||||
<div className={css.userStack}>
|
||||
{renderMessageImages({ images, align: 'end' })}
|
||||
{showBubble && <div className={css.bubble}>
|
||||
{projectUserText(text, referenceLabels)}
|
||||
{projectUserText(text, referenceLabels, skillNames)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
|
||||
</div>}
|
||||
{referenceLabels.length > 0 && (
|
||||
@@ -279,6 +281,7 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
|
||||
content={data.content}
|
||||
renderMessageImages={renderMessageImages}
|
||||
{...data.referenceLabels === undefined ? {} : { referenceLabels: data.referenceLabels }}
|
||||
{...data.skillNames === undefined ? {} : { skillNames: data.skillNames }}
|
||||
t={t}
|
||||
actions={text => (
|
||||
<MessageIconActions
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
PartialAssistant, RunningToolCall, TurnNavigationItem,
|
||||
} from '../contract/snapshot.ts'
|
||||
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
|
||||
import { sessionRecallLabels } from './event-projection.ts'
|
||||
import { sessionRecallLabels, skillInvocationName } from './event-projection.ts'
|
||||
import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts'
|
||||
import { ChatTurnProcessProjector } from './turn-process-presentation.ts'
|
||||
|
||||
@@ -506,6 +506,84 @@ class ReferenceLabelProjector {
|
||||
}
|
||||
}
|
||||
|
||||
function withSkillNames(
|
||||
node: ChatConversationViewNode,
|
||||
names: readonly string[],
|
||||
): ChatConversationViewNode {
|
||||
const candidate = node as ChatNode
|
||||
if (candidate.kind !== 'user' && candidate.kind !== 'steering') return node
|
||||
const current = candidate.data.skillNames ?? EMPTY_KEYS
|
||||
const hasNames = Object.hasOwn(candidate.data, 'skillNames')
|
||||
if (sameReferences(current, names) && hasNames === (names.length > 0)) return node
|
||||
const data: Record<string, unknown> = { ...candidate.data }
|
||||
if (names.length === 0) delete data.skillNames
|
||||
else data.skillNames = names
|
||||
return { ...candidate, data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill names each direct message's step loaded, keyed by message Node key.
|
||||
*
|
||||
* A step's `skill-invocation` injections follow the direct messages the host
|
||||
* scanned for `/name` gestures and precede the step's first non-message Node
|
||||
* (the assistant step, a Turn error, a command); every Node of another kind
|
||||
* therefore closes the batch. Names attach to every direct message of the
|
||||
* batch: the bubble decorates only the tokens its own text carries.
|
||||
* @param nodes - every materialized Chat Node, in any order.
|
||||
* @returns the loaded skill names per direct message key; absent for none.
|
||||
*/
|
||||
function skillNamesByMessage(nodes: readonly ChatConversationViewNode[]): Map<string, readonly string[]> {
|
||||
const ordered = [...nodes].sort((left, right) => left.anchorSeq - right.anchorSeq)
|
||||
const result = new Map<string, readonly string[]>()
|
||||
let batchMessages: string[] = []
|
||||
let batchNames: string[] = []
|
||||
const flush = (): void => {
|
||||
if (batchNames.length > 0) for (const key of batchMessages) result.set(key, batchNames)
|
||||
batchMessages = []
|
||||
batchNames = []
|
||||
}
|
||||
for (const node of ordered) {
|
||||
const candidate = node as ChatNode
|
||||
if (candidate.kind === 'user' || candidate.kind === 'steering') {
|
||||
batchMessages.push(node.key)
|
||||
continue
|
||||
}
|
||||
if (candidate.kind !== 'context') {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
const name = skillInvocationName(candidate.data.source)
|
||||
if (name !== null && !batchNames.includes(name)) batchNames.push(name)
|
||||
}
|
||||
flush()
|
||||
return result
|
||||
}
|
||||
|
||||
/** Attaches each direct message's step-loaded skill names to its Node. */
|
||||
class SkillNameProjector {
|
||||
replace(nodes: readonly ChatConversationViewNode[]): readonly ChatConversationViewNode[] {
|
||||
const names = skillNamesByMessage(nodes)
|
||||
return nodes.map(node => withSkillNames(node, names.get(node.key) ?? EMPTY_KEYS))
|
||||
}
|
||||
|
||||
apply(
|
||||
upserts: readonly ChatConversationViewNode[],
|
||||
store: ChatNodeStore,
|
||||
): readonly ChatConversationViewNode[] {
|
||||
const byKey = new Map(upserts.map(node => [node.key, node]))
|
||||
const all = new Map(store.values().map(node => [node.key, node]))
|
||||
for (const [key, node] of byKey) all.set(key, node)
|
||||
const names = skillNamesByMessage([...all.values()])
|
||||
for (const [key, node] of all) {
|
||||
const candidate = node as ChatNode
|
||||
if (candidate.kind !== 'user' && candidate.kind !== 'steering') continue
|
||||
const next = withSkillNames(node, names.get(key) ?? EMPTY_KEYS)
|
||||
if (next !== node || byKey.has(key)) byKey.set(key, next)
|
||||
}
|
||||
return [...byKey.values()]
|
||||
}
|
||||
}
|
||||
|
||||
interface LegacyContribution {
|
||||
readonly anchorSeq: number
|
||||
readonly nodes: readonly ConversationNode[]
|
||||
@@ -756,6 +834,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
|
||||
private readonly navigation = new MutableTurnNavigationIndex()
|
||||
private readonly legacy = new LegacySliceBuilder()
|
||||
private readonly referenceLabels = new ReferenceLabelProjector()
|
||||
private readonly skillNames = new SkillNameProjector()
|
||||
private order: readonly string[] = EMPTY_KEYS
|
||||
/** Last published timeline: a Turn boundary can land without a new node. */
|
||||
private timeline: ConversationTimelineSnapshot | null = null
|
||||
@@ -769,7 +848,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
|
||||
readonly nodes: readonly ChatConversationViewNode[]
|
||||
readonly timeline: ConversationTimelineSnapshot
|
||||
}): ChatSnapshot {
|
||||
const nodes = this.referenceLabels.replace(input.nodes)
|
||||
const nodes = this.skillNames.replace(this.referenceLabels.replace(input.nodes))
|
||||
this.store.replace(nodes)
|
||||
this.order = orderedVisibleChatNodes(nodes).map(node => node.key)
|
||||
this.locations.rebuild(this.order, this.store)
|
||||
@@ -785,7 +864,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
|
||||
readonly upserts: readonly ChatConversationViewNode[]
|
||||
readonly timeline: ConversationTimelineSnapshot
|
||||
}): ChatSnapshot {
|
||||
const upserts = this.referenceLabels.apply(input.upserts, this.store)
|
||||
const upserts = this.skillNames.apply(this.referenceLabels.apply(input.upserts, this.store), this.store)
|
||||
const processTurns = new Set<number>()
|
||||
let structural = false
|
||||
const contentOnly: ChatConversationViewNode[] = []
|
||||
|
||||
@@ -88,6 +88,17 @@ export function sessionRecallLabels(source: unknown): string[] {
|
||||
return collect(record, 'references', 'label')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the skill name a durable skill-invocation injection loaded.
|
||||
* @param source - Logged `user/message` source.
|
||||
* @returns The skill name, or null for every other source.
|
||||
*/
|
||||
export function skillInvocationName(source: unknown): string | null {
|
||||
const record = asRecord(source)
|
||||
if (record === null || readString(record, 'kind') !== 'skill-invocation') return null
|
||||
return readString(record, 'name')
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify finalized Assistant content for Chat rendering.
|
||||
* @param content - Core content blocks.
|
||||
|
||||
@@ -9,11 +9,15 @@ import { contextForm, contextProvenance } from './event-projection.ts'
|
||||
interface ReferencedUserMessageNode extends UserMessageNode {
|
||||
/** Labels cited by the immediately following session-reference context. */
|
||||
readonly referenceLabels?: readonly string[]
|
||||
/** Skill names the same step's `skill-invocation` injections loaded. */
|
||||
readonly skillNames?: readonly string[]
|
||||
}
|
||||
|
||||
interface ReferencedSteeringMessageNode extends SteeringMessageNode {
|
||||
/** Labels cited by the immediately following session-reference context. */
|
||||
readonly referenceLabels?: readonly string[]
|
||||
/** Skill names the same step's `skill-invocation` injections loaded. */
|
||||
readonly skillNames?: readonly string[]
|
||||
}
|
||||
|
||||
type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | ContextMessageNode
|
||||
|
||||
@@ -48,10 +48,11 @@ interface MessageItemProps {
|
||||
readonly node: ConversationNode
|
||||
readonly t: ChatNodeViewProps['t']
|
||||
readonly referenceLabels?: readonly string[]
|
||||
readonly skillNames?: readonly string[]
|
||||
}
|
||||
|
||||
/** Legacy-node fixture adapter for the independently registered renderers. */
|
||||
function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps) {
|
||||
function MessageItem({ node, t: translate, referenceLabels, skillNames }: MessageItemProps) {
|
||||
const kind = node.kind === 'assistant' ? 'assistant-step' : node.kind
|
||||
const viewNode: ChatConversationViewNode = {
|
||||
key: `fixture:${node.kind}:${node.seq}`,
|
||||
@@ -63,8 +64,12 @@ function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps)
|
||||
visibility: 'visible',
|
||||
data: node.kind === 'model-retry'
|
||||
? { attempts: [node], current: node }
|
||||
: (node.kind === 'user' || node.kind === 'steering') && referenceLabels !== undefined
|
||||
? { ...node, referenceLabels }
|
||||
: (node.kind === 'user' || node.kind === 'steering') && (referenceLabels !== undefined || skillNames !== undefined)
|
||||
? {
|
||||
...node,
|
||||
...(referenceLabels === undefined ? {} : { referenceLabels }),
|
||||
...(skillNames === undefined ? {} : { skillNames }),
|
||||
}
|
||||
: node,
|
||||
}
|
||||
const props = { node: viewNode, t: translate, renderMessageImages, useChat: useDetachedChat } as ChatNodeViewProps
|
||||
@@ -141,6 +146,22 @@ describe('MessageItem arms', () => {
|
||||
expect(view.container.textContent).toContain('README.md, please.')
|
||||
})
|
||||
|
||||
it('decorates a slash token as a skill chip only when the step resolved that skill', () => {
|
||||
const message = {
|
||||
kind: 'user' as const,
|
||||
seq: 1,
|
||||
time: 1_000,
|
||||
content: [{ type: 'text', text: '/123 then /demo-skill go' }] as never,
|
||||
source: null,
|
||||
}
|
||||
const plain = render(<MessageItem t={t} node={message} />)
|
||||
expect(plain.container.querySelectorAll('[data-ref-chip]').length).toBe(0)
|
||||
const resolved = render(<MessageItem t={t} node={message} skillNames={['demo-skill']} />)
|
||||
const chips = [...resolved.container.querySelectorAll('[data-ref-chip="skill"]')]
|
||||
expect(chips.map(chip => chip.textContent)).toEqual(['/demo-skill'])
|
||||
expect(resolved.container.textContent).toContain('/123 then ')
|
||||
})
|
||||
|
||||
it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
|
||||
@@ -1766,6 +1766,58 @@ describe('built-in conversation node Definitions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('associates a direct message with the skill invocations injected for its step', () => {
|
||||
const skillInvocation = (id: string) => ({
|
||||
...textMessage(id, 'instructions'),
|
||||
source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' },
|
||||
})
|
||||
const instructions = (id: string) => ({
|
||||
...textMessage(id, 'workspace rules'),
|
||||
source: { kind: 'agent-instructions', changes: [{ path: 'AGENTS.md' }] },
|
||||
})
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'user/message', textMessage('gesture', '/demo-skill go'), { surfaceOp: 'append' }),
|
||||
at(3, 'step/start', { turn: 1, step: 1 }),
|
||||
at(4, 'user/message', instructions('rules-1'), { surfaceOp: 'append' }),
|
||||
at(5, 'user/message', skillInvocation('skill-body'), { surfaceOp: 'append' }),
|
||||
at(6, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: assistantMessage('answer-1', 'done'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(7, 'step/end', { turn: 1, step: 1 }),
|
||||
at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
at(9, 'turn/start', { turn: 2 }),
|
||||
at(10, 'user/message', textMessage('later', '/demo-skill again?'), { surfaceOp: 'append' }),
|
||||
at(11, 'step/start', { turn: 2, step: 1 }),
|
||||
at(12, 'user/message', instructions('rules-2'), { surfaceOp: 'append' }),
|
||||
])
|
||||
|
||||
const users = [...snapshot(value).nodes.values()].filter(candidate => candidate.kind === 'user')
|
||||
expect(users).toHaveLength(2)
|
||||
expect(users[0]?.data).toMatchObject({ skillNames: ['demo-skill'] })
|
||||
expect(users[1]?.data).not.toHaveProperty('skillNames')
|
||||
})
|
||||
|
||||
it('updates an already published direct node when its skill injection arrives', () => {
|
||||
const value = assembler([
|
||||
at(1, 'user/message', textMessage('gesture', '/demo-skill go'), { surfaceOp: 'append' }),
|
||||
])
|
||||
const before = node(snapshot(value), 'user')
|
||||
expect(before?.data).not.toHaveProperty('skillNames')
|
||||
|
||||
value.append(at(2, 'user/message', {
|
||||
...textMessage('skill-body', 'instructions'),
|
||||
source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' },
|
||||
}, { surfaceOp: 'append' }))
|
||||
value.flush()
|
||||
|
||||
const after = node(snapshot(value), 'user')
|
||||
expect(after?.key).toBe(before?.key)
|
||||
expect(after?.data).toMatchObject({ skillNames: ['demo-skill'] })
|
||||
})
|
||||
|
||||
it('keeps replacement copies out of Chat business nodes', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
|
||||
@@ -23,12 +23,20 @@ export interface TextRefRange {
|
||||
/** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */
|
||||
const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g
|
||||
const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g
|
||||
/**
|
||||
* What may follow a `/name` token: whitespace or the draft end, optionally
|
||||
* after trailing sentence punctuation. The host skill gesture
|
||||
* (`dsh-tool-skill`) is whitespace-bounded, so `/nfs-hg/xxx` or `/plan.md`
|
||||
* is a path, never a reference.
|
||||
*/
|
||||
const SLASH_TOKEN_END_RE = /^[.,;:!?,。;:!?]*(?:\s|$)/
|
||||
|
||||
/**
|
||||
* Scan the draft for plain-text reference tokens against the hot lexicons.
|
||||
* Word-boundary discipline: the trigger must sit at the draft
|
||||
* start or after whitespace ('x/name' never matches); the name must be an
|
||||
* exact lexicon member.
|
||||
* exact lexicon member; a `/name` token must end at whitespace, the draft
|
||||
* end, or trailing sentence punctuation ('/name/x' is a path).
|
||||
* @param draft - draft text.
|
||||
* @param lexicon - per-trigger name lists (a missing trigger scans nothing).
|
||||
* @returns matched ranges in draft order.
|
||||
@@ -44,6 +52,7 @@ export function scanTextRefs(
|
||||
while ((m = TEXT_REF_RE.exec(draft)) !== null) {
|
||||
const trigger = m[2] as '/' | '@'
|
||||
const name = m[3] ?? ''
|
||||
if (trigger === '/' && !SLASH_TOKEN_END_RE.test(draft.slice(m.index + m[0].length))) continue
|
||||
if (lexicon.get(trigger)?.includes(name)) {
|
||||
const start = m.index + (m[1]?.length ?? 0)
|
||||
out.push({ start, end: start + 1 + name.length, trigger })
|
||||
|
||||
@@ -347,6 +347,14 @@ describe('decorations: scanTextRefs', () => {
|
||||
expect(scanTextRefs('/research @goal', lexicon)).toEqual([])
|
||||
})
|
||||
|
||||
it('a "/" token continued by a path never matches, even when the name is on the lexicon', () => {
|
||||
expect(scanTextRefs('/goal/x /goal/ /goal.md', lexicon)).toEqual([])
|
||||
})
|
||||
|
||||
it('a "/" token may end at trailing punctuation before whitespace', () => {
|
||||
expect(scanTextRefs('/goal。 then', lexicon)).toEqual([{ start: 0, end: 5, trigger: '/' }])
|
||||
})
|
||||
|
||||
it('word boundary: a trigger glued to text never matches', () => {
|
||||
expect(scanTextRefs('x/goal y@research', lexicon)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md
|
||||
README.md: 1a2c65759f5983fbece83f538ee9ee09902dd020
|
||||
README.zh.md: feebeaff0b5e7e9258f15637d0a78a897e0f24c1
|
||||
README.md: 8556575b00e1d3073f251d83f3dee0bafb4c8969
|
||||
README.zh.md: 22d0417bc2d37719150ec46aad849ad2bce8b460
|
||||
@@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation` and the goal domain package; the s
|
||||
|
||||
### The command-input bubble
|
||||
|
||||
Each durable `/goal` run projects as a right-aligned monospace user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run.
|
||||
Each durable `/goal` run projects as a right-aligned user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row; the leading `/goal` token renders as a command reference chip in the code face through ui-primitives `projectUserText`, and the objective stays plain body text. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run.
|
||||
|
||||
### Failures
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-reference"
|
||||
|
||||
### 指令输入气泡
|
||||
|
||||
每条持久的 `/goal` 运行都投影为一个右对齐的等宽用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。
|
||||
每条持久的 `/goal` 运行都投影为一个右对齐的用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前;开头的 `/goal` token 经 ui-primitives 的 `projectUserText` 以等宽代码字体渲染为指令引用 chip,目标文本保持正文字体。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。
|
||||
|
||||
### 失败
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@
|
||||
border-radius: 22px;
|
||||
background: var(--dsw-specific-bubble);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-markdown-code);
|
||||
/* The command echo is a user message, not dense code-in-prose: keep the
|
||||
code family from the token but read at the body size, riding the
|
||||
Settings font-size axis like the user bubble (14/22 at the default). */
|
||||
/* The command echo is a user message: it inherits the body face like the
|
||||
user bubble and rides the same Settings font-size axis (14/22 at the
|
||||
default); the `/goal` command chip, set in the code face by the shared
|
||||
projection, is what marks the line as a command. */
|
||||
font-size: var(--dsh-content-font-size, 14px);
|
||||
line-height: calc(22px + var(--dsh-content-font-delta, 0px));
|
||||
white-space: pre-wrap;
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { memo } from 'react'
|
||||
import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { projectUserText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { GoalCommandInputData } from './goal-command-input.ts'
|
||||
import { GOAL_COMMAND, type GoalCommandInputData } from './goal-command-input.ts'
|
||||
import css from './GoalCommandInputView.module.css'
|
||||
|
||||
type GoalCommandInputViewProps =
|
||||
PropsRuntime<'conversation.chat.node', 'command-input'>
|
||||
& PropsLocale<'goal'>
|
||||
|
||||
/** Right-aligned `/goal` input bubble without ordinary message actions. */
|
||||
/**
|
||||
* Right-aligned `/goal` input bubble without ordinary message actions. The
|
||||
* echoed line decorates its leading `/goal` token as a command chip — the run
|
||||
* this Node projects is the fact that the token was a command — and keeps
|
||||
* the objective as plain text.
|
||||
*/
|
||||
export const GoalCommandInputView = memo(function GoalCommandInputView({
|
||||
node, t,
|
||||
}: GoalCommandInputViewProps) {
|
||||
@@ -22,7 +27,7 @@ export const GoalCommandInputView = memo(function GoalCommandInputView({
|
||||
>
|
||||
<div className={css.stack}>
|
||||
<div className={css.bubble}>
|
||||
<MessageText text={data.text} />
|
||||
{projectUserText(data.text, [], [GOAL_COMMAND], 'command')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,9 @@ import type {
|
||||
ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
/** The command name whose runs this projection owns. */
|
||||
export const GOAL_COMMAND = 'goal'
|
||||
|
||||
/** Goal-owned human command input projected independently of model messages. */
|
||||
export interface GoalCommandInputData {
|
||||
readonly commandId: CommandId
|
||||
@@ -36,7 +39,7 @@ export function goalCommandText(event: SessionEvent<'command/run'>): string {
|
||||
export const goalCommandInputDefinition: ConversationNodeDefinition<GoalCommandInputState> = {
|
||||
kind: 'goal-command-input',
|
||||
target: 'chat',
|
||||
match: event => event.type === 'command/run' && event.data.name === 'goal'
|
||||
match: event => event.type === 'command/run' && event.data.name === GOAL_COMMAND
|
||||
? { id: String(event.data.commandId), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match) => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* The command-input bubble's typography as CSS text. jsdom has no layout, so
|
||||
* this reads the declarations that make the bubble share the user bubble's
|
||||
* face and size axis: the shared projection sets the `/goal` chip in the code
|
||||
* face, so the bubble itself must not pin a different family.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(
|
||||
fileURLToPath(new URL('../src/client/GoalCommandInputView.module.css', import.meta.url)),
|
||||
'utf8',
|
||||
).replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
|
||||
function declarations(selector: string): string[] {
|
||||
const rule = new RegExp(`(?:^|\\})\\s*${selector.replace(/[.[\]():*+^$\\]/g, '\\$&')}\\s*\\{([^{}]*)\\}`).exec(css)
|
||||
if (rule === null) throw new Error(`GoalCommandInputView.module.css has no \`${selector}\` rule`)
|
||||
return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
describe('GoalCommandInputView.module.css typography', () => {
|
||||
it('types the command bubble like the user bubble: no family override, same size axis', () => {
|
||||
const bubble = declarations('.bubble')
|
||||
expect(bubble.some(declaration => /^font(-family)?:/.test(declaration))).toBe(false)
|
||||
expect(bubble).toEqual(expect.arrayContaining([
|
||||
'font-size: var(--dsh-content-font-size, 14px)',
|
||||
'line-height: calc(22px + var(--dsh-content-font-delta, 0px))',
|
||||
]))
|
||||
})
|
||||
})
|
||||
@@ -133,5 +133,8 @@ describe('goal command input projection', () => {
|
||||
|
||||
expect(bubble.textContent).toBe('/goal ship it')
|
||||
expect(within(bubble).queryByRole('button')).toBeNull()
|
||||
// The executed command token reads as a reference chip; the objective stays plain text.
|
||||
const chips = [...bubble.querySelectorAll('[data-ref-chip]')]
|
||||
expect(chips.map(chip => [chip.getAttribute('data-ref-chip'), chip.textContent])).toEqual([['command', '/goal']])
|
||||
})
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: c280f0b179b115f417514b40b21179eaac77ad79
|
||||
README.zh.md: e9cea4b31bdbefaa9cc076d5b6866d4e870a70d9
|
||||
README.md: 758132bc9c193594a9407a105820633c75e58880
|
||||
README.zh.md: 8b3fffa9e73464d526c3a2f11b212797c2707c69
|
||||
@@ -33,7 +33,7 @@ Compose feature UI from these atoms whenever the web client needs a standard con
|
||||
|
||||
### Rendering agent output
|
||||
|
||||
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `MessageText` remains the literal-text primitive for user-authored content.
|
||||
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows.
|
||||
|
||||
### Localizing copy
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ kind: "package-library"
|
||||
|
||||
### 渲染 agent 输出
|
||||
|
||||
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`MessageText` 仍是用户创作内容的字面文本原语。
|
||||
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。
|
||||
|
||||
### 本地化文案
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels } from './markdown/MarkdownText.tsx'
|
||||
export { MessageText } from './markdown/MessageText.tsx'
|
||||
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
|
||||
export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'
|
||||
export * from './icons/index.tsx'
|
||||
@@ -1,9 +0,0 @@
|
||||
.text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
/* Font metrics inherit from the consumer's container (bubble 16/24,
|
||||
assistant flow 16/28) — a generic text primitive must not pin its own
|
||||
size, or every consumer's line grid breaks (44px bubble spec regression). */
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
// MessageText is the literal-text primitive for user and steering content; assistant output uses MarkdownText.
|
||||
|
||||
import css from './MessageText.module.css'
|
||||
|
||||
export function MessageText({ text }: { text: string }) {
|
||||
return <div className={css.text}>{text}</div>
|
||||
}
|
||||
@@ -20,6 +20,14 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Skill and command tokens read as code: the theme's code family at the
|
||||
consumer's own size and line height, so the chip sits on the bubble line
|
||||
like the plain runs around it. `@` chips keep the body face — a file or
|
||||
session name is a label, not code. */
|
||||
.slashChip {
|
||||
font-family: var(--dsw-font-markdown-code-font-family);
|
||||
}
|
||||
|
||||
/* Inline reference glyphs (always ReferenceIcon svgs) ride the consumer's own
|
||||
font: 1em keeps the glyph at the text's size in the bubble (14px + user
|
||||
setting), the queue preview's fixed 13px line, and any future consumer — a
|
||||
|
||||
@@ -4,17 +4,27 @@
|
||||
* only, and every part renders inline so a single-line message never breaks
|
||||
* across lines. Three decoration sources, by precedence: the wire session form
|
||||
* `@[label](dsh-session:...)` folds to its label; exact session labels
|
||||
* supplied by an adjacent recall decorate their bare `@label` mention; and
|
||||
* plain `/name` / `@name` word-boundary tokens decorate by shape alone (sent
|
||||
* tokens were validated at compose time).
|
||||
* supplied by an adjacent recall decorate their bare `@label` mention; plain
|
||||
* `@name` word-boundary tokens decorate by shape alone; and a plain `/name`
|
||||
* token decorates only when the caller names it: a skill the host actually
|
||||
* loaded for that message (ui-chat reads the step's `skill-invocation`
|
||||
* injections) or the command a command-input bubble echoes, so `/123` or a
|
||||
* stray `/word` stays plain text. A `/name`
|
||||
* token is whitespace-bounded like the host skill gesture
|
||||
* (`dsh-tool-skill`), optionally before trailing sentence punctuation, so
|
||||
* slash paths (`/nfs-hg/xxx`, `/plan.md`) stay plain even for a loaded name.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ReferenceIcon } from './ReferenceIcon.tsx'
|
||||
import css from './user-text.module.css'
|
||||
|
||||
/** The wire form a session chip serializes to; label is the display text. */
|
||||
const SESSION_WIRE_RE = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu
|
||||
|
||||
/** Sentence punctuation a bare `@name` token may carry without being part of the reference. */
|
||||
const TRAILING_PUNCTUATION_RE = /[.,;:!?,。;:!?]+$/u
|
||||
|
||||
interface DecorationRange {
|
||||
readonly start: number
|
||||
readonly end: number
|
||||
@@ -29,9 +39,18 @@ interface DecorationRange {
|
||||
* Split one sent text into inline plain runs and reference chips.
|
||||
* @param text - the logged model text of the message or queue row.
|
||||
* @param sessionLabels - exact session mention labels associated by an adjacent recall.
|
||||
* @param slashNames - names a `/name` token may decorate as: the skills the
|
||||
* host loaded for this message, or the command a command bubble echoes
|
||||
* (unsent queue rows pass none).
|
||||
* @param slashKind - the chip kind those tokens render as.
|
||||
* @returns inline nodes covering the whole text.
|
||||
*/
|
||||
export function projectUserText(text: string, sessionLabels: readonly string[]): ReactNode {
|
||||
export function projectUserText(
|
||||
text: string,
|
||||
sessionLabels: readonly string[],
|
||||
slashNames: readonly string[] = [],
|
||||
slashKind: 'skill' | 'command' = 'skill',
|
||||
): ReactNode {
|
||||
const ranges: DecorationRange[] = []
|
||||
SESSION_WIRE_RE.lastIndex = 0
|
||||
let wire: RegExpExecArray | null
|
||||
@@ -52,15 +71,18 @@ export function projectUserText(text: string, sessionLabels: readonly string[]):
|
||||
start = text.indexOf(label, start + label.length)
|
||||
}
|
||||
}
|
||||
const re = /(^|\s)(\/[\w-]+|@"[^"\n]+"|@[^\s]+)/gu
|
||||
// The `/` alternative's lookahead admits the same trailing punctuation set
|
||||
// TRAILING_PUNCTUATION_RE strips.
|
||||
const re = /(^|\s)(\/[\w-]+(?=[.,;:!?,。;:!?]*(?:\s|$))|@"[^"\n]+"|@[^\s]+)/gu
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const tokenStart = m.index + (m[1] as string).length // (^|\s) captures '' at line start
|
||||
const rawLabel = m[2] as string // non-optional alternation capture
|
||||
const label = rawLabel.startsWith('@"')
|
||||
? rawLabel
|
||||
: rawLabel.replace(/[.,;:!?,。;:!?]+$/gu, '')
|
||||
: rawLabel.replace(TRAILING_PUNCTUATION_RE, '')
|
||||
if (label.length <= 1) continue
|
||||
if (label.startsWith('/') && !slashNames.includes(label.slice(1))) continue
|
||||
ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' })
|
||||
}
|
||||
const rankOf = (range: DecorationRange): number => range.kind === 'session' ? 0 : 1
|
||||
@@ -88,8 +110,8 @@ export function projectUserText(text: string, sessionLabels: readonly string[]):
|
||||
parts.push(
|
||||
<span
|
||||
key={tokenStart}
|
||||
className={css.refChip}
|
||||
data-ref-chip={referenceKind ?? 'skill'}
|
||||
className={clsx(css.refChip, referenceKind === undefined && css.slashChip)}
|
||||
data-ref-chip={referenceKind ?? slashKind}
|
||||
title={label}
|
||||
>
|
||||
{referenceKind !== undefined && (
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { JsonBlock, MarkdownText } from './markdown-test-components.tsx'
|
||||
import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts'
|
||||
import { mathCompatibility } from '../src/markdown/mathCompatibility.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('MessageText', () => {
|
||||
it('renders the text verbatim', () => {
|
||||
const { container } = render(<MessageText text={'# line1\n`line2`'} />)
|
||||
expect(container.textContent).toBe('# line1\n`line2`')
|
||||
expect(container.querySelector('h1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownText', () => {
|
||||
it('renders CommonMark and GFM elements as semantic DOM', () => {
|
||||
const markdown = [
|
||||
|
||||
@@ -24,4 +24,13 @@ describe('user-text.module.css font-size axis', () => {
|
||||
'height: 1em',
|
||||
]))
|
||||
})
|
||||
|
||||
it('sets slash chips in the code face at the consumer size', () => {
|
||||
// Skill and command tokens read as code — the family the theme publishes
|
||||
// for code — while the size and line height stay the consumer's, so the
|
||||
// chip rides the bubble line like the plain runs around it.
|
||||
const slashChip = declarations('.slashChip')
|
||||
expect(slashChip.some(declaration => /^font-family: var\(--dsw-font-/.test(declaration))).toBe(true)
|
||||
expect(slashChip.some(declaration => /^(font|font-size|line-height):/.test(declaration))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -8,12 +8,17 @@ import { describe, expect, it } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { projectUserText } from '../src/user-text.tsx'
|
||||
|
||||
const project = (text: string, labels: readonly string[] = []) =>
|
||||
render(<div data-host>{projectUserText(text, labels)}</div>).container.querySelector('[data-host]')!
|
||||
const project = (
|
||||
text: string,
|
||||
labels: readonly string[] = [],
|
||||
slashNames: readonly string[] = [],
|
||||
slashKind: 'skill' | 'command' = 'skill',
|
||||
) =>
|
||||
render(<div data-host>{projectUserText(text, labels, slashNames, slashKind)}</div>).container.querySelector('[data-host]')!
|
||||
|
||||
describe('projectUserText', () => {
|
||||
it('keeps a decorated single-line message on one line: every part is inline', () => {
|
||||
const host = project('反反复复 /dsh-acp-test @执行几个命令测试', ['执行几个命令测试'])
|
||||
const host = project('反反复复 /dsh-acp-test @执行几个命令测试', ['执行几个命令测试'], ['dsh-acp-test'])
|
||||
expect(host.querySelectorAll('div').length).toBe(0)
|
||||
expect(host.textContent).toBe('反反复复 /dsh-acp-test 执行几个命令测试')
|
||||
const chips = host.querySelectorAll('[data-ref-chip]')
|
||||
@@ -56,12 +61,37 @@ describe('projectUserText', () => {
|
||||
})
|
||||
|
||||
it('strips trailing punctuation and skips degenerate tokens', () => {
|
||||
const host = project('用 /plan。 试试 @。')
|
||||
const host = project('用 /plan。 试试 @。', [], ['plan'])
|
||||
const chips = [...host.querySelectorAll('[data-ref-chip]')]
|
||||
expect(chips.map(c => c.textContent)).toEqual(['/plan'])
|
||||
expect(host.textContent).toBe('用 /plan。 试试 @。')
|
||||
})
|
||||
|
||||
it('decorates a slash token only when the host resolved it as a skill in that step', () => {
|
||||
const bare = project('/123')
|
||||
expect(bare.querySelectorAll('[data-ref-chip]').length).toBe(0)
|
||||
expect(bare.textContent).toBe('/123')
|
||||
const unresolved = project('用 /plan 看看')
|
||||
expect(unresolved.querySelectorAll('[data-ref-chip]').length).toBe(0)
|
||||
const resolved = project('用 /plan 看看', [], ['plan'])
|
||||
expect([...resolved.querySelectorAll('[data-ref-chip]')].map(c => [c.getAttribute('data-ref-chip'), c.textContent]))
|
||||
.toEqual([['skill', '/plan']])
|
||||
})
|
||||
|
||||
it('marks a resolved slash token as a command chip when the caller says so', () => {
|
||||
const host = project('/goal ship it\nsecond line', [], ['goal'], 'command')
|
||||
const chips = [...host.querySelectorAll('[data-ref-chip]')]
|
||||
expect(chips.map(c => [c.getAttribute('data-ref-chip'), c.textContent])).toEqual([['command', '/goal']])
|
||||
expect(host.textContent).toBe('/goal ship it\nsecond line')
|
||||
})
|
||||
|
||||
it('leaves slash paths undecorated even for a resolved name: a /name token ends at whitespace or trailing punctuation', () => {
|
||||
const text = '测试一下ui,不用管我:\n/nfs-hg/xxx/yyy 与 /root-dir/ 和 /plan.md'
|
||||
const host = project(text, [], ['nfs-hg', 'root-dir', 'plan'])
|
||||
expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0)
|
||||
expect(host.textContent).toBe(text)
|
||||
})
|
||||
|
||||
it('prefers the longer recall label when one nests inside another', () => {
|
||||
const host = project('@会话一 收尾', ['会话', '会话一'])
|
||||
const chips = [...host.querySelectorAll('[data-ref-chip="session"]')]
|
||||
|
||||
Reference in new issue
Block a user