@saadnvd1/agent-os 0.3.723 → 0.3.731

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.
Files changed (150) hide show
  1. package/README.md +1 -0
  2. package/app/api/bus/ports/route.test.ts +43 -0
  3. package/app/api/bus/ports/route.ts +35 -0
  4. package/app/api/loops/route.ts +17 -0
  5. package/app/api/sessions/[id]/export/turn/route.test.ts +65 -0
  6. package/app/api/sessions/[id]/export/turn/route.ts +33 -0
  7. package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
  8. package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
  9. package/app/api/tasks/[id]/loop/next/route.ts +25 -0
  10. package/app/api/tasks/[id]/loop/route.ts +72 -0
  11. package/app/api/tasks/[id]/loop/run/route.ts +12 -0
  12. package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
  13. package/app/api/tasks/[id]/mode/route.ts +29 -0
  14. package/app/api/tasks/[id]/report/draft/route.ts +54 -0
  15. package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
  16. package/app/api/tasks/[id]/report/route.ts +39 -0
  17. package/app/api/workspaces/[id]/mode/route.ts +23 -0
  18. package/app/api/workspaces/[id]/reports/route.ts +17 -0
  19. package/app/page.tsx +8 -0
  20. package/components/Analyst/PairChip.tsx +131 -0
  21. package/components/Analyst/PairSwitch.tsx +76 -0
  22. package/components/Analyst/ReviewCard.tsx +272 -0
  23. package/components/Analyst/SignalChips.tsx +39 -0
  24. package/components/Analyst/StartAnalystSheet.tsx +109 -0
  25. package/components/Analyst/cardState.test.ts +109 -0
  26. package/components/Analyst/cardState.ts +95 -0
  27. package/components/Analyst/pair.ts +81 -0
  28. package/components/Chat/Code.tsx +3 -0
  29. package/components/Chat/Messages.tsx +8 -1
  30. package/components/Chat/TurnCopy.tsx +84 -0
  31. package/components/Chat/itemContext.ts +14 -0
  32. package/components/Draft/DraftChips.tsx +10 -0
  33. package/components/Draft/DraftPanel.tsx +7 -0
  34. package/components/Pane/DesktopTabBar.tsx +6 -0
  35. package/components/Pane/MobileTabBar.tsx +17 -0
  36. package/components/Pane/MobileViewPicker.tsx +4 -0
  37. package/components/Pane/PaneMenu.test.ts +21 -0
  38. package/components/Pane/PaneMenu.tsx +16 -0
  39. package/components/Pane/PaneViewToggle.tsx +12 -0
  40. package/components/Pane/index.tsx +19 -0
  41. package/components/Report/DraftCard.tsx +89 -0
  42. package/components/Report/EstimateBar.tsx +51 -0
  43. package/components/Report/EvidenceSheet.tsx +98 -0
  44. package/components/Report/ModeChooser.tsx +43 -0
  45. package/components/Report/ReportView.tsx +289 -0
  46. package/components/SessionList/GroupShelves.tsx +57 -22
  47. package/components/SessionList/MoveToGroupMenu.test.ts +77 -0
  48. package/components/SessionList/SessionRow.tsx +12 -1
  49. package/components/SessionList/SessionRowMenu.tsx +6 -0
  50. package/components/SessionList/SidebarContextMenu.test.ts +21 -1
  51. package/components/SessionList/SidebarContextMenu.tsx +33 -9
  52. package/components/SessionList/index.tsx +11 -2
  53. package/components/SessionList/useGroupDrag.ts +83 -0
  54. package/components/Sessions/RolePill.tsx +7 -0
  55. package/components/Tasks/Loop/LoopButton.tsx +74 -0
  56. package/components/Tasks/Loop/LoopSetup.tsx +146 -0
  57. package/components/Tasks/Loop/LoopView.tsx +350 -0
  58. package/components/Tasks/Loop/labels.ts +17 -0
  59. package/components/Tasks/TaskRow.tsx +2 -0
  60. package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
  61. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  62. package/components/ui/segmented.tsx +55 -0
  63. package/components/ui/status-pill.tsx +3 -0
  64. package/contexts/PaneContext.tsx +21 -1
  65. package/data/exports/queries.ts +11 -0
  66. package/data/loops/index.ts +3 -0
  67. package/data/loops/keys.ts +4 -0
  68. package/data/loops/mutations.ts +68 -0
  69. package/data/loops/queries.ts +40 -0
  70. package/data/push/topics.ts +6 -0
  71. package/data/reports/index.ts +115 -0
  72. package/lib/agents/brief.ts +1 -1
  73. package/lib/analyst/forward.ts +1 -1
  74. package/lib/db/changes.ts +7 -0
  75. package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
  76. package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
  77. package/lib/done/index.ts +2 -0
  78. package/lib/drafts.ts +2 -0
  79. package/lib/export/markdown.ts +7 -1
  80. package/lib/export/turn.ts +48 -0
  81. package/lib/health/config.ts +2 -0
  82. package/lib/health/env/classes.ts +14 -1
  83. package/lib/health/port-wait-tick.ts +222 -0
  84. package/lib/health/port-wait.ts +160 -0
  85. package/lib/health/tick.ts +9 -2
  86. package/lib/health/types.ts +2 -0
  87. package/lib/loops/analyse.ts +46 -0
  88. package/lib/loops/analyst-prompt.ts +203 -0
  89. package/lib/loops/checkCriteria.ts +114 -0
  90. package/lib/loops/compactEvidence.ts +140 -0
  91. package/lib/loops/controls.ts +317 -0
  92. package/lib/loops/decideStop.ts +113 -0
  93. package/lib/loops/deps.ts +128 -0
  94. package/lib/loops/diffMap.ts +39 -0
  95. package/lib/loops/draftCriteria.ts +71 -0
  96. package/lib/loops/gatherEvidence.ts +265 -0
  97. package/lib/loops/hooks.ts +40 -0
  98. package/lib/loops/index.ts +7 -0
  99. package/lib/loops/route.ts +83 -0
  100. package/lib/loops/runCycle.ts +323 -0
  101. package/lib/loops/screenInstruction.ts +59 -0
  102. package/lib/loops/sendInstruction.ts +83 -0
  103. package/lib/loops/snapshot.ts +38 -0
  104. package/lib/loops/stopLoop.ts +86 -0
  105. package/lib/loops/store.ts +481 -0
  106. package/lib/loops/summary.ts +27 -0
  107. package/lib/loops/tickLoops.ts +141 -0
  108. package/lib/loops/types.ts +223 -0
  109. package/lib/loops/world.test-helpers.ts +223 -0
  110. package/lib/models/plan-step.ts +4 -1
  111. package/lib/orchestrator/digest.ts +2 -1
  112. package/lib/orchestrator/loop-guard.ts +5 -2
  113. package/lib/orchestrator/pr-wake.ts +4 -0
  114. package/lib/orchestrator/watcher.ts +8 -1
  115. package/lib/panes.ts +52 -0
  116. package/lib/plans/acceptance.ts +23 -0
  117. package/lib/{ports.ts → ports/allocatePorts.ts} +30 -7
  118. package/lib/ports/ensurePorts.ts +138 -0
  119. package/lib/ports/findHardcodedPorts.ts +116 -0
  120. package/lib/ports/index.ts +15 -0
  121. package/lib/ports/listListeners.ts +134 -0
  122. package/lib/ports/reassignPort.ts +45 -0
  123. package/lib/ports/types.ts +62 -0
  124. package/lib/ports/whoHolds.ts +155 -0
  125. package/lib/project-config/brief.ts +4 -2
  126. package/lib/project-config/session.ts +19 -3
  127. package/lib/reports/drafts.ts +87 -0
  128. package/lib/reports/end-hook.ts +17 -0
  129. package/lib/reports/estimate.ts +107 -0
  130. package/lib/reports/evidence/build.ts +193 -0
  131. package/lib/reports/evidence/gather.ts +229 -0
  132. package/lib/reports/fixtures.ts +17 -0
  133. package/lib/reports/generate.ts +159 -0
  134. package/lib/reports/hooks.ts +30 -0
  135. package/lib/reports/mode.ts +65 -0
  136. package/lib/reports/model.ts +115 -0
  137. package/lib/reports/read.ts +26 -0
  138. package/lib/reports/store.ts +180 -0
  139. package/lib/reports/template.ts +117 -0
  140. package/lib/reports/types.ts +135 -0
  141. package/lib/reports/validate.ts +142 -0
  142. package/lib/sidebar/groupDrop.ts +36 -0
  143. package/lib/sidebar/row-menu.ts +9 -0
  144. package/lib/tasks/contract/routes.ts +5 -0
  145. package/lib/tasks/contract/types.ts +3 -0
  146. package/lib/tasks/finish.ts +2 -0
  147. package/lib/tasks/move-carry.ts +1 -0
  148. package/lib/windows-ports.ts +36 -18
  149. package/package.json +2 -2
  150. package/stores/analystUi.ts +19 -0
@@ -0,0 +1,289 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import {
5
+ AlertTriangle,
6
+ ArrowRight,
7
+ CheckCircle2,
8
+ Hand,
9
+ Loader2,
10
+ RefreshCw,
11
+ type LucideIcon,
12
+ } from "lucide-react";
13
+ import { Button } from "@/components/ui/button";
14
+ import { StatusPill } from "@/components/ui/status-pill";
15
+ import { useGenerateReport, useTaskMode, useTaskReport } from "@/data/reports";
16
+ import {
17
+ MODE_COPY,
18
+ type EvidenceRef,
19
+ type HealthReport,
20
+ type LineTone,
21
+ } from "@/lib/reports/types";
22
+ import { DraftCard } from "./DraftCard";
23
+ import { EstimateBar } from "./EstimateBar";
24
+ import { EvidenceSheet } from "./EvidenceSheet";
25
+ import { ModeChooser } from "./ModeChooser";
26
+
27
+ const TONES: Record<LineTone, { icon: LucideIcon; cls: string; word: string }> =
28
+ {
29
+ done: {
30
+ icon: CheckCircle2,
31
+ cls: "text-green-600 dark:text-green-400",
32
+ word: "Done",
33
+ },
34
+ warn: {
35
+ icon: AlertTriangle,
36
+ cls: "text-amber-600 dark:text-amber-400",
37
+ word: "Needs attention",
38
+ },
39
+ blocked: {
40
+ icon: Hand,
41
+ cls: "text-red-600 dark:text-red-400",
42
+ word: "Blocked",
43
+ },
44
+ next: { icon: ArrowRight, cls: "text-primary", word: "Next" },
45
+ };
46
+
47
+ const STATE_TONE = {
48
+ merged: "green",
49
+ done: "green",
50
+ working: "primary",
51
+ review: "primary",
52
+ "checks-failing": "red",
53
+ blocked: "red",
54
+ } as const;
55
+
56
+ const when = (iso: string) =>
57
+ new Date(
58
+ iso.includes("T") ? iso : `${iso.replace(" ", "T")}Z`
59
+ ).toLocaleTimeString([], {
60
+ hour: "numeric",
61
+ minute: "2-digit",
62
+ });
63
+
64
+ // A task's plain-English health report: what's done, what needs attention,
65
+ // what's next, each line backed by evidence that opens underneath.
66
+ export function ReportView({
67
+ sessionId,
68
+ name,
69
+ }: {
70
+ sessionId: string;
71
+ name?: string;
72
+ }) {
73
+ const { data, isPending, isError, error } = useTaskReport(sessionId);
74
+ const generate = useGenerateReport(sessionId);
75
+ const setMode = useTaskMode(sessionId);
76
+ const [open, setOpen] = useState<EvidenceRef | null>(null);
77
+ const [changingMode, setChangingMode] = useState(false);
78
+ const asked = useRef(false);
79
+
80
+ const report = data?.report ?? null;
81
+ // Opening the view builds the first report, or a fresh one when the head moved.
82
+ useEffect(() => {
83
+ if (!data || asked.current || data.generating) return;
84
+ if (!report || report.stale) {
85
+ asked.current = true;
86
+ generate.mutate();
87
+ }
88
+ }, [data, report, generate]);
89
+
90
+ if (isPending)
91
+ return <div className="bg-muted/40 m-4 h-40 animate-pulse rounded-lg" />;
92
+ if (isError)
93
+ return <p className="text-destructive p-4 text-sm">{error.message}</p>;
94
+
95
+ const mode = data.mode;
96
+ const busy = data.generating || generate.isPending;
97
+
98
+ return (
99
+ <div className="h-full overflow-y-auto">
100
+ <div className="mx-auto max-w-2xl space-y-5 px-4 py-4">
101
+ <header className="space-y-2">
102
+ <div className="flex flex-wrap items-center gap-2">
103
+ <h2 className="text-lg font-semibold">Your Project Progress</h2>
104
+ {report && (
105
+ <StatusPill
106
+ tone={
107
+ STATE_TONE[report.state as keyof typeof STATE_TONE] ?? "muted"
108
+ }
109
+ >
110
+ {report.state.replace("-", " ")}
111
+ </StatusPill>
112
+ )}
113
+ <button
114
+ type="button"
115
+ onClick={() => setChangingMode((c) => !c)}
116
+ aria-expanded={changingMode}
117
+ className="bg-secondary ml-auto inline-flex min-h-11 items-center rounded-lg px-3 text-xs"
118
+ >
119
+ {MODE_COPY[mode.mode].label}
120
+ {mode.source === "task" ? " · this task" : ""}
121
+ </button>
122
+ </div>
123
+ {name && (
124
+ <p className="text-muted-foreground truncate text-sm">{name}</p>
125
+ )}
126
+ {changingMode && (
127
+ <div className="bg-muted/40 space-y-2 rounded-lg p-3">
128
+ <ModeChooser
129
+ mode={mode.mode}
130
+ onChange={(m) => setMode.mutate(m)}
131
+ disabled={setMode.isPending}
132
+ />
133
+ {mode.source === "task" && (
134
+ <Button
135
+ variant="ghost"
136
+ className="min-h-11"
137
+ onClick={() => setMode.mutate(null)}
138
+ >
139
+ Use the workspace default
140
+ </Button>
141
+ )}
142
+ </div>
143
+ )}
144
+ </header>
145
+
146
+ {!report ? (
147
+ <div className="bg-card space-y-3 rounded-xl p-5 text-center shadow-sm">
148
+ <p className="text-sm">
149
+ {busy
150
+ ? "Reading the evidence…"
151
+ : "No report has been built for this task yet."}
152
+ </p>
153
+ {busy ? (
154
+ <Loader2 className="mx-auto h-5 w-5 animate-spin" />
155
+ ) : (
156
+ <Button className="min-h-11" onClick={() => generate.mutate()}>
157
+ Build the report
158
+ </Button>
159
+ )}
160
+ </div>
161
+ ) : (
162
+ <ReportBody
163
+ report={report}
164
+ sessionId={sessionId}
165
+ mode={mode.mode}
166
+ busy={busy}
167
+ onRefresh={() => generate.mutate()}
168
+ onOpen={setOpen}
169
+ />
170
+ )}
171
+ </div>
172
+ <EvidenceSheet
173
+ evidence={open}
174
+ stale={!!open?.sha && !!report?.head && open.sha !== report.head}
175
+ onClose={() => setOpen(null)}
176
+ />
177
+ </div>
178
+ );
179
+ }
180
+
181
+ function ReportBody({
182
+ report,
183
+ sessionId,
184
+ mode,
185
+ busy,
186
+ onRefresh,
187
+ onOpen,
188
+ }: {
189
+ report: HealthReport;
190
+ sessionId: string;
191
+ mode: string;
192
+ busy: boolean;
193
+ onRefresh: () => void;
194
+ onOpen: (e: EvidenceRef) => void;
195
+ }) {
196
+ const byId = new Map(report.evidence.map((e) => [e.id, e]));
197
+ const chips = (ids: string[]) =>
198
+ ids
199
+ .map((id) => byId.get(id))
200
+ .filter((e): e is EvidenceRef => !!e)
201
+ .map((e) => (
202
+ <button
203
+ key={e.id}
204
+ type="button"
205
+ onClick={() => onOpen(e)}
206
+ className="bg-secondary text-muted-foreground hover:text-foreground relative max-w-full truncate rounded-md px-2 py-0.5 text-[11px] before:absolute before:inset-x-0 before:-inset-y-3"
207
+ >
208
+ {e.label}
209
+ </button>
210
+ ));
211
+
212
+ return (
213
+ <>
214
+ {report.stale && (
215
+ <p className="rounded-lg bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400">
216
+ New work was pushed since this report.
217
+ </p>
218
+ )}
219
+ <section className="bg-card space-y-4 rounded-xl p-4 shadow-sm">
220
+ <EstimateBar estimate={report.estimate} />
221
+ <p className="text-base font-medium">{report.headline}</p>
222
+ <ul className="space-y-3">
223
+ {report.lines.map((l) => {
224
+ const t = TONES[l.tone];
225
+ return (
226
+ <li key={l.id} className="flex gap-3">
227
+ <t.icon
228
+ className={`mt-0.5 h-5 w-5 shrink-0 ${t.cls}`}
229
+ aria-label={t.word}
230
+ />
231
+ <div className="min-w-0 flex-1 space-y-1.5">
232
+ <p className="text-sm">{l.text}</p>
233
+ <div className="flex flex-wrap gap-1.5">
234
+ {chips(l.evidence)}
235
+ </div>
236
+ </div>
237
+ </li>
238
+ );
239
+ })}
240
+ </ul>
241
+ </section>
242
+
243
+ {report.recommendation && (
244
+ <section className="bg-card space-y-2 rounded-xl p-4 shadow-sm">
245
+ <h3 className="text-sm font-semibold">Analyst recommendation</h3>
246
+ <p className="text-sm">{report.recommendation.text}</p>
247
+ <div className="flex flex-wrap gap-1.5">
248
+ {chips(report.recommendation.evidence)}
249
+ </div>
250
+ </section>
251
+ )}
252
+
253
+ {report.draft && mode !== "manual" && (
254
+ <DraftCard
255
+ key={`${report.draft.source}:${report.draft.id}`}
256
+ sessionId={sessionId}
257
+ draft={report.draft}
258
+ />
259
+ )}
260
+
261
+ <footer className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
262
+ <span>
263
+ Built from {report.evidence.length} item
264
+ {report.evidence.length === 1 ? "" : "s"} · {when(report.createdAt)}
265
+ {report.source === "model" && report.model
266
+ ? ` · ${report.model}`
267
+ : " · no model"}
268
+ </span>
269
+ {report.dropped > 0 && (
270
+ <span>
271
+ {report.dropped} unsupported claim{report.dropped === 1 ? "" : "s"}{" "}
272
+ left out
273
+ </span>
274
+ )}
275
+ <Button
276
+ variant="ghost"
277
+ className="ml-auto min-h-11"
278
+ disabled={busy}
279
+ onClick={onRefresh}
280
+ >
281
+ <RefreshCw
282
+ className={`mr-1.5 h-3.5 w-3.5 ${busy ? "animate-spin" : ""}`}
283
+ />
284
+ Refresh
285
+ </Button>
286
+ </footer>
287
+ </>
288
+ );
289
+ }
@@ -18,6 +18,7 @@ import {
18
18
  } from "@/components/ui/dropdown-menu";
19
19
  import { cn } from "@/lib/utils";
20
20
  import { SessionRow } from "./SessionRow";
21
+ import { useDropZone } from "./useGroupDrag";
21
22
 
22
23
  function GroupHeader({
23
24
  shelf,
@@ -154,6 +155,51 @@ function GroupHeader({
154
155
  );
155
156
  }
156
157
 
158
+ function GroupSection({
159
+ shelf,
160
+ siblings,
161
+ workspaceName,
162
+ }: {
163
+ shelf: GroupShelf;
164
+ siblings: GroupShelf[];
165
+ workspaceName?: string;
166
+ }) {
167
+ const { group } = shelf;
168
+ const update = useUpdateGroup();
169
+ const drop = useDropZone(
170
+ { groupId: group.id, workspaceId: group.workspace_id },
171
+ group.collapsed
172
+ ? () => update.mutate({ id: group.id, collapsed: false })
173
+ : undefined
174
+ );
175
+ const empty = !group.collapsed && shelf.rows.length === 0;
176
+ return (
177
+ <section
178
+ aria-label={group.name}
179
+ data-drop-over={drop.over || undefined}
180
+ className={cn(
181
+ "rounded-lg transition-colors",
182
+ drop.over && "bg-primary/10 ring-primary/50 ring-1"
183
+ )}
184
+ {...drop.props}
185
+ >
186
+ <GroupHeader
187
+ shelf={shelf}
188
+ siblings={siblings}
189
+ workspaceName={workspaceName}
190
+ />
191
+ {empty && (
192
+ <p className="text-muted-foreground/70 px-2.5 pb-2 text-xs">
193
+ Nothing here yet · drag a session in, or use its ⋯ menu
194
+ </p>
195
+ )}
196
+ {(group.collapsed ? [] : shelf.rows).map((r) => (
197
+ <SessionRow key={r.session.id} row={r} muted={r.settled === "auto"} />
198
+ ))}
199
+ </section>
200
+ );
201
+ }
202
+
157
203
  // The user's groups, in order, between Pinned and the status shelves. A
158
204
  // folded group keeps its header and counts; an auto-settled member is muted.
159
205
  export function GroupShelves({
@@ -166,30 +212,19 @@ export function GroupShelves({
166
212
  }) {
167
213
  return groups.map((shelf) => {
168
214
  const { group } = shelf;
169
- const siblings = groups.filter(
170
- (g) => g.group.workspace_id === group.workspace_id
171
- );
172
- const empty = !group.collapsed && shelf.rows.length === 0;
173
215
  return (
174
- <section key={group.id} aria-label={group.name}>
175
- <GroupHeader
176
- shelf={shelf}
177
- siblings={siblings}
178
- workspaceName={
179
- workspaceNames && group.workspace_id
180
- ? workspaceNames.get(group.workspace_id)
181
- : undefined
182
- }
183
- />
184
- {empty && (
185
- <p className="text-muted-foreground/70 px-2.5 pb-2 text-xs">
186
- Nothing here yet · move a session in from its ⋯ menu
187
- </p>
216
+ <GroupSection
217
+ key={group.id}
218
+ shelf={shelf}
219
+ siblings={groups.filter(
220
+ (g) => g.group.workspace_id === group.workspace_id
188
221
  )}
189
- {(group.collapsed ? [] : shelf.rows).map((r) => (
190
- <SessionRow key={r.session.id} row={r} muted={r.settled === "auto"} />
191
- ))}
192
- </section>
222
+ workspaceName={
223
+ workspaceNames && group.workspace_id
224
+ ? workspaceNames.get(group.workspace_id)
225
+ : undefined
226
+ }
227
+ />
193
228
  );
194
229
  });
195
230
  }
@@ -0,0 +1,77 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { sessionKeys } from "@/data/sessions/keys";
7
+ import type { SessionGroup } from "@/lib/sidebar/shelves";
8
+ import { groupTargets } from "./SidebarContextMenu";
9
+
10
+ // Radix menus need a real browser; plain elements show what the menu offers.
11
+ vi.mock("./RowMenuParts", async () => {
12
+ const { createElement: h } = await import("react");
13
+ const part = (p: { children?: unknown }) =>
14
+ h("div", null, p.children as never);
15
+ return {
16
+ icon: "",
17
+ MenuItem: part,
18
+ Expander: part,
19
+ };
20
+ });
21
+
22
+ (
23
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
24
+ ).IS_REACT_ACT_ENVIRONMENT = true;
25
+
26
+ let root: Root;
27
+ let host: HTMLElement;
28
+ beforeEach(() => {
29
+ host = document.createElement("div");
30
+ document.body.append(host);
31
+ root = createRoot(host);
32
+ });
33
+ afterEach(() => {
34
+ act(() => root.unmount());
35
+ host.remove();
36
+ });
37
+
38
+ const group = (id: string, name: string, workspace_id: string | null) =>
39
+ ({ id, name, workspace_id, sort_order: 0 }) as SessionGroup;
40
+
41
+ async function menuText(groups: SessionGroup[], workspaceId: string | null) {
42
+ const { MoveToGroupMenu } = await import("./MoveToGroupMenu");
43
+ const client = new QueryClient();
44
+ client.setQueryData(sessionKeys.list(), {
45
+ sessions: [],
46
+ sessionGroups: groups,
47
+ });
48
+ await act(async () =>
49
+ root.render(
50
+ createElement(
51
+ QueryClientProvider,
52
+ { client },
53
+ createElement(MoveToGroupMenu, {
54
+ kind: "dropdown",
55
+ sessionId: "s1",
56
+ groupId: null,
57
+ workspaceId,
58
+ })
59
+ )
60
+ )
61
+ );
62
+ return host.textContent ?? "";
63
+ }
64
+
65
+ describe("Move to group", () => {
66
+ it("offers an empty group, and one made from the all-workspaces view", async () => {
67
+ // Viewing every workspace, "New group" files the group under a workspace
68
+ // (it used to be none, which no session can join).
69
+ const [target] = groupTargets(null, [{ id: "w1", name: "Work" }]);
70
+ const text = await menuText(
71
+ [group("g1", "Fresh", target.id), group("g2", "Elsewhere", "w2")],
72
+ "w1"
73
+ );
74
+ expect(text).toContain("Fresh");
75
+ expect(text).not.toContain("Elsewhere");
76
+ });
77
+ });
@@ -26,6 +26,7 @@ import { RowShell, type RowMenuKind } from "./RowShell";
26
26
  import { CardBadge, NestRail, RowDot, RowRename, UnreadDot } from "./RowParts";
27
27
  import { RowSubtitle } from "./RowSubtitle";
28
28
  import { OrchestratorAsks } from "./OrchestratorAsks";
29
+ import { useRowDrag } from "./useGroupDrag";
29
30
 
30
31
  // One session: status dot, title over its project, and on the right what
31
32
  // needs you or when it last moved. Workers sit under their conductor. Drawn
@@ -64,6 +65,16 @@ export const SessionRow = memo(function SessionRow({
64
65
  ctx.onSelect(session.id);
65
66
  };
66
67
 
68
+ // The server files by the project's workspace first.
69
+ const workspaceId =
70
+ ctx.projects.find((p) => p.id === session.project_id)?.workspace_id ??
71
+ session.workspace_id ??
72
+ null;
73
+ const drag = useRowDrag(
74
+ { id: session.id, groupId: session.session_group_id ?? null, workspaceId },
75
+ !renaming
76
+ );
77
+
67
78
  const badge = rowStatusBadge(row);
68
79
  const cardCount = usePendingCardSessions().get(session.id) ?? 0;
69
80
 
@@ -77,7 +88,7 @@ export const SessionRow = memo(function SessionRow({
77
88
  );
78
89
 
79
90
  return (
80
- <div data-session-row={session.id}>
91
+ <div data-session-row={session.id} {...drag}>
81
92
  <RowShell
82
93
  title={session.name}
83
94
  tipKey={`session:${session.id}`}
@@ -27,6 +27,7 @@ import {
27
27
  RotateCcw,
28
28
  Sparkles,
29
29
  Trash2,
30
+ Eye,
30
31
  } from "lucide-react";
31
32
  import type { Session } from "@/lib/db";
32
33
  import { selectionActions } from "@/stores/sessionSelection";
@@ -51,6 +52,7 @@ import { useRowContext } from "./RowContext";
51
52
  import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
52
53
  import { shareUiActions } from "@/stores/shareUi";
53
54
  import { exportUiActions } from "@/stores/exportUi";
55
+ import { analystUiActions } from "@/stores/analystUi";
54
56
  import { copySessionLink } from "@/hooks/useCopyToClipboard";
55
57
  import { useLinkedHostNames } from "@/data/hosts";
56
58
  import { remoteBlocks, remoteNote } from "@/lib/hosts/remote-menu";
@@ -202,6 +204,10 @@ export function SessionRowMenu({
202
204
  return item("Export for AI…", Download, () =>
203
205
  exportUiActions.open(session.id, session.name)
204
206
  );
207
+ case "add-analyst":
208
+ return item("Add analyst…", Eye, () =>
209
+ analystUiActions.open(session.id, session.name)
210
+ );
205
211
  case "fork":
206
212
  return item("Fork session", Copy, () => ctx.onFork(session.id));
207
213
  case "fresh-start":
@@ -34,7 +34,11 @@ vi.mock("@/components/ui/dialog", async () => {
34
34
  };
35
35
  });
36
36
 
37
- import { SidebarContextMenu, isEditableTarget } from "./SidebarContextMenu";
37
+ import {
38
+ SidebarContextMenu,
39
+ groupTargets,
40
+ isEditableTarget,
41
+ } from "./SidebarContextMenu";
38
42
 
39
43
  (
40
44
  globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
@@ -102,3 +106,19 @@ describe("SidebarContextMenu", () => {
102
106
  expect(isEditableTarget(div)).toBe(false);
103
107
  });
104
108
  });
109
+
110
+ describe("groupTargets", () => {
111
+ const ws = [
112
+ { id: "w1", name: "Work" },
113
+ { id: "w2", name: "Home" },
114
+ ];
115
+ it("uses the selected workspace", () => {
116
+ expect(groupTargets("w2", ws)).toEqual([{ id: "w2", label: "New group" }]);
117
+ });
118
+ it("never offers a group of no workspace when workspaces exist", () => {
119
+ expect(groupTargets(null, [ws[0]])).toEqual([
120
+ { id: "w1", label: "New group" },
121
+ ]);
122
+ expect(groupTargets(null, ws).map((t) => t.id)).toEqual(["w1", "w2"]);
123
+ });
124
+ });
@@ -16,15 +16,33 @@ export function isEditableTarget(target: EventTarget | null): boolean {
16
16
  // Right-click anywhere in the session list (empty space or a group header)
17
17
  // offers "New group". Rows keep their own menu, which wins because it has
18
18
  // already handled the event; text fields keep the browser's.
19
+ // A group belongs to one workspace and only that workspace's sessions can
20
+ // join it. With a workspace selected the group goes there; viewing all of
21
+ // them, the choice is one entry per workspace (a group of no workspace could
22
+ // hold nothing).
23
+ export function groupTargets(
24
+ workspaceId: string | null,
25
+ workspaces: readonly { id: string; name: string }[]
26
+ ): { id: string | null; label: string }[] {
27
+ if (workspaceId || workspaces.length === 0)
28
+ return [{ id: workspaceId, label: "New group" }];
29
+ if (workspaces.length === 1)
30
+ return [{ id: workspaces[0].id, label: "New group" }];
31
+ return workspaces.map((w) => ({ id: w.id, label: `New group in ${w.name}` }));
32
+ }
33
+
19
34
  export function SidebarContextMenu({
20
35
  workspaceId,
36
+ workspaces = [],
21
37
  children,
22
38
  }: {
23
39
  workspaceId: string | null;
40
+ workspaces?: readonly { id: string; name: string }[];
24
41
  children?: ReactNode;
25
42
  }) {
26
43
  const createGroup = useCreateGroup();
27
44
  const [naming, setNaming] = useState(false);
45
+ const [target, setTarget] = useState<string | null>(workspaceId);
28
46
 
29
47
  return (
30
48
  <>
@@ -37,21 +55,27 @@ export function SidebarContextMenu({
37
55
  >
38
56
  {children}
39
57
  </CM.ContextMenuTrigger>
40
- <CM.ContextMenuContent className="w-44">
41
- <CM.ContextMenuItem
42
- className="min-h-11 md:min-h-8"
43
- onSelect={() => setNaming(true)}
44
- >
45
- <FolderPlus className="mr-2 h-3.5 w-3.5" />
46
- New group
47
- </CM.ContextMenuItem>
58
+ <CM.ContextMenuContent className="w-52">
59
+ {groupTargets(workspaceId, workspaces).map((t) => (
60
+ <CM.ContextMenuItem
61
+ key={t.id ?? "none"}
62
+ className="min-h-11 md:min-h-8"
63
+ onSelect={() => {
64
+ setTarget(t.id);
65
+ setNaming(true);
66
+ }}
67
+ >
68
+ <FolderPlus className="mr-2 h-3.5 w-3.5" />
69
+ <span className="truncate">{t.label}</span>
70
+ </CM.ContextMenuItem>
71
+ ))}
48
72
  </CM.ContextMenuContent>
49
73
  </CM.ContextMenu>
50
74
  <WorkspaceNameDialog
51
75
  open={naming}
52
76
  title="New group"
53
77
  submitLabel="Create"
54
- onSubmit={(name) => createGroup.mutate({ name, workspaceId })}
78
+ onSubmit={(name) => createGroup.mutate({ name, workspaceId: target })}
55
79
  onClose={() => setNaming(false)}
56
80
  />
57
81
  </>
@@ -21,6 +21,7 @@ import { SidebarSearch } from "./SidebarSearch";
21
21
  import { ProjectFilter } from "./ProjectFilter";
22
22
  import { SelectionToolbar } from "./SelectionToolbar";
23
23
  import { SidebarBody } from "./SidebarBody";
24
+ import { useDropZone } from "./useGroupDrag";
24
25
  import { SidebarContextMenu } from "./SidebarContextMenu";
25
26
  import { SidebarDialogs, type SidebarDialog } from "./SidebarDialogs";
26
27
  import type { SessionListProps } from "./SessionList.types";
@@ -43,6 +44,8 @@ export function SessionList({
43
44
  pinControls,
44
45
  }: SessionListProps) {
45
46
  const data = useSidebarData(sessionStatuses);
47
+ // Dropping outside every group takes a session out of its group.
48
+ const outside = useDropZone(null);
46
49
  useSessionTips(data);
47
50
  const { shelves, ui, project } = data;
48
51
  const mutations = useSessionListMutations({ onSelectSession: onSelect });
@@ -194,8 +197,14 @@ export function SessionList({
194
197
  )}
195
198
 
196
199
  <ScrollArea className="w-full flex-1">
197
- <SidebarContextMenu workspaceId={data.workspace?.id ?? null}>
198
- <div className="min-h-[60vh] max-w-full px-1.5 pb-6">
200
+ <SidebarContextMenu
201
+ workspaceId={data.workspace?.id ?? null}
202
+ workspaces={data.workspaces}
203
+ >
204
+ <div
205
+ className="min-h-[60vh] max-w-full px-1.5 pb-6"
206
+ {...outside.props}
207
+ >
199
208
  <RowProvider value={rowContext}>
200
209
  <SidebarBody
201
210
  data={data}