@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.
- package/README.md +1 -0
- package/app/api/bus/ports/route.test.ts +43 -0
- package/app/api/bus/ports/route.ts +35 -0
- package/app/api/loops/route.ts +17 -0
- package/app/api/sessions/[id]/export/turn/route.test.ts +65 -0
- package/app/api/sessions/[id]/export/turn/route.ts +33 -0
- package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
- package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
- package/app/api/tasks/[id]/loop/next/route.ts +25 -0
- package/app/api/tasks/[id]/loop/route.ts +72 -0
- package/app/api/tasks/[id]/loop/run/route.ts +12 -0
- package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
- package/app/api/tasks/[id]/mode/route.ts +29 -0
- package/app/api/tasks/[id]/report/draft/route.ts +54 -0
- package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
- package/app/api/tasks/[id]/report/route.ts +39 -0
- package/app/api/workspaces/[id]/mode/route.ts +23 -0
- package/app/api/workspaces/[id]/reports/route.ts +17 -0
- package/app/page.tsx +8 -0
- package/components/Analyst/PairChip.tsx +131 -0
- package/components/Analyst/PairSwitch.tsx +76 -0
- package/components/Analyst/ReviewCard.tsx +272 -0
- package/components/Analyst/SignalChips.tsx +39 -0
- package/components/Analyst/StartAnalystSheet.tsx +109 -0
- package/components/Analyst/cardState.test.ts +109 -0
- package/components/Analyst/cardState.ts +95 -0
- package/components/Analyst/pair.ts +81 -0
- package/components/Chat/Code.tsx +3 -0
- package/components/Chat/Messages.tsx +8 -1
- package/components/Chat/TurnCopy.tsx +84 -0
- package/components/Chat/itemContext.ts +14 -0
- package/components/Draft/DraftChips.tsx +10 -0
- package/components/Draft/DraftPanel.tsx +7 -0
- package/components/Pane/DesktopTabBar.tsx +6 -0
- package/components/Pane/MobileTabBar.tsx +17 -0
- package/components/Pane/MobileViewPicker.tsx +4 -0
- package/components/Pane/PaneMenu.test.ts +21 -0
- package/components/Pane/PaneMenu.tsx +16 -0
- package/components/Pane/PaneViewToggle.tsx +12 -0
- package/components/Pane/index.tsx +19 -0
- package/components/Report/DraftCard.tsx +89 -0
- package/components/Report/EstimateBar.tsx +51 -0
- package/components/Report/EvidenceSheet.tsx +98 -0
- package/components/Report/ModeChooser.tsx +43 -0
- package/components/Report/ReportView.tsx +289 -0
- package/components/SessionList/GroupShelves.tsx +57 -22
- package/components/SessionList/MoveToGroupMenu.test.ts +77 -0
- package/components/SessionList/SessionRow.tsx +12 -1
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/SessionList/SidebarContextMenu.test.ts +21 -1
- package/components/SessionList/SidebarContextMenu.tsx +33 -9
- package/components/SessionList/index.tsx +11 -2
- package/components/SessionList/useGroupDrag.ts +83 -0
- package/components/Sessions/RolePill.tsx +7 -0
- package/components/Tasks/Loop/LoopButton.tsx +74 -0
- package/components/Tasks/Loop/LoopSetup.tsx +146 -0
- package/components/Tasks/Loop/LoopView.tsx +350 -0
- package/components/Tasks/Loop/labels.ts +17 -0
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/components/ui/segmented.tsx +55 -0
- package/components/ui/status-pill.tsx +3 -0
- package/contexts/PaneContext.tsx +21 -1
- package/data/exports/queries.ts +11 -0
- package/data/loops/index.ts +3 -0
- package/data/loops/keys.ts +4 -0
- package/data/loops/mutations.ts +68 -0
- package/data/loops/queries.ts +40 -0
- package/data/push/topics.ts +6 -0
- package/data/reports/index.ts +115 -0
- package/lib/agents/brief.ts +1 -1
- package/lib/analyst/forward.ts +1 -1
- package/lib/db/changes.ts +7 -0
- package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
- package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
- package/lib/done/index.ts +2 -0
- package/lib/drafts.ts +2 -0
- package/lib/export/markdown.ts +7 -1
- package/lib/export/turn.ts +48 -0
- package/lib/health/config.ts +2 -0
- package/lib/health/env/classes.ts +14 -1
- package/lib/health/port-wait-tick.ts +222 -0
- package/lib/health/port-wait.ts +160 -0
- package/lib/health/tick.ts +9 -2
- package/lib/health/types.ts +2 -0
- package/lib/loops/analyse.ts +46 -0
- package/lib/loops/analyst-prompt.ts +203 -0
- package/lib/loops/checkCriteria.ts +114 -0
- package/lib/loops/compactEvidence.ts +140 -0
- package/lib/loops/controls.ts +317 -0
- package/lib/loops/decideStop.ts +113 -0
- package/lib/loops/deps.ts +128 -0
- package/lib/loops/diffMap.ts +39 -0
- package/lib/loops/draftCriteria.ts +71 -0
- package/lib/loops/gatherEvidence.ts +265 -0
- package/lib/loops/hooks.ts +40 -0
- package/lib/loops/index.ts +7 -0
- package/lib/loops/route.ts +83 -0
- package/lib/loops/runCycle.ts +323 -0
- package/lib/loops/screenInstruction.ts +59 -0
- package/lib/loops/sendInstruction.ts +83 -0
- package/lib/loops/snapshot.ts +38 -0
- package/lib/loops/stopLoop.ts +86 -0
- package/lib/loops/store.ts +481 -0
- package/lib/loops/summary.ts +27 -0
- package/lib/loops/tickLoops.ts +141 -0
- package/lib/loops/types.ts +223 -0
- package/lib/loops/world.test-helpers.ts +223 -0
- package/lib/models/plan-step.ts +4 -1
- package/lib/orchestrator/digest.ts +2 -1
- package/lib/orchestrator/loop-guard.ts +5 -2
- package/lib/orchestrator/pr-wake.ts +4 -0
- package/lib/orchestrator/watcher.ts +8 -1
- package/lib/panes.ts +52 -0
- package/lib/plans/acceptance.ts +23 -0
- package/lib/{ports.ts → ports/allocatePorts.ts} +30 -7
- package/lib/ports/ensurePorts.ts +138 -0
- package/lib/ports/findHardcodedPorts.ts +116 -0
- package/lib/ports/index.ts +15 -0
- package/lib/ports/listListeners.ts +134 -0
- package/lib/ports/reassignPort.ts +45 -0
- package/lib/ports/types.ts +62 -0
- package/lib/ports/whoHolds.ts +155 -0
- package/lib/project-config/brief.ts +4 -2
- package/lib/project-config/session.ts +19 -3
- package/lib/reports/drafts.ts +87 -0
- package/lib/reports/end-hook.ts +17 -0
- package/lib/reports/estimate.ts +107 -0
- package/lib/reports/evidence/build.ts +193 -0
- package/lib/reports/evidence/gather.ts +229 -0
- package/lib/reports/fixtures.ts +17 -0
- package/lib/reports/generate.ts +159 -0
- package/lib/reports/hooks.ts +30 -0
- package/lib/reports/mode.ts +65 -0
- package/lib/reports/model.ts +115 -0
- package/lib/reports/read.ts +26 -0
- package/lib/reports/store.ts +180 -0
- package/lib/reports/template.ts +117 -0
- package/lib/reports/types.ts +135 -0
- package/lib/reports/validate.ts +142 -0
- package/lib/sidebar/groupDrop.ts +36 -0
- package/lib/sidebar/row-menu.ts +9 -0
- package/lib/tasks/contract/routes.ts +5 -0
- package/lib/tasks/contract/types.ts +3 -0
- package/lib/tasks/finish.ts +2 -0
- package/lib/tasks/move-carry.ts +1 -0
- package/lib/windows-ports.ts +36 -18
- package/package.json +2 -2
- 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
|
-
<
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
{
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 {
|
|
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-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
198
|
-
|
|
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}
|