daemon: integrate QoL slices 1-5 (model/rename/catalog, history deletes, stats, repo prepare+imageUsed+built) — 95.3% cover green

This commit is contained in:
Raphael Westphal
2026-08-19 10:07:52 +02:00
parent 7287b831e4
commit 698b742cb3
16 changed files with 1506 additions and 36 deletions
+46
View File
@@ -0,0 +1,46 @@
# Integration task: merge 5 web slices (already-recovered blobs in ../../.pi/scratch/rec4/)
Base: this worktree at current master + the ChatView.tsx from slice 1 (model/rename —
already placed). The other slices each edited overlapping files. Merge ALL slices.
## Blob map (.pi/scratch/rec4/<sha>.txt)
- Slice 1 (model switch + rename): ChatView=e2c7b1c1 (PLACED), derive=6c499294? NO —
check: derive blobs: 6c499294 (has notice? grep error_notice), 73f1de97, abf953bf.
ChatView.test=0617e495, protocol=29b7795f is slice-5's, slice-1 protocol is
9aec478e? — IDENTIFY by features:
- s1: catalogCache, SessionModel routes, error_notice/notice-line, rename
- s2: Clear history menu, Delete session, Route.Session, trash button (SessionsView=8ab1292a? grep)
- s3: SessionStats/Route.Stats, formatTokens in store.ts, SessionsView global line
- s4 (web-only QoL): renderMarkdown in ChatStream=01f25bac(also has FAB/search),
ChatView=17c34004 (timestamps/placeholder/ArrowUp), derive=?? (ts field), css=5ba557b7 or 4b643f25 or 19c9becd (largest superset)
- s5: SpawnView badges/prepare (SpawnView=ec5f2892? grep prepare), protocol RepoPrepare/Repos/imageUsed, SpawnView.test=996b2e63
## Procedure
1. protocol.ts: start from master's, add ALL routes/types from s1+s2+s3+s5 blobs
(SessionModel, ModelCatalog, Session(id) DELETE, ClearHistoryResponse, Stats,
SessionStats, StatsTotals, Repos/RepoImage/RepoPrepare, imageUsed on SpawnResponse).
2. ChatView.tsx: base = e2c7b1c1 (placed). Graft: s2 overflow menu (Clear/Delete,
from a8c6b3b0), s3 stats chip fetch (from 5ef417c8), s4 QoL pieces that live in
ChatView (search toggle+hotkey, ⋯ timestamps toggle, placeholder switch,
ArrowUp recall — from 17c34004).
3. ChatStream.tsx: base = 01f25bac (s4: markdown+FAB+search+ts). Verify it compiles
against s1's notice-line message shape; adapt.
4. derive.ts: master + s1 notice message + s4 ts field (whichever blob has both, else merge).
5. store.ts: master + s3 formatTokens (+ any stats helpers).
6. SessionsView.tsx: master + s2 trash + s3 global stats line.
7. SpawnView.tsx: master + s5 badges/prepare/imageUsed.
8. index.css: take the LARGEST blob (superset ordering: 19c9becd 22628 > 5ba557b7 21318
> 4b643f25 21285) and verify it contains all classes used by merged components;
add missing rules from the others.
9. Tests: merge test blobs the same way (ChatView.test from 0617e495 + grafts from
s2/s3/s4 test files; SpawnView.test=996b2e63; ChatStream tests inside 1c6e7edd?
identify; derive tests). Aim: keep every test that matches merged features; drop
tests for unmerged ones.
10. Gates: bunx tsc@5.9 --noEmit && bun run build && bun run coverage — ALL four
metrics ≥95. Iterate until green. You may simplify/drop marginal features'
tests if a graft is cursed, but NEVER ship a failing suite.
Work tree = this worktree; blobs are read-only. Report per-file what you took from
which slice + final verbatim coverage/build tails.
+190 -4
View File
@@ -1,6 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import type { EventFrame } from "./protocol";
import type {
EventFrame,
ModelCatalogEntry,
RenameBody,
SetModelBody,
} from "./protocol";
import { Route } from "./protocol";
import { ApiError, errMessage, fetchJson } from "./api";
import {
@@ -18,6 +23,38 @@ const HISTORY_LIMIT: number = 1000;
const LATEST_QUERY: string = "latest=1";
const TEXTAREA_MAX_H: number = 200;
const SEND_KEY: string = "Enter";
const ESCAPE_KEY: string = "Escape";
/** Catalog cache shared across mounts and session switches: the model list
* is static for a daemon run, so it is fetched at most once per page load. */
let catalogCache: ModelCatalogEntry[] | null = null;
export interface CatalogGroup {
provider: string;
models: ModelCatalogEntry[];
}
/** Group catalog entries by provider, preserving arrival order. */
export function groupCatalog(entries: ModelCatalogEntry[]): CatalogGroup[] {
const groups = new Map<string, ModelCatalogEntry[]>();
for (const entry of entries) {
const list = groups.get(entry.provider);
if (list === undefined) groups.set(entry.provider, [entry]);
else list.push(entry);
}
return Array.from(
groups,
([provider, models]): CatalogGroup => ({
provider,
models,
}),
);
}
/** Test seam: drop the module-level catalog cache. */
export function resetCatalogCache(): void {
catalogCache = null;
}
interface Props {
store: SessionsStore;
@@ -35,6 +72,14 @@ export default function ChatView({ store, pushToast }: Props) {
const [tasksOpen, setTasksOpen] = useState<boolean>(false);
const [hasOlder, setHasOlder] = useState<boolean>(false);
const [loadingOlder, setLoadingOlder] = useState<boolean>(false);
const [modelOpen, setModelOpen] = useState<boolean>(false);
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(
catalogCache,
);
const [catalogError, setCatalogError] = useState<string>("");
const [switching, setSwitching] = useState<boolean>(false);
const [renaming, setRenaming] = useState<boolean>(false);
const [renameDraft, setRenameDraft] = useState<string>("");
const lastSeqRef = useRef<number>(0);
const loadedRef = useRef<boolean>(false);
@@ -192,6 +237,65 @@ export default function ChatView({ store, pushToast }: Props) {
}
};
const toggleModelPicker = (): void => {
setModelOpen((open) => !open);
if (catalogCache === null) {
setCatalogError("");
void fetchJson<ModelCatalogEntry[]>(Route.ModelCatalog)
.then((list) => {
catalogCache = list;
setCatalog(list);
})
.catch((err: unknown) => setCatalogError(errMessage(err)));
}
};
const pickModel = async (
provider: string,
modelId: string,
): Promise<void> => {
if (switching) return;
setSwitching(true);
try {
await fetchJson(Route.SessionModel(sessionId), {
method: "POST",
body: JSON.stringify({ provider, modelId } satisfies SetModelBody),
});
setModelOpen(false);
} catch (err) {
if (err instanceof ApiError && err.status === 409)
pushToast("session offline");
else pushToast(errMessage(err));
} finally {
setSwitching(false);
}
};
const startRename = (): void => {
setRenameDraft(session?.name ?? "");
setRenaming(true);
};
const cancelRename = (): void => {
setRenaming(false);
setRenameDraft("");
};
const saveRename = async (): Promise<void> => {
const name = renameDraft.trim();
if (name.length === 0) return;
try {
await fetchJson(Route.Session(sessionId), {
method: "PATCH",
body: JSON.stringify({ name } satisfies RenameBody),
});
setRenaming(false);
await store.refresh();
} catch (err) {
pushToast(errMessage(err));
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
// IME composition: Enter confirms the candidate window, not a send
if (e.nativeEvent.isComposing) return;
@@ -213,10 +317,92 @@ export default function ChatView({ store, pushToast }: Props) {
<div className="chat-layout">
<div className="chat-col">
<div className="chat-header">
<div className="title">
{session?.name ?? session?.repo ?? sessionId}
{renaming ? (
<div className="rename-row">
<input
className="rename-input"
value={renameDraft}
aria-label="Session name"
onChange={(e) => setRenameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === SEND_KEY && !e.shiftKey) {
e.preventDefault();
void saveRename();
}
if (e.key === ESCAPE_KEY) cancelRename();
}}
/>
<button
type="button"
className="icon-btn"
aria-label="Save session name"
onClick={() => void saveRename()}
>
</button>
<button
type="button"
className="icon-btn"
aria-label="Cancel rename"
onClick={cancelRename}
>
</button>
</div>
) : (
<>
<div className="title">
{session?.name ?? session?.repo ?? sessionId}
</div>
<button
type="button"
className="icon-btn"
aria-label="Rename session"
onClick={startRename}
>
</button>
</>
)}
<div className="model-picker">
<button
type="button"
className="model-chip"
aria-expanded={modelOpen}
aria-label="Switch model"
onClick={toggleModelPicker}
>
<span>{session?.model || "model"}</span> <span></span>
</button>
{modelOpen && (
<div className="model-popover">
{catalogError.length > 0 && (
<div className="notice-line">{catalogError}</div>
)}
{catalog === null && catalogError.length === 0 && (
<div className="model-loading">loading</div>
)}
{catalog !== null &&
groupCatalog(catalog).map((group) => (
<div key={group.provider}>
<div className="model-group">{group.provider}</div>
{group.models.map((m) => (
<button
key={`${m.provider}/${m.id}`}
type="button"
className="model-option"
aria-label={`Switch to ${m.provider}/${m.id}`}
disabled={switching}
onClick={() => void pickModel(m.provider, m.id)}
>
{m.name}
</button>
))}
</div>
))}
</div>
)}
</div>
<div className="sub">{session?.model}</div>
{(chat.usage.inputTokens > 0 || chat.usage.outputTokens > 0) && (
<span className="usage-chip" title="tokens this session">
{chat.usage.inputTokens.toLocaleString()}