@steerable/agent-shell-web 0.6.65 → 0.6.67

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.65",
3
+ "version": "0.6.67",
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.65",
49
- "@steerable/agent-ui": "0.6.65"
48
+ "@steerable/agent-protocol": "0.6.67",
49
+ "@steerable/agent-ui": "0.6.67"
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.65"
62
+ "@steerable/pack-sdk": "0.6.67"
63
63
  },
64
64
  "scripts": {
65
65
  "gen": "echo agent-shell-web",
@@ -56,8 +56,8 @@
56
56
  * │ ✎ 新对话 │ ← 只打开落地页,有内容才落库
57
57
  * │ 🧩 插件 │ ← /settings?section=plugins(智能体 / Skills / MCP / 网络搜索)
58
58
  * │ 会话 v 📁+ │ ← 📁+ 打开新建项目弹窗
59
- * │ v 📁 项目A (hover: ✎··)│ ← ✎ 新建对话;·· 菜单:重命名/换目录/访达/删
60
- * │ ...(项目内对话) │
59
+ * │ v 📁 项目A (hover: ✎··)│ ← 拖拽组头排序;✎ 新建对话;·· 菜单:重命名/换目录/访达/删
60
+ * │ ...(项目内对话,超过 5 条再展开)│
61
61
  * │ 今天 │
62
62
  * │ ...(无项目对话,按日期分组) │ ← 无项目排在项目分组之后
63
63
  * ├─────────────────────────────────┤
@@ -109,7 +109,11 @@ import {
109
109
  import { usePendingAskUserChatIds } from "@/components/chat/AskUserPromptProvider";
110
110
  import { usePendingApprovalChatIds } from "@/components/chat/ApprovalPromptProvider";
111
111
  import type { UseChatsAndAgentsResult } from "@/hooks/useChatsAndAgents";
112
- import { useProjects } from "@/hooks/useProjects";
112
+ import { persistProjectOrder, useProjects } from "@/hooks/useProjects";
113
+ import {
114
+ nextProjectOrder,
115
+ type ProjectDropPlace,
116
+ } from "@/lib/project-order";
113
117
  import type { RightPanelState } from "@/layouts/AgentLayout";
114
118
  import { BrandLockup } from "@/components/BrandLockup";
115
119
  import { ConfirmDialog } from "@/components/ConfirmDialog";
@@ -121,6 +125,48 @@ import { SidebarAccount } from "@/components/SidebarAccount";
121
125
  import { CreateProjectModal } from "@/components/CreateProjectModal";
122
126
  import { t } from "@/i18n";
123
127
 
128
+ function projectDropPlace(element: HTMLElement, clientY: number): ProjectDropPlace {
129
+ const rect = element.getBoundingClientRect();
130
+ return clientY < rect.top + rect.height / 2 ? "before" : "after";
131
+ }
132
+
133
+ /** 项目里先露出的普通会话条数。每一次「显示更多」再露出同样多条。 */
134
+ const PROJECT_CHAT_PAGE = 5;
135
+
136
+ /**
137
+ * 进行中、等待输入的会话保持原位,不占用这 5 条名额。
138
+ * 当前打开的会话如果落在名额之外,仍单独留在列表里。
139
+ */
140
+ function visibleProjectChats<T extends { id: string }>(
141
+ items: readonly T[],
142
+ limit: number,
143
+ options: {
144
+ currentId?: string;
145
+ isAlwaysVisible: (chat: T) => boolean;
146
+ },
147
+ ): { rows: T[]; hiddenCount: number } {
148
+ let ordinary = 0;
149
+ const kept = new Set<string>();
150
+ for (const chat of items) {
151
+ if (options.isAlwaysVisible(chat)) {
152
+ kept.add(chat.id);
153
+ continue;
154
+ }
155
+ if (ordinary < limit) {
156
+ kept.add(chat.id);
157
+ ordinary += 1;
158
+ }
159
+ }
160
+ if (
161
+ options.currentId &&
162
+ items.some((chat) => chat.id === options.currentId)
163
+ ) {
164
+ kept.add(options.currentId);
165
+ }
166
+ const rows = items.filter((chat) => kept.has(chat.id));
167
+ return { rows, hiddenCount: items.length - rows.length };
168
+ }
169
+
124
170
  function placeProjectMenu(anchor: HTMLElement): { top: number; left: number } {
125
171
  const box = anchor.getBoundingClientRect();
126
172
  const width = 224;
@@ -481,6 +527,10 @@ export function AgentSidebar({
481
527
  const [collapsedProjectIds, setCollapsedProjectIds] = useState<Set<string>>(
482
528
  () => new Set(),
483
529
  );
530
+ // 每个项目已展开的普通会话名额。缺省是 PROJECT_CHAT_PAGE;Infinity 表示已全部展开。
531
+ const [projectChatLimits, setProjectChatLimits] = useState<
532
+ Record<string, number>
533
+ >({});
484
534
  const [renamingProjectId, setRenamingProjectId] = useState<string | null>(
485
535
  null,
486
536
  );
@@ -497,6 +547,37 @@ export function AgentSidebar({
497
547
  id: string;
498
548
  anchor: HTMLElement;
499
549
  } | null>(null);
550
+ const draggingProjectIdRef = useRef<string | null>(null);
551
+ const projectDragMovedRef = useRef(false);
552
+ const [draggingProjectId, setDraggingProjectId] = useState<string | null>(
553
+ null,
554
+ );
555
+ const [dropTarget, setDropTarget] = useState<{
556
+ id: string;
557
+ place: ProjectDropPlace;
558
+ } | null>(null);
559
+
560
+ const endProjectDrag = useCallback(() => {
561
+ draggingProjectIdRef.current = null;
562
+ setDraggingProjectId(null);
563
+ setDropTarget(null);
564
+ // dragend 之后浏览器有时还会补一次 click。等这次 click 过去再清标记,
565
+ // 避免松手把项目折叠掉;下一次真正的点击仍能折叠。
566
+ window.setTimeout(() => {
567
+ projectDragMovedRef.current = false;
568
+ }, 0);
569
+ }, []);
570
+
571
+ useEffect(() => {
572
+ if (!draggingProjectId) return;
573
+ const { cursor, userSelect } = document.body.style;
574
+ document.body.style.cursor = "grabbing";
575
+ document.body.style.userSelect = "none";
576
+ return () => {
577
+ document.body.style.cursor = cursor;
578
+ document.body.style.userSelect = userSelect;
579
+ };
580
+ }, [draggingProjectId]);
500
581
 
501
582
  const showProjectsChrome = hostToolChrome("projects");
502
583
 
@@ -592,14 +673,49 @@ export function AgentSidebar({
592
673
  }
593
674
  }, []);
594
675
 
595
- const toggleProjectCollapsed = useCallback((projectId: string) => {
596
- setCollapsedProjectIds((prev) => {
597
- const next = new Set(prev);
598
- if (next.has(projectId)) next.delete(projectId);
599
- else next.add(projectId);
600
- return next;
601
- });
602
- }, []);
676
+ const toggleProjectCollapsed = useCallback(
677
+ (projectId: string) => {
678
+ const collapsing = !collapsedProjectIds.has(projectId);
679
+ setCollapsedProjectIds((prev) => {
680
+ const next = new Set(prev);
681
+ if (next.has(projectId)) next.delete(projectId);
682
+ else next.add(projectId);
683
+ return next;
684
+ });
685
+ // 收起项目后再打开,回到最初的 5 条,不记住上次展开到第几页。
686
+ if (collapsing) {
687
+ setProjectChatLimits((limits) => {
688
+ if (limits[projectId] === undefined) return limits;
689
+ const next = { ...limits };
690
+ delete next[projectId];
691
+ return next;
692
+ });
693
+ }
694
+ },
695
+ [collapsedProjectIds],
696
+ );
697
+
698
+ const revealMoreProjectChats = useCallback(
699
+ (projectId: string, hiddenCount: number) => {
700
+ setProjectChatLimits((limits) => {
701
+ if (hiddenCount === 0) {
702
+ if (limits[projectId] === undefined) return limits;
703
+ const next = { ...limits };
704
+ delete next[projectId];
705
+ return next;
706
+ }
707
+ const current = limits[projectId] ?? PROJECT_CHAT_PAGE;
708
+ return {
709
+ ...limits,
710
+ [projectId]:
711
+ hiddenCount <= PROJECT_CHAT_PAGE
712
+ ? Number.POSITIVE_INFINITY
713
+ : current + PROJECT_CHAT_PAGE,
714
+ };
715
+ });
716
+ },
717
+ [],
718
+ );
603
719
 
604
720
  // Keep the shared "next new chat" agent aligned with the current chat. The
605
721
  // actual picker now lives in ChatInput, so the sidebar no longer renders the
@@ -918,6 +1034,22 @@ export function AgentSidebar({
918
1034
 
919
1035
  const hasElectron = hasHostBridge();
920
1036
 
1037
+ const projectChatWindow = (
1038
+ items: typeof normalizedChats,
1039
+ limit: number,
1040
+ ) =>
1041
+ visibleProjectChats(items, limit, {
1042
+ currentId: currentChatId,
1043
+ isAlwaysVisible: (chat) =>
1044
+ Boolean(
1045
+ chat.isStreaming ||
1046
+ streamingChatIds.has(chat.id) ||
1047
+ chat.needsUserInput ||
1048
+ pendingAskUserChatIds.has(chat.id) ||
1049
+ pendingApprovalChatIds.has(chat.id),
1050
+ ),
1051
+ });
1052
+
921
1053
  return (
922
1054
  <div className="flex h-full w-full flex-col border-r border-agent-border/60 bg-agent-muted/70 backdrop-blur-md">
923
1055
  {/* ───── Brand + actions ───── */}
@@ -1059,8 +1191,75 @@ export function AgentSidebar({
1059
1191
  </div>
1060
1192
  ) : (
1061
1193
  projectGroups.map(({ project, items }) => (
1062
- <div key={project.id} className="mb-1">
1063
- <div className="group/proj relative">
1194
+ <div
1195
+ key={project.id}
1196
+ data-testid="sidebar-project"
1197
+ data-project-id={project.id}
1198
+ className="relative pb-1"
1199
+ onDragOver={(event) => {
1200
+ if (!draggingProjectIdRef.current) return;
1201
+ event.preventDefault();
1202
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
1203
+ const place = projectDropPlace(
1204
+ event.currentTarget,
1205
+ event.clientY,
1206
+ );
1207
+ setDropTarget((current) =>
1208
+ current?.id === project.id && current.place === place
1209
+ ? current
1210
+ : { id: project.id, place },
1211
+ );
1212
+ }}
1213
+ onDragLeave={(event) => {
1214
+ const next = event.relatedTarget;
1215
+ if (
1216
+ next instanceof Node &&
1217
+ event.currentTarget.contains(next)
1218
+ ) {
1219
+ return;
1220
+ }
1221
+ setDropTarget((current) =>
1222
+ current?.id === project.id ? null : current,
1223
+ );
1224
+ }}
1225
+ onDrop={(event) => {
1226
+ event.preventDefault();
1227
+ const fromId = draggingProjectIdRef.current;
1228
+ const place = projectDropPlace(
1229
+ event.currentTarget,
1230
+ event.clientY,
1231
+ );
1232
+ endProjectDrag();
1233
+ if (!fromId) return;
1234
+ const next = nextProjectOrder(
1235
+ projectGroups.map((group) => group.project.id),
1236
+ fromId,
1237
+ project.id,
1238
+ place,
1239
+ );
1240
+ if (!next) return;
1241
+ void persistProjectOrder(next).catch((err) => {
1242
+ setProjectError(
1243
+ err instanceof Error ? err.message : String(err),
1244
+ );
1245
+ });
1246
+ }}
1247
+ >
1248
+ {dropTarget?.id === project.id && draggingProjectId ? (
1249
+ <div
1250
+ className={[
1251
+ "pointer-events-none absolute inset-x-2 z-10 h-0.5 rounded-full bg-agent-foreground",
1252
+ dropTarget.place === "before" ? "top-0" : "bottom-1",
1253
+ ].join(" ")}
1254
+ data-testid="sidebar-project-drop-indicator"
1255
+ />
1256
+ ) : null}
1257
+ <div
1258
+ className={[
1259
+ "group/proj relative",
1260
+ draggingProjectId === project.id ? "opacity-40" : "",
1261
+ ].join(" ")}
1262
+ >
1064
1263
  {renamingProjectId === project.id ? (
1065
1264
  <form
1066
1265
  className="flex items-center px-2.5 pb-1 pt-1.5"
@@ -1087,8 +1286,31 @@ export function AgentSidebar({
1087
1286
  <>
1088
1287
  <button
1089
1288
  type="button"
1090
- onClick={() => toggleProjectCollapsed(project.id)}
1091
- className="flex w-full min-w-0 items-center px-2 pb-0.5 pt-1 text-[11px] font-medium text-agent-muted-foreground/90 transition-colors hover:text-agent-foreground"
1289
+ draggable
1290
+ data-testid="sidebar-project-handle"
1291
+ data-project-id={project.id}
1292
+ onDragStart={(event) => {
1293
+ projectDragMovedRef.current = true;
1294
+ draggingProjectIdRef.current = project.id;
1295
+ setDraggingProjectId(project.id);
1296
+ const transfer = event.dataTransfer;
1297
+ if (!transfer) return;
1298
+ transfer.effectAllowed = "move";
1299
+ try {
1300
+ transfer.setData("text/plain", project.name);
1301
+ } catch (error) {
1302
+ // 排序状态记在 ref 里。个别环境拒绝写入 DataTransfer。
1303
+ void error;
1304
+ }
1305
+ }}
1306
+ onDragEnd={() => {
1307
+ endProjectDrag();
1308
+ }}
1309
+ onClick={() => {
1310
+ if (projectDragMovedRef.current) return;
1311
+ toggleProjectCollapsed(project.id);
1312
+ }}
1313
+ className="flex w-full min-w-0 cursor-grab items-center px-2 pb-0.5 pt-1 text-[11px] font-medium text-agent-muted-foreground/90 transition-colors hover:text-agent-foreground active:cursor-grabbing"
1092
1314
  title={`${project.name}\n${project.folderPath}`}
1093
1315
  >
1094
1316
  <LuFolder className="mr-1 h-3.5 w-3.5 shrink-0" />
@@ -1106,8 +1328,8 @@ export function AgentSidebar({
1106
1328
  <div
1107
1329
  className={`absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 transition-opacity ${
1108
1330
  projectMenu?.id === project.id
1109
- ? "opacity-100"
1110
- : "opacity-0 group-hover/proj:opacity-100"
1331
+ ? "pointer-events-auto opacity-100"
1332
+ : "pointer-events-none opacity-0 group-hover/proj:pointer-events-auto group-hover/proj:opacity-100"
1111
1333
  }`}
1112
1334
  >
1113
1335
  <button
@@ -1146,7 +1368,45 @@ export function AgentSidebar({
1146
1368
  {t("No chats yet. Hover the project name and click + to start one.")}
1147
1369
  </div>
1148
1370
  ) : (
1149
- items.map((chat) => renderChatRow(chat))
1371
+ (() => {
1372
+ const limit =
1373
+ projectChatLimits[project.id] ??
1374
+ PROJECT_CHAT_PAGE;
1375
+ const folded = projectChatWindow(
1376
+ items,
1377
+ PROJECT_CHAT_PAGE,
1378
+ );
1379
+ const shown = projectChatWindow(items, limit);
1380
+ const fullyShown = shown.hiddenCount === 0;
1381
+ return (
1382
+ <>
1383
+ {shown.rows.map((chat) =>
1384
+ renderChatRow(chat),
1385
+ )}
1386
+ {folded.hiddenCount > 0 && (
1387
+ <button
1388
+ type="button"
1389
+ data-testid="sidebar-project-show-more"
1390
+ data-project-id={project.id}
1391
+ aria-expanded={fullyShown}
1392
+ onClick={() =>
1393
+ revealMoreProjectChats(
1394
+ project.id,
1395
+ shown.hiddenCount,
1396
+ )
1397
+ }
1398
+ className="flex h-7 w-full items-center rounded-full px-2.5 text-left text-[11px] text-agent-muted-foreground/80 transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
1399
+ >
1400
+ {fullyShown
1401
+ ? t("Show less")
1402
+ : t("Show {count} more", {
1403
+ count: shown.hiddenCount,
1404
+ })}
1405
+ </button>
1406
+ )}
1407
+ </>
1408
+ );
1409
+ })()
1150
1410
  )}
1151
1411
  </div>
1152
1412
  )}
@@ -32,7 +32,13 @@ import {
32
32
  import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
33
33
  import type { ExecPolicy } from '@/lib/exec-policy';
34
34
  import { getWebChatModes, hostToolCapability, settingsChrome } from '@/lib/host-tools';
35
- import { hostClipboardAvailable, requestHostPaste, type HostClipboardFile } from '@/lib/host-paste';
35
+ import {
36
+ hostClipboardAvailable,
37
+ releaseComposerPasteTarget,
38
+ requestHostPaste,
39
+ setComposerPasteTarget,
40
+ type HostClipboardFile,
41
+ } from '@/lib/host-paste';
36
42
  import { attachmentFromPath, type AttachmentFile } from '@/lib/attachments';
37
43
  import { dropPointHitsRect, hostFileDropAvailable, listenHostFileDrop } from '@/lib/host-file-drop';
38
44
  import type { SteerOutcome } from '@steerable/agent-ui';
@@ -510,10 +516,17 @@ function getSelectionOffsets(root: HTMLDivElement): { start: number; end: number
510
516
  return measureRange.toString().length;
511
517
  };
512
518
 
513
- return {
514
- start: getOffset(range.startContainer, range.startOffset),
515
- end: getOffset(range.endContainer, range.endOffset),
516
- };
519
+ try {
520
+ return {
521
+ start: getOffset(range.startContainer, range.startOffset),
522
+ end: getOffset(range.endContainer, range.endOffset),
523
+ };
524
+ } catch {
525
+ // The caret can sit on the empty scaffold's <br> with an offset the
526
+ // range rejects. Paste still belongs at the end of the draft.
527
+ const end = getEditableText(root).length;
528
+ return { start: end, end };
529
+ }
517
530
  }
518
531
 
519
532
  function setCaretOffset(root: HTMLDivElement, offset: number) {
@@ -1149,6 +1162,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1149
1162
  return;
1150
1163
  }
1151
1164
 
1165
+ const wrote =
1166
+ editorText !== value ||
1167
+ pendingCaretOffset !== null ||
1168
+ (!value && !hasEmptyImeScaffold(editor));
1152
1169
  if (editorText !== value || pendingCaretOffset !== null) {
1153
1170
  writeEditorValue(
1154
1171
  editor,
@@ -1167,8 +1184,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1167
1184
  lastPushedValueRef.current = value;
1168
1185
  updateEditorHeight(editor);
1169
1186
 
1170
- if (pendingCaretOffset !== null && focused) {
1171
- setCaretOffset(editor, Math.min(pendingCaretOffset, value.length));
1187
+ // replaceChildren drops the DOM selection. Without a caret inside the
1188
+ // field, WKWebView ignores Ctrl/Cmd+V after a send.
1189
+ if (focused && wrote) {
1190
+ setCaretOffset(editor, Math.min(pendingCaretOffset ?? value.length, value.length));
1172
1191
  pendingCaretOffsetRef.current = null;
1173
1192
  }
1174
1193
  }, [value, mentionReferences, agents, chats, disabled, skills, mcpTools]);
@@ -1494,6 +1513,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1494
1513
  useLayoutEffect(() => {
1495
1514
  const editor = editorRef.current;
1496
1515
  if (!editor) return;
1516
+ setComposerPasteTarget(editor);
1497
1517
  const onCompositionStart = () => {
1498
1518
  isComposingRef.current = true;
1499
1519
  inputSyncGenRef.current += 1;
@@ -1519,6 +1539,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1519
1539
  editor.addEventListener('hostpaste', onHostPaste);
1520
1540
  editor.addEventListener('hostpastefiles', onHostPasteFiles);
1521
1541
  return () => {
1542
+ releaseComposerPasteTarget(editor);
1522
1543
  editor.removeEventListener('compositionstart', onCompositionStart, true);
1523
1544
  editor.removeEventListener('hostpaste', onHostPaste);
1524
1545
  editor.removeEventListener('hostpastefiles', onHostPasteFiles);
@@ -224,10 +224,13 @@ export function LocalChatPanel({
224
224
  const [attachmentError, setAttachmentError] = useState<string | null>(null);
225
225
  const [mentionReferences, setMentionReferences] = useState<MentionReference[]>([]);
226
226
  const inputRef = useRef<ChatInputHandle>(null);
227
+ // 空会话首屏和对话底部各有一棵输入框。发出第一条消息会卸掉正在聚焦的那棵;
228
+ // 不重新聚焦的话,Ctrl/Cmd+V 没有落点。
229
+ const showEmptyHero = Boolean(emptyHero) && messages.length === 0 && !isStreaming;
227
230
 
228
231
  useEffect(() => {
229
232
  inputRef.current?.focusAtEnd();
230
- }, [chatId]);
233
+ }, [chatId, showEmptyHero]);
231
234
 
232
235
  const handleSelectPrompt = useCallback((prompt: string) => {
233
236
  setInputValue(prompt);
@@ -315,9 +318,6 @@ export function LocalChatPanel({
315
318
  }
316
319
  }, [inputValue, files, mentionReferences, onSubmit, mode, execPolicy, chatId, selectedAgentId]);
317
320
 
318
- // 空会话 hero:messages 为空且未在流式时整面板切到居中首屏布局。
319
- const showEmptyHero = Boolean(emptyHero) && messages.length === 0 && !isStreaming;
320
-
321
321
  const sessionTodos = useMemo(
322
322
  () =>
323
323
  resolveLatestSessionTodos({
@@ -21,7 +21,8 @@ import type { InspectTaskInput } from './executed-actions-model';
21
21
  import type { ChildInfo } from './OrchestrationChildrenCard';
22
22
  import type { ChatMode } from './ChatInput';
23
23
  import type { TurnBlock } from './turn-timeline';
24
- import type { TurnFile } from './turn-files';
24
+ import { followedPreviewPath, type TurnFile } from './turn-files';
25
+ import { publishConversationPreview } from '@/layouts/conversation-preview';
25
26
  import { inferDurationMs, readPersistedDurationMs } from './elapsed';
26
27
  import type { LlmSpeedSnapshot } from './process-status';
27
28
 
@@ -54,6 +55,27 @@ import type { LlmSpeedSnapshot } from './process-status';
54
55
 
55
56
  const NEAR_BOTTOM_THRESHOLD_PX = 100;
56
57
 
58
+ /** 视口上三分之一附近的助手消息,当作当前正在看的回合。 */
59
+ function assistantIdInView(container: HTMLElement): string | null {
60
+ const bounds = container.getBoundingClientRect();
61
+ const anchor = bounds.top + container.clientHeight * 0.35;
62
+ const nodes = container.querySelectorAll<HTMLElement>(
63
+ '[data-message-role="assistant"][data-message-id]',
64
+ );
65
+ let bestId: string | null = null;
66
+ let bestDistance = Number.POSITIVE_INFINITY;
67
+ for (const node of nodes) {
68
+ const rect = node.getBoundingClientRect();
69
+ if (rect.bottom < bounds.top || rect.top > bounds.bottom) continue;
70
+ const distance = Math.abs(rect.top - anchor);
71
+ const messageId = node.getAttribute('data-message-id');
72
+ if (!messageId || distance >= bestDistance) continue;
73
+ bestId = messageId;
74
+ bestDistance = distance;
75
+ }
76
+ return bestId;
77
+ }
78
+
57
79
  function internalTrigger(message: ChatMessage): 'goal' | 'loop' | null {
58
80
  if (!message.messageMetadata) return null;
59
81
  try {
@@ -240,6 +262,41 @@ export function MessageList({
240
262
  };
241
263
  }, [checkAtBottom]);
242
264
 
265
+ // 右侧预览跟着这一屏在看的回合。停在底部时用最近一份能打开的产物。
266
+ useEffect(() => {
267
+ if (!chatId) return;
268
+ const publish = () => {
269
+ const el = containerRef.current;
270
+ if (!el) return;
271
+ const atBottom =
272
+ el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_THRESHOLD_PX;
273
+ const ordered = visibleMessages
274
+ .filter((message) => message.role === 'assistant')
275
+ .map((message) => ({
276
+ id: message.id,
277
+ files: message.id === lastAssistantId
278
+ ? (turnFilesByMessageId?.[message.id] ?? currentTurnFiles)
279
+ : turnFilesByMessageId?.[message.id],
280
+ }));
281
+ const selected = followedPreviewPath(ordered, {
282
+ atBottom,
283
+ focusedId: atBottom ? null : assistantIdInView(el),
284
+ });
285
+ if (!selected) return;
286
+ publishConversationPreview({
287
+ chatId,
288
+ messageId: selected.messageId,
289
+ path: selected.path,
290
+ });
291
+ };
292
+ publish();
293
+ const el = containerRef.current;
294
+ el?.addEventListener('scroll', publish, { passive: true });
295
+ return () => {
296
+ el?.removeEventListener('scroll', publish);
297
+ };
298
+ }, [chatId, currentTurnFiles, lastAssistantId, messages, turnFilesByMessageId]);
299
+
243
300
  // Snap to bottom on first paint, regardless of whether the user "was" at
244
301
  // the bottom — there's no "before" on mount.
245
302
  useLayoutEffect(() => {
@@ -388,3 +388,60 @@ export function groupTurnFiles(
388
388
 
389
389
  return { deliverables, intermediates, totalAdditions, totalDeletions };
390
390
  }
391
+
392
+ /** 右侧栏能直接打开的扩展名。交付物优先,同层里靠前的类型优先。 */
393
+ const RIGHT_PREVIEW_EXTENSIONS = [
394
+ '.pptx', // shell-neutral:allow — Office 幻灯片扩展名,不是产品品牌
395
+ '.ppt', // shell-neutral:allow — Office 幻灯片扩展名,不是产品品牌
396
+ '.docx',
397
+ '.doc',
398
+ '.md',
399
+ '.markdown',
400
+ ];
401
+
402
+ /**
403
+ * 这一轮在右侧栏里该显示的文件。
404
+ * 交付物压过脚本和中间产物;同一层里幻灯片、文档、Markdown 依次优先。
405
+ */
406
+ export function previewPathForTurn(files: readonly TurnFile[] | undefined): string | null {
407
+ if (!files || files.length === 0) return null;
408
+ let best: { path: string; rank: number; deliverable: boolean } | null = null;
409
+ for (const file of files) {
410
+ if (isIgnoredTurnFile(file.path)) continue;
411
+ const rank = RIGHT_PREVIEW_EXTENSIONS.indexOf(getFileExtension(file.path));
412
+ if (rank < 0) continue;
413
+ const deliverable = getTurnFileCategory(file) === 'deliverable';
414
+ if (
415
+ !best
416
+ || (deliverable && !best.deliverable)
417
+ || (deliverable === best.deliverable && rank < best.rank)
418
+ ) {
419
+ best = { path: file.path, rank, deliverable };
420
+ }
421
+ }
422
+ return best?.path ?? null;
423
+ }
424
+
425
+ /**
426
+ * 右侧预览跟着对话走。
427
+ * 停在底部时用最近一轮有预览的产物;往上翻时只用当前看着的那条助手消息。
428
+ */
429
+ export function followedPreviewPath(
430
+ ordered: readonly { id: string; files?: readonly TurnFile[] }[],
431
+ options: { atBottom: boolean; focusedId: string | null },
432
+ ): { messageId: string; path: string } | null {
433
+ const pick = (entry: { id: string; files?: readonly TurnFile[] } | undefined) => {
434
+ if (!entry) return null;
435
+ const path = previewPathForTurn(entry.files);
436
+ return path ? { messageId: entry.id, path } : null;
437
+ };
438
+ if (options.atBottom) {
439
+ for (let index = ordered.length - 1; index >= 0; index -= 1) {
440
+ const found = pick(ordered[index]);
441
+ if (found) return found;
442
+ }
443
+ return null;
444
+ }
445
+ if (!options.focusedId) return null;
446
+ return pick(ordered.find((entry) => entry.id === options.focusedId));
447
+ }