@steerable/agent-shell-web 0.6.67 → 0.6.69

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.67",
3
+ "version": "0.6.69",
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.67",
49
- "@steerable/agent-ui": "0.6.67"
48
+ "@steerable/agent-protocol": "0.6.69",
49
+ "@steerable/agent-ui": "0.6.69"
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.67"
62
+ "@steerable/pack-sdk": "0.6.69"
63
63
  },
64
64
  "scripts": {
65
65
  "gen": "echo agent-shell-web",
@@ -114,6 +114,7 @@ import {
114
114
  nextProjectOrder,
115
115
  type ProjectDropPlace,
116
116
  } from "@/lib/project-order";
117
+ import { chatActivityTime, latestActivityTime } from "@/lib/sidebar-order";
117
118
  import type { RightPanelState } from "@/layouts/AgentLayout";
118
119
  import { BrandLockup } from "@/components/BrandLockup";
119
120
  import { ConfirmDialog } from "@/components/ConfirmDialog";
@@ -389,6 +390,10 @@ export function AgentSidebar({
389
390
  const [streamingChatIds, setStreamingChatIds] = useState<Set<string>>(
390
391
  () => new Set(),
391
392
  );
393
+ // 回合已经开始、列表里的 updatedAt 还没刷新时,用这个时间把会话抬到前面。
394
+ const [liveActivityAt, setLiveActivityAt] = useState<ReadonlyMap<string, number>>(
395
+ () => new Map(),
396
+ );
392
397
  const pendingAskUserChatIds = usePendingAskUserChatIds();
393
398
  const pendingApprovalChatIds = usePendingApprovalChatIds();
394
399
 
@@ -409,6 +414,14 @@ export function AgentSidebar({
409
414
  }
410
415
  return next;
411
416
  });
417
+ if (isStreaming) {
418
+ setLiveActivityAt((prev) => {
419
+ if (prev.has(chatId)) return prev;
420
+ const next = new Map(prev);
421
+ next.set(chatId, Date.now());
422
+ return next;
423
+ });
424
+ }
412
425
  };
413
426
  window.addEventListener("chat:streaming-change", handleStreamingChange);
414
427
  return () => {
@@ -419,6 +432,25 @@ export function AgentSidebar({
419
432
  };
420
433
  }, []);
421
434
 
435
+ // 列表里的 updatedAt 追上本地时间后丢掉抬升,避免旧回合一直占在最前。
436
+ useEffect(() => {
437
+ setLiveActivityAt((prev) => {
438
+ if (prev.size === 0) return prev;
439
+ let changed = false;
440
+ const next = new Map(prev);
441
+ for (const [id, stamp] of prev) {
442
+ if (streamingChatIds.has(id)) continue;
443
+ const chat = chats.find((item) => item.id === id);
444
+ const updated = chat?.updatedAt ? Date.parse(chat.updatedAt) : Number.NaN;
445
+ if (!chat || (Number.isFinite(updated) && updated >= stamp)) {
446
+ next.delete(id);
447
+ changed = true;
448
+ }
449
+ }
450
+ return changed ? next : prev;
451
+ });
452
+ }, [chats, streamingChatIds]);
453
+
422
454
  // 当前打开的会话由 AgentChatView 自己报开始/结束。切走后的会话回合还在
423
455
  // 后端跑,卸载时不会再报结束,这里用 live-stream 对账,结束后摘掉指示。
424
456
  const seenActiveBackgroundRef = useRef<Set<string>>(new Set());
@@ -752,35 +784,31 @@ export function AgentSidebar({
752
784
  const normalizedChats = useMemo(() => {
753
785
  return chats
754
786
  .map((chat) => {
755
- const updated = chat.updatedAt ? new Date(chat.updatedAt) : null;
756
- const created = new Date(chat.createdAt);
757
- const sortDate =
758
- updated && !Number.isNaN(updated.getTime())
759
- ? updated
760
- : !Number.isNaN(created.getTime())
761
- ? created
762
- : new Date();
763
787
  const { displayTitle, isAutomation } = parseChatTitle(chat.title);
764
788
  return {
765
789
  id: chat.id,
766
790
  title: displayTitle || t("New conversation"),
767
791
  isAutomation,
768
792
  isPinned: chat.isPinned,
769
- sortDate,
793
+ sortDate: new Date(
794
+ chatActivityTime(
795
+ chat.updatedAt,
796
+ chat.createdAt,
797
+ liveActivityAt.get(chat.id),
798
+ ),
799
+ ),
770
800
  agentId: chat.agentId ?? null,
771
801
  projectId: chat.projectId ?? null,
772
802
  isStreaming: chat.isStreaming,
773
803
  needsUserInput: chat.needsUserInput,
774
804
  };
775
805
  })
776
- .sort((a, b) => {
777
- if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
778
- return b.sortDate.getTime() - a.sortDate.getTime();
779
- });
780
- }, [chats]);
806
+ .sort((a, b) => b.sortDate.getTime() - a.sortDate.getTime());
807
+ }, [chats, liveActivityAt]);
781
808
 
782
809
  // 顶层按项目分组:无项目对话(含项目已被删但列表还没刷新的孤儿会话)
783
- // 保持原有的日期分组;每个项目一个分组,组内按 pin + 时间排序。
810
+ // 保持日期分组。会话按最后活动时间新到旧。项目按组内最近一次会话排序,
811
+ // 没有会话的项目留在后面,并保持它们原来的相对顺序。
784
812
  const knownProjectIds = useMemo(
785
813
  () =>
786
814
  showProjectsChrome
@@ -797,16 +825,23 @@ export function AgentSidebar({
797
825
  [normalizedChats, knownProjectIds],
798
826
  );
799
827
 
800
- const projectGroups = useMemo(
801
- () =>
802
- showProjectsChrome
803
- ? projects.map((project) => ({
804
- project,
805
- items: normalizedChats.filter((c) => c.projectId === project.id),
806
- }))
807
- : [],
808
- [projects, normalizedChats, showProjectsChrome],
809
- );
828
+ const projectGroups = useMemo(() => {
829
+ if (!showProjectsChrome) return [];
830
+ return projects
831
+ .map((project, index) => ({
832
+ project,
833
+ index,
834
+ items: normalizedChats.filter((c) => c.projectId === project.id),
835
+ }))
836
+ .sort((a, b) => {
837
+ const byActivity =
838
+ latestActivityTime(b.items.map((item) => item.sortDate.getTime())) -
839
+ latestActivityTime(a.items.map((item) => item.sortDate.getTime()));
840
+ if (byActivity !== 0) return byActivity;
841
+ return a.index - b.index;
842
+ })
843
+ .map(({ project, items }) => ({ project, items }));
844
+ }, [projects, normalizedChats, showProjectsChrome]);
810
845
 
811
846
  const pinnedChats = useMemo(
812
847
  () => normalizedChats.filter((c) => c.isPinned),
@@ -6,7 +6,8 @@ import { t } from '@/i18n';
6
6
  /**
7
7
  * ExecPolicyPicker — 输入框上的命令沙箱切换(类 Codex 底部权限档)。
8
8
  *
9
- * 切档立刻记住,下一轮才生效:当前回合的 `execSandbox` 已经随流发出。
9
+ * 切档立刻记住。排队的下一轮按当时的档开流;插进当前回合的消息会改
10
+ * 这一回合后面的命令沙箱。已经在跑的那条命令保持它开始时的档。
10
11
  * 工作区 = Seatbelt/bwrap 只允许写项目家目录、源文件夹(含各自子目录)或本对话工作区;完整权限
11
12
  * = 不下发命令沙箱,本机路径(如 Downloads)不再被 Operation not permitted 拦住。
12
13
  */
@@ -58,6 +58,12 @@ export interface UseChatsAndAgentsResult {
58
58
  * 找不到 id 时静默忽略。
59
59
  */
60
60
  patchChatTitle: (chatId: string, title: string) => void;
61
+ /**
62
+ * 就地把某个 chat 的最后更新时间改掉(不打后端、不重新拉已在列表里的会话)。
63
+ * 用于 `chat-activity`:消息落库后侧边栏按这个时间重排。
64
+ * 当前已加载的页里没有这条会话时,退回刷新第一页。
65
+ */
66
+ patchChatUpdatedAt: (chatId: string, updatedAt: string) => void;
61
67
  /** True while a `loadMoreChats` call is in flight. */
62
68
  isLoadingMoreChats: boolean;
63
69
  /** True iff the last list-chats response reported more pages available. */
@@ -149,6 +155,43 @@ export function useChatsAndAgents(): UseChatsAndAgentsResult {
149
155
  [list],
150
156
  );
151
157
 
158
+ // 消息落库后的活动时间。叠在已加载列表上,避免整表刷新把分页打回第一页。
159
+ const [activityAt, setActivityAt] = useState<ReadonlyMap<string, string>>(() => new Map());
160
+ const chats = useMemo(() => {
161
+ if (activityAt.size === 0) return list.chats;
162
+ return list.chats.map((chat) => {
163
+ const next = activityAt.get(chat.id);
164
+ if (!next || chat.updatedAt >= next) return chat;
165
+ return { ...chat, updatedAt: next };
166
+ });
167
+ }, [activityAt, list.chats]);
168
+ useEffect(() => {
169
+ setActivityAt((prev) => {
170
+ if (prev.size === 0) return prev;
171
+ let changed = false;
172
+ const next = new Map(prev);
173
+ for (const [id, stamp] of prev) {
174
+ const chat = list.chats.find((item) => item.id === id);
175
+ if (chat && chat.updatedAt >= stamp) {
176
+ next.delete(id);
177
+ changed = true;
178
+ }
179
+ }
180
+ return changed ? next : prev;
181
+ });
182
+ }, [list.chats]);
183
+ const patchChatUpdatedAt = useCallback((chatId: string, updatedAt: string) => {
184
+ const known = list.chats.some((chat) => chat.id === chatId);
185
+ setActivityAt((prev) => {
186
+ const current = prev.get(chatId);
187
+ if (current && current >= updatedAt) return prev;
188
+ const next = new Map(prev);
189
+ next.set(chatId, updatedAt);
190
+ return next;
191
+ });
192
+ if (!known) void list.refreshChats();
193
+ }, [list.chats, list.refreshChats]);
194
+
152
195
  const deleteChat = useCallback(
153
196
  async (chatId: string) => {
154
197
  try {
@@ -164,7 +207,7 @@ export function useChatsAndAgents(): UseChatsAndAgentsResult {
164
207
  );
165
208
 
166
209
  return {
167
- chats: list.chats,
210
+ chats,
168
211
  agents: list.agents,
169
212
  isLoading: list.isLoading,
170
213
  error: mergedError,
@@ -175,6 +218,7 @@ export function useChatsAndAgents(): UseChatsAndAgentsResult {
175
218
  createChat,
176
219
  deleteChat,
177
220
  patchChatTitle: list.patchChatTitle,
221
+ patchChatUpdatedAt,
178
222
  isLoadingMoreChats: list.isLoadingMoreChats,
179
223
  hasMoreChats: list.hasMoreChats,
180
224
  loadMoreChats: list.loadMoreChats,
@@ -164,6 +164,10 @@ function AgentLayoutContent() {
164
164
  useEffect(() => {
165
165
  patchChatTitleRef.current = data.patchChatTitle;
166
166
  }, [data.patchChatTitle]);
167
+ const patchChatUpdatedAtRef = useRef(data.patchChatUpdatedAt);
168
+ useEffect(() => {
169
+ patchChatUpdatedAtRef.current = data.patchChatUpdatedAt;
170
+ }, [data.patchChatUpdatedAt]);
167
171
  const refreshChatsRef = useRef(data.refreshChats);
168
172
  useEffect(() => {
169
173
  refreshChatsRef.current = data.refreshChats;
@@ -187,6 +191,16 @@ function AgentLayoutContent() {
187
191
  });
188
192
  return unsubscribe;
189
193
  }, []);
194
+ // 消息落库后的活动时间。只改这条会话在内存里的 updatedAt,侧边栏按它重排。
195
+ useEffect(() => {
196
+ const bridge = getHostBridge();
197
+ if (!bridge?.onChatActivity) return;
198
+ const unsubscribe = bridge.onChatActivity((payload) => {
199
+ if (!payload?.chatId || !payload.updatedAt) return;
200
+ patchChatUpdatedAtRef.current(payload.chatId, payload.updatedAt);
201
+ });
202
+ return unsubscribe;
203
+ }, []);
190
204
  useEffect(() => {
191
205
  const bridge = getHostBridge();
192
206
  if (!bridge?.onStoreChanged) return;
@@ -651,6 +651,7 @@ export function installBrowserDevHostMock() {
651
651
  onMenuOpenTerminal: () => undefined,
652
652
  offMenuOpenTerminal: () => undefined,
653
653
  onChatTitleUpdated: () => () => undefined,
654
+ onChatActivity: () => () => undefined,
654
655
  onSuggestedReplies: (callback) => {
655
656
  suggestedReplyListeners.add(callback);
656
657
  return () => {
@@ -254,10 +254,14 @@ export function createHostChatTransport(chatId: string): HostChatTransport {
254
254
  },
255
255
  // 轮中转向:仅在 CoreLoop 路径(STEERABLE_USE_CORELOOP=1)且回合仍
256
256
  // 运行时生效;否则主进程软失败,useChatStream 保留草稿。
257
- steer: async (content: string) => {
257
+ steer: async (content: string, options?: { execPolicy?: 'workspace' | 'full' }) => {
258
258
  const bridge = getHostBridge();
259
259
  if (!bridge?.localBackend.steerChat) return false;
260
- return await bridge.localBackend.steerChat(chatId, content);
260
+ return await bridge.localBackend.steerChat(
261
+ chatId,
262
+ content,
263
+ options?.execPolicy ? { execPolicy: options.execPolicy } : undefined,
264
+ );
261
265
  },
262
266
  stream: async (input: ChatStreamSendInput, onEvent) => {
263
267
  const bridge = getHostBridge();
@@ -159,8 +159,15 @@ export interface HostBridge {
159
159
  onEvent: (payload: LocalBackendStreamEvent) => void,
160
160
  ) => Promise<string | null>;
161
161
  cancelStream: (streamId: string) => void;
162
- /** 轮中转向:注入一条用户消息到运行中的 CoreLoop 回合;false = 无可转向回合。 */
163
- steerChat?: (chatId: string, content: string) => Promise<boolean>;
162
+ /**
163
+ * 轮中转向:注入一条用户消息到运行中的 CoreLoop 回合;false = 无可转向回合。
164
+ * `execPolicy` 改这一回合后面的命令沙箱(工作区 / 完整权限)。
165
+ */
166
+ steerChat?: (
167
+ chatId: string,
168
+ content: string,
169
+ options?: { execPolicy?: 'workspace' | 'full' },
170
+ ) => Promise<boolean>;
164
171
  };
165
172
  /** 会话附件:把源文件路径 / base64 字节拷贝进会话空间(`attachments:save`)。 */
166
173
  attachments?: {
@@ -239,6 +246,13 @@ export interface HostBridge {
239
246
  onChatCreated?: (
240
247
  callback: (payload: { chatId: string; agentId?: string | null }) => void,
241
248
  ) => () => void;
249
+ /**
250
+ * 会话有了新的活动时间(写入消息或改会话)。载荷是 `{chatId, updatedAt}`。
251
+ * 侧边栏据此改内存里的时间并重排,不整表刷新。
252
+ */
253
+ onChatActivity?: (
254
+ callback: (payload: { chatId: string; updatedAt: string }) => void,
255
+ ) => () => void;
242
256
  /**
243
257
  * 另一个进程提交了宿主库。侧栏据此重新拉会话列表。
244
258
  * 载荷为空;渲染端不依赖里面的字段。
@@ -257,8 +257,12 @@ export function createHttpBridge(): HostBridge {
257
257
  http<T>(input.method, input.path, input.body),
258
258
  startStream,
259
259
  cancelStream,
260
- steerChat: async (chatId, content) => {
261
- const result = await http<{ ok?: boolean }>('POST', '/host/steer', { chatId, content });
260
+ steerChat: async (chatId, content, options) => {
261
+ const result = await http<{ ok?: boolean }>('POST', '/host/steer', {
262
+ chatId,
263
+ content,
264
+ ...(options?.execPolicy ? { execPolicy: options.execPolicy } : {}),
265
+ });
262
266
  return result.ok === true;
263
267
  },
264
268
  },
@@ -286,6 +290,9 @@ export function createHttpBridge(): HostBridge {
286
290
  onChatCreated: (callback) =>
287
291
  subscribeChannel<{ chatId: string; agentId?: string | null }>('chat-created', callback),
288
292
 
293
+ onChatActivity: (callback) =>
294
+ subscribeChannel<{ chatId: string; updatedAt: string }>('chat-activity', callback),
295
+
289
296
  onStoreChanged: (callback) => subscribeChannel('store:changed', () => callback()),
290
297
 
291
298
  onTaskUpdated: (callback) =>
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 侧栏会话与项目的显示顺序。
3
+ * 会话按最后活动时间新到旧;项目按组内最近一次会话的活动时间,没有会话的项目留在后面。
4
+ */
5
+
6
+ /**
7
+ * 会话的排序时间。有效的 `updatedAt` 优先,否则用 `createdAt`。
8
+ * `liveAt` 是进行中回合在列表刷新前的本地时间,只在比已保存时间更新时抬高。
9
+ */
10
+ export function chatActivityTime(
11
+ updatedAt: string | null | undefined,
12
+ createdAt: string | null | undefined,
13
+ liveAt?: number,
14
+ now = Date.now(),
15
+ ): number {
16
+ const updated = updatedAt ? Date.parse(updatedAt) : Number.NaN;
17
+ const created = createdAt ? Date.parse(createdAt) : Number.NaN;
18
+ const persisted = Number.isFinite(updated)
19
+ ? updated
20
+ : Number.isFinite(created)
21
+ ? created
22
+ : now;
23
+ return liveAt !== undefined && Number.isFinite(liveAt) && liveAt > persisted
24
+ ? liveAt
25
+ : persisted;
26
+ }
27
+
28
+ /** 一组会话里最近的活动时间。没有会话时为 0,这样空项目排在有对话的项目后面。 */
29
+ export function latestActivityTime(times: readonly number[]): number {
30
+ let latest = 0;
31
+ for (const time of times) {
32
+ if (time > latest) latest = time;
33
+ }
34
+ return latest;
35
+ }
@@ -101,6 +101,31 @@ function readStoredMode(): ChatMode {
101
101
  return clampWebChatMode(localStorage.getItem(CHAT_MODE_STORAGE_KEY));
102
102
  }
103
103
 
104
+ interface ComposerTurnSettings {
105
+ execPolicy: ExecPolicy;
106
+ mode: ChatMode;
107
+ modelOverride: string | null;
108
+ effortOverride: string | null;
109
+ }
110
+
111
+ /**
112
+ * 排队和插队失败后的新回合不经过输入框的正常提交,这里在真正开流时
113
+ * 补上当前输入框的命令沙箱、模式、模型和编排开关。
114
+ */
115
+ function applyComposerTurnSettings<T extends { metadata?: Record<string, unknown> }>(
116
+ input: T,
117
+ settings: ComposerTurnSettings,
118
+ ): T {
119
+ const metadata: Record<string, unknown> = { ...(input.metadata ?? {}) };
120
+ metadata.execPolicy = settings.execPolicy;
121
+ if (settings.mode === "plan") metadata.mode = "plan";
122
+ else delete metadata.mode;
123
+ if (settings.modelOverride) metadata.model = settings.modelOverride;
124
+ if (settings.effortOverride) metadata.reasoningEffort = settings.effortOverride;
125
+ metadata.orchestration = isOrchestrationSettingEnabled();
126
+ return { ...input, metadata };
127
+ }
128
+
104
129
  function extractPersistedActions(
105
130
  messages: ChatMessageWithMetadata[] | undefined,
106
131
  ): Record<string, ExecutedAction[]> {
@@ -388,10 +413,29 @@ function AgentChatView({
388
413
  const chatProject = chat?.projectId
389
414
  ? (projects.find((p) => p.id === chat.projectId) ?? null)
390
415
  : null;
391
- const transport = useMemo(
392
- () => createHostChatTransport(chatId),
393
- [chatId],
394
- );
416
+ // 插队 / 排队在开流或注入时读这里,而不是排队那一刻的闭包。
417
+ const composerSettingsRef = useRef<ComposerTurnSettings>({
418
+ execPolicy: readStoredExecPolicy(),
419
+ mode: readStoredMode(),
420
+ modelOverride: null,
421
+ effortOverride: null,
422
+ });
423
+ const transport = useMemo(() => {
424
+ const inner = createHostChatTransport(chatId);
425
+ return {
426
+ cancelActive: () => inner.cancelActive(),
427
+ stream: (
428
+ input: { content: string; metadata?: Record<string, unknown> },
429
+ onEvent: (event: SSEEvent) => void,
430
+ ) => inner.stream(applyComposerTurnSettings(input, composerSettingsRef.current), onEvent),
431
+ steer: (content: string) => {
432
+ if (!inner.steer) return Promise.resolve(false);
433
+ return inner.steer(content, {
434
+ execPolicy: composerSettingsRef.current.execPolicy,
435
+ });
436
+ },
437
+ };
438
+ }, [chatId]);
395
439
 
396
440
  // 4.6a 后台任务:header 角标与消息列尾部的终态卡共用这一份订阅,两处
397
441
  // 因此不会各拉一次任务表、也不会显示互相错位的状态。
@@ -874,6 +918,12 @@ function AgentChatView({
874
918
  // ModelPicker 自取(GET /api/v2/llm/models → sidecar models.list)。
875
919
  const [modelOverride, setModelOverride] = useState<string | null>(null);
876
920
  const [effortOverride, setEffortOverride] = useState<string | null>(null);
921
+ composerSettingsRef.current = {
922
+ execPolicy,
923
+ mode,
924
+ modelOverride,
925
+ effortOverride,
926
+ };
877
927
 
878
928
  const handleSubmit = useCallback(
879
929
  (input: { content: string; metadata?: Record<string, unknown> }) => {