@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-web",
3
- "version": "0.6.64",
3
+ "version": "0.6.66",
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.64",
49
- "@steerable/agent-ui": "0.6.64"
48
+ "@steerable/agent-protocol": "0.6.66",
49
+ "@steerable/agent-ui": "0.6.66"
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.64"
62
+ "@steerable/pack-sdk": "0.6.66"
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
  )}