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