@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
@@ -0,0 +1,49 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { TipsView } from "@/lib/tips/schema";
3
+
4
+ export const tipsKeys = { all: ["tips"] as const };
5
+
6
+ // The browser's own date: the tip changes at the user's midnight.
7
+ const localDay = () => new Date().toLocaleDateString("sv");
8
+
9
+ async function send(init?: { body: object }): Promise<TipsView> {
10
+ const res = await fetch(
11
+ init ? "/api/tips" : `/api/tips?day=${localDay()}`,
12
+ init && {
13
+ method: "POST",
14
+ headers: { "Content-Type": "application/json" },
15
+ body: JSON.stringify(init.body),
16
+ }
17
+ ).catch(() => {
18
+ throw new Error("Couldn't reach AgentOS. Try again.");
19
+ });
20
+ if (!res.ok) throw new Error(`Tips failed (${res.status})`);
21
+ return res.json();
22
+ }
23
+
24
+ // The tips file only changes with an update; the day can change under an open tab.
25
+ export const useTips = () =>
26
+ useQuery({
27
+ queryKey: tipsKeys.all,
28
+ queryFn: () => send(),
29
+ staleTime: 10 * 60_000,
30
+ refetchOnWindowFocus: true,
31
+ });
32
+
33
+ function useTipsMutation<V>(body: (v: V) => object) {
34
+ const queryClient = useQueryClient();
35
+ return useMutation({
36
+ mutationFn: (v: V) => send({ body: body(v) }),
37
+ onSuccess: (view) => queryClient.setQueryData(tipsKeys.all, view),
38
+ });
39
+ }
40
+
41
+ export const useMarkTipSeen = () =>
42
+ useTipsMutation<string>((id) => ({
43
+ action: "seen",
44
+ ids: [id],
45
+ day: localDay(),
46
+ }));
47
+
48
+ export const useSetTipsOff = () =>
49
+ useTipsMutation<boolean>((off) => ({ action: "off", off }));
@@ -61,3 +61,8 @@ export const useUpdateBannerSetting = () =>
61
61
  useUpdateMutation<boolean>((banner) =>
62
62
  send("PUT", "/api/updates/settings", { banner })
63
63
  );
64
+
65
+ export const useAutoUpdateSetting = () =>
66
+ useUpdateMutation<{ auto?: boolean; idleMinutes?: number }>((patch) =>
67
+ send("PUT", "/api/updates/settings", patch)
68
+ );
@@ -0,0 +1,12 @@
1
+ import { useEffect } from "react";
2
+ import { useChatViews } from "@/data/chat-views";
3
+ import { chatPositionsActions } from "@/stores/chatPositions";
4
+
5
+ /** Hands the server's reading positions to the page; chats wait for them to open where they were left. */
6
+ export function useChatViewsSync(): void {
7
+ const { data, isError } = useChatViews();
8
+ useEffect(() => {
9
+ if (data) chatPositionsActions.hydrate(data);
10
+ else if (isError) chatPositionsActions.giveUp();
11
+ }, [data, isError]);
12
+ }
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import { Keyboard, MessageSquare, Repeat } from "lucide-react";
4
+ import type { Session } from "@/lib/db";
5
+ import { chordHint } from "@/lib/keybindings/chord";
6
+ import { keysOf } from "@/lib/keybindings/keymap";
7
+ import { previousChatId } from "@/lib/nav/recent";
8
+ import { useNavKeys, useSingleKeys } from "@/hooks/useNavKeys";
9
+ import { usePaletteCommands } from "@/hooks/usePaletteCommands";
10
+ import { bookmarksUiActions } from "@/stores/bookmarksUi";
11
+ import { chatOpened } from "@/stores/chatPositions";
12
+ import { newDraft } from "@/stores/drafts";
13
+ import { inboxUiActions } from "@/stores/inboxUi";
14
+ import { paletteActions, paletteUi } from "@/stores/palette";
15
+ import { settingsUiActions } from "@/stores/settingsUi";
16
+ import { shortcutsUiActions } from "@/stores/shortcutsUi";
17
+
18
+ /** The chats ⌘P, O C and the like switch between, as the palette's Sessions tab. */
19
+ function openSwitcher() {
20
+ if (paletteUi.open && paletteUi.tab === "sessions")
21
+ return paletteActions.setOpen(false);
22
+ paletteActions.open();
23
+ paletteActions.setTab("sessions");
24
+ }
25
+
26
+ /**
27
+ * What each shortcut in keymap.ts does, and the palette commands that go
28
+ * with them. Opening a chat goes through `select`, so history, position and
29
+ * the address follow as they do for a click.
30
+ */
31
+ export function useNavActions({
32
+ sessions,
33
+ activeSessionId,
34
+ select,
35
+ }: {
36
+ sessions: Session[];
37
+ activeSessionId: string | null | undefined;
38
+ select: (session: Session) => void;
39
+ }) {
40
+ const [singles, setSingles] = useSingleKeys();
41
+ const previous = () => {
42
+ const open = sessions.filter((s) => !s.archived_at);
43
+ const id = previousChatId(
44
+ open.map((s) => s.id),
45
+ chatOpened,
46
+ activeSessionId
47
+ );
48
+ const session = open.find((s) => s.id === id);
49
+ if (session) select(session);
50
+ };
51
+ const actions = {
52
+ "palette.open": () =>
53
+ paletteUi.open ? paletteActions.setOpen(false) : paletteActions.open(),
54
+ "switcher.open": openSwitcher,
55
+ "chat.open": openSwitcher,
56
+ "chat.previous": previous,
57
+ "chat.new": () => newDraft({ kind: "current" }),
58
+ "go.inbox": () => inboxUiActions.open(),
59
+ "go.messages": () => inboxUiActions.open("messages"),
60
+ "go.bookmarks": () => bookmarksUiActions.open(activeSessionId ?? null),
61
+ "go.settings": () => settingsUiActions.open(),
62
+ "help.open": shortcutsUiActions.open,
63
+ };
64
+ useNavKeys(actions);
65
+ usePaletteCommands("nav", [
66
+ {
67
+ id: "nav.switch",
68
+ title: "Switch chat",
69
+ group: "Go to",
70
+ hint: chordHint(keysOf("switcher.open")),
71
+ keywords: ["recent", "open chat", "session", "switcher"],
72
+ icon: MessageSquare,
73
+ run: openSwitcher,
74
+ },
75
+ {
76
+ id: "nav.previous",
77
+ title: "Previous chat",
78
+ group: "Go to",
79
+ hint: "G P",
80
+ keywords: ["flip", "alternate", "last", "back"],
81
+ icon: Repeat,
82
+ run: previous,
83
+ },
84
+ {
85
+ id: "nav.shortcuts",
86
+ title: "Keyboard shortcuts",
87
+ group: "Go to",
88
+ hint: "?",
89
+ keywords: ["keys", "help", "hotkeys"],
90
+ icon: Keyboard,
91
+ run: shortcutsUiActions.open,
92
+ },
93
+ {
94
+ id: "nav.single-keys",
95
+ title: singles
96
+ ? "Turn off single-key shortcuts"
97
+ : "Turn on single-key shortcuts",
98
+ group: "Settings",
99
+ keywords: ["keyboard", "letters", "vim", "linear"],
100
+ icon: Keyboard,
101
+ run: () => setSingles(!singles),
102
+ },
103
+ ]);
104
+ }
@@ -0,0 +1,105 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { useKeybindings } from "@/hooks/useKeybindings";
5
+ import { useStoredFlag } from "@/hooks/useStoredFlag";
6
+ import { isFreeTarget } from "@/lib/keybindings/focus";
7
+ import { KEYMAP, isSequence, keysOf } from "@/lib/keybindings/keymap";
8
+ import {
9
+ SEQUENCE_MS,
10
+ SequenceMatcher,
11
+ plainKey,
12
+ } from "@/lib/keybindings/sequence";
13
+ import { shortcutsUiActions } from "@/stores/shortcutsUi";
14
+
15
+ export const SINGLE_KEYS_FLAG = "agentos-single-key-shortcuts";
16
+
17
+ /** The single-key shortcuts' on/off switch, kept per browser. */
18
+ export const useSingleKeys = () => useStoredFlag(SINGLE_KEYS_FLAG, true);
19
+
20
+ export type NavActions = Record<string, () => void>;
21
+
22
+ const SEQUENCES = Object.fromEntries(
23
+ KEYMAP.filter((k) => k.single && isSequence(k.keys)).map((k) => [
24
+ k.keys,
25
+ k.id,
26
+ ])
27
+ );
28
+ const BARE = Object.fromEntries(
29
+ KEYMAP.filter((k) => k.single && !isSequence(k.keys)).map((k) => [
30
+ k.keys,
31
+ k.id,
32
+ ])
33
+ );
34
+
35
+ const CHORD_IDS = ["switcher.open", "palette.open"];
36
+
37
+ const dialogOpen = () =>
38
+ !!document.querySelector('[role="dialog"][data-state="open"]');
39
+
40
+ /**
41
+ * The app's keys by id (see keymap.ts): chords through the registry, bare
42
+ * keys and two-key sequences here, only while nothing is being typed into
43
+ * and the single-key switch is on.
44
+ */
45
+ export function useNavKeys(actions: NavActions) {
46
+ const latest = useRef(actions);
47
+ useEffect(() => {
48
+ latest.current = actions;
49
+ });
50
+ const run = (id: string) => latest.current[id]?.();
51
+ const [singles] = useSingleKeys();
52
+ const singlesOn = useRef(singles);
53
+ useEffect(() => {
54
+ singlesOn.current = singles;
55
+ });
56
+
57
+ // The sidebar's ⌘↑/⌘↓ are bound by useSessionSwitching, from the same table.
58
+ useKeybindings(
59
+ "nav",
60
+ CHORD_IDS.map((id) => ({ id, keys: keysOf(id), run: () => run(id) }))
61
+ );
62
+
63
+ useEffect(() => {
64
+ const matcher = new SequenceMatcher(SEQUENCES);
65
+ let timer: ReturnType<typeof setTimeout> | undefined;
66
+ const clear = () => {
67
+ clearTimeout(timer);
68
+ matcher.reset();
69
+ shortcutsUiActions.setPending(null);
70
+ };
71
+ const onKey = (e: KeyboardEvent) => {
72
+ if (!singlesOn.current) return clear();
73
+ if (e.metaKey || e.ctrlKey || e.altKey || e.isComposing) return clear();
74
+ if (!isFreeTarget(e.target) || dialogOpen()) return clear();
75
+ if (e.key === "Escape") return clear();
76
+ if (e.key === "Shift") return;
77
+ const bare = plainKey(e.key, e.shiftKey);
78
+ if (bare === null) return clear();
79
+ const waiting = matcher.pending() !== null;
80
+ const step = matcher.feed(bare);
81
+ if (step.kind === "match") {
82
+ e.preventDefault();
83
+ clear();
84
+ run(step.id);
85
+ } else if (step.kind === "pending") {
86
+ e.preventDefault();
87
+ shortcutsUiActions.setPending(step.first);
88
+ clearTimeout(timer);
89
+ timer = setTimeout(clear, SEQUENCE_MS);
90
+ } else {
91
+ clear();
92
+ const id = BARE[bare.toLowerCase()];
93
+ if (id && !waiting && !e.repeat) {
94
+ e.preventDefault();
95
+ run(id);
96
+ }
97
+ }
98
+ };
99
+ window.addEventListener("keydown", onKey, true);
100
+ return () => {
101
+ window.removeEventListener("keydown", onKey, true);
102
+ clear();
103
+ };
104
+ }, []);
105
+ }
@@ -13,7 +13,11 @@ export function useReloadWhenUpdated(view: UpdateView | undefined) {
13
13
  useEffect(() => {
14
14
  if (!run) return;
15
15
  if (isActivePhase(run.phase)) watched.current = run.id;
16
- else if (run.phase === "done" && watched.current === run.id)
16
+ // A rollback restarted the server onto the old build: reload onto it too.
17
+ else if (
18
+ (run.phase === "done" || run.phase === "rolled_back") &&
19
+ watched.current === run.id
20
+ )
17
21
  window.location.reload();
18
22
  }, [run]);
19
23
  }
@@ -10,6 +10,8 @@ import {
10
10
  type SessionRef,
11
11
  } from "@/lib/session-url";
12
12
  import { usePanes } from "@/contexts/PaneContext";
13
+ import { navHistoryActions } from "@/stores/navHistory";
14
+ import { chatPositionsActions } from "@/stores/chatPositions";
13
15
 
14
16
  interface Options {
15
17
  sessions: Session[];
@@ -59,6 +61,9 @@ export function useSessionUrl({
59
61
  };
60
62
  });
61
63
 
64
+ const nameOf = (id: string | null) =>
65
+ (id && latest.current.sessions.find((s) => s.id === id)?.name) || "Chat";
66
+
62
67
  // Made on first use, outside render.
63
68
  const syncRef = useRef<SessionUrlSync | null>(null);
64
69
  const getSync = useCallback(() => {
@@ -79,8 +84,13 @@ export function useSessionUrl({
79
84
  syncRef.current = new SessionUrlSync(
80
85
  {
81
86
  href: () => window.location.href,
82
- push: (href) => window.history.pushState(null, "", href),
83
- replace: (href) => window.history.replaceState(null, "", href),
87
+ push: (href, id) => navHistoryActions.push(href, id, nameOf(id)),
88
+ replace: (href, id) => navHistoryActions.replace(href, id, nameOf(id)),
89
+ settle: (id) => {
90
+ const entry = navHistoryActions.currentState();
91
+ if (entry?.sessionId !== id)
92
+ navHistoryActions.replace(window.location.href, id, nameOf(id));
93
+ },
84
94
  },
85
95
  {
86
96
  find: (ref) => pickSession(ref, latest.current.sessions)?.id ?? null,
@@ -119,8 +129,10 @@ export function useSessionUrl({
119
129
  // A rename (by hand, or the title a first turn gives it) moves the slug.
120
130
  const focusedName = sessions.find((s) => s.id === focusedId)?.name ?? null;
121
131
  useEffect(() => {
122
- if (focusedName !== null) getSync().renamed();
123
- }, [getSync, focusedName]);
132
+ if (focusedName === null) return;
133
+ getSync().renamed();
134
+ if (focusedId) navHistoryActions.rename(focusedId, focusedName);
135
+ }, [getSync, focusedName, focusedId]);
124
136
 
125
137
  const ready = hydrated && loaded;
126
138
  useEffect(() => {
@@ -128,7 +140,13 @@ export function useSessionUrl({
128
140
  }, [getSync, ready]);
129
141
 
130
142
  useEffect(() => {
131
- const onPop = () => void getSync().popstate();
143
+ const onPop = () => {
144
+ // Back or forward onto an entry: it remembers where the reader was.
145
+ const entry = navHistoryActions.popped();
146
+ if (entry?.sessionId && entry.pos)
147
+ chatPositionsActions.setTarget(entry.sessionId, entry.pos);
148
+ void getSync().popstate();
149
+ };
132
150
  window.addEventListener("popstate", onPop);
133
151
  return () => window.removeEventListener("popstate", onPop);
134
152
  }, [getSync]);
package/lib/bus/index.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  } from "./waits/store";
40
40
  import { HUMAN, overRateLimit, wakeLine, type BusMessageView } from "./format";
41
41
  import { groupNames } from "../session-groups/store";
42
+ import { sessionWorkspace } from "../schedules/store";
42
43
 
43
44
  export * from "./format";
44
45
  export {
@@ -103,6 +104,7 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
103
104
  const sessions = liveSessions();
104
105
  const named = candidates(sessions);
105
106
  const caller = sessionById(callerId);
107
+ const callerWorkspace = caller ? sessionWorkspace(caller) : null;
106
108
  const orchestrator = heldOrchestrator(caller);
107
109
  const badges = waitBadges();
108
110
  const groups = groupNames();
@@ -127,9 +129,9 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
127
129
  groups.has(s.session_group_id) && {
128
130
  group: groups.get(s.session_group_id),
129
131
  }),
130
- ...(caller?.workspace_id &&
132
+ ...(callerWorkspace &&
131
133
  s.role === "orchestrator" &&
132
- s.workspace_id === caller.workspace_id && { yours: true }),
134
+ s.workspace_id === callerWorkspace && { yours: true }),
133
135
  };
134
136
  })
135
137
  );
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { Session } from "../db";
9
+ import { sessionWorkspace } from "../models/resolve";
9
10
 
10
11
  type RoleSession = Pick<
11
12
  Session,
@@ -56,9 +57,10 @@ export function resolveRoleAlias<T extends RoleSession>(
56
57
  error: `"@${role}" matches ${found.length} sessions: ${found.map(label).join(", ")}. Use the name or the id`,
57
58
  };
58
59
  }
59
- if (role === "supervisor" && caller?.workspace_id) {
60
+ const workspace = caller ? sessionWorkspace(caller) : null;
61
+ if (role === "supervisor" && workspace) {
60
62
  const orchestrator = others.find(
61
- (s) => s.role === "orchestrator" && s.workspace_id === caller.workspace_id
63
+ (s) => s.role === "orchestrator" && s.workspace_id === workspace
62
64
  );
63
65
  if (orchestrator) return { ok: true, session: orchestrator };
64
66
  }
@@ -73,7 +73,8 @@ function ownOrchestrator(
73
73
  callerId?: string | null
74
74
  ): Session | undefined {
75
75
  if (!/^(my )?orchestrator$/i.test(ref.trim())) return undefined;
76
- const workspace = sessionById(callerId)?.workspace_id;
76
+ const caller = sessionById(callerId);
77
+ const workspace = caller ? sessionWorkspace(caller) : null;
77
78
  if (!workspace) return undefined;
78
79
  return sessions.find(
79
80
  (s) => s.role === "orchestrator" && s.workspace_id === workspace
package/lib/busy.ts CHANGED
@@ -2,12 +2,14 @@ import { db } from "./db";
2
2
  import { runningStarts } from "./simulator";
3
3
 
4
4
  // What a restart would cut off and have to start over: reviews, task
5
- // setups and simulator starts run in the server. (Chat turns run in their own workers and
5
+ // setups, simulator starts, merges and stack landings run in the server. (Chat turns run in their own workers and
6
6
  // outlive it.) Older than half an hour is stuck, not in flight.
7
7
  export function inFlight(): {
8
8
  reviews: number;
9
9
  setups: number;
10
10
  simulatorStarts: number;
11
+ merges: number;
12
+ landings: number;
11
13
  } {
12
14
  const count = (sql: string) => (db.prepare(sql).get() as { n: number }).n;
13
15
  return {
@@ -22,5 +24,25 @@ export function inFlight(): {
22
24
  AND created_at > datetime('now', '-30 minutes')`
23
25
  ),
24
26
  simulatorStarts: runningStarts(),
27
+ merges: mergesInFlight(),
28
+ landings: count(
29
+ `SELECT COUNT(*) AS n FROM stacks WHERE status = 'landing'`
30
+ ),
25
31
  };
26
32
  }
33
+
34
+ // Between `gh pr merge` and the tidying after it (lib/tasks/finish.ts keeps
35
+ // both on globalThis, so every bundle sees the same ones).
36
+ function mergesInFlight(): number {
37
+ const g = globalThis as unknown as {
38
+ __agentosSigningOff?: Set<string>;
39
+ __agentosAfterMerge?: Map<string, unknown>;
40
+ };
41
+ return new Set([
42
+ ...(g.__agentosSigningOff ?? []),
43
+ ...(g.__agentosAfterMerge?.keys() ?? []),
44
+ ]).size;
45
+ }
46
+
47
+ export const inFlightTotal = (w: ReturnType<typeof inFlight>): number =>
48
+ w.reviews + w.setups + w.simulatorStarts + w.merges + w.landings;
@@ -37,13 +37,16 @@ export function escapeInterrupts(
37
37
  }
38
38
 
39
39
  // What Esc does in a chat: set the composer's suggestion aside, stop the
40
- // turn, or nothing (something on top takes it, or it isn't a bare Esc).
40
+ // turn, or, with nothing else to do, leave the composer so the bare-key
41
+ // shortcuts (g i) work; nothing when something on top takes it or it isn't
42
+ // a bare Esc.
41
43
  export function escapeAction(
42
44
  e: EscapeKey,
43
45
  running: boolean,
44
46
  overlayOpen: boolean,
45
- ghostShown: boolean
46
- ): "dismiss" | "interrupt" | null {
47
+ ghostShown: boolean,
48
+ inComposer = false
49
+ ): "dismiss" | "interrupt" | "blur" | null {
47
50
  const bare =
48
51
  e.key === "Escape" &&
49
52
  !e.repeat &&
@@ -51,5 +54,6 @@ export function escapeAction(
51
54
  !(e.metaKey || e.ctrlKey || e.altKey || e.shiftKey);
52
55
  if (!bare || overlayOpen) return null;
53
56
  if (ghostShown) return "dismiss";
54
- return running ? "interrupt" : null;
57
+ if (running) return "interrupt";
58
+ return inComposer ? "blur" : null;
55
59
  }
@@ -0,0 +1,41 @@
1
+ import { NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { readCapped } from "../security/read-capped";
4
+ import type { ChatViewInput } from "./types";
5
+
6
+ const MAX_BODY_BYTES = 2048;
7
+
8
+ const position = z.object({
9
+ itemId: z.string().min(1).max(200).nullable(),
10
+ offsetPx: z.number().int().min(-100000).max(100000),
11
+ atEnd: z.boolean(),
12
+ // False keeps `viewed_at` as it was: the reader left with newer items below
13
+ // them that they haven't seen.
14
+ advance: z.boolean().optional(),
15
+ });
16
+ const body = z.union([position, z.object({ viewed: z.literal(true) })]);
17
+
18
+ /** The PUT body: a position to save, `null` for "opened just now", or the
19
+ * response that refuses it. */
20
+ export async function readViewBody(
21
+ request: Request
22
+ ): Promise<{ input: ChatViewInput | null } | NextResponse> {
23
+ const raw = await readCapped(request, MAX_BODY_BYTES);
24
+ if (raw === null)
25
+ return NextResponse.json({ error: "Too large" }, { status: 413 });
26
+ let json: unknown = null;
27
+ try {
28
+ json = JSON.parse(raw);
29
+ } catch {
30
+ // Not JSON: refused below.
31
+ }
32
+ const parsed = body.safeParse(json);
33
+ if (!parsed.success)
34
+ return NextResponse.json({ error: "Bad request" }, { status: 400 });
35
+ return {
36
+ input:
37
+ "viewed" in parsed.data
38
+ ? null
39
+ : { ...parsed.data, advance: parsed.data.advance !== false },
40
+ };
41
+ }
@@ -0,0 +1,67 @@
1
+ import { db } from "../db";
2
+ import { MAX_VIEWS, type ChatView, type ChatViewInput } from "./types";
3
+
4
+ interface Row {
5
+ session_id: string;
6
+ item_id: string | null;
7
+ offset_px: number;
8
+ at_end: number;
9
+ viewed_at: number;
10
+ }
11
+
12
+ const toView = (r: Row): ChatView => ({
13
+ sessionId: r.session_id,
14
+ itemId: r.item_id,
15
+ offsetPx: r.offset_px,
16
+ atEnd: r.at_end === 1,
17
+ viewedAt: r.viewed_at,
18
+ });
19
+
20
+ /** The most recently viewed chats first. */
21
+ export function listChatViews(limit = MAX_VIEWS): ChatView[] {
22
+ return (
23
+ db
24
+ .prepare(`SELECT * FROM chat_views ORDER BY viewed_at DESC LIMIT ?`)
25
+ .all(limit) as Row[]
26
+ ).map(toView);
27
+ }
28
+
29
+ /** Saves where the reader is. Without a position it only marks the chat as
30
+ * viewed now (opening it), keeping what was saved. */
31
+ export function saveChatView(
32
+ sessionId: string,
33
+ input: ChatViewInput | null,
34
+ now = Date.now()
35
+ ): ChatView {
36
+ if (input) {
37
+ db.prepare(
38
+ `INSERT INTO chat_views (session_id, item_id, offset_px, at_end, viewed_at)
39
+ VALUES (?, ?, ?, ?, ?)
40
+ ON CONFLICT(session_id) DO UPDATE SET item_id = excluded.item_id,
41
+ offset_px = excluded.offset_px, at_end = excluded.at_end,
42
+ viewed_at = CASE WHEN ? = 1 THEN excluded.viewed_at
43
+ ELSE chat_views.viewed_at END`
44
+ ).run(
45
+ sessionId,
46
+ input.itemId,
47
+ input.offsetPx,
48
+ input.atEnd ? 1 : 0,
49
+ now,
50
+ input.advance ? 1 : 0
51
+ );
52
+ } else {
53
+ db.prepare(
54
+ `INSERT INTO chat_views (session_id, viewed_at) VALUES (?, ?)
55
+ ON CONFLICT(session_id) DO UPDATE SET viewed_at = excluded.viewed_at`
56
+ ).run(sessionId, now);
57
+ }
58
+ return toView(
59
+ db
60
+ .prepare(`SELECT * FROM chat_views WHERE session_id = ?`)
61
+ .get(sessionId) as Row
62
+ );
63
+ }
64
+
65
+ export function deleteChatView(sessionId: string): void {
66
+ db.prepare(`DELETE FROM chat_views WHERE session_id = ?`).run(sessionId);
67
+ }
@@ -0,0 +1,20 @@
1
+ /** Where a chat was last being read: the first row in view, how far down it
2
+ * sat, and whether the reader was following the latest output. */
3
+ export interface ChatView {
4
+ sessionId: string;
5
+ itemId: string | null;
6
+ offsetPx: number;
7
+ atEnd: boolean;
8
+ /** Epoch ms of the last time the reader had this chat open. */
9
+ viewedAt: number;
10
+ }
11
+
12
+ export interface ChatViewInput {
13
+ itemId: string | null;
14
+ offsetPx: number;
15
+ atEnd: boolean;
16
+ /** Whether this counts as having seen everything up to now. */
17
+ advance: boolean;
18
+ }
19
+
20
+ export const MAX_VIEWS = 500;
@@ -0,0 +1,30 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261031T1500_chat_views",
5
+ up: (db) => {
6
+ // Where each chat was last being read, for the person, on any device. No
7
+ // FK to sessions, like chat_bookmarks; a trigger drops a deleted chat's row.
8
+ // viewed_at is epoch milliseconds, the unit chat items carry.
9
+ db.exec(`
10
+ CREATE TABLE IF NOT EXISTS chat_views (
11
+ session_id TEXT PRIMARY KEY,
12
+ item_id TEXT,
13
+ offset_px INTEGER NOT NULL DEFAULT 0,
14
+ at_end INTEGER NOT NULL DEFAULT 1,
15
+ viewed_at INTEGER NOT NULL
16
+ )
17
+ `);
18
+ db.exec(
19
+ `CREATE INDEX IF NOT EXISTS idx_chat_views_viewed
20
+ ON chat_views(viewed_at DESC)`
21
+ );
22
+ db.exec(`
23
+ CREATE TRIGGER IF NOT EXISTS chat_views_session_deleted
24
+ AFTER DELETE ON sessions
25
+ BEGIN
26
+ DELETE FROM chat_views WHERE session_id = OLD.id;
27
+ END
28
+ `);
29
+ },
30
+ } satisfies Migration;