Просмотр исходного кода

fix(web-ui): cwd-relative path summaries, sweep glare rework, uniform 16px chat rhythm

Tool row summaries strip the session workspace root; the running sweep
becomes a glare-band overlay (deepsuite ShimmerText pattern); assistant
nodes that render nothing no longer split tool-row groups; block and
tool-row spacing collapse to one 16px rhythm.
Yif 2 месяцев назад
Родитель
Сommit
16b54aa4c4

+ 8 - 3
packages/client/ui-conversation/src/client/chat/ChatView.module.css

@@ -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 {
@@ -58,6 +59,10 @@
 .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);

+ 45 - 30
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -49,19 +49,20 @@ 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, onOpenDetails, selected, cwd }: {
   renderSlot: RenderToolRow
   node: CodeSubCall
   onOpenDetails: OpenDetails
   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,
+    callId: node.callId, toolName, block: node, cwd,
     openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) },
-  }), [node, toolName, seq, onOpenDetails])
+  }), [node, toolName, seq, cwd, onOpenDetails])
   return (
     <div className={css.callRow} data-selected={selected || undefined}>
       {renderSlot('conversation.chat.toolview', owner, {
@@ -77,7 +78,9 @@ 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, seq, onOpenDetails, selected, subCalls, selectedCallId, cwd,
+}: {
   renderSlot: RenderToolRow
   callId: string
   toolName: string
@@ -91,11 +94,13 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
   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,
+    callId, toolName, block, cwd,
     openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) },
-  }), [callId, toolName, block, seq, onOpenDetails])
+  }), [callId, toolName, block, seq, cwd, onOpenDetails])
   return (
     <div className={css.callRow} data-selected={selected || undefined}>
       {renderSlot('conversation.chat.toolview', owner, {
@@ -111,6 +116,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
               node={node}
               onOpenDetails={onOpenDetails}
               selected={node.callId === selectedCallId}
+              cwd={cwd}
             />
           ))}
         </div>
@@ -119,8 +125,8 @@ 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, onOpenDetails, selectedCallId, codeDispatches, cwd }: {
   renderSlot: RenderToolRow
   results: readonly ToolResultNode[]
   onOpenDetails: OpenDetails
@@ -128,6 +134,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
   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}>
@@ -143,6 +151,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
           selected={node.callId === selectedCallId}
           subCalls={codeDispatches.get(node.callId)}
           selectedCallId={selectedCallId}
+          cwd={cwd}
         />
       ))}
     </div>
@@ -157,27 +166,29 @@ const LOADER_CELLS = [0, 5, 10, 15] as const
 
 function TurnDots() {
   return (
-    <svg
-      className={css.turnDots}
-      width="17.5"
-      height="2.5"
-      viewBox="0 0 17.5 2.5"
-      shapeRendering="crispEdges"
-      aria-hidden="true"
-    >
-      {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>
+    /* 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>
   )
 }
 
@@ -199,8 +210,10 @@ 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, openDetails, 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)
@@ -301,6 +314,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
           onOpenDetails={openDetails}
           selectedCallId={inGroup ? selectedCallId : undefined}
           codeDispatches={codeDispatches}
+          cwd={cwd}
         />
       )
     }
@@ -342,6 +356,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
                   selected={call.callId === selectedCallId}
                   subCalls={codeDispatches.get(call.callId)}
                   selectedCallId={selectedCallId}
+                  cwd={cwd}
                 />
               ))}
             </div>

+ 2 - 2
packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx

@@ -25,8 +25,8 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
   others: <IconSparkle16 size={14} />,
 }
 
-export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) {
-  const model = toolRowModel(toolName, block)
+export function GenericToolCard({ toolName, block, cwd, openDetails }: ToolRowOwnerProps) {
+  const model = toolRowModel(toolName, block, cwd)
   return (
     <ToolRow
       variant={model.variant}

+ 23 - 18
packages/client/ui-conversation/src/client/chat/ToolRow.module.css

@@ -6,34 +6,39 @@
   flex-direction: column;
 }
 
-/* Row sweep mask (running signal): the row content itself is the medium —
-   an animated mask band modulates the alpha of everything in the row
-   (glyphs, icon, dot), so the light reads as passing THROUGH the content,
-   not behind it. The mask is ALWAYS mounted with the band parked off-screen
-   (-50% = past the right edge; tiled copies sit outside the view too), so
-   at rest it is a no-op. Running only adds the position animation. On exit
-   the animation drops and `transition: mask-position` carries the band from
-   its animated position onward off-screen right (before-change style
-   includes animation effects per css-transitions-1) — the sweep finishes
-   instead of snapping. */
 .row {
+  position: relative; /* sweep-glare overlay anchor */
+  overflow: hidden;
   display: flex;
   align-items: center;
   height: 24px;
   min-width: 0;
-  mask-image: linear-gradient(100deg, #000 30%, rgba(0, 0, 0, 0.35) 50%, #000 70%);
-  mask-size: 200% 100%;
-  mask-position: -50% 0;
-  transition: mask-position 1.6s ease-out;
 }
 
-.root[data-state='running'] .row {
-  animation: dsh-tool-row-sweep 2.2s linear infinite;
+/* 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;
 }
 
 @keyframes dsh-tool-row-sweep {
-  from { mask-position: 150% 0; }
-  to { mask-position: -50% 0; }
+  0% { left: -300px; }
+  90%, 100% { left: 100%; }
 }
 
 /* Clickable rows keep only the cursor affordance — no hover fill. */

+ 11 - 0
packages/client/ui-conversation/src/client/chat/chat-flow.ts

@@ -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]

+ 2 - 0
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -143,6 +143,8 @@ export interface ToolRowOwnerProps {
   toolName: string
   /** Frozen call slice: the running call or the settled result node. */
   block: ToolCallBlock
+  /** Session workspace root; path summaries display relative to it. */
+  cwd?: string | undefined
   /** Open the details panel for this call (session-level facility, supplied by the view). */
   openDetails: () => void
 }

+ 11 - 2
packages/client/ui-conversation/src/client/contract/tool-call-model.ts

@@ -101,6 +101,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)
@@ -130,16 +138,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.

+ 1 - 1
packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx

@@ -66,7 +66,7 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
  * @param props.className - positioning class from the owner.
  * @returns the blurred-ellipse svg element.
  */
-export function HeroGlow({ className }: { className?: string }) {
+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 (

+ 4 - 4
packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css

@@ -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);

+ 20 - 11
packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css

@@ -1,28 +1,37 @@
 /* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
 
-/* Row sweep mask — same masked in-flight signal and exit-glide contract as
-   ToolRow (mask always mounted, band parked off-screen; running only adds
-   the animation; the transition finishes the sweep on exit). */
 .root {
+  position: relative; /* sweep-glare overlay anchor */
+  overflow: hidden;
   display: flex;
   align-items: center;
   height: 24px;
   min-width: 0;
   cursor: pointer;
   border-radius: 6px;
-  mask-image: linear-gradient(100deg, #000 30%, rgba(0, 0, 0, 0.35) 50%, #000 70%);
-  mask-size: 200% 100%;
-  mask-position: -50% 0;
-  transition: mask-position 1.6s ease-out;
 }
 
-.root[data-state='running'] {
-  animation: dsh-bash-row-sweep 2.2s linear infinite;
+/* 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 {
-  from { mask-position: 150% 0; }
-  to { mask-position: -50% 0; }
+  0% { left: -300px; }
+  90%, 100% { left: 100%; }
 }
 
 .leading {

+ 10 - 0
packages/client/ui-conversation/tests/chat-tool-row.spec.tsx

@@ -64,6 +64,16 @@ describe('tool-call-model', () => {
     expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
   })
 
+  it('displays workspace-rooted paths relative to the session cwd', () => {
+    const cwd = '/Users/u/ws/'
+    expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts')
+    expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), cwd).summary).toBe('a.md')
+    // Paths outside the workspace (and non-path summaries) stay verbatim.
+    expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/etc/hosts"}' }), cwd).summary).toBe('/etc/hosts')
+    expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' }), cwd).summary).toBe('pwd')
+    expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md')
+  })
+
   it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => {
     expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n  "a": 1\n}')
     expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw')

+ 15 - 0
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -131,6 +131,21 @@ describe('chat-flow derivation', () => {
     expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
     expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
   })
+
+  it('skips render-nothing assistant nodes so tool runs stay one group', () => {
+    // A tool-call-only step message (and blank text/reasoning) renders nothing:
+    // it must not split the run into two groups with an empty line between.
+    const headsOnly: AssistantMessageNode = {
+      kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2,
+      blocks: [{ kind: 'tool-call', callId: 'b', name: 'read', argsRaw: '{}' }, { kind: 'text', text: ' \n' }, { kind: 'reasoning', text: '' }],
+    }
+    const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')])
+    expect(flowKeys(items)).toBe('g3')
+    expect(items[0]!.kind === 'tool-group' && items[0].results.map(r => r.callId)).toEqual(['a', 'b'])
+    // Interrupted and visible-content nodes still render (已停止 marker / prose).
+    expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5')
+    expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
+  })
 })
 
 describe('ChatView', () => {