Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions ui/goose2/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -708,6 +729,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
onSelectSession={handleSelectSession}
onSelectSearchResult={handleSelectSearchResult}
onStartChatFromProject={handleStartChatFromProject}
onStartChatWithSkill={handleStartChatWithSkill}
/>
)}
</main>
Expand Down
5 changes: 4 additions & 1 deletion ui/goose2/src/app/ui/AppShellContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -27,6 +28,7 @@ interface AppShellContentProps {
query?: string,
) => void;
onStartChatFromProject: (project: ProjectInfo) => void;
onStartChatWithSkill: (skill: SkillInfo, projectId?: string | null) => void;
}

export function AppShellContent({
Expand All @@ -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":
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
},
);
});
});
20 changes: 17 additions & 3 deletions ui/goose2/src/features/chat/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"
Expand All @@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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]);
Expand Down
51 changes: 51 additions & 0 deletions ui/goose2/src/features/chat/hooks/useChatInputFilePicker.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
70 changes: 70 additions & 0 deletions ui/goose2/src/features/chat/hooks/useChatInputSubmit.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading