@steerable/agent-shell-web 0.6.64 → 0.6.66

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.
@@ -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(() => {
@@ -42,7 +42,7 @@ export function TaskOutcomeCards({ tasks, onInspect, onDismiss }: TaskOutcomeCar
42
42
  ))}
43
43
  {overflow > 0 && (
44
44
  <div className="px-1 text-[11px] text-agent-muted-foreground">
45
- {t('{count} more background tasks finished. See the tasks button in the title bar.', {
45
+ {t('{count} more background tasks finished. See Chat resources in the title bar.', {
46
46
  count: overflow,
47
47
  })}
48
48
  </div>
@@ -103,7 +103,7 @@ function TaskOutcomeCard({
103
103
  <span className="min-w-0 truncate font-mono text-[10px]">
104
104
  {task.worktreeBranch}
105
105
  </span>
106
- <span>{t('Awaiting merge. Handle it from the tasks button in the title bar.')}</span>
106
+ <span>{t('Awaiting merge. Handle it from Chat resources in the title bar.')}</span>
107
107
  </div>
108
108
  )}
109
109
  </div>
@@ -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
+ }
@@ -5,7 +5,8 @@
5
5
  import { useCallback, useEffect, useSyncExternalStore } from 'react';
6
6
  import { hasHostBridge } from '@/lib/host-bridge';
7
7
  import { hostToolChrome } from '@/lib/host-tools';
8
- import { listProjects, type LocalProject } from '@/lib/local-api';
8
+ import { listProjects, reorderProjects, type LocalProject } from '@/lib/local-api';
9
+ import { applyProjectIdOrder } from '@/lib/project-order';
9
10
 
10
11
  type ProjectsSnapshot = {
11
12
  projects: LocalProject[];
@@ -57,6 +58,34 @@ export async function refreshProjects(): Promise<void> {
57
58
  }
58
59
  }
59
60
 
61
+ /**
62
+ * 立刻按 orderedIds 重排共享列表,再写到服务端。
63
+ * 保存失败时把列表滚回拖拽前的顺序,并抛出原来的错误。
64
+ */
65
+ export async function persistProjectOrder(orderedIds: string[]): Promise<void> {
66
+ const previous = snapshot;
67
+ const seq = ++requestSeq;
68
+ snapshot = {
69
+ projects: applyProjectIdOrder(previous.projects, orderedIds),
70
+ error: previous.error,
71
+ };
72
+ emit();
73
+ try {
74
+ const res = await reorderProjects(orderedIds);
75
+ if (seq !== requestSeq) return;
76
+ snapshot = {
77
+ projects: Array.isArray(res.projects) ? res.projects : snapshot.projects,
78
+ error: null,
79
+ };
80
+ emit();
81
+ } catch (err) {
82
+ if (seq !== requestSeq) return;
83
+ snapshot = previous;
84
+ emit();
85
+ throw err instanceof Error ? err : new Error(String(err));
86
+ }
87
+ }
88
+
60
89
  export function useProjects(): {
61
90
  projects: LocalProject[];
62
91
  error: string | null;