@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
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import fs from "fs";
11
+ import path from "path";
11
12
  import type { ProjectConfig } from "../project-config";
12
13
  import { gitFetch, isAncestor } from "../git-exec";
13
14
  import { expandHome } from "../tasks/session";
@@ -19,14 +20,22 @@ export type DeployConfig = NonNullable<ProjectConfig["deploy"]>;
19
20
  export type DeployVerdict =
20
21
  | { state: "ok"; detail: string }
21
22
  | { state: "failed"; detail: string }
22
- | { state: "pending" }
23
+ | { state: "pending"; detail?: string }
23
24
  | { state: "unchecked"; detail: string };
24
25
 
25
26
  export const DEPLOY_WITHIN_MIN = 30;
26
27
  // After the deploy lands, the server gets this long to answer healthy.
27
28
  export const HEALTH_GRACE_MS = 3 * 60 * 1000;
28
29
 
30
+ // scripts/autodeploy holds a new head AGENTOS_MIN_GAP seconds after the last
31
+ // restart (`restarted`, next to `deployed`), so a merge isn't late until the
32
+ // gap is over plus this margin.
33
+ export const GAP_MARGIN_MS = 10 * 60 * 1000;
34
+ const DEFAULT_GAP_S = 3600;
35
+
29
36
  export interface DeployDeps {
37
+ // When the deploy last restarted the server, in ms; null when unknown.
38
+ restartedAt: (file: string) => number | null;
30
39
  // The commit a state file names, or null when there's no such file.
31
40
  readSha: (file: string) => { sha: string; at: number } | null;
32
41
  // Whether `sha` is in the history of `tip` (fetching it if need be);
@@ -89,7 +98,22 @@ async function health(
89
98
  }
90
99
  }
91
100
 
101
+ function restartedAt(file: string): number | null {
102
+ try {
103
+ const s = Number(fs.readFileSync(expandHome(file), "utf-8").trim());
104
+ return Number.isFinite(s) && s > 0 ? s * 1000 : null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ const gapMs = () => {
111
+ const s = Number(process.env.AGENTOS_MIN_GAP ?? DEFAULT_GAP_S);
112
+ return (Number.isFinite(s) && s >= 0 ? s : DEFAULT_GAP_S) * 1000;
113
+ };
114
+
92
115
  export const DEFAULT_DEPS: DeployDeps = {
116
+ restartedAt,
93
117
  readSha,
94
118
  contains,
95
119
  health,
@@ -159,8 +183,21 @@ async function judge(
159
183
  state: "failed",
160
184
  detail: `the deploy of ${failed.sha.slice(0, 7)} (which has ${short}) failed; see the deploy's log`,
161
185
  };
186
+ // The state files sit together; `restarted` is beside `deployed`.
187
+ const restarted = deps.restartedAt(
188
+ path.join(path.dirname(config.deployed), "restarted")
189
+ );
190
+ const gapEnd = restarted === null ? 0 : restarted + gapMs();
191
+ if (gapEnd > input.now)
192
+ return {
193
+ state: "pending",
194
+ detail: `deploy waiting: restart gap, ~${Math.ceil((gapEnd - input.now) / 60000)} min left`,
195
+ };
162
196
  const within = (config.within_minutes ?? DEPLOY_WITHIN_MIN) * 60 * 1000;
163
- if (input.now - input.mergedAt >= within)
197
+ if (
198
+ input.now - input.mergedAt >= within &&
199
+ input.now >= gapEnd + GAP_MARGIN_MS
200
+ )
164
201
  return {
165
202
  state: "failed",
166
203
  detail: `${short} not deployed ${Math.round(within / 60000)} minutes after the merge`,
@@ -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
+ }
@@ -6,6 +6,7 @@ import type { TaskState } from "../../tasks/state";
6
6
  import type { DigestSignals } from "../../analyst/types";
7
7
  import { latestChecks } from "../../analyst/signals";
8
8
  import type { EvidenceRef } from "../types";
9
+ import { sessionPath } from "../../session-url";
9
10
 
10
11
  const EXCERPT = 1500;
11
12
  const cap = (s: string, n = EXCERPT) =>
@@ -82,7 +83,7 @@ export function testEvidence(
82
83
  status: c.passed ? "pass" : "fail",
83
84
  at,
84
85
  sha: head ?? undefined,
85
- href: `/?session=${sessionId}`,
86
+ href: sessionPath({ id: sessionId }),
86
87
  }));
87
88
  }
88
89
 
@@ -101,7 +102,7 @@ export function diffEvidence(
101
102
  label: `${m[3]} (+${m[1] === "-" ? 0 : m[1]} −${m[2] === "-" ? 0 : m[2]})`,
102
103
  status: "info",
103
104
  at,
104
- href: `/?session=${sessionId}`,
105
+ href: sessionPath({ id: sessionId }),
105
106
  });
106
107
  if (out.length >= 40) break;
107
108
  }
@@ -174,7 +175,7 @@ export function logEvidence(log: {
174
175
  status: "pass",
175
176
  at: log.createdAt,
176
177
  excerpt: log.mergeCommit ? `Merge commit ${log.mergeCommit}` : undefined,
177
- href: `/?session=${log.sessionId}`,
178
+ href: sessionPath({ id: log.sessionId }),
178
179
  };
179
180
  }
180
181
 
@@ -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",
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { PatternStat, Summary } from "./aggregate";
8
8
  import type { AuditFindings } from "./store";
9
+ import { sessionPath } from "../session-url";
9
10
 
10
11
  export const fmt = (n: number) =>
11
12
  n >= 1e9
@@ -245,7 +246,7 @@ export function renderSummary(
245
246
  "biggest output (est.)",
246
247
  ],
247
248
  s.sessions.slice(0, 30).map((x) => [
248
- `[${x.id.slice(0, 8)}](/?session=${x.id})`,
249
+ `[${x.id.slice(0, 8)}](${sessionPath(x)})`,
249
250
  x.kind,
250
251
  x.name.slice(0, 40),
251
252
  x.turns,
@@ -18,7 +18,7 @@ import { getWorkspace } from "../workspaces";
18
18
  import { amendmentsOf } from "../orchestrator/amendments";
19
19
  import { PLAN_FILE } from "../models/plan-step";
20
20
  import { estimateCost, rowModel } from "../usage/cost";
21
- import { sessionLink } from "../session-url";
21
+ import { sessionPath } from "../session-url";
22
22
  import type { LogData, LogEvent, LogUsageRow } from "./build";
23
23
 
24
24
  const expandHome = (p: string) => p.replace(/^~/, os.homedir());
@@ -296,6 +296,6 @@ export function gatherLogData(s: Session, opts: GatherOpts = {}): LogData {
296
296
  timeline,
297
297
  usage: usageRows(s.id),
298
298
  outcome: finalMessage(s.id),
299
- sessionPath: sessionLink("", s.id),
299
+ sessionPath: sessionPath(s),
300
300
  };
301
301
  }
@@ -0,0 +1,29 @@
1
+ // The readable part of a session's address (`/s/<slug>-<id8>`). Cosmetic:
2
+ // only the short id resolves, so the slug is rebuilt from the current name
3
+ // and a rename never breaks a link. No imports: the browser uses it too.
4
+
5
+ export const SLUG_MAX = 48;
6
+ export const SHORT_ID_LEN = 8;
7
+
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+
10
+ export const isUuid = (id: string): boolean => UUID.test(id);
11
+
12
+ /** Lowercase ASCII words joined by `-`; `session` when nothing is left. */
13
+ export function sessionSlug(name: string): string {
14
+ const words = (name ?? "")
15
+ .normalize("NFKD")
16
+ .replace(/\p{M}/gu, "")
17
+ .toLowerCase()
18
+ .replace(/[^a-z0-9]+/g, "-")
19
+ .replace(/^-+|-+$/g, "");
20
+ if (!words) return "session";
21
+ if (words.length <= SLUG_MAX) return words;
22
+ const cut = words.slice(0, SLUG_MAX);
23
+ const atWord = cut.lastIndexOf("-");
24
+ return (atWord > 0 ? cut.slice(0, atWord) : cut).replace(/-+$/, "");
25
+ }
26
+
27
+ /** The first group of a uuid, lowercase. */
28
+ export const shortId = (id: string): string =>
29
+ id.slice(0, SHORT_ID_LEN).toLowerCase();