feat: pin sessions to workspace revisions
This commit is contained in:
@@ -6,16 +6,25 @@ import {
|
||||
deleteSession, getSessionDocuments,
|
||||
} from "./sessions";
|
||||
|
||||
test("createSession POSTs only {question} and returns the id", async () => {
|
||||
test("createSession migrates legacy selections and POSTs browser preferences", async () => {
|
||||
localStorage.clear();
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({
|
||||
workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
})),
|
||||
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ id: "s1" });
|
||||
}),
|
||||
);
|
||||
expect(await createSession({ question: "q" })).toEqual({ id: "s1" });
|
||||
expect(body).toEqual({ question: "q" });
|
||||
expect(body).toEqual({
|
||||
question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem("thothii.workspace-registry.v1.preferences")!)).toEqual({
|
||||
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
});
|
||||
});
|
||||
|
||||
test.each([202, 204])("prewarmRuntime accepts a body-less %s response", async (status) => {
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
import { apiFetch } from "./client";
|
||||
import { getSettings } from "./settings";
|
||||
import { workspacePreferences, type WorkspacePreference } from "../workspaces/preferences";
|
||||
import type {
|
||||
Principal, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse,
|
||||
} from "./types";
|
||||
|
||||
export const createSession = (i: { question: string; name?: string }) =>
|
||||
apiFetch<{ id: string }>("/sessions", { method: "POST", body: JSON.stringify(i) });
|
||||
type NewSessionInput = { question: string; name?: string } & Partial<WorkspacePreference>;
|
||||
|
||||
async function selectedPreferences(): Promise<WorkspacePreference> {
|
||||
const saved = workspacePreferences.load();
|
||||
if (saved.workspaceId && saved.provider && saved.model && saved.thinking) return saved;
|
||||
|
||||
// A direct new-session entry point can run before the composer has mounted. Seed its
|
||||
// browser-local preferences from the legacy read-only defaults once, then keep them local.
|
||||
const legacy = await getSettings();
|
||||
return workspacePreferences.save({
|
||||
workspaceId: saved.workspaceId ?? legacy.workspace,
|
||||
provider: saved.provider ?? legacy.provider,
|
||||
model: saved.model ?? legacy.model,
|
||||
thinking: saved.thinking ?? legacy.thinking,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSession(i: NewSessionInput) {
|
||||
const preferences = await selectedPreferences();
|
||||
return apiFetch<{ id: string }>("/sessions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...preferences, ...i }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Best-effort warm-up; callers must not await it before showing the composer. */
|
||||
export const prewarmRuntime = () =>
|
||||
|
||||
@@ -13,6 +13,7 @@ function renderShell() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
FakeEventSource.instances = [];
|
||||
(globalThis as any).EventSource = FakeEventSource;
|
||||
useSessionStore.getState().resetSession();
|
||||
@@ -121,8 +122,7 @@ test("a DWH-unreachable precheck shows a specific alert and preserves the questi
|
||||
expect(FakeEventSource.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("model selector shows the three Pi-enabled models and persists the selected provider", async () => {
|
||||
let saved: unknown;
|
||||
test("model selector shows the three Pi-enabled models and stores the selected provider locally", async () => {
|
||||
server.use(
|
||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({
|
||||
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
@@ -132,11 +132,6 @@ test("model selector shows the three Pi-enabled models and persists the selected
|
||||
{ provider: "deepseek", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", reasoning: true },
|
||||
{ provider: "local-qwen", id: "qwen3.6-35b-a3b", name: "Qwen3.6 35B A3B Local", reasoning: false },
|
||||
] })),
|
||||
http.put("http://localhost:8787/settings", async ({ request }) => {
|
||||
const body = await request.json() as Record<string, string>;
|
||||
saved = body;
|
||||
return HttpResponse.json(body);
|
||||
}),
|
||||
);
|
||||
renderShell();
|
||||
|
||||
@@ -147,9 +142,10 @@ test("model selector shows the three Pi-enabled models and persists the selected
|
||||
]);
|
||||
});
|
||||
await userEvent.selectOptions(select, "qwen3.6-35b-a3b");
|
||||
await waitFor(() => expect(saved).toMatchObject({
|
||||
provider: "local-qwen", model: "qwen3.6-35b-a3b",
|
||||
}));
|
||||
await waitFor(() => expect(JSON.parse(localStorage.getItem("thothii.workspace-registry.v1.preferences")!))
|
||||
.toEqual({
|
||||
workspaceId: "default", provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low",
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -27,9 +27,13 @@ test("the form has only a question field (no workspace/model/provider/thinking)"
|
||||
expect(screen.queryByLabelText(/thinking/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("submitting posts only { question } and calls onCreated", async () => {
|
||||
test("submitting includes browser-local migrated preferences and calls onCreated", async () => {
|
||||
localStorage.clear();
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({
|
||||
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
})),
|
||||
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ id: "s1" });
|
||||
@@ -40,7 +44,10 @@ test("submitting posts only { question } and calls onCreated", async () => {
|
||||
await userEvent.type(await screen.findByLabelText(/question/i), "Quante vendite nel 2025?");
|
||||
await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ question: "Quante vendite nel 2025?" }));
|
||||
await waitFor(() => expect(body).toEqual({
|
||||
question: "Quante vendite nel 2025?",
|
||||
workspaceId: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
}));
|
||||
await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s1"));
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSessionStore } from "../store/sessionStore";
|
||||
import { ComposerFooter, ContextGauge, SteerInput } from "./SteerInput";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/steer", () =>
|
||||
new HttpResponse(null, { status: 204 }),
|
||||
@@ -15,6 +16,25 @@ beforeEach(() => {
|
||||
);
|
||||
});
|
||||
|
||||
test("new sessions send the browser-selected workspace, model, provider, and thinking", async () => {
|
||||
let body: unknown;
|
||||
localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
|
||||
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high",
|
||||
}));
|
||||
server.use(http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ id: "s1" });
|
||||
}));
|
||||
render(<SteerInput sessionId={null} />);
|
||||
|
||||
await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q");
|
||||
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
await waitFor(() => expect(body).toEqual({
|
||||
question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high",
|
||||
}));
|
||||
});
|
||||
|
||||
test("renders a text input and submit button", () => {
|
||||
render(<SteerInput sessionId="s1" />);
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// frontend/src/shell/SteerInput.tsx
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CornerDownLeft } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { postSteer, createSession } from "../api/sessions";
|
||||
import { ApiError } from "../api/client";
|
||||
import { getSettings, putSettings, type Settings } from "../api/settings";
|
||||
import { getSettings } from "../api/settings";
|
||||
import { listWorkspaces } from "../api/workspaces";
|
||||
import { listModels } from "../api/models";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { workspacePreferences, type WorkspacePreference } from "../workspaces/preferences";
|
||||
|
||||
const THINKING_LEVELS = ["low", "medium", "high"] as const;
|
||||
|
||||
@@ -151,25 +152,33 @@ export function SteerInput({
|
||||
|
||||
/**
|
||||
* Status strip beneath the composer. Left: the active workspace selector.
|
||||
* Right: live context usage plus model + thinking-level selectors (these replace
|
||||
* the old Settings dialog and persist through PUT /settings).
|
||||
* Right: live context usage plus browser-local workspace/model/thinking selectors.
|
||||
*/
|
||||
export function ComposerFooter() {
|
||||
const qc = useQueryClient();
|
||||
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: getSettings });
|
||||
const { data: workspaces = [] } = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces });
|
||||
const { data: modelsData } = useQuery({ queryKey: ["models"], queryFn: listModels });
|
||||
const models = modelsData?.models ?? [];
|
||||
const tokenUsage = useSessionStore((state) => state.tokenUsage);
|
||||
const [preferences, setPreferences] = useState<WorkspacePreference>(() => workspacePreferences.load());
|
||||
|
||||
const workspace = settings?.workspace ?? "";
|
||||
const model = settings?.model ?? "";
|
||||
const thinking = settings?.thinking ?? "medium";
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setPreferences(workspacePreferences.migrate({
|
||||
workspaceId: settings.workspace,
|
||||
provider: settings.provider,
|
||||
model: settings.model,
|
||||
thinking: settings.thinking,
|
||||
}));
|
||||
}, [settings]);
|
||||
|
||||
// PUT /settings replaces the whole object, so always send the merged settings.
|
||||
async function update(patch: Partial<Settings>) {
|
||||
await putSettings({ workspace, provider: settings?.provider, model, thinking, ...patch });
|
||||
qc.invalidateQueries({ queryKey: ["settings"] });
|
||||
const workspace = preferences.workspaceId ?? settings?.workspace ?? "";
|
||||
const model = preferences.model ?? settings?.model ?? "";
|
||||
const thinking = preferences.thinking ?? settings?.thinking ?? "medium";
|
||||
|
||||
function update(patch: WorkspacePreference) {
|
||||
const next = workspacePreferences.save({ ...preferences, ...patch });
|
||||
setPreferences(next);
|
||||
}
|
||||
|
||||
function onModelChange(id: string) {
|
||||
@@ -185,7 +194,7 @@ export function ComposerFooter() {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border/60 px-3 py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FooterSelect aria-label="Workspace" value={workspace} onChange={(v) => update({ workspace: v })}>
|
||||
<FooterSelect aria-label="Workspace" value={workspace} onChange={(v) => update({ workspaceId: v })}>
|
||||
{workspaces.length === 0 ? (
|
||||
<option value="">— workspace —</option>
|
||||
) : (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { App } from "../App";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
FakeEventSource.instances = [];
|
||||
(globalThis as any).EventSource = FakeEventSource;
|
||||
useSessionStore.getState().resetSession();
|
||||
@@ -19,6 +20,9 @@ test("F1: create session -> widget via SSE -> respond -> POST /response", async
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions", () => HttpResponse.json({ id: "s1" })),
|
||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([])),
|
||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({
|
||||
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
})),
|
||||
http.get("http://localhost:8787/workspaces", () =>
|
||||
HttpResponse.json([{ name: "default", file: "default.db" }]),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { workspacePreferences } from "./preferences";
|
||||
|
||||
test("migrates legacy selections once and keeps later browser choices", () => {
|
||||
localStorage.clear();
|
||||
|
||||
expect(workspacePreferences.migrate({
|
||||
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||
})).toMatchObject({ workspaceId: "psd-clinical", model: "glm-5.2" });
|
||||
workspacePreferences.save({ workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "high" });
|
||||
|
||||
expect(workspacePreferences.migrate({ workspaceId: "legacy" })).toMatchObject({
|
||||
workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "high",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface WorkspacePreference {
|
||||
workspaceId?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "thothii.workspace-registry.v1.preferences";
|
||||
|
||||
function storage(): Storage | undefined {
|
||||
try { return window.localStorage; } catch { return undefined; }
|
||||
}
|
||||
|
||||
function parse(raw: string | null): WorkspacePreference | undefined {
|
||||
if (raw === null) return undefined;
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as WorkspacePreference : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Anonymous, non-secret browser choices for the workspace session composer. */
|
||||
export const workspacePreferences = {
|
||||
load(): WorkspacePreference {
|
||||
return parse(storage()?.getItem(STORAGE_KEY) ?? null) ?? {};
|
||||
},
|
||||
|
||||
save(value: WorkspacePreference): WorkspacePreference {
|
||||
try { storage()?.setItem(STORAGE_KEY, JSON.stringify(value)); } catch { /* storage is optional */ }
|
||||
return value;
|
||||
},
|
||||
|
||||
/** Copy the pre-local-storage server values only when this browser has no saved choice. */
|
||||
migrate(legacy: WorkspacePreference): WorkspacePreference {
|
||||
const current = storage()?.getItem(STORAGE_KEY);
|
||||
return current === null || current === undefined ? this.save(legacy) : this.load();
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user