Merge remote-tracking branch 'origin/master' into worktree/context-source-cards

# Conflicts:
#	apps/web/tests/snapshots/bash-abort-row/ui.expected.md
#	packages/client/runtime/README.i18n.yaml
#	packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
creatixchu
2026-08-05 10:40:23 +08:00
274 changed files with 8323 additions and 1093 deletions

View File

@@ -16,7 +16,9 @@
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 16px 24px;
/* Sides = composer clearance + 16px: on narrow viewports the transcript
stays exactly 32px narrower than the input card (the shared width rule). */
padding: 16px calc(var(--dsh-composer-side-clearance) + 16px);
}
:global([data-conversation-scroll]) .root {
@@ -31,10 +33,11 @@
min-height: auto;
}
/* Message column: 736px fixed width, centered on the same axis as the
input box; the scroller itself stays full-bleed. */
/* Message column: shared chat width (ConversationRoot --dsh-chat-content-width),
centered on the same axis as the input box (which caps at chat + 16px); the
scroller itself stays full-bleed. */
.column {
max-width: 736px;
max-width: var(--dsh-chat-content-width);
width: 100%;
margin: 0 auto;
display: flex;
@@ -166,7 +169,7 @@
height: 0;
display: flex;
justify-content: flex-end;
padding-right: max(0px, calc((100% - 736px) / 2));
padding-right: max(0px, calc((100% - var(--dsh-chat-content-width)) / 2));
pointer-events: none;
}

View File

@@ -1,12 +1,12 @@
// Shared IconActions chrome for user, steering, and assistant messages: copy
// live, optional branch wiring, and an optional date-aware clock.
import { useCallback, useId } from 'react'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import {
IconBranchOutline16, IconCopyOutline16, Tooltip,
IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { formatMessageClock, formatRunDuration, writeClipboard } from './message-chrome.ts'
import { formatMessageClock, formatRunDuration } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './MessageIconActions.module.css'
@@ -41,9 +41,32 @@ export function MessageIconActions({
}: MessageIconActionsProps) {
const day = useCalendarDay()
const reasonId = useId()
// Same success chrome as CodeBlock: a short check swap after the write,
// gated so re-clicks during the window neither re-copy nor stack timers.
const [copied, setCopied] = useState(false)
const copyPending = useRef(false)
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const copyEpoch = useRef(0)
useEffect(() => () => {
copyEpoch.current += 1
copyPending.current = false
if (copyTimer.current !== null) clearTimeout(copyTimer.current)
}, [])
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
if (copied || copyPending.current) return
const epoch = copyEpoch.current
copyPending.current = true
void writeClipboard(text).then((ok) => {
if (epoch !== copyEpoch.current) return
copyPending.current = false
if (!ok) return
setCopied(true)
copyTimer.current = window.setTimeout(() => {
copyTimer.current = null
setCopied(false)
}, 1000)
})
}, [copied, text])
const clockEl = time === undefined ? null : (
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
{formatMessageClock(time, t, day)}
@@ -58,9 +81,9 @@ export function MessageIconActions({
return (
<div className={className === undefined ? css.actions : `${css.actions} ${className}`}>
{clock === 'start' ? clockEl : null}
<Tooltip label={t('copy')} side="bottom">
<button type="button" className={css.action} aria-label={t('copy')} onClick={onCopy}>
<IconCopyOutline16 />
<Tooltip label={copied ? t('copied') : t('copy')} side="bottom">
<button type="button" className={css.action} aria-label={copied ? t('copied') : t('copy')} onClick={onCopy}>
{copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}
</button>
</Tooltip>
{showBranch && onBranch !== undefined && (

View File

@@ -1,23 +1,25 @@
/* Session stats row: 12/20 tertiary text under the flow, aligned to the
736px message column axis. */
shared message column axis (--dsh-chat-content-width). */
.root {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
max-width: 736px;
/* Block, not flex: text-overflow only elides a block's inline content, so
an overlong line ends in … instead of a mid-glyph clip. */
display: block;
text-align: center;
max-width: var(--dsh-chat-content-width);
width: 100%;
margin: 0 auto;
box-sizing: border-box;
padding: 4px 24px 0px;
padding: 4px calc(var(--dsh-composer-side-clearance) + 16px) 0px;
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sep {
color: var(--dsw-alias-separator-primary);
margin: 0 10px; /* carries the former flex gap */
}

View File

@@ -152,7 +152,7 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection }:
<div className={css.root}>
{groups.map((group, i) => (
<Fragment key={group}>
{i > 0 && <span className={css.sep} aria-hidden>|</span>}
{i > 0 && <><span className={css.sep} aria-hidden>|</span>{' '}</>}
<span>{group}</span>
</Fragment>
))}

View File

@@ -1,5 +1,4 @@
// Shared chrome helpers for user/assistant IconActions rows: clipboard write
// and the compact date+clock label from a session-event epoch.
// Shared time-label helpers for user/assistant IconActions rows.
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
@@ -8,46 +7,6 @@ export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'>
/** The elapsed-duration share of the conversation dictionary. */
export type RunDurationTranslate = Translate<'duration.seconds' | 'duration.minutes'>
/**
* Best-effort clipboard write; rejections stay swallowed (no success chrome).
* @param text - Plain text to place on the clipboard.
*/
export async function writeClipboard(text: string): Promise<void> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* oxlint-disable-next-line typescript/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
} catch {
// Denied permissions / iframe policy.
}
return
}
// execCommand('copy') is the only clipboard fallback where the async API
// is missing (insecure contexts); deprecated but deliberately retained.
/* oxlint-disable typescript/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
exec('copy')
} catch {
// Clipboard unavailable; the button stays idle.
}
/* oxlint-enable typescript/no-deprecated */
el.remove()
}
function pad2(n: number): string {
return String(n).padStart(2, '0')
}

View File

@@ -42,8 +42,10 @@ export const zh = {
'details.input': '输入',
'details.output': '输出',
'details.running': '运行中…',
'todo.title': '任务清单',
'todo.progress': '{done}/{total} 项任务 · {active} 项进行中',
'todo.title': '任务',
'todo.progress.done': '{done} 已完成',
'todo.progress.active': '{active} 进行中',
'todo.progress.pending': '{pending} 待处理',
'todo.rowTitle': '更新任务清单',
'todo.completed': '{done}/{total} 已完成',
'chat.loadingHistory': '载入历史…',
@@ -156,7 +158,9 @@ export const en = {
'details.output': 'Output',
'details.running': 'Running…',
'todo.title': 'To-dos',
'todo.progress': '{done}/{total} tasks · {active} in progress',
'todo.progress.done': '{done} completed',
'todo.progress.active': '{active} in progress',
'todo.progress.pending': '{pending} pending',
'todo.rowTitle': 'Update to-do list',
'todo.completed': '{done}/{total} completed',
'chat.loadingHistory': 'Loading history…',

View File

@@ -16,20 +16,21 @@
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
/* Flex gap still applies after this item; subtract it together with the
design's overlap so the later composer paints over the queue edge. */
margin: 0 auto calc(
0px - var(--dsh-composer-stack-gap) - var(--dsh-queue-composer-overlap)
);
padding: 2px 12px;
/* Cancel the stack gap after this item and tuck 3px under the input card
(square bottom), reading as one attached surface. */
margin: 0 auto calc(0px - var(--dsh-composer-stack-gap) - 3px);
/* Horizontal padding completes the shared dock inset (this wrapper only
subtracts two insets from its width); no vertical padding, so the visual
gap above the panel stays the uniform stack gap. */
padding: 0 var(--dsh-composer-dock-inset);
}
.panel {
position: relative;
overflow: hidden;
width: 100%;
padding-top: 2px;
border-radius: 14px 14px 0 0;
padding: 2px 0;
border-radius: 12px 12px 0 0;
background: var(--dsw-specific-tip);
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
@@ -39,6 +40,7 @@
position: absolute;
inset: 0;
border: 1px solid var(--dsw-alias-border-l1);
/* The input card's own top border closes the shape below. */
border-bottom: none;
border-radius: inherit;
content: '';
@@ -52,7 +54,9 @@
gap: 10px;
width: 100%;
height: 36px;
padding: 4px 16px 4px 12px;
/* Right inset 12px puts the chevron on the same vertical line as the Todo
header's chevron (12px body padding there). */
padding: 4px 12px;
border: none;
border-radius: 8px;
background: transparent;
@@ -70,11 +74,18 @@
cursor: default;
}
.lead {
display: grid;
flex: none;
place-items: center;
color: var(--dsw-alias-label-tertiary);
}
.count {
flex: 1 1 auto;
min-width: 0;
font-family: Inter, var(--dsw-font-family);
font-size: 14px;
font-size: 13px;
font-weight: 500;
line-height: 24px;
}

View File

@@ -8,8 +8,8 @@ import { useEffect, useId, useMemo, useState } from 'react'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14,
IconCloseOutline16, IconEditOutline16, IconSendOutline16, IconTrashOutline16,
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16,
IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
import { NS } from '../locales.ts'
@@ -87,6 +87,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
disabled={interactionActive}
onClick={() => { setCollapsed(value => !value) }}
>
<span className={css.lead} aria-hidden><IconQueueOutline14 /></span>
<span className={css.count}>{t('queue.count', { n: queue.length })}</span>
<span className={css.chevron} aria-hidden>
{expanded ? <IconChevronDownOutline14 /> : <IconChevronUpOutline14 />}
@@ -96,6 +97,8 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
<ul id={listId} className={css.list} hidden={!listVisible}>
{listVisible && queue.map(row => (
<li key={row.id} className={css.row}>
{/* Single-item strip has no count header, so the row itself carries the queue glyph. */}
{queue.length === 1 && <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>}
{editing?.id === row.id
? (
<input
@@ -121,74 +124,83 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
{editing?.id === row.id
? (
<>
<button
type="button"
className={css.action}
aria-label={t('queue.save')}
title={t('queue.save')}
disabled={busy !== null || editing.text.trim() === ''}
onClick={() => { void saveEdit() }}
>
<IconCheckOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.cancelEdit')}
title={t('queue.cancelEdit')}
disabled={busy !== null}
onClick={() => { setEditing(null) }}
>
<IconCloseOutline16 size={14} />
</button>
<Tooltip label={t('queue.save')} side="bottom" delayMs={500}>
<button
type="button"
className={css.action}
aria-label={t('queue.save')}
disabled={busy !== null || editing.text.trim() === ''}
onClick={() => { void saveEdit() }}
>
<IconCheckOutline16 size={14} />
</button>
</Tooltip>
<Tooltip label={t('queue.cancelEdit')} side="bottom" delayMs={500}>
<button
type="button"
className={css.action}
aria-label={t('queue.cancelEdit')}
disabled={busy !== null}
onClick={() => { setEditing(null) }}
>
<IconCloseOutline16 size={14} />
</button>
</Tooltip>
</>
)
: (
<>
<button
type="button"
className={css.action}
aria-label={t('queue.edit')}
title={row.text === null ? t('queue.edit.unsupported') : t('queue.edit')}
disabled={busy !== null || row.text === null}
onClick={() => {
if (row.text !== null) setEditing({ id: row.id, text: row.text })
}}
>
<IconEditOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.remove')}
title={t('queue.remove')}
disabled={busy !== null}
onClick={() => {
void applyAction(
row.id,
{ kind: 'remove' },
t('queue.removeFailed'),
)
}}
>
<IconTrashOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.steer')}
title={running ? t('queue.steer') : t('queue.steer.unavailable')}
disabled={busy !== null || !running}
onClick={() => {
void applyAction(
row.id,
{ kind: 'steer' },
t('queue.steerFailed'),
)
}}
>
<IconSendOutline16 size={14} />
</button>
<Tooltip label={t('queue.edit')} side="bottom" delayMs={500} disabled={row.text === null}>
<button
type="button"
className={css.action}
aria-label={t('queue.edit')}
// Disabled buttons fire no hover events, so the
// unsupported hint stays a native title.
title={row.text === null ? t('queue.edit.unsupported') : undefined}
disabled={busy !== null || row.text === null}
onClick={() => {
if (row.text !== null) setEditing({ id: row.id, text: row.text })
}}
>
<IconEditOutline16 size={14} />
</button>
</Tooltip>
<Tooltip label={t('queue.remove')} side="bottom" delayMs={500}>
<button
type="button"
className={css.action}
aria-label={t('queue.remove')}
disabled={busy !== null}
onClick={() => {
void applyAction(
row.id,
{ kind: 'remove' },
t('queue.removeFailed'),
)
}}
>
<IconTrashOutline16 size={14} />
</button>
</Tooltip>
<Tooltip label={t('queue.steer')} side="bottom" delayMs={500} disabled={!running}>
<button
type="button"
className={css.action}
aria-label={t('queue.steer')}
title={running ? undefined : t('queue.steer.unavailable')}
disabled={busy !== null || !running}
onClick={() => {
void applyAction(
row.id,
{ kind: 'steer' },
t('queue.steerFailed'),
)
}}
>
<IconSendOutline14 />
</button>
</Tooltip>
</>
)}
</div>}

View File

@@ -8,13 +8,15 @@
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 32px 12px;
/* Sides = clearance + 16px so the card lands on the shared content width
(input card - 32) at every viewport. */
padding: 8px calc(var(--dsh-composer-side-clearance) + 16px) 12px;
}
.card {
overflow: hidden;
width: 100%;
max-width: 776px;
max-width: var(--dsh-chat-content-width);
border: 1px solid var(--dsw-alias-state-warn-secondary);
border-radius: 20px;
background: var(--dsw-specific-input-major);
@@ -84,7 +86,9 @@
/* Card-level row, not body content. Its padding reproduces the metrics the row
had inside the body: 14px above (the flex gap of 6 plus the row's 8px top
margin, neither of which reaches it out here) and the body's former 14px
bottom pad below, so the resting card is unchanged. */
bottom pad below, so the resting card is unchanged. Buttons are the shared
outline/primary capsules (Button atom, matching QuestionComposer's footer);
only the reject's danger hover is local. */
.actionRow {
display: flex;
justify-content: flex-end;
@@ -92,36 +96,6 @@
padding: 14px 16px 14px;
}
.allow,
.reject {
padding: 6px 16px;
border-radius: 10px;
font-size: 13px;
line-height: 18px;
cursor: pointer;
}
.allow:disabled,
.reject:disabled {
opacity: 0.5;
cursor: default;
}
/* Primary action: filled ink (draft's rightmost emphasis, minus the dropped
always-allow button). */
.allow {
border: none;
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-label-primary-foreground);
}
/* Secondary: quiet outline. */
.reject {
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
background: transparent;
color: var(--dsw-alias-label-secondary);
}
.reject:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);

View File

@@ -14,6 +14,7 @@
// grant storage.
import { useMemo, useState } from 'react'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts'
import css from './ApprovalPanel.module.css'
@@ -69,12 +70,12 @@ function ApprovalFlow({ pending, command, t }: {
{command !== undefined && <div className={css.command}>{command}</div>}
</div>
<div className={css.actionRow}>
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
<Button variant="outline" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
{t('approval.reject')}
</button>
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
</Button>
<Button variant="primary" disabled={answered} onClick={() => { answer('allowed-once') }}>
{t('approval.allowOnce')}
</button>
</Button>
</div>
</div>
</div>

View File

@@ -9,6 +9,20 @@
height: 100%;
min-width: 0;
background: var(--dsw-alias-bg-base);
/* Shared width axis for the whole column: one content width W
(--dsh-chat-content-width) for the transcript, the dock cards
(todo/goal/queue: card minus four insets, 4 x 8 = 32), and the takeover
cards (question/approval/plan review); the input card alone is W + 32px.
The relation also holds when a narrow viewport shrinks everything: the
chat scroller and the takeover frames pad clearance + 16px per side while
the input card clears the bare clearance, so the input card stays exactly
content + 32px at every width. Declared on the root because the
transcript and the composer seat are sibling subtrees. */
--dsh-chat-content-width: 748px;
--dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px);
--dsh-composer-side-clearance: 16px;
--dsh-composer-dock-inset: 8px;
}
.header {
@@ -134,16 +148,11 @@
}
/* Composer context stack (Figma 9:937): standalone dock cards share one
rhythm; the terminal queue strip additionally tucks under the input card. */
rhythm above the input card. */
.composerStack {
/* Horizontal geometry (card width, clearance, dock inset) rides the shared
.root variables above so takeover siblings match the stack. */
--dsh-composer-stack-gap: 6px;
--dsh-queue-composer-overlap: 5px;
/* InputBar and dock registrants derive their horizontal geometry from the
same card width, outer clearance, and dock inset. */
--dsh-composer-card-max-width: 800px;
--dsh-composer-side-clearance: 32px;
--dsh-composer-dock-inset: 12px;
display: flex;
flex-direction: column;
@@ -239,7 +248,9 @@
gap: 12px;
/* Foot inside the centered box floats the stack a bit above true center. */
padding-bottom: 32px;
width: min(776px, calc(100% - 48px));
/* Card cap + both clearances: the hero input card lands at exactly the same
width as the docked composer at every viewport. */
width: min(calc(var(--dsh-composer-card-max-width) + 2 * var(--dsh-composer-side-clearance)), 100%);
z-index: 1;
}
@@ -263,7 +274,9 @@
display: flex;
align-items: center;
min-width: 0;
padding-left: 8px;
/* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to
the card's inner controls below. */
padding-left: 20px;
}
/* Hero: the composer sits inside the session scroll body; center there so

View File

@@ -11,23 +11,28 @@
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
viewport bottom inside the centered message column; textarea on top, action
row below, one primary circle button bottom-right. Input width rides the
column (800 is a cap, not a fixed size — layout rule: the box shrinks with
the center column keeping its padding). Hero variant = the same card
centered in the empty state; the transition between the two is a position
move of one component. */
column (--dsh-composer-card-max-width = chat content + 32px, 16px per side,
is a cap, not a fixed size — layout rule: the box shrinks with the center
column keeping its clearance). Hero variant = the same card centered in the
empty state; the transition between the two is a position move of one
component. */
.root {
display: flex;
flex-direction: column;
align-items: center;
/* figma Input_Bottom: pad L32/R32/B8; the bottom gradient mask is owned by
the chat scroller. No top pad: the composer stack's gap owns the space
above; error/status strips still carry their own margin. */
/* Side pads ride the shared clearance (figma Input_Bottom drew L32/R32/B8;
the sides narrow with the shared width axis); the bottom gradient mask
is owned by the chat scroller. No top pad: the composer stack's gap owns
the space above; error/status strips still carry their own margin. */
padding: 0 var(--dsh-composer-side-clearance) 8px;
}
.hero {
padding: 0;
/* No bottom pad in the centered hero, but the side clearance must survive:
the hero wrapper is full-width on narrow viewports, so this padding is
the only thing keeping the card off the edges there. */
padding: 0 var(--dsh-composer-side-clearance);
}
.error,
@@ -83,7 +88,7 @@
the input border is one notch weaker than buttons) — exactly the
l2-darkmode-thin pair. Fill: the input surface token (elevated in dark). */
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 20px;
border-radius: 22px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
font-size: 16px;
@@ -256,8 +261,16 @@
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 10px 10px 10px;
/* 2px moved from the bottom pad to the top: the whole control row sits 2px
lower in the card (it read too high against the textarea) while the card
height and the controls' own centering stay untouched. */
padding: 2px 8px 6px;
min-width: 0;
/* Size container so the chips inside can collapse to icon-only when the
card runs out of row width (PermissionSelect @container rule). Anonymous
on purpose: CSS modules hash container-name per module, so a name declared
here can never match a query in another module's sheet. */
container-type: inline-size;
}
.tools,
@@ -268,13 +281,15 @@
min-width: 0;
}
/* figma 75:8208: 16 between + and the mode chips; 4 between Plan / Read-only. */
/* figma 75:8208 drew 16 between + and the mode chips and 4 between Plan /
Read-only; the chip gap widened to 12 so the pill chips read as separate
controls. */
.tools {
gap: 16px;
}
.modes {
gap: 4px;
gap: 12px;
}
.trailing {
@@ -354,6 +369,10 @@
color: #fff;
cursor: pointer;
transition: background-color 100ms ease;
/* Opts out of the row's 2px downward shift (.row top pad): the send circle
keeps its original seat while the smaller chips sit lower. Transform, not
margin, so flex centering math is untouched. */
transform: translateY(-2px);
}
.primary:hover:not(:disabled) {

View File

@@ -9,7 +9,7 @@
import { useEffect, useRef } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: the `plan` projection key merge (the TodoDock posture — the
// composer reads a host-computed value; the domain owns the key).
import type {} from '@deepseek-ai/dsh-plan-mode/client'
@@ -489,19 +489,20 @@ export function InputBar({
</div>
<div className={css.row}>
<div className={css.tools}>
<button
type="button"
className={css.add}
aria-label={t('input.commands')}
title={t('input.commands')}
aria-haspopup="listbox"
aria-expanded={commandMenuOpen}
disabled={locked || toggleCommandMenu === undefined}
onMouseDown={keepFocus}
onClick={onToggleCommandMenu}
>
<IconPlusOutline16 size={14} />
</button>
<Tooltip label={t('input.commands')} side="top" delayMs={500}>
<button
type="button"
className={css.add}
aria-label={t('input.commands')}
aria-haspopup="listbox"
aria-expanded={commandMenuOpen}
disabled={locked || toggleCommandMenu === undefined}
onMouseDown={keepFocus}
onClick={onToggleCommandMenu}
>
<IconPlusOutline16 size={14} />
</button>
</Tooltip>
<div className={css.modes}>
{accessSelect}
{renderSlot('conversation.input.plan', { locked })}
@@ -512,25 +513,26 @@ export function InputBar({
{rightItems}
{renderSlot('conversation.input.model', { locked })}
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
<button
type="button"
className={css.primary}
aria-label={primaryLabel}
title={primaryLabel}
disabled={stopping ? stop === undefined : empty || disabled || machineBusy}
onMouseDown={keepFocus}
onClick={onPrimary}
>
{stopping ? (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
</svg>
)}
</button>
<Tooltip label={primaryLabel} side="top" delayMs={500}>
<button
type="button"
className={css.primary}
aria-label={primaryLabel}
disabled={stopping ? stop === undefined : empty || disabled || machineBusy}
onMouseDown={keepFocus}
onClick={onPrimary}
>
{stopping ? (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
</svg>
)}
</button>
</Tooltip>
</div>
</div>
</div>

View File

@@ -7,7 +7,8 @@
height: 28px;
padding: 0 4px 0 8px;
border: none;
border-radius: 8px;
/* Rounded chip chrome, matching the sibling model trigger. */
border-radius: 24px;
outline: none;
background: transparent;
color: var(--dsw-alias-label-secondary);
@@ -30,6 +31,18 @@
cursor: default;
}
.triggerIcon {
display: inline-flex;
flex: 0 0 auto;
}
/* The shared 16px glyphs render one step smaller on the exposed trigger;
the dropdown rows keep the full 16px. */
.triggerIcon svg {
width: 14px;
height: 14px;
}
.triggerLabel {
min-width: 0;
overflow: hidden;
@@ -40,4 +53,21 @@
.chevron {
flex: 0 0 auto;
color: var(--dsw-alias-label-caption);
transition: transform 120ms ease;
}
/* Narrow composer: the trigger collapses to icon + chevron so the row keeps
fitting. Only triggers that actually carry a mode glyph drop their label —
a host-configured mode without one keeps its text as the sole identifier.
The 460px cut is the point where the row (attach + modes + model + send)
starts squeezing labels; the container is the composer row (InputBar .row —
anonymous query because CSS modules hash container-names per module). */
@container (max-width: 460px) {
.trigger:has(.triggerIcon) .triggerLabel {
display: none;
}
}
.chevronOpen {
transform: rotate(180deg);
}

View File

@@ -1,12 +1,50 @@
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
import { Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14, Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerBarProps } from '../contract/slots.ts'
import css from './PermissionSelect.module.css'
const FULL_ACCESS = 'danger-full-access'
/* Shield glyphs (design set 1556): check = read-only, pencil = workspace
write, exclamation = full access. currentColor so the trigger and menu
rows tint them with their own text color. */
const shieldOutline = 'M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z'
const permissionGlyphs = {
'read-only': (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
<path d="M12.1654 5.7552L8.9447 9.41475C8.73044 9.65816 8.53628 9.8804 8.35774 10.0423C8.1713 10.2114 7.94235 10.3717 7.64016 10.4254C7.48207 10.4535 7.32 10.4552 7.16151 10.4294C6.85843 10.3801 6.62728 10.2223 6.43836 10.0559C6.25752 9.89653 6.06037 9.67732 5.84264 9.43705L4.72925 8.20897L5.63557 7.38707L6.74897 8.61594C6.98603 8.87755 7.12974 9.03533 7.24673 9.13839C7.31033 9.19443 7.34485 9.21476 7.35823 9.22122C7.38068 9.22484 7.40352 9.22515 7.42593 9.22122C7.40522 9.22502 7.42893 9.23294 7.53583 9.136C7.65132 9.03126 7.79316 8.87139 8.02643 8.60638L11.2479 4.94763L12.1654 5.7552Z" fill="currentColor" />
</svg>
),
'workspace-write': (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d="M8.08887 0.251709C8.20479 0.23085 8.32486 0.241168 8.43652 0.282959L15.0215 2.75171C15.2787 2.84819 15.4492 3.09414 15.4492 3.3689V7.0105C15.4492 7.10986 15.4441 7.2081 15.4414 7.30542C15.0285 7.07175 14.5905 6.87695 14.1309 6.73022V3.82495L8.20508 1.60327L2.2793 3.82495V7.0105C2.27936 9.7171 3.4745 11.5379 5.02734 12.7947C5.01025 12.9942 5 13.1962 5 13.4001C5.00001 13.7617 5.02722 14.1169 5.08008 14.4636C2.91555 13.0393 0.961014 10.752 0.960938 7.0105V3.3689C0.960938 3.09417 1.13146 2.84821 1.38867 2.75171L7.97461 0.282959L8.08887 0.251709Z" fill="currentColor" />
<path d="M11.3525 5.64688V6.85688H5V5.64688H11.3525Z" fill="currentColor" />
<path d="M9.5824 8.29376V9.50376H5V8.29376H9.5824Z" fill="currentColor" />
<path d="M14.6647 15.6852H10.0338C10.3878 15.3751 10.7567 15.0517 11.0772 14.7706C11.2531 14.6164 11.4144 14.4746 11.5511 14.3547H14.6647V15.6852Z" fill="currentColor" />
<path d="M8.14852 14.1308L7.33925 15.4976C7.22458 15.6912 7.42245 15.9194 7.63037 15.8333L9.09785 15.2254L15.0399 10.0719L14.0905 8.97733L8.14852 14.1308Z" fill="currentColor" />
</svg>
),
[FULL_ACCESS]: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
<path d="M9.10094 4.5V8.75939H7.59888V4.5H9.10094Z" fill="currentColor" />
<path d="M9.10094 9.8114V11.5H7.59888V9.8114H9.10094Z" fill="currentColor" />
</svg>
),
} as Record<string, ReactNode>
/** Glyph for a permission option value; host-configured names outside the design set get none. */
function permissionGlyph(value: string): ReactNode | undefined {
return permissionGlyphs[value]
}
/**
* Display transform: kebab-case machine names render as title-case labels
* (`workspace-write` → `Workspace Write`); non-kebab host-configured names
@@ -52,7 +90,10 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
const items: MenuEntry[] = value.options
.filter(o => o.value !== 'custom')
.map(option => ({ id: option.value, label: optionLabel(option) }))
.map((option) => {
const icon = permissionGlyph(option.value)
return { id: option.value, label: optionLabel(option), ...icon === undefined ? {} : { icon } }
})
const submit = (id: string): void => {
setPick(id)
@@ -102,10 +143,14 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
disabled={locked || busy}
onClick={() => { setOpen(!open) }}
>
{permissionGlyph(currentValue) !== undefined && (
<span className={css.triggerIcon} aria-hidden>{permissionGlyph(currentValue)}</span>
)}
<span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span>
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
{/* Same glyph + open rotation as the sibling ModelSelect trigger. */}
<span className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden>
<IconChevronDownOutline14 />
</span>
</button>
}
/>

View File

@@ -1,6 +1,7 @@
/* Todo strip in the composer context stack (Figma 1236:32276): tip surface,
14px radius, status icons + secondary item labels. Its visible card aligns
with the GoalBar and the Queue panel inside their shared dock column. */
status icons + secondary item labels. Its visible card aligns with the
GoalBar and the Queue panel inside their shared dock column: 12px radius,
36px collapsed row, 12px side padding, 14px tertiary leading glyph. */
.root {
box-sizing: border-box;
@@ -24,7 +25,7 @@
var(--dsh-composer-dock-inset)
);
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 14px;
border-radius: 12px;
background: var(--dsw-specific-tip);
/* Elevated surface: `--dsw-specific-tip` is the same dark rung as the menu
surface, and `.list` scrolls inside this card, so the thumb takes the l2
@@ -39,7 +40,7 @@
display: flex;
flex-direction: column;
gap: 8px;
padding: 9px 15px;
padding: 6px 12px;
}
.header {
@@ -54,9 +55,16 @@
cursor: pointer;
}
.lead {
display: grid;
flex: none;
place-items: center;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
font-size: 13px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);

View File

@@ -13,7 +13,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots
// declare) and the payload type. Type-only by construction — the outlet is
// free of host value imports, so no host Context merge enters this program.
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChecklistOutline14, IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { NS } from '../locales.ts'
import css from './TodoPanel.module.css'
@@ -78,11 +78,18 @@ function StatusGlyph({ status }: { status: TodoItem['status'] }) {
}
}
/** Header summary: "<done>/<total> tasks · <n> in progress". */
/** Header summary: "·"-joined per-status counts; zero-count segments are omitted as noise (a non-empty list keeps at least one). */
function progressLabel(todos: readonly TodoItem[], t: TodoPanelProps['t']): string {
const done = todos.filter(item => item.status === 'completed').length
const active = todos.filter(item => item.status === 'in_progress').length
return t('todo.progress', { done, total: todos.length, active })
const pending = todos.length - done - active
// En spaces (U+2002): HTML collapses runs of ASCII spaces, so widening the
// separator breathing room needs a literal wide space.
return [
...done > 0 ? [t('todo.progress.done', { done })] : [],
...active > 0 ? [t('todo.progress.active', { active })] : [],
...pending > 0 ? [t('todo.progress.pending', { pending })] : [],
].join('\u2002·\u2002')
}
export function TodoPanel({ todos, t }: TodoPanelProps) {
@@ -98,6 +105,7 @@ export function TodoPanel({ todos, t }: TodoPanelProps) {
aria-expanded={!collapsed}
onClick={() => { setCollapsed(v => !v) }}
>
<span className={css.lead} aria-hidden><IconChecklistOutline14 /></span>
<span className={css.title}>{t('todo.title')}</span>
<span className={css.progress}>{progressLabel(todos, t)}</span>
<span className={css.chevron} aria-hidden>