@saadnvd1/agent-os 0.3.768 → 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.
Files changed (77) hide show
  1. package/app/api/sessions/[id]/artifacts/[artifactId]/compare/route.ts +41 -0
  2. package/app/api/sessions/resolve/route.test.ts +81 -0
  3. package/app/api/sessions/resolve/route.ts +14 -0
  4. package/app/api/shares/preview/route.ts +6 -1
  5. package/app/api/shares/route.test.ts +51 -0
  6. package/app/page.tsx +1 -0
  7. package/components/Artifacts/ArtifactActions.tsx +27 -3
  8. package/components/Artifacts/ArtifactPreview.tsx +27 -12
  9. package/components/Artifacts/Compare/DiffView.tsx +116 -0
  10. package/components/Artifacts/Compare/SideBySide.tsx +60 -0
  11. package/components/Artifacts/Compare/VersionPicker.tsx +47 -0
  12. package/components/Artifacts/Compare/index.tsx +191 -0
  13. package/components/Chat/ChatPanel.tsx +3 -9
  14. package/components/Chat/EventRow.tsx +4 -4
  15. package/components/Chat/Messages.tsx +12 -6
  16. package/components/Chat/SessionAnchor.tsx +22 -0
  17. package/components/Chat/ShareFromHere.tsx +39 -0
  18. package/components/Chat/session-link.ts +21 -4
  19. package/components/CommandPalette/CommandPalette.tsx +5 -4
  20. package/components/CommandPalette/CommandsBody.tsx +97 -131
  21. package/components/CommandPalette/PaletteRows.tsx +79 -0
  22. package/components/CommandPalette/PaletteTabs.tsx +51 -0
  23. package/components/CommandPalette/conversations/ConversationSearch.tsx +13 -11
  24. package/components/CommandPalette/useAppCommands.ts +14 -0
  25. package/components/CommandPalette/useConversationRows.ts +55 -0
  26. package/components/Health/EnvFailures/SessionList.tsx +2 -2
  27. package/components/Pane/PaneMenu.tsx +1 -1
  28. package/components/SessionList/QueuedRow.tsx +4 -4
  29. package/components/SessionList/SessionRowMenu.tsx +1 -1
  30. package/components/SessionList/useSessionSwitching.ts +2 -2
  31. package/components/Share/MessageList.tsx +87 -0
  32. package/components/Share/RangePicker.tsx +59 -0
  33. package/components/Share/ShareDialog.tsx +113 -52
  34. package/components/Shares/SharedLinksPanel.tsx +3 -5
  35. package/data/session-artifacts/keys.ts +2 -0
  36. package/data/session-artifacts/queries.ts +25 -0
  37. package/data/shares/queries.ts +11 -0
  38. package/hooks/useCopyToClipboard.ts +5 -2
  39. package/hooks/usePaletteCommands.ts +4 -1
  40. package/hooks/useSessionHref.ts +20 -0
  41. package/hooks/useSessionUrl.ts +40 -11
  42. package/lib/artifacts/actions.ts +11 -1
  43. package/lib/artifacts/compare/compareVersions.ts +66 -0
  44. package/lib/artifacts/compare/diff.ts +178 -0
  45. package/lib/artifacts/compare/extractText.ts +64 -0
  46. package/lib/artifacts/compare/index.ts +11 -0
  47. package/lib/artifacts/compare/types.ts +44 -0
  48. package/lib/artifacts/match.ts +64 -0
  49. package/lib/artifacts/store.ts +39 -10
  50. package/lib/artifacts/tools.ts +9 -4
  51. package/lib/artifacts/versions.ts +2 -1
  52. package/lib/away/deploy.ts +39 -2
  53. package/lib/palette/registry.ts +71 -17
  54. package/lib/palette/sections.ts +143 -0
  55. package/lib/palette/tabs.ts +21 -0
  56. package/lib/palette/usage.ts +81 -0
  57. package/lib/reports/evidence/build.ts +4 -3
  58. package/lib/security/demo.ts +3 -0
  59. package/lib/session-audit/render.ts +2 -1
  60. package/lib/session-logs/gather.ts +2 -2
  61. package/lib/session-slug.ts +29 -0
  62. package/lib/session-url.ts +162 -40
  63. package/lib/sessions/resolve.ts +55 -0
  64. package/lib/settings-url.ts +1 -1
  65. package/lib/share/markdown.ts +3 -0
  66. package/lib/share/range.ts +189 -0
  67. package/lib/share/render.tsx +3 -0
  68. package/lib/share/service.ts +64 -19
  69. package/lib/share/snapshot.ts +39 -2
  70. package/lib/share/styles.ts +2 -0
  71. package/lib/share/view.ts +11 -0
  72. package/lib/welcome-url.ts +1 -1
  73. package/next.config.ts +4 -0
  74. package/package.json +1 -1
  75. package/stores/chatJump.ts +5 -1
  76. package/stores/palette.ts +13 -11
  77. package/stores/shareUi.ts +6 -1
@@ -36,7 +36,7 @@ export function useSessionSwitching(
36
36
  {
37
37
  id: "session.previous",
38
38
  title: "Previous session",
39
- group: "Sessions",
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: "Sessions",
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
- }, [kind, sessionId, artifactId, tools, dropped]);
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
- kind,
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 empty =
153
- kind === "chat" && !!preview && nothingLeft(preview.items, dropped);
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
- <div className="min-h-0">
214
- <div className="text-muted-foreground mb-1 text-xs font-medium">
215
- Messages
216
- </div>
217
- <ul className="max-h-64 divide-y overflow-y-auto rounded-lg border">
218
- {preview?.items.map((item) => (
219
- <li key={item.id}>
220
- <label className="flex min-h-11 cursor-pointer items-center gap-3 px-3 py-1.5 text-sm">
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
@@ -10,6 +10,7 @@ import {
10
10
  type SharedLinkRow,
11
11
  } from "@/data/shares";
12
12
  import { cn } from "@/lib/utils";
13
+ import { SessionAnchor } from "@/components/Chat/SessionAnchor";
13
14
 
14
15
  const when = (iso: string) =>
15
16
  new Date(iso).toLocaleDateString(undefined, {
@@ -44,12 +45,9 @@ function Row({ share }: { share: SharedLinkRow }) {
44
45
 
45
46
  const title =
46
47
  share.kind === "chat" && share.sessionId ? (
47
- <a
48
- href={`/?session=${encodeURIComponent(share.sessionId)}`}
49
- className="hover:underline"
50
- >
48
+ <SessionAnchor sessionId={share.sessionId} className="hover:underline">
51
49
  {share.title}
52
- </a>
50
+ </SessionAnchor>
53
51
  ) : (
54
52
  share.title
55
53
  );
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
2
  import type {
3
3
  CreatedShare,
4
+ MessageList,
4
5
  PreviewRequest,
5
6
  SharePreview,
6
7
  SharesResponse,
@@ -40,6 +41,16 @@ export function useSharePreview() {
40
41
  });
41
42
  }
42
43
 
44
+ /** Every message of a chat, for picking the part to share. */
45
+ export function useShareMessages() {
46
+ return useMutation({
47
+ mutationFn: async (req: PreviewRequest) =>
48
+ json<MessageList>(
49
+ await post("/api/shares/preview", { ...req, listOnly: true })
50
+ ),
51
+ });
52
+ }
53
+
43
54
  /** The share as a PDF, printed by the server. */
44
55
  export function useSharePdf() {
45
56
  return useMutation({
@@ -65,9 +65,12 @@ export async function writeClipboard(text: string): Promise<void> {
65
65
  }
66
66
 
67
67
  // A session's own address (lib/session-url), for a bookmark or another device.
68
- export async function copySessionLink(sessionId: string): Promise<void> {
68
+ export async function copySessionLink(session: {
69
+ id: string;
70
+ name: string;
71
+ }): Promise<void> {
69
72
  try {
70
- await writeClipboard(sessionLink(window.location.origin, sessionId));
73
+ await writeClipboard(sessionLink(window.location.origin, session));
71
74
  toast.success("Link copied");
72
75
  } catch {
73
76
  toast.error("Could not copy to clipboard");
@@ -17,7 +17,10 @@ export function usePaletteCommands(
17
17
  });
18
18
  const shape = commands
19
19
  ? commands
20
- .map((c) => `${c.id}\u0000${c.title}\u0000${c.hint ?? ""}`)
20
+ .map(
21
+ (c) =>
22
+ `${c.id}\u0000${c.title}\u0000${c.hint ?? ""}\u0000${c.status ?? ""}`
23
+ )
21
24
  .join("\u0001")
22
25
  : null;
23
26
  useEffect(() => {
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import { useContext } from "react";
4
+ import { QueryClientContext } from "@tanstack/react-query";
5
+ import { sessionKeys } from "@/data/sessions/keys";
6
+ import type { SessionsResponse } from "@/data/sessions/queries";
7
+ import { sessionPath } from "@/lib/session-url";
8
+
9
+ /**
10
+ * A session's address from just its id: readable when the list has its name,
11
+ * `/s/<id8>` otherwise (opening that fills the slug in). Works without a
12
+ * query client too: a chat rendered to static markup (an export, a share).
13
+ */
14
+ export function useSessionHref(id: string): string {
15
+ const list = useContext(QueryClientContext)?.getQueryData<SessionsResponse>(
16
+ sessionKeys.list()
17
+ );
18
+ const name = list?.sessions.find((s) => s.id === id)?.name;
19
+ return sessionPath({ id, name });
20
+ }
@@ -3,7 +3,12 @@
3
3
  import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
4
4
  import { toast } from "sonner";
5
5
  import type { Session } from "@/lib/db";
6
- import { SessionUrlSync, locateSession } from "@/lib/session-url";
6
+ import {
7
+ SessionUrlSync,
8
+ locateSession,
9
+ pickSession,
10
+ type SessionRef,
11
+ } from "@/lib/session-url";
7
12
  import { usePanes } from "@/contexts/PaneContext";
8
13
 
9
14
  interface Options {
@@ -16,9 +21,9 @@ interface Options {
16
21
  }
17
22
 
18
23
  /**
19
- * The address bar names the focused session (`/?session=<id>`), and an
20
- * address opens its session: on load, after pairing, and on back/forward.
21
- * With split panes it follows the focused pane.
24
+ * The address bar names the focused session (`/s/<name>-<id8>`, following
25
+ * renames), and an address opens its session: on load, after pairing, and on
26
+ * back/forward. With split panes it follows the focused pane.
22
27
  */
23
28
  export function useSessionUrl({
24
29
  sessions,
@@ -78,23 +83,22 @@ export function useSessionUrl({
78
83
  replace: (href) => window.history.replaceState(null, "", href),
79
84
  },
80
85
  {
81
- isKnown: (id) => latest.current.sessions.some((s) => s.id === id),
86
+ find: (ref) => pickSession(ref, latest.current.sessions)?.id ?? null,
87
+ nameOf: (id) =>
88
+ latest.current.sessions.find((s) => s.id === id)?.name ?? null,
82
89
  open: (id) => {
83
90
  const session = latest.current.sessions.find((s) => s.id === id);
84
91
  if (session) reveal(session);
85
92
  },
86
93
  // Made a moment ago and not in the list yet: ask once more. Archived,
87
94
  // merged and dropped sessions aren't listed and stay not found.
88
- openUnlisted: async (id) => {
95
+ openUnlisted: async (ref) => {
89
96
  const fresh = await latest.current.reloadSessions();
90
- const session = fresh.find((s) => s.id === id);
97
+ const session = pickSession(ref, fresh);
91
98
  if (session) reveal(session);
92
99
  return !!session;
93
100
  },
94
- notFound: () =>
95
- toast.error("Session not found", {
96
- description: "It may have been archived or deleted.",
97
- }),
101
+ notFound: (ref) => void explainNotFound(ref),
98
102
  }
99
103
  );
100
104
  return syncRef.current;
@@ -112,6 +116,12 @@ export function useSessionUrl({
112
116
  getSync().focusChanged(focusedId);
113
117
  }, [getSync, focusedId]);
114
118
 
119
+ // A rename (by hand, or the title a first turn gives it) moves the slug.
120
+ const focusedName = sessions.find((s) => s.id === focusedId)?.name ?? null;
121
+ useEffect(() => {
122
+ if (focusedName !== null) getSync().renamed();
123
+ }, [getSync, focusedName]);
124
+
115
125
  const ready = hydrated && loaded;
116
126
  useEffect(() => {
117
127
  if (ready) void getSync().start();
@@ -123,3 +133,22 @@ export function useSessionUrl({
123
133
  return () => window.removeEventListener("popstate", onPop);
124
134
  }, [getSync]);
125
135
  }
136
+
137
+ // An archived session says so, by name; anything else isn't found.
138
+ async function explainNotFound(ref: SessionRef): Promise<void> {
139
+ const params = new URLSearchParams(
140
+ "id" in ref ? { ref: ref.id } : { ref: ref.short, slug: ref.slug ?? "" }
141
+ );
142
+ const found = await fetch(`/api/sessions/resolve?${params}`)
143
+ .then((r) => (r.ok ? r.json() : null))
144
+ .catch(() => null);
145
+ if (found?.archived) {
146
+ toast.error(`${found.name} is on the Done shelf`, {
147
+ description: "Bring it back from there to open it.",
148
+ });
149
+ return;
150
+ }
151
+ toast.error("Session not found", {
152
+ description: "It may have been archived or deleted.",
153
+ });
154
+ }