feat(web): replace the @ mention with an /agent command, and stage both switches until send (#122)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-29 23:37:37 +08:00
committed by GitHub
parent b9de3da879
commit 37fc715ce9
28 changed files with 1115 additions and 696 deletions
@@ -0,0 +1,94 @@
/**
* Agent handoff for the chat input area (pure logic, shared by chat-input.tsx /
* chat-page.tsx and unit tests). A target agent is picked from the `/agent` command's picker
* and pinned as a highlighted chip at the front of the input; the text body carries no marker
* of its own. Nothing is sent at pick time — sending is what performs the handoff, and it
* opens a NEW conversation for that agent instead of posting to the current Session.
* - `filterAgents`: the picker's search box — filters candidates by agentId or display name.
* - `stagedSendRoute`: where a send goes once a switch chip is staged (and when a staged
* `/model` fork must refuse to go at all).
*
* The origin **marker blocks** these flows produce and render — `[handoff_from]`,
* `[scheduled_task]`, `[model_switch_from]` — are defined in core's marker module
* (`@prismshadow/penguin-core/markers`) alongside every other message marker, and are
* re-exported below under this feature's existing names.
*/
import { buildHandoffMessage, buildModelSwitchMessage } from "@prismshadow/penguin-core/markers";
import type { AgentSummary } from "@prismshadow/penguin-server/api";
export {
parseHandoffMessage,
parseModelSwitchMessage,
parseScheduledMessage,
} from "@prismshadow/penguin-core/markers";
export type {
HandoffOrigin,
ModelSwitchOrigin,
ScheduledOrigin,
} from "@prismshadow/penguin-core/markers";
/** First message of an `/agent` handoff conversation (core's `[handoff_from]` origin block). */
export const handoffMessage = buildHandoffMessage;
/** First message of a `/model` switch new conversation (core's `[model_switch_from]` origin block). */
export const modelSwitchMessage = buildModelSwitchMessage;
/**
* Filters the `/agent` picker's candidates by its search box: a case-insensitive **substring**
* match on the agentId or the display name — the same rule the model picker's search box uses,
* so a word typed from memory ("creator") still finds the agent wherever it sits in the id. An
* empty query returns every candidate.
*/
export function filterAgents(agents: AgentSummary[], query: string): AgentSummary[] {
const q = query.trim().toLowerCase();
if (!q) return agents;
return agents.filter(
(a) => a.agentId.toLowerCase().includes(q) || (a.name ?? "").toLowerCase().includes(q),
);
}
/**
* Where a send goes with the composer's switch chips staged:
* - `post` — no chip is in play: the ordinary task / steer-fallback / follow-up post;
* - `handoff` — a staged `/agent` target: opens a NEW chat for that agent, the current Session
* is not posted to at all;
* - `model` — a staged `/model` target: forks this conversation onto that model;
* - `blocked` — a staged `/model` target that must NOT go out yet (see below).
*/
export type StagedSendRoute = "post" | "handoff" | "model" | "blocked";
/**
* The staged-switch send decision, pulled out of the composer so it can be reasoned about (and
* unit-tested) on its own: staging is the whole point of `/agent` and `/model`, so *when* the
* staged pick is allowed to fire is the behaviour worth pinning down.
*
* The one rule that isn't merely "which chip is staged": a `/model` fork branches a NEW Session
* off **this** Session's Trace, so it may only run while this Session is idle. A run can start
* from outside the composer at any time — a queued follow-up auto-sending, a scheduled Task
* firing, another tab or the CLI posting — and forking then would point the new model at a
* Trace that is still being appended to, while navigating the user away from a live run. Rather
* than silently falling through to `post` (which would deliver the message to the very Session
* the user was switching away from), the send is refused until the Session goes idle.
*
* A staged handoff has no such constraint: it never reads or writes the running Session.
*/
export function stagedSendRoute({
handoffTarget,
pendingModel,
canSwitchModel,
sessionBusy,
}: {
/** An `/agent` handoff target is staged. */
handoffTarget: boolean;
/** A `/model` fork target is staged. */
pendingModel: boolean;
/** The host can actually perform a fork (an active session supplies onSwitchModel; the draft page does not). */
canSwitchModel: boolean;
/** This Session is running or compacting — its Trace is still being appended to. */
sessionBusy: boolean;
}): StagedSendRoute {
// The two chips are mutually exclusive by construction (picking either clears the other);
// should they ever coexist, the handoff wins — it is the one that touches nothing here.
if (handoffTarget) return "handoff";
if (!pendingModel || !canSwitchModel) return "post";
return sessionBusy ? "blocked" : "model";
}
@@ -1,94 +0,0 @@
/**
* @-agent handoff for the chat input area (pure logic, shared by chat-input.tsx /
* chat-page.tsx and unit tests). Only a **leading** @ is meaningful: a target picked from
* the menu is pinned as a highlighted chip at the front of the input (the text itself
* carries no @ marker); hand-typed/pasted text starting with `@<agentId>` takes effect the
* same way on send. Any @ elsewhere in the text is plain text.
* - `matchMention`: finds the `@` prefix currently being typed from the text before the
* caret, driving the agent-picker popup;
* - `filterAgents`: filters candidates by prefix (agentId or display name, case-insensitive);
* - `splitLeadingMention`: on send, parses a leading `@<agentId>`, splitting off the target
* agent from the remaining text.
*
* The origin **marker blocks** these flows produce and render — `[handoff_from]`,
* `[scheduled_task]`, `[model_switch_from]` — are defined in core's marker module
* (`@prismshadow/penguin-core/markers`) alongside every other message marker, and are
* re-exported below under this feature's existing names.
*/
import { buildHandoffMessage, buildModelSwitchMessage } from "@prismshadow/penguin-core/markers";
import type { AgentSummary } from "@prismshadow/penguin-server/api";
export {
parseHandoffMessage,
parseModelSwitchMessage,
parseScheduledMessage,
} from "@prismshadow/penguin-core/markers";
export type {
HandoffOrigin,
ModelSwitchOrigin,
ScheduledOrigin,
} from "@prismshadow/penguin-core/markers";
/** First message of an @-handoff new conversation (core's `[handoff_from]` origin block). */
export const handoffMessage = buildHandoffMessage;
/** First message of a `/model` switch new conversation (core's `[model_switch_from]` origin block). */
export const modelSwitchMessage = buildModelSwitchMessage;
/** Id characters allowed between `@` and the caret (matches core's id convention: letters, digits, underscore, hyphen). */
const ID_PREFIX = /^[\w-]*$/;
/**
* The @ mention currently being typed: `start` is the index of `@` in the full text,
* `query` is the prefix between `@` and the caret, and `end` is the end position of the
* same token to the right of the caret — selecting a candidate replaces the **entire**
* `start..end` token (no leftover tail when the caret sits mid-token).
*/
export interface MentionMatch {
start: number;
end: number;
query: string;
}
/**
* Finds the @ mention currently being typed at the caret; returns null if none.
* `@` must be at the start of the text or preceded by whitespace (to avoid treating
* ordinary text like emails as mentions); only id characters are allowed between `@` and
* the caret.
*/
export function matchMention(text: string, caret: number): MentionMatch | null {
const before = text.slice(0, caret);
const at = before.lastIndexOf("@");
if (at < 0) return null;
if (at > 0 && !/\s/.test(before[at - 1]!)) return null;
const query = before.slice(at + 1);
if (!ID_PREFIX.test(query)) return null;
const rest = /^[\w-]*/.exec(text.slice(caret))![0];
return { start: at, end: caret + rest.length, query };
}
/** Filters candidate agents by prefix (agentId or display name, case-insensitive); an empty prefix returns all. */
export function filterAgents(agents: AgentSummary[], query: string): AgentSummary[] {
const q = query.toLowerCase();
return agents.filter(
(a) => a.agentId.toLowerCase().startsWith(q) || (a.name ?? "").toLowerCase().startsWith(q),
);
}
/**
* Parses a leading mention: when text (expected to already be trimmed) starts with
* `@<existing agentId>`, splits off the target agent from the remaining text (the id is
* the longest `[\w-]+` run after `@`, and must exactly match an existing agentId — `@foo2`
* does not count as @-ing foo; leading whitespace in the remaining text is trimmed).
* Returns null when the text doesn't start with an @ for an existing agent; an @ elsewhere
* in the text is never parsed.
*/
export function splitLeadingMention(
text: string,
agents: AgentSummary[],
): { agent: AgentSummary; rest: string } | null {
const m = /^@([\w-]+)([\s\S]*)$/.exec(text);
if (!m) return null;
const agent = agents.find((a) => a.agentId === m[1]);
if (!agent) return null;
return { agent, rest: m[2]!.trimStart() };
}
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -59,7 +59,7 @@ import { latestTaskHasSubagent, taskStartCount } from "./agent-topology";
import { ChatInput } from "./chat-input";
import { DraftView } from "./draft-view";
import { GoalStatusBanner } from "./goal-banner";
import { handoffMessage, modelSwitchMessage } from "./agent-mentions";
import { handoffMessage, modelSwitchMessage } from "./agent-handoff";
import { sameModelRef } from "../models/model-grouping";
import { providerInfo } from "@prismshadow/penguin-core/model-catalog";
import { FilesPanel } from "./files-panel";
@@ -287,11 +287,14 @@ export function ChatPage() {
() => void reloadSessions(),
);
// Chat input area draft: caches text, @ target, and selected skills keyed by sessionId; restored after navigating away and back or a refresh, discarded on successful send.
// Chat input area draft: caches text, both staged switch chips (`/agent` target, `/model`
// target) and the selected skills keyed by sessionId; restored after navigating away and back
// or a refresh, discarded on successful send.
const {
initial: sessionDraft,
onTextChange: onDraftTextChange,
onHandoffTargetChange: onDraftHandoffChange,
onPendingModelChange: onDraftPendingModelChange,
onSkillsChange: onDraftSkillsChange,
discard: discardSessionDraft,
} = useSessionDraft(selected?.sessionId ?? null);
@@ -636,10 +639,10 @@ export function ChatPage() {
[projectId, selected, addSession, discardSessionDraft, navigate],
);
// @ handoff: doesn't use the current Session — creates a new chat for the @-mentioned agent
// /agent handoff: doesn't use the current Session — creates a new chat for the picked agent
// (approval mode carries over from the input area's current value; model/Workspace use the
// creation defaults). The first input = a [handoff_from] source block (current agent / Session
// / Workspace info) + the user's input and images with the @ mention stripped; jumps to the new
// / Workspace info) + the user's input and images; jumps to the new
// chat once sent.
// Returns false on failure, keeping the draft so it can be resent (deletes the empty Session that never got its first message sent).
const onHandoff = useCallback(
@@ -887,6 +890,7 @@ export function ChatPage() {
modeSaving={modeSaving}
autoFocus
agents={agents}
currentAgentId={selected.agentId}
skills={agentSkills}
{...(sessionDraft.skills && sessionDraft.skills.length > 0
? { initialSkills: sessionDraft.skills }
@@ -899,6 +903,10 @@ export function ChatPage() {
? { initialHandoffTargetId: sessionDraft.handoffAgentId }
: {})}
onHandoffTargetChange={onDraftHandoffChange}
{...(sessionDraft.switchModelRef
? { initialPendingModelRef: sessionDraft.switchModelRef }
: {})}
onPendingModelChange={onDraftPendingModelChange}
/>
);
+28 -11
View File
@@ -8,7 +8,7 @@
*
* The key must include userId (#68): if the same browser logs into different accounts in
* succession and the key only contains the Project/Session ID, the later user would recover the
* previous user's text, Workspace, model selection, and @ target — a cross-account information leak.
* previous user's text, Workspace, model selection, and handoff target — a cross-account information leak.
*/
import type { ApprovalMode } from "@prismshadow/penguin-server/api";
@@ -25,8 +25,17 @@ export interface DraftCache {
* (product hasn't shipped, so no migration is done).
*/
modelRef?: { provider: string; modelId: string };
/** The @ handoff target (chip) at the front of the input box: resolved again by id on restore, dropped if no longer valid. */
/** The `/agent` handoff target (chip) at the front of the input box: resolved again by id on restore, dropped if no longer valid. */
handoffAgentId?: string;
/**
* The `/model` switch target (the other switch chip; a paired reference, same shape as
* modelRef above): cached for exactly the same reason as handoffAgentId — the composer text
* is cached and ChatInput remounts on every session switch, so a chip left in component state
* would vanish while the text it belongs to came back, and the next Enter would post to the
* current session on the old model. Resolved again against the model list on restore and
* dropped when that model is no longer available.
*/
switchModelRef?: { provider: string; modelId: string };
/**
* Preselected skill names (written by the quick-invoke action on the Skill library page):
* used as the initial selection when ChatInput mounts, then trimmed to remove names not in the
@@ -46,10 +55,22 @@ export interface DraftStorage {
export const draftKey = (userId: string, projectId: string): string =>
`penguin.chatDraft.${userId}.${projectId}`;
/** Cache key for an existing session's input area: one per "user × Session" (only stores text and @ target; everything else is locked to the Session). */
/** Cache key for an existing session's input area: one per "user × Session" (only stores text and the handoff target; everything else is locked to the Session). */
export const sessionDraftKey = (userId: string, sessionId: string): string =>
`penguin.chatDraft.session.${userId}.${sessionId}`;
/**
* A cached model reference must be a paired `{ provider, modelId }` object; anything else — the
* old string-typed modelId, a half reference, a non-object — yields undefined and the field is
* dropped. Shared by the two model fields (the draft's selection and the staged `/model` switch).
*/
function parseModelRef(value: unknown): { provider: string; modelId: string } | undefined {
if (typeof value !== "object" || value === null) return undefined;
const r = value as Record<string, unknown>;
if (typeof r.provider !== "string" || typeof r.modelId !== "string") return undefined;
return { provider: r.provider, modelId: r.modelId };
}
/** Parses and validates raw JSON field-by-field: null / malformed JSON / non-object / invalid fields are all dropped. */
export function parseDraft(raw: string | null): DraftCache {
if (!raw) return {};
@@ -61,15 +82,11 @@ export function parseDraft(raw: string | null): DraftCache {
if (typeof o.text === "string") out.text = o.text;
if (typeof o.agentId === "string") out.agentId = o.agentId;
if (typeof o.workspace === "string") out.workspace = o.workspace;
// The model reference must be a paired { provider, modelId } object; the old string-typed
// modelId and any malformed shape are dropped.
if (typeof o.modelRef === "object" && o.modelRef !== null) {
const r = o.modelRef as Record<string, unknown>;
if (typeof r.provider === "string" && typeof r.modelId === "string") {
out.modelRef = { provider: r.provider, modelId: r.modelId };
}
}
const modelRef = parseModelRef(o.modelRef);
if (modelRef) out.modelRef = modelRef;
if (typeof o.handoffAgentId === "string") out.handoffAgentId = o.handoffAgentId;
const switchModelRef = parseModelRef(o.switchModelRef);
if (switchModelRef) out.switchModelRef = switchModelRef;
if (Array.isArray(o.skills)) {
// Elements are validated one by one: non-string items are filtered out; if empty after
// filtering, the whole field is omitted.
+4 -59
View File
@@ -57,7 +57,6 @@ import { ChatInput } from "./chat-input";
import { buildSkillsMessage } from "./skill-use";
import { clearDraft, draftKey, loadDraft, saveDraft } from "./draft-cache";
import type { DraftCache } from "./draft-cache";
import { handoffMessage } from "./agent-mentions";
import { sameModelRef } from "../models/model-grouping";
/** Coalescing window for writing body text to the cache: keystrokes are frequent, so a short batch accumulates before persisting (option changes are still written immediately). */
@@ -135,10 +134,6 @@ export function DraftView({
cached.approvalMode ?? "allow-all",
);
const [modelRef, setModelRef] = useState<ModelRefDto | null>(cached.modelRef ?? null);
/** @-handoff target (chip): draft content just like the body text, cached alongside it (fed in via the ChatInput callback). */
const [handoffAgentId, setHandoffAgentId] = useState<string | null>(
cached.handoffAgentId ?? null,
);
const textRef = useRef(cached.text ?? "");
/**
* Selected skills (prefilled by "quick invoke" from the Skills page + checked in
@@ -301,19 +296,9 @@ export function DraftView({
const data: DraftCache = { text: textRef.current, workspace, approvalMode };
if (agentId) data.agentId = agentId;
if (modelRef) data.modelRef = modelRef;
if (handoffAgentId) data.handoffAgentId = handoffAgentId;
if (skillsRef.current.length > 0) data.skills = skillsRef.current;
saveDraft(draftKey(userId, projectId), data);
}, [
cancelPendingSave,
userId,
projectId,
agentId,
workspace,
approvalMode,
modelRef,
handoffAgentId,
]);
}, [cancelPendingSave, userId, projectId, agentId, workspace, approvalMode, modelRef]);
// The timer and unmount cleanup read persistNow via a ref to always get the **latest version**: a stale closure would write back outdated options.
const persistRef = useRef(persistNow);
@@ -374,8 +359,8 @@ export function DraftView({
setCurrentAgentId(a.agentId);
};
// One in-flight guard shared by every send entry point (composer send / example task /
// @-handoff): a second submission while one is running would create a second Session with
// One in-flight guard shared by both send entry points (composer send / example task): a
// second submission while one is running would create a second Session with
// its own first task and a racing navigation. The ref is the synchronous guard; the state
// drives disabled styling on the example button (the composer has its own busy state).
const sendingRef = useRef(false);
@@ -448,45 +433,7 @@ export function DraftView({
[exampleBusy, agentSkills, onSend],
);
// @ handoff: opens a new chat for the @-mentioned agent (approval mode carries over from the
// draft's current value; model/Workspace use the creation defaults), first input =
// [handoff_from] source block + the text and images with the @ mention stripped.
const selectedAgent = agents.find((a) => a.agentId === agentId) ?? null;
const onHandoff = useCallback(
async (target: AgentSummary, input: TaskInputPart[]): Promise<boolean> => {
if (!selectedAgent || sendingRef.current) return false;
sendingRef.current = true;
setSending(true);
const origin: TaskInputPart = {
type: "text",
text: handoffMessage({
agentId: selectedAgent.agentId,
...(selectedAgent.name !== undefined ? { agentName: selectedAgent.name } : {}),
}),
};
let createdId: string | null = null;
try {
const created = await api.createSession(projectId, target.agentId, { approvalMode });
createdId = created.session.sessionId;
const res = await api.postTask(createdId, { input: [origin, ...input] });
add(created.session);
discardDraft();
navigate(`/chat/${res.sessionId}`);
return true;
} catch (e) {
if (createdId) void api.deleteSession(createdId).catch(() => undefined);
// The new chat uses the project's default model (createSession doesn't specify a model reference), so the error copy's model context follows suit.
toastError(
apiErrorText(e, models?.defaultModel ? { modelId: models.defaultModel.modelId } : {}),
);
return false;
} finally {
sendingRef.current = false;
setSending(false);
}
},
[projectId, selectedAgent, approvalMode, add, discardDraft, navigate, models],
);
// Capability info for the currently selected model (vision/context window) switches instantly with the selection (matched by paired reference).
const modelInfo = models?.models.find((m) => sameModelRef(m, modelRef));
@@ -540,14 +487,12 @@ export function DraftView({
modeSaving={false}
autoFocus
agents={agents}
{...(agentId ? { currentAgentId: agentId } : {})}
skills={agentSkills}
{...(cached.skills && cached.skills.length > 0 ? { initialSkills: cached.skills } : {})}
onSkillsChange={onSkillsChange}
onHandoff={onHandoff}
initialText={cached.text ?? ""}
onTextChange={onTextChange}
{...(cached.handoffAgentId ? { initialHandoffTargetId: cached.handoffAgentId } : {})}
onHandoffTargetChange={setHandoffAgentId}
/>
{/* Ownership selection right below the card (small pill dropdowns, styled after ChatGPT's project picker button) */}
@@ -1,7 +1,7 @@
/**
* Provenance banners for conversations opened from another conversation — each collapses a
* machine-inserted source block (the raw text is never shown; the model still sees it):
* - `HandoffBanner` (`[handoff_from]`, @ delegation): "Handed off from <agent>'s chat";
* - `HandoffBanner` (`[handoff_from]`, the /agent handoff): "Handed off from <agent>'s chat";
* - `ModelSwitchBanner` (`[model_switch_from]`, the /model command): "switched model —
* continued from the earlier conversation".
* When there's a source Session, the whole line is clickable and jumps back to it (the
@@ -9,13 +9,18 @@
*/
import { useNavigate } from "react-router";
import { S } from "../../lib/strings";
import type { HandoffOrigin, ModelSwitchOrigin } from "./agent-mentions";
import type { HandoffOrigin, ModelSwitchOrigin } from "./agent-handoff";
/** Display name of the source agent: `displayName (@id)` when the display name differs from the id, otherwise just `@id`. */
/**
* Display name of the source agent: `displayName (id)` when the display name differs from the
* id, otherwise just the id. No `@` sigil — the mention trigger it stood for is gone (`/agent`
* replaced it), and the composer's own handoff chip spells the agent out without one, so the
* banner would otherwise name the same agent differently from the control that started it.
*/
function agentLabel(origin: HandoffOrigin): string {
return origin.agentName && origin.agentName !== origin.agentId
? `${origin.agentName} (@${origin.agentId})`
: `@${origin.agentId}`;
? `${origin.agentName} (${origin.agentId})`
: origin.agentId;
}
const bannerFrame =
@@ -27,7 +27,7 @@ import {
parseHandoffMessage,
parseModelSwitchMessage,
parseScheduledMessage,
} from "./agent-mentions";
} from "./agent-handoff";
import { parseGoalMessage } from "./goal-use";
import { parseSkillsMessage } from "./skill-use";
import { TaskStatsLine } from "./task-stats-line";
@@ -176,7 +176,7 @@ function ReconnectLine({ item, ctx }: { item: ReconnectItem; ctx: StreamRenderCo
export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderContext }) {
switch (item.kind) {
case "user_text": {
// Source block for a chat created via @ handoff: collapsed into a single-line handoff notice (the raw text isn't shown), clickable to jump back to the original chat.
// Source block for a chat created via the /agent handoff: collapsed into a single-line handoff notice (the raw text isn't shown), clickable to jump back to the original chat.
const handoff = parseHandoffMessage(item.text);
if (handoff) return <HandoffBanner origin={handoff} />;
// Source block for a chat opened by the /model switch: collapsed into a single-line switch notice, clickable to jump back to the source conversation.
@@ -7,7 +7,7 @@
*/
import { S } from "../../lib/strings";
import { formatDateTime } from "../../lib/format";
import type { ScheduledOrigin } from "./agent-mentions";
import type { ScheduledOrigin } from "./agent-handoff";
export function ScheduledBanner({ origin }: { origin: ScheduledOrigin }) {
return (
@@ -1,9 +1,9 @@
/**
* Positional slash-command matching for the chat input (pure logic, unit-tested):
* like @ mentions, a `/` opens the command menu from ANY caret position — it must sit at
* the start of the text or be preceded by whitespace (so URLs and paths like `a/b` never
* trigger it), with only command characters between the `/` and the caret. Running a
* command removes just the `start..end` token, leaving the rest of the text intact.
* a `/` opens the command menu from ANY caret position — it must sit at the start of the
* text or be preceded by whitespace (so URLs and paths like `a/b` never trigger it), with
* only command characters between the `/` and the caret. Running a command removes just the
* `start..end` token, leaving the rest of the text intact.
*/
/** Command characters allowed between `/` and the caret (command names and skill names: letters, digits, underscore, hyphen). */
@@ -1,15 +1,17 @@
/**
* Draft auto-cache for an existing session's input area: text +
* @-handoff target + selected skills are cached to localStorage keyed by "user x Session"
* (see draft-cache's sessionDraftKey; the user dimension prevents cross-account leakage on
* the same browser, #68), restored after navigating away/reloading. Model / Workspace /
* approval mode are locked to the Session and need no caching.
* Draft auto-cache for an existing session's input area: text + the two staged switch chips
* (`/agent` handoff target, `/model` switch target) + selected skills are cached to
* localStorage keyed by "user x Session" (see draft-cache's sessionDraftKey; the user
* dimension prevents cross-account leakage on the same browser, #68), restored after
* navigating away/reloading. The Session's OWN model / Workspace / approval mode are locked
* and need no caching — the cached model reference here is the pending switch target, not the
* session's model.
*
* Write strategy matches the draft page: text is debounced and merge-written (an unflushed
* edit gets one extra flush before switching sessions/unmounting); @ target and skill
* selection write immediately; **clearing content deletes the key** (leaving an empty shell
* per session would bloat localStorage); discard on a successful send cancels the pending
* timer first, otherwise it would write the just-cleared draft back.
* edit gets one extra flush before switching sessions/unmounting); the switch chips and the
* skill selection write immediately; **clearing content deletes the key** (leaving an empty
* shell per session would bloat localStorage); discard on a successful send cancels the
* pending timer first, otherwise it would write the just-cleared draft back.
*
* ChatPage keys session content blocks by sessionId, so ChatInput remounts accordingly, but
* this hook is mounted on ChatPage itself and does not remount — switching sessions is
@@ -17,6 +19,7 @@
* resets the refs to the new session's initial values.
*/
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { ModelRefDto } from "@prismshadow/penguin-server/api";
import { useAuth } from "../../state/auth";
import { clearDraft, loadDraft, saveDraft, sessionDraftKey } from "./draft-cache";
import type { DraftCache } from "./draft-cache";
@@ -28,6 +31,8 @@ export function useSessionDraft(sessionId: string | null): {
initial: DraftCache;
onTextChange: (text: string) => void;
onHandoffTargetChange: (agentId: string | null) => void;
/** Staged `/model` switch target change (picked/removed); like the handoff target, a discrete action writes immediately. */
onPendingModelChange: (ref: ModelRefDto | null) => void;
/** Selected-skills change (wired directly to ChatInput's onSkillsChange; a discrete action writes immediately). */
onSkillsChange: (names: string[]) => void;
/** Discard the current session's draft after a successful send. */
@@ -40,6 +45,7 @@ export function useSessionDraft(sessionId: string | null): {
const textRef = useRef(initial.text ?? "");
const handoffRef = useRef<string | null>(initial.handoffAgentId ?? null);
const switchModelRef = useRef<ModelRefDto | null>(initial.switchModelRef ?? null);
const skillsRef = useRef<string[]>(initial.skills ?? []);
const timer = useRef<number | null>(null);
@@ -55,13 +61,15 @@ export function useSessionDraft(sessionId: string | null): {
if (!key) return;
const text = textRef.current;
const handoffAgentId = handoffRef.current;
const pendingModel = switchModelRef.current;
const skills = skillsRef.current;
if (!text && !handoffAgentId && skills.length === 0) {
if (!text && !handoffAgentId && !pendingModel && skills.length === 0) {
clearDraft(key);
return;
}
const data: DraftCache = { text };
if (handoffAgentId) data.handoffAgentId = handoffAgentId;
if (pendingModel) data.switchModelRef = pendingModel;
if (skills.length > 0) data.skills = skills;
saveDraft(key, data);
}, [cancelPending, key]);
@@ -72,6 +80,7 @@ export function useSessionDraft(sessionId: string | null): {
useEffect(() => {
textRef.current = initial.text ?? "";
handoffRef.current = initial.handoffAgentId ?? null;
switchModelRef.current = initial.switchModelRef ?? null;
skillsRef.current = initial.skills ?? [];
return () => {
if (timer.current !== null) {
@@ -103,10 +112,19 @@ export function useSessionDraft(sessionId: string | null): {
[persistNow],
);
const onPendingModelChange = useCallback(
(ref: ModelRefDto | null) => {
switchModelRef.current = ref;
// Same as the handoff target: discrete action writes immediately.
persistNow();
},
[persistNow],
);
const onSkillsChange = useCallback(
(names: string[]) => {
skillsRef.current = names;
// Same as @ target: discrete action writes immediately.
// Same as the handoff target: discrete action writes immediately.
persistNow();
},
[persistNow],
@@ -114,12 +132,21 @@ export function useSessionDraft(sessionId: string | null): {
const discard = useCallback(() => {
cancelPending();
// Also clear selected skills: ChatInput's clear after a successful send doesn't fire a
// callback (same convention as onTextChange); without this, a later text flush would
// resurrect the already-sent selection.
// Also clear the selected skills and both staged switch chips: ChatInput's clear after a
// successful send doesn't fire a callback (same convention as onTextChange); without this,
// a later text flush would resurrect the already-sent selection.
skillsRef.current = [];
handoffRef.current = null;
switchModelRef.current = null;
if (key) clearDraft(key);
}, [cancelPending, key]);
return { initial, onTextChange, onHandoffTargetChange, onSkillsChange, discard };
return {
initial,
onTextChange,
onHandoffTargetChange,
onPendingModelChange,
onSkillsChange,
discard,
};
}
@@ -226,7 +226,7 @@ export function SkillsPage() {
* would only be noise here), and points the Agent to default_agent before
* entering draft mode — the route state explicitly carries agentId
* (overriding whatever was last selected in the cache). handoffAgentId
* must be cleared: a leftover @ target would forward the whole skill
* must be cleared: a leftover handoff target would forward the whole skill
* invocation to a different Agent — quick invoke must always start a new
* conversation with default_agent.
*/
+11 -3
View File
@@ -731,8 +731,12 @@ When done, open index.html in a browser and self-test once.`,
contextUsage: "Context usage",
contextUnknown: "Context usage: unknown until the next request reports it",
slashHint: "Type / for commands",
mentionHint: "@ to handoff to another agent",
mentionRemove: "Remove @ target",
switchAgent: "Hand off to another agent — opens a new session on send",
switchAgentTitle: "Choose agent",
agentSearchPlaceholder: "Search agents: id / name",
agentsNoMatch: "No matching agents",
handoffTargetTitle: (agent: string) => `Sending hands this conversation to ${agent}`,
handoffRemove: "Remove handoff target",
skillsSelect: "Skills",
skillRemove: "Remove skill",
skillsSearchPlaceholder: "Search skills",
@@ -743,8 +747,12 @@ When done, open index.html in a browser and self-test once.`,
handoffFrom: (agent: string) => `Handed off from ${agent}'s conversation`,
handoffBack: (title?: string) =>
title ? `Back to the original conversation: ${title}` : "Back to the original conversation",
switchModel: "Switch model — continue this conversation in a new session",
switchModel: "Switch model — on send, continues this conversation in a new session",
switchModelTitle: "Switch model",
modelSwitchTargetTitle: (model: string) => `Sending continues this conversation on ${model}`,
modelSwitchRemove: "Remove model switch",
modelSwitchBusyHint:
"The model switch waits for this turn to finish: the new session continues from this session's record",
modelSwitchFrom: (prevModel?: string) =>
prevModel
? `Switched model (was ${prevModel}) — continued from the earlier conversation`
+14 -5
View File
@@ -715,8 +715,13 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
contextUsage: "上下文占用",
contextUnknown: "上下文占用:压缩后待下次请求回报",
slashHint: "输入 / 使用命令",
mentionHint: "@ handoff 给其他 Agent",
mentionRemove: "移除 @ 目标",
/** `/agent` handoff: command description, picker title, search box, no-match hint, and the staged target's description and remove button. */
switchAgent: "交给其他 Agent,发送时开启新会话",
switchAgentTitle: "选择 Agent",
agentSearchPlaceholder: "搜索 Agent:id / 名称",
agentsNoMatch: "没有匹配的 Agent",
handoffTargetTitle: (agent: string) => `发送后交接给 ${agent}`,
handoffRemove: "移除交接目标",
/** Skill multi-select dropdown (input toolbar): button text, search box, empty state, and no-match hint. */
skillsSelect: "技能",
skillRemove: "移除技能",
@@ -727,12 +732,16 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
skillsAutoMessage: (names: string[]): string => `使用 ${names.join("、")} 技能`,
handoffFrom: (agent: string) => `由 ${agent} 的对话交接而来`,
handoffBack: (title?: string) => (title ? `回到原对话:${title}` : "回到原对话"),
/** /model 切换:命令描述、拾取器标题、切换来源横幅与空正文自动消息。 */
switchModel: "切换模型开启新会话延续本对话",
/** `/model` switch: command description, picker title, the staged target's description and remove button, the switch-origin banner, and the empty-body auto message. */
switchModel: "切换模型,发送时开启新会话延续本对话",
switchModelTitle: "切换模型",
modelSwitchTargetTitle: (model: string) => `发送后换用 ${model} 延续本对话`,
modelSwitchRemove: "移除切换模型",
/** Why Send is disabled with a model switch staged: the fork branches off a Trace this Session is still writing. */
modelSwitchBusyHint: "本轮结束后才能切换模型:新会话要从当前会话的记录接续",
modelSwitchFrom: (prevModel?: string) =>
prevModel ? `已切换模型(原为 ${prevModel}),延续原会话` : "已切换模型,延续原会话",
/** /model 切换且正文为空时自动发送的首条消息正文(与 skillsAutoMessage 同一约定)。 */
/** First message body auto-sent when `/model` is staged and the composer is empty (same convention as skillsAutoMessage). */
modelSwitchAutoMessage: "换用新模型继续这段对话",
scheduledFrom: (name: string) => `由定时任务「${name}」触发`,
emptyGreeting: "开始一段新对话",