@@ -390,9 +390,11 @@ export function ChatInput({
- {stickyPersona && (
-
-
-
- @{stickyPersona.displayName}
-
-
-
- )}
+
{queuedMessage && (
@@ -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({
void;
}) {
const { t } = useTranslation("chat");
- const Icon = attachment.kind === "directory" ? FolderClosed : FileText;
return (
- }
title={attachment.path ?? attachment.name}
- >
-
- {attachment.name}
-
-
+ onRemove={() => onRemove(attachment.id)}
+ removeLabel={t("attachments.remove")}
+ />
);
}
@@ -97,7 +91,7 @@ export function ChatInputAttachments({
}
return (
-
+
{attachments.map((attachment, index) =>
attachment.kind === "image" ? (
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 (
+
+ {persona && (
+ }
+ onRemove={onClearPersona}
+ removeLabel={t("persona.clearActive")}
+ />
+ )}
+ {skills.map((skill) => (
+ }
+ onRemove={() => onRemoveSkill(skill.id)}
+ removeLabel={t("skill.clearSelected", {
+ skill: skill.name,
+ })}
+ />
+ ))}
+
+ );
+}
diff --git a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx
index bde1f4c894..741fb8af69 100644
--- a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx
+++ b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx
@@ -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 */}
- {personas.length > 0 && (
-
onPersonaChange?.(id)}
- onCreatePersona={onCreatePersona}
- triggerVariant="icon"
- />
- )}
-
{showContextUsage && (
-
+
diff --git a/ui/goose2/src/features/chat/ui/ChatView.tsx b/ui/goose2/src/features/chat/ui/ChatView.tsx
index 38ed5186e6..3d93ecbb70 100644
--- a/ui/goose2/src/features/chat/ui/ChatView.tsx
+++ b/ui/goose2/src/features/chat/ui/ChatView.tsx
@@ -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}
diff --git a/ui/goose2/src/features/chat/ui/ComposerChip.tsx b/ui/goose2/src/features/chat/ui/ComposerChip.tsx
new file mode 100644
index 0000000000..0d6b9245d8
--- /dev/null
+++ b/ui/goose2/src/features/chat/ui/ComposerChip.tsx
@@ -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 = {
+ 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 (
+
+
+ {label}
+
+ );
+}
diff --git a/ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx b/ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx
index 4b9a0cbea7..6e5b5c22c1 100644
--- a/ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx
+++ b/ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx
@@ -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({
))}
+ {filteredSkills.length > 0 && (
+
+ {t("mention.skillsTitle")}
+
+ )}
+ {filteredSkills.map((skill, i) => {
+ const globalIndex = filteredPersonas.length + i;
+ return (
+
+ );
+ })}
+
{filteredFiles.length > 0 && (
{t("mention.filesTitle")}
)}
{filteredFiles.map((file, i) => {
- const globalIndex = filteredPersonas.length + i;
+ const globalIndex =
+ filteredPersonas.length + filteredSkills.length + i;
return (
diff --git a/ui/goose2/src/features/chat/ui/MessageBubbleActions.tsx b/ui/goose2/src/features/chat/ui/MessageBubbleActions.tsx
new file mode 100644
index 0000000000..699b0b9ff7
--- /dev/null
+++ b/ui/goose2/src/features/chat/ui/MessageBubbleActions.tsx
@@ -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 (
+
+ {isUser && timestamp}
+ {textContent && (
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+ )}
+ {!isUser && onRetryMessage && (
+ onRetryMessage(messageId)}
+ >
+
+
+ )}
+ {isUser && onEditMessage && (
+ onEditMessage(messageId)}
+ >
+
+
+ )}
+ {!isUser && timestamp}
+
+ );
+}
diff --git a/ui/goose2/src/features/chat/ui/MessageMetadataChip.tsx b/ui/goose2/src/features/chat/ui/MessageMetadataChip.tsx
new file mode 100644
index 0000000000..ad32dfade5
--- /dev/null
+++ b/ui/goose2/src/features/chat/ui/MessageMetadataChip.tsx
@@ -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 = {
+ 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 (
+
+ {Icon ? : null}
+ {chip.label}
+
+ );
+}
diff --git a/ui/goose2/src/features/chat/ui/PersonaPicker.tsx b/ui/goose2/src/features/chat/ui/PersonaPicker.tsx
index 0e422be8c5..6435f0a40d 100644
--- a/ui/goose2/src/features/chat/ui/PersonaPicker.tsx
+++ b/ui/goose2/src/features/chat/ui/PersonaPicker.tsx
@@ -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) {
diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.asyncSend.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.asyncSend.test.tsx
index 1f8dbc3064..a5f09301b6 100644
--- a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.asyncSend.test.tsx
+++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.asyncSend.test.tsx
@@ -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();
diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx
index b99a26b21b..fb0138b6c9 100644
--- a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx
+++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx
@@ -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),
diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.skills.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.skills.test.tsx
new file mode 100644
index 0000000000..38e6584c20
--- /dev/null
+++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.skills.test.tsx
@@ -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;
+} | null = null;
+
+vi.mock("../../hooks/useVoiceDictation", () => ({
+ useVoiceDictation: (options: {
+ onAutoSubmit?: (text: string) => boolean | Promise;
+ }) => {
+ 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
+>(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();
+
+ 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(
+ ,
+ );
+
+ 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();
+
+ 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();
+
+ 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(
+ ,
+ );
+
+ 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();
+
+ 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);
+ });
+});
diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx
index f59623df07..9ca9f8b2c7 100644
--- a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx
+++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx
@@ -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();
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(
{
await user.keyboard("{Enter}");
expect(onSend).toHaveBeenCalledWith("hello", "reviewer", undefined);
- expect(screen.getByText("@Reviewer")).toBeInTheDocument();
+ expect(screen.getByText("Reviewer")).toBeInTheDocument();
});
});
diff --git a/ui/goose2/src/features/chat/ui/__tests__/MentionAutocomplete.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/MentionAutocomplete.test.tsx
index 9042702e04..3dea2c156f 100644
--- a/ui/goose2/src/features/chat/ui/__tests__/MentionAutocomplete.test.tsx
+++ b/ui/goose2/src/features/chat/ui/__tests__/MentionAutocomplete.test.tsx
@@ -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: {
@@ -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", () => {
@@ -106,9 +130,11 @@ describe("MentionAutocomplete", () => {
@@ -140,9 +166,11 @@ describe("MentionAutocomplete", () => {
,
diff --git a/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.skillChips.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.skillChips.test.tsx
new file mode 100644
index 0000000000..f726042f55
--- /dev/null
+++ b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.skillChips.test.tsx
@@ -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 {
+ 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(
+ ,
+ );
+
+ expect(screen.getByText("capture-task")).toBeInTheDocument();
+ expect(screen.getByText("redo the settings modal")).toBeInTheDocument();
+ expect(screen.queryByText(/Use the capture-task skill/i)).toBeNull();
+ });
+});
diff --git a/ui/goose2/src/features/chat/ui/mentionDetection.ts b/ui/goose2/src/features/chat/ui/mentionDetection.ts
new file mode 100644
index 0000000000..4be1a1417a
--- /dev/null
+++ b/ui/goose2/src/features/chat/ui/mentionDetection.ts
@@ -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,
+ });
+}
diff --git a/ui/goose2/src/features/home/ui/HomeScreen.test.tsx b/ui/goose2/src/features/home/ui/HomeScreen.test.tsx
index 3492b20abb..143904465b 100644
--- a/ui/goose2/src/features/home/ui/HomeScreen.test.tsx
+++ b/ui/goose2/src/features/home/ui/HomeScreen.test.tsx
@@ -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");
});
});
diff --git a/ui/goose2/src/features/home/ui/HomeScreen.tsx b/ui/goose2/src/features/home/ui/HomeScreen.tsx
index 77b1b2e477..55dc96f7d2 100644
--- a/ui/goose2/src/features/home/ui/HomeScreen.tsx
+++ b/ui/goose2/src/features/home/ui/HomeScreen.tsx
@@ -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}
diff --git a/ui/goose2/src/features/skills/lib/skillChatPrompt.ts b/ui/goose2/src/features/skills/lib/skillChatPrompt.ts
new file mode 100644
index 0000000000..611a36c407
--- /dev/null
+++ b/ui/goose2/src/features/skills/lib/skillChatPrompt.ts
@@ -0,0 +1,130 @@
+import type { SkillInfo } from "../api/skills";
+import type { ChatSkillDraft } from "@/features/chat/types";
+
+type SkillLike = Pick;
+type SkillDraftLike = Pick;
+export type SkillCommandMatch = {
+ 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,
+): 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(
+ text: string,
+ skills: TSkill[],
+): SkillCommandMatch | 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,
+ };
+}
diff --git a/ui/goose2/src/features/skills/ui/SkillsListSections.tsx b/ui/goose2/src/features/skills/ui/SkillsListSections.tsx
index 55b41caebd..9a2f7cb58d 100644
--- a/ui/goose2/src/features/skills/ui/SkillsListSections.tsx
+++ b/ui/goose2/src/features/skills/ui/SkillsListSections.tsx
@@ -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) => (
))}
diff --git a/ui/goose2/src/features/skills/ui/SkillsView.tsx b/ui/goose2/src/features/skills/ui/SkillsView.tsx
index ed35d2b211..589ae9a024 100644
--- a/ui/goose2/src/features/skills/ui/SkillsView.tsx
+++ b/ui/goose2/src/features/skills/ui/SkillsView.tsx
@@ -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],
);
diff --git a/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx b/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx
index 53daa70194..dacb6e157e 100644
--- a/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx
+++ b/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx
@@ -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();
+ 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();
+ 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();
diff --git a/ui/goose2/src/shared/api/__tests__/acpNotificationHandler.test.ts b/ui/goose2/src/shared/api/__tests__/acpNotificationHandler.test.ts
index 89fabcb3b4..81341cf460 100644
--- a/ui/goose2/src/shared/api/__tests__/acpNotificationHandler.test.ts
+++ b/ui/goose2/src/shared/api/__tests__/acpNotificationHandler.test.ts
@@ -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([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({
diff --git a/ui/goose2/src/shared/api/acp.ts b/ui/goose2/src/shared/api/acp.ts
index 4506f48c58..f43c8fad62 100644
--- a/ui/goose2/src/shared/api/acp.ts
+++ b/ui/goose2/src/shared/api/acp.ts
@@ -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 {
- 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) {
diff --git a/ui/goose2/src/shared/api/acpNotificationHandler.ts b/ui/goose2/src/shared/api/acpNotificationHandler.ts
index f0936598d0..61435bf809 100644
--- a/ui/goose2/src/shared/api/acpNotificationHandler.ts
+++ b/ui/goose2/src/shared/api/acpNotificationHandler.ts
@@ -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,
diff --git a/ui/goose2/src/shared/api/acpSkillReplayChips.ts b/ui/goose2/src/shared/api/acpSkillReplayChips.ts
new file mode 100644
index 0000000000..872f8d8c1d
--- /dev/null
+++ b/ui/goose2/src/shared/api/acpSkillReplayChips.ts
@@ -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>();
+
+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,
+ 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 };
+}
diff --git a/ui/goose2/src/shared/i18n/locales/en/chat.json b/ui/goose2/src/shared/i18n/locales/en/chat.json
index 1a0bdfcd74..32663d97e6 100644
--- a/ui/goose2/src/shared/i18n/locales/en/chat.json
+++ b/ui/goose2/src/shared/i18n/locales/en/chat.json
@@ -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}}"
diff --git a/ui/goose2/src/shared/i18n/locales/es/chat.json b/ui/goose2/src/shared/i18n/locales/es/chat.json
index c11e9df401..9f7b27ec38 100644
--- a/ui/goose2/src/shared/i18n/locales/es/chat.json
+++ b/ui/goose2/src/shared/i18n/locales/es/chat.json
@@ -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}}"
diff --git a/ui/goose2/src/shared/types/messages.ts b/ui/goose2/src/shared/types/messages.ts
index ba4d434eff..6caac990ec 100644
--- a/ui/goose2/src/shared/types/messages.ts
+++ b/ui/goose2/src/shared/types/messages.ts
@@ -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 } : {}),
},
};
}