diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 53e197f2..f7a47199 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -270,6 +270,8 @@ pub(crate) struct WorkspaceSettings { pub(crate) launch_script: Option, #[serde(default, rename = "launchScripts")] pub(crate) launch_scripts: Option>, + #[serde(default, rename = "ideas")] + pub(crate) ideas: Option>, #[serde(default, rename = "worktreeSetupScript")] pub(crate) worktree_setup_script: Option, } @@ -283,6 +285,13 @@ pub(crate) struct LaunchScriptEntry { pub(crate) label: Option, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct IdeaEntry { + pub(crate) id: String, + pub(crate) title: String, + pub(crate) body: String, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub(crate) struct WorktreeSetupStatus { #[serde(rename = "shouldRun")] diff --git a/src-tauri/src/workspaces/tests.rs b/src-tauri/src/workspaces/tests.rs index 2e7a6959..5386bd10 100644 --- a/src-tauri/src/workspaces/tests.rs +++ b/src-tauri/src/workspaces/tests.rs @@ -47,6 +47,7 @@ fn workspace_with_id_and_kind( codex_args: None, launch_script: None, launch_scripts: None, + ideas: None, worktree_setup_script: None, }, } diff --git a/src/App.tsx b/src/App.tsx index de747111..6fe8f68f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import "./styles/diff-viewer.css"; import "./styles/file-tree.css"; import "./styles/panel-tabs.css"; import "./styles/prompts.css"; +import "./styles/ideas.css"; import "./styles/debug.css"; import "./styles/terminal.css"; import "./styles/plan.css"; @@ -92,6 +93,7 @@ import { useCopyThread } from "./features/threads/hooks/useCopyThread"; import { useTerminalController } from "./features/terminal/hooks/useTerminalController"; import { useWorkspaceLaunchScript } from "./features/app/hooks/useWorkspaceLaunchScript"; import { useWorkspaceLaunchScripts } from "./features/app/hooks/useWorkspaceLaunchScripts"; +import { useWorkspaceIdeas } from "./features/ideas/hooks/useWorkspaceIdeas"; import { useWorktreeSetupScript } from "./features/app/hooks/useWorktreeSetupScript"; import { useGitCommitController } from "./features/app/hooks/useGitCommitController"; import { WorkspaceHome } from "./features/workspaces/components/WorkspaceHome"; @@ -800,6 +802,11 @@ function MainApp() { activeTerminalId, }); + const ideasState = useWorkspaceIdeas({ + activeWorkspace, + updateWorkspaceSettings, + }); + const worktreeSetupScriptState = useWorktreeSetupScript({ ensureTerminalWithTitle, restartTerminalSession, @@ -1868,6 +1875,11 @@ function MainApp() { pushError, syncError, commitsAhead: gitLogAhead, + ideas: ideasState.ideas, + onCreateIdea: ideasState.createIdea, + onUpdateIdea: ideasState.updateIdea, + onDeleteIdea: ideasState.deleteIdea, + onSendIdea: handleSendPrompt, onSendPrompt: handleSendPrompt, onSendPromptToNewAgent: handleSendPromptToNewAgent, onCreatePrompt: handleCreatePrompt, diff --git a/src/features/app/components/MainHeader.tsx b/src/features/app/components/MainHeader.tsx index 867f4086..79f2b73b 100644 --- a/src/features/app/components/MainHeader.tsx +++ b/src/features/app/components/MainHeader.tsx @@ -565,7 +565,7 @@ export function MainHeader({ draftIcon={launchScriptsState.draftIcon} draftLabel={launchScriptsState.draftLabel} isSaving={launchScriptsState.isSaving} - error={launchScriptsState.errorById[entry.id] ?? null} + error={launchScriptsState.errorById?.[entry.id] ?? null} onRun={() => launchScriptsState.onRunScript(entry.id)} onOpenEditor={() => launchScriptsState.onOpenEditor(entry.id)} onCloseEditor={launchScriptsState.onCloseEditor} diff --git a/src/features/app/hooks/useGitPanelController.ts b/src/features/app/hooks/useGitPanelController.ts index 5d8f35cb..e6d0f820 100644 --- a/src/features/app/hooks/useGitPanelController.ts +++ b/src/features/app/hooks/useGitPanelController.ts @@ -39,7 +39,7 @@ export function useGitPanelController({ "split" | "unified" >("split"); const [filePanelMode, setFilePanelMode] = useState< - "git" | "files" | "prompts" + "git" | "files" | "prompts" | "ideas" >("git"); const [selectedPullRequest, setSelectedPullRequest] = useState(null); diff --git a/src/features/app/hooks/useWorkspaceLaunchScript.ts b/src/features/app/hooks/useWorkspaceLaunchScript.ts index 9ed7bcb3..a6521620 100644 --- a/src/features/app/hooks/useWorkspaceLaunchScript.ts +++ b/src/features/app/hooks/useWorkspaceLaunchScript.ts @@ -12,7 +12,10 @@ type PendingLaunch = { type UseWorkspaceLaunchScriptOptions = { activeWorkspace: WorkspaceInfo | null; - updateWorkspaceSettings: (id: string, settings: WorkspaceSettings) => Promise; + updateWorkspaceSettings: ( + id: string, + settings: Partial, + ) => Promise; openTerminal: () => void; ensureLaunchTerminal: (workspaceId: string) => string; restartLaunchSession: (workspaceId: string, terminalId: string) => Promise; @@ -82,7 +85,6 @@ export function useWorkspaceLaunchScript({ const nextScript = trimmed.length > 0 ? draftScript : null; try { await updateWorkspaceSettings(activeWorkspace.id, { - ...activeWorkspace.settings, launchScript: nextScript, }); setEditorOpen(false); diff --git a/src/features/app/hooks/useWorkspaceLaunchScripts.ts b/src/features/app/hooks/useWorkspaceLaunchScripts.ts index 3d7c7213..08b0b2bf 100644 --- a/src/features/app/hooks/useWorkspaceLaunchScripts.ts +++ b/src/features/app/hooks/useWorkspaceLaunchScripts.ts @@ -23,7 +23,10 @@ type PendingLaunch = { type UseWorkspaceLaunchScriptsOptions = { activeWorkspace: WorkspaceInfo | null; - updateWorkspaceSettings: (id: string, settings: WorkspaceSettings) => Promise; + updateWorkspaceSettings: ( + id: string, + settings: Partial, + ) => Promise; openTerminal: () => void; ensureLaunchTerminal: (workspaceId: string, entry: LaunchScriptEntry, title: string) => string; restartLaunchSession: (workspaceId: string, terminalId: string) => Promise; @@ -196,7 +199,6 @@ export function useWorkspaceLaunchScripts({ }, ]; await updateWorkspaceSettings(activeWorkspace.id, { - ...activeWorkspace.settings, launchScripts: nextScripts, }); setNewEditorOpen(false); @@ -240,7 +242,6 @@ export function useWorkspaceLaunchScripts({ }; }); await updateWorkspaceSettings(activeWorkspace.id, { - ...activeWorkspace.settings, launchScripts: nextScripts, }); setEditorOpenId(null); @@ -271,7 +272,6 @@ export function useWorkspaceLaunchScripts({ try { const nextScripts = launchScripts.filter((entry) => entry.id !== editorOpenId); await updateWorkspaceSettings(activeWorkspace.id, { - ...activeWorkspace.settings, launchScripts: nextScripts, }); setEditorOpenId(null); @@ -326,9 +326,7 @@ export function useWorkspaceLaunchScripts({ useEffect(() => { const pending = pendingRunRef.current; - const pendingKey = pending - ? `${pending.workspaceId}:${pending.terminalId}` - : null; + const pendingKey = pending ? `${pending.workspaceId}:${pending.terminalId}` : null; if ( !pending || terminalState?.readyKey !== pendingKey || diff --git a/src/features/git/hooks/usePullRequestComposer.ts b/src/features/git/hooks/usePullRequestComposer.ts index a4020217..d6a5bc7c 100644 --- a/src/features/git/hooks/usePullRequestComposer.ts +++ b/src/features/git/hooks/usePullRequestComposer.ts @@ -9,7 +9,7 @@ type UsePullRequestComposerOptions = { activeWorkspace: WorkspaceInfo | null; selectedPullRequest: GitHubPullRequest | null; gitPullRequestDiffs: GitHubPullRequestDiff[]; - filePanelMode: "git" | "files" | "prompts"; + filePanelMode: "git" | "files" | "prompts" | "ideas"; gitPanelMode: "diff" | "log" | "issues" | "prs"; centerMode: "chat" | "diff"; isCompact: boolean; diff --git a/src/features/ideas/components/IdeasPanel.test.tsx b/src/features/ideas/components/IdeasPanel.test.tsx new file mode 100644 index 00000000..396c5e4c --- /dev/null +++ b/src/features/ideas/components/IdeasPanel.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment jsdom +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { IdeaEntry } from "../../../types"; +import { IdeasPanel } from "./IdeasPanel"; + +describe("IdeasPanel", () => { + it("hides the title line when title is empty", () => { + const ideas: IdeaEntry[] = [ + { id: "idea-1", title: "", body: "Body only" }, + ]; + + render( + , + ); + + expect(screen.getAllByText("Body only").length).toBeGreaterThan(0); + }); + + it("sends body-only ideas", () => { + const onSendIdea = vi.fn(); + const ideas: IdeaEntry[] = [ + { id: "idea-1", title: "", body: "Body only" }, + ]; + + const { container } = render( + , + ); + + const row = container.querySelector(".prompt-row"); + if (!row) { + throw new Error("Idea row not found"); + } + const sendButton = row.querySelector('button[title="Send to current agent"]'); + if (!sendButton) { + throw new Error("Send button not found"); + } + fireEvent.click(sendButton); + + expect(onSendIdea).toHaveBeenCalledWith("Body only"); + }); + + it("confirms delete before invoking callback", () => { + const onDeleteIdea = vi.fn().mockResolvedValue(undefined); + const ideas: IdeaEntry[] = [ + { id: "idea-1", title: "Idea", body: "Body" }, + ]; + + const { container } = render( + , + ); + + const row = container.querySelector(".prompt-row"); + if (!row) { + throw new Error("Idea row not found"); + } + + const deleteButton = row.querySelector('button[title="Delete idea"]'); + if (!deleteButton) { + throw new Error("Delete button not found"); + } + fireEvent.click(deleteButton); + + expect(onDeleteIdea).not.toHaveBeenCalled(); + + const confirmButtons = row.querySelectorAll('button.idea-delete'); + const confirmButton = confirmButtons[confirmButtons.length - 1]; + fireEvent.click(confirmButton); + + expect(onDeleteIdea).toHaveBeenCalledWith("idea-1"); + }); +}); diff --git a/src/features/ideas/components/IdeasPanel.tsx b/src/features/ideas/components/IdeasPanel.tsx new file mode 100644 index 00000000..9f18e4b4 --- /dev/null +++ b/src/features/ideas/components/IdeasPanel.tsx @@ -0,0 +1,275 @@ +import { useMemo, useState } from "react"; +import Lightbulb from "lucide-react/dist/esm/icons/lightbulb"; +import Plus from "lucide-react/dist/esm/icons/plus"; +import Search from "lucide-react/dist/esm/icons/search"; +import type { IdeaEntry } from "../../../types"; +import { PanelTabs, type PanelTabId } from "../../layout/components/PanelTabs"; + +type IdeasPanelProps = { + ideas: IdeaEntry[]; + filePanelMode: PanelTabId; + onFilePanelModeChange: (mode: PanelTabId) => void; + onSendIdea: (text: string) => void | Promise; + onCreateIdea: (title: string, body: string) => void | Promise; + onUpdateIdea: (id: string, title: string, body: string) => void | Promise; + onDeleteIdea: (id: string) => void | Promise; +}; + +type IdeaEditorState = { + mode: "create" | "edit"; + id?: string; + title: string; + body: string; +}; + +function buildIdeaText(title: string, body: string) { + const trimmedTitle = title.trim(); + const trimmedBody = body.trim(); + if (trimmedTitle && trimmedBody) { + return `${trimmedTitle}\n\n${trimmedBody}`; + } + return trimmedBody || trimmedTitle; +} + +export function IdeasPanel({ + ideas, + filePanelMode, + onFilePanelModeChange, + onSendIdea, + onCreateIdea, + onUpdateIdea, + onDeleteIdea, +}: IdeasPanelProps) { + const [query, setQuery] = useState(""); + const [editor, setEditor] = useState(null); + const [editorError, setEditorError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [pendingDeleteId, setPendingDeleteId] = useState(null); + + const normalizedQuery = query.trim().toLowerCase(); + + const filteredIdeas = useMemo(() => { + if (!normalizedQuery) { + return ideas; + } + return ideas.filter((idea) => { + const haystack = `${idea.title} ${idea.body}`.toLowerCase(); + return haystack.includes(normalizedQuery); + }); + }, [ideas, normalizedQuery]); + + const totalCount = filteredIdeas.length; + const hasIdeas = totalCount > 0; + + const startCreate = () => { + setEditorError(null); + setPendingDeleteId(null); + setEditor({ mode: "create", title: "", body: "" }); + }; + + const startEdit = (idea: IdeaEntry) => { + setEditorError(null); + setPendingDeleteId(null); + setEditor({ mode: "edit", id: idea.id, title: idea.title, body: idea.body }); + }; + + const handleSave = async () => { + if (!editor || isSaving) { + return; + } + const title = editor.title.trim(); + const body = editor.body.trim(); + if (!body) { + setEditorError("Body is required."); + return; + } + setEditorError(null); + setIsSaving(true); + try { + if (editor.mode === "create") { + await onCreateIdea(title, body); + } else if (editor.id) { + await onUpdateIdea(editor.id, title, body); + } + setEditor(null); + } catch (err) { + setEditorError(err instanceof Error ? err.message : String(err)); + } finally { + setIsSaving(false); + } + }; + + const handleDelete = async (id: string) => { + try { + await onDeleteIdea(id); + if (editor?.id === id) { + setEditor(null); + } + } catch (err) { + setEditorError(err instanceof Error ? err.message : String(err)); + } + }; + + const renderIdeaRow = (idea: IdeaEntry) => { + const isPendingDelete = pendingDeleteId === idea.id; + const displayTitle = idea.title.trim(); + const hasTitle = Boolean(displayTitle); + return ( +
+
+ {hasTitle &&
{displayTitle}
} +
{idea.body}
+
+
+ + + +
+ {isPendingDelete && ( +
+ Delete this idea? + + +
+ )} +
+ ); + }; + + return ( +