@morlay/ui-conversation-message-actions 0.0.17 → 0.0.19-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +29 -98
  2. package/dist/client.cjs +1380 -0
  3. package/dist/client.d.cts +6 -0
  4. package/dist/client.d.mts +6 -0
  5. package/dist/index.d.cts +98 -0
  6. package/dist/index.d.mts +3 -10
  7. package/dist/index.mjs +460 -2
  8. package/dist/invariant.d.cts +7 -0
  9. package/dist/{plan-BSa7k9HO.d.mts → plan-CT_6Y1fD.d.cts} +11 -9
  10. package/dist/plan-Cm9hCord.d.mts +101 -0
  11. package/dist/plan.d.cts +2 -0
  12. package/dist/plan.d.mts +2 -2
  13. package/dist/plan.mjs +145 -11
  14. package/dist/shared-LK3nImWW.mjs +55 -0
  15. package/dist/testing.d.cts +3242 -0
  16. package/dist/testing.d.mts +25 -19
  17. package/dist/testing.mjs +2140 -3939
  18. package/package.json +35 -32
  19. package/src/client/chat-node/MessageIconActions.styles.ts +76 -0
  20. package/src/client/chat-node/MessageIconActions.tsx +30 -11
  21. package/src/client/chat-node/MessageItem.styles.ts +247 -0
  22. package/src/client/chat-node/MessageItem.tsx +32 -39
  23. package/src/client/chat-node/message-chrome.ts +0 -2
  24. package/src/client/chat-node/register.ts +3 -4
  25. package/src/client/chat-node/use-calendar-day.ts +0 -4
  26. package/src/client/composer-stats/StatsPills.styles.ts +57 -0
  27. package/src/client/composer-stats/StatsPills.tsx +384 -0
  28. package/src/client/composer-stats/register.ts +17 -0
  29. package/src/client/composer-stats/stat-dialog.styles.ts +70 -0
  30. package/src/client/composer-stats/stat-dialog.ts +52 -0
  31. package/src/client/composer-stats/token-format.ts +85 -0
  32. package/src/client/composer-stats/turn-reading.ts +35 -0
  33. package/src/client/controller.ts +32 -114
  34. package/src/client/index.ts +8 -24
  35. package/src/index.ts +102 -111
  36. package/src/plan.ts +189 -24
  37. package/src/shared.ts +1 -1
  38. package/src/testing.ts +1 -5
  39. package/src/types.ts +1 -1
  40. package/dist/client.js +0 -806
  41. package/dist/src-B4HwXsob.mjs +0 -499
  42. package/src/client/chat-node/MessageIconActions.module.css +0 -86
  43. package/src/client/chat-node/MessageItem.module.css +0 -290
  44. package/src/client/css-modules.d.ts +0 -4
  45. package/src/client/import-action.tsx +0 -74
@@ -1,18 +1,24 @@
1
1
  // MessageItem: the user / admitted-steering chat node renderer.
2
- // 新版 `dsh-client-ui-chat` 已内置全部 chat-node 渲染器;本项目仅**替换**
2
+ // 官方 `@deepseek-ai/dsh-client-ui-chat` 内置全部 chat-node 渲染器;本项目仅**替换**
3
3
  // `user`/`steering` 两个 key(keyed slot reuse 即替换),在消息动作行上提供
4
- // edit / retry(新版无此能力)。其余 key 由新版内置渲染器处理。
4
+ // edit / retry(上游无此能力)。其余 key 由官方内置渲染器处理。
5
5
 
6
- import { memo, useState } from "react";
6
+ import {
7
+ markdownLabels,
8
+ ReferenceMarkdown,
9
+ styling,
10
+ type ReferenceActions,
11
+ } from "@morlay/dsh-client-ui-primitives/client";
12
+ import { memo, useMemo, useState } from "react";
7
13
  import type { ReactNode } from "react";
8
14
  import type { InjectFace } from "@deepseek-ai/dsh-client-ui-slots";
9
15
  import { Button, Modal } from "@deepseek-ai/dsh-client-ui-primitives";
10
16
  import type { UserMessageNode } from "@deepseek-ai/dsh-client-ui-chat/client";
11
17
  import { JsonBlock } from "@deepseek-ai/dsh-client-ui-primitives";
12
18
  import type { ChatNodeViewProps, ChatViewSlotProps } from "@deepseek-ai/dsh-client-ui-chat/client";
13
- import type { RenderMessageImages } from "@deepseek-ai/dsh-client-ui-conversation/client";
19
+ import type { RenderMessageImages } from "@morlay/dsh-client-ui-conversation/client";
14
20
  import { MessageIconActions } from "./MessageIconActions.tsx";
15
- import css from "./MessageItem.module.css";
21
+ import { styles } from "./MessageItem.styles.ts";
16
22
  import type { EditableMessageBlock } from "../../shared.ts";
17
23
  import type { SessionEditorFace } from "../controller.ts";
18
24
 
@@ -33,56 +39,37 @@ function contentParts(content: readonly unknown[]): {
33
39
  images.push({ attachment: (b as UserImage).attachment });
34
40
  } else rest.push(block);
35
41
  }
36
- return { text: texts.join(""), images, rest };
37
- }
38
-
39
- function projectUserText(text: string): ReactNode {
40
- const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g;
41
- const parts: ReactNode[] = [];
42
- let cursor = 0;
43
- let m: RegExpExecArray | null;
44
- while ((m = re.exec(text)) !== null) {
45
- const tokenStart = m.index + (m[1]?.length ?? 0);
46
- const label = m[2] ?? "";
47
- if (tokenStart > cursor) parts.push(<span key={cursor}>{text.slice(cursor, tokenStart)}</span>);
48
- parts.push(
49
- <span
50
- key={tokenStart}
51
- className={css.refChip}
52
- data-ref-chip={label.startsWith("@") ? "subagent" : "skill"}
53
- >
54
- {label}
55
- </span>,
56
- );
57
- cursor = tokenStart + label.length;
58
- }
59
- if (parts.length === 0) return <span>{text}</span>;
60
- if (cursor < text.length) parts.push(<span key={cursor}>{text.slice(cursor)}</span>);
61
- return <>{parts}</>;
42
+ // 多块消息按空行拼回 raw markdown(新式提交就是单块原文)。
43
+ return { text: texts.join("\n\n"), images, rest };
62
44
  }
63
45
 
64
46
  function UserStyleBubble({
65
47
  content,
66
48
  renderMessageImages,
67
49
  actions,
50
+ references,
68
51
  t,
69
52
  }: {
70
53
  content: readonly unknown[];
71
54
  renderMessageImages: RenderMessageImages;
72
55
 
73
56
  actions?: (text: string) => ReactNode;
57
+ // 引用 chip 的点击目标(owner props;缺省时 chip 照常渲染但点击无效果)。
58
+ references?: ReferenceActions | undefined;
74
59
  t: ChatViewSlotProps["t"];
75
60
  }): ReactNode {
76
61
  const { text, images, rest } = contentParts(content);
77
62
  const truncated = (total: number): string => t("json.truncated", { total });
63
+ // labels 按 locale revision 稳定(官方 MarkdownText 在它上面 memo 渲染缓存)。
64
+ const labels = useMemo(() => markdownLabels(t), [t]);
78
65
  const showBubble = text !== "" || rest.length > 0;
79
66
  return (
80
- <div className={css.userRow} data-time-hover-root>
81
- <div className={css.userStack}>
67
+ <div {...styling.props(styles.userRow)} data-time-hover-root>
68
+ <div {...styling.props(styles.userStack)}>
82
69
  {renderMessageImages({ images, align: "end" })}
83
70
  {showBubble && (
84
- <div className={css.bubble}>
85
- {projectUserText(text)}
71
+ <div {...styling.props(styles.bubble)}>
72
+ <ReferenceMarkdown text={text} labels={labels} actions={references} />
86
73
  {rest.map((block, i) => (
87
74
  <JsonBlock
88
75
  key={i}
@@ -103,9 +90,15 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
103
90
  node,
104
91
  renderMessageImages,
105
92
  t,
93
+ openFile,
94
+ openSkill,
106
95
  recall,
107
96
  retry,
108
- }: ChatNodeViewProps<"user" | "steering"> & InjectFace<SessionEditorFace>) {
97
+ }: ChatNodeViewProps<"user" | "steering"> & {
98
+ // keyed 渲染器由 owner props 展开调用:引用 chip 的点击目标从这里来。
99
+ openFile: (path: string) => void;
100
+ openSkill: (name: string) => void;
101
+ } & InjectFace<SessionEditorFace>) {
109
102
  const data = node.data;
110
103
  const [confirmingRecall, setConfirmingRecall] = useState<EditableMessageBlock | null>(null);
111
104
  const [confirmingRetry, setConfirmingRetry] = useState(false);
@@ -153,7 +146,7 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
153
146
  closeLabel="关闭"
154
147
  description="将撤回该消息及其之后的对话内容到输入框,请修改后重新发送。"
155
148
  footer={
156
- <div className={css.confirmActions}>
149
+ <div {...styling.props(styles.confirmActions)}>
157
150
  <Button variant="outline" onClick={() => setConfirmingRecall(null)}>
158
151
  取消
159
152
  </Button>
@@ -179,7 +172,7 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
179
172
  closeLabel="关闭"
180
173
  description={`将重新生成第 ${turn} 轮的回复,并抛弃该回合之后的内容。`}
181
174
  footer={
182
- <div className={css.confirmActions}>
175
+ <div {...styling.props(styles.confirmActions)}>
183
176
  <Button variant="outline" onClick={() => setConfirmingRetry(false)}>
184
177
  取消
185
178
  </Button>
@@ -199,13 +192,13 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
199
192
  <UserStyleBubble
200
193
  content={data.content}
201
194
  renderMessageImages={renderMessageImages}
195
+ references={{ openFile, openSkill }}
202
196
  t={t}
203
197
  actions={(text) => (
204
198
  <MessageIconActions
205
199
  text={text}
206
200
  time={data.time}
207
201
  clock="start"
208
- className={css.actions}
209
202
  t={t}
210
203
  onEdit={onEdit}
211
204
  onRetry={onRetry}
@@ -1,5 +1,3 @@
1
- // Shared time-label helpers for user/assistant IconActions rows.
2
-
3
1
  import type { Translate } from "@deepseek-ai/dsh-client-ui-slots";
4
2
 
5
3
  export type ClockTranslate = Translate<"clock.md" | "clock.ymd">;
@@ -1,7 +1,7 @@
1
1
  import type { Context } from "@deepseek-ai/cordis";
2
2
  import type { SessionId } from "@deepseek-ai/dsh-session";
3
- import "@deepseek-ai/dsh-client-ui-conversation/client";
4
- import "@deepseek-ai/dsh-client-ui-chat/client";
3
+ import "@morlay/dsh-client-ui-conversation/client";
4
+ import type {} from "@deepseek-ai/dsh-client-ui-chat/client";
5
5
  import type {} from "@deepseek-ai/dsh-client-ui-renderer/client";
6
6
  import { UserMessageNodeView } from "./MessageItem.tsx";
7
7
  import type { SessionEditorController } from "../controller.ts";
@@ -21,8 +21,7 @@ export function registerChatNodeRenderers(
21
21
  name: "conversation.chat.node",
22
22
  key,
23
23
  locale: NS,
24
- // keyed slot 同 key 同 priority 会抛错;priority -1 让本项目
25
- // 渲染器以最低优先级渲染,shadow 上游默认(priority 0)注册。
24
+
26
25
  priority: -1,
27
26
  inject: injectFace,
28
27
  } as never,
@@ -1,7 +1,3 @@
1
- // Component-local calendar-day tick: memoized message rows keep stable props
2
- // across midnight, so the IconActions clock needs a local day seat that
3
- // re-fires at the next local midnight without reaching for framework hooks.
4
-
5
1
  import { useEffect, useState } from "react";
6
2
  import { msUntilNextLocalMidnight, startOfLocalDay } from "./message-chrome.ts";
7
3
 
@@ -0,0 +1,57 @@
1
+ import type { CSSProps } from "@morlay/dsh-client-ui-primitives/client";
2
+
3
+ export const styles = {
4
+ // 上游 `StatsPills.module.css` 的 `.root` 原样:统计行在 composer dock 里是 shrink-wrap 的
5
+ // 一个条目,与同为 dock 子项的上下文占用按钮并排——行内间距由 dock 的 `gap: 12px` 统一,
6
+ // 统计自己不再撑满整行(撑满会把占用按钮挤到行尾,两块之间的间距就跟行内间距对不上)。
7
+ root: {
8
+ display: "flex",
9
+ justifyContent: "center",
10
+ gap: "12px",
11
+ minWidth: "0",
12
+ maxWidth: "100%",
13
+ boxSizing: "border-box",
14
+ fontSize: "var(--dsh-content-font-size-secondary, 13px)",
15
+ lineHeight: "calc(20px + var(--dsh-content-font-delta-secondary, 0px))",
16
+ },
17
+ anchor: {
18
+ display: "inline-flex",
19
+ minWidth: "0",
20
+ },
21
+ pill: {
22
+ cursor: "pointer",
23
+
24
+ "&:hover, &[aria-expanded='true']": {
25
+ background: "var(--dsw-alias-interactive-bg-hover)",
26
+ color: "var(--dsw-alias-label-secondary)",
27
+ },
28
+ display: "inline-flex",
29
+ alignItems: "center",
30
+ gap: "6px",
31
+ boxSizing: "border-box",
32
+ maxWidth: "100%",
33
+ padding: "1px 8px",
34
+ border: "none",
35
+ borderRadius: "24px",
36
+ background: "transparent",
37
+ color: "var(--dsw-alias-label-tertiary)",
38
+ font: "inherit",
39
+ fontVariantNumeric: "tabular-nums",
40
+ lineHeight: "inherit",
41
+ whiteSpace: "nowrap",
42
+ "& svg": {
43
+ width: "14px",
44
+ height: "14px",
45
+ flex: "none",
46
+ },
47
+ },
48
+ label: {
49
+ minWidth: "0",
50
+ overflow: "hidden",
51
+ textOverflow: "ellipsis",
52
+ },
53
+ sep: {
54
+ color: "var(--dsw-alias-separator-primary)",
55
+ margin: "0 6px",
56
+ },
57
+ } satisfies Record<string, CSSProps>;
@@ -0,0 +1,384 @@
1
+ // Session stats under the composer, split into two icon pills: a gauge pill
2
+ // (turn/step counts + output speed) opening the time-and-speed dialog, and a
3
+ // database pill (total tokens + cache hit) opening the token-usage dialog.
4
+ // Settled-node identity prevents stream-delta updates from rerendering the row.
5
+ // Carried from upstream ui-chat (composer.dock id 'stats', priority -1 shadows it)
6
+ // so the fixed token-format (out-of-range cacheRead no longer hangs) stays wired;
7
+ // data-composer-stats pairs with ui-conversation's InputBar `:has()` bottom clearance.
8
+
9
+ import { memo, useMemo, useState } from "react";
10
+ import { createPortal } from "react-dom";
11
+ import { IconDatabaseOutline16, IconGaugeOutline16 } from "@deepseek-ai/dsh-client-ui-primitives";
12
+ import type { UseProjection } from "@deepseek-ai/dsh-api-session-controller/client";
13
+ import type { SnapshotSelectorHook } from "@deepseek-ai/dsh-client-ui-slots";
14
+ // Type-only: merges the sessionStats key into SessionProjectionMap for useProjection.
15
+ import type {} from "@deepseek-ai/dsh-session-stats/client";
16
+ import type { TokenUsageProjection } from "@deepseek-ai/dsh-token-meter/client";
17
+ import type { ChatSnapshot, ChatViewSlotProps } from "@deepseek-ai/dsh-client-ui-chat/client";
18
+ import { formatTokensPerSecond } from "../chat-node/message-chrome.ts";
19
+ import { assistantStepReading } from "./turn-reading.ts";
20
+ import { formatCacheHitPercent, formatExactTokens, formatTokens } from "./token-format.ts";
21
+ import { MEASURE_STYLE, useStatDialog } from "./stat-dialog.ts";
22
+ import { styling } from "@morlay/dsh-client-ui-primitives/client";
23
+ import { styles } from "./StatsPills.styles.ts";
24
+ import { styles as dialogStyles } from "./stat-dialog.styles.ts";
25
+
26
+ interface WindowStats {
27
+ turns: number;
28
+ steps: number;
29
+ /** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
30
+ llmMs: number;
31
+ /** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
32
+ toolMs: number;
33
+ /** Summed first-token latency over `ttftSteps`; 0 when no step records it. */
34
+ ttftMs: number;
35
+ /** Steps carrying a recorded TTFT. */
36
+ ttftSteps: number;
37
+ /** Summed decode wall time over steps that also report output tokens. */
38
+ decodeMs: number;
39
+ /** Summed output tokens over the same decode-timed steps. */
40
+ decodeTokens: number;
41
+ }
42
+
43
+ /**
44
+ * Fold assistant and tool-result nodes into window-scoped display totals —
45
+ * the FALLBACK for assemblies without the `sessionStats` projection.
46
+ *
47
+ * Every displayed figure rides that durable whole-log projection (and token
48
+ * accounting rides `tokenUsage`) because the window is paged and compaction
49
+ * rewrites it; this fold answers "what is on screen" only when no projection
50
+ * value is served. Its field names deliberately mirror the projection's so
51
+ * the two swap wholesale.
52
+ * @param nodes - snapshot nodes.
53
+ * @returns fallback counts and summed wall times.
54
+ */
55
+ export function deriveStats(nodes: ChatSnapshot["legacy"]["nodes"]): WindowStats {
56
+ const turns = new Set<number>();
57
+ let steps = 0;
58
+ let llmMs = 0;
59
+ let toolMs = 0;
60
+ let ttftMs = 0;
61
+ let ttftSteps = 0;
62
+ let decodeMs = 0;
63
+ let decodeTokens = 0;
64
+ for (const node of nodes) {
65
+ if (node.kind === "tool-result") {
66
+ if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime);
67
+ continue;
68
+ }
69
+ if (node.kind !== "assistant") continue;
70
+ turns.add(node.turn);
71
+ steps += 1;
72
+ if (node.timing !== undefined && node.timing.stepStartTime !== null) {
73
+ llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime);
74
+ }
75
+ const reading = assistantStepReading(node);
76
+ if (reading.ttftMs !== null) {
77
+ ttftMs += reading.ttftMs;
78
+ ttftSteps += 1;
79
+ }
80
+ if (reading.decodeMs !== null && reading.outputTokens !== null) {
81
+ decodeMs += reading.decodeMs;
82
+ decodeTokens += reading.outputTokens;
83
+ }
84
+ }
85
+ return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens };
86
+ }
87
+
88
+ /**
89
+ * Compact duration: 45.2s under a minute, 2m42s from there on.
90
+ * @param ms - duration in milliseconds.
91
+ * @returns display string.
92
+ */
93
+ export function formatDuration(ms: number, t: ChatViewSlotProps["t"]): string {
94
+ const s = ms / 1_000;
95
+ if (s < 60) return t("duration.compactSeconds", { seconds: Math.round(s * 10) / 10 });
96
+ const whole = Math.round(s);
97
+ return t("duration.compactMinutes", {
98
+ minutes: Math.floor(whole / 60),
99
+ seconds: whole % 60,
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Display-ready cache-hit share of prompt-side input over the whole durable log.
105
+ * @param usage - the session's token-usage projection value.
106
+ * @returns integer text when integer rounding stays below 100, otherwise the
107
+ * minimum decimal precision that still rounds below 100; a full hit returns
108
+ * 100, and no billed input returns null.
109
+ */
110
+ export function cacheHitPercent(usage: TokenUsageProjection): string | null {
111
+ const denominator = billedInputTokens(usage);
112
+ return formatCacheHitPercent(usage.cacheReadTokens, denominator);
113
+ }
114
+
115
+ /**
116
+ * Sum the three disjoint prompt-side billing buckets.
117
+ * @param usage - the session's token-usage projection value.
118
+ * @returns billed input tokens.
119
+ */
120
+ export function billedInputTokens(usage: TokenUsageProjection): number {
121
+ return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
122
+ }
123
+
124
+ /** Props: the conversation-snapshot selector plus the projection read seat. */
125
+ export interface StatsPillsProps {
126
+ useChat: SnapshotSelectorHook<ChatSnapshot>;
127
+ useProjection: UseProjection;
128
+ /** The owning dock's locale seat. */
129
+ t: ChatViewSlotProps["t"];
130
+ }
131
+
132
+ function exactCount(value: number, t: ChatViewSlotProps["t"]): string {
133
+ return t("message.turnUsage.count", { count: formatExactTokens(value, t) });
134
+ }
135
+
136
+ /** External open state one pill's dialog reads and writes (the row's exclusive slot). */
137
+ type PillDialog = Pick<ReturnType<typeof useStatDialog>, "open" | "setOpen">;
138
+
139
+ function TimePill({
140
+ stats,
141
+ t,
142
+ dialog,
143
+ }: {
144
+ stats: WindowStats;
145
+ t: ChatViewSlotProps["t"];
146
+ dialog: PillDialog;
147
+ }) {
148
+ const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog);
149
+ const counts = t("stats.counts", { turns: stats.turns, steps: stats.steps });
150
+ const tps =
151
+ stats.decodeMs > 0
152
+ ? t("message.tokensPerSecond", {
153
+ tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
154
+ })
155
+ : null;
156
+ const label = (
157
+ <span {...styling.props(styles.label)}>
158
+ {counts}
159
+ {tps !== null && (
160
+ <>
161
+ <span {...styling.props(styles.sep)} aria-hidden>
162
+ ·
163
+ </span>
164
+ {tps}
165
+ </>
166
+ )}
167
+ </span>
168
+ );
169
+ // A window without one timed figure has no dialog rows to show, so the pill
170
+ // stays a plain reading instead of a button opening an empty dialog.
171
+ if (stats.llmMs <= 0 && stats.toolMs <= 0 && stats.ttftSteps <= 0 && stats.decodeMs <= 0) {
172
+ return (
173
+ <span {...styling.props(styles.anchor)}>
174
+ <span {...styling.props(styles.pill)}>
175
+ <IconGaugeOutline16 />
176
+ {label}
177
+ </span>
178
+ </span>
179
+ );
180
+ }
181
+ return (
182
+ <span ref={rootRef} {...styling.props(styles.anchor)}>
183
+ <button
184
+ type="button"
185
+ {...styling.props(styles.pill)}
186
+ aria-haspopup="dialog"
187
+ aria-expanded={open}
188
+ aria-label={tps === null ? counts : `${counts} · ${tps}`}
189
+ onClick={() => {
190
+ setOpen(!open);
191
+ }}
192
+ >
193
+ <IconGaugeOutline16 />
194
+ {label}
195
+ </button>
196
+ {open &&
197
+ createPortal(
198
+ <div
199
+ ref={panelRef}
200
+ {...styling.props(dialogStyles.panel)}
201
+ role="dialog"
202
+ aria-label={t("stats.dialog.title")}
203
+ style={pos ?? MEASURE_STYLE}
204
+ >
205
+ <div {...styling.props(dialogStyles.title)}>
206
+ <span {...styling.props(dialogStyles.titleLabel)}>
207
+ <IconGaugeOutline16 />
208
+ {t("stats.dialog.title")}
209
+ </span>
210
+ </div>
211
+ <div {...styling.props(dialogStyles.titleRule)} aria-hidden />
212
+ <dl {...styling.props(dialogStyles.details)} data-session-stats-details>
213
+ {stats.llmMs > 0 && (
214
+ <>
215
+ <dt>{t("stats.dialog.llmTime")}</dt>
216
+ <dd>{formatDuration(stats.llmMs, t)}</dd>
217
+ </>
218
+ )}
219
+ {stats.toolMs > 0 && (
220
+ <>
221
+ <dt>{t("stats.dialog.toolTime")}</dt>
222
+ <dd>{formatDuration(stats.toolMs, t)}</dd>
223
+ </>
224
+ )}
225
+ {stats.ttftSteps > 0 && (
226
+ <>
227
+ <dt>{t("stats.dialog.ttft")}</dt>
228
+ <dd>{formatDuration(stats.ttftMs / stats.ttftSteps, t)}</dd>
229
+ </>
230
+ )}
231
+ {stats.decodeMs > 0 && (
232
+ <>
233
+ <dt>{t("stats.dialog.speed")}</dt>
234
+ <dd>
235
+ {t("message.tokensPerSecond", {
236
+ tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
237
+ })}
238
+ </dd>
239
+ </>
240
+ )}
241
+ </dl>
242
+ </div>,
243
+ document.body,
244
+ )}
245
+ </span>
246
+ );
247
+ }
248
+
249
+ function UsagePill({
250
+ usage,
251
+ t,
252
+ dialog,
253
+ }: {
254
+ usage: TokenUsageProjection;
255
+ t: ChatViewSlotProps["t"];
256
+ dialog: PillDialog;
257
+ }) {
258
+ const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog);
259
+ // Same aggregate as the Turn pill's totalTokens: every prompt-side billing bucket plus output.
260
+ const total = billedInputTokens(usage) + usage.outputTokens;
261
+ const totalText = t("message.turnUsage.count", { count: formatTokens(total, t) });
262
+ const cacheHit = cacheHitPercent(usage);
263
+ const cacheHitText = cacheHit !== null ? t("stats.cacheHit", { percent: cacheHit }) : null;
264
+ return (
265
+ <span ref={rootRef} {...styling.props(styles.anchor)}>
266
+ <button
267
+ type="button"
268
+ {...styling.props(styles.pill)}
269
+ aria-haspopup="dialog"
270
+ aria-expanded={open}
271
+ aria-label={cacheHitText === null ? totalText : `${totalText} · ${cacheHitText}`}
272
+ onClick={() => {
273
+ setOpen(!open);
274
+ }}
275
+ >
276
+ <IconDatabaseOutline16 />
277
+ <span {...styling.props(styles.label)}>
278
+ {totalText}
279
+ {cacheHitText !== null && (
280
+ <>
281
+ <span {...styling.props(styles.sep)} aria-hidden>
282
+ ·
283
+ </span>
284
+ {cacheHitText}
285
+ </>
286
+ )}
287
+ </span>
288
+ </button>
289
+ {open &&
290
+ createPortal(
291
+ <div
292
+ ref={panelRef}
293
+ {...styling.props(dialogStyles.panel)}
294
+ role="dialog"
295
+ aria-label={t("stats.dialog.usageTitle")}
296
+ style={pos ?? MEASURE_STYLE}
297
+ >
298
+ <div {...styling.props(dialogStyles.title)}>
299
+ <span {...styling.props(dialogStyles.titleLabel)}>
300
+ <IconDatabaseOutline16 />
301
+ {t("stats.dialog.usageTitle")}
302
+ </span>
303
+ <span {...styling.props(dialogStyles.titleValue)}>{exactCount(total, t)}</span>
304
+ </div>
305
+ <div {...styling.props(dialogStyles.titleRule)} aria-hidden />
306
+ {/* jscpd:ignore-start -- the session-total bucket rows deliberately mirror
307
+ TurnUsagePanel's per-turn dl: same skin, different data contract (the
308
+ buckets are always present here; per-turn fields are optional). A
309
+ session that never wrote cache drops the row, as the per-turn panel
310
+ drops its absent fields. */}
311
+ <dl {...styling.props(dialogStyles.details)} data-session-stats-usage>
312
+ {cacheHit !== null && (
313
+ <>
314
+ <dt>{t("message.turnUsage.cacheHit")}</dt>
315
+ <dd>{`${cacheHit}%`}</dd>
316
+ </>
317
+ )}
318
+ <dt>{t("message.turnUsage.input")}</dt>
319
+ <dd>{exactCount(usage.uncachedInputTokens, t)}</dd>
320
+ <dt>{t("message.turnUsage.cacheRead")}</dt>
321
+ <dd>{exactCount(usage.cacheReadTokens, t)}</dd>
322
+ {usage.cacheWriteTokens !== 0 && (
323
+ <>
324
+ <dt>{t("message.turnUsage.cacheWrite")}</dt>
325
+ <dd>{exactCount(usage.cacheWriteTokens, t)}</dd>
326
+ </>
327
+ )}
328
+ <dt>{t("message.turnUsage.output")}</dt>
329
+ <dd>{exactCount(usage.outputTokens, t)}</dd>
330
+ </dl>
331
+ {/* jscpd:ignore-end */}
332
+ </div>,
333
+ document.body,
334
+ )}
335
+ </span>
336
+ );
337
+ }
338
+
339
+ export const StatsPills = memo(function StatsPills({ useChat, useProjection, t }: StatsPillsProps) {
340
+ const settledNodes = useChat((s) => s.legacy.nodes);
341
+ const usage = useProjection("tokenUsage");
342
+ // One exclusive slot for both dialogs: opening either pill closes the other.
343
+ const [openPill, setOpenPill] = useState<"time" | "usage" | null>(null);
344
+ // Every figure rides the durable sessionStats projection, so paging and
345
+ // compaction cannot change any of them; an assembly without the unit falls
346
+ // back to the window-scoped fold wholesale (same field names), paid only
347
+ // while no projection value is served.
348
+ const projected = useProjection("sessionStats");
349
+ const stats = useMemo(() => projected ?? deriveStats(settledNodes), [projected, settledNodes]);
350
+ // Gated on actual token activity: a session whose steps all settled without
351
+ // billing (e.g. every request failed) shows its counts without a usage pill.
352
+ const hasTokens = usage !== undefined && (billedInputTokens(usage) > 0 || usage.outputTokens > 0);
353
+ if (stats.steps === 0 && !hasTokens) return null;
354
+ // data-composer-stats: InputBar's `.root:has([data-composer-stats])` rule
355
+ // tightens the composer's bottom clearance only while this row renders.
356
+ return (
357
+ <div {...styling.props(styles.root)} data-composer-stats>
358
+ {stats.steps > 0 && (
359
+ <TimePill
360
+ stats={stats}
361
+ t={t}
362
+ dialog={{
363
+ open: openPill === "time",
364
+ setOpen: (open) => {
365
+ setOpenPill(open ? "time" : null);
366
+ },
367
+ }}
368
+ />
369
+ )}
370
+ {hasTokens && (
371
+ <UsagePill
372
+ usage={usage}
373
+ t={t}
374
+ dialog={{
375
+ open: openPill === "usage",
376
+ setOpen: (open) => {
377
+ setOpenPill(open ? "usage" : null);
378
+ },
379
+ }}
380
+ />
381
+ )}
382
+ </div>
383
+ );
384
+ });
@@ -0,0 +1,17 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import type {} from "@deepseek-ai/dsh-client-ui-chat/client";
3
+ import type {} from "@morlay/dsh-client-ui-conversation/client";
4
+ import { StatsPills } from "./StatsPills.tsx";
5
+
6
+ // 官方 ui-chat 的字典命名空间:文案沿用上游(`stats.*` / `message.turnUsage.*`)。
7
+ const NS = "chat";
8
+
9
+ // 覆盖注册:官方 ui-chat 在 `conversation.composer.dock` 注册 id `stats`(优先级 0),
10
+ // 我们以 priority -1 同 id 重新注册——最低优先级渲染,shadow 官方那一格。
11
+ // 覆盖是 shadow 不是删除:官方 stats 行仍在注册表里。
12
+ export function registerComposerStats(slots: Context["slots"]): () => void {
13
+ return slots.register(
14
+ { name: "conversation.composer.dock", id: "stats", order: 0, priority: -1, locale: NS },
15
+ StatsPills,
16
+ );
17
+ }