@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.
Files changed (45) hide show
  1. package/app/api/sessions/[id]/done/route.test.ts +70 -0
  2. package/app/api/sessions/[id]/done/route.ts +32 -6
  3. package/app/api/updates/check/route.ts +17 -0
  4. package/app/api/updates/route.test.ts +205 -0
  5. package/app/api/updates/route.ts +11 -0
  6. package/app/api/updates/run/route.ts +50 -0
  7. package/app/api/updates/settings/route.ts +25 -0
  8. package/components/Chat/ChatPanel.tsx +18 -5
  9. package/components/Chat/Messages.test.ts +44 -0
  10. package/components/Chat/Messages.tsx +29 -7
  11. package/components/Chat/composer/ComposerBody.tsx +5 -2
  12. package/components/Chat/composer/hook.test.ts +24 -0
  13. package/components/Chat/composer/insert.ts +18 -0
  14. package/components/SessionList/useDoneAction.test.ts +149 -0
  15. package/components/SessionList/useDoneAction.ts +17 -12
  16. package/components/Settings/SettingsDialog.test.ts +3 -0
  17. package/components/Settings/SettingsDialog.tsx +9 -0
  18. package/components/Settings/commands.ts +9 -0
  19. package/components/Settings/entry-points.test.ts +1 -0
  20. package/components/Updates/UpdateActions.tsx +93 -0
  21. package/components/Updates/UpdateBanner.test.ts +198 -0
  22. package/components/Updates/UpdateBanner.tsx +212 -0
  23. package/components/Updates/UpdatesSection.tsx +150 -0
  24. package/components/Updates/index.ts +2 -0
  25. package/components/views/DesktopView.tsx +2 -0
  26. package/components/views/MobileView.tsx +2 -0
  27. package/data/done/index.ts +33 -7
  28. package/data/updates/index.ts +63 -0
  29. package/hooks/useDismissedUpdate.ts +56 -0
  30. package/hooks/useReloadWhenUpdated.ts +19 -0
  31. package/lib/chat/quote.ts +5 -0
  32. package/lib/done/deadline.ts +45 -0
  33. package/lib/done/index.ts +4 -1
  34. package/lib/settings-url.ts +1 -0
  35. package/lib/updates/banner.ts +114 -0
  36. package/lib/updates/check.ts +240 -0
  37. package/lib/updates/run.ts +452 -0
  38. package/lib/updates/settings.ts +38 -0
  39. package/lib/updates/version.ts +30 -0
  40. package/lib/updates/view.ts +80 -0
  41. package/package.json +1 -1
  42. package/scripts/redeploy +4 -0
  43. package/scripts/update-run.mjs +82 -0
  44. package/scripts/update-run.test.ts +64 -0
  45. 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&apos;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&apos;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
+ }
@@ -0,0 +1,2 @@
1
+ export { UpdateBanner } from "./UpdateBanner";
2
+ export { UpdatesSection } from "./UpdatesSection";
@@ -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">
@@ -17,12 +17,34 @@ async function json<T>(res: Response): Promise<T> {
17
17
  return data;
18
18
  }
19
19
 
20
- const post = (url: string, body?: object) =>
21
- fetch(url, {
22
- method: "POST",
23
- headers: { "Content-Type": "application/json" },
24
- body: body ? JSON.stringify(body) : undefined,
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(`/api/sessions/${id}/done`)
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
- await statusDetector.refreshCache();
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)
@@ -15,6 +15,7 @@ export const SETTINGS_SECTIONS = [
15
15
  "schedules",
16
16
  "audits",
17
17
  "setup",
18
+ "updates",
18
19
  ] as const;
19
20
 
20
21
  export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];