add skills to the chat composer (#8881)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-04-28 14:21:45 -07:00
committed by GitHub
parent 23d3db445f
commit 69efb796ae
49 changed files with 2086 additions and 540 deletions
+22
View File
@@ -32,6 +32,8 @@ import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection";
import { perfLog } from "@/shared/lib/perfLog";
import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore";
import { sanitizeReplayMessages } from "@/features/chat/lib/replaySanitizer";
import type { SkillInfo } from "@/features/skills/api/skills";
import { toChatSkillDraft } from "@/features/skills/lib/skillChatPrompt";
export type AppView =
| "home"
@@ -339,6 +341,25 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
[createNewTab],
);
const handleStartChatWithSkill = useCallback(
(skill: SkillInfo, projectId?: string | null) => {
const project = projectId
? projectStore.projects.find((candidate) => candidate.id === projectId)
: undefined;
void createNewTab(DEFAULT_CHAT_TITLE, project)
.then((session) => {
useChatStore
.getState()
.setSkillDrafts(session.id, [toChatSkillDraft(skill)]);
})
.catch((error) => {
console.error("Failed to start chat with skill:", error);
});
},
[createNewTab, projectStore.projects],
);
const handleNewChatInProject = useCallback(
(projectId: string) => {
const project = projectStore.projects.find((p) => p.id === projectId);
@@ -708,6 +729,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
onSelectSession={handleSelectSession}
onSelectSearchResult={handleSelectSearchResult}
onStartChatFromProject={handleStartChatFromProject}
onStartChatWithSkill={handleStartChatWithSkill}
/>
)}
</main>
+4 -1
View File
@@ -5,6 +5,7 @@ import { AgentsView } from "@/features/agents/ui/AgentsView";
import { ProjectsView } from "@/features/projects/ui/ProjectsView";
import { SessionHistoryView } from "@/features/sessions/ui/SessionHistoryView";
import type { ChatSession } from "@/features/chat/stores/chatSessionStore";
import type { SkillInfo } from "@/features/skills/api/skills";
import type { ProjectInfo } from "@/features/projects/api/projects";
import type { AppView } from "../AppShell";
@@ -27,6 +28,7 @@ interface AppShellContentProps {
query?: string,
) => void;
onStartChatFromProject: (project: ProjectInfo) => void;
onStartChatWithSkill: (skill: SkillInfo, projectId?: string | null) => void;
}
export function AppShellContent({
@@ -41,10 +43,11 @@ export function AppShellContent({
onSelectSession,
onSelectSearchResult,
onStartChatFromProject,
onStartChatWithSkill,
}: AppShellContentProps) {
switch (activeView) {
case "skills":
return <SkillsView />;
return <SkillsView onStartChatWithSkill={onStartChatWithSkill} />;
case "agents":
return <AgentsView />;
case "projects":
@@ -0,0 +1,99 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useChatSessionStore } from "../../stores/chatSessionStore";
import { useChatStore } from "../../stores/chatStore";
const mockAcpSendMessage = vi.fn();
const mockAcpCancelSession = vi.fn();
const mockAcpLoadSession = vi.fn();
const mockAcpPrepareSession = vi.fn();
const mockAcpSetModel = vi.fn();
const mockGetGooseSessionId = vi.fn();
vi.mock("@/shared/api/acp", () => ({
acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args),
acpCancelSession: (...args: unknown[]) => mockAcpCancelSession(...args),
acpLoadSession: (...args: unknown[]) => mockAcpLoadSession(...args),
acpPrepareSession: (...args: unknown[]) => mockAcpPrepareSession(...args),
acpSetModel: (...args: unknown[]) => mockAcpSetModel(...args),
}));
vi.mock("@/shared/api/acpSessionTracker", () => ({
getGooseSessionId: (...args: unknown[]) => mockGetGooseSessionId(...args),
}));
import { useChat } from "../useChat";
describe("useChat skill chips", () => {
beforeEach(() => {
mockAcpSendMessage.mockReset();
mockAcpCancelSession.mockReset();
mockAcpLoadSession.mockReset();
mockAcpPrepareSession.mockReset();
mockAcpSetModel.mockReset();
mockGetGooseSessionId.mockReset();
mockAcpSendMessage.mockResolvedValue(undefined);
mockGetGooseSessionId.mockReturnValue(null);
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
activeSessionId: null,
isConnected: true,
});
useChatSessionStore.setState({
sessions: [],
activeSessionId: null,
isLoading: false,
contextPanelOpenBySession: {},
activeWorkspaceBySession: {},
});
useAgentStore.setState({
personas: [],
personasLoading: false,
agents: [],
agentsLoading: false,
activeAgentId: null,
isLoading: false,
personaEditorOpen: false,
editingPersona: null,
personaEditorMode: "create",
});
});
it("stores user-visible chips separately from the agent prompt", async () => {
const { result } = renderHook(() => useChat("session-1"));
await act(async () => {
await result.current.sendMessage(
"redo the settings modal",
undefined,
undefined,
{
displayText: "redo the settings modal",
assistantPrompt: "Use these skills for this request: capture-task.",
chips: [{ label: "capture-task", type: "skill" }],
},
);
});
const message = useChatStore.getState().messagesBySession["session-1"][0];
expect(message.content).toEqual([
{ type: "text", text: "redo the settings modal" },
]);
expect(message.metadata?.chips).toEqual([
{ label: "capture-task", type: "skill" },
]);
expect(mockAcpSendMessage).toHaveBeenCalledWith(
"session-1",
"redo the settings modal",
{
assistantPrompt: "Use these skills for this request: capture-task.",
systemPrompt: undefined,
personaId: undefined,
personaName: undefined,
images: undefined,
},
);
});
});
+17 -3
View File
@@ -28,6 +28,8 @@ import {
} from "../lib/attachments";
import { sanitizeReplayMessages } from "../lib/replaySanitizer";
import { i18n } from "@/shared/i18n";
import type { ChatSendOptions } from "../types";
import { buildSkillRetryOptions } from "../lib/skillSendPayload";
// TODO: Remove this fallback once goose2 has first-class /-commands.
const MANUAL_COMPACT_TRIGGER = "/compact";
@@ -146,16 +148,18 @@ export function useChat(
text: string,
overridePersona?: { id: string; name?: string },
attachments?: ChatAttachmentDraft[],
sendOptions?: ChatSendOptions,
) => {
const sid = sessionId.slice(0, 8);
const tSendStart = performance.now();
const images = buildAcpImages(attachments);
const hasAttachments = (attachments?.length ?? 0) > 0;
const hasAssistantPrompt = Boolean(sendOptions?.assistantPrompt?.trim());
const currentChatState = useChatStore
.getState()
.getSessionRuntime(sessionId).chatState;
if (
(!text.trim() && !hasAttachments) ||
(!text.trim() && !hasAttachments && !hasAssistantPrompt) ||
currentChatState === "streaming" ||
currentChatState === "thinking" ||
currentChatState === "compacting"
@@ -180,8 +184,9 @@ export function useChat(
// Create and add user message
const userMessage = createUserMessage(
text,
sendOptions?.displayText ?? text,
buildMessageAttachments(attachments),
sendOptions?.chips,
);
if (effectivePersonaInfo) {
userMessage.metadata = {
@@ -250,6 +255,9 @@ export function useChat(
);
await acpSendMessage(sessionId, acpPrompt, {
systemPrompt,
...(sendOptions?.assistantPrompt
? { assistantPrompt: sendOptions.assistantPrompt }
: {}),
personaId: effectivePersonaInfo?.id,
personaName: effectivePersonaInfo?.name,
images: images?.map(
@@ -350,11 +358,17 @@ export function useChat(
if (textContent && "text" in textContent) {
const targetPersonaId = lastUserMessage.metadata?.targetPersonaId;
const targetPersonaName = lastUserMessage.metadata?.targetPersonaName;
await sendMessage(
const retryOptions = buildSkillRetryOptions(
textContent.text,
lastUserMessage.metadata?.chips,
);
await sendMessage(
textContent.text || (retryOptions ? " " : ""),
targetPersonaId
? { id: targetPersonaId, name: targetPersonaName }
: undefined,
undefined,
retryOptions,
);
}
}, [sessionId, store, sendMessage]);
@@ -0,0 +1,51 @@
import { useCallback } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { useTranslation } from "react-i18next";
import { normalizeDialogSelection } from "./useChatInputAttachments";
interface UseChatInputFilePickerOptions {
disabled: boolean;
addPathAttachments: (paths: string[]) => Promise<void>;
}
export function useChatInputFilePicker({
disabled,
addPathAttachments,
}: UseChatInputFilePickerOptions) {
const { t } = useTranslation("chat");
const handleAttachFiles = useCallback(async () => {
if (disabled) {
return;
}
try {
const selected = await open({
title: t("attachments.chooseFilesDialogTitle"),
multiple: true,
});
await addPathAttachments(normalizeDialogSelection(selected));
} catch {
// Dialog plugin may be unavailable in some environments.
}
}, [addPathAttachments, disabled, t]);
const handleAttachFolders = useCallback(async () => {
if (disabled) {
return;
}
try {
const selected = await open({
directory: true,
title: t("attachments.chooseFoldersDialogTitle"),
multiple: true,
});
await addPathAttachments(normalizeDialogSelection(selected));
} catch {
// Dialog plugin may be unavailable in some environments.
}
}, [addPathAttachments, disabled, t]);
return { handleAttachFiles, handleAttachFolders };
}
@@ -0,0 +1,70 @@
import { useCallback, type RefObject } from "react";
import type { SkillCommandMatch } from "@/features/skills/lib/skillChatPrompt";
import type { ChatAttachmentDraft } from "@/shared/types/messages";
import { skillDraftSnapshotsMatch } from "../lib/chatInputSnapshots";
import { submitComposerMessage } from "../lib/submitComposerMessage";
import type { ChatInputProps, ChatSkillDraft } from "../types";
interface UseChatInputSubmitOptions {
attachmentsRef: RefObject<ChatAttachmentDraft[]>;
selectedSkillsRef: RefObject<ChatSkillDraft[]>;
selectedPersonaId?: string | null;
onSend: ChatInputProps["onSend"];
setSelectedSkills: (skills: ChatSkillDraft[]) => void;
resolveSkillSlashCommand: (
message: string,
) => SkillCommandMatch<ChatSkillDraft> | null;
}
export function useChatInputSubmit({
attachmentsRef,
selectedSkillsRef,
selectedPersonaId,
onSend,
setSelectedSkills,
resolveSkillSlashCommand,
}: UseChatInputSubmitOptions) {
const submitChatInputMessage = useCallback(
(
submittedText: string,
submittedAttachments: ChatAttachmentDraft[],
submittedSkills: ChatSkillDraft[],
) =>
submitComposerMessage({
text: submittedText,
attachments: submittedAttachments,
skills: submittedSkills,
selectedPersonaId,
onSend,
resolveSkillSlashCommand,
}),
[onSend, resolveSkillSlashCommand, selectedPersonaId],
);
const handleVoiceAutoSubmit = useCallback(
async (submittedText: string) => {
const submittedAttachments = attachmentsRef.current;
const submittedSkills = selectedSkillsRef.current;
const accepted = await submitChatInputMessage(
submittedText,
submittedAttachments,
submittedSkills,
);
if (
accepted &&
skillDraftSnapshotsMatch(selectedSkillsRef.current, submittedSkills)
) {
setSelectedSkills([]);
}
return accepted;
},
[
attachmentsRef,
selectedSkillsRef,
setSelectedSkills,
submitChatInputMessage,
],
);
return { submitChatInputMessage, handleVoiceAutoSubmit };
}
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ChatSkillDraft } from "../types";
import type { ChatAttachmentDraft } from "@/shared/types/messages";
import type { ChatSendOptions } from "../types";
import { INITIAL_TOKEN_STATE } from "@/shared/types/chat";
import { useChat } from "./useChat";
import { useAutoCompactPreferences } from "./useAutoCompactPreferences";
@@ -36,6 +38,7 @@ interface UseChatSessionControllerOptions {
}
const PENDING_HOME_SESSION_ID = "__home_pending__";
const EMPTY_SKILL_DRAFTS: ChatSkillDraft[] = [];
export function useChatSessionController({
sessionId,
@@ -71,6 +74,10 @@ export function useChatSessionController({
const pendingDraftValue = useChatStore(
(s) => s.draftsBySession[PENDING_HOME_SESSION_ID] ?? "",
);
const pendingSkillDrafts = useChatStore(
(s) =>
s.skillDraftsBySession[PENDING_HOME_SESSION_ID] ?? EMPTY_SKILL_DRAFTS,
);
const pendingQueuedMessage = useChatStore(
(s) => s.queuedMessageBySession[PENDING_HOME_SESSION_ID] ?? null,
);
@@ -478,9 +485,14 @@ export function useChatSessionController({
text: string,
overridePersona?: { id: string; name?: string },
attachments?: ChatAttachmentDraft[],
sendOptions?: ChatSendOptions,
) => {
if (!canAutoCompactBeforeSend(overridePersona)) {
void sendMessage(text, overridePersona, attachments);
if (sendOptions) {
void sendMessage(text, overridePersona, attachments, sendOptions);
} else {
void sendMessage(text, overridePersona, attachments);
}
return true;
}
@@ -490,7 +502,11 @@ export function useChatSessionController({
return false;
}
void sendMessage(text, overridePersona, attachments);
if (sendOptions) {
void sendMessage(text, overridePersona, attachments, sendOptions);
} else {
void sendMessage(text, overridePersona, attachments);
}
return true;
})();
},
@@ -505,6 +521,7 @@ export function useChatSessionController({
const deferredSend = useRef<{
text: string;
attachments?: ChatAttachmentDraft[];
sendOptions?: ChatSendOptions;
resolve?: (accepted: boolean) => void;
} | null>(null);
const queue = useMessageQueue(
@@ -514,10 +531,15 @@ export function useChatSessionController({
);
const handleSend = useCallback(
(text: string, personaId?: string, attachments?: ChatAttachmentDraft[]) => {
(
text: string,
personaId?: string,
attachments?: ChatAttachmentDraft[],
sendOptions?: ChatSendOptions,
) => {
if (!sessionId) {
if (!queue.queuedMessage) {
queue.enqueue(text, personaId, attachments);
queue.enqueue(text, personaId, attachments, sendOptions);
}
return true;
}
@@ -525,16 +547,16 @@ export function useChatSessionController({
if (personaId && personaId !== selectedPersonaId) {
handlePersonaChange(personaId);
return new Promise<boolean>((resolve) => {
deferredSend.current = { text, attachments, resolve };
deferredSend.current = { text, attachments, sendOptions, resolve };
});
}
if (chatState !== "idle" && !queue.queuedMessage) {
queue.enqueue(text, personaId, attachments);
queue.enqueue(text, personaId, attachments, sendOptions);
return true;
}
return sendWithAutoCompact(text, undefined, attachments);
return sendWithAutoCompact(text, undefined, attachments, sendOptions);
},
[
chatState,
@@ -548,9 +570,14 @@ export function useChatSessionController({
useEffect(() => {
if (deferredSend.current && selectedPersona) {
const { text, attachments, resolve } = deferredSend.current;
const { text, attachments, sendOptions, resolve } = deferredSend.current;
deferredSend.current = null;
const sendResult = sendWithAutoCompact(text, undefined, attachments);
const sendResult = sendWithAutoCompact(
text,
undefined,
attachments,
sendOptions,
);
if (sendResult instanceof Promise) {
void sendResult.then((accepted) => {
if (accepted === false) {
@@ -575,13 +602,25 @@ export function useChatSessionController({
const sessionDraftValue = useChatStore((s) =>
sessionId ? (s.draftsBySession[sessionId] ?? "") : "",
);
const sessionSkillDrafts = useChatStore((s) =>
sessionId
? (s.skillDraftsBySession[sessionId] ?? EMPTY_SKILL_DRAFTS)
: EMPTY_SKILL_DRAFTS,
);
const draftValue = sessionId ? sessionDraftValue : pendingDraftValue;
const selectedSkills = sessionId ? sessionSkillDrafts : pendingSkillDrafts;
const handleDraftChange = useCallback(
(text: string) => {
useChatStore.getState().setDraft(stateSessionId, text);
},
[stateSessionId],
);
const handleSkillsChange = useCallback(
(skills: typeof selectedSkills) => {
useChatStore.getState().setSkillDrafts(stateSessionId, skills);
},
[stateSessionId],
);
const scrollTarget = useChatStore((s) =>
sessionId ? (s.scrollTargetMessageBySession[sessionId] ?? null) : null,
);
@@ -599,16 +638,25 @@ export function useChatSessionController({
let cancelled = false;
void pendingDraftValue;
void pendingSkillDrafts;
void pendingQueuedMessage;
const syncPendingHomeState = async () => {
const chatState = useChatStore.getState();
const pendingDraft =
chatState.draftsBySession[PENDING_HOME_SESSION_ID] ?? "";
const pendingSkills =
chatState.skillDraftsBySession[PENDING_HOME_SESSION_ID] ?? [];
if (pendingDraft && !chatState.draftsBySession[sessionId]) {
chatState.setDraft(sessionId, pendingDraft);
}
if (
pendingSkills.length > 0 &&
!chatState.skillDraftsBySession[sessionId]?.length
) {
chatState.setSkillDrafts(sessionId, pendingSkills);
}
const hasPendingProvider = pendingProviderId !== undefined;
const hasPendingPersona = pendingPersonaId !== undefined;
@@ -704,6 +752,7 @@ export function useChatSessionController({
}
useChatStore.getState().clearDraft(PENDING_HOME_SESSION_ID);
useChatStore.getState().clearSkillDrafts(PENDING_HOME_SESSION_ID);
useChatStore.getState().dismissQueuedMessage(PENDING_HOME_SESSION_ID);
useChatStore.getState().cleanupSession(PENDING_HOME_SESSION_ID);
};
@@ -716,6 +765,7 @@ export function useChatSessionController({
}, [
activeWorkspace?.path,
pendingDraftValue,
pendingSkillDrafts,
pendingModelSelection,
pendingPersonaId,
pendingProjectId,
@@ -750,6 +800,8 @@ export function useChatSessionController({
handleSend,
draftValue,
handleDraftChange,
selectedSkills,
handleSkillsChange,
scrollTarget,
handleScrollTargetHandled,
projectMetadataPending,
@@ -1,10 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { listSkills } from "@/features/skills/api/skills";
import {
expandSkillSlashCommand,
resolveSkillSlashCommand,
} from "@/features/skills/lib/skillChatPrompt";
import { listFilesForMentions } from "@/shared/api/system";
import type { Persona } from "@/shared/types/agents";
import {
useMentionDetection,
type FileMentionItem,
type MentionItem,
type SkillMentionItem,
} from "../ui/MentionAutocomplete";
import { useArtifactPolicyContext } from "./ArtifactPolicyContext";
@@ -15,6 +21,7 @@ interface MentionHandlersOptions {
setText: (value: string) => void;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
onPersonaChange?: ((id: string | null) => void) | undefined;
onSkillMentionSelect?: (skill: SkillMentionItem) => void;
}
function basename(path: string): string {
@@ -55,7 +62,7 @@ function sameStringArray(a: string[], b: string[]): boolean {
}
/**
* Combines persona + file mention detection, filtering, and selection handlers.
* Combines persona + skill + file mention detection, filtering, and selection handlers.
* Keeps ChatInput under the file-size limit by centralising mention logic.
*/
export function useMentionHandlers({
@@ -65,6 +72,7 @@ export function useMentionHandlers({
setText,
textareaRef,
onPersonaChange,
onSkillMentionSelect,
}: MentionHandlersOptions) {
const { getAllSessionArtifacts } = useArtifactPolicyContext();
const normalizedProjectRoots = useMemo(
@@ -76,6 +84,35 @@ export function useMentionHandlers({
[normalizedProjectRoots],
);
const [projectFilePaths, setProjectFilePaths] = useState<string[]>([]);
const [skillMentionItems, setSkillMentionItems] = useState<
SkillMentionItem[]
>([]);
useEffect(() => {
let cancelled = false;
void listSkills(normalizedProjectRoots)
.then((skills) => {
if (cancelled) return;
setSkillMentionItems(
skills.map((skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
sourceLabel: skill.sourceLabel,
})),
);
})
.catch((error) => {
if (cancelled) return;
console.error("Failed to load skills for mentions:", error);
setSkillMentionItems([]);
});
return () => {
cancelled = true;
};
}, [normalizedProjectRoots]);
useEffect(() => {
// Clear stale results immediately so users never see files from the
@@ -140,12 +177,13 @@ export function useMentionHandlers({
mentionStartIndex,
mentionSelectedIndex,
filteredPersonas,
filteredSkills,
filteredFiles,
detectMention,
closeMention,
navigateMention,
confirmMention,
} = useMentionDetection(personas, fileMentionItems);
} = useMentionDetection(personas, skillMentionItems, fileMentionItems);
// ---- post-selection cursor placement ------------------------------------
// After a mention is confirmed we update `text` via setState. A useEffect
@@ -202,30 +240,63 @@ export function useMentionHandlers({
[text, mentionStartIndex, mentionQuery, closeMention, setText],
);
const handleSkillMentionSelect = useCallback(
(skill: SkillMentionItem) => {
const before = text.slice(0, mentionStartIndex);
const after = text.slice(mentionStartIndex + 1 + mentionQuery.length);
const newText = `${before}${after}`.trimStart();
pendingCursorRef.current = Math.min(before.length, newText.length);
setText(newText);
closeMention();
onSkillMentionSelect?.(skill);
},
[
text,
mentionStartIndex,
mentionQuery,
closeMention,
onSkillMentionSelect,
setText,
],
);
const handleMentionConfirm = useCallback(
(item: MentionItem) => {
if (item.type === "persona") {
handlePersonaMentionSelect(item.persona);
} else if (item.type === "skill") {
handleSkillMentionSelect(item.skill);
} else {
handleFileMentionSelect(item.file);
}
},
[handlePersonaMentionSelect, handleFileMentionSelect],
[
handlePersonaMentionSelect,
handleSkillMentionSelect,
handleFileMentionSelect,
],
);
return {
fileMentionItems,
skillMentionItems,
mentionOpen,
mentionQuery,
mentionStartIndex,
mentionSelectedIndex,
filteredPersonas,
filteredSkills,
filteredFiles,
expandSkillSlashCommand: (message: string) =>
expandSkillSlashCommand(message, skillMentionItems),
resolveSkillSlashCommand: (message: string) =>
resolveSkillSlashCommand(message, skillMentionItems),
detectMention,
closeMention,
navigateMention,
confirmMention,
handlePersonaMentionSelect,
handleSkillMentionSelect,
handleFileMentionSelect,
handleMentionConfirm,
};
@@ -3,6 +3,7 @@ import type { ChatState } from "@/shared/types/chat";
import { isPromiseLike } from "@/shared/lib/isPromiseLike";
import type { ChatAttachmentDraft } from "@/shared/types/messages";
import { useChatStore } from "../stores/chatStore";
import type { ChatSendOptions } from "../types";
const MAX_CONSECUTIVE_SEND_FAILURES = 2;
@@ -11,6 +12,7 @@ function getQueuedMessageKey(
text: string;
personaId?: string;
attachments?: ChatAttachmentDraft[];
sendOptions?: ChatSendOptions;
} | null,
): string | null {
if (!queuedMessage) {
@@ -20,6 +22,7 @@ function getQueuedMessageKey(
return JSON.stringify({
text: queuedMessage.text,
personaId: queuedMessage.personaId ?? null,
sendOptions: queuedMessage.sendOptions ?? null,
attachments:
queuedMessage.attachments?.map((attachment) => ({
id: attachment.id,
@@ -45,6 +48,7 @@ export function useMessageQueue(
text: string,
overridePersona?: { id: string; name?: string },
attachments?: ChatAttachmentDraft[],
sendOptions?: ChatSendOptions,
) => boolean | Promise<boolean>,
) {
const queuedMessage = useChatStore(
@@ -104,12 +108,19 @@ export function useMessageQueue(
idleCycle: idleCycleRef.current,
};
const { text, personaId, attachments } = queuedMessage;
const sendResult = sendMessage(
text,
personaId ? { id: personaId } : undefined,
attachments,
);
const { text, personaId, attachments, sendOptions } = queuedMessage;
const sendResult = sendOptions
? sendMessage(
text,
personaId ? { id: personaId } : undefined,
attachments,
sendOptions,
)
: sendMessage(
text,
personaId ? { id: personaId } : undefined,
attachments,
);
const finalize = (accepted: boolean | undefined) => {
const latestQueuedMessage =
@@ -145,11 +156,17 @@ export function useMessageQueue(
}, [chatState, queuedMessage, queuedMessageKey, sendMessage, sessionId]);
const enqueue = useCallback(
(text: string, personaId?: string, attachments?: ChatAttachmentDraft[]) => {
(
text: string,
personaId?: string,
attachments?: ChatAttachmentDraft[],
sendOptions?: ChatSendOptions,
) => {
useChatStore.getState().enqueueMessage(sessionId, {
text,
personaId,
attachments,
sendOptions,
});
},
[sessionId],
@@ -23,6 +23,7 @@ interface UseVoiceDictationOptions {
personaId?: string,
attachments?: ChatAttachmentDraft[],
) => boolean | Promise<boolean>;
onAutoSubmit?: (text: string) => boolean | Promise<boolean>;
resetTextarea: () => void;
/**
* When true, auto-submit on trigger phrase will NOT call `onSend`.
@@ -43,6 +44,7 @@ export function useVoiceDictation({
clearAttachments,
selectedPersonaId,
onSend,
onAutoSubmit,
resetTextarea,
isSendLocked = false,
}: UseVoiceDictationOptions) {
@@ -140,11 +142,13 @@ export function useVoiceDictation({
textRef.current = merged;
return;
}
const sendResult = onSend(
merged.trim(),
selectedPersonaId ?? undefined,
attachments.length > 0 ? attachments : undefined,
);
const sendResult = onAutoSubmit
? onAutoSubmit(merged.trim())
: onSend(
merged.trim(),
selectedPersonaId ?? undefined,
attachments.length > 0 ? attachments : undefined,
);
if (isPromiseLike<boolean>(sendResult)) {
void sendResult
.then((accepted) => {
@@ -183,6 +187,7 @@ export function useVoiceDictation({
attachments,
clearAttachments,
isSendLocked,
onAutoSubmit,
onSend,
resetTextarea,
selectedPersonaId,
@@ -0,0 +1,10 @@
export function getChatInputPlaceholder(
t: (key: string, options?: { agent: string }) => string,
agent: string,
isRecording: boolean,
isTranscribing: boolean,
): string {
if (isRecording) return t("toolbar.voiceInputRecording");
if (isTranscribing) return t("toolbar.voiceInputTranscribing");
return t("input.placeholder", { agent });
}
@@ -0,0 +1,22 @@
import type { ChatAttachmentDraft } from "@/shared/types/messages";
import type { ChatSkillDraft } from "../types";
export function attachmentSnapshotsMatch(
current: ChatAttachmentDraft[],
snapshot: ChatAttachmentDraft[],
) {
return (
current.length === snapshot.length &&
current.every((attachment, index) => attachment.id === snapshot[index]?.id)
);
}
export function skillDraftSnapshotsMatch(
current: ChatSkillDraft[],
snapshot: ChatSkillDraft[],
) {
return (
current.length === snapshot.length &&
current.every((skill, index) => skill.id === snapshot[index]?.id)
);
}
@@ -0,0 +1,68 @@
import {
formatSkillInstructionPrompt,
type SkillCommandMatch,
} from "@/features/skills/lib/skillChatPrompt";
import type { MessageChip } from "@/shared/types/messages";
import type { ChatSendOptions, ChatSkillDraft } from "../types";
interface SkillSendPayload {
messageText: string;
sendOptions?: ChatSendOptions;
}
export function buildSkillSendPayload(
submittedText: string,
submittedSkills: ChatSkillDraft[],
slashSkillCommand: SkillCommandMatch | null,
): SkillSendPayload {
const chips =
submittedSkills.length > 0
? submittedSkills.map((skill) => ({
label: skill.name,
type: "skill" as const,
}))
: slashSkillCommand
? [{ label: slashSkillCommand.skill.name, type: "skill" as const }]
: [];
if (chips.length === 0) {
return { messageText: submittedText };
}
const skillsForPrompt =
submittedSkills.length > 0
? submittedSkills
: slashSkillCommand
? [slashSkillCommand.skill]
: [];
const assistantPrompt = formatSkillInstructionPrompt(skillsForPrompt);
const displayText =
submittedSkills.length > 0
? submittedText.trim()
: (slashSkillCommand?.displayText ?? "");
return {
messageText: displayText || " ",
sendOptions: {
chips,
displayText,
assistantPrompt,
},
};
}
export function buildSkillRetryOptions(
text: string,
chips?: MessageChip[],
): ChatSendOptions | undefined {
const skillChips = chips?.filter((chip) => chip.type === "skill") ?? [];
if (skillChips.length === 0) return undefined;
return {
displayText: text,
assistantPrompt: formatSkillInstructionPrompt(
skillChips.map((chip) => ({ name: chip.label })),
),
chips: skillChips,
};
}
@@ -0,0 +1,50 @@
import type { SkillCommandMatch } from "@/features/skills/lib/skillChatPrompt";
import { isPromiseLike } from "@/shared/lib/isPromiseLike";
import type { ChatAttachmentDraft } from "@/shared/types/messages";
import type { ChatInputProps, ChatSkillDraft } from "../types";
import { buildSkillSendPayload } from "./skillSendPayload";
interface SubmitComposerMessageOptions {
text: string;
attachments: ChatAttachmentDraft[];
skills: ChatSkillDraft[];
selectedPersonaId?: string | null;
onSend: ChatInputProps["onSend"];
resolveSkillSlashCommand: (
message: string,
) => SkillCommandMatch<ChatSkillDraft> | null;
}
export async function submitComposerMessage({
text,
attachments,
skills,
selectedPersonaId,
onSend,
resolveSkillSlashCommand,
}: SubmitComposerMessageOptions) {
const slashSkillCommand =
skills.length === 0 ? resolveSkillSlashCommand(text) : null;
const { messageText, sendOptions } = buildSkillSendPayload(
text,
skills,
slashSkillCommand,
);
const submittedAttachments = attachments.length > 0 ? attachments : undefined;
const sendResult = sendOptions
? onSend(
messageText,
selectedPersonaId ?? undefined,
submittedAttachments,
sendOptions,
)
: onSend(
messageText.trim(),
selectedPersonaId ?? undefined,
submittedAttachments,
);
const accepted = isPromiseLike<boolean>(sendResult)
? await sendResult
: sendResult;
return accepted !== false;
}
@@ -25,6 +25,7 @@ describe("chatStore", () => {
sessionStateById: {},
queuedMessageBySession: {},
draftsBySession: {},
skillDraftsBySession: {},
activeSessionId: null,
isConnected: false,
});
@@ -144,6 +145,18 @@ describe("chatStore", () => {
expect(useChatStore.getState().draftsBySession.s1).toBeUndefined();
});
it("stores and clears skill draft chips per session", () => {
const store = useChatStore.getState();
store.setSkillDrafts("s1", [{ id: "skill-1", name: "code-review" }]);
expect(useChatStore.getState().skillDraftsBySession.s1).toEqual([
{ id: "skill-1", name: "code-review" },
]);
store.clearSkillDrafts("s1");
expect(useChatStore.getState().skillDraftsBySession.s1).toBeUndefined();
});
it("removes session data during cleanup including queued messages and drafts", () => {
const store = useChatStore.getState();
@@ -151,6 +164,7 @@ describe("chatStore", () => {
store.setChatState("s1", "streaming");
store.enqueueMessage("s1", { text: "queued" });
store.setDraft("s1", "draft text");
store.setSkillDrafts("s1", [{ id: "skill-1", name: "code-review" }]);
store.setActiveSession("s1");
store.cleanupSession("s1");
@@ -158,6 +172,7 @@ describe("chatStore", () => {
expect(store.sessionStateById.s1).toBeUndefined();
expect(store.queuedMessageBySession.s1).toBeUndefined();
expect(store.draftsBySession.s1).toBeUndefined();
expect(useChatStore.getState().skillDraftsBySession.s1).toBeUndefined();
expect(store.activeSessionId).toBeNull();
});
@@ -187,6 +202,7 @@ describe("chatStore draft localStorage persistence", () => {
sessionStateById: {},
queuedMessageBySession: {},
draftsBySession: {},
skillDraftsBySession: {},
activeSessionId: null,
isConnected: false,
});
@@ -245,6 +261,7 @@ describe("chatStore session loading state", () => {
sessionStateById: {},
queuedMessageBySession: {},
draftsBySession: {},
skillDraftsBySession: {},
activeSessionId: null,
isConnected: false,
loadingSessionIds: new Set<string>(),
+34 -31
View File
@@ -14,36 +14,8 @@ import {
INITIAL_SESSION_CHAT_RUNTIME,
INITIAL_TOKEN_STATE,
} from "@/shared/types/chat";
const DRAFTS_STORAGE_KEY = "goose:chat-drafts";
function loadCachedDrafts(): Record<string, string> {
if (typeof window === "undefined") return {};
try {
const stored = window.localStorage.getItem(DRAFTS_STORAGE_KEY);
if (!stored) return {};
const parsed = JSON.parse(stored);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function persistDrafts(drafts: Record<string, string>): void {
if (typeof window === "undefined") return;
try {
const nonEmpty = Object.fromEntries(
Object.entries(drafts).filter(([, v]) => v.length > 0),
);
if (Object.keys(nonEmpty).length === 0) {
window.localStorage.removeItem(DRAFTS_STORAGE_KEY);
} else {
window.localStorage.setItem(DRAFTS_STORAGE_KEY, JSON.stringify(nonEmpty));
}
} catch {
// localStorage may be unavailable
}
}
import type { ChatSendOptions, ChatSkillDraft } from "../types";
import { loadCachedDrafts, persistDrafts } from "./draftPersistence";
function createInitialSessionRuntime(): SessionChatRuntime {
return {
@@ -56,6 +28,7 @@ export interface QueuedMessage {
text: string;
personaId?: string;
attachments?: ChatAttachmentDraft[];
sendOptions?: ChatSendOptions;
}
export interface ScrollTargetMessage {
@@ -68,6 +41,7 @@ interface ChatStoreState {
sessionStateById: Record<string, SessionChatRuntime>;
queuedMessageBySession: Record<string, QueuedMessage>;
draftsBySession: Record<string, string>;
skillDraftsBySession: Record<string, ChatSkillDraft[]>;
activeSessionId: string | null;
isConnected: boolean;
loadingSessionIds: Set<string>;
@@ -113,6 +87,8 @@ interface ChatStoreActions {
dismissQueuedMessage: (sessionId: string) => void;
setDraft: (sessionId: string, text: string) => void;
clearDraft: (sessionId: string) => void;
setSkillDrafts: (sessionId: string, skills: ChatSkillDraft[]) => void;
clearSkillDrafts: (sessionId: string) => void;
setSessionLoading: (sessionId: string, loading: boolean) => void;
setScrollTargetMessage: (
sessionId: string,
@@ -131,6 +107,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
sessionStateById: {},
queuedMessageBySession: {},
draftsBySession: loadCachedDrafts(),
skillDraftsBySession: {},
activeSessionId: null,
isConnected: false,
loadingSessionIds: new Set<string>(),
@@ -450,6 +427,27 @@ export const useChatStore = create<ChatStore>((set, get) => ({
persistDrafts(get().draftsBySession);
},
setSkillDrafts: (sessionId, skills) =>
set((state) => {
if (skills.length === 0) {
const { [sessionId]: _, ...rest } = state.skillDraftsBySession;
return { skillDraftsBySession: rest };
}
return {
skillDraftsBySession: {
...state.skillDraftsBySession,
[sessionId]: skills,
},
};
}),
clearSkillDrafts: (sessionId) =>
set((state) => {
const { [sessionId]: _, ...rest } = state.skillDraftsBySession;
return { skillDraftsBySession: rest };
}),
// Session loading (replay)
setSessionLoading: (sessionId, loading) =>
set((state) => {
@@ -495,13 +493,18 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const { [sessionId]: ___, ...remainingQueued } =
state.queuedMessageBySession;
const { [sessionId]: ____, ...remainingDrafts } = state.draftsBySession;
const { [sessionId]: _____, ...remainingTargets } =
const { [sessionId]: removedSkillDrafts, ...remainingSkillDrafts } =
state.skillDraftsBySession;
void removedSkillDrafts;
const { [sessionId]: removedTarget, ...remainingTargets } =
state.scrollTargetMessageBySession;
void removedTarget;
return {
messagesBySession: rest,
sessionStateById: remainingSessionState,
queuedMessageBySession: remainingQueued,
draftsBySession: remainingDrafts,
skillDraftsBySession: remainingSkillDrafts,
scrollTargetMessageBySession: remainingTargets,
activeSessionId:
state.activeSessionId === sessionId ? null : state.activeSessionId,
@@ -0,0 +1,29 @@
const DRAFTS_STORAGE_KEY = "goose:chat-drafts";
export function loadCachedDrafts(): Record<string, string> {
if (typeof window === "undefined") return {};
try {
const stored = window.localStorage.getItem(DRAFTS_STORAGE_KEY);
if (!stored) return {};
const parsed = JSON.parse(stored);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
export function persistDrafts(drafts: Record<string, string>): void {
if (typeof window === "undefined") return;
try {
const nonEmpty = Object.fromEntries(
Object.entries(drafts).filter(([, v]) => v.length > 0),
);
if (Object.keys(nonEmpty).length === 0) {
window.localStorage.removeItem(DRAFTS_STORAGE_KEY);
} else {
window.localStorage.setItem(DRAFTS_STORAGE_KEY, JSON.stringify(nonEmpty));
}
} catch {
// localStorage may be unavailable
}
}
+17 -2
View File
@@ -1,6 +1,6 @@
import type { AcpProvider } from "@/shared/api/acp";
import type { Persona } from "@/shared/types/agents";
import type { ChatAttachmentDraft } from "@/shared/types/messages";
import type { ChatAttachmentDraft, MessageChip } from "@/shared/types/messages";
export interface ModelOption {
id: string;
@@ -21,11 +21,25 @@ export interface ProjectOption {
color?: string | null;
}
export interface ChatSkillDraft {
id: string;
name: string;
description?: string;
sourceLabel?: string;
}
export interface ChatSendOptions {
displayText?: string;
assistantPrompt?: string;
chips?: MessageChip[];
}
export interface ChatInputProps {
onSend: (
text: string,
personaId?: string,
attachments?: ChatAttachmentDraft[],
options?: ChatSendOptions,
) => boolean | Promise<boolean>;
onStop?: () => void;
isStreaming?: boolean;
@@ -34,11 +48,12 @@ export interface ChatInputProps {
onDismissQueue?: () => void;
initialValue?: string;
onDraftChange?: (text: string) => void;
selectedSkills?: ChatSkillDraft[];
onSkillsChange?: (skills: ChatSkillDraft[]) => void;
className?: string;
personas?: Persona[];
selectedPersonaId?: string | null;
onPersonaChange?: (personaId: string | null) => void;
onCreatePersona?: () => void;
providers?: AcpProvider[];
providersLoading?: boolean;
selectedProvider?: string;
+98 -117
View File
@@ -1,37 +1,27 @@
import { useState, useRef, useCallback, useEffect, useMemo } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
attachmentSnapshotsMatch,
skillDraftSnapshotsMatch,
} from "../lib/chatInputSnapshots";
import { getChatInputPlaceholder } from "../lib/chatInputPlaceholder";
import { cn } from "@/shared/lib/cn";
import { isPromiseLike } from "@/shared/lib/isPromiseLike";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverAnchor } from "@/shared/ui/popover";
import { MentionAutocomplete } from "./MentionAutocomplete";
import { useMentionHandlers } from "../hooks/useMentionHandlers";
import { ChatInputToolbar } from "./ChatInputToolbar";
import { formatProviderLabel } from "@/shared/ui/icons/ProviderIcons";
import { TooltipProvider } from "@/shared/ui/tooltip";
import { PersonaAvatar } from "./PersonaPicker";
import { useAttachmentDropTarget } from "../hooks/useAttachmentDropTarget";
import {
normalizeDialogSelection,
useChatInputAttachments,
} from "../hooks/useChatInputAttachments";
import { useChatInputAttachments } from "../hooks/useChatInputAttachments";
import { useChatInputFilePicker } from "../hooks/useChatInputFilePicker";
import { ChatInputAttachments } from "./ChatInputAttachments";
import { ChatInputSelectionChips } from "./ChatInputSelectionChips";
import { useChatInputSubmit } from "../hooks/useChatInputSubmit";
import { useVoiceDictation } from "../hooks/useVoiceDictation";
import type { ChatAttachmentDraft } from "@/shared/types/messages";
import type { ChatInputProps } from "../types";
function attachmentSnapshotsMatch(
current: ChatAttachmentDraft[],
snapshot: ChatAttachmentDraft[],
) {
return (
current.length === snapshot.length &&
current.every((attachment, index) => attachment.id === snapshot[index]?.id)
);
}
import type { ChatInputProps, ChatSkillDraft } from "../types";
export function ChatInput({
onSend,
@@ -42,11 +32,12 @@ export function ChatInput({
onDismissQueue,
initialValue = "",
onDraftChange,
selectedSkills: selectedSkillsProp,
onSkillsChange,
className,
personas = [],
selectedPersonaId = null,
onPersonaChange,
onCreatePersona,
providers = [],
providersLoading = false,
selectedProvider = "goose",
@@ -71,6 +62,11 @@ export function ChatInput({
}: ChatInputProps) {
const { t } = useTranslation("chat");
const [text, setTextRaw] = useState(initialValue);
const [internalSelectedSkills, setInternalSelectedSkills] = useState<
ChatSkillDraft[]
>([]);
const selectedSkills = selectedSkillsProp ?? internalSelectedSkills;
const setSelectedSkills = onSkillsChange ?? setInternalSelectedSkills;
const textRef = useRef(initialValue);
useEffect(() => {
setTextRaw(initialValue);
@@ -96,6 +92,8 @@ export function ChatInput({
} = useChatInputAttachments();
const attachmentsRef = useRef(attachments);
attachmentsRef.current = attachments;
const selectedSkillsRef = useRef(selectedSkills);
selectedSkillsRef.current = selectedSkills;
const resetTextarea = useCallback(() => {
if (textareaRef.current) {
@@ -105,17 +103,6 @@ export function ChatInput({
const hasQueuedMessage = queuedMessage !== null;
const dictation = useVoiceDictation({
text,
setText,
attachments,
clearAttachments,
selectedPersonaId,
onSend,
resetTextarea,
isSendLocked: hasQueuedMessage || disabled,
});
const activePersona = useMemo(
() => personas.find((persona) => persona.id === selectedPersonaId) ?? null,
[personas, selectedPersonaId],
@@ -129,20 +116,37 @@ export function ChatInput({
const stickyPersona = activePersona;
const canSend =
(text.trim().length > 0 || attachments.length > 0) &&
(text.trim().length > 0 ||
attachments.length > 0 ||
selectedSkills.length > 0) &&
!hasQueuedMessage &&
!disabled;
const handleSkillMentionAdded = useCallback(
(skill: (typeof selectedSkills)[number]) => {
if (
selectedSkills.some((selectedSkill) => selectedSkill.id === skill.id)
) {
return;
}
setSelectedSkills([...selectedSkills, skill]);
},
[selectedSkills, setSelectedSkills],
);
const {
mentionOpen,
mentionSelectedIndex,
filteredPersonas,
filteredSkills,
filteredFiles,
resolveSkillSlashCommand,
detectMention,
closeMention,
navigateMention,
confirmMention,
handlePersonaMentionSelect,
handleSkillMentionSelect,
handleFileMentionSelect,
handleMentionConfirm,
} = useMentionHandlers({
@@ -152,6 +156,7 @@ export function ChatInput({
setText,
textareaRef,
onPersonaChange,
onSkillMentionSelect: handleSkillMentionAdded,
});
useEffect(() => {
@@ -171,21 +176,34 @@ export function ChatInput({
useEffect(() => textareaRef.current?.focus(), []);
const { submitChatInputMessage, handleVoiceAutoSubmit } = useChatInputSubmit({
attachmentsRef,
selectedSkillsRef,
selectedPersonaId,
onSend,
setSelectedSkills,
resolveSkillSlashCommand,
});
const dictation = useVoiceDictation({
text,
setText,
attachments,
clearAttachments,
selectedPersonaId,
onSend,
onAutoSubmit: handleVoiceAutoSubmit,
resetTextarea,
isSendLocked: hasQueuedMessage || disabled,
});
const handleSend = useCallback(async () => {
if (!canSend) {
return;
}
// If recording, stop without waiting for final flush and send what's
// already transcribed into the textarea. This makes Send a single click
// even while the mic is hot; any in-flight audio after the user clicked
// Send is intentionally dropped.
//
// Also handles the edge case where the user clicks Send while a
// getUserMedia startup is still pending (isRecording is still false but
// a stream is about to be acquired) — stopRecording sets the internal
// cancel flag so the pending startup tears itself down instead of
// leaving the OS mic indicator on.
// Stop without flushing so Send uses the text already in the composer.
// This also cancels an in-flight microphone startup.
if (
dictation.isRecording ||
dictation.isTranscribing ||
@@ -195,25 +213,25 @@ export function ChatInput({
}
const submittedText = text;
const submittedSkills = selectedSkills;
const submittedAttachments = attachments;
const sendResult = onSend(
submittedText.trim(),
selectedPersonaId ?? undefined,
submittedAttachments.length > 0 ? submittedAttachments : undefined,
const accepted = await submitChatInputMessage(
submittedText,
submittedAttachments,
submittedSkills,
);
const accepted = isPromiseLike<boolean>(sendResult)
? await sendResult
: sendResult;
if (accepted === false) {
if (!accepted) {
return;
}
const draftStillMatchesSubmission =
textRef.current === submittedText &&
skillDraftSnapshotsMatch(selectedSkillsRef.current, submittedSkills) &&
attachmentSnapshotsMatch(attachmentsRef.current, submittedAttachments);
if (!draftStillMatchesSubmission) {
return;
}
setText("");
setSelectedSkills([]);
clearAttachments();
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
@@ -223,9 +241,10 @@ export function ChatInput({
canSend,
clearAttachments,
dictation,
onSend,
selectedPersonaId,
selectedSkills,
setSelectedSkills,
setText,
submitChatInputMessage,
text,
]);
@@ -302,39 +321,10 @@ export function ChatInput({
void addPathAttachments(paths);
},
});
const handleAttachFiles = useCallback(async () => {
if (disabled) {
return;
}
try {
const selected = await open({
title: t("attachments.chooseFilesDialogTitle"),
multiple: true,
});
await addPathAttachments(normalizeDialogSelection(selected));
} catch {
// Dialog plugin may be unavailable in some environments.
}
}, [addPathAttachments, disabled, t]);
const handleAttachFolders = useCallback(async () => {
if (disabled) {
return;
}
try {
const selected = await open({
directory: true,
title: t("attachments.chooseFoldersDialogTitle"),
multiple: true,
});
await addPathAttachments(normalizeDialogSelection(selected));
} catch {
// Dialog plugin may be unavailable in some environments.
}
}, [addPathAttachments, disabled, t]);
const { handleAttachFiles, handleAttachFolders } = useChatInputFilePicker({
disabled,
addPathAttachments,
});
const providerDisplayName =
providers.find((provider) => provider.id === selectedProvider)?.label ??
@@ -352,14 +342,24 @@ export function ChatInput({
);
return selectedModel?.displayName ?? selectedModel?.name ?? currentModelId;
}, [availableModels, currentModel, currentModelId]);
const effectivePlaceholder = t("input.placeholder", {
agent: agentDisplayName,
});
const inputPlaceholder = getChatInputPlaceholder(
t,
agentDisplayName,
dictation.isRecording,
dictation.isTranscribing,
);
const handleClearStickyPersona = useCallback(() => {
onPersonaChange?.(null);
}, [onPersonaChange]);
const handleRemoveSkill = useCallback(
(skillId: string) => {
setSelectedSkills(selectedSkills.filter((skill) => skill.id !== skillId));
},
[selectedSkills, setSelectedSkills],
);
return (
<TooltipProvider delayDuration={300}>
<div className={cn("px-4 pb-6 pt-2", className)}>
@@ -390,9 +390,11 @@ export function ChatInput({
<MentionAutocomplete
filteredPersonas={filteredPersonas}
filteredSkills={filteredSkills}
filteredFiles={filteredFiles}
isOpen={mentionOpen}
onSelectPersona={handlePersonaMentionSelect}
onSelectSkill={handleSkillMentionSelect}
onSelectFile={handleFileMentionSelect}
onClose={closeMention}
selectedIndex={mentionSelectedIndex}
@@ -403,24 +405,12 @@ export function ChatInput({
onRemove={removeAttachment}
/>
{stickyPersona && (
<div className="mb-2 flex items-center gap-1.5">
<span className="inline-flex items-center gap-1.5 rounded-full bg-brand/10 px-2.5 py-1 text-[11px] font-medium text-brand">
<PersonaAvatar persona={stickyPersona} size="sm" />
<span>@{stickyPersona.displayName}</span>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="ml-0.5 size-auto p-0 opacity-60 hover:bg-transparent hover:opacity-100"
onClick={handleClearStickyPersona}
aria-label={t("persona.clearActive")}
>
<X className="size-3" />
</Button>
</span>
</div>
)}
<ChatInputSelectionChips
persona={stickyPersona}
skills={selectedSkills}
onClearPersona={handleClearStickyPersona}
onRemoveSkill={handleRemoveSkill}
/>
{queuedMessage && (
<div className="mb-2 flex items-center gap-2 rounded-lg bg-muted/60 px-3 py-1.5">
@@ -445,13 +435,7 @@ export function ChatInput({
onChange={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={
dictation.isRecording
? t("toolbar.voiceInputRecording")
: dictation.isTranscribing
? t("toolbar.voiceInputTranscribing")
: effectivePlaceholder
}
placeholder={inputPlaceholder}
disabled={disabled}
rows={1}
className="mb-3 min-h-[36px] max-h-[200px] w-full resize-none bg-transparent px-1 text-[14px] leading-relaxed text-foreground placeholder:font-light placeholder:text-muted-foreground/60 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:opacity-60"
@@ -460,10 +444,7 @@ export function ChatInput({
</PopoverAnchor>
<ChatInputToolbar
personas={personas}
selectedPersonaId={selectedPersonaId}
onPersonaChange={onPersonaChange}
onCreatePersona={onCreatePersona}
providers={providers}
providersLoading={providersLoading}
selectedProvider={selectedProvider}
@@ -1,5 +1,6 @@
import { useState } from "react";
import { FileText, FolderClosed, X } from "lucide-react";
import { X } from "lucide-react";
import { IconFileText } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { ImageLightbox } from "@/shared/ui/ImageLightbox";
import type {
@@ -8,6 +9,7 @@ import type {
ChatFileAttachmentDraft,
ChatImageAttachmentDraft,
} from "@/shared/types/messages";
import { ComposerChip } from "./ComposerChip";
function DraftImageAttachment({
attachment,
@@ -64,24 +66,16 @@ function DraftPathAttachment({
onRemove: (id: string) => void;
}) {
const { t } = useTranslation("chat");
const Icon = attachment.kind === "directory" ? FolderClosed : FileText;
return (
<div
className="group relative flex items-center gap-2 rounded-full border border-border bg-muted/40 px-3 py-1.5 pr-8 text-xs text-foreground"
<ComposerChip
tone="file"
label={attachment.name}
leading={<IconFileText className="size-3.5" />}
title={attachment.path ?? attachment.name}
>
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="max-w-44 truncate">{attachment.name}</span>
<button
type="button"
onClick={() => onRemove(attachment.id)}
className="absolute right-2 flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100 hover:text-foreground"
aria-label={t("attachments.remove")}
>
<X className="h-3 w-3" />
</button>
</div>
onRemove={() => onRemove(attachment.id)}
removeLabel={t("attachments.remove")}
/>
);
}
@@ -97,7 +91,7 @@ export function ChatInputAttachments({
}
return (
<div className="mb-2 flex flex-wrap gap-2">
<div className="mb-2 flex flex-wrap items-center gap-2">
{attachments.map((attachment, index) =>
attachment.kind === "image" ? (
<DraftImageAttachment
@@ -0,0 +1,52 @@
import { IconStack2 } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import type { Persona } from "@/shared/types/agents";
import type { ChatSkillDraft } from "../types";
import { ComposerChip } from "./ComposerChip";
import { PersonaAvatar } from "./PersonaPicker";
interface ChatInputSelectionChipsProps {
persona: Persona | null;
skills: ChatSkillDraft[];
onClearPersona: () => void;
onRemoveSkill: (skillId: string) => void;
}
export function ChatInputSelectionChips({
persona,
skills,
onClearPersona,
onRemoveSkill,
}: ChatInputSelectionChipsProps) {
const { t } = useTranslation("chat");
if (!persona && skills.length === 0) {
return null;
}
return (
<div className="mb-2 flex flex-wrap items-center gap-2">
{persona && (
<ComposerChip
tone="agent"
label={persona.displayName}
leading={<PersonaAvatar persona={persona} size="xs" />}
onRemove={onClearPersona}
removeLabel={t("persona.clearActive")}
/>
)}
{skills.map((skill) => (
<ComposerChip
key={skill.id}
tone="skill"
label={skill.name}
leading={<IconStack2 className="size-3.5" />}
onRemove={() => onRemoveSkill(skill.id)}
removeLabel={t("skill.clearSelected", {
skill: skill.name,
})}
/>
))}
</div>
);
}
@@ -12,11 +12,9 @@ import { useTranslation } from "react-i18next";
import { useLocaleFormatting } from "@/shared/i18n";
import { IconLibraryPlusFilled } from "@tabler/icons-react";
import type { AcpProvider } from "@/shared/api/acp";
import type { Persona } from "@/shared/types/agents";
import { cn } from "@/shared/lib/cn";
import { ChatInputSelector } from "./ChatInputSelector";
import { ContextRing } from "./ContextRing";
import { PersonaPicker } from "./PersonaPicker";
import type { ProjectOption } from "../types";
import { Button } from "@/shared/ui/button";
import {
@@ -52,11 +50,7 @@ function ProjectDot({ color }: { color?: string | null }) {
}
interface ChatInputToolbarProps {
// Personas
personas: Persona[];
selectedPersonaId: string | null;
onPersonaChange?: (personaId: string | null) => void;
onCreatePersona?: () => void;
// Provider
providers: AcpProvider[];
providersLoading?: boolean;
@@ -103,10 +97,7 @@ interface ChatInputToolbarProps {
}
export function ChatInputToolbar({
personas,
selectedPersonaId,
onPersonaChange,
onCreatePersona,
providers,
providersLoading,
selectedProvider,
@@ -298,16 +289,6 @@ export function ChatInputToolbar({
{/* Right side: actions */}
<div className="flex items-center">
<div className="flex items-center gap-px">
{personas.length > 0 && (
<PersonaPicker
personas={personas}
selectedPersonaId={selectedPersonaId}
onPersonaChange={(id) => onPersonaChange?.(id)}
onCreatePersona={onCreatePersona}
triggerVariant="icon"
/>
)}
{showContextUsage && (
<Popover
open={isContextPopoverOpen}
@@ -441,7 +422,7 @@ export function ChatInputToolbar({
voiceTranscribing && "animate-pulse",
)}
>
<Mic />
<Mic className="h-4 w-4" />
</Button>
</span>
</TooltipTrigger>
+2 -1
View File
@@ -119,6 +119,8 @@ export function ChatView({
onDismissQueue={controller.queue.dismiss}
initialValue={controller.draftValue}
onDraftChange={controller.handleDraftChange}
selectedSkills={controller.selectedSkills}
onSkillsChange={controller.handleSkillsChange}
onStop={controller.stopStreaming}
isStreaming={
controller.chatState === "streaming" ||
@@ -127,7 +129,6 @@ export function ChatView({
personas={controller.personas}
selectedPersonaId={controller.selectedPersonaId}
onPersonaChange={controller.handlePersonaChange}
onCreatePersona={controller.handleCreatePersona}
providers={controller.pickerAgents}
providersLoading={controller.providersLoading}
selectedProvider={controller.selectedProvider}
@@ -0,0 +1,61 @@
import { X } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/shared/lib/cn";
type ComposerChipTone = "file" | "agent" | "skill" | "automation";
const toneClasses: Record<ComposerChipTone, string> = {
file: "bg-gray-100/70 text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700",
agent:
"bg-blue-100/20 text-blue-700 hover:bg-blue-100/30 dark:bg-blue-100/10 dark:text-blue-100 dark:hover:bg-blue-100/15",
skill:
"bg-yellow-100/25 text-yellow-700 hover:bg-yellow-100/35 dark:bg-yellow-100/10 dark:text-yellow-100 dark:hover:bg-yellow-100/15",
automation:
"bg-green-100/20 text-green-700 hover:bg-green-100/30 dark:bg-green-100/10 dark:text-green-100 dark:hover:bg-green-100/15",
};
interface ComposerChipProps {
tone: ComposerChipTone;
label: string;
removeLabel: string;
onRemove: () => void;
leading?: ReactNode;
title?: string;
className?: string;
}
export function ComposerChip({
tone,
label,
removeLabel,
onRemove,
leading,
title,
className,
}: ComposerChipProps) {
return (
<span
className={cn(
"group inline-flex h-6 max-w-64 items-center gap-1.5 rounded-full pl-[9px] pr-2 text-xs font-normal transition-colors",
toneClasses[tone],
className,
)}
title={title ?? label}
>
<button
type="button"
onClick={onRemove}
className="group/remove relative flex size-3.5 shrink-0 items-center justify-center rounded-full text-current focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={removeLabel}
>
{leading ? (
<span className="flex items-center justify-center opacity-100 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
{leading}
</span>
) : null}
<X className="absolute size-3.5 opacity-0 transition-opacity group-hover:opacity-45 group-focus-within:opacity-45 group-hover/remove:opacity-100 group-focus-visible/remove:opacity-100" />
</button>
<span className="min-w-0 truncate">{label}</span>
</span>
);
}
@@ -1,54 +1,33 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Sparkles, User } from "lucide-react";
import { Sparkles, User, Zap } from "lucide-react";
import { IconFile, IconFolder } from "@tabler/icons-react";
import { cn } from "@/shared/lib/cn";
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
import { PopoverContent } from "@/shared/ui/popover";
import type { Persona } from "@/shared/types/agents";
// ---------------------------------------------------------------------------
// Fuzzy subsequence matcher (fzf-style)
// ---------------------------------------------------------------------------
/** Returns true when every character in `query` appears in `target` in order. */
export function fuzzyMatch(query: string, target: string): boolean {
let qi = 0;
for (let ti = 0; ti < target.length && qi < query.length; ti++) {
if (query[qi] === target[ti]) qi++;
}
return qi === query.length;
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface FileMentionItem {
/** Absolute path used when inserting into the message. */
resolvedPath: string;
/** Shortened display path (e.g. ~/project/src/foo.ts). */
displayPath: string;
/** Just the filename portion. */
filename: string;
kind: "file" | "folder" | "path";
}
export type MentionItem =
| { type: "persona"; persona: Persona }
| { type: "file"; file: FileMentionItem };
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
import type {
FileMentionItem,
MentionItem,
SkillMentionItem,
} from "./mentionDetection";
export { fuzzyMatch, useMentionDetection } from "./mentionDetection";
export type {
FileMentionItem,
MentionItem,
SkillMentionItem,
} from "./mentionDetection";
interface MentionAutocompleteProps {
/** Pre-filtered personas from the hook. */
filteredPersonas: Persona[];
/** Pre-filtered skills from the hook. */
filteredSkills?: SkillMentionItem[];
/** Pre-filtered files from the hook. */
filteredFiles?: FileMentionItem[];
isOpen: boolean;
onSelectPersona: (persona: Persona) => void;
onSelectSkill?: (skill: SkillMentionItem) => void;
onSelectFile?: (file: FileMentionItem) => void;
onClose?: (() => void) | undefined;
selectedIndex?: number;
@@ -56,9 +35,11 @@ interface MentionAutocompleteProps {
export function MentionAutocomplete({
filteredPersonas,
filteredSkills = [],
filteredFiles = [],
isOpen,
onSelectPersona,
onSelectSkill,
onSelectFile,
selectedIndex: controlledIndex,
}: MentionAutocompleteProps) {
@@ -80,21 +61,26 @@ export function MentionAutocomplete({
type: "persona" as const,
persona: p,
}));
for (const skill of filteredSkills) {
result.push({ type: "skill" as const, skill });
}
for (const f of filteredFiles) {
result.push({ type: "file" as const, file: f });
}
return result;
}, [filteredPersonas, filteredFiles]);
}, [filteredPersonas, filteredSkills, filteredFiles]);
const handleSelect = useCallback(
(item: MentionItem) => {
if (item.type === "persona") {
onSelectPersona(item.persona);
} else if (item.type === "skill") {
onSelectSkill?.(item.skill);
} else {
onSelectFile?.(item.file);
}
},
[onSelectPersona, onSelectFile],
[onSelectPersona, onSelectSkill, onSelectFile],
);
if (!isOpen || items.length === 0) return null;
@@ -152,13 +138,53 @@ export function MentionAutocomplete({
</button>
))}
{filteredSkills.length > 0 && (
<div className="mt-1 px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{t("mention.skillsTitle")}
</div>
)}
{filteredSkills.map((skill, i) => {
const globalIndex = filteredPersonas.length + i;
return (
<button
key={skill.id}
ref={(el) => {
if (el) itemRefs.current.set(globalIndex, el);
else itemRefs.current.delete(globalIndex);
}}
type="button"
role="option"
aria-selected={globalIndex === selectedIndex}
className={cn(
"flex w-full items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors",
globalIndex === selectedIndex
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
onClick={() => handleSelect({ type: "skill", skill })}
onMouseEnter={() => setInternalIndex(globalIndex)}
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand/10 text-brand">
<Zap className="h-3.5 w-3.5" />
</div>
<div className="flex min-w-0 flex-col">
<span className="text-sm font-medium">{skill.name}</span>
<span className="truncate text-[10px] text-muted-foreground">
{skill.description || skill.sourceLabel}
</span>
</div>
</button>
);
})}
{filteredFiles.length > 0 && (
<div className="mt-1 px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{t("mention.filesTitle")}
</div>
)}
{filteredFiles.map((file, i) => {
const globalIndex = filteredPersonas.length + i;
const globalIndex =
filteredPersonas.length + filteredSkills.length + i;
return (
<button
key={file.resolvedPath}
@@ -232,149 +258,3 @@ function MentionAvatar({ persona }: { persona: Persona }) {
</div>
);
}
// ---------------------------------------------------------------------------
// Hook — mention detection + keyboard navigation
// ---------------------------------------------------------------------------
export function useMentionDetection(
personas: Persona[] = [],
files: FileMentionItem[] = [],
) {
const [mentionState, setMentionState] = useState<{
isOpen: boolean;
query: string;
startIndex: number;
selectedIndex: number;
}>({ isOpen: false, query: "", startIndex: -1, selectedIndex: 0 });
const { filteredPersonas, filteredFiles } = useMemo(() => {
if (!mentionState.isOpen) {
return { filteredPersonas: personas, filteredFiles: files };
}
const q = mentionState.query.toLowerCase();
if (!q) return { filteredPersonas: personas, filteredFiles: files };
return {
filteredPersonas: personas.filter((p) =>
fuzzyMatch(q, p.displayName.toLowerCase()),
),
filteredFiles: files.filter(
(f) =>
fuzzyMatch(q, f.filename.toLowerCase()) ||
fuzzyMatch(q, f.displayPath.toLowerCase()),
),
};
}, [personas, files, mentionState.isOpen, mentionState.query]);
const totalCount = filteredPersonas.length + filteredFiles.length;
const detectMention = useCallback(
(value: string, cursorPos: number) => {
const beforeCursor = value.slice(0, cursorPos);
const lastAt = beforeCursor.lastIndexOf("@");
if (lastAt === -1) {
if (mentionState.isOpen) {
setMentionState({
isOpen: false,
query: "",
startIndex: -1,
selectedIndex: 0,
});
}
return;
}
// @ must be at start of input or preceded by whitespace
if (lastAt > 0 && !/\s/.test(beforeCursor[lastAt - 1])) {
if (mentionState.isOpen) {
setMentionState({
isOpen: false,
query: "",
startIndex: -1,
selectedIndex: 0,
});
}
return;
}
const query = beforeCursor.slice(lastAt + 1);
// Close if there's a space after the query (mention completed) or too long
if (query.includes(" ") || query.length > 50) {
if (mentionState.isOpen) {
setMentionState({
isOpen: false,
query: "",
startIndex: -1,
selectedIndex: 0,
});
}
return;
}
setMentionState((prev) => ({
isOpen: true,
query,
startIndex: lastAt,
selectedIndex: prev.query !== query ? 0 : prev.selectedIndex,
}));
},
[mentionState.isOpen],
);
const closeMention = useCallback(() => {
setMentionState({
isOpen: false,
query: "",
startIndex: -1,
selectedIndex: 0,
});
}, []);
const navigateMention = useCallback(
(direction: "up" | "down"): boolean => {
if (!mentionState.isOpen || totalCount === 0) return false;
setMentionState((prev) => {
const delta = direction === "down" ? 1 : -1;
const next = (prev.selectedIndex + delta + totalCount) % totalCount;
return { ...prev, selectedIndex: next };
});
return true;
},
[mentionState.isOpen, totalCount],
);
/** Confirm the currently highlighted item. Returns persona, file, or null. */
const confirmMention = useCallback((): MentionItem | null => {
if (!mentionState.isOpen || totalCount === 0) return null;
const idx = mentionState.selectedIndex;
if (idx < filteredPersonas.length) {
return { type: "persona", persona: filteredPersonas[idx] };
}
const fileIdx = idx - filteredPersonas.length;
if (fileIdx < filteredFiles.length) {
return { type: "file", file: filteredFiles[fileIdx] };
}
return null;
}, [
mentionState.isOpen,
mentionState.selectedIndex,
totalCount,
filteredPersonas,
filteredFiles,
]);
return {
mentionOpen: mentionState.isOpen,
mentionQuery: mentionState.query,
mentionStartIndex: mentionState.startIndex,
mentionSelectedIndex: mentionState.selectedIndex,
filteredPersonas,
filteredFiles,
detectMention,
closeMention,
navigateMention,
confirmMention,
};
}
@@ -1,13 +1,6 @@
import { memo } from "react";
import { useTranslation } from "react-i18next";
import {
Copy,
Check,
RotateCcw,
Pencil,
FileText,
FolderClosed,
} from "lucide-react";
import { Check, FileText, FolderClosed } from "lucide-react";
import { IconRobot } from "@tabler/icons-react";
import { openPath } from "@tauri-apps/plugin-opener";
import { cn } from "@/shared/lib/cn";
@@ -20,11 +13,7 @@ import {
formatProviderLabel,
} from "@/shared/ui/icons/ProviderIcons";
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
import {
MessageActions,
MessageAction,
MessageResponse,
} from "@/shared/ui/ai-elements/message";
import { MessageResponse } from "@/shared/ui/ai-elements/message";
import {
Reasoning,
ReasoningTrigger,
@@ -45,6 +34,8 @@ import type {
ReasoningContent as ReasoningContentType,
SystemNotificationContent,
} from "@/shared/types/messages";
import { MessageBubbleActions } from "./MessageBubbleActions";
import { MessageMetadataChip } from "./MessageMetadataChip";
function MessageAttachmentRow({
attachment,
@@ -202,6 +193,9 @@ function renderContentBlock(
case "text": {
const tc = content as TextContent;
if (isUserMessage) {
if (!tc.text.trim()) {
return null;
}
return (
<p key={`text-${index}`} className="whitespace-pre-wrap break-words">
{tc.text}
@@ -283,31 +277,6 @@ function renderContentBlock(
}
}
function CopyAction({
copied,
onCopy,
}: {
copied: boolean;
onCopy: () => void;
}) {
const { t } = useTranslation(["chat", "common"]);
return (
<MessageAction
size="xs"
variant="ghost-light"
className={cn(
"text-muted-foreground",
copied && "bg-accent text-foreground hover:bg-accent active:bg-accent",
)}
tooltip={copied ? t("message.copied") : t("common:actions.copy")}
onClick={onCopy}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</MessageAction>
);
}
export const MessageBubble = memo(function MessageBubble({
message,
isStreaming,
@@ -369,6 +338,7 @@ export const MessageBubble = memo(function MessageBubble({
(assistantDisplayName || personaAvatarUrl || assistantProviderIcon),
);
const messageAttachments = message.metadata?.attachments ?? [];
const messageChips = message.metadata?.chips ?? [];
const timestamp = (
<span
data-role="message-timestamp"
@@ -430,6 +400,16 @@ export const MessageBubble = memo(function MessageBubble({
)}
onClick={handleContentClick}
>
{isUser && messageChips.length > 0 && (
<div className="mb-1.5 flex flex-wrap gap-1.5">
{messageChips.map((chip) => (
<MessageMetadataChip
key={`${chip.type}-${chip.label}`}
chip={chip}
/>
))}
</div>
)}
{messageAttachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{messageAttachments.map((attachment) => (
@@ -480,38 +460,16 @@ export const MessageBubble = memo(function MessageBubble({
isUser ? "right-0" : "left-0",
)}
>
<MessageActions className="pt-0">
{isUser && timestamp}
{textContent && (
<CopyAction
copied={isCopyConfirmed}
onCopy={() => copyToClipboard(textContent)}
/>
)}
{!isUser && onRetryMessage && (
<MessageAction
size="xs"
variant="ghost-light"
className="text-muted-foreground"
tooltip={t("common:actions.retry")}
onClick={() => onRetryMessage(message.id)}
>
<RotateCcw className="size-3.5" />
</MessageAction>
)}
{isUser && onEditMessage && (
<MessageAction
size="xs"
variant="ghost-light"
className="text-muted-foreground"
tooltip={t("common:actions.edit")}
onClick={() => onEditMessage(message.id)}
>
<Pencil className="size-3.5" />
</MessageAction>
)}
{!isUser && timestamp}
</MessageActions>
<MessageBubbleActions
isUser={isUser}
messageId={message.id}
timestamp={timestamp}
textContent={textContent}
copied={isCopyConfirmed}
onCopy={() => copyToClipboard(textContent)}
onRetryMessage={onRetryMessage}
onEditMessage={onEditMessage}
/>
</div>
</div>
</div>
@@ -0,0 +1,77 @@
import type { ReactNode } from "react";
import { Check, Copy, Pencil, RotateCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/shared/lib/cn";
import { MessageAction, MessageActions } from "@/shared/ui/ai-elements/message";
interface MessageBubbleActionsProps {
isUser: boolean;
messageId: string;
timestamp: ReactNode;
textContent: string;
copied: boolean;
onCopy: () => void;
onRetryMessage?: (messageId: string) => void;
onEditMessage?: (messageId: string) => void;
}
export function MessageBubbleActions({
isUser,
messageId,
timestamp,
textContent,
copied,
onCopy,
onRetryMessage,
onEditMessage,
}: MessageBubbleActionsProps) {
const { t } = useTranslation(["chat", "common"]);
return (
<MessageActions className="pt-0">
{isUser && timestamp}
{textContent && (
<MessageAction
size="xs"
variant="ghost-light"
className={cn(
"text-muted-foreground",
copied &&
"bg-accent text-foreground hover:bg-accent active:bg-accent",
)}
tooltip={copied ? t("message.copied") : t("common:actions.copy")}
onClick={onCopy}
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</MessageAction>
)}
{!isUser && onRetryMessage && (
<MessageAction
size="xs"
variant="ghost-light"
className="text-muted-foreground"
tooltip={t("common:actions.retry")}
onClick={() => onRetryMessage(messageId)}
>
<RotateCcw className="size-3.5" />
</MessageAction>
)}
{isUser && onEditMessage && (
<MessageAction
size="xs"
variant="ghost-light"
className="text-muted-foreground"
tooltip={t("common:actions.edit")}
onClick={() => onEditMessage(messageId)}
>
<Pencil className="size-3.5" />
</MessageAction>
)}
{!isUser && timestamp}
</MessageActions>
);
}
@@ -0,0 +1,28 @@
import { IconStack2 } from "@tabler/icons-react";
import { cn } from "@/shared/lib/cn";
import type { MessageChip } from "@/shared/types/messages";
const messageChipClasses: Record<MessageChip["type"], string> = {
skill:
"bg-yellow-100/25 text-yellow-700 dark:bg-yellow-100/10 dark:text-yellow-100",
extension:
"bg-blue-100/20 text-blue-700 dark:bg-blue-100/10 dark:text-blue-100",
recipe:
"bg-green-100/20 text-green-700 dark:bg-green-100/10 dark:text-green-100",
};
export function MessageMetadataChip({ chip }: { chip: MessageChip }) {
const Icon = chip.type === "skill" ? IconStack2 : null;
return (
<span
className={cn(
"inline-flex h-6 max-w-64 items-center gap-1.5 rounded-full pl-[9px] pr-2 text-xs font-normal",
messageChipClasses[chip.type],
)}
>
{Icon ? <Icon className="size-3.5 shrink-0" /> : null}
<span className="min-w-0 truncate">{chip.label}</span>
</span>
);
}
@@ -194,10 +194,11 @@ function PersonaAvatar({
size = "sm",
}: {
persona?: Persona;
size?: "sm" | "md";
size?: "xs" | "sm" | "md";
}) {
const dim = size === "sm" ? "h-4 w-4" : "h-6 w-6";
const iconDim = size === "sm" ? "h-2.5 w-2.5" : "h-3.5 w-3.5";
const dim =
size === "xs" ? "h-3.5 w-3.5" : size === "sm" ? "h-4 w-4" : "h-6 w-6";
const iconDim = size === "md" ? "h-3.5 w-3.5" : "h-2.5 w-2.5";
const avatarSrc = useAvatarSrc(persona?.avatar);
if (avatarSrc) {
@@ -33,6 +33,10 @@ vi.mock("@/shared/api/system", () => ({
mockListFilesForMentions(roots, maxResults),
}));
vi.mock("@/features/skills/api/skills", () => ({
listSkills: vi.fn().mockResolvedValue([]),
}));
describe("ChatInput async send handling", () => {
beforeEach(() => {
mockListFilesForMentions.mockClear();
@@ -46,6 +46,10 @@ vi.mock("@/shared/api/system", () => ({
readImageAttachment: (path: string) => mockReadImageAttachment(path),
}));
vi.mock("@/features/skills/api/skills", () => ({
listSkills: vi.fn().mockResolvedValue([]),
}));
const mockOpenDialog = vi.fn();
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: (...args: unknown[]) => mockOpenDialog(...args),
@@ -0,0 +1,255 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChatInput } from "../ChatInput";
const mockVoiceDictation = {
isEnabled: true,
isRecording: false,
isTranscribing: false,
isStarting: vi.fn(() => false),
stopRecording: vi.fn(),
toggleRecording: vi.fn(),
};
let lastVoiceDictationOptions: {
onAutoSubmit?: (text: string) => boolean | Promise<boolean>;
} | null = null;
vi.mock("../../hooks/useVoiceDictation", () => ({
useVoiceDictation: (options: {
onAutoSubmit?: (text: string) => boolean | Promise<boolean>;
}) => {
lastVoiceDictationOptions = options;
return mockVoiceDictation;
},
}));
vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({
useAgentProviderStatus: () => ({
readyAgentIds: new Set(["goose", "claude-acp", "codex-acp"]),
loading: false,
refresh: vi.fn(),
}),
}));
vi.mock("@/shared/api/system", () => ({
listFilesForMentions: vi.fn().mockResolvedValue([]),
}));
type SkillMentionFixture = {
id: string;
name: string;
description: string;
sourceLabel: string;
};
const mockListSkills = vi.fn<
(projectDirs?: string[]) => Promise<SkillMentionFixture[]>
>(async () => []);
vi.mock("@/features/skills/api/skills", () => ({
listSkills: (projectDirs?: string[]) => mockListSkills(projectDirs),
}));
describe("ChatInput skill mentions", () => {
beforeEach(() => {
mockListSkills.mockClear();
mockListSkills.mockResolvedValue([]);
lastVoiceDictationOptions = null;
mockVoiceDictation.isStarting.mockReset();
mockVoiceDictation.isStarting.mockReturnValue(false);
});
it("shows skills in @mention results and creates a skill chip", async () => {
const user = userEvent.setup();
mockListSkills.mockResolvedValue([
{
id: "global:/skills/code-review",
name: "code-review",
description: "Reviews code",
sourceLabel: "Personal",
},
]);
render(<ChatInput onSend={vi.fn()} />);
await waitFor(() => {
expect(mockListSkills).toHaveBeenCalled();
});
const input = screen.getByRole("textbox");
await user.type(input, "@code");
expect(await screen.findByText("Skills")).toBeInTheDocument();
await user.click(
await screen.findByRole("option", { name: /code-review/i }),
);
expect(input).toHaveValue("");
expect(screen.getByText("code-review")).toBeInTheDocument();
});
it("expands selected skill chips before sending", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
render(
<ChatInput
onSend={onSend}
selectedSkills={[
{
id: "global:/skills/code-review",
name: "code-review",
description: "Reviews code",
sourceLabel: "Personal",
},
]}
onSkillsChange={vi.fn()}
/>,
);
await user.type(screen.getByRole("textbox"), "check this diff");
await user.keyboard("{Enter}");
expect(onSend).toHaveBeenCalledWith(
"check this diff",
undefined,
undefined,
{
assistantPrompt: "Use these skills for this request: code-review.",
chips: [{ label: "code-review", type: "skill" }],
displayText: "check this diff",
},
);
});
it("expands direct slash skill commands before sending", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
mockListSkills.mockResolvedValue([
{
id: "global:/skills/code-review",
name: "code-review",
description: "Reviews code",
sourceLabel: "Personal",
},
]);
render(<ChatInput onSend={onSend} />);
await waitFor(() => {
expect(mockListSkills).toHaveBeenCalled();
});
const input = screen.getByRole("textbox");
await user.type(input, "/code-review check this diff");
await user.keyboard("{Enter}");
expect(onSend).toHaveBeenCalledWith(
"check this diff",
undefined,
undefined,
{
assistantPrompt: "Use these skills for this request: code-review.",
chips: [{ label: "code-review", type: "skill" }],
displayText: "check this diff",
},
);
});
it("expands colon-qualified slash skill commands before sending", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
mockListSkills.mockResolvedValue([
{
id: "global:/skills/github",
name: "github:github",
description: "Works with GitHub",
sourceLabel: "Personal",
},
]);
render(<ChatInput onSend={onSend} />);
await waitFor(() => {
expect(mockListSkills).toHaveBeenCalled();
});
const input = screen.getByRole("textbox");
await user.type(input, "/github:github triage this PR");
await user.keyboard("{Enter}");
expect(onSend).toHaveBeenCalledWith(
"triage this PR",
undefined,
undefined,
{
assistantPrompt: "Use these skills for this request: github:github.",
chips: [{ label: "github:github", type: "skill" }],
displayText: "triage this PR",
},
);
});
it("expands selected skill chips for voice auto-submit", async () => {
const onSend = vi.fn();
const onSkillsChange = vi.fn();
render(
<ChatInput
onSend={onSend}
selectedSkills={[
{
id: "global:/skills/code-review",
name: "code-review",
description: "Reviews code",
sourceLabel: "Personal",
},
]}
onSkillsChange={onSkillsChange}
/>,
);
await act(async () => {
const accepted =
await lastVoiceDictationOptions?.onAutoSubmit?.("check this diff");
expect(accepted).toBe(true);
});
expect(onSend).toHaveBeenCalledWith(
"check this diff",
undefined,
undefined,
{
assistantPrompt: "Use these skills for this request: code-review.",
chips: [{ label: "code-review", type: "skill" }],
displayText: "check this diff",
},
);
expect(onSkillsChange).toHaveBeenCalledWith([]);
});
it("does not expand reserved slash commands as skills", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
mockListSkills.mockResolvedValue([
{
id: "global:/skills/compact",
name: "compact",
description: "A compacting skill",
sourceLabel: "Personal",
},
]);
render(<ChatInput onSend={onSend} />);
await waitFor(() => {
expect(mockListSkills).toHaveBeenCalled();
});
const input = screen.getByRole("textbox");
await user.type(input, "/compact");
await user.keyboard("{Enter}");
expect(onSend).toHaveBeenCalledWith("/compact", undefined, undefined);
});
});
@@ -36,6 +36,10 @@ vi.mock("@/shared/api/system", () => ({
mockListFilesForMentions(roots, maxResults),
}));
vi.mock("@/features/skills/api/skills", () => ({
listSkills: vi.fn().mockResolvedValue([]),
}));
const TEST_PERSONAS: Persona[] = [
{
id: "builtin-solo",
@@ -90,7 +94,9 @@ describe("ChatInput", () => {
it("renders with default placeholder", () => {
render(<ChatInput onSend={vi.fn()} />);
expect(
screen.getByPlaceholderText("Message Goose, @ to mention agents"),
screen.getByPlaceholderText(
"Message Goose, @ to mention agents or skills",
),
).toBeInTheDocument();
});
@@ -385,7 +391,7 @@ describe("ChatInput", () => {
await user.click(screen.getByRole("option", { name: /reviewer/i }));
expect(input).toHaveValue("");
expect(screen.getByText("@Reviewer")).toBeInTheDocument();
expect(screen.getByText("Reviewer")).toBeInTheDocument();
});
it("shows project files in @mention results and inserts the selected path", async () => {
@@ -518,7 +524,6 @@ describe("ChatInput", () => {
it("keeps the mic toggle enabled while recording even if voice input becomes unavailable", () => {
render(
<ChatInputToolbar
personas={[]}
selectedPersonaId={null}
providers={[]}
selectedProvider="goose"
@@ -556,6 +561,6 @@ describe("ChatInput", () => {
await user.keyboard("{Enter}");
expect(onSend).toHaveBeenCalledWith("hello", "reviewer", undefined);
expect(screen.getByText("@Reviewer")).toBeInTheDocument();
expect(screen.getByText("Reviewer")).toBeInTheDocument();
});
});
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import {
MentionAutocomplete,
type FileMentionItem,
type SkillMentionItem,
fuzzyMatch,
} from "../MentionAutocomplete";
import { Popover, PopoverAnchor } from "@/shared/ui/popover";
@@ -48,9 +49,19 @@ const FILES: FileMentionItem[] = [
},
];
const SKILLS: SkillMentionItem[] = [
{
id: "global:/skills/code-review",
name: "code-review",
description: "Reviews code before it ships",
sourceLabel: "Personal",
},
];
function renderAutocomplete(props: {
selectedIndex?: number;
filteredPersonas?: Persona[];
filteredSkills?: SkillMentionItem[];
filteredFiles?: FileMentionItem[];
}) {
return render(
@@ -60,9 +71,11 @@ function renderAutocomplete(props: {
</PopoverAnchor>
<MentionAutocomplete
filteredPersonas={props.filteredPersonas ?? PERSONAS}
filteredSkills={props.filteredSkills ?? []}
filteredFiles={props.filteredFiles ?? FILES}
isOpen
onSelectPersona={vi.fn()}
onSelectSkill={vi.fn()}
onSelectFile={vi.fn()}
selectedIndex={props.selectedIndex}
/>
@@ -77,6 +90,15 @@ describe("MentionAutocomplete", () => {
expect(screen.getByText("file0.ts")).toBeInTheDocument();
});
it("renders skill items", () => {
renderAutocomplete({ filteredSkills: SKILLS, filteredFiles: [] });
expect(screen.getByText("Skills")).toBeInTheDocument();
expect(screen.getByText("code-review")).toBeInTheDocument();
expect(
screen.getByText("Reviews code before it ships"),
).toBeInTheDocument();
});
it("calls scrollIntoView on the selected item", () => {
const scrollIntoView = vi.fn();
Element.prototype.scrollIntoView = scrollIntoView;
@@ -88,9 +110,11 @@ describe("MentionAutocomplete", () => {
</PopoverAnchor>
<MentionAutocomplete
filteredPersonas={PERSONAS}
filteredSkills={[]}
filteredFiles={FILES}
isOpen
onSelectPersona={vi.fn()}
onSelectSkill={vi.fn()}
onSelectFile={vi.fn()}
selectedIndex={0}
/>
@@ -106,9 +130,11 @@ describe("MentionAutocomplete", () => {
</PopoverAnchor>
<MentionAutocomplete
filteredPersonas={PERSONAS}
filteredSkills={[]}
filteredFiles={FILES}
isOpen
onSelectPersona={vi.fn()}
onSelectSkill={vi.fn()}
onSelectFile={vi.fn()}
selectedIndex={10}
/>
@@ -140,9 +166,11 @@ describe("MentionAutocomplete", () => {
</PopoverAnchor>
<MentionAutocomplete
filteredPersonas={PERSONAS}
filteredSkills={[]}
filteredFiles={FILES}
isOpen={false}
onSelectPersona={vi.fn()}
onSelectSkill={vi.fn()}
onSelectFile={vi.fn()}
/>
</Popover>,
@@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MessageBubble } from "../MessageBubble";
import type { Message } from "@/shared/types/messages";
vi.mock("@tauri-apps/plugin-opener", () => ({
openPath: vi.fn(),
}));
function userMessage(text: string, overrides: Partial<Message> = {}): Message {
return {
id: "u1",
role: "user",
created: Date.now(),
content: [{ type: "text", text }],
...overrides,
};
}
describe("MessageBubble skill chips", () => {
it("renders user message chips from metadata", () => {
render(
<MessageBubble
message={userMessage("redo the settings modal", {
metadata: {
chips: [{ label: "capture-task", type: "skill" }],
},
})}
/>,
);
expect(screen.getByText("capture-task")).toBeInTheDocument();
expect(screen.getByText("redo the settings modal")).toBeInTheDocument();
expect(screen.queryByText(/Use the capture-task skill/i)).toBeNull();
});
});
@@ -0,0 +1,239 @@
import { useCallback, useMemo, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { isReservedSlashCommand } from "@/features/skills/lib/skillChatPrompt";
import type { Persona } from "@/shared/types/agents";
export function fuzzyMatch(query: string, target: string): boolean {
let qi = 0;
for (let ti = 0; ti < target.length && qi < query.length; ti++) {
if (query[qi] === target[ti]) qi++;
}
return qi === query.length;
}
export interface FileMentionItem {
resolvedPath: string;
displayPath: string;
filename: string;
kind: "file" | "folder" | "path";
}
export interface SkillMentionItem {
id: string;
name: string;
description: string;
sourceLabel: string;
}
export type MentionItem =
| { type: "persona"; persona: Persona }
| { type: "skill"; skill: SkillMentionItem }
| { type: "file"; file: FileMentionItem };
export function useMentionDetection(
personas: Persona[] = [],
skills: SkillMentionItem[] = [],
files: FileMentionItem[] = [],
) {
const [mentionState, setMentionState] = useState<{
isOpen: boolean;
trigger: "@" | "/";
query: string;
startIndex: number;
selectedIndex: number;
}>({
isOpen: false,
trigger: "@",
query: "",
startIndex: -1,
selectedIndex: 0,
});
const { filteredPersonas, filteredSkills, filteredFiles } = useMemo(() => {
if (!mentionState.isOpen) {
return {
filteredPersonas: personas,
filteredSkills: skills,
filteredFiles: files,
};
}
const q = mentionState.query.toLowerCase();
const matchesSkill = (skill: SkillMentionItem) =>
fuzzyMatch(q, skill.name.toLowerCase()) ||
fuzzyMatch(q, skill.description.toLowerCase()) ||
fuzzyMatch(q, skill.sourceLabel.toLowerCase());
const matchingSkills = q ? skills.filter(matchesSkill) : skills;
if (mentionState.trigger === "/") {
return {
filteredPersonas: [],
filteredSkills: matchingSkills,
filteredFiles: [],
};
}
if (!q) {
return {
filteredPersonas: personas,
filteredSkills: skills,
filteredFiles: files,
};
}
return {
filteredPersonas: personas.filter((p) =>
fuzzyMatch(q, p.displayName.toLowerCase()),
),
filteredSkills: matchingSkills,
filteredFiles: files.filter(
(f) =>
fuzzyMatch(q, f.filename.toLowerCase()) ||
fuzzyMatch(q, f.displayPath.toLowerCase()),
),
};
}, [
personas,
skills,
files,
mentionState.isOpen,
mentionState.query,
mentionState.trigger,
]);
const totalCount =
filteredPersonas.length + filteredSkills.length + filteredFiles.length;
const detectMention = useCallback(
(value: string, cursorPos: number) => {
const beforeCursor = value.slice(0, cursorPos);
const lastAt = beforeCursor.lastIndexOf("@");
const slashAtStart = beforeCursor.startsWith("/") ? 0 : -1;
if (lastAt === -1 && slashAtStart === -1) {
if (mentionState.isOpen) closeMentionState(setMentionState);
return;
}
if (slashAtStart === 0 && lastAt === -1) {
const query = beforeCursor.slice(1);
if (
query.includes(" ") ||
query.length > 50 ||
isReservedSlashCommand(query)
) {
if (mentionState.isOpen) closeMentionState(setMentionState);
return;
}
setMentionState((prev) => ({
isOpen: true,
trigger: "/",
query,
startIndex: 0,
selectedIndex:
prev.query !== query || prev.trigger !== "/"
? 0
: prev.selectedIndex,
}));
return;
}
if (lastAt > 0 && !/\s/.test(beforeCursor[lastAt - 1])) {
if (mentionState.isOpen) closeMentionState(setMentionState);
return;
}
const query = beforeCursor.slice(lastAt + 1);
if (query.includes(" ") || query.length > 50) {
if (mentionState.isOpen) closeMentionState(setMentionState);
return;
}
setMentionState((prev) => ({
isOpen: true,
trigger: "@",
query,
startIndex: lastAt,
selectedIndex:
prev.query !== query || prev.trigger !== "@" ? 0 : prev.selectedIndex,
}));
},
[mentionState.isOpen],
);
const closeMention = useCallback(() => {
closeMentionState(setMentionState);
}, []);
const navigateMention = useCallback(
(direction: "up" | "down"): boolean => {
if (!mentionState.isOpen || totalCount === 0) return false;
setMentionState((prev) => {
const delta = direction === "down" ? 1 : -1;
const next = (prev.selectedIndex + delta + totalCount) % totalCount;
return { ...prev, selectedIndex: next };
});
return true;
},
[mentionState.isOpen, totalCount],
);
const confirmMention = useCallback((): MentionItem | null => {
if (!mentionState.isOpen || totalCount === 0) return null;
const idx = mentionState.selectedIndex;
if (idx < filteredPersonas.length) {
return { type: "persona", persona: filteredPersonas[idx] };
}
const skillIdx = idx - filteredPersonas.length;
if (skillIdx < filteredSkills.length) {
return { type: "skill", skill: filteredSkills[skillIdx] };
}
const fileIdx = skillIdx - filteredSkills.length;
if (fileIdx < filteredFiles.length) {
return { type: "file", file: filteredFiles[fileIdx] };
}
return null;
}, [
mentionState.isOpen,
mentionState.selectedIndex,
totalCount,
filteredPersonas,
filteredSkills,
filteredFiles,
]);
return {
mentionOpen: mentionState.isOpen,
mentionQuery: mentionState.query,
mentionStartIndex: mentionState.startIndex,
mentionSelectedIndex: mentionState.selectedIndex,
filteredPersonas,
filteredSkills,
filteredFiles,
detectMention,
closeMention,
navigateMention,
confirmMention,
};
}
function closeMentionState(
setMentionState: Dispatch<
SetStateAction<{
isOpen: boolean;
trigger: "@" | "/";
query: string;
startIndex: number;
selectedIndex: number;
}>
>,
) {
setMentionState({
isOpen: false,
trigger: "@",
query: "",
startIndex: -1,
selectedIndex: 0,
});
}
@@ -83,6 +83,10 @@ vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({
}),
}));
vi.mock("@/features/skills/api/skills", () => ({
listSkills: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/features/chat/hooks/useChatSessionController", () => ({
useChatSessionController: () => mockController,
}));
@@ -179,14 +183,16 @@ describe("HomeScreen", () => {
it("renders the chat input placeholder with default agent name when no persona selected", () => {
renderHome();
expect(
screen.getByPlaceholderText("Message Goose, @ to mention agents"),
screen.getByPlaceholderText(
"Message Goose, @ to mention agents or skills",
),
).toBeInTheDocument();
});
it("renders the assistant chooser affordance", () => {
it("renders the agent/model chooser affordance", () => {
renderHome();
expect(
screen.getByRole("button", { name: /choose assistant/i }),
screen.getByRole("button", { name: /choose agent and model/i }),
).toBeInTheDocument();
});
@@ -200,17 +206,17 @@ describe("HomeScreen", () => {
).toBeInTheDocument();
});
it("forwards persona selection through the shared session controller", async () => {
it("forwards agent selection through the shared session controller", async () => {
vi.useRealTimers();
const user = userEvent.setup();
renderHome();
await user.click(screen.getByRole("button", { name: /choose assistant/i }));
await user.click(screen.getByRole("menuitem", { name: /solo/i }));
expect(mockController.handlePersonaChange).toHaveBeenLastCalledWith(
"builtin-solo",
await user.click(
screen.getByRole("button", { name: /choose agent and model/i }),
);
await user.click(screen.getByRole("button", { name: /claude code/i }));
expect(setSelectedProvider).toHaveBeenLastCalledWith("claude-acp");
});
});
@@ -72,6 +72,8 @@ function HomeComposer({
onDismissQueue={controller.queue.dismiss}
initialValue={controller.draftValue}
onDraftChange={controller.handleDraftChange}
selectedSkills={controller.selectedSkills}
onSkillsChange={controller.handleSkillsChange}
onStop={controller.stopStreaming}
isStreaming={
controller.chatState === "streaming" ||
@@ -80,7 +82,6 @@ function HomeComposer({
personas={controller.personas}
selectedPersonaId={controller.selectedPersonaId}
onPersonaChange={controller.handlePersonaChange}
onCreatePersona={controller.handleCreatePersona}
providers={controller.pickerAgents}
providersLoading={controller.providersLoading}
selectedProvider={controller.selectedProvider}
@@ -0,0 +1,130 @@
import type { SkillInfo } from "../api/skills";
import type { ChatSkillDraft } from "@/features/chat/types";
type SkillLike = Pick<SkillInfo, "name">;
type SkillDraftLike = Pick<ChatSkillDraft, "name">;
export type SkillCommandMatch<TSkill extends SkillLike = SkillLike> = {
skill: TSkill;
promptText: string;
displayText: string;
};
const SKILL_INSTRUCTION_PREFIX = "Use these skills for this request:";
const RESERVED_SLASH_COMMANDS = new Set([
"clear",
"compact",
"doctor",
"prompt",
"prompts",
"skills",
]);
export function isReservedSlashCommand(command: string): boolean {
return RESERVED_SLASH_COMMANDS.has(command.trim().toLowerCase());
}
export function formatSkillChatPrompt(
skillName: string,
taskText = "",
): string {
const name = skillName.trim();
const task = taskText.trimStart();
if (!task) {
return `Use the ${name} skill`;
}
return `Use the ${name} skill to ${task}`;
}
export function formatSkillDraftsChatPrompt(
skills: SkillDraftLike[],
taskText = "",
): string {
if (skills.length === 0) {
return taskText;
}
if (skills.length === 1) {
return formatSkillChatPrompt(skills[0].name, taskText);
}
const skillNames = skills
.map((skill) => skill.name.trim())
.filter(Boolean)
.join(", ");
const task = taskText.trimStart();
if (!task) {
return `Use the ${skillNames} skills`;
}
return `Use the ${skillNames} skills to ${task}`;
}
export function formatSkillInstructionPrompt(skills: SkillDraftLike[]): string {
const skillNames = skills
.map((skill) => skill.name.trim())
.filter(Boolean)
.join(", ");
return `${SKILL_INSTRUCTION_PREFIX} ${skillNames}.`;
}
export function parseSkillInstructionPrompt(text: string): string[] {
const trimmed = text.trim();
if (!trimmed.startsWith(SKILL_INSTRUCTION_PREFIX)) {
return [];
}
return trimmed
.slice(SKILL_INSTRUCTION_PREFIX.length)
.trim()
.replace(/[.。]+$/, "")
.split(",")
.map((name) => name.trim())
.filter(Boolean);
}
export function toChatSkillDraft(
skill: Pick<SkillInfo, "id" | "name" | "description" | "sourceLabel">,
): ChatSkillDraft {
return {
id: skill.id,
name: skill.name,
description: skill.description,
sourceLabel: skill.sourceLabel,
};
}
export function expandSkillSlashCommand(
text: string,
skills: SkillLike[],
): string | null {
const match = resolveSkillSlashCommand(text, skills);
return match?.promptText ?? null;
}
export function resolveSkillSlashCommand<TSkill extends SkillLike>(
text: string,
skills: TSkill[],
): SkillCommandMatch<TSkill> | null {
const match = text.trimStart().match(/^\/(\S+)(?:\s+([\s\S]*))?$/);
if (!match) {
return null;
}
const command = match[1].toLowerCase();
if (isReservedSlashCommand(command)) {
return null;
}
const skill = skills.find(
(candidate) => candidate.name.toLowerCase() === command,
);
if (!skill) {
return null;
}
const displayText = match[2]?.trimStart() ?? "";
return {
skill,
promptText: formatSkillChatPrompt(skill.name, displayText),
displayText,
};
}
@@ -6,6 +6,8 @@ import {
AccordionSectionTrigger,
} from "@/shared/ui/accordion";
import { Button } from "@/shared/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { IconMessagePlus } from "@tabler/icons-react";
import type { SkillViewInfo } from "../lib/skillCategories";
export interface SkillsSection {
@@ -58,7 +60,7 @@ export function SkillsListSections({
{section.skills.map((skill) => (
<div
key={`${section.id}-${skill.id}`}
className="group relative flex items-start gap-3 px-5 py-4 transition-colors hover:bg-muted/20"
className="group relative flex items-center gap-3 px-5 py-4 transition-colors hover:bg-muted/20"
>
<button
type="button"
@@ -77,18 +79,32 @@ export function SkillsListSections({
) : null}
</div>
{onStartChat ? (
<Button
type="button"
variant="inline-subtle"
size="xs"
className="relative z-20 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
onClick={() => onStartChat(skill)}
aria-label={t("view.startChat", {
name: skill.name,
})}
>
{t("view.useInChat")}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline-flat"
size="icon-xs"
className="relative z-20 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
onClick={() => onStartChat(skill)}
aria-label={t("view.startChat", {
name: skill.name,
})}
>
<IconMessagePlus className="size-3.5" />
<span className="sr-only">
{t("view.startChatShort")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
sideOffset={8}
>
<p>{t("view.startChatShort")}</p>
</TooltipContent>
</Tooltip>
) : null}
</div>
))}
@@ -122,7 +122,7 @@ function SkillCategoryFilter({
}
interface SkillsViewProps {
onStartChatWithSkill?: (skillName: string, projectId?: string | null) => void;
onStartChatWithSkill?: (skill: SkillInfo, projectId?: string | null) => void;
}
function FilterButton({
@@ -369,7 +369,7 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
const handleStartChat = useCallback(
(skill: SkillInfo) => {
onStartChatWithSkill?.(skill.name, skill.projectLinks[0]?.id ?? null);
onStartChatWithSkill?.(skill, skill.projectLinks[0]?.id ?? null);
},
[onStartChatWithSkill],
);
@@ -242,6 +242,43 @@ describe("SkillsView", () => {
expect(screen.getByText("Quality")).toBeInTheDocument();
});
it("starts a chat with the selected skill from the list", async () => {
listSkills.mockResolvedValue(mockSkills);
const onStartChatWithSkill = vi.fn();
const user = userEvent.setup();
render(<SkillsView onStartChatWithSkill={onStartChatWithSkill} />);
await screen.findByText("test-writer");
await user.click(
screen.getByRole("button", { name: "Start chat with test-writer" }),
);
expect(onStartChatWithSkill).toHaveBeenCalledWith(
expect.objectContaining({ name: "test-writer" }),
"project-alpha",
);
});
it("starts a chat with the selected skill from the detail page", async () => {
listSkills.mockResolvedValue(mockSkills);
const onStartChatWithSkill = vi.fn();
const user = userEvent.setup();
render(<SkillsView onStartChatWithSkill={onStartChatWithSkill} />);
await screen.findByText("code-review");
await user.click(
screen.getByRole("button", { name: "Open code-review details" }),
);
await user.click(screen.getByRole("button", { name: "Start chat" }));
expect(onStartChatWithSkill).toHaveBeenCalledWith(
expect.objectContaining({ name: "code-review" }),
null,
);
});
it("returns to the list without losing filters", async () => {
listSkills.mockResolvedValue(mockSkills);
const user = userEvent.setup();
@@ -245,6 +245,49 @@ describe("acpNotificationHandler", () => {
});
});
it("replay restores skill chips from assistant-only user chunks", async () => {
const replaySessionId = "replay-skill-session";
useChatStore.setState({
loadingSessionIds: new Set<string>([replaySessionId]),
});
await handleSessionNotification({
sessionId: replaySessionId,
update: {
sessionUpdate: "user_message_chunk",
messageId: "user-1",
content: {
type: "text",
text: "Use these skills for this request: capture-task.",
annotations: { audience: ["assistant"] },
},
},
} as never);
await handleSessionNotification({
sessionId: replaySessionId,
update: {
sessionUpdate: "user_message_chunk",
messageId: "user-1",
content: {
type: "text",
text: "redo the settings modal",
},
},
} as never);
const buffer = getReplayBuffer(replaySessionId);
expect(buffer).toHaveLength(1);
expect(buffer?.[0]).toMatchObject({
id: "user-1",
role: "user",
content: [{ type: "text", text: "redo the settings modal" }],
metadata: {
chips: [{ label: "capture-task", type: "skill" }],
},
});
});
it("replay preserves gooseSessionId in MCP app payloads before tracker registration", async () => {
const replaySessionId = "replay-goose-session-2";
useChatStore.setState({
+9 -1
View File
@@ -20,6 +20,7 @@ export interface AcpProvider {
export interface AcpSendMessageOptions {
systemPrompt?: string;
assistantPrompt?: string;
personaId?: string;
personaName?: string;
/** Image attachments as [base64Data, mimeType] pairs. */
@@ -64,7 +65,7 @@ export async function acpSendMessage(
prompt: string,
options: AcpSendMessageOptions = {},
): Promise<void> {
const { systemPrompt, personaId, images } = options;
const { systemPrompt, assistantPrompt, personaId, images } = options;
const sid = sessionId.slice(0, 8);
const tStart = performance.now();
@@ -81,6 +82,13 @@ export async function acpSendMessage(
annotations: { audience: ["assistant"] },
});
}
if (assistantPrompt?.trim()) {
content.push({
type: "text",
text: assistantPrompt,
annotations: { audience: ["assistant"] },
});
}
content.push({ type: "text", text: prompt });
if (images) {
for (const [data, mimeType] of images) {
@@ -5,16 +5,15 @@ import type {
import { useChatStore } from "@/features/chat/stores/chatStore";
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
import {
ensureReplayBuffer,
getBufferedMessage,
findLatestUnpairedToolRequest,
} from "@/features/chat/hooks/replayBuffer";
import type {
TextContent,
ToolRequestContent,
ToolResponseContent,
} from "@/shared/types/messages";
import type { AcpNotificationHandler } from "./acpConnection";
import { handleReplayUserMessageChunk } from "./acpSkillReplayChips";
import {
attachMcpAppPayload,
extractToolResultText,
@@ -168,31 +167,7 @@ function handleReplay(
clearReplayAssistantMessage(sessionId);
if (update.content.type !== "text" || !("text" in update.content)) break;
const messageId = update.messageId ?? crypto.randomUUID();
const buffer = ensureReplayBuffer(sessionId);
const existing = getBufferedMessage(sessionId, messageId);
// biome-ignore lint/suspicious/noExplicitAny: wire format has annotations but SDK types don't
const rawAnn = (update.content as any).annotations;
const ann: TextContent["annotations"] | undefined =
typeof rawAnn === "object" && rawAnn !== null ? rawAnn : undefined;
// Drop assistant-only blocks so they never enter chat state.
if (
ann?.audience &&
ann.audience.length > 0 &&
!ann.audience.includes("user")
)
break;
const textBlock = makeTextBlock(update.content.text, ann);
if (!existing) {
buffer.push({
id: messageId,
role: "user",
created: Date.now(),
content: [textBlock],
metadata: { userVisible: true, agentVisible: true },
});
} else {
existing.content.push(textBlock);
}
handleReplayUserMessageChunk(sessionId, messageId, update.content);
break;
}
@@ -469,13 +444,6 @@ function findStreamingMessageId(sessionId: string): string | null {
.streamingMessageId;
}
function makeTextBlock(
text: string,
ann?: TextContent["annotations"],
): TextContent {
return { type: "text", text, ...(ann ? { annotations: ann } : {}) };
}
function ensureLiveAssistantMessage(
sessionId: string,
gooseSessionId: string,
@@ -0,0 +1,124 @@
import { parseSkillInstructionPrompt } from "@/features/skills/lib/skillChatPrompt";
import {
ensureReplayBuffer,
getBufferedMessage,
} from "@/features/chat/hooks/replayBuffer";
import type { MessageChip, TextContent } from "@/shared/types/messages";
const pendingReplayChips = new Map<string, Map<string, MessageChip[]>>();
export function getPendingReplayChips(sessionId: string, messageId: string) {
const byMessage = pendingReplayChips.get(sessionId);
return byMessage?.get(messageId) ?? [];
}
export function setPendingReplayChips(
sessionId: string,
messageId: string,
chips: MessageChip[],
) {
if (chips.length === 0) return;
const byMessage = pendingReplayChips.get(sessionId) ?? new Map();
byMessage.set(messageId, chips);
pendingReplayChips.set(sessionId, byMessage);
}
export function clearPendingReplayChips(sessionId: string, messageId: string) {
const byMessage = pendingReplayChips.get(sessionId);
if (!byMessage) return;
byMessage.delete(messageId);
if (byMessage.size === 0) {
pendingReplayChips.delete(sessionId);
}
}
export function skillInstructionToChips(text: string): MessageChip[] {
return parseSkillInstructionPrompt(text).map((label) => ({
label,
type: "skill" as const,
}));
}
export function handleReplayUserMessageChunk(
sessionId: string,
messageId: string,
content: { text: string },
): void {
const buffer = ensureReplayBuffer(sessionId);
const existing = getBufferedMessage(sessionId, messageId);
const ann = getTextAnnotations(content);
if (isAssistantOnly(ann)) {
const chips = skillInstructionToChips(content.text);
if (chips.length > 0) {
attachReplayChips(sessionId, messageId, existing, chips);
}
return;
}
const textBlock = makeTextBlock(content.text, ann);
const chips = getPendingReplayChips(sessionId, messageId);
if (!existing) {
buffer.push({
id: messageId,
role: "user",
created: Date.now(),
content: [textBlock],
metadata: {
userVisible: true,
agentVisible: true,
...(chips.length > 0 ? { chips } : {}),
},
});
} else {
existing.content.push(textBlock);
attachReplayChips(sessionId, messageId, existing, chips);
}
clearPendingReplayChips(sessionId, messageId);
}
export function clearSkillReplayChips(): void {
pendingReplayChips.clear();
}
function getTextAnnotations(content: {
text: string;
annotations?: unknown;
}): TextContent["annotations"] | undefined {
const rawAnn = content.annotations;
return typeof rawAnn === "object" && rawAnn !== null
? (rawAnn as TextContent["annotations"])
: undefined;
}
function isAssistantOnly(ann?: TextContent["annotations"]) {
return Boolean(
ann?.audience && ann.audience.length > 0 && !ann.audience.includes("user"),
);
}
function attachReplayChips(
sessionId: string,
messageId: string,
existing: ReturnType<typeof getBufferedMessage>,
chips: MessageChip[],
) {
if (chips.length === 0) return;
if (existing) {
existing.metadata = {
...existing.metadata,
chips: [...(existing.metadata?.chips ?? []), ...chips],
};
} else {
setPendingReplayChips(sessionId, messageId, chips);
}
}
function makeTextBlock(
text: string,
ann?: TextContent["annotations"],
): TextContent {
return ann
? { type: "text", text, annotations: ann }
: { type: "text", text };
}
@@ -108,7 +108,7 @@
},
"input": {
"ariaLabel": "Chat message input",
"placeholder": "Message {{agent}}, @ to mention agents"
"placeholder": "Message {{agent}}, @ to mention agents or skills"
},
"loading": {
"compacting": "Compacting conversation...",
@@ -118,6 +118,7 @@
"mention": {
"ariaLabel": "Mention suggestions",
"title": "Mention an agent",
"skillsTitle": "Skills",
"filesTitle": "Files"
},
"notifications": {
@@ -135,6 +136,9 @@
"clearActive": "Clear active assistant",
"defaultDescription": "No agent selected - chat directly with Goose"
},
"skill": {
"clearSelected": "Remove {{skill}} skill"
},
"queue": {
"dismiss": "Dismiss queued message",
"label": "Queued: {{text}}"
@@ -108,7 +108,7 @@
},
"input": {
"ariaLabel": "Entrada de mensaje del chat",
"placeholder": "Enviar mensaje a {{agent}}, usa @ para mencionar agentes"
"placeholder": "Enviar mensaje a {{agent}}, usa @ para mencionar agentes o habilidades"
},
"loading": {
"compacting": "Compactando conversación...",
@@ -118,6 +118,7 @@
"mention": {
"ariaLabel": "Sugerencias de menciones",
"title": "Menciona un agente",
"skillsTitle": "Habilidades",
"filesTitle": "Archivos"
},
"notifications": {
@@ -135,6 +136,9 @@
"clearActive": "Quitar asistente activo",
"defaultDescription": "Sin agente seleccionado: chatea directamente con Goose"
},
"skill": {
"clearSelected": "Quitar habilidad {{skill}}"
},
"queue": {
"dismiss": "Descartar mensaje en cola",
"label": "En cola: {{text}}"
+2
View File
@@ -242,6 +242,7 @@ export function getTextContent(message: Message): string {
export function createUserMessage(
text: string,
attachments?: MessageAttachment[],
chips?: MessageChip[],
): Message {
return {
id: crypto.randomUUID(),
@@ -252,6 +253,7 @@ export function createUserMessage(
userVisible: true,
agentVisible: true,
...(attachments ? { attachments } : {}),
...(chips && chips.length > 0 ? { chips } : {}),
},
};
}