@saadnvd1/agent-os 0.3.770 → 0.3.775
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/sessions/[id]/artifacts/[artifactId]/compare/route.ts +41 -0
- package/app/api/shares/preview/route.ts +6 -1
- package/app/api/shares/route.test.ts +51 -0
- package/app/page.tsx +1 -0
- package/components/Artifacts/ArtifactActions.tsx +27 -3
- package/components/Artifacts/ArtifactPreview.tsx +27 -12
- package/components/Artifacts/Compare/DiffView.tsx +116 -0
- package/components/Artifacts/Compare/SideBySide.tsx +60 -0
- package/components/Artifacts/Compare/VersionPicker.tsx +47 -0
- package/components/Artifacts/Compare/index.tsx +191 -0
- package/components/Chat/Messages.tsx +6 -0
- package/components/Chat/ShareFromHere.tsx +39 -0
- package/components/CommandPalette/CommandPalette.tsx +5 -4
- package/components/CommandPalette/CommandsBody.tsx +97 -131
- package/components/CommandPalette/PaletteRows.tsx +79 -0
- package/components/CommandPalette/PaletteTabs.tsx +51 -0
- package/components/CommandPalette/conversations/ConversationSearch.tsx +13 -11
- package/components/CommandPalette/useAppCommands.ts +14 -0
- package/components/CommandPalette/useConversationRows.ts +55 -0
- package/components/SessionList/useSessionSwitching.ts +2 -2
- package/components/Share/MessageList.tsx +87 -0
- package/components/Share/RangePicker.tsx +59 -0
- package/components/Share/ShareDialog.tsx +113 -52
- package/data/session-artifacts/keys.ts +2 -0
- package/data/session-artifacts/queries.ts +25 -0
- package/data/shares/queries.ts +11 -0
- package/hooks/usePaletteCommands.ts +4 -1
- package/lib/artifacts/actions.ts +11 -1
- package/lib/artifacts/compare/compareVersions.ts +66 -0
- package/lib/artifacts/compare/diff.ts +178 -0
- package/lib/artifacts/compare/extractText.ts +64 -0
- package/lib/artifacts/compare/index.ts +11 -0
- package/lib/artifacts/compare/types.ts +44 -0
- package/lib/artifacts/match.ts +64 -0
- package/lib/artifacts/store.ts +39 -10
- package/lib/artifacts/tools.ts +9 -4
- package/lib/artifacts/versions.ts +2 -1
- package/lib/palette/registry.ts +71 -17
- package/lib/palette/sections.ts +143 -0
- package/lib/palette/tabs.ts +21 -0
- package/lib/palette/usage.ts +81 -0
- package/lib/security/demo.ts +3 -0
- package/lib/share/markdown.ts +3 -0
- package/lib/share/range.ts +189 -0
- package/lib/share/render.tsx +3 -0
- package/lib/share/service.ts +64 -19
- package/lib/share/snapshot.ts +39 -2
- package/lib/share/styles.ts +2 -0
- package/lib/share/view.ts +11 -0
- package/package.json +1 -1
- package/stores/palette.ts +13 -11
- package/stores/shareUi.ts +6 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { PALETTE_TABS, type PaletteTab } from "@/lib/palette/registry";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
// The palette's tabs. Tab and Shift-Tab in the input move between them, so
|
|
8
|
+
// these keep out of the focus order and never take focus from it.
|
|
9
|
+
export function PaletteTabs({
|
|
10
|
+
tab,
|
|
11
|
+
onSelect,
|
|
12
|
+
}: {
|
|
13
|
+
tab: PaletteTab;
|
|
14
|
+
onSelect: (tab: PaletteTab) => void;
|
|
15
|
+
}) {
|
|
16
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
ref.current
|
|
19
|
+
?.querySelector('[aria-selected="true"]')
|
|
20
|
+
?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
21
|
+
}, [tab]);
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
ref={ref}
|
|
25
|
+
role="tablist"
|
|
26
|
+
aria-label="Palette sections"
|
|
27
|
+
className="border-foreground/[0.06] flex shrink-0 scrollbar-none gap-1 overflow-x-auto border-b px-2 py-1.5"
|
|
28
|
+
>
|
|
29
|
+
{PALETTE_TABS.map((t) => (
|
|
30
|
+
<button
|
|
31
|
+
key={t.id}
|
|
32
|
+
type="button"
|
|
33
|
+
role="tab"
|
|
34
|
+
id={`palette-tab-${t.id}`}
|
|
35
|
+
aria-selected={t.id === tab}
|
|
36
|
+
tabIndex={-1}
|
|
37
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
38
|
+
onClick={() => onSelect(t.id)}
|
|
39
|
+
className={cn(
|
|
40
|
+
"flex min-h-11 shrink-0 items-center rounded-lg px-3 text-sm whitespace-nowrap md:min-h-8 md:px-2.5 md:text-xs",
|
|
41
|
+
t.id === tab
|
|
42
|
+
? "bg-primary/10 text-foreground font-medium"
|
|
43
|
+
: "text-muted-foreground hover:text-foreground"
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
{t.label}
|
|
47
|
+
</button>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -8,14 +8,16 @@ import { MIN_QUERY, useConversationSearch } from "@/data/search";
|
|
|
8
8
|
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
|
9
9
|
import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
|
|
10
10
|
import { useViewport } from "@/hooks/useViewport";
|
|
11
|
+
import { cycleTab } from "@/lib/palette/tabs";
|
|
11
12
|
import { paletteActions, paletteUi } from "@/stores/palette";
|
|
13
|
+
import { PaletteTabs } from "../PaletteTabs";
|
|
12
14
|
import { NoResults, Recent, Skeleton } from "./EmptyState";
|
|
13
15
|
import { Preview } from "./Preview";
|
|
14
16
|
import { ResultList, flatten } from "./ResultList";
|
|
15
17
|
import { loadRecent, saveRecent, withRecent } from "./recent";
|
|
16
18
|
import { useOpenHit } from "./useOpenHit";
|
|
17
19
|
|
|
18
|
-
// ⌘K's
|
|
20
|
+
// ⌘K's Conversations tab: what was said in any chat, found as you type. Results on
|
|
19
21
|
// the left, the selected message in its conversation on the right; a phone
|
|
20
22
|
// shows the list, and a tap shows the message.
|
|
21
23
|
export function ConversationSearch() {
|
|
@@ -97,7 +99,10 @@ export function ConversationSearch() {
|
|
|
97
99
|
openHit(highlighted);
|
|
98
100
|
} else if (e.key === "Tab") {
|
|
99
101
|
e.preventDefault();
|
|
100
|
-
paletteActions.
|
|
102
|
+
paletteActions.setTab(
|
|
103
|
+
cycleTab("conversations", e.shiftKey ? -1 : 1),
|
|
104
|
+
text
|
|
105
|
+
);
|
|
101
106
|
}
|
|
102
107
|
}}
|
|
103
108
|
placeholder="Search every conversation…"
|
|
@@ -111,8 +116,12 @@ export function ConversationSearch() {
|
|
|
111
116
|
esc
|
|
112
117
|
</kbd>
|
|
113
118
|
</div>
|
|
119
|
+
<PaletteTabs
|
|
120
|
+
tab="conversations"
|
|
121
|
+
onSelect={(tab) => paletteActions.setTab(tab, text)}
|
|
122
|
+
/>
|
|
114
123
|
{workspace && (
|
|
115
|
-
<div className="flex items-center gap-2 px-3
|
|
124
|
+
<div className="flex items-center gap-2 px-3 py-2">
|
|
116
125
|
<div className="flex overflow-hidden rounded-md border text-xs">
|
|
117
126
|
{(
|
|
118
127
|
[
|
|
@@ -222,19 +231,12 @@ export function ConversationSearch() {
|
|
|
222
231
|
<kbd className="font-mono">↵</kbd> open
|
|
223
232
|
</span>
|
|
224
233
|
<span>
|
|
225
|
-
<kbd className="font-mono">tab</kbd>
|
|
234
|
+
<kbd className="font-mono">tab</kbd> next tab
|
|
226
235
|
</span>
|
|
227
236
|
<span>
|
|
228
237
|
<kbd className="font-mono">esc</kbd> close
|
|
229
238
|
</span>
|
|
230
239
|
</span>
|
|
231
|
-
<button
|
|
232
|
-
type="button"
|
|
233
|
-
onClick={() => paletteActions.setMode("commands", text)}
|
|
234
|
-
className="h-8 sm:hidden"
|
|
235
|
-
>
|
|
236
|
-
Commands
|
|
237
|
-
</button>
|
|
238
240
|
<span className="ml-auto font-mono">
|
|
239
241
|
{searching && data
|
|
240
242
|
? `${groups.length} ${groups.length === 1 ? "chat" : "chats"} · ${data.tookMs} ms`
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
Scale,
|
|
24
24
|
} from "lucide-react";
|
|
25
25
|
import type { Session } from "@/lib/db";
|
|
26
|
+
import type { SessionStatus } from "@/components/views/types";
|
|
26
27
|
import type { PaletteCommand } from "@/lib/palette/registry";
|
|
27
28
|
import { useWorkspacesQuery } from "@/data/workspaces";
|
|
28
29
|
import { useRecipesQuery } from "@/data/recipes";
|
|
@@ -57,13 +58,25 @@ function switchMode(current: string | undefined, to: "light" | "dark"): string {
|
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
const STATE: Record<string, { status: "running" | "waiting"; detail: string }> =
|
|
62
|
+
{
|
|
63
|
+
waiting: { status: "waiting", detail: "Waiting on you" },
|
|
64
|
+
running: { status: "running", detail: "Working" },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function sessionState(s: SessionStatus | undefined) {
|
|
68
|
+
return s?.need || (s?.asks ?? 0) > 0 ? STATE.waiting : STATE[s?.status ?? ""];
|
|
69
|
+
}
|
|
70
|
+
|
|
60
71
|
// The palette's app-wide commands and its session search.
|
|
61
72
|
export function useAppCommands({
|
|
62
73
|
sessions,
|
|
74
|
+
statuses,
|
|
63
75
|
onSelectSession,
|
|
64
76
|
onSearchCode,
|
|
65
77
|
}: {
|
|
66
78
|
sessions: Session[];
|
|
79
|
+
statuses: Record<string, SessionStatus>;
|
|
67
80
|
onSelectSession: (session: Session) => void;
|
|
68
81
|
onSearchCode: () => void;
|
|
69
82
|
}) {
|
|
@@ -260,6 +273,7 @@ export function useAppCommands({
|
|
|
260
273
|
group: "Sessions",
|
|
261
274
|
keywords: [s.working_directory, s.branch_name ?? ""].filter(Boolean),
|
|
262
275
|
icon: MessageSquare,
|
|
276
|
+
...sessionState(statuses[s.id]),
|
|
263
277
|
run: () => onSelectSession(s),
|
|
264
278
|
}))
|
|
265
279
|
);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { MessagesSquare, Search } from "lucide-react";
|
|
5
|
+
import type { PaletteCommand } from "@/lib/palette/registry";
|
|
6
|
+
import { MIN_QUERY, useConversationSearch } from "@/data/search";
|
|
7
|
+
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
|
8
|
+
import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
|
|
9
|
+
import { paletteActions } from "@/stores/palette";
|
|
10
|
+
import { plain } from "./conversations/format";
|
|
11
|
+
import { useOpenHit } from "./conversations/useOpenHit";
|
|
12
|
+
|
|
13
|
+
// Conversation matches offered last in All.
|
|
14
|
+
const CONVERSATIONS = 3;
|
|
15
|
+
|
|
16
|
+
// The few conversations a query finds, then a way into the full search.
|
|
17
|
+
export function useConversationRows(
|
|
18
|
+
query: string,
|
|
19
|
+
enabled: boolean
|
|
20
|
+
): PaletteCommand[] {
|
|
21
|
+
const { workspace } = useSelectedWorkspace();
|
|
22
|
+
const debounced = useDebouncedValue(query.trim(), 150);
|
|
23
|
+
const openHit = useOpenHit();
|
|
24
|
+
const { data: found } = useConversationSearch(
|
|
25
|
+
debounced,
|
|
26
|
+
workspace?.id ?? "all",
|
|
27
|
+
{ limit: CONVERSATIONS, enabled }
|
|
28
|
+
);
|
|
29
|
+
return useMemo(() => {
|
|
30
|
+
const q = query.trim();
|
|
31
|
+
if (!enabled || q.length < MIN_QUERY || q !== debounced) return [];
|
|
32
|
+
const hits = (found?.groups ?? []).flatMap((group) =>
|
|
33
|
+
group.hits.slice(0, 1).map((hit) => ({
|
|
34
|
+
id: `conversation:${group.sessionId}:${hit.itemId}`,
|
|
35
|
+
title: group.name,
|
|
36
|
+
detail: plain(hit.snippet),
|
|
37
|
+
group: "Conversations",
|
|
38
|
+
icon: MessagesSquare,
|
|
39
|
+
run: () => openHit(group, hit),
|
|
40
|
+
keepOpen: true,
|
|
41
|
+
}))
|
|
42
|
+
);
|
|
43
|
+
return [
|
|
44
|
+
...hits,
|
|
45
|
+
{
|
|
46
|
+
id: "conversation:all",
|
|
47
|
+
title: `Search all conversations for “${q}”`,
|
|
48
|
+
group: "Conversations",
|
|
49
|
+
icon: Search,
|
|
50
|
+
keepOpen: true,
|
|
51
|
+
run: () => paletteActions.setTab("conversations", q),
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
}, [found, query, debounced, enabled, openHit]);
|
|
55
|
+
}
|
|
@@ -36,7 +36,7 @@ export function useSessionSwitching(
|
|
|
36
36
|
{
|
|
37
37
|
id: "session.previous",
|
|
38
38
|
title: "Previous session",
|
|
39
|
-
group: "
|
|
39
|
+
group: "Actions",
|
|
40
40
|
hint: chordHint(PREVIOUS),
|
|
41
41
|
keywords: ["switch", "up"],
|
|
42
42
|
icon: MessageSquare,
|
|
@@ -45,7 +45,7 @@ export function useSessionSwitching(
|
|
|
45
45
|
{
|
|
46
46
|
id: "session.next",
|
|
47
47
|
title: "Next session",
|
|
48
|
-
group: "
|
|
48
|
+
group: "Actions",
|
|
49
49
|
hint: chordHint(NEXT),
|
|
50
50
|
keywords: ["switch", "down"],
|
|
51
51
|
icon: MessageSquare,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
import { TYPE_LABEL } from "@/lib/share/view";
|
|
6
|
+
import type { PreviewItem } from "@/data/shares";
|
|
7
|
+
|
|
8
|
+
// The chat's messages. The text picks the range (click for a start,
|
|
9
|
+
// shift-click for an end); the checkbox leaves one message out of it.
|
|
10
|
+
export function MessageList({
|
|
11
|
+
rows,
|
|
12
|
+
inside,
|
|
13
|
+
dropped,
|
|
14
|
+
touch,
|
|
15
|
+
onPick,
|
|
16
|
+
onToggle,
|
|
17
|
+
}: {
|
|
18
|
+
rows: PreviewItem[];
|
|
19
|
+
inside: Set<string>;
|
|
20
|
+
dropped: string[];
|
|
21
|
+
touch: boolean;
|
|
22
|
+
onPick: (id: string, extend: boolean) => void;
|
|
23
|
+
onToggle: (id: string) => void;
|
|
24
|
+
}) {
|
|
25
|
+
const first = rows.find((r) => inside.has(r.id))?.id;
|
|
26
|
+
const listRef = useRef<HTMLUListElement>(null);
|
|
27
|
+
const firstRef = useRef<HTMLLIElement>(null);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (listRef.current && firstRef.current)
|
|
30
|
+
listRef.current.scrollTop = firstRef.current.offsetTop;
|
|
31
|
+
}, [first]);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="shrink-0">
|
|
35
|
+
<div className="text-muted-foreground mb-1 text-xs font-medium">
|
|
36
|
+
Messages
|
|
37
|
+
</div>
|
|
38
|
+
<p className="text-muted-foreground mb-1.5 text-xs">
|
|
39
|
+
{touch
|
|
40
|
+
? "Tap a message to start the range, then another to end it."
|
|
41
|
+
: "Click a message to start the range, shift-click to end it."}
|
|
42
|
+
</p>
|
|
43
|
+
<ul
|
|
44
|
+
ref={listRef}
|
|
45
|
+
className="relative max-h-64 divide-y overflow-y-auto rounded-lg border md:max-h-[40vh]"
|
|
46
|
+
>
|
|
47
|
+
{rows.map((item) => {
|
|
48
|
+
const on = inside.has(item.id);
|
|
49
|
+
return (
|
|
50
|
+
<li
|
|
51
|
+
key={item.id}
|
|
52
|
+
ref={item.id === first ? firstRef : undefined}
|
|
53
|
+
className={cn("flex items-center", on && "bg-primary/5")}
|
|
54
|
+
>
|
|
55
|
+
<label className="flex min-h-11 w-11 shrink-0 cursor-pointer items-center justify-center">
|
|
56
|
+
<input
|
|
57
|
+
type="checkbox"
|
|
58
|
+
className="h-4 w-4"
|
|
59
|
+
aria-label="Include this message"
|
|
60
|
+
disabled={!on}
|
|
61
|
+
checked={on && !dropped.includes(item.id)}
|
|
62
|
+
onChange={() => onToggle(item.id)}
|
|
63
|
+
/>
|
|
64
|
+
</label>
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
aria-pressed={on}
|
|
68
|
+
onClick={(e) => onPick(item.id, e.shiftKey)}
|
|
69
|
+
className={cn(
|
|
70
|
+
"flex min-h-11 min-w-0 flex-1 items-center gap-3 py-1.5 pr-3 text-left text-sm",
|
|
71
|
+
!on && "text-muted-foreground opacity-60"
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
<span className="text-muted-foreground w-12 shrink-0 text-xs">
|
|
75
|
+
{TYPE_LABEL[item.type] ?? item.type}
|
|
76
|
+
</span>
|
|
77
|
+
<span className="min-w-0 flex-1 truncate">
|
|
78
|
+
{item.excerpt || "(empty)"}
|
|
79
|
+
</span>
|
|
80
|
+
</button>
|
|
81
|
+
</li>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
</ul>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import {
|
|
5
|
+
PRESETS,
|
|
6
|
+
presetRange,
|
|
7
|
+
type RangePreset,
|
|
8
|
+
type RangeRow,
|
|
9
|
+
} from "@/lib/share/range";
|
|
10
|
+
|
|
11
|
+
// The part of the chat to share: a preset, with what it comes to underneath.
|
|
12
|
+
export function RangePicker({
|
|
13
|
+
rows,
|
|
14
|
+
active,
|
|
15
|
+
summary,
|
|
16
|
+
onPick,
|
|
17
|
+
}: {
|
|
18
|
+
rows: RangeRow[];
|
|
19
|
+
active: RangePreset | null;
|
|
20
|
+
summary: string;
|
|
21
|
+
onPick: (preset: RangePreset) => void;
|
|
22
|
+
}) {
|
|
23
|
+
const now = new Date();
|
|
24
|
+
return (
|
|
25
|
+
<div className="space-y-1.5">
|
|
26
|
+
<div
|
|
27
|
+
className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap"
|
|
28
|
+
role="group"
|
|
29
|
+
aria-label="Part of the chat to share"
|
|
30
|
+
>
|
|
31
|
+
{PRESETS.map((p) => {
|
|
32
|
+
const empty = !presetRange(rows, p.id, now);
|
|
33
|
+
return (
|
|
34
|
+
<Button
|
|
35
|
+
key={p.id}
|
|
36
|
+
size="sm"
|
|
37
|
+
variant={active === p.id ? "default" : "outline"}
|
|
38
|
+
className="min-h-11"
|
|
39
|
+
aria-pressed={active === p.id}
|
|
40
|
+
disabled={empty}
|
|
41
|
+
title={empty ? "No messages today" : undefined}
|
|
42
|
+
onClick={() => onPick(p.id)}
|
|
43
|
+
>
|
|
44
|
+
{p.label}
|
|
45
|
+
</Button>
|
|
46
|
+
);
|
|
47
|
+
})}
|
|
48
|
+
</div>
|
|
49
|
+
<p
|
|
50
|
+
className="text-muted-foreground text-xs"
|
|
51
|
+
aria-live="polite"
|
|
52
|
+
data-testid="share-range-summary"
|
|
53
|
+
>
|
|
54
|
+
{summary}
|
|
55
|
+
{!active && " · Custom range"}
|
|
56
|
+
</p>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { useSnapshot } from "valtio";
|
|
5
5
|
import { toast } from "sonner";
|
|
6
6
|
import { Check, Copy, ExternalLink, Link2 } from "lucide-react";
|
|
@@ -19,12 +19,18 @@ import {
|
|
|
19
19
|
SelectValue,
|
|
20
20
|
} from "@/components/ui/select";
|
|
21
21
|
import { DownloadMenu } from "./DownloadMenu";
|
|
22
|
+
import { MessageList } from "./MessageList";
|
|
23
|
+
import { RangePicker } from "./RangePicker";
|
|
24
|
+
import { useCoarsePointer } from "@/components/Chat/composer/useCoarsePointer";
|
|
22
25
|
import { cn } from "@/lib/utils";
|
|
23
26
|
import { Switch } from "@/components/ui/switch";
|
|
24
27
|
import { writeClipboard } from "@/hooks/useCopyToClipboard";
|
|
25
28
|
import {
|
|
26
29
|
useCreateShare,
|
|
30
|
+
useShareMessages,
|
|
27
31
|
useSharePreview,
|
|
32
|
+
type PreviewItem,
|
|
33
|
+
type PreviewRequest,
|
|
28
34
|
type ShareExpiry,
|
|
29
35
|
type SharePreview,
|
|
30
36
|
} from "@/data/shares";
|
|
@@ -34,12 +40,21 @@ import {
|
|
|
34
40
|
DEFAULT_EXPIRY,
|
|
35
41
|
EXPIRIES,
|
|
36
42
|
EXPIRY_LABEL,
|
|
37
|
-
TYPE_LABEL,
|
|
38
43
|
formatBytes,
|
|
39
|
-
nothingLeft,
|
|
40
44
|
redactionSummary,
|
|
41
45
|
toggleDrop,
|
|
42
46
|
} from "@/lib/share/view";
|
|
47
|
+
import {
|
|
48
|
+
DEFAULT_PRESET,
|
|
49
|
+
activePreset,
|
|
50
|
+
clickRange,
|
|
51
|
+
inRange,
|
|
52
|
+
presetRange,
|
|
53
|
+
rangeFromHere,
|
|
54
|
+
rangeSummary,
|
|
55
|
+
type RangePreset,
|
|
56
|
+
type ShareRange,
|
|
57
|
+
} from "@/lib/share/range";
|
|
43
58
|
|
|
44
59
|
// Opened from a menu or an artifact: the redacted page exactly as it would be
|
|
45
60
|
// uploaded, with what to leave out, then Create link or Download.
|
|
@@ -52,7 +67,7 @@ export function ShareDialog() {
|
|
|
52
67
|
>
|
|
53
68
|
{target && (
|
|
54
69
|
<ShareBody
|
|
55
|
-
key={target.sessionId ?? target.artifactId}
|
|
70
|
+
key={`${target.sessionId ?? target.artifactId}:${target.fromId ?? ""}`}
|
|
56
71
|
target={{ ...target }}
|
|
57
72
|
/>
|
|
58
73
|
)}
|
|
@@ -69,6 +84,7 @@ function ShareBody({
|
|
|
69
84
|
artifactId?: string;
|
|
70
85
|
title: string;
|
|
71
86
|
version?: number;
|
|
87
|
+
fromId?: string;
|
|
72
88
|
};
|
|
73
89
|
}) {
|
|
74
90
|
const [tools, setTools] = useState(false);
|
|
@@ -80,22 +96,60 @@ function ShareBody({
|
|
|
80
96
|
const [follow, setFollow] = useState(false);
|
|
81
97
|
const [copied, setCopied] = useState(false);
|
|
82
98
|
const [tab, setTab] = useState<"page" | "markdown">("page");
|
|
99
|
+
const [rows, setRows] = useState<PreviewItem[] | null>(null);
|
|
100
|
+
const [range, setRange] = useState<ShareRange | null>(null);
|
|
101
|
+
const [anchor, setAnchor] = useState<string | null>(null);
|
|
102
|
+
const [armed, setArmed] = useState(false);
|
|
83
103
|
const previewReq = useSharePreview();
|
|
104
|
+
const listReq = useShareMessages();
|
|
84
105
|
const create = useCreateShare();
|
|
106
|
+
const coarse = useCoarsePointer();
|
|
85
107
|
const seq = useRef(0);
|
|
86
108
|
|
|
87
109
|
const { kind, sessionId, artifactId } = target;
|
|
110
|
+
const chat = kind === "chat";
|
|
111
|
+
|
|
112
|
+
// A chat opens on its last exchange, or on the message it was shared from.
|
|
88
113
|
useEffect(() => {
|
|
114
|
+
if (!chat) return;
|
|
115
|
+
let live = true;
|
|
116
|
+
listReq
|
|
117
|
+
.mutateAsync({ kind, sessionId, includeTools: true })
|
|
118
|
+
.then(({ items }) => {
|
|
119
|
+
if (!live) return;
|
|
120
|
+
const from = target.fromId ? rangeFromHere(items, target.fromId) : null;
|
|
121
|
+
setRows(items);
|
|
122
|
+
setRange(from ?? presetRange(items, DEFAULT_PRESET, new Date()));
|
|
123
|
+
setAnchor(from?.fromId ?? null);
|
|
124
|
+
})
|
|
125
|
+
.catch((err: Error) => live && setError(err.message));
|
|
126
|
+
return () => {
|
|
127
|
+
live = false;
|
|
128
|
+
};
|
|
129
|
+
// listReq is stable enough; re-running on it would loop.
|
|
130
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
131
|
+
}, [chat, kind, sessionId, target.fromId]);
|
|
132
|
+
|
|
133
|
+
const ready =
|
|
134
|
+
!chat || (rows !== null && range !== null) || rows?.length === 0;
|
|
135
|
+
const request: PreviewRequest = useMemo(
|
|
136
|
+
() => ({
|
|
137
|
+
kind,
|
|
138
|
+
sessionId,
|
|
139
|
+
artifactId,
|
|
140
|
+
includeTools: tools,
|
|
141
|
+
dropIds: dropped,
|
|
142
|
+
...(range ?? {}),
|
|
143
|
+
}),
|
|
144
|
+
[kind, sessionId, artifactId, tools, dropped, range]
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (!ready) return;
|
|
89
149
|
const mine = ++seq.current;
|
|
90
150
|
const timer = setTimeout(async () => {
|
|
91
151
|
try {
|
|
92
|
-
const p = await previewReq.mutateAsync(
|
|
93
|
-
kind,
|
|
94
|
-
sessionId,
|
|
95
|
-
artifactId,
|
|
96
|
-
includeTools: tools,
|
|
97
|
-
dropIds: dropped,
|
|
98
|
-
});
|
|
152
|
+
const p = await previewReq.mutateAsync(request);
|
|
99
153
|
if (mine === seq.current) {
|
|
100
154
|
setPreview(p);
|
|
101
155
|
setError(null);
|
|
@@ -107,7 +161,7 @@ function ShareBody({
|
|
|
107
161
|
return () => clearTimeout(timer);
|
|
108
162
|
// previewReq is stable enough; re-running on it would loop.
|
|
109
163
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
110
|
-
}, [
|
|
164
|
+
}, [ready, request]);
|
|
111
165
|
|
|
112
166
|
const toggle = (id: string) => setDropped((d) => toggleDrop(d, id));
|
|
113
167
|
|
|
@@ -115,11 +169,7 @@ function ShareBody({
|
|
|
115
169
|
if (!preview) return;
|
|
116
170
|
try {
|
|
117
171
|
const res = await create.mutateAsync({
|
|
118
|
-
|
|
119
|
-
sessionId,
|
|
120
|
-
artifactId,
|
|
121
|
-
includeTools: tools,
|
|
122
|
-
dropIds: dropped,
|
|
172
|
+
...request,
|
|
123
173
|
sharedAt: preview.sharedAt,
|
|
124
174
|
hash: preview.hash,
|
|
125
175
|
expiresIn,
|
|
@@ -149,8 +199,33 @@ function ShareBody({
|
|
|
149
199
|
};
|
|
150
200
|
|
|
151
201
|
const linked = preview?.connectLinked ?? false;
|
|
152
|
-
const
|
|
153
|
-
|
|
202
|
+
const visibleRows = useMemo(
|
|
203
|
+
() => (rows ?? []).filter((r) => tools || !r.tools),
|
|
204
|
+
[rows, tools]
|
|
205
|
+
);
|
|
206
|
+
const covered = useMemo(() => inRange(rows ?? [], range), [rows, range]);
|
|
207
|
+
const inside = useMemo(() => new Set(covered.map((r) => r.id)), [covered]);
|
|
208
|
+
const shared = visibleRows.filter(
|
|
209
|
+
(r) => inside.has(r.id) && !dropped.includes(r.id)
|
|
210
|
+
);
|
|
211
|
+
const empty = chat && !!rows && shared.length === 0;
|
|
212
|
+
const active = activePreset(rows ?? [], range, new Date());
|
|
213
|
+
|
|
214
|
+
const pickPreset = (preset: RangePreset) => {
|
|
215
|
+
const r = presetRange(rows ?? [], preset, new Date());
|
|
216
|
+
if (!r) return;
|
|
217
|
+
setRange(r);
|
|
218
|
+
setAnchor(r.fromId);
|
|
219
|
+
setArmed(false);
|
|
220
|
+
};
|
|
221
|
+
const pickRow = (id: string, shift: boolean) => {
|
|
222
|
+
const extend = shift || (coarse && armed);
|
|
223
|
+
setRange(
|
|
224
|
+
clickRange(rows ?? [], anchor ?? range?.fromId ?? null, id, extend)
|
|
225
|
+
);
|
|
226
|
+
if (!extend) setAnchor(id);
|
|
227
|
+
setArmed(coarse && !extend);
|
|
228
|
+
};
|
|
154
229
|
const summary = preview ? redactionSummary(preview.hits) : "";
|
|
155
230
|
|
|
156
231
|
return (
|
|
@@ -166,6 +241,15 @@ function ShareBody({
|
|
|
166
241
|
</DialogDescription>
|
|
167
242
|
</div>
|
|
168
243
|
|
|
244
|
+
{chat && rows && rows.length > 0 && (
|
|
245
|
+
<RangePicker
|
|
246
|
+
rows={rows}
|
|
247
|
+
active={active}
|
|
248
|
+
summary={rangeSummary(visibleRows.length, shared)}
|
|
249
|
+
onPick={pickPreset}
|
|
250
|
+
/>
|
|
251
|
+
)}
|
|
252
|
+
|
|
169
253
|
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto md:flex-row md:overflow-hidden">
|
|
170
254
|
<div className="flex min-h-[45vh] flex-1 flex-col gap-2 md:min-h-0">
|
|
171
255
|
<div className="flex gap-2" role="group" aria-label="Preview format">
|
|
@@ -210,31 +294,14 @@ function ShareBody({
|
|
|
210
294
|
<span>Include tool calls and thinking</span>
|
|
211
295
|
<Switch checked={tools} onCheckedChange={setTools} />
|
|
212
296
|
</label>
|
|
213
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
<input
|
|
222
|
-
type="checkbox"
|
|
223
|
-
className="h-4 w-4 shrink-0"
|
|
224
|
-
checked={!dropped.includes(item.id)}
|
|
225
|
-
onChange={() => toggle(item.id)}
|
|
226
|
-
/>
|
|
227
|
-
<span className="text-muted-foreground w-12 shrink-0 text-xs">
|
|
228
|
-
{TYPE_LABEL[item.type] ?? item.type}
|
|
229
|
-
</span>
|
|
230
|
-
<span className="min-w-0 flex-1 truncate">
|
|
231
|
-
{item.excerpt || "(empty)"}
|
|
232
|
-
</span>
|
|
233
|
-
</label>
|
|
234
|
-
</li>
|
|
235
|
-
))}
|
|
236
|
-
</ul>
|
|
237
|
-
</div>
|
|
297
|
+
<MessageList
|
|
298
|
+
rows={visibleRows}
|
|
299
|
+
inside={inside}
|
|
300
|
+
dropped={dropped}
|
|
301
|
+
touch={coarse}
|
|
302
|
+
onPick={pickRow}
|
|
303
|
+
onToggle={toggle}
|
|
304
|
+
/>
|
|
238
305
|
</>
|
|
239
306
|
)}
|
|
240
307
|
|
|
@@ -372,15 +439,9 @@ function ShareBody({
|
|
|
372
439
|
<div className="flex gap-2">
|
|
373
440
|
<DownloadMenu
|
|
374
441
|
preview={preview}
|
|
375
|
-
title={target.title}
|
|
442
|
+
title={target.title || preview?.title || ""}
|
|
376
443
|
disabled={!preview || preview.tooBig || empty}
|
|
377
|
-
request={
|
|
378
|
-
kind,
|
|
379
|
-
sessionId,
|
|
380
|
-
artifactId,
|
|
381
|
-
includeTools: tools,
|
|
382
|
-
dropIds: dropped,
|
|
383
|
-
}}
|
|
444
|
+
request={request}
|
|
384
445
|
/>
|
|
385
446
|
{linked && (
|
|
386
447
|
<Button
|
|
@@ -7,4 +7,6 @@ export const sessionArtifactKeys = {
|
|
|
7
7
|
// A page's versions, by any version's id; pageId keys the invalidation.
|
|
8
8
|
versions: (pageId: string) =>
|
|
9
9
|
[...sessionArtifactKeys.all, "versions", pageId] as const,
|
|
10
|
+
compare: (sessionId: string, from: string, to: string, mode: string) =>
|
|
11
|
+
[...sessionArtifactKeys.all, "compare", sessionId, from, to, mode] as const,
|
|
10
12
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useQuery } from "@tanstack/react-query";
|
|
2
2
|
import type { SessionArtifact } from "@/lib/session-artifacts/types";
|
|
3
3
|
import type { PageVersions } from "@/lib/artifacts/actions";
|
|
4
|
+
import type { CompareMode, CompareResult } from "@/lib/artifacts/compare/types";
|
|
4
5
|
import { sessionArtifactKeys } from "./keys";
|
|
5
6
|
|
|
6
7
|
export type { SessionArtifact } from "@/lib/session-artifacts/types";
|
|
@@ -76,3 +77,27 @@ export function useArtifactVersions(
|
|
|
76
77
|
staleTime: 30_000,
|
|
77
78
|
});
|
|
78
79
|
}
|
|
80
|
+
|
|
81
|
+
/** What changed between two versions of a page. */
|
|
82
|
+
export function useArtifactCompare(
|
|
83
|
+
sessionId: string,
|
|
84
|
+
fromId: string,
|
|
85
|
+
toId: string,
|
|
86
|
+
mode: CompareMode,
|
|
87
|
+
enabled = true
|
|
88
|
+
) {
|
|
89
|
+
return useQuery({
|
|
90
|
+
queryKey: sessionArtifactKeys.compare(sessionId, fromId, toId, mode),
|
|
91
|
+
queryFn: async (): Promise<Extract<CompareResult, { ok: true }>> => {
|
|
92
|
+
const res = await fetch(
|
|
93
|
+
`${artifactContentUrl(sessionId, fromId)}/compare?to=${encodeURIComponent(toId)}&mode=${mode}`
|
|
94
|
+
);
|
|
95
|
+
const data = await res.json().catch(() => ({}));
|
|
96
|
+
if (!res.ok) throw new Error(data.error || "Couldn't compare versions");
|
|
97
|
+
return data;
|
|
98
|
+
},
|
|
99
|
+
enabled: enabled && !!fromId && !!toId,
|
|
100
|
+
// Versions never change once saved.
|
|
101
|
+
staleTime: Infinity,
|
|
102
|
+
});
|
|
103
|
+
}
|