@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
@@ -26,6 +26,11 @@ export type { Migration } from "./migration-types";
26
26
  // typecheck, lint and test) and is not committed. A duplicate id or key, or a
27
27
  // file whose name does not match its id, throws when this module loads.
28
28
  // lib/db/migrations.test.ts enforces the rest.
29
+ //
30
+ // A migration must leave the previous release runnable: an automatic update
31
+ // that isn't healthy rolls the code back but not the database (chat workers
32
+ // keep writing through the restart, so a restore would drop their work). Add
33
+ // columns and tables; don't rename or drop what the last release reads.
29
34
 
30
35
  export const migrationKey = (m: Migration): string => m.key ?? String(m.id);
31
36
  const migrationName = (m: Migration): string =>
@@ -93,6 +98,22 @@ function compareToUpTo(m: Migration, upTo: number | string): number {
93
98
  // All migrations in order - never modify existing ones, only add new
94
99
  export const migrations: Migration[] = buildRegistry(migrationFiles);
95
100
 
101
+ /** How many of this build's migrations the database has; the update health check reads it. */
102
+ export function migrationStatus(db: Database.Database): {
103
+ known: number;
104
+ applied: number;
105
+ } {
106
+ const rows = db.prepare(`SELECT id, key FROM _migrations`).all() as {
107
+ id: number;
108
+ key: string | null;
109
+ }[];
110
+ const have = new Set(rows.map((r) => r.key ?? String(r.id)));
111
+ return {
112
+ known: migrations.length,
113
+ applied: migrations.filter((m) => have.has(migrationKey(m))).length,
114
+ };
115
+ }
116
+
96
117
  // `upTo`: stop after this legacy id or timestamp key, inclusive (tests that
97
118
  // start from an older database).
98
119
  export function runMigrations(
@@ -27,3 +27,20 @@ export function fieldOwnsKey(target: EventTarget | null): boolean {
27
27
  if (el.isContentEditable) return (el.textContent ?? "").trim().length > 0;
28
28
  return false;
29
29
  }
30
+
31
+ // Whether a bare letter is free for the app: nothing is being typed into, no
32
+ // terminal or dialog holds the key, and no menu is open.
33
+ export function isFreeTarget(target: EventTarget | null): boolean {
34
+ const el = target as HTMLElement | null;
35
+ if (!el || typeof el.closest !== "function") return true;
36
+ if (el.closest(".xterm")) return false;
37
+ if (el.closest('[role="dialog"],[role="menu"],[role="listbox"]'))
38
+ return false;
39
+ const tag = el.tagName;
40
+ if (tag === "TEXTAREA" || tag === "SELECT") return false;
41
+ if (tag === "INPUT") return NON_TEXT.has((el as HTMLInputElement).type);
42
+ return !(
43
+ el.isContentEditable ||
44
+ el.closest('[contenteditable]:not([contenteditable="false"])')
45
+ );
46
+ }
@@ -0,0 +1,120 @@
1
+ // Every shortcut the app has, in one table: the keys that act on it, the
2
+ // shortcuts panel and the palette hints all read this. A `keys` string with
3
+ // a space is a sequence (press g, then i); without one it is a chord.
4
+
5
+ export type KeyGroup = "Go to" | "Chats" | "General" | "Browser";
6
+
7
+ export interface KeyEntry {
8
+ id: string;
9
+ keys: string;
10
+ label: string;
11
+ group: KeyGroup;
12
+ // A bare key or sequence: only while nothing is being typed, and off with
13
+ // the single-key switch.
14
+ single?: boolean;
15
+ // The browser acts on it, not the app: listed so it can be found.
16
+ browser?: boolean;
17
+ }
18
+
19
+ export const KEYMAP: KeyEntry[] = [
20
+ { id: "go.inbox", keys: "g i", label: "Inbox", group: "Go to", single: true },
21
+ {
22
+ id: "go.bookmarks",
23
+ keys: "g b",
24
+ label: "Bookmarks",
25
+ group: "Go to",
26
+ single: true,
27
+ },
28
+ {
29
+ id: "go.messages",
30
+ keys: "g m",
31
+ label: "Messages",
32
+ group: "Go to",
33
+ single: true,
34
+ },
35
+ {
36
+ id: "go.settings",
37
+ keys: "g s",
38
+ label: "Settings",
39
+ group: "Go to",
40
+ single: true,
41
+ },
42
+ {
43
+ id: "chat.previous",
44
+ keys: "g p",
45
+ label: "Previous chat (flip back and forth)",
46
+ group: "Chats",
47
+ single: true,
48
+ },
49
+ {
50
+ id: "chat.open",
51
+ keys: "o c",
52
+ label: "Open a chat",
53
+ group: "Chats",
54
+ single: true,
55
+ },
56
+ {
57
+ id: "chat.new",
58
+ keys: "c",
59
+ label: "New chat",
60
+ group: "Chats",
61
+ single: true,
62
+ },
63
+ { id: "switcher.open", keys: "mod+p", label: "Switch chat", group: "Chats" },
64
+ {
65
+ id: "session.previous",
66
+ keys: "mod+up",
67
+ label: "Chat above in the sidebar",
68
+ group: "Chats",
69
+ },
70
+ {
71
+ id: "session.next",
72
+ keys: "mod+down",
73
+ label: "Chat below in the sidebar",
74
+ group: "Chats",
75
+ },
76
+ {
77
+ id: "palette.open",
78
+ keys: "mod+k",
79
+ label: "Command palette",
80
+ group: "General",
81
+ },
82
+ {
83
+ id: "help.open",
84
+ keys: "?",
85
+ label: "Keyboard shortcuts",
86
+ group: "General",
87
+ single: true,
88
+ },
89
+ {
90
+ id: "history.back",
91
+ keys: "mod+[",
92
+ label: "Back to the chat you came from",
93
+ group: "Browser",
94
+ browser: true,
95
+ },
96
+ {
97
+ id: "history.forward",
98
+ keys: "mod+]",
99
+ label: "Forward",
100
+ group: "Browser",
101
+ browser: true,
102
+ },
103
+ ];
104
+
105
+ export const keysOf = (id: string): string => {
106
+ const e = KEYMAP.find((k) => k.id === id);
107
+ if (!e) throw new Error(`No shortcut "${id}"`);
108
+ return e.keys;
109
+ };
110
+
111
+ export const isSequence = (keys: string) => keys.includes(" ");
112
+
113
+ /** The table's entries a search finds, in table order; no search finds all. */
114
+ export function searchKeymap(query: string): KeyEntry[] {
115
+ const q = query.trim().toLowerCase();
116
+ if (!q) return KEYMAP;
117
+ return KEYMAP.filter((k) =>
118
+ `${k.label} ${k.keys} ${k.group}`.toLowerCase().includes(q)
119
+ );
120
+ }
@@ -0,0 +1,64 @@
1
+ // Two-key sequences (g then i). The matcher is fed every plain key press; it
2
+ // says whether the key started a sequence, finished one, or is nobody's.
3
+
4
+ export const SEQUENCE_MS = 1500;
5
+
6
+ /**
7
+ * The key a bare shortcut sees, or null. The hints print letters in
8
+ * capitals, so Shift+G counts as g; other shifted keys are nobody's but "?".
9
+ */
10
+ export function plainKey(key: string, shift: boolean): string | null {
11
+ if (key === "?" || /^[a-z]$/i.test(key)) return key.toLowerCase();
12
+ return shift ? null : key;
13
+ }
14
+
15
+ export type SequenceStep =
16
+ | { kind: "none" }
17
+ | { kind: "pending"; first: string }
18
+ | { kind: "match"; id: string };
19
+
20
+ export class SequenceMatcher {
21
+ private first: string | null = null;
22
+ private at = 0;
23
+ private starts: Set<string>;
24
+ private pairs: Map<string, string>;
25
+
26
+ /** `sequences` maps "g i" to the id it runs. */
27
+ constructor(
28
+ sequences: Record<string, string>,
29
+ private now: () => number = Date.now
30
+ ) {
31
+ this.pairs = new Map(
32
+ Object.entries(sequences).map(([k, id]) => [k.toLowerCase(), id])
33
+ );
34
+ this.starts = new Set([...this.pairs.keys()].map((k) => k.split(" ")[0]));
35
+ }
36
+
37
+ /** A key was pressed with no modifier but maybe shift. */
38
+ feed(key: string): SequenceStep {
39
+ const k = key.toLowerCase();
40
+ if (this.first !== null && this.now() - this.at <= SEQUENCE_MS) {
41
+ const id = this.pairs.get(`${this.first} ${k}`);
42
+ this.first = null;
43
+ if (id) return { kind: "match", id };
44
+ }
45
+ this.first = null;
46
+ if (this.starts.has(k)) {
47
+ this.first = k;
48
+ this.at = this.now();
49
+ return { kind: "pending", first: k };
50
+ }
51
+ return { kind: "none" };
52
+ }
53
+
54
+ /** Whether the first key of a sequence is waiting for its second. */
55
+ pending(): string | null {
56
+ return this.first !== null && this.now() - this.at <= SEQUENCE_MS
57
+ ? this.first
58
+ : null;
59
+ }
60
+
61
+ reset(): void {
62
+ this.first = null;
63
+ }
64
+ }
@@ -259,7 +259,7 @@ export async function changeCode(
259
259
  id: string,
260
260
  next: { generate: true } | { code: string } | null
261
261
  ): Promise<void> {
262
- const rt = runtimeFor(id);
262
+ runtimeFor(id);
263
263
  let code: string | null = null;
264
264
  if (next && "code" in next) {
265
265
  const why = codeRefusal(next.code);
@@ -0,0 +1,59 @@
1
+ /**
2
+ * What the page remembers of the history entries it made, for the menu behind
3
+ * the back button: the browser won't list its own history. Entries are keyed
4
+ * by the `index` also kept in each entry's state, so a reload or a back
5
+ * button still finds its place.
6
+ */
7
+ export interface MirrorEntry {
8
+ index: number;
9
+ sessionId: string;
10
+ name: string;
11
+ }
12
+
13
+ export interface Mirror {
14
+ entries: MirrorEntry[];
15
+ current: number;
16
+ }
17
+
18
+ export const MAX_MIRROR = 100;
19
+
20
+ export const emptyMirror = (): Mirror => ({ entries: [], current: -1 });
21
+
22
+ /** Standing on an entry that is new (a push) or known (replace or reload):
23
+ * a push drops the forward entries, as the browser does. */
24
+ export function visit(
25
+ m: Mirror,
26
+ entry: MirrorEntry,
27
+ how: "push" | "replace"
28
+ ): Mirror {
29
+ const kept =
30
+ how === "push"
31
+ ? m.entries.filter((e) => e.index < entry.index)
32
+ : m.entries.filter((e) => e.index !== entry.index);
33
+ const entries = [...kept, entry]
34
+ .sort((a, b) => a.index - b.index)
35
+ .slice(-MAX_MIRROR);
36
+ return { entries, current: entry.index };
37
+ }
38
+
39
+ /** Back or forward landed on the entry with this index. */
40
+ export const moveTo = (m: Mirror, index: number): Mirror =>
41
+ m.entries.some((e) => e.index === index) ? { ...m, current: index } : m;
42
+
43
+ export const earlier = (m: Mirror): MirrorEntry[] =>
44
+ m.entries.filter((e) => e.index < m.current).reverse();
45
+
46
+ export const later = (m: Mirror): MirrorEntry[] =>
47
+ m.entries.filter((e) => e.index > m.current);
48
+
49
+ /** A renamed chat reads under its new name in the menu. */
50
+ export const renameIn = (
51
+ m: Mirror,
52
+ sessionId: string,
53
+ name: string
54
+ ): Mirror => ({
55
+ ...m,
56
+ entries: m.entries.map((e) =>
57
+ e.sessionId === sessionId ? { ...e, name } : e
58
+ ),
59
+ });
@@ -0,0 +1,109 @@
1
+ import type { Position } from "./position";
2
+
3
+ /**
4
+ * The one place the page writes browser history. A chat switch or a bookmark
5
+ * pushes an entry; where the reader is in the chat goes into the CURRENT
6
+ * entry's state with replaceState, at most once per POSITION_EVERY_MS, so
7
+ * back lands on the message that was on screen without filling the history
8
+ * (Safari throws after 100 calls in 30 seconds, Firefox throws on a burst,
9
+ * Chrome drops them silently).
10
+ *
11
+ * Next patches pushState/replaceState to carry its own keys in the state it
12
+ * is given, so the state is always the current one with `aos` replaced.
13
+ */
14
+ export interface NavState {
15
+ v: 1;
16
+ /** Counts up with every push; the menu's key for the entry. */
17
+ index: number;
18
+ sessionId: string | null;
19
+ pos?: Position;
20
+ }
21
+
22
+ export interface HistoryLike {
23
+ state: unknown;
24
+ pushState(state: unknown, unused: string, url?: string): void;
25
+ replaceState(state: unknown, unused: string, url?: string): void;
26
+ }
27
+
28
+ export const POSITION_EVERY_MS = 2000;
29
+ // Position writes stop, navigation never does, when a burst gets this close
30
+ // to the limits above.
31
+ const BURST_WINDOW_MS = 30_000;
32
+ const BURST_MAX = 50;
33
+
34
+ export function navStateOf(state: unknown): NavState | null {
35
+ const aos = (state as { aos?: NavState } | null)?.aos;
36
+ return aos && aos.v === 1 && typeof aos.index === "number" ? aos : null;
37
+ }
38
+
39
+ const withNav = (state: unknown, aos: NavState) => ({
40
+ ...(state && typeof state === "object" ? state : {}),
41
+ aos,
42
+ });
43
+
44
+ export class HistoryWriter {
45
+ private pending: { sessionId: string; pos: Position } | null = null;
46
+ private lastWrite = -Infinity;
47
+ private timer: ReturnType<typeof setTimeout> | null = null;
48
+ private writes: number[] = [];
49
+
50
+ constructor(private h: HistoryLike) {}
51
+
52
+ /** The state of the entry the page is on. */
53
+ current(): NavState | null {
54
+ return navStateOf(this.h.state);
55
+ }
56
+
57
+ /** Where the reader is now; written into the current entry, coalesced. */
58
+ setPosition(sessionId: string, pos: Position): void {
59
+ this.pending = { sessionId, pos };
60
+ if (this.timer) return;
61
+ const wait = this.lastWrite + POSITION_EVERY_MS - Date.now();
62
+ if (wait <= 0) this.flush();
63
+ else this.timer = setTimeout(() => this.flush(), wait);
64
+ }
65
+
66
+ /** Writes the waiting position now, if it belongs to the current entry. */
67
+ flush(): void {
68
+ if (this.timer) clearTimeout(this.timer);
69
+ this.timer = null;
70
+ const p = this.pending;
71
+ this.pending = null;
72
+ const cur = this.current();
73
+ if (!p || !cur || cur.sessionId !== p.sessionId) return;
74
+ const now = Date.now();
75
+ this.writes = this.writes.filter((t) => now - t < BURST_WINDOW_MS);
76
+ if (this.writes.length >= BURST_MAX) return;
77
+ this.writes.push(now);
78
+ this.lastWrite = now;
79
+ this.h.replaceState(withNav(this.h.state, { ...cur, pos: p.pos }), "");
80
+ }
81
+
82
+ /** A new entry for a chat. The one being left keeps its position first. */
83
+ push(href: string, sessionId: string | null): NavState {
84
+ this.flush();
85
+ const cur = this.current();
86
+ const next: NavState = {
87
+ v: 1,
88
+ index: (cur?.index ?? 0) + 1,
89
+ sessionId,
90
+ };
91
+ this.h.pushState(withNav(null, next), "", href);
92
+ return next;
93
+ }
94
+
95
+ /** The same entry at another address (a rename), keeping its state. A page
96
+ * that has none yet gets its first, index 0. */
97
+ replace(href: string, sessionId: string | null): NavState {
98
+ const cur = this.current();
99
+ const next: NavState = cur
100
+ ? { ...cur, sessionId }
101
+ : { v: 1, index: 0, sessionId };
102
+ // A different chat takes the entry over: its position was another's.
103
+ if (cur && cur.sessionId !== sessionId) delete next.pos;
104
+ if (this.pending && this.pending.sessionId !== sessionId)
105
+ this.pending = null;
106
+ this.h.replaceState(withNav(this.h.state, next), "", href);
107
+ return next;
108
+ }
109
+ }
@@ -0,0 +1,67 @@
1
+ import type { ChatItem } from "@/lib/chat/events";
2
+ import type { ChatView } from "@/lib/chat-views/types";
3
+
4
+ /** Where a reader is in a chat: the block in view's key, how far its top is
5
+ * from the top of the list, and whether they follow the latest output. */
6
+ export interface Position {
7
+ block: string | null;
8
+ offsetPx: number;
9
+ atEnd: boolean;
10
+ }
11
+
12
+ export interface RowRect {
13
+ key: string;
14
+ top: number;
15
+ bottom: number;
16
+ }
17
+
18
+ /** The first row whose bottom is below the top of the list, and how far its
19
+ * top sits from there (negative when it is partly scrolled off). */
20
+ export function anchorOf(
21
+ listTop: number,
22
+ rows: RowRect[],
23
+ atEnd: boolean
24
+ ): Position {
25
+ if (atEnd) return { block: null, offsetPx: 0, atEnd: true };
26
+ const row = rows.find((r) => r.bottom > listTop + 1);
27
+ if (!row) return { block: null, offsetPx: 0, atEnd: true };
28
+ return {
29
+ block: row.key,
30
+ offsetPx: Math.round(row.top - listTop),
31
+ atEnd: false,
32
+ };
33
+ }
34
+
35
+ export type OpenAt =
36
+ | { kind: "latest" }
37
+ | { kind: "new"; itemId: string; hasBefore: boolean }
38
+ | { kind: "position"; block: string; offsetPx: number };
39
+
40
+ interface OpenInput {
41
+ /** What the server remembers about this chat; none when never opened. */
42
+ view: Pick<ChatView, "itemId" | "offsetPx" | "atEnd" | "viewedAt"> | null;
43
+ /** The loaded items, oldest first. */
44
+ items: Pick<ChatItem, "id" | "createdAt">[];
45
+ /** Opened from a notification, Needs you or the Inbox. */
46
+ latest: boolean;
47
+ }
48
+
49
+ /**
50
+ * Where a chat opens when nothing asked for a particular message:
51
+ * - from a notification, Needs you or the Inbox: the latest;
52
+ * - with items newer than the last time it was open: the first new one;
53
+ * - otherwise where it was left, unless that was the end;
54
+ * - never opened: the latest.
55
+ */
56
+ export function decideOpen({ view, items, latest }: OpenInput): OpenAt {
57
+ if (latest || !view) return { kind: "latest" };
58
+ const firstNew = items.findIndex((i) => i.createdAt > view.viewedAt);
59
+ if (firstNew !== -1)
60
+ return {
61
+ kind: "new",
62
+ itemId: items[firstNew].id,
63
+ hasBefore: firstNew > 0,
64
+ };
65
+ if (view.atEnd || !view.itemId) return { kind: "latest" };
66
+ return { kind: "position", block: view.itemId, offsetPx: view.offsetPx };
67
+ }
@@ -0,0 +1,34 @@
1
+ // Which chat was open before this one, and chats most-recently-opened first.
2
+ // "Opened" is the chat's viewed_at, which the page stamps whenever a chat is
3
+ // shown, so it is the same on every device.
4
+
5
+ /** When each chat was last opened, by session id (epoch ms). */
6
+ export type Opened = Record<string, number | undefined>;
7
+
8
+ /** Most recently opened first; never-opened chats keep their order after. */
9
+ export function recentFirst<T extends { id: string }>(
10
+ items: readonly T[],
11
+ opened: Opened
12
+ ): T[] {
13
+ return items
14
+ .map((item, i) => ({ item, i, at: opened[item.id] ?? -1 }))
15
+ .sort((a, b) => b.at - a.at || a.i - b.i)
16
+ .map((x) => x.item);
17
+ }
18
+
19
+ /** The chat opened just before the current one, among those still open. */
20
+ export function previousChatId(
21
+ ids: readonly string[],
22
+ opened: Opened,
23
+ currentId: string | null | undefined
24
+ ): string | null {
25
+ let best: string | null = null;
26
+ let bestAt = -1;
27
+ for (const id of ids) {
28
+ const at = opened[id];
29
+ if (id === currentId || at === undefined || at <= bestAt) continue;
30
+ best = id;
31
+ bestAt = at;
32
+ }
33
+ return best;
34
+ }
@@ -30,6 +30,7 @@ import { isFast } from "../project-tier/getProjectTier";
30
30
  import { workspaceTask } from "./targets";
31
31
  import { markStatusDirty } from "../status/dirty";
32
32
  import { setPr } from "../db/session-parts";
33
+ import { noteAutoArmed } from "./fast-lane-state";
33
34
 
34
35
  // Judged again at least this often, for what the fingerprint can't see.
35
36
  export const ARMED_RECHECK_MS = 5 * 60 * 1000;
@@ -140,6 +141,10 @@ async function armedNow(workspaceId: string, task: Session): Promise<string> {
140
141
  export function disarm(workspaceId: string, ref: string): string {
141
142
  const task = workspaceTask(workspaceId, ref);
142
143
  setArmed(task.id, false);
144
+ // A fast task not yet auto-armed would be armed on the next pass; mark
145
+ // its PR as handled so the disarm sticks either way.
146
+ if (isFast(task) && task.pr_number)
147
+ noteAutoArmed(task.id, workspaceId, task.pr_number);
143
148
  return `Disarmed ${task.name}: it merges only through sign_off now.`;
144
149
  }
145
150
 
@@ -18,6 +18,8 @@ export interface Section {
18
18
  export const PER_TAB = 5;
19
19
  export const RECENT = 5;
20
20
  const NEEDS_YOU = 5;
21
+ // The hint on the chat on show, which a switcher skips past.
22
+ export const CURRENT = "Current";
21
23
  const SUGGESTED = 6;
22
24
 
23
25
  const label = (tab: PaletteTab) =>
@@ -61,11 +63,10 @@ export function tabItems(
61
63
  usage: Usage,
62
64
  now: number
63
65
  ): PaletteCommand[] {
64
- return scoreCommands(
65
- commands.filter((c) => tabOf(c) === tab),
66
- query,
67
- boostFor(usage, now)
68
- ).map((m) => m.c);
66
+ const inTab = commands.filter((c) => tabOf(c) === tab);
67
+ // Sessions read most-recent-first (the order they were registered in).
68
+ if (tab === "sessions" && !query.trim()) return inTab;
69
+ return scoreCommands(inTab, query, boostFor(usage, now)).map((m) => m.c);
69
70
  }
70
71
 
71
72
  // The short list All opens on: the last things run, sessions that need the
@@ -99,13 +100,18 @@ export function emptySections(
99
100
  .filter((c): c is PaletteCommand => !!c),
100
101
  RECENT
101
102
  );
103
+ const recentChats = take(
104
+ commands.filter((c) => tabOf(c) === "sessions" && c.hint !== CURRENT),
105
+ RECENT
106
+ );
102
107
  const sections: Section[] = [
108
+ { name: "Recent chats", items: recentChats },
103
109
  { name: "Recent", items: recent },
104
110
  { name: "Needs you / running", items: busy },
105
111
  { name: "This session", items: here },
106
112
  ];
107
113
  // A first run has no history: offer the commands that start things.
108
- if (recent.length + busy.length + here.length < 4)
114
+ if (recent.length + busy.length + here.length + recentChats.length < 4)
109
115
  sections.push({
110
116
  name: "Suggested",
111
117
  items: take(
@@ -67,7 +67,9 @@ export function setTimer(
67
67
  if (!isTimezone(timezone)) throw new Error(`Unknown time zone "${timezone}"`);
68
68
  const bad = cronError(cron);
69
69
  if (bad) throw new Error(bad);
70
- if (minGapMinutes(cron, timezone) < MIN_GAP_MINUTES)
70
+ if (
71
+ minGapMinutes(cron, timezone, Date.now(), MIN_GAP_MINUTES) < MIN_GAP_MINUTES
72
+ )
71
73
  throw new Error("A recipe runs at most once an hour");
72
74
  const enabled = input.enabled ?? true;
73
75
  if (!existing) {
@@ -261,16 +261,25 @@ export const MIN_GAP_MINUTES = 60;
261
261
  // A message to a session, at most this often.
262
262
  export const MESSAGE_MIN_GAP_MINUTES = 10;
263
263
 
264
- // The shortest gap between runs over the next few weeks of them.
264
+ // The shortest gap between runs over the next few weeks of them. With
265
+ // `stopBelow`, it returns as soon as a gap under it turns up: "every minute"
266
+ // then costs two searches, not two hundred.
265
267
  export function minGapMinutes(
266
268
  cron: string,
267
269
  timezone: string,
268
- from = Date.now()
270
+ from = Date.now(),
271
+ stopBelow = 0
269
272
  ): number {
270
- const runs = nextRuns(cron, from, 200, timezone);
273
+ const parsed = parseCron(cron);
271
274
  let min = Infinity;
272
- for (let i = 1; i < runs.length; i++)
273
- min = Math.min(min, (runs[i] - runs[i - 1]) / 60_000);
275
+ let prev = nextRun(parsed, from, timezone);
276
+ for (let i = 1; i < 200 && prev !== null; i++) {
277
+ const next = nextRun(parsed, prev, timezone);
278
+ if (next === null) break;
279
+ min = Math.min(min, (next - prev) / 60_000);
280
+ if (min < stopBelow) break;
281
+ prev = next;
282
+ }
274
283
  return min;
275
284
  }
276
285
 
@@ -298,7 +298,7 @@ function validate(
298
298
  if (
299
299
  !oneOff &&
300
300
  (input.kind === "task" || input.kind === "session") &&
301
- minGapMinutes(cron, timezone) < MIN_GAP_MINUTES
301
+ minGapMinutes(cron, timezone, Date.now(), MIN_GAP_MINUTES) < MIN_GAP_MINUTES
302
302
  )
303
303
  throw new Error(
304
304
  "A task or session schedule runs at most once an hour; for more often, post to the orchestrator"
@@ -309,7 +309,8 @@ function validate(
309
309
  if (
310
310
  !oneOff &&
311
311
  (message || agentCheckIn) &&
312
- minGapMinutes(cron, timezone) < MESSAGE_MIN_GAP_MINUTES
312
+ minGapMinutes(cron, timezone, Date.now(), MESSAGE_MIN_GAP_MINUTES) <
313
+ MESSAGE_MIN_GAP_MINUTES
313
314
  )
314
315
  throw new Error(
315
316
  `A message schedule runs at most every ${MESSAGE_MIN_GAP_MINUTES} minutes`
@@ -150,6 +150,8 @@ const READS = [
150
150
  "demo",
151
151
  // The changelog: a static file, no agent.
152
152
  "whats-new",
153
+ // Tip of the Day: reading is a static file plus two settings.
154
+ "tips",
153
155
  ].map(route);
154
156
 
155
157
  // The only writes, each a database write and nothing more in demo mode: