@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
@@ -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
+ }
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),
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { StepCounts } from "@/lib/chat/tool-outcome";
4
4
  import {
5
+ Fragment,
5
6
  memo,
6
7
  useCallback,
7
8
  useEffect,
@@ -52,7 +53,7 @@ import { RowState, RowStore } from "./rowState";
52
53
  import { useKeybindings } from "@/hooks/useKeybindings";
53
54
  import { Background, type BackgroundValue } from "./background";
54
55
  import { FindBar } from "./FindBar";
55
- import { FIND_EVENT, isFindKey, useFind } from "./useFind";
56
+ import { useFind, useFindShortcut } from "./useFind";
56
57
  import { McpServers } from "./McpServers";
57
58
  import { ChatSessionId } from "./session";
58
59
  import { ArtifactCard } from "./Artifact";
@@ -65,6 +66,8 @@ import { UserTurn } from "./EventRow";
65
66
  import { EventGroup } from "./EventTurns";
66
67
  import { needsMoreHistory } from "./needs-more-history";
67
68
  import { useGoToItem } from "./useGoToItem";
69
+ import { NewDivider } from "./NewDivider";
70
+ import { usePosition } from "./usePosition";
68
71
  import { NeedsYouStrip } from "@/components/Orchestrator/NeedsYouStrip";
69
72
  import { useResolveQuestion } from "@/data/orchestrators";
70
73
  import { needsYouFocus, needsYouFocusActions } from "@/stores/needsYouFocus";
@@ -80,7 +83,6 @@ import {
80
83
  TurnStarts,
81
84
  type BookmarkChat,
82
85
  } from "@/components/Bookmarks/context";
83
- import { bookmarksUi, bookmarksUiActions } from "@/stores/bookmarksUi";
84
86
  import {
85
87
  AssistantMessage,
86
88
  CommandOutput,
@@ -236,18 +238,23 @@ const Block = memo(
236
238
  export function TimelineRows({
237
239
  blocks,
238
240
  actions,
241
+ newFrom,
239
242
  }: {
240
243
  blocks: TimelineBlock[];
241
244
  actions: ItemActions;
245
+ // The block the reader hasn't seen yet begins at, when they were away.
246
+ newFrom?: string | null;
242
247
  }) {
243
248
  return blocks.map((b) => (
244
- <div
245
- key={blockKey(b)}
246
- data-block={blockKey(b)}
247
- className="chat-row mx-auto w-full max-w-3xl px-4 pb-4"
248
- >
249
- <Block block={b} actions={actions} />
250
- </div>
249
+ <Fragment key={blockKey(b)}>
250
+ {newFrom === blockKey(b) && <NewDivider />}
251
+ <div
252
+ data-block={blockKey(b)}
253
+ className="chat-row mx-auto w-full max-w-3xl px-4 pb-4"
254
+ >
255
+ <Block block={b} actions={actions} />
256
+ </div>
257
+ </Fragment>
251
258
  ));
252
259
  }
253
260
 
@@ -368,9 +375,17 @@ export function ChatPanel({
368
375
  if ((e.target as Element).closest?.("[data-find-bar]")) return;
369
376
  const overlay = !!document.querySelector(OVERLAY_SELECTOR);
370
377
  const ghost = !!root.querySelector(GHOST_SELECTOR);
371
- const action = escapeAction(e, escape.current.running, overlay, ghost);
378
+ const composer = (e.target as Element).closest?.(".chat-composer");
379
+ const action = escapeAction(
380
+ e,
381
+ escape.current.running,
382
+ overlay,
383
+ ghost,
384
+ !!composer
385
+ );
372
386
  if (action === "interrupt") escape.current.interrupt();
373
387
  else if (action === "dismiss") setDismissed(escape.current.suggestion);
388
+ else if (action === "blur") (e.target as HTMLElement).blur?.();
374
389
  };
375
390
  window.addEventListener("keydown", onKey, true);
376
391
  return () => window.removeEventListener("keydown", onKey, true);
@@ -511,11 +526,7 @@ export function ChatPanel({
511
526
  copySelection(e, itemsByIdRef.current),
512
527
  []
513
528
  );
514
- // Starts at the end; older pages load as the reader nears the top.
515
- useLayoutEffect(() => {
516
- const el = listRef.current;
517
- if (el) el.scrollTop = el.scrollHeight;
518
- }, [sessionId]);
529
+ // Older pages load as the reader nears the top.
519
530
  const olderRef = useRef({ hasMore, loadingOlder, loadOlder });
520
531
  useEffect(() => {
521
532
  olderRef.current = { hasMore, loadingOlder, loadOlder };
@@ -590,17 +601,20 @@ export function ChatPanel({
590
601
  () => ({ sessionId, turnStarts: turnStartStore }),
591
602
  [sessionId, turnStartStore]
592
603
  );
593
- // A bookmark opened from a list: once this chat is the one showing.
594
- const { jump } = useSnapshot(bookmarksUi);
595
- useEffect(() => {
596
- // Waits for this chat's first page: until it lands, the items are
597
- // the last chat's, and every jump would miss.
598
- if (!jump || jump.sessionId !== sessionId || loadedFor !== sessionId)
599
- return;
600
- const itemId = bookmarksUiActions.takeJump(sessionId);
601
- if (itemId)
602
- goTo((all) => (all.some((i) => i.id === itemId) ? itemId : null));
603
- }, [jump, sessionId, goTo, loadedFor]);
604
+ const { newFrom } = usePosition({
605
+ sessionId,
606
+ active,
607
+ loadedFor,
608
+ items,
609
+ hasMore,
610
+ loadingOlder,
611
+ loadOlder,
612
+ listRef,
613
+ release,
614
+ toEnd,
615
+ foldEvents: !!orchestratorOf,
616
+ goTo,
617
+ });
604
618
  const resolveQuestion = useResolveQuestion(orchestratorOf ?? "");
605
619
  // The question the next message answers, resolved when it is sent.
606
620
  const [replyTo, setReplyTo] = useState<{ id: number; text: string } | null>(
@@ -626,39 +640,8 @@ export function ChatPanel({
626
640
  scrollToBlock,
627
641
  scroller: useCallback(() => listRef.current, []),
628
642
  });
629
- const activeRef = useRef(active);
630
- useEffect(() => {
631
- activeRef.current = active;
632
- });
633
- // ⌘F (Ctrl+F off a Mac) in this chat, or the phone header's search
634
- // button, opens the find bar; pressed again, the field takes the caret.
635
- const [findFocus, setFindFocus] = useState(0);
636
- const showFind = find.show;
643
+ const findFocus = useFindShortcut(sessionId, rootRef, active, find.show);
637
644
  const closeFind = find.close;
638
- useEffect(() => {
639
- const open = () => {
640
- showFind();
641
- setFindFocus((n) => n + 1);
642
- };
643
- const onKey = (e: KeyboardEvent) => {
644
- const root = rootRef.current;
645
- const here =
646
- root?.contains(e.target as Node) ||
647
- (activeRef.current && e.target === document.body);
648
- if (!here || !isFindKey(e)) return;
649
- e.preventDefault();
650
- open();
651
- };
652
- const onFind = (e: Event) => {
653
- if ((e as CustomEvent<string>).detail === sessionId) open();
654
- };
655
- window.addEventListener("keydown", onKey, true);
656
- window.addEventListener(FIND_EVENT, onFind);
657
- return () => {
658
- window.removeEventListener("keydown", onKey, true);
659
- window.removeEventListener(FIND_EVENT, onFind);
660
- };
661
- }, [sessionId, showFind]);
662
645
  // Another conversation starts without the last one's find.
663
646
  useEffect(() => closeFind, [sessionId, closeFind]);
664
647
  const idle = !running && connected;
@@ -728,7 +711,11 @@ export function ChatPanel({
728
711
  <ChatSessionId.Provider value={sessionId}>
729
712
  <ChatIdle.Provider value={idle}>
730
713
  <RetryTurn.Provider value={retryTurn}>
731
- <TimelineRows blocks={blocks} actions={actions} />
714
+ <TimelineRows
715
+ blocks={blocks}
716
+ actions={actions}
717
+ newFrom={newFrom}
718
+ />
732
719
  </RetryTurn.Provider>
733
720
  </ChatIdle.Provider>
734
721
  </ChatSessionId.Provider>
@@ -0,0 +1,15 @@
1
+ // "New" above the first thing that arrived while the reader was away.
2
+ export function NewDivider() {
3
+ return (
4
+ <div
5
+ data-new-divider
6
+ role="separator"
7
+ aria-label="New messages"
8
+ className="text-primary mx-auto flex w-full max-w-3xl items-center gap-3 px-4 pb-3 text-xs font-medium"
9
+ >
10
+ <span className="bg-primary/40 h-px flex-1" />
11
+ New
12
+ <span className="bg-primary/40 h-px flex-1" />
13
+ </div>
14
+ );
15
+ }
@@ -1,4 +1,5 @@
1
1
  import type { MouseEvent } from "react";
2
+ import { navHistoryActions } from "@/stores/navHistory";
2
3
  import {
3
4
  hrefWithRef,
4
5
  hrefWithSession,
@@ -8,7 +9,7 @@ import {
8
9
  // Opens a session through its address, the way a shared link does. The
9
10
  // address it lands on is rewritten to the session's readable one.
10
11
  export function openSessionById(id: string): void {
11
- go(hrefWithSession(location.href, { id }));
12
+ go(hrefWithSession(location.href, { id }), id);
12
13
  }
13
14
 
14
15
  export function openSessionLink(
@@ -18,10 +19,12 @@ export function openSessionLink(
18
19
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
19
20
  e.preventDefault();
20
21
  if (typeof target === "string") openSessionById(target);
21
- else go(hrefWithRef(location.href, target));
22
+ else
23
+ go(hrefWithRef(location.href, target), "id" in target ? target.id : null);
22
24
  }
23
25
 
24
- function go(href: string): void {
25
- window.history.pushState(null, "", href);
26
+ // A link is a chat switch: it gets a history entry, then opens as an address.
27
+ export function go(href: string, sessionId: string | null): void {
28
+ navHistoryActions.push(href, sessionId, "Chat");
26
29
  window.dispatchEvent(new PopStateEvent("popstate"));
27
30
  }
@@ -1,6 +1,13 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import {
4
+ type RefObject,
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from "react";
4
11
  import type { ChatItem } from "@/lib/chat/events";
5
12
  import {
6
13
  flattenHits,
@@ -253,3 +260,45 @@ export function useFind(deps: FindDeps) {
253
260
  previous: useCallback(() => go(-1), [go]),
254
261
  };
255
262
  }
263
+
264
+ /**
265
+ * ⌘F (Ctrl+F off a Mac) in this chat, or the phone header's search button,
266
+ * opens the find bar; pressed again, the field takes the caret. Returns a
267
+ * counter that changes each time, for the bar to focus on.
268
+ */
269
+ export function useFindShortcut(
270
+ sessionId: string,
271
+ rootRef: RefObject<HTMLElement | null>,
272
+ active: boolean,
273
+ show: () => void
274
+ ): number {
275
+ const [focus, setFocus] = useState(0);
276
+ const activeRef = useRef(active);
277
+ useEffect(() => {
278
+ activeRef.current = active;
279
+ });
280
+ useEffect(() => {
281
+ const open = () => {
282
+ show();
283
+ setFocus((n) => n + 1);
284
+ };
285
+ const onKey = (e: KeyboardEvent) => {
286
+ const here =
287
+ rootRef.current?.contains(e.target as Node) ||
288
+ (activeRef.current && e.target === document.body);
289
+ if (!here || !isFindKey(e)) return;
290
+ e.preventDefault();
291
+ open();
292
+ };
293
+ const onFind = (e: Event) => {
294
+ if ((e as CustomEvent<string>).detail === sessionId) open();
295
+ };
296
+ window.addEventListener("keydown", onKey, true);
297
+ window.addEventListener(FIND_EVENT, onFind);
298
+ return () => {
299
+ window.removeEventListener("keydown", onKey, true);
300
+ window.removeEventListener(FIND_EVENT, onFind);
301
+ };
302
+ }, [sessionId, show, rootRef]);
303
+ return focus;
304
+ }