@a-t-h-i/bot-lobby 0.6.27 → 0.6.28
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/README.md +30 -11
- package/package.json +1 -1
- package/src/lobby/host.ts +19 -0
- package/src/lobby/planner.ts +131 -0
- package/src/lobby/quickfix.ts +40 -1
- package/src/lobby/recovery.ts +415 -0
- package/src/lobby/resume.ts +66 -0
- package/src/lobby/runtime.ts +80 -7
- package/src/lobby/service.ts +196 -11
- package/src/lobby/sessions.ts +13 -3
- package/src/lobby/task-rows.ts +2 -0
- package/src/pi/commands.ts +38 -2
- package/src/pi/events.ts +6 -3
- package/src/pi/switch.ts +7 -2
- package/src/pi/ui.ts +9 -2
- package/src/state/plan-history.ts +142 -0
- package/src/state/recovery.ts +170 -0
- package/src/state/themes.ts +127 -0
- package/src/webui/api/index.ts +24 -2
- package/src/webui/api/planner.ts +47 -1
- package/src/webui/api/status.ts +2 -0
- package/src/webui/api/tasks.ts +35 -5
- package/src/webui/api/themes.ts +37 -0
- package/src/webui/command.ts +26 -1
- package/src/webui/dev/fake-service.ts +60 -0
- package/src/webui/dev/fixtures.ts +4 -1
- package/src/webui/palette-core.ts +260 -0
- package/src/webui/protocol.ts +56 -0
- package/src/webui/server.ts +10 -0
- package/webui/dist/assets/index-BzCxEueR.css +2 -0
- package/webui/dist/assets/index-qisGNoCh.js +108 -0
- package/webui/dist/build.json +36 -28
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BUzOUrih.css +0 -2
- package/webui/dist/assets/index-myC4UiEg.js +0 -108
|
@@ -12,6 +12,7 @@ import type { BackgroundSession, DialogAnswer, SessionDialog } from "../../lobby
|
|
|
12
12
|
import type { PlanComment } from "../../state/comments.ts";
|
|
13
13
|
import type { LobbyService } from "../../lobby/host.ts";
|
|
14
14
|
import { loadScenario, type ScenarioFixture } from "./fixtures.ts";
|
|
15
|
+
import type { PlanRecord, PlanSummary } from "../../state/plan-history.ts";
|
|
15
16
|
|
|
16
17
|
interface StreamState {
|
|
17
18
|
step?: ReturnType<typeof setTimeout>;
|
|
@@ -264,6 +265,7 @@ function fakeBackground(entry: NonNullable<ScenarioFixture["backgroundSessions"]
|
|
|
264
265
|
},
|
|
265
266
|
...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
|
|
266
267
|
...(entry.planId ? { planId: entry.planId } : {}),
|
|
268
|
+
...(entry.taskId ? { taskId: entry.taskId } : {}),
|
|
267
269
|
feed,
|
|
268
270
|
dialogs,
|
|
269
271
|
get alive() {
|
|
@@ -528,6 +530,24 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
|
|
|
528
530
|
},
|
|
529
531
|
sendToTask: (taskId: string, _text: string) => (tasks.some((task) => (task as { id: string }).id === taskId) ? `sent — the session driving ${taskId} passes it to its oracle` : `no task ${taskId}`),
|
|
530
532
|
sendToSession: (_sessionId: string, _text: string) => "sent — that session passes it to its oracle within a few seconds",
|
|
533
|
+
resumeTask: async (taskId: string) => {
|
|
534
|
+
const task = tasks.find((entry) => (entry as { id: string }).id === taskId) as { id: string; title: string; paused?: boolean; ownerSessionId?: string } | undefined;
|
|
535
|
+
if (!task) return { notice: `no task ${taskId}` };
|
|
536
|
+
const owner = task.ownerSessionId;
|
|
537
|
+
const background = backgrounds.find((session) => session.alive && ((owner && session.sessionId === owner) || session.taskId === taskId));
|
|
538
|
+
const elsewhere = Boolean(owner && (fixture.liveSessions ?? []).some((session) => session.sessionId === owner));
|
|
539
|
+
lobbyTopics.bump("tasks");
|
|
540
|
+
if (owner === fixture.status.sessionId || background || elsewhere) {
|
|
541
|
+
task.paused = false;
|
|
542
|
+
const where = background ? `in ${background.name}` : elsewhere ? "— the session driving it in another terminal carries on" : "in this window";
|
|
543
|
+
return { notice: `resumed ${taskId} ${where}`, ...(background ? { key: background.key } : {}) };
|
|
544
|
+
}
|
|
545
|
+
task.paused = false;
|
|
546
|
+
const session = fakeBackground({ key: `S${backgrounds.length + 1}`, name: task.title, status: "starting", taskId });
|
|
547
|
+
backgrounds.push(session);
|
|
548
|
+
lobbyTopics.bump("sessions");
|
|
549
|
+
return { notice: `resumed ${taskId} in a background session`, key: session.key };
|
|
550
|
+
},
|
|
531
551
|
sessions: () => [...backgrounds],
|
|
532
552
|
startSession: (start: { request?: string; plan?: { id: string; title: string }; auto?: boolean }) => {
|
|
533
553
|
const key = `S${backgrounds.length + 1}`;
|
|
@@ -537,6 +557,7 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
|
|
|
537
557
|
},
|
|
538
558
|
liveSessions: () => [...(fixture.liveSessions ?? [])],
|
|
539
559
|
planner: () => planning,
|
|
560
|
+
...previousPlansFake(fixture.mockPreviousPlans ?? []),
|
|
540
561
|
newPlanner: (seed?: { issue: { number: number; title: string; url?: string }; body: string }, seats?: string[]) => {
|
|
541
562
|
planning = fakePlanner({ ...defaultPlanner(), ...(seed ? { messages: [{ role: "you", text: seed.body, at: Date.now() }] } : {}), ...(seats ? { seats } : {}) });
|
|
542
563
|
return planning;
|
|
@@ -571,3 +592,42 @@ export function disposeFixtureService(service: LobbyService): void {
|
|
|
571
592
|
for (const id of opened.get(service) ?? []) promptHub.dismiss(id);
|
|
572
593
|
opened.delete(service);
|
|
573
594
|
}
|
|
595
|
+
|
|
596
|
+
/** Previous plans held in memory: listed, read, archived, deleted and carried on as the page asks. */
|
|
597
|
+
function previousPlansFake(seed: NonNullable<ScenarioFixture["mockPreviousPlans"]>) {
|
|
598
|
+
const records = seed.map((plan) => ({ ...plan }))
|
|
599
|
+
const find = (id: string) => {
|
|
600
|
+
const record = records.find((entry) => entry.id === id && !entry.savedAs?.length)
|
|
601
|
+
if (!record) throw new Error(`no plan ${id}`)
|
|
602
|
+
return record
|
|
603
|
+
}
|
|
604
|
+
const asRecord = (plan: (typeof records)[number]) => ({
|
|
605
|
+
id: plan.id,
|
|
606
|
+
title: plan.title,
|
|
607
|
+
createdAt: plan.createdAt,
|
|
608
|
+
updatedAt: plan.updatedAt,
|
|
609
|
+
...(plan.archivedAt ? { archivedAt: plan.archivedAt } : {}),
|
|
610
|
+
snapshot: { version: 1, messages: plan.messages, ...(plan.draft ? { reply: { status: "grilling", questions: [], plan: plan.draft } } : {}), questions: [], notes: plan.notes ?? [], members: [], seats: ["backend", "designer", "qa", "researcher"], pins: [], satOut: [], lastStatus: [], sat: [], memberNotes: [], turns: plan.rounds ?? 1, mode: "normal", answered: [], lineComments: [], savedParts: [], autoContinued: false, attempts: 1, running: false },
|
|
611
|
+
}) as unknown as PlanRecord
|
|
612
|
+
return {
|
|
613
|
+
previousPlans: (archived: boolean): PlanSummary[] => records
|
|
614
|
+
.filter((plan) => !plan.savedAs?.length && Boolean(plan.archivedAt) === archived)
|
|
615
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
616
|
+
.map((plan) => ({ id: plan.id, title: plan.title, createdAt: plan.createdAt, updatedAt: plan.updatedAt, ...(plan.archivedAt ? { archivedAt: plan.archivedAt } : {}), messages: plan.messages.length, rounds: plan.rounds ?? 1, hasDraft: Boolean(plan.draft) })),
|
|
617
|
+
previousPlan: (id: string) => asRecord(find(id)),
|
|
618
|
+
archivePlan: (id: string, archived: boolean) => {
|
|
619
|
+
const plan = find(id)
|
|
620
|
+
if (archived) plan.archivedAt = new Date().toISOString()
|
|
621
|
+
else delete plan.archivedAt
|
|
622
|
+
lobbyTopics.bump("planner")
|
|
623
|
+
return archived ? `archived "${plan.title}" — show archived plans to bring it back` : `"${plan.title}" is back among the previous plans`
|
|
624
|
+
},
|
|
625
|
+
deletePlan: (id: string) => {
|
|
626
|
+
const plan = find(id)
|
|
627
|
+
records.splice(records.indexOf(plan), 1)
|
|
628
|
+
lobbyTopics.bump("planner")
|
|
629
|
+
return `deleted "${plan.title}" for good`
|
|
630
|
+
},
|
|
631
|
+
reopenPlan: (id: string) => `carrying on "${find(id).title}"`,
|
|
632
|
+
}
|
|
633
|
+
}
|
|
@@ -75,9 +75,11 @@ export interface ScenarioFixture {
|
|
|
75
75
|
/** Comments by task id for `tasks.comments`. */
|
|
76
76
|
taskComments?: Record<string, Array<Record<string, unknown>>>;
|
|
77
77
|
/** Background sessions for `sessions.list` (plain data, made live below). */
|
|
78
|
-
backgroundSessions?: Array<{ key: string; name: string; status: string; sessionId?: string; planId?: string; dialogs?: Array<Record<string, unknown>> }>;
|
|
78
|
+
backgroundSessions?: Array<{ key: string; name: string; status: string; sessionId?: string; planId?: string; taskId?: string; dialogs?: Array<Record<string, unknown>> }>;
|
|
79
79
|
/** Live sessions in other terminals for `sessions.list`. */
|
|
80
80
|
liveSessions?: Array<{ sessionId: string; pid: number; name?: string; taskId?: string; mode: string }>;
|
|
81
|
+
/** Previous plans (planning sessions never saved as a task) for `planner.previous*`. */
|
|
82
|
+
mockPreviousPlans?: Array<{ id: string; title: string; createdAt: string; updatedAt: string; archivedAt?: string; savedAs?: string[]; draft?: string; rounds?: number; messages: Array<Record<string, unknown>>; notes?: Array<Record<string, unknown>> }>;
|
|
81
83
|
/** The planning session for the `planner.*` calls (absent means none started). */
|
|
82
84
|
mockPlanner?: {
|
|
83
85
|
seats: string[];
|
|
@@ -164,6 +166,7 @@ export function loadScenario(name: string | undefined): ScenarioFixture {
|
|
|
164
166
|
autoTasks: Array.isArray(parsed.autoTasks) ? (parsed.autoTasks as string[]) : [],
|
|
165
167
|
backgroundSessions: Array.isArray(parsed.backgroundSessions) ? (parsed.backgroundSessions as ScenarioFixture["backgroundSessions"]) : [],
|
|
166
168
|
liveSessions: Array.isArray(parsed.liveSessions) ? (parsed.liveSessions as ScenarioFixture["liveSessions"]) : [],
|
|
169
|
+
mockPreviousPlans: Array.isArray(parsed.mockPreviousPlans) ? (parsed.mockPreviousPlans as ScenarioFixture["mockPreviousPlans"]) : [],
|
|
167
170
|
mockPlanner: isRecord(parsed.mockPlanner) ? (parsed.mockPlanner as unknown as ScenarioFixture["mockPlanner"]) : undefined,
|
|
168
171
|
mockQuickfix: Array.isArray(parsed.mockQuickfix) ? (parsed.mockQuickfix as ScenarioFixture["mockQuickfix"]) : [],
|
|
169
172
|
mockMetrics: Array.isArray(parsed.mockMetrics) ? (parsed.mockMetrics as ScenarioFixture["mockMetrics"]) : [],
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour themes, as plain data and pure functions (no DOM, so they can be
|
|
3
|
+
* unit-tested). A theme is two maps of shadcn variables, one for light and one
|
|
4
|
+
* for dark: exactly what tweakcn exports. A few themes come with the page; the
|
|
5
|
+
* user can paste or upload another from tweakcn (its `:root { … } .dark { … }`
|
|
6
|
+
* CSS, or its registry JSON).
|
|
7
|
+
*
|
|
8
|
+
* An imported file is never injected as it is. Only variables from an allow
|
|
9
|
+
* list are kept, and a value is kept only when it is made of colour, length
|
|
10
|
+
* and font-name characters (no `url()`, no `;`, no braces), so a theme can
|
|
11
|
+
* recolour the page but cannot load anything or break out of its rule.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type Vars = Record<string, string>
|
|
15
|
+
|
|
16
|
+
export interface Palette {
|
|
17
|
+
id: string
|
|
18
|
+
name: string
|
|
19
|
+
/** Overrides for the light page; absent means the page's own colours. */
|
|
20
|
+
light?: Vars
|
|
21
|
+
/** Overrides for the dark page. */
|
|
22
|
+
dark?: Vars
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The variables a theme may set, as tweakcn names them. Radius and shadows are ignored: the page keeps one calm 8px corner. */
|
|
26
|
+
const COLOUR_VARS = [
|
|
27
|
+
"background",
|
|
28
|
+
"foreground",
|
|
29
|
+
"card",
|
|
30
|
+
"card-foreground",
|
|
31
|
+
"popover",
|
|
32
|
+
"popover-foreground",
|
|
33
|
+
"primary",
|
|
34
|
+
"primary-foreground",
|
|
35
|
+
"secondary",
|
|
36
|
+
"secondary-foreground",
|
|
37
|
+
"muted",
|
|
38
|
+
"muted-foreground",
|
|
39
|
+
"accent",
|
|
40
|
+
"accent-foreground",
|
|
41
|
+
"destructive",
|
|
42
|
+
"border",
|
|
43
|
+
"input",
|
|
44
|
+
"ring",
|
|
45
|
+
"chart-1",
|
|
46
|
+
"chart-2",
|
|
47
|
+
"chart-3",
|
|
48
|
+
"chart-4",
|
|
49
|
+
"chart-5",
|
|
50
|
+
] as const
|
|
51
|
+
|
|
52
|
+
const FONT_VARS = ["font-sans", "font-mono"] as const
|
|
53
|
+
|
|
54
|
+
const ALLOWED = new Set<string>([...COLOUR_VARS, ...FONT_VARS])
|
|
55
|
+
|
|
56
|
+
/** What a colour or length may be made of. */
|
|
57
|
+
const SAFE_VALUE = /^[\w\s#%.,()/+*'"-]{1,300}$/
|
|
58
|
+
const BAD_VALUE = /url\s*\(|expression|import|javascript|\\|<|>/i
|
|
59
|
+
|
|
60
|
+
export const MAX_THEME_BYTES = 200_000
|
|
61
|
+
|
|
62
|
+
/** `oklch(0.6 0.2 250)`, `#fff`, `hsl(…)`, `var(--x)`, a font stack: kept; anything else: dropped. */
|
|
63
|
+
export function safeValue(raw: string): string | undefined {
|
|
64
|
+
const value = raw.replace(/!important/gi, "").trim()
|
|
65
|
+
if (!value || !SAFE_VALUE.test(value) || BAD_VALUE.test(value)) return undefined
|
|
66
|
+
return value
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function keep(name: string, raw: string, into: Vars): void {
|
|
70
|
+
const key = name.replace(/^--/, "").toLowerCase()
|
|
71
|
+
if (!ALLOWED.has(key)) return
|
|
72
|
+
const value = safeValue(raw)
|
|
73
|
+
if (value) into[key] = value
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function declarations(block: string): Vars {
|
|
77
|
+
const found: Vars = {}
|
|
78
|
+
for (const match of block.matchAll(/--([a-z0-9-]+)\s*:\s*([^;}]+)/gi)) keep(match[1]!, match[2]!, found)
|
|
79
|
+
return found
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A stored theme read back with the same filter as an import (the store is the user's to edit). */
|
|
83
|
+
export function cleanVars(map: unknown): Vars | undefined {
|
|
84
|
+
if (!map || typeof map !== "object") return undefined
|
|
85
|
+
const out: Vars = {}
|
|
86
|
+
for (const [key, value] of Object.entries(map as Record<string, unknown>)) if (typeof value === "string") keep(key, value, out)
|
|
87
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface Parsed {
|
|
91
|
+
light?: Vars
|
|
92
|
+
dark?: Vars
|
|
93
|
+
name?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A theme from tweakcn's CSS (`:root {…} .dark {…}`) or its registry JSON (`{ "cssVars": { "light": {…}, "dark": {…} } }`). */
|
|
97
|
+
export function parseTheme(source: string): Parsed | { error: string } {
|
|
98
|
+
const text = source.trim()
|
|
99
|
+
if (!text) return { error: "Paste a theme first." }
|
|
100
|
+
if (text.length > MAX_THEME_BYTES) return { error: "That file is too large to be a theme." }
|
|
101
|
+
let light: Vars = {}
|
|
102
|
+
let dark: Vars = {}
|
|
103
|
+
let name: string | undefined
|
|
104
|
+
if (text.startsWith("{")) {
|
|
105
|
+
let json: unknown
|
|
106
|
+
try {
|
|
107
|
+
json = JSON.parse(text)
|
|
108
|
+
} catch {
|
|
109
|
+
return { error: "That is not valid JSON or CSS." }
|
|
110
|
+
}
|
|
111
|
+
const record = json as { name?: unknown; title?: unknown; cssVars?: { theme?: Record<string, unknown>; light?: Record<string, unknown>; dark?: Record<string, unknown> } }
|
|
112
|
+
const vars = record.cssVars
|
|
113
|
+
if (!vars || typeof vars !== "object") return { error: "That JSON has no cssVars. Copy the theme's CSS from tweakcn instead." }
|
|
114
|
+
const read = (map: Record<string, unknown> | undefined): Vars => {
|
|
115
|
+
const out: Vars = {}
|
|
116
|
+
for (const [key, value] of Object.entries(map ?? {})) if (typeof value === "string") keep(key, value, out)
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
light = { ...read(vars.theme), ...read(vars.light) }
|
|
120
|
+
dark = { ...read(vars.theme), ...read(vars.dark) }
|
|
121
|
+
name = typeof record.title === "string" ? record.title : typeof record.name === "string" ? record.name : undefined
|
|
122
|
+
} else {
|
|
123
|
+
// Only the plain `:root` and `.dark` rules; `@theme inline` and the rest are the page's own business.
|
|
124
|
+
for (const match of text.matchAll(/(?<=^|[}\s])((?::root|html|\.dark|:root\.dark|html\.dark)(?:\s*,\s*[^{]+)?)\s*\{([^}]*)\}/g)) {
|
|
125
|
+
const selector = match[1]!.trim()
|
|
126
|
+
const vars = declarations(match[2]!)
|
|
127
|
+
if (/\.dark/.test(selector)) dark = { ...dark, ...vars }
|
|
128
|
+
else light = { ...light, ...vars }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const count = (vars: Vars) => Object.keys(vars).filter((key) => (COLOUR_VARS as readonly string[]).includes(key)).length
|
|
132
|
+
if (count(light) < 5 && count(dark) < 5) return { error: "No theme colours found. Copy the code from tweakcn's Code panel (the :root and .dark blocks)." }
|
|
133
|
+
return {
|
|
134
|
+
...(count(light) >= 5 ? { light } : {}),
|
|
135
|
+
...(count(dark) >= 5 ? { dark } : {}),
|
|
136
|
+
...(name ? { name } : {}),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Page variables a theme does not name are worked out from the ones it does, so a theme from tweakcn is whole. */
|
|
141
|
+
const DERIVED: Vars = {
|
|
142
|
+
"glass-border": "var(--border)",
|
|
143
|
+
typing: "var(--ring)",
|
|
144
|
+
"tab-active": "color-mix(in oklab, var(--primary) 14%, var(--background))",
|
|
145
|
+
you: "color-mix(in oklab, var(--primary) 9%, var(--card))",
|
|
146
|
+
heading: "var(--foreground)",
|
|
147
|
+
link: "var(--primary)",
|
|
148
|
+
bullet: "var(--primary)",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const SANS_FALLBACK = `"Inter Variable", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`
|
|
152
|
+
const MONO_FALLBACK = `ui-monospace, "JetBrains Mono", "Cascadia Mono", "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace`
|
|
153
|
+
|
|
154
|
+
function rule(selector: string, vars: Vars): string {
|
|
155
|
+
const lines: string[] = []
|
|
156
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
157
|
+
if (key === "font-sans") lines.push(`--app-font-sans:${value},${SANS_FALLBACK};`)
|
|
158
|
+
else if (key === "font-mono") lines.push(`--app-font-mono:${value},${MONO_FALLBACK};`)
|
|
159
|
+
else lines.push(`--${key}:${value};`)
|
|
160
|
+
}
|
|
161
|
+
for (const [key, value] of Object.entries(DERIVED)) if (!(key in vars)) lines.push(`--${key}:${value};`)
|
|
162
|
+
return `${selector}{${lines.join("")}}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The style sheet for a theme, or `""` for the page's own colours. */
|
|
166
|
+
export function paletteCss(palette: Palette): string {
|
|
167
|
+
const parts: string[] = []
|
|
168
|
+
if (palette.light) parts.push(rule(":root", palette.light))
|
|
169
|
+
if (palette.dark) parts.push(rule(".dark", palette.dark))
|
|
170
|
+
return parts.join("\n")
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A palette from one hue, in oklch: a tinted mist and a tinted near-black, the hue as the accent. Chroma 0 is a neutral theme. */
|
|
174
|
+
function tinted(id: string, name: string, hue: number, chroma: number, lift = 0): Palette {
|
|
175
|
+
const h = hue
|
|
176
|
+
const c = chroma
|
|
177
|
+
const n = (value: number) => Number(value.toFixed(3))
|
|
178
|
+
const light: Vars = {
|
|
179
|
+
background: `oklch(0.975 ${n(c * 0.1)} ${h})`,
|
|
180
|
+
foreground: `oklch(0.22 ${n(c * 0.25)} ${h})`,
|
|
181
|
+
card: "oklch(1 0 0)",
|
|
182
|
+
"card-foreground": `oklch(0.22 ${n(c * 0.25)} ${h})`,
|
|
183
|
+
popover: "oklch(1 0 0 / 0.92)",
|
|
184
|
+
"popover-foreground": `oklch(0.22 ${n(c * 0.25)} ${h})`,
|
|
185
|
+
primary: `oklch(${n(0.5 + lift)} ${c} ${h})`,
|
|
186
|
+
"primary-foreground": "oklch(0.99 0 0)",
|
|
187
|
+
secondary: `oklch(0.3 ${n(c * 0.3)} ${h} / 0.06)`,
|
|
188
|
+
"secondary-foreground": `oklch(0.22 ${n(c * 0.25)} ${h})`,
|
|
189
|
+
muted: `oklch(0.3 ${n(c * 0.3)} ${h} / 0.05)`,
|
|
190
|
+
"muted-foreground": `oklch(0.5 ${n(c * 0.2)} ${h})`,
|
|
191
|
+
accent: `oklch(${n(0.5 + lift)} ${c} ${h} / 0.1)`,
|
|
192
|
+
"accent-foreground": `oklch(0.22 ${n(c * 0.25)} ${h})`,
|
|
193
|
+
destructive: "oklch(0.52 0.19 28)",
|
|
194
|
+
border: `oklch(0.92 ${n(c * 0.08)} ${h})`,
|
|
195
|
+
input: `oklch(0.87 ${n(c * 0.1)} ${h})`,
|
|
196
|
+
ring: `oklch(${n(0.6 + lift)} ${n(c * 0.9)} ${h})`,
|
|
197
|
+
"chart-1": `oklch(${n(0.5 + lift)} ${c} ${h})`,
|
|
198
|
+
"chart-2": "oklch(0.52 0.12 160)",
|
|
199
|
+
"chart-3": "oklch(0.55 0.1 200)",
|
|
200
|
+
"chart-4": "oklch(0.55 0.15 340)",
|
|
201
|
+
"chart-5": "oklch(0.6 0.13 75)",
|
|
202
|
+
}
|
|
203
|
+
const dark: Vars = {
|
|
204
|
+
background: `oklch(0.145 ${n(c * 0.08)} ${h})`,
|
|
205
|
+
foreground: `oklch(0.93 ${n(c * 0.05)} ${h})`,
|
|
206
|
+
card: `oklch(0.18 ${n(c * 0.09)} ${h})`,
|
|
207
|
+
"card-foreground": `oklch(0.93 ${n(c * 0.05)} ${h})`,
|
|
208
|
+
popover: `oklch(0.18 ${n(c * 0.09)} ${h} / 0.94)`,
|
|
209
|
+
"popover-foreground": `oklch(0.93 ${n(c * 0.05)} ${h})`,
|
|
210
|
+
primary: `oklch(${n(0.76 + lift / 2)} ${n(c * 0.85)} ${h})`,
|
|
211
|
+
"primary-foreground": `oklch(0.18 ${n(c * 0.4)} ${h})`,
|
|
212
|
+
secondary: "oklch(1 0 0 / 0.045)",
|
|
213
|
+
"secondary-foreground": `oklch(0.93 ${n(c * 0.05)} ${h})`,
|
|
214
|
+
muted: "oklch(1 0 0 / 0.04)",
|
|
215
|
+
"muted-foreground": `oklch(0.7 ${n(c * 0.15)} ${h})`,
|
|
216
|
+
accent: `oklch(0.76 ${n(c * 0.85)} ${h} / 0.14)`,
|
|
217
|
+
"accent-foreground": `oklch(0.93 ${n(c * 0.05)} ${h})`,
|
|
218
|
+
destructive: "oklch(0.74 0.12 25)",
|
|
219
|
+
border: `oklch(0.25 ${n(c * 0.1)} ${h})`,
|
|
220
|
+
input: `oklch(0.3 ${n(c * 0.1)} ${h})`,
|
|
221
|
+
ring: `oklch(${n(0.76 + lift / 2)} ${n(c * 0.85)} ${h})`,
|
|
222
|
+
"chart-1": `oklch(${n(0.76 + lift / 2)} ${n(c * 0.85)} ${h})`,
|
|
223
|
+
"chart-2": "oklch(0.78 0.11 160)",
|
|
224
|
+
"chart-3": "oklch(0.8 0.1 195)",
|
|
225
|
+
"chart-4": "oklch(0.78 0.11 340)",
|
|
226
|
+
"chart-5": "oklch(0.8 0.12 80)",
|
|
227
|
+
}
|
|
228
|
+
return { id, name, light, dark }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export const DEFAULT_ID = "mist"
|
|
232
|
+
|
|
233
|
+
/** The themes that come with the page; Mist is the page's own colours and sets nothing. */
|
|
234
|
+
export const PRESETS: readonly Palette[] = [
|
|
235
|
+
{ id: DEFAULT_ID, name: "Mist" },
|
|
236
|
+
tinted("forest", "Forest", 155, 0.12),
|
|
237
|
+
tinted("ocean", "Ocean", 225, 0.12),
|
|
238
|
+
tinted("violet", "Violet", 305, 0.16),
|
|
239
|
+
tinted("rose", "Rose", 5, 0.16),
|
|
240
|
+
tinted("amber", "Amber", 70, 0.14, 0.06),
|
|
241
|
+
tinted("mono", "Mono", 260, 0),
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
/** Colours for a preview swatch, per mode: Mist's own values by hand, the rest from the theme. */
|
|
245
|
+
const MIST_SWATCH = {
|
|
246
|
+
light: { background: "#f4f5f9", card: "#ffffff", primary: "#4650d1", accent: "#e6e9fb" },
|
|
247
|
+
dark: { background: "#08090c", card: "#0e1015", primary: "#8f9bff", accent: "#151a33" },
|
|
248
|
+
} as const
|
|
249
|
+
|
|
250
|
+
export function swatch(palette: Palette, dark: boolean): { background: string; card: string; primary: string; accent: string } {
|
|
251
|
+
const vars = dark ? palette.dark : palette.light
|
|
252
|
+
const base = MIST_SWATCH[dark ? "dark" : "light"]
|
|
253
|
+
if (!vars) return { ...base }
|
|
254
|
+
return {
|
|
255
|
+
background: vars.background ?? base.background,
|
|
256
|
+
card: vars.card ?? base.card,
|
|
257
|
+
primary: vars.primary ?? base.primary,
|
|
258
|
+
accent: vars.primary ? `color-mix(in oklab, ${vars.primary} 18%, ${vars.background ?? base.background})` : base.accent,
|
|
259
|
+
}
|
|
260
|
+
}
|
package/src/webui/protocol.ts
CHANGED
|
@@ -81,6 +81,21 @@ export interface KeyInfo {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/** `status.get`: everything the page shell needs before it reads a tab. */
|
|
84
|
+
/** A colour theme the user saved, kept for every pi session (`themes.json`). */
|
|
85
|
+
export interface SavedThemeInfo {
|
|
86
|
+
id: string;
|
|
87
|
+
name: string;
|
|
88
|
+
light?: Record<string, string>;
|
|
89
|
+
dark?: Record<string, string>;
|
|
90
|
+
savedAt: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The saved themes and the chosen one. */
|
|
94
|
+
export interface ThemeStoreInfo {
|
|
95
|
+
themes: SavedThemeInfo[];
|
|
96
|
+
active?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
84
99
|
export interface StatusInfo {
|
|
85
100
|
workspace: { name: string; branch?: string };
|
|
86
101
|
branch?: string;
|
|
@@ -94,6 +109,8 @@ export interface StatusInfo {
|
|
|
94
109
|
tabs: TabInfo[];
|
|
95
110
|
keys: KeyInfo[];
|
|
96
111
|
windows: Array<{ name: string; url: string }>;
|
|
112
|
+
/** What this window carried on after pi stopped unexpectedly: the page says so once. */
|
|
113
|
+
recovered?: { at: number; text: string };
|
|
97
114
|
}
|
|
98
115
|
|
|
99
116
|
/** Agent work time as of the reply: the page adds the time that passes while `running`. */
|
|
@@ -216,6 +233,34 @@ export interface PlannerSnapshot {
|
|
|
216
233
|
busy: boolean;
|
|
217
234
|
}
|
|
218
235
|
|
|
236
|
+
/** A previous plan as a list shows it: a planning session never saved as a task. */
|
|
237
|
+
export interface PreviousPlanInfo {
|
|
238
|
+
id: string;
|
|
239
|
+
title: string;
|
|
240
|
+
createdAt: string;
|
|
241
|
+
updatedAt: string;
|
|
242
|
+
archivedAt?: string;
|
|
243
|
+
messages: number;
|
|
244
|
+
rounds: number;
|
|
245
|
+
hasDraft: boolean;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A previous plan, whole, to read. */
|
|
249
|
+
export interface PreviousPlanDetail {
|
|
250
|
+
id: string;
|
|
251
|
+
title: string;
|
|
252
|
+
createdAt: string;
|
|
253
|
+
updatedAt: string;
|
|
254
|
+
archivedAt?: string;
|
|
255
|
+
rounds: number;
|
|
256
|
+
seats: PanelMember[];
|
|
257
|
+
messages: PlannerMessage[];
|
|
258
|
+
draft?: string;
|
|
259
|
+
notes: PanelNote[];
|
|
260
|
+
/** Where it started, when it came from an issue. */
|
|
261
|
+
seed?: string;
|
|
262
|
+
}
|
|
263
|
+
|
|
219
264
|
/** One issue a planning session can start from. */
|
|
220
265
|
export interface PlannerSeedInfo {
|
|
221
266
|
issue: { number: number; title: string; url?: string };
|
|
@@ -552,6 +597,7 @@ export interface Api {
|
|
|
552
597
|
"tasks.message": { request: { taskId: string; text: string; attachments?: string[] }; result: { notice: string } };
|
|
553
598
|
"tasks.get": { request: { taskId: string }; result: TaskDetail };
|
|
554
599
|
"tasks.open": { request: { taskId: string }; result: { sessionId?: string; key?: string; notice?: string } };
|
|
600
|
+
"tasks.resume": { request: { taskId: string }; result: { notice: string; key?: string } };
|
|
555
601
|
"tasks.deliveryReview": { request: { taskId: string }; result: { delivery: Delivery } };
|
|
556
602
|
"tasks.deliveryDefer": { request: { taskId: string; reviewId: string }; result: { delivery: Delivery } };
|
|
557
603
|
"tasks.deliver": { request: { taskId: string; reviewId: string; action: DeliveryAction; confirmMain?: boolean }; result: { delivery: Delivery } };
|
|
@@ -576,6 +622,11 @@ export interface Api {
|
|
|
576
622
|
"planner.retry": { request: Record<string, never>; result: { notice: string } };
|
|
577
623
|
"planner.commentLine": { request: { line: string; text: string }; result: { notice: string } };
|
|
578
624
|
"planner.answer": { request: Record<string, never>; result: { notice: string } };
|
|
625
|
+
"planner.previous": { request: { archived?: boolean }; result: { plans: PreviousPlanInfo[] } };
|
|
626
|
+
"planner.previousGet": { request: { id: string }; result: PreviousPlanDetail };
|
|
627
|
+
"planner.previousArchive": { request: { id: string; archived: boolean }; result: { notice: string } };
|
|
628
|
+
"planner.previousDelete": { request: { id: string }; result: { notice: string } };
|
|
629
|
+
"planner.previousOpen": { request: { id: string }; result: { notice: string } };
|
|
579
630
|
"planner.save": { request: Record<string, never>; result: { notice: string } };
|
|
580
631
|
"quickfix.list": { request: Record<string, never>; result: { jobs: QuickFixJob[] } };
|
|
581
632
|
"quickfix.submit": { request: { text: string; attachments?: string[] }; result: { notice: string; id: string } };
|
|
@@ -612,6 +663,11 @@ export interface Api {
|
|
|
612
663
|
"settings.get": { request: Record<string, never>; result: SettingsInfo };
|
|
613
664
|
"settings.set": { request: { patch: Record<string, unknown> }; result: { config: BotLobbyConfig } };
|
|
614
665
|
"settings.linters": { request: Record<string, never>; result: { linters: SettingsLinter[] } };
|
|
666
|
+
"themes.get": { request: Record<string, never>; result: ThemeStoreInfo };
|
|
667
|
+
"themes.save": { request: { name: string; light?: Record<string, string>; dark?: Record<string, string> }; result: ThemeStoreInfo & { id: string } };
|
|
668
|
+
"themes.rename": { request: { id: string; name: string }; result: ThemeStoreInfo };
|
|
669
|
+
"themes.remove": { request: { id: string }; result: ThemeStoreInfo };
|
|
670
|
+
"themes.choose": { request: { id: string }; result: ThemeStoreInfo };
|
|
615
671
|
}
|
|
616
672
|
|
|
617
673
|
export type ApiName = keyof Api;
|
package/src/webui/server.ts
CHANGED
|
@@ -31,6 +31,14 @@ import { EventHub } from "./events.ts";
|
|
|
31
31
|
import { servePreview } from "./files.ts";
|
|
32
32
|
import { MAX_UPLOAD_BYTES, saveUpload } from "./uploads.ts";
|
|
33
33
|
|
|
34
|
+
/** What a call made while pi switches sessions is told; the page reads again once the new session is up. */
|
|
35
|
+
export const SWITCHING = "pi is switching sessions — the page catches up in a moment";
|
|
36
|
+
|
|
37
|
+
/** pi's refusal of a context from a session it has replaced or reloaded. */
|
|
38
|
+
export function staleSession(error: unknown): boolean {
|
|
39
|
+
return /ctx is stale after session replacement or reload/i.test((error as Error)?.message ?? "");
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
/** The default port; the server tries it, then the next free port up to +20. */
|
|
35
43
|
export const DEFAULT_PORT = 7347;
|
|
36
44
|
/** How many ports past the base are tried. */
|
|
@@ -320,6 +328,8 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
|
|
|
320
328
|
return;
|
|
321
329
|
}
|
|
322
330
|
if (error instanceof HttpError) sendError(res, error.code, error.message, error.status);
|
|
331
|
+
// Between pi replacing the session and the lobby following it, the old session answers nothing.
|
|
332
|
+
else if (staleSession(error)) sendError(res, "failed", SWITCHING, 503);
|
|
323
333
|
else {
|
|
324
334
|
lobbyFeed.log("LOBBY", `the web server could not do that — ${error.message}`, "error");
|
|
325
335
|
sendError(res, "failed", "the lobby could not do that", 500);
|