fix(web): reset new-conversation draft when project chat defaults are saved (#223)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-06 20:10:43 +08:00
committed by GitHub
parent c98749fbff
commit 75a8aa12c8
6 changed files with 249 additions and 4 deletions
@@ -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);
}
};
@@ -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<ChatDefaultsChangedDetail>(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<ChatDefaultsChangedDetail>).detail;
return detail && detail.projectId === projectId ? detail : null;
}
@@ -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) => {
@@ -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,
);
}
}
+78 -2
View File
@@ -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<ChatDefaultsDto | null>(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
+48
View File
@@ -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: () => {