@steerable/agent-shell-web 0.6.73 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-web",
3
- "version": "0.6.73",
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.6.73",
49
- "@steerable/agent-ui": "0.6.73"
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.6.73"
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">{chat.title}</span>
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
- setPendingDeleteChat({ id: chat.id, title: chat.title });
1296
+ setChatMenu({
1297
+ id: chat.id,
1298
+ title: chat.title,
1299
+ anchor: event.currentTarget,
1300
+ });
1006
1301
  }}
1007
- disabled={isDeleting}
1008
- 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-destructive disabled:cursor-not-allowed disabled:opacity-100"
1009
- title={t("Delete chat")}
1010
- aria-label={t("Delete chat")}
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
- <LuTrash2
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
- const ALLOW_BUTTONS: DecisionButton[] = [
49
- { kind: 'allow_once', label: 'Allow once', tone: 'allow' },
50
- { kind: 'allow_for_session', label: 'Allow for this chat', tone: 'allow' },
51
- { kind: 'allow_always', label: 'Always allow', tone: 'allow' },
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
- <>{t('Agent requests to run a command outside the workspace')}</>
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
- {t('({mode} action)', {
138
- mode: MODE_LABEL[current.mode] ? t(MODE_LABEL[current.mode]) : current.mode,
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
- <p className="mt-2 text-[11px] leading-relaxed text-agent-muted-foreground">
179
- {t(
180
- 'Runs in the workspace sandbox. Writes outside it are blocked. To write outside, switch the sandbox to "Full access" and try again.',
181
- )}
182
- </p>
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
- ? ALLOW_BUTTONS.filter((b) => b.kind !== 'allow_always')
192
- : ALLOW_BUTTONS
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
- '"This chat" decisions are remembered in this chat until the agent process exits. With no action for about 3 minutes, the request is denied.',
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 data = resultData(result);
110
- const calls = Array.isArray((data as { calls?: unknown } | null)?.calls)
111
- ? ((data as { calls: RunCodeCall[] }).calls)
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 data = resultData(action.result) as { calls?: RunCodeCall[] } | null;
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
  }
@@ -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
- window.steerableHost?.setLocale?.(next);
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
+ }
@@ -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}` 推过来。返回的函数
@@ -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;
@@ -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';
@@ -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
- window.steerableHost?.setLocale?.(getLocale());
108
+ notifyHostLocale(getLocale());
109
109
  document.title = BRAND_NAME;
110
110
  const icon = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
111
111
  if (icon) {