@steerable/agent-shell-web 0.6.57 → 0.6.59

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-web",
3
- "version": "0.6.57",
3
+ "version": "0.6.59",
4
4
  "type": "module",
5
5
  "description": "Steerable framework — product-neutral renderer SPA source for @steerable/agent-shell (React + Vite). Consumed by product web entry points via the `@/` alias (anchored at this package's src/) and the createProductViteConfig factory. Published as a source package (src/), plus the compiled vite.base config factory (vite.base.js + .d.ts) for Node-side config loading.",
6
6
  "license": "Apache-2.0",
@@ -45,8 +45,8 @@
45
45
  "react-router-dom": "^6.30.0",
46
46
  "rehype-raw": "^7.0.0",
47
47
  "remark-gfm": "^4.0.1",
48
- "@steerable/agent-ui": "0.6.57",
49
- "@steerable/agent-protocol": "0.6.57"
48
+ "@steerable/agent-protocol": "0.6.59",
49
+ "@steerable/agent-ui": "0.6.59"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@tailwindcss/vite": "^4.3.0",
@@ -59,7 +59,7 @@
59
59
  "typescript": "^5.8.3",
60
60
  "vite": "^5.4.21",
61
61
  "vitest": "^3.2.4",
62
- "@steerable/pack-sdk": "0.6.57"
62
+ "@steerable/pack-sdk": "0.6.59"
63
63
  },
64
64
  "scripts": {
65
65
  "gen": "echo agent-shell-web",
@@ -61,6 +61,7 @@
61
61
  * │ 今天 │
62
62
  * │ ...(无项目对话,按日期分组) │ ← 无项目排在项目分组之后
63
63
  * ├─────────────────────────────────┤
64
+ * │ DeepSeek 可用 ↻ │ ← DeepSeek / Kimi 时显示供应商账户余额
64
65
  * │ ⚙ 设置 v0.2.2 │ ← /settings;右侧是当前版本,检查更新在设置页
65
66
  * └─────────────────────────────────┘
66
67
  *
@@ -116,6 +117,7 @@ import {
116
117
  SidebarVersionLabel,
117
118
  useAppRelease,
118
119
  } from "@/components/SidebarRelease";
120
+ import { SidebarAccount } from "@/components/SidebarAccount";
119
121
  import { CreateProjectModal } from "@/components/CreateProjectModal";
120
122
  import { t } from "@/i18n";
121
123
 
@@ -881,7 +883,9 @@ export function AgentSidebar({
881
883
  };
882
884
  }, [bridge, handleOpenNewChat]);
883
885
 
884
- // Auto-load next page when the chat scroller approaches the bottom.
886
+ // Auto-load next page when the chat scroller approaches the bottom. Project
887
+ // groups need the complete list: otherwise an older project chat outside the
888
+ // first page makes its project incorrectly render as empty.
885
889
  // Throttled by `isLoadingMoreChats` inside the hook.
886
890
  useEffect(() => {
887
891
  const container = chatScrollRef.current;
@@ -892,7 +896,9 @@ export function AgentSidebar({
892
896
  const nearBottom =
893
897
  container.scrollTop + container.clientHeight >=
894
898
  container.scrollHeight - 60;
895
- if (nearBottom) void loadMoreChats();
899
+ const needsCompleteProjectGroups =
900
+ showProjectsChrome && projects.length > 0;
901
+ if (nearBottom || needsCompleteProjectGroups) void loadMoreChats();
896
902
  };
897
903
  container.addEventListener("scroll", maybeLoadMore, { passive: true });
898
904
  const tickId = window.requestAnimationFrame(maybeLoadMore);
@@ -906,6 +912,8 @@ export function AgentSidebar({
906
912
  loadMoreChats,
907
913
  isRecentsExpanded,
908
914
  normalizedChats.length,
915
+ projects.length,
916
+ showProjectsChrome,
909
917
  ]);
910
918
 
911
919
  const hasElectron = hasHostBridge();
@@ -1243,6 +1251,7 @@ export function AgentSidebar({
1243
1251
 
1244
1252
  {/* ───── Footer: 设置 ───── */}
1245
1253
  <div className="flex-shrink-0 border-t border-agent-border/40 px-2.5 py-1.5">
1254
+ <SidebarAccount />
1246
1255
  {hasGeneralSettingsChrome() && (
1247
1256
  <button
1248
1257
  type="button"
@@ -0,0 +1,161 @@
1
+ /**
2
+ * 侧栏底部的供应商账户余额。只在当前模型是 DeepSeek 或 Kimi,
3
+ * 且本机已经保存了密钥时出现。数字来自供应商余额接口,不是本地估算。
4
+ */
5
+
6
+ import { useCallback, useEffect, useRef, useState } from 'react';
7
+ import { LuGauge, LuLoaderCircle, LuRefreshCw } from 'react-icons/lu';
8
+ import { t } from '@/i18n';
9
+ import { hasHostBridge } from '@/lib/host-bridge';
10
+ import { getLlmAccount, LLM_SETTINGS_CHANGED_EVENT, type LlmAccount } from '@/lib/local-api';
11
+
12
+ const POLL_MS = 60_000;
13
+
14
+ function formatMoney(amount: string, currency: string | null): string {
15
+ const code = (currency ?? '').toUpperCase();
16
+ if (code === 'CNY') return `¥${amount}`;
17
+ if (code === 'USD') return `$${amount}`;
18
+ return code ? `${code} ${amount}` : amount;
19
+ }
20
+
21
+ function numericMoney(amount: string | null): number | null {
22
+ if (amount === null) return null;
23
+ const value = Number(amount);
24
+ return Number.isFinite(value) ? value : null;
25
+ }
26
+
27
+ function breakdown(account: LlmAccount): string {
28
+ if (account.status !== 'ready') return '';
29
+ const currency = account.currency;
30
+ const total = numericMoney(account.total);
31
+ const granted = numericMoney(account.granted);
32
+ const toppedUp = numericMoney(account.toppedUp);
33
+ const hasGrantedBalance = granted !== null && granted > 0;
34
+ const toppedUpAddsInformation =
35
+ toppedUp !== null && total !== null && Math.abs(toppedUp - total) > 0.000001;
36
+ if (!hasGrantedBalance && !toppedUpAddsInformation) return '';
37
+
38
+ const parts: string[] = [];
39
+ if (account.provider === 'deepseek') {
40
+ if (hasGrantedBalance && account.granted) {
41
+ parts.push(t('Granted {amount}', { amount: formatMoney(account.granted, currency) }));
42
+ }
43
+ if (account.toppedUp) {
44
+ parts.push(t('Topped up {amount}', { amount: formatMoney(account.toppedUp, currency) }));
45
+ }
46
+ } else if (account.provider === 'moonshot') {
47
+ if (hasGrantedBalance && account.granted) {
48
+ parts.push(t('Voucher {amount}', { amount: formatMoney(account.granted, currency) }));
49
+ }
50
+ if (account.toppedUp) {
51
+ parts.push(t('Cash {amount}', { amount: formatMoney(account.toppedUp, currency) }));
52
+ }
53
+ }
54
+ return parts.join(' · ');
55
+ }
56
+
57
+ export function SidebarAccount() {
58
+ const [account, setAccount] = useState<LlmAccount | null>(null);
59
+ const [refreshing, setRefreshing] = useState(false);
60
+ const requestId = useRef(0);
61
+
62
+ const load = useCallback(async (refresh: boolean) => {
63
+ if (!hasHostBridge()) return;
64
+ const id = ++requestId.current;
65
+ setRefreshing(true);
66
+ try {
67
+ const next = await getLlmAccount(refresh);
68
+ if (id === requestId.current) setAccount(next);
69
+ } catch {
70
+ // 本机接口暂时不可达时保留上一次成功的余额。
71
+ } finally {
72
+ if (id === requestId.current) setRefreshing(false);
73
+ }
74
+ }, []);
75
+
76
+ useEffect(() => {
77
+ if (!hasHostBridge()) return;
78
+ void load(false);
79
+ const timer = window.setInterval(() => void load(false), POLL_MS);
80
+ const onFocus = () => void load(false);
81
+ const onSettings = () => void load(true);
82
+ window.addEventListener('focus', onFocus);
83
+ window.addEventListener(LLM_SETTINGS_CHANGED_EVENT, onSettings);
84
+ return () => {
85
+ window.clearInterval(timer);
86
+ window.removeEventListener('focus', onFocus);
87
+ window.removeEventListener(LLM_SETTINGS_CHANGED_EVENT, onSettings);
88
+ };
89
+ }, [load]);
90
+
91
+ if (!account || (account.status !== 'ready' && account.status !== 'failed')) return null;
92
+
93
+ const detail = breakdown(account);
94
+ const availableLabel = account.available === true
95
+ ? t('Available')
96
+ : account.available === false
97
+ ? t('Unavailable')
98
+ : null;
99
+
100
+ return (
101
+ <div
102
+ data-testid="sidebar-account"
103
+ className="mb-1.5 px-1 py-1"
104
+ aria-label={t('Account balance')}
105
+ >
106
+ <div className="flex items-center gap-1.5">
107
+ <div className="flex min-w-0 flex-1 items-center gap-1.5 text-[11px] text-agent-foreground">
108
+ <LuGauge className="h-3 w-3 shrink-0 text-agent-muted-foreground" />
109
+ <span className="truncate font-medium">{account.label}</span>
110
+ </div>
111
+ <button
112
+ type="button"
113
+ data-testid="sidebar-account-refresh"
114
+ title={t('Refresh balance')}
115
+ aria-label={t('Refresh balance')}
116
+ disabled={refreshing}
117
+ onClick={() => void load(true)}
118
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground/70 transition-colors hover:bg-agent-foreground/10 hover:text-agent-foreground disabled:opacity-50"
119
+ >
120
+ {refreshing ? (
121
+ <LuLoaderCircle className="h-3 w-3 animate-spin" />
122
+ ) : (
123
+ <LuRefreshCw className="h-3 w-3" />
124
+ )}
125
+ </button>
126
+ </div>
127
+ {account.status === 'ready' && account.total ? (
128
+ <>
129
+ <div className="mt-1.5 flex items-center justify-between gap-2 px-0.5 text-xs">
130
+ <span className="shrink-0 text-agent-foreground">{t('Account balance')}</span>
131
+ <div className="flex min-w-0 items-center justify-end gap-1">
132
+ <span
133
+ data-testid="sidebar-account-total"
134
+ className="truncate font-medium tabular-nums text-agent-foreground"
135
+ >
136
+ {formatMoney(account.total, account.currency)}
137
+ </span>
138
+ {availableLabel ? (
139
+ <span
140
+ className={`shrink-0 text-[10px] ${account.available ? 'text-emerald-600' : 'text-amber-600'}`}
141
+ >
142
+ {availableLabel}
143
+ </span>
144
+ ) : null}
145
+ </div>
146
+ </div>
147
+ {detail ? (
148
+ <p
149
+ className="mt-1 truncate px-0.5 text-[9px] leading-none text-agent-muted-foreground"
150
+ title={detail}
151
+ >
152
+ {detail}
153
+ </p>
154
+ ) : null}
155
+ </>
156
+ ) : (
157
+ <p className="mt-1 text-[10px] text-agent-muted-foreground">{t('Balance unavailable')}</p>
158
+ )}
159
+ </div>
160
+ );
161
+ }
@@ -85,6 +85,18 @@ export function TerminalView({
85
85
  // Cmd+V / Ctrl+Shift+V → 从剪贴板读出来 paste 到 PTY
86
86
  // Cmd/Ctrl+A → 全选
87
87
  const isMac = navigator.platform.toUpperCase().includes('MAC');
88
+ // WKWebView's navigator.clipboard.readText() is empty or prompts; the
89
+ // desktop host reads the OS pasteboard directly.
90
+ const pasteFromClipboard = () => {
91
+ const readText = bridge?.readClipboardText
92
+ ? () => bridge.readClipboardText!()
93
+ : () => navigator.clipboard.readText();
94
+ void readText()
95
+ .then((text) => {
96
+ if (text) term.paste(text);
97
+ })
98
+ .catch(() => {});
99
+ };
88
100
  term.attachCustomKeyEventHandler((event) => {
89
101
  if (event.type !== 'keydown') return true;
90
102
  const mod = isMac ? event.metaKey : event.ctrlKey;
@@ -103,12 +115,8 @@ export function TerminalView({
103
115
  (isMac && mod && key === 'v') ||
104
116
  (!isMac && event.ctrlKey && event.shiftKey && key === 'v')
105
117
  ) {
106
- void navigator.clipboard
107
- .readText()
108
- .then((text) => {
109
- if (text) term.paste(text);
110
- })
111
- .catch(() => {});
118
+ event.preventDefault();
119
+ pasteFromClipboard();
112
120
  return false;
113
121
  }
114
122
  if (mod && key === 'a' && !event.shiftKey) {
@@ -125,12 +133,7 @@ export function TerminalView({
125
133
  void navigator.clipboard.writeText(sel).catch(() => {});
126
134
  term.clearSelection();
127
135
  } else {
128
- void navigator.clipboard
129
- .readText()
130
- .then((text) => {
131
- if (text) term.paste(text);
132
- })
133
- .catch(() => {});
136
+ pasteFromClipboard();
134
137
  }
135
138
  };
136
139
  containerRef.current.addEventListener('contextmenu', onContextMenu);
@@ -85,7 +85,10 @@ export type { ExecPolicy, McpToolItem, SkillItem };
85
85
  * - Compact-mode responsive layout.
86
86
  */
87
87
 
88
- const MIN_HEIGHT_PX = 36;
88
+ // Empty field should read as two lines of space. Two lines of text measure
89
+ // 40px (`pt-2` + two 16px line boxes) and still look like one row, so the
90
+ // floor is one line box taller.
91
+ const MIN_HEIGHT_PX = 56;
89
92
  const MAX_HEIGHT_PX = 220;
90
93
  const MENTION_PATTERN = /(@[^\s@]+)/g;
91
94
  const MAX_MENTION_SUGGESTIONS = 8;
@@ -1571,12 +1574,14 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1571
1574
  };
1572
1575
 
1573
1576
  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
1574
- if (
1575
- hostClipboardAvailable() &&
1577
+ // Cmd/Ctrl+V never reaches a paste event when the app menu owns the
1578
+ // shortcut, and WKWebView leaves that event's clipboard empty. Read the
1579
+ // OS pasteboard instead. Match `code` too: with an IME, `key` may not be "v".
1580
+ const pasteShortcut =
1576
1581
  (event.metaKey || event.ctrlKey) &&
1577
1582
  !event.altKey &&
1578
- event.key.toLowerCase() === 'v'
1579
- ) {
1583
+ (event.key.toLowerCase() === 'v' || event.key === '\u0016' || event.code === 'KeyV');
1584
+ if (hostClipboardAvailable() && pasteShortcut) {
1580
1585
  event.preventDefault();
1581
1586
  requestHostPaste(event.currentTarget);
1582
1587
  return;
@@ -42,10 +42,9 @@ import type { LlmSpeedSnapshot } from './process-status';
42
42
  * - **No per-chat scroll memory across tabs.** Switching chats remounts
43
43
  * this list (parent passes a new `key`), so we don't need the
44
44
  * `chatScrollMemoryRef` machinery.
45
- * - **No sticky user-message group / `--user-msg-h` CSS var.** The cloud
46
- * product uses these to keep the user prompt pinned at the top of the
47
- * viewport while reading long assistant replies; agent UX defers that
48
- * to a later phase.
45
+ * - **Sticky user-message group per turn.** Grouped turns pin user prompt
46
+ * cards at the top with `sticky top-0 z-10 bg-agent-canvas/95 backdrop-blur-xs`
47
+ * until the next turn scrolls into view and naturally pushes it off.
49
48
  * - **No load-more on scroll-up.** Local-backend currently hydrates the
50
49
  * last 200 messages on mount (see `AgentPage.tsx`). Pagination is a
51
50
  * phase-2c follow-up.
@@ -247,41 +246,72 @@ export function MessageList({
247
246
  scrollToBottom('auto');
248
247
  }, [lastMessageId, lastContentLen, lastTimelineSig, suggestedSig, scrollToBottom]);
249
248
 
250
- // Group consecutive user messages into turns (方案 A).
251
- // A group of 2+ user messages renders as a single connected card with
252
- // dividers and "↳ 追加" badges, instead of separate disconnected bubbles.
253
- type MessageItem =
254
- | { type: 'user-group'; messages: ChatMessage[]; key: string }
255
- | { type: 'assistant'; message: ChatMessage; originalIndex: number };
249
+ // Group consecutive messages into conversation turns.
250
+ // Each turn has an optional user group (1+ consecutive user messages)
251
+ // and zero or more subsequent assistant messages.
252
+ // Grouping into turn containers allows the user prompt to be sticky at
253
+ // the top of the viewport (`sticky top-0`) while scrolling through long
254
+ // assistant reasoning/tools/markdown in that turn, until the next turn
255
+ // scrolls into view and naturally pushes it off.
256
+ type UserGroupItem = { messages: ChatMessage[]; key: string };
257
+ type AssistantItem = { message: ChatMessage; originalIndex: number };
258
+ type ConversationTurn = {
259
+ key: string;
260
+ userGroup?: UserGroupItem;
261
+ assistants: AssistantItem[];
262
+ };
256
263
 
257
- const groupedItems = useMemo<MessageItem[]>(() => {
258
- const items: MessageItem[] = [];
259
- let currentUserGroup: ChatMessage[] = [];
264
+ const conversationTurns = useMemo<ConversationTurn[]>(() => {
265
+ const turns: ConversationTurn[] = [];
266
+ let currentTurn: ConversationTurn | null = null;
267
+ let pendingUserGroup: ChatMessage[] = [];
260
268
 
261
269
  for (let i = 0; i < visibleMessages.length; i++) {
262
270
  const msg = visibleMessages[i];
263
271
  if (msg.role === 'user') {
264
- currentUserGroup.push(msg);
272
+ if (currentTurn && currentTurn.assistants.length > 0) {
273
+ // Previous turn already has assistant replies; finalize it
274
+ turns.push(currentTurn);
275
+ currentTurn = null;
276
+ }
277
+ pendingUserGroup.push(msg);
265
278
  } else {
266
- if (currentUserGroup.length > 0) {
267
- items.push({
268
- type: 'user-group',
269
- messages: currentUserGroup,
270
- key: `ug-${currentUserGroup[0].id}`,
271
- });
272
- currentUserGroup = [];
279
+ if (!currentTurn) {
280
+ currentTurn = {
281
+ key: pendingUserGroup.length > 0
282
+ ? `turn-${pendingUserGroup[0].id}`
283
+ : `turn-head-${msg.id}`,
284
+ userGroup: pendingUserGroup.length > 0
285
+ ? {
286
+ messages: pendingUserGroup,
287
+ key: `ug-${pendingUserGroup[0].id}`,
288
+ }
289
+ : undefined,
290
+ assistants: [],
291
+ };
292
+ pendingUserGroup = [];
273
293
  }
274
- items.push({ type: 'assistant', message: msg, originalIndex: i });
294
+ currentTurn.assistants.push({ message: msg, originalIndex: i });
275
295
  }
276
296
  }
277
- if (currentUserGroup.length > 0) {
278
- items.push({
279
- type: 'user-group',
280
- messages: currentUserGroup,
281
- key: `ug-${currentUserGroup[0].id}`,
297
+
298
+ if (pendingUserGroup.length > 0) {
299
+ if (currentTurn) {
300
+ turns.push(currentTurn);
301
+ }
302
+ turns.push({
303
+ key: `turn-${pendingUserGroup[0].id}`,
304
+ userGroup: {
305
+ messages: pendingUserGroup,
306
+ key: `ug-${pendingUserGroup[0].id}`,
307
+ },
308
+ assistants: [],
282
309
  });
310
+ } else if (currentTurn) {
311
+ turns.push(currentTurn);
283
312
  }
284
- return items;
313
+
314
+ return turns;
285
315
  }, [visibleMessages]);
286
316
 
287
317
  return (
@@ -294,148 +324,154 @@ export function MessageList({
294
324
  emptyState ?? null
295
325
  ) : (
296
326
  <div className="mx-auto w-full max-w-3xl space-y-4">
297
- {groupedItems.map((item) => {
298
- if (item.type === 'user-group') {
299
- if (item.messages.length === 1) {
300
- return (
301
- <UserMessage
302
- key={item.messages[0].id}
303
- message={item.messages[0]}
304
- agents={agents}
305
- chats={chats}
306
- />
307
- );
308
- }
309
- // Multiple consecutive user messages: render as a unified combined card.
310
- return (
311
- <div
312
- key={item.key}
313
- className="group/user-group"
314
- data-message-role="user-group"
315
- >
316
- <div className="flex justify-start">
317
- <div className="mx-auto w-full max-w-[var(--chat-input-box-width)] overflow-hidden rounded-agent-lg border border-agent-border/80 bg-agent-muted/70 shadow-sm">
318
- {item.messages.map((msg, idx) => (
319
- <UserMessage
320
- key={msg.id}
321
- message={msg}
322
- agents={agents}
323
- chats={chats}
324
- isGrouped
325
- isAppended={idx > 0}
326
- />
327
- ))}
327
+ {conversationTurns.map((turn) => (
328
+ <div
329
+ key={turn.key}
330
+ className="conversation-turn relative space-y-4"
331
+ data-conversation-turn={turn.key}
332
+ >
333
+ {turn.userGroup && (
334
+ <div className="sticky top-0 z-10 -mx-3 px-3 py-1 bg-agent-canvas/95 backdrop-blur-xs">
335
+ {turn.userGroup.messages.length === 1 ? (
336
+ <UserMessage
337
+ key={turn.userGroup.messages[0].id}
338
+ message={turn.userGroup.messages[0]}
339
+ agents={agents}
340
+ chats={chats}
341
+ />
342
+ ) : (
343
+ <div
344
+ key={turn.userGroup.key}
345
+ className="group/user-group"
346
+ data-message-role="user-group"
347
+ >
348
+ <div className="flex justify-start">
349
+ <div className="mx-auto w-full max-w-[var(--chat-input-box-width)] overflow-hidden rounded-agent-lg border border-agent-border/80 bg-agent-muted/70 shadow-sm">
350
+ {turn.userGroup.messages.map((msg, idx) => (
351
+ <UserMessage
352
+ key={msg.id}
353
+ message={msg}
354
+ agents={agents}
355
+ chats={chats}
356
+ isGrouped
357
+ isAppended={idx > 0}
358
+ />
359
+ ))}
360
+ </div>
361
+ </div>
328
362
  </div>
329
- </div>
363
+ )}
330
364
  </div>
331
- );
332
- }
365
+ )}
333
366
 
334
- const message = item.message;
335
- const isLast = item.originalIndex === visibleMessages.length - 1;
336
- // Action attribution: prefer the per-message map (set when the
337
- // backend's `message_id` event reconciles the stream-time queue
338
- // with the DB id). The framework keeps the in-flight assistant's
339
- // placeholder id even after the backend emits `message_id`, so
340
- // the current page cannot always key by DB id until reload.
341
- // Keep showing `currentTurnActions` on the latest assistant turn
342
- // after streaming ends; it is cleared when the next turn starts.
343
- const persistedActions =
344
- executedActionsByMessageId?.[message.id];
345
- const isStreamingTail = isStreaming && isLast;
346
- const isCurrentTurnTail =
347
- isLast && currentTurnActions !== undefined && currentTurnActions.length > 0;
348
- const actions = persistedActions
349
- ?? (isStreamingTail || isCurrentTurnTail ? currentTurnActions : undefined);
350
- const persistedChildren = orchestrationChildrenByMessageId?.[message.id];
351
- const isChildrenTail =
352
- isLast && currentTurnChildren !== undefined && currentTurnChildren.length > 0;
353
- const childList = persistedChildren
354
- ?? (isStreamingTail || isChildrenTail ? currentTurnChildren : undefined);
367
+ {turn.assistants.map((item) => {
368
+ const message = item.message;
369
+ const isLast = item.originalIndex === visibleMessages.length - 1;
370
+ // Action attribution: prefer the per-message map (set when the
371
+ // backend's `message_id` event reconciles the stream-time queue
372
+ // with the DB id). The framework keeps the in-flight assistant's
373
+ // placeholder id even after the backend emits `message_id`, so
374
+ // the current page cannot always key by DB id until reload.
375
+ // Keep showing `currentTurnActions` on the latest assistant turn
376
+ // after streaming ends; it is cleared when the next turn starts.
377
+ const persistedActions =
378
+ executedActionsByMessageId?.[message.id];
379
+ const isStreamingTail = isStreaming && isLast;
380
+ const isCurrentTurnTail =
381
+ isLast && currentTurnActions !== undefined && currentTurnActions.length > 0;
382
+ const actions = persistedActions
383
+ ?? (isStreamingTail || isCurrentTurnTail ? currentTurnActions : undefined);
384
+ const persistedChildren = orchestrationChildrenByMessageId?.[message.id];
385
+ const isChildrenTail =
386
+ isLast && currentTurnChildren !== undefined && currentTurnChildren.length > 0;
387
+ const childList = persistedChildren
388
+ ?? (isStreamingTail || isChildrenTail ? currentTurnChildren : undefined);
355
389
 
356
- let isPlanMode = false;
357
- if (message.messageMetadata) {
358
- try {
359
- const meta = JSON.parse(message.messageMetadata);
360
- if (meta?.mode === 'plan') {
390
+ let isPlanMode = false;
391
+ if (message.messageMetadata) {
392
+ try {
393
+ const meta = JSON.parse(message.messageMetadata);
394
+ if (meta?.mode === 'plan') {
395
+ isPlanMode = true;
396
+ }
397
+ } catch {}
398
+ } else if (isStreamingTail && mode === 'plan') {
361
399
  isPlanMode = true;
362
400
  }
363
- } catch {}
364
- } else if (isStreamingTail && mode === 'plan') {
365
- isPlanMode = true;
366
- }
367
401
 
368
- const persistedTimeline = timelineByMessageId?.[message.id];
369
- const isCurrentTimelineTail = isLast && currentTurnTimeline !== undefined;
370
- const turnTimeline = persistedTimeline
371
- ?? (isStreamingTail || isCurrentTimelineTail ? currentTurnTimeline : undefined);
402
+ const persistedTimeline = timelineByMessageId?.[message.id];
403
+ const isCurrentTimelineTail = isLast && currentTurnTimeline !== undefined;
404
+ const turnTimeline = persistedTimeline
405
+ ?? (isStreamingTail || isCurrentTimelineTail ? currentTurnTimeline : undefined);
372
406
 
373
- const persistedTurnFiles = turnFilesByMessageId?.[message.id];
374
- const isTurnFilesTail =
375
- isLast && currentTurnFiles !== undefined && currentTurnFiles.length > 0;
376
- const turnFiles = persistedTurnFiles
377
- ?? (isTurnFilesTail ? currentTurnFiles : undefined);
407
+ const persistedTurnFiles = turnFilesByMessageId?.[message.id];
408
+ const isTurnFilesTail =
409
+ isLast && currentTurnFiles !== undefined && currentTurnFiles.length > 0;
410
+ const turnFiles = persistedTurnFiles
411
+ ?? (isTurnFilesTail ? currentTurnFiles : undefined);
378
412
 
379
- const metadataJson =
380
- typeof message.messageMetadata === 'string'
381
- ? message.messageMetadata
382
- : undefined;
383
- let previousUser: ChatMessage | undefined;
384
- let previousUserCreatedAt: string | undefined;
385
- for (let i = item.originalIndex - 1; i >= 0; i -= 1) {
386
- if (visibleMessages[i].role === 'user') {
387
- previousUser = visibleMessages[i];
388
- previousUserCreatedAt = previousUser.createdAt;
389
- break;
390
- }
391
- }
392
- const durationMs = isStreamingTail
393
- ? undefined
394
- : durationByMessageId?.[message.id]
395
- ?? readPersistedDurationMs(metadataJson)
396
- ?? inferDurationMs(previousUserCreatedAt, message.createdAt);
397
- const persistedSpeed = llmSpeedByMessageId?.[message.id];
398
- const isSpeedTail = isLast && currentLlmSpeed !== undefined;
399
- const llmSpeed =
400
- persistedSpeed
401
- ?? (isStreamingTail || isSpeedTail ? currentLlmSpeed : undefined);
413
+ const metadataJson =
414
+ typeof message.messageMetadata === 'string'
415
+ ? message.messageMetadata
416
+ : undefined;
417
+ let previousUser: ChatMessage | undefined;
418
+ let previousUserCreatedAt: string | undefined;
419
+ for (let i = item.originalIndex - 1; i >= 0; i -= 1) {
420
+ if (visibleMessages[i].role === 'user') {
421
+ previousUser = visibleMessages[i];
422
+ previousUserCreatedAt = previousUser.createdAt;
423
+ break;
424
+ }
425
+ }
426
+ const durationMs = isStreamingTail
427
+ ? undefined
428
+ : durationByMessageId?.[message.id]
429
+ ?? readPersistedDurationMs(metadataJson)
430
+ ?? inferDurationMs(previousUserCreatedAt, message.createdAt);
431
+ const persistedSpeed = llmSpeedByMessageId?.[message.id];
432
+ const isSpeedTail = isLast && currentLlmSpeed !== undefined;
433
+ const llmSpeed =
434
+ persistedSpeed
435
+ ?? (isStreamingTail || isSpeedTail ? currentLlmSpeed : undefined);
402
436
 
403
- return (
404
- <div key={message.id}>
405
- <AssistantMessage
406
- message={message}
407
- isStreaming={isStreamingTail}
408
- agents={agents}
409
- chats={chats}
410
- chatId={chatId}
411
- currentAgent={currentAgent}
412
- executedActions={actions}
413
- timeline={turnTimeline}
414
- orchestrationChildren={childList}
415
- previousUser={previousUser}
416
- currentRound={isStreamingTail ? currentRound : undefined}
417
- isPlanMode={isPlanMode}
418
- onRegenerate={onRegenerate}
419
- startedAtMs={isStreamingTail ? currentTurnStartedAtMs : undefined}
420
- durationMs={durationMs}
421
- llmSpeed={llmSpeed}
422
- turnFiles={turnFiles}
423
- onShare={message.id === lastAssistantId ? onShare : undefined}
424
- onInspectTask={onInspectTask}
425
- />
426
- {!isStreaming &&
427
- message.id === lastAssistantId &&
428
- suggestedReplies &&
429
- suggestedReplies.length > 0 &&
430
- onSelectSuggestion ? (
431
- <SuggestedReplies
432
- suggestions={suggestedReplies}
433
- onSelect={onSelectSuggestion}
434
- />
435
- ) : null}
436
- </div>
437
- );
438
- })}
437
+ return (
438
+ <div key={message.id}>
439
+ <AssistantMessage
440
+ message={message}
441
+ isStreaming={isStreamingTail}
442
+ agents={agents}
443
+ chats={chats}
444
+ chatId={chatId}
445
+ currentAgent={currentAgent}
446
+ executedActions={actions}
447
+ timeline={turnTimeline}
448
+ orchestrationChildren={childList}
449
+ previousUser={previousUser}
450
+ currentRound={isStreamingTail ? currentRound : undefined}
451
+ isPlanMode={isPlanMode}
452
+ onRegenerate={onRegenerate}
453
+ startedAtMs={isStreamingTail ? currentTurnStartedAtMs : undefined}
454
+ durationMs={durationMs}
455
+ llmSpeed={llmSpeed}
456
+ turnFiles={turnFiles}
457
+ onShare={message.id === lastAssistantId ? onShare : undefined}
458
+ onInspectTask={onInspectTask}
459
+ />
460
+ {!isStreaming &&
461
+ message.id === lastAssistantId &&
462
+ suggestedReplies &&
463
+ suggestedReplies.length > 0 &&
464
+ onSelectSuggestion ? (
465
+ <SuggestedReplies
466
+ suggestions={suggestedReplies}
467
+ onSelect={onSelectSuggestion}
468
+ />
469
+ ) : null}
470
+ </div>
471
+ );
472
+ })}
473
+ </div>
474
+ ))}
439
475
  {/* W7-1: 中断提示卡在消息列尾部、与最后一条用户消息同列——
440
476
  视觉上隶属于被截断的那一轮,而非全局横幅。流式期间不显示
441
477
  (进行中的流不是中断)。 */}
@@ -484,6 +484,19 @@ async function request<T>(input: LocalBackendRequestInput): Promise<T> {
484
484
  return { success: true, message: 'deleted in browser mock', chatId: deleteChatId } as T;
485
485
  }
486
486
 
487
+ if (method === 'GET' && cleanPath === '/api/v2/llm/account') {
488
+ return {
489
+ status: 'unsupported',
490
+ provider: null,
491
+ label: '',
492
+ available: null,
493
+ currency: null,
494
+ total: null,
495
+ granted: null,
496
+ toppedUp: null,
497
+ } as T;
498
+ }
499
+
487
500
  throw new Error(`Browser dev mock does not implement ${method} ${path}`);
488
501
  }
489
502
 
@@ -42,10 +42,7 @@ export function requestHostPaste(target?: HTMLElement | null): void {
42
42
  const readText = bridge?.readClipboardText;
43
43
  if ((!readClipboard && !readText) || pending) return;
44
44
  pending = true;
45
- const read = readClipboard
46
- ? readClipboard()
47
- : readText!().then((text) => ({ text, files: [] as HostClipboardFile[] }));
48
- void read
45
+ void readHostClipboard(readClipboard, readText)
49
46
  .then((clip) => {
50
47
  if (clip.files.length > 0) insertHostFiles(clip.files, target ?? null);
51
48
  if (clip.text) insertHostText(clip.text, target ?? null);
@@ -58,6 +55,30 @@ export function requestHostPaste(target?: HTMLElement | null): void {
58
55
  });
59
56
  }
60
57
 
58
+ /**
59
+ * Prefer the rich pasteboard (text, files, screenshots). Desktop capabilities
60
+ * may allow only `host_read_clipboard_text`; a denial of the rich command must
61
+ * still insert plain text.
62
+ */
63
+ function readHostClipboard(
64
+ readClipboard: (() => Promise<HostClipboard>) | undefined,
65
+ readText: (() => Promise<string>) | undefined,
66
+ ): Promise<HostClipboard> {
67
+ const plain = (): Promise<HostClipboard> => {
68
+ if (!readText) return Promise.reject(new Error('clipboard text is unavailable'));
69
+ return readText().then((text) => ({ text, files: [] }));
70
+ };
71
+ if (!readClipboard) return plain();
72
+ return Promise.resolve()
73
+ .then(() => readClipboard())
74
+ .catch((error: unknown) => {
75
+ if (!readText) throw error;
76
+ return plain().catch(() => {
77
+ throw error;
78
+ });
79
+ });
80
+ }
81
+
61
82
  export function hostClipboardAvailable(): boolean {
62
83
  const bridge = getHostBridge();
63
84
  return (
@@ -67,13 +88,23 @@ export function hostClipboardAvailable(): boolean {
67
88
 
68
89
  function isEditable(target: EventTarget | null): target is HTMLElement {
69
90
  return (
70
- target instanceof HTMLInputElement ||
71
- target instanceof HTMLTextAreaElement ||
72
- (target instanceof HTMLElement && target.isContentEditable)
91
+ !isTerminalInput(target) &&
92
+ (target instanceof HTMLInputElement ||
93
+ target instanceof HTMLTextAreaElement ||
94
+ (target instanceof HTMLElement && target.isContentEditable))
73
95
  );
74
96
  }
75
97
 
98
+ /**
99
+ * xterm's hidden textarea. The terminal pastes into its PTY itself; text
100
+ * written into this textarea never reaches the shell.
101
+ */
102
+ function isTerminalInput(target: EventTarget | null): boolean {
103
+ return target instanceof HTMLTextAreaElement && target.closest('.xterm') !== null;
104
+ }
105
+
76
106
  function focusedEditable(): HTMLElement | null {
107
+ if (isTerminalInput(document.activeElement)) return null;
77
108
  if (isEditable(document.activeElement)) return document.activeElement;
78
109
  if (lastEditable?.isConnected) return lastEditable;
79
110
  return null;
@@ -736,6 +736,25 @@ export async function getSidecarSandboxPosture() {
736
736
  });
737
737
  }
738
738
 
739
+ /** 当前 DeepSeek / Kimi 密钥在供应商侧的真实余额。其他厂商为 unsupported。 */
740
+ export interface LlmAccount {
741
+ status: 'ready' | 'unsupported' | 'missing_key' | 'failed';
742
+ provider: 'deepseek' | 'moonshot' | null;
743
+ label: string;
744
+ available: boolean | null;
745
+ currency: string | null;
746
+ total: string | null;
747
+ granted: string | null;
748
+ toppedUp: string | null;
749
+ }
750
+
751
+ export async function getLlmAccount(refresh = false) {
752
+ return bridge().localBackend.request<LlmAccount>({
753
+ method: 'GET',
754
+ path: refresh ? '/api/v2/llm/account?refresh=1' : '/api/v2/llm/account',
755
+ });
756
+ }
757
+
739
758
  export async function getLlmSettings() {
740
759
  return bridge().localBackend.request<LlmSettings>({
741
760
  method: 'GET',
@@ -743,12 +762,17 @@ export async function getLlmSettings() {
743
762
  });
744
763
  }
745
764
 
765
+ /** 模型设置保存后通知侧栏重新读取供应商余额。 */
766
+ export const LLM_SETTINGS_CHANGED_EVENT = 'steerable:llm-settings-changed';
767
+
746
768
  export async function setLlmSettings(input: LlmSettings) {
747
- return bridge().localBackend.request<LlmSettings>({
769
+ const saved = await bridge().localBackend.request<LlmSettings>({
748
770
  method: 'POST',
749
771
  path: '/api/v2/local-settings/llm',
750
772
  body: input,
751
773
  });
774
+ window.dispatchEvent(new Event(LLM_SETTINGS_CHANGED_EVENT));
775
+ return saved;
752
776
  }
753
777
 
754
778
  /* ---------------- LLM link diagnosis ---------------- */
@@ -75,26 +75,44 @@ body {
75
75
  * ToolExecutionCard header: CJK status labels (已完成 / 失败 / …) wrap at
76
76
  * every character when the flex row is squeezed. Keep the trailing status
77
77
  * cluster at content width; let the truncated summary take the leftover.
78
- * Host-side until @steerable/agent-ui ships the same layout classes.
78
+ *
79
+ * WKWebView (Tauri) lays out flex items of a <button> in the border box, so
80
+ * padding-right on that button does not inset 「已完成」. Clear that padding
81
+ * and inset the status with margin. The card also puts the same inset on an
82
+ * inner span; these direct-child rules only match the older button markup.
79
83
  */
80
- .steerable-tool-execution > button > span.truncate {
84
+ .steerable-tool-execution span.truncate {
81
85
  min-width: 0;
82
86
  flex: 1 1 0%;
83
87
  }
84
88
 
85
- .steerable-tool-execution > button > span.ml-auto {
89
+ .steerable-tool-execution span.ml-auto {
86
90
  flex-shrink: 0;
87
91
  white-space: nowrap;
88
92
  }
89
93
 
90
- /* 12px 字号下收紧工具行:框架卡默认还是 px-3 py-2。 */
91
- .steerable-tool-execution > button {
92
- gap: 0.375rem;
93
- padding: 0.25rem 0.625rem;
94
+ .steerable-tool-execution > div > button.flex-1 {
95
+ padding-left: 0;
96
+ padding-right: 0;
97
+ padding-inline: 0;
98
+ }
99
+
100
+ .steerable-tool-execution > div > button.flex-1 > span.ml-auto {
101
+ margin-inline-end: 0.625rem;
102
+ }
103
+
104
+ .steerable-tool-execution > div > button.flex-1.pl-2\.5 > :first-child {
105
+ margin-inline-start: 0.625rem;
106
+ }
107
+
108
+ .steerable-tool-execution > div > button.shrink-0 {
109
+ padding-left: 0;
110
+ padding-right: 0;
111
+ padding-inline: 0;
94
112
  }
95
113
 
96
- .steerable-tool-execution > button + div {
97
- padding: 0.375rem 0.625rem;
114
+ .steerable-tool-execution > div > button.shrink-0 > svg {
115
+ margin-inline: 0.625rem;
98
116
  }
99
117
 
100
118
  /*