@steerable/agent-shell-web 0.6.20 → 0.6.22

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/README.md CHANGED
@@ -17,4 +17,10 @@ bundle; there is no prebuilt `dist/` here. See
17
17
  [`docs/spec/architecture.md`](https://github.com/pathlyapp/steerable-framework/blob/develop/docs/spec/architecture.md)
18
18
  for the tier model.
19
19
 
20
+ Products that require authentication can register an `AppShellGate` from
21
+ `@steerable/agent-shell-web/auth/gate` before calling `bootstrap()`. An
22
+ enabled gate renders before the application router and calls
23
+ `onAuthenticated` to continue normal bootstrap. With no gate, or a disabled
24
+ gate, startup is unchanged.
25
+
20
26
  License: Apache-2.0.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-web",
3
- "version": "0.6.20",
3
+ "version": "0.6.22",
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",
@@ -14,6 +14,7 @@
14
14
  "url": "https://github.com/pathlyapp/steerable-framework/issues"
15
15
  },
16
16
  "exports": {
17
+ "./auth/gate": "./src/auth/gate.ts",
17
18
  "./vite.base": {
18
19
  "types": "./vite.base.d.ts",
19
20
  "default": "./vite.base.js"
@@ -45,8 +46,8 @@
45
46
  "react-router-dom": "^6.30.0",
46
47
  "rehype-raw": "^7.0.0",
47
48
  "remark-gfm": "^4.0.1",
48
- "@steerable/agent-ui": "0.6.20",
49
- "@steerable/agent-protocol": "0.6.20"
49
+ "@steerable/agent-ui": "0.6.22",
50
+ "@steerable/agent-protocol": "0.6.22"
50
51
  },
51
52
  "devDependencies": {
52
53
  "@tailwindcss/vite": "^4.3.0",
@@ -59,7 +60,7 @@
59
60
  "typescript": "^5.8.3",
60
61
  "vite": "^5.4.21",
61
62
  "vitest": "^3.2.4",
62
- "@steerable/pack-sdk": "0.6.20"
63
+ "@steerable/pack-sdk": "0.6.22"
63
64
  },
64
65
  "scripts": {
65
66
  "gen": "echo agent-shell-web",
@@ -0,0 +1,26 @@
1
+ import type { ComponentType } from 'react';
2
+
3
+ /**
4
+ * Optional product-provided gate rendered before the application router.
5
+ */
6
+ export interface AppShellGate {
7
+ /** Allows one product entry to disable its gate for unsupported runtimes. */
8
+ enabled(): boolean;
9
+ /** Renders authentication UI and resumes bootstrap through the callback. */
10
+ Component: ComponentType<{ onAuthenticated: () => void }>;
11
+ }
12
+
13
+ let registeredGate: AppShellGate | null = null;
14
+
15
+ /** Registers the app-shell gate before calling `bootstrap()`. */
16
+ export function registerAppShellGate(gate: AppShellGate): void {
17
+ if (registeredGate) {
18
+ throw new Error('[app-shell-gate] a gate is already registered');
19
+ }
20
+ registeredGate = gate;
21
+ }
22
+
23
+ /** Returns the product-provided gate, if one was registered. */
24
+ export function getAppShellGate(): AppShellGate | null {
25
+ return registeredGate;
26
+ }
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import { LuCheck, LuGitBranch, LuListTodo, LuListTree, LuLoaderCircle } from 'react-icons/lu';
4
- import type { LocalChat, LocalChatAgent, LocalTask } from '@/lib/local-api';
4
+ import type { LocalChat, LocalTask } from '@/lib/local-api';
5
5
  import {
6
6
  activateChatBranch,
7
7
  getChatBranches,
@@ -13,7 +13,6 @@ import { actionableTasks, summarizeTasks, type ChatTaskSummary } from './chat/us
13
13
 
14
14
  interface ChatHeaderProps {
15
15
  chat: LocalChat | null;
16
- agent: LocalChatAgent | null;
17
16
  /**
18
17
  * W1.2.1: called after the active branch switches — the page re-hydrates
19
18
  * the message list from the re-projected store. When provided (and the
@@ -29,8 +28,6 @@ interface ChatHeaderProps {
29
28
  /**
30
29
  * ChatHeader — slim title bar above ChatPanel. Shows:
31
30
  * - chat title (完整显示,长标题换行不截断)
32
- * - bound agent name + icon (read-only here; switching is done from the
33
- * sidebar's expert team list)
34
31
  * - shortened chat id (for support / debugging)
35
32
  *
36
33
  * Terminal / 场景包调试窗 / 本地模型设置 USED to live here, but per the
@@ -40,7 +37,6 @@ interface ChatHeaderProps {
40
37
  */
41
38
  export function ChatHeader({
42
39
  chat,
43
- agent,
44
40
  onBranchSwitched,
45
41
  onInspectTask,
46
42
  tasks = [],
@@ -186,18 +182,6 @@ export function ChatHeader({
186
182
  >
187
183
  {chat?.title ?? '未选择对话'}
188
184
  </span>
189
- <span className="shrink-0 text-xs text-agent-muted-foreground">
190
- {agent ? (
191
- <>
192
- <span className="mr-0.5">{agent.icon || '🤖'}</span>
193
- {agent.name}
194
- </>
195
- ) : chat?.agentId ? (
196
- <span className="font-mono">{chat.agentId}</span>
197
- ) : (
198
- <span className="italic">no agent</span>
199
- )}
200
- </span>
201
185
  {chat && (
202
186
  <span className="hidden shrink-0 font-mono text-[10px] text-agent-muted-foreground/70 md:inline">
203
187
  {shortenId(chat.id)}
@@ -1,25 +1,21 @@
1
- import { useEffect, useState } from 'react';
2
1
  import { LuShieldAlert, LuTriangleAlert } from 'react-icons/lu';
3
2
  import {
4
- getElectronBridge,
5
3
  type ApprovalDecisionKind,
6
4
  type ApprovalPromptRequest,
7
5
  } from '@/lib/electron-bridge';
8
6
 
9
7
  /**
10
- * ApprovalModalHost — W4-1 审批代数的 Electron UI 半侧。
8
+ * ApprovalPromptMenu — W4-1 审批代数的输入区 UI。
11
9
  *
12
- * 挂载一次(AgentPage),订阅主进程广播的 `approval:request`,弹出模态
13
- * 让用户对工具调用做 7 变体决策(允许/拒绝 × 一次性/会话/持久 + 中止)。
14
- * 决定经 `approval:decide` IPC 回到主进程,再应答 sidecar 的反向调用。
10
+ * 由 ApprovalPromptProvider 提供当前请求,在普通输入框的位置让用户对
11
+ * 工具调用做 7 变体决策(允许/拒绝 × 一次性/会话/持久 + 中止)。
15
12
  *
16
13
  * 持久化语义(与框架 ApprovalExecutor 对齐):
17
14
  * - 一次性:只作用于本次调用,不缓存。
18
15
  * - 会话:按 category(默认=工具名)缓存在本 chat 的会话级 cache。
19
16
  * - 持久:写入 ~/.steerable/approvals.json,跨会话生效。
20
17
  *
21
- * 队列为 FIFO:模型一批并发多个工具调用时逐个请示(sidecar 串行等待
22
- * 每个应答)。组件卸载/无窗口时主进程 fail-closed 为 deny_once。
18
+ * 请求队列及刷新恢复由 Provider 管理。
23
19
  */
24
20
 
25
21
  const MODE_LABEL: Record<string, string> = {
@@ -88,26 +84,15 @@ function DecisionRow({
88
84
  );
89
85
  }
90
86
 
91
- export function ApprovalModalHost() {
92
- const [queue, setQueue] = useState<ApprovalPromptRequest[]>([]);
93
-
94
- useEffect(() => {
95
- const bridge = getElectronBridge();
96
- if (!bridge?.approval) return;
97
- return bridge.approval.onRequest((request) => {
98
- setQueue((prev) => [...prev, request]);
99
- });
100
- }, []);
101
-
102
- const current = queue[0] ?? null;
103
- if (!current) return null;
104
-
105
- const decide = (kind: ApprovalDecisionKind) => {
106
- const bridge = getElectronBridge();
107
- setQueue((prev) => prev.slice(1));
108
- void bridge?.approval?.decide({ requestId: current.requestId, kind });
109
- };
110
-
87
+ export function ApprovalPromptMenu({
88
+ request: current,
89
+ pendingCount,
90
+ onDecide,
91
+ }: {
92
+ request: ApprovalPromptRequest;
93
+ pendingCount: number;
94
+ onDecide: (kind: ApprovalDecisionKind) => void;
95
+ }) {
111
96
  const destructive = current.mode === 'destructive';
112
97
  // 网络出口拓宽(W-egress-ask):sidecar 的 web 工具被 egress 代理 403
113
98
  // 后发起。白名单是代理进程级的(会话寿命),所以「始终」变体对网络
@@ -121,13 +106,9 @@ export function ApprovalModalHost() {
121
106
 
122
107
  return (
123
108
  <div
124
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
125
- role="dialog"
126
- aria-modal="true"
127
- aria-label="工具调用审批"
128
- data-testid="approval-dialog"
109
+ className="overflow-hidden rounded-agent-lg border border-agent-border bg-agent-canvas text-sm text-agent-foreground shadow-xl"
110
+ data-testid="approval-composer"
129
111
  >
130
- <div className="w-full max-w-lg rounded-agent-lg border border-agent-border bg-agent-canvas shadow-xl">
131
112
  <div className="flex items-center gap-2 border-b border-agent-border px-4 py-3">
132
113
  {destructive ? (
133
114
  <LuTriangleAlert className="h-4 w-4 shrink-0 text-agent-destructive" />
@@ -154,9 +135,9 @@ export function ApprovalModalHost() {
154
135
  </>
155
136
  )}
156
137
  </div>
157
- {queue.length > 1 && (
138
+ {pendingCount > 0 && (
158
139
  <div className="mt-0.5 text-[11px] text-agent-muted-foreground">
159
- 还有 {queue.length - 1} 个待审批
140
+ 还有 {pendingCount} 个待审批
160
141
  </div>
161
142
  )}
162
143
  </div>
@@ -195,7 +176,7 @@ export function ApprovalModalHost() {
195
176
  ? ALLOW_BUTTONS.filter((b) => b.kind !== 'allow_always')
196
177
  : ALLOW_BUTTONS
197
178
  }
198
- onPick={decide}
179
+ onPick={onDecide}
199
180
  />
200
181
  <div className="flex items-center justify-between gap-2">
201
182
  <DecisionRow
@@ -204,11 +185,11 @@ export function ApprovalModalHost() {
204
185
  ? DENY_BUTTONS.filter((b) => b.kind !== 'deny_always')
205
186
  : DENY_BUTTONS
206
187
  }
207
- onPick={decide}
188
+ onPick={onDecide}
208
189
  />
209
190
  <button
210
191
  type="button"
211
- onClick={() => decide('abort')}
192
+ onClick={() => onDecide('abort')}
212
193
  className="shrink-0 rounded-agent-md bg-agent-destructive px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
213
194
  data-testid="approval-abort"
214
195
  >
@@ -221,9 +202,8 @@ export function ApprovalModalHost() {
221
202
  : '「会话」决定在本对话内记住;「始终」决定写入本机 ~/.steerable/approvals.json,跨对话生效。不操作约 2 分钟后按拒绝处理。'}
222
203
  </p>
223
204
  </div>
224
- </div>
225
205
  </div>
226
206
  );
227
207
  }
228
208
 
229
- export default ApprovalModalHost;
209
+ export default ApprovalPromptMenu;
@@ -0,0 +1,98 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import {
12
+ getElectronBridge,
13
+ type ApprovalDecisionKind,
14
+ type ApprovalPromptRequest,
15
+ } from '@/lib/electron-bridge';
16
+
17
+ interface ApprovalPromptContextValue {
18
+ current: ApprovalPromptRequest | null;
19
+ pendingCount: number;
20
+ decide: (kind: ApprovalDecisionKind) => void;
21
+ }
22
+
23
+ const ApprovalPromptContext = createContext<ApprovalPromptContextValue | null>(null);
24
+
25
+ /** Keeps approval prompts alive while the active composer presents them. */
26
+ export function ApprovalPromptProvider({ children }: { children: ReactNode }) {
27
+ const [queue, setQueue] = useState<ApprovalPromptRequest[]>([]);
28
+ const decidedRequestIds = useRef(new Set<string>());
29
+
30
+ useEffect(() => {
31
+ const bridge = getElectronBridge();
32
+ if (!bridge?.approval) return;
33
+ let active = true;
34
+ const addRequests = (
35
+ requests: ApprovalPromptRequest[],
36
+ placement: 'append' | 'prepend' = 'append',
37
+ ) => {
38
+ setQueue((previous) => {
39
+ const known = new Set(previous.map(({ requestId }) => requestId));
40
+ const additions = requests.filter(
41
+ ({ requestId }) =>
42
+ !known.has(requestId) && !decidedRequestIds.current.has(requestId),
43
+ );
44
+ if (additions.length === 0) return previous;
45
+ return placement === 'prepend'
46
+ ? [...additions, ...previous]
47
+ : [...previous, ...additions];
48
+ });
49
+ };
50
+ const unsubscribe = bridge.approval.onRequest((request) => {
51
+ addRequests([request]);
52
+ });
53
+ void bridge.approval
54
+ .pending()
55
+ .then((requests) => {
56
+ if (active) addRequests(requests, 'prepend');
57
+ })
58
+ .catch((error) => {
59
+ console.error('恢复待审批请求失败:', error);
60
+ });
61
+ return () => {
62
+ active = false;
63
+ unsubscribe();
64
+ };
65
+ }, []);
66
+
67
+ const current = queue[0] ?? null;
68
+ const decide = useCallback(
69
+ (kind: ApprovalDecisionKind) => {
70
+ if (!current) return;
71
+ const bridge = getElectronBridge();
72
+ decidedRequestIds.current.add(current.requestId);
73
+ setQueue((previous) => previous.slice(1));
74
+ void bridge?.approval?.decide({ requestId: current.requestId, kind });
75
+ },
76
+ [current],
77
+ );
78
+
79
+ const value = useMemo(
80
+ () => ({
81
+ current,
82
+ pendingCount: Math.max(0, queue.length - 1),
83
+ decide,
84
+ }),
85
+ [current, decide, queue.length],
86
+ );
87
+
88
+ return (
89
+ <ApprovalPromptContext.Provider value={value}>
90
+ {children}
91
+ </ApprovalPromptContext.Provider>
92
+ );
93
+ }
94
+
95
+ /** Returns the approval prompt currently assigned to the chat composer. */
96
+ export function useApprovalPrompt(): ApprovalPromptContextValue | null {
97
+ return useContext(ApprovalPromptContext);
98
+ }
@@ -0,0 +1,99 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import { getElectronBridge, type AskUserPromptRequest } from '@/lib/electron-bridge';
12
+
13
+ type AnswerValue = string | string[];
14
+
15
+ interface AskUserPromptContextValue {
16
+ current: AskUserPromptRequest | null;
17
+ pendingCount: number;
18
+ answer: (answers: Record<string, AnswerValue>) => void;
19
+ }
20
+
21
+ const AskUserPromptContext = createContext<AskUserPromptContextValue | null>(null);
22
+
23
+ /**
24
+ * Keeps structured user prompts alive across chat navigation while allowing
25
+ * the active composer to present the current prompt.
26
+ */
27
+ export function AskUserPromptProvider({ children }: { children: ReactNode }) {
28
+ const [queue, setQueue] = useState<AskUserPromptRequest[]>([]);
29
+ const answeredRequestIds = useRef(new Set<string>());
30
+
31
+ useEffect(() => {
32
+ const bridge = getElectronBridge();
33
+ if (!bridge?.askUser) return;
34
+ let active = true;
35
+ const addRequests = (
36
+ requests: AskUserPromptRequest[],
37
+ placement: 'append' | 'prepend' = 'append',
38
+ ) => {
39
+ setQueue((previous) => {
40
+ const known = new Set(previous.map(({ requestId }) => requestId));
41
+ const additions = requests.filter(
42
+ ({ requestId }) =>
43
+ !known.has(requestId) && !answeredRequestIds.current.has(requestId),
44
+ );
45
+ if (additions.length === 0) return previous;
46
+ return placement === 'prepend'
47
+ ? [...additions, ...previous]
48
+ : [...previous, ...additions];
49
+ });
50
+ };
51
+ const unsubscribe = bridge.askUser.onRequest((request) => {
52
+ addRequests([request]);
53
+ });
54
+ void bridge.askUser
55
+ .pending()
56
+ .then((requests) => {
57
+ if (active) addRequests(requests, 'prepend');
58
+ })
59
+ .catch((error) => {
60
+ console.error('恢复待回答问题失败:', error);
61
+ });
62
+ return () => {
63
+ active = false;
64
+ unsubscribe();
65
+ };
66
+ }, []);
67
+
68
+ const current = queue[0] ?? null;
69
+ const answer = useCallback(
70
+ (answers: Record<string, AnswerValue>) => {
71
+ if (!current) return;
72
+ const bridge = getElectronBridge();
73
+ answeredRequestIds.current.add(current.requestId);
74
+ setQueue((previous) => previous.slice(1));
75
+ void bridge?.askUser?.answer({ requestId: current.requestId, answers });
76
+ },
77
+ [current],
78
+ );
79
+
80
+ const value = useMemo(
81
+ () => ({
82
+ current,
83
+ pendingCount: Math.max(0, queue.length - 1),
84
+ answer,
85
+ }),
86
+ [answer, current, queue.length],
87
+ );
88
+
89
+ return (
90
+ <AskUserPromptContext.Provider value={value}>
91
+ {children}
92
+ </AskUserPromptContext.Provider>
93
+ );
94
+ }
95
+
96
+ /** Returns the structured prompt currently assigned to the chat composer. */
97
+ export function useAskUserPrompt(): AskUserPromptContextValue | null {
98
+ return useContext(AskUserPromptContext);
99
+ }
@@ -11,6 +11,8 @@ import { getFriendlyDate } from './timestamp';
11
11
  import { useCopy } from './useCopy';
12
12
  import type { TurnBlock } from './turn-timeline';
13
13
  import { TurnProcessGroup } from './TurnProcessGroup';
14
+ import { TurnFilesCard } from './TurnFilesCard';
15
+ import type { TurnFile } from './turn-files';
14
16
 
15
17
  /**
16
18
  * AssistantMessage — Tier-1 port of `deeppath`'s assistant-bubble.
@@ -55,6 +57,11 @@ interface AssistantMessageProps {
55
57
  */
56
58
  agents: LocalChatAgent[];
57
59
  chats?: LocalChat[];
60
+ /**
61
+ * 当前对话。正文行内代码里的路径按它绑定的项目根解析相对路径后,
62
+ * 确认存在的会变成可点击(见 FilePathCode)。
63
+ */
64
+ chatId?: string | null;
58
65
  /** Active chat-level agent — used as a fallback during streaming. */
59
66
  currentAgent: LocalChatAgent | null;
60
67
  /**
@@ -91,6 +98,12 @@ interface AssistantMessageProps {
91
98
  startedAtMs?: number;
92
99
  /** Frozen wall-clock of a finished turn. */
93
100
  durationMs?: number;
101
+ /**
102
+ * 本回合产生/修改的文件列表(local-backend 回合收尾时收集,经
103
+ * `turn_files` SSE + messageMetadata.turnFiles 持久化)。渲染在回答
104
+ * 气泡之下,点击用系统默认应用打开。空/undefined = 不渲染。
105
+ */
106
+ turnFiles?: TurnFile[];
94
107
  /**
95
108
  * 分享当前对话(截图复制到剪贴板)。只传给最近一条助手消息,画在时间戳
96
109
  * 行上,跟复制 / 重新生成同一排。
@@ -198,6 +211,7 @@ export function AssistantMessage({
198
211
  isStreaming,
199
212
  agents,
200
213
  chats = [],
214
+ chatId = null,
201
215
  currentAgent,
202
216
  executedActions,
203
217
  timeline,
@@ -207,6 +221,7 @@ export function AssistantMessage({
207
221
  onRegenerate,
208
222
  startedAtMs,
209
223
  durationMs,
224
+ turnFiles,
210
225
  onShare,
211
226
  }: AssistantMessageProps) {
212
227
  const content = message.content || '';
@@ -263,6 +278,7 @@ export function AssistantMessage({
263
278
  durationMs={durationMs}
264
279
  agents={agents}
265
280
  chats={chats}
281
+ chatId={chatId}
266
282
  emptyFallback={
267
283
  failure ? (
268
284
  <TurnErrorBubble reason={failure.reason} />
@@ -290,7 +306,7 @@ export function AssistantMessage({
290
306
  renderAnswer={(block, isLast) => (
291
307
  <div className={bubbleClass(isPlanMode)}>
292
308
  <div className="markdown-content text-sm leading-relaxed text-agent-foreground">
293
- <Markdown agents={agents} chats={chats}>{block.content}</Markdown>
309
+ <Markdown agents={agents} chats={chats} chatId={chatId}>{block.content}</Markdown>
294
310
  </div>
295
311
  {isStreaming && isLast && <StreamingCursor />}
296
312
  </div>
@@ -312,7 +328,7 @@ export function AssistantMessage({
312
328
  {displayContent ? (
313
329
  <div className={bubbleClass(isPlanMode)}>
314
330
  <div className="markdown-content text-sm leading-relaxed text-agent-foreground">
315
- <Markdown agents={agents} chats={chats}>{displayContent}</Markdown>
331
+ <Markdown agents={agents} chats={chats} chatId={chatId}>{displayContent}</Markdown>
316
332
  </div>
317
333
  {isStreaming && <StreamingCursor />}
318
334
  </div>
@@ -338,6 +354,13 @@ export function AssistantMessage({
338
354
  ) : null}
339
355
  </>
340
356
  )}
357
+ {/* 回合产物文件列表:钉在回答之下、时间戳行之上(Codex 式收尾)。
358
+ 回合收尾才有数据,流式期间天然为空。 */}
359
+ {!isStreaming && turnFiles && turnFiles.length > 0 && (
360
+ <div className="mt-1.5">
361
+ <TurnFilesCard files={turnFiles} />
362
+ </div>
363
+ )}
341
364
  <div className="mt-1 flex items-center gap-2 text-[11px] text-agent-muted-foreground">
342
365
  <span>
343
366
  {message.createdAt