@saadnvd1/agent-os 0.3.799 → 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 (101) hide show
  1. package/app/api/chat-views/[sessionId]/route.ts +19 -0
  2. package/app/api/chat-views/route.test.ts +79 -0
  3. package/app/api/chat-views/route.ts +8 -0
  4. package/app/page.tsx +17 -14
  5. package/components/Chat/ChatPanel.tsx +46 -59
  6. package/components/Chat/NewDivider.tsx +15 -0
  7. package/components/Chat/session-link.ts +7 -4
  8. package/components/Chat/useFind.ts +50 -1
  9. package/components/Chat/usePosition.ts +363 -0
  10. package/components/CodeSearchDialog.tsx +90 -0
  11. package/components/CommandPalette/CommandsBody.tsx +12 -4
  12. package/components/CommandPalette/useAppCommands.ts +29 -11
  13. package/components/DevServers/ServerLogsModal.tsx +24 -21
  14. package/components/Nav/HistoryButtons.tsx +128 -0
  15. package/components/Pane/MobileTabBar.tsx +3 -0
  16. package/components/Pane/index.tsx +2 -2
  17. package/components/SessionList/useSessionSwitching.ts +3 -2
  18. package/components/Shortcuts/PendingKeys.tsx +19 -0
  19. package/components/Shortcuts/ShortcutsButton.tsx +22 -0
  20. package/components/Shortcuts/ShortcutsDialog.tsx +122 -0
  21. package/components/SidebarFooter.tsx +2 -0
  22. package/components/Terminal/hooks/useTerminalConnection.ts +4 -3
  23. package/components/views/DesktopView.tsx +7 -9
  24. package/components/views/MobileView.tsx +2 -8
  25. package/components/views/lazy.ts +2 -2
  26. package/content/tips.json +146 -61
  27. package/content/whats-new.json +32 -0
  28. package/data/chat-views/index.ts +2 -0
  29. package/data/chat-views/keys.ts +3 -0
  30. package/data/chat-views/queries.ts +34 -0
  31. package/data/sessions/pin.ts +0 -1
  32. package/hooks/useChatViewsSync.ts +12 -0
  33. package/hooks/useNavActions.ts +104 -0
  34. package/hooks/useNavKeys.ts +105 -0
  35. package/hooks/useSessionUrl.ts +23 -5
  36. package/lib/bus/index.ts +4 -2
  37. package/lib/bus/role-alias.ts +4 -2
  38. package/lib/bus/targets.ts +2 -1
  39. package/lib/chat/escape.ts +8 -4
  40. package/lib/chat-views/body.ts +41 -0
  41. package/lib/chat-views/store.ts +67 -0
  42. package/lib/chat-views/types.ts +20 -0
  43. package/lib/db/migrations/20261031T1500_chat_views.ts +30 -0
  44. package/lib/keybindings/focus.ts +17 -0
  45. package/lib/keybindings/keymap.ts +120 -0
  46. package/lib/keybindings/sequence.ts +64 -0
  47. package/lib/live/service.ts +1 -1
  48. package/lib/nav/history-mirror.ts +59 -0
  49. package/lib/nav/history-writer.ts +109 -0
  50. package/lib/nav/position.ts +67 -0
  51. package/lib/nav/recent.ts +34 -0
  52. package/lib/orchestrator/armed.ts +5 -0
  53. package/lib/palette/sections.ts +12 -6
  54. package/lib/session-url.ts +11 -5
  55. package/package.json +1 -1
  56. package/public/tips/chat-access-picker-dark.webp +0 -0
  57. package/public/tips/chat-access-picker.webp +0 -0
  58. package/public/tips/chat-attach-files-poster.webp +0 -0
  59. package/public/tips/chat-attach-files.mp4 +0 -0
  60. package/public/tips/chat-bookmark-dark.webp +0 -0
  61. package/public/tips/chat-bookmark.webp +0 -0
  62. package/public/tips/chat-plan-mode-dark.webp +0 -0
  63. package/public/tips/chat-plan-mode.webp +0 -0
  64. package/public/tips/chat-search-poster.webp +0 -0
  65. package/public/tips/chat-search.mp4 +0 -0
  66. package/public/tips/chat-tab-suggestion-poster.webp +0 -0
  67. package/public/tips/chat-tab-suggestion.mp4 +0 -0
  68. package/public/tips/chat-terminal-handoff-poster.webp +0 -0
  69. package/public/tips/chat-terminal-handoff.mp4 +0 -0
  70. package/public/tips/chat-undo-dark.webp +0 -0
  71. package/public/tips/chat-undo.webp +0 -0
  72. package/public/tips/export-ai-dark.webp +0 -0
  73. package/public/tips/export-ai.webp +0 -0
  74. package/public/tips/net-messages-dark.webp +0 -0
  75. package/public/tips/net-messages.webp +0 -0
  76. package/public/tips/recipe-save-dark.webp +0 -0
  77. package/public/tips/recipe-save.webp +0 -0
  78. package/public/tips/schedule-first-dark.webp +0 -0
  79. package/public/tips/schedule-first.webp +0 -0
  80. package/public/tips/share-check-dark.webp +0 -0
  81. package/public/tips/share-check.webp +0 -0
  82. package/public/tips/sidebar-draft-poster.webp +0 -0
  83. package/public/tips/sidebar-draft.mp4 +0 -0
  84. package/public/tips/sidebar-groups-dark.webp +0 -0
  85. package/public/tips/sidebar-groups.webp +0 -0
  86. package/public/tips/sidebar-load-dark.webp +0 -0
  87. package/public/tips/sidebar-load.webp +0 -0
  88. package/public/tips/sidebar-snooze-dark.webp +0 -0
  89. package/public/tips/sidebar-snooze.webp +0 -0
  90. package/public/tips/tasks-plan-pieces-dark.webp +0 -0
  91. package/public/tips/tasks-plan-pieces.webp +0 -0
  92. package/public/tips/tasks-sign-off-dark.webp +0 -0
  93. package/public/tips/tasks-sign-off.webp +0 -0
  94. package/public/tips/tasks-stacks-dark.webp +0 -0
  95. package/public/tips/tasks-stacks.webp +0 -0
  96. package/scripts/check +2 -2
  97. package/stores/chatJump.ts +2 -6
  98. package/stores/chatPositions.ts +65 -0
  99. package/stores/navHistory.ts +98 -0
  100. package/stores/shortcutsUi.ts +20 -0
  101. package/components/QuickSwitcher.tsx +0 -286
@@ -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
@@ -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;
@@ -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
+ });