@saadnvd1/agent-os 0.3.553 → 0.3.556
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/app/api/sessions/[id]/done/route.test.ts +70 -0
- package/app/api/sessions/[id]/done/route.ts +32 -6
- package/app/api/updates/check/route.ts +17 -0
- package/app/api/updates/route.test.ts +205 -0
- package/app/api/updates/route.ts +11 -0
- package/app/api/updates/run/route.ts +50 -0
- package/app/api/updates/settings/route.ts +25 -0
- package/components/Chat/ChatPanel.tsx +18 -5
- package/components/Chat/Messages.test.ts +44 -0
- package/components/Chat/Messages.tsx +29 -7
- package/components/Chat/composer/ComposerBody.tsx +5 -2
- package/components/Chat/composer/hook.test.ts +24 -0
- package/components/Chat/composer/insert.ts +18 -0
- package/components/SessionList/useDoneAction.test.ts +149 -0
- package/components/SessionList/useDoneAction.ts +17 -12
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/commands.ts +9 -0
- package/components/Settings/entry-points.test.ts +1 -0
- package/components/Updates/UpdateActions.tsx +93 -0
- package/components/Updates/UpdateBanner.test.ts +198 -0
- package/components/Updates/UpdateBanner.tsx +212 -0
- package/components/Updates/UpdatesSection.tsx +150 -0
- package/components/Updates/index.ts +2 -0
- package/components/views/DesktopView.tsx +2 -0
- package/components/views/MobileView.tsx +2 -0
- package/data/done/index.ts +33 -7
- package/data/updates/index.ts +63 -0
- package/hooks/useDismissedUpdate.ts +56 -0
- package/hooks/useReloadWhenUpdated.ts +19 -0
- package/lib/chat/quote.ts +5 -0
- package/lib/done/deadline.ts +45 -0
- package/lib/done/index.ts +4 -1
- package/lib/settings-url.ts +1 -0
- package/lib/updates/banner.ts +114 -0
- package/lib/updates/check.ts +240 -0
- package/lib/updates/run.ts +452 -0
- package/lib/updates/settings.ts +38 -0
- package/lib/updates/version.ts +30 -0
- package/lib/updates/view.ts +80 -0
- package/package.json +1 -1
- package/scripts/redeploy +4 -0
- package/scripts/update-run.mjs +82 -0
- package/scripts/update-run.test.ts +64 -0
- package/server.ts +16 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { ArrowUpCircle, ChevronDown, Loader2, X } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { useUpdates } from "@/data/updates";
|
|
7
|
+
import { useDismissedUpdate } from "@/hooks/useDismissedUpdate";
|
|
8
|
+
import { useReloadWhenUpdated } from "@/hooks/useReloadWhenUpdated";
|
|
9
|
+
import {
|
|
10
|
+
bannerState,
|
|
11
|
+
type BannerState,
|
|
12
|
+
type UpdateView,
|
|
13
|
+
} from "@/lib/updates/banner";
|
|
14
|
+
import { cn } from "@/lib/utils";
|
|
15
|
+
import { UpdateActions } from "./UpdateActions";
|
|
16
|
+
|
|
17
|
+
const STEPS = ["pulling", "installing", "building", "restarting"] as const;
|
|
18
|
+
const LABEL: Record<(typeof STEPS)[number], string> = {
|
|
19
|
+
pulling: "Pulling the new version",
|
|
20
|
+
installing: "Installing dependencies",
|
|
21
|
+
building: "Building",
|
|
22
|
+
restarting: "Restarting",
|
|
23
|
+
};
|
|
24
|
+
const SLOW_MS = 10 * 60_000;
|
|
25
|
+
|
|
26
|
+
function useSince(startedAt: number | undefined, on: boolean) {
|
|
27
|
+
const [now, setNow] = useState(() => Date.now());
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!on) return;
|
|
30
|
+
const t = setInterval(() => setNow(Date.now()), 5000);
|
|
31
|
+
return () => clearInterval(t);
|
|
32
|
+
}, [on]);
|
|
33
|
+
return startedAt ? now - startedAt : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Log({ lines }: { lines: string[] }) {
|
|
37
|
+
const [open, setOpen] = useState(false);
|
|
38
|
+
if (!lines.length) return null;
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
className="text-muted-foreground hover:text-foreground min-h-11 text-xs underline md:min-h-0"
|
|
44
|
+
onClick={() => setOpen(!open)}
|
|
45
|
+
>
|
|
46
|
+
{open ? "Hide log" : "Show log"}
|
|
47
|
+
</button>
|
|
48
|
+
{open && (
|
|
49
|
+
<pre className="bg-background/60 mt-1 max-h-48 w-full overflow-auto rounded-md p-2 text-xs whitespace-pre-wrap">
|
|
50
|
+
{lines.join("\n")}
|
|
51
|
+
</pre>
|
|
52
|
+
)}
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The bar itself; what to show and what the buttons do come in as props. */
|
|
58
|
+
export function UpdateBannerView({
|
|
59
|
+
state,
|
|
60
|
+
view,
|
|
61
|
+
onDismiss,
|
|
62
|
+
}: {
|
|
63
|
+
state: Exclude<BannerState, "hidden">;
|
|
64
|
+
view: UpdateView;
|
|
65
|
+
onDismiss: () => void;
|
|
66
|
+
}) {
|
|
67
|
+
const [notesOpen, setNotesOpen] = useState(false);
|
|
68
|
+
const run = view.run;
|
|
69
|
+
const elapsed = useSince(run?.startedAt, state === "running");
|
|
70
|
+
const latest = view.latest;
|
|
71
|
+
const failed = state === "failed";
|
|
72
|
+
|
|
73
|
+
let text: React.ReactNode;
|
|
74
|
+
if (state === "available") text = <>AgentOS {latest?.version} is available</>;
|
|
75
|
+
else if (state === "pending")
|
|
76
|
+
text = <>AgentOS will update when nothing is working</>;
|
|
77
|
+
else if (state === "built")
|
|
78
|
+
text = <>Built {run?.toVersion}. Restart AgentOS to finish updating.</>;
|
|
79
|
+
else if (failed) text = <>The update didn't finish. {run?.error}</>;
|
|
80
|
+
else {
|
|
81
|
+
const step = Math.max(
|
|
82
|
+
0,
|
|
83
|
+
STEPS.indexOf(run!.phase as (typeof STEPS)[number])
|
|
84
|
+
);
|
|
85
|
+
text =
|
|
86
|
+
elapsed > SLOW_MS ? (
|
|
87
|
+
<>Taking longer than expected. Check the update log, then reload.</>
|
|
88
|
+
) : (
|
|
89
|
+
<>
|
|
90
|
+
{LABEL[STEPS[step]]}… step {step + 1} of {STEPS.length}
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const extra = (
|
|
96
|
+
<>
|
|
97
|
+
{state === "available" && !view.canUpdate.ok && view.canUpdate.reason && (
|
|
98
|
+
<p className="text-muted-foreground text-xs">{view.canUpdate.reason}</p>
|
|
99
|
+
)}
|
|
100
|
+
{failed && <Log lines={run?.log ?? []} />}
|
|
101
|
+
{notesOpen && latest && (
|
|
102
|
+
<ul className="text-muted-foreground list-disc space-y-0.5 pl-4 text-xs">
|
|
103
|
+
{latest.notes.map((n, i) => (
|
|
104
|
+
<li key={i}>{n}</li>
|
|
105
|
+
))}
|
|
106
|
+
</ul>
|
|
107
|
+
)}
|
|
108
|
+
</>
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div
|
|
113
|
+
role={failed ? "alert" : "status"}
|
|
114
|
+
className={cn(
|
|
115
|
+
"flex items-start gap-2 border-b px-3 py-2 text-sm",
|
|
116
|
+
failed ? "bg-destructive/10 text-foreground" : "bg-muted/60"
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
<span className="flex h-6 shrink-0 items-center md:h-8">
|
|
120
|
+
{state === "running" ? (
|
|
121
|
+
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
|
122
|
+
) : (
|
|
123
|
+
<ArrowUpCircle
|
|
124
|
+
className={cn(
|
|
125
|
+
"h-4 w-4",
|
|
126
|
+
failed ? "text-destructive" : "text-primary"
|
|
127
|
+
)}
|
|
128
|
+
/>
|
|
129
|
+
)}
|
|
130
|
+
</span>
|
|
131
|
+
<div className="min-w-0 flex-1 space-y-1">
|
|
132
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 md:min-h-8">
|
|
133
|
+
<p className="min-w-0 flex-1 basis-40">{text}</p>
|
|
134
|
+
{state === "available" && latest && latest.notes.length > 0 && (
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
aria-expanded={notesOpen}
|
|
138
|
+
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center gap-1 text-xs md:min-h-0"
|
|
139
|
+
onClick={() => setNotesOpen(!notesOpen)}
|
|
140
|
+
>
|
|
141
|
+
What's new
|
|
142
|
+
<ChevronDown
|
|
143
|
+
className={cn(
|
|
144
|
+
"h-3 w-3 transition-transform",
|
|
145
|
+
notesOpen && "rotate-180"
|
|
146
|
+
)}
|
|
147
|
+
/>
|
|
148
|
+
</button>
|
|
149
|
+
)}
|
|
150
|
+
{state === "available" && latest?.url && (
|
|
151
|
+
<a
|
|
152
|
+
href={latest.url}
|
|
153
|
+
target="_blank"
|
|
154
|
+
rel="noreferrer noopener"
|
|
155
|
+
className="text-primary inline-flex min-h-11 items-center text-xs underline md:min-h-0"
|
|
156
|
+
>
|
|
157
|
+
Release notes
|
|
158
|
+
</a>
|
|
159
|
+
)}
|
|
160
|
+
{(state === "available" || state === "pending") && (
|
|
161
|
+
<UpdateActions view={view} />
|
|
162
|
+
)}
|
|
163
|
+
{state === "running" && elapsed > SLOW_MS && (
|
|
164
|
+
<Button size="sm" onClick={() => window.location.reload()}>
|
|
165
|
+
Reload
|
|
166
|
+
</Button>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
{extra}
|
|
170
|
+
</div>
|
|
171
|
+
{state !== "running" && state !== "pending" && (
|
|
172
|
+
<Button
|
|
173
|
+
variant="ghost"
|
|
174
|
+
size="icon-sm"
|
|
175
|
+
aria-label={
|
|
176
|
+
state === "available" ? "Dismiss until the next version" : "Dismiss"
|
|
177
|
+
}
|
|
178
|
+
className="-my-1 -mr-1 h-11 w-11 shrink-0 md:h-8 md:w-8"
|
|
179
|
+
onClick={onDismiss}
|
|
180
|
+
>
|
|
181
|
+
<X className="h-4 w-4" />
|
|
182
|
+
</Button>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Held apart: the page re-renders on every status push.
|
|
189
|
+
export function UpdateBanner() {
|
|
190
|
+
const { data: view } = useUpdates();
|
|
191
|
+
const dismissed = useDismissedUpdate();
|
|
192
|
+
useReloadWhenUpdated(view);
|
|
193
|
+
const state = bannerState({
|
|
194
|
+
view,
|
|
195
|
+
dismissedVersion: dismissed.version,
|
|
196
|
+
dismissedRun: dismissed.run,
|
|
197
|
+
});
|
|
198
|
+
if (state === "hidden" || !view) return null;
|
|
199
|
+
return (
|
|
200
|
+
<UpdateBannerView
|
|
201
|
+
state={state}
|
|
202
|
+
view={view}
|
|
203
|
+
onDismiss={() =>
|
|
204
|
+
state === "available" && view.latest
|
|
205
|
+
? dismissed.dismissVersion(view.latest.version)
|
|
206
|
+
: view.run
|
|
207
|
+
? dismissed.dismissRun(view.run.id)
|
|
208
|
+
: undefined
|
|
209
|
+
}
|
|
210
|
+
/>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
import { Loader2 } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { SettingToggle } from "@/components/Settings/SettingToggle";
|
|
7
|
+
import {
|
|
8
|
+
useCheckForUpdates,
|
|
9
|
+
useUpdateBannerSetting,
|
|
10
|
+
useUpdates,
|
|
11
|
+
} from "@/data/updates";
|
|
12
|
+
import { isActivePhase } from "@/lib/updates/banner";
|
|
13
|
+
import { UpdateActions } from "./UpdateActions";
|
|
14
|
+
|
|
15
|
+
function ago(at: number | null): string {
|
|
16
|
+
if (!at) return "not yet";
|
|
17
|
+
const min = Math.floor((Date.now() - at) / 60_000);
|
|
18
|
+
if (min < 1) return "just now";
|
|
19
|
+
if (min < 60) return `${min} minute${min === 1 ? "" : "s"} ago`;
|
|
20
|
+
const h = Math.floor(min / 60);
|
|
21
|
+
return h < 48
|
|
22
|
+
? `${h} hour${h === 1 ? "" : "s"} ago`
|
|
23
|
+
: `${Math.floor(h / 24)} days ago`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex items-baseline justify-between gap-4 py-1.5 text-sm">
|
|
29
|
+
<dt className="text-muted-foreground">{label}</dt>
|
|
30
|
+
<dd className="min-w-0 text-right font-medium">{value}</dd>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Settings > Updates: what's installed, what's out, and the switch for the banner.
|
|
36
|
+
export function UpdatesSection() {
|
|
37
|
+
const { data: view, isLoading, error } = useUpdates();
|
|
38
|
+
const check = useCheckForUpdates();
|
|
39
|
+
const banner = useUpdateBannerSetting();
|
|
40
|
+
|
|
41
|
+
if (isLoading || !view)
|
|
42
|
+
return (
|
|
43
|
+
<p className="text-muted-foreground text-sm">
|
|
44
|
+
{error ? "Couldn't load update status." : "Loading…"}
|
|
45
|
+
</p>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const running = isActivePhase(view.run?.phase);
|
|
49
|
+
const onCheck = () =>
|
|
50
|
+
check.mutate(undefined, {
|
|
51
|
+
onError: (e) => {
|
|
52
|
+
// Asked again inside a minute: the answer on screen is fresh.
|
|
53
|
+
if ((e as { status?: number }).status !== 429) toast.error(e.message);
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
let summary = "AgentOS is up to date.";
|
|
58
|
+
if (view.status === "diverged")
|
|
59
|
+
summary =
|
|
60
|
+
"This checkout has commits the remote doesn't, so it can't update itself.";
|
|
61
|
+
else if (view.status === "unknown")
|
|
62
|
+
summary = view.checkError ?? "Updates haven't been checked yet.";
|
|
63
|
+
else if (view.available)
|
|
64
|
+
summary = `${view.latest?.version} is available (${view.behind} newer change${view.behind === 1 ? "" : "s"}).`;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="space-y-5">
|
|
68
|
+
<dl className="divide-border divide-y">
|
|
69
|
+
<Row
|
|
70
|
+
label="Installed"
|
|
71
|
+
value={
|
|
72
|
+
view.current ? (
|
|
73
|
+
<>
|
|
74
|
+
{view.current.version}{" "}
|
|
75
|
+
<span className="text-muted-foreground font-mono text-xs">
|
|
76
|
+
{view.current.sha.slice(0, 7)}
|
|
77
|
+
</span>
|
|
78
|
+
</>
|
|
79
|
+
) : (
|
|
80
|
+
"Unknown"
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
/>
|
|
84
|
+
<Row label="Latest" value={view.latest?.version ?? "Unknown"} />
|
|
85
|
+
<Row label="Last checked" value={ago(view.checkedAt)} />
|
|
86
|
+
</dl>
|
|
87
|
+
|
|
88
|
+
<p className="text-sm" role="status">
|
|
89
|
+
{summary}
|
|
90
|
+
</p>
|
|
91
|
+
{view.checkError && view.status === "ok" && (
|
|
92
|
+
<p className="text-muted-foreground text-xs">
|
|
93
|
+
The last check failed: {view.checkError}
|
|
94
|
+
</p>
|
|
95
|
+
)}
|
|
96
|
+
{view.run?.phase === "failed" && (
|
|
97
|
+
<p className="text-destructive text-sm" role="alert">
|
|
98
|
+
The last update failed. {view.run.error}
|
|
99
|
+
</p>
|
|
100
|
+
)}
|
|
101
|
+
{running && (
|
|
102
|
+
<p className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
103
|
+
<Loader2 className="h-4 w-4 animate-spin" /> Updating (
|
|
104
|
+
{view.run?.phase})…
|
|
105
|
+
</p>
|
|
106
|
+
)}
|
|
107
|
+
{view.available && !view.canUpdate.ok && view.canUpdate.reason && (
|
|
108
|
+
<p className="text-muted-foreground text-xs">{view.canUpdate.reason}</p>
|
|
109
|
+
)}
|
|
110
|
+
{view.pending && (
|
|
111
|
+
<p className="text-muted-foreground text-sm">
|
|
112
|
+
Will update when nothing is working.
|
|
113
|
+
</p>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
117
|
+
<Button
|
|
118
|
+
variant="outline"
|
|
119
|
+
size="sm"
|
|
120
|
+
className="min-h-11 md:min-h-0"
|
|
121
|
+
disabled={check.isPending || running}
|
|
122
|
+
onClick={onCheck}
|
|
123
|
+
>
|
|
124
|
+
{check.isPending ? "Checking…" : "Check for updates"}
|
|
125
|
+
</Button>
|
|
126
|
+
{view.available && !running && <UpdateActions view={view} />}
|
|
127
|
+
{view.available && view.latest?.url && (
|
|
128
|
+
<a
|
|
129
|
+
href={view.latest.url}
|
|
130
|
+
target="_blank"
|
|
131
|
+
rel="noreferrer noopener"
|
|
132
|
+
className="text-primary text-sm underline"
|
|
133
|
+
>
|
|
134
|
+
Release notes
|
|
135
|
+
</a>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<SettingToggle
|
|
140
|
+
title="Show update banner"
|
|
141
|
+
detail="Tell everyone using this AgentOS when a newer version is out. Nothing updates unless someone chooses to."
|
|
142
|
+
checked={view.bannerEnabled}
|
|
143
|
+
locked={view.bannerLocked}
|
|
144
|
+
onChange={(on) =>
|
|
145
|
+
banner.mutate(on, { onError: (e) => toast.error(e.message) })
|
|
146
|
+
}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { DesktopSidebar } from "./DesktopSidebar";
|
|
4
4
|
import { DesktopAppActions, SidebarToggle } from "./DesktopAppActions";
|
|
5
|
+
import { UpdateBanner } from "@/components/Updates";
|
|
5
6
|
import { PaneLayout } from "@/components/PaneLayout";
|
|
6
7
|
import { PaneBarSlotsProvider } from "@/components/Pane/PaneBarSlots";
|
|
7
8
|
import type { ViewProps } from "./types";
|
|
@@ -53,6 +54,7 @@ export function DesktopView({
|
|
|
53
54
|
|
|
54
55
|
{/* Main content */}
|
|
55
56
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
57
|
+
<UpdateBanner />
|
|
56
58
|
{/* Pane Layout - full height */}
|
|
57
59
|
<div className="min-h-0 flex-1">
|
|
58
60
|
<PaneBarSlotsProvider
|
|
@@ -4,6 +4,7 @@ import { SessionList } from "@/components/SessionList";
|
|
|
4
4
|
import { SidebarFooter } from "@/components/SidebarFooter";
|
|
5
5
|
import { PaneLayout } from "@/components/PaneLayout";
|
|
6
6
|
import { SwipeSidebar } from "@/components/mobile/SwipeSidebar";
|
|
7
|
+
import { UpdateBanner } from "@/components/Updates";
|
|
7
8
|
import type { ViewProps } from "./types";
|
|
8
9
|
import { QuickSwitcher, StartServerDialog } from "./lazy";
|
|
9
10
|
import { fileOpenActions } from "@/stores/fileOpen";
|
|
@@ -29,6 +30,7 @@ export function MobileView({
|
|
|
29
30
|
}: ViewProps) {
|
|
30
31
|
return (
|
|
31
32
|
<main className="app-backdrop h-app fixed inset-x-0 top-[var(--app-top,0px)] flex flex-col overflow-hidden pb-[env(safe-area-inset-bottom)]">
|
|
33
|
+
<UpdateBanner />
|
|
32
34
|
{/* Swipe sidebar */}
|
|
33
35
|
<SwipeSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)}>
|
|
34
36
|
<div className="flex h-full flex-col">
|
package/data/done/index.ts
CHANGED
|
@@ -17,12 +17,34 @@ async function json<T>(res: Response): Promise<T> {
|
|
|
17
17
|
return data;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
// Longer than the server's own deadline for done (lib/done/deadline.ts), so
|
|
21
|
+
// the pending toast always ends even if the server never answers.
|
|
22
|
+
export const DONE_REQUEST_TIMEOUT_MS = 180_000;
|
|
23
|
+
|
|
24
|
+
async function post(url: string, body?: object, timeoutMs?: number) {
|
|
25
|
+
const abort = new AbortController();
|
|
26
|
+
const timer = timeoutMs
|
|
27
|
+
? setTimeout(
|
|
28
|
+
() =>
|
|
29
|
+
abort.abort(
|
|
30
|
+
new Error(
|
|
31
|
+
"The server didn't answer in time. It may still be working: check the row, then try again."
|
|
32
|
+
)
|
|
33
|
+
),
|
|
34
|
+
timeoutMs
|
|
35
|
+
)
|
|
36
|
+
: undefined;
|
|
37
|
+
try {
|
|
38
|
+
return await fetch(url, {
|
|
39
|
+
signal: abort.signal,
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "Content-Type": "application/json" },
|
|
42
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
43
|
+
});
|
|
44
|
+
} finally {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
26
48
|
|
|
27
49
|
function useDoneMutation<V, R>(fn: (v: V) => Promise<R>) {
|
|
28
50
|
const queryClient = useQueryClient();
|
|
@@ -40,7 +62,11 @@ export function useDoneSession() {
|
|
|
40
62
|
async (id: string) =>
|
|
41
63
|
(
|
|
42
64
|
await json<{ outcome: DoneOutcome }>(
|
|
43
|
-
await post(
|
|
65
|
+
await post(
|
|
66
|
+
`/api/sessions/${id}/done`,
|
|
67
|
+
undefined,
|
|
68
|
+
DONE_REQUEST_TIMEOUT_MS
|
|
69
|
+
)
|
|
44
70
|
)
|
|
45
71
|
).outcome
|
|
46
72
|
);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { isActivePhase, type UpdateView } from "@/lib/updates/banner";
|
|
3
|
+
|
|
4
|
+
export const updateKeys = { all: ["updates"] as const };
|
|
5
|
+
|
|
6
|
+
async function send(
|
|
7
|
+
method: string,
|
|
8
|
+
url: string,
|
|
9
|
+
body?: object
|
|
10
|
+
): Promise<UpdateView> {
|
|
11
|
+
const res = await fetch(url, {
|
|
12
|
+
method,
|
|
13
|
+
...(body && {
|
|
14
|
+
headers: { "Content-Type": "application/json" },
|
|
15
|
+
body: JSON.stringify(body),
|
|
16
|
+
}),
|
|
17
|
+
}).catch(() => {
|
|
18
|
+
throw new Error("Couldn't reach AgentOS. Try again.");
|
|
19
|
+
});
|
|
20
|
+
const data = await res.json().catch(() => ({}));
|
|
21
|
+
if (!res.ok)
|
|
22
|
+
throw Object.assign(
|
|
23
|
+
new Error(data.error || `Request failed (${res.status})`),
|
|
24
|
+
{ status: res.status }
|
|
25
|
+
);
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Polled quickly only while an update runs; the server looks for new
|
|
30
|
+
// versions itself, so a half hour is plenty for the rest.
|
|
31
|
+
export function useUpdates() {
|
|
32
|
+
return useQuery({
|
|
33
|
+
queryKey: updateKeys.all,
|
|
34
|
+
queryFn: () => send("GET", "/api/updates"),
|
|
35
|
+
staleTime: 5 * 60_000,
|
|
36
|
+
refetchInterval: (query) =>
|
|
37
|
+
isActivePhase(query.state.data?.run?.phase) ? 2000 : 30 * 60_000,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function useUpdateMutation<V>(fn: (v: V) => Promise<UpdateView>) {
|
|
42
|
+
const queryClient = useQueryClient();
|
|
43
|
+
return useMutation({
|
|
44
|
+
mutationFn: fn,
|
|
45
|
+
onSuccess: (view) => queryClient.setQueryData(updateKeys.all, view),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const useCheckForUpdates = () =>
|
|
50
|
+
useUpdateMutation<void>(() => send("POST", "/api/updates/check"));
|
|
51
|
+
|
|
52
|
+
export const useRunUpdate = () =>
|
|
53
|
+
useUpdateMutation<"now" | "idle">((when) =>
|
|
54
|
+
send("POST", "/api/updates/run", { when })
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
export const useCancelUpdate = () =>
|
|
58
|
+
useUpdateMutation<void>(() => send("DELETE", "/api/updates/run"));
|
|
59
|
+
|
|
60
|
+
export const useUpdateBannerSetting = () =>
|
|
61
|
+
useUpdateMutation<boolean>((banner) =>
|
|
62
|
+
send("PUT", "/api/updates/settings", { banner })
|
|
63
|
+
);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
4
|
+
import { DISMISSED_RUN_KEY, DISMISSED_VERSION_KEY } from "@/lib/updates/banner";
|
|
5
|
+
|
|
6
|
+
// What this browser has dismissed: the version whose banner was closed, and
|
|
7
|
+
// the failed or built run whose notice was. In localStorage, so it holds
|
|
8
|
+
// across reloads, and per browser because a tab with no device identity
|
|
9
|
+
// can't be told apart server-side.
|
|
10
|
+
const listeners = new Set<() => void>();
|
|
11
|
+
|
|
12
|
+
function read(key: string): string | null {
|
|
13
|
+
try {
|
|
14
|
+
return localStorage.getItem(key);
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function subscribe(fn: () => void) {
|
|
21
|
+
listeners.add(fn);
|
|
22
|
+
window.addEventListener("storage", fn);
|
|
23
|
+
return () => {
|
|
24
|
+
listeners.delete(fn);
|
|
25
|
+
window.removeEventListener("storage", fn);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function write(key: string, value: string) {
|
|
30
|
+
try {
|
|
31
|
+
localStorage.setItem(key, value);
|
|
32
|
+
} catch {}
|
|
33
|
+
listeners.forEach((fn) => fn());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useDismissedUpdate() {
|
|
37
|
+
const version = useSyncExternalStore(
|
|
38
|
+
subscribe,
|
|
39
|
+
() => read(DISMISSED_VERSION_KEY),
|
|
40
|
+
() => null
|
|
41
|
+
);
|
|
42
|
+
const run = useSyncExternalStore(
|
|
43
|
+
subscribe,
|
|
44
|
+
() => read(DISMISSED_RUN_KEY),
|
|
45
|
+
() => null
|
|
46
|
+
);
|
|
47
|
+
const dismissVersion = useCallback(
|
|
48
|
+
(v: string) => write(DISMISSED_VERSION_KEY, v),
|
|
49
|
+
[]
|
|
50
|
+
);
|
|
51
|
+
const dismissRun = useCallback(
|
|
52
|
+
(id: string) => write(DISMISSED_RUN_KEY, id),
|
|
53
|
+
[]
|
|
54
|
+
);
|
|
55
|
+
return { version, run, dismissVersion, dismissRun };
|
|
56
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { isActivePhase, type UpdateView } from "@/lib/updates/banner";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reloads once the update this page watched run has finished and the server
|
|
8
|
+
* is back on the new build. A page that never saw it run doesn't reload.
|
|
9
|
+
*/
|
|
10
|
+
export function useReloadWhenUpdated(view: UpdateView | undefined) {
|
|
11
|
+
const watched = useRef<string | null>(null);
|
|
12
|
+
const run = view?.run;
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!run) return;
|
|
15
|
+
if (isActivePhase(run.phase)) watched.current = run.id;
|
|
16
|
+
else if (run.phase === "done" && watched.current === run.id)
|
|
17
|
+
window.location.reload();
|
|
18
|
+
}, [run]);
|
|
19
|
+
}
|
package/lib/chat/quote.ts
CHANGED
|
@@ -9,6 +9,11 @@ export function quoteMarkdown(selection: string): string {
|
|
|
9
9
|
return `${lines.map((l) => (l.trim() ? `> ${l}` : ">")).join("\n")}\n\n`;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// A whole message to quote: after whatever is already written.
|
|
13
|
+
export function quoteInsert(text: string, at = Date.now()) {
|
|
14
|
+
return { text: quoteMarkdown(text), at, append: true };
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
// Markdown as editor paragraphs, one per line (the composer writes each
|
|
13
18
|
// paragraph as a line), to put in at the caret.
|
|
14
19
|
export function markdownParagraphs(
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Done merges, stops the agent and cleans up; any step can stall on a lock or
|
|
2
|
+
// a slow remote. A request must always end: past this it answers that it is
|
|
3
|
+
// still going, rather than leaving the row on "Merging…".
|
|
4
|
+
export const DONE_DEADLINE_MS = 150_000;
|
|
5
|
+
|
|
6
|
+
export async function withinDeadline<T>(
|
|
7
|
+
work: Promise<T>,
|
|
8
|
+
name: string,
|
|
9
|
+
ms = DONE_DEADLINE_MS
|
|
10
|
+
): Promise<T> {
|
|
11
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
12
|
+
const late = new Promise<never>((_, reject) => {
|
|
13
|
+
timer = setTimeout(
|
|
14
|
+
() =>
|
|
15
|
+
reject(
|
|
16
|
+
new Error(
|
|
17
|
+
`Done on ${name} is taking longer than ${Math.round(ms / 1000)}s and is still running in the background. Check the row in a moment; if it hasn't moved, try Done again.`
|
|
18
|
+
)
|
|
19
|
+
),
|
|
20
|
+
ms
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
// The work keeps going; its late failure must not be an unhandled rejection.
|
|
24
|
+
work.catch(() => {});
|
|
25
|
+
try {
|
|
26
|
+
return await Promise.race([work, late]);
|
|
27
|
+
} finally {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Best-effort: waits for `work` up to `ms`, then carries on without it.
|
|
33
|
+
export async function withTimeout(
|
|
34
|
+
work: Promise<unknown>,
|
|
35
|
+
ms: number
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
38
|
+
work.catch(() => {});
|
|
39
|
+
await Promise.race([
|
|
40
|
+
work,
|
|
41
|
+
new Promise((resolve) => {
|
|
42
|
+
timer = setTimeout(resolve, ms);
|
|
43
|
+
}),
|
|
44
|
+
]).finally(() => clearTimeout(timer));
|
|
45
|
+
}
|
package/lib/done/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { archiveSession } from "./archive";
|
|
|
22
22
|
import { settleSession } from "../sidebar/shelf-state";
|
|
23
23
|
import { demoMode } from "../security/demo";
|
|
24
24
|
import { planDone } from "./plan";
|
|
25
|
+
import { withTimeout } from "./deadline";
|
|
25
26
|
import { deleteMergedRemote } from "./remote";
|
|
26
27
|
import { settleWorktree, type WorktreeFate } from "./worktree";
|
|
27
28
|
import { sessionMergePolicy } from "../tasks/finish";
|
|
@@ -151,7 +152,9 @@ export async function doneSession(
|
|
|
151
152
|
// A sign-off just before this (the orchestrator's sign_off, then done)
|
|
152
153
|
// is still cleaning up in the background: read what it leaves.
|
|
153
154
|
await mergeSettled(s.id);
|
|
154
|
-
|
|
155
|
+
// A quick change has no tmux session, and a listing that stalls (a slow
|
|
156
|
+
// ssh host, a loaded tmux) must not hold up a merge that doesn't read it.
|
|
157
|
+
if (!s.quick_of) await withTimeout(statusDetector.refreshCache(), 10_000);
|
|
155
158
|
const plan = await planDone(s, opts.callerId);
|
|
156
159
|
if (plan.action === "refuse") throw new Error(plan.reason);
|
|
157
160
|
if (plan.action === "merge" && opts.noMerge)
|