@saadnvd1/agent-os 0.3.794 → 0.3.807

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 (167) hide show
  1. package/app/api/busy/route.ts +2 -2
  2. package/app/api/chat-views/[sessionId]/route.ts +19 -0
  3. package/app/api/chat-views/route.test.ts +79 -0
  4. package/app/api/chat-views/route.ts +8 -0
  5. package/app/api/tips/route.test.ts +56 -0
  6. package/app/api/tips/route.ts +45 -0
  7. package/app/api/updates/health/route.ts +22 -0
  8. package/app/api/updates/route.test.ts +53 -0
  9. package/app/api/updates/settings/route.ts +37 -10
  10. package/app/page.tsx +17 -14
  11. package/components/Chat/ChatPanel.tsx +74 -59
  12. package/components/Chat/NewDivider.tsx +15 -0
  13. package/components/Chat/needs-more-history.test.ts +28 -0
  14. package/components/Chat/needs-more-history.ts +14 -0
  15. package/components/Chat/session-link.ts +7 -4
  16. package/components/Chat/useFind.ts +50 -1
  17. package/components/Chat/usePosition.ts +363 -0
  18. package/components/CodeSearchDialog.tsx +90 -0
  19. package/components/CommandPalette/CommandsBody.tsx +12 -4
  20. package/components/CommandPalette/useAppCommands.ts +29 -11
  21. package/components/DevServers/ServerLogsModal.tsx +24 -21
  22. package/components/Nav/HistoryButtons.tsx +128 -0
  23. package/components/Pane/MobileTabBar.tsx +3 -0
  24. package/components/Pane/index.tsx +2 -2
  25. package/components/Recipes/TimerFields.tsx +2 -1
  26. package/components/Schedules/ScheduleForm.tsx +1 -1
  27. package/components/SessionList/useSessionSwitching.ts +3 -2
  28. package/components/Settings/SettingsDialog.tsx +1 -1
  29. package/components/Shortcuts/PendingKeys.tsx +19 -0
  30. package/components/Shortcuts/ShortcutsButton.tsx +22 -0
  31. package/components/Shortcuts/ShortcutsDialog.tsx +122 -0
  32. package/components/SidebarFooter.tsx +4 -0
  33. package/components/Terminal/hooks/useTerminalConnection.ts +4 -3
  34. package/components/Tips/TipButton.tsx +39 -0
  35. package/components/Tips/TipDialog.tsx +148 -0
  36. package/components/Tips/TipHost.tsx +14 -0
  37. package/components/Tips/TipMedia.tsx +71 -0
  38. package/components/Tips/index.ts +2 -0
  39. package/components/Updates/AutoUpdates.tsx +141 -0
  40. package/components/Updates/UpdateBanner.test.ts +39 -1
  41. package/components/Updates/UpdateBanner.tsx +31 -5
  42. package/components/Updates/UpdatesSection.tsx +24 -3
  43. package/components/views/DesktopView.tsx +7 -9
  44. package/components/views/MobileView.tsx +2 -8
  45. package/components/views/lazy.ts +2 -2
  46. package/content/tips.json +541 -0
  47. package/content/whats-new.json +56 -0
  48. package/data/chat-views/index.ts +2 -0
  49. package/data/chat-views/keys.ts +3 -0
  50. package/data/chat-views/queries.ts +34 -0
  51. package/data/sessions/pin.ts +0 -1
  52. package/data/tips/index.ts +49 -0
  53. package/data/updates/index.ts +5 -0
  54. package/hooks/useChatViewsSync.ts +12 -0
  55. package/hooks/useNavActions.ts +104 -0
  56. package/hooks/useNavKeys.ts +105 -0
  57. package/hooks/useReloadWhenUpdated.ts +5 -1
  58. package/hooks/useSessionUrl.ts +23 -5
  59. package/lib/bus/index.ts +4 -2
  60. package/lib/bus/role-alias.ts +4 -2
  61. package/lib/bus/targets.ts +2 -1
  62. package/lib/busy.ts +23 -1
  63. package/lib/chat/escape.ts +8 -4
  64. package/lib/chat-views/body.ts +41 -0
  65. package/lib/chat-views/store.ts +67 -0
  66. package/lib/chat-views/types.ts +20 -0
  67. package/lib/db/migrations/20261031T1500_chat_views.ts +30 -0
  68. package/lib/db/migrations.ts +21 -0
  69. package/lib/keybindings/focus.ts +17 -0
  70. package/lib/keybindings/keymap.ts +120 -0
  71. package/lib/keybindings/sequence.ts +64 -0
  72. package/lib/live/service.ts +1 -1
  73. package/lib/nav/history-mirror.ts +59 -0
  74. package/lib/nav/history-writer.ts +109 -0
  75. package/lib/nav/position.ts +67 -0
  76. package/lib/nav/recent.ts +34 -0
  77. package/lib/orchestrator/armed.ts +5 -0
  78. package/lib/palette/sections.ts +12 -6
  79. package/lib/recipes/timer.ts +3 -1
  80. package/lib/schedules/cron.ts +14 -5
  81. package/lib/schedules/store.ts +3 -2
  82. package/lib/security/demo.ts +2 -0
  83. package/lib/session-url.ts +11 -5
  84. package/lib/tasks/finish.ts +5 -5
  85. package/lib/tips/conditions.ts +38 -0
  86. package/lib/tips/index.ts +2 -0
  87. package/lib/tips/load.ts +26 -0
  88. package/lib/tips/media.ts +101 -0
  89. package/lib/tips/pick.ts +35 -0
  90. package/lib/tips/schema.ts +82 -0
  91. package/lib/tips/state.ts +26 -0
  92. package/lib/tips/view.ts +60 -0
  93. package/lib/updates/auto.ts +149 -0
  94. package/lib/updates/banner.ts +78 -3
  95. package/lib/updates/check.ts +64 -8
  96. package/lib/updates/history.ts +99 -0
  97. package/lib/updates/run.ts +334 -23
  98. package/lib/updates/settings.ts +66 -0
  99. package/lib/updates/view.ts +14 -3
  100. package/package.json +2 -2
  101. package/public/tips/.gitkeep +0 -0
  102. package/public/tips/chat-access-picker-dark.webp +0 -0
  103. package/public/tips/chat-access-picker.webp +0 -0
  104. package/public/tips/chat-attach-files-poster.webp +0 -0
  105. package/public/tips/chat-attach-files.mp4 +0 -0
  106. package/public/tips/chat-bookmark-dark.webp +0 -0
  107. package/public/tips/chat-bookmark.webp +0 -0
  108. package/public/tips/chat-plan-mode-dark.webp +0 -0
  109. package/public/tips/chat-plan-mode.webp +0 -0
  110. package/public/tips/chat-search-poster.webp +0 -0
  111. package/public/tips/chat-search.mp4 +0 -0
  112. package/public/tips/chat-slash-menu-poster.webp +0 -0
  113. package/public/tips/chat-slash-menu.mp4 +0 -0
  114. package/public/tips/chat-tab-suggestion-poster.webp +0 -0
  115. package/public/tips/chat-tab-suggestion.mp4 +0 -0
  116. package/public/tips/chat-terminal-handoff-poster.webp +0 -0
  117. package/public/tips/chat-terminal-handoff.mp4 +0 -0
  118. package/public/tips/chat-undo-dark.webp +0 -0
  119. package/public/tips/chat-undo.webp +0 -0
  120. package/public/tips/export-ai-dark.webp +0 -0
  121. package/public/tips/export-ai.webp +0 -0
  122. package/public/tips/net-messages-dark.webp +0 -0
  123. package/public/tips/net-messages.webp +0 -0
  124. package/public/tips/recipe-save-dark.webp +0 -0
  125. package/public/tips/recipe-save.webp +0 -0
  126. package/public/tips/schedule-first-dark.webp +0 -0
  127. package/public/tips/schedule-first.webp +0 -0
  128. package/public/tips/share-check-dark.webp +0 -0
  129. package/public/tips/share-check.webp +0 -0
  130. package/public/tips/sidebar-draft-poster.webp +0 -0
  131. package/public/tips/sidebar-draft.mp4 +0 -0
  132. package/public/tips/sidebar-groups-dark.webp +0 -0
  133. package/public/tips/sidebar-groups.webp +0 -0
  134. package/public/tips/sidebar-load-dark.webp +0 -0
  135. package/public/tips/sidebar-load.webp +0 -0
  136. package/public/tips/sidebar-snooze-dark.webp +0 -0
  137. package/public/tips/sidebar-snooze.webp +0 -0
  138. package/public/tips/tasks-plan-pieces-dark.webp +0 -0
  139. package/public/tips/tasks-plan-pieces.webp +0 -0
  140. package/public/tips/tasks-sign-off-dark.webp +0 -0
  141. package/public/tips/tasks-sign-off.webp +0 -0
  142. package/public/tips/tasks-stacks-dark.webp +0 -0
  143. package/public/tips/tasks-stacks.webp +0 -0
  144. package/scripts/autodeploy +4 -0
  145. package/scripts/autodeploy.test.ts +12 -0
  146. package/scripts/check +2 -2
  147. package/scripts/docs-links.test.ts +1 -25
  148. package/scripts/lib/commands.sh +32 -1
  149. package/scripts/lib/deps-swap.sh +8 -6
  150. package/scripts/lib/heading-slugs.ts +26 -0
  151. package/scripts/lib/update-verify.mjs +90 -0
  152. package/scripts/redeploy +31 -2
  153. package/scripts/redeploy.test.ts +94 -3
  154. package/scripts/tips-lint.test.ts +77 -0
  155. package/scripts/tips-media.ts +31 -0
  156. package/scripts/update-e2e.test.ts +215 -0
  157. package/scripts/update-recover.mjs +130 -0
  158. package/scripts/update-recover.test.ts +144 -0
  159. package/scripts/update-run.mjs +182 -40
  160. package/scripts/update-run.test.ts +208 -2
  161. package/server.ts +3 -2
  162. package/stores/chatJump.ts +2 -6
  163. package/stores/chatPositions.ts +65 -0
  164. package/stores/navHistory.ts +98 -0
  165. package/stores/shortcutsUi.ts +20 -0
  166. package/stores/tips.ts +14 -0
  167. package/components/QuickSwitcher.tsx +0 -286
@@ -1,5 +1,5 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { inFlight } from "@/lib/busy";
2
+ import { inFlight, inFlightTotal } from "@/lib/busy";
3
3
  import { requireLocalTrust } from "@/lib/security/route-guard";
4
4
 
5
5
  // GET /api/busy - work a restart would cut off; scripts/redeploy waits
@@ -8,6 +8,6 @@ export async function GET(request: NextRequest) {
8
8
  const refused = requireLocalTrust(request);
9
9
  if (refused) return refused;
10
10
  const work = inFlight();
11
- const busy = work.reviews + work.setups + work.simulatorStarts > 0;
11
+ const busy = inFlightTotal(work) > 0;
12
12
  return NextResponse.json({ ...work, busy });
13
13
  }
@@ -0,0 +1,19 @@
1
+ import { NextResponse } from "next/server";
2
+ import { readViewBody } from "@/lib/chat-views/body";
3
+ import { saveChatView } from "@/lib/chat-views/store";
4
+
5
+ type Ctx = { params: Promise<{ sessionId: string }> };
6
+
7
+ // Ids are uuids today; anything id-shaped fits other machines' sessions too.
8
+ const ID_SHAPE = /^[A-Za-z0-9_-]{1,128}$/;
9
+
10
+ // PUT /api/chat-views/:sessionId { itemId, offsetPx, atEnd } - the reader is
11
+ // here in this chat; or { viewed: true } - they just opened it.
12
+ export async function PUT(request: Request, { params }: Ctx) {
13
+ const { sessionId } = await params;
14
+ if (!ID_SHAPE.test(sessionId))
15
+ return NextResponse.json({ error: "Bad request" }, { status: 400 });
16
+ const body = await readViewBody(request);
17
+ if (body instanceof NextResponse) return body;
18
+ return NextResponse.json({ view: saveChatView(sessionId, body.input) });
19
+ }
@@ -0,0 +1,79 @@
1
+ import { randomUUID } from "crypto";
2
+ import { describe, expect, it } from "vitest";
3
+ import { getDb } from "@/lib/db";
4
+ import * as one from "./[sessionId]/route";
5
+ import * as list from "./route";
6
+
7
+ const put = (sessionId: string, body: unknown, raw = false) =>
8
+ one.PUT(
9
+ new Request("http://x/api/chat-views/" + sessionId, {
10
+ method: "PUT",
11
+ body: raw ? (body as string) : JSON.stringify(body),
12
+ }),
13
+ { params: Promise.resolve({ sessionId }) }
14
+ );
15
+ const views = async () =>
16
+ (await (await list.GET()).json()).views as {
17
+ sessionId: string;
18
+ itemId: string | null;
19
+ offsetPx: number;
20
+ atEnd: boolean;
21
+ viewedAt: number;
22
+ }[];
23
+
24
+ describe("/api/chat-views", () => {
25
+ it("saves a position, keeps it when a chat is only opened, and lists newest first", async () => {
26
+ const a = randomUUID();
27
+ const b = randomUUID();
28
+ const res = await put(a, { itemId: "i1", offsetPx: 40, atEnd: false });
29
+ expect(res.status).toBe(200);
30
+ await new Promise((r) => setTimeout(r, 5));
31
+ await put(b, { viewed: true });
32
+ await new Promise((r) => setTimeout(r, 5));
33
+ await put(a, { viewed: true });
34
+ const mine = (await views()).filter(
35
+ (v) => v.sessionId === a || v.sessionId === b
36
+ );
37
+ expect(mine.map((v) => v.sessionId)).toEqual([a, b]);
38
+ expect(mine[0]).toMatchObject({ itemId: "i1", offsetPx: 40, atEnd: false });
39
+ expect(mine[1]).toMatchObject({ itemId: null, atEnd: true });
40
+ expect(mine[0].viewedAt).toBeGreaterThan(mine[1].viewedAt);
41
+ });
42
+
43
+ it("keeps viewed_at when the reader left with things unseen below", async () => {
44
+ const id = randomUUID();
45
+ await put(id, { viewed: true });
46
+ const first = (await views()).find((v) => v.sessionId === id)!.viewedAt;
47
+ await new Promise((r) => setTimeout(r, 5));
48
+ await put(id, { itemId: "i", offsetPx: 3, atEnd: false, advance: false });
49
+ const kept = (await views()).find((v) => v.sessionId === id)!;
50
+ expect(kept).toMatchObject({ itemId: "i", offsetPx: 3, atEnd: false });
51
+ expect(kept.viewedAt).toBe(first);
52
+ });
53
+
54
+ it("refuses a bad body and a bad id", async () => {
55
+ const id = randomUUID();
56
+ expect((await put(id, { offsetPx: "x" })).status).toBe(400);
57
+ expect((await put(id, "not json", true)).status).toBe(400);
58
+ expect(
59
+ (await put(id, { itemId: "", offsetPx: 0, atEnd: true })).status
60
+ ).toBe(400);
61
+ expect((await put("bad id!", { viewed: true })).status).toBe(400);
62
+ expect((await put(id, { viewed: true, x: "y".repeat(5000) })).status).toBe(
63
+ 413
64
+ );
65
+ });
66
+
67
+ it("is dropped with its chat", async () => {
68
+ const id = randomUUID();
69
+ getDb()
70
+ .prepare(
71
+ `INSERT INTO sessions (id, name, tmux_name, working_directory) VALUES (?, 'v', ?, '/tmp')`
72
+ )
73
+ .run(id, `claude-${id}`);
74
+ await put(id, { viewed: true });
75
+ expect((await views()).some((v) => v.sessionId === id)).toBe(true);
76
+ getDb().prepare(`DELETE FROM sessions WHERE id = ?`).run(id);
77
+ expect((await views()).some((v) => v.sessionId === id)).toBe(false);
78
+ });
79
+ });
@@ -0,0 +1,8 @@
1
+ import { NextResponse } from "next/server";
2
+ import { listChatViews } from "@/lib/chat-views/store";
3
+
4
+ // GET /api/chat-views - where each chat was last being read and when, newest
5
+ // first: the switcher's recent order and the place a chat reopens at.
6
+ export async function GET() {
7
+ return NextResponse.json({ views: listChatViews() });
8
+ }
@@ -0,0 +1,56 @@
1
+ import { NextRequest } from "next/server";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { putSetting } from "@/lib/settings-store";
4
+ import { GET, POST } from "./route";
5
+
6
+ beforeEach(() => {
7
+ for (const k of ["tips.off", "tips.seen", "tips.today"]) putSetting(k, null);
8
+ });
9
+
10
+ const get = (q = "?day=2026-11-01", headers: Record<string, string> = {}) =>
11
+ GET(new NextRequest(`http://localhost/api/tips${q}`, { headers }));
12
+ const post = (body: unknown) =>
13
+ POST(
14
+ new NextRequest("http://localhost/api/tips", {
15
+ method: "POST",
16
+ body: JSON.stringify(body),
17
+ })
18
+ );
19
+
20
+ describe("/api/tips", () => {
21
+ it("returns today's tip and the deck, and 304s on the same ETag", async () => {
22
+ const res = await get();
23
+ const body = await res.json();
24
+ expect(body.today.id).toEqual(expect.any(String));
25
+ expect(body.deck.length).toBeGreaterThan(10);
26
+ const again = await get("?day=2026-11-01", {
27
+ "if-none-match": res.headers.get("etag")!,
28
+ });
29
+ expect(again.status).toBe(304);
30
+ });
31
+
32
+ it("rejects a bad day and a bad body", async () => {
33
+ expect((await get("?day=tomorrow")).status).toBe(400);
34
+ expect((await post({ action: "dance" })).status).toBe(400);
35
+ expect((await post({ action: "seen", ids: ["a"], day: "x" })).status).toBe(
36
+ 400
37
+ );
38
+ });
39
+
40
+ it("remembers a tip as read", async () => {
41
+ const { today } = await (await get()).json();
42
+ const after = await (
43
+ await post({ action: "seen", ids: [today.id], day: "2026-11-01" })
44
+ ).json();
45
+ expect(after.today).toEqual({ id: today.id, read: true });
46
+ expect((await (await get()).json()).today.read).toBe(true);
47
+ });
48
+
49
+ it("turns tips off and on", async () => {
50
+ const off = await (await post({ action: "off", off: true })).json();
51
+ expect(off).toMatchObject({ off: true, today: null, deck: [] });
52
+ expect((await (await get()).json()).off).toBe(true);
53
+ const on = await (await post({ action: "off", off: false })).json();
54
+ expect(on.off).toBe(false);
55
+ });
56
+ });
@@ -0,0 +1,45 @@
1
+ import { createHash } from "crypto";
2
+ import { NextRequest, NextResponse } from "next/server";
3
+ import { z } from "zod";
4
+ import { markSeen, setTipsOff, tipsView } from "@/lib/tips";
5
+
6
+ const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
7
+
8
+ const post = z.discriminatedUnion("action", [
9
+ z.object({
10
+ action: z.literal("seen"),
11
+ ids: z.array(z.string().max(80)).max(100),
12
+ day,
13
+ }),
14
+ z.object({ action: z.literal("off"), off: z.boolean() }),
15
+ ]);
16
+
17
+ // GET /api/tips?day=YYYY-MM-DD - today's tip and the deck that applies now.
18
+ // `day` is the caller's local date, so the tip changes at their midnight.
19
+ export async function GET(request: NextRequest) {
20
+ const parsed = day
21
+ .optional()
22
+ .safeParse(request.nextUrl.searchParams.get("day") ?? undefined);
23
+ if (!parsed.success)
24
+ return NextResponse.json({ error: "Bad day" }, { status: 400 });
25
+ const text = JSON.stringify(tipsView(parsed.data));
26
+ const etag = `"${createHash("sha1").update(text).digest("hex").slice(0, 16)}"`;
27
+ const headers = { ETag: etag, "Cache-Control": "private, no-cache" };
28
+ if (request.headers.get("if-none-match") === etag)
29
+ return new NextResponse(null, { status: 304, headers });
30
+ return new NextResponse(text, {
31
+ headers: { ...headers, "Content-Type": "application/json" },
32
+ });
33
+ }
34
+
35
+ // POST /api/tips - { action: "seen", ids, day } or { action: "off", off }.
36
+ export async function POST(request: NextRequest) {
37
+ const body = post.safeParse(await request.json().catch(() => null));
38
+ if (!body.success)
39
+ return NextResponse.json({ error: "Bad request" }, { status: 400 });
40
+ if (body.data.action === "seen") markSeen(body.data.ids, body.data.day);
41
+ else setTipsOff(body.data.off);
42
+ return NextResponse.json(
43
+ tipsView(body.data.action === "seen" ? body.data.day : undefined)
44
+ );
45
+ }
@@ -0,0 +1,22 @@
1
+ import { NextResponse } from "next/server";
2
+ import { buildId } from "@/lib/build";
3
+ import { db } from "@/lib/db";
4
+ import { migrationStatus } from "@/lib/db/migrations";
5
+
6
+ const startedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
7
+
8
+ // GET /api/updates/health - what scripts/update-run.mjs checks after an
9
+ // update's restart: the build this server runs, whether every migration it
10
+ // knows applied, and when it started (a change means it crashed and came back).
11
+ export function GET() {
12
+ let migrations: { known: number; applied: number } | null = null;
13
+ try {
14
+ migrations = migrationStatus(db);
15
+ } catch {}
16
+ return NextResponse.json({
17
+ ok: !!migrations,
18
+ build: buildId(),
19
+ startedAt,
20
+ migrations,
21
+ });
22
+ }
@@ -32,6 +32,7 @@ const root = await import("./route");
32
32
  const runRoute = await import("./run/route");
33
33
  const checkRoute = await import("./check/route");
34
34
  const settingsRoute = await import("./settings/route");
35
+ const healthRoute = await import("./health/route");
35
36
 
36
37
  const req = (
37
38
  path: string,
@@ -65,6 +66,7 @@ beforeEach(() => {
65
66
  throttled = undefined;
66
67
  refreshed = null;
67
68
  delete process.env.AGENTOS_UPDATE_BANNER;
69
+ delete process.env.AGENTOS_AUTO_UPDATE;
68
70
  process.env.AGENTOS_DEV = "1";
69
71
  db.prepare(`DELETE FROM settings WHERE key LIKE 'updates.%'`).run();
70
72
  });
@@ -205,3 +207,54 @@ describe("PUT /api/updates/settings", () => {
205
207
  ).toBe(409);
206
208
  });
207
209
  });
210
+
211
+ describe("automatic updates settings", () => {
212
+ it("are on by default, and turn off and back on, with an idle time", async () => {
213
+ const first = await (await root.GET(req("", "GET", loopback))).json();
214
+ expect(first.auto).toMatchObject({
215
+ enabled: true,
216
+ locked: false,
217
+ idleMinutes: 10,
218
+ });
219
+ let view = await (
220
+ await settingsRoute.PUT(
221
+ req("/settings", "PUT", loopback, { auto: false, idleMinutes: 30 })
222
+ )
223
+ ).json();
224
+ expect(view.auto).toMatchObject({ enabled: false, idleMinutes: 30 });
225
+ view = await (
226
+ await settingsRoute.PUT(req("/settings", "PUT", loopback, { auto: true }))
227
+ ).json();
228
+ expect(view.auto.enabled).toBe(true);
229
+ });
230
+
231
+ it("refuses an idle time it doesn't offer, and a pinned switch", async () => {
232
+ expect(
233
+ (
234
+ await settingsRoute.PUT(
235
+ req("/settings", "PUT", loopback, { idleMinutes: 7 })
236
+ )
237
+ ).status
238
+ ).toBe(400);
239
+ process.env.AGENTOS_AUTO_UPDATE = "off";
240
+ expect(
241
+ (
242
+ await settingsRoute.PUT(
243
+ req("/settings", "PUT", loopback, { auto: true })
244
+ )
245
+ ).status
246
+ ).toBe(409);
247
+ const view = await (await root.GET(req("", "GET", loopback))).json();
248
+ expect(view.auto).toMatchObject({ enabled: false, locked: true });
249
+ });
250
+ });
251
+
252
+ describe("GET /api/updates/health", () => {
253
+ it("says which build runs and that every migration applied", async () => {
254
+ const body = await healthRoute.GET().json();
255
+ expect(body.ok).toBe(true);
256
+ expect(body.build).toBeTruthy();
257
+ expect(body.migrations.known).toBeGreaterThan(0);
258
+ expect(body.migrations.applied).toBe(body.migrations.known);
259
+ });
260
+ });
@@ -1,25 +1,52 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { requireApprover } from "@/lib/security/approver";
3
- import { bannerLocked, setBannerEnabled } from "@/lib/updates/settings";
3
+ import {
4
+ IDLE_MINUTES,
5
+ autoLocked,
6
+ bannerLocked,
7
+ idleMinutesLocked,
8
+ setAutoEnabled,
9
+ setBannerEnabled,
10
+ setIdleMinutes,
11
+ } from "@/lib/updates/settings";
4
12
  import { updateView } from "@/lib/updates/view";
5
13
 
6
- // PUT /api/updates/settings {banner: boolean} - show the update banner or not.
14
+ const conflict = (error: string) =>
15
+ NextResponse.json({ error }, { status: 409 });
16
+
17
+ // PUT /api/updates/settings {banner?, auto?, idleMinutes?} - show the update
18
+ // banner, install updates automatically, and how long everything must be
19
+ // idle first.
7
20
  export async function PUT(request: NextRequest) {
8
21
  const allowed = requireApprover(request);
9
22
  if (!allowed.ok) return allowed.response;
10
23
  const body = (await request.json().catch(() => null)) as {
11
24
  banner?: unknown;
25
+ auto?: unknown;
26
+ idleMinutes?: unknown;
12
27
  } | null;
13
- if (typeof body?.banner !== "boolean")
28
+ const { banner, auto, idleMinutes } = body ?? {};
29
+ if (
30
+ (banner === undefined && auto === undefined && idleMinutes === undefined) ||
31
+ (banner !== undefined && typeof banner !== "boolean") ||
32
+ (auto !== undefined && typeof auto !== "boolean") ||
33
+ (idleMinutes !== undefined &&
34
+ !(IDLE_MINUTES as readonly unknown[]).includes(idleMinutes))
35
+ )
14
36
  return NextResponse.json(
15
- { error: "banner must be true or false." },
37
+ {
38
+ error: `Send banner or auto (true or false), or idleMinutes (${IDLE_MINUTES.join(", ")}).`,
39
+ },
16
40
  { status: 400 }
17
41
  );
18
- if (bannerLocked())
19
- return NextResponse.json(
20
- { error: "The banner is pinned off by AGENTOS_UPDATE_BANNER." },
21
- { status: 409 }
22
- );
23
- setBannerEnabled(body.banner);
42
+ if (banner !== undefined && bannerLocked())
43
+ return conflict("The banner is pinned off by AGENTOS_UPDATE_BANNER.");
44
+ if (auto !== undefined && autoLocked())
45
+ return conflict("Automatic updates are pinned off by AGENTOS_AUTO_UPDATE.");
46
+ if (idleMinutes !== undefined && idleMinutesLocked())
47
+ return conflict("The idle time is pinned by AGENTOS_AUTO_UPDATE_IDLE_MIN.");
48
+ if (typeof banner === "boolean") setBannerEnabled(banner);
49
+ if (typeof auto === "boolean") setAutoEnabled(auto);
50
+ if (typeof idleMinutes === "number") setIdleMinutes(idleMinutes);
24
51
  return NextResponse.json(await updateView({ mayUpdate: true }));
25
52
  }
package/app/page.tsx CHANGED
@@ -7,6 +7,9 @@ import { subscribe } from "valtio";
7
7
  import { viewSwitchStore, viewSwitchActions } from "@/stores/viewSwitch";
8
8
  import { useOpenSession } from "@/hooks/useOpenSession";
9
9
  import { useSessionUrl } from "@/hooks/useSessionUrl";
10
+ import { useNavActions } from "@/hooks/useNavActions";
11
+ import { useChatViewsSync } from "@/hooks/useChatViewsSync";
12
+ import { chatPositionsActions } from "@/stores/chatPositions";
10
13
  import { tmuxAttachStore, tmuxAttachActions } from "@/stores/tmuxAttach";
11
14
  import type { AttachSpec } from "@/lib/hosts/attach";
12
15
  import { memo, useState, useEffect, useCallback, useMemo, useRef } from "react";
@@ -55,7 +58,8 @@ import { useDraftKeys } from "@/hooks/useDraftKeys";
55
58
  import { useDraftRequests } from "@/hooks/useDraftRequests";
56
59
  import { newDraft } from "@/stores/drafts";
57
60
  import { useOpenOrchestrator } from "@/hooks/useOpenOrchestrator";
58
- import { paletteActions, paletteUi } from "@/stores/palette";
61
+ import { ShortcutsDialog } from "@/components/Shortcuts/ShortcutsDialog";
62
+ import { PendingKeys } from "@/components/Shortcuts/PendingKeys";
59
63
  import { useAppCommands } from "@/components/CommandPalette/useAppCommands";
60
64
  import { useMoveCommands } from "@/components/Tasks/useMoveCommands";
61
65
  import dynamic from "next/dynamic";
@@ -187,6 +191,8 @@ const AppDialogs = memo(function AppDialogs({
187
191
  <AwayReport />
188
192
  <IncomingLinks />
189
193
  <CommandPalette />
194
+ <ShortcutsDialog />
195
+ <PendingKeys />
190
196
  </>
191
197
  );
192
198
  });
@@ -530,9 +536,12 @@ function HomeContent() {
530
536
  isMobile,
531
537
  });
532
538
 
539
+ useChatViewsSync();
540
+
533
541
  // Notification click handler
534
542
  const handleNotificationClick = useCallback(
535
543
  (sessionId: string) => {
544
+ chatPositionsActions.wantLatest(sessionId);
536
545
  const session = sessions.find((s) => s.id === sessionId);
537
546
  if (session) {
538
547
  attachToSession(session);
@@ -571,19 +580,6 @@ function HomeContent() {
571
580
  if (isHydrated && !isMobile) setSidebarOpen(true);
572
581
  }, [isMobile, isHydrated]);
573
582
 
574
- // Cmd+K: the command palette, which searches sessions too.
575
- useEffect(() => {
576
- const handleKeyDown = (e: KeyboardEvent) => {
577
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
578
- e.preventDefault();
579
- if (paletteUi.open) paletteActions.setOpen(false);
580
- else paletteActions.open();
581
- }
582
- };
583
- window.addEventListener("keydown", handleKeyDown);
584
- return () => window.removeEventListener("keydown", handleKeyDown);
585
- }, []);
586
-
587
583
  // Picking a session with a tap, ⌘K or the keyboard: the composer is focused
588
584
  // inside the gesture, which iOS requires. Other ways in (URL, notification,
589
585
  // a session just created) use attachToSession's later focus.
@@ -674,6 +670,7 @@ function HomeContent() {
674
670
  // A workspace's orchestrator row opens its chat, made on first open.
675
671
  const handleOrchestratorOpened = useCallback(
676
672
  (sessionId: string) => {
673
+ chatPositionsActions.wantLatest(sessionId);
677
674
  if (isMobile) setSidebarOpen(false);
678
675
  void handleSessionCreated(sessionId);
679
676
  },
@@ -724,8 +721,14 @@ function HomeContent() {
724
721
  chatMetaActions.setActive(focusedActiveTab?.sessionId ?? null);
725
722
  }, [focusedActiveTab?.sessionId]);
726
723
 
724
+ useNavActions({
725
+ sessions,
726
+ activeSessionId: focusedActiveTab?.sessionId,
727
+ select: selectSession,
728
+ });
727
729
  useAppCommands({
728
730
  sessions,
731
+ activeSessionId: focusedActiveTab?.sessionId,
729
732
  statuses: sessionStatuses,
730
733
  onSelectSession: selectSession,
731
734
  onSearchCode: () => setShowQuickSwitcher(true),