@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
package/lib/palette/registry.ts
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
106
|
-
.sort((a, b) => b.
|
|
107
|
-
.map((
|
|
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
|
+
}
|
package/lib/security/demo.ts
CHANGED
|
@@ -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",
|
package/lib/share/markdown.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/share/render.tsx
CHANGED
|
@@ -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);
|