Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress
# Conflicts: # docs/core-data-structures/session.i18n.yaml # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
This commit is contained in:
@@ -7,6 +7,7 @@ import type { ViewTab } from './contract/views.ts'
|
||||
import type {
|
||||
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
|
||||
} from './contract/slots.ts'
|
||||
import { resolveToolPath } from './contract/tool-call-model.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
@@ -167,6 +168,13 @@ export function apply(ctx: Context): void {
|
||||
actions.select(target)
|
||||
layout.openDetails()
|
||||
},
|
||||
openFile: (path) => {
|
||||
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
|
||||
void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => {
|
||||
// Host/OS open failures stay silent in the chat row; the native
|
||||
// app surfaces its own error dialog when the path is unusable.
|
||||
})
|
||||
},
|
||||
loadOlder: () => { void scoped.loadOlder() },
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,18 +9,6 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.pulse {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 14px;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
animation: pulse 1s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.2; }
|
||||
}
|
||||
|
||||
/* Interrupted-turn terminal marker: quiet inline tag, no animation. */
|
||||
.stopped {
|
||||
align-self: flex-start;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// reasoning as the figma Think summary row (expand = indented gray text),
|
||||
// other-block JSON fallback. Tool-call heads are NOT rendered here: the chat
|
||||
// view groups them into tool rows through its keyed toolview slot (figma
|
||||
// step-summary flow). Shared by finalized nodes and the streaming partial
|
||||
// (pulse marker).
|
||||
// step-summary flow). Shared by finalized nodes and the streaming partial;
|
||||
// the turn-level loading dots live in the chat view's tail, not here.
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -14,7 +14,7 @@ import css from './AssistantMarkdown.module.css'
|
||||
export interface AssistantMarkdownProps {
|
||||
blocks: readonly AssistantBlock[]
|
||||
streaming: boolean
|
||||
/** Frozen partial of an aborted turn: rendered with a 已停止 marker, no pulse. */
|
||||
/** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
|
||||
interrupted?: boolean | undefined
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
return (
|
||||
<ToolRow
|
||||
variant="think"
|
||||
icon={<IconThinkOutline14 />}
|
||||
icon={<IconThinkOutline14 size={14} />}
|
||||
title="Think"
|
||||
summary={firstLine(text)}
|
||||
body={text}
|
||||
@@ -58,7 +58,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
|
||||
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
|
||||
}
|
||||
})}
|
||||
{streaming && <span className={css.pulse} />}
|
||||
{interrupted && <span className={css.stopped}>已停止</span>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* Chat flow: block gap 16 between narration/bubbles/tool groups (figma);
|
||||
tool rows inside a group gap 10. Input padding cap rides the skeleton. */
|
||||
/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool
|
||||
runs) via the column gap and between consecutive tool rows via the group
|
||||
gap. Input padding cap rides the skeleton. */
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
@@ -30,7 +31,7 @@
|
||||
.toolGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.callRow {
|
||||
@@ -51,6 +52,35 @@
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* Turn loader: one row of four 2.5px pixels (StateDot blue) chasing left to
|
||||
right with a stepped trail — flat keyframe holds, no tweening. Phase
|
||||
offsets come from per-rect animation-delay (index * -250ms) set inline
|
||||
by the component. */
|
||||
.turnDots {
|
||||
align-self: flex-start;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* One message line box: the dots center inside the text line height. */
|
||||
height: 26px;
|
||||
/* Same pin as StateDot: ongoing blue has no alias token (business-primary
|
||||
is the 500 step, not this 450). */
|
||||
color: var(--dsw-static-deepseek-450);
|
||||
}
|
||||
|
||||
.turnDotCell {
|
||||
fill: currentColor;
|
||||
opacity: 0.15;
|
||||
animation: dsh-turn-dots-chase 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes dsh-turn-dots-chase {
|
||||
0%, 24.9% { opacity: 1; }
|
||||
25%, 49.9% { opacity: 0.6; }
|
||||
50%, 74.9% { opacity: 0.35; }
|
||||
75%, 100% { opacity: 0.15; }
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -25,7 +25,6 @@ import type {
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { SelectionTarget } from '../contract/views.ts'
|
||||
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
@@ -36,7 +35,7 @@ import css from './ChatView.module.css'
|
||||
|
||||
const FOLLOW_THRESHOLD = 24
|
||||
|
||||
type OpenDetails = (target: SelectionTarget) => void
|
||||
type OpenFile = (path: string) => void
|
||||
|
||||
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
|
||||
type RenderToolRow = ChatViewSlotProps['renderSlot']
|
||||
@@ -49,19 +48,18 @@ type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
|
||||
* top-level call (same registrations, same fallback), nested by the parent.
|
||||
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
|
||||
* renders the running state exactly as a native in-flight row. */
|
||||
const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected }: {
|
||||
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd }: {
|
||||
renderSlot: RenderToolRow
|
||||
node: CodeSubCall
|
||||
onOpenDetails: OpenDetails
|
||||
openFile: OpenFile
|
||||
selected: boolean
|
||||
cwd: string | undefined
|
||||
}) {
|
||||
const settled = 'kind' in node
|
||||
const toolName = settled ? node.call?.name ?? '' : node.name
|
||||
const seq = settled ? node.seq : node.time
|
||||
const owner = useMemo(() => ({
|
||||
callId: node.callId, toolName, block: node,
|
||||
openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) },
|
||||
}), [node, toolName, seq, onOpenDetails])
|
||||
callId: node.callId, toolName, block: node, openFile, cwd,
|
||||
}), [node, toolName, openFile, cwd])
|
||||
return (
|
||||
<div className={css.callRow} data-selected={selected || undefined}>
|
||||
{renderSlot('conversation.chat.toolview', owner, {
|
||||
@@ -77,25 +75,26 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, s
|
||||
* GenericToolCard at this render site. A `run_code` call additionally
|
||||
* renders its logged sub-dispatches as always-visible indented rows —
|
||||
* each one the same keyed-slot dispatch as a native top-level call. */
|
||||
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId }: {
|
||||
const CallRow = memo(function CallRow({
|
||||
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd,
|
||||
}: {
|
||||
renderSlot: RenderToolRow
|
||||
callId: string
|
||||
toolName: string
|
||||
block: ToolResultNode | RunningToolCall
|
||||
/** Surface seq for finalized results; the call's turn for running calls. */
|
||||
seq: number
|
||||
onOpenDetails: OpenDetails
|
||||
openFile: OpenFile
|
||||
selected: boolean
|
||||
/** `run_code` sub-dispatches in dispatch order (reference-stable per
|
||||
* parent; running entries settle in place); undefined for ordinary calls. */
|
||||
subCalls?: readonly CodeSubCall[] | undefined
|
||||
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
|
||||
selectedCallId?: string | undefined
|
||||
/** Session workspace root for path-relative summaries. */
|
||||
cwd: string | undefined
|
||||
}) {
|
||||
const owner = useMemo(() => ({
|
||||
callId, toolName, block,
|
||||
openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) },
|
||||
}), [callId, toolName, block, seq, onOpenDetails])
|
||||
callId, toolName, block, openFile, cwd,
|
||||
}), [callId, toolName, block, openFile, cwd])
|
||||
return (
|
||||
<div className={css.callRow} data-selected={selected || undefined}>
|
||||
{renderSlot('conversation.chat.toolview', owner, {
|
||||
@@ -109,8 +108,9 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
|
||||
key={node.callId}
|
||||
renderSlot={renderSlot}
|
||||
node={node}
|
||||
onOpenDetails={onOpenDetails}
|
||||
openFile={openFile}
|
||||
selected={node.callId === selectedCallId}
|
||||
cwd={cwd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -119,15 +119,17 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
|
||||
)
|
||||
})
|
||||
|
||||
/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches }: {
|
||||
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: {
|
||||
renderSlot: RenderToolRow
|
||||
results: readonly ToolResultNode[]
|
||||
onOpenDetails: OpenDetails
|
||||
openFile: OpenFile
|
||||
/** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */
|
||||
selectedCallId: string | undefined
|
||||
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
/** Session workspace root for path-relative summaries. */
|
||||
cwd: string | undefined
|
||||
}) {
|
||||
return (
|
||||
<div className={css.toolGroup}>
|
||||
@@ -138,17 +140,51 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
|
||||
callId={node.callId}
|
||||
toolName={node.call?.name ?? ''}
|
||||
block={node}
|
||||
seq={node.seq}
|
||||
onOpenDetails={onOpenDetails}
|
||||
openFile={openFile}
|
||||
selected={node.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(node.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot
|
||||
* 2px cell, same blue) chasing left to right with a stepped trail — flat
|
||||
* keyframe holds, no tweening, no rotation. Phase offsets come from
|
||||
* per-rect animation-delay. */
|
||||
const LOADER_CELLS = [0, 5, 10, 15] as const
|
||||
|
||||
function TurnDots() {
|
||||
return (
|
||||
/* The wrapper is a 26px line box (message line height) so the loader
|
||||
occupies one text line and centers the dots inside it. */
|
||||
<div className={css.turnDots} aria-hidden="true">
|
||||
<svg
|
||||
width="17.5"
|
||||
height="2.5"
|
||||
viewBox="0 0 17.5 2.5"
|
||||
shapeRendering="crispEdges"
|
||||
>
|
||||
{LOADER_CELLS.map((x, index) => (
|
||||
<rect
|
||||
key={x}
|
||||
className={css.turnDotCell}
|
||||
x={x}
|
||||
y="0"
|
||||
width="2.5"
|
||||
height="2.5"
|
||||
/* Negative delay phases the chase so every cell animates from mount. */
|
||||
style={{ animationDelay: `${(index - LOADER_CELLS.length) * 250}ms` }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The streaming partial, isolated so chunk batches re-render only this tail.
|
||||
* onGrow lets the scroll owner follow content the parent never re-renders for. */
|
||||
function StreamingTail({ useSession, onGrow }: {
|
||||
@@ -167,8 +203,11 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
* The chat view slot entry: pure component over the composed props (tool rows
|
||||
* render through the declared keyed hole's renderSlot share).
|
||||
*/
|
||||
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
|
||||
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
// Workspace root off the session list row: path summaries display relative to it.
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
const running = useSession(s => s.running)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const pending = useSession(s => s.pending)
|
||||
@@ -265,9 +304,10 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
|
||||
key={item.key}
|
||||
renderSlot={renderSlot}
|
||||
results={item.results}
|
||||
onOpenDetails={openDetails}
|
||||
openFile={openFile}
|
||||
selectedCallId={inGroup ? selectedCallId : undefined}
|
||||
codeDispatches={codeDispatches}
|
||||
cwd={cwd}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -304,16 +344,19 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
|
||||
callId={call.callId}
|
||||
toolName={call.name}
|
||||
block={call}
|
||||
seq={call.turn}
|
||||
onOpenDetails={openDetails}
|
||||
openFile={openFile}
|
||||
selected={call.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(call.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pending.map(item => <PendingCard key={item.key} item={item} />)}
|
||||
{/* Turn-level loading signal: rides the whole running turn (first-token
|
||||
wait, tool execution, streaming) so it never flickers per step. */}
|
||||
{running && <TurnDots />}
|
||||
</div>
|
||||
</div>
|
||||
<StatsLine useSession={useSession} />
|
||||
|
||||
@@ -13,20 +13,21 @@ import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.t
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import { IconSparkle16 } from './IconSparkle16.tsx'
|
||||
|
||||
/** Variant leading icons (figma table). */
|
||||
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
|
||||
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
think: <IconThinkOutline14 />,
|
||||
search: <IconSearchOutline16 />,
|
||||
read: <IconBrowseOutline16 />,
|
||||
bash: <IconApiOutline14 size={16} />,
|
||||
write: <IconEditOutline16 />,
|
||||
edit: <IconEditOutline16 />,
|
||||
code: <IconCodeOutline16 />,
|
||||
others: <IconSparkle16 />,
|
||||
think: <IconThinkOutline14 size={14} />,
|
||||
search: <IconSearchOutline16 size={14} />,
|
||||
read: <IconBrowseOutline16 size={14} />,
|
||||
bash: <IconApiOutline14 size={14} />,
|
||||
write: <IconEditOutline16 size={14} />,
|
||||
edit: <IconEditOutline16 size={14} />,
|
||||
code: <IconCodeOutline16 size={14} />,
|
||||
others: <IconSparkle16 size={14} />,
|
||||
}
|
||||
|
||||
export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const singleFile = model.filePath !== undefined
|
||||
return (
|
||||
<ToolRow
|
||||
variant={model.variant}
|
||||
@@ -34,9 +35,11 @@ export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerPr
|
||||
icon={VARIANT_ICONS[model.variant]}
|
||||
title={model.title}
|
||||
summary={model.summary}
|
||||
body={model.body}
|
||||
// Single-file tools never expose an args body — the path link is the only action.
|
||||
body={singleFile ? null : model.body}
|
||||
state={model.state}
|
||||
onOpenDetails={openDetails}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={singleFile ? openFile : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,22 +7,47 @@
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row[data-clickable] {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
/* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band —
|
||||
theme background at 60% — glides over the row content from off-left to
|
||||
off-right, washing glyphs and icon toward the background as it passes.
|
||||
ease-out with a 10% end hold gives each pass a beat before the next. */
|
||||
.root[data-state='running'] .row::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-tool-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.row[data-clickable]:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@keyframes dsh-tool-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
/* Expand-on-row (Think / code): pointer only — no row fill hover. */
|
||||
.row[data-expandable] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.leading {
|
||||
position: relative; /* .chevronHover overlay anchor */
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -65,11 +90,36 @@ button.leading {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
|
||||
into a down chevron before the row is opened. The chevron overlays the
|
||||
icon cell absolutely so both can stay mounted for the opacity transition. */
|
||||
.iconIdle {
|
||||
display: inline-flex;
|
||||
opacity: 1;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.chevronHover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.row:hover .iconIdle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.row:hover .chevronHover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sep {
|
||||
@@ -92,6 +142,29 @@ button.leading {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* File-tool path: same geometry as .summary; hover underline + pointer. */
|
||||
.fileLink {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Expanded body: pad-left 22 indented gray text, no border, no fill. */
|
||||
.body {
|
||||
padding: 4px 0 4px 22px;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
|
||||
// 16px leading slot (state dot / tool icon, chevron when expanded) + title +
|
||||
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
|
||||
// separator dot + FILL-truncated summary. Expanded body is indented gray text;
|
||||
// no inline output (full results live in the details panel). Expand state is
|
||||
// component-local view state; row click hands the selection off to the owner.
|
||||
// component-local view state. File-tool summaries are path links that open
|
||||
// through the host; the row itself is not a details-panel control.
|
||||
|
||||
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -24,15 +25,20 @@ export interface ToolRowProps {
|
||||
state: ToolRowState
|
||||
/** Makes the row itself the expand control instead of only its leading icon. */
|
||||
expandOnRowClick?: boolean | undefined
|
||||
/** Selection handoff (row click), already bound to this call by the owner. */
|
||||
onOpenDetails?: (() => void) | undefined
|
||||
/**
|
||||
* Filesystem path from tool args; when set with onOpenFile, the summary
|
||||
* renders as a hover-underline link that opens the host default app.
|
||||
*/
|
||||
filePath?: string | undefined
|
||||
/** Open the path with the host OS default application (already cwd-resolved). */
|
||||
onOpenFile?: ((path: string) => void) | undefined
|
||||
}
|
||||
|
||||
/** Leading-slot state substitution: the tool icon yields to the state semantic
|
||||
* (running = blue ring, error = red, interrupted = amber halo; ok = icon). */
|
||||
/** Leading-slot state substitution: the tool icon yields to the terminal state
|
||||
* semantic (error = red, interrupted = amber halo). Running keeps the icon —
|
||||
* the row sweep (CSS on data-state) carries the in-flight signal. */
|
||||
function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
|
||||
switch (state) {
|
||||
case 'running': return <StateDot state="ongoing" />
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
default: return icon
|
||||
@@ -48,10 +54,15 @@ export function ToolRow({
|
||||
body,
|
||||
state,
|
||||
expandOnRowClick = false,
|
||||
onOpenDetails,
|
||||
filePath,
|
||||
onOpenFile,
|
||||
}: ToolRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const expandable = body !== null
|
||||
// A row that names a single file keeps one interaction (open that path);
|
||||
// args expand is off whether or not the open callback is wired yet.
|
||||
const singleFile = filePath !== undefined
|
||||
const fileLink = singleFile && onOpenFile !== undefined
|
||||
const expandable = body !== null && !singleFile
|
||||
const open = expanded && expandable
|
||||
const rowExpands = expandable && expandOnRowClick
|
||||
const toggleExpand = () => {
|
||||
@@ -66,15 +77,32 @@ export function ToolRow({
|
||||
event.preventDefault()
|
||||
toggleExpand()
|
||||
}
|
||||
const openFile = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
if (filePath !== undefined) onOpenFile?.(filePath)
|
||||
}
|
||||
// Expandable rows preview the toggle on hover: the tool icon yields to a
|
||||
// down chevron (CSS swap on .row:hover); state dots still take precedence.
|
||||
const collapsedIcon = expandable
|
||||
? (
|
||||
<>
|
||||
<span className={css.iconIdle}>{icon}</span>
|
||||
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
|
||||
</>
|
||||
)
|
||||
: icon
|
||||
const leading = open
|
||||
? <IconChevronDownOutline14 className={css.chevron} />
|
||||
: leadingFor(state, collapsedIcon)
|
||||
return (
|
||||
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
|
||||
<div
|
||||
className={css.row}
|
||||
data-clickable={rowExpands || onOpenDetails !== undefined || undefined}
|
||||
data-expandable={rowExpands || undefined}
|
||||
role={rowExpands ? 'button' : undefined}
|
||||
tabIndex={rowExpands ? 0 : undefined}
|
||||
aria-expanded={rowExpands ? open : undefined}
|
||||
onClick={rowExpands ? toggleExpand : onOpenDetails}
|
||||
onClick={rowExpands ? toggleExpand : undefined}
|
||||
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
|
||||
>
|
||||
{expandable && !rowExpands ? (
|
||||
@@ -84,18 +112,28 @@ export function ToolRow({
|
||||
aria-expanded={open}
|
||||
onClick={toggleFromLeading}
|
||||
>
|
||||
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
|
||||
{leading}
|
||||
</button>
|
||||
) : (
|
||||
<span className={css.leading}>
|
||||
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
|
||||
{leading}
|
||||
</span>
|
||||
)}
|
||||
<span className={css.title}>{title}</span>
|
||||
{!open && (
|
||||
<>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}>{summary}</span>
|
||||
{fileLink ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.fileLink}
|
||||
onClick={openFile}
|
||||
>
|
||||
{summary}
|
||||
</button>
|
||||
) : (
|
||||
<span className={css.summary}>{summary}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,16 @@ export type ChatFlowItem =
|
||||
| { kind: 'node'; key: string; node: ConversationNode }
|
||||
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
|
||||
|
||||
/** An assistant node that renders nothing: only tool-call heads (rows render
|
||||
* via the grouping pass) and blank text/reasoning. Skipped by the flow so it
|
||||
* neither costs column gaps nor splits a tool-row run. Interrupted nodes
|
||||
* always render (the 已停止 marker). */
|
||||
function rendersNothing(node: ConversationNode): boolean {
|
||||
return node.kind === 'assistant' && node.interrupted !== true
|
||||
&& node.blocks.every(b => b.kind === 'tool-call'
|
||||
|| ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* Group finalized nodes into the step-summary flow.
|
||||
* @param nodes - snapshot nodes (surface order).
|
||||
@@ -21,6 +31,7 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
|
||||
const items: ChatFlowItem[] = []
|
||||
let group: ToolResultNode[] | null = null
|
||||
for (const node of nodes) {
|
||||
if (rendersNothing(node)) continue
|
||||
if (node.kind === 'tool-result') {
|
||||
if (group === null) {
|
||||
group = [node]
|
||||
|
||||
@@ -143,8 +143,13 @@ export interface ToolRowOwnerProps {
|
||||
toolName: string
|
||||
/** Frozen call slice: the running call or the settled result node. */
|
||||
block: ToolCallBlock
|
||||
/** Open the details panel for this call (session-level facility, supplied by the view). */
|
||||
openDetails: () => void
|
||||
/** Session workspace root; path summaries display relative to it. */
|
||||
cwd?: string | undefined
|
||||
/**
|
||||
* Open a tool-arg filesystem path with the host OS default application.
|
||||
* The chat view resolves relative paths against the session cwd.
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,6 +288,11 @@ export type ConversationSessionSlotProps =
|
||||
export interface ChatViewInjected {
|
||||
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
|
||||
openDetails: (target: SelectionTarget) => void
|
||||
/**
|
||||
* Open a tool-arg filesystem path with the host OS default application
|
||||
* (relative paths resolve against the session cwd).
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
loadOlder: () => void
|
||||
}
|
||||
|
||||
@@ -307,6 +317,8 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
|
||||
export interface EmptyWorkspaceOwnerProps {
|
||||
open: boolean
|
||||
anchorRef?: RefObject<HTMLElement>
|
||||
/** Currently active workspace (renders a trailing check in the picker list). */
|
||||
selectedId?: WorkspaceId | undefined
|
||||
onPick: (workspaceId: WorkspaceId) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@ export interface ToolRowModel {
|
||||
variant: ToolRowVariant
|
||||
title: string
|
||||
summary: string
|
||||
/**
|
||||
* Filesystem path from args (`path` / `file_path`) when the row is a file
|
||||
* tool; absent for URL reads and non-file tools. The chat view resolves
|
||||
* relative values against the session cwd before opening.
|
||||
*/
|
||||
filePath: string | undefined
|
||||
/** Expanded-body text (pretty args); null = row not expandable. */
|
||||
body: string | null
|
||||
state: ToolRowState
|
||||
@@ -101,6 +107,14 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
|
||||
others: [],
|
||||
}
|
||||
|
||||
/** Strip the workspace root from workspace-rooted absolute paths (display only). */
|
||||
function relativizeToCwd(text: string, cwd: string | undefined): string {
|
||||
if (cwd === undefined || cwd === '') return text
|
||||
const root = cwd.replace(/[/\\]+$/, '')
|
||||
if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1)
|
||||
return text
|
||||
}
|
||||
|
||||
function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
|
||||
const parsed = parseArgs(argsRaw)
|
||||
if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw)
|
||||
@@ -113,6 +127,35 @@ function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
|
||||
return firstLine(argsRaw)
|
||||
}
|
||||
|
||||
/** Path keys only — never `url` (web_fetch lands on the read variant). */
|
||||
const FILE_PATH_KEYS = ['path', 'file_path'] as const
|
||||
|
||||
/** File-tool variants whose summary may be an openable workspace path. */
|
||||
const FILE_PATH_VARIANTS: ReadonlySet<ToolRowVariant> = new Set(['read', 'write', 'edit'])
|
||||
|
||||
function deriveFilePath(variant: ToolRowVariant, argsRaw: string): string | undefined {
|
||||
if (!FILE_PATH_VARIANTS.has(variant)) return undefined
|
||||
const parsed = parseArgs(argsRaw)
|
||||
if (typeof parsed !== 'object' || parsed === null) return undefined
|
||||
const picked = pickString(parsed as Record<string, unknown>, FILE_PATH_KEYS)
|
||||
return picked === undefined ? undefined : firstLine(picked)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tool-arg path against the session cwd for host.openPath.
|
||||
* Absolute POSIX/Windows paths pass through; relative paths join under cwd.
|
||||
* @param cwd - session working directory (may be absent for ungrouped sessions).
|
||||
* @param path - path as carried in tool args.
|
||||
* @returns a host-facing path string.
|
||||
*/
|
||||
export function resolveToolPath(cwd: string | undefined, path: string): string {
|
||||
if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path
|
||||
if (cwd === undefined || cwd === '') return path
|
||||
const base = cwd.replace(/[/\\]+$/, '')
|
||||
const rel = path.replace(/^[/\\]+/, '')
|
||||
return `${base}/${rel}`
|
||||
}
|
||||
|
||||
function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
|
||||
if (argsRaw === '') return null
|
||||
const parsed = parseArgs(argsRaw)
|
||||
@@ -130,16 +173,17 @@ function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
|
||||
* Derive the full row model from a frozen call slice.
|
||||
* @param toolName - wire tool name (dispatch-supplied; survives windowless results).
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @param cwd - session workspace root; workspace-rooted path summaries display relative to it.
|
||||
* @returns the row model.
|
||||
*/
|
||||
export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowModel {
|
||||
export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel {
|
||||
const variant = classifyTool(toolName)
|
||||
const done = 'kind' in block
|
||||
const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const state: ToolRowState = !done ? 'running'
|
||||
: block.error?.code === 'interrupted' ? 'stopped'
|
||||
: block.isError ? 'error' : 'ok'
|
||||
const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw)
|
||||
const base = argsRaw === '' ? block.callId : relativizeToCwd(deriveSummary(variant, argsRaw), cwd)
|
||||
const toolTitle = TOOL_TITLES[toolName]
|
||||
// Others keeps the static "Tool call" title (figma literal); the real tool
|
||||
// name rides the mutable summary slot unless the tool owns a specific title.
|
||||
@@ -150,6 +194,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowMod
|
||||
variant,
|
||||
title: toolTitle ?? VARIANT_TITLES[variant],
|
||||
summary,
|
||||
filePath: deriveFilePath(variant, argsRaw),
|
||||
body: deriveBody(variant, argsRaw),
|
||||
state,
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -72,13 +72,6 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
|
||||
.tabs {
|
||||
display: flex;
|
||||
@@ -87,7 +80,7 @@
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* figma .Tab 34:11442: 13/16 wt510 text, gap 8 to the 3px bar (no bottom rounding). */
|
||||
/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar (no bottom rounding). */
|
||||
.tab {
|
||||
position: relative;
|
||||
padding: 0 0 11px;
|
||||
@@ -95,7 +88,7 @@
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
font-weight: 510;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -139,11 +132,45 @@
|
||||
NOT absolute+transform: a transform would make this box the containing
|
||||
block for position:fixed descendants (pickers/modals), shrinking them. */
|
||||
.composerHero {
|
||||
position: relative; /* .heroGlow positioning context */
|
||||
align-self: center;
|
||||
/* figma 75:8208: 12 between hero chrome / workspace row / card. */
|
||||
gap: 12px;
|
||||
/* Foot inside the centered box floats the stack a bit above true center. */
|
||||
padding-bottom: 32px;
|
||||
width: min(776px, calc(100% - 48px));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Blue backdrop ellipse (figma 313:14109), centered on the input card: the
|
||||
card's resting center sits ~92px above the stack bottom (32 foot pad +
|
||||
half of the ~120px two-row card); width tracks the card (glow asset 1051
|
||||
vs design card 776) so blur scales in userSpace with it. z-index -1 keeps
|
||||
it behind the in-flow hero content inside this stacking context. */
|
||||
.heroGlow {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 92px;
|
||||
z-index: -1;
|
||||
width: calc(100% * 1051 / 776);
|
||||
aspect-ratio: 1051 / 468;
|
||||
transform: translate(-50%, 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.heroWorkspaceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.root[data-phase='hero'] {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Settling (session replaying, hero/docked unknown): keep the composer
|
||||
mounted but invisible so no wrong layout flashes before the phase lands. */
|
||||
.root[data-phase='settling'] .composerStack {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
|
||||
import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
|
||||
import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
|
||||
import { DisabledInputBar } from './DisabledInputBar.tsx'
|
||||
import css from './ConversationRoot.module.css'
|
||||
|
||||
@@ -36,33 +36,53 @@ export function ConversationRoot({
|
||||
workspace => workspace.workspaceId === pendingWorkspaceId,
|
||||
)
|
||||
|
||||
// Clear the pending pick once the session lands in it, or when the picked
|
||||
// workspace disappears from a ready list (deleted from the sidebar).
|
||||
useEffect(() => {
|
||||
if (pendingWorkspaceId !== undefined
|
||||
&& sessionWorkspace?.workspaceId === pendingWorkspaceId) {
|
||||
if (pendingWorkspaceId === undefined) return
|
||||
if (sessionWorkspace?.workspaceId === pendingWorkspaceId
|
||||
|| (workspaces.phase === 'ready' && pendingWorkspace === undefined)) {
|
||||
setPendingWorkspaceId(undefined)
|
||||
}
|
||||
}, [pendingWorkspaceId, sessionWorkspace?.workspaceId])
|
||||
}, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace])
|
||||
|
||||
const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))
|
||||
// While a session is still replaying (loading + blank) the hero/docked
|
||||
// choice is unknowable — render the composer hidden instead of flashing
|
||||
// the centered hero and snapping to the docked bar (or vice versa).
|
||||
const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading'
|
||||
const hero = sessionId === undefined || (composerPhase === 'blank' && openState === 'open')
|
||||
const zone: InputZone | undefined =
|
||||
session === undefined || inputState === undefined ? undefined : { session, input: inputState }
|
||||
|
||||
// Flow optimization — worth a close PR review for code/boundary issues.
|
||||
// The chip is a selector; label resolution walks the flow top-down:
|
||||
// 1. a just-picked workspace (pending) → its title;
|
||||
// 2. cold start, no session yet → placeholder ("Choose workspace");
|
||||
// 3. the blank session's workspace is in the list → its title;
|
||||
// 4. list still loading → cwd folder name bridges so the title does not
|
||||
// flash on refresh (empty cwd → placeholder);
|
||||
// 5. list ready but no owning workspace (deleted from the sidebar) →
|
||||
// placeholder, never the deleted folder's name via cwd.
|
||||
const chipTitle = pendingWorkspace?.title
|
||||
?? (sessionId === undefined
|
||||
? undefined
|
||||
: sessionWorkspace?.title
|
||||
?? (workspaces.phase === 'ready' || cwd === undefined || cwd === ''
|
||||
? undefined
|
||||
: workspaceLabel(cwd)))
|
||||
|
||||
const heroWorkspaceRow = (
|
||||
<>
|
||||
<div className={css.heroWorkspaceRow}>
|
||||
<WorkspaceChip
|
||||
buttonRef={pickerAnchor}
|
||||
label={
|
||||
pendingWorkspace?.title
|
||||
?? (sessionId === undefined
|
||||
? workspaceLabel('')
|
||||
: sessionWorkspace?.title ?? workspaceLabel(cwd ?? ''))
|
||||
}
|
||||
label={chipTitle}
|
||||
menuOpen={pickerOpen}
|
||||
onClick={() => { setPickerOpen(open => !open) }}
|
||||
/>
|
||||
{renderSlot('conversation.hero.workspace', {
|
||||
open: pickerOpen,
|
||||
anchorRef: pickerAnchor,
|
||||
selectedId: pendingWorkspaceId ?? sessionWorkspace?.workspaceId,
|
||||
onPick: (workspaceId) => {
|
||||
setPickerOpen(false)
|
||||
setPendingWorkspaceId(workspaceId)
|
||||
@@ -72,10 +92,13 @@ export function ConversationRoot({
|
||||
},
|
||||
onClose: () => { setPickerOpen(false) },
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
|
||||
const inputBar = sessionId === undefined
|
||||
// The placeholder chip ("Choose workspace") and the inert input travel
|
||||
// together: a blank session whose workspace vanished (deleted from the
|
||||
// sidebar) reverts to the same disabled bar as the initial no-session state.
|
||||
const inputBar = sessionId === undefined || (hero && chipTitle === undefined)
|
||||
? <DisabledInputBar />
|
||||
: renderSlot('conversation.composer.bar', {
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
@@ -87,6 +110,7 @@ export function ConversationRoot({
|
||||
|
||||
const composerBar = (
|
||||
<div className={clsx(css.composerStack, hero && css.composerHero)}>
|
||||
{hero && <HeroGlow className={css.heroGlow} />}
|
||||
{hero && <HeroShell />}
|
||||
{hero && heroWorkspaceRow}
|
||||
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
|
||||
@@ -96,7 +120,7 @@ export function ConversationRoot({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css.root} data-phase={hero ? 'hero' : 'active'}>
|
||||
<div className={css.root} data-phase={settling ? 'settling' : hero ? 'hero' : 'active'}>
|
||||
{/* Mounted for every real session, hero included: ConversationSession
|
||||
renders no chrome while blank but owns the draft-persistence mirror
|
||||
bind — unmounting it in the hero would lose pre-first-send text on
|
||||
|
||||
@@ -31,7 +31,6 @@ export function ConversationSession({
|
||||
const activeId = useStore(s => s.view) ?? 'chat'
|
||||
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
|
||||
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
|
||||
const turns = useSession(s => countTurns(s))
|
||||
const composerPhase = useSession(s => s.composerPhase)
|
||||
const blank = useSession(s => s.blank)
|
||||
const inputState = useInput(s => s)
|
||||
@@ -69,7 +68,6 @@ export function ConversationSession({
|
||||
)
|
||||
})}
|
||||
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
|
||||
<span className={css.meta}>· {turns} turns</span>
|
||||
</nav>
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
@@ -95,9 +93,3 @@ export function ConversationSession({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function countTurns(snapshot: { nodes: readonly { kind: string }[] }): number {
|
||||
let count = 0
|
||||
for (const node of snapshot.nodes) if (node.kind === 'user') count += 1
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function DisabledInputBar() {
|
||||
<div className={css.trailing}>
|
||||
<button type="button" className={css.primary} aria-label="Send message" disabled>
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
|
||||
<path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -7,20 +7,18 @@
|
||||
import { useId } from 'react'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import {
|
||||
FishLogo, IconChevronDownOutline14, IconFolderOpen16,
|
||||
FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import css from './HeroShell.module.css'
|
||||
|
||||
/**
|
||||
* Basename label for the workspace chip / menu rows (the shared derivation);
|
||||
* empty → the design's "New Workspace" placeholder copy; separator-only
|
||||
* paths echo the raw cwd.
|
||||
* @param cwd - workspace directory path ('' for none).
|
||||
* Basename label for the workspace chip (the shared derivation);
|
||||
* separator-only paths echo the raw cwd.
|
||||
* @param cwd - workspace directory path (non-empty).
|
||||
* @returns chip label.
|
||||
*/
|
||||
export function workspaceLabel(cwd: string): string {
|
||||
if (cwd === '') return 'New Workspace'
|
||||
const base = workspaceTitleOf(cwd)
|
||||
return base !== '' ? base : cwd
|
||||
}
|
||||
@@ -28,15 +26,17 @@ export function workspaceLabel(cwd: string): string {
|
||||
/**
|
||||
* The workspace chip (folder + label + chevron), always interactive: before
|
||||
* the first message the workspace stays switchable — picking another one
|
||||
* moves the New Session flow to that workspace's blank session.
|
||||
* @param props.label - chip label (see {@link workspaceLabel}).
|
||||
* moves the New Session flow to that workspace's blank session. Without a
|
||||
* label the chip renders its placeholder state: closed folder + the
|
||||
* "Choose workspace" call to action.
|
||||
* @param props.label - chip label (see {@link workspaceLabel}); omitted → placeholder.
|
||||
* @param props.menuOpen - menu expansion echo.
|
||||
* @param props.onClick - menu toggle.
|
||||
* @returns the chip button element.
|
||||
*/
|
||||
export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
|
||||
buttonRef?: RefObject<HTMLButtonElement>
|
||||
label: string
|
||||
label?: string | undefined
|
||||
menuOpen?: boolean
|
||||
onClick?: () => void
|
||||
}) {
|
||||
@@ -50,13 +50,49 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
|
||||
aria-expanded={menuOpen}
|
||||
onClick={onClick}
|
||||
>
|
||||
<IconFolderOpen16 className={css.folder} size={16} />
|
||||
<span className={css.workspaceLabel}>{label}</span>
|
||||
{label === undefined
|
||||
? <IconFolderClose16 className={css.folder} size={16} />
|
||||
: <IconFolderOpen16 className={css.folder} size={16} />}
|
||||
<span className={css.workspaceLabel}>{label ?? 'Choose workspace'}</span>
|
||||
<IconChevronDownOutline14 className={css.chevron} size={12} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The soft blue backdrop ellipse (figma 313:14109). Rendered by the hero
|
||||
* owner (ConversationRoot), not HeroShell, so it can center on the input
|
||||
* card; the owner's className supplies all positioning.
|
||||
* @param props.className - positioning class from the owner.
|
||||
* @returns the blurred-ellipse svg element.
|
||||
*/
|
||||
export function HeroGlow({ className }: { className?: string | undefined }) {
|
||||
// Stable filter id so multiple hero mounts do not collide in the DOM.
|
||||
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<filter
|
||||
id={glowFilterId}
|
||||
x="0"
|
||||
y="0"
|
||||
width="1051"
|
||||
height="468"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g filter={`url(#${glowFilterId})`}>
|
||||
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.08" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */
|
||||
export interface HeroShellProps {
|
||||
/** Overlay content after the stack (modals). */
|
||||
@@ -64,13 +100,12 @@ export interface HeroShellProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the hero chrome (headline + glow; no composer, no workspace row).
|
||||
* Render the hero chrome (headline only; no glow, no composer, no workspace
|
||||
* row — the glow is the owner's {@link HeroGlow}).
|
||||
* @param props - see {@link HeroShellProps}.
|
||||
* @returns the centered hero element tree.
|
||||
*/
|
||||
export function HeroShell({ children }: HeroShellProps) {
|
||||
// Stable filter id so multiple hero mounts do not collide in the DOM.
|
||||
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div className={css.stack}>
|
||||
@@ -80,29 +115,6 @@ export function HeroShell({ children }: HeroShellProps) {
|
||||
Let's start building
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
{/* figma 313:14109: soft ellipse behind workspace + composer; width
|
||||
tracks the card (glow asset 1051 vs design card 776) so blur
|
||||
scales in userSpace with it. */}
|
||||
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<filter
|
||||
id={glowFilterId}
|
||||
x="0"
|
||||
y="0"
|
||||
width="1051"
|
||||
height="468"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g filter={`url(#${glowFilterId})`}>
|
||||
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
|
||||
</g>
|
||||
</svg>
|
||||
{/* The resident composer (rendered by ConversationRoot at its stable
|
||||
tree position; the workspace row rides its accessory hole) is
|
||||
CSS-positioned into this gap during the hero phase — see
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
margin-bottom: -70px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
/* Cap matches InputBar card width (800). Glow may paint past the sides. */
|
||||
@@ -24,17 +23,15 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600; title block
|
||||
keeps 36px below the headline before the flex gap. */
|
||||
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. */
|
||||
.headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 36px;
|
||||
font-size: 26px;
|
||||
line-height: 32px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -44,8 +41,9 @@
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
/* Workspace row sits 12px above the input card (figma y80 → y112). Glow is
|
||||
centered on this block so it stays under the picker + InputBar together. */
|
||||
/* Workspace row sits 12px above the input card (figma y80 → y112). The blue
|
||||
glow lives with the owner (ConversationRoot .heroGlow) so it can center on
|
||||
the input card. */
|
||||
.body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -55,19 +53,7 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Design input 776 → glow SVG 1051×468 (ellipse 851×268 + blur pad). */
|
||||
.glow {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
z-index: 0;
|
||||
width: calc(100% * 1051 / 776);
|
||||
aspect-ratio: 1051 / 468;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.body > :not(.glow) {
|
||||
.body > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -88,7 +74,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: fit-content;
|
||||
max-width: min(100%, 360px);
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
|
||||
@@ -171,6 +171,10 @@
|
||||
.input,
|
||||
.mirror,
|
||||
.backdrop {
|
||||
/* Textareas default to content-box (unlike buttons/inputs): without this the
|
||||
width:100% textarea gains its padding OUTSIDE the card and text runs past
|
||||
the right padding — and wraps 28px later than the mirror/backdrop layers. */
|
||||
box-sizing: border-box;
|
||||
/* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these
|
||||
metrics or the highlight ranges drift off the glyphs. */
|
||||
padding: 4px 12px 0 16px;
|
||||
@@ -306,11 +310,14 @@
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-button-info-fill);
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
/* Static white, not the foreground token: the arrow stays white on the blue
|
||||
fill in both themes (design 34:10465). */
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
|
||||
.primary:hover {
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-button-info-hover);
|
||||
}
|
||||
|
||||
@@ -319,14 +326,6 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Stop state: same slot, dimmed brand fill — the running-state send-key
|
||||
replacement is a design gap filled by us (figma gives no stop form). */
|
||||
.stopping,
|
||||
.stopping:hover {
|
||||
background: var(--dsw-alias-button-primary-dimmed);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.retry {
|
||||
margin-left: 8px;
|
||||
padding: 1px 8px;
|
||||
|
||||
@@ -372,7 +372,7 @@ export function InputBar({
|
||||
{machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.primary, running && css.stopping)}
|
||||
className={css.primary}
|
||||
aria-label={primaryLabel}
|
||||
title={primaryLabel}
|
||||
disabled={!running && (empty || disabled || machineBusy)}
|
||||
@@ -381,11 +381,11 @@ export function InputBar({
|
||||
>
|
||||
{running ? (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
|
||||
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" />
|
||||
<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 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<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>
|
||||
|
||||
@@ -92,15 +92,15 @@
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.glyphPending {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.glyphProgress {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
animation: todo-progress-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.glyphPending {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
@keyframes todo-progress-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
|
||||
|
||||
.root {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.root:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
|
||||
.root[data-state='running']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-bash-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-bash-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
@@ -39,7 +58,7 @@
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sep {
|
||||
|
||||
@@ -12,10 +12,10 @@ import css from './bash-sample.module.css'
|
||||
|
||||
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 <IconApiOutline14 size={16} />
|
||||
// Running keeps the icon — the row sweep carries the in-flight signal.
|
||||
default: return <IconApiOutline14 size={14} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ function stateStatus(state: ToolRowState): string | null {
|
||||
}
|
||||
|
||||
/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */
|
||||
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
|
||||
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
||||
const status = stateStatus(model.state)
|
||||
@@ -40,8 +40,6 @@ export function BashRow({ toolName, block, openDetails, sessionId, useSessions }
|
||||
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
||||
data-variant="bash"
|
||||
data-state={model.state}
|
||||
data-clickable
|
||||
onClick={openDetails}
|
||||
>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
|
||||
@@ -6,12 +6,6 @@
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.leading {
|
||||
@@ -29,6 +23,7 @@
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 500; /* figma wt510, rendered 500 */
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
// ellipsized text; the durable list itself renders in the TodoPanel above the
|
||||
// composer, so the row stays one line. Chrome matches ToolRow (figma 780:53675).
|
||||
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
@@ -61,29 +60,18 @@ function leadingFor(state: ToolRowState) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
|
||||
/** One-line plan update row. 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. */
|
||||
export function TodoRow({ toolName, block }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const summary = summarize(argsRaw) ?? { text: model.summary, extra: 0 }
|
||||
// Button semantics, not a <button>: the row carries inline spans a button
|
||||
// would flatten, and ToolRow takes the same role/tabIndex/Enter-Space route.
|
||||
const openFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
openDetails()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={css.row}
|
||||
data-sample="todo-row"
|
||||
data-state={model.state}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openDetails}
|
||||
onKeyDown={openFromKeyboard}
|
||||
>
|
||||
<span className={css.leading} aria-hidden>{leadingFor(model.state)}</span>
|
||||
<span className={css.title}>更新任务清单</span>
|
||||
|
||||
Reference in New Issue
Block a user