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:
@@ -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
@@ -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()} ↓
|
||||
|
||||
Reference in New Issue
Block a user