mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
add skills to the chat composer (#8881)
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user