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:
@@ -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
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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: "开始一段新对话",
|
||||
|
||||
Reference in New Issue
Block a user