@steerable/agent-shell-web 0.6.74 → 0.7.1
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/AppShell.tsx +2 -0
- package/src/components/AgentSidebar.tsx +340 -13
- package/src/components/ChatHeader.tsx +95 -0
- package/src/components/TerminalView.tsx +5 -0
- package/src/components/ZoomToast.tsx +52 -0
- package/src/components/chat/ApprovalModal.tsx +86 -22
- package/src/components/chat/ExecutedActionsCard.tsx +13 -1
- package/src/components/chat/executed-actions-model.ts +32 -10
- package/src/components/chat/timestamp.ts +12 -0
- package/src/components/settings/AppearanceSettingsPanel.tsx +87 -0
- package/src/i18n/index.tsx +8 -1
- package/src/layouts/AgentLayout.tsx +8 -0
- package/src/lib/chat-row-keys.ts +10 -0
- package/src/lib/host-bridge.ts +21 -0
- package/src/lib/http-bridge.ts +2 -1
- package/src/lib/local-api.ts +32 -0
- package/src/lib/tauri-bridge.ts +21 -0
- package/src/main.tsx +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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-protocol": "0.
|
|
49
|
-
"@steerable/agent-ui": "0.
|
|
48
|
+
"@steerable/agent-protocol": "0.7.1",
|
|
49
|
+
"@steerable/agent-ui": "0.7.1"
|
|
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.
|
|
62
|
+
"@steerable/pack-sdk": "0.7.1"
|
|
63
63
|
},
|
|
64
64
|
"scripts": {
|
|
65
65
|
"gen": "echo agent-shell-web",
|
package/src/AppShell.tsx
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Outlet } from 'react-router-dom';
|
|
2
|
+
import { ZoomToast } from './components/ZoomToast';
|
|
2
3
|
import { hasHostBridge } from './lib/host-bridge';
|
|
3
4
|
import { isDemoMode } from './lib/demo-flag';
|
|
4
5
|
|
|
5
6
|
export function AppShell() {
|
|
6
7
|
return (
|
|
7
8
|
<div className="flex h-full w-full flex-col bg-agent-canvas text-agent-foreground">
|
|
9
|
+
<ZoomToast />
|
|
8
10
|
<main className="flex flex-1 overflow-hidden">
|
|
9
11
|
<Outlet />
|
|
10
12
|
</main>
|
|
@@ -88,10 +88,13 @@ import {
|
|
|
88
88
|
LuFolderPlus,
|
|
89
89
|
LuPencil,
|
|
90
90
|
LuEllipsis,
|
|
91
|
+
LuGitBranch,
|
|
91
92
|
LuCircleHelp,
|
|
93
|
+
LuTerminal,
|
|
92
94
|
} from "react-icons/lu";
|
|
93
95
|
import { RiPushpin2Fill, RiPushpin2Line } from "react-icons/ri";
|
|
94
96
|
import { parseChatTitle } from "@/lib/chat-title";
|
|
97
|
+
import { chatRowKeys } from "@/lib/chat-row-keys";
|
|
95
98
|
import { getDateGroupLabel, getDateGroupPriority } from "@/lib/date-groups";
|
|
96
99
|
import { getHostBridge, hasHostBridge } from "@/lib/host-bridge";
|
|
97
100
|
import { hasGeneralSettingsChrome, hostToolChrome, settingsChrome } from "@/lib/host-tools";
|
|
@@ -100,7 +103,9 @@ import {
|
|
|
100
103
|
deleteProject,
|
|
101
104
|
getChatLiveStream,
|
|
102
105
|
openLocalPath,
|
|
106
|
+
forkChat,
|
|
103
107
|
setChatPinned,
|
|
108
|
+
updateChatTitle,
|
|
104
109
|
updateProject,
|
|
105
110
|
type LocalChat,
|
|
106
111
|
type LocalChatAgent,
|
|
@@ -175,6 +180,103 @@ function placeProjectMenu(anchor: HTMLElement): { top: number; left: number } {
|
|
|
175
180
|
return { top: Math.max(8, box.top), left };
|
|
176
181
|
}
|
|
177
182
|
|
|
183
|
+
function formatForkBytes(bytes: number | undefined): string {
|
|
184
|
+
if (typeof bytes !== "number" || !Number.isFinite(bytes)) return "";
|
|
185
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
186
|
+
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
187
|
+
return `${bytes} B`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function ChatRowMenu({
|
|
191
|
+
anchor,
|
|
192
|
+
onClose,
|
|
193
|
+
onRename,
|
|
194
|
+
onFork,
|
|
195
|
+
onDelete,
|
|
196
|
+
}: {
|
|
197
|
+
anchor: HTMLElement;
|
|
198
|
+
onClose: () => void;
|
|
199
|
+
onRename: () => void;
|
|
200
|
+
onFork: () => void;
|
|
201
|
+
onDelete: () => void;
|
|
202
|
+
}) {
|
|
203
|
+
const pos = placeProjectMenu(anchor);
|
|
204
|
+
const buttonRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
205
|
+
const [index, setIndex] = useState(0);
|
|
206
|
+
const actions = [
|
|
207
|
+
{ testId: "sidebar-chat-rename", label: t("Rename"), run: onRename, danger: false, ariaLabel: undefined as string | undefined },
|
|
208
|
+
{ testId: "sidebar-chat-fork", label: t("Fork"), run: onFork, danger: false, ariaLabel: undefined as string | undefined },
|
|
209
|
+
{ testId: "sidebar-chat-delete", label: t("Delete"), run: onDelete, danger: true, ariaLabel: t("Delete chat") },
|
|
210
|
+
];
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
buttonRefs.current[index]?.focus();
|
|
213
|
+
}, [index]);
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
const onKey = (event: KeyboardEvent) => {
|
|
216
|
+
if (event.key === "Escape") {
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
onClose();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
const count = actions.length;
|
|
224
|
+
setIndex((current) =>
|
|
225
|
+
event.key === "ArrowDown" ? (current + 1) % count : (current - 1 + count) % count,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
window.addEventListener("keydown", onKey);
|
|
230
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
231
|
+
}, [onClose, actions.length]);
|
|
232
|
+
return createPortal(
|
|
233
|
+
<>
|
|
234
|
+
<div className="fixed inset-0 z-[90]" onClick={onClose} />
|
|
235
|
+
<div
|
|
236
|
+
role="menu"
|
|
237
|
+
className="fixed z-[91] w-44 overflow-hidden rounded-2xl border border-agent-border bg-agent-canvas p-1 shadow-lg"
|
|
238
|
+
style={pos}
|
|
239
|
+
data-testid="sidebar-chat-menu"
|
|
240
|
+
>
|
|
241
|
+
{actions.map((action, actionIndex) => (
|
|
242
|
+
<button
|
|
243
|
+
key={action.testId}
|
|
244
|
+
ref={(node) => {
|
|
245
|
+
buttonRefs.current[actionIndex] = node;
|
|
246
|
+
}}
|
|
247
|
+
type="button"
|
|
248
|
+
role="menuitem"
|
|
249
|
+
data-testid={action.testId}
|
|
250
|
+
title={action.ariaLabel}
|
|
251
|
+
aria-label={action.ariaLabel}
|
|
252
|
+
onClick={action.run}
|
|
253
|
+
onKeyDown={(event) => {
|
|
254
|
+
if (event.key !== "Enter") return;
|
|
255
|
+
event.preventDefault();
|
|
256
|
+
action.run();
|
|
257
|
+
}}
|
|
258
|
+
className={[
|
|
259
|
+
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs hover:bg-agent-foreground/5",
|
|
260
|
+
action.danger ? "text-agent-destructive" : "text-agent-foreground",
|
|
261
|
+
actionIndex === actions.length - 1 ? "mt-1 border-t border-agent-border/60" : "",
|
|
262
|
+
].join(" ")}
|
|
263
|
+
>
|
|
264
|
+
{actionIndex === 0 ? (
|
|
265
|
+
<LuPencil className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
|
|
266
|
+
) : action.danger ? (
|
|
267
|
+
<LuTrash2 className="h-3.5 w-3.5 shrink-0" />
|
|
268
|
+
) : (
|
|
269
|
+
<LuGitBranch className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
|
|
270
|
+
)}
|
|
271
|
+
{action.label}
|
|
272
|
+
</button>
|
|
273
|
+
))}
|
|
274
|
+
</div>
|
|
275
|
+
</>,
|
|
276
|
+
document.body,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
178
280
|
function ProjectOverflowMenu({
|
|
179
281
|
project,
|
|
180
282
|
chatCount,
|
|
@@ -369,6 +471,7 @@ export function AgentSidebar({
|
|
|
369
471
|
refreshChats,
|
|
370
472
|
setSelectedAgentId,
|
|
371
473
|
deleteChat,
|
|
474
|
+
patchChatTitle,
|
|
372
475
|
isLoadingMoreChats,
|
|
373
476
|
hasMoreChats,
|
|
374
477
|
loadMoreChats,
|
|
@@ -567,6 +670,23 @@ export function AgentSidebar({
|
|
|
567
670
|
null,
|
|
568
671
|
);
|
|
569
672
|
const [renamingValue, setRenamingValue] = useState("");
|
|
673
|
+
const [chatMenu, setChatMenu] = useState<{
|
|
674
|
+
id: string;
|
|
675
|
+
title: string;
|
|
676
|
+
anchor: HTMLElement;
|
|
677
|
+
} | null>(null);
|
|
678
|
+
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
|
|
679
|
+
const [renamingChatValue, setRenamingChatValue] = useState("");
|
|
680
|
+
const [renameError, setRenameError] = useState<string | null>(null);
|
|
681
|
+
const [renameHint, setRenameHint] = useState<{
|
|
682
|
+
chatId: string;
|
|
683
|
+
text: string;
|
|
684
|
+
} | null>(null);
|
|
685
|
+
const renameCancelledRef = useRef(false);
|
|
686
|
+
const renameBusyRef = useRef(false);
|
|
687
|
+
const [renamingBusy, setRenamingBusy] = useState(false);
|
|
688
|
+
const [forkingChatId, setForkingChatId] = useState<string | null>(null);
|
|
689
|
+
const forkingChatRef = useRef<string | null>(null);
|
|
570
690
|
const [pendingDeleteProject, setPendingDeleteProject] =
|
|
571
691
|
useState<LocalProject | null>(null);
|
|
572
692
|
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(
|
|
@@ -908,10 +1028,92 @@ export function AgentSidebar({
|
|
|
908
1028
|
[pinningChatId, refreshChats],
|
|
909
1029
|
);
|
|
910
1030
|
|
|
1031
|
+
useEffect(() => {
|
|
1032
|
+
if (!renameHint) return;
|
|
1033
|
+
const timer = window.setTimeout(() => setRenameHint(null), 3000);
|
|
1034
|
+
return () => window.clearTimeout(timer);
|
|
1035
|
+
}, [renameHint]);
|
|
1036
|
+
|
|
1037
|
+
const beginChatRename = useCallback((chat: { id: string; title: string }) => {
|
|
1038
|
+
setChatMenu(null);
|
|
1039
|
+
setRenameError(null);
|
|
1040
|
+
setRenameHint(null);
|
|
1041
|
+
renameCancelledRef.current = false;
|
|
1042
|
+
setRenamingChatId(chat.id);
|
|
1043
|
+
setRenamingChatValue(chat.title);
|
|
1044
|
+
}, []);
|
|
1045
|
+
|
|
1046
|
+
const commitChatRename = useCallback(
|
|
1047
|
+
async (chatId: string, previous: string) => {
|
|
1048
|
+
if (renameBusyRef.current) return;
|
|
1049
|
+
const next = renamingChatValue.trim();
|
|
1050
|
+
if (!next || next === previous) {
|
|
1051
|
+
setRenamingChatId(null);
|
|
1052
|
+
setRenameError(null);
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
renameBusyRef.current = true;
|
|
1056
|
+
setRenamingBusy(true);
|
|
1057
|
+
try {
|
|
1058
|
+
await updateChatTitle(chatId, next);
|
|
1059
|
+
patchChatTitle(chatId, next);
|
|
1060
|
+
setRenamingChatId(null);
|
|
1061
|
+
setRenameError(null);
|
|
1062
|
+
} catch (err) {
|
|
1063
|
+
const status = (err as { status?: number }).status;
|
|
1064
|
+
if (status === 409) {
|
|
1065
|
+
setRenamingChatId(null);
|
|
1066
|
+
setRenameError(null);
|
|
1067
|
+
setRenameHint({
|
|
1068
|
+
chatId,
|
|
1069
|
+
text: t(
|
|
1070
|
+
"This chat is running in another process. Try renaming it again in a moment.",
|
|
1071
|
+
),
|
|
1072
|
+
});
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
setRenameError(err instanceof Error ? err.message : String(err));
|
|
1076
|
+
} finally {
|
|
1077
|
+
renameBusyRef.current = false;
|
|
1078
|
+
setRenamingBusy(false);
|
|
1079
|
+
}
|
|
1080
|
+
},
|
|
1081
|
+
[patchChatTitle, renamingChatValue],
|
|
1082
|
+
);
|
|
1083
|
+
|
|
1084
|
+
const forkThisChat = useCallback(
|
|
1085
|
+
async (chat: { id: string }) => {
|
|
1086
|
+
if (forkingChatRef.current) return;
|
|
1087
|
+
forkingChatRef.current = chat.id;
|
|
1088
|
+
setForkingChatId(chat.id);
|
|
1089
|
+
setChatMenu(null);
|
|
1090
|
+
try {
|
|
1091
|
+
const created = await forkChat(chat.id);
|
|
1092
|
+
await refreshChats();
|
|
1093
|
+
navigate(`/agent/${created.chatId}`);
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
const status = (err as { status?: number }).status;
|
|
1096
|
+
const bytes = (err as { data?: { bytes?: number } }).data?.bytes;
|
|
1097
|
+
const text =
|
|
1098
|
+
status === 409
|
|
1099
|
+
? t("This chat is still generating. Fork it after this turn finishes.")
|
|
1100
|
+
: status === 413
|
|
1101
|
+
? t("This workspace is too large to fork ({size}).", {
|
|
1102
|
+
size: formatForkBytes(bytes),
|
|
1103
|
+
})
|
|
1104
|
+
: t(err instanceof Error && err.message ? err.message : 'Fork failed');
|
|
1105
|
+
setRenameHint({ chatId: chat.id, text });
|
|
1106
|
+
} finally {
|
|
1107
|
+
forkingChatRef.current = null;
|
|
1108
|
+
setForkingChatId(null);
|
|
1109
|
+
}
|
|
1110
|
+
},
|
|
1111
|
+
[navigate, refreshChats],
|
|
1112
|
+
);
|
|
1113
|
+
|
|
911
1114
|
// 单条会话行——无项目日期分组和项目分组共用同一个渲染,避免两份 JSX。
|
|
912
1115
|
const renderChatRow = (chat: (typeof normalizedChats)[number]) => {
|
|
913
1116
|
const isCurrent = currentChatId === chat.id;
|
|
914
|
-
const isDeleting = deletingChatId === chat.id;
|
|
915
1117
|
const isPinning = pinningChatId === chat.id;
|
|
916
1118
|
const isStreaming = Boolean(
|
|
917
1119
|
chat.isStreaming || streamingChatIds.has(chat.id),
|
|
@@ -921,6 +1123,80 @@ export function AgentSidebar({
|
|
|
921
1123
|
pendingAskUserChatIds.has(chat.id) ||
|
|
922
1124
|
pendingApprovalChatIds.has(chat.id),
|
|
923
1125
|
);
|
|
1126
|
+
const renameHintText =
|
|
1127
|
+
renameHint?.chatId === chat.id ? (
|
|
1128
|
+
<p
|
|
1129
|
+
className="px-2.5 pb-1 text-[10px] leading-snug text-agent-muted-foreground"
|
|
1130
|
+
data-testid="sidebar-chat-rename-hint"
|
|
1131
|
+
>
|
|
1132
|
+
{renameHint.text}
|
|
1133
|
+
</p>
|
|
1134
|
+
) : null;
|
|
1135
|
+
|
|
1136
|
+
if (renamingChatId === chat.id) {
|
|
1137
|
+
return (
|
|
1138
|
+
<div
|
|
1139
|
+
key={chat.id}
|
|
1140
|
+
className="group/item relative"
|
|
1141
|
+
data-testid="sidebar-chat-row"
|
|
1142
|
+
data-chat-id={chat.id}
|
|
1143
|
+
>
|
|
1144
|
+
<form
|
|
1145
|
+
className="px-2.5 py-0.5"
|
|
1146
|
+
aria-busy={renamingBusy}
|
|
1147
|
+
onSubmit={(event) => {
|
|
1148
|
+
event.preventDefault();
|
|
1149
|
+
void commitChatRename(chat.id, chat.title);
|
|
1150
|
+
}}
|
|
1151
|
+
>
|
|
1152
|
+
<div className="relative">
|
|
1153
|
+
<input
|
|
1154
|
+
autoFocus
|
|
1155
|
+
readOnly={renamingBusy}
|
|
1156
|
+
data-testid="sidebar-chat-rename-input"
|
|
1157
|
+
value={renamingChatValue}
|
|
1158
|
+
aria-label={t("Rename")}
|
|
1159
|
+
onChange={(event) => setRenamingChatValue(event.target.value)}
|
|
1160
|
+
onBlur={() => {
|
|
1161
|
+
if (renameCancelledRef.current) {
|
|
1162
|
+
renameCancelledRef.current = false;
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
if (renameBusyRef.current) return;
|
|
1166
|
+
void commitChatRename(chat.id, chat.title);
|
|
1167
|
+
}}
|
|
1168
|
+
onFocus={(event) => event.currentTarget.select()}
|
|
1169
|
+
onKeyDown={(event) => {
|
|
1170
|
+
if (event.key !== "Escape") return;
|
|
1171
|
+
event.preventDefault();
|
|
1172
|
+
if (renameBusyRef.current) return;
|
|
1173
|
+
renameCancelledRef.current = true;
|
|
1174
|
+
setRenamingChatId(null);
|
|
1175
|
+
setRenameError(null);
|
|
1176
|
+
}}
|
|
1177
|
+
className="h-6 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-2 pr-6 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30 read-only:opacity-70"
|
|
1178
|
+
/>
|
|
1179
|
+
{renamingBusy ? (
|
|
1180
|
+
<LuLoaderCircle
|
|
1181
|
+
className="pointer-events-none absolute right-1.5 top-1 h-3.5 w-3.5 animate-spin text-agent-muted-foreground"
|
|
1182
|
+
data-testid="sidebar-chat-rename-busy"
|
|
1183
|
+
aria-hidden="true"
|
|
1184
|
+
/>
|
|
1185
|
+
) : null}
|
|
1186
|
+
</div>
|
|
1187
|
+
{renameError ? (
|
|
1188
|
+
<p
|
|
1189
|
+
className="mt-0.5 text-[10px] leading-snug text-agent-destructive"
|
|
1190
|
+
data-testid="sidebar-chat-rename-error"
|
|
1191
|
+
>
|
|
1192
|
+
{renameError}
|
|
1193
|
+
</p>
|
|
1194
|
+
) : null}
|
|
1195
|
+
</form>
|
|
1196
|
+
{renameHintText}
|
|
1197
|
+
</div>
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
924
1200
|
|
|
925
1201
|
return (
|
|
926
1202
|
<div
|
|
@@ -934,6 +1210,11 @@ export function AgentSidebar({
|
|
|
934
1210
|
onClick={() => {
|
|
935
1211
|
navigate(`/agent/${chat.id}`);
|
|
936
1212
|
}}
|
|
1213
|
+
onKeyDown={(event) => {
|
|
1214
|
+
if (event.key !== chatRowKeys.rename) return;
|
|
1215
|
+
event.preventDefault();
|
|
1216
|
+
beginChatRename(chat);
|
|
1217
|
+
}}
|
|
937
1218
|
className={[
|
|
938
1219
|
"flex h-7 w-full min-w-0 items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors duration-200",
|
|
939
1220
|
isCurrent
|
|
@@ -969,7 +1250,17 @@ export function AgentSidebar({
|
|
|
969
1250
|
aria-label={t("Triggered by automation")}
|
|
970
1251
|
/>
|
|
971
1252
|
)}
|
|
972
|
-
<span className="min-w-0 truncate leading-none">
|
|
1253
|
+
<span className="min-w-0 truncate leading-none group-hover/item:pr-14 group-focus-within/item:pr-14">
|
|
1254
|
+
{chat.title}
|
|
1255
|
+
</span>
|
|
1256
|
+
{forkingChatId === chat.id ? (
|
|
1257
|
+
<span
|
|
1258
|
+
className="shrink-0 text-[10px] text-agent-muted-foreground"
|
|
1259
|
+
data-testid="sidebar-chat-forking"
|
|
1260
|
+
>
|
|
1261
|
+
{t("Forking…")}
|
|
1262
|
+
</span>
|
|
1263
|
+
) : null}
|
|
973
1264
|
</button>
|
|
974
1265
|
{/* Semi-transparent gradient container for action buttons:
|
|
975
1266
|
fades long text smoothly to the left so buttons don't collide with text. */}
|
|
@@ -1002,21 +1293,21 @@ export function AgentSidebar({
|
|
|
1002
1293
|
type="button"
|
|
1003
1294
|
onClick={(event) => {
|
|
1004
1295
|
event.stopPropagation();
|
|
1005
|
-
|
|
1296
|
+
setChatMenu({
|
|
1297
|
+
id: chat.id,
|
|
1298
|
+
title: chat.title,
|
|
1299
|
+
anchor: event.currentTarget,
|
|
1300
|
+
});
|
|
1006
1301
|
}}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
data-testid="sidebar-chat-delete"
|
|
1302
|
+
className="flex h-6 w-6 items-center justify-center rounded-full text-agent-foreground/70 transition-all duration-200 hover:bg-agent-foreground/10 hover:text-agent-foreground"
|
|
1303
|
+
title={t("More actions")}
|
|
1304
|
+
aria-label={t("More actions")}
|
|
1305
|
+
data-testid="sidebar-chat-more"
|
|
1012
1306
|
>
|
|
1013
|
-
<
|
|
1014
|
-
className={["h-3.5 w-3.5", isDeleting ? "animate-pulse" : ""].join(
|
|
1015
|
-
" ",
|
|
1016
|
-
)}
|
|
1017
|
-
/>
|
|
1307
|
+
<LuEllipsis className="h-3.5 w-3.5" />
|
|
1018
1308
|
</button>
|
|
1019
1309
|
</div>
|
|
1310
|
+
{renameHintText}
|
|
1020
1311
|
</div>
|
|
1021
1312
|
);
|
|
1022
1313
|
};
|
|
@@ -1352,6 +1643,16 @@ export function AgentSidebar({
|
|
|
1352
1643
|
<span className="min-w-0 truncate normal-case">
|
|
1353
1644
|
{project.name}
|
|
1354
1645
|
</span>
|
|
1646
|
+
{project.origin === "cli" ? (
|
|
1647
|
+
<span
|
|
1648
|
+
className="ml-1 inline-flex shrink-0"
|
|
1649
|
+
title={t("Created from the command line")}
|
|
1650
|
+
aria-label={t("Created from the command line")}
|
|
1651
|
+
data-testid="sidebar-project-cli-origin"
|
|
1652
|
+
>
|
|
1653
|
+
<LuTerminal className="h-3 w-3" aria-hidden="true" />
|
|
1654
|
+
</span>
|
|
1655
|
+
) : null}
|
|
1355
1656
|
<span className="ml-1 shrink-0">
|
|
1356
1657
|
{collapsedProjectIds.has(project.id) ? (
|
|
1357
1658
|
<LuChevronRight className="h-3 w-3" />
|
|
@@ -1611,6 +1912,32 @@ export function AgentSidebar({
|
|
|
1611
1912
|
);
|
|
1612
1913
|
})()}
|
|
1613
1914
|
|
|
1915
|
+
{chatMenu ? (
|
|
1916
|
+
<ChatRowMenu
|
|
1917
|
+
anchor={chatMenu.anchor}
|
|
1918
|
+
onClose={() => {
|
|
1919
|
+
const chatId = chatMenu.id;
|
|
1920
|
+
setChatMenu(null);
|
|
1921
|
+
requestAnimationFrame(() => {
|
|
1922
|
+
const row = document.querySelector(
|
|
1923
|
+
`[data-testid="sidebar-chat-row"][data-chat-id="${CSS.escape(chatId)}"]`,
|
|
1924
|
+
);
|
|
1925
|
+
row?.querySelector("button")?.focus();
|
|
1926
|
+
});
|
|
1927
|
+
}}
|
|
1928
|
+
onRename={() => beginChatRename(chatMenu)}
|
|
1929
|
+
onFork={() => {
|
|
1930
|
+
const target = chatMenu;
|
|
1931
|
+
setChatMenu(null);
|
|
1932
|
+
void forkThisChat(target);
|
|
1933
|
+
}}
|
|
1934
|
+
onDelete={() => {
|
|
1935
|
+
setPendingDeleteChat({ id: chatMenu.id, title: chatMenu.title });
|
|
1936
|
+
setChatMenu(null);
|
|
1937
|
+
}}
|
|
1938
|
+
/>
|
|
1939
|
+
) : null}
|
|
1940
|
+
|
|
1614
1941
|
<ConfirmDialog
|
|
1615
1942
|
open={pendingDeleteChat !== null}
|
|
1616
1943
|
title={t("Delete chat")}
|
|
@@ -30,6 +30,8 @@ import type { LocalChat, LocalProject, LocalTask } from '@/lib/local-api';
|
|
|
30
30
|
import {
|
|
31
31
|
activateChatBranch,
|
|
32
32
|
discardTaskWorktree,
|
|
33
|
+
forkChat,
|
|
34
|
+
getChat,
|
|
33
35
|
getChatBranches,
|
|
34
36
|
mergeTaskWorktree,
|
|
35
37
|
openLocalPath,
|
|
@@ -49,6 +51,7 @@ import {
|
|
|
49
51
|
import { hostToolChrome } from '@/lib/host-tools';
|
|
50
52
|
import { PanelToggleButton } from '@/layouts/PanelToggleButton';
|
|
51
53
|
import { t } from '@/i18n';
|
|
54
|
+
import { forkSourceTime } from './chat/timestamp';
|
|
52
55
|
import {
|
|
53
56
|
getTurnFileCategory,
|
|
54
57
|
isIgnoredTurnFile,
|
|
@@ -127,6 +130,9 @@ export function ChatHeader({
|
|
|
127
130
|
const [exportError, setExportError] = useState<string | null>(null);
|
|
128
131
|
const [sharing, setSharing] = useState(false);
|
|
129
132
|
const [pinning, setPinning] = useState(false);
|
|
133
|
+
const [forking, setForking] = useState(false);
|
|
134
|
+
const [forkError, setForkError] = useState<string | null>(null);
|
|
135
|
+
const [forkedFrom, setForkedFrom] = useState<{ chatId: string; title: string } | null>(null);
|
|
130
136
|
const [openingPath, setOpeningPath] = useState<string | null>(null);
|
|
131
137
|
const [menuBox, setMenuBox] = useState<{ top: number; left: number; width: number } | null>(
|
|
132
138
|
null,
|
|
@@ -135,6 +141,50 @@ export function ChatHeader({
|
|
|
135
141
|
const summaryButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
136
142
|
const popoverRef = useRef<HTMLDivElement | null>(null);
|
|
137
143
|
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (!chat?.id) {
|
|
146
|
+
setForkedFrom(null);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
let cancelled = false;
|
|
150
|
+
getChat(chat.id)
|
|
151
|
+
.then((doc) => {
|
|
152
|
+
if (!cancelled) setForkedFrom(doc.forkedFrom);
|
|
153
|
+
})
|
|
154
|
+
.catch(() => {
|
|
155
|
+
if (!cancelled) setForkedFrom(null);
|
|
156
|
+
});
|
|
157
|
+
return () => {
|
|
158
|
+
cancelled = true;
|
|
159
|
+
};
|
|
160
|
+
}, [chat?.id]);
|
|
161
|
+
|
|
162
|
+
const forkThisChat = async () => {
|
|
163
|
+
if (!chat || forking) return;
|
|
164
|
+
setForking(true);
|
|
165
|
+
setForkError(null);
|
|
166
|
+
try {
|
|
167
|
+
const created = await forkChat(chat.id);
|
|
168
|
+
await onChatChanged?.();
|
|
169
|
+
window.location.hash = `#/agent/${created.chatId}`;
|
|
170
|
+
setPopover(null);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
const status = (err as { status?: number }).status;
|
|
173
|
+
const bytes = (err as { data?: { bytes?: number } }).data?.bytes;
|
|
174
|
+
setForkError(
|
|
175
|
+
status === 409
|
|
176
|
+
? t('This chat is still generating. Fork it after this turn finishes.')
|
|
177
|
+
: status === 413
|
|
178
|
+
? t('This workspace is too large to fork ({size}).', {
|
|
179
|
+
size: typeof bytes === 'number' ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : '',
|
|
180
|
+
})
|
|
181
|
+
: t(err instanceof Error && err.message ? err.message : 'Fork failed'),
|
|
182
|
+
);
|
|
183
|
+
} finally {
|
|
184
|
+
setForking(false);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
138
188
|
const showTerminalChrome = hostToolChrome('terminal');
|
|
139
189
|
const allowOpenPath = hostToolChrome('local-fs');
|
|
140
190
|
const openIds = openPanelIds ?? (rightPanel ? [rightPanel] : []);
|
|
@@ -306,6 +356,9 @@ export function ChatHeader({
|
|
|
306
356
|
onShare={shareChat}
|
|
307
357
|
showBranches={Boolean(onBranchSwitched)}
|
|
308
358
|
onOpenBranches={() => void openBranchPage()}
|
|
359
|
+
forking={forking}
|
|
360
|
+
forkError={forkError}
|
|
361
|
+
onFork={() => void forkThisChat()}
|
|
309
362
|
/>
|
|
310
363
|
))}
|
|
311
364
|
{popover === 'summary' && (
|
|
@@ -337,6 +390,26 @@ export function ChatHeader({
|
|
|
337
390
|
>
|
|
338
391
|
{chat?.title ?? t('No chat selected')}
|
|
339
392
|
</span>
|
|
393
|
+
{forkedFrom ? (
|
|
394
|
+
<span
|
|
395
|
+
className="inline-flex min-w-0 items-center gap-1 text-[10px] text-agent-muted-foreground"
|
|
396
|
+
data-testid="chat-fork-source"
|
|
397
|
+
>
|
|
398
|
+
<span className="min-w-0 truncate">
|
|
399
|
+
{t('Forked from "{title}" · {time}', {
|
|
400
|
+
title: forkedFrom.title,
|
|
401
|
+
time: forkSourceTime(chat?.createdAt),
|
|
402
|
+
})}
|
|
403
|
+
</span>
|
|
404
|
+
<a
|
|
405
|
+
href={`#/agent/${forkedFrom.chatId}`}
|
|
406
|
+
className="shrink-0 hover:underline"
|
|
407
|
+
data-testid="chat-fork-open-source"
|
|
408
|
+
>
|
|
409
|
+
{t('Open the original chat')}
|
|
410
|
+
</a>
|
|
411
|
+
</span>
|
|
412
|
+
) : null}
|
|
340
413
|
</div>
|
|
341
414
|
{chat && (
|
|
342
415
|
<button
|
|
@@ -404,6 +477,9 @@ function ChatActionsMenu({
|
|
|
404
477
|
onShare,
|
|
405
478
|
showBranches,
|
|
406
479
|
onOpenBranches,
|
|
480
|
+
forking,
|
|
481
|
+
forkError,
|
|
482
|
+
onFork,
|
|
407
483
|
}: {
|
|
408
484
|
pinned: boolean;
|
|
409
485
|
pinning: boolean;
|
|
@@ -417,6 +493,9 @@ function ChatActionsMenu({
|
|
|
417
493
|
onShare: () => void;
|
|
418
494
|
showBranches: boolean;
|
|
419
495
|
onOpenBranches: () => void;
|
|
496
|
+
forking: boolean;
|
|
497
|
+
forkError: string | null;
|
|
498
|
+
onFork: () => void;
|
|
420
499
|
}) {
|
|
421
500
|
return (
|
|
422
501
|
<>
|
|
@@ -466,6 +545,22 @@ function ChatActionsMenu({
|
|
|
466
545
|
data-action="share"
|
|
467
546
|
/>
|
|
468
547
|
)}
|
|
548
|
+
<MenuRow
|
|
549
|
+
icon={forking ? <LuLoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <LuGitBranch className="h-3.5 w-3.5" />}
|
|
550
|
+
label={t('Fork')}
|
|
551
|
+
title={forkError ?? t('Fork')}
|
|
552
|
+
disabled={forking}
|
|
553
|
+
onClick={onFork}
|
|
554
|
+
data-testid="header-fork-chat"
|
|
555
|
+
/>
|
|
556
|
+
{forkError ? (
|
|
557
|
+
<p
|
|
558
|
+
className="px-3 pb-1 text-[10px] leading-snug text-agent-muted-foreground"
|
|
559
|
+
data-testid="header-fork-error"
|
|
560
|
+
>
|
|
561
|
+
{forkError}
|
|
562
|
+
</p>
|
|
563
|
+
) : null}
|
|
469
564
|
{showBranches && (
|
|
470
565
|
<MenuRow
|
|
471
566
|
icon={<LuGitBranch className="h-3.5 w-3.5" />}
|
|
@@ -197,10 +197,15 @@ export function TerminalView({
|
|
|
197
197
|
window.addEventListener('resize', handleResize);
|
|
198
198
|
const ro = new ResizeObserver(handleResize);
|
|
199
199
|
ro.observe(containerRef.current);
|
|
200
|
+
// Webview zoom changes CSS pixel size without a window resize on WKWebView.
|
|
201
|
+
const offZoom = bridge.onZoomChanged?.(() => {
|
|
202
|
+
requestAnimationFrame(() => handleResize());
|
|
203
|
+
});
|
|
200
204
|
|
|
201
205
|
const containerEl = containerRef.current;
|
|
202
206
|
return () => {
|
|
203
207
|
cancelled = true;
|
|
208
|
+
offZoom?.();
|
|
204
209
|
window.removeEventListener('resize', handleResize);
|
|
205
210
|
containerEl?.removeEventListener('contextmenu', onContextMenu);
|
|
206
211
|
ro.disconnect();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { t } from '@/i18n';
|
|
3
|
+
import { getHostBridge } from '@/lib/host-bridge';
|
|
4
|
+
|
|
5
|
+
const TOAST_MS = 1500;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Desktop zoom level, centered at the top of the window.
|
|
9
|
+
* Shown after each zoom change, then hidden. Absent when the host has no zoom.
|
|
10
|
+
*/
|
|
11
|
+
export function ZoomToast() {
|
|
12
|
+
const [scale, setScale] = useState<number | null>(null);
|
|
13
|
+
const [visible, setVisible] = useState(false);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const bridge = getHostBridge();
|
|
17
|
+
if (!bridge?.onZoomChanged) return;
|
|
18
|
+
let timer = 0;
|
|
19
|
+
const off = bridge.onZoomChanged(({ scale: next }) => {
|
|
20
|
+
setScale(next);
|
|
21
|
+
setVisible(true);
|
|
22
|
+
window.clearTimeout(timer);
|
|
23
|
+
timer = window.setTimeout(() => setVisible(false), TOAST_MS);
|
|
24
|
+
});
|
|
25
|
+
return () => {
|
|
26
|
+
off();
|
|
27
|
+
window.clearTimeout(timer);
|
|
28
|
+
};
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
if (!visible || scale == null) return null;
|
|
32
|
+
const percent = Math.round(scale * 100);
|
|
33
|
+
return (
|
|
34
|
+
<div className="pointer-events-none fixed inset-x-0 top-3 z-50 flex justify-center">
|
|
35
|
+
<div
|
|
36
|
+
className="pointer-events-auto flex items-center gap-2 rounded-full border border-agent-border bg-agent-canvas/95 px-3 py-1 text-xs text-agent-foreground shadow-sm"
|
|
37
|
+
data-testid="zoom-toast"
|
|
38
|
+
>
|
|
39
|
+
<span>{percent}%</span>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
className="text-agent-muted-foreground hover:text-agent-foreground"
|
|
43
|
+
onClick={() => {
|
|
44
|
+
void getHostBridge()?.setZoom?.(1);
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{t('Reset')}
|
|
48
|
+
</button>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -45,11 +45,28 @@ interface DecisionButton {
|
|
|
45
45
|
tone: 'allow' | 'deny' | 'abort';
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
]
|
|
48
|
+
function categoryName(label: string): string {
|
|
49
|
+
return t(label);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function allowButtons(request: ApprovalPromptRequest): DecisionButton[] {
|
|
53
|
+
const category = request.category !== request.toolName && request.categoryLabel
|
|
54
|
+
? categoryName(request.categoryLabel)
|
|
55
|
+
: undefined;
|
|
56
|
+
return [
|
|
57
|
+
{ kind: 'allow_once', label: 'Allow once', tone: 'allow' },
|
|
58
|
+
{
|
|
59
|
+
kind: 'allow_for_session',
|
|
60
|
+
label: category ? t('Allow {category} for this chat', { category }) : 'Allow for this chat',
|
|
61
|
+
tone: 'allow',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
kind: 'allow_always',
|
|
65
|
+
label: category ? t('Always allow {category}', { category }) : 'Always allow',
|
|
66
|
+
tone: 'allow',
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
}
|
|
53
70
|
|
|
54
71
|
const DENY_BUTTONS: DecisionButton[] = [
|
|
55
72
|
{ kind: 'deny_once', label: 'Deny once', tone: 'deny' },
|
|
@@ -101,6 +118,15 @@ export function ApprovalPromptMenu({
|
|
|
101
118
|
// 用户能认出 api.github.com,认不出 cdn.jsdelivr.net.evil.com)。
|
|
102
119
|
const isEgress = current.category === 'network_egress';
|
|
103
120
|
const isSandboxEscalation = current.category === 'sandbox_escalation';
|
|
121
|
+
const isBrowser = current.category === 'browser';
|
|
122
|
+
const sandboxOff = current.sandboxEnforced === false;
|
|
123
|
+
const outsideWorkspace =
|
|
124
|
+
current.outsideWorkspace === true &&
|
|
125
|
+
!isEgress &&
|
|
126
|
+
!isSandboxEscalation &&
|
|
127
|
+
!isBrowser &&
|
|
128
|
+
!sandboxOff;
|
|
129
|
+
const cwd = typeof current.arguments.cwd === 'string' ? current.arguments.cwd : '';
|
|
104
130
|
const egressHost =
|
|
105
131
|
typeof current.arguments.host === 'string' ? current.arguments.host : '';
|
|
106
132
|
const egressPort =
|
|
@@ -126,17 +152,31 @@ export function ApprovalPromptMenu({
|
|
|
126
152
|
{egressHost}
|
|
127
153
|
{egressPort !== null ? `:${egressPort}` : ''}
|
|
128
154
|
</span>
|
|
155
|
+
{current.categoryLabel ? (
|
|
156
|
+
<span className="text-agent-muted-foreground">
|
|
157
|
+
{t('({category})', { category: categoryName(current.categoryLabel) })}
|
|
158
|
+
</span>
|
|
159
|
+
) : null}
|
|
129
160
|
</>
|
|
130
161
|
) : isSandboxEscalation ? (
|
|
131
|
-
<>
|
|
162
|
+
<>
|
|
163
|
+
{t('Agent requests to run a command outside the workspace')}
|
|
164
|
+
{current.categoryLabel ? (
|
|
165
|
+
<span className="ml-1 text-agent-muted-foreground">
|
|
166
|
+
{t('({category})', { category: categoryName(current.categoryLabel) })}
|
|
167
|
+
</span>
|
|
168
|
+
) : null}
|
|
169
|
+
</>
|
|
132
170
|
) : (
|
|
133
171
|
<>
|
|
134
172
|
{t('Agent requests to run')}
|
|
135
173
|
<span className="mx-1 font-mono">{current.toolName}</span>
|
|
136
174
|
<span className="text-agent-muted-foreground">
|
|
137
|
-
{
|
|
138
|
-
|
|
139
|
-
|
|
175
|
+
{current.categoryLabel
|
|
176
|
+
? t('({category})', { category: categoryName(current.categoryLabel) })
|
|
177
|
+
: t('({mode} action)', {
|
|
178
|
+
mode: MODE_LABEL[current.mode] ? t(MODE_LABEL[current.mode]) : current.mode,
|
|
179
|
+
})}
|
|
140
180
|
</span>
|
|
141
181
|
</>
|
|
142
182
|
)}
|
|
@@ -165,6 +205,32 @@ export function ApprovalPromptMenu({
|
|
|
165
205
|
'The workspace sandbox blocked this command. Allowing it retries only this command, without limiting where it can write.',
|
|
166
206
|
)}
|
|
167
207
|
</p>
|
|
208
|
+
) : isBrowser ? (
|
|
209
|
+
<p className="mt-2 text-[11px] leading-relaxed text-agent-muted-foreground">
|
|
210
|
+
{t(
|
|
211
|
+
'Opening pages, clicking, filling, and pressing keys act on that site and can send information off this computer. Scrolling, reading, and screenshots do not need approval.',
|
|
212
|
+
)}
|
|
213
|
+
</p>
|
|
214
|
+
) : outsideWorkspace ? (
|
|
215
|
+
<p className="mt-2 text-[11px] leading-relaxed text-agent-muted-foreground">
|
|
216
|
+
{current.outsideWorkspaceKind === 'workspace'
|
|
217
|
+
? cwd
|
|
218
|
+
? t(
|
|
219
|
+
'The command runs in the sandbox. Working directory {cwd} is outside this chat workspace, so writes there are blocked.',
|
|
220
|
+
{ cwd },
|
|
221
|
+
)
|
|
222
|
+
: t(
|
|
223
|
+
'The command runs in the sandbox. Its working directory is outside this chat workspace, so writes there are blocked.',
|
|
224
|
+
)
|
|
225
|
+
: cwd
|
|
226
|
+
? t(
|
|
227
|
+
'The command runs in the sandbox. Working directory {cwd} is outside the project, so writes there are blocked. Add that directory as a project source folder when a write is required.',
|
|
228
|
+
{ cwd },
|
|
229
|
+
)
|
|
230
|
+
: t(
|
|
231
|
+
'The command runs in the sandbox. Its working directory is outside the project, so writes there are blocked. Add that directory as a project source folder when a write is required.',
|
|
232
|
+
)}
|
|
233
|
+
</p>
|
|
168
234
|
) : (
|
|
169
235
|
<>
|
|
170
236
|
{current.category !== current.toolName && (
|
|
@@ -175,11 +241,13 @@ export function ApprovalPromptMenu({
|
|
|
175
241
|
)}
|
|
176
242
|
</p>
|
|
177
243
|
)}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
244
|
+
{!sandboxOff && (
|
|
245
|
+
<p className="mt-2 text-[11px] leading-relaxed text-agent-muted-foreground">
|
|
246
|
+
{t(
|
|
247
|
+
'Runs in the workspace sandbox. Writes outside it are blocked. To write outside, switch the sandbox to "Full access" and try again.',
|
|
248
|
+
)}
|
|
249
|
+
</p>
|
|
250
|
+
)}
|
|
183
251
|
</>
|
|
184
252
|
)}
|
|
185
253
|
</div>
|
|
@@ -188,8 +256,8 @@ export function ApprovalPromptMenu({
|
|
|
188
256
|
<DecisionRow
|
|
189
257
|
buttons={
|
|
190
258
|
isEgress
|
|
191
|
-
?
|
|
192
|
-
:
|
|
259
|
+
? allowButtons(current).filter((b) => b.kind !== 'allow_always')
|
|
260
|
+
: allowButtons(current)
|
|
193
261
|
}
|
|
194
262
|
onPick={onDecide}
|
|
195
263
|
/>
|
|
@@ -213,12 +281,8 @@ export function ApprovalPromptMenu({
|
|
|
213
281
|
</div>
|
|
214
282
|
<p className="text-[11px] leading-relaxed text-agent-muted-foreground">
|
|
215
283
|
{isEgress
|
|
216
|
-
? t(
|
|
217
|
-
|
|
218
|
-
)
|
|
219
|
-
: t(
|
|
220
|
-
'"This chat" decisions are remembered in this chat. "Always" decisions are written to ~/.steerable/approvals.json on this computer and apply across chats. With no action for about 2 minutes, the request is denied.',
|
|
221
|
-
)}
|
|
284
|
+
? t('With no action for about 3 minutes, the request is denied and that network access fails.')
|
|
285
|
+
: t('"This chat" applies only to this chat. "Always" is saved on this computer and applies to every chat.')}
|
|
222
286
|
</p>
|
|
223
287
|
</div>
|
|
224
288
|
</div>
|
|
@@ -60,6 +60,8 @@ export interface ExecutedAction {
|
|
|
60
60
|
* port-only egress); none = no backend on this platform.
|
|
61
61
|
*/
|
|
62
62
|
sandbox?: { backend?: string; enforcement: string };
|
|
63
|
+
/** Set when a session allow covered this call, so the row can say so. */
|
|
64
|
+
approval?: { kind?: string; category?: string; cached?: boolean; waitMs?: number };
|
|
63
65
|
/**
|
|
64
66
|
* 工具注册时声明的卡片。`declared` 为真时行标题用 `title`,
|
|
65
67
|
* 不再按工具名猜测。旧事件没有这个字段,仍走原来的摘要。
|
|
@@ -70,6 +72,7 @@ export interface ExecutedAction {
|
|
|
70
72
|
title: string;
|
|
71
73
|
declared: boolean;
|
|
72
74
|
};
|
|
75
|
+
durationMs?: number;
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
export interface ExecutedActionsCardProps {
|
|
@@ -126,6 +129,13 @@ function sandboxBadge(sandbox: ExecutedAction['sandbox']): string | null {
|
|
|
126
129
|
}
|
|
127
130
|
}
|
|
128
131
|
|
|
132
|
+
function sessionAllowNote(action: ExecutedAction): string | undefined {
|
|
133
|
+
if (action.approval?.kind === 'allow_for_session' && action.approval.cached === true) {
|
|
134
|
+
return t('Allowed for this chat');
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
129
139
|
function actionToTool(action: ExecutedAction, idx: number): ToolExecutionPayload {
|
|
130
140
|
const status = deriveStatus(action);
|
|
131
141
|
const env = parseToolEnvelope(action.result);
|
|
@@ -156,7 +166,7 @@ function actionToTool(action: ExecutedAction, idx: number): ToolExecutionPayload
|
|
|
156
166
|
args: action.arguments,
|
|
157
167
|
output: action.result,
|
|
158
168
|
error: errorText,
|
|
159
|
-
durationMs: null,
|
|
169
|
+
durationMs: typeof action.durationMs === 'number' ? action.durationMs : null,
|
|
160
170
|
icon: null,
|
|
161
171
|
expandable: true,
|
|
162
172
|
};
|
|
@@ -533,6 +543,7 @@ export function ToolsFlow({
|
|
|
533
543
|
renderOutput={extras.renderOutput ?? renderActionOutput}
|
|
534
544
|
onActivate={inspect.onActivate}
|
|
535
545
|
activateTitle={inspect.activateTitle}
|
|
546
|
+
note={sessionAllowNote(action)}
|
|
536
547
|
className="rounded-none border-0 border-t border-agent-border first:border-t-0"
|
|
537
548
|
/>
|
|
538
549
|
);
|
|
@@ -595,6 +606,7 @@ export function ExecutedActionsCard({
|
|
|
595
606
|
renderOutput={extras.renderOutput ?? renderActionOutput}
|
|
596
607
|
onActivate={inspect.onActivate}
|
|
597
608
|
activateTitle={inspect.activateTitle}
|
|
609
|
+
note={sessionAllowNote(action)}
|
|
598
610
|
className="rounded-none border-0 border-t border-agent-border first:border-t-0"
|
|
599
611
|
/>
|
|
600
612
|
);
|
|
@@ -106,14 +106,9 @@ export function summarizeRunCodeAction(
|
|
|
106
106
|
: t('Program');
|
|
107
107
|
const head =
|
|
108
108
|
description.length > 40 ? `${description.slice(0, 37)}…` : description;
|
|
109
|
-
const
|
|
110
|
-
const calls =
|
|
111
|
-
|
|
112
|
-
: [];
|
|
113
|
-
const failed =
|
|
114
|
-
result &&
|
|
115
|
-
typeof result === 'object' &&
|
|
116
|
-
(result as { success?: unknown }).success === false;
|
|
109
|
+
const parsed = asObject(parseJsonValue(result));
|
|
110
|
+
const calls = runCodeCalls(result);
|
|
111
|
+
const failed = parsed?.success === false;
|
|
117
112
|
if (failed) return t('Program "{name}" failed', { name: head });
|
|
118
113
|
return calls.length > 0
|
|
119
114
|
? t('Program "{name}" · {count} inner tools', { name: head, count: calls.length })
|
|
@@ -121,10 +116,14 @@ export function summarizeRunCodeAction(
|
|
|
121
116
|
}
|
|
122
117
|
|
|
123
118
|
export interface ExecutedActionLike {
|
|
119
|
+
id?: string;
|
|
124
120
|
tool: string;
|
|
125
121
|
arguments?: unknown;
|
|
126
122
|
result?: unknown;
|
|
127
123
|
sandbox?: { backend?: string; enforcement: string };
|
|
124
|
+
approval?: { kind?: string; cached?: boolean };
|
|
125
|
+
view?: { title?: string; declared?: boolean };
|
|
126
|
+
durationMs?: number;
|
|
128
127
|
}
|
|
129
128
|
|
|
130
129
|
export interface DelegateSubagentView {
|
|
@@ -602,21 +601,44 @@ export function summarizeTaskAction(
|
|
|
602
601
|
return null;
|
|
603
602
|
}
|
|
604
603
|
|
|
604
|
+
function runCodeBody(result: unknown): Record<string, unknown> | null {
|
|
605
|
+
const obj = asObject(parseJsonValue(result));
|
|
606
|
+
if (!obj) return null;
|
|
607
|
+
return asObject(obj.data) ?? obj;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function runCodeCalls(result: unknown): RunCodeCall[] {
|
|
611
|
+
const calls = runCodeBody(result)?.calls;
|
|
612
|
+
return Array.isArray(calls) ? (calls as RunCodeCall[]) : [];
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function approvalFromNestedResult(result: unknown): ExecutedActionLike['approval'] {
|
|
616
|
+
const marker = asObject(runCodeBody(result)?._approval);
|
|
617
|
+
if (!marker || typeof marker.kind !== 'string') return undefined;
|
|
618
|
+
return {
|
|
619
|
+
kind: marker.kind,
|
|
620
|
+
...(marker.cached === true ? { cached: true } : {}),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
605
624
|
/** Flatten a run_code action into the outer program plus inner tool rows. */
|
|
606
625
|
export function expandRunCodeActions<T extends ExecutedActionLike>(actions: T[]): T[] {
|
|
607
626
|
const out: T[] = [];
|
|
608
627
|
for (const action of actions) {
|
|
609
628
|
out.push(action);
|
|
610
629
|
if (action.tool !== 'run_code') continue;
|
|
611
|
-
const
|
|
612
|
-
const calls = Array.isArray(data?.calls) ? data.calls : [];
|
|
630
|
+
const calls = runCodeCalls(action.result);
|
|
613
631
|
for (const call of calls) {
|
|
614
632
|
const name = typeof call.tool === 'string' ? call.tool : 'tool';
|
|
615
633
|
out.push({
|
|
616
634
|
...action,
|
|
635
|
+
id: undefined,
|
|
636
|
+
view: undefined,
|
|
637
|
+
durationMs: undefined,
|
|
617
638
|
tool: name,
|
|
618
639
|
arguments: call.arguments,
|
|
619
640
|
result: call.result,
|
|
641
|
+
approval: approvalFromNestedResult(call.result),
|
|
620
642
|
});
|
|
621
643
|
}
|
|
622
644
|
}
|
|
@@ -22,3 +22,15 @@ export function getFriendlyDate(date: Date): string {
|
|
|
22
22
|
});
|
|
23
23
|
return `${month}-${day} ${time}`;
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
/** Fork source bar: month, day, and 24-hour time from the new chat's createdAt. */
|
|
27
|
+
export function forkSourceTime(iso: string | undefined): string {
|
|
28
|
+
if (!iso) return '';
|
|
29
|
+
const date = new Date(iso);
|
|
30
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
31
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
32
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
33
|
+
const hour = String(date.getHours()).padStart(2, '0');
|
|
34
|
+
const minute = String(date.getMinutes()).padStart(2, '0');
|
|
35
|
+
return `${month}-${day} ${hour}:${minute}`;
|
|
36
|
+
}
|
|
@@ -1,10 +1,96 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
1
2
|
import {
|
|
2
3
|
THINKING_DISPLAY_OPTIONS,
|
|
3
4
|
persistThinkingDisplay,
|
|
4
5
|
useThinkingDisplay,
|
|
5
6
|
} from '@/lib/show-thinking-content';
|
|
7
|
+
import { getHostBridge } from '@/lib/host-bridge';
|
|
6
8
|
import { localeEndonym, supportedLocales, useI18n } from '@/i18n';
|
|
7
9
|
|
|
10
|
+
function stepZoom(scale: number, direction: -1 | 1): number {
|
|
11
|
+
const snapped = Math.round(scale * 10) / 10;
|
|
12
|
+
const next = Math.round((snapped + direction * 0.1) * 10) / 10;
|
|
13
|
+
return Math.min(2, Math.max(0.8, next));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function InterfaceZoomRow() {
|
|
17
|
+
const { t } = useI18n();
|
|
18
|
+
const [scale, setScale] = useState<number | null>(null);
|
|
19
|
+
const [available, setAvailable] = useState(false);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const zoom = getHostBridge();
|
|
23
|
+
if (!zoom?.getZoom) return;
|
|
24
|
+
setAvailable(true);
|
|
25
|
+
let active = true;
|
|
26
|
+
void zoom.getZoom().then((next) => {
|
|
27
|
+
if (active) setScale(next);
|
|
28
|
+
});
|
|
29
|
+
const off = zoom.onZoomChanged?.((payload) => setScale(payload.scale));
|
|
30
|
+
return () => {
|
|
31
|
+
active = false;
|
|
32
|
+
off?.();
|
|
33
|
+
};
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
if (!available || scale == null) return null;
|
|
37
|
+
const percent = Math.round(scale * 100);
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
className="flex items-center justify-between gap-3 rounded-agent-md border border-agent-border bg-agent-card p-2.5"
|
|
41
|
+
data-testid="interface-zoom"
|
|
42
|
+
>
|
|
43
|
+
<div>
|
|
44
|
+
<p className="text-xs font-medium text-agent-foreground">{t('Interface zoom')}</p>
|
|
45
|
+
<p className="mt-1 text-xs text-agent-muted-foreground" data-testid="interface-zoom-level">
|
|
46
|
+
{percent}%
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
<div className="flex items-center gap-1">
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
className="h-7 w-7 rounded-full border border-agent-border text-sm text-agent-foreground disabled:opacity-30"
|
|
53
|
+
aria-label={t('Zoom out')}
|
|
54
|
+
data-testid="interface-zoom-out"
|
|
55
|
+
disabled={percent <= 80}
|
|
56
|
+
onClick={() => {
|
|
57
|
+
const next = stepZoom(scale, -1);
|
|
58
|
+
setScale(next);
|
|
59
|
+
void getHostBridge()?.setZoom?.(next);
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
−
|
|
63
|
+
</button>
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
className="h-7 w-7 rounded-full border border-agent-border text-sm text-agent-foreground disabled:opacity-30"
|
|
67
|
+
aria-label={t('Zoom in')}
|
|
68
|
+
data-testid="interface-zoom-in"
|
|
69
|
+
disabled={percent >= 200}
|
|
70
|
+
onClick={() => {
|
|
71
|
+
const next = stepZoom(scale, 1);
|
|
72
|
+
setScale(next);
|
|
73
|
+
void getHostBridge()?.setZoom?.(next);
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
+
|
|
77
|
+
</button>
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
className="h-7 rounded-full border border-agent-border px-2 text-xs text-agent-foreground"
|
|
81
|
+
data-testid="interface-zoom-reset"
|
|
82
|
+
onClick={() => {
|
|
83
|
+
setScale(1);
|
|
84
|
+
void getHostBridge()?.setZoom?.(1);
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{t('Reset')}
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
8
94
|
/**
|
|
9
95
|
* 界面偏好:思考过程正文按「隐藏 / 显示5行 / 完整显示」三档。
|
|
10
96
|
* 产品声明了多于一种语言时,在这里切换。立即写入 localStorage。
|
|
@@ -96,6 +182,7 @@ export function AppearanceSettingsPanel() {
|
|
|
96
182
|
})}
|
|
97
183
|
</div>
|
|
98
184
|
</div>
|
|
185
|
+
<InterfaceZoomRow />
|
|
99
186
|
</div>
|
|
100
187
|
);
|
|
101
188
|
}
|
package/src/i18n/index.tsx
CHANGED
|
@@ -9,8 +9,15 @@ import {
|
|
|
9
9
|
supportedLocales,
|
|
10
10
|
t,
|
|
11
11
|
} from './runtime';
|
|
12
|
+
import { getHostBridge } from '@/lib/host-bridge';
|
|
12
13
|
|
|
13
14
|
export { configureI18n, getLocale, registerLocale, setLocale, supportedLocales, t };
|
|
15
|
+
|
|
16
|
+
/** Tell the desktop shell the page locale so native menus can relabel. */
|
|
17
|
+
export function notifyHostLocale(locale: string): void {
|
|
18
|
+
getHostBridge()?.setLocale?.(locale);
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
export { LOCALE_STORAGE_KEY, SOURCE_LOCALE, normalizeLocale, resolveLocale } from './translate';
|
|
15
22
|
|
|
16
23
|
interface I18nContextValue {
|
|
@@ -35,7 +42,7 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
|
|
35
42
|
t,
|
|
36
43
|
setLocale: (next) => {
|
|
37
44
|
if (!setLocale(next)) return;
|
|
38
|
-
|
|
45
|
+
notifyHostLocale(next);
|
|
39
46
|
},
|
|
40
47
|
}),
|
|
41
48
|
[locale],
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
} from '@/hooks/useChatsAndAgents';
|
|
20
20
|
import { hostToolChrome, sanitizeRightPanelKind, settingsChrome } from '@/lib/host-tools';
|
|
21
21
|
import { t } from '@/i18n';
|
|
22
|
+
import { BRAND_NAME } from '@/brand';
|
|
23
|
+
import { chatWindowTitle } from '@/lib/chat-row-keys';
|
|
22
24
|
import {
|
|
23
25
|
closeRightPanelTab,
|
|
24
26
|
collapseRightPanel,
|
|
@@ -139,6 +141,12 @@ export type AgentOutletContext = UseChatsAndAgentsResult & {
|
|
|
139
141
|
function AgentLayoutContent() {
|
|
140
142
|
const data = useChatsAndAgents();
|
|
141
143
|
const { chatId } = useParams<{ chatId?: string }>();
|
|
144
|
+
const currentChatTitle = chatId
|
|
145
|
+
? data.chats.find((chat) => chat.id === chatId)?.title
|
|
146
|
+
: undefined;
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
document.title = chatWindowTitle(currentChatTitle, BRAND_NAME);
|
|
149
|
+
}, [currentChatTitle]);
|
|
142
150
|
|
|
143
151
|
// ── 包槽位 fallback 发送通道:AgentPage 注册 handleSubmit,包面板(如
|
|
144
152
|
// PPT 单页修改在 sidecar 未就绪时)经 sendChatMessage 退回主聊天发送。
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** 侧栏会话行快捷键。改这个表即可重绑,组件不要写死键名。 */
|
|
2
|
+
export const chatRowKeys = {
|
|
3
|
+
rename: 'F2',
|
|
4
|
+
} as const;
|
|
5
|
+
|
|
6
|
+
/** 当前会话标题写进窗口标题;没有会话时只留产品名。 */
|
|
7
|
+
export function chatWindowTitle(chatTitle: string | undefined, brand: string): string {
|
|
8
|
+
const title = chatTitle?.trim();
|
|
9
|
+
return title ? `${title} — ${brand}` : brand;
|
|
10
|
+
}
|
package/src/lib/host-bridge.ts
CHANGED
|
@@ -44,6 +44,20 @@ export interface ApprovalPromptRequest {
|
|
|
44
44
|
round: number;
|
|
45
45
|
/** 发起审批的对话。缺省时卡片不绑定会话(旧请求)。 */
|
|
46
46
|
chatId?: string;
|
|
47
|
+
/** 已按当前语言解析的类别名。缺省时类别就是工具名。 */
|
|
48
|
+
categoryLabel?: string;
|
|
49
|
+
/** shell 的 cwd 在该对话可写目录之外。 */
|
|
50
|
+
outsideWorkspace?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* `workspace`:没有绑定项目,cwd 在对话工作区外。
|
|
53
|
+
* 缺省时用项目外的说明(可加为源文件夹)。
|
|
54
|
+
*/
|
|
55
|
+
outsideWorkspaceKind?: 'workspace';
|
|
56
|
+
/**
|
|
57
|
+
* 命令沙箱是否还在。`false` 时不说写入会被拒绝。
|
|
58
|
+
* 缺省保持旧 sidecar 的说明。
|
|
59
|
+
*/
|
|
60
|
+
sandboxEnforced?: boolean;
|
|
47
61
|
}
|
|
48
62
|
|
|
49
63
|
/**
|
|
@@ -222,6 +236,13 @@ export interface HostBridge {
|
|
|
222
236
|
readClipboard?: () => Promise<HostClipboard>;
|
|
223
237
|
/** Plain text from the OS pasteboard. Empty when the clipboard has no text. */
|
|
224
238
|
readClipboardText?: () => Promise<string>;
|
|
239
|
+
/**
|
|
240
|
+
* Desktop webview zoom, 0.8–2 in steps of 0.1. Absent in the browser,
|
|
241
|
+
* where the page must not register zoom shortcuts or show the setting.
|
|
242
|
+
*/
|
|
243
|
+
getZoom?: () => Promise<number>;
|
|
244
|
+
setZoom?: (scale: number) => Promise<number>;
|
|
245
|
+
onZoomChanged?: (callback: (payload: { scale: number }) => void) => () => void;
|
|
225
246
|
/**
|
|
226
247
|
* 异步 AI 标题就绪通知。Backend 在每条 chat 首条助手回复完成后 fire-and-forget
|
|
227
248
|
* 跑 LLM 生成标题;完成时通过这个通道把 `{chatId, title}` 推过来。返回的函数
|
package/src/lib/http-bridge.ts
CHANGED
|
@@ -65,8 +65,9 @@ async function http<T>(method: string, path: string, body?: unknown): Promise<T>
|
|
|
65
65
|
});
|
|
66
66
|
const data = (await res.json().catch(() => null)) as T;
|
|
67
67
|
if (!res.ok) {
|
|
68
|
-
const err = new Error(httpErrorMessage(data, res.status)) as Error & { status?: number };
|
|
68
|
+
const err = new Error(httpErrorMessage(data, res.status)) as Error & { status?: number; data?: unknown };
|
|
69
69
|
err.status = res.status;
|
|
70
|
+
err.data = data;
|
|
70
71
|
throw err;
|
|
71
72
|
}
|
|
72
73
|
return data;
|
package/src/lib/local-api.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* may drift from any public cloud API. Aligning the two is out of scope.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { getLocale } from '@/i18n';
|
|
15
16
|
import { getHostBridge } from './host-bridge';
|
|
16
17
|
|
|
17
18
|
export interface LocalChat {
|
|
@@ -466,6 +467,8 @@ export interface LocalProject {
|
|
|
466
467
|
trusted?: boolean;
|
|
467
468
|
/** 侧边栏顺序,越小越靠前。缺省时列表顺序由服务端决定。 */
|
|
468
469
|
sortOrder?: number;
|
|
470
|
+
/** 命令行创建的项目。桌面创建的记录没有该字段。 */
|
|
471
|
+
origin?: 'cli';
|
|
469
472
|
createdAt: string;
|
|
470
473
|
updatedAt: string;
|
|
471
474
|
}
|
|
@@ -619,6 +622,35 @@ export async function updateChatProject(chatId: string, projectId: string | null
|
|
|
619
622
|
});
|
|
620
623
|
}
|
|
621
624
|
|
|
625
|
+
export async function getChat(chatId: string) {
|
|
626
|
+
return bridge().localBackend.request<{
|
|
627
|
+
id: string;
|
|
628
|
+
title: string;
|
|
629
|
+
forkedFrom: { chatId: string; title: string } | null;
|
|
630
|
+
}>({
|
|
631
|
+
method: 'GET',
|
|
632
|
+
path: `/api/v2/chats/${encodeURIComponent(chatId)}`,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** 复制会话。409 表示原会话正被占用,413 表示工作区超过复制上限。 */
|
|
637
|
+
export async function forkChat(chatId: string) {
|
|
638
|
+
return bridge().localBackend.request<{ chatId: string }>({
|
|
639
|
+
method: 'POST',
|
|
640
|
+
path: `/api/v2/chats/${encodeURIComponent(chatId)}/fork`,
|
|
641
|
+
body: { locale: getLocale() },
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** 用户重命名。409 表示会话锁被占用,调用方恢复旧名。 */
|
|
646
|
+
export async function updateChatTitle(chatId: string, title: string) {
|
|
647
|
+
return bridge().localBackend.request<{ id: string; title: string }>({
|
|
648
|
+
method: 'PATCH',
|
|
649
|
+
path: `/api/v2/chats/${encodeURIComponent(chatId)}/settings`,
|
|
650
|
+
body: { title },
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
622
654
|
/* ---------------- Local LLM settings ---------------- */
|
|
623
655
|
|
|
624
656
|
export type LlmProvider = 'ollama' | 'openai-compat' | 'anthropic' | 'google' | 'openai-responses';
|
package/src/lib/tauri-bridge.ts
CHANGED
|
@@ -95,6 +95,27 @@ export function createTauriBridge(): HostBridge {
|
|
|
95
95
|
offMenuPaste: pasteMenu.off,
|
|
96
96
|
readClipboard: () => invoke<HostClipboard>('host_read_clipboard'),
|
|
97
97
|
readClipboardText: () => invoke<string>('host_read_clipboard_text'),
|
|
98
|
+
getZoom: () => invoke<number>('ui_get_zoom'),
|
|
99
|
+
setZoom: (scale) => invoke<number>('ui_set_zoom', { scale }),
|
|
100
|
+
setLocale: (locale) => {
|
|
101
|
+
void invoke('ui_set_locale', { locale });
|
|
102
|
+
},
|
|
103
|
+
onZoomChanged(callback) {
|
|
104
|
+
let unlisten: UnlistenFn | undefined;
|
|
105
|
+
let cancelled = false;
|
|
106
|
+
const pending = listen<{ scale: number }>('ui:zoom-changed', (event) => {
|
|
107
|
+
callback(event.payload);
|
|
108
|
+
});
|
|
109
|
+
void pending.then((stop) => {
|
|
110
|
+
if (cancelled) stop();
|
|
111
|
+
else unlisten = stop;
|
|
112
|
+
});
|
|
113
|
+
return () => {
|
|
114
|
+
cancelled = true;
|
|
115
|
+
if (unlisten) unlisten();
|
|
116
|
+
else void pending.then((stop) => stop());
|
|
117
|
+
};
|
|
118
|
+
},
|
|
98
119
|
app: {
|
|
99
120
|
snapshot: () => invoke<AppReleaseSnapshot>('app_release_snapshot'),
|
|
100
121
|
check: () => invoke<AppReleaseSnapshot>('app_release_check'),
|
package/src/main.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { StrictMode, lazy, Suspense, type ReactNode } from 'react';
|
|
|
2
2
|
import { createRoot, type Root } from 'react-dom/client';
|
|
3
3
|
import { createHashRouter, RouterProvider } from 'react-router-dom';
|
|
4
4
|
import { AppShell } from './AppShell';
|
|
5
|
-
import { I18nProvider, configureI18n, getLocale } from './i18n';
|
|
5
|
+
import { I18nProvider, configureI18n, getLocale, notifyHostLocale } from './i18n';
|
|
6
6
|
import { AgentLayout } from './layouts/AgentLayout';
|
|
7
7
|
import { AgentPage } from './pages/AgentPage';
|
|
8
8
|
import { SettingsPage } from './pages/SettingsPage';
|
|
@@ -105,7 +105,7 @@ export async function bootstrap(): Promise<void> {
|
|
|
105
105
|
defaultLocale: import.meta.env.VITE_DEFAULT_LOCALE || 'en',
|
|
106
106
|
});
|
|
107
107
|
document.documentElement.lang = getLocale() === 'zh' ? 'zh-CN' : getLocale();
|
|
108
|
-
|
|
108
|
+
notifyHostLocale(getLocale());
|
|
109
109
|
document.title = BRAND_NAME;
|
|
110
110
|
const icon = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
|
111
111
|
if (icon) {
|