@saadnvd1/agent-os 0.3.715 → 0.3.723
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/agent-roles/route.test.ts +67 -0
- package/app/api/agent-roles/route.ts +61 -0
- package/app/api/analyst/[id]/recommendations/[recId]/again/route.ts +15 -0
- package/app/api/analyst/[id]/recommendations/[recId]/dismiss/route.ts +13 -0
- package/app/api/analyst/[id]/recommendations/[recId]/send/route.ts +13 -0
- package/app/api/analyst/[id]/review/route.ts +22 -0
- package/app/api/analyst/[id]/route.ts +44 -0
- package/app/api/analyst/route.ts +10 -0
- package/app/api/bus/spawn/route.ts +22 -1
- package/app/api/models/availability/route.ts +9 -0
- package/app/api/models/choices/route.ts +9 -0
- package/app/api/sessions/[id]/analyst/route.ts +11 -0
- package/app/api/sessions/[id]/crew/route.ts +38 -0
- package/app/api/sessions/[id]/export/estimate/route.ts +23 -0
- package/app/api/sessions/[id]/export/route.test.ts +125 -0
- package/app/api/sessions/[id]/export/route.ts +34 -0
- package/app/api/sessions/[id]/model/route.ts +49 -0
- package/app/api/sessions/route.ts +16 -0
- package/app/page.tsx +5 -0
- package/components/Chat/Messages.tsx +104 -10
- package/components/Draft/DraftChips.tsx +6 -1
- package/components/Draft/DraftPanel.tsx +51 -2
- package/components/Draft/ModelUnavailableDialog.tsx +78 -0
- package/components/Draft/RoleChips.tsx +110 -0
- package/components/Export/ExportActions.tsx +136 -0
- package/components/Export/ExportSheet.tsx +217 -0
- package/components/Export/RangePicker.tsx +144 -0
- package/components/Export/Seg.tsx +38 -0
- package/components/Export/TurnSelectBar.tsx +45 -0
- package/components/Models/AgentRoleCard.tsx +114 -0
- package/components/Models/AgentRolesSection.tsx +132 -0
- package/components/Models/ModelBadges.tsx +48 -0
- package/components/Models/ModelsSection.tsx +4 -2
- package/components/Models/RoleModelSelect.tsx +87 -0
- package/components/Pane/DesktopTabBar.tsx +2 -0
- package/components/Pane/MobileTabBar.tsx +12 -0
- package/components/Pane/PaneMenu.tsx +10 -0
- package/components/SessionList/SessionRow.tsx +18 -7
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/Sessions/RolePill.tsx +196 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Share/ShareDialog.tsx +13 -0
- package/data/analyst/index.ts +3 -0
- package/data/analyst/keys.ts +6 -0
- package/data/analyst/mutations.ts +116 -0
- package/data/analyst/queries.ts +35 -0
- package/data/exports/index.ts +2 -0
- package/data/exports/keys.ts +3 -0
- package/data/exports/queries.ts +51 -0
- package/data/models/index.ts +98 -0
- package/data/push/topics.ts +5 -0
- package/data/sessions/queries.ts +26 -0
- package/lib/agents/brief.ts +1 -0
- package/lib/analyst/__fixtures__/items.ts +57 -0
- package/lib/analyst/__fixtures__/mocks.ts +49 -0
- package/lib/analyst/brief.ts +17 -0
- package/lib/analyst/buildDigest.ts +204 -0
- package/lib/analyst/createLink.ts +98 -0
- package/lib/analyst/endLink.ts +31 -0
- package/lib/analyst/forward.ts +109 -0
- package/lib/analyst/index.ts +16 -0
- package/lib/analyst/linkView.ts +38 -0
- package/lib/analyst/onTurnEnd.ts +119 -0
- package/lib/analyst/parseReview.ts +41 -0
- package/lib/analyst/pushDigest.ts +143 -0
- package/lib/analyst/resume.ts +35 -0
- package/lib/analyst/route.ts +50 -0
- package/lib/analyst/signals.ts +121 -0
- package/lib/analyst/store.ts +303 -0
- package/lib/analyst/types.ts +140 -0
- package/lib/bus/condense.ts +5 -1
- package/lib/bus/index.ts +5 -0
- package/lib/bus/read-scope.ts +12 -0
- package/lib/bus/role-alias.ts +70 -0
- package/lib/bus/targets.ts +3 -0
- package/lib/chat/drivers/opencode-mapper.ts +55 -1
- package/lib/chat/drivers/pi-approvals.ts +7 -4
- package/lib/chat/drivers/pi-mapper.ts +38 -0
- package/lib/chat/drivers/pi-text-tools.ts +104 -0
- package/lib/chat/drivers/pi.ts +8 -3
- package/lib/chat/drivers/turn-items.ts +36 -11
- package/lib/chat/drivers/turn-outcome.ts +108 -0
- package/lib/chat/events.ts +8 -0
- package/lib/chat/registry.ts +13 -0
- package/lib/chat/store.ts +19 -0
- package/lib/chat/turns.ts +1 -0
- package/lib/chat/worker/extras.ts +4 -2
- package/lib/claims/collectLocal.ts +3 -1
- package/lib/db/changes.ts +6 -0
- package/lib/db/migrations/20261021T1400_session_agent_roles.ts +20 -0
- package/lib/db/migrations/20261021T1430_analyst_links.ts +52 -0
- package/lib/db/session-parts/columns.ts +2 -0
- package/lib/db/types.ts +3 -0
- package/lib/done/archive.ts +2 -0
- package/lib/drafts.ts +4 -0
- package/lib/export/build.ts +470 -0
- package/lib/export/compact.ts +156 -0
- package/lib/export/estimate.ts +83 -0
- package/lib/export/fixtures.ts +45 -0
- package/lib/export/html.tsx +195 -0
- package/lib/export/json.ts +39 -0
- package/lib/export/load.ts +64 -0
- package/lib/export/markdown.ts +127 -0
- package/lib/export/request.ts +36 -0
- package/lib/export/service.ts +142 -0
- package/lib/export/summary.ts +86 -0
- package/lib/export/turns.ts +63 -0
- package/lib/export/view.ts +173 -0
- package/lib/models/agent-binding.ts +102 -0
- package/lib/models/agent-roles-view.ts +157 -0
- package/lib/models/agent-roles.ts +87 -0
- package/lib/models/availability-refs.ts +17 -0
- package/lib/models/availability.ts +91 -0
- package/lib/models/catalog.ts +156 -0
- package/lib/models/config.ts +27 -0
- package/lib/models/locality.ts +33 -0
- package/lib/models/providers.ts +11 -0
- package/lib/models/resolve.ts +50 -12
- package/lib/models/roles.ts +12 -0
- package/lib/needs-you.ts +1 -1
- package/lib/orchestrator/facts.ts +7 -2
- package/lib/orchestrator/read.ts +5 -1
- package/lib/orchestrator/untrusted.ts +5 -0
- package/lib/projects.ts +1 -0
- package/lib/session-artifacts/agentos-files.ts +94 -0
- package/lib/session-artifacts/collect.ts +17 -0
- package/lib/sessions/crew.ts +72 -0
- package/lib/sessions/launch.ts +49 -9
- package/lib/settings-url.ts +1 -0
- package/lib/share/markdown.ts +2 -2
- package/lib/share/redact.ts +16 -10
- package/lib/share/render.tsx +1 -1
- package/lib/share/snapshot.ts +2 -2
- package/lib/sidebar/row-menu.ts +6 -1
- package/lib/tasks/index.ts +6 -0
- package/lib/worktree-deps/caches.ts +3 -2
- package/lib/worktree-deps/clone.ts +3 -1
- package/lib/worktree-deps/paths.ts +26 -0
- package/package.json +1 -1
- package/server.ts +9 -1
- package/stores/exportUi.ts +87 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { useSnapshot } from "valtio";
|
|
5
|
+
import { AlertTriangle, Info } from "lucide-react";
|
|
6
|
+
import { Switch } from "@/components/ui/switch";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
} from "@/components/ui/dialog";
|
|
13
|
+
import { useExportEstimate, type ExportEstimate } from "@/data/exports";
|
|
14
|
+
import { exportUi, exportUiActions } from "@/stores/exportUi";
|
|
15
|
+
import {
|
|
16
|
+
COMPACT_RULES,
|
|
17
|
+
EXPORT_FORMATS,
|
|
18
|
+
formatBytes,
|
|
19
|
+
redactionStatus,
|
|
20
|
+
tokenLabel,
|
|
21
|
+
type ExportFormat,
|
|
22
|
+
type ExportMode,
|
|
23
|
+
type ExportRequest,
|
|
24
|
+
} from "@/lib/export/view";
|
|
25
|
+
import { cn } from "@/lib/utils";
|
|
26
|
+
import { ExportActions } from "./ExportActions";
|
|
27
|
+
import { RangePicker } from "./RangePicker";
|
|
28
|
+
import { Seg } from "./Seg";
|
|
29
|
+
import { TurnSelectBar } from "./TurnSelectBar";
|
|
30
|
+
|
|
31
|
+
// Opened from a menu or a message: what to export (format, detail, turns,
|
|
32
|
+
// masking), what it comes to, and where it goes (copy, download, share).
|
|
33
|
+
export function ExportSheet() {
|
|
34
|
+
const ui = useSnapshot(exportUi);
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<Dialog
|
|
38
|
+
open={ui.open && !!ui.sessionId}
|
|
39
|
+
onOpenChange={(open) => !open && exportUiActions.close()}
|
|
40
|
+
>
|
|
41
|
+
{ui.sessionId && (
|
|
42
|
+
<SheetBody
|
|
43
|
+
key={ui.sessionId}
|
|
44
|
+
sessionId={ui.sessionId}
|
|
45
|
+
title={ui.title}
|
|
46
|
+
/>
|
|
47
|
+
)}
|
|
48
|
+
</Dialog>
|
|
49
|
+
{ui.selecting && <TurnSelectBar />}
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function SheetBody({ sessionId, title }: { sessionId: string; title: string }) {
|
|
55
|
+
const { req, fromItemId } = useSnapshot(exportUi);
|
|
56
|
+
const [est, setEst] = useState<ExportEstimate | null>(null);
|
|
57
|
+
const [error, setError] = useState<string | null>(null);
|
|
58
|
+
const estimate = useExportEstimate(sessionId);
|
|
59
|
+
const seq = useRef(0);
|
|
60
|
+
const wire = JSON.stringify(req);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const mine = ++seq.current;
|
|
64
|
+
const timer = setTimeout(async () => {
|
|
65
|
+
try {
|
|
66
|
+
const e = await estimate.mutateAsync(JSON.parse(wire) as ExportRequest);
|
|
67
|
+
if (mine !== seq.current) return;
|
|
68
|
+
setEst(e);
|
|
69
|
+
setError(null);
|
|
70
|
+
exportUi.turns = e.turns;
|
|
71
|
+
const from = exportUi.fromItemId;
|
|
72
|
+
if (from) {
|
|
73
|
+
exportUi.fromItemId = null;
|
|
74
|
+
const turn = e.turns.find((t) => t.id === from);
|
|
75
|
+
if (turn)
|
|
76
|
+
exportUiActions.setRequest({
|
|
77
|
+
range: { from: turn.n, to: e.selected.of },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (mine === seq.current) setError((err as Error).message);
|
|
82
|
+
}
|
|
83
|
+
}, 250);
|
|
84
|
+
return () => clearTimeout(timer);
|
|
85
|
+
// The mutation object changes every render; the request is the input.
|
|
86
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
87
|
+
}, [wire, fromItemId]);
|
|
88
|
+
|
|
89
|
+
const name = est?.title ?? title;
|
|
90
|
+
const set = exportUiActions.setRequest;
|
|
91
|
+
const size = est?.estimate[req.mode];
|
|
92
|
+
const stale = estimate.isPending;
|
|
93
|
+
const token = (m: ExportMode) =>
|
|
94
|
+
est ? tokenLabel(est.estimate[m].tokens).replace("about ", "~") : " ";
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<DialogContent className="flex h-[100dvh] w-screen max-w-none flex-col gap-3 rounded-none p-4 sm:h-auto sm:max-h-[90vh] sm:max-w-[560px] sm:rounded-lg">
|
|
98
|
+
<div>
|
|
99
|
+
<DialogTitle>Export for AI</DialogTitle>
|
|
100
|
+
<DialogDescription className="truncate">{name}</DialogDescription>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto">
|
|
104
|
+
<Section label="Format">
|
|
105
|
+
<Seg<ExportFormat>
|
|
106
|
+
label="Format"
|
|
107
|
+
value={req.format}
|
|
108
|
+
options={EXPORT_FORMATS.map((f) => ({ id: f.id, label: f.label }))}
|
|
109
|
+
onChange={(format) => set({ format })}
|
|
110
|
+
/>
|
|
111
|
+
<p className="text-muted-foreground text-xs">
|
|
112
|
+
{EXPORT_FORMATS.find((f) => f.id === req.format)?.hint}
|
|
113
|
+
</p>
|
|
114
|
+
</Section>
|
|
115
|
+
|
|
116
|
+
<Section label="Detail">
|
|
117
|
+
<Seg<ExportMode>
|
|
118
|
+
label="Detail"
|
|
119
|
+
value={req.mode}
|
|
120
|
+
options={[
|
|
121
|
+
{ id: "compact", label: "Compact", sub: token("compact") },
|
|
122
|
+
{ id: "full", label: "Full", sub: token("full") },
|
|
123
|
+
]}
|
|
124
|
+
onChange={(mode) => set({ mode })}
|
|
125
|
+
/>
|
|
126
|
+
<details className="text-muted-foreground text-xs">
|
|
127
|
+
<summary className="flex min-h-8 cursor-pointer items-center">
|
|
128
|
+
What Compact drops
|
|
129
|
+
</summary>
|
|
130
|
+
<ul className="list-disc space-y-0.5 pl-5">
|
|
131
|
+
{COMPACT_RULES.map((r) => (
|
|
132
|
+
<li key={r}>{r}</li>
|
|
133
|
+
))}
|
|
134
|
+
</ul>
|
|
135
|
+
</details>
|
|
136
|
+
</Section>
|
|
137
|
+
|
|
138
|
+
<Section label="Range">
|
|
139
|
+
<RangePicker
|
|
140
|
+
range={req.range}
|
|
141
|
+
est={est}
|
|
142
|
+
onChange={(range) => set({ range })}
|
|
143
|
+
onSelectInChat={() => exportUiActions.startSelect()}
|
|
144
|
+
/>
|
|
145
|
+
</Section>
|
|
146
|
+
|
|
147
|
+
<Section label="Masking">
|
|
148
|
+
<label className="flex min-h-11 items-center justify-between gap-3 text-sm">
|
|
149
|
+
<span>Mask paths, machine names and emails</span>
|
|
150
|
+
<Switch
|
|
151
|
+
checked={req.redact === "all"}
|
|
152
|
+
onCheckedChange={(on) => set({ redact: on ? "all" : "secrets" })}
|
|
153
|
+
/>
|
|
154
|
+
</label>
|
|
155
|
+
<p className="text-muted-foreground text-xs">
|
|
156
|
+
Credentials are always masked.{" "}
|
|
157
|
+
{est ? redactionStatus(est.hits) + "." : ""}
|
|
158
|
+
</p>
|
|
159
|
+
</Section>
|
|
160
|
+
|
|
161
|
+
<div
|
|
162
|
+
className={cn(
|
|
163
|
+
"space-y-2 rounded-lg border p-3 text-sm",
|
|
164
|
+
stale && "opacity-60"
|
|
165
|
+
)}
|
|
166
|
+
aria-live="polite"
|
|
167
|
+
>
|
|
168
|
+
<p className="font-medium">
|
|
169
|
+
{size
|
|
170
|
+
? `${formatBytes(size.bytes)} · ${tokenLabel(size.tokens)}`
|
|
171
|
+
: (error ?? "Working out the size…")}
|
|
172
|
+
</p>
|
|
173
|
+
{est?.warnings.map((w) => (
|
|
174
|
+
<p
|
|
175
|
+
key={w.code}
|
|
176
|
+
className={cn(
|
|
177
|
+
"flex gap-2 text-xs",
|
|
178
|
+
w.level === "danger" && "text-destructive",
|
|
179
|
+
w.level === "warn" && "text-amber-600 dark:text-amber-400",
|
|
180
|
+
w.level === "info" && "text-muted-foreground"
|
|
181
|
+
)}
|
|
182
|
+
>
|
|
183
|
+
{w.level === "info" ? (
|
|
184
|
+
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
185
|
+
) : (
|
|
186
|
+
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
187
|
+
)}
|
|
188
|
+
<span>{w.text}</span>
|
|
189
|
+
</p>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<ExportActions
|
|
195
|
+
sessionId={sessionId}
|
|
196
|
+
title={name}
|
|
197
|
+
req={req as ExportRequest}
|
|
198
|
+
est={est}
|
|
199
|
+
/>
|
|
200
|
+
</DialogContent>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function Section({
|
|
205
|
+
label,
|
|
206
|
+
children,
|
|
207
|
+
}: {
|
|
208
|
+
label: string;
|
|
209
|
+
children: React.ReactNode;
|
|
210
|
+
}) {
|
|
211
|
+
return (
|
|
212
|
+
<section className="space-y-2">
|
|
213
|
+
<h3 className="text-muted-foreground text-xs font-medium">{label}</h3>
|
|
214
|
+
{children}
|
|
215
|
+
</section>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { MousePointerClick } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import type { ExportEstimate } from "@/data/exports";
|
|
7
|
+
import type { ExportRange } from "@/lib/export/view";
|
|
8
|
+
|
|
9
|
+
type Chip = "all" | "last1" | "last5" | "compaction" | "custom";
|
|
10
|
+
|
|
11
|
+
const CHIPS: { id: Chip; label: string }[] = [
|
|
12
|
+
{ id: "all", label: "Whole session" },
|
|
13
|
+
{ id: "last1", label: "Last turn" },
|
|
14
|
+
{ id: "last5", label: "Last 5" },
|
|
15
|
+
{ id: "compaction", label: "Since compaction" },
|
|
16
|
+
{ id: "custom", label: "Custom" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const chipOf = (r: ExportRange | undefined): Chip =>
|
|
20
|
+
!r
|
|
21
|
+
? "all"
|
|
22
|
+
: "sinceCompaction" in r
|
|
23
|
+
? "compaction"
|
|
24
|
+
: "last" in r
|
|
25
|
+
? r.last === 1
|
|
26
|
+
? "last1"
|
|
27
|
+
: r.last === 5
|
|
28
|
+
? "last5"
|
|
29
|
+
: "custom"
|
|
30
|
+
: "custom";
|
|
31
|
+
|
|
32
|
+
const when = (at: string) => at.slice(5, 16).replace("T", " ");
|
|
33
|
+
const clock = (at: string) => at.slice(11, 16);
|
|
34
|
+
|
|
35
|
+
const SELECT =
|
|
36
|
+
"bg-background border-input min-h-11 w-full min-w-0 rounded-md border px-2 text-sm";
|
|
37
|
+
|
|
38
|
+
export function RangePicker({
|
|
39
|
+
range,
|
|
40
|
+
est,
|
|
41
|
+
onChange,
|
|
42
|
+
onSelectInChat,
|
|
43
|
+
}: {
|
|
44
|
+
range: ExportRange | undefined;
|
|
45
|
+
est: ExportEstimate | null;
|
|
46
|
+
onChange: (r: ExportRange | undefined) => void;
|
|
47
|
+
onSelectInChat: () => void;
|
|
48
|
+
}) {
|
|
49
|
+
const chip = chipOf(range);
|
|
50
|
+
const turns = (est?.turns ?? []).filter((t) => t.n > 0);
|
|
51
|
+
const sel = est?.selected;
|
|
52
|
+
const pick = (id: Chip) => {
|
|
53
|
+
if (id === "all") onChange(undefined);
|
|
54
|
+
else if (id === "last1") onChange({ last: 1 });
|
|
55
|
+
else if (id === "last5") onChange({ last: 5 });
|
|
56
|
+
else if (id === "compaction") onChange({ sinceCompaction: true });
|
|
57
|
+
else
|
|
58
|
+
onChange({
|
|
59
|
+
from: sel?.from ?? 1,
|
|
60
|
+
to: sel?.to ?? Math.max(1, turns.length),
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
const custom = range && "from" in range ? range : null;
|
|
64
|
+
const options = turns.map((t) => (
|
|
65
|
+
<option key={t.n} value={t.n}>
|
|
66
|
+
{`#${t.n} · ${when(t.at)} · ${t.excerpt.slice(0, 40)}`}
|
|
67
|
+
</option>
|
|
68
|
+
));
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="space-y-2">
|
|
72
|
+
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Range">
|
|
73
|
+
{CHIPS.map((c) => (
|
|
74
|
+
<button
|
|
75
|
+
key={c.id}
|
|
76
|
+
type="button"
|
|
77
|
+
aria-pressed={chip === c.id}
|
|
78
|
+
onClick={() => pick(c.id)}
|
|
79
|
+
className={cn(
|
|
80
|
+
"min-h-11 rounded-full border px-3.5 text-sm transition-colors",
|
|
81
|
+
chip === c.id
|
|
82
|
+
? "border-primary bg-primary/10 text-foreground"
|
|
83
|
+
: "text-muted-foreground hover:bg-foreground/[0.04]"
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
{c.label}
|
|
87
|
+
</button>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{custom && (
|
|
92
|
+
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2">
|
|
93
|
+
<label className="text-muted-foreground space-y-1 text-xs">
|
|
94
|
+
From
|
|
95
|
+
<select
|
|
96
|
+
className={SELECT}
|
|
97
|
+
value={custom.from}
|
|
98
|
+
onChange={(e) =>
|
|
99
|
+
onChange({
|
|
100
|
+
from: Number(e.target.value),
|
|
101
|
+
to: Math.max(custom.to, Number(e.target.value)),
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
>
|
|
105
|
+
{options}
|
|
106
|
+
</select>
|
|
107
|
+
</label>
|
|
108
|
+
<label className="text-muted-foreground space-y-1 text-xs">
|
|
109
|
+
To
|
|
110
|
+
<select
|
|
111
|
+
className={SELECT}
|
|
112
|
+
value={custom.to}
|
|
113
|
+
onChange={(e) =>
|
|
114
|
+
onChange({
|
|
115
|
+
from: Math.min(custom.from, Number(e.target.value)),
|
|
116
|
+
to: Number(e.target.value),
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
>
|
|
120
|
+
{options}
|
|
121
|
+
</select>
|
|
122
|
+
</label>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
<div className="flex items-center justify-between gap-2">
|
|
127
|
+
<p className="text-muted-foreground min-w-0 text-xs">
|
|
128
|
+
{sel && sel.startedAt && sel.endedAt
|
|
129
|
+
? `Turns ${sel.from}–${sel.to} · ${sel.turns} ${sel.turns === 1 ? "turn" : "turns"} · ${clock(sel.startedAt)}–${clock(sel.endedAt)}`
|
|
130
|
+
: "Counting turns…"}
|
|
131
|
+
</p>
|
|
132
|
+
<Button
|
|
133
|
+
variant="outline"
|
|
134
|
+
size="sm"
|
|
135
|
+
className="min-h-11 shrink-0"
|
|
136
|
+
onClick={onSelectInChat}
|
|
137
|
+
>
|
|
138
|
+
<MousePointerClick className="h-4 w-4" />
|
|
139
|
+
Select in chat
|
|
140
|
+
</Button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
// A row of exclusive choices, each at least 44px tall.
|
|
6
|
+
export function Seg<T extends string>({
|
|
7
|
+
label,
|
|
8
|
+
value,
|
|
9
|
+
options,
|
|
10
|
+
onChange,
|
|
11
|
+
}: {
|
|
12
|
+
label: string;
|
|
13
|
+
value: T;
|
|
14
|
+
options: { id: T; label: string; sub?: string }[];
|
|
15
|
+
onChange: (id: T) => void;
|
|
16
|
+
}) {
|
|
17
|
+
return (
|
|
18
|
+
<div className="flex gap-1.5" role="group" aria-label={label}>
|
|
19
|
+
{options.map((o) => (
|
|
20
|
+
<button
|
|
21
|
+
key={o.id}
|
|
22
|
+
type="button"
|
|
23
|
+
aria-pressed={value === o.id}
|
|
24
|
+
onClick={() => onChange(o.id)}
|
|
25
|
+
className={cn(
|
|
26
|
+
"flex min-h-11 min-w-0 flex-1 flex-col items-center justify-center rounded-lg border px-2 text-sm transition-colors",
|
|
27
|
+
value === o.id
|
|
28
|
+
? "border-primary bg-primary/10 text-foreground"
|
|
29
|
+
: "text-muted-foreground hover:bg-foreground/[0.04]"
|
|
30
|
+
)}
|
|
31
|
+
>
|
|
32
|
+
<span className="font-medium">{o.label}</span>
|
|
33
|
+
{o.sub && <span className="text-[11px] opacity-80">{o.sub}</span>}
|
|
34
|
+
</button>
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSnapshot } from "valtio";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { exportUi, exportUiActions, pickedRange } from "@/stores/exportUi";
|
|
6
|
+
import { tokenLabel } from "@/lib/export/view";
|
|
7
|
+
|
|
8
|
+
// The bar under the chat while turns are being picked for an export.
|
|
9
|
+
export function TurnSelectBar() {
|
|
10
|
+
const { pick, turns } = useSnapshot(exportUi);
|
|
11
|
+
const range = pickedRange(pick);
|
|
12
|
+
const picked = range
|
|
13
|
+
? turns.filter((t) => t.n >= range.from && t.n <= range.to)
|
|
14
|
+
: [];
|
|
15
|
+
const tokens = picked.reduce((sum, t) => sum + t.tokens, 0);
|
|
16
|
+
const count = range ? range.to - range.from + 1 : 0;
|
|
17
|
+
const text = !range
|
|
18
|
+
? "Tap the first turn to export"
|
|
19
|
+
: pick.to === null
|
|
20
|
+
? `Turn ${range.from} · tap the last turn, or export just this one`
|
|
21
|
+
: `Turns ${range.from}–${range.to} · ${count} ${count === 1 ? "turn" : "turns"} · ${tokenLabel(tokens)}`;
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
role="region"
|
|
25
|
+
aria-label="Select turns to export"
|
|
26
|
+
className="bg-background fixed inset-x-0 bottom-0 z-40 flex flex-wrap items-center gap-2 border-t p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]"
|
|
27
|
+
>
|
|
28
|
+
<p className="min-w-[140px] flex-1 text-sm">{text}</p>
|
|
29
|
+
<Button
|
|
30
|
+
variant="outline"
|
|
31
|
+
className="min-h-11"
|
|
32
|
+
onClick={exportUiActions.cancelSelect}
|
|
33
|
+
>
|
|
34
|
+
Cancel
|
|
35
|
+
</Button>
|
|
36
|
+
<Button
|
|
37
|
+
className="min-h-11"
|
|
38
|
+
disabled={!range}
|
|
39
|
+
onClick={exportUiActions.finishSelect}
|
|
40
|
+
>
|
|
41
|
+
Export…
|
|
42
|
+
</Button>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AlertTriangle } from "lucide-react";
|
|
4
|
+
import type { AgentRoleView } from "@/lib/models/agent-roles-view";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { RoleModelSelect } from "./RoleModelSelect";
|
|
7
|
+
|
|
8
|
+
const SOURCE: Record<string, string> = {
|
|
9
|
+
task: "Set for the task",
|
|
10
|
+
project: "From this project",
|
|
11
|
+
workspace: "From the workspace",
|
|
12
|
+
settings: "Set in Settings",
|
|
13
|
+
env: "From the server's environment",
|
|
14
|
+
inherited: "Same as Gate review until set here",
|
|
15
|
+
default: "Default",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const initial = (label: string) => label.slice(0, 1);
|
|
19
|
+
|
|
20
|
+
// One role: what it does, the model it runs on, where that came from, and
|
|
21
|
+
// whether the model's server answers. A role whose local model is down says so.
|
|
22
|
+
export function AgentRoleCard({
|
|
23
|
+
role,
|
|
24
|
+
scope,
|
|
25
|
+
pending,
|
|
26
|
+
onChange,
|
|
27
|
+
onFallback,
|
|
28
|
+
}: {
|
|
29
|
+
role: AgentRoleView;
|
|
30
|
+
scope: "settings" | "workspace" | "project";
|
|
31
|
+
pending: boolean;
|
|
32
|
+
onChange: (ref: string | null) => void;
|
|
33
|
+
onFallback: (ref: string | null) => void;
|
|
34
|
+
}) {
|
|
35
|
+
const down = role.choice?.availability.state === "down";
|
|
36
|
+
const label = `Default (${role.default.split("/").slice(1).join("/")})`;
|
|
37
|
+
const inheritLabel =
|
|
38
|
+
scope === "settings"
|
|
39
|
+
? role.modelRole === "advise"
|
|
40
|
+
? "Same as Gate review"
|
|
41
|
+
: label
|
|
42
|
+
: "Inherit";
|
|
43
|
+
return (
|
|
44
|
+
<li
|
|
45
|
+
className={cn(
|
|
46
|
+
"bg-muted/40 space-y-2 rounded-xl px-3 py-3",
|
|
47
|
+
down && "ring-destructive/50 ring-1"
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
<label htmlFor={`agent-role-${role.id}`} className="flex gap-2.5">
|
|
51
|
+
<span className="bg-accent text-primary grid h-8 w-8 shrink-0 place-items-center rounded-lg text-sm font-semibold">
|
|
52
|
+
{initial(role.label)}
|
|
53
|
+
</span>
|
|
54
|
+
<span className="min-w-0">
|
|
55
|
+
<span className="block text-sm font-medium">{role.label}</span>
|
|
56
|
+
<span className="text-muted-foreground block text-xs">
|
|
57
|
+
{role.purpose}
|
|
58
|
+
</span>
|
|
59
|
+
</span>
|
|
60
|
+
</label>
|
|
61
|
+
<RoleModelSelect
|
|
62
|
+
id={`agent-role-${role.id}`}
|
|
63
|
+
value={role.value}
|
|
64
|
+
inheritLabel={
|
|
65
|
+
role.value === null && role.binding
|
|
66
|
+
? `${inheritLabel} · ${role.binding.ref.split("/").slice(1).join("/")}`
|
|
67
|
+
: inheritLabel
|
|
68
|
+
}
|
|
69
|
+
disabled={pending}
|
|
70
|
+
onChange={onChange}
|
|
71
|
+
/>
|
|
72
|
+
<p className="text-muted-foreground text-xs">
|
|
73
|
+
{role.error
|
|
74
|
+
? role.error
|
|
75
|
+
: role.binding
|
|
76
|
+
? `${SOURCE[role.binding.source] ?? role.binding.source}${role.choice?.endpoint ? ` · ${role.choice.endpoint}` : ""}`
|
|
77
|
+
: ""}
|
|
78
|
+
</p>
|
|
79
|
+
{down && (
|
|
80
|
+
<div
|
|
81
|
+
role="alert"
|
|
82
|
+
className="bg-destructive/10 text-foreground ring-destructive/35 flex gap-2 rounded-lg p-2.5 text-xs ring-1"
|
|
83
|
+
>
|
|
84
|
+
<AlertTriangle className="text-destructive mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
85
|
+
<span>
|
|
86
|
+
<b className="font-medium">
|
|
87
|
+
This model's server isn't answering.
|
|
88
|
+
</b>{" "}
|
|
89
|
+
{role.choice?.availability.error
|
|
90
|
+
? `${role.choice.availability.error}. `
|
|
91
|
+
: ""}
|
|
92
|
+
New {role.label} sessions can't start until it is back
|
|
93
|
+
{role.fallback ? ", or you start on the fallback" : ""}.
|
|
94
|
+
</span>
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
{scope === "settings" && (
|
|
98
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
99
|
+
<span className="text-muted-foreground text-xs">Fallback</span>
|
|
100
|
+
<div className="min-w-44 flex-1">
|
|
101
|
+
<RoleModelSelect
|
|
102
|
+
id={`agent-role-fallback-${role.id}`}
|
|
103
|
+
value={role.fallback}
|
|
104
|
+
inheritLabel="None (don't fall back)"
|
|
105
|
+
claudeOnly
|
|
106
|
+
disabled={pending}
|
|
107
|
+
onChange={onFallback}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
</li>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useAgentRolesQuery, useSaveAgentRoles } from "@/data/models";
|
|
5
|
+
import { useProjectsQuery } from "@/data/projects/queries";
|
|
6
|
+
import { useWorkspacesQuery } from "@/data/workspaces";
|
|
7
|
+
import type { RoleMap } from "@/lib/models/config";
|
|
8
|
+
import type { RoleScope } from "@/lib/models/agent-roles-view";
|
|
9
|
+
import { cn } from "@/lib/utils";
|
|
10
|
+
import { AgentRoleCard } from "./AgentRoleCard";
|
|
11
|
+
|
|
12
|
+
const SCOPES: { id: RoleScope; label: string }[] = [
|
|
13
|
+
{ id: "settings", label: "Default" },
|
|
14
|
+
{ id: "workspace", label: "Workspace" },
|
|
15
|
+
{ id: "project", label: "Project" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
// Settings > Agent roles: the model each of Developer, Analyst, Reviewer and
|
|
19
|
+
// Supervisor runs on, for everything or for one workspace or project.
|
|
20
|
+
export function AgentRolesSection() {
|
|
21
|
+
const [scope, setScope] = useState<RoleScope>("settings");
|
|
22
|
+
const [target, setTarget] = useState<string | null>(null);
|
|
23
|
+
const workspaces = useWorkspacesQuery();
|
|
24
|
+
const projects = useProjectsQuery();
|
|
25
|
+
const options =
|
|
26
|
+
scope === "workspace"
|
|
27
|
+
? (workspaces.data ?? []).map((w) => ({ id: w.id, name: w.name }))
|
|
28
|
+
: scope === "project"
|
|
29
|
+
? (projects.data ?? [])
|
|
30
|
+
.filter((p) => !p.is_uncategorized)
|
|
31
|
+
.map((p) => ({ id: p.id, name: p.name }))
|
|
32
|
+
: [];
|
|
33
|
+
const id = scope === "settings" ? null : (target ?? options[0]?.id ?? null);
|
|
34
|
+
const { data, isPending, isError, error } = useAgentRolesQuery(scope, id);
|
|
35
|
+
const save = useSaveAgentRoles(scope, id);
|
|
36
|
+
|
|
37
|
+
const saved = (key: "value" | "fallback"): RoleMap =>
|
|
38
|
+
Object.fromEntries(
|
|
39
|
+
(data?.roles ?? [])
|
|
40
|
+
.filter((r) => r[key])
|
|
41
|
+
.map((r) => [r.modelRole, r[key] as string])
|
|
42
|
+
);
|
|
43
|
+
const setRole = (modelRole: string, ref: string | null) => {
|
|
44
|
+
const roles: RoleMap = { ...saved("value") };
|
|
45
|
+
if (ref) roles[modelRole as keyof RoleMap] = ref;
|
|
46
|
+
else delete roles[modelRole as keyof RoleMap];
|
|
47
|
+
save.mutate({ roles });
|
|
48
|
+
};
|
|
49
|
+
const setFallback = (modelRole: string, ref: string | null) => {
|
|
50
|
+
const fallbacks: RoleMap = { ...saved("fallback") };
|
|
51
|
+
if (ref) fallbacks[modelRole as keyof RoleMap] = ref;
|
|
52
|
+
else delete fallbacks[modelRole as keyof RoleMap];
|
|
53
|
+
save.mutate({ roles: saved("value"), fallbacks });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="space-y-4">
|
|
58
|
+
<div
|
|
59
|
+
role="tablist"
|
|
60
|
+
className="bg-muted inline-flex max-w-full gap-0.5 overflow-x-auto rounded-xl p-0.5"
|
|
61
|
+
>
|
|
62
|
+
{SCOPES.map((s) => (
|
|
63
|
+
<button
|
|
64
|
+
key={s.id}
|
|
65
|
+
role="tab"
|
|
66
|
+
aria-selected={scope === s.id}
|
|
67
|
+
type="button"
|
|
68
|
+
onClick={() => {
|
|
69
|
+
setScope(s.id);
|
|
70
|
+
setTarget(null);
|
|
71
|
+
}}
|
|
72
|
+
className={cn(
|
|
73
|
+
"h-11 rounded-lg px-3 text-sm whitespace-nowrap md:h-8",
|
|
74
|
+
scope === s.id
|
|
75
|
+
? "bg-background text-foreground ring-border ring-1"
|
|
76
|
+
: "text-muted-foreground hover:text-foreground"
|
|
77
|
+
)}
|
|
78
|
+
>
|
|
79
|
+
{s.label}
|
|
80
|
+
</button>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
{scope !== "settings" && (
|
|
84
|
+
<select
|
|
85
|
+
aria-label={scope === "workspace" ? "Workspace" : "Project"}
|
|
86
|
+
value={id ?? ""}
|
|
87
|
+
onChange={(e) => setTarget(e.target.value)}
|
|
88
|
+
className="border-input bg-background h-11 w-full rounded-md border px-3 text-sm md:h-9"
|
|
89
|
+
>
|
|
90
|
+
{options.length === 0 && <option value="">None yet</option>}
|
|
91
|
+
{options.map((o) => (
|
|
92
|
+
<option key={o.id} value={o.id}>
|
|
93
|
+
{o.name}
|
|
94
|
+
</option>
|
|
95
|
+
))}
|
|
96
|
+
</select>
|
|
97
|
+
)}
|
|
98
|
+
<p className="text-muted-foreground text-xs">
|
|
99
|
+
The model each role runs on. A workspace or project can override these,
|
|
100
|
+
and a session can when it starts. A Pi model (a server you run) runs
|
|
101
|
+
sessions started in the role; tasks and the merge gate stay on Claude
|
|
102
|
+
Code models. Planning, gate review, scope check, helpers and watchers
|
|
103
|
+
are in Models.
|
|
104
|
+
</p>
|
|
105
|
+
{scope !== "settings" && !id ? (
|
|
106
|
+
<p className="text-muted-foreground text-sm">
|
|
107
|
+
There is no {scope} to set roles for yet.
|
|
108
|
+
</p>
|
|
109
|
+
) : isPending ? (
|
|
110
|
+
<div className="bg-muted/40 h-64 animate-pulse rounded-lg" />
|
|
111
|
+
) : isError ? (
|
|
112
|
+
<p className="text-destructive text-sm">{error.message}</p>
|
|
113
|
+
) : data ? (
|
|
114
|
+
<ul className="space-y-2.5">
|
|
115
|
+
{data.roles.map((r) => (
|
|
116
|
+
<AgentRoleCard
|
|
117
|
+
key={r.id}
|
|
118
|
+
role={r}
|
|
119
|
+
scope={scope}
|
|
120
|
+
pending={save.isPending}
|
|
121
|
+
onChange={(ref) => setRole(r.modelRole, ref)}
|
|
122
|
+
onFallback={(ref) => setFallback(r.modelRole, ref)}
|
|
123
|
+
/>
|
|
124
|
+
))}
|
|
125
|
+
</ul>
|
|
126
|
+
) : null}
|
|
127
|
+
{save.isError && (
|
|
128
|
+
<p className="text-destructive text-xs">{save.error.message}</p>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|