@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.
- package/app/api/chat-views/[sessionId]/route.ts +19 -0
- package/app/api/chat-views/route.test.ts +79 -0
- package/app/api/chat-views/route.ts +8 -0
- package/app/page.tsx +17 -14
- package/components/Chat/ChatPanel.tsx +46 -59
- package/components/Chat/NewDivider.tsx +15 -0
- package/components/Chat/session-link.ts +7 -4
- package/components/Chat/useFind.ts +50 -1
- package/components/Chat/usePosition.ts +363 -0
- package/components/CodeSearchDialog.tsx +90 -0
- package/components/CommandPalette/CommandsBody.tsx +12 -4
- package/components/CommandPalette/useAppCommands.ts +29 -11
- package/components/DevServers/ServerLogsModal.tsx +24 -21
- package/components/Nav/HistoryButtons.tsx +128 -0
- package/components/Pane/MobileTabBar.tsx +3 -0
- package/components/Pane/index.tsx +2 -2
- package/components/SessionList/useSessionSwitching.ts +3 -2
- package/components/Shortcuts/PendingKeys.tsx +19 -0
- package/components/Shortcuts/ShortcutsButton.tsx +22 -0
- package/components/Shortcuts/ShortcutsDialog.tsx +122 -0
- package/components/SidebarFooter.tsx +2 -0
- package/components/Terminal/hooks/useTerminalConnection.ts +4 -3
- package/components/views/DesktopView.tsx +7 -9
- package/components/views/MobileView.tsx +2 -8
- package/components/views/lazy.ts +2 -2
- package/content/tips.json +146 -61
- package/content/whats-new.json +32 -0
- package/data/chat-views/index.ts +2 -0
- package/data/chat-views/keys.ts +3 -0
- package/data/chat-views/queries.ts +34 -0
- package/data/sessions/pin.ts +0 -1
- package/hooks/useChatViewsSync.ts +12 -0
- package/hooks/useNavActions.ts +104 -0
- package/hooks/useNavKeys.ts +105 -0
- package/hooks/useSessionUrl.ts +23 -5
- package/lib/bus/index.ts +4 -2
- package/lib/bus/role-alias.ts +4 -2
- package/lib/bus/targets.ts +2 -1
- package/lib/chat/escape.ts +8 -4
- package/lib/chat-views/body.ts +41 -0
- package/lib/chat-views/store.ts +67 -0
- package/lib/chat-views/types.ts +20 -0
- package/lib/db/migrations/20261031T1500_chat_views.ts +30 -0
- package/lib/keybindings/focus.ts +17 -0
- package/lib/keybindings/keymap.ts +120 -0
- package/lib/keybindings/sequence.ts +64 -0
- package/lib/live/service.ts +1 -1
- package/lib/nav/history-mirror.ts +59 -0
- package/lib/nav/history-writer.ts +109 -0
- package/lib/nav/position.ts +67 -0
- package/lib/nav/recent.ts +34 -0
- package/lib/orchestrator/armed.ts +5 -0
- package/lib/palette/sections.ts +12 -6
- package/lib/session-url.ts +11 -5
- package/package.json +1 -1
- package/public/tips/chat-access-picker-dark.webp +0 -0
- package/public/tips/chat-access-picker.webp +0 -0
- package/public/tips/chat-attach-files-poster.webp +0 -0
- package/public/tips/chat-attach-files.mp4 +0 -0
- package/public/tips/chat-bookmark-dark.webp +0 -0
- package/public/tips/chat-bookmark.webp +0 -0
- package/public/tips/chat-plan-mode-dark.webp +0 -0
- package/public/tips/chat-plan-mode.webp +0 -0
- package/public/tips/chat-search-poster.webp +0 -0
- package/public/tips/chat-search.mp4 +0 -0
- package/public/tips/chat-tab-suggestion-poster.webp +0 -0
- package/public/tips/chat-tab-suggestion.mp4 +0 -0
- package/public/tips/chat-terminal-handoff-poster.webp +0 -0
- package/public/tips/chat-terminal-handoff.mp4 +0 -0
- package/public/tips/chat-undo-dark.webp +0 -0
- package/public/tips/chat-undo.webp +0 -0
- package/public/tips/export-ai-dark.webp +0 -0
- package/public/tips/export-ai.webp +0 -0
- package/public/tips/net-messages-dark.webp +0 -0
- package/public/tips/net-messages.webp +0 -0
- package/public/tips/recipe-save-dark.webp +0 -0
- package/public/tips/recipe-save.webp +0 -0
- package/public/tips/schedule-first-dark.webp +0 -0
- package/public/tips/schedule-first.webp +0 -0
- package/public/tips/share-check-dark.webp +0 -0
- package/public/tips/share-check.webp +0 -0
- package/public/tips/sidebar-draft-poster.webp +0 -0
- package/public/tips/sidebar-draft.mp4 +0 -0
- package/public/tips/sidebar-groups-dark.webp +0 -0
- package/public/tips/sidebar-groups.webp +0 -0
- package/public/tips/sidebar-load-dark.webp +0 -0
- package/public/tips/sidebar-load.webp +0 -0
- package/public/tips/sidebar-snooze-dark.webp +0 -0
- package/public/tips/sidebar-snooze.webp +0 -0
- package/public/tips/tasks-plan-pieces-dark.webp +0 -0
- package/public/tips/tasks-plan-pieces.webp +0 -0
- package/public/tips/tasks-sign-off-dark.webp +0 -0
- package/public/tips/tasks-sign-off.webp +0 -0
- package/public/tips/tasks-stacks-dark.webp +0 -0
- package/public/tips/tasks-stacks.webp +0 -0
- package/scripts/check +2 -2
- package/stores/chatJump.ts +2 -6
- package/stores/chatPositions.ts +65 -0
- package/stores/navHistory.ts +98 -0
- package/stores/shortcutsUi.ts +20 -0
- 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 {
|
|
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 {
|
|
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
|
-
<
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
|
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
|
|
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
|
|
22
|
+
else
|
|
23
|
+
go(hrefWithRef(location.href, target), "id" in target ? target.id : null);
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
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 {
|
|
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
|
+
}
|