@saadnvd1/agent-os 0.3.585 → 0.3.587
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/README.md +1 -0
- package/app/api/bookmarks/[id]/route.ts +35 -0
- package/app/api/bookmarks/route.test.ts +103 -0
- package/app/api/bookmarks/route.ts +18 -0
- package/app/api/sessions/[id]/bookmarks/route.ts +38 -0
- package/app/api/tasks/[id]/merge/route.ts +5 -1
- package/app/globals.css +1 -0
- package/app/page.tsx +16 -0
- package/components/Bookmarks/BookmarkButton.tsx +149 -0
- package/components/Bookmarks/BookmarkRow.tsx +161 -0
- package/components/Bookmarks/BookmarksDialog.tsx +186 -0
- package/components/Bookmarks/ChatBookmarks.tsx +58 -0
- package/components/Bookmarks/ChatBookmarksButton.tsx +38 -0
- package/components/Bookmarks/EmptyBookmarks.tsx +20 -0
- package/components/Bookmarks/context.ts +41 -0
- package/components/Bookmarks/helpers.test.ts +69 -0
- package/components/Bookmarks/helpers.ts +65 -0
- package/components/Bookmarks/index.ts +4 -0
- package/components/Bookmarks/useBookmarkToggle.ts +51 -0
- package/components/Chat/ChatPanel.tsx +73 -32
- package/components/Chat/Messages.test.ts +14 -9
- package/components/Chat/Messages.tsx +59 -43
- package/components/Chat/Tools.tsx +3 -1
- package/components/Chat/useLongPress.ts +43 -0
- package/components/CommandPalette/useAppCommands.ts +10 -0
- package/components/Pane/DesktopTabBar.tsx +10 -0
- package/components/Pane/MobileTabBar.tsx +10 -0
- package/components/Pane/index.tsx +8 -0
- package/components/SessionList/SidebarHeader.tsx +5 -0
- package/data/bookmarks/index.ts +2 -0
- package/data/bookmarks/keys.ts +7 -0
- package/data/bookmarks/queries.ts +146 -0
- package/data/chat/useChat.ts +4 -0
- package/data/push/topics.ts +3 -0
- package/lib/bookmarks/body.ts +23 -0
- package/lib/bookmarks/snapshot.ts +33 -0
- package/lib/bookmarks/store.ts +333 -0
- package/lib/bookmarks/types.ts +38 -0
- package/lib/db/changes.ts +3 -0
- package/lib/db/migrations.ts +28 -0
- package/lib/tasks/finish.ts +26 -4
- package/lib/tasks/move-bundle.ts +28 -0
- package/lib/tasks/move-chat.ts +10 -1
- package/lib/tasks/remote.ts +21 -3
- package/package.json +1 -1
- package/stores/bookmarksUi.ts +50 -0
- package/styles/themes.css +3 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Loader2, Search } from "lucide-react";
|
|
5
|
+
import { useSnapshot } from "valtio";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from "@/components/ui/dialog";
|
|
14
|
+
import { useBookmarks, type Bookmark } from "@/data/bookmarks";
|
|
15
|
+
import { useWorkspacesQuery } from "@/data/workspaces";
|
|
16
|
+
import { useUnarchiveSession } from "@/data/done";
|
|
17
|
+
import { toast } from "sonner";
|
|
18
|
+
import { hrefWithSession } from "@/lib/session-url";
|
|
19
|
+
import { bookmarksUi, bookmarksUiActions } from "@/stores/bookmarksUi";
|
|
20
|
+
import { BookmarkRow } from "./BookmarkRow";
|
|
21
|
+
import { EmptyBookmarks } from "./EmptyBookmarks";
|
|
22
|
+
import { groupByDay } from "./helpers";
|
|
23
|
+
|
|
24
|
+
// Opens the chat by its address, then takes it to the message.
|
|
25
|
+
function openChat(b: Bookmark) {
|
|
26
|
+
bookmarksUiActions.setOpen(false);
|
|
27
|
+
window.history.pushState(
|
|
28
|
+
null,
|
|
29
|
+
"",
|
|
30
|
+
hrefWithSession(location.href, b.sessionId)
|
|
31
|
+
);
|
|
32
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
33
|
+
bookmarksUiActions.jump(b.sessionId, b.itemId);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Every bookmark: one workspace's or all of them, searched on the server.
|
|
37
|
+
export function BookmarksDialog() {
|
|
38
|
+
const snap = useSnapshot(bookmarksUi);
|
|
39
|
+
// Widened to every workspace by hand; opening it again starts narrow.
|
|
40
|
+
const [all, setAll] = useState(false);
|
|
41
|
+
const [openedFor, setOpenedFor] = useState(snap.open);
|
|
42
|
+
if (snap.open !== openedFor) {
|
|
43
|
+
setOpenedFor(snap.open);
|
|
44
|
+
if (snap.open) setAll(false);
|
|
45
|
+
}
|
|
46
|
+
const [text, setText] = useState("");
|
|
47
|
+
const [q, setQ] = useState("");
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const t = setTimeout(() => setQ(text.trim()), 250);
|
|
50
|
+
return () => clearTimeout(t);
|
|
51
|
+
}, [text]);
|
|
52
|
+
const scope = all || !snap.workspaceId ? "all" : snap.workspaceId;
|
|
53
|
+
const list = useBookmarks(scope, q, snap.open);
|
|
54
|
+
const bookmarks = list.data?.pages.flatMap((p) => p.bookmarks) ?? [];
|
|
55
|
+
const { data: workspaces = [] } = useWorkspacesQuery();
|
|
56
|
+
const unarchive = useUnarchiveSession();
|
|
57
|
+
// An archived chat only opens from the sidebar, so it comes back first.
|
|
58
|
+
const openBookmark = (b: Bookmark) => {
|
|
59
|
+
if (b.state !== "archived") return openChat(b);
|
|
60
|
+
unarchive.mutate(b.sessionId, {
|
|
61
|
+
onSuccess: () => {
|
|
62
|
+
toast.success(`${b.sessionName} is back`);
|
|
63
|
+
openChat(b);
|
|
64
|
+
},
|
|
65
|
+
onError: (err) => toast.error(err.message),
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
const where = workspaces.find((w) => w.id === snap.workspaceId)?.name;
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Dialog open={snap.open} onOpenChange={bookmarksUiActions.setOpen}>
|
|
72
|
+
<DialogContent className="max-h-[90vh] max-w-xl overflow-y-auto [&>*]:min-w-0">
|
|
73
|
+
<DialogHeader>
|
|
74
|
+
<DialogTitle>Bookmarks</DialogTitle>
|
|
75
|
+
<DialogDescription className="sr-only">
|
|
76
|
+
Messages starred in any chat. Open one to go to it.
|
|
77
|
+
</DialogDescription>
|
|
78
|
+
</DialogHeader>
|
|
79
|
+
<div className="flex items-center gap-2">
|
|
80
|
+
<label className="border-input bg-background text-muted-foreground flex h-11 min-w-0 flex-1 items-center gap-2 rounded-md border px-3 md:h-9">
|
|
81
|
+
<Search className="h-4 w-4 shrink-0" />
|
|
82
|
+
<input
|
|
83
|
+
value={text}
|
|
84
|
+
onChange={(e) => setText(e.target.value)}
|
|
85
|
+
placeholder="Search bookmarks"
|
|
86
|
+
aria-label="Search bookmarks"
|
|
87
|
+
className="text-foreground min-w-0 flex-1 bg-transparent text-base outline-none md:text-sm"
|
|
88
|
+
/>
|
|
89
|
+
</label>
|
|
90
|
+
{snap.workspaceId && (
|
|
91
|
+
<div className="flex shrink-0 overflow-hidden rounded-md border text-xs">
|
|
92
|
+
{[
|
|
93
|
+
[false, where ?? "This workspace"],
|
|
94
|
+
[true, "All"],
|
|
95
|
+
].map(([value, label]) => (
|
|
96
|
+
<button
|
|
97
|
+
key={String(value)}
|
|
98
|
+
type="button"
|
|
99
|
+
aria-pressed={all === value}
|
|
100
|
+
onClick={() => setAll(value as boolean)}
|
|
101
|
+
className={cn(
|
|
102
|
+
"text-muted-foreground max-w-36 truncate px-3 py-2 md:py-1.5",
|
|
103
|
+
all === value && "bg-accent text-foreground"
|
|
104
|
+
)}
|
|
105
|
+
>
|
|
106
|
+
{label}
|
|
107
|
+
</button>
|
|
108
|
+
))}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
<div className="-mx-2">
|
|
113
|
+
{list.isPending &&
|
|
114
|
+
[0, 1, 2].map((i) => (
|
|
115
|
+
<div
|
|
116
|
+
key={i}
|
|
117
|
+
className="bg-muted/40 mx-2 mb-2 h-14 animate-pulse rounded-xl"
|
|
118
|
+
/>
|
|
119
|
+
))}
|
|
120
|
+
{list.isError && (
|
|
121
|
+
<p className="text-destructive px-2 text-sm">
|
|
122
|
+
{list.error.message}
|
|
123
|
+
</p>
|
|
124
|
+
)}
|
|
125
|
+
{!list.isPending &&
|
|
126
|
+
!list.isError &&
|
|
127
|
+
bookmarks.length === 0 &&
|
|
128
|
+
(q ? (
|
|
129
|
+
<div className="text-muted-foreground px-4 py-8 text-center text-sm">
|
|
130
|
+
No bookmarks match “{q}”
|
|
131
|
+
{scope === "all" ? "." : " in this workspace."}
|
|
132
|
+
{scope !== "all" && (
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
onClick={() => setAll(true)}
|
|
136
|
+
className="text-primary block min-h-11 w-full"
|
|
137
|
+
>
|
|
138
|
+
Search all workspaces
|
|
139
|
+
</button>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
) : (
|
|
143
|
+
<EmptyBookmarks
|
|
144
|
+
title="No bookmarks yet"
|
|
145
|
+
body={
|
|
146
|
+
scope === "all"
|
|
147
|
+
? "Messages you star in any chat collect here."
|
|
148
|
+
: "Messages you star in any chat in this workspace collect here."
|
|
149
|
+
}
|
|
150
|
+
/>
|
|
151
|
+
))}
|
|
152
|
+
{groupByDay(bookmarks).map((g) => (
|
|
153
|
+
<section key={g.day}>
|
|
154
|
+
<h3 className="text-muted-foreground px-2 pt-3 pb-1 font-mono text-[11px] tracking-wide uppercase">
|
|
155
|
+
{g.day}
|
|
156
|
+
</h3>
|
|
157
|
+
{g.bookmarks.map((b) => (
|
|
158
|
+
<BookmarkRow
|
|
159
|
+
key={b.id}
|
|
160
|
+
bookmark={b}
|
|
161
|
+
showSession
|
|
162
|
+
editing={snap.editing === b.id}
|
|
163
|
+
onOpen={openBookmark}
|
|
164
|
+
/>
|
|
165
|
+
))}
|
|
166
|
+
</section>
|
|
167
|
+
))}
|
|
168
|
+
{list.hasNextPage && (
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
onClick={() => list.fetchNextPage()}
|
|
172
|
+
disabled={list.isFetchingNextPage}
|
|
173
|
+
className="text-muted-foreground hover:text-foreground flex min-h-11 w-full items-center justify-center text-sm"
|
|
174
|
+
>
|
|
175
|
+
{list.isFetchingNextPage ? (
|
|
176
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
177
|
+
) : (
|
|
178
|
+
"Show older"
|
|
179
|
+
)}
|
|
180
|
+
</button>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</DialogContent>
|
|
184
|
+
</Dialog>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSnapshot } from "valtio";
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogDescription,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
} from "@/components/ui/dialog";
|
|
11
|
+
import { useSessionBookmarks } from "@/data/bookmarks";
|
|
12
|
+
import { bookmarksUi, bookmarksUiActions } from "@/stores/bookmarksUi";
|
|
13
|
+
import { BookmarkRow } from "./BookmarkRow";
|
|
14
|
+
import { EmptyBookmarks } from "./EmptyBookmarks";
|
|
15
|
+
|
|
16
|
+
// One chat's bookmarks, in chat order; a tap takes the chat to the message.
|
|
17
|
+
export function ChatBookmarksDialog() {
|
|
18
|
+
const snap = useSnapshot(bookmarksUi);
|
|
19
|
+
const sessionId = snap.chatSessionId;
|
|
20
|
+
const { data = [], isPending } = useSessionBookmarks(sessionId);
|
|
21
|
+
return (
|
|
22
|
+
<Dialog
|
|
23
|
+
open={!!sessionId}
|
|
24
|
+
onOpenChange={(open) => !open && bookmarksUiActions.closeChat()}
|
|
25
|
+
>
|
|
26
|
+
<DialogContent className="max-h-[85vh] max-w-md overflow-y-auto [&>*]:min-w-0">
|
|
27
|
+
<DialogHeader>
|
|
28
|
+
<DialogTitle>Bookmarks in this chat</DialogTitle>
|
|
29
|
+
<DialogDescription className="sr-only">
|
|
30
|
+
The messages starred in this chat, in the order they were said.
|
|
31
|
+
</DialogDescription>
|
|
32
|
+
</DialogHeader>
|
|
33
|
+
<div className="-mx-2 space-y-0.5">
|
|
34
|
+
{isPending && !!sessionId && (
|
|
35
|
+
<div className="bg-muted/40 mx-2 h-14 animate-pulse rounded-xl" />
|
|
36
|
+
)}
|
|
37
|
+
{!isPending && data.length === 0 && (
|
|
38
|
+
<EmptyBookmarks
|
|
39
|
+
title="No bookmarks in this chat"
|
|
40
|
+
body="Star a message, a tool call or a turn to come back to it here. On a phone you can also hold a message."
|
|
41
|
+
/>
|
|
42
|
+
)}
|
|
43
|
+
{data.map((b) => (
|
|
44
|
+
<BookmarkRow
|
|
45
|
+
key={b.id}
|
|
46
|
+
bookmark={b}
|
|
47
|
+
editing={snap.editing === b.id}
|
|
48
|
+
onOpen={() => {
|
|
49
|
+
bookmarksUiActions.closeChat();
|
|
50
|
+
bookmarksUiActions.jump(b.sessionId, b.itemId);
|
|
51
|
+
}}
|
|
52
|
+
/>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
</DialogContent>
|
|
56
|
+
</Dialog>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Star } from "lucide-react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
import { useSessionBookmarks } from "@/data/bookmarks";
|
|
6
|
+
import { bookmarksUiActions } from "@/stores/bookmarksUi";
|
|
7
|
+
|
|
8
|
+
// The star in a chat's header: how many it has, and its list.
|
|
9
|
+
export function ChatBookmarksButton({
|
|
10
|
+
sessionId,
|
|
11
|
+
className,
|
|
12
|
+
}: {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
className?: string;
|
|
15
|
+
}) {
|
|
16
|
+
const { data = [] } = useSessionBookmarks(sessionId);
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
aria-label={`Bookmarks in this chat${data.length ? ` (${data.length})` : ""}`}
|
|
21
|
+
title="Bookmarks in this chat"
|
|
22
|
+
onClick={(e) => {
|
|
23
|
+
e.stopPropagation();
|
|
24
|
+
bookmarksUiActions.openChat(sessionId);
|
|
25
|
+
}}
|
|
26
|
+
className={cn(
|
|
27
|
+
"text-muted-foreground hover:text-foreground hover:bg-accent flex shrink-0 items-center justify-center gap-1 rounded-md",
|
|
28
|
+
data.length > 0 && "px-1.5",
|
|
29
|
+
className
|
|
30
|
+
)}
|
|
31
|
+
>
|
|
32
|
+
<Star className="h-3.5 w-3.5" />
|
|
33
|
+
{data.length > 0 && (
|
|
34
|
+
<span className="text-xs tabular-nums">{data.length}</span>
|
|
35
|
+
)}
|
|
36
|
+
</button>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Star } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
export function EmptyBookmarks({
|
|
5
|
+
title,
|
|
6
|
+
body,
|
|
7
|
+
}: {
|
|
8
|
+
title: string;
|
|
9
|
+
body: ReactNode;
|
|
10
|
+
}) {
|
|
11
|
+
return (
|
|
12
|
+
<div className="text-muted-foreground px-4 py-8 text-center text-sm">
|
|
13
|
+
<div className="bg-muted mx-auto mb-3 flex h-9 w-9 items-center justify-center rounded-lg">
|
|
14
|
+
<Star className="h-4 w-4" />
|
|
15
|
+
</div>
|
|
16
|
+
<p className="text-foreground font-medium">{title}</p>
|
|
17
|
+
<p className="mt-0.5">{body}</p>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useSyncExternalStore } from "react";
|
|
4
|
+
|
|
5
|
+
// Where each turn starts (a TurnEnd's bookmark marks the turn's first
|
|
6
|
+
// message). Kept outside React state so a streamed word redraws only the
|
|
7
|
+
// turn ends whose start changed, not every star.
|
|
8
|
+
export class TurnStarts {
|
|
9
|
+
private map = new Map<string, string>();
|
|
10
|
+
private listeners = new Set<() => void>();
|
|
11
|
+
set(map: Map<string, string>) {
|
|
12
|
+
this.map = map;
|
|
13
|
+
this.listeners.forEach((l) => l());
|
|
14
|
+
}
|
|
15
|
+
get = (turnEndId: string) => this.map.get(turnEndId) ?? null;
|
|
16
|
+
subscribe = (l: () => void) => {
|
|
17
|
+
this.listeners.add(l);
|
|
18
|
+
return () => this.listeners.delete(l);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The chat the stars inside it belong to.
|
|
23
|
+
export interface BookmarkChat {
|
|
24
|
+
sessionId: string;
|
|
25
|
+
turnStarts: TurnStarts;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const BookmarkChatContext = createContext<BookmarkChat | null>(null);
|
|
29
|
+
|
|
30
|
+
export const useBookmarkChat = () => useContext(BookmarkChatContext);
|
|
31
|
+
|
|
32
|
+
const NO_STARTS = new TurnStarts();
|
|
33
|
+
|
|
34
|
+
export function useTurnStart(turnEndId: string): string | null {
|
|
35
|
+
const starts = useBookmarkChat()?.turnStarts ?? NO_STARTS;
|
|
36
|
+
return useSyncExternalStore(
|
|
37
|
+
starts.subscribe,
|
|
38
|
+
() => starts.get(turnEndId),
|
|
39
|
+
() => null
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Bookmark } from "@/lib/bookmarks/types";
|
|
3
|
+
import {
|
|
4
|
+
STATE_BADGE,
|
|
5
|
+
canOpen,
|
|
6
|
+
createdAt,
|
|
7
|
+
excerpt,
|
|
8
|
+
groupByDay,
|
|
9
|
+
} from "./helpers";
|
|
10
|
+
|
|
11
|
+
const b = (createdAt: string, state: Bookmark["state"] = "live"): Bookmark => ({
|
|
12
|
+
id: createdAt,
|
|
13
|
+
sessionId: "s",
|
|
14
|
+
itemId: "i",
|
|
15
|
+
scope: "item",
|
|
16
|
+
kind: "assistant",
|
|
17
|
+
text: "t",
|
|
18
|
+
note: null,
|
|
19
|
+
sessionName: "n",
|
|
20
|
+
workspaceId: null,
|
|
21
|
+
createdAt,
|
|
22
|
+
state,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("bookmark helpers", () => {
|
|
26
|
+
it("reads SQLite's zoneless time as UTC", () => {
|
|
27
|
+
expect(createdAt(b("2026-10-14 09:30:00")).toISOString()).toBe(
|
|
28
|
+
"2026-10-14T09:30:00.000Z"
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("groups newest-first bookmarks by local day", () => {
|
|
33
|
+
const now = new Date(2026, 9, 14, 18);
|
|
34
|
+
const at = (d: Date) => d.toISOString();
|
|
35
|
+
const groups = groupByDay(
|
|
36
|
+
[
|
|
37
|
+
b(at(new Date(2026, 9, 14, 12))),
|
|
38
|
+
b(at(new Date(2026, 9, 14, 8))),
|
|
39
|
+
b(at(new Date(2026, 9, 13, 23))),
|
|
40
|
+
b(at(new Date(2026, 9, 9, 10))),
|
|
41
|
+
],
|
|
42
|
+
now
|
|
43
|
+
);
|
|
44
|
+
expect(groups.map((g) => [g.day, g.bookmarks.length])).toEqual([
|
|
45
|
+
["Today", 2],
|
|
46
|
+
["Yesterday", 1],
|
|
47
|
+
[
|
|
48
|
+
new Date(2026, 9, 9).toLocaleDateString(undefined, {
|
|
49
|
+
month: "short",
|
|
50
|
+
day: "numeric",
|
|
51
|
+
}),
|
|
52
|
+
1,
|
|
53
|
+
],
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("badges archived and deleted chats, and won't open a deleted one", () => {
|
|
58
|
+
expect(STATE_BADGE.live).toBeNull();
|
|
59
|
+
expect(STATE_BADGE.archived).toBe("archived");
|
|
60
|
+
expect(STATE_BADGE.deleted).toBe("conversation deleted");
|
|
61
|
+
expect(canOpen(b("x", "deleted"))).toBe(false);
|
|
62
|
+
expect(canOpen(b("x", "archived"))).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("flattens and clips an excerpt", () => {
|
|
66
|
+
expect(excerpt("a\n\n b")).toBe("a b");
|
|
67
|
+
expect(excerpt("x".repeat(300), 10)).toBe("xxxxxxxxx…");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Bookmark, BookmarkState } from "@/lib/bookmarks/types";
|
|
2
|
+
|
|
3
|
+
export const KIND_LABEL: Record<Bookmark["kind"], string> = {
|
|
4
|
+
user: "You",
|
|
5
|
+
assistant: "Reply",
|
|
6
|
+
tool: "Tool",
|
|
7
|
+
turn: "Turn",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const STATE_BADGE: Record<BookmarkState, string | null> = {
|
|
11
|
+
live: null,
|
|
12
|
+
moved: null,
|
|
13
|
+
archived: "archived",
|
|
14
|
+
deleted: "conversation deleted",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// A deleted chat has nothing to open.
|
|
18
|
+
export const canOpen = (b: Bookmark) => b.state !== "deleted";
|
|
19
|
+
|
|
20
|
+
// SQLite's datetime('now') is UTC without a zone.
|
|
21
|
+
export const createdAt = (b: Bookmark) =>
|
|
22
|
+
new Date(
|
|
23
|
+
/[zZ]|[+-]\d\d:?\d\d$/.test(b.createdAt)
|
|
24
|
+
? b.createdAt
|
|
25
|
+
: b.createdAt.replace(" ", "T") + "Z"
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export const excerpt = (text: string, max = 240) => {
|
|
29
|
+
const t = text.replace(/\s+/g, " ").trim();
|
|
30
|
+
return t.length > max ? t.slice(0, max - 1) + "…" : t;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function dayLabel(d: Date, now: Date): string {
|
|
34
|
+
const start = (x: Date) =>
|
|
35
|
+
new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
36
|
+
const days = Math.round((start(now) - start(d)) / 86_400_000);
|
|
37
|
+
if (days === 0) return "Today";
|
|
38
|
+
if (days === 1) return "Yesterday";
|
|
39
|
+
return d.toLocaleDateString(undefined, {
|
|
40
|
+
month: "short",
|
|
41
|
+
day: "numeric",
|
|
42
|
+
...(d.getFullYear() !== now.getFullYear() && { year: "numeric" }),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Newest-first bookmarks, in runs by local day. */
|
|
47
|
+
export function groupByDay(
|
|
48
|
+
bookmarks: Bookmark[],
|
|
49
|
+
now = new Date()
|
|
50
|
+
): { day: string; bookmarks: Bookmark[] }[] {
|
|
51
|
+
const out: { day: string; bookmarks: Bookmark[] }[] = [];
|
|
52
|
+
for (const b of bookmarks) {
|
|
53
|
+
const day = dayLabel(createdAt(b), now);
|
|
54
|
+
const last = out[out.length - 1];
|
|
55
|
+
if (last?.day === day) last.bookmarks.push(b);
|
|
56
|
+
else out.push({ day, bookmarks: [b] });
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const timeOf = (b: Bookmark) =>
|
|
62
|
+
createdAt(b).toLocaleTimeString(undefined, {
|
|
63
|
+
hour: "numeric",
|
|
64
|
+
minute: "2-digit",
|
|
65
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { bookmarkKey, type BookmarkScope } from "@/lib/bookmarks/types";
|
|
6
|
+
import { useSessionBookmarks, useToggleBookmark } from "@/data/bookmarks";
|
|
7
|
+
import { bookmarksUiActions } from "@/stores/bookmarksUi";
|
|
8
|
+
|
|
9
|
+
// Whether an item is starred in its chat, and a toggle that says what it did
|
|
10
|
+
// with a way back.
|
|
11
|
+
export function useBookmarkToggle(
|
|
12
|
+
sessionId: string,
|
|
13
|
+
itemId: string | null,
|
|
14
|
+
scope: BookmarkScope
|
|
15
|
+
) {
|
|
16
|
+
const { data } = useSessionBookmarks(sessionId);
|
|
17
|
+
const existing = useMemo(
|
|
18
|
+
() =>
|
|
19
|
+
(itemId &&
|
|
20
|
+
data?.find(
|
|
21
|
+
(b) => bookmarkKey(b.itemId, b.scope) === bookmarkKey(itemId, scope)
|
|
22
|
+
)) ||
|
|
23
|
+
null,
|
|
24
|
+
[data, itemId, scope]
|
|
25
|
+
);
|
|
26
|
+
const toggle = useToggleBookmark(sessionId);
|
|
27
|
+
const run = useCallback(() => {
|
|
28
|
+
if (!itemId) return;
|
|
29
|
+
toggle.mutate(
|
|
30
|
+
{ itemId, scope, existing },
|
|
31
|
+
{
|
|
32
|
+
onSuccess: (made) => {
|
|
33
|
+
if (!made) return toast("Bookmark removed");
|
|
34
|
+
toast("Bookmarked", {
|
|
35
|
+
action: {
|
|
36
|
+
label: "Undo",
|
|
37
|
+
onClick: () => toggle.mutate({ itemId, scope, existing: made }),
|
|
38
|
+
},
|
|
39
|
+
cancel: {
|
|
40
|
+
label: "Add note",
|
|
41
|
+
onClick: () => bookmarksUiActions.openChat(sessionId, made.id),
|
|
42
|
+
},
|
|
43
|
+
classNames: { cancelButton: "!bg-muted !text-foreground" },
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
onError: (err) => toast.error(err.message),
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
}, [toggle, itemId, scope, existing, sessionId]);
|
|
50
|
+
return { on: !!existing, toggle: run };
|
|
51
|
+
}
|
|
@@ -65,6 +65,13 @@ import { useViewport } from "@/hooks/useViewport";
|
|
|
65
65
|
import { useChatCommands } from "./useChatCommands";
|
|
66
66
|
import { copySelection } from "./copy-selection";
|
|
67
67
|
import { arrivalKey, useStickToBottom } from "./stick-to-bottom";
|
|
68
|
+
import { useSnapshot } from "valtio";
|
|
69
|
+
import {
|
|
70
|
+
BookmarkChatContext,
|
|
71
|
+
TurnStarts,
|
|
72
|
+
type BookmarkChat,
|
|
73
|
+
} from "@/components/Bookmarks/context";
|
|
74
|
+
import { bookmarksUi, bookmarksUiActions } from "@/stores/bookmarksUi";
|
|
68
75
|
import {
|
|
69
76
|
AssistantMessage,
|
|
70
77
|
CommandOutput,
|
|
@@ -295,6 +302,7 @@ export function ChatPanel({
|
|
|
295
302
|
requestFiles,
|
|
296
303
|
search,
|
|
297
304
|
compacting,
|
|
305
|
+
loadedFor,
|
|
298
306
|
} = useChat(sessionId, (text) => setPrefill({ text, at: Date.now() }));
|
|
299
307
|
const startFresh = useFreshStart();
|
|
300
308
|
const { isMobile } = useViewport();
|
|
@@ -511,6 +519,37 @@ export function ChatPanel({
|
|
|
511
519
|
scrollToBlock,
|
|
512
520
|
foldEvents: !!orchestratorOf,
|
|
513
521
|
});
|
|
522
|
+
// Where each turn starts, for a TurnEnd's "Bookmark turn".
|
|
523
|
+
const turnStarts = useMemo(() => {
|
|
524
|
+
const m = new Map<string, string>();
|
|
525
|
+
let user: string | null = null;
|
|
526
|
+
for (const i of items) {
|
|
527
|
+
if (i.kind === "user") user = i.id;
|
|
528
|
+
else if (i.kind === "turn_end" && user) m.set(i.id, user);
|
|
529
|
+
}
|
|
530
|
+
return m;
|
|
531
|
+
}, [items]);
|
|
532
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
533
|
+
const turnStartStore = useMemo(() => new TurnStarts(), [sessionId]);
|
|
534
|
+
useLayoutEffect(
|
|
535
|
+
() => turnStartStore.set(turnStarts),
|
|
536
|
+
[turnStartStore, turnStarts]
|
|
537
|
+
);
|
|
538
|
+
const bookmarkChat = useMemo<BookmarkChat>(
|
|
539
|
+
() => ({ sessionId, turnStarts: turnStartStore }),
|
|
540
|
+
[sessionId, turnStartStore]
|
|
541
|
+
);
|
|
542
|
+
// A bookmark opened from a list: once this chat is the one showing.
|
|
543
|
+
const { jump } = useSnapshot(bookmarksUi);
|
|
544
|
+
useEffect(() => {
|
|
545
|
+
// Waits for this chat's first page: until it lands, the items are
|
|
546
|
+
// the last chat's, and every jump would miss.
|
|
547
|
+
if (!jump || jump.sessionId !== sessionId || loadedFor !== sessionId)
|
|
548
|
+
return;
|
|
549
|
+
const itemId = bookmarksUiActions.takeJump(sessionId);
|
|
550
|
+
if (itemId)
|
|
551
|
+
goTo((all) => (all.some((i) => i.id === itemId) ? itemId : null));
|
|
552
|
+
}, [jump, sessionId, goTo, loadedFor]);
|
|
514
553
|
const resolveQuestion = useResolveQuestion(orchestratorOf ?? "");
|
|
515
554
|
// The question the next message answers, resolved when it is sent.
|
|
516
555
|
const [replyTo, setReplyTo] = useState<{ id: number; text: string } | null>(
|
|
@@ -588,39 +627,41 @@ export function ChatPanel({
|
|
|
588
627
|
<RowState.Provider value={rowState}>
|
|
589
628
|
<Background.Provider value={background}>
|
|
590
629
|
<ToolBodies.Provider value={bodies}>
|
|
591
|
-
<
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
630
|
+
<BookmarkChatContext.Provider value={bookmarkChat}>
|
|
631
|
+
<div
|
|
632
|
+
// A new conversation starts over, scrolled to the end.
|
|
633
|
+
key={sessionId}
|
|
634
|
+
ref={listRef}
|
|
635
|
+
onScroll={onListScroll}
|
|
636
|
+
onCopy={onCopy}
|
|
637
|
+
className="h-full overflow-y-auto overscroll-y-contain"
|
|
638
|
+
>
|
|
639
|
+
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pt-5 pb-4">
|
|
640
|
+
{hasMore && (
|
|
641
|
+
<div className="text-muted-foreground flex min-h-11 items-center justify-center text-xs">
|
|
642
|
+
{loadingOlder ? (
|
|
643
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
644
|
+
) : (
|
|
645
|
+
<button
|
|
646
|
+
type="button"
|
|
647
|
+
onClick={loadOlder}
|
|
648
|
+
className="hover:text-foreground min-h-11 px-3"
|
|
649
|
+
>
|
|
650
|
+
Show earlier messages
|
|
651
|
+
</button>
|
|
652
|
+
)}
|
|
653
|
+
</div>
|
|
654
|
+
)}
|
|
655
|
+
<SetupCard sessionId={sessionId} />
|
|
656
|
+
{blocks.length === 0 && !queue.length && (
|
|
657
|
+
<p className="text-muted-foreground py-16 text-center text-sm">
|
|
658
|
+
Ask anything to start.
|
|
659
|
+
</p>
|
|
660
|
+
)}
|
|
661
|
+
</div>
|
|
662
|
+
<TimelineRows blocks={blocks} actions={actions} />
|
|
621
663
|
</div>
|
|
622
|
-
|
|
623
|
-
</div>
|
|
664
|
+
</BookmarkChatContext.Provider>
|
|
624
665
|
</ToolBodies.Provider>
|
|
625
666
|
</Background.Provider>
|
|
626
667
|
</RowState.Provider>
|