feat(desktop): show structured skill invocations

This commit is contained in:
SivanCola committed 2026-07-12 01:24:42 +08:00
1 parent fc30cb66d3
commit 19d580ccda
12 files changed
+488 -69

No files matched your search

+11 -2
View File
@@ -4422,8 +4422,17 @@ func historyMessagesWithPlannerDisplaysAndLookups(
if m.Role == provider.RoleAssistant && len(m.MemoryCitations) > 0 {
hm.MemoryCitations = append([]provider.MemoryCitation(nil), m.MemoryCitations...)
}
if m.Role == provider.RoleUser && content != m.Content && !agent.ContainsMemoryCompilerExecution(m.Content) {
hm.SubmitText = m.Content
if m.Role == provider.RoleUser && content != m.Content {
if agent.ContainsMemoryCompilerExecution(m.Content) {
// Never expose the compiler contract itself. A safely unwrapped
// slash invocation is useful display metadata, though: it lets the
// frontend restore the selected skill/subagent in history and trash.
if replay := control.StripComposePrefixes(m.Content); strings.HasPrefix(strings.TrimSpace(replay), "/") && replay != content {
hm.SubmitText = replay
}
} else {
hm.SubmitText = m.Content
}
}
if m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {
hm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))
@@ -5,6 +5,7 @@ import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { Composer, composerPickFileEntry } from "../components/Composer";
import { UserMessage } from "../components/Message";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { AppBindings } from "../lib/bridge";
@@ -1038,6 +1039,7 @@ console.log("\ncomposer goal toggle");
const dom = installDom();
let commandsCalls = 0;
let availableCommands: CommandInfo[] = [
{ name: "mcp", description: "Manage MCP servers", kind: "builtin", group: "integrations" },
{ name: "explore", description: "Investigate the codebase", kind: "subagent" },
{ name: "superpowers:writing-plans", description: "Write a plan", kind: "skill", plugin: "superpowers" },
{ name: "toolbox:writing-plans", description: "Write another plan", kind: "skill", plugin: "toolbox" },
@@ -1049,7 +1051,7 @@ console.log("\ncomposer goal toggle");
return availableCommands;
},
});
const { root, rerender } = await renderComposer({ workspaceScopeKey: "runtime-0" });
const { root, calls, rerender } = await renderComposer({ workspaceScopeKey: "runtime-0" });
await waitFor("plugin commands loaded", () => commandsCalls > 0);
await replaceComposerDraft(rerender, 2000, "/m");
@@ -1079,7 +1081,43 @@ console.log("\ncomposer goal toggle");
textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flushTimers();
});
eq(textarea.value, "/superpowers:writing-plans ", "selecting a plugin skill inserts its qualified name");
eq(textarea.value, "", "selecting a plugin skill keeps the task input separate from the invocation");
ok(document.querySelector(".invocation-display--composer")?.textContent?.includes("Writing Plans") === true, "selected skill renders as composer context");
ok(document.querySelector(".invocation-display--composer")?.textContent?.includes("superpowers") === true, "selected plugin skill keeps its source visible");
ok(document.querySelector(".composer__content > .invocation-display--composer + textarea") !== null, "selected skill lives inside the task body column above the textarea");
await act(async () => {
textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Backspace", bubbles: true, cancelable: true }));
await flushTimers();
});
ok(document.querySelector(".invocation-display--composer") === null, "Backspace removes a selected skill from an empty task input");
await replaceComposerDraft(rerender, 2002, "/writing-plans");
await waitFor("skill menu after removal", () => Boolean(document.querySelector(".slashmenu")));
await act(async () => {
textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flushTimers();
});
await replaceComposerDraft(rerender, 2003, "Draft the release plan");
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("composer send button did not render");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send[0], "Draft the release plan", "selected skill keeps the visible transcript text clean");
eq(calls.submit[0], "/superpowers:writing-plans Draft the release plan", "selected skill preserves the provider-visible slash invocation");
ok(document.querySelector(".invocation-display--composer") === null, "selected skill clears after send");
await replaceComposerDraft(rerender, 2004, "/mcp");
await waitFor("builtin command menu", () => Boolean(document.querySelector(".slashmenu")));
await act(async () => {
textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flushTimers();
});
eq(textarea.value, "/mcp ", "management commands keep the existing inline argument flow");
ok(document.querySelector(".invocation-display--composer") === null, "management commands do not become selected abilities");
await act(async () => {
root.unmount();
@@ -1087,5 +1125,72 @@ console.log("\ncomposer goal toggle");
dom.window.close();
}
{
const dom = installDom();
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(
<LocaleProvider>
<UserMessage
id="h1"
text="Draft the release plan"
submitText="/superpowers:writing-plans Draft the release plan"
/>
</LocaleProvider>,
);
await flushTimers();
});
ok(document.querySelector(".invocation-display--message")?.textContent?.includes("Writing Plans") === true, "restored history renders the selected skill header");
ok(document.querySelector(".invocation-display--message")?.textContent?.includes("superpowers") === true, "restored history retains plugin source from the qualified command");
eq(document.querySelector(".msg__text")?.textContent, "Draft the release plan", "history message keeps slash syntax out of the task body");
await act(async () => {
root.render(
<LocaleProvider>
<UserMessage
id="h2"
text="Format this file"
submitText={"以下是用户引用的历史会话上下文:\n\n[会话:Earlier]\n...\n\n---\n\n当前用户问题:\n/my-formatter Format this file"}
/>
</LocaleProvider>,
);
await flushTimers();
});
ok(document.querySelector(".invocation-display--message")?.textContent?.includes("My Formatter") === true, "history and trash previews recover selected abilities after referenced-session context");
await act(async () => {
root.render(
<LocaleProvider>
<UserMessage
id="h3"
text={"Compare these commands\n/other-command"}
submitText={"/reasonix-develop Compare these commands\n/other-command"}
/>
</LocaleProvider>,
);
await flushTimers();
});
ok(document.querySelector(".invocation-display--message")?.textContent?.includes("Reasonix Develop") === true, "history recovery ignores slash-prefixed lines inside the task body");
await act(async () => {
root.render(
<LocaleProvider>
<UserMessage
id="h4"
text={"Compare these commands\n/other-command"}
submitText={"以下是用户引用的历史会话上下文:\n\n[会话:Earlier]\n...\n\n---\n\n当前用户问题:\nCompare these commands\n/other-command"}
/>
</LocaleProvider>,
);
await flushTimers();
});
ok(document.querySelector(".invocation-display--message") === null, "ordinary referenced-session text does not turn task slash lines into a skill header");
await act(async () => root.unmount());
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -35,5 +35,11 @@ eq(
"messages without hidden submit context use the rebuilt submit text",
);
eq(
replaySubmitText("/reasonix-develop review this change", "review this change", "review the updated change", "review the updated change"),
"/reasonix-develop review the updated change",
"editing a structured skill message preserves its slash invocation",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
+124 -62
View File
@@ -9,6 +9,7 @@ import { canUsePromptHistory, isFnKeyEvent, promptHistoryDirectionFromEvent } fr
import { cacheGeneration, loadOlder } from "../lib/composerHistory";
import { SPINNER_WORDS, useI18n } from "../lib/i18n";
import { detectShortcutPlatform, formatShortcutCombo, matchesShortcut } from "../lib/keyboardShortcuts";
import { commandUsesStructuredInvocation, invocationDisplayForCommand } from "../lib/invocationDisplay";
import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
import { createRafResizeUpdater } from "../lib/resizeDrag";
import { useToast } from "../lib/toast";
@@ -28,6 +29,7 @@ import { Tooltip } from "./Tooltip";
import { ComposerContextCard } from "./ComposerContextCard";
import { ContextWindowRing } from "./ContextWindowRing";
import { ImageViewer } from "./ImageViewer";
import { InvocationBadge } from "./InvocationBadge";
import { VirtualMenu } from "./VirtualMenu";
import { dirEntryMenuLabel, dirEntrySubmitPath } from "./FileReferenceMenu";
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
@@ -57,6 +59,7 @@ const COMPOSER_MAX_HEIGHT = 360;
const COMPOSER_RUN_STRIP_RESERVED = 30;
const COMPOSER_MAX_VIEWPORT_RATIO = 0.4;
const COMPOSER_AUTO_RESERVED_HEIGHT = 58;
const COMPOSER_INVOCATION_RESERVED_HEIGHT = 32;
const PROMPT_HISTORY_PREFETCH_REMAINING = 3;
// Grace after compositionend to swallow a confirm-Enter that lands just after
// it; the real gap is a few ms, so keep it short or a deliberate quick second
@@ -82,6 +85,7 @@ type FileRefSearchCacheEntry = {
type ComposerDraft = {
text: string;
selectedInvocation: CommandInfo | null;
attachments: Attachment[];
workspaceRefs: WorkspaceReference[];
pastedBlocks: PastedBlock[];
@@ -173,6 +177,7 @@ export function composerPickFileEntry(
function emptyComposerDraft(): ComposerDraft {
return {
text: "",
selectedInvocation: null,
attachments: [],
workspaceRefs: [],
pastedBlocks: [],
@@ -200,6 +205,7 @@ function guidanceTextMatches(queued: string, consumed: string): boolean {
function cloneComposerDraft(draft: ComposerDraft): ComposerDraft {
return {
text: draft.text,
selectedInvocation: draft.selectedInvocation ? { ...draft.selectedInvocation } : null,
attachments: [...draft.attachments],
workspaceRefs: [...draft.workspaceRefs],
pastedBlocks: [...draft.pastedBlocks],
@@ -319,8 +325,8 @@ function clampComposerHeight(height: number): number {
return Math.min(Math.max(Math.round(height), COMPOSER_MIN_HEIGHT), composerMaxHeight());
}
function composerAutoInputMaxHeight(): number {
return Math.max(32, composerMaxHeight() - COMPOSER_AUTO_RESERVED_HEIGHT);
function composerAutoInputMaxHeight(extraReservedHeight = 0): number {
return Math.max(32, composerMaxHeight() - COMPOSER_AUTO_RESERVED_HEIGHT - extraReservedHeight);
}
function loadComposerHeight(): number | null {
@@ -609,6 +615,7 @@ export function Composer({
}, []);
const [workspaceRefs, setWorkspaceRefs] = useState<WorkspaceReference[]>([]);
const [selectedInvocation, setSelectedInvocation] = useState<CommandInfo | null>(null);
const [pastedBlocks, setPastedBlocks] = useState<PastedBlock[]>([]);
const [openPastedLabels, setOpenPastedLabels] = useState<string[]>([]);
const [pendingPaste, setPendingPaste] = useState(0);
@@ -681,11 +688,13 @@ export function Composer({
const draftsBySessionRef = useRef<Record<string, ComposerDraft>>({});
const activeDraftKeyRef = useRef(draftKey);
const textRef = useRef(text);
const selectedInvocationRef = useRef(selectedInvocation);
const attachmentsRef = useRef(attachments);
const workspaceRefsRef = useRef(workspaceRefs);
const openPastedLabelsRef = useRef(openPastedLabels);
const sessionRefsRef = useRef(sessionRefs);
textRef.current = text;
selectedInvocationRef.current = selectedInvocation;
attachmentsRef.current = attachments;
workspaceRefsRef.current = workspaceRefs;
pastedBlocksRef.current = pastedBlocks;
@@ -699,6 +708,7 @@ export function Composer({
const snapshotComposerDraft = (): ComposerDraft => ({
text: textRef.current,
selectedInvocation: selectedInvocationRef.current ? { ...selectedInvocationRef.current } : null,
attachments: [...attachmentsRef.current],
workspaceRefs: [...workspaceRefsRef.current],
pastedBlocks: [...pastedBlocksRef.current],
@@ -718,11 +728,13 @@ export function Composer({
const restoreComposerDraft = (draft: ComposerDraft) => {
const next = cloneComposerDraft(draft);
textRef.current = next.text;
selectedInvocationRef.current = next.selectedInvocation;
attachmentsRef.current = next.attachments;
workspaceRefsRef.current = next.workspaceRefs;
openPastedLabelsRef.current = next.openPastedLabels;
sessionRefsRef.current = next.sessionRefs;
setText(next.text);
setSelectedInvocation(next.selectedInvocation);
setAttachments(next.attachments);
setWorkspaceRefs(next.workspaceRefs);
pastedBlocksRef.current = next.pastedBlocks;
@@ -1351,6 +1363,8 @@ export function Composer({
if (targetDraftKey === activeDraftKeyRef.current) {
textRef.current = "";
setText("");
selectedInvocationRef.current = null;
setSelectedInvocation(null);
historyIndexRef.current = -1;
setHistoryIndex(-1);
clearAttachments();
@@ -1367,6 +1381,7 @@ export function Composer({
}
const draft = cloneComposerDraft(draftsBySessionRef.current[targetDraftKey] ?? emptyComposerDraft());
draft.text = "";
draft.selectedInvocation = null;
draft.attachments = [];
draft.workspaceRefs = [];
draft.pastedBlocks = [];
@@ -1483,7 +1498,8 @@ export function Composer({
const currentSessionRefs = sessionRefsRef.current;
const currentPastedBlocks = [...pastedBlocksRef.current];
const sessionContext = currentSessionRefs.length === 0 ? "" : await buildSessionContext(currentSessionRefs);
const baseSubmitText = [expandPastedBlocks(trimmedText, currentPastedBlocks), refs].filter(Boolean).join(trimmedText && refs ? " " : "");
const invocationPrefix = selectedInvocationRef.current ? `/${selectedInvocationRef.current.name}` : "";
const baseSubmitText = [invocationPrefix, expandPastedBlocks(trimmedText, currentPastedBlocks), refs].filter(Boolean).join(" ");
const submitText = sessionContext ? `${sessionContext}${baseSubmitText}` : baseSubmitText;
if (running) {
const guidanceText = displayText.trim();
@@ -1963,7 +1979,22 @@ export function Composer({
setTextCaretEnd([base, ...queued].filter((part) => part.trim() !== "").join("\n"));
};
const pickCommand = (c: CommandInfo) => setTextCaretEnd("/" + c.name + " ");
const clearSelectedInvocation = () => {
selectedInvocationRef.current = null;
setSelectedInvocation(null);
requestAnimationFrame(() => taRef.current?.focus());
};
const pickCommand = (c: CommandInfo) => {
if (!commandUsesStructuredInvocation(c)) {
setTextCaretEnd("/" + c.name + " ");
return;
}
selectedInvocationRef.current = c;
setSelectedInvocation(c);
textRef.current = "";
setTextCaretEnd("");
};
const activePastedBlocks = pastedBlocks.filter((block) => text.includes(block.label));
const shellModeActive = text.trimStart().startsWith("!");
@@ -2010,13 +2041,13 @@ export function Composer({
if (!node) return;
const previousHeight = node.style.height;
node.style.height = "auto";
const maxHeight = composerAutoInputMaxHeight();
const maxHeight = composerAutoInputMaxHeight(selectedInvocationRef.current ? COMPOSER_INVOCATION_RESERVED_HEIGHT : 0);
const nextHeight = Math.min(node.scrollHeight, maxHeight);
const nextOverflow = node.scrollHeight > maxHeight + 1;
node.style.height = previousHeight;
setTextareaAutoHeight((current) => (current === nextHeight ? current : nextHeight));
setTextareaAutoOverflow((current) => (current === nextOverflow ? current : nextOverflow));
}, [composerHeight]);
}, [composerHeight, selectedInvocation]);
useLayoutEffect(() => {
measureTextareaAutoHeight();
@@ -2302,6 +2333,19 @@ export function Composer({
return;
}
if (
e.key === "Backspace" &&
!composing &&
selectedInvocationRef.current &&
e.currentTarget.value === "" &&
e.currentTarget.selectionStart === 0 &&
e.currentTarget.selectionEnd === 0
) {
e.preventDefault();
clearSelectedInvocation();
return;
}
syncPromptHistoryGeneration();
const canUseCurrentPromptHistory = () => canUsePromptHistory({
@@ -2453,7 +2497,9 @@ export function Composer({
? ({ height: `${textareaAutoHeight}px`, overflowY: textareaAutoOverflow ? "auto" : "hidden" } as CSSProperties)
: undefined;
const composerAutoExpanded = composerHeight === null && textareaAutoHeight !== null && textareaAutoHeight > 40;
const composerResizeValue = composerHeight ?? clampComposerHeight((textareaAutoHeight ?? 0) + COMPOSER_AUTO_RESERVED_HEIGHT);
const composerResizeValue = composerHeight ?? clampComposerHeight(
(textareaAutoHeight ?? 0) + COMPOSER_AUTO_RESERVED_HEIGHT + (selectedInvocation ? COMPOSER_INVOCATION_RESERVED_HEIGHT : 0),
);
void onSetMode;
const chooseApprovalMode = (nextMode: ToolApprovalMode) => {
onSetToolApprovalMode(nextMode);
@@ -3045,69 +3091,85 @@ export function Composer({
</div>
)}
<div
className={`composer${dragOver ? " composer--dragover" : ""}${disabled || readOnly ? " composer--disabled" : ""}${shellModeActive ? " composer--shell" : ""}`}
className={`composer${selectedInvocation ? " composer--has-invocation" : ""}${dragOver ? " composer--dragover" : ""}${disabled || readOnly ? " composer--disabled" : ""}${shellModeActive ? " composer--shell" : ""}`}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
>
<span className="composer__caret">{shellModeActive ? "$" : "›"}</span>
<textarea
id="composer-input"
ref={taRef}
className="composer__input"
aria-label={t("composer.placeholder")}
value={text}
onChange={(e) => {
resetPromptHistoryNavigation();
setText(e.target.value);
if (composerPrompt) setComposerPrompt(null);
}}
onSelect={rememberCaret}
onClick={rememberCaret}
onKeyUp={rememberCaret}
onFocus={rememberCaret}
onContextMenu={openInputMenu}
onPaste={onPaste}
onKeyDown={onKeyDown}
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={() => {
composingRef.current = false;
lastCompositionEndAt.current = Date.now();
}}
style={textareaStyle}
placeholder={composerPlaceholder}
rows={1}
disabled={disabled || readOnly}
/>
{composerPrompt && (
<span className="composer__prompt" role="status">
{composerPrompt}
</span>
)}
{running && (
<Tooltip label={t("composer.stop")}>
<div className="composer__input-row">
<span className="composer__caret">{shellModeActive ? "$" : "›"}</span>
<div className="composer__content">
{selectedInvocation && (() => {
const invocation = invocationDisplayForCommand(selectedInvocation);
return (
<InvocationBadge
invocation={invocation}
kind={selectedInvocation.kind === "subagent" ? "subagent" : "skill"}
description={selectedInvocation.description}
onRemove={clearSelectedInvocation}
variant="composer"
/>
);
})()}
<textarea
id="composer-input"
ref={taRef}
className="composer__input"
aria-label={t("composer.placeholder")}
value={text}
onChange={(e) => {
resetPromptHistoryNavigation();
setText(e.target.value);
if (composerPrompt) setComposerPrompt(null);
}}
onSelect={rememberCaret}
onClick={rememberCaret}
onKeyUp={rememberCaret}
onFocus={rememberCaret}
onContextMenu={openInputMenu}
onPaste={onPaste}
onKeyDown={onKeyDown}
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={() => {
composingRef.current = false;
lastCompositionEndAt.current = Date.now();
}}
style={textareaStyle}
placeholder={composerPlaceholder}
rows={1}
disabled={disabled || readOnly}
/>
</div>
{composerPrompt && (
<span className="composer__prompt" role="status">
{composerPrompt}
</span>
)}
{running && (
<Tooltip label={t("composer.stop")}>
<button
className="composer__btn composer__btn--stop"
type="button"
onClick={handleCancel}
aria-label={t("composer.stop")}
>
<Square size={12} fill="currentColor" />
</button>
</Tooltip>
)}
<Tooltip label={submitTooltip}>
<button
className="composer__btn composer__btn--stop"
type="button"
onClick={handleCancel}
aria-label={t("composer.stop")}
className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
onClick={submit}
disabled={submitBlocked}
aria-label={submitTooltip}
>
<Square size={12} fill="currentColor" />
{running ? <CornerDownRight size={16} /> : <ArrowUp size={16} />}
</button>
</Tooltip>
)}
<Tooltip label={submitTooltip}>
<button
className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
onClick={submit}
disabled={submitBlocked}
aria-label={submitTooltip}
>
{running ? <CornerDownRight size={16} /> : <ArrowUp size={16} />}
</button>
</Tooltip>
</div>
</div>
<ContextMenu
open={inputMenuPoint !== null}
@@ -0,0 +1,43 @@
import { Box, X } from "lucide-react";
import type { InvocationDisplay } from "../lib/invocationDisplay";
import { useT } from "../lib/i18n";
import { Tooltip } from "./Tooltip";
export function InvocationBadge({
invocation,
kind = "skill",
description,
onRemove,
variant,
}: {
invocation: InvocationDisplay;
kind?: "skill" | "subagent";
description?: string;
onRemove?: () => void;
variant: "composer" | "message";
}) {
const t = useT();
return (
<div className={`invocation-display invocation-display--${variant} invocation-display--${kind}`} role="group" aria-label={t("composer.selectedInvocation")}>
<Tooltip label={description || `/${invocation.name}`}>
<span className="invocation-display__identity">
<Box size={variant === "composer" ? 19 : 17} />
<span className="invocation-display__name">{invocation.label}</span>
{invocation.source && <span className="invocation-display__source">{t("slash.plugin", { name: invocation.source })}</span>}
</span>
</Tooltip>
{onRemove && (
<Tooltip label={t("composer.removeInvocation")}>
<button
type="button"
className="invocation-display__remove"
onClick={onRemove}
aria-label={t("composer.removeInvocationNamed", { name: invocation.label })}
>
<X size={14} />
</button>
</Tooltip>
)}
</div>
);
}
@@ -16,8 +16,10 @@ import { useGSAPCollapse } from "../lib/useGSAPCollapse";
import { displayReasoningText } from "../lib/reasoningDisplay";
import { stripMemoryCompilerExecution } from "../lib/memoryCompilerDisplay";
import { visibleTranscriptMemoryCitations } from "../lib/memoryCitationVisibility";
import { invocationDisplayFromMessage } from "../lib/invocationDisplay";
import type { Item, MessageActionScope } from "../lib/useController";
import type { CheckpointMeta, MemoryCitation } from "../lib/types";
import { InvocationBadge } from "./InvocationBadge";
type AssistantItem = Extract<Item, { kind: "assistant" }>;
export type TurnActionMenu = "summary" | "rewind";
@@ -197,6 +199,7 @@ export function UserMessage({
const actionText = stripMemoryCompilerExecution(imSource?.text ?? text);
const hasMemoryCompiler = Boolean(submitText?.includes("<memory-compiler-execution>"));
const { text: displayText, attachments } = parseAttachmentRefsForDisplay(actionText);
const invocation = imSource ? null : invocationDisplayFromMessage(displayText, submitText);
const orderedAttachments = sortDisplayAttachments(attachments);
const sourceLabel = imSource ? imSourceLabel(imSource, t) : "";
const sentAt = createdAt === undefined ? null : messageDate(createdAt);
@@ -379,6 +382,7 @@ export function UserMessage({
data-entrance={id || undefined}
>
<div className={`msg__body${editing ? " msg__body--editing" : ""}`}>
{invocation && <InvocationBadge invocation={invocation} variant="message" />}
{editing ? (
<form className="msg-edit" onSubmit={(event) => void submitEdit(event)}>
{orderedDraftAttachments.length > 0 && (
@@ -0,0 +1,53 @@
import type { CommandInfo } from "./types";
export type InvocationDisplay = {
name: string;
label: string;
source?: string;
};
export function commandUsesStructuredInvocation(command: CommandInfo): boolean {
return command.kind === "skill" || command.kind === "subagent";
}
export function invocationLabel(name: string): string {
const unqualified = name.split(":").pop() || name;
return unqualified
.split(/[-_.]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function invocationDisplayForCommand(command: CommandInfo): InvocationDisplay {
return {
name: command.name,
label: invocationLabel(command.name),
source: command.plugin || command.name.split(":").slice(0, -1).join(":") || undefined,
};
}
export function invocationDisplayFromMessage(displayText: string, submitText?: string): InvocationDisplay | null {
const display = displayText.trim();
const submit = submitText?.trim() ?? "";
if (!submit || submit === display || /^\/[A-Za-z0-9_.:-]+(?:\s|$)/.test(display)) return null;
const sessionQuestionMarker = "当前用户问题:\n";
const markerIndex = submit.lastIndexOf(sessionQuestionMarker);
const invocationText = (markerIndex >= 0 ? submit.slice(markerIndex + sessionQuestionMarker.length) : submit).trimStart();
const match = markerIndex >= 0
? /^\/([A-Za-z0-9_.:-]+)(?=\s|$)/.exec(invocationText)
: /(?:^|\n)\/([A-Za-z0-9_.:-]+)(?=\s|$)/.exec(invocationText);
if (!match) return null;
if (markerIndex < 0) {
const slashOffset = match.index + match[0].lastIndexOf("/");
const prefix = invocationText.slice(0, slashOffset).trim();
if (prefix && !prefix.startsWith("<") && !prefix.startsWith("[Plan mode")) return null;
}
const name = match[1];
return {
name,
label: invocationLabel(name),
source: name.includes(":") ? name.split(":").slice(0, -1).join(":") : undefined,
};
}
+3
View File
@@ -572,6 +572,9 @@ export const en = {
"composer.pasteImageFailed": "Could not read clipboard image",
"composer.imageInputUnsupported": "Current model will not receive images directly. Use an image-capable model or an OCR/vision MCP tool to inspect the attached path.",
"composer.contextItems": "Context items",
"composer.selectedInvocation": "Selected skill or subagent",
"composer.removeInvocation": "Remove selected skill or subagent",
"composer.removeInvocationNamed": "Remove {name}",
"composer.workspaceReferences": "Workspace references",
"composer.removeReference": "Remove reference",
+3
View File
@@ -445,6 +445,9 @@ export const zhTW: Record<DictKey, string> = {
"imageViewer.title": "圖片預覽",
"composer.imageInputUnsupported": "目前模型不會直接接收圖片。可切換到支援圖片的模型,或讓 OCR/識圖 MCP 工具讀取附件路徑。",
"composer.contextItems": "上下文項目",
"composer.selectedInvocation": "已選擇的技能或子智慧體",
"composer.removeInvocation": "移除已選擇的技能或子智慧體",
"composer.removeInvocationNamed": "移除 {name}",
"composer.workspaceReferences": "工作區引用",
"composer.removeReference": "移除引用",
+3
View File
@@ -573,6 +573,9 @@ export const zh: Record<DictKey, string> = {
"composer.pasteImageFailed": "未能读取剪贴板图片",
"composer.imageInputUnsupported": "当前模型不会直接接收图片。可切换到支持图片的模型,或让 OCR/识图 MCP 工具读取附件路径。",
"composer.contextItems": "上下文项目",
"composer.selectedInvocation": "已选择的技能或子智能体",
"composer.removeInvocation": "移除已选择的技能或子智能体",
"composer.removeInvocationNamed": "移除 {name}",
"composer.workspaceReferences": "工作区引用",
"composer.removeReference": "移除引用",
+116 -3
View File
@@ -5704,8 +5704,9 @@ body > .mermaid-diagram--fullscreen {
}
.composer {
display: flex;
gap: 8px;
align-items: center;
flex-direction: column;
gap: 5px;
align-items: stretch;
min-height: 0;
background: transparent;
border: none;
@@ -5716,8 +5717,118 @@ body > .mermaid-diagram--fullscreen {
.composer-card--resized .composer,
.composer-card--autosized .composer {
flex: 1;
}
.composer__input-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
min-height: 0;
flex: 1;
}
.composer__content {
display: flex;
flex: 1;
flex-direction: column;
align-items: stretch;
gap: 5px;
min-width: 0;
min-height: 0;
height: 100%;
}
.composer-card--resized .composer__input-row,
.composer-card--autosized .composer__input-row {
align-items: flex-start;
}
.composer--has-invocation .composer__caret {
margin-top: 32px;
}
.invocation-display {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 100%;
color: var(--accent);
}
.invocation-display--composer {
align-self: flex-start;
height: 27px;
}
.invocation-display--message {
margin-bottom: 6px;
color: var(--accent);
}
.invocation-display--message .invocation-display__name {
font-size: 13px;
font-weight: 620;
}
.invocation-display--message .invocation-display__source {
font-size: 10.5px;
}
.invocation-display__identity {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 0;
max-width: 100%;
padding: 0 2px;
}
.invocation-display__identity > svg {
flex: 0 0 auto;
stroke-width: 1.8;
}
.invocation-display__name {
min-width: 0;
overflow: hidden;
color: var(--accent);
font-size: 15px;
font-weight: 590;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.invocation-display__source {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
color: var(--fg-faint);
font-size: 11px;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.invocation-display__remove {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--fg-faint);
opacity: 0;
transition: opacity 0.12s ease, color 0.12s ease, background 0.12s ease;
}
.invocation-display:hover .invocation-display__remove,
.invocation-display:focus-within .invocation-display__remove {
opacity: 1;
}
.invocation-display__remove:hover,
.invocation-display__remove:focus-visible {
outline: none;
background: var(--hover);
color: var(--fg);
}
@media (hover: none) {
.invocation-display__remove {
opacity: 1;
}
}
.composer--dragover {
outline: 1px dashed var(--accent);
outline-offset: -4px;
@@ -5749,6 +5860,7 @@ body > .mermaid-diagram--fullscreen {
}
.composer__input {
flex: 1;
width: 100%;
resize: none;
border: none;
background: none;
@@ -5765,7 +5877,8 @@ body > .mermaid-diagram--fullscreen {
display: none; /* Chrome / Safari / Wails webview */
}
.composer-card--resized .composer__input {
height: 100%;
height: auto;
min-height: 0;
max-height: none;
overflow-y: auto;
scrollbar-width: thin; /* restore scrollbar when resized */
+15
View File
@@ -91,6 +91,21 @@ func TestHistoryMessagesDoNotReplayMemoryCompilerContract(t *testing.T) {
assertNoHistoryMemoryContract(t, got[0].Content)
}
func TestHistoryMessagesRestoreCompiledSkillInvocationWithoutContract(t *testing.T) {
raw := historyMemoryCompilerContract(t, "/reasonix-develop ship the refactor")
msgs := []provider.Message{{Role: provider.RoleUser, Content: raw}}
got := historyMessages(msgs, func(string) string { return "ship the refactor" })
if len(got) != 1 {
t.Fatalf("history length = %d, want 1: %+v", len(got), got)
}
if got[0].Content != "ship the refactor" || got[0].SubmitText != "/reasonix-develop ship the refactor" {
t.Fatalf("compiled skill history = %+v", got[0])
}
assertNoHistoryMemoryContract(t, got[0].Content)
assertNoHistoryMemoryContract(t, got[0].SubmitText)
}
func TestHistoryMessagesStripActiveGoalFromVisibleUserContent(t *testing.T) {
raw := "<active-goal>\nship the approval redesign\n</active-goal>\n\ncontinue implementation"
msgs := []provider.Message{