@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
@@ -1,50 +1,152 @@
1
- // Every session has its own address: `/?session=<id>`. The id, not the name:
2
- // names change, ids don't. Nothing here grants access; the page behind the
3
- // address is behind the device gate like any other.
1
+ // Every session has its own address: `/s/<slug>-<id8>`, the slug made from
2
+ // its name and id8 the first group of its uuid. Only the id resolves: the
3
+ // slug is rebuilt from the current name, so a rename never breaks a link,
4
+ // and a stale one is rewritten in place. `/?session=<id>`, the address before,
5
+ // still opens and is rewritten the same way. Nothing here grants access; the
6
+ // page behind the address is behind the device gate like any other.
4
7
 
5
8
  import { getAllPaneIds, type PaneState } from "./panes";
9
+ import { isUuid, sessionSlug, shortId, SHORT_ID_LEN } from "./session-slug";
6
10
 
7
11
  export const SESSION_PARAM = "session";
12
+ export const SESSION_PATH = "/s/";
8
13
 
9
14
  // Ids are uuids today; anything id-shaped is accepted so other machines'
10
15
  // sessions fit too. Everything else in the address is ignored.
11
16
  const ID_SHAPE = /^[A-Za-z0-9_-]{1,128}$/;
17
+ const SHORT = new RegExp(`^[0-9a-f]{${SHORT_ID_LEN}}$`);
18
+ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
19
+ // The one path segment of `/s/<ref>`.
20
+ const REF_PATH = /^\/s\/([A-Za-z0-9-]{1,200})$/;
12
21
 
13
22
  // The site-relative origin used to parse paths with URL.
14
23
  const BASE = "http://agentos.invalid";
15
24
 
16
- export function sessionIdFromHref(href: string): string | null {
17
- const id = new URL(href, BASE).searchParams.get(SESSION_PARAM);
18
- return id && ID_SHAPE.test(id) ? id : null;
25
+ /** What an address names: a whole id, or a short id and its (cosmetic) slug. */
26
+ export type SessionRef =
27
+ { id: string } | { short: string; slug: string | null };
28
+
29
+ /** A session as links need it: the name only makes the slug. */
30
+ export interface SessionLinkable {
31
+ id: string;
32
+ name?: string | null;
33
+ }
34
+
35
+ function refFromSegment(segment: string): SessionRef | null {
36
+ if (isUuid(segment)) return { id: segment.toLowerCase() };
37
+ const lower = segment.toLowerCase();
38
+ const dash = lower.lastIndexOf("-");
39
+ const short = lower.slice(dash + 1);
40
+ if (!SHORT.test(short)) return null;
41
+ const slug = dash > 0 ? lower.slice(0, dash) : null;
42
+ if (slug !== null && !SLUG.test(slug)) return null;
43
+ return { short, slug };
44
+ }
45
+
46
+ export function sessionRefFromHref(href: string): SessionRef | null {
47
+ const url = new URL(href, BASE);
48
+ const m = REF_PATH.exec(url.pathname);
49
+ if (m) return refFromSegment(m[1]);
50
+ const id = url.searchParams.get(SESSION_PARAM);
51
+ return id && ID_SHAPE.test(id) ? { id } : null;
52
+ }
53
+
54
+ /** Whether a ref could name this id (the slug isn't consulted). */
55
+ export function refMatches(ref: SessionRef, id: string): boolean {
56
+ if ("id" in ref) return ref.id === id || ref.id === id.toLowerCase();
57
+ return isUuid(id) && id.toLowerCase().startsWith(ref.short);
58
+ }
59
+
60
+ /**
61
+ * The session a ref names among these: the one id, else the one with the
62
+ * prefix, else (two sharing a prefix) the one whose slug matches, then the
63
+ * newest.
64
+ */
65
+ export function pickSession<
66
+ T extends { id: string; name: string; created_at?: string | null },
67
+ >(ref: SessionRef, sessions: readonly T[]): T | null {
68
+ const found = sessions.filter((s) => refMatches(ref, s.id));
69
+ if (found.length <= 1) return found[0] ?? null;
70
+ if ("short" in ref && ref.slug) {
71
+ const bySlug = found.filter((s) => sessionSlug(s.name) === ref.slug);
72
+ if (bySlug.length === 1) return bySlug[0];
73
+ if (bySlug.length > 1) return newest(bySlug);
74
+ }
75
+ return newest(found);
76
+ }
77
+
78
+ const newest = <T extends { created_at?: string | null }>(xs: T[]): T =>
79
+ xs.reduce((a, b) => ((b.created_at ?? "") > (a.created_at ?? "") ? b : a));
80
+
81
+ /**
82
+ * A session's address: `/s/<slug>-<id8>`, or `/s/<id8>` when the name isn't
83
+ * at hand (opening it fills the slug in). An id that isn't a uuid keeps the
84
+ * old `/?session=<id>`.
85
+ */
86
+ export function sessionPath(s: SessionLinkable): string {
87
+ if (!isUuid(s.id)) return `/?${SESSION_PARAM}=${encodeURIComponent(s.id)}`;
88
+ const short = shortId(s.id);
89
+ return s.name == null
90
+ ? `${SESSION_PATH}${short}`
91
+ : `${SESSION_PATH}${sessionSlug(s.name)}-${short}`;
92
+ }
93
+
94
+ /** The address a ref names as it stands, slug and all. */
95
+ export function refPath(ref: SessionRef): string {
96
+ if ("id" in ref) return sessionPath({ id: ref.id });
97
+ const slug = ref.slug ? `${ref.slug}-` : "";
98
+ return `${SESSION_PATH}${slug}${ref.short}`;
19
99
  }
20
100
 
21
- /** The same address with the session set (or removed), as path+query+hash. */
22
- export function hrefWithSession(href: string, id: string | null): string {
101
+ function hrefWithPath(href: string, path: string | null): string {
23
102
  const url = new URL(href, BASE);
24
- if (id) url.searchParams.set(SESSION_PARAM, id);
25
- else url.searchParams.delete(SESSION_PARAM);
103
+ url.searchParams.delete(SESSION_PARAM);
104
+ const target = new URL(path ?? "/", BASE);
105
+ url.pathname = target.pathname;
106
+ target.searchParams.forEach((v, k) => url.searchParams.set(k, v));
26
107
  return url.pathname + url.search + url.hash;
27
108
  }
28
109
 
110
+ /**
111
+ * The same address showing this session (or none), as path+query+hash. Every
112
+ * other query param (`?settings=`, `?welcome=`) stays.
113
+ */
114
+ export function hrefWithSession(
115
+ href: string,
116
+ s: SessionLinkable | null
117
+ ): string {
118
+ return hrefWithPath(href, s ? sessionPath(s) : null);
119
+ }
120
+
121
+ /** The same address showing the session a ref names. */
122
+ export function hrefWithRef(href: string, ref: SessionRef): string {
123
+ return hrefWithPath(href, refPath(ref));
124
+ }
125
+
29
126
  /** The session a link to this same instance opens, or null for any other link. */
30
- export function inAppSessionId(href: string, origin: string): string | null {
127
+ export function inAppSessionRef(
128
+ href: string,
129
+ origin: string
130
+ ): SessionRef | null {
31
131
  let url: URL;
32
132
  try {
33
133
  url = new URL(href);
34
134
  } catch {
35
135
  return null;
36
136
  }
37
- if (url.origin !== origin || url.pathname !== "/") return null;
38
- return sessionIdFromHref(href);
137
+ if (url.origin !== origin) return null;
138
+ if (url.pathname !== "/" && !url.pathname.startsWith(SESSION_PATH))
139
+ return null;
140
+ return sessionRefFromHref(href);
39
141
  }
40
142
 
41
- export function sessionLink(origin: string, id: string): string {
42
- return `${origin}/?${SESSION_PARAM}=${encodeURIComponent(id)}`;
143
+ export function sessionLink(origin: string, s: SessionLinkable): string {
144
+ return `${origin}${sessionPath(s)}`;
43
145
  }
44
146
 
45
147
  /**
46
148
  * The address to come back to after pairing, or null. Only the app's own
47
- * page with a query survives: what a link to a session is. Checked after
149
+ * page survives: `/` with a query, or a session's `/s/<ref>`. Checked after
48
150
  * parsing, as `/.//host` and the like resolve to another host.
49
151
  */
50
152
  export function safeNextPath(raw: string | null | undefined): string | null {
@@ -55,7 +157,9 @@ export function safeNextPath(raw: string | null | undefined): string | null {
55
157
  } catch {
56
158
  return null;
57
159
  }
58
- if (url.origin !== BASE || url.pathname !== "/" || !url.search) return null;
160
+ if (url.origin !== BASE) return null;
161
+ if (REF_PATH.test(url.pathname)) return url.pathname + url.search;
162
+ if (url.pathname !== "/" || !url.search) return null;
59
163
  return "/" + url.search;
60
164
  }
61
165
 
@@ -94,8 +198,10 @@ export interface SessionUrlEnv {
94
198
  }
95
199
 
96
200
  export interface SessionUrlDeps {
97
- /** A session the sidebar lists. */
98
- isKnown(id: string): boolean;
201
+ /** The listed session a ref names, or null. */
202
+ find(ref: SessionRef): string | null;
203
+ /** A listed session's current name, for its address. */
204
+ nameOf(id: string): string | null;
99
205
  /** Show a listed session. */
100
206
  open(id: string): void;
101
207
  /**
@@ -103,9 +209,9 @@ export interface SessionUrlDeps {
103
209
  * found and opened. Archived, merged and dropped sessions aren't listed and
104
210
  * stay not found.
105
211
  */
106
- openUnlisted(id: string): Promise<boolean>;
212
+ openUnlisted(ref: SessionRef): Promise<boolean>;
107
213
  /** The address named a session that can't be opened. */
108
- notFound(id: string): void;
214
+ notFound(ref: SessionRef): void;
109
215
  }
110
216
 
111
217
  /**
@@ -115,13 +221,15 @@ export interface SessionUrlDeps {
115
221
  *
116
222
  * A switch away from a live session pushes an entry, so back returns to it.
117
223
  * Anything else replaces: the first sync, leaving a session that was archived
118
- * or deleted, and leaving a sessionless address.
224
+ * or deleted, leaving a sessionless address, and rewriting an address that
225
+ * names the shown session another way (an old `?session=`, a stale slug, a
226
+ * rename).
119
227
  */
120
228
  export class SessionUrlSync {
121
229
  private started = false;
122
230
  // A session the address asked for, until it's the one focused (or the
123
231
  // open plainly didn't land).
124
- private pending: string | null = null;
232
+ private pending: SessionRef | null = null;
125
233
  private pendingUntil = 0;
126
234
  private focused: string | null = null;
127
235
 
@@ -134,8 +242,8 @@ export class SessionUrlSync {
134
242
  /** Once panes and sessions are loaded: follow the address it opened with. */
135
243
  async start(): Promise<void> {
136
244
  if (this.started) return;
137
- const id = sessionIdFromHref(this.env.href());
138
- if (id) await this.go(id);
245
+ const ref = sessionRefFromHref(this.env.href());
246
+ if (ref) await this.go(ref);
139
247
  this.started = true;
140
248
  if (!this.pending) this.sync();
141
249
  }
@@ -144,42 +252,56 @@ export class SessionUrlSync {
144
252
  focusChanged(id: string | null): void {
145
253
  this.focused = id;
146
254
  if (this.pending && this.now() < this.pendingUntil) {
147
- if (id !== this.pending) return;
255
+ if (!id || !refMatches(this.pending, id)) return;
148
256
  }
149
257
  this.pending = null;
150
258
  if (this.started) this.sync();
151
259
  }
152
260
 
261
+ /** The focused session's name changed: the address takes the new slug. */
262
+ renamed(): void {
263
+ if (this.started && !this.pending) this.write("replace");
264
+ }
265
+
153
266
  /** Back or forward landed on another entry. */
154
267
  async popstate(): Promise<void> {
155
268
  if (!this.started) return;
156
- const id = sessionIdFromHref(this.env.href());
157
- if (id && id !== this.focused) await this.go(id);
269
+ const ref = sessionRefFromHref(this.env.href());
270
+ if (ref) {
271
+ const id = this.deps.find(ref);
272
+ if (!id || id !== this.focused) await this.go(ref);
273
+ }
158
274
  // An entry with no session, or one that couldn't open: say what's shown.
159
275
  if (!this.pending) this.write("replace");
160
276
  }
161
277
 
162
- private async go(id: string): Promise<void> {
163
- if (id === this.focused) return;
164
- this.pending = id;
278
+ private async go(ref: SessionRef): Promise<void> {
279
+ const id = this.deps.find(ref);
280
+ if (id && id === this.focused) return;
281
+ this.pending = ref;
165
282
  this.pendingUntil = this.now() + PENDING_MS;
166
- if (this.deps.isKnown(id)) return this.deps.open(id);
167
- const opened = await this.deps.openUnlisted(id).catch(() => false);
283
+ if (id) return this.deps.open(id);
284
+ const opened = await this.deps.openUnlisted(ref).catch(() => false);
168
285
  if (opened) return;
169
- if (this.pending === id) this.pending = null;
170
- this.deps.notFound(id);
286
+ if (this.pending === ref) this.pending = null;
287
+ this.deps.notFound(ref);
171
288
  }
172
289
 
173
290
  private sync(): void {
174
- const current = sessionIdFromHref(this.env.href());
175
- if (current === this.focused) return;
176
- this.write(current && this.deps.isKnown(current) ? "push" : "replace");
291
+ const ref = sessionRefFromHref(this.env.href());
292
+ const current = ref && this.deps.find(ref);
293
+ this.write(current && current !== this.focused ? "push" : "replace");
177
294
  }
178
295
 
179
296
  private write(mode: "push" | "replace"): void {
180
297
  const href = this.env.href();
181
- if (sessionIdFromHref(href) === this.focused) return;
182
- const next = hrefWithSession(href, this.focused);
298
+ const id = this.focused;
299
+ const next = hrefWithSession(
300
+ href,
301
+ id ? { id, name: this.deps.nameOf(id) ?? undefined } : null
302
+ );
303
+ const url = new URL(href, BASE);
304
+ if (next === url.pathname + url.search + url.hash) return;
183
305
  if (mode === "push") this.env.push(next);
184
306
  else this.env.replace(next);
185
307
  }
@@ -0,0 +1,55 @@
1
+ // The session a link's ref names, archived ones included: what the page asks
2
+ // when its list doesn't have it (lib/session-url).
3
+
4
+ import { db } from "../db";
5
+ import { pickSession, type SessionRef } from "../session-url";
6
+ import { isUuid, SHORT_ID_LEN } from "../session-slug";
7
+
8
+ export interface ResolvedSession {
9
+ id: string;
10
+ name: string;
11
+ archived: boolean;
12
+ }
13
+
14
+ interface Row {
15
+ id: string;
16
+ name: string;
17
+ archived_at: string | null;
18
+ created_at: string | null;
19
+ }
20
+
21
+ const ID_SHAPE = /^[A-Za-z0-9_-]{1,128}$/;
22
+ const SHORT = new RegExp(`^[0-9a-f]{${SHORT_ID_LEN}}$`);
23
+ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
24
+
25
+ /** The ref a request's `ref` and `slug` params name, or null when malformed. */
26
+ export function refFromParams(
27
+ ref: string | null,
28
+ slug: string | null
29
+ ): SessionRef | null {
30
+ if (!ref) return null;
31
+ if (SHORT.test(ref)) {
32
+ return { short: ref, slug: slug && SLUG.test(slug) ? slug : null };
33
+ }
34
+ // A whole id; never a shorter prefix, which would let a caller walk the table.
35
+ if (isUuid(ref) || (ID_SHAPE.test(ref) && ref.length > SHORT_ID_LEN))
36
+ return { id: ref };
37
+ return null;
38
+ }
39
+
40
+ export function resolveSessionRef(ref: SessionRef): ResolvedSession | null {
41
+ const cols = `id, name, archived_at, created_at`;
42
+ const rows =
43
+ "id" in ref
44
+ ? (db
45
+ .prepare(`SELECT ${cols} FROM sessions WHERE id = ?`)
46
+ .all(ref.id) as Row[])
47
+ : // A range on the key, so the index finds the prefix.
48
+ (db
49
+ .prepare(`SELECT ${cols} FROM sessions WHERE id >= ? AND id < ?`)
50
+ .all(ref.short, `${ref.short}~`) as Row[]);
51
+ const hit = pickSession(ref, rows);
52
+ return hit
53
+ ? { id: hit.id, name: hit.name, archived: hit.archived_at !== null }
54
+ : null;
55
+ }
@@ -1,6 +1,6 @@
1
1
  // Settings has its own address: `/?settings=<section>`, or a bare
2
2
  // `?settings` for the list (a phone) or the first section. It sits beside
3
- // `?session=`, so a link can name both.
3
+ // a session's address (`/s/<name>-<id8>`), so a link can name both.
4
4
 
5
5
  export const SETTINGS_PARAM = "settings";
6
6
 
@@ -12,6 +12,7 @@ import { toMarkdown } from "mdast-util-to-markdown";
12
12
  import type { Nodes as HastNodes } from "hast";
13
13
  import type { Nodes, Parents, RootContent } from "mdast";
14
14
  import type { Snapshot, SnapshotItem } from "./snapshot";
15
+ import { EARLIER_NOT_SHARED, LATER_NOT_SHARED } from "./range";
15
16
 
16
17
  const DROP_TAGS = new Set([
17
18
  "script",
@@ -119,7 +120,9 @@ export function chatToMarkdown(
119
120
  ): string {
120
121
  const parts = [
121
122
  agentHeader(snap.title, opts.sharedAt),
123
+ ...(snap.cutBefore ? [`*${EARLIER_NOT_SHARED}.*`] : []),
122
124
  ...snap.items.map(itemToMarkdown),
125
+ ...(snap.cutAfter ? [`*${LATER_NOT_SHARED}.*`] : []),
123
126
  ];
124
127
  if (snap.toolsHidden) parts.push("*Tool calls and thinking were left out.*");
125
128
  return parts.join("\n\n") + "\n";
@@ -0,0 +1,189 @@
1
+ // Which part of a chat a share covers. Pure, imports nothing outside itself,
2
+ // so the dialog and the server resolve a range the same way.
3
+
4
+ /** A range of messages by id, both ends included. */
5
+ export interface ShareRange {
6
+ fromId: string;
7
+ toId: string;
8
+ }
9
+
10
+ export interface RangeRow {
11
+ id: string;
12
+ type: string;
13
+ createdAt?: number;
14
+ }
15
+
16
+ export type RangePreset = "last" | "last5" | "today" | "all";
17
+
18
+ export const PRESETS: { id: RangePreset; label: string }[] = [
19
+ { id: "last", label: "Last exchange" },
20
+ { id: "last5", label: "Last 5 exchanges" },
21
+ { id: "today", label: "Today" },
22
+ { id: "all", label: "Whole chat" },
23
+ ];
24
+
25
+ export const EARLIER_NOT_SHARED = "Earlier messages not shared";
26
+ export const LATER_NOT_SHARED = "Later messages not shared";
27
+
28
+ export const DEFAULT_PRESET: RangePreset = "last";
29
+
30
+ /** Positions in `ids` that a range covers, or null when an end isn't there or they're backwards. A missing end means the chat's own start or end. */
31
+ export function windowOf(
32
+ ids: string[],
33
+ fromId?: string,
34
+ toId?: string
35
+ ): { start: number; end: number } | null {
36
+ const start = fromId === undefined ? 0 : ids.indexOf(fromId);
37
+ const end = toId === undefined ? ids.length - 1 : ids.indexOf(toId);
38
+ if (start < 0 || end < 0 || start > end) return null;
39
+ return { start, end };
40
+ }
41
+
42
+ /** Where each exchange begins: a user prompt. Rows before the first prompt are an exchange of their own. */
43
+ export function exchangeStarts(rows: RangeRow[]): number[] {
44
+ const starts = rows.flatMap((r, i) => (r.type === "user" ? [i] : []));
45
+ if (rows.length && starts[0] !== 0) starts.unshift(0);
46
+ return starts;
47
+ }
48
+
49
+ const startOfDay = (now: Date) =>
50
+ new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
51
+
52
+ /** The range a preset means, or null when it has nothing to pick (no messages today). */
53
+ export function presetRange(
54
+ rows: RangeRow[],
55
+ preset: RangePreset,
56
+ now: Date
57
+ ): ShareRange | null {
58
+ if (!rows.length) return null;
59
+ const last = rows[rows.length - 1].id;
60
+ const starts = exchangeStarts(rows);
61
+ let from: number;
62
+ switch (preset) {
63
+ case "last":
64
+ from = starts[starts.length - 1];
65
+ break;
66
+ case "last5":
67
+ from = starts[Math.max(0, starts.length - 5)];
68
+ break;
69
+ case "all":
70
+ from = 0;
71
+ break;
72
+ case "today": {
73
+ const day = startOfDay(now);
74
+ const todays = rows
75
+ .map((r, i) => ({ r, i }))
76
+ .filter(({ r }) => (r.createdAt ?? 0) >= day);
77
+ if (!todays.length) return null;
78
+ from = (todays.find(({ r }) => r.type === "user") ?? todays[0]).i;
79
+ break;
80
+ }
81
+ }
82
+ return { fromId: rows[from].id, toId: last };
83
+ }
84
+
85
+ /** The preset a range equals, or null for a custom one. */
86
+ export function activePreset(
87
+ rows: RangeRow[],
88
+ range: ShareRange | null,
89
+ now: Date
90
+ ): RangePreset | null {
91
+ if (!range) return null;
92
+ return (
93
+ PRESETS.find((p) => {
94
+ const r = presetRange(rows, p.id, now);
95
+ return r && r.fromId === range.fromId && r.toId === range.toId;
96
+ })?.id ?? null
97
+ );
98
+ }
99
+
100
+ /** From a message to the latest one. */
101
+ export function rangeFromHere(rows: RangeRow[], id: string): ShareRange | null {
102
+ return rows.some((r) => r.id === id)
103
+ ? { fromId: id, toId: rows[rows.length - 1].id }
104
+ : null;
105
+ }
106
+
107
+ /** A click sets the range to one message; shift-click stretches it from the anchor to the clicked one, either direction. */
108
+ export function clickRange(
109
+ rows: RangeRow[],
110
+ anchorId: string | null,
111
+ clickedId: string,
112
+ extend: boolean
113
+ ): ShareRange {
114
+ const a = extend && anchorId ? rows.findIndex((r) => r.id === anchorId) : -1;
115
+ const b = rows.findIndex((r) => r.id === clickedId);
116
+ if (a < 0 || b < 0) return { fromId: clickedId, toId: clickedId };
117
+ const [lo, hi] = a <= b ? [a, b] : [b, a];
118
+ return { fromId: rows[lo].id, toId: rows[hi].id };
119
+ }
120
+
121
+ /** Rows the range covers (all of them when there is none yet). */
122
+ export function inRange<T extends RangeRow>(
123
+ rows: T[],
124
+ range: ShareRange | null
125
+ ): T[] {
126
+ if (!range) return rows;
127
+ const w = windowOf(
128
+ rows.map((r) => r.id),
129
+ range.fromId,
130
+ range.toId
131
+ );
132
+ return w ? rows.slice(w.start, w.end + 1) : [];
133
+ }
134
+
135
+ const clock = (d: Date) => {
136
+ const h = d.getHours();
137
+ const m = String(d.getMinutes()).padStart(2, "0");
138
+ return { text: `${h % 12 || 12}:${m}`, ampm: h < 12 ? "AM" : "PM" };
139
+ };
140
+
141
+ const sameDay = (a: Date, b: Date) =>
142
+ a.getFullYear() === b.getFullYear() &&
143
+ a.getMonth() === b.getMonth() &&
144
+ a.getDate() === b.getDate();
145
+
146
+ const MONTHS = [
147
+ "Jan",
148
+ "Feb",
149
+ "Mar",
150
+ "Apr",
151
+ "May",
152
+ "Jun",
153
+ "Jul",
154
+ "Aug",
155
+ "Sep",
156
+ "Oct",
157
+ "Nov",
158
+ "Dec",
159
+ ];
160
+
161
+ const dayLabel = (d: Date) => `${MONTHS[d.getMonth()]} ${d.getDate()}`;
162
+
163
+ /** "2:14–2:40 PM", "11:50 AM–12:10 PM", or with dates when it crosses a day. */
164
+ export function timeSpan(from: number, to: number): string {
165
+ const a = new Date(from);
166
+ const b = new Date(to);
167
+ const ca = clock(a);
168
+ const cb = clock(b);
169
+ if (!sameDay(a, b))
170
+ return `${dayLabel(a)}, ${ca.text} ${ca.ampm} – ${dayLabel(b)}, ${cb.text} ${cb.ampm}`;
171
+ if (ca.text === cb.text && ca.ampm === cb.ampm)
172
+ return `${ca.text} ${ca.ampm}`;
173
+ return ca.ampm === cb.ampm
174
+ ? `${ca.text}–${cb.text} ${cb.ampm}`
175
+ : `${ca.text} ${ca.ampm}–${cb.text} ${cb.ampm}`;
176
+ }
177
+
178
+ /** "Sharing 6 of 120 messages · 2:14–2:40 PM". */
179
+ export function rangeSummary(total: number, shared: RangeRow[]): string {
180
+ if (!shared.length) return "Nothing selected to share";
181
+ const noun = total === 1 ? "message" : "messages";
182
+ const times = shared
183
+ .map((r) => r.createdAt)
184
+ .filter((t): t is number => typeof t === "number" && t > 0);
185
+ const base = `Sharing ${shared.length} of ${total} ${noun}`;
186
+ return times.length
187
+ ? `${base} · ${timeSpan(Math.min(...times), Math.max(...times))}`
188
+ : base;
189
+ }
@@ -14,6 +14,7 @@ import remarkGfm from "remark-gfm";
14
14
  import { Prism as Highlighter } from "react-syntax-highlighter";
15
15
  import type { Snapshot, SnapshotItem } from "./snapshot";
16
16
  import { STYLES } from "./styles";
17
+ import { EARLIER_NOT_SHARED, LATER_NOT_SHARED } from "./range";
17
18
 
18
19
  export const MAX_SHARE_BYTES = 5 * 1024 * 1024;
19
20
 
@@ -123,9 +124,11 @@ export function renderChatSnapshot(
123
124
  ): string {
124
125
  const body = renderToStaticMarkup(
125
126
  <>
127
+ {snapshot.cutBefore && <div className="cut">{EARLIER_NOT_SHARED}</div>}
126
128
  {snapshot.items.map((item) => (
127
129
  <Item key={item.id} item={item} />
128
130
  ))}
131
+ {snapshot.cutAfter && <div className="cut">{LATER_NOT_SHARED}</div>}
129
132
  </>
130
133
  );
131
134
  const date = opts.sharedAt.slice(0, 10);