Selaa lähdekoodia

Polish web chat presentation

Yichen Jiang 1 kuukausi sitten
vanhempi
sitoutus
b121adcf1a
23 muutettua tiedostoa jossa 205 lisäystä ja 91 poistoa
  1. 9 5
      apps/web/tests/live-interactions.e2e.ts
  2. 1 0
      apps/web/tests/snapshots/live-interactions/cancel.expected.md
  3. 1 0
      apps/web/tests/snapshots/live-interactions/error-auth.expected.md
  4. 25 0
      apps/web/tests/snapshots/live-interactions/loading.expected.md
  5. 1 0
      apps/web/tests/snapshots/live-interactions/retry.expected.md
  6. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  7. 1 1
      packages/client/ui-conversation/README.md
  8. 1 1
      packages/client/ui-conversation/README.zh.md
  9. 35 23
      packages/client/ui-conversation/src/client/chat/ChatView.module.css
  10. 5 30
      packages/client/ui-conversation/src/client/chat/ChatView.tsx
  11. 11 5
      packages/client/ui-conversation/src/client/chat/MessageItem.tsx
  12. 4 0
      packages/client/ui-conversation/src/client/queue/QueueDock.module.css
  13. 5 1
      packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
  14. 1 0
      packages/client/ui-conversation/tests/chat-view.spec.tsx
  15. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  16. 1 1
      packages/client/ui-primitives/README.md
  17. 2 1
      packages/client/ui-primitives/README.zh.md
  18. 12 0
      packages/client/ui-primitives/src/icons/index.tsx
  19. 1 0
      packages/client/ui-primitives/src/index.ts
  20. 39 7
      packages/client/ui-primitives/src/markdown/JsonBlock.module.css
  21. 35 6
      packages/client/ui-primitives/src/markdown/JsonBlock.tsx
  22. 2 2
      packages/client/ui-primitives/tests/icons.spec.tsx
  23. 9 4
      packages/client/ui-primitives/tests/markdown.spec.tsx

+ 9 - 5
apps/web/tests/live-interactions.e2e.ts

@@ -27,11 +27,12 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url))
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
-// One golden per interactive end-state: what the user is left looking at
-// after cancel, after a non-retryable failure (pins the FIXME(web-error-surface)
-// gap as a reviewable artifact: NO error copy in the tree), and after retry
-// recovery — three genuinely different terminal surfaces of one fixture.
+// One golden pins the stable mid-turn loading state; the other three capture
+// what the user is left looking at after cancel, after a non-retryable failure
+// (pins the FIXME(web-error-surface) gap as a reviewable artifact: NO error
+// copy in the tree), and after retry recovery.
 const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md')
+const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md')
 const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
 const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
 const MODE = webSnapshotMode()
@@ -133,6 +134,9 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
     // The marker IS the synchronization: the stream is provably parked in the
     // hang (prefix chunks delivered to the loop) before the stop click.
     await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true)
+    await expect(page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible()).resolves.toBe(true)
+    const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
+    await compareOrRefreshGolden(LOADING_EXPECTED, loadingSnapshot, MODE)
     await page.getByRole('button', { name: 'Stop generating' }).click()
     await settled
     expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
@@ -231,7 +235,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.jsonl', 'cancel.expected.md', 'error-auth.expected.md', 'retry.expected.md',
+      'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'error-auth.expected.md', 'retry.expected.md',
     ])
   })
 })

+ 1 - 0
apps/web/tests/snapshots/live-interactions/cancel.expected.md

@@ -11,6 +11,7 @@
   - img
 - button "编辑":
   - img
+- button "上下文注入"
 - paragraph: partial
 - text: 已停止
 - button "复制":

+ 1 - 0
apps/web/tests/snapshots/live-interactions/error-auth.expected.md

@@ -11,6 +11,7 @@
   - img
 - button "编辑":
   - img
+- button "上下文注入"
 - textbox "Message the agent"
 - button "Add attachment":
   - img

+ 25 - 0
apps/web/tests/snapshots/live-interactions/loading.expected.md

@@ -0,0 +1,25 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "Reply with a one-sentence description" [disabled]
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- button "编辑":
+  - img
+- button "上下文注入"
+- paragraph: partial
+- status: Deep diving...
+- textbox "Message the agent"
+- button "Add attachment":
+  - img
+- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- button "Plan mode off, press to turn on": Plan off
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "Stop generating"

+ 1 - 0
apps/web/tests/snapshots/live-interactions/retry.expected.md

@@ -11,6 +11,7 @@
   - img
 - button "编辑":
   - img
+- button "上下文注入"
 - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
   - img
   - img

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 855c42b3377e80b0d8f21a418da0a591782439e1
-README.zh.md: 31cf2c7b5a9a0740c2be9079ce55d897d175a6d0
+README.md: bf24994e6d6103755e776cf5b694d16d8d6cc2fa
+README.zh.md: b43c108b973b5d29f78210e33323bacc80c6148f

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
+Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, text-icon context disclosures, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
 
 The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
 

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
+会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、文本图标式上下文展开区、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
 
 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
 

+ 35 - 23
packages/client/ui-conversation/src/client/chat/ChatView.module.css

@@ -66,33 +66,45 @@
   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 {
+/* Turn activity keeps the former loader's one-line footprint. A pale
+   brand-blue band sweeps from left to right; reduced-motion keeps it static. */
+.turnStatus {
   align-self: flex-start;
   flex: none;
-  display: flex;
+  display: inline-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; }
+  font: var(--dsw-font-s-strong-14);
+  white-space: nowrap;
+  background: linear-gradient(
+    90deg,
+    var(--dsw-static-deepseek-500) 0%,
+    var(--dsw-static-deepseek-500) 40%,
+    var(--dsw-static-deepseek-200) 50%,
+    var(--dsw-static-deepseek-500) 60%,
+    var(--dsw-static-deepseek-500) 100%
+  );
+  background-position: 100% 0;
+  background-size: 250% 100%;
+  background-clip: text;
+  color: transparent;
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  animation: dsh-turn-status-shimmer 1.8s linear infinite;
+}
+
+@keyframes dsh-turn-status-shimmer {
+  to {
+    background-position: 0 0;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .turnStatus {
+    background-position: 0 0;
+    background-size: 100% 100%;
+    animation: none;
+  }
 }
 
 .hint {

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

@@ -178,36 +178,11 @@ const CommandRow = memo(function CommandRow({ renderSlot, node }: {
   )
 })
 
-/** 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() {
+/** Turn-level model activity label retained across first-token, tool, and streaming phases. */
+function TurnStatus() {
   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 className={css.turnStatus} role="status" aria-live="polite">
+      Deep diving...
     </div>
   )
 }
@@ -426,7 +401,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
               double-render the same wait. */}
           {/* Turn-level loading signal: rides the whole running turn (first-token
               wait, tool execution, streaming) so it never flickers per step. */}
-          {running && <TurnDots />}
+          {running && <TurnStatus />}
         </div>
         {!atBottom && (
           <div className={css.toBottomSlot}>

+ 11 - 5
packages/client/ui-conversation/src/client/chat/MessageItem.tsx

@@ -1,15 +1,17 @@
 // MessageItem: the four simple node kinds — user bubble (right-aligned, with
 // clock + copy / branch / edit IconActions), steering (badged bubble), context
-// injection and unknown-surface JSON rows. Props are frozen node slices off
-// the snapshot cache; memo holds across streaming because unchanged nodes
-// keep their references.
+// injection as a text-icon disclosure, and unknown-surface JSON rows. Props
+// are frozen node slices off the snapshot cache; memo holds across streaming
+// because unchanged nodes keep their references.
 
 import { memo } from 'react'
 import type { ReactNode } from 'react'
 import type {
   ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
 } from '@deepseek-ai/dsh-client-runtime/client'
-import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
+import {
+  IconTextOutline14, JsonBlock, MessageText,
+} from '@deepseek-ai/dsh-client-ui-primitives'
 import { MessageIconActions } from './MessageIconActions.tsx'
 import css from './MessageItem.module.css'
 
@@ -95,7 +97,11 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
     case 'context':
       return (
         <div className={css.contextRow}>
-          <JsonBlock label="上下文注入" payload={{ content: node.content, source: node.source }} />
+          <JsonBlock
+            label="上下文注入"
+            payload={{ content: node.content, source: node.source }}
+            collapsedIcon={<IconTextOutline14 />}
+          />
         </div>
       )
     default:

+ 4 - 0
packages/client/ui-conversation/src/client/queue/QueueDock.module.css

@@ -49,6 +49,10 @@
   border-radius: 8px;
 }
 
+.row + .row {
+  box-shadow: inset 0 1px 0 var(--dsw-alias-border-l1);
+}
+
 .preview,
 .editor {
   flex: 1 1 auto;

+ 5 - 1
packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx

@@ -111,7 +111,11 @@ describe('MessageItem arms', () => {
     const ctxView = render(
       <MessageItem node={{ kind: 'context', seq: 3, content: [], source: null } as never} />,
     )
-    expect(ctxView.getByText(/上下文注入/)).toBeTruthy()
+    const contextToggle = ctxView.getByRole('button', { name: '上下文注入' })
+    expect(contextToggle.getAttribute('aria-expanded')).toBe('false')
+    expect(contextToggle.querySelector('svg')).not.toBeNull()
+    fireEvent.click(contextToggle)
+    expect(contextToggle.getAttribute('aria-expanded')).toBe('true')
     const unknownView = render(
       <MessageItem node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
     )

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

@@ -318,6 +318,7 @@ describe('ChatView', () => {
     const view = render(<h.ChatView {...h.props} />)
     expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
     expect(view.getByText('cmd-r1')).toBeTruthy()
+    expect(view.getByRole('status').textContent).toBe('Deep diving...')
   })
 
   it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 0ef3c20f848b3d331c007911d0837f11cd72c024
-README.zh.md: af94551bfb9e12dbadcef6a96a54f9bf7ea71299
+README.md: ba315a02563596a680bc1849c07b8dec3cfdab21
+README.zh.md: c0ecfb42765a3a539fb2aa470133f86b36ca2f57

+ 1 - 1
packages/client/ui-primitives/README.md

@@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
 
 ## Markdown rendering
 
-`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
+`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. `JsonBlock` renders a bounded JSON disclosure with design-system chevrons, `aria-expanded`, and an optional semantic collapsed-state icon. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
 
 ## Terminal output
 

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 2 - 1
packages/client/ui-primitives/README.zh.md


+ 12 - 0
packages/client/ui-primitives/src/icons/index.tsx

@@ -685,6 +685,18 @@ export const IconChecklistOutline14 = ({ size = 14, className }: IconProps) => (
   </svg>
 )
 
+/** Text document glyph for context-disclosure rows. */
+export const IconTextOutline14 = ({ size = 14, className }: IconProps) => (
+  <svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path
+      fillRule="evenodd"
+      clipRule="evenodd"
+      d="M4.083 1.167h4.55c.31 0 .606.123.825.342l2.7 2.7c.219.219.342.516.342.825v6.05c0 .966-.784 1.75-1.75 1.75H4.083a1.75 1.75 0 0 1-1.75-1.75V2.917c0-.967.784-1.75 1.75-1.75Zm4.084 1.166H4.083a.583.583 0 0 0-.583.584v8.166c0 .323.261.584.583.584h6.667a.583.583 0 0 0 .583-.584V5.25H9.917a1.75 1.75 0 0 1-1.75-1.75V2.333Zm1.166.825 1.175 1.175h-.591a.583.583 0 0 1-.584-.583v-.592ZM5.25 7h4.667v1.167H5.25V7Zm0 2.333h4.667V10.5H5.25V9.333Z"
+      fill="currentColor"
+    />
+  </svg>
+)
+
 /** ic_ds_List_Pen_outline_16 */
 export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => (
   <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">

+ 1 - 0
packages/client/ui-primitives/src/index.ts

@@ -24,6 +24,7 @@ export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
 export type { TerminalBlockProps } from './TerminalBlock.tsx'
 export { CodeBlock } from './markdown/CodeBlock.tsx'
 export { JsonBlock } from './markdown/JsonBlock.tsx'
+export type { JsonBlockProps } from './markdown/JsonBlock.tsx'
 export { MarkdownText } from './markdown/MarkdownText.tsx'
 export { MessageText } from './markdown/MessageText.tsx'
 export { extractMarkdownPlainText } from './markdown/plain-text.ts'

+ 39 - 7
packages/client/ui-primitives/src/markdown/JsonBlock.module.css

@@ -3,22 +3,54 @@
 }
 
 .toggle {
-  font-size: 12px;
-  line-height: 18px;
+  display: inline-flex;
+  align-items: center;
+  min-height: 24px;
+  font-size: 14px;
+  line-height: 24px;
   color: var(--dsw-alias-label-secondary);
-  padding: 2px 6px;
+  padding: 0;
   border: none;
   background: transparent;
   cursor: pointer;
-  border-radius: 6px;
 }
 
-.toggle:hover {
-  background: var(--dsw-alias-interactive-bg-hover);
+.leading {
+  position: relative;
+  flex: none;
+  width: 16px;
+  height: 16px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 6px;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.iconIdle {
+  display: inline-flex;
+  opacity: 1;
+  transition: opacity 100ms ease;
+}
+
+.chevronHover {
+  position: absolute;
+  inset: 0;
+  margin: auto;
+  opacity: 0;
+  transition: opacity 100ms ease;
+}
+
+.toggle:hover .iconIdle {
+  opacity: 0;
+}
+
+.toggle:hover .chevronHover {
+  opacity: 1;
 }
 
 .body {
-  margin: 4px 0 0;
+  margin: 4px 0 0 22px;
   padding: 8px;
   max-height: 200px;
   overflow: auto;

+ 35 - 6
packages/client/ui-primitives/src/markdown/JsonBlock.tsx

@@ -1,15 +1,28 @@
-// JsonBlock: collapsible JSON block (conversation side; independent from the RPC panel's PayloadJson to avoid cross-panel coupling).
+// JsonBlock: accessible JSON disclosure row (conversation side; independent
+// from the RPC panel's PayloadJson to avoid cross-panel coupling).
 
-import { useMemo, useState } from 'react'
+import { useMemo, useState, type ReactNode } from 'react'
+import { IconChevronDownOutline14, IconChevronRightOutline14 } from '../icons/index.tsx'
 import css from './JsonBlock.module.css'
 
 const MAX_CHARS = 20_000
 
-export function JsonBlock({ label, payload, defaultOpen = false }: {
+/** Props for the compact JSON disclosure used in conversation content. */
+export interface JsonBlockProps {
   label: string
   payload: unknown
   defaultOpen?: boolean
-}) {
+  /** Semantic glyph shown while collapsed; hover previews the disclosure chevron. */
+  collapsedIcon?: ReactNode
+}
+
+/** Render a bounded, pretty-printed JSON disclosure. */
+export function JsonBlock({
+  label,
+  payload,
+  defaultOpen = false,
+  collapsedIcon,
+}: JsonBlockProps) {
   const [open, setOpen] = useState(defaultOpen)
   const body = useMemo(() => {
     if (!open) return ''
@@ -23,10 +36,26 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
     }
     return s.length > MAX_CHARS ? `${s.slice(0, MAX_CHARS)}\n… 已截断,共 ${s.length} 字符` : s
   }, [open, payload])
+  const leading = open
+    ? <IconChevronDownOutline14 />
+    : collapsedIcon === undefined
+      ? <IconChevronRightOutline14 />
+      : (
+        <>
+          <span className={css.iconIdle}>{collapsedIcon}</span>
+          <IconChevronRightOutline14 className={css.chevronHover} />
+        </>
+      )
   return (
     <div className={css.root}>
-      <button type="button" className={css.toggle} onClick={() => { setOpen(v => !v) }}>
-        {open ? '▾' : '▸'} {label}
+      <button
+        type="button"
+        className={css.toggle}
+        aria-expanded={open}
+        onClick={() => { setOpen(v => !v) }}
+      >
+        <span className={css.leading} aria-hidden="true">{leading}</span>
+        <span>{label}</span>
       </button>
       {open && <pre className={css.body}>{body}</pre>}
     </div>

+ 2 - 2
packages/client/ui-primitives/tests/icons.spec.tsx

@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
 const iconNames = Object.keys(icons)
 
 describe('ic_ds_ icon set', () => {
-  it('exports the full P-I set (45 deepsuite + 14 figma extracts + the hand-authored sparkle)', () => {
-    expect(iconNames.length).toBe(60)
+  it('exports the full P-I set (45 deepsuite + 14 figma extracts + 2 hand-authored glyphs)', () => {
+    expect(iconNames.length).toBe(61)
   })
 
   it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {

+ 9 - 4
packages/client/ui-primitives/tests/markdown.spec.tsx

@@ -124,12 +124,17 @@ describe('MarkdownText', () => {
 })
 
 describe('JsonBlock', () => {
-  it('collapsed by default; toggle reveals pretty-printed payload', () => {
-    render(<JsonBlock label="args" payload={{ a: 1 }} />)
+  it('collapsed by default; accessible toggle reveals pretty-printed payload', () => {
+    render(<JsonBlock label="args" payload={{ a: 1 }} collapsedIcon={<span data-testid="json-icon">T</span>} />)
+    const toggle = screen.getByRole('button', { name: 'args' })
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    expect(screen.getByTestId('json-icon')).toBeDefined()
     expect(screen.queryByText(/"a": 1/)).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: /args/ }))
+    fireEvent.click(toggle)
+    expect(toggle.getAttribute('aria-expanded')).toBe('true')
     expect(screen.getByText(/"a": 1/)).toBeDefined()
-    fireEvent.click(screen.getByRole('button', { name: /args/ }))
+    fireEvent.click(toggle)
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
     expect(screen.queryByText(/"a": 1/)).toBeNull()
   })
 

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä