@steerable/agent-shell-web 0.6.20 → 0.6.21
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 +4 -4
- package/src/components/chat/ApprovalModal.tsx +21 -41
- package/src/components/chat/ApprovalPromptProvider.tsx +98 -0
- package/src/components/chat/AskUserPromptProvider.tsx +99 -0
- package/src/components/chat/AssistantMessage.tsx +25 -2
- package/src/components/chat/ChatInput.tsx +116 -13
- package/src/components/chat/FilePathCode.tsx +65 -0
- package/src/components/chat/LocalChatPanel.tsx +19 -0
- package/src/components/chat/Markdown.tsx +54 -25
- package/src/components/chat/MessageList.tsx +66 -18
- package/src/components/chat/ModelPicker.tsx +5 -3
- package/src/components/chat/SuggestedReplies.tsx +34 -0
- package/src/components/chat/TurnFilesCard.tsx +113 -0
- package/src/components/chat/TurnProcessGroup.tsx +7 -2
- package/src/components/chat/path-mentions.ts +148 -0
- package/src/components/chat/suggested-replies-model.ts +40 -0
- package/src/components/chat/turn-files.ts +50 -0
- package/src/layouts/AgentLayout.tsx +15 -26
- package/src/lib/browser-dev-electron-mock.ts +15 -0
- package/src/lib/electron-bridge.ts +13 -7
- package/src/lib/http-bridge.ts +8 -2
- package/src/lib/local-api.ts +31 -0
- package/src/pages/AgentPage.tsx +126 -1
- package/src/pages/ChatPanelPreviewPage.tsx +4 -0
- package/src/components/chat/AskUserModalHost.tsx +0 -61
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.21",
|
|
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.
|
|
49
|
-
"@steerable/agent-protocol": "0.6.
|
|
48
|
+
"@steerable/agent-ui": "0.6.21",
|
|
49
|
+
"@steerable/agent-protocol": "0.6.21"
|
|
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.
|
|
62
|
+
"@steerable/pack-sdk": "0.6.21"
|
|
63
63
|
},
|
|
64
64
|
"scripts": {
|
|
65
65
|
"gen": "echo agent-shell-web",
|
|
@@ -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
|
-
*
|
|
8
|
+
* ApprovalPromptMenu — W4-1 审批代数的输入区 UI。
|
|
11
9
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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="
|
|
125
|
-
|
|
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
|
-
{
|
|
138
|
+
{pendingCount > 0 && (
|
|
158
139
|
<div className="mt-0.5 text-[11px] text-agent-muted-foreground">
|
|
159
|
-
还有 {
|
|
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={
|
|
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={
|
|
188
|
+
onPick={onDecide}
|
|
208
189
|
/>
|
|
209
190
|
<button
|
|
210
191
|
type="button"
|
|
211
|
-
onClick={() =>
|
|
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
|
|
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
|
|
@@ -42,6 +42,10 @@ import {
|
|
|
42
42
|
type McpToolItem,
|
|
43
43
|
type SkillItem,
|
|
44
44
|
} from '@/lib/slash-sources';
|
|
45
|
+
import { useAskUserPrompt } from './AskUserPromptProvider';
|
|
46
|
+
import { AskUserQuestionMenu } from './AskUserQuestionMenu';
|
|
47
|
+
import { useApprovalPrompt } from './ApprovalPromptProvider';
|
|
48
|
+
import { ApprovalPromptMenu } from './ApprovalModal';
|
|
45
49
|
import { ExecPolicyPicker } from './ExecPolicyPicker';
|
|
46
50
|
|
|
47
51
|
export type ChatMode = 'agent' | 'plan';
|
|
@@ -704,6 +708,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
704
708
|
},
|
|
705
709
|
ref,
|
|
706
710
|
) {
|
|
711
|
+
const askUserPrompt = useAskUserPrompt();
|
|
712
|
+
const approvalPrompt = useApprovalPrompt();
|
|
707
713
|
const editorRef = useRef<HTMLDivElement>(null);
|
|
708
714
|
const agentMenuRef = useRef<HTMLDivElement>(null);
|
|
709
715
|
const skillOptionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
@@ -731,6 +737,12 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
731
737
|
const compositionEndAtRef = useRef<number>(0);
|
|
732
738
|
|
|
733
739
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
740
|
+
// 工具栏"空间不足时优先隐藏快捷键提示"的测量 refs,见下方 useLayoutEffect。
|
|
741
|
+
const toolbarRowRef = useRef<HTMLDivElement>(null);
|
|
742
|
+
const toolbarLeftRef = useRef<HTMLDivElement>(null);
|
|
743
|
+
const hintRef = useRef<HTMLDivElement>(null);
|
|
744
|
+
const sendButtonRef = useRef<HTMLButtonElement>(null);
|
|
745
|
+
const [hintsSuppressed, setHintsSuppressed] = useState(false);
|
|
734
746
|
const [localFiles, setLocalFiles] = useState<AttachmentFile[]>([]);
|
|
735
747
|
const actualFiles = files ?? localFiles;
|
|
736
748
|
const actualOnFilesChange = onFilesChange ?? setLocalFiles;
|
|
@@ -1050,6 +1062,50 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1050
1062
|
return () => window.removeEventListener('keydown', onKey);
|
|
1051
1063
|
}, [isStreaming, onCancel]);
|
|
1052
1064
|
|
|
1065
|
+
// 快捷键提示是工具栏里优先级最低的元素:空间不够时第一个隐藏(先于
|
|
1066
|
+
// 左侧选择器截断)。容器查询只看输入盒宽度、看不到左侧内容(模型 id
|
|
1067
|
+
// 长度可变)的实际占用,所以直接测量:左组自然宽度 + 提示 + 发送键
|
|
1068
|
+
// 放不下就收起提示。测量时临时取消左组 flex 收缩并强制显示提示,
|
|
1069
|
+
// 拿到的都是不依赖当前收起状态的自然宽度——结果稳定,不会来回闪烁。
|
|
1070
|
+
useLayoutEffect(() => {
|
|
1071
|
+
const row = toolbarRowRef.current;
|
|
1072
|
+
const left = toolbarLeftRef.current;
|
|
1073
|
+
if (!row || !left) return;
|
|
1074
|
+
|
|
1075
|
+
const check = () => {
|
|
1076
|
+
if (row.clientWidth === 0) return; // 无布局环境(happy-dom)不干预
|
|
1077
|
+
const hint = hintRef.current;
|
|
1078
|
+
const send = sendButtonRef.current;
|
|
1079
|
+
const prevShrink = left.style.flexShrink;
|
|
1080
|
+
left.style.flexShrink = '0';
|
|
1081
|
+
const prevHintDisplay = hint?.style.display ?? '';
|
|
1082
|
+
if (hint) hint.style.display = 'block';
|
|
1083
|
+
const needed =
|
|
1084
|
+
left.getBoundingClientRect().width +
|
|
1085
|
+
(hint?.getBoundingClientRect().width ?? 0) +
|
|
1086
|
+
(send?.getBoundingClientRect().width ?? 0) +
|
|
1087
|
+
16; // gap-2 ×2:左右组之间 + 提示与发送键之间
|
|
1088
|
+
left.style.flexShrink = prevShrink;
|
|
1089
|
+
if (hint) hint.style.display = prevHintDisplay;
|
|
1090
|
+
const available = row.clientWidth - 16; // px-2 两侧
|
|
1091
|
+
const suppress = needed > available + 1;
|
|
1092
|
+
setHintsSuppressed((prev) => (prev === suppress ? prev : suppress));
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
check();
|
|
1096
|
+
const resizeObs = new ResizeObserver(check);
|
|
1097
|
+
resizeObs.observe(row);
|
|
1098
|
+
// 左组内容变化(切换模型/沙箱档、设置齿轮挂载)不改变行宽,
|
|
1099
|
+
// ResizeObserver 观察不到,用 MutationObserver 兜底重新测量。
|
|
1100
|
+
const mutationObs = new MutationObserver(check);
|
|
1101
|
+
mutationObs.observe(left, { childList: true, subtree: true, characterData: true });
|
|
1102
|
+
return () => {
|
|
1103
|
+
resizeObs.disconnect();
|
|
1104
|
+
mutationObs.disconnect();
|
|
1105
|
+
};
|
|
1106
|
+
// isStreaming 切换提示文案(kbd 标签不同、宽度不同),需要重测。
|
|
1107
|
+
}, [isStreaming]);
|
|
1108
|
+
|
|
1053
1109
|
const trimmed = value.trim();
|
|
1054
1110
|
const canSend = (trimmed.length > 0 || actualFiles.length > 0) && !disabled && !isStreaming;
|
|
1055
1111
|
|
|
@@ -1454,6 +1510,42 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1454
1510
|
editorRef.current?.focus();
|
|
1455
1511
|
};
|
|
1456
1512
|
|
|
1513
|
+
if (approvalPrompt?.current) {
|
|
1514
|
+
return (
|
|
1515
|
+
<div className="chat-input-container @container relative px-3 pb-3 pt-1">
|
|
1516
|
+
<ApprovalPromptMenu
|
|
1517
|
+
key={approvalPrompt.current.requestId}
|
|
1518
|
+
request={approvalPrompt.current}
|
|
1519
|
+
pendingCount={approvalPrompt.pendingCount}
|
|
1520
|
+
onDecide={approvalPrompt.decide}
|
|
1521
|
+
/>
|
|
1522
|
+
</div>
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
if (askUserPrompt?.current) {
|
|
1527
|
+
const request = askUserPrompt.current;
|
|
1528
|
+
return (
|
|
1529
|
+
<div
|
|
1530
|
+
className="chat-input-container @container relative px-3 pb-3 pt-1"
|
|
1531
|
+
data-testid="ask-user-composer"
|
|
1532
|
+
>
|
|
1533
|
+
<AskUserQuestionMenu
|
|
1534
|
+
key={request.requestId}
|
|
1535
|
+
intro={request.intro}
|
|
1536
|
+
questions={request.questions}
|
|
1537
|
+
onSubmit={askUserPrompt.answer}
|
|
1538
|
+
onAutoContinue={() => askUserPrompt.answer({})}
|
|
1539
|
+
bottomHint={
|
|
1540
|
+
askUserPrompt.pendingCount > 0
|
|
1541
|
+
? `还有 ${askUserPrompt.pendingCount} 组问题待回答`
|
|
1542
|
+
: undefined
|
|
1543
|
+
}
|
|
1544
|
+
/>
|
|
1545
|
+
</div>
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1457
1549
|
return (
|
|
1458
1550
|
// @container:底栏元素的显隐/宽度用容器查询(@sm = 384px)而不是
|
|
1459
1551
|
// 视口断点——侧边栏占宽后,视口够宽但输入框本身可能已经很窄。
|
|
@@ -1708,11 +1800,14 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1708
1800
|
))}
|
|
1709
1801
|
</div>
|
|
1710
1802
|
)}
|
|
1711
|
-
<div
|
|
1803
|
+
<div
|
|
1804
|
+
ref={toolbarRowRef}
|
|
1805
|
+
className="flex items-center justify-between gap-2 px-2 pb-2 pt-1"
|
|
1806
|
+
>
|
|
1712
1807
|
{/* Left toolbar: mode + exec sandbox + host extras + settings.
|
|
1713
1808
|
Agent picker lives in the meta row above the box, next to
|
|
1714
1809
|
the project badge. min-w-0 允许窄屏时胶囊截断收缩。 */}
|
|
1715
|
-
<div className="flex min-w-0 items-center gap-1">
|
|
1810
|
+
<div ref={toolbarLeftRef} className="flex min-w-0 items-center gap-1">
|
|
1716
1811
|
{mode && onModeChange && (
|
|
1717
1812
|
<ModeToggle
|
|
1718
1813
|
mode={mode}
|
|
@@ -1732,7 +1827,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1732
1827
|
type="button"
|
|
1733
1828
|
onClick={handlePickFiles}
|
|
1734
1829
|
disabled={disabled || isStreaming}
|
|
1735
|
-
className="flex h-7 w-7 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
1830
|
+
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
1736
1831
|
title="上传文件(可多选)"
|
|
1737
1832
|
aria-label="上传文件"
|
|
1738
1833
|
>
|
|
@@ -1752,7 +1847,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1752
1847
|
<button
|
|
1753
1848
|
type="button"
|
|
1754
1849
|
onClick={onOpenSettings}
|
|
1755
|
-
className="flex h-7 w-7 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
1850
|
+
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
1756
1851
|
title="LLM 设置"
|
|
1757
1852
|
aria-label="LLM 设置"
|
|
1758
1853
|
data-testid="chat-llm-settings"
|
|
@@ -1761,9 +1856,14 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1761
1856
|
</button>
|
|
1762
1857
|
)}
|
|
1763
1858
|
</div>
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1859
|
+
{/* shrink-0:发送键是固定尺寸圆钮,窄容器下不允许被压扁;
|
|
1860
|
+
收缩量由左侧可截断的选择器吸收。 */}
|
|
1861
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
1862
|
+
{/* 快捷键提示:空间不足时优先隐藏(测量逻辑见上方 effect) */}
|
|
1863
|
+
<div
|
|
1864
|
+
ref={hintRef}
|
|
1865
|
+
className={`${hintsSuppressed ? 'hidden' : 'block'} text-[11px] text-agent-muted-foreground/80`}
|
|
1866
|
+
>
|
|
1767
1867
|
{isStreaming ? (
|
|
1768
1868
|
<>
|
|
1769
1869
|
<kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
|
|
@@ -1815,15 +1915,16 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1815
1915
|
)}
|
|
1816
1916
|
</div>
|
|
1817
1917
|
<button
|
|
1918
|
+
ref={sendButtonRef}
|
|
1818
1919
|
type="button"
|
|
1819
1920
|
onClick={handleSendClick}
|
|
1820
1921
|
disabled={!isStreaming && !canSend}
|
|
1821
1922
|
className={
|
|
1822
1923
|
isStreaming
|
|
1823
|
-
? 'flex h-8 w-8 items-center justify-center rounded-full bg-agent-destructive text-white transition hover:opacity-90'
|
|
1924
|
+
? 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-agent-destructive text-white transition hover:opacity-90'
|
|
1824
1925
|
: canSend
|
|
1825
|
-
? 'flex h-8 w-8 items-center justify-center rounded-full bg-agent-foreground text-agent-canvas transition hover:opacity-90'
|
|
1826
|
-
: 'flex h-8 w-8 cursor-not-allowed items-center justify-center rounded-full bg-agent-muted text-agent-muted-foreground'
|
|
1926
|
+
? 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-agent-foreground text-agent-canvas transition hover:opacity-90'
|
|
1927
|
+
: 'flex h-8 w-8 shrink-0 cursor-not-allowed items-center justify-center rounded-full bg-agent-muted text-agent-muted-foreground'
|
|
1827
1928
|
}
|
|
1828
1929
|
title={
|
|
1829
1930
|
isStreaming
|
|
@@ -1867,8 +1968,10 @@ function ModeToggle({
|
|
|
1867
1968
|
onChange: (mode: ChatMode) => void;
|
|
1868
1969
|
}) {
|
|
1869
1970
|
return (
|
|
1971
|
+
// shrink-0:分段控件被 flex 压缩会压扁文字;极窄容器(<@sm)退化为
|
|
1972
|
+
// 纯图标(title 兜底语义),把收缩量让给可截断的选择器。
|
|
1870
1973
|
<div
|
|
1871
|
-
className="inline-flex h-7 items-center rounded-full border border-agent-border bg-agent-canvas p-0.5"
|
|
1974
|
+
className="inline-flex h-7 shrink-0 items-center rounded-full border border-agent-border bg-agent-canvas p-0.5"
|
|
1872
1975
|
role="radiogroup"
|
|
1873
1976
|
aria-label="对话模式"
|
|
1874
1977
|
data-testid="mode-toggle"
|
|
@@ -1889,7 +1992,7 @@ function ModeToggle({
|
|
|
1889
1992
|
].join(' ')}
|
|
1890
1993
|
>
|
|
1891
1994
|
<LuInfinity className="h-3.5 w-3.5" />
|
|
1892
|
-
<span>Agent</span>
|
|
1995
|
+
<span className="@max-sm:hidden">Agent</span>
|
|
1893
1996
|
</button>
|
|
1894
1997
|
<button
|
|
1895
1998
|
type="button"
|
|
@@ -1907,7 +2010,7 @@ function ModeToggle({
|
|
|
1907
2010
|
].join(' ')}
|
|
1908
2011
|
>
|
|
1909
2012
|
<LuListChecks className="h-3.5 w-3.5" />
|
|
1910
|
-
<span>Plan</span>
|
|
2013
|
+
<span className="@max-sm:hidden">Plan</span>
|
|
1911
2014
|
</button>
|
|
1912
2015
|
</div>
|
|
1913
2016
|
);
|