@saadnvd1/agent-os 0.3.470 → 0.3.472
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 +139 -36
- package/app/api/fresh-start/route.ts +9 -5
- package/app/api/models/route.ts +34 -0
- package/app/api/models/test/route.ts +17 -0
- package/app/api/sessions/[id]/summarize/route.ts +4 -1
- package/app/api/tasks/[id]/model/route.ts +23 -0
- package/app/api/tasks/route.ts +5 -1
- package/app/api/usage/compare/route.ts +21 -0
- package/app/api/usage/route.ts +8 -3
- package/app/api/workspaces/[id]/models/route.ts +27 -0
- package/components/Chat/ChatPanel.tsx +5 -5
- package/components/Chat/StaleChip.tsx +7 -6
- package/components/CommandPalette/useAppCommands.ts +19 -1
- package/components/Draft/DraftChips.tsx +28 -0
- package/components/Draft/DraftPanel.tsx +1 -1
- package/components/Models/ModelSelect.tsx +67 -0
- package/components/Models/ModelsSection.tsx +199 -0
- package/components/Models/ProviderCard.tsx +271 -0
- package/components/SessionList/SidebarHeader.tsx +5 -1
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/commands.ts +19 -0
- package/components/Settings/entry-points.test.ts +1 -0
- package/components/Tasks/ResumeOnModel.tsx +72 -0
- package/components/Tasks/TaskRow.tsx +4 -0
- package/components/Usage/CompareView.tsx +211 -0
- package/components/Usage/ModelSpend.tsx +133 -0
- package/components/Usage/UsageDialog.tsx +69 -5
- package/components/Usage/format.ts +9 -0
- package/components/Workspaces/FreshStartForm.tsx +13 -13
- package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/data/models/index.ts +125 -0
- package/data/usage/index.ts +5 -0
- package/lib/agents/launch.ts +13 -2
- package/lib/chat/context.ts +1 -1
- package/lib/chat/refresh.ts +31 -13
- package/lib/chat/runner.ts +5 -0
- package/lib/chat/stale.ts +25 -16
- package/lib/chat/worker/extras.ts +12 -0
- package/lib/chat/worker/host.ts +70 -24
- package/lib/chat/worker/main.ts +5 -2
- package/lib/db/migrations.ts +70 -0
- package/lib/db/types.ts +6 -0
- package/lib/drafts.ts +3 -0
- package/lib/models/config.ts +218 -0
- package/lib/models/launch.ts +93 -0
- package/lib/models/plan-step.ts +94 -0
- package/lib/models/providers.ts +163 -0
- package/lib/models/resolve.ts +246 -0
- package/lib/models/resume.ts +99 -0
- package/lib/models/roles.ts +98 -0
- package/lib/models/secrets.ts +86 -0
- package/lib/models/session-model.ts +40 -0
- package/lib/models/test-provider.ts +116 -0
- package/lib/orchestrator/act.ts +13 -1
- package/lib/orchestrator/armed.ts +1 -2
- package/lib/orchestrator/brief.ts +5 -4
- package/lib/orchestrator/claude-cli.ts +48 -14
- package/lib/orchestrator/diff.ts +1 -1
- package/lib/orchestrator/external-gates.ts +8 -43
- package/lib/orchestrator/gates.ts +10 -10
- package/lib/orchestrator/home.ts +2 -2
- package/lib/orchestrator/pr-wake.ts +2 -2
- package/lib/orchestrator/rebase.ts +6 -45
- package/lib/orchestrator/review.ts +20 -5
- package/lib/orchestrator/scope.ts +11 -0
- package/lib/orchestrator/serve.ts +13 -0
- package/lib/orchestrator/signoff.ts +13 -8
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +38 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/pr-generation.ts +5 -2
- package/lib/providers/registry.ts +1 -1
- package/lib/session-audit/render.ts +1 -1
- package/lib/session-titles.ts +1 -1
- package/lib/sessions/launch.ts +19 -4
- package/lib/settings-url.ts +1 -0
- package/lib/stacks/land.ts +23 -16
- package/lib/tasks/brief.ts +3 -14
- package/lib/tasks/code-review.ts +69 -107
- package/lib/tasks/gh.ts +3 -2
- package/lib/tasks/index.ts +60 -9
- package/lib/tasks/queue.ts +14 -5
- package/lib/tasks/start.ts +7 -2
- package/lib/tasks/state.ts +4 -4
- package/lib/usage/by-model.ts +105 -0
- package/lib/usage/check-runs.ts +170 -0
- package/lib/usage/compare.ts +280 -0
- package/lib/usage/cost.ts +71 -0
- package/lib/usage/report.ts +51 -0
- package/lib/usage/turns.ts +107 -18
- package/package.json +1 -1
- package/stores/usageUi.ts +11 -2
- package/scripts/check-code-review.ts +0 -21
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Cpu } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { ModelSelect } from "@/components/Models/ModelSelect";
|
|
7
|
+
import { useModelsQuery, useResumeOnModel } from "@/data/models";
|
|
8
|
+
import type { TaskView } from "@/lib/tasks";
|
|
9
|
+
|
|
10
|
+
// "Resume on model…": the task carries on in the same conversation and
|
|
11
|
+
// worktree on the model picked, which it keeps from then on.
|
|
12
|
+
export function ResumeOnModel({ task }: { task: TaskView }) {
|
|
13
|
+
const [open, setOpen] = useState(false);
|
|
14
|
+
const [ref, setRef] = useState<string | null>(null);
|
|
15
|
+
const models = useModelsQuery(open);
|
|
16
|
+
const resume = useResumeOnModel();
|
|
17
|
+
if (!open)
|
|
18
|
+
return (
|
|
19
|
+
<Button
|
|
20
|
+
size="sm"
|
|
21
|
+
variant="outline"
|
|
22
|
+
className="h-11 md:h-8"
|
|
23
|
+
title={task.model ? `Runs on ${task.model}` : undefined}
|
|
24
|
+
onClick={() => setOpen(true)}
|
|
25
|
+
>
|
|
26
|
+
<Cpu className="h-3.5 w-3.5" />
|
|
27
|
+
Resume on model…
|
|
28
|
+
</Button>
|
|
29
|
+
);
|
|
30
|
+
return (
|
|
31
|
+
<div className="bg-muted/40 w-full space-y-2 rounded-lg p-2">
|
|
32
|
+
<p className="text-muted-foreground text-xs">
|
|
33
|
+
Now on {task.model ?? "its role's model"}
|
|
34
|
+
{task.planning ? ", planning" : ""}. Its conversation and worktree carry
|
|
35
|
+
on.
|
|
36
|
+
</p>
|
|
37
|
+
<ModelSelect
|
|
38
|
+
value={ref}
|
|
39
|
+
effective={models.data?.effective}
|
|
40
|
+
disabled={!models.data || resume.isPending}
|
|
41
|
+
onChange={setRef}
|
|
42
|
+
/>
|
|
43
|
+
<div className="flex gap-2">
|
|
44
|
+
<Button
|
|
45
|
+
size="sm"
|
|
46
|
+
className="h-11 md:h-8"
|
|
47
|
+
disabled={!ref || resume.isPending}
|
|
48
|
+
onClick={() =>
|
|
49
|
+
ref &&
|
|
50
|
+
resume.mutate(
|
|
51
|
+
{ taskId: task.id, model: ref },
|
|
52
|
+
{ onSuccess: () => setOpen(false) }
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
>
|
|
56
|
+
{resume.isPending ? "Resuming…" : "Resume"}
|
|
57
|
+
</Button>
|
|
58
|
+
<Button
|
|
59
|
+
size="sm"
|
|
60
|
+
variant="ghost"
|
|
61
|
+
className="h-11 md:h-8"
|
|
62
|
+
onClick={() => setOpen(false)}
|
|
63
|
+
>
|
|
64
|
+
Cancel
|
|
65
|
+
</Button>
|
|
66
|
+
</div>
|
|
67
|
+
{resume.isError && (
|
|
68
|
+
<p className="text-destructive text-xs">{resume.error.message}</p>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -15,6 +15,7 @@ import { tmuxAttachActions } from "@/stores/tmuxAttach";
|
|
|
15
15
|
import { tasksUiActions } from "@/stores/tasksUi";
|
|
16
16
|
import { cn } from "@/lib/utils";
|
|
17
17
|
import { MoveTaskButton } from "./MoveTaskButton";
|
|
18
|
+
import { ResumeOnModel } from "./ResumeOnModel";
|
|
18
19
|
|
|
19
20
|
const STATE: Record<TaskState, { label: string; tone: string }> = {
|
|
20
21
|
working: { label: "Working", tone: "text-muted-foreground" },
|
|
@@ -58,6 +59,8 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
58
59
|
<p className="text-muted-foreground truncate font-mono text-[11px]">
|
|
59
60
|
{task.hostName && `${task.hostName} · `}
|
|
60
61
|
{task.projectName} · {task.branch}
|
|
62
|
+
{task.model &&
|
|
63
|
+
` · ${task.model}${task.planning ? " (planning)" : ""}`}
|
|
61
64
|
</p>
|
|
62
65
|
{task.cardUrl && (
|
|
63
66
|
<a
|
|
@@ -114,6 +117,7 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
114
117
|
</Button>
|
|
115
118
|
)}
|
|
116
119
|
<MoveTaskButton task={task} onError={setMoveError} />
|
|
120
|
+
{!task.hostId && <ResumeOnModel task={task} />}
|
|
117
121
|
<span className="flex-1" />
|
|
118
122
|
{confirmDrop ? (
|
|
119
123
|
<Button
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useCompareQuery } from "@/data/models";
|
|
5
|
+
import type { Cohort, Comparison } from "@/lib/usage/compare";
|
|
6
|
+
import { ROLE_LABELS, isModelRole } from "@/lib/models/roles";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
import { formatPct, formatTokens, formatUsd } from "./format";
|
|
9
|
+
import { SpendTable } from "./ModelSpend";
|
|
10
|
+
import { emptySpend, sumSpend } from "@/lib/usage/by-model";
|
|
11
|
+
|
|
12
|
+
const RANGES = [7, 30, 90] as const;
|
|
13
|
+
|
|
14
|
+
const per = (n: number, tasks: number) => (tasks ? n / tasks : 0);
|
|
15
|
+
const minutes = (ms: number) => `${Math.round(ms / 60000)}m`;
|
|
16
|
+
|
|
17
|
+
const totalCohort = (cs: Cohort[]): Cohort =>
|
|
18
|
+
cs.reduce<Cohort>(
|
|
19
|
+
(t, c) => ({
|
|
20
|
+
...t,
|
|
21
|
+
tasks: t.tasks + c.tasks,
|
|
22
|
+
spend: sumSpend([t.spend, c.spend]),
|
|
23
|
+
planPct: t.planPct + c.planPct,
|
|
24
|
+
wallMs: t.wallMs + c.wallMs,
|
|
25
|
+
ciFirstGreen: add(t.ciFirstGreen, c.ciFirstGreen),
|
|
26
|
+
reviewFirstPass: add(t.reviewFirstPass, c.reviewFirstPass),
|
|
27
|
+
gateFailures: t.gateFailures + c.gateFailures,
|
|
28
|
+
rework: t.rework + c.rework,
|
|
29
|
+
merged: add(t.merged, c.merged),
|
|
30
|
+
dropped: add(t.dropped, c.dropped),
|
|
31
|
+
}),
|
|
32
|
+
{
|
|
33
|
+
key: "Total",
|
|
34
|
+
baseline: false,
|
|
35
|
+
tasks: 0,
|
|
36
|
+
smallSample: false,
|
|
37
|
+
spend: emptySpend(),
|
|
38
|
+
planPct: 0,
|
|
39
|
+
wallMs: 0,
|
|
40
|
+
ciFirstGreen: { yes: 0, of: 0 },
|
|
41
|
+
reviewFirstPass: { yes: 0, of: 0 },
|
|
42
|
+
gateFailures: 0,
|
|
43
|
+
rework: 0,
|
|
44
|
+
merged: { yes: 0, of: 0 },
|
|
45
|
+
dropped: { yes: 0, of: 0 },
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
function add(a: { yes: number; of: number }, b: { yes: number; of: number }) {
|
|
50
|
+
return { yes: a.yes + b.yes, of: a.of + b.of };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ROWS: { label: string; value: (c: Cohort) => string }[] = [
|
|
54
|
+
{ label: "Tasks", value: (c) => String(c.tasks) },
|
|
55
|
+
{ label: "Turns", value: (c) => String(c.spend.turns) },
|
|
56
|
+
{
|
|
57
|
+
label: "Tokens",
|
|
58
|
+
value: (c) =>
|
|
59
|
+
formatTokens(
|
|
60
|
+
c.spend.inputTokens +
|
|
61
|
+
c.spend.outputTokens +
|
|
62
|
+
c.spend.cacheReadTokens +
|
|
63
|
+
c.spend.cacheWriteTokens
|
|
64
|
+
),
|
|
65
|
+
},
|
|
66
|
+
{ label: "Est. cost", value: (c) => formatUsd(c.spend.costUsd) },
|
|
67
|
+
{
|
|
68
|
+
label: "Cost per task",
|
|
69
|
+
value: (c) => formatUsd(per(c.spend.costUsd, c.tasks)),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
label: "Claude window per task",
|
|
73
|
+
value: (c) =>
|
|
74
|
+
c.planPct ? `${per(c.planPct, c.tasks).toFixed(1)} pts` : "—",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
label: "Agent time per task",
|
|
78
|
+
value: (c) => minutes(per(c.wallMs, c.tasks)),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
label: "CI green on first push",
|
|
82
|
+
value: (c) => formatPct(c.ciFirstGreen.yes, c.ciFirstGreen.of),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
label: "Review passed first try",
|
|
86
|
+
value: (c) => formatPct(c.reviewFirstPass.yes, c.reviewFirstPass.of),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
label: "Gate failures per task",
|
|
90
|
+
value: (c) => per(c.gateFailures, c.tasks).toFixed(1),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
label: "Rework commits per task",
|
|
94
|
+
value: (c) => per(c.rework, c.tasks).toFixed(1),
|
|
95
|
+
},
|
|
96
|
+
{ label: "Merged", value: (c) => formatPct(c.merged.yes, c.merged.of) },
|
|
97
|
+
{ label: "Dropped", value: (c) => formatPct(c.dropped.yes, c.dropped.of) },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
// Claude-only tasks against routed ones, side by side, over a date range.
|
|
101
|
+
export function CompareView({ enabled }: { enabled: boolean }) {
|
|
102
|
+
const [days, setDays] = useState<number>(30);
|
|
103
|
+
const { data, isPending, isError, error } = useCompareQuery(days, enabled);
|
|
104
|
+
return (
|
|
105
|
+
<section className="space-y-3">
|
|
106
|
+
<div className="flex items-center justify-between gap-2">
|
|
107
|
+
<h3 className="text-sm font-medium">Before and after routing</h3>
|
|
108
|
+
<div className="bg-foreground/[0.04] flex rounded-lg p-0.5">
|
|
109
|
+
{RANGES.map((d) => (
|
|
110
|
+
<button
|
|
111
|
+
key={d}
|
|
112
|
+
type="button"
|
|
113
|
+
onClick={() => setDays(d)}
|
|
114
|
+
aria-pressed={days === d}
|
|
115
|
+
className={cn(
|
|
116
|
+
"h-11 rounded-md px-3 text-xs md:h-8",
|
|
117
|
+
days === d
|
|
118
|
+
? "bg-card text-foreground shadow-sm"
|
|
119
|
+
: "text-muted-foreground"
|
|
120
|
+
)}
|
|
121
|
+
>
|
|
122
|
+
{d} days
|
|
123
|
+
</button>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
{isError && <p className="text-destructive text-sm">{error.message}</p>}
|
|
128
|
+
{isPending && !data && (
|
|
129
|
+
<div className="bg-foreground/[0.04] h-40 animate-pulse rounded-xl" />
|
|
130
|
+
)}
|
|
131
|
+
{data && <CompareTables data={data} />}
|
|
132
|
+
</section>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function CompareTables({ data }: { data: Comparison }) {
|
|
137
|
+
if (!data.cohorts.length)
|
|
138
|
+
return (
|
|
139
|
+
<p className="text-muted-foreground text-xs">
|
|
140
|
+
No tasks ran in this range.
|
|
141
|
+
</p>
|
|
142
|
+
);
|
|
143
|
+
const columns = [...data.cohorts, totalCohort(data.cohorts)];
|
|
144
|
+
const cell = "py-1.5 pr-2 text-right tabular-nums";
|
|
145
|
+
return (
|
|
146
|
+
<>
|
|
147
|
+
<div className="overflow-x-auto">
|
|
148
|
+
<table className="w-full text-xs">
|
|
149
|
+
<thead>
|
|
150
|
+
<tr>
|
|
151
|
+
<th className="py-1 pr-2 text-left font-normal" />
|
|
152
|
+
{columns.map((c) => (
|
|
153
|
+
<th
|
|
154
|
+
key={c.key}
|
|
155
|
+
className={cn(cell, "align-bottom font-medium")}
|
|
156
|
+
>
|
|
157
|
+
<span className="font-mono">{c.key}</span>
|
|
158
|
+
{c.baseline && (
|
|
159
|
+
<span className="text-muted-foreground block font-normal">
|
|
160
|
+
baseline
|
|
161
|
+
</span>
|
|
162
|
+
)}
|
|
163
|
+
{c.smallSample && (
|
|
164
|
+
<span className="block font-normal text-amber-600 dark:text-amber-400">
|
|
165
|
+
small sample
|
|
166
|
+
</span>
|
|
167
|
+
)}
|
|
168
|
+
</th>
|
|
169
|
+
))}
|
|
170
|
+
</tr>
|
|
171
|
+
</thead>
|
|
172
|
+
<tbody>
|
|
173
|
+
{ROWS.map((row) => (
|
|
174
|
+
<tr key={row.label} className="border-border border-t">
|
|
175
|
+
<td className="text-muted-foreground py-1.5 pr-2">
|
|
176
|
+
{row.label}
|
|
177
|
+
</td>
|
|
178
|
+
{columns.map((c) => (
|
|
179
|
+
<td
|
|
180
|
+
key={c.key}
|
|
181
|
+
className={cn(cell, c.key === "Total" && "font-medium")}
|
|
182
|
+
>
|
|
183
|
+
{row.value(c)}
|
|
184
|
+
</td>
|
|
185
|
+
))}
|
|
186
|
+
</tr>
|
|
187
|
+
))}
|
|
188
|
+
</tbody>
|
|
189
|
+
</table>
|
|
190
|
+
</div>
|
|
191
|
+
<SpendTable
|
|
192
|
+
title="Role · model"
|
|
193
|
+
rows={data.roles.map((r) => ({
|
|
194
|
+
...r,
|
|
195
|
+
key: `${r.role} ${r.provider}/${r.model}`,
|
|
196
|
+
label: `${isModelRole(r.role) ? ROLE_LABELS[r.role].label : "Before routing"} · ${r.provider}/${r.model}`,
|
|
197
|
+
}))}
|
|
198
|
+
total={sumSpend(data.roles)}
|
|
199
|
+
/>
|
|
200
|
+
<p className="text-muted-foreground text-[11px]">
|
|
201
|
+
A task counts under the provider its own turns ran on (anthropic is the
|
|
202
|
+
baseline); its reviews and checks count toward its cost. Dollars are
|
|
203
|
+
list-price estimates; for Claude, the plan's real measure is its
|
|
204
|
+
5-hour window, shared out by how far each turn moved it (an estimate).
|
|
205
|
+
Outcomes come from the orchestrator's checks, so only its tasks
|
|
206
|
+
have them. Under 10 tasks a column is a small sample: read it as a hint,
|
|
207
|
+
not a result.
|
|
208
|
+
</p>
|
|
209
|
+
</>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { SpendByModel, TokenSpend } from "@/lib/usage/by-model";
|
|
5
|
+
import { ROLE_LABELS, isModelRole } from "@/lib/models/roles";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { formatTokens, formatUsd } from "./format";
|
|
8
|
+
|
|
9
|
+
const roleLabel = (r: string) =>
|
|
10
|
+
isModelRole(r) ? ROLE_LABELS[r].label : "Before routing";
|
|
11
|
+
|
|
12
|
+
// Tokens and estimated cost by role and by model, in the plan's 5-hour
|
|
13
|
+
// window or the last 24 hours.
|
|
14
|
+
export function ModelSpend({
|
|
15
|
+
data,
|
|
16
|
+
}: {
|
|
17
|
+
data: { fiveHour: SpendByModel; day: SpendByModel } | undefined;
|
|
18
|
+
}) {
|
|
19
|
+
const [span, setSpan] = useState<"fiveHour" | "day">("fiveHour");
|
|
20
|
+
if (!data) return null;
|
|
21
|
+
const shown = data[span];
|
|
22
|
+
return (
|
|
23
|
+
<section className="space-y-3">
|
|
24
|
+
<div className="flex items-center justify-between gap-2">
|
|
25
|
+
<h3 className="text-sm font-medium">By model and role</h3>
|
|
26
|
+
<div className="bg-foreground/[0.04] flex rounded-lg p-0.5">
|
|
27
|
+
{(
|
|
28
|
+
[
|
|
29
|
+
["fiveHour", "5-hour window"],
|
|
30
|
+
["day", "24 hours"],
|
|
31
|
+
] as const
|
|
32
|
+
).map(([v, label]) => (
|
|
33
|
+
<button
|
|
34
|
+
key={v}
|
|
35
|
+
type="button"
|
|
36
|
+
onClick={() => setSpan(v)}
|
|
37
|
+
aria-pressed={span === v}
|
|
38
|
+
className={cn(
|
|
39
|
+
"h-11 rounded-md px-3 text-xs md:h-8",
|
|
40
|
+
span === v
|
|
41
|
+
? "bg-card text-foreground shadow-sm"
|
|
42
|
+
: "text-muted-foreground"
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
{label}
|
|
46
|
+
</button>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
<SpendTable
|
|
51
|
+
title="Role"
|
|
52
|
+
rows={shown.roles.map((r) => ({
|
|
53
|
+
key: r.role,
|
|
54
|
+
label: roleLabel(r.role),
|
|
55
|
+
...r,
|
|
56
|
+
}))}
|
|
57
|
+
total={shown.total}
|
|
58
|
+
/>
|
|
59
|
+
<SpendTable
|
|
60
|
+
title="Model"
|
|
61
|
+
rows={shown.models.map((m) => ({
|
|
62
|
+
key: `${m.provider}/${m.model}`,
|
|
63
|
+
label: `${m.provider}/${m.model}`,
|
|
64
|
+
...m,
|
|
65
|
+
}))}
|
|
66
|
+
total={shown.total}
|
|
67
|
+
/>
|
|
68
|
+
</section>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function SpendTable({
|
|
73
|
+
title,
|
|
74
|
+
rows,
|
|
75
|
+
total,
|
|
76
|
+
}: {
|
|
77
|
+
title: string;
|
|
78
|
+
rows: (TokenSpend & { key: string; label: string })[];
|
|
79
|
+
total: TokenSpend;
|
|
80
|
+
}) {
|
|
81
|
+
if (!rows.length)
|
|
82
|
+
return <p className="text-muted-foreground text-xs">Nothing yet.</p>;
|
|
83
|
+
const cell = "py-1.5 pr-2 text-right tabular-nums";
|
|
84
|
+
return (
|
|
85
|
+
<div className="overflow-x-auto">
|
|
86
|
+
<table className="w-full text-xs">
|
|
87
|
+
<thead className="text-muted-foreground">
|
|
88
|
+
<tr>
|
|
89
|
+
<th className="py-1 pr-2 text-left font-normal">{title}</th>
|
|
90
|
+
<th className={cn(cell, "font-normal")}>In</th>
|
|
91
|
+
<th className={cn(cell, "font-normal")}>Out</th>
|
|
92
|
+
<th className={cn(cell, "font-normal")}>Cache r/w</th>
|
|
93
|
+
<th className={cn(cell, "font-normal")}>Turns</th>
|
|
94
|
+
<th className={cn(cell, "font-normal")}>Est. cost</th>
|
|
95
|
+
</tr>
|
|
96
|
+
</thead>
|
|
97
|
+
<tbody>
|
|
98
|
+
{[...rows, { ...total, key: "__total", label: "Total" }].map((r) => (
|
|
99
|
+
<tr
|
|
100
|
+
key={r.key}
|
|
101
|
+
className={cn(
|
|
102
|
+
"border-border border-t",
|
|
103
|
+
r.key === "__total" && "font-medium"
|
|
104
|
+
)}
|
|
105
|
+
>
|
|
106
|
+
<td className="max-w-48 truncate py-1.5 pr-2 font-mono md:max-w-72">
|
|
107
|
+
{r.label}
|
|
108
|
+
</td>
|
|
109
|
+
<td className={cell}>{formatTokens(r.inputTokens)}</td>
|
|
110
|
+
<td className={cell}>{formatTokens(r.outputTokens)}</td>
|
|
111
|
+
<td className={cell}>
|
|
112
|
+
{formatTokens(r.cacheReadTokens)}/
|
|
113
|
+
{formatTokens(r.cacheWriteTokens)}
|
|
114
|
+
</td>
|
|
115
|
+
<td className={cell}>{r.turns}</td>
|
|
116
|
+
<td
|
|
117
|
+
className={cell}
|
|
118
|
+
title={
|
|
119
|
+
r.unpriced
|
|
120
|
+
? "Partly the agent's own figure: not in the price table"
|
|
121
|
+
: undefined
|
|
122
|
+
}
|
|
123
|
+
>
|
|
124
|
+
{formatUsd(r.costUsd)}
|
|
125
|
+
{r.unpriced ? "*" : ""}
|
|
126
|
+
</td>
|
|
127
|
+
</tr>
|
|
128
|
+
))}
|
|
129
|
+
</tbody>
|
|
130
|
+
</table>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -12,11 +12,19 @@ import {
|
|
|
12
12
|
import { useUsageQuery } from "@/data/usage";
|
|
13
13
|
import type { UsageRange } from "@/lib/usage/aggregate";
|
|
14
14
|
import { cn } from "@/lib/utils";
|
|
15
|
-
import { usageUi, usageUiActions } from "@/stores/usageUi";
|
|
15
|
+
import { usageUi, usageUiActions, type UsageTab } from "@/stores/usageUi";
|
|
16
|
+
import { CompareView } from "./CompareView";
|
|
17
|
+
import { ModelSpend } from "./ModelSpend";
|
|
16
18
|
import { DayBars, SpendList } from "./SpendCharts";
|
|
17
19
|
import { WindowCard } from "./WindowCard";
|
|
18
20
|
import { formatUsd } from "./format";
|
|
19
21
|
|
|
22
|
+
const TABS: { value: UsageTab; label: string }[] = [
|
|
23
|
+
{ value: "spend", label: "Spend" },
|
|
24
|
+
{ value: "models", label: "By model" },
|
|
25
|
+
{ value: "compare", label: "Compare" },
|
|
26
|
+
];
|
|
27
|
+
|
|
20
28
|
const RANGES: { value: UsageRange; label: string }[] = [
|
|
21
29
|
{ value: "today", label: "Today" },
|
|
22
30
|
{ value: "7d", label: "7 days" },
|
|
@@ -26,7 +34,7 @@ const RANGES: { value: UsageRange; label: string }[] = [
|
|
|
26
34
|
// The account's rolling windows, and what chats cost by day, session and
|
|
27
35
|
// workspace.
|
|
28
36
|
export function UsageDialog() {
|
|
29
|
-
const { open } = useSnapshot(usageUi);
|
|
37
|
+
const { open, tab } = useSnapshot(usageUi);
|
|
30
38
|
const [range, setRange] = useState<UsageRange>("7d");
|
|
31
39
|
const { data, isPending, isError, error } = useUsageQuery(range, open);
|
|
32
40
|
const report = data?.report;
|
|
@@ -41,8 +49,8 @@ export function UsageDialog() {
|
|
|
41
49
|
<DialogHeader>
|
|
42
50
|
<DialogTitle>Usage</DialogTitle>
|
|
43
51
|
<DialogDescription>
|
|
44
|
-
The account's limits, and what chat sessions
|
|
45
|
-
cost.
|
|
52
|
+
The account's limits, and what chat sessions and gate reviews
|
|
53
|
+
on this machine cost.
|
|
46
54
|
</DialogDescription>
|
|
47
55
|
</DialogHeader>
|
|
48
56
|
|
|
@@ -56,7 +64,34 @@ export function UsageDialog() {
|
|
|
56
64
|
)}
|
|
57
65
|
</section>
|
|
58
66
|
|
|
59
|
-
<
|
|
67
|
+
<div
|
|
68
|
+
role="tablist"
|
|
69
|
+
aria-label="Usage views"
|
|
70
|
+
className="bg-foreground/[0.04] flex rounded-lg p-0.5"
|
|
71
|
+
>
|
|
72
|
+
{TABS.map((t) => (
|
|
73
|
+
<button
|
|
74
|
+
key={t.value}
|
|
75
|
+
type="button"
|
|
76
|
+
role="tab"
|
|
77
|
+
aria-selected={tab === t.value}
|
|
78
|
+
onClick={() => usageUiActions.setTab(t.value)}
|
|
79
|
+
className={cn(
|
|
80
|
+
"h-11 flex-1 rounded-md px-3 text-xs md:h-8",
|
|
81
|
+
tab === t.value
|
|
82
|
+
? "bg-card text-foreground shadow-sm"
|
|
83
|
+
: "text-muted-foreground"
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
{t.label}
|
|
87
|
+
</button>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{tab === "models" && <ModelSpend data={data?.models} />}
|
|
92
|
+
{tab === "compare" && <CompareView enabled={open} />}
|
|
93
|
+
|
|
94
|
+
<section className={cn("space-y-3", tab !== "spend" && "hidden")}>
|
|
60
95
|
<div className="flex items-center justify-between gap-2">
|
|
61
96
|
<h3 className="text-sm font-medium">Chat spend</h3>
|
|
62
97
|
<div className="bg-foreground/[0.04] flex rounded-lg p-0.5">
|
|
@@ -124,6 +159,35 @@ export function UsageDialog() {
|
|
|
124
159
|
</>
|
|
125
160
|
)}
|
|
126
161
|
</section>
|
|
162
|
+
|
|
163
|
+
{data?.reviews && data.reviews.total.turns > 0 && (
|
|
164
|
+
<section className="space-y-3">
|
|
165
|
+
<h3 className="text-sm font-medium">Gate reviews</h3>
|
|
166
|
+
<div className="flex items-baseline gap-2">
|
|
167
|
+
<span className="text-2xl font-semibold tabular-nums">
|
|
168
|
+
{formatUsd(data.reviews.total.costUsd)}
|
|
169
|
+
</span>
|
|
170
|
+
<span className="text-muted-foreground text-xs">
|
|
171
|
+
estimate · {data.reviews.total.turns} runs ·{" "}
|
|
172
|
+
{data.reviews.total.tokens.toLocaleString()} tokens
|
|
173
|
+
</span>
|
|
174
|
+
</div>
|
|
175
|
+
<SpendList
|
|
176
|
+
title="By PR"
|
|
177
|
+
rows={data.reviews.sessions.map((s) => ({
|
|
178
|
+
key: s.id,
|
|
179
|
+
label: s.name,
|
|
180
|
+
...s,
|
|
181
|
+
}))}
|
|
182
|
+
total={data.reviews.total}
|
|
183
|
+
limit={10}
|
|
184
|
+
/>
|
|
185
|
+
<p className="text-muted-foreground text-[11px]">
|
|
186
|
+
Each run of the orchestrator's independent reviewer and scope
|
|
187
|
+
check, from its own result. Not part of chat spend.
|
|
188
|
+
</p>
|
|
189
|
+
</section>
|
|
190
|
+
)}
|
|
127
191
|
</DialogContent>
|
|
128
192
|
</Dialog>
|
|
129
193
|
);
|
|
@@ -10,3 +10,12 @@ export function formatDuration(seconds: number): string {
|
|
|
10
10
|
if (h < 48) return `${h}h${m % 60 ? ` ${m % 60}m` : ""}`;
|
|
11
11
|
return `${Math.floor(h / 24)}d ${h % 24}h`;
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
export function formatTokens(n: number): string {
|
|
15
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
16
|
+
if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
|
|
17
|
+
return String(n);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const formatPct = (yes: number, of: number) =>
|
|
21
|
+
of ? `${Math.round((yes / of) * 100)}%` : "—";
|
|
@@ -19,7 +19,7 @@ export function FreshStartForm() {
|
|
|
19
19
|
return <p className="text-destructive text-sm">{error.message}</p>;
|
|
20
20
|
return (
|
|
21
21
|
<Form
|
|
22
|
-
key={`${data.orchestratorTokens}:${data.taskTokens}:${data.
|
|
22
|
+
key={`${data.orchestratorTokens}:${data.taskTokens}:${data.compactTokens}`}
|
|
23
23
|
saved={data}
|
|
24
24
|
/>
|
|
25
25
|
);
|
|
@@ -29,13 +29,13 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
29
29
|
const update = useUpdateFreshStart();
|
|
30
30
|
const orchestrator = useThreshold(saved.orchestratorTokens, 200);
|
|
31
31
|
const task = useThreshold(saved.taskTokens, 200);
|
|
32
|
-
const
|
|
33
|
-
const error = orchestrator.error ?? task.error ??
|
|
32
|
+
const compact = useThreshold(saved.compactTokens, 150);
|
|
33
|
+
const error = orchestrator.error ?? task.error ?? compact.error;
|
|
34
34
|
const dirty =
|
|
35
35
|
!error &&
|
|
36
36
|
(orchestrator.tokens !== saved.orchestratorTokens ||
|
|
37
37
|
task.tokens !== saved.taskTokens ||
|
|
38
|
-
|
|
38
|
+
compact.tokens !== saved.compactTokens);
|
|
39
39
|
|
|
40
40
|
return (
|
|
41
41
|
<form
|
|
@@ -46,7 +46,7 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
46
46
|
{
|
|
47
47
|
orchestratorTokens: orchestrator.tokens,
|
|
48
48
|
taskTokens: task.tokens,
|
|
49
|
-
|
|
49
|
+
compactTokens: compact.tokens,
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
onSuccess: () => toast.success("Context settings saved"),
|
|
@@ -65,17 +65,17 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
65
65
|
</p>
|
|
66
66
|
<ThresholdRow label="Orchestrators" field={orchestrator} />
|
|
67
67
|
<ThresholdRow label="Tasks" field={task} />
|
|
68
|
-
<h3 className="pt-2 text-sm font-medium">Compact
|
|
68
|
+
<h3 className="pt-2 text-sm font-medium">Compact</h3>
|
|
69
69
|
<p className="text-muted-foreground text-sm">
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
to.
|
|
70
|
+
Chats and tasks compact when their context passes this size: Claude Code
|
|
71
|
+
does it itself, and between turns AgentOS compacts a chat over it, or
|
|
72
|
+
idle past its prompt cache, before the next message. The composer offers
|
|
73
|
+
to first. Orchestrators start fresh instead.
|
|
74
74
|
</p>
|
|
75
75
|
<ThresholdRow
|
|
76
|
-
label="
|
|
77
|
-
field={
|
|
78
|
-
switchLabel="Compact
|
|
76
|
+
label="Chats and tasks"
|
|
77
|
+
field={compact}
|
|
78
|
+
switchLabel="Compact chats and tasks at this size"
|
|
79
79
|
/>
|
|
80
80
|
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
81
81
|
<div className="flex justify-end">
|