@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.
- package/app/api/sessions/[id]/artifacts/[artifactId]/compare/route.ts +41 -0
- package/app/api/sessions/resolve/route.test.ts +81 -0
- package/app/api/sessions/resolve/route.ts +14 -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/ChatPanel.tsx +3 -9
- package/components/Chat/EventRow.tsx +4 -4
- package/components/Chat/Messages.tsx +12 -6
- package/components/Chat/SessionAnchor.tsx +22 -0
- package/components/Chat/ShareFromHere.tsx +39 -0
- package/components/Chat/session-link.ts +21 -4
- 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/Health/EnvFailures/SessionList.tsx +2 -2
- package/components/Pane/PaneMenu.tsx +1 -1
- package/components/SessionList/QueuedRow.tsx +4 -4
- package/components/SessionList/SessionRowMenu.tsx +1 -1
- 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/components/Shares/SharedLinksPanel.tsx +3 -5
- 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/useCopyToClipboard.ts +5 -2
- package/hooks/usePaletteCommands.ts +4 -1
- package/hooks/useSessionHref.ts +20 -0
- package/hooks/useSessionUrl.ts +40 -11
- 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/away/deploy.ts +39 -2
- 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/reports/evidence/build.ts +4 -3
- package/lib/security/demo.ts +3 -0
- package/lib/session-audit/render.ts +2 -1
- package/lib/session-logs/gather.ts +2 -2
- package/lib/session-slug.ts +29 -0
- package/lib/session-url.ts +162 -40
- package/lib/sessions/resolve.ts +55 -0
- package/lib/settings-url.ts +1 -1
- 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/lib/welcome-url.ts +1 -1
- package/next.config.ts +4 -0
- package/package.json +1 -1
- package/stores/chatJump.ts +5 -1
- package/stores/palette.ts +13 -11
- package/stores/shareUi.ts +6 -1
package/lib/session-url.ts
CHANGED
|
@@ -1,50 +1,152 @@
|
|
|
1
|
-
// Every session has its own address:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|
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
|
|
38
|
-
|
|
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,
|
|
42
|
-
return `${origin}
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
98
|
-
|
|
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(
|
|
212
|
+
openUnlisted(ref: SessionRef): Promise<boolean>;
|
|
107
213
|
/** The address named a session that can't be opened. */
|
|
108
|
-
notFound(
|
|
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,
|
|
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:
|
|
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
|
|
138
|
-
if (
|
|
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
|
|
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
|
|
157
|
-
if (
|
|
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(
|
|
163
|
-
|
|
164
|
-
this.
|
|
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 (
|
|
167
|
-
const opened = await this.deps.openUnlisted(
|
|
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 ===
|
|
170
|
-
this.deps.notFound(
|
|
286
|
+
if (this.pending === ref) this.pending = null;
|
|
287
|
+
this.deps.notFound(ref);
|
|
171
288
|
}
|
|
172
289
|
|
|
173
290
|
private sync(): void {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
this.write(current && this.
|
|
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
|
-
|
|
182
|
-
const next = hrefWithSession(
|
|
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
|
+
}
|
package/lib/settings-url.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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);
|