|
|
@@ -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} />
|