diff --git a/packages/web/src/components/layout/project-dialogs.tsx b/packages/web/src/components/layout/project-dialogs.tsx index ffcbe95..39efaf5 100644 --- a/packages/web/src/components/layout/project-dialogs.tsx +++ b/packages/web/src/components/layout/project-dialogs.tsx @@ -20,7 +20,11 @@ import { } from "../../lib/semantic-id"; import { agentDisplayName, projectDisplayName, useProject } from "../../state/project"; import { useAuth } from "../../state/auth"; -import { clearDraftModelRef } from "../../features/chat/draft-cache"; +import { clearDraftChatDefaults, clearDraftModelRef } from "../../features/chat/draft-cache"; +import { + dispatchChatDefaultsChanged, + type ChatDefaultsChangedDetail, +} from "../../features/chat/chat-defaults-event"; import { ModelSelect } from "../../features/chat/model-select"; import { SELECTABLE_THINKING_LEVELS } from "../../features/chat/thinking-level"; import { WorkspaceSelect } from "../../features/chat/workspace-select"; @@ -479,10 +483,21 @@ function ChatDefaultsSection({ projectId, isOwner }: { projectId: string; isOwne * One Save persists both writes: the `[default_chat]` block (whole-block PUT — a field * left "not set" is simply omitted, which clears it) and, when changed, the default * model via the narrow route. Failures toast and keep the edits for retry. + * + * Each landed write also resets the saving user's new-conversation draft so new chats + * pick the change up instead of being shadowed by the values a previous /chat/new visit + * pinned into the cache: the corresponding cache fields are stripped (typed text and + * staged skills always survive), and one same-tab event carries the fresh values to any + * MOUNTED draft view — its component state still holds the old selections and its + * debounced persist would silently write them right back over the stripped cache. */ const save = async () => { if (busy || (!blockDirty && !modelDirty)) return; setBusy(true); + // Collected per landed write, dispatched in `finally`: when the block PUT lands but the + // model PUT throws, the block change still happened server-side and live views must + // still reseed from it. + let changed: ChatDefaultsChangedDetail | null = null; try { if (blockDirty) { const body: ChatDefaultsDto = { @@ -491,7 +506,14 @@ function ChatDefaultsSection({ projectId, isOwner }: { projectId: string; isOwne ...(approval ? { approvalMode: approval as ApprovalMode } : {}), ...(thinking ? { thinkingLevel: thinking as ChatDefaultsDto["thinkingLevel"] } : {}), }; - setSaved(await api.putChatDefaults(projectId, body)); + const stored = await api.putChatDefaults(projectId, body); + setSaved(stored); + // Release the draft-cached Agent / Workspace / approval pins so the next + // /chat/new seeds from the just-saved block. The model pin is deliberately NOT + // touched here: it is the switch-becomes-default carry-over, released only below + // when the default model itself changed. + if (user) clearDraftChatDefaults(user.userId, projectId); + changed = { projectId, defaults: stored }; } if (modelDirty && modelRef) { const res = await api.putDefaultModel(projectId, { @@ -502,10 +524,12 @@ function ChatDefaultsSection({ projectId, isOwner }: { projectId: string; isOwne // Same follow-through as the models page: drop the draft-cached model pin so open // drafts pick up the new default. if (user) clearDraftModelRef(user.userId, projectId); + changed = { ...(changed ?? { projectId }), defaultModel: res.defaultModel }; } } catch (e) { toastError(apiErrorText(e)); } finally { + if (changed) dispatchChatDefaultsChanged(changed); setBusy(false); } }; diff --git a/packages/web/src/features/chat/chat-defaults-event.ts b/packages/web/src/features/chat/chat-defaults-event.ts new file mode 100644 index 0000000..4f95dcc --- /dev/null +++ b/packages/web/src/features/chat/chat-defaults-event.ts @@ -0,0 +1,35 @@ +/** + * Same-tab notification that a Project's new-chat defaults were saved (project-settings + * dialog → any live view seeded from them). The dialog and the draft page are mounted by + * different trees (sidebar vs route), so a window CustomEvent is the narrowest channel: the + * dialog dispatches after its PUTs land, carrying the fresh server-confirmed values, and a + * mounted DraftView / ChatPage reseeds from the payload without refetching what the dialog + * already holds. Same-tab only by design — other tabs (and the stripped localStorage cache) + * pick the new defaults up on their next /chat/new mount. + */ +import type { ChatDefaultsDto, ModelRefDto } from "@prismshadow/penguin-server/api"; + +export const CHAT_DEFAULTS_CHANGED_EVENT = "penguin:chat-defaults-changed"; + +export interface ChatDefaultsChangedDetail { + projectId: string; + /** Present iff the `[default_chat]` block changed: the stored block the PUT returned. */ + defaults?: ChatDefaultsDto; + /** Present iff the Project default model changed: the new default (paired reference). */ + defaultModel?: ModelRefDto; +} + +export function dispatchChatDefaultsChanged(detail: ChatDefaultsChangedDetail): void { + window.dispatchEvent( + new CustomEvent(CHAT_DEFAULTS_CHANGED_EVENT, { detail }), + ); +} + +/** Listener-side accessor: returns the detail when the event targets `projectId`, else null. */ +export function chatDefaultsChangedDetail( + e: Event, + projectId: string, +): ChatDefaultsChangedDetail | null { + const detail = (e as CustomEvent).detail; + return detail && detail.projectId === projectId ? detail : null; +} diff --git a/packages/web/src/features/chat/chat-page.tsx b/packages/web/src/features/chat/chat-page.tsx index f8fd042..1cb4dd1 100644 --- a/packages/web/src/features/chat/chat-page.tsx +++ b/packages/web/src/features/chat/chat-page.tsx @@ -59,6 +59,7 @@ import { latestTaskHasSubagent, taskStartCount } from "./agent-topology"; import { ChatInput } from "./chat-input"; import { ConversationOutline, OutlineMenuButton, useOutlineRailFit } from "./conversation-outline"; import { DraftView } from "./draft-view"; +import { CHAT_DEFAULTS_CHANGED_EVENT, chatDefaultsChangedDetail } from "./chat-defaults-event"; import { advanceCostStat, applyUsageFetch, createCostStatHold } from "./header-stats"; import type { CostStatDisplay } from "./header-stats"; import { buildInputHistory } from "./input-history"; @@ -605,6 +606,34 @@ export function ChatPage() { }; }, [projectId]); + // Project new-chat defaults saved in this tab (project-settings dialog) with a CHANGED + // default model: refetch the model config so the "project default" marker and a later + // draft mount see the fresh default. models goes null first — DraftView's model + // fallback holds off while null, so it cannot re-pin the stale default from the old + // response in the meantime (the mounted draft's own selection comes straight from the + // event payload, see DraftView.onDefaultsChanged); a failed refetch leaves models null, + // the same degraded state as a failed mount fetch. + useEffect(() => { + if (!projectId) return; + let cancelled = false; + const onEvent = (e: Event) => { + const detail = chatDefaultsChangedDetail(e, projectId); + if (!detail || detail.defaultModel === undefined) return; + setModels(null); + api + .getModels(projectId) + .then((res) => { + if (!cancelled) setModels(res); + }) + .catch(() => undefined); + }; + window.addEventListener(CHAT_DEFAULTS_CHANGED_EVENT, onEvent); + return () => { + cancelled = true; + window.removeEventListener(CHAT_DEFAULTS_CHANGED_EVENT, onEvent); + }; + }, [projectId]); + // Self-heal: the server returned a new session_id, update the route and list (shared by tasks and compact). const syncHealedSessionId = useCallback( async (currentId: string, respondedId: string) => { diff --git a/packages/web/src/features/chat/draft-cache.ts b/packages/web/src/features/chat/draft-cache.ts index 59c5c1a..415348b 100644 --- a/packages/web/src/features/chat/draft-cache.ts +++ b/packages/web/src/features/chat/draft-cache.ts @@ -149,3 +149,36 @@ export function clearDraftModelRef( const draft = loadDraft(key, storage); if (draft.modelRef) saveDraft(key, { ...draft, modelRef: undefined }, storage); } + +/** + * Drops the draft-cached Agent / Workspace / approval-mode selections for this user × + * Project — the fields the `[default_chat]` block seeds — so the next new-conversation + * draft re-seeds from the just-saved Project defaults instead of the values a previous + * visit pinned into the cache (the draft page persists all selections on mount, so a + * stale cache otherwise shadows a defaults change forever). Called by the + * project-settings save when the block actually changed. Deliberately narrower than the + * full seeded set: typed text and staged skills are user content; modelRef is the + * "switch-becomes-default" carry-over released only by clearDraftModelRef when the + * default MODEL itself changes (the model is not part of the `[default_chat]` block); + * the handoff/switch chips are explicit user staging, never default-derived. A draft + * with none of the three fields is a no-op, never an errant write. + */ +export function clearDraftChatDefaults( + userId: string, + projectId: string, + storage: DraftStorage = localStorage, +): void { + const key = draftKey(userId, projectId); + const draft = loadDraft(key, storage); + if ( + draft.agentId !== undefined || + draft.workspace !== undefined || + draft.approvalMode !== undefined + ) { + saveDraft( + key, + { ...draft, agentId: undefined, workspace: undefined, approvalMode: undefined }, + storage, + ); + } +} diff --git a/packages/web/src/features/chat/draft-view.tsx b/packages/web/src/features/chat/draft-view.tsx index 3110aec..5b3b8c0 100644 --- a/packages/web/src/features/chat/draft-view.tsx +++ b/packages/web/src/features/chat/draft-view.tsx @@ -26,6 +26,13 @@ * field, the Project's new-chat defaults ([default_chat]) prefill Agent / Workspace / * approval mode (precedence: route state > draft cache > project default > built-in * fallback); the model default already flows through models.defaultModel. + * + * Saving the Project's new-chat defaults resets the seeded selections so new chats pick + * the change up: the project-settings dialog strips the cached pins (next visits reseed + * from the fresh defaults) and dispatches a same-tab chat-defaults-changed event that a + * MOUNTED draft answers by resetting Agent / Workspace / approval mode / Model in + * component state (see onDefaultsChanged) — typed-but-unsent text and staged skills + * always survive. */ import { useCallback, useEffect, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router"; @@ -60,6 +67,11 @@ import { EXAMPLE_FOLDERS } from "./example-tasks"; import type { ExampleFolderId, ExampleTask, ExampleTaskId } from "./example-tasks"; import { clearDraft, draftKey, loadDraft, saveDraft } from "./draft-cache"; import type { DraftCache } from "./draft-cache"; +import { + CHAT_DEFAULTS_CHANGED_EVENT, + chatDefaultsChangedDetail, + type ChatDefaultsChangedDetail, +} from "./chat-defaults-event"; import { effectiveThinkingLevel } from "./thinking-level"; import { WorkspaceSelect, pillClass } from "./workspace-select"; import { sameModelRef } from "../models/model-grouping"; @@ -161,15 +173,21 @@ export function DraftView({ // route location.state > mount-time draft cache > project default > built-in fallback; // null = still loading (the thinking picker below stays disabled until resolved). const [chatDefaults, setChatDefaults] = useState(null); + /** + * Set once the chat-defaults-changed event delivered a fresh block (see the reseed + * handler below): from then on the mount-time fetch must not apply — it was started + * earlier and would overwrite the fresher event payload when it resolves. + */ + const defaultsFromEventRef = useRef(false); useEffect(() => { let cancelled = false; api .getChatDefaults(projectId) .then((res) => { - if (!cancelled) setChatDefaults(res); + if (!cancelled && !defaultsFromEventRef.current) setChatDefaults(res); }) .catch(() => { - if (!cancelled) setChatDefaults({}); + if (!cancelled && !defaultsFromEventRef.current) setChatDefaults({}); }); return () => { cancelled = true; @@ -292,6 +310,64 @@ export function DraftView({ ); }, [models, modelRef]); + /** + * Live reseed: the project-settings dialog saved new defaults in THIS tab while the + * draft is mounted. The dialog already stripped the cached pins, but this component's + * state still holds the old selections and persistNow would silently write them right + * back over the stripped cache — so the seeded fields are reset here to exactly what a + * fresh /chat/new mount would now produce (with the cache stripped, the seeding + * precedence collapses to: fresh project default > built-in fallback); the persist + * effect then pins the NEW values. Typed text and staged skills are user content and + * stay untouched; route-state overrides and in-mount picks are superseded — the save is + * the later explicit intent, and the next fresh mount would drop them anyway. Values + * come from the event payload (server-confirmed by the dialog's PUTs), not a refetch. + * The mount-time seeding effects re-run when chatDefaults changes but cannot fight + * this: their apply-once refs are already consumed, and where they are not, they + * re-apply the same fresh values. + */ + const onDefaultsChanged = useCallback( + (detail: ChatDefaultsChangedDetail) => { + if (detail.defaults) { + const d = detail.defaults; + defaultsFromEventRef.current = true; + setChatDefaults(d); + touchedRef.current = { agent: false, workspace: false, approval: false }; + setWorkspace(d.workspace ?? ""); + setApprovalMode(d.approvalMode ?? "allow-all"); + const valid = (id: string | undefined): id is string => + id !== undefined && agents.some((a) => a.agentId === id); + if (valid(d.agentId)) { + setAgentId(d.agentId); + } else if (agents.length > 0) { + // No (valid) default Agent in the new block: the same fallback chain a fresh + // mount runs — the global current Agent, then default_agent, then the first. + // Skipped while the list is empty (nothing to validate against; keep the pick). + setAgentId( + currentAgent?.agentId ?? + (agents.find((a) => a.agentId === "default_agent") ?? agents[0])?.agentId ?? + null, + ); + } + } + // New default model: adopt it directly (the event carries the authoritative pair). + // Setting null and leaning on the fallback effect would race ChatPage's models + // refetch and re-pin the STALE default from the old models prop. + if (detail.defaultModel !== undefined) setModelRef(detail.defaultModel); + }, + [agents, currentAgent], + ); + /** Latest-closure mirror for the window listener (same convention as persistRef). */ + const onDefaultsChangedRef = useRef(onDefaultsChanged); + onDefaultsChangedRef.current = onDefaultsChanged; + useEffect(() => { + const onEvent = (e: Event) => { + const detail = chatDefaultsChangedDetail(e, projectId); + if (detail) onDefaultsChangedRef.current(detail); + }; + window.addEventListener(CHAT_DEFAULTS_CHANGED_EVENT, onEvent); + return () => window.removeEventListener(CHAT_DEFAULTS_CHANGED_EVENT, onEvent); + }, [projectId]); + // —— Conversation-time thinking level (backed by the Agent settings) —— // The picker DISPLAYS the effective level, resolved by the same chain core applies when // the Session is created (core agent.ts `configuredThinkingLevel`): the Agent's explicit diff --git a/packages/web/test/draft-cache.test.ts b/packages/web/test/draft-cache.test.ts index 8910446..df61c53 100644 --- a/packages/web/test/draft-cache.test.ts +++ b/packages/web/test/draft-cache.test.ts @@ -11,6 +11,7 @@ import { describe, expect, it } from "vitest"; import { clearDraft, + clearDraftChatDefaults, clearDraftModelRef, draftKey, loadDraft, @@ -221,6 +222,53 @@ describe("load / save / clear (key isolation, errors silenced)", () => { }); }); + it("clearDraftChatDefaults strips only the [default_chat]-seeded selections, preserving user content and the model pin", () => { + // Called by the project-settings save when the block changed: the next /chat/new must + // reseed Agent / Workspace / approval mode from the fresh defaults, while typed text, + // staged skills, the handoff/switch chips and the switch-becomes-default model + // carry-over (released only by clearDraftModelRef) all survive. + const s = memStorage(); + saveDraft( + draftKey("user-a1", "project-a"), + { + text: "typed but unsent", + agentId: "old_default_agent", + workspace: "/srv/old-default", + approvalMode: "read-only", + modelRef: { provider: "deepseek", modelId: "deepseek-v4-pro" }, + handoffAgentId: "agent_helper", + switchModelRef: { provider: "openai", modelId: "gpt-5" }, + skills: ["agent-creation"], + }, + s, + ); + clearDraftChatDefaults("user-a1", "project-a", s); + expect(loadDraft(draftKey("user-a1", "project-a"), s)).toEqual({ + text: "typed but unsent", + modelRef: { provider: "deepseek", modelId: "deepseek-v4-pro" }, + handoffAgentId: "agent_helper", + switchModelRef: { provider: "openai", modelId: "gpt-5" }, + skills: ["agent-creation"], + }); + }); + + it('clearDraftChatDefaults strips any subset of the three fields (a cached "" workspace counts) and is otherwise a no-op', () => { + const s = memStorage(); + // A single seeded field is enough to rewrite; "" workspace is an explicit "auto temp" + // pin and must be stripped like any other value (undefined-check, not truthiness). + saveDraft(draftKey("user-a1", "project-a"), { text: "t", workspace: "" }, s); + clearDraftChatDefaults("user-a1", "project-a", s); + expect(loadDraft(draftKey("user-a1", "project-a"), s)).toEqual({ text: "t" }); + // Nothing seeded left (or no draft at all): a no-op, never an errant write. + clearDraftChatDefaults("user-a1", "project-a", s); + clearDraftChatDefaults("user-b2", "project-a", s); + expect(s.map.has(draftKey("user-b2", "project-a"))).toBe(false); + // Scoped by user × Project: another user's pins survive. + saveDraft(draftKey("user-b2", "project-a"), { agentId: "default_agent" }, s); + clearDraftChatDefaults("user-a1", "project-a", s); + expect(loadDraft(draftKey("user-b2", "project-a"), s)).toEqual({ agentId: "default_agent" }); + }); + it("storage throwing (quota/private mode): save does not throw, load yields an empty draft", () => { const broken: DraftStorage = { getItem: () => {