From 6a4e5cd0d37ea23c9dd89468dbfe05f938cbd6c8 Mon Sep 17 00:00:00 2001 From: Harshith Pasupuleti Date: Sat, 5 Sep 2026 19:19:48 +0530 Subject: [PATCH 01/16] feat: add advanced workspace mode Add onboarding and settings mode selection, sidebar worktree management, scoped tabs, and worktree deletion. Keep composer project context aligned with the active project and selected worktree. --- electron/main.ts | 32 ++ electron/preload.ts | 2 + electron/worktree-manager.test.ts | 12 + electron/worktree-manager.ts | 21 ++ src/App.tsx | 19 +- src/components/advanced-shell.tsx | 449 ++++++++++++++++++++++++++++ src/components/agent-panel.tsx | 96 ++++-- src/components/global-tab-bar.tsx | 55 ++-- src/components/ui-mode-selector.tsx | 75 +++++ src/components/ui/sidebar.tsx | 6 +- src/electron.d.ts | 1 + src/launch/authenticated-stage.tsx | 36 ++- src/settings/app.tsx | 28 ++ src/store/ui-mode-store.ts | 46 +++ 14 files changed, 812 insertions(+), 66 deletions(-) create mode 100644 src/components/advanced-shell.tsx create mode 100644 src/components/ui-mode-selector.tsx create mode 100644 src/store/ui-mode-store.ts diff --git a/electron/main.ts b/electron/main.ts index eb9220c..9b912d9 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -20,6 +20,7 @@ import { createWorktree, listBranches, listWorktrees, + removeWorktree, resolveInstallCommand, samePath, switchWorktreeBranch, @@ -1503,6 +1504,37 @@ function registerIpc(): void { ); }); + ipcMain.handle("worktrees:delete", async (_event, input: { projectId: string; path: string }) => { + const project = getProject(input.projectId); + if (!project) throw new Error(`Project not found: ${input.projectId}`); + const target = listWorktrees(project.path).find( + (worktree) => !worktree.isProjectRoot && samePath(worktree.path, input.path), + ); + if (!target) throw new Error("Workspace is no longer available"); + const threads = listThreads().filter( + (thread) => + thread.project_id === input.projectId && + thread.worktree_path != null && + samePath(thread.worktree_path, target.path), + ); + const manager = requireAgentManager(); + + // Remove tabs first so deleting the active session cannot select another + // chat from the workspace that is about to disappear. + let tabs = await readOpenTabsState(); + for (const thread of threads) tabs = await closeThreadTab(thread.id); + for (const thread of threads) await manager.deleteThread(thread.id); + + const removed = removeWorktree(project.path, target.path); + await updateWorkspaceSelection(project.id, project.path); + if (tabs.activeThreadId && manager.getState().threadId !== tabs.activeThreadId) { + await manager.switchThread(tabs.activeThreadId); + } + if (!tabs.activeThreadId) await manager.clearActiveThread(); + broadcastOpenTabsChanged(mainWindow, tabs); + return removed; + }); + ipcMain.handle("shell:openExternal", async (_event, url: string) => { await ensureAuthCallbackServer(); const resolvedCallbackUrl = getAuthCallbackUrl(); diff --git a/electron/preload.ts b/electron/preload.ts index aef85d4..e1e233d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -176,6 +176,8 @@ const api = { ipcRenderer.invoke("worktrees:list", projectId), create: (input: { projectId: string; name: string }): Promise => ipcRenderer.invoke("worktrees:create", input), + delete: (input: { projectId: string; path: string }): Promise => + ipcRenderer.invoke("worktrees:delete", input), switch: (input: { projectId: string; path: string }): Promise => ipcRenderer.invoke("worktrees:switch", input), getSelections: (): Promise> => diff --git a/electron/worktree-manager.test.ts b/electron/worktree-manager.test.ts index 02b95b2..731f6ad 100644 --- a/electron/worktree-manager.test.ts +++ b/electron/worktree-manager.test.ts @@ -21,6 +21,7 @@ import { listChildWorktrees, listWorktrees, parseWorktreePorcelain, + removeWorktree, resolveInstallCommand, samePath, switchWorktreeBranch, @@ -258,6 +259,17 @@ describe("listWorktrees / isLiveWorktree", () => { expect(switched.isProjectRoot).toBe(true); expect(git(projectPath, ["branch", "--show-current"])).toBe("feature/header"); }); + + test("removes a linked worktree and its generated branch", () => { + const worktree = createWorktree({ projectPath, projectId: PROJECT_ID, name: "remove-me" }); + + const removed = removeWorktree(projectPath, worktree.path); + + expect(removed.path).toBe(worktree.path); + expect(existsSync(worktree.path)).toBe(false); + expect(listChildWorktrees(projectPath)).toEqual([]); + expect(git(projectPath, ["branch", "--list", "pipper/remove-me"])).toBe(""); + }); }); describe("parseWorktreePorcelain", () => { diff --git a/electron/worktree-manager.ts b/electron/worktree-manager.ts index 2a38455..3fa2fb6 100644 --- a/electron/worktree-manager.ts +++ b/electron/worktree-manager.ts @@ -634,6 +634,27 @@ export function listChildWorktrees(projectPath: string): Worktree[] { return listWorktrees(projectPath).filter((w) => !w.isProjectRoot); } +/** Remove a linked worktree and its generated branch. The project root is + * intentionally protected because removing it would destroy the project. */ +export function removeWorktree(projectPath: string, worktreePath: string): Worktree { + const target = listWorktrees(projectPath).find( + (worktree) => !worktree.isProjectRoot && samePath(worktree.path, worktreePath), + ); + if (!target) throw new Error("Workspace is no longer available"); + + git(projectPath, ["worktree", "remove", "--force", target.path]); + if (target.branch) { + try { + git(projectPath, ["branch", "-D", target.branch]); + } catch { + // A user-owned branch may be shared or protected; removing the worktree + // is still complete when Git refuses to delete that branch. + } + } + invalidateWorktreeCache(projectPath); + return target; +} + /** * True when a bound worktree path still points at a live worktree — used to * validate a thread's stored `worktree_path` before binding a session to it. diff --git a/src/App.tsx b/src/App.tsx index 936c9bf..34d1afa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,14 @@ import { Dropdown, DropdownSeparator } from "@/components/ui/dropdown"; import { MenuItem } from "@/components/ui/menu-item"; import { useLauncherUpdateStore } from "@/store/launcher-update-store"; import { reportStartupMilestone } from "@/lib/startup-timing"; -import { Bell, FolderPlus, GitBranch, GitDiffIcon, Plus , PlusMinusIcon} from "@phosphor-icons/react"; +import { + Bell, + FolderPlus, + GitBranch, + GitDiffIcon, + Plus, + PlusMinusIcon, +} from "@phosphor-icons/react"; import { SleeplessControl } from "@/components/sleepless-control"; import { ProjectThreadsDropdown } from "@/components/project-threads-dropdown"; @@ -44,10 +51,13 @@ import { startMonitorRuntimeObserver, } from "@/lib/monitor-runtime-observer"; import { useMonitorTabSync } from "@/lib/monitor-tab-sync"; +import { AdvancedShell } from "@/components/advanced-shell"; +import { useUiModeStore } from "@/store/ui-mode-store"; const EMPTY_WORKTREES: Worktree[] = []; export default function App() { + const uiMode = useUiModeStore((state) => state.mode); const { activeProject, loadActiveProject, isLoading, error: projectError } = useProjectStore(); // ── Workspace view routing ──────────────────────────────────────────── @@ -434,6 +444,7 @@ export default function App() { useEffect(() => { if (!window.omni?.worktrees?.onSetupProgress) return; return window.omni.worktrees.onSetupProgress((progress) => { + if (uiMode === "advanced") return; if (progress.status === "installing") { toast({ icon: , @@ -455,7 +466,7 @@ export default function App() { } // "skipped" (no package.json) is intentionally silent. }); - }, []); + }, [uiMode]); // Terminals belong to their workspace: entering another workspace (picker // switch, project switch, cross-workspace activation) stashes the visible @@ -518,6 +529,10 @@ export default function App() { ); } + if (uiMode === "advanced") { + return ; + } + return (
{/* Headless: keeps the diff store fed from the active agent thread. */} diff --git a/src/components/advanced-shell.tsx b/src/components/advanced-shell.tsx new file mode 100644 index 0000000..0f222e3 --- /dev/null +++ b/src/components/advanced-shell.tsx @@ -0,0 +1,449 @@ +import { useEffect, useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Archive, + FunnelSimple, + FolderPlus, + GitBranch, + Plus, + Trash, + Wrench, +} from "@phosphor-icons/react"; +import type { Project } from "../../contracts/projects.ts"; +import type { Worktree } from "../../contracts/worktrees.ts"; +import { AgentView } from "@/components/agent-view"; +import { DiffIngestor } from "@/components/diff-ingestor"; +import { GlobalTabBar } from "@/components/global-tab-bar"; +import { TerminalSession } from "@/components/terminal-session"; +import { Toaster } from "@/components/ui/toaster"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, +} from "@/components/ui/sidebar"; +import { Button } from "@/components/ui/button"; +import { ProjectIcon } from "@/components/ui/icon-picker"; +import { useProjectStore } from "@/store/project-store"; +import { useThreadStore } from "@/store/thread-store"; +import { useTerminalStore } from "@/store/terminal-store"; +import { useWorktreeStore } from "@/store/worktree-store"; +import { useWorkspaceViewStore } from "@/store/workspace-view-store"; +import { cn } from "@/lib/utils"; +import { normalizeWorkspacePath } from "../../contracts/workspace-scope.ts"; + +function WorkspaceNameDialog({ + project, + isCreating, + onCancel, + onSubmit, +}: { + project: Project; + isCreating: boolean; + onCancel: () => void; + onSubmit: (name: string) => void; +}) { + const [name, setName] = useState(""); + + return ( +
+
{ + event.preventDefault(); + if (name.trim()) onSubmit(name.trim()); + }} + > +
+

New workspace

+

+ Create an isolated workspace in {project.name}. +

+
+ +
+ + +
+
+
+ ); +} + +function WorkspaceRow({ + worktree, + selected, + onSelect, + onArchive, + onDelete, + archived, +}: { + worktree: Worktree; + selected: boolean; + onSelect: () => void; + onArchive?: () => void; + onDelete?: () => void; + archived?: boolean; +}) { + const [menuOpen, setMenuOpen] = useState(false); + const name = worktree.workspaceName ?? (worktree.isProjectRoot ? "Main" : "Workspace"); + + return ( +
+ + {menuOpen && ( +
+ + +
+ )} +
+ ); +} + +export function AdvancedShell() { + const activeProject = useProjectStore((state) => state.activeProject); + const queryClient = useQueryClient(); + const { + selectedWorktreePathByProject, + isCreating, + loadWorktrees, + createWorktree, + switchWorktree, + syncSelections, + } = useWorktreeStore(); + const loadProjectThreads = useThreadStore((state) => state.loadProjectThreads); + const [projects, setProjects] = useState([]); + const [worktreesByProject, setWorktreesByProject] = useState>({}); + const [dialogProject, setDialogProject] = useState(null); + const [archivedKeys, setArchivedKeys] = useState>(() => { + try { + return new Set(JSON.parse(window.localStorage.getItem("pipper.archived-workspaces") ?? "[]")); + } catch { + return new Set(); + } + }); + + const selectedPath = activeProject + ? normalizeWorkspacePath(selectedWorktreePathByProject[activeProject.id], activeProject.path) + : null; + + useEffect(() => { + void window.omni.projects + .list() + .then((items) => setProjects(items.sort((a, b) => a.name.localeCompare(b.name)))) + .catch(() => setProjects([])); + const unsubscribe = window.omni.projects.onListChanged?.((project) => { + setProjects((current) => + [...current.filter((item) => item.id !== project.id), project].sort((a, b) => + a.name.localeCompare(b.name), + ), + ); + }); + return unsubscribe; + }, []); + + useEffect(() => { + if (projects.length === 0) return; + let cancelled = false; + void Promise.all( + projects.map(async (project) => { + try { + return [project.id, await window.omni.worktrees.list(project.id)] as const; + } catch { + return [project.id, []] as const; + } + }), + ).then((entries) => { + if (cancelled) return; + setWorktreesByProject(Object.fromEntries(entries)); + }); + return () => { + cancelled = true; + }; + }, [projects]); + + useEffect(() => { + if (!activeProject) return; + void loadWorktrees(activeProject.id); + void loadProjectThreads(activeProject.id, { reset: true }); + }, [activeProject?.id, loadProjectThreads, loadWorktrees]); + + const terminalTabsRevision = useTerminalStore((state) => state.tabsRevision); + const terminalSessions = useMemo( + () => + useTerminalStore.getState().sessions.map((session) => ({ id: session.id, cwd: session.cwd })), + [terminalTabsRevision], + ); + const workspaceMode = useWorkspaceViewStore((state) => state.mode); + const activeTerminalId = useWorkspaceViewStore((state) => state.activeTerminalId); + const showTerminalView = workspaceMode === "terminal" && activeTerminalId != null; + + const openProject = async (project: Project) => { + if (project.id !== activeProject?.id) await window.omni.projects.setActive(project.id); + }; + + const createWorkspace = async (name: string) => { + if (!dialogProject) return; + const project = dialogProject; + const worktree = await createWorktree(project.id, name); + setDialogProject(null); + if (!worktree) return; + const thread = await selectWorkspace(project, worktree.path); + if (thread) { + await window.omni.threads.rename(thread.id, name); + void queryClient.invalidateQueries({ queryKey: ["open-tabs"] }); + } + setWorktreesByProject((current) => ({ + ...current, + [project.id]: [...(current[project.id] ?? []), worktree], + })); + await loadWorktrees(project.id); + }; + + const workspaceKey = (projectId: string, path: string) => `${projectId}:${path}`; + const persistArchived = (next: Set) => { + setArchivedKeys(next); + try { + window.localStorage.setItem("pipper.archived-workspaces", JSON.stringify([...next])); + } catch { + // Keep the current session state when storage is unavailable. + } + }; + const archiveWorkspace = async (project: Project, worktree: Worktree) => { + if (worktree.isProjectRoot) return; + if (project.id === activeProject?.id && worktree.path === selectedPath) { + await switchWorktree(project.id, project.path); + } + const next = new Set(archivedKeys); + next.add(workspaceKey(project.id, worktree.path)); + persistArchived(next); + }; + const restoreWorkspace = (project: Project, worktree: Worktree) => { + const next = new Set(archivedKeys); + next.delete(workspaceKey(project.id, worktree.path)); + persistArchived(next); + }; + const deleteWorkspace = async (project: Project, worktree: Worktree) => { + if (worktree.isProjectRoot) return; + if ( + !window.confirm( + `Delete workspace "${worktree.workspaceName ?? "Workspace"}" and all of its chats?`, + ) + ) + return; + await window.omni.worktrees.delete({ projectId: project.id, path: worktree.path }); + const next = new Set(archivedKeys); + next.delete(workspaceKey(project.id, worktree.path)); + persistArchived(next); + setWorktreesByProject((current) => ({ + ...current, + [project.id]: (current[project.id] ?? []).filter((item) => item.path !== worktree.path), + })); + await syncSelections(); + if (project.id === activeProject?.id) await loadWorktrees(project.id); + }; + + const selectWorkspace = async (project: Project, path: string) => { + if (project.id !== activeProject?.id) await window.omni.projects.setActive(project.id); + await switchWorktree(project.id, path); + }; + + return ( + +
+ + +
+ + + + {projects.map((project) => { + const active = activeProject?.id === project.id; + return ( + +
+ void openProject(project)} + > + + {project.name} + + +
+
+ {(worktreesByProject[project.id] ?? []) + .filter((worktree) => !worktree.isProjectRoot) + .filter( + (worktree) => + !archivedKeys.has(workspaceKey(project.id, worktree.path)), + ) + .map((worktree) => ( + void selectWorkspace(project, worktree.path)} + onArchive={() => void archiveWorkspace(project, worktree)} + onDelete={() => void deleteWorkspace(project, worktree)} + /> + ))} + {(worktreesByProject[project.id] ?? []) + .filter((worktree) => !worktree.isProjectRoot) + .filter((worktree) => + archivedKeys.has(workspaceKey(project.id, worktree.path)), + ) + .map((worktree) => ( + void selectWorkspace(project, worktree.path)} + onArchive={() => restoreWorkspace(project, worktree)} + onDelete={() => void deleteWorkspace(project, worktree)} + /> + ))} +
+
+ ); + })} +
+
+ +
+ + Projects + +
+ + +
+
+
+
+ +
+
+
+
+
+ +
+
+
+ + {terminalSessions.map((session) => { + const active = showTerminalView && activeTerminalId === session.id; + return ( +
+ +
+ ); + })} +
+
+ +
+
+
+
+ {dialogProject && ( + setDialogProject(null)} + onSubmit={(name) => void createWorkspace(name)} + /> + )} +
+ ); +} diff --git a/src/components/agent-panel.tsx b/src/components/agent-panel.tsx index 45ac228..8d45346 100644 --- a/src/components/agent-panel.tsx +++ b/src/components/agent-panel.tsx @@ -48,7 +48,6 @@ import { selectThread } from "@/lib/thread-actions"; import { MarkdownRenderer } from "@/components/ui/markdown-renderer"; import { AssistantTraceDeck } from "@/components/ui/assistant-trace-deck"; import { ThinkingIndicator } from "@/components/ui/thinking-indicator"; -import { AmbientPixelField } from "@/components/ambient-pixel-field"; import { AgentSlashCommandMenu } from "@/components/agent-slash-command-menu"; import { AgentContinueMenu } from "@/components/agent-continue-menu"; import { AgentQuestionCard, AgentQuestionDock } from "@/components/agent-question"; @@ -94,6 +93,23 @@ const iconButtonClass = "inline-flex size-6 items-center justify-center rounded-full text-muted-foreground/60 hover:text-foreground hover:bg-hover transition-colors duration-100 cursor-pointer outline-none focus-visible:ring-1 focus-visible:ring-ring"; const COMPOSER_TURN_MARKER = ; +type ComposerProjectContext = { + id: string; + name: string; + icon?: string | null; +}; + +function withProjectChip( + content: ComposerContent, + project: ComposerProjectContext, +): ComposerContent { + const entities = getEntityTokens(content).filter((entity) => entity.kind !== "project"); + return buildContent( + [{ kind: "project", id: project.id, label: project.name, icon: project.icon }, ...entities], + getFreeText(content), + ); +} + type ConversationScrollMode = "reading" | "anchoring" | "following"; type PendingScrollAction = { kind: "restore" | "new-turn"; @@ -678,6 +694,15 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { (state) => state.markDraftUserEditedProject, ); const isDraftMode = draft != null; + const liveComposerProject = useMemo(() => { + if (isDraftMode) return null; + const projectId = snapshot?.projectId ?? activeProject?.id ?? null; + if (!projectId) return null; + return ( + projectsList.find((project) => project.id === projectId) ?? + (activeProject?.id === projectId ? activeProject : null) + ); + }, [activeProject, isDraftMode, projectsList, snapshot?.projectId]); const isDiffSplit = useIsDiffSplit(); const selectedWorktreePathByProject = useWorktreeStore( (state) => state.selectedWorktreePathByProject, @@ -695,7 +720,24 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { // automatically bootstrap into draft mode so the composer is always ready. useEffect(() => { if (window.omni?.benchmark?.enabled) return; - if (isDraftMode || requestedThreadId || snapshot?.threadId) return; + if (isDraftMode) { + // Project hydration can finish after the initial blank draft was + // created. Bind only untouched drafts so their project/CWD context is + // still auto-populated without overwriting user choices. + if (!draft?.projectId && !draft?.dirty && activeProject) { + const worktreePath = normalizeWorkspacePath( + selectedWorktreePathByProject[activeProject.id], + activeProject.path, + ); + beginDraft({ + projectId: activeProject.id, + previousActiveProjectId: activeProject.id, + worktreePath, + }); + } + return; + } + if (requestedThreadId || snapshot?.threadId) return; const project = activeProject; const worktreePath = project ? normalizeWorkspacePath(selectedWorktreePathByProject[project.id], project.path) @@ -707,6 +749,8 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { }); }, [ isDraftMode, + draft?.projectId, + draft?.dirty, requestedThreadId, snapshot?.threadId, activeProject, @@ -1496,13 +1540,16 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { if (!trimmed && !sendFiles.length && !editState?.images.length) return; } await handleSend(trimmed || getFreeText(textOnly), sendFiles); - setLiveContent(blankContent()); + setLiveContent( + liveComposerProject ? withProjectChip(blankContent(), liveComposerProject) : blankContent(), + ); setInputValue(""); }; const handleLiveContentChange = (next: ComposerContent) => { - setLiveContent(next); - setInputValue(getFreeText(next)); + const content = liveComposerProject ? withProjectChip(next, liveComposerProject) : next; + setLiveContent(content); + setInputValue(getFreeText(content)); }; const handleDraftSend = async (content: ComposerContent, files: File[]) => { @@ -1943,8 +1990,6 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { } }; - const currentProject = projectsList.find((p) => p.id === snapshot?.projectId) || activeProject; - const emptyStateSubject = currentProject?.name ?? "your project"; const visibleAgentError = agentError && agentError !== dismissedAgentError ? agentError : null; const runtimeControlsDisabled = isRuntimeActionPending || isSwitchingThread || isConnecting || !snapshot; @@ -2134,6 +2179,16 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { }); }, [inputValue, isDraftMode]); + // The active thread already knows its project context, so keep that context + // visible as a non-editable composer chip in both UI modes. + useEffect(() => { + if (isDraftMode || !liveComposerProject) return; + setLiveContent((prev) => { + if (extractProjectId(prev) === liveComposerProject.id) return prev; + return withProjectChip(prev, liveComposerProject); + }); + }, [isDraftMode, liveComposerProject]); + const handleReasoningChange = (value: string) => { if (!thoughtLevelConfigId) return; setConfigOption(thoughtLevelConfigId, value).catch((err) => { @@ -2189,18 +2244,6 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) {
- {/* Full-bleed: the ambient field spans the whole panel, while the - reading column below keeps the conversation and composer centred. */} - {allMessages.length === 0 && !isDraftMode && ( - - )}
{isDraftMode ? null : allMessages.length === 0 ? ( -
-

- - What should we cook in - - - {emptyStateSubject} - -

-
+
-
- {dialogProject && ( - void createWorkspace(name)} - onInitialize={() => void initializeDialogRepo()} - onRetryRepoState={() => void checkDialogRepoState(dialogProject.id)} - /> - )} - + {dialogProject && ( + void createWorkspace(name)} + onInitialize={() => void initializeDialogRepo()} + onRetryRepoState={() => void checkDialogRepoState(dialogProject.id)} + /> + )} + + ); } diff --git a/src/components/conversation-turn-identity.behavior.test.tsx b/src/components/conversation-turn-identity.behavior.test.tsx index f37466c..f5bbde8 100644 --- a/src/components/conversation-turn-identity.behavior.test.tsx +++ b/src/components/conversation-turn-identity.behavior.test.tsx @@ -1,6 +1,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, test } from "vitest"; import { ConversationTurnIdentity } from "@/components/conversation-turn-identity"; +import { toneIdentity } from "@/lib/workspace-tone"; describe("conversation turn identity", () => { test("uses the shared user identity as a noninteractive marker", () => { @@ -10,7 +11,7 @@ describe("conversation turn identity", () => { ); expect(html).toContain('data-pipper-id="user-turn-identity"'); - expect(html).toContain('fill="#26B25A"'); + expect(html).toContain(`fill="${toneIdentity("ready").bg}"`); expect(html).toContain("size-7"); expect(html).not.toContain("opacity-60"); expect(html).not.toContain("saturate-50"); diff --git a/src/components/conversation-turn-identity.tsx b/src/components/conversation-turn-identity.tsx index d678be4..496f844 100644 --- a/src/components/conversation-turn-identity.tsx +++ b/src/components/conversation-turn-identity.tsx @@ -4,6 +4,8 @@ import { memo } from "react"; import { StopIcon } from "@phosphor-icons/react"; import { motion, useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; +import { toneIdentity } from "@/lib/workspace-tone"; +import { useWorkspaceTone } from "@/lib/workspace-tone-context"; interface ConversationTurnIdentityProps { role: "user" | "assistant"; @@ -14,25 +16,26 @@ interface ConversationTurnIdentityProps { className?: string; } -const USER_IDENTITY_MARK = ( - -); +function UserIdentityMark({ fill, ink }: { fill: string; ink: string }) { + return ( + + ); +} const ASSISTANT_IDENTITY_MARK = ( - {USER_IDENTITY_MARK} + ); } diff --git a/src/components/workspace-control-panel.tsx b/src/components/workspace-control-panel.tsx index 161bb28..8ee7784 100644 --- a/src/components/workspace-control-panel.tsx +++ b/src/components/workspace-control-panel.tsx @@ -141,28 +141,17 @@ function actionLabel(kind: string): string { * - stale: no PR and nothing of the user's own to save or share, but the * base branch has moved on — catching up is the next step */ -export type HeaderTone = "neutral" | "action" | "ready" | "merged" | "stale"; +import { type HeaderTone, HEADER_TONE_FILL, toneInsetShadow, toneWash } from "@/lib/workspace-tone"; -/** Shared tone gradients: the panel header and the sidebar's active workspace - * card both paint from this map so the two stay in lockstep. */ -export const HEADER_TONE_GRADIENT: Record = { - neutral: "from-zinc-300/50 via-zinc-600/20 to-zinc-600/0", - action: "from-[#FFAA4F] via-[#6F5121] to-[#6F5121]/0", - ready: "from-[#088139] via-[#114526] to-[#114526]/0", - merged: "from-violet-500/80 via-violet-900/25 to-violet-900/0", - stale: "from-[#5CA8FF] via-[#1F3F66] to-[#1F3F66]/0", -}; - -/** Solid tone colours, used where the state has to read as a colour rather - * than a fill (the sidebar active workspace card's inset shadow). Based on - * each gradient's `from` colour, tuned a touch darker for the glow. */ -export const HEADER_TONE_COLOR: Record = { - neutral: "#a1a1aa", - action: "#E08A2E", - ready: "#088139", - merged: "#8b5cf6", - stale: "#5CA8FF", -}; +// Re-exported so existing consumers keep importing the tone vocabulary from +// this component; the source of truth is `@/lib/workspace-tone`. +export { + type HeaderTone, + HEADER_TONE_GRADIENT, + HEADER_TONE_COLOR, + toneInsetShadow, + toneWash, +} from "@/lib/workspace-tone"; function headerTone(status: WorkspaceGitStatus, dirtyCount: number): HeaderTone { if (!status.openPrNumber) { @@ -896,7 +885,7 @@ export function WorkspaceControlPanel({ : null; return ( {action === "mergePr" ? "Merging…" : "Merge"} @@ -986,7 +976,7 @@ export function WorkspaceControlPanel({ : []; return ( {action === "delete" ? "Deleting…" : "Delete"} @@ -1107,15 +1098,30 @@ export function WorkspaceControlPanel({
) : ( <> - {/* Gradient state header: PR pill + state action, Check/Changes - tabs. Sticky with an opaque surface-1 base so it stays put - while the tab content scrolls beneath it. */} + {/* State header: PR pill + state action, Check/Changes tabs. + Sticky with an opaque surface-1 base so it stays put while the + tab content scrolls beneath it. The git state reads as an + inset glow (same tone as the sidebar's active workspace card) + rather than a gradient fill, so the header keeps the parent + background; the controls sit on top of the glow unaffected. + The glow lives on a layer behind the content and is masked + away toward the bottom edge, so the header flows into the + body instead of casting a band across it. The header's own + background is a smooth tone wash over surface-1 (not flat + black) that eases out at the same bottom edge. */}
+