Merge remote-tracking branch 'origin/master' into fix/web-ui-optimization

This commit is contained in:
imccyu
2026-07-28 15:06:17 +08:00
122 changed files with 1735 additions and 629 deletions
@@ -135,13 +135,15 @@ export function apply(ctx: Context): void {
'conversation.input.model': { kind: 'single', scope: 'session' },
},
inject: (sessionId: SessionId): ComposerBarInjected => {
const shell = inputHub.shell(sessionId)
return {
keyboard: inputHub.keyboard(sessionId),
keyboard: shell,
stop: () => {
scopedConversation(sessions, sessionId).cancel().catch(() => {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
})
},
hooks: { notices: shell.notices, lexicon: shell.lexicon },
}
},
}, InputBar)
@@ -40,13 +40,20 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) {
const last = blocks.length - 1
// Tool-call heads render as tool rows in the chat view's grouping pass, so
// a node that is only those heads (or empty) would paint an empty root
// between tool groups — skip the shell unless something visible remains.
const hasVisible = streaming
|| interrupted === true
|| blocks.some(block => block.kind !== 'tool-call')
if (!hasVisible) return null
return (
<div className={css.root} data-streaming={streaming || undefined}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
// Tool-call heads render as tool rows in the chat view's grouping pass.
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
}
@@ -1,11 +1,11 @@
/** Conversation slot declarations and their composed component props. */
import type { ReactNode, RefObject } from 'react'
import type {
MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, InputActions, InputState } from '../input/contract.ts'
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
@@ -222,6 +222,13 @@ export interface ComposerBarInjected {
keyboard: ComposerKeyboard
/** Cancel the in-flight turn. */
stop: () => void
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
hooks: {
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
notices: ObservableSnapshot<InputNotice | null>
/** Hot plain-text reference lexicon for the decoration scan (decision 21). */
lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
}
}
/**
@@ -233,11 +240,11 @@ export interface InputControlOwnerProps {
locked: boolean
}
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share. */
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */
export type ComposerBarProps =
PropsRuntime<'conversation.composer.bar'>
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
& ComposerBarInjected
& InjectFace<ComposerBarInjected>
/**
* Composer chain currency: what ConversationRoot dispatches at its
@@ -77,8 +77,6 @@ export interface InputNotice {
* satisfies it structurally.
*/
export interface ComposerKeyboard {
/** Latest surfaced notice store (null after none). */
readonly notices: SnapshotStore<InputNotice | null>
/** Live machine state for event-handler reads (render reads go through useInput). */
readonly snapshot: InputState
/** Draft write with the DOM-observed edit shape (narrows occurrence math). */
@@ -99,8 +97,6 @@ export interface ComposerKeyboard {
space(): boolean
/** Dismiss the popupSelect shell (any interaction outside the box). */
dismissPopup(): void
/** Hot plain-text reference lexicons for the decoration scan (decision 21; empty Map without a pipeline). */
lexicon(): ReadonlyMap<'/' | '@', readonly string[]>
}
/** One queued-message row projected from the session/queued frames (T9 supplies the store). */
@@ -206,11 +206,14 @@ export class SessionInputShell implements SessionInput {
}
/**
* Hot plain-text reference lexicons for the decoration scan (decision 21).
* @returns the controller's per-trigger aggregation; empty Map without a pipeline.
* Hot plain-text reference lexicon source for the decoration scan
* (decision 21): delegates to the controller's aggregated store. Stable
* identity per shell; without a pipeline the snapshot is the empty Map and
* subscribers never fire.
*/
lexicon(): ReadonlyMap<'/' | '@', readonly string[]> {
return this.deps.slash?.()?.lexicon() ?? EMPTY_LEXICON
readonly lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>> = {
getSnapshot: () => this.deps.slash?.()?.lexicon.getSnapshot() ?? EMPTY_LEXICON,
subscribe: fn => this.deps.slash?.()?.lexicon.subscribe(fn) ?? (() => {}),
}
/**
@@ -1,11 +1,12 @@
/** The default composer body: the 'conversation.composer.bar' slot entry
* (decision 20). Machine state arrives through the standard provide channel
* (useInput + inputActions); the keyboard/DOM command face and stop arrive
* through this entry's own inject; layout-phase inputs (variant, placeholder,
* through this entry's own inject, whose hooks compartment binds
* useNotices/useLexicon; layout-phase inputs (variant, placeholder,
* region-slot content) ride the owner props. Session facts
* (running/removed/promptError) are self-selected via useSession. */
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -27,15 +28,12 @@ const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
]
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, renderSlot,
useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon,
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
const noticeStore = keyboard.notices
const notice = useSyncExternalStore(
(fn: () => void) => noticeStore.subscribe(fn),
() => noticeStore.getSnapshot(),
)
const notice = useNotices(s => s)
const lexicon = useLexicon(s => s)
const promptError = useSession(s => s.promptError)
const running = useSession(s => s.running)
const disabled = useSession(s => s.removed)
@@ -244,7 +242,7 @@ export function InputBar({
// claim token highlights through behind the textarea glyphs; each U+FFFC
// placeholder renders as a chip (the textarea's own glyph is invisible, the
// backdrop chip supplies the visual); the claim hint is ghost text.
const deco = deriveDecorations(input, keyboard.lexicon())
const deco = deriveDecorations(input, lexicon)
const backdrop: ReactNode[] = []
{
// Segment boundaries: the token range end, every chip offset, and every
@@ -1,25 +1,41 @@
/* todo_write plan-update row: title + progress summary on one line. */
/* todo_write plan-update row: ToolRow chrome (figma 780:53675) —
[16 checklist] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */
.row {
display: flex;
align-items: center;
gap: 8px;
height: 24px;
min-width: 0;
cursor: pointer;
border-radius: 6px;
font-size: 13px;
}
.badge {
.leading {
flex: none;
color: var(--dsw-alias-state-business-primary);
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
font-weight: 500; /* figma wt510, rendered 500 */
color: var(--dsw-alias-label-primary);
color: var(--dsw-alias-label-primary-dimmed);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
@@ -28,11 +44,15 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
.err {
flex: none;
margin-left: 8px;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
line-height: 16px;
}
@@ -3,13 +3,13 @@
// hole like the bash sample (a product registration, not a sample). The row
// summarizes the written list (counts + active item) from the call args; the
// durable list itself renders in the TodoPanel above the composer, so the
// row stays one line.
// row stays one line. Chrome matches ToolRow (figma 780:53675).
import type { KeyboardEvent } from 'react'
import type { Context } from 'cordis'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './todo-row.module.css'
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
@@ -40,6 +40,17 @@ function summarize(argsRaw: string): string | null {
: head
}
/** Leading-slot state substitution matches ToolRow / bash: icon yields to the
* state semantic while running or failed; ok keeps the checklist glyph. */
function leadingFor(state: ToolRowState) {
switch (state) {
case 'running': return <StateDot state="ongoing" />
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconChecklistOutline16 />
}
}
/** One-line plan update row (click opens the raw args in details). Non-ok
* execution states keep the generic row's dot semantics — a cancelled call
* wrote no todo/write, so it must not read as a completed update. */
@@ -64,10 +75,9 @@ export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
onClick={openDetails}
onKeyDown={openFromKeyboard}
>
{model.state === 'ok'
? <span className={css.badge} aria-hidden></span>
: <StateDot state={model.state === 'running' ? 'ongoing' : model.state === 'stopped' ? 'warning' : 'error'} />}
<span className={css.leading} aria-hidden>{leadingFor(model.state)}</span>
<span className={css.title}></span>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{summary}</span>
{model.state === 'error' && <span className={css.err}>failed</span>}
{model.state === 'stopped' && <span className={css.err}></span>}