@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.
Files changed (52) hide show
  1. package/app/api/sessions/[id]/artifacts/[artifactId]/compare/route.ts +41 -0
  2. package/app/api/shares/preview/route.ts +6 -1
  3. package/app/api/shares/route.test.ts +51 -0
  4. package/app/page.tsx +1 -0
  5. package/components/Artifacts/ArtifactActions.tsx +27 -3
  6. package/components/Artifacts/ArtifactPreview.tsx +27 -12
  7. package/components/Artifacts/Compare/DiffView.tsx +116 -0
  8. package/components/Artifacts/Compare/SideBySide.tsx +60 -0
  9. package/components/Artifacts/Compare/VersionPicker.tsx +47 -0
  10. package/components/Artifacts/Compare/index.tsx +191 -0
  11. package/components/Chat/Messages.tsx +6 -0
  12. package/components/Chat/ShareFromHere.tsx +39 -0
  13. package/components/CommandPalette/CommandPalette.tsx +5 -4
  14. package/components/CommandPalette/CommandsBody.tsx +97 -131
  15. package/components/CommandPalette/PaletteRows.tsx +79 -0
  16. package/components/CommandPalette/PaletteTabs.tsx +51 -0
  17. package/components/CommandPalette/conversations/ConversationSearch.tsx +13 -11
  18. package/components/CommandPalette/useAppCommands.ts +14 -0
  19. package/components/CommandPalette/useConversationRows.ts +55 -0
  20. package/components/SessionList/useSessionSwitching.ts +2 -2
  21. package/components/Share/MessageList.tsx +87 -0
  22. package/components/Share/RangePicker.tsx +59 -0
  23. package/components/Share/ShareDialog.tsx +113 -52
  24. package/data/session-artifacts/keys.ts +2 -0
  25. package/data/session-artifacts/queries.ts +25 -0
  26. package/data/shares/queries.ts +11 -0
  27. package/hooks/usePaletteCommands.ts +4 -1
  28. package/lib/artifacts/actions.ts +11 -1
  29. package/lib/artifacts/compare/compareVersions.ts +66 -0
  30. package/lib/artifacts/compare/diff.ts +178 -0
  31. package/lib/artifacts/compare/extractText.ts +64 -0
  32. package/lib/artifacts/compare/index.ts +11 -0
  33. package/lib/artifacts/compare/types.ts +44 -0
  34. package/lib/artifacts/match.ts +64 -0
  35. package/lib/artifacts/store.ts +39 -10
  36. package/lib/artifacts/tools.ts +9 -4
  37. package/lib/artifacts/versions.ts +2 -1
  38. package/lib/palette/registry.ts +71 -17
  39. package/lib/palette/sections.ts +143 -0
  40. package/lib/palette/tabs.ts +21 -0
  41. package/lib/palette/usage.ts +81 -0
  42. package/lib/security/demo.ts +3 -0
  43. package/lib/share/markdown.ts +3 -0
  44. package/lib/share/range.ts +189 -0
  45. package/lib/share/render.tsx +3 -0
  46. package/lib/share/service.ts +64 -19
  47. package/lib/share/snapshot.ts +39 -2
  48. package/lib/share/styles.ts +2 -0
  49. package/lib/share/view.ts +11 -0
  50. package/package.json +1 -1
  51. package/stores/palette.ts +13 -11
  52. package/stores/shareUi.ts +6 -1
@@ -3,6 +3,36 @@
3
3
  * take them back the same way, so the palette itself knows no feature.
4
4
  */
5
5
 
6
+ export type PaletteTab =
7
+ "all" | "sessions" | "commands" | "settings" | "conversations";
8
+
9
+ export const PALETTE_TABS: { id: PaletteTab; label: string }[] = [
10
+ { id: "all", label: "All" },
11
+ { id: "sessions", label: "Sessions" },
12
+ { id: "commands", label: "Commands" },
13
+ { id: "settings", label: "Settings" },
14
+ { id: "conversations", label: "Conversations" },
15
+ ];
16
+
17
+ // Which tab each heading's commands are found under; a heading not listed is
18
+ // a command. A command can name its own tab instead.
19
+ const GROUP_TAB: Record<string, PaletteTab> = {
20
+ Sessions: "sessions",
21
+ Settings: "settings",
22
+ Conversations: "conversations",
23
+ };
24
+
25
+ // Headings whose commands act on the focused session.
26
+ const SESSION_GROUPS = new Set(["Chat", "Task"]);
27
+
28
+ export function tabOf(c: PaletteCommand): PaletteTab {
29
+ return c.tab ?? GROUP_TAB[c.group] ?? "commands";
30
+ }
31
+
32
+ export function actsOnFocusedSession(c: PaletteCommand): boolean {
33
+ return SESSION_GROUPS.has(c.group);
34
+ }
35
+
6
36
  export interface PaletteCommand {
7
37
  id: string;
8
38
  title: string;
@@ -14,6 +44,10 @@ export interface PaletteCommand {
14
44
  hint?: string;
15
45
  // A line under the title (what a conversation hit said).
16
46
  detail?: string;
47
+ // Overrides the tab its group maps to.
48
+ tab?: PaletteTab;
49
+ // A session that is working or waiting on the user.
50
+ status?: "running" | "waiting";
17
51
  // Running it leaves the palette open (it changes the palette itself).
18
52
  keepOpen?: boolean;
19
53
  icon?: unknown;
@@ -84,25 +118,45 @@ export function fuzzyScore(query: string, text: string): number | null {
84
118
  return score;
85
119
  }
86
120
 
87
- // The commands a query finds, best first; all of them, in order, for none.
88
- export function matchCommands(
121
+ export interface Scored {
122
+ c: PaletteCommand;
123
+ score: number;
124
+ }
125
+
126
+ // The commands a query finds, best first, with their scores. `boost` (0 to 1,
127
+ // e.g. how much it's used) scales a match up by at most half: it can break a
128
+ // close call, never rescue a non-match or outrank a far better match. With no
129
+ // query the boost alone orders them, registry order breaking ties.
130
+ export function scoreCommands(
89
131
  commands: PaletteCommand[],
90
- query: string
91
- ): PaletteCommand[] {
92
- if (!query.trim()) return commands;
132
+ query: string,
133
+ boost: (c: PaletteCommand) => number = () => 0
134
+ ): Scored[] {
135
+ const empty = !query.trim();
93
136
  return commands
94
137
  .map((c, i) => {
95
- const scores = [c.title, ...(c.keywords ?? []), c.group].map(
96
- (text, j) => {
97
- const s = fuzzyScore(query, text);
98
- // A title match counts most.
99
- return s === null ? null : j === 0 ? s * 1.5 : s;
100
- }
101
- );
102
- const best = Math.max(...scores.map((s) => s ?? -Infinity));
103
- return { c, i, best };
138
+ let base = 0;
139
+ if (!empty) {
140
+ const scores = [c.title, ...(c.keywords ?? []), c.group].map(
141
+ (text, j) => {
142
+ const s = fuzzyScore(query, text);
143
+ // A title match counts most.
144
+ return s === null ? null : j === 0 ? s * 1.5 : s;
145
+ }
146
+ );
147
+ base = Math.max(...scores.map((s) => s ?? -Infinity));
148
+ }
149
+ const b = boost(c);
150
+ return { c, i, base, score: empty ? b : base + Math.abs(base) * 0.5 * b };
104
151
  })
105
- .filter((m) => m.best > -Infinity)
106
- .sort((a, b) => b.best - a.best || a.i - b.i)
107
- .map((m) => m.c);
152
+ .filter((m) => m.base > -Infinity)
153
+ .sort((a, b) => b.score - a.score || a.i - b.i)
154
+ .map(({ c, score }) => ({ c, score }));
155
+ }
156
+
157
+ export function matchCommands(
158
+ commands: PaletteCommand[],
159
+ query: string
160
+ ): PaletteCommand[] {
161
+ return scoreCommands(commands, query).map((m) => m.c);
108
162
  }
@@ -0,0 +1,143 @@
1
+ import {
2
+ PALETTE_TABS,
3
+ actsOnFocusedSession,
4
+ scoreCommands,
5
+ tabOf,
6
+ type PaletteCommand,
7
+ type PaletteTab,
8
+ } from "./registry";
9
+ import { recentIds, usageBoost, type Usage } from "./usage";
10
+
11
+ export interface Section {
12
+ name: string;
13
+ items: PaletteCommand[];
14
+ // The tab that lists the whole section, when this is only part of it.
15
+ more?: { tab: PaletteTab; count: number };
16
+ }
17
+
18
+ export const PER_TAB = 5;
19
+ export const RECENT = 5;
20
+ const NEEDS_YOU = 5;
21
+ const SUGGESTED = 6;
22
+
23
+ const label = (tab: PaletteTab) =>
24
+ PALETTE_TABS.find((t) => t.id === tab)?.label ?? tab;
25
+
26
+ export const boostFor = (usage: Usage, now: number) => (c: PaletteCommand) =>
27
+ usageBoost(usage[c.id], now);
28
+
29
+ // What a query finds in All: each tab's best few under its heading, the
30
+ // strongest group first, with how many more its tab holds. Conversations are
31
+ // the caller's, to put last.
32
+ export function allSections(
33
+ commands: PaletteCommand[],
34
+ query: string,
35
+ usage: Usage,
36
+ now: number
37
+ ): Section[] {
38
+ const scored = scoreCommands(commands, query, boostFor(usage, now));
39
+ const byTab = new Map<PaletteTab, typeof scored>();
40
+ for (const m of scored) {
41
+ const tab = tabOf(m.c);
42
+ if (tab === "conversations") continue;
43
+ byTab.set(tab, [...(byTab.get(tab) ?? []), m]);
44
+ }
45
+ return [...byTab.entries()]
46
+ .sort((a, b) => b[1][0].score - a[1][0].score)
47
+ .map(([tab, ms]) => ({
48
+ name: label(tab),
49
+ items: ms.slice(0, PER_TAB).map((m) => m.c),
50
+ ...(ms.length > PER_TAB
51
+ ? { more: { tab, count: ms.length } }
52
+ : undefined),
53
+ }));
54
+ }
55
+
56
+ // One tab's commands, for a query or (ordered by use) without one.
57
+ export function tabItems(
58
+ commands: PaletteCommand[],
59
+ tab: PaletteTab,
60
+ query: string,
61
+ usage: Usage,
62
+ now: number
63
+ ): PaletteCommand[] {
64
+ return scoreCommands(
65
+ commands.filter((c) => tabOf(c) === tab),
66
+ query,
67
+ boostFor(usage, now)
68
+ ).map((m) => m.c);
69
+ }
70
+
71
+ // The short list All opens on: the last things run, sessions that need the
72
+ // user or are working, and what the focused session can do.
73
+ export function emptySections(
74
+ commands: PaletteCommand[],
75
+ usage: Usage
76
+ ): Section[] {
77
+ const seen = new Set<string>();
78
+ const take = (items: PaletteCommand[], limit: number) => {
79
+ const out: PaletteCommand[] = [];
80
+ for (const c of items) {
81
+ if (out.length >= limit) break;
82
+ if (seen.has(c.id)) continue;
83
+ seen.add(c.id);
84
+ out.push(c);
85
+ }
86
+ return out;
87
+ };
88
+ // A session that needs you is listed there, not also under Recent.
89
+ const rank = (c: PaletteCommand) => (c.status === "waiting" ? 0 : 1);
90
+ const busy = take(
91
+ commands.filter((c) => c.status).sort((a, b) => rank(a) - rank(b)),
92
+ NEEDS_YOU
93
+ );
94
+ const here = take(commands.filter(actsOnFocusedSession), 5);
95
+ const byId = new Map(commands.map((c) => [c.id, c]));
96
+ const recent = take(
97
+ recentIds(usage, commands.length)
98
+ .map((id) => byId.get(id))
99
+ .filter((c): c is PaletteCommand => !!c),
100
+ RECENT
101
+ );
102
+ const sections: Section[] = [
103
+ { name: "Recent", items: recent },
104
+ { name: "Needs you / running", items: busy },
105
+ { name: "This session", items: here },
106
+ ];
107
+ // A first run has no history: offer the commands that start things.
108
+ if (recent.length + busy.length + here.length < 4)
109
+ sections.push({
110
+ name: "Suggested",
111
+ items: take(
112
+ commands.filter((c) => tabOf(c) === "commands"),
113
+ SUGGESTED
114
+ ),
115
+ });
116
+ return sections.filter((s) => s.items.length);
117
+ }
118
+
119
+ // Each cut-short section ends with a row that opens its whole tab.
120
+ export function withSeeAll(
121
+ sections: Section[],
122
+ open: (tab: PaletteTab) => void,
123
+ icon?: unknown
124
+ ): Section[] {
125
+ return sections.map((s) => {
126
+ const more = s.more;
127
+ if (!more) return s;
128
+ return {
129
+ ...s,
130
+ items: [
131
+ ...s.items,
132
+ {
133
+ id: `see-all:${more.tab}`,
134
+ title: `See all ${more.count} →`,
135
+ group: s.name,
136
+ icon,
137
+ keepOpen: true,
138
+ run: () => open(more.tab),
139
+ },
140
+ ],
141
+ };
142
+ });
143
+ }
@@ -0,0 +1,21 @@
1
+ import { PALETTE_TABS, type PaletteTab } from "./registry";
2
+
3
+ const PREFIXES: Record<string, PaletteTab> = {
4
+ ">": "commands",
5
+ "@": "sessions",
6
+ };
7
+
8
+ // A leading `>` or `@` on a fresh query picks a tab; null when there's none.
9
+ export function parsePrefix(
10
+ text: string
11
+ ): { tab: PaletteTab; text: string } | null {
12
+ const tab = PREFIXES[text[0]];
13
+ return tab ? { tab, text: text.slice(1).trimStart() } : null;
14
+ }
15
+
16
+ // The next tab (or the one before), wrapping round.
17
+ export function cycleTab(tab: PaletteTab, by: 1 | -1): PaletteTab {
18
+ const n = PALETTE_TABS.length;
19
+ const i = PALETTE_TABS.findIndex((t) => t.id === tab);
20
+ return PALETTE_TABS[(i + by + n) % n].id;
21
+ }
@@ -0,0 +1,81 @@
1
+ export interface UsageEntry {
2
+ count: number;
3
+ last: number;
4
+ }
5
+ export type Usage = Record<string, UsageEntry>;
6
+
7
+ export const USAGE_KEY = "agentOS-palette-usage";
8
+ export const MAX_USAGE = 200;
9
+ const DAY = 86_400_000;
10
+ const HALF_LIFE_DAYS = 14;
11
+ // Uses at which the count part of the boost tops out.
12
+ const SATURATES_AT = 20;
13
+
14
+ // How strongly past use favours an item, 0 to 1: more uses count more (with
15
+ // diminishing returns) and the whole fades by half every two weeks.
16
+ export function usageBoost(entry: UsageEntry | undefined, now: number): number {
17
+ if (!entry) return 0;
18
+ const age = Math.max(0, now - entry.last) / DAY;
19
+ const weight = Math.min(
20
+ 1,
21
+ Math.log2(1 + entry.count) / Math.log2(1 + SATURATES_AT)
22
+ );
23
+ return weight * Math.pow(0.5, age / HALF_LIFE_DAYS);
24
+ }
25
+
26
+ // The usage with one more run of `id`, keeping the MAX_USAGE items that
27
+ // matter most right now.
28
+ export function recordUse(usage: Usage, id: string, now: number): Usage {
29
+ const next: Usage = {
30
+ ...usage,
31
+ [id]: { count: (usage[id]?.count ?? 0) + 1, last: now },
32
+ };
33
+ const ids = Object.keys(next);
34
+ if (ids.length <= MAX_USAGE) return next;
35
+ const keep = ids
36
+ .sort((a, b) => usageBoost(next[b], now) - usageBoost(next[a], now))
37
+ .slice(0, MAX_USAGE);
38
+ return Object.fromEntries(keep.map((k) => [k, next[k]]));
39
+ }
40
+
41
+ // The ids last run most recently, newest first.
42
+ export function recentIds(usage: Usage, limit: number): string[] {
43
+ return Object.entries(usage)
44
+ .sort((a, b) => b[1].last - a[1].last)
45
+ .slice(0, limit)
46
+ .map(([id]) => id);
47
+ }
48
+
49
+ function valid(e: unknown): e is UsageEntry {
50
+ const u = e as UsageEntry | null;
51
+ return (
52
+ !!u && Number.isFinite(u.count) && Number.isFinite(u.last) && u.count > 0
53
+ );
54
+ }
55
+
56
+ export function loadUsage(): Usage {
57
+ try {
58
+ const raw: unknown = JSON.parse(localStorage.getItem(USAGE_KEY) ?? "{}");
59
+ if (!raw || typeof raw !== "object") return {};
60
+ const usage: Usage = {};
61
+ for (const [id, e] of Object.entries(raw)) if (valid(e)) usage[id] = e;
62
+ return usage;
63
+ } catch {
64
+ return {};
65
+ }
66
+ }
67
+
68
+ export function saveUsage(usage: Usage): void {
69
+ try {
70
+ localStorage.setItem(USAGE_KEY, JSON.stringify(usage));
71
+ } catch {
72
+ // Private mode or a full disk: ranking just forgets.
73
+ }
74
+ }
75
+
76
+ // Records a run in storage and returns the new usage.
77
+ export function noteUse(id: string, now = Date.now()): Usage {
78
+ const next = recordUse(loadUsage(), id, now);
79
+ saveUsage(next);
80
+ return next;
81
+ }
@@ -64,6 +64,7 @@ export const STATIC_SIBLINGS = [
64
64
  "browse",
65
65
  "check",
66
66
  "clone",
67
+ "compare",
67
68
  "connect",
68
69
  "detect",
69
70
  "done-idle",
@@ -84,6 +85,7 @@ export const STATIC_SIBLINGS = [
84
85
  "pdf",
85
86
  "preview",
86
87
  "queue",
88
+ "resolve",
87
89
  "status",
88
90
  ];
89
91
  // No leading "-": an id that reaches a command line can't read as an option.
@@ -101,6 +103,7 @@ const READS = [
101
103
  "sessions/[id]/pr",
102
104
  "sessions/[id]/artifacts",
103
105
  "sessions/[id]/artifacts/[id]",
106
+ "sessions/[id]/artifacts/[id]/compare",
104
107
  "sessions/[id]/setup",
105
108
  "sessions/[id]/preview",
106
109
  "projects",
@@ -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);