@steerable/agent-shell-web 0.6.22 → 0.6.25

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.22",
3
+ "version": "0.6.25",
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",
@@ -46,8 +46,8 @@
46
46
  "react-router-dom": "^6.30.0",
47
47
  "rehype-raw": "^7.0.0",
48
48
  "remark-gfm": "^4.0.1",
49
- "@steerable/agent-ui": "0.6.22",
50
- "@steerable/agent-protocol": "0.6.22"
49
+ "@steerable/agent-ui": "0.6.25",
50
+ "@steerable/agent-protocol": "0.6.25"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@tailwindcss/vite": "^4.3.0",
@@ -60,7 +60,7 @@
60
60
  "typescript": "^5.8.3",
61
61
  "vite": "^5.4.21",
62
62
  "vitest": "^3.2.4",
63
- "@steerable/pack-sdk": "0.6.22"
63
+ "@steerable/pack-sdk": "0.6.25"
64
64
  },
65
65
  "scripts": {
66
66
  "gen": "echo agent-shell-web",
package/src/brand.ts CHANGED
@@ -15,6 +15,14 @@ export const LOCAL_ASSISTANT_AGENT_ID = 'local-assistant';
15
15
 
16
16
  export const BRAND_NAME: string = import.meta.env.VITE_BRAND_NAME ?? 'Steerable Shell';
17
17
 
18
+ /**
19
+ * 品牌锁头标题。产品/包可在 brand.title 注入;空字符串表示不配标题,
20
+ * 侧栏只按原比例显示 logo。未 define 时回落 displayName,避免旧
21
+ * vite.base.js 漏注入时露出壳默认名。
22
+ */
23
+ export const BRAND_TITLE: string =
24
+ import.meta.env.VITE_BRAND_TITLE ?? import.meta.env.VITE_BRAND_NAME ?? 'Steerable Shell';
25
+
18
26
  /** shell 默认 logo(中性通用图标);包品牌 logo 由包 web 模块注册覆盖。 */
19
27
  let brandLogoUrl: string = shellLogoUrl;
20
28
 
@@ -120,13 +120,7 @@ import {
120
120
  import type { UseChatsAndAgentsResult } from '@/hooks/useChatsAndAgents';
121
121
  import type { PackChatSlotContribution } from '@/packs/registry';
122
122
  import type { RightPanelState } from '@/layouts/AgentLayout';
123
- // 必须 import 而不是写 src="/favicon.png":public/ 下的资源 Vite 永远按绝对路径
124
- // /favicon.png 输出,dev 模式下 dev server 提供根路径所以能加载,但打包成
125
- // Electron 后渲染进程走 file:// 协议,/favicon.png 会被解析成文件系统根目录
126
- // 下的 favicon.png(必然 404)。改成模块导入后,Vite 会把图片放到 dist/assets
127
- // 下并发出 base-relative URL,配合 vite.config.ts 的 `base: './'` 在两种模式
128
- // 下都能正确加载。
129
- import { getBrandLogoUrl, BRAND_NAME } from '@/brand';
123
+ import { BrandLockup } from '@/components/BrandLockup';
130
124
 
131
125
  const DEFAULT_DOT_COLOR = '#7c3aed';
132
126
 
@@ -627,22 +621,7 @@ export function AgentSidebar({
627
621
  <div className="flex h-full w-full flex-col border-r border-agent-border/60 bg-agent-muted/70 backdrop-blur-md">
628
622
  {/* ───── Brand + actions ───── */}
629
623
  <div className="flex h-12 flex-shrink-0 items-center justify-between px-3">
630
- <div className="flex min-w-0 items-center gap-2">
631
- {/*
632
- 品牌 logo —— src 用 module-imported asset,不要写 /favicon.png(见
633
- 文件顶部 import 的注释)。shell 默认是中性通用图标;产品品牌
634
- logo 由激活包的 web 模块在注册时注入(getBrandLogoUrl)。
635
- */}
636
- <img
637
- src={getBrandLogoUrl()}
638
- alt={BRAND_NAME}
639
- className="h-6 w-6 flex-shrink-0 select-none"
640
- draggable={false}
641
- />
642
- <span className="truncate text-sm font-semibold tracking-tight text-agent-foreground">
643
- {BRAND_NAME}
644
- </span>
645
- </div>
624
+ <BrandLockup />
646
625
  <div className="flex items-center gap-1">
647
626
  <button
648
627
  type="button"
@@ -0,0 +1,24 @@
1
+ import { BRAND_NAME, BRAND_TITLE, getBrandLogoUrl } from '@/brand';
2
+
3
+ /**
4
+ * 侧栏品牌锁头。配了 brand.title 就 logo + 标题;没配标题则只显示 logo,
5
+ * 并按原图宽高比缩放(一体字标不要压成方图)。
6
+ */
7
+ export function BrandLockup() {
8
+ const title = BRAND_TITLE.trim();
9
+ return (
10
+ <div className="flex min-w-0 items-center gap-2">
11
+ <img
12
+ src={getBrandLogoUrl()}
13
+ alt={title || BRAND_NAME}
14
+ className="h-6 w-auto max-w-[10rem] flex-shrink-0 select-none object-contain object-left"
15
+ draggable={false}
16
+ />
17
+ {title ? (
18
+ <span className="truncate text-sm font-semibold tracking-tight text-agent-foreground">
19
+ {title}
20
+ </span>
21
+ ) : null}
22
+ </div>
23
+ );
24
+ }
@@ -167,6 +167,7 @@ export function TaskProcessPanel({
167
167
  <TurnProcessGroup
168
168
  blocks={blocks}
169
169
  isStreaming={live}
170
+ showThinkingContent
170
171
  agents={[]}
171
172
  chats={[]}
172
173
  emptyFallback={
@@ -1,24 +1,22 @@
1
- import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
1
+ import { Fragment, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
2
2
  import { LuChevronDown, LuChevronRight } from 'react-icons/lu';
3
3
  import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
4
+ import { useShowThinkingContent } from '@/lib/show-thinking-content';
4
5
  import { Markdown } from './Markdown';
5
6
  import { ToolsFlow } from './ExecutedActionsCard';
6
- import { formatElapsedCompact } from './elapsed';
7
- import {
8
- countProcessTools,
9
- processHasReasoning,
10
- splitTurnProcess,
11
- type TurnBlock,
12
- } from './turn-timeline';
7
+ import { processStatusLabel } from './process-status';
8
+ import { processHasReasoning, splitTurnProcess, type TurnBlock } from './turn-timeline';
13
9
 
14
10
  /**
15
11
  * Codex / DeepSeek-style turn process: think + tool rows stay in one
16
- * disclosure. Expanded while the turn streams; collapsed once the trailing
17
- * summary is on screen. The summary itself stays outside the fold.
18
- * Elapsed time ticks while streaming (Codex status line) and freezes as
19
- * 「工作了 …」 after the turn ends.
12
+ * disclosure. Default is collapsed (settings 「显示思考内容」 off). While
13
+ * streaming, a fixed 7-line peek shows the latest reasoning; it folds when
14
+ * the turn ends. The status line still shows 思考中 / 工具名 / tok/s /
15
+ * elapsed. The summary itself stays outside the fold.
20
16
  */
21
17
 
18
+ export const THINKING_PEEK_LINES = 7;
19
+
22
20
  function useLiveElapsedMs(
23
21
  startedAtMs: number | undefined,
24
22
  enabled: boolean,
@@ -34,27 +32,6 @@ function useLiveElapsedMs(
34
32
  return Math.max(0, now - startedAtMs);
35
33
  }
36
34
 
37
- function processLabel(
38
- process: TurnBlock[],
39
- isStreaming: boolean,
40
- elapsedMs: number | undefined,
41
- ): string {
42
- const elapsed =
43
- elapsedMs != null && (isStreaming || elapsedMs >= 1000)
44
- ? formatElapsedCompact(elapsedMs)
45
- : null;
46
- if (isStreaming) {
47
- return elapsed ? `正在执行… ${elapsed}` : '正在执行…';
48
- }
49
- const parts: string[] = [];
50
- const tools = countProcessTools(process);
51
- if (tools > 0) parts.push(`${tools} 次工具调用`);
52
- if (processHasReasoning(process)) parts.push('已思考');
53
- if (elapsed) parts.push(`工作了 ${elapsed}`);
54
- if (parts.length === 0) return '执行过程';
55
- return parts.join(' · ');
56
- }
57
-
58
35
  function ProcessBlocks({
59
36
  blocks,
60
37
  isStreaming,
@@ -102,6 +79,51 @@ function ProcessBlocks({
102
79
  );
103
80
  }
104
81
 
82
+ function ThinkingPeek({
83
+ blocks,
84
+ isStreaming,
85
+ agents,
86
+ chats,
87
+ chatId,
88
+ }: {
89
+ blocks: TurnBlock[];
90
+ isStreaming: boolean;
91
+ agents: LocalChatAgent[];
92
+ chats: LocalChat[];
93
+ chatId?: string | null;
94
+ }) {
95
+ const scrollerRef = useRef<HTMLDivElement>(null);
96
+ const reasoning = blocks.filter(
97
+ (block): block is Extract<TurnBlock, { type: 'reasoning' }> =>
98
+ block.type === 'reasoning' && block.content.trim().length > 0,
99
+ );
100
+ const sig = reasoning.map((block) => block.content.length).join(',');
101
+ useLayoutEffect(() => {
102
+ const el = scrollerRef.current;
103
+ if (!el) return;
104
+ el.scrollTop = el.scrollHeight;
105
+ }, [sig]);
106
+ const lastIndex = reasoning.length - 1;
107
+ return (
108
+ <div
109
+ ref={scrollerRef}
110
+ className="h-[7lh] overflow-hidden border-l border-agent-border/70 pl-3 text-xs leading-relaxed text-agent-muted-foreground"
111
+ data-thinking-peek=""
112
+ data-testid="thinking-peek"
113
+ data-peek-lines={THINKING_PEEK_LINES}
114
+ >
115
+ {reasoning.map((block, index) => (
116
+ <div key={`peek-reasoning-${index}`}>
117
+ <Markdown agents={agents} chats={chats} chatId={chatId}>{block.content}</Markdown>
118
+ {isStreaming && index === lastIndex && (
119
+ <span className="ml-0.5 inline-block h-3 w-[2px] animate-agent-cursor-blink bg-agent-muted-foreground/60 align-text-bottom" />
120
+ )}
121
+ </div>
122
+ ))}
123
+ </div>
124
+ );
125
+ }
126
+
105
127
  export function TurnProcessGroup({
106
128
  blocks,
107
129
  isStreaming,
@@ -112,6 +134,7 @@ export function TurnProcessGroup({
112
134
  streamingHint,
113
135
  startedAtMs,
114
136
  durationMs,
137
+ showThinkingContent: showThinkingContentProp,
115
138
  renderAnswer,
116
139
  }: {
117
140
  blocks: TurnBlock[];
@@ -126,11 +149,24 @@ export function TurnProcessGroup({
126
149
  startedAtMs?: number;
127
150
  /** Frozen wall-clock of a finished turn (metadata or just-ended stream). */
128
151
  durationMs?: number;
152
+ /**
153
+ * Override the settings preference. Omitted = read 「显示思考内容」.
154
+ * Task process panel passes true so the inspector stays expanded.
155
+ */
156
+ showThinkingContent?: boolean;
129
157
  renderAnswer: (block: Extract<TurnBlock, { type: 'text' }>, isLast: boolean) => ReactNode;
130
158
  }) {
159
+ const preference = useShowThinkingContent();
160
+ const showThinkingContent = showThinkingContentProp ?? preference;
131
161
  const { process, answer } = splitTurnProcess(blocks);
132
- const [open, setOpen] = useState(() => isStreaming || answer.length === 0);
162
+ const [open, setOpen] = useState(
163
+ () => showThinkingContent && (isStreaming || answer.length === 0),
164
+ );
133
165
  const wasStreamingRef = useRef(isStreaming);
166
+ const isStreamingRef = useRef(isStreaming);
167
+ const answerLenRef = useRef(answer.length);
168
+ isStreamingRef.current = isStreaming;
169
+ answerLenRef.current = answer.length;
134
170
  const liveElapsedMs = useLiveElapsedMs(startedAtMs, isStreaming);
135
171
  const lastLiveElapsedRef = useRef<number | undefined>(undefined);
136
172
  if (isStreaming && liveElapsedMs != null) {
@@ -140,24 +176,48 @@ export function TurnProcessGroup({
140
176
  ? liveElapsedMs
141
177
  : durationMs ?? lastLiveElapsedRef.current;
142
178
 
179
+ const lastProcess = process[process.length - 1];
180
+ const reasoningClockRef = useRef<{ index: number; startedAt: number } | null>(null);
181
+ if (isStreaming && lastProcess?.type === 'reasoning') {
182
+ const index = process.length - 1;
183
+ if (reasoningClockRef.current?.index !== index) {
184
+ reasoningClockRef.current = { index, startedAt: Date.now() };
185
+ }
186
+ } else if (!isStreaming) {
187
+ reasoningClockRef.current = null;
188
+ }
189
+ const reasoningElapsedMs =
190
+ isStreaming && lastProcess?.type === 'reasoning' && reasoningClockRef.current
191
+ ? Math.max(0, Date.now() - reasoningClockRef.current.startedAt)
192
+ : undefined;
193
+
194
+ useEffect(() => {
195
+ if (!showThinkingContent) {
196
+ setOpen(false);
197
+ return;
198
+ }
199
+ if (isStreamingRef.current || answerLenRef.current === 0) setOpen(true);
200
+ }, [showThinkingContent]);
201
+
143
202
  useEffect(() => {
144
203
  const wasStreaming = wasStreamingRef.current;
145
204
  if (wasStreaming && !isStreaming && answer.length > 0) {
146
205
  setOpen(false);
147
206
  }
148
207
  if (!wasStreaming && isStreaming) {
149
- setOpen(true);
208
+ setOpen(showThinkingContent);
150
209
  }
151
210
  wasStreamingRef.current = isStreaming;
152
- }, [isStreaming, answer.length]);
211
+ }, [isStreaming, answer.length, showThinkingContent]);
153
212
 
154
213
  if (blocks.length === 0) return <>{emptyFallback}</>;
155
214
 
156
215
  const showToggle = process.length > 0;
157
216
  const showProcess = !showToggle || open;
158
- const lastProcess = process[process.length - 1];
217
+ const showThinkingPeek =
218
+ isStreaming && !open && showToggle && processHasReasoning(process);
159
219
  const showStreamingHint =
160
- Boolean(streamingHint) && isStreaming && answer.length === 0 && lastProcess?.type === 'tools';
220
+ Boolean(streamingHint) && isStreaming && answer.length === 0 && !showToggle;
161
221
 
162
222
  return (
163
223
  <div className="space-y-1.5" data-turn-timeline>
@@ -175,7 +235,14 @@ export function TurnProcessGroup({
175
235
  ) : (
176
236
  <LuChevronRight className="h-3.5 w-3.5 shrink-0" />
177
237
  )}
178
- <span className="truncate">{processLabel(process, isStreaming, elapsedMs)}</span>
238
+ <span className="truncate">
239
+ {processStatusLabel({
240
+ process,
241
+ isStreaming,
242
+ elapsedMs,
243
+ reasoningElapsedMs,
244
+ })}
245
+ </span>
179
246
  </button>
180
247
  )}
181
248
  {showProcess && (
@@ -187,6 +254,15 @@ export function TurnProcessGroup({
187
254
  chatId={chatId}
188
255
  />
189
256
  )}
257
+ {showThinkingPeek && (
258
+ <ThinkingPeek
259
+ blocks={process}
260
+ isStreaming={answer.length === 0}
261
+ agents={agents}
262
+ chats={chats}
263
+ chatId={chatId}
264
+ />
265
+ )}
190
266
  {showStreamingHint ? streamingHint : null}
191
267
  {answer.map((block, index) => (
192
268
  <Fragment key={`answer-${index}`}>
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Turn-process status line: live 「思考中 / 调用工具中」 plus finished
3
+ * 「N 次工具调用 · 已思考 · 工作了 …」. Token speed is a coarse estimate
4
+ * from reasoning text (same CJK/other weights as the desktop compactor).
5
+ */
6
+
7
+ import { formatElapsedCompact } from './elapsed';
8
+ import {
9
+ countProcessTools,
10
+ processHasReasoning,
11
+ type TurnBlock,
12
+ } from './turn-timeline';
13
+
14
+ export function estimateTextTokens(text: string): number {
15
+ if (!text) return 0;
16
+ let cjk = 0;
17
+ for (let i = 0; i < text.length; i++) {
18
+ const code = text.charCodeAt(i);
19
+ if (
20
+ (code >= 0x4e00 && code <= 0x9fff) ||
21
+ (code >= 0x3000 && code <= 0x303f) ||
22
+ (code >= 0xff00 && code <= 0xffef)
23
+ ) {
24
+ cjk++;
25
+ }
26
+ }
27
+ return Math.ceil(cjk * 0.6 + (text.length - cjk) * 0.25);
28
+ }
29
+
30
+ export function formatTokenSpeed(tokens: number, elapsedMs: number): string | null {
31
+ if (tokens < 1 || elapsedMs < 400) return null;
32
+ const perSec = tokens / (elapsedMs / 1000);
33
+ if (perSec < 0.5) return null;
34
+ const shown = perSec >= 10 ? Math.round(perSec) : Math.round(perSec * 10) / 10;
35
+ return `${shown} tok/s`;
36
+ }
37
+
38
+ export function lastToolsAreRunning(process: TurnBlock[]): boolean {
39
+ const last = process[process.length - 1];
40
+ if (last?.type !== 'tools' || last.actions.length === 0) return false;
41
+ return last.actions.some((action) => action.result == null);
42
+ }
43
+
44
+ export function activeToolNames(process: TurnBlock[]): string[] {
45
+ for (let i = process.length - 1; i >= 0; i -= 1) {
46
+ const block = process[i];
47
+ if (block.type !== 'tools' || block.actions.length === 0) continue;
48
+ const running = block.actions.filter((action) => action.result == null);
49
+ const source = running.length > 0 ? running : block.actions;
50
+ return source.slice(-2).map((action) => action.tool);
51
+ }
52
+ return [];
53
+ }
54
+
55
+ function formatElapsedPart(
56
+ isStreaming: boolean,
57
+ elapsedMs: number | undefined,
58
+ ): string | null {
59
+ if (elapsedMs == null) return null;
60
+ if (!isStreaming && elapsedMs < 1000) return null;
61
+ return formatElapsedCompact(elapsedMs);
62
+ }
63
+
64
+ export function processStatusLabel(input: {
65
+ process: TurnBlock[];
66
+ isStreaming: boolean;
67
+ elapsedMs?: number;
68
+ reasoningElapsedMs?: number;
69
+ }): string {
70
+ const { process, isStreaming, elapsedMs, reasoningElapsedMs } = input;
71
+ const elapsed = formatElapsedPart(isStreaming, elapsedMs);
72
+
73
+ if (isStreaming) {
74
+ const parts: string[] = [];
75
+ const last = process[process.length - 1];
76
+ if (lastToolsAreRunning(process)) {
77
+ parts.push('调用工具中');
78
+ const names = activeToolNames(process);
79
+ if (names.length > 0) parts.push(names.join('、'));
80
+ } else {
81
+ parts.push('思考中');
82
+ if (last?.type === 'reasoning') {
83
+ const speed = formatTokenSpeed(
84
+ estimateTextTokens(last.content),
85
+ reasoningElapsedMs ?? elapsedMs ?? 0,
86
+ );
87
+ if (speed) parts.push(speed);
88
+ }
89
+ }
90
+ if (elapsed) parts.push(elapsed);
91
+ return parts.join(' · ');
92
+ }
93
+
94
+ const parts: string[] = [];
95
+ const tools = countProcessTools(process);
96
+ if (tools > 0) parts.push(`${tools} 次工具调用`);
97
+ if (processHasReasoning(process)) parts.push('已思考');
98
+ if (elapsed) parts.push(`工作了 ${elapsed}`);
99
+ if (parts.length === 0) return '执行过程';
100
+ return parts.join(' · ');
101
+ }
@@ -0,0 +1,44 @@
1
+ import { persistShowThinkingContent, useShowThinkingContent } from '@/lib/show-thinking-content';
2
+
3
+ /**
4
+ * 界面偏好:思考过程正文默认折叠,状态行仍显示思考中 / 工具名 / 速度 / 时间。
5
+ * 立即写入 localStorage,不走设置页顶部的模型保存按钮。
6
+ */
7
+ export function AppearanceSettingsPanel() {
8
+ const showThinkingContent = useShowThinkingContent();
9
+
10
+ return (
11
+ <div
12
+ className="space-y-3 rounded-agent-md border border-agent-border bg-agent-card p-4"
13
+ data-testid="appearance-settings-panel"
14
+ >
15
+ <div className="flex items-start justify-between gap-4">
16
+ <div className="min-w-0">
17
+ <p className="text-sm font-medium text-agent-foreground">显示思考内容</p>
18
+ <p className="mt-1 text-xs leading-relaxed text-agent-muted-foreground">
19
+ 关闭后流式中只露出 7 行思考,结束后自动折叠。状态行仍显示思考中、工具名、速度和用时。
20
+ </p>
21
+ </div>
22
+ <button
23
+ type="button"
24
+ role="switch"
25
+ aria-checked={showThinkingContent}
26
+ aria-label="显示思考内容"
27
+ data-testid="show-thinking-content-toggle"
28
+ onClick={() => persistShowThinkingContent(!showThinkingContent)}
29
+ className={[
30
+ 'relative mt-0.5 inline-flex h-5 w-9 shrink-0 items-center rounded-full px-0.5 transition-colors',
31
+ showThinkingContent ? 'bg-agent-foreground' : 'bg-agent-foreground/25',
32
+ ].join(' ')}
33
+ >
34
+ <span
35
+ className={[
36
+ 'h-4 w-4 rounded-full bg-agent-canvas shadow-sm transition-transform',
37
+ showThinkingContent ? 'translate-x-4' : 'translate-x-0',
38
+ ].join(' ')}
39
+ />
40
+ </button>
41
+ </div>
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 聊天里「显示思考内容」的本机偏好。
3
+ *
4
+ * 默认关闭:思考 / 工具过程折叠,只留状态行。打开后流式中自动展开。
5
+ * 存在 localStorage,设置页开关立即生效,不走后端。
6
+ */
7
+
8
+ import { useEffect, useState } from 'react';
9
+
10
+ export const SHOW_THINKING_CONTENT_STORAGE_KEY = 'agent-show-thinking-content';
11
+ export const SHOW_THINKING_CONTENT_EVENT = 'agent-show-thinking-content-changed';
12
+
13
+ export function readShowThinkingContent(): boolean {
14
+ if (typeof localStorage === 'undefined') return false;
15
+ return localStorage.getItem(SHOW_THINKING_CONTENT_STORAGE_KEY) === '1';
16
+ }
17
+
18
+ export function persistShowThinkingContent(show: boolean): void {
19
+ if (typeof localStorage !== 'undefined') {
20
+ localStorage.setItem(SHOW_THINKING_CONTENT_STORAGE_KEY, show ? '1' : '0');
21
+ }
22
+ if (typeof window === 'undefined') return;
23
+ window.dispatchEvent(
24
+ new CustomEvent<boolean>(SHOW_THINKING_CONTENT_EVENT, { detail: show }),
25
+ );
26
+ }
27
+
28
+ /** 当前页与跨标签的偏好变更都会刷新。 */
29
+ export function useShowThinkingContent(): boolean {
30
+ const [show, setShow] = useState(readShowThinkingContent);
31
+ useEffect(() => {
32
+ const onCustom = (event: Event) => {
33
+ const detail = (event as CustomEvent<boolean>).detail;
34
+ setShow(typeof detail === 'boolean' ? detail : readShowThinkingContent());
35
+ };
36
+ const onStorage = (event: StorageEvent) => {
37
+ if (event.key === SHOW_THINKING_CONTENT_STORAGE_KEY) {
38
+ setShow(event.newValue === '1');
39
+ }
40
+ };
41
+ window.addEventListener(SHOW_THINKING_CONTENT_EVENT, onCustom);
42
+ window.addEventListener('storage', onStorage);
43
+ return () => {
44
+ window.removeEventListener(SHOW_THINKING_CONTENT_EVENT, onCustom);
45
+ window.removeEventListener('storage', onStorage);
46
+ };
47
+ }, []);
48
+ return show;
49
+ }
@@ -188,8 +188,23 @@ export function ChatPanelPreviewPage() {
188
188
  currentTurnActions: MOCK_ACTIONS.slice(0, 2),
189
189
  timelineByMsgId: {} as Record<string, TurnBlock[]>,
190
190
  currentTurnTimeline: [
191
- { type: 'reasoning', content: '先检查连通性,再跑测试。' },
192
- { type: 'tools', actions: MOCK_ACTIONS.slice(0, 2) },
191
+ {
192
+ type: 'reasoning',
193
+ content: [
194
+ '先检查服务是否还能连上。',
195
+ '如果端口通了,再跑本目录的测试套件。',
196
+ '失败的话只读失败文件,不要整仓扫一遍。',
197
+ '断言颜色对不上时优先看 AssistantMessage 的默认色。',
198
+ '改完再复跑一次,确认没有带出新的失败。',
199
+ '工具调用保持最少:连通性检查、跑测试、必要时读文件。',
200
+ '最后用一两句说清楚结果和下一步。',
201
+ '如果还在思考,后面的句子会被 7 行窗口裁掉。',
202
+ ].join('\n'),
203
+ },
204
+ {
205
+ type: 'tools',
206
+ actions: [MOCK_ACTIONS[0], { ...MOCK_ACTIONS[1], result: undefined }],
207
+ },
193
208
  ],
194
209
  currentTurnStartedAtMs: Date.now() - 12_000,
195
210
  currentRound: 2,
@@ -1,8 +1,9 @@
1
1
  import { useRef, useState } from 'react';
2
- import { LuActivity, LuBlocks, LuBot, LuChartBar, LuNetwork, LuPlug, LuSearch, LuSettings, LuShieldCheck } from 'react-icons/lu';
2
+ import { LuActivity, LuBlocks, LuBot, LuChartBar, LuMonitor, LuNetwork, LuPlug, LuSearch, LuSettings, LuShieldCheck } from 'react-icons/lu';
3
3
  import { useOutletContext, useSearchParams } from 'react-router-dom';
4
4
  import { isElectron } from '@/lib/electron-bridge';
5
5
  import { AgentsSettingsPanel } from '@/components/settings/AgentsSettingsPanel';
6
+ import { AppearanceSettingsPanel } from '@/components/settings/AppearanceSettingsPanel';
6
7
  import { DiagnoseSettingsPanel } from '@/components/settings/DiagnoseSettingsPanel';
7
8
  import { InsightsSettingsPanel } from '@/components/settings/InsightsSettingsPanel';
8
9
  import {
@@ -32,7 +33,7 @@ function resolveSection(raw: string | null): SettingsSection {
32
33
  * - `skills` 侧栏「Skill 设置」
33
34
  * - `mcp` 侧栏「MCP 设置」
34
35
  * - `agents` 侧栏「智能体管理」
35
- * - 缺省/`general` 侧栏底「设置」(模型 / 搜索 / 用量 / 安全 / 洞察 / 遥测)
36
+ * - 缺省/`general` 侧栏底「设置」(界面 / 模型 / 搜索 / 用量 / 安全 / 洞察 / 遥测)
36
37
  *
37
38
  * Panel 数据自管理(挂载即拉取),页面本身不持有后端状态。
38
39
  */
@@ -111,6 +112,14 @@ export function SettingsPage() {
111
112
 
112
113
  {section === 'general' && (
113
114
  <>
115
+ <section className="space-y-3" data-testid="settings-section-appearance">
116
+ <h2 className="flex items-center gap-1.5 text-sm font-semibold text-agent-foreground">
117
+ <LuMonitor className="h-4 w-4 text-agent-muted-foreground" />
118
+ 界面
119
+ </h2>
120
+ <AppearanceSettingsPanel />
121
+ </section>
122
+
114
123
  <section className="space-y-3" data-testid="settings-section-llm">
115
124
  <h2 className="flex items-center gap-1.5 text-sm font-semibold text-agent-foreground">
116
125
  <LuSettings className="h-4 w-4 text-agent-muted-foreground" />
package/vite.base.js CHANGED
@@ -50,6 +50,7 @@ export function createProductViteConfig(options) {
50
50
  // logo 不走 define——资产必须静态 import 才进 bundle,由包 web 模块
51
51
  // 在注册时经 setBrandLogoUrl 注入(本包 src/brand.ts)。
52
52
  'import.meta.env.VITE_BRAND_NAME': JSON.stringify(brand?.displayName ?? 'Steerable Shell'),
53
+ 'import.meta.env.VITE_BRAND_TITLE': JSON.stringify(brand?.title ?? ''),
53
54
  'import.meta.env.VITE_BRAND_TAGLINE': JSON.stringify(brand?.tagline ?? '一款本地桌面 AI 伙伴'),
54
55
  'import.meta.env.VITE_DEFAULT_AGENT_ID': JSON.stringify(brand?.defaultAgentId ?? 'local-assistant'),
55
56
  },