From 4bea212fa559affddc84ca3e0ccd7f83a2d5d314 Mon Sep 17 00:00:00 2001 From: OpenSource Date: Sat, 21 Mar 2026 03:31:02 +0100 Subject: [PATCH 1/4] feat: chat organization with folders, pinning, and sidebar grouping Co-Authored-By: Claude Opus 4.6 (1M context) --- electron/src/ipc/claude-sessions.ts | 32 +- electron/src/ipc/files.ts | 62 ++ electron/src/ipc/folders.ts | 149 +++++ electron/src/ipc/sessions.ts | 42 ++ electron/src/lib/data-dir.ts | 6 + electron/src/main.ts | 2 + electron/src/preload.ts | 14 + shared/lib/session-persistence.ts | 9 + src/components/AppLayout.tsx | 5 +- src/components/AppSidebar.tsx | 30 +- src/components/InputBar.tsx | 143 ++--- src/components/ProjectFilesPanel.tsx | 426 ++++++++++++-- src/components/SettingsView.tsx | 8 +- .../settings/AppearanceSettings.tsx | 15 + src/components/sidebar/BranchSection.tsx | 105 ++++ src/components/sidebar/FolderSection.tsx | 221 +++++++ src/components/sidebar/PinnedSection.tsx | 84 +++ src/components/sidebar/ProjectSection.tsx | 537 ++++++++++-------- src/components/sidebar/SessionItem.tsx | 150 ++++- src/components/ui/button.tsx | 2 +- src/components/ui/dialog.tsx | 6 +- src/components/ui/dropdown-menu.tsx | 50 +- src/components/ui/popover.tsx | 2 +- src/components/ui/select.tsx | 6 +- src/components/ui/slider.tsx | 2 +- src/components/ui/switch.tsx | 2 +- src/components/ui/tabs.tsx | 2 +- src/components/ui/tooltip.tsx | 2 +- src/hooks/session/types.ts | 2 + src/hooks/session/useDraftMaterialization.ts | 2 + src/hooks/useAppOrchestrator.ts | 134 ++++- src/hooks/useSessionManager.ts | 8 + src/hooks/useSettings.ts | 29 + src/index.css | 29 +- src/lib/sidebar-grouping.ts | 288 ++++++++++ src/types/index.ts | 1 + src/types/ui.ts | 18 + src/types/window.d.ts | 23 + 38 files changed, 2267 insertions(+), 381 deletions(-) create mode 100644 electron/src/ipc/folders.ts create mode 100644 src/components/sidebar/BranchSection.tsx create mode 100644 src/components/sidebar/FolderSection.tsx create mode 100644 src/components/sidebar/PinnedSection.tsx create mode 100644 src/lib/sidebar-grouping.ts diff --git a/electron/src/ipc/claude-sessions.ts b/electron/src/ipc/claude-sessions.ts index 2276af0..f16f8fb 100644 --- a/electron/src/ipc/claude-sessions.ts +++ b/electron/src/ipc/claude-sessions.ts @@ -74,6 +74,27 @@ async function setSessionPermissionMode( log(logLabel, `session=${sessionId.slice(0, 8)} mode=${permissionMode}`); } +/** + * Explicitly set permissionMode on a freshly created query handle. + * The SDK CLI may ignore the `permissionMode` query option for resumed sessions + * (it loads saved state from disk). Calling setPermissionMode() on the live handle + * is guaranteed to take effect. + */ +async function enforcePermissionMode( + sessionId: string, + queryHandle: QueryHandle, + permissionMode: string | undefined, + context: string, +): Promise { + if (!permissionMode || permissionMode === "default") return; + try { + await queryHandle.setPermissionMode(permissionMode); + log("PERMISSION_MODE_ENFORCED", `session=${sessionId.slice(0, 8)} mode=${permissionMode} (${context})`); + } catch (err) { + reportError("PERMISSION_MODE_ENFORCE_ERR", err, { engine: "claude", sessionId, permissionMode, context }); + } +} + function summarizeSpawnOptions(options: Record): Record { const mcpServers = options.mcpServers; const mcpSummary = mcpServers && typeof mcpServers === "object" @@ -115,7 +136,7 @@ function summarizeEvent(event: Record): string { switch (event.type) { case "system": { if (event.subtype === "init") { - return `system/init session=${(event.session_id as string)?.slice(0, 8)} model=${event.model}`; + return `system/init session=${(event.session_id as string)?.slice(0, 8)} model=${event.model} permMode=${event.permissionMode ?? "?"}`; } if (event.subtype === "task_started") { return `system/task_started task=${(event.task_id as string)?.slice(0, 8)} tool_use=${(event.tool_use_id as string)?.slice(0, 12)} desc="${event.description}"`; @@ -593,6 +614,9 @@ async function restartSession( return { error: `Restart failed: ${errMsg}` }; } + // Restarted sessions always resume — enforce permission mode on the live handle. + await enforcePermissionMode(sessionId, q, opts.permissionMode, "restart"); + startEventLoop(sessionId, q, newSession, getMainWindow); return { ok: true, restarted: true }; @@ -692,6 +716,12 @@ export function register(getMainWindow: () => BrowserWindow | null): void { const q = query({ prompt: channel, options: queryOptions }); session.queryHandle = q; + // For resumed sessions, explicitly enforce the permission mode on the live + // handle — the SDK CLI may load its own saved state and ignore the query option. + if (options.resume) { + await enforcePermissionMode(sessionId, q, options.permissionMode, "start-resume"); + } + startEventLoop(sessionId, q, session, getMainWindow); void captureEvent("session_created", { diff --git a/electron/src/ipc/files.ts b/electron/src/ipc/files.ts index c76cf6c..73dffc7 100644 --- a/electron/src/ipc/files.ts +++ b/electron/src/ipc/files.ts @@ -273,6 +273,68 @@ export function register(getMainWindow: () => BrowserWindow | null): void { } }); + ipcMain.handle("shell:show-item-in-folder", async (_event, filePath: string) => { + try { + shell.showItemInFolder(filePath); + return { ok: true }; + } catch (err) { + const errMsg = reportError("SHELL:SHOW_ITEM_ERR", err, { filePath }); + return { error: errMsg }; + } + }); + + ipcMain.handle("file:rename", async (_event, { oldPath, newPath }: { oldPath: string; newPath: string }) => { + try { + // Ensure target doesn't already exist + if (fs.existsSync(newPath)) { + return { error: "A file or folder with that name already exists" }; + } + await fsPromises.rename(oldPath, newPath); + return { ok: true }; + } catch (err) { + const errMsg = reportError("FILE:RENAME_ERR", err, { oldPath, newPath }); + return { error: errMsg }; + } + }); + + ipcMain.handle("file:trash", async (_event, filePath: string) => { + try { + await shell.trashItem(filePath); + return { ok: true }; + } catch (err) { + const errMsg = reportError("FILE:TRASH_ERR", err, { filePath }); + return { error: errMsg }; + } + }); + + ipcMain.handle("file:new-file", async (_event, filePath: string) => { + try { + if (fs.existsSync(filePath)) { + return { error: "A file with that name already exists" }; + } + // Ensure parent directory exists + await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); + await fsPromises.writeFile(filePath, "", "utf-8"); + return { ok: true }; + } catch (err) { + const errMsg = reportError("FILE:NEW_FILE_ERR", err, { filePath }); + return { error: errMsg }; + } + }); + + ipcMain.handle("file:new-folder", async (_event, folderPath: string) => { + try { + if (fs.existsSync(folderPath)) { + return { error: "A folder with that name already exists" }; + } + await fsPromises.mkdir(folderPath, { recursive: true }); + return { ok: true }; + } catch (err) { + const errMsg = reportError("FILE:NEW_FOLDER_ERR", err, { folderPath }); + return { error: errMsg }; + } + }); + ipcMain.handle("files:list", async (_event, cwd: string) => { try { const files = await listProjectFiles(cwd); diff --git a/electron/src/ipc/folders.ts b/electron/src/ipc/folders.ts new file mode 100644 index 0000000..34b0c7b --- /dev/null +++ b/electron/src/ipc/folders.ts @@ -0,0 +1,149 @@ +import { ipcMain } from "electron"; +import fs from "fs"; +import path from "path"; +import crypto from "crypto"; +import { getProjectFoldersFilePath, getProjectSessionsDir } from "../lib/data-dir"; +import { reportError } from "../lib/error-utils"; + +interface ChatFolder { + id: string; + projectId: string; + name: string; + createdAt: number; + order: number; + pinned?: boolean; +} + +function readFolders(projectId: string): ChatFolder[] { + const filePath = getProjectFoldersFilePath(projectId); + if (!fs.existsSync(filePath)) return []; + try { + return JSON.parse(fs.readFileSync(filePath, "utf-8")); + } catch { + return []; + } +} + +function writeFolders(projectId: string, folders: ChatFolder[]): void { + const filePath = getProjectFoldersFilePath(projectId); + fs.writeFileSync(filePath, JSON.stringify(folders, null, 2), "utf-8"); +} + +/** + * Clear folderId from all session files that reference a deleted folder. + * Patches both .json and .meta.json files. + */ +async function clearFolderFromSessions(projectId: string, folderId: string): Promise { + const dir = getProjectSessionsDir(projectId); + let files: string[]; + try { + files = await fs.promises.readdir(dir); + } catch { + return; // no sessions dir + } + + const metaFiles = files.filter((f) => f.endsWith(".meta.json")); + for (const metaFile of metaFiles) { + try { + const metaPath = path.join(dir, metaFile); + const raw = await fs.promises.readFile(metaPath, "utf-8"); + const meta = JSON.parse(raw); + if (meta.folderId !== folderId) continue; + + // Clear from meta sidecar + meta.folderId = undefined; + await fs.promises.writeFile(metaPath, JSON.stringify(meta), "utf-8"); + + // Clear from main session file + const mainFile = metaFile.replace(/\.meta\.json$/, ".json"); + const mainPath = path.join(dir, mainFile); + try { + const mainRaw = await fs.promises.readFile(mainPath, "utf-8"); + const mainData = JSON.parse(mainRaw); + if (mainData.folderId === folderId) { + mainData.folderId = undefined; + await fs.promises.writeFile(mainPath, JSON.stringify(mainData), "utf-8"); + } + } catch { + // main file missing or corrupted — skip + } + } catch { + // skip corrupted meta files + } + } +} + +export function register(): void { + ipcMain.handle("folders:list", async (_event, projectId: string) => { + try { + return readFolders(projectId); + } catch (err) { + reportError("FOLDERS:LIST_ERR", err, { projectId }); + return []; + } + }); + + ipcMain.handle("folders:create", async (_event, { projectId, name }: { projectId: string; name: string }) => { + try { + const folders = readFolders(projectId); + const maxOrder = folders.reduce((max, f) => Math.max(max, f.order), -1); + const folder: ChatFolder = { + id: crypto.randomUUID(), + projectId, + name, + createdAt: Date.now(), + order: maxOrder + 1, + }; + folders.push(folder); + writeFolders(projectId, folders); + return folder; + } catch (err) { + const message = reportError("FOLDERS:CREATE_ERR", err, { projectId }); + return { error: message }; + } + }); + + ipcMain.handle("folders:delete", async (_event, { projectId, folderId }: { projectId: string; folderId: string }) => { + try { + const folders = readFolders(projectId); + const updated = folders.filter((f) => f.id !== folderId); + writeFolders(projectId, updated); + // Clear folderId from all sessions referencing this folder + await clearFolderFromSessions(projectId, folderId); + return { ok: true }; + } catch (err) { + const message = reportError("FOLDERS:DELETE_ERR", err, { projectId, folderId }); + return { error: message }; + } + }); + + ipcMain.handle("folders:rename", async (_event, { projectId, folderId, name }: { projectId: string; folderId: string; name: string }) => { + try { + const folders = readFolders(projectId); + const folder = folders.find((f) => f.id === folderId); + if (folder) { + folder.name = name; + writeFolders(projectId, folders); + } + return { ok: true }; + } catch (err) { + const message = reportError("FOLDERS:RENAME_ERR", err, { projectId, folderId }); + return { error: message }; + } + }); + + ipcMain.handle("folders:pin", async (_event, { projectId, folderId, pinned }: { projectId: string; folderId: string; pinned: boolean }) => { + try { + const folders = readFolders(projectId); + const folder = folders.find((f) => f.id === folderId); + if (folder) { + folder.pinned = pinned || undefined; + writeFolders(projectId, folders); + } + return { ok: true }; + } catch (err) { + const message = reportError("FOLDERS:PIN_ERR", err, { projectId, folderId }); + return { error: message }; + } + }); +} diff --git a/electron/src/ipc/sessions.ts b/electron/src/ipc/sessions.ts index bebafaf..cff96dd 100644 --- a/electron/src/ipc/sessions.ts +++ b/electron/src/ipc/sessions.ts @@ -131,6 +131,48 @@ export function register(): void { } }); + ipcMain.handle("sessions:update-meta", async ( + _event, + { projectId, sessionId, patch }: { + projectId: string; + sessionId: string; + patch: { pinned?: boolean; folderId?: string | null; branch?: string }; + }, + ) => { + try { + // Patch the .meta.json sidecar + const metaPath = getMetaFilePath(projectId, sessionId); + try { + const metaRaw = await fs.promises.readFile(metaPath, "utf-8"); + const meta = JSON.parse(metaRaw); + if ("pinned" in patch) meta.pinned = patch.pinned || undefined; + if ("folderId" in patch) meta.folderId = patch.folderId || undefined; + if ("branch" in patch) meta.branch = patch.branch || undefined; + await fs.promises.writeFile(metaPath, JSON.stringify(meta), "utf-8"); + } catch { + // meta sidecar missing — will be recreated on next full save + } + + // Patch the main .json file (read → merge → write) + const filePath = getSessionFilePath(projectId, sessionId); + try { + const raw = await fs.promises.readFile(filePath, "utf-8"); + const data = JSON.parse(raw); + if ("pinned" in patch) data.pinned = patch.pinned || undefined; + if ("folderId" in patch) data.folderId = patch.folderId || undefined; + if ("branch" in patch) data.branch = patch.branch || undefined; + await fs.promises.writeFile(filePath, JSON.stringify(data), "utf-8"); + } catch { + // main file missing — nothing to patch + } + + return { ok: true }; + } catch (err) { + const message = reportError("SESSIONS:UPDATE_META_ERR", err, { projectId, sessionId }); + return { error: message }; + } + }); + ipcMain.handle("sessions:delete", async (_event, projectId: string, sessionId: string) => { try { const filePath = getSessionFilePath(projectId, sessionId); diff --git a/electron/src/lib/data-dir.ts b/electron/src/lib/data-dir.ts index 9bacebb..f539129 100644 --- a/electron/src/lib/data-dir.ts +++ b/electron/src/lib/data-dir.ts @@ -17,3 +17,9 @@ export function getProjectSessionsDir(projectId: string): string { export function getSessionFilePath(projectId: string, sessionId: string): string { return path.join(getProjectSessionsDir(projectId), `${sessionId}.json`); } + +export function getProjectFoldersFilePath(projectId: string): string { + const dir = path.join(getDataDir(), "folders"); + fs.mkdirSync(dir, { recursive: true }); + return path.join(dir, `${projectId}.json`); +} diff --git a/electron/src/main.ts b/electron/src/main.ts index afaf703..759584e 100644 --- a/electron/src/main.ts +++ b/electron/src/main.ts @@ -33,6 +33,7 @@ import { terminals } from "./ipc/terminal"; import * as spacesIpc from "./ipc/spaces"; import * as projectsIpc from "./ipc/projects"; import * as sessionsIpc from "./ipc/sessions"; +import * as foldersIpc from "./ipc/folders"; import * as ccImportIpc from "./ipc/cc-import"; import * as filesIpc from "./ipc/files"; import * as claudeSessionsIpc from "./ipc/claude-sessions"; @@ -185,6 +186,7 @@ ipcMain.on("app:set-min-width", (_event, minWidth: number) => { spacesIpc.register(); projectsIpc.register(getMainWindow); sessionsIpc.register(); +foldersIpc.register(); ccImportIpc.register(); filesIpc.register(getMainWindow); claudeSessionsIpc.register(getMainWindow); diff --git a/electron/src/preload.ts b/electron/src/preload.ts index 09a50dd..ed15b1a 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -95,9 +95,14 @@ contextBridge.exposeInMainWorld("claude", { restartSession: (sessionId: string, mcpServers?: unknown[], cwd?: string, effort?: string, model?: string) => ipcRenderer.invoke("claude:restart-session", { sessionId, mcpServers, cwd, effort, model }), readFile: (filePath: string) => ipcRenderer.invoke("file:read", filePath), + renameFile: (oldPath: string, newPath: string) => ipcRenderer.invoke("file:rename", { oldPath, newPath }), + trashItem: (filePath: string) => ipcRenderer.invoke("file:trash", filePath), + newFile: (filePath: string) => ipcRenderer.invoke("file:new-file", filePath), + newFolder: (folderPath: string) => ipcRenderer.invoke("file:new-folder", folderPath), writeClipboardText: (text: string) => ipcRenderer.invoke("clipboard:write-text", text), openInEditor: (filePath: string, line?: number, editor?: string) => ipcRenderer.invoke("file:open-in-editor", { filePath, line, editor }), openExternal: (url: string) => ipcRenderer.invoke("shell:open-external", url), + showItemInFolder: (filePath: string) => ipcRenderer.invoke("shell:show-item-in-folder", filePath), generateTitle: (message: string, cwd?: string, engine?: string, sessionId?: string) => ipcRenderer.invoke("claude:generate-title", { message, cwd, engine, sessionId }), projects: { @@ -116,6 +121,15 @@ contextBridge.exposeInMainWorld("claude", { list: (projectId: string) => ipcRenderer.invoke("sessions:list", projectId), delete: (projectId: string, sessionId: string) => ipcRenderer.invoke("sessions:delete", projectId, sessionId), search: (projectIds: string[], query: string) => ipcRenderer.invoke("sessions:search", { projectIds, query }), + updateMeta: (projectId: string, sessionId: string, patch: { pinned?: boolean; folderId?: string | null; branch?: string }) => + ipcRenderer.invoke("sessions:update-meta", { projectId, sessionId, patch }), + }, + folders: { + list: (projectId: string) => ipcRenderer.invoke("folders:list", projectId), + create: (projectId: string, name: string) => ipcRenderer.invoke("folders:create", { projectId, name }), + delete: (projectId: string, folderId: string) => ipcRenderer.invoke("folders:delete", { projectId, folderId }), + rename: (projectId: string, folderId: string, name: string) => ipcRenderer.invoke("folders:rename", { projectId, folderId, name }), + pin: (projectId: string, folderId: string, pinned: boolean) => ipcRenderer.invoke("folders:pin", { projectId, folderId, pinned }), }, spaces: { list: () => ipcRenderer.invoke("spaces:list"), diff --git a/shared/lib/session-persistence.ts b/shared/lib/session-persistence.ts index 7178cf3..576118c 100644 --- a/shared/lib/session-persistence.ts +++ b/shared/lib/session-persistence.ts @@ -14,6 +14,12 @@ export interface SessionMeta { totalCost?: number; engine?: "claude" | "acp" | "codex"; codexThreadId?: string; + /** Which folder this chat belongs to (undefined = root level). */ + folderId?: string; + /** Whether this chat is pinned to the top of the sidebar. */ + pinned?: boolean; + /** Git branch at session creation time. */ + branch?: string; } /** @@ -45,5 +51,8 @@ export function extractSessionMeta(data: Record, lastMessageAt: totalCost: (data.totalCost as number) || 0, engine: data.engine as SessionMeta["engine"], codexThreadId: data.codexThreadId as string | undefined, + folderId: data.folderId as string | undefined, + pinned: data.pinned as boolean | undefined, + branch: data.branch as string | undefined, }; } diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index df72cab..3329659 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -370,7 +370,7 @@ Link: ${issue.url}`; return (
{/* Glass tint overlay — sits behind content, tints the native transparency */} @@ -415,6 +415,7 @@ Link: ${issue.url}`; onCreateFolder={o.handleCreateFolder} onRenameFolder={o.handleRenameFolder} onDeleteFolder={o.handleDeleteFolder} + onPinFolder={o.handlePinFolder} onSetOrganizeByChatBranch={settings.setOrganizeByChatBranch} spaces={spaceManager.spaces} activeSpaceId={spaceManager.activeSpaceId} @@ -437,6 +438,8 @@ Link: ${issue.url}`; onThemeChange={settings.setTheme} islandLayout={settings.islandLayout} onIslandLayoutChange={settings.setIslandLayout} + islandShine={settings.islandShine} + onIslandShineChange={settings.setIslandShine} autoGroupTools={settings.autoGroupTools} onAutoGroupToolsChange={settings.setAutoGroupTools} avoidGroupingEdits={settings.avoidGroupingEdits} diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 05aede9..4c35af9 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -3,7 +3,7 @@ import { Bug, PanelLeft, Plus } from "lucide-react"; import { isMac } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; -import type { ChatSession, InstalledAgent, Project, Space } from "@/types"; +import type { ChatFolder, ChatSession, InstalledAgent, Project, Space } from "@/types"; import { APP_SIDEBAR_WIDTH } from "@/lib/layout-constants"; import { SidebarSearch } from "./SidebarSearch"; import { SpaceBar } from "./SpaceBar"; @@ -18,6 +18,8 @@ interface AppSidebarProps { activeSessionId: string | null; jiraBoardProjectId: string | null; jiraBoardEnabled: boolean; + foldersByProject: Record; + organizeByChatBranch: boolean; onNewChat: (projectId: string) => void; onToggleProjectJiraBoard: (projectId: string) => void; onSelectSession: (id: string) => void; @@ -32,6 +34,13 @@ interface AppSidebarProps { onNavigateToMessage: (sessionId: string, messageId: string) => void; onMoveProjectToSpace: (projectId: string, spaceId: string) => void; onReorderProject: (projectId: string, targetProjectId: string) => void; + onPinSession: (sessionId: string, pinned: boolean) => void; + onMoveSessionToFolder: (sessionId: string, folderId: string | null) => void; + onCreateFolder: (projectId: string) => void; + onRenameFolder: (projectId: string, folderId: string, name: string) => void; + onDeleteFolder: (projectId: string, folderId: string) => void; + onPinFolder: (projectId: string, folderId: string, pinned: boolean) => void; + onSetOrganizeByChatBranch: (on: boolean) => void; spaces: Space[]; activeSpaceId: string; onSelectSpace: (id: string) => void; @@ -50,6 +59,8 @@ export const AppSidebar = memo(function AppSidebar({ activeSessionId, jiraBoardProjectId, jiraBoardEnabled, + foldersByProject, + organizeByChatBranch, onNewChat, onToggleProjectJiraBoard, onSelectSession, @@ -64,6 +75,13 @@ export const AppSidebar = memo(function AppSidebar({ onNavigateToMessage, onMoveProjectToSpace, onReorderProject, + onPinSession, + onMoveSessionToFolder, + onCreateFolder, + onRenameFolder, + onDeleteFolder, + onPinFolder, + onSetOrganizeByChatBranch, spaces, activeSpaceId, onSelectSpace, @@ -219,6 +237,7 @@ export const AppSidebar = memo(function AppSidebar({ const projectSessions = sessions.filter( (s) => s.projectId === project.id, ); + const projectFolders = foldersByProject[project.id] ?? []; return ( onNewChat(project.id)} onToggleJiraBoard={() => onToggleProjectJiraBoard(project.id)} onSelectSession={onSelectSession} @@ -250,6 +271,13 @@ export const AppSidebar = memo(function AppSidebar({ onReorderProject(project.id, targetId) } defaultChatLimit={defaultChatLimit} + onPinSession={onPinSession} + onMoveSessionToFolder={onMoveSessionToFolder} + onCreateFolder={() => onCreateFolder(project.id)} + onRenameFolder={onRenameFolder} + onDeleteFolder={onDeleteFolder} + onPinFolder={onPinFolder} + onSetOrganizeByChatBranch={onSetOrganizeByChatBranch} agents={agents} /> ); diff --git a/src/components/InputBar.tsx b/src/components/InputBar.tsx index 31fb821..e3ce13a 100644 --- a/src/components/InputBar.tsx +++ b/src/components/InputBar.tsx @@ -139,8 +139,10 @@ function ModelDropdown({ return ( - + + {modelList.map((m) => { @@ -227,13 +229,11 @@ function PermissionDropdown({ return ( - + + {PERMISSION_MODES.map((m) => { @@ -275,21 +275,23 @@ function PlanModeToggle({ return ( - +

- {planMode ? "Plan mode on" : "Plan mode off"} ({isMac ? "⌘" : "Ctrl"}+⇧+P) + {planMode ? "Plan mode on" : "Plan mode off"} (⇧+Tab)

@@ -371,14 +373,11 @@ function EngineControls({ {codexEffortOptions.length > 0 && onCodexEffortChange && ( - + + {codexEffortOptions.map((opt) => ( @@ -411,14 +410,11 @@ function EngineControls({ {onAcpPermissionBehaviorChange && ( - + + {ACP_PERMISSION_BEHAVIORS.map((b) => ( @@ -444,13 +440,10 @@ function EngineControls({ return ( - + + {flat.map((o) => ( @@ -510,6 +503,11 @@ const CLAUDE_EFFORT_DESCRIPTIONS: Record = { max: "Maximum effort", }; +/** Shared className overrides for ghost toolbar buttons in the input bar. + * Applied on top of `
{/* Remove button — top-right, stops propagation to prevent opening editor */} - + + ))} @@ -1664,12 +1664,14 @@ export const InputBar = memo(function InputBar({ )} - + + ))} @@ -1690,24 +1692,28 @@ export const InputBar = memo(function InputBar({
{/* Left controls — scrollable as a defensive fallback (should never trigger with proper MIN_CHAT_WIDTH) */}
- + + {/* Voice dictation button */} {speech.isAvailable ? ( - + {speech.error @@ -1739,11 +1745,13 @@ export const InputBar = memo(function InputBar({ ) : speech.nativeHint ? ( - + + {speech.nativeHint} @@ -1752,18 +1760,15 @@ export const InputBar = memo(function InputBar({ {agents && agents.length > 1 && onAgentChange && ( - + + {(() => { @@ -1860,9 +1865,11 @@ export const InputBar = memo(function InputBar({ return ( - +
diff --git a/src/components/ProjectFilesPanel.tsx b/src/components/ProjectFilesPanel.tsx index 403ceac..0fdc883 100644 --- a/src/components/ProjectFilesPanel.tsx +++ b/src/components/ProjectFilesPanel.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useMemo, useRef, useState, type CSSProperties } from "react"; import { FolderTree, ChevronRight, @@ -7,10 +7,27 @@ import { FolderOpen, RefreshCw, Search, + Copy, + ClipboardCopy, + ExternalLink, + Eye, + FolderSearch, + Pencil, + Trash2, + FilePlus, + FolderPlus, + Type, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { PanelHeader } from "@/components/PanelHeader"; import { OpenInEditorButton } from "./OpenInEditorButton"; import { useProjectFiles } from "@/hooks/useProjectFiles"; @@ -21,6 +38,8 @@ import { collectDirPaths, type FileTreeNode, } from "@/lib/file-tree"; +import { copyToClipboard } from "@/lib/clipboard"; +import { isMac } from "@/lib/utils"; // ── File icon by extension ── @@ -49,6 +68,15 @@ function getFileIconColor(extension?: string): string { return EXTENSION_ICON_COLORS[extension] ?? "text-muted-foreground/70"; } +const REVEAL_LABEL = isMac ? "Reveal in Finder" : "Show in Explorer"; +const TRASH_LABEL = isMac ? "Move to Trash" : "Move to Recycle Bin"; + +/** Get the parent directory of a path (e.g. "src/lib/foo.ts" → "src/lib"). */ +function dirname(p: string): string { + const idx = p.lastIndexOf("/"); + return idx === -1 ? "" : p.slice(0, idx); +} + // ── Props ── interface ProjectFilesPanelProps { @@ -71,6 +99,9 @@ export const ProjectFilesPanel = memo(function ProjectFilesPanel({ const [debouncedQuery, setDebouncedQuery] = useState(""); const debounceRef = useRef>(undefined); + // Inline creation state: { parentDir (relative), type } + const [creating, setCreating] = useState<{ parentDir: string; type: "file" | "folder" } | null>(null); + // Debounce search input const handleSearchChange = useCallback((value: string) => { setSearchQuery(value); @@ -123,6 +154,41 @@ export const ProjectFilesPanel = memo(function ProjectFilesPanel({ [cwd, onPreviewFile], ); + // Start inline creation under a directory + const handleStartCreate = useCallback((parentDir: string, type: "file" | "folder") => { + // Ensure the parent is expanded so the inline input is visible + setExpandedDirs((prev) => { + if (prev.has(parentDir)) return prev; + const next = new Set(prev); + next.add(parentDir); + return next; + }); + setCreating({ parentDir, type }); + }, []); + + // Commit inline creation + const handleCommitCreate = useCallback(async (name: string) => { + if (!cwd || !creating) return; + const trimmed = name.trim(); + if (!trimmed) { + setCreating(null); + return; + } + const fullPath = `${cwd}/${creating.parentDir ? `${creating.parentDir}/` : ""}${trimmed}`; + const result = creating.type === "file" + ? await window.claude.newFile(fullPath) + : await window.claude.newFolder(fullPath); + + setCreating(null); + if (result.ok) { + refresh(); + } + }, [cwd, creating, refresh]); + + const handleCancelCreate = useCallback(() => { + setCreating(null); + }, []); + if (!cwd) { return (
@@ -205,14 +271,96 @@ export const ProjectFilesPanel = memo(function ProjectFilesPanel({ cwd={cwd} onToggleDir={toggleDir} onFileClick={handleFileClick} + onRefresh={refresh} + onStartCreate={handleStartCreate} + creatingUnder={ + creating && creating.parentDir === item.node.path + ? creating.type + : null + } + onCommitCreate={handleCommitCreate} + onCancelCreate={handleCancelCreate} /> ))} + {/* Inline creation at root level */} + {creating && creating.parentDir === "" && ( + + )}
); }); +// ── InlineCreateInput ── + +function InlineCreateInput({ + depth, + type, + onCommit, + onCancel, +}: { + depth: number; + type: "file" | "folder"; + onCommit: (name: string) => void; + onCancel: () => void; +}) { + const [value, setValue] = useState(""); + const inputRef = useRef(null); + + const handleBlur = useCallback(() => { + if (value.trim()) { + onCommit(value); + } else { + onCancel(); + } + }, [value, onCommit, onCancel]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (value.trim()) { + onCommit(value); + } else { + onCancel(); + } + } + if (e.key === "Escape") { + onCancel(); + } + }, [value, onCommit, onCancel]); + + return ( +
+ + + {type === "folder" + ? + : + } + + setValue(e.target.value)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + placeholder={type === "folder" ? "folder name" : "filename"} + className="min-w-0 flex-1 rounded bg-foreground/[0.06] px-1.5 py-0.5 text-xs text-foreground outline-none ring-1 ring-foreground/10 focus:ring-foreground/20" + /> +
+ ); +} + // ── FileTreeRow ── interface FileTreeRowProps { @@ -222,6 +370,11 @@ interface FileTreeRowProps { cwd: string; onToggleDir: (path: string) => void; onFileClick: (node: FileTreeNode, event: React.MouseEvent) => void; + onRefresh: () => void; + onStartCreate: (parentDir: string, type: "file" | "folder") => void; + creatingUnder: "file" | "folder" | null; + onCommitCreate: (name: string) => void; + onCancelCreate: () => void; } const FileTreeRow = memo(function FileTreeRow({ @@ -231,8 +384,20 @@ const FileTreeRow = memo(function FileTreeRow({ cwd, onToggleDir, onFileClick, + onRefresh, + onStartCreate, + creatingUnder, + onCommitCreate, + onCancelCreate, }: FileTreeRowProps) { const isDir = node.type === "directory"; + const absolutePath = `${cwd}/${node.path}`; + const [menuOpen, setMenuOpen] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); + const [renameName, setRenameName] = useState(node.name); + // Track cursor position so the menu opens where the user right-clicked. + const [menuPos, setMenuPos] = useState({ x: 0, y: 0 }); + const rowRef = useRef(null); const handleClick = useCallback( (e: React.MouseEvent) => { @@ -245,41 +410,238 @@ const FileTreeRow = memo(function FileTreeRow({ [isDir, node, onToggleDir, onFileClick], ); + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + const rowRect = rowRef.current?.getBoundingClientRect(); + setMenuPos({ + x: rowRect ? e.clientX - rowRect.left : e.nativeEvent.offsetX, + y: rowRect ? e.clientY - rowRect.top : e.nativeEvent.offsetY, + }); + setMenuOpen(true); + }, []); + + // ── Copy actions ── + + const handleCopyName = useCallback(() => { + void copyToClipboard(node.name); + }, [node.name]); + + const handleCopyPath = useCallback(() => { + void copyToClipboard(node.path); + }, [node.path]); + + const handleCopyAbsolutePath = useCallback(() => { + void copyToClipboard(absolutePath); + }, [absolutePath]); + + // ── Open / reveal ── + + const handleRevealInFinder = useCallback(() => { + void window.claude.showItemInFolder(absolutePath); + }, [absolutePath]); + + const handleOpenInEditor = useCallback(() => { + void window.claude.openInEditor(absolutePath); + }, [absolutePath]); + + // ── Rename ── + + const handleStartRename = useCallback(() => { + setRenameName(node.name); + setIsRenaming(true); + }, [node.name]); + + const handleCommitRename = useCallback(async () => { + const trimmed = renameName.trim(); + setIsRenaming(false); + if (!trimmed || trimmed === node.name) return; + + const parentDir = dirname(absolutePath); + const newAbsPath = `${parentDir}/${trimmed}`; + const result = await window.claude.renameFile(absolutePath, newAbsPath); + if (result.ok) { + onRefresh(); + } + }, [renameName, node.name, absolutePath, onRefresh]); + + // ── Delete (trash) ── + + const handleTrash = useCallback(async () => { + const result = await window.claude.trashItem(absolutePath); + if (result.ok) { + onRefresh(); + } + }, [absolutePath, onRefresh]); + + // ── New file/folder ── + + const handleNewFile = useCallback(() => { + const parentDir = isDir ? node.path : dirname(node.path); + onStartCreate(parentDir, "file"); + }, [isDir, node.path, onStartCreate]); + + const handleNewFolder = useCallback(() => { + const parentDir = isDir ? node.path : dirname(node.path); + onStartCreate(parentDir, "folder"); + }, [isDir, node.path, onStartCreate]); + + // Invisible anchor positioned at cursor coordinates inside the row. + const anchorStyle: CSSProperties = { + position: "absolute", + left: menuPos.x, + top: menuPos.y, + width: 0, + height: 0, + pointerEvents: "none", + }; + + // ── Inline rename mode ── + if (isRenaming) { + return ( +
+ {isDir ? ( + + + + ) : ( + + )} + + {isDir + ? + : + } + + setRenameName(e.target.value)} + onBlur={() => void handleCommitRename()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleCommitRename(); + } + if (e.key === "Escape") setIsRenaming(false); + }} + className="min-w-0 flex-1 rounded bg-foreground/[0.06] px-1.5 py-0.5 text-xs text-foreground outline-none ring-1 ring-foreground/10 focus:ring-foreground/20" + /> +
+ ); + } + return ( -
- {/* Chevron for directories */} - {isDir ? ( - - + <> +
+ {/* Chevron for directories */} + {isDir ? ( + + + + ) : ( + + )} + + {/* Icon */} + + {isDir && isExpanded && } + {isDir && !isExpanded && } + {!isDir && } - ) : ( - - )} - {/* Icon */} - - {isDir && isExpanded && } - {isDir && !isExpanded && } - {!isDir && } - + {/* Name */} + {node.name} - {/* Name */} - {node.name} + {/* Reserve the same trailing space for both row types so folders and files align. */} + + {!isDir && ( + + )} + - {/* Reserve the same trailing space for both row types so folders and files align. */} - - {!isDir && ( - - )} - -
+ {/* Context menu — anchored to a 0×0 element at the cursor position */} + + + + + + {!isDir && ( + <> + + + Open in Editor + + + + Preview + + + + )} + + + New File + + + + New Folder + + + + + Copy Name + + + + Copy Path + + + + Copy Absolute Path + + + + + {REVEAL_LABEL} + + + + Rename + + void handleTrash()} + > + + {TRASH_LABEL} + + + +
+ {/* Inline creation input — rendered directly below this directory row when active */} + {creatingUnder && isDir && isExpanded && ( + + )} + ); }); diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 803c9b3..9905bb5 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -65,6 +65,8 @@ interface SettingsViewProps { onThemeChange: (t: ThemeOption) => void; islandLayout: boolean; onIslandLayoutChange: (enabled: boolean) => void; + islandShine: boolean; + onIslandShineChange: (enabled: boolean) => void; autoGroupTools: boolean; onAutoGroupToolsChange: (enabled: boolean) => void; avoidGroupingEdits: boolean; @@ -95,6 +97,8 @@ export const SettingsView = memo(function SettingsView({ onThemeChange, islandLayout, onIslandLayoutChange, + islandShine, + onIslandShineChange, autoGroupTools, onAutoGroupToolsChange, avoidGroupingEdits, @@ -155,6 +159,8 @@ export const SettingsView = memo(function SettingsView({ onThemeChange={onThemeChange} islandLayout={islandLayout} onIslandLayoutChange={onIslandLayoutChange} + islandShine={islandShine} + onIslandShineChange={onIslandShineChange} autoGroupTools={autoGroupTools} onAutoGroupToolsChange={onAutoGroupToolsChange} avoidGroupingEdits={avoidGroupingEdits} @@ -235,7 +241,7 @@ export const SettingsView = memo(function SettingsView({ default: return null; } - }, [activeSection, appSettings, updateAppSettings, agents, onSaveAgent, onDeleteAgent, theme, onThemeChange, islandLayout, onIslandLayoutChange, autoGroupTools, onAutoGroupToolsChange, avoidGroupingEdits, onAvoidGroupingEditsChange, autoExpandTools, onAutoExpandToolsChange, transparentToolPicker, onTransparentToolPickerChange, coloredSidebarIcons, onColoredSidebarIconsChange, transparency, onTransparencyChange, glassSupported, onReplayWelcome]); + }, [activeSection, appSettings, updateAppSettings, agents, onSaveAgent, onDeleteAgent, theme, onThemeChange, islandLayout, onIslandLayoutChange, islandShine, onIslandShineChange, autoGroupTools, onAutoGroupToolsChange, avoidGroupingEdits, onAvoidGroupingEditsChange, autoExpandTools, onAutoExpandToolsChange, transparentToolPicker, onTransparentToolPickerChange, coloredSidebarIcons, onColoredSidebarIconsChange, transparency, onTransparencyChange, glassSupported, onReplayWelcome]); return (
diff --git a/src/components/settings/AppearanceSettings.tsx b/src/components/settings/AppearanceSettings.tsx index ca66927..288d75b 100644 --- a/src/components/settings/AppearanceSettings.tsx +++ b/src/components/settings/AppearanceSettings.tsx @@ -12,6 +12,8 @@ interface AppearanceSettingsProps { onThemeChange: (t: ThemeOption) => void; islandLayout: boolean; onIslandLayoutChange: (enabled: boolean) => void; + islandShine: boolean; + onIslandShineChange: (enabled: boolean) => void; autoGroupTools: boolean; onAutoGroupToolsChange: (enabled: boolean) => void; avoidGroupingEdits: boolean; @@ -35,6 +37,8 @@ export const AppearanceSettings = memo(function AppearanceSettings({ onThemeChange, islandLayout, onIslandLayoutChange, + islandShine, + onIslandShineChange, autoGroupTools, onAutoGroupToolsChange, avoidGroupingEdits, @@ -239,6 +243,17 @@ export const AppearanceSettings = memo(function AppearanceSettings({ onCheckedChange={onColoredSidebarIconsChange} /> + + + +
{/* ── Transparency section ── */} diff --git a/src/components/sidebar/BranchSection.tsx b/src/components/sidebar/BranchSection.tsx new file mode 100644 index 0000000..d37d9d7 --- /dev/null +++ b/src/components/sidebar/BranchSection.tsx @@ -0,0 +1,105 @@ +import { useState } from "react"; +import { GitBranch, ChevronRight } from "lucide-react"; +import type { ChatFolder, ChatSession, InstalledAgent } from "@/types"; +import type { SidebarItem } from "@/lib/sidebar-grouping"; +import { FolderSection } from "./FolderSection"; +import { SessionItem } from "./SessionItem"; + +export function BranchSection({ + branchName, + children, + activeSessionId, + islandLayout, + allFolders, + onSelectSession, + onDeleteSession, + onRenameSession, + onPinSession, + onMoveSessionToFolder, + onPinFolder, + onRenameFolder, + onDeleteFolder, + agents, +}: { + branchName: string; + children: SidebarItem[]; + activeSessionId: string | null; + islandLayout: boolean; + allFolders: ChatFolder[]; + onSelectSession: (id: string) => void; + onDeleteSession: (id: string) => void; + onRenameSession: (id: string, title: string) => void; + onPinSession: (id: string, pinned: boolean) => void; + onMoveSessionToFolder: (sessionId: string, folderId: string | null) => void; + onPinFolder: (projectId: string, folderId: string, pinned: boolean) => void; + onRenameFolder: (projectId: string, folderId: string, name: string) => void; + onDeleteFolder: (projectId: string, folderId: string) => void; + agents?: InstalledAgent[]; +}) { + const [expanded, setExpanded] = useState(true); + + return ( +
+ {/* Branch header */} + + + {/* Branch contents */} + {expanded && ( +
+ {children.map((item) => { + if (item.type === "folder" && item.folder) { + return ( + onPinFolder(item.folder!.projectId, item.folder!.id, pinned)} + onRenameFolder={(name) => onRenameFolder(item.folder!.projectId, item.folder!.id, name)} + onDeleteFolder={() => onDeleteFolder(item.folder!.projectId, item.folder!.id)} + agents={agents} + /> + ); + } + if (item.type === "session" && item.session) { + return ( + onSelectSession(item.session!.id)} + onDelete={() => onDeleteSession(item.session!.id)} + onRename={(title) => onRenameSession(item.session!.id, title)} + onPinToggle={() => onPinSession(item.session!.id, !item.session!.pinned)} + folders={allFolders} + onMoveToFolder={(folderId) => onMoveSessionToFolder(item.session!.id, folderId)} + agents={agents} + /> + ); + } + return null; + })} +
+ )} +
+ ); +} diff --git a/src/components/sidebar/FolderSection.tsx b/src/components/sidebar/FolderSection.tsx new file mode 100644 index 0000000..8137ce3 --- /dev/null +++ b/src/components/sidebar/FolderSection.tsx @@ -0,0 +1,221 @@ +import { useState, useCallback } from "react"; +import { Folder, ChevronRight, Pencil, Trash2, MoreHorizontal, Pin, PinOff } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { ChatFolder, ChatSession, InstalledAgent } from "@/types"; +import { SessionItem } from "./SessionItem"; +import { + isSidebarDragKind, + handleSidebarFolderDrop, +} from "@/lib/sidebar-dnd"; + +export function FolderSection({ + folder, + sessions, + activeSessionId, + islandLayout, + allFolders, + onSelectSession, + onDeleteSession, + onRenameSession, + onPinSession, + onMoveSessionToFolder, + onPinFolder, + onRenameFolder, + onDeleteFolder, + agents, + defaultCollapsed = false, +}: { + folder: ChatFolder; + sessions: ChatSession[]; + activeSessionId: string | null; + islandLayout: boolean; + allFolders: ChatFolder[]; + onSelectSession: (id: string) => void; + onDeleteSession: (id: string) => void; + onRenameSession: (id: string, title: string) => void; + onPinSession: (id: string, pinned: boolean) => void; + onMoveSessionToFolder: (sessionId: string, folderId: string | null) => void; + onPinFolder: (pinned: boolean) => void; + onRenameFolder: (name: string) => void; + onDeleteFolder: () => void; + agents?: InstalledAgent[]; + defaultCollapsed?: boolean; +}) { + const [expanded, setExpanded] = useState(!defaultCollapsed); + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(folder.name); + const [isDragOver, setIsDragOver] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + + const handleRename = useCallback(() => { + const trimmed = editName.trim(); + if (trimmed && trimmed !== folder.name) { + onRenameFolder(trimmed); + } + setIsEditing(false); + }, [editName, folder.name, onRenameFolder]); + + const handleDragOver = useCallback((e: React.DragEvent) => { + if (isSidebarDragKind("session", e.dataTransfer)) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setIsDragOver(true); + } + }, []); + + const handleDragLeave = useCallback(() => { + setIsDragOver(false); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + setIsDragOver(false); + handleSidebarFolderDrop(e, folder.id, { + onMoveSessionToFolder: (sessionId, folderId) => { + onMoveSessionToFolder(sessionId, folderId); + }, + onReorderFolder: () => { + // folder reorder not implemented yet + }, + }); + }, + [folder.id, onMoveSessionToFolder], + ); + + if (isEditing) { + return ( +
+ setEditName(e.target.value)} + onBlur={handleRename} + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(); + if (e.key === "Escape") setIsEditing(false); + }} + className="flex-1 rounded-lg bg-black/5 px-2 py-1 text-[13px] text-sidebar-foreground outline-none ring-1 ring-sidebar-ring dark:bg-white/5" + /> +
+ ); + } + + return ( +
+ {/* Folder header */} +
{ + e.preventDefault(); + setMenuOpen(true); + }} + > + + +
+ + + + + + onPinFolder(!folder.pinned)}> + {folder.pinned ? ( + <> + + Unpin + + ) : ( + <> + + Pin + + )} + + { + setEditName(folder.name); + setIsEditing(true); + }} + > + + Rename + + + + Delete + + + +
+
+ + {/* Folder contents */} + {expanded && ( +
+ {sessions.length === 0 ? ( +

+ No chats +

+ ) : ( + sessions.map((session) => ( + onSelectSession(session.id)} + onDelete={() => onDeleteSession(session.id)} + onRename={(title) => onRenameSession(session.id, title)} + onPinToggle={() => onPinSession(session.id, !session.pinned)} + folders={allFolders} + onMoveToFolder={(folderId) => onMoveSessionToFolder(session.id, folderId)} + agents={agents} + /> + )) + )} +
+ )} +
+ ); +} diff --git a/src/components/sidebar/PinnedSection.tsx b/src/components/sidebar/PinnedSection.tsx new file mode 100644 index 0000000..f689f84 --- /dev/null +++ b/src/components/sidebar/PinnedSection.tsx @@ -0,0 +1,84 @@ +import { Pin } from "lucide-react"; +import type { ChatFolder, ChatSession, InstalledAgent } from "@/types"; +import type { SidebarItem } from "@/lib/sidebar-grouping"; +import { SessionItem } from "./SessionItem"; +import { FolderSection } from "./FolderSection"; + +export function PinnedSection({ + sessions, + pinnedFolders, + activeSessionId, + islandLayout, + folders, + onSelectSession, + onDeleteSession, + onRenameSession, + onPinSession, + onMoveSessionToFolder, + onPinFolder, + onRenameFolder, + onDeleteFolder, + agents, +}: { + sessions: ChatSession[]; + pinnedFolders?: SidebarItem[]; + activeSessionId: string | null; + islandLayout: boolean; + folders: ChatFolder[]; + onSelectSession: (id: string) => void; + onDeleteSession: (id: string) => void; + onRenameSession: (id: string, title: string) => void; + onPinSession: (id: string, pinned: boolean) => void; + onMoveSessionToFolder: (sessionId: string, folderId: string | null) => void; + onPinFolder: (projectId: string, folderId: string, pinned: boolean) => void; + onRenameFolder: (projectId: string, folderId: string, name: string) => void; + onDeleteFolder: (projectId: string, folderId: string) => void; + agents?: InstalledAgent[]; +}) { + if (sessions.length === 0 && (!pinnedFolders || pinnedFolders.length === 0)) return null; + + return ( +
+
+ +

+ Pinned +

+
+ {pinnedFolders?.map((item) => item.folder && ( + onPinFolder(item.folder!.projectId, item.folder!.id, pinned)} + onRenameFolder={(name) => onRenameFolder(item.folder!.projectId, item.folder!.id, name)} + onDeleteFolder={() => onDeleteFolder(item.folder!.projectId, item.folder!.id)} + agents={agents} + /> + ))} + {sessions.map((session) => ( + onSelectSession(session.id)} + onDelete={() => onDeleteSession(session.id)} + onRename={(title) => onRenameSession(session.id, title)} + onPinToggle={() => onPinSession(session.id, false)} + folders={folders} + onMoveToFolder={(folderId) => onMoveSessionToFolder(session.id, folderId)} + agents={agents} + /> + ))} +
+ ); +} diff --git a/src/components/sidebar/ProjectSection.tsx b/src/components/sidebar/ProjectSection.tsx index 36eccec..b9d60ee 100644 --- a/src/components/sidebar/ProjectSection.tsx +++ b/src/components/sidebar/ProjectSection.tsx @@ -4,6 +4,7 @@ import { Trash2, MoreHorizontal, FolderOpen, + FolderPlus, SquarePen, KanbanSquare, ChevronRight, @@ -12,6 +13,7 @@ import { ArrowRightLeft, Smile, X, + GitBranch, } from "lucide-react"; import { resolveLucideIcon } from "@/lib/icon-utils"; import { Button } from "@/components/ui/button"; @@ -24,6 +26,7 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, + DropdownMenuCheckboxItem, } from "@/components/ui/dropdown-menu"; import { Popover, @@ -31,60 +34,23 @@ import { PopoverContent, } from "@/components/ui/popover"; import { IconPicker } from "@/components/IconPicker"; -import type { ChatSession, InstalledAgent, Project, Space } from "@/types"; +import type { ChatFolder, ChatSession, InstalledAgent, Project, Space } from "@/types"; import { SessionItem } from "./SessionItem"; import { CCSessionList } from "./CCSessionList"; - -interface SessionGroup { - label: string; - sessions: ChatSession[]; -} - -/** Sort key: latest user-message timestamp, falling back to creation time. */ -function getSortTimestamp(session: ChatSession): number { - return session.lastMessageAt ?? session.createdAt; -} - -function groupSessionsByDate(sessions: ChatSession[]): SessionGroup[] { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const todayMs = today.getTime(); - const yesterdayMs = todayMs - 86_400_000; - const weekAgoMs = todayMs - 7 * 86_400_000; - - const groups: SessionGroup[] = [ - { label: "Today", sessions: [] }, - { label: "Yesterday", sessions: [] }, - { label: "Last 7 Days", sessions: [] }, - { label: "Older", sessions: [] }, - ]; - - // Sort by most recent user activity first - const sorted = [...sessions].sort((a, b) => getSortTimestamp(b) - getSortTimestamp(a)); - - for (const session of sorted) { - const ts = getSortTimestamp(session); - if (ts >= todayMs) { - groups[0].sessions.push(session); - } else if (ts >= yesterdayMs) { - groups[1].sessions.push(session); - } else if (ts >= weekAgoMs) { - groups[2].sessions.push(session); - } else { - groups[3].sessions.push(session); - } - } - - return groups.filter((g) => g.sessions.length > 0); -} +import { PinnedSection } from "./PinnedSection"; +import { FolderSection } from "./FolderSection"; +import { BranchSection } from "./BranchSection"; +import { buildSidebarGroups, type SidebarItem } from "@/lib/sidebar-grouping"; export function ProjectSection({ islandLayout, project, sessions, + folders, activeSessionId, jiraBoardEnabled, isJiraBoardOpen, + organizeByChatBranch, onNewChat, onToggleJiraBoard, onSelectSession, @@ -98,14 +64,23 @@ export function ProjectSection({ onMoveToSpace, onReorderProject, defaultChatLimit, + onPinSession, + onMoveSessionToFolder, + onCreateFolder, + onRenameFolder, + onDeleteFolder, + onPinFolder, + onSetOrganizeByChatBranch, agents, }: { islandLayout: boolean; project: Project; sessions: ChatSession[]; + folders: ChatFolder[]; activeSessionId: string | null; jiraBoardEnabled: boolean; isJiraBoardOpen: boolean; + organizeByChatBranch: boolean; onNewChat: () => void; onToggleJiraBoard: () => void; onSelectSession: (id: string) => void; @@ -119,6 +94,13 @@ export function ProjectSection({ onMoveToSpace: (spaceId: string) => void; onReorderProject: (targetProjectId: string) => void; defaultChatLimit: number; + onPinSession: (sessionId: string, pinned: boolean) => void; + onMoveSessionToFolder: (sessionId: string, folderId: string | null) => void; + onCreateFolder: () => void; + onRenameFolder: (projectId: string, folderId: string, name: string) => void; + onDeleteFolder: (projectId: string, folderId: string) => void; + onPinFolder: (projectId: string, folderId: string, pinned: boolean) => void; + onSetOrganizeByChatBranch: (on: boolean) => void; agents?: InstalledAgent[]; }) { const [expanded, setExpanded] = useState(true); @@ -128,7 +110,7 @@ export function ProjectSection({ const [iconPickerOpen, setIconPickerOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const openingIconPickerRef = useRef(false); - // Pagination: show N chats initially, load 20 more on each click + // Pagination: show N items initially, load 20 more on each click const [visibleCount, setVisibleCount] = useState(defaultChatLimit); // Reset visible count when the configured limit changes @@ -136,19 +118,36 @@ export function ProjectSection({ setVisibleCount(defaultChatLimit); }, [defaultChatLimit]); - // Sort all sessions by latest message, then slice for pagination - const sortedSessions = useMemo( - () => [...sessions].sort((a, b) => getSortTimestamp(b) - getSortTimestamp(a)), - [sessions], - ); - const visibleSessions = useMemo( - () => sortedSessions.slice(0, visibleCount), - [sortedSessions, visibleCount], + // Build grouped sidebar items using the grouping algorithm + const sidebarItems = useMemo( + () => buildSidebarGroups(sessions, folders, organizeByChatBranch), + [sessions, folders, organizeByChatBranch], ); - const hasMore = sortedSessions.length > visibleCount; - const remainingCount = sortedSessions.length - visibleCount; - const groups = useMemo(() => groupSessionsByDate(visibleSessions), [visibleSessions]); + // Count non-pinned items for pagination + const pinnedItem = sidebarItems.find((item) => item.type === "pinned"); + const contentItems = sidebarItems.filter((item) => item.type !== "pinned"); + + // For pagination, count total visible sessions (not groups) + const totalSessionCount = sessions.filter((s) => !s.pinned).length; + const hasMore = totalSessionCount > visibleCount; + const remainingCount = totalSessionCount - visibleCount; + + // Limit content items to visibleCount sessions + const visibleContentItems = useMemo(() => { + let sessionsSoFar = 0; + const visible: SidebarItem[] = []; + for (const item of contentItems) { + if (sessionsSoFar >= visibleCount) break; + visible.push(item); + if (item.type === "session") { + sessionsSoFar += 1; + } else { + sessionsSoFar += item.sessions.length; + } + } + return visible; + }, [contentItems, visibleCount]); const handleRename = () => { const trimmed = editName.trim(); @@ -176,9 +175,76 @@ export function ProjectSection({ ); } + /** Render a single sidebar item (folder, branch, or session). */ + function renderItem(item: SidebarItem) { + if (item.type === "folder" && item.folder) { + return ( + onPinFolder(project.id, item.folder!.id, pinned)} + onRenameFolder={(name) => onRenameFolder(project.id, item.folder!.id, name)} + onDeleteFolder={() => onDeleteFolder(project.id, item.folder!.id)} + agents={agents} + /> + ); + } + + if (item.type === "branch" && item.children) { + return ( + + ); + } + + if (item.type === "session" && item.session) { + return ( + onSelectSession(item.session!.id)} + onDelete={() => onDeleteSession(item.session!.id)} + onRename={(title) => onRenameSession(item.session!.id, title)} + onPinToggle={() => onPinSession(item.session!.id, !item.session!.pinned)} + folders={folders} + onMoveToFolder={(folderId) => onMoveSessionToFolder(item.session!.id, folderId)} + agents={agents} + /> + ); + } + + return null; + } + return (
{ // Accept project drops for reorder if (e.dataTransfer.types.includes("application/x-project-id")) { @@ -198,16 +264,20 @@ export function ProjectSection({ > {/* Project header row */}
{ e.dataTransfer.setData("application/x-project-id", project.id); e.dataTransfer.effectAllowed = "move"; }} + onContextMenu={(e) => { + e.preventDefault(); + setMenuOpen(true); + }} > - {jiraBoardEnabled && ( +
+ {jiraBoardEnabled && ( + + )} + - )} - + + + + + + + { + if (!openingIconPickerRef.current) return; + e.preventDefault(); + openingIconPickerRef.current = false; + }} + > + + + New folder + + + + Organize by branch + + + { + setEditName(project.name); + setIsEditing(true); + }} + > + + Rename + + { + e.preventDefault(); + openingIconPickerRef.current = true; + setMenuOpen(false); + requestAnimationFrame(() => setIconPickerOpen(true)); + }} + > + + Set icon + + {project.icon && ( + onUpdateIcon(null, null)}> + + Remove icon + + )} + + + + + Resume CC Chat + + + + + + {otherSpaces.length > 0 && ( + + + + Move to space + + + {otherSpaces.map((s) => { + const SpIcon = s.iconType === "lucide" ? resolveLucideIcon(s.icon) : null; + return ( + onMoveToSpace(s.id)}> + {s.iconType === "emoji" ? ( + {s.icon} + ) : SpIcon ? ( + + ) : null} + {s.name} + + ); + })} + + + )} + + + + Delete + + + + - - - - - - - { - if (!openingIconPickerRef.current) return; - e.preventDefault(); - openingIconPickerRef.current = false; - }} - > - { - setEditName(project.name); - setIsEditing(true); - }} - > - - Rename - - { - e.preventDefault(); - openingIconPickerRef.current = true; - setMenuOpen(false); - requestAnimationFrame(() => setIconPickerOpen(true)); + {/* Icon picker popover — anchored to the ... button, triggered from dropdown "Set icon" */} + + { + onUpdateIcon(icon, type); + setIconPickerOpen(false); }} - > - - Set icon - - {project.icon && ( - onUpdateIcon(null, null)}> - - Remove icon - - )} - - - - - Resume CC Chat - - - - - - {otherSpaces.length > 0 && ( - - - - Move to space - - - {otherSpaces.map((s) => { - const SpIcon = s.iconType === "lucide" ? resolveLucideIcon(s.icon) : null; - return ( - onMoveToSpace(s.id)} - > - {s.iconType === "emoji" ? ( - {s.icon} - ) : SpIcon ? ( - - ) : null} - {s.name} - - ); - })} - - - )} - - - - Delete - - - - - - {/* Icon picker popover — anchored to the ⋯ button, triggered from dropdown "Set icon" */} - - { - onUpdateIcon(icon, type); - setIconPickerOpen(false); - }} - /> - + /> + +
{/* Nested chats */} {expanded && (
- {groups.map((group, i) => ( -
-
-

- {group.label} -

-
- {group.sessions.map((session) => ( - onSelectSession(session.id)} - onDelete={() => onDeleteSession(session.id)} - onRename={(title) => onRenameSession(session.id, title)} - agents={agents} - /> - ))} -
- ))} + {/* Pinned section */} + {pinnedItem && ( + + )} - {/* Load more button */} - {hasMore && ( - - )} + + + )} - {sessions.length === 0 && ( -

- No conversations yet -

- )} + {sessions.length === 0 && ( +

+ No conversations yet +

+ )}
)}
diff --git a/src/components/sidebar/SessionItem.tsx b/src/components/sidebar/SessionItem.tsx index 276df3a..c24afdd 100644 --- a/src/components/sidebar/SessionItem.tsx +++ b/src/components/sidebar/SessionItem.tsx @@ -1,15 +1,32 @@ -import { useState } from "react"; -import { Pencil, Trash2, MoreHorizontal, Loader2 } from "lucide-react"; +import { useState, useCallback } from "react"; +import { + Pencil, + Trash2, + MoreHorizontal, + Loader2, + Pin, + PinOff, + FolderInput, + FolderMinus, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import type { ChatSession, InstalledAgent } from "@/types"; +import type { ChatFolder, ChatSession, InstalledAgent } from "@/types"; import { AgentIcon } from "@/components/AgentIcon"; import { getSessionEngineIcon } from "@/lib/engine-icons"; +import { + writeSidebarDragPayload, + clearSidebarDragPayload, +} from "@/lib/sidebar-dnd"; export function SessionItem({ islandLayout, @@ -18,6 +35,9 @@ export function SessionItem({ onSelect, onDelete, onRename, + onPinToggle, + folders, + onMoveToFolder, agents, }: { islandLayout: boolean; @@ -26,10 +46,17 @@ export function SessionItem({ onSelect: () => void; onDelete: () => void; onRename: (title: string) => void; + /** Toggle pin state. Omit if pin feature not available in this context. */ + onPinToggle?: () => void; + /** Available folders for "Move to folder" submenu. Omit to hide the menu. */ + folders?: ChatFolder[]; + /** Move session to a folder (null = remove from folder). */ + onMoveToFolder?: (folderId: string | null) => void; agents?: InstalledAgent[]; }) { const [isEditing, setIsEditing] = useState(false); const [editTitle, setEditTitle] = useState(session.title); + const [menuOpen, setMenuOpen] = useState(false); const handleRename = () => { const trimmed = editTitle.trim(); @@ -39,6 +66,21 @@ export function SessionItem({ setIsEditing(false); }; + const handleDragStart = useCallback( + (e: React.DragEvent) => { + writeSidebarDragPayload(e.dataTransfer, { + kind: "session", + id: session.id, + }); + e.dataTransfer.effectAllowed = "move"; + }, + [session.id], + ); + + const handleDragEnd = useCallback(() => { + clearSidebarDragPayload(); + }, []); + if (isEditing) { return (
@@ -57,11 +99,22 @@ export function SessionItem({ ); } + const hasFolderMenu = folders && folders.length > 0 && onMoveToFolder; + return ( -
+
{ + e.preventDefault(); + setMenuOpen(true); + }} + >
- + - - - {modelList.map((m) => { - const effortOptions = effortOptionsByModel?.[m.id] ?? []; - if (effortOptions.length > 0 && onModelEffortChange) { - const isSelected = m.id === selectedModelId; - return ( - - -
-
{m.label}
- {m.description && ( -
{m.description}
- )} -
-
- - {effortOptions.map((effort) => { - const isActive = isSelected && effort === activeEffort; - return ( - onModelEffortChange(m.id, effort)} - className={isActive ? "bg-accent" : ""} - > -
-
- {effort} - {isActive && Current} -
-
{CLAUDE_EFFORT_DESCRIPTIONS[effort]}
-
-
- ); - })} -
-
- ); - } - - return ( - onModelChange(m.id)} - className={m.id === selectedModelId ? "bg-accent" : ""} - > -
-
{m.label}
- {m.description && ( -
{m.description}
- )} -
-
- ); - })} -
-
- ); -} - /** Permission mode dropdown — used by Claude and Codex engines */ function PermissionDropdown({ permissionMode, @@ -298,200 +185,65 @@ function PlanModeToggle({ ); } -/** Renders the correct combination of controls per engine */ +/** Renders plan/permission controls per engine (model/config moved to engine picker) */ function EngineControls({ isCodexAgent, isACPAgent, isProcessing, - showACPConfigOptions, - // Model - modelList, - selectedModel, - selectedModelId, - onModelChange, - onClaudeModelEffortChange, - claudeEffortOptionsByModel, - claudeActiveEffort, - modelsLoading, - modelsLoadingText, - // Permission permissionMode, onPermissionModeChange, - // Plan planMode, onPlanModeChange, - // Codex effort - codexEffortOptions, - codexActiveEffort, - onCodexEffortChange, - // ACP acpPermissionBehavior, onAcpPermissionBehaviorChange, - acpConfigOptions, - acpConfigOptionsLoading, - onACPConfigChange, }: { isCodexAgent: boolean; isACPAgent: boolean; isProcessing: boolean; - showACPConfigOptions: boolean; - modelList: Array<{ id: string; label: string; description?: string }>; - selectedModel: { id: string; label: string; description?: string } | undefined; - selectedModelId: string; - onModelChange: (id: string) => void; - onClaudeModelEffortChange?: (model: string, effort: ClaudeEffort) => void; - claudeEffortOptionsByModel: Partial>; - claudeActiveEffort: ClaudeEffort; - modelsLoading: boolean; - modelsLoadingText: string; permissionMode: string; onPermissionModeChange: (mode: string) => void; planMode: boolean; onPlanModeChange: (enabled: boolean) => void; - codexEffortOptions: Array<{ reasoningEffort: string; description: string }>; - codexActiveEffort?: string; - onCodexEffortChange?: (effort: string) => void; acpPermissionBehavior?: AcpPermissionBehavior; onAcpPermissionBehaviorChange?: (behavior: AcpPermissionBehavior) => void; - acpConfigOptions?: ACPConfigOption[]; - acpConfigOptionsLoading?: boolean; - onACPConfigChange?: (configId: string, value: string) => void; }) { - if (isCodexAgent) { - return ( - <> - - {/* Codex reasoning effort dropdown */} - {codexEffortOptions.length > 0 && onCodexEffortChange && ( - - - - - - {codexEffortOptions.map((opt) => ( - onCodexEffortChange(opt.reasoningEffort)} - className={opt.reasoningEffort === codexActiveEffort ? "bg-accent" : ""} - > -
-
{opt.reasoningEffort}
- {opt.description && ( -
{opt.description}
- )} -
-
- ))} -
-
- )} - - - - ); - } - if (isACPAgent) { + if (!onAcpPermissionBehaviorChange) return null; return ( - <> - {/* ACP permission behavior dropdown */} - {onAcpPermissionBehaviorChange && ( - - - - - - {ACP_PERMISSION_BEHAVIORS.map((b) => ( - onAcpPermissionBehaviorChange(b.id)} - className={b.id === acpPermissionBehavior ? "bg-accent" : ""} - > -
-
{b.label}
-
{b.description}
-
-
- ))} -
-
- )} - {/* Agent-provided config dropdowns */} - {showACPConfigOptions && acpConfigOptions && acpConfigOptions.length > 0 && onACPConfigChange && - acpConfigOptions.map((opt) => { - const flat = flattenConfigOptions(opt.options); - const current = flat.find((o) => o.value === opt.currentValue); - return ( - - - - - - {flat.map((o) => ( - onACPConfigChange(opt.id, o.value)} - className={o.value === opt.currentValue ? "bg-accent" : ""} - > -
-
{o.name}
- {o.description && ( -
{o.description}
- )} -
-
- ))} -
-
- ); - }) - } - {acpConfigOptionsLoading && !showACPConfigOptions && ( -
- - Loading options... -
- )} - + + + + + + {ACP_PERMISSION_BEHAVIORS.map((b) => ( + onAcpPermissionBehaviorChange(b.id)} + className={b.id === acpPermissionBehavior ? "bg-accent" : ""} + > +
+
{b.label}
+
{b.description}
+
+
+ ))} +
+
); } - // Claude SDK controls return ( <> - - + ); } @@ -866,11 +618,6 @@ export const InputBar = memo(function InputBar({ const selectedModel = modelList.find((m) => m.id === preferredModelId) ?? modelList[0]; const selectedModelId = selectedModel?.id ?? preferredModelId; const claudeCurrentModel = supportedModels?.find((m) => m.value === selectedModelId); - const claudeEffortOptionsByModel = Object.fromEntries( - (supportedModels ?? []) - .filter((m) => m.supportsEffort && (m.supportedEffortLevels?.length ?? 0) > 0) - .map((m) => [m.value, m.supportedEffortLevels ?? []]), - ) as Partial>; const claudeEffortOptions = claudeCurrentModel?.supportsEffort ? (claudeCurrentModel.supportedEffortLevels ?? []) : []; @@ -1757,44 +1504,171 @@ export const InputBar = memo(function InputBar({ ) : null} - {agents && agents.length > 1 && onAgentChange && ( - - - - - - {(() => { - // An agent "will open new chat" if engine differs OR same ACP engine but different agent - const willOpenNewChat = (agent: InstalledAgent) => { - if (lockedEngine == null) return false; - if (agent.engine !== lockedEngine) return true; - if (lockedEngine === "acp" && lockedAgentId && agent.id !== lockedAgentId) return true; - return false; - }; - const sameEngine = agents.filter((a) => !willOpenNewChat(a)); - const crossEngine = agents.filter((a) => willOpenNewChat(a)); - - const renderItem = (agent: InstalledAgent, crossEngine: boolean) => ( - onAgentChange(agent.engine === "claude" ? null : agent)} - className={ - (selectedAgent?.id ?? "claude-code") === agent.id ? "bg-accent" : "" - } - > - + {/* Engine picker — always visible, includes agent switching + model/config submenus */} + + + + + + {(() => { + // Engine-specific config items (model/effort/ACP config) — shared between + // multi-agent submenu and single-agent direct rendering + const configItems = ( + <> + {/* Model list (Claude + Codex) */} + {!isACPAgent && !modelsLoading && modelList.length > 0 && modelList.map((m) => ( + onModelChange(m.id)} + className={m.id === selectedModelId ? "bg-accent" : ""} + > +
+
{m.label}
+ {m.description && ( +
{m.description}
+ )} +
+
+ ))} + {/* Claude effort for current model */} + {!isCodexAgent && !isACPAgent && claudeEffortOptions.length > 0 && ( + <> + +
Effort
+ {claudeEffortOptions.map((effort) => ( + onClaudeModelEffortChange(selectedModelId, effort)} + className={effort === claudeActiveEffort ? "bg-accent" : ""} + > +
+
+ {effort} + {effort === claudeActiveEffort && Current} +
+
{CLAUDE_EFFORT_DESCRIPTIONS[effort]}
+
+
+ ))} + + )} + {/* Models loading */} + {!isACPAgent && modelsLoading && ( + + + {modelsLoadingText} + + )} + {/* Codex effort */} + {isCodexAgent && codexEffortOptions.length > 0 && onCodexEffortChange && ( + <> + +
Effort
+ {codexEffortOptions.map((opt) => ( + onCodexEffortChange(opt.reasoningEffort)} + className={opt.reasoningEffort === codexActiveEffort ? "bg-accent" : ""} + > +
+
{opt.reasoningEffort}
+ {opt.description && ( +
{opt.description}
+ )} +
+
+ ))} + + )} + {/* ACP config options */} + {isACPAgent && showACPConfigOptions && acpConfigOptions && acpConfigOptions.length > 0 && onACPConfigChange && + acpConfigOptions.map((opt) => { + const flat = flattenConfigOptions(opt.options); + const current = flat.find((o) => o.value === opt.currentValue); + return ( + + +
+
{opt.name}
+
{current?.name ?? opt.currentValue}
+
+
+ + {flat.map((o) => ( + onACPConfigChange(opt.id, o.value)} + className={o.value === opt.currentValue ? "bg-accent" : ""} + > +
+
{o.name}
+ {o.description && ( +
{o.description}
+ )} +
+
+ ))} +
+
+ ); + }) + } + {/* ACP config loading */} + {isACPAgent && acpConfigOptionsLoading && !showACPConfigOptions && ( + + + Loading options... + + )} + + ); + + // Single agent or no agent list — show config directly + if (!agents || agents.length <= 1 || !onAgentChange) { + return configItems; + } + + // Multi-agent — current agent is a submenu with config, others are clickable items + const willOpenNewChat = (agent: InstalledAgent) => { + if (lockedEngine == null) return false; + if (agent.engine !== lockedEngine) return true; + if (lockedEngine === "acp" && lockedAgentId && agent.id !== lockedAgentId) return true; + return false; + }; + const sameEngine = agents.filter((a) => !willOpenNewChat(a)); + const crossEngine = agents.filter((a) => willOpenNewChat(a)); + + const renderAgent = (agent: InstalledAgent, isCrossEngine: boolean) => { + const isCurrent = (selectedAgent?.id ?? "claude-code") === agent.id; + + const agentLabel = ( + <> +
{agent.name} @@ -1802,55 +1676,59 @@ export const InputBar = memo(function InputBar({ Beta )}
- {crossEngine && ( -
- Opens new chat -
+ {isCrossEngine && ( +
Opens new chat
)}
-
+ ); + if (isCurrent) { + return ( + + + {agentLabel} + + + {configItems} + + + ); + } + return ( - <> - {sameEngine.map((a) => renderItem(a, false))} - {crossEngine.length > 0 && sameEngine.length > 0 && ( - - )} - {crossEngine.map((a) => renderItem(a, true))} - + onAgentChange(agent.engine === "claude" ? null : agent)} + > + {agentLabel} + ); - })()} -
-
- )} + }; + + return ( + <> + {sameEngine.map((a) => renderAgent(a, false))} + {crossEngine.length > 0 && sameEngine.length > 0 && ( + + )} + {crossEngine.map((a) => renderAgent(a, true))} + + ); + })()} + +
diff --git a/src/components/sidebar/PinnedSection.tsx b/src/components/sidebar/PinnedSection.tsx index f689f84..eb59050 100644 --- a/src/components/sidebar/PinnedSection.tsx +++ b/src/components/sidebar/PinnedSection.tsx @@ -1,4 +1,3 @@ -import { Pin } from "lucide-react"; import type { ChatFolder, ChatSession, InstalledAgent } from "@/types"; import type { SidebarItem } from "@/lib/sidebar-grouping"; import { SessionItem } from "./SessionItem"; @@ -38,13 +37,7 @@ export function PinnedSection({ if (sessions.length === 0 && (!pinnedFolders || pinnedFolders.length === 0)) return null; return ( -
-
- -

- Pinned -

-
+
{pinnedFolders?.map((item) => item.folder && ( window.claude.sessions.list(p.id)), ).then((results) => { - const all = results.flat().map((s) => ({ - id: s.id, - projectId: s.projectId, - title: s.title, - createdAt: s.createdAt, - lastMessageAt: s.lastMessageAt || s.createdAt, - model: s.model, - planMode: s.planMode, - totalCost: s.totalCost, - isActive: false, - engine: s.engine, - codexThreadId: s.codexThreadId, - })); + const all = results.flat().map((session) => toChatSession(session, false)); setSessions(all); }).catch(() => { /* IPC failure — leave sessions empty */ }); }, [projects]); diff --git a/src/hooks/session/useSessionPersistence.ts b/src/hooks/session/useSessionPersistence.ts index 3901fdd..6733cd7 100644 --- a/src/hooks/session/useSessionPersistence.ts +++ b/src/hooks/session/useSessionPersistence.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect } from "react"; import { toast } from "sonner"; import type { PersistedSession, ClaudeEvent, SystemInitEvent, EngineId } from "../../types"; import { toMcpStatusState } from "../../lib/mcp-utils"; +import { buildPersistedSession } from "../../lib/session-records"; import type { ACPSessionEvent, ACPPermissionEvent, ACPTurnCompleteEvent } from "../../types/acp"; import { normalizeToolInput as acpNormalizeToolInput, pickAutoResponseOption } from "../../lib/acp-adapter"; import { DRAFT_ID } from "./types"; @@ -147,18 +148,16 @@ export function useSessionPersistence({ const bgState = backgroundStoreRef.current.get(sid); const session = sessionsRef.current.find((s) => s.id === sid); if (bgState && session) { - window.claude.sessions.save({ - id: sid, - projectId: session.projectId, - title: session.title, - createdAt: session.createdAt, - messages: bgState.messages, - model: session.model || bgState.sessionInfo?.model, - planMode: session.planMode, - totalCost: bgState.totalCost, - engine: session.engine, - ...(session.engine === "codex" && session.codexThreadId ? { codexThreadId: session.codexThreadId } : {}), - }); + const persisted = buildPersistedSession( + { + ...session, + model: session.model || bgState.sessionInfo?.model, + }, + bgState.messages, + bgState.totalCost, + bgState.contextUsage, + ); + window.claude.sessions.save(persisted); } } }; @@ -401,21 +400,12 @@ export function useSessionPersistence({ if (!session) return; // Never persist queued messages — unsent queue state is runtime-only. const msgs = messagesRef.current.filter((m) => !m.isQueued); - const data: PersistedSession = { - id, - projectId: session.projectId, - title: session.title, - createdAt: session.createdAt, - messages: msgs, - model: session.model, - planMode: session.planMode, - totalCost: totalCostRef.current, - contextUsage: contextUsageRef.current, - engine: session.engine, - ...(session.agentId ? { agentId: session.agentId } : {}), - ...(session.agentSessionId ? { agentSessionId: session.agentSessionId } : {}), - ...(session.engine === "codex" && session.codexThreadId ? { codexThreadId: session.codexThreadId } : {}), - }; + const data: PersistedSession = buildPersistedSession( + session, + msgs, + totalCostRef.current, + contextUsageRef.current, + ); await persistSessionWithCodexFallback(data); }, [persistSessionWithCodexFallback]); diff --git a/src/hooks/useAppOrchestrator.ts b/src/hooks/useAppOrchestrator.ts index 08b7422..f73d6c5 100644 --- a/src/hooks/useAppOrchestrator.ts +++ b/src/hooks/useAppOrchestrator.ts @@ -169,6 +169,11 @@ export function useAppOrchestrator() { window.claude.getGlassSupported().then((supported) => setGlassSupported(supported)); }, []); + // Keep Electron's native theme in sync so Windows Mica follows the app theme. + useEffect(() => { + window.claude.setThemeSource(settings.theme); + }, [settings.theme]); + // Toggle the glass-enabled CSS class when the transparency setting changes. // Preload applies the initial class from localStorage so first paint stays in sync. useEffect(() => { diff --git a/src/hooks/useSessionManager.ts b/src/hooks/useSessionManager.ts index 086c9cb..0761218 100644 --- a/src/hooks/useSessionManager.ts +++ b/src/hooks/useSessionManager.ts @@ -2,6 +2,7 @@ import { useState, useCallback, useRef } from "react"; import type { ChatSession, UIMessage, PermissionRequest, McpServerStatus, McpServerConfig, ModelInfo, AcpPermissionBehavior, EngineId, Project } from "../types"; import type { ACPConfigOption, ACPPermissionEvent } from "../types/acp"; import { toMcpStatusState } from "../lib/mcp-utils"; +import { toChatSession } from "../lib/session-records"; import { useClaude } from "./useClaude"; import { useACP } from "./useACP"; import { useCodex } from "./useCodex"; @@ -342,10 +343,9 @@ export function useSessionManager(projects: Project[], acpPermissionBehavior: Ac if (ids.length === 0) return; const uniqueIds = [...new Set(ids)]; const lists = await Promise.all(uniqueIds.map((projectId) => window.claude.sessions.list(projectId))); - const refreshed = lists.flat().map((s) => ({ - ...s, - isActive: s.id === activeSessionIdRef.current, - })); + const refreshed = lists.flat().map((session) => + toChatSession(session, session.id === activeSessionIdRef.current), + ); setSessions((prev) => { const keep = prev.filter((s) => !uniqueIds.includes(s.projectId)); const map = new Map(); diff --git a/src/index.css b/src/index.css index 0d30682..07bde8f 100644 --- a/src/index.css +++ b/src/index.css @@ -166,6 +166,39 @@ html.glass-enabled #root { --sidebar-accent: oklch(0.965 0.001 286.029 / 0.3); /* Light mode glass: keep dark text (same as Windows) — the always-key-appearance fix keeps the glass bright enough for standard dark-on-light text. */ + --sidebar-ring: oklch(0.64 0 0); +} + +/* macOS light glass needs light sidebar text because the native material reads + visually darker. Windows Mica stays light enough that default dark text is + more legible, so leave the base light tokens alone there. */ +html.platform-darwin.glass-enabled:not(.dark) { + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + /* Keep --sidebar-accent-foreground at light-mode default for other accent + surfaces; selected chats are overridden below. */ +} + +/* Elements on light backgrounds (search box) — reset to dark text/icons */ +html.platform-darwin.glass-enabled:not(.dark) .glass-outline, +html.platform-darwin.glass-enabled:not(.dark) .bg-sidebar-accent { + --sidebar-foreground: oklch(0.145 0 0); +} + +/* Sidebar search stays on the white-text treatment in light glass mode. */ +html.platform-darwin.glass-enabled:not(.dark) .sidebar-search-glass { + --sidebar-foreground: oklch(0.985 0 0); +} + +/* In light mode with glass enabled, selected chats should stay white. */ +html.platform-darwin.glass-enabled:not(.dark) .session-item-active { + color: oklch(0.985 0 0); +} + +/* In light glass mode, sidebar engine icons match white text via inversion */ +html.platform-darwin.glass-enabled:not(.dark) .session-item-button img { + filter: invert(1) brightness(2); } /* In glass mode, only the outermost bg-sidebar (AppLayout root) provides @@ -401,8 +434,8 @@ html:not(.dark) .no-island-shine .island { /* Windows: smaller corner radius to match native chrome, no traffic-light padding */ html.platform-win32 { --radius: 0.375rem; - --island-radius: 0.625rem; - --island-control-radius: 0.5625rem; + --island-radius: 0.5rem; + --island-control-radius: 0.4375rem; } html.platform-win32 .drag-region { -webkit-app-region: none; diff --git a/src/lib/layout-constants.ts b/src/lib/layout-constants.ts index 8d28ce4..435b424 100644 --- a/src/lib/layout-constants.ts +++ b/src/lib/layout-constants.ts @@ -1,5 +1,5 @@ -export const MIN_CHAT_WIDTH_ISLAND = 828; -export const MIN_CHAT_WIDTH_FLAT = 828; +export const MIN_CHAT_WIDTH_ISLAND = 704; +export const MIN_CHAT_WIDTH_FLAT = 704; export const BOTTOM_CHAT_MAX_WIDTH_CLASS = "max-w-[61.5rem]"; export const CHAT_INPUT_MAX_WIDTH_CLASS = BOTTOM_CHAT_MAX_WIDTH_CLASS; export const APP_SIDEBAR_WIDTH = 280; diff --git a/src/lib/session-records.test.ts b/src/lib/session-records.test.ts new file mode 100644 index 0000000..c147752 --- /dev/null +++ b/src/lib/session-records.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import type { ChatSession, UIMessage } from "@/types"; +import { buildPersistedSession, toChatSession } from "./session-records"; + +describe("session records", () => { + it("keeps folder, pin, and branch metadata when hydrating sidebar sessions", () => { + const session = toChatSession({ + id: "session-1", + projectId: "project-1", + title: "Chat", + createdAt: 100, + lastMessageAt: 200, + totalCost: 12, + engine: "claude", + folderId: "folder-1", + pinned: true, + branch: "feature/test", + }, false); + + expect(session.folderId).toBe("folder-1"); + expect(session.pinned).toBe(true); + expect(session.branch).toBe("feature/test"); + expect(session.isActive).toBe(false); + }); + + it("keeps folder, pin, and branch metadata when building persisted sessions", () => { + const session: ChatSession = { + id: "session-1", + projectId: "project-1", + title: "Chat", + createdAt: 100, + totalCost: 12, + isActive: true, + engine: "claude", + folderId: "folder-1", + pinned: true, + branch: "feature/test", + }; + const messages: UIMessage[] = [{ + id: "message-1", + role: "user", + content: "hi", + timestamp: 101, + }]; + + const persisted = buildPersistedSession(session, messages, 12, null); + + expect(persisted.folderId).toBe("folder-1"); + expect(persisted.pinned).toBe(true); + expect(persisted.branch).toBe("feature/test"); + expect(persisted.messages).toEqual(messages); + }); +}); diff --git a/src/lib/session-records.ts b/src/lib/session-records.ts new file mode 100644 index 0000000..4a9f78f --- /dev/null +++ b/src/lib/session-records.ts @@ -0,0 +1,50 @@ +import type { SessionMeta as SessionListItem } from "@shared/lib/session-persistence"; +import type { ChatSession, ContextUsage, PersistedSession, UIMessage } from "@/types"; + +export function toChatSession( + session: SessionListItem, + isActive: boolean, +): ChatSession { + return { + id: session.id, + projectId: session.projectId, + title: session.title, + createdAt: session.createdAt, + lastMessageAt: session.lastMessageAt || session.createdAt, + model: session.model, + planMode: session.planMode, + totalCost: session.totalCost ?? 0, + isActive, + engine: session.engine, + codexThreadId: session.codexThreadId, + folderId: session.folderId, + pinned: session.pinned, + branch: session.branch, + }; +} + +export function buildPersistedSession( + session: ChatSession, + messages: UIMessage[], + totalCost: number, + contextUsage: ContextUsage | null, +): PersistedSession { + return { + id: session.id, + projectId: session.projectId, + title: session.title, + createdAt: session.createdAt, + messages, + model: session.model, + planMode: session.planMode, + totalCost, + contextUsage, + engine: session.engine, + folderId: session.folderId, + pinned: session.pinned, + branch: session.branch, + ...(session.agentId ? { agentId: session.agentId } : {}), + ...(session.agentSessionId ? { agentSessionId: session.agentSessionId } : {}), + ...(session.engine === "codex" && session.codexThreadId ? { codexThreadId: session.codexThreadId } : {}), + }; +} diff --git a/src/lib/sidebar-grouping.test.ts b/src/lib/sidebar-grouping.test.ts new file mode 100644 index 0000000..5dc2435 --- /dev/null +++ b/src/lib/sidebar-grouping.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import type { ChatFolder, ChatSession } from "@/types"; +import { buildSidebarGroups } from "./sidebar-grouping"; + +function makeFolder(overrides: Partial = {}): ChatFolder { + return { + id: overrides.id ?? "folder-1", + projectId: overrides.projectId ?? "project-1", + name: overrides.name ?? "New folder", + createdAt: overrides.createdAt ?? 100, + order: overrides.order ?? 0, + pinned: overrides.pinned, + }; +} + +function makeSession(overrides: Partial = {}): ChatSession { + return { + id: overrides.id ?? "session-1", + projectId: overrides.projectId ?? "project-1", + title: overrides.title ?? "Chat", + createdAt: overrides.createdAt ?? 10, + totalCost: overrides.totalCost ?? 0, + isActive: overrides.isActive ?? false, + folderId: overrides.folderId, + pinned: overrides.pinned, + branch: overrides.branch, + lastMessageAt: overrides.lastMessageAt, + model: overrides.model, + planMode: overrides.planMode, + engine: overrides.engine, + agentSessionId: overrides.agentSessionId, + agentId: overrides.agentId, + codexThreadId: overrides.codexThreadId, + isProcessing: overrides.isProcessing, + hasPendingPermission: overrides.hasPendingPermission, + titleGenerating: overrides.titleGenerating, + }; +} + +describe("buildSidebarGroups", () => { + it("shows empty folders at the top level in branch mode", () => { + const emptyFolder = makeFolder({ + id: "folder-empty", + name: "New folder", + createdAt: 300, + }); + const branchSession = makeSession({ + id: "session-branch", + branch: "feature/test", + createdAt: 100, + lastMessageAt: 200, + }); + + const items = buildSidebarGroups([branchSession], [emptyFolder], true); + + expect(items).toHaveLength(2); + expect(items[0]?.type).toBe("folder"); + expect(items[0]?.folder?.id).toBe("folder-empty"); + expect(items[1]?.type).toBe("branch"); + }); + + it("does not duplicate folders that already contain feature-branch chats", () => { + const activeFolder = makeFolder({ + id: "folder-active", + name: "Feature work", + createdAt: 50, + }); + const branchSession = makeSession({ + id: "session-branch", + branch: "feature/test", + folderId: "folder-active", + createdAt: 100, + lastMessageAt: 200, + }); + + const items = buildSidebarGroups([branchSession], [activeFolder], true); + + expect(items).toHaveLength(1); + expect(items[0]?.type).toBe("branch"); + expect(items[0]?.children).toHaveLength(1); + expect(items[0]?.children?.[0]?.type).toBe("folder"); + expect(items[0]?.children?.[0]?.folder?.id).toBe("folder-active"); + }); +}); diff --git a/src/lib/sidebar-grouping.ts b/src/lib/sidebar-grouping.ts index cd00608..2760601 100644 --- a/src/lib/sidebar-grouping.ts +++ b/src/lib/sidebar-grouping.ts @@ -178,6 +178,9 @@ function buildWithBranches( ): void { const items: SidebarItem[] = []; const folderMap = new Map(folders.map((f) => [f.id, f])); + const folderIdsWithAnySessions = new Set( + sessions.flatMap((session) => (session.folderId ? [session.folderId] : [])), + ); // Partition sessions by branch const branchBuckets = new Map(); @@ -215,6 +218,18 @@ function buildWithBranches( const mainFolderItems = buildFlatItems(mainSessions, folderMap); items.push(...mainFolderItems); + // Empty folders are not branch-specific, so keep them at the top level. + for (const folder of folders) { + if (folderIdsWithAnySessions.has(folder.id)) continue; + items.push({ + type: "folder", + label: folder.name, + folder, + sortTimestamp: folder.createdAt, + sessions: [], + }); + } + // Sort all items by recency (folders use createdAt when empty) items.sort((a, b) => { return b.sortTimestamp - a.sortTimestamp; diff --git a/src/types/window.d.ts b/src/types/window.d.ts index 05bdae3..1321c4a 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -6,6 +6,7 @@ import type { InstalledAgent, ModelInfo, McpServerConfig, McpServerStatus, AppSettings, ClaudeEffort, + ThemeOption, } from "./ui"; import type { ACPSessionEvent, ACPPermissionEvent, ACPTurnCompleteEvent, ACPConfigOption } from "./acp"; import type { EngineId, AppPermissionBehavior } from "./engine"; @@ -55,6 +56,7 @@ declare global { interface Window { claude: { getGlassSupported: () => Promise; + setThemeSource: (themeSource: ThemeOption) => void; setMinWidth: (width: number) => void; glass: { setTintColor: (tintColor: string | null) => void; From 3c75610502173be57e96ea2beff1df885d60b6f5 Mon Sep 17 00:00:00 2001 From: OpenSource Date: Sun, 22 Mar 2026 02:24:59 +0100 Subject: [PATCH 3/4] feat: split view with drag-to-split, resizable panes, and per-pane tool drawers Add multi-pane split view system allowing up to 4 concurrent chat sessions side by side. Sessions can be opened in split view via sidebar context menu or by dragging from sidebar onto the chat area. Each pane hosts its own engine triple (Claude/ACP/Codex) via the new useSessionPane hook, with independent tool drawers, scroll state, and permission handling. Background event routing is updated to skip sessions that are actively rendered in split panes. Also adds release skill for automated version bumping and GitHub release creation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .agents/skills/release/SKILL.md | 162 +++++ .../references/release-notes-template.md | 119 ++++ shared/lib/session-persistence.ts | 3 + src/components/AppLayout.tsx | 613 +++++++++++++++++- src/components/AppSidebar.tsx | 6 + src/components/ChatHeader.tsx | 34 +- src/components/sidebar/BranchSection.tsx | 8 + src/components/sidebar/FolderSection.tsx | 6 + src/components/sidebar/PinnedSection.tsx | 8 + src/components/sidebar/ProjectSection.tsx | 12 + src/components/sidebar/SessionItem.tsx | 13 + src/components/split/PaneToolDrawer.tsx | 109 ++++ src/components/split/PaneToolTabBar.tsx | 85 +++ src/components/split/SplitDropZone.tsx | 46 ++ src/components/split/SplitHandle.tsx | 44 ++ src/components/split/SplitPaneHost.tsx | 50 ++ src/hooks/session/types.ts | 12 + src/hooks/session/useExtraPaneLoader.ts | 108 +++ src/hooks/session/useSessionPane.ts | 128 ++++ src/hooks/session/useSessionPersistence.ts | 14 +- src/hooks/useAppOrchestrator.ts | 47 +- src/hooks/usePaneResize.ts | 106 +++ src/hooks/useSessionManager.ts | 117 +++- src/hooks/useSplitDragDrop.ts | 219 +++++++ src/hooks/useSplitView.ts | 266 ++++++++ src/lib/layout-constants.ts | 65 ++ src/lib/session-records.ts | 1 + src/lib/split-layout.test.ts | 81 +++ src/lib/split-layout.ts | 115 ++++ 29 files changed, 2515 insertions(+), 82 deletions(-) create mode 100644 .agents/skills/release/SKILL.md create mode 100644 .agents/skills/release/references/release-notes-template.md create mode 100644 src/components/split/PaneToolDrawer.tsx create mode 100644 src/components/split/PaneToolTabBar.tsx create mode 100644 src/components/split/SplitDropZone.tsx create mode 100644 src/components/split/SplitHandle.tsx create mode 100644 src/components/split/SplitPaneHost.tsx create mode 100644 src/hooks/session/useExtraPaneLoader.ts create mode 100644 src/hooks/session/useSessionPane.ts create mode 100644 src/hooks/usePaneResize.ts create mode 100644 src/hooks/useSplitDragDrop.ts create mode 100644 src/hooks/useSplitView.ts create mode 100644 src/lib/split-layout.test.ts create mode 100644 src/lib/split-layout.ts diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md new file mode 100644 index 0000000..e3f3e1f --- /dev/null +++ b/.agents/skills/release/SKILL.md @@ -0,0 +1,162 @@ +--- +name: release +description: "Run the Harnss release workflow — review staged diff, bump version, commit, tag, push, and create a GitHub release. Use when releasing, bumping version, tagging, or creating a release. Argument: major, minor, or patch." +--- + +# Harnss Release Workflow + +Run the full release pipeline. Bump type is passed as `$ARGUMENTS` (major, minor, or patch). + +## Step 1: Pre-flight Checks + +Run these commands and **read every line of output**: + +```bash +git status +git diff --cached --stat +git diff --cached +``` + +If the diff is too large to read in one shot, read it in chunks (e.g., per-directory or line ranges). **You must read the entire diff before proceeding.** + +Review for: +- Test files, scratch files, temp files, debug artifacts (e.g., `test-*.ts`, `scratch.*`, `*.tmp`, `random-*.md`) +- Files that shouldn't be committed (`.env`, credentials, large binaries) + +If you find any: +- Unstage or remove them +- Tell the user what you removed + +If there are no staged changes but unstaged changes exist, ask the user if they want to stage anything first. +If the working tree is completely clean (nothing to release), tell the user and stop. + +## Step 2: Version Bump + +### Determine new version + +1. Read the current version from `package.json` (the `"version"` field) +2. Get the latest tag: `git tag --sort=-v:refname | head -1` +3. Parse the current version as `MAJOR.MINOR.PATCH` +4. Apply the bump type from `$ARGUMENTS`: + - `major` → `(MAJOR+1).0.0` + - `minor` → `MAJOR.(MINOR+1).0` + - `patch` → `MAJOR.MINOR.(PATCH+1)` +5. If `$ARGUMENTS` is empty or invalid, ask the user which bump type they want + +### Check for SDK updates + +```bash +npm view @anthropic-ai/Codex-agent-sdk version +``` + +Compare with the version in `package.json` under `dependencies["@anthropic-ai/Codex-agent-sdk"]` (strip the `^` prefix for comparison). If a newer version exists, update the dependency version (keep the `^` prefix) and tell the user. + +### Apply changes + +1. Edit `package.json` to set the new version number +2. Stage it: `git add package.json` +3. If the SDK was updated, also run: + ```bash + pnpm install + git add package.json pnpm-lock.yaml + ``` + +## Step 3: Commit + +Choose the commit message based on what's staged: + +### Feature/fix changes staged (not just version bump) + +``` +feat: short summary (2-4 key themes) + +- Change description 1 +- Change description 2 +- ... + +Co-Authored-By: Codex Opus 4.6 (1M context) +``` + +Use `fix:` instead of `feat:` if all changes are bug fixes. + +### Only version bump staged + +``` +chore: bump version to X.Y.Z + +Co-Authored-By: Codex Opus 4.6 (1M context) +``` + +If the SDK was also updated: + +``` +chore: bump version to X.Y.Z and update Codex-agent-sdk to A.B.C + +Co-Authored-By: Codex Opus 4.6 (1M context) +``` + +### Always use a HEREDOC + +```bash +git commit -m "$(cat <<'EOF' + + +Co-Authored-By: Codex Opus 4.6 (1M context) +EOF +)" +``` + +## Step 4: Tag & Push + +```bash +git tag vX.Y.Z HEAD +git push origin master && git push origin vX.Y.Z +``` + +If push fails, report the error and stop. + +## Step 5: GitHub Release + +### Gather context + +Get the previous release tag: + +```bash +git tag --sort=-v:refname | head -2 | tail -1 +``` + +Read the full diff and commit log since the previous release: + +```bash +git log v{prev}...HEAD --oneline +git diff v{prev}...HEAD --stat +git diff v{prev}...HEAD +``` + +Read ALL of this output. For the full diff, read it in chunks if needed — every line matters for writing accurate release notes. + +### Write release notes + +Load the template from [references/release-notes-template.md](references/release-notes-template.md) and follow its format exactly. + +### Create the release + +```bash +gh release create vX.Y.Z --title "vX.Y.Z — Short Descriptive Phrase" --notes "$(cat <<'EOF' + +EOF +)" +``` + +The title uses an em dash (`—`), not a hyphen. + +Output the release URL when done so the user can verify. + +## Important Notes + +- **Never skip reading the full diff** in Step 1. Every line matters. +- The `Co-Authored-By` trailer is **mandatory** on every commit. +- Repo: `https://github.com/OpenSource03/harnss` +- Main branch: `master` +- Changelog URL format: `https://github.com/OpenSource03/harnss/compare/v{prev}...v{current}` +- Package manager: `pnpm` (never use npm or yarn for installs) diff --git a/.agents/skills/release/references/release-notes-template.md b/.agents/skills/release/references/release-notes-template.md new file mode 100644 index 0000000..2e9ae6d --- /dev/null +++ b/.agents/skills/release/references/release-notes-template.md @@ -0,0 +1,119 @@ +# Harnss Release Notes Template + +## Title Format + +`v{X.Y.Z} — Short Descriptive Phrase` + +- Use an em dash (`—`), not a hyphen +- Name 2-3 headline features, joined by commas and `&` +- Examples: + - `v0.21.0 — Virtualized Chat, Mermaid Diagrams & Deep Folder Tagging` + - `v0.15.0 — Slash Commands, Tool Grouping & Project Files` + - `v0.14.0 — Codex Engine Config, Auth Flow & Settings Refresh` + - `v0.13.1 — Windows Compatibility Fixes` + +## Audience & Tone + +**Write for users, not developers.** Release notes are read by people who use the app, not people who built it. + +- ✅ "Long conversations are dramatically faster now" +- ❌ "Replaced `content-visibility: auto` with `@tanstack/react-virtual` windowing" +- ✅ "When Claude draws a diagram, it now actually renders as a visual diagram" +- ❌ "Mermaid fenced code blocks render as SVG via async `mermaid.render()` with LRU cache" +- ✅ "Type `/clear` in the composer and hit Enter to open a fresh chat" +- ❌ "Added `LOCAL_CLEAR_COMMAND` slash command with `source: 'local'` that calls `onClear()` callback" + +**Rules of thumb:** +- Describe what the user *experiences*, not what the code does +- No internal names, no version numbers, no API terms, no implementation details +- If you can't explain it in plain English, simplify or skip it +- Bug fixes: describe the symptom the user saw, not the root cause + +## Notes Structure + +Sections can be free-form paragraphs OR bullet lists — pick whichever reads more naturally. + +```markdown +## What's New + +### {emoji} {User-Facing Section Title} +Short paragraph explaining what changed and why users care. + +### {emoji} {User-Facing Section Title} +- **{Feature name}** — what it does for the user +- **{Feature name}** — what it does for the user + +### 🐛 Bug Fixes +- Fixed a bug where [symptom the user experienced] +- Fixed [thing] that caused [user-visible problem] + +--- + +**Full Changelog**: https://github.com/OpenSource03/harnss/compare/v{prev}...v{current} +``` + +## Rules + +1. Use `## What's New` for feature releases, `## Changes` for patch/fix-only releases +2. Group under `### {emoji} {Category Title}` headers — keep titles plain, not technical +3. End with `---` separator and Full Changelog link +4. **Always write for users first.** Internal details, library names, and implementation notes belong in commit messages and CLAUDE.md, not release notes. + +## Emoji Conventions + +| Emoji | Category | +|-------|----------| +| ⚡ | Performance, speed, snappiness | +| 📦 | Grouping, packaging | +| 📂 | Files, folders, filesystem | +| 🔍 | Search, inspection | +| 📨 | Messages, queues, communication | +| 🛠 | Tools, integrations | +| 🎨 | UI, visual polish | +| ⚙️ | Settings, configuration | +| 🔐 | Auth, security, permissions | +| 🔄 | Updates, syncing | +| 🌳 | Git, version control | +| 🐛 | Bug fixes | +| ✨ | New features (generic) | + +## Example: Feature Release (v0.21.0) + +```markdown +## What's New + +### ⚡ Much Faster Chat +Long conversations are dramatically faster now. We replaced the old rendering approach with a proper virtualized list — only the messages you can actually see are rendered at any time. Scrolling is smoother, switching sessions is snappier, and the app uses less memory overall. + +### 📊 Mermaid Diagrams +When Claude draws a diagram using a mermaid code block, it now actually renders as a visual diagram — flowcharts, sequence diagrams, pie charts, git graphs, and more. Diagrams adapt to your light/dark theme automatically. While Claude is still typing, you see the raw source; once the message is complete, the diagram appears. + +### 📂 Deep Folder Inclusion (`@#`) +You can now use `@#foldername` in the composer to include the full contents of a folder — not just the file tree, but every file inside it. Regular `@folder` still gives you the structure overview. If the folder is large, Harnss will warn you before sending. + +### ⌨️ `/clear` Command +Type `/clear` in the composer and hit Enter to instantly open a fresh chat — without sending anything to the agent. + +### 🐛 Bug Fixes +- Fixed a bug where switching out of plan mode could reset the permission level incorrectly +- Fixed markdown characters occasionally getting eaten during streaming (apostrophes, backticks, etc.) +- Permission prompts now show a notification if something goes wrong, instead of failing silently + +--- + +**Full Changelog**: https://github.com/OpenSource03/harnss/compare/v0.20.0...v0.21.0 +``` + +## Example: Patch Release + +```markdown +## Changes + +### 🐛 Bug Fixes +- Fixed the app hanging when switching sessions during an active stream +- Fixed copy button not working in certain sandboxed contexts + +--- + +**Full Changelog**: https://github.com/OpenSource03/harnss/compare/v0.21.0...v0.21.1 +``` diff --git a/shared/lib/session-persistence.ts b/shared/lib/session-persistence.ts index 576118c..9f08423 100644 --- a/shared/lib/session-persistence.ts +++ b/shared/lib/session-persistence.ts @@ -20,6 +20,8 @@ export interface SessionMeta { pinned?: boolean; /** Git branch at session creation time. */ branch?: string; + /** Agent ID — which agent was used for this session. */ + agentId?: string; } /** @@ -54,5 +56,6 @@ export function extractSessionMeta(data: Record, lastMessageAt: folderId: data.folderId as string | undefined, pinned: data.pinned as boolean | undefined, branch: data.branch as string | undefined, + agentId: data.agentId as string | undefined, }; } diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 68f80d1..bc7f7cb 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,5 +1,7 @@ -import { useCallback, useRef, useEffect, useLayoutEffect, useState } from "react"; +import React, { useCallback, useMemo, useRef, useEffect, useLayoutEffect, useState } from "react"; +import { LayoutGroup, motion } from "motion/react"; import { PanelLeft } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { normalizeRatios } from "@/hooks/useSettings"; import { useAppOrchestrator } from "@/hooks/useAppOrchestrator"; @@ -12,6 +14,7 @@ import { ISLAND_RADIUS, RESIZE_HANDLE_WIDTH_ISLAND, TOOL_PICKER_WIDTH_ISLAND, + equalWidthFractions, getMinChatWidth, } from "@/lib/layout-constants"; import type { GrabbedElement } from "@/types/ui"; @@ -39,6 +42,14 @@ import { CodexAuthDialog } from "./CodexAuthDialog"; import { JiraBoardPanel } from "./JiraBoardPanel"; import type { JiraIssue } from "@shared/types/jira"; import { isMac, isWindows } from "@/lib/utils"; +import { SplitHandle } from "./split/SplitHandle"; +import { SplitDropZone } from "./split/SplitDropZone"; +import { PaneToolDrawer } from "./split/PaneToolDrawer"; +import { SplitPaneHost } from "./split/SplitPaneHost"; +import { usePaneResize } from "@/hooks/usePaneResize"; +import { useSplitDragDrop } from "@/hooks/useSplitDragDrop"; +import { MIN_CHAT_WIDTH_SPLIT, SPLIT_HANDLE_WIDTH } from "@/lib/layout-constants"; +import { getMaxVisibleSplitPaneCount } from "@/lib/split-layout"; const JIRA_BOARD_BY_SPACE_KEY = "harnss-jira-board-by-space"; @@ -76,6 +87,7 @@ export function AppLayout() { handleCreateSpace, handleEditSpace, handleDeleteSpace, handleSaveSpace, handleMoveProjectToSpace, handleSeedDevExampleSpaceData, + splitView, } = o; const glassOverlayStyle = useSpaceTheme( @@ -179,9 +191,10 @@ export function AppLayout() { if (project) { setJiraBoardProjectForSpace(project.spaceId || "default", null); } + splitView.dismissSplitView(); await handleNewChat(projectId); }, - [handleNewChat, projectManager.projects, setJiraBoardProjectForSpace], + [handleNewChat, projectManager.projects, setJiraBoardProjectForSpace, splitView.dismissSplitView], ); const handleComposerClear = useCallback( @@ -203,9 +216,10 @@ export function AppLayout() { if (project) { setJiraBoardProjectForSpace(project.spaceId || "default", null); } + splitView.dismissSplitView(); handleSelectSession(sessionId); }, - [handleSelectSession, manager.sessions, projectManager.projects, setJiraBoardProjectForSpace], + [handleSelectSession, manager.sessions, projectManager.projects, setJiraBoardProjectForSpace, splitView.dismissSplitView], ); const handleToggleProjectJiraBoard = useCallback((projectId: string) => { @@ -320,11 +334,149 @@ Link: ${issue.url}`; handleBottomResizeStart, handleBottomSplitStart, } = resize; + // ── Split view resize ── + const splitContainerRef = useRef(null); + const topRowRef = useRef(null); + const [availableSplitWidth, setAvailableSplitWidth] = useState(0); + + useLayoutEffect(() => { + const element = contentRef.current; + if (!element) return; + + const updateAvailableSplitWidth = () => { + setAvailableSplitWidth(element.getBoundingClientRect().width); + }; + + updateAvailableSplitWidth(); + const resizeObserver = new ResizeObserver(updateAvailableSplitWidth); + resizeObserver.observe(element); + return () => { + resizeObserver.disconnect(); + }; + }, [contentRef]); + + const maxSplitPaneCount = useMemo( + () => getMaxVisibleSplitPaneCount(availableSplitWidth), + [availableSplitWidth], + ); + + const paneResize = usePaneResize({ + widthFractions: splitView.widthFractions, + setWidthFractions: splitView.setWidthFractions, + containerRef: splitContainerRef, + }); + + const isSplitActive = splitView.enabled; + const splitPaneSessionIds = splitView.visibleSessionIds; + + // ── Drag-and-drop from sidebar ── + const visibleSessionIds = useMemo(() => { + const ids = new Set(); + if (isSplitActive) { + for (const sessionId of splitView.visibleSessionIds) ids.add(sessionId); + return ids; + } + + if (manager.activeSessionId) ids.add(manager.activeSessionId); + return ids; + }, [isSplitActive, manager.activeSessionId, splitView.visibleSessionIds]); + + useEffect(() => { + if (!isSplitActive) { + if (splitView.focusedSessionId !== null) { + splitView.setFocusedSession(null); + } + return; + } + + if (splitView.focusedSessionId && visibleSessionIds.has(splitView.focusedSessionId)) { + return; + } + + splitView.setFocusedSession(splitPaneSessionIds[0] ?? null); + }, [ + isSplitActive, + splitPaneSessionIds, + splitView.focusedSessionId, + splitView.setFocusedSession, + visibleSessionIds, + ]); + + useEffect(() => { + const validSessionIds = new Set(manager.sessions.map((session) => session.id)); + splitView.pruneSplitSessions(validSessionIds); + }, [manager.sessions, splitView.pruneSplitSessions]); + + const requestAddSplitSession = useCallback((sessionId: string, position?: number) => { + const result = splitView.requestAddSplitSession({ + sessionId, + activeSessionId: manager.activeSessionId, + maxPaneCount: maxSplitPaneCount, + position, + }); + + if (!result.ok && result.reason === "insufficient-width") { + toast.error("Widen the window to add another split pane."); + } + + return result.ok; + }, [manager.activeSessionId, maxSplitPaneCount, splitView]); + + const handleCloseSplitPane = useCallback(async (sessionId: string | null) => { + if (!sessionId) { + splitView.dismissSplitView(); + return; + } + + if (sessionId !== manager.activeSessionId) { + splitView.removeSplitSession(sessionId); + return; + } + + const paneIndex = splitView.visibleSessionIds.indexOf(sessionId); + const remainingSessionIds = splitView.visibleSessionIds.filter((visibleSessionId) => visibleSessionId !== sessionId); + const replacementSessionId = remainingSessionIds[paneIndex] ?? remainingSessionIds[paneIndex - 1] ?? remainingSessionIds[0] ?? null; + + splitView.removeSplitSession(sessionId); + if (!replacementSessionId) { + return; + } + + await manager.switchSession(replacementSessionId); + }, [ + manager.activeSessionId, + manager.switchSession, + splitView.removeSplitSession, + splitView.visibleSessionIds, + ]); + + const splitDragDrop = useSplitDragDrop({ + paneCount: splitView.paneCount, + canAcceptDrop: !!manager.activeSessionId && splitView.paneCount < maxSplitPaneCount, + containerRef: splitContainerRef, + widthFractions: splitView.widthFractions, + onDrop: (sessionId, position) => { + requestAddSplitSession(sessionId, position); + }, + visibleSessionIds, + }); + const previewDropPosition = splitDragDrop.dragState.isDragging ? splitDragDrop.dragState.dropPosition : null; + const previewPaneCount = previewDropPosition === null ? splitView.paneCount : splitView.paneCount + 1; + const previewWidthFractions = useMemo( + () => previewDropPosition === null ? splitView.widthFractions : equalWidthFractions(previewPaneCount), + [previewDropPosition, previewPaneCount, splitView.widthFractions], + ); + // ── Chat scroll fade & titlebar tinting ── const chatIslandRef = useRef(null); const lastTopScrollProgressRef = useRef(0); + // Per-pane scroll progress refs for split view (up to 4 panes) + const paneRefs = useRef<(HTMLDivElement | null)[]>([]); + const lastPaneScrollProgressRefs = useRef([]); + + useEffect(() => { // Grabbed elements are session-specific context — discard on switch setGrabbedElements([]); @@ -342,6 +494,23 @@ Link: ${issue.url}`; chatIslandRef.current?.style.setProperty("--chat-top-progress", clamped.toFixed(3)); }, []); + /** Create a scroll progress callback for a specific pane index. */ + const makePaneScrollCallback = useCallback((paneIndex: number) => { + return (progress: number) => { + const clamped = Math.max(0, Math.min(1, progress)); + const prev = lastPaneScrollProgressRefs.current[paneIndex] ?? 0; + if (Math.abs(prev - clamped) < 0.005) return; + lastPaneScrollProgressRefs.current[paneIndex] = clamped; + paneRefs.current[paneIndex]?.style.setProperty("--chat-top-progress", clamped.toFixed(3)); + }; + }, []); + + // Memoize per-pane scroll callbacks (stable across renders) + const paneScrollCallbacks = useMemo( + () => splitPaneSessionIds.map((_, index) => makePaneScrollCallback(index)), + [makePaneScrollCallback, splitPaneSessionIds], + ); + const handleScrolledToMessage = useCallback(() => { setScrollToMessageId(undefined); }, []); @@ -379,6 +548,231 @@ Link: ${issue.url}`; : `linear-gradient(to bottom, ${chatSurfaceColor} 0%, ${chatSurfaceColor} 34%, color-mix(in oklab, ${chatSurfaceColor} 90.5%, transparent) 60%, transparent 100%), radial-gradient(142% 92% at 50% 0%, color-mix(in srgb, black ${topFadeShadowOpacity}%, transparent) 0%, transparent 72%)`; const bottomFadeBackground = `linear-gradient(to top, ${chatSurfaceColor}, transparent)`; + const getPreviewPaneMetrics = useCallback((previewIndex: number) => { + const widthPercent = (previewWidthFractions[previewIndex] ?? (1 / previewPaneCount)) * 100; + const totalHandleWidth = (previewPaneCount - 1) * SPLIT_HANDLE_WIDTH; + const handleSharePx = totalHandleWidth / previewPaneCount; + return { widthPercent, handleSharePx }; + }, [previewPaneCount, previewWidthFractions]); + const shouldAnimateSplitLayout = !paneResize.isResizing; + const showSinglePaneSplitPreview = !isSplitActive && splitDragDrop.dragState.isDragging && !!manager.activeSessionId; + const singlePanePreviewPosition = splitDragDrop.dragState.dropPosition === 0 ? 0 : 1; + const singlePanePreviewPaneStyle = useMemo(() => { + const { widthPercent, handleSharePx } = getPreviewPaneMetrics(singlePanePreviewPosition); + return { + width: `calc(${widthPercent}% - ${handleSharePx}px)`, + minWidth: MIN_CHAT_WIDTH_SPLIT, + } as React.CSSProperties; + }, [getPreviewPaneMetrics, singlePanePreviewPosition]); + + const renderSplitPane = useCallback(( + sessionId: string, + session: typeof manager.activeSession, + paneState: typeof manager.primaryPane, + displayIndex: number, + isActiveSessionPane: boolean, + previewIndex: number, + ) => { + const drawer = splitView.getDrawerState(sessionId); + const { widthPercent, handleSharePx } = getPreviewPaneMetrics(previewIndex); + const selectedPaneAgent = isActiveSessionPane + ? selectedAgent + : session?.agentId + ? agents.find((agent) => agent.id === session.agentId) ?? null + : session?.engine === "codex" + ? agents.find((agent) => agent.engine === "codex") ?? null + : null; + + return ( + { paneRefs.current[displayIndex] = element; }} + className={`chat-island island flex flex-col overflow-hidden rounded-[var(--island-radius)] bg-background ${ + splitView.focusedSessionId === sessionId ? "ring-2 ring-primary/15" : "" + }`} + style={{ + width: `calc(${widthPercent}% - ${handleSharePx}px)`, + minWidth: MIN_CHAT_WIDTH_SPLIT, + flexShrink: 0, + "--chat-fade-strength": String(chatFadeStrength), + } as React.CSSProperties} + onClick={() => splitView.setFocusedSession(sessionId)} + > +
+
+
+ {}} + showDevFill={isActiveSessionPane ? devFillEnabled : false} + onSeedDevExampleConversation={isActiveSessionPane ? manager.seedDevExampleConversation : undefined} + onSeedDevExampleSpaceData={isActiveSessionPane ? handleSeedDevExampleSpaceData : undefined} + onClosePane={() => { + void handleCloseSplitPane(sessionId); + }} + /> +
+ +
+
+ 0 ? paneState.codex.codexModels : manager.supportedModels) + : session?.engine === "acp" + ? [] + : paneState.claude.supportedModels.length > 0 + ? paneState.claude.supportedModels + : manager.supportedModels + } + codexModelsLoadingMessage={isActiveSessionPane ? manager.codexModelsLoadingMessage : null} + codexEffort={isActiveSessionPane ? manager.codexEffort : undefined} + onCodexEffortChange={isActiveSessionPane ? manager.setCodexEffort : undefined} + codexModelData={isActiveSessionPane ? manager.codexRawModels : undefined} + grabbedElements={isActiveSessionPane ? grabbedElements : []} + onRemoveGrabbedElement={handleRemoveGrabbedElement} + lockedEngine={isActiveSessionPane ? lockedEngine : (session?.engine ?? null)} + lockedAgentId={isActiveSessionPane ? lockedAgentId : (session?.agentId ?? null)} + isIslandLayout={isIsland} + /> +
+
+ splitView.toggleToolTab(sessionId, toolId)} + onHeightChange={(height) => splitView.setDrawerHeight(sessionId, height)} + availableContextual={availableContextual} + > + {drawer.activeTab === "terminal" && ( + spaceTerminals.setActiveTab(spaceManager.activeSpaceId, tabId)} + onCreateTerminal={() => spaceTerminals.createTerminal(spaceManager.activeSpaceId, activeSpaceTerminalCwd ?? undefined)} + onEnsureTerminal={() => spaceTerminals.ensureTerminal(spaceManager.activeSpaceId, activeSpaceTerminalCwd ?? undefined)} + onCloseTerminal={(tabId) => spaceTerminals.closeTerminal(spaceManager.activeSpaceId, tabId)} + resolvedTheme={resolvedTheme} + /> + )} + {drawer.activeTab === "git" && ( + + )} + {drawer.activeTab === "browser" && ( + + )} + {drawer.activeTab === "files" && ( + + )} + {drawer.activeTab === "project-files" && ( + + )} + {drawer.activeTab === "mcp" && ( + + )} + + + ); + }, [activeProjectPath, activeSpaceProject?.path, activeSpaceTerminalCwd, activeSpaceTerminals.activeTabId, activeSpaceTerminals.tabs, agents, availableContextual, bottomFadeBackground, chatFadeStrength, devFillEnabled, getPreviewPaneMetrics, grabbedElements, handleAgentChange, handleAgentWorktreeChange, handleClaudeModelEffortChange, handleCloseSplitPane, handleComposerClear, handleElementGrab, handleFullRevert, handleModelChange, handlePermissionModeChange, handlePlanModeChange, handleRemoveGrabbedElement, handleRevert, handleSeedDevExampleSpaceData, handleStop, isIsland, lockedAgentId, lockedEngine, manager, paneScrollCallbacks, resolvedTheme, selectedAgent, settings, showThinking, shouldAnimateSplitLayout, sidebar.isOpen, sidebar.toggle, spaceManager.activeSpaceId, spaceTerminals, splitView, titlebarSurfaceColor, topFadeBackground]); + const { activeTools } = settings; const showCodexAuthDialog = !!manager.activeSessionId && @@ -449,6 +843,10 @@ Link: ${issue.url}`; onDeleteSpace={handleDeleteSpace} onOpenSettings={() => setShowSettings(true)} agents={agents} + onOpenInSplitView={(sessionId) => { + void requestAddSplitSession(sessionId); + }} + canOpenSessionInSplitView={(sessionId) => splitView.canShowSessionSplitAction(sessionId, manager.activeSessionId)} />
@@ -485,12 +883,149 @@ Link: ${issue.url}`; {/* Keep chat area mounted (hidden) when settings is open to avoid destroying/recreating the entire ChatView DOM tree on toggle */}
- {/* ── Top row: Chat | Right Panel | Tools Column | ToolPicker ── */} -
-
{ + topRowRef.current = element; + if (!isSplitActive) { + splitContainerRef.current = element; + } + }} + className="relative flex min-h-0 flex-1" + onDragEnter={!isSplitActive ? splitDragDrop.handleDragEnter : undefined} + onDragOver={!isSplitActive ? splitDragDrop.handleDragOver : undefined} + onDragLeave={!isSplitActive ? splitDragDrop.handleDragLeave : undefined} + onDrop={!isSplitActive ? splitDragDrop.handleDrop : undefined} + > + + {/* ══════ SPLIT VIEW RENDERING ══════ */} + {isSplitActive ? ( + +
+ {splitPaneSessionIds.map((sessionId, displayIndex) => { + const isActiveSessionPane = sessionId === manager.activeSessionId; + const ghostBeforeThisPane = previewDropPosition !== null && previewDropPosition <= displayIndex; + const panePreviewIndex = ghostBeforeThisPane ? displayIndex + 1 : displayIndex; + const dropZonePreviewIndex = previewDropPosition ?? splitPaneSessionIds.length; + const dropZoneMetrics = getPreviewPaneMetrics(dropZonePreviewIndex); + const dropZoneStyle = { + width: `calc(${dropZoneMetrics.widthPercent}% - ${dropZoneMetrics.handleSharePx}px + ${SPLIT_HANDLE_WIDTH}px)`, + minWidth: MIN_CHAT_WIDTH_SPLIT, + } as React.CSSProperties; + + return ( + + {displayIndex === 0 && splitDragDrop.dragState.isDragging && splitDragDrop.dragState.dropPosition === 0 && ( + session.id === splitDragDrop.dragState.draggedSessionId)} + style={dropZoneStyle} + /> + )} + + {displayIndex > 0 && ( + <> + {splitDragDrop.dragState.isDragging && splitDragDrop.dragState.dropPosition === displayIndex && ( + session.id === splitDragDrop.dragState.draggedSessionId)} + style={dropZoneStyle} + /> + )} + paneResize.handleSplitResizeStart(displayIndex - 1, event)} + onDoubleClick={paneResize.handleSplitDoubleClick} + /> + + )} + + {isActiveSessionPane ? ( + renderSplitPane( + sessionId, + manager.activeSession, + manager.primaryPane, + displayIndex, + true, + panePreviewIndex, + ) + ) : ( + + {({ session, paneState }) => + renderSplitPane(sessionId, session, paneState, displayIndex, false, panePreviewIndex) + } + + )} + + {displayIndex === splitPaneSessionIds.length - 1 + && splitDragDrop.dragState.isDragging + && splitDragDrop.dragState.dropPosition === splitPaneSessionIds.length + && ( + session.id === splitDragDrop.dragState.draggedSessionId)} + style={dropZoneStyle} + /> + )} + + ); + })} +
+
+ ) : ( + /* ══════ NORMAL (SINGLE PANE) RENDERING ══════ */ + <> + {showSinglePaneSplitPreview && singlePanePreviewPosition === 0 && ( + <> + session.id === splitDragDrop.dragState.draggedSessionId)} + style={singlePanePreviewPaneStyle} + /> + +
+ + + )} + + { + (chatIslandRef as React.MutableRefObject).current = el; + }} + className={`chat-island island relative flex min-w-0 flex-col overflow-hidden rounded-[var(--island-radius)] bg-background ${ + showSinglePaneSplitPreview ? "shrink-0" : "flex-1" + }`} + style={(showSinglePaneSplitPreview + ? { + ...singlePanePreviewPaneStyle, + "--chat-fade-strength": String(chatFadeStrength), + } + : { + minWidth: minChatWidth, + "--chat-fade-strength": String(chatFadeStrength), + }) as React.CSSProperties} > {jiraBoardProject ? ( )} -
+
- {hasRightPanel && ( + {showSinglePaneSplitPreview && singlePanePreviewPosition === 1 && ( <> + +
+ + session.id === splitDragDrop.dragState.draggedSessionId)} + style={singlePanePreviewPaneStyle} + /> + + )} + + {hasRightPanel && ( + {/* Resize handle — between chat and right panel */}
- + )} {/* Tools panels — always mounted when session active to preserve terminal/browser state. @@ -793,7 +1354,14 @@ Link: ${issue.url}`; normalizedToolRatiosRef.current = sideRatios; return ( - <> + {/* Resize handle — only visible when side tools column is showing */} {hasToolsColumn && (
- + ); })()} {/* Tool picker — always visible */} {manager.activeSessionId && ( -
+ -
+ + )} + )}
{/* end top row */} - {/* ── Bottom tools row — tools placed in the bottom row via right-click menu ── */} - {manager.activeSessionId && (() => { + {/* ── Bottom tools row — tools placed in the bottom row via right-click menu (hidden in split view) ── */} + {!isSplitActive && manager.activeSessionId && (() => { // Build tool components for bottom-placed tools only. // Note: moving a tool between side↔bottom is an explicit user action, // so the unmount/remount is acceptable. diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 4c35af9..f3b3312 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -49,6 +49,8 @@ interface AppSidebarProps { onDeleteSpace: (id: string) => void; onOpenSettings: () => void; agents?: InstalledAgent[]; + onOpenInSplitView?: (sessionId: string) => void; + canOpenSessionInSplitView?: (sessionId: string) => boolean; } export const AppSidebar = memo(function AppSidebar({ @@ -90,6 +92,8 @@ export const AppSidebar = memo(function AppSidebar({ onDeleteSpace, onOpenSettings, agents, + onOpenInSplitView, + canOpenSessionInSplitView, }: AppSidebarProps) { // Load default chat limit from main-process settings const [defaultChatLimit, setDefaultChatLimit] = useState(10); @@ -279,6 +283,8 @@ export const AppSidebar = memo(function AppSidebar({ onPinFolder={onPinFolder} onSetOrganizeByChatBranch={onSetOrganizeByChatBranch} agents={agents} + onOpenInSplitView={onOpenInSplitView} + canOpenSessionInSplitView={canOpenSessionInSplitView} /> ); })} diff --git a/src/components/ChatHeader.tsx b/src/components/ChatHeader.tsx index 311d0ef..9d7fc6f 100644 --- a/src/components/ChatHeader.tsx +++ b/src/components/ChatHeader.tsx @@ -1,5 +1,5 @@ import { memo } from "react"; -import { ChevronDown, Info, Loader2, PanelLeft } from "lucide-react"; +import { ChevronDown, Info, Loader2, PanelLeft, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -22,6 +22,7 @@ const ACP_PERMISSION_BEHAVIOR_LABELS: Record = { interface ChatHeaderProps { islandLayout: boolean; sidebarOpen: boolean; + showSidebarToggle?: boolean; isProcessing: boolean; model?: string; sessionId?: string; @@ -35,11 +36,14 @@ interface ChatHeaderProps { showDevFill?: boolean; onSeedDevExampleConversation?: () => void; onSeedDevExampleSpaceData?: () => void; + /** Close this split pane (renders an X button on the right). */ + onClosePane?: () => void; } export const ChatHeader = memo(function ChatHeader({ islandLayout, sidebarOpen, + showSidebarToggle = true, isProcessing, model, sessionId, @@ -53,6 +57,7 @@ export const ChatHeader = memo(function ChatHeader({ showDevFill, onSeedDevExampleConversation, onSeedDevExampleSpaceData, + onClosePane, }: ChatHeaderProps) { const modeLabel = permissionMode ? PERMISSION_MODE_LABELS[permissionMode] : null; const acpBehaviorLabel = acpPermissionBehavior @@ -60,6 +65,8 @@ export const ChatHeader = memo(function ChatHeader({ : null; const permissionDisplay = acpBehaviorLabel ?? modeLabel; const macIslandTitlebarOffsetClass = islandLayout && isMac ? "translate-y-0.5" : ""; + const shouldShowSidebarToggle = showSidebarToggle && !sidebarOpen; + const shouldReserveSidebarInset = shouldShowSidebarToggle && isMac; // Collect all session detail rows for the unified tooltip const detailRows: { label: string; value: string }[] = []; @@ -77,10 +84,10 @@ export const ChatHeader = memo(function ChatHeader({ className={`chat-header pointer-events-auto drag-region flex items-center gap-3 ${ islandLayout ? "h-8 px-3" : "h-[3.25rem] px-4" } ${ - !sidebarOpen && isMac ? (islandLayout ? "ps-[78px]" : "ps-[84px]") : "" + shouldReserveSidebarInset ? (islandLayout ? "ps-[78px]" : "ps-[84px]") : "" }`} > - {!sidebarOpen && ( + {shouldShowSidebarToggle && ( + + + Close pane + + + )} {showDevSeedButton && ( diff --git a/src/components/sidebar/BranchSection.tsx b/src/components/sidebar/BranchSection.tsx index d37d9d7..cecb0eb 100644 --- a/src/components/sidebar/BranchSection.tsx +++ b/src/components/sidebar/BranchSection.tsx @@ -20,6 +20,8 @@ export function BranchSection({ onRenameFolder, onDeleteFolder, agents, + onOpenInSplitView, + canOpenSessionInSplitView, }: { branchName: string; children: SidebarItem[]; @@ -35,6 +37,8 @@ export function BranchSection({ onRenameFolder: (projectId: string, folderId: string, name: string) => void; onDeleteFolder: (projectId: string, folderId: string) => void; agents?: InstalledAgent[]; + onOpenInSplitView?: (sessionId: string) => void; + canOpenSessionInSplitView?: (sessionId: string) => boolean; }) { const [expanded, setExpanded] = useState(true); @@ -76,6 +80,8 @@ export function BranchSection({ onRenameFolder={(name) => onRenameFolder(item.folder!.projectId, item.folder!.id, name)} onDeleteFolder={() => onDeleteFolder(item.folder!.projectId, item.folder!.id)} agents={agents} + onOpenInSplitView={onOpenInSplitView} + canOpenSessionInSplitView={canOpenSessionInSplitView} /> ); } @@ -93,6 +99,8 @@ export function BranchSection({ folders={allFolders} onMoveToFolder={(folderId) => onMoveSessionToFolder(item.session!.id, folderId)} agents={agents} + onOpenInSplitView={onOpenInSplitView ? () => onOpenInSplitView(item.session!.id) : undefined} + canOpenInSplitView={canOpenSessionInSplitView?.(item.session!.id) ?? true} /> ); } diff --git a/src/components/sidebar/FolderSection.tsx b/src/components/sidebar/FolderSection.tsx index 8137ce3..74987e9 100644 --- a/src/components/sidebar/FolderSection.tsx +++ b/src/components/sidebar/FolderSection.tsx @@ -30,6 +30,8 @@ export function FolderSection({ onDeleteFolder, agents, defaultCollapsed = false, + onOpenInSplitView, + canOpenSessionInSplitView, }: { folder: ChatFolder; sessions: ChatSession[]; @@ -46,6 +48,8 @@ export function FolderSection({ onDeleteFolder: () => void; agents?: InstalledAgent[]; defaultCollapsed?: boolean; + onOpenInSplitView?: (sessionId: string) => void; + canOpenSessionInSplitView?: (sessionId: string) => boolean; }) { const [expanded, setExpanded] = useState(!defaultCollapsed); const [isEditing, setIsEditing] = useState(false); @@ -211,6 +215,8 @@ export function FolderSection({ folders={allFolders} onMoveToFolder={(folderId) => onMoveSessionToFolder(session.id, folderId)} agents={agents} + onOpenInSplitView={onOpenInSplitView ? () => onOpenInSplitView(session.id) : undefined} + canOpenInSplitView={canOpenSessionInSplitView?.(session.id) ?? true} /> )) )} diff --git a/src/components/sidebar/PinnedSection.tsx b/src/components/sidebar/PinnedSection.tsx index eb59050..d9d08fe 100644 --- a/src/components/sidebar/PinnedSection.tsx +++ b/src/components/sidebar/PinnedSection.tsx @@ -18,6 +18,8 @@ export function PinnedSection({ onRenameFolder, onDeleteFolder, agents, + onOpenInSplitView, + canOpenSessionInSplitView, }: { sessions: ChatSession[]; pinnedFolders?: SidebarItem[]; @@ -33,6 +35,8 @@ export function PinnedSection({ onRenameFolder: (projectId: string, folderId: string, name: string) => void; onDeleteFolder: (projectId: string, folderId: string) => void; agents?: InstalledAgent[]; + onOpenInSplitView?: (sessionId: string) => void; + canOpenSessionInSplitView?: (sessionId: string) => boolean; }) { if (sessions.length === 0 && (!pinnedFolders || pinnedFolders.length === 0)) return null; @@ -55,6 +59,8 @@ export function PinnedSection({ onRenameFolder={(name) => onRenameFolder(item.folder!.projectId, item.folder!.id, name)} onDeleteFolder={() => onDeleteFolder(item.folder!.projectId, item.folder!.id)} agents={agents} + onOpenInSplitView={onOpenInSplitView} + canOpenSessionInSplitView={canOpenSessionInSplitView} /> ))} {sessions.map((session) => ( @@ -70,6 +76,8 @@ export function PinnedSection({ folders={folders} onMoveToFolder={(folderId) => onMoveSessionToFolder(session.id, folderId)} agents={agents} + onOpenInSplitView={onOpenInSplitView ? () => onOpenInSplitView(session.id) : undefined} + canOpenInSplitView={canOpenSessionInSplitView?.(session.id) ?? true} /> ))}
diff --git a/src/components/sidebar/ProjectSection.tsx b/src/components/sidebar/ProjectSection.tsx index b9d60ee..cf78e61 100644 --- a/src/components/sidebar/ProjectSection.tsx +++ b/src/components/sidebar/ProjectSection.tsx @@ -72,6 +72,8 @@ export function ProjectSection({ onPinFolder, onSetOrganizeByChatBranch, agents, + onOpenInSplitView, + canOpenSessionInSplitView, }: { islandLayout: boolean; project: Project; @@ -102,6 +104,8 @@ export function ProjectSection({ onPinFolder: (projectId: string, folderId: string, pinned: boolean) => void; onSetOrganizeByChatBranch: (on: boolean) => void; agents?: InstalledAgent[]; + onOpenInSplitView?: (sessionId: string) => void; + canOpenSessionInSplitView?: (sessionId: string) => boolean; }) { const [expanded, setExpanded] = useState(true); const [isEditing, setIsEditing] = useState(false); @@ -195,6 +199,8 @@ export function ProjectSection({ onRenameFolder={(name) => onRenameFolder(project.id, item.folder!.id, name)} onDeleteFolder={() => onDeleteFolder(project.id, item.folder!.id)} agents={agents} + onOpenInSplitView={onOpenInSplitView} + canOpenSessionInSplitView={canOpenSessionInSplitView} /> ); } @@ -217,6 +223,8 @@ export function ProjectSection({ onRenameFolder={onRenameFolder} onDeleteFolder={onDeleteFolder} agents={agents} + onOpenInSplitView={onOpenInSplitView} + canOpenSessionInSplitView={canOpenSessionInSplitView} /> ); } @@ -235,6 +243,8 @@ export function ProjectSection({ folders={folders} onMoveToFolder={(folderId) => onMoveSessionToFolder(item.session!.id, folderId)} agents={agents} + onOpenInSplitView={onOpenInSplitView ? () => onOpenInSplitView(item.session!.id) : undefined} + canOpenInSplitView={canOpenSessionInSplitView?.(item.session!.id) ?? true} /> ); } @@ -466,6 +476,8 @@ export function ProjectSection({ onRenameFolder={onRenameFolder} onDeleteFolder={onDeleteFolder} agents={agents} + onOpenInSplitView={onOpenInSplitView} + canOpenSessionInSplitView={canOpenSessionInSplitView} /> )} diff --git a/src/components/sidebar/SessionItem.tsx b/src/components/sidebar/SessionItem.tsx index c24afdd..cbcf90c 100644 --- a/src/components/sidebar/SessionItem.tsx +++ b/src/components/sidebar/SessionItem.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from "react"; import { + Columns2, Pencil, Trash2, MoreHorizontal, @@ -39,6 +40,8 @@ export function SessionItem({ folders, onMoveToFolder, agents, + onOpenInSplitView, + canOpenInSplitView = true, }: { islandLayout: boolean; session: ChatSession; @@ -53,6 +56,9 @@ export function SessionItem({ /** Move session to a folder (null = remove from folder). */ onMoveToFolder?: (folderId: string | null) => void; agents?: InstalledAgent[]; + /** Open this session in the split view secondary pane. */ + onOpenInSplitView?: () => void; + canOpenInSplitView?: boolean; }) { const [isEditing, setIsEditing] = useState(false); const [editTitle, setEditTitle] = useState(session.title); @@ -222,6 +228,13 @@ export function SessionItem({ {(onPinToggle || hasFolderMenu) && } + {onOpenInSplitView && canOpenInSplitView && ( + + + Open in Split View + + )} + { setEditTitle(session.title); diff --git a/src/components/split/PaneToolDrawer.tsx b/src/components/split/PaneToolDrawer.tsx new file mode 100644 index 0000000..70007f1 --- /dev/null +++ b/src/components/split/PaneToolDrawer.tsx @@ -0,0 +1,109 @@ +/** + * PaneToolDrawer — bottom tool area for a single split view pane. + * + * Contains a PaneToolTabBar for tool selection and renders the active tool + * content below it. Height is resizable via a drag handle at the top. + */ + +import { memo, useCallback, useRef } from "react"; +import type { ToolId } from "@/components/ToolPicker"; +import { PaneToolTabBar } from "./PaneToolTabBar"; +import { MIN_PANE_DRAWER_HEIGHT, MAX_PANE_DRAWER_HEIGHT } from "@/lib/layout-constants"; + +interface PaneToolDrawerProps { + /** Whether the drawer is open. */ + open: boolean; + /** Currently active tool tab. */ + activeTab: ToolId | null; + /** Drawer height in pixels. */ + height: number; + /** Called when a tool tab is toggled. */ + onToggleTab: (toolId: ToolId) => void; + /** Called during height resize drag. */ + onHeightChange: (height: number) => void; + /** Which contextual tools are available (have data). */ + availableContextual?: Set; + /** The tool content to render (provided by the parent). */ + children: React.ReactNode; +} + +export const PaneToolDrawer = memo(function PaneToolDrawer({ + open, + activeTab, + height, + onToggleTab, + onHeightChange, + availableContextual, + children, +}: PaneToolDrawerProps) { + const draggingRef = useRef(false); + const startYRef = useRef(0); + const startHeightRef = useRef(0); + + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + draggingRef.current = true; + startYRef.current = e.clientY; + startHeightRef.current = height; + + const handleMove = (moveEvent: MouseEvent) => { + if (!draggingRef.current) return; + const delta = startYRef.current - moveEvent.clientY; + const newHeight = Math.max( + MIN_PANE_DRAWER_HEIGHT, + Math.min(MAX_PANE_DRAWER_HEIGHT, startHeightRef.current + delta), + ); + onHeightChange(newHeight); + }; + + const handleUp = () => { + draggingRef.current = false; + document.removeEventListener("mousemove", handleMove); + document.removeEventListener("mouseup", handleUp); + }; + + document.addEventListener("mousemove", handleMove); + document.addEventListener("mouseup", handleUp); + }, + [height, onHeightChange], + ); + + if (!open || !activeTab) { + // Still render the tab bar even when closed (it acts as the toggle) + return ( + + ); + } + + return ( +
+ {/* Resize handle at top of drawer — h-2 hit target with thin visible pill */} +
+
+
+ + {/* Tab bar */} + + + {/* Tool content — explicit height, no flex-1 (parent is shrink-0, so there's nothing to grow into) */} +
+ {children} +
+
+ ); +}); diff --git a/src/components/split/PaneToolTabBar.tsx b/src/components/split/PaneToolTabBar.tsx new file mode 100644 index 0000000..ca60717 --- /dev/null +++ b/src/components/split/PaneToolTabBar.tsx @@ -0,0 +1,85 @@ +/** + * PaneToolTabBar — horizontal icon strip for per-pane tool selection in split view. + * + * Renders a compact row of tool icon buttons. Clicking a tab activates it + * (showing the tool in the pane's drawer). Clicking the active tab deactivates it + * (closing the drawer). + */ + +import { memo } from "react"; +import { + Terminal, + Globe, + GitBranch, + FileText, + FolderTree, + ListTodo, + Bot, + Plug, +} from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import type { ToolId } from "@/components/ToolPicker"; + +const TOOL_CONFIG: { + id: ToolId; + label: string; + Icon: React.FC<{ className?: string }>; + /** Only show when this condition is met (always show if undefined). */ + contextual?: boolean; +}[] = [ + { id: "terminal", label: "Terminal", Icon: Terminal }, + { id: "git", label: "Git", Icon: GitBranch }, + { id: "files", label: "Files", Icon: FileText }, + { id: "project-files", label: "Project Files", Icon: FolderTree }, + { id: "mcp", label: "MCP Servers", Icon: Plug }, + { id: "browser", label: "Browser", Icon: Globe }, + { id: "tasks", label: "Tasks", Icon: ListTodo, contextual: true }, + { id: "agents", label: "Agents", Icon: Bot, contextual: true }, +]; + +interface PaneToolTabBarProps { + /** Currently active tool tab (null if drawer is closed). */ + activeTab: ToolId | null; + /** Called when a tab is clicked. */ + onToggleTab: (toolId: ToolId) => void; + /** Which contextual tools have data to show (tasks with items, agents running). */ + availableContextual?: Set; +} + +export const PaneToolTabBar = memo(function PaneToolTabBar({ + activeTab, + onToggleTab, + availableContextual, +}: PaneToolTabBarProps) { + return ( +
+ {TOOL_CONFIG.map(({ id, label, Icon, contextual }) => { + // Skip contextual tools that have no data + if (contextual && !availableContextual?.has(id)) return null; + + const isActive = activeTab === id; + + return ( + + + + + + {label} + + + ); + })} +
+ ); +}); diff --git a/src/components/split/SplitDropZone.tsx b/src/components/split/SplitDropZone.tsx new file mode 100644 index 0000000..9db22ff --- /dev/null +++ b/src/components/split/SplitDropZone.tsx @@ -0,0 +1,46 @@ +/** + * SplitDropZone — animated dashed drop target for drag-to-split. + * + * Renders a vertical dashed-border region at a specific position in the + * split pane layout. Animates in when a session is being dragged from + * the sidebar, showing where the new pane would be inserted. + */ + +import { memo } from "react"; +import { motion } from "motion/react"; +import { Plus } from "lucide-react"; +import type { ChatSession } from "@/types"; + +interface SplitDropZoneProps { + /** Whether a drop zone is being shown. */ + active: boolean; + /** Session being dragged (for preview label). */ + session?: ChatSession | null; + style?: React.CSSProperties; +} + +export const SplitDropZone = memo(function SplitDropZone({ + active, + session, + style, +}: SplitDropZoneProps) { + if (!active) return null; + + return ( + +
+
+ +
+ + {session?.title ?? "Drop to open"} + +
+
+ ); +}); diff --git a/src/components/split/SplitHandle.tsx b/src/components/split/SplitHandle.tsx new file mode 100644 index 0000000..28dc06f --- /dev/null +++ b/src/components/split/SplitHandle.tsx @@ -0,0 +1,44 @@ +/** + * SplitHandle — vertical draggable divider between two split view panes. + * + * Uses the same visual pattern as the existing resize handles in the app + * (subtle pill indicator, hover/active states). Supports double-click to + * reset to 50/50 split. + */ + +import { memo } from "react"; + +interface SplitHandleProps { + /** Whether island layout mode is active (affects gap sizing). */ + isIsland: boolean; + /** Whether any resize is in progress (for visual feedback). */ + isResizing: boolean; + /** Called when drag starts — returns a handler for mouse move. */ + onResizeStart: (e: React.MouseEvent) => void; + /** Called on double-click to reset split to 50/50. */ + onDoubleClick: () => void; +} + +export const SplitHandle = memo(function SplitHandle({ + isIsland, + isResizing, + onResizeStart, + onDoubleClick, +}: SplitHandleProps) { + return ( +
+
+
+ ); +}); diff --git a/src/components/split/SplitPaneHost.tsx b/src/components/split/SplitPaneHost.tsx new file mode 100644 index 0000000..d162218 --- /dev/null +++ b/src/components/split/SplitPaneHost.tsx @@ -0,0 +1,50 @@ +import type { ChatSession, EngineId } from "@/types"; +import type { SessionPaneState } from "@/hooks/session/useSessionPane"; +import type { SessionPaneBootstrap } from "@/hooks/session/types"; +import { useExtraPaneLoader } from "@/hooks/session/useExtraPaneLoader"; +import { useSessionPane } from "@/hooks/session/useSessionPane"; + +interface SplitPaneHostRenderData { + session: ChatSession | null; + paneState: SessionPaneState; +} + +interface SplitPaneHostProps { + sessionId: string; + acpPermissionBehavior: "ask" | "auto_accept" | "allow_all"; + loadBootstrap: (sessionId: string) => Promise; + children: (data: SplitPaneHostRenderData) => React.ReactNode; +} + +export function SplitPaneHost({ + sessionId, + acpPermissionBehavior, + loadBootstrap, + children, +}: SplitPaneHostProps) { + const loader = useExtraPaneLoader({ + sessionId, + loadBootstrap, + }); + + const readySession = loader.readyId ? loader.session : null; + const activeEngine: EngineId = readySession?.engine ?? "claude"; + const paneState = useSessionPane({ + activeSessionId: loader.readyId, + activeEngine, + claudeSessionId: activeEngine === "claude" ? loader.readyId : null, + acpSessionId: activeEngine === "acp" ? loader.readyId : null, + codexSessionId: activeEngine === "codex" ? loader.readyId : null, + codexSessionModel: activeEngine === "codex" ? readySession?.model : undefined, + codexPlanModeEnabled: activeEngine === "codex" ? !!readySession?.planMode : false, + initialMessages: loader.initialMessages, + initialMeta: loader.initialMeta, + initialPermission: loader.initialPermission, + initialConfigOptions: loader.initialConfigOptions, + initialSlashCommands: loader.initialSlashCommands, + initialRawAcpPermission: loader.initialRawAcpPermission, + acpPermissionBehavior, + }); + + return <>{children({ session: readySession, paneState })}; +} diff --git a/src/hooks/session/types.ts b/src/hooks/session/types.ts index c19f5c5..65250b5 100644 --- a/src/hooks/session/types.ts +++ b/src/hooks/session/types.ts @@ -45,6 +45,16 @@ export interface QueuedMessage { messageId: string; } +export interface SessionPaneBootstrap { + session: ChatSession; + initialMessages: UIMessage[]; + initialMeta: InitialMeta | null; + initialPermission: PermissionRequest | null; + initialConfigOptions: ACPConfigOption[]; + initialSlashCommands: SlashCommand[]; + initialRawAcpPermission: ACPPermissionEvent | null; +} + /** Shared refs that multiple sub-hooks need to read/write */ export interface SharedSessionRefs { activeSessionIdRef: React.MutableRefObject; @@ -79,6 +89,8 @@ export interface SharedSessionRefs { acpPermissionBehaviorRef: React.MutableRefObject; /** Current git branch for the active project — set by the orchestrator. */ currentBranchRef: React.MutableRefObject; + /** Split view: session IDs currently visible in extra panes. */ + visibleSplitSessionIdsRef: React.MutableRefObject; } /** State setters from the orchestrator that sub-hooks need */ diff --git a/src/hooks/session/useExtraPaneLoader.ts b/src/hooks/session/useExtraPaneLoader.ts new file mode 100644 index 0000000..f0fe361 --- /dev/null +++ b/src/hooks/session/useExtraPaneLoader.ts @@ -0,0 +1,108 @@ +/** + * useExtraPaneLoader — loads session bootstrap data for one split view pane. + * + * The hook only marks a pane as ready after the bootstrap data has been loaded, + * which prevents engine hooks from binding to a session ID before their initial + * messages and metadata are available. + */ + +import { useEffect, useRef, useState, startTransition } from "react"; +import type { ChatSession, PermissionRequest, SlashCommand, UIMessage } from "../../types"; +import type { ACPConfigOption, ACPPermissionEvent } from "../../types/acp"; +import type { InitialMeta, SessionPaneBootstrap } from "./types"; + +interface ExtraPaneLoaderResult { + readyId: string | null; + session: ChatSession | null; + initialMessages: UIMessage[]; + initialMeta: InitialMeta | null; + initialPermission: PermissionRequest | null; + initialConfigOptions: ACPConfigOption[]; + initialSlashCommands: SlashCommand[]; + initialRawAcpPermission: ACPPermissionEvent | null; +} + +interface UseExtraPaneLoaderOptions { + sessionId: string | null; + loadBootstrap: (sessionId: string) => Promise; +} + +export function useExtraPaneLoader({ + sessionId, + loadBootstrap, +}: UseExtraPaneLoaderOptions): ExtraPaneLoaderResult { + const [readyId, setReadyId] = useState(null); + const [session, setSession] = useState(null); + const [initialMessages, setInitialMessages] = useState([]); + const [initialMeta, setInitialMeta] = useState(null); + const [initialPermission, setInitialPermission] = useState(null); + const [initialConfigOptions, setInitialConfigOptions] = useState([]); + const [initialSlashCommands, setInitialSlashCommands] = useState([]); + const [initialRawAcpPermission, setInitialRawAcpPermission] = useState(null); + + const latestSessionIdRef = useRef(null); + + useEffect(() => { + if (sessionId === latestSessionIdRef.current) { + return; + } + latestSessionIdRef.current = sessionId; + + if (!sessionId) { + startTransition(() => { + setReadyId(null); + setSession(null); + setInitialMessages([]); + setInitialMeta(null); + setInitialPermission(null); + setInitialConfigOptions([]); + setInitialSlashCommands([]); + setInitialRawAcpPermission(null); + }); + return; + } + + void loadBootstrap(sessionId).then((bootstrap) => { + if (!bootstrap || latestSessionIdRef.current !== sessionId) { + return; + } + + startTransition(() => { + setReadyId(sessionId); + setSession(bootstrap.session); + setInitialMessages(bootstrap.initialMessages); + setInitialMeta(bootstrap.initialMeta); + setInitialPermission(bootstrap.initialPermission); + setInitialConfigOptions(bootstrap.initialConfigOptions); + setInitialSlashCommands(bootstrap.initialSlashCommands); + setInitialRawAcpPermission(bootstrap.initialRawAcpPermission); + }); + }).catch(() => { + if (latestSessionIdRef.current !== sessionId) { + return; + } + + startTransition(() => { + setReadyId(null); + setSession(null); + setInitialMessages([]); + setInitialMeta(null); + setInitialPermission(null); + setInitialConfigOptions([]); + setInitialSlashCommands([]); + setInitialRawAcpPermission(null); + }); + }); + }, [loadBootstrap, sessionId]); + + return { + readyId, + session, + initialMessages, + initialMeta, + initialPermission, + initialConfigOptions, + initialSlashCommands, + initialRawAcpPermission, + }; +} diff --git a/src/hooks/session/useSessionPane.ts b/src/hooks/session/useSessionPane.ts new file mode 100644 index 0000000..c2beec3 --- /dev/null +++ b/src/hooks/session/useSessionPane.ts @@ -0,0 +1,128 @@ +/** + * useSessionPane — encapsulates one complete engine triple (useClaude + useACP + useCodex) + * for a single "pane" of the UI. + * + * In single mode, only the primary pane is active. In split view, both primary + * and secondary panes are active simultaneously. The hook is always called + * unconditionally (satisfying Rules of Hooks); when not in use, all engine hooks + * receive null sessionIds and hold dormant empty state. + */ + +import { useClaude } from "../useClaude"; +import { useACP } from "../useACP"; +import { useCodex } from "../useCodex"; +import type { UIMessage, PermissionRequest, EngineId, AcpPermissionBehavior, ContextUsage, SessionInfo } from "../../types"; +import type { ACPConfigOption, ACPPermissionEvent } from "../../types/acp"; +import type { SlashCommand } from "../../types"; +import type { InitialMeta } from "./types"; + +export interface UseSessionPaneOptions { + /** The logical session ID for this pane (or null when the pane is unused). */ + activeSessionId: string | null; + /** Which engine this pane's session uses. */ + activeEngine: EngineId; + + // ── Per-engine session IDs (pre-computed by the caller) ── + claudeSessionId: string | null; + acpSessionId: string | null; + codexSessionId: string | null; + codexSessionModel: string | undefined; + codexPlanModeEnabled: boolean; + + // ── Initial state for session restoration ── + initialMessages: UIMessage[]; + initialMeta: InitialMeta | null; + initialPermission: PermissionRequest | null; + + // ── ACP-specific initial state ── + initialConfigOptions?: ACPConfigOption[]; + initialSlashCommands?: SlashCommand[]; + initialRawAcpPermission?: ACPPermissionEvent | null; + acpPermissionBehavior: AcpPermissionBehavior; +} + +export interface SessionPaneState { + /** Individual engine hook returns — sub-hooks need direct access. */ + claude: ReturnType; + acp: ReturnType; + codex: ReturnType; + + /** The currently-selected engine for this pane. */ + engine: ReturnType | ReturnType | ReturnType; + + /** Convenience accessors — derived from the active engine. */ + messages: UIMessage[]; + totalCost: number; + contextUsage: ContextUsage | null; + isProcessing: boolean; + isConnected: boolean; + isCompacting: boolean; + sessionInfo: SessionInfo | null; + pendingPermission: PermissionRequest | null; +} + +export function useSessionPane({ + activeEngine, + claudeSessionId, + acpSessionId, + codexSessionId, + codexSessionModel, + codexPlanModeEnabled, + initialMessages, + initialMeta, + initialPermission, + initialConfigOptions, + initialSlashCommands, + initialRawAcpPermission, + acpPermissionBehavior, +}: UseSessionPaneOptions): SessionPaneState { + const isACP = activeEngine === "acp"; + const isCodex = activeEngine === "codex"; + const isClaude = activeEngine === "claude"; + + // ── Engine hooks (always called — Rules of Hooks) ── + const claude = useClaude({ + sessionId: claudeSessionId, + initialMessages: isClaude ? initialMessages : [], + initialMeta: isClaude ? initialMeta : null, + initialPermission: isClaude ? initialPermission : null, + }); + + const acp = useACP({ + sessionId: acpSessionId, + initialMessages: isACP ? initialMessages : [], + initialConfigOptions: isACP ? initialConfigOptions : undefined, + initialSlashCommands: isACP ? initialSlashCommands : undefined, + initialMeta: isACP ? initialMeta : null, + initialPermission: isACP ? initialPermission : null, + initialRawAcpPermission: isACP ? initialRawAcpPermission : null, + acpPermissionBehavior, + }); + + const codex = useCodex({ + sessionId: codexSessionId, + sessionModel: codexSessionModel, + planModeEnabled: codexPlanModeEnabled, + initialMessages: isCodex ? initialMessages : [], + initialMeta: isCodex ? initialMeta : null, + initialPermission: isCodex ? initialPermission : null, + }); + + // Pick the active engine's state + const engine = isCodex ? codex : isACP ? acp : claude; + + return { + claude, + acp, + codex, + engine, + messages: engine.messages, + totalCost: engine.totalCost, + contextUsage: engine.contextUsage, + isProcessing: engine.isProcessing, + isConnected: engine.isConnected, + isCompacting: "isCompacting" in engine ? !!engine.isCompacting : false, + sessionInfo: engine.sessionInfo, + pendingPermission: engine.pendingPermission, + }; +} diff --git a/src/hooks/session/useSessionPersistence.ts b/src/hooks/session/useSessionPersistence.ts index 6733cd7..f6a4b7f 100644 --- a/src/hooks/session/useSessionPersistence.ts +++ b/src/hooks/session/useSessionPersistence.ts @@ -50,6 +50,7 @@ export function useSessionPersistence({ switchSessionRef, acpPermissionBehaviorRef, saveTimerRef, + visibleSplitSessionIdsRef, } = refs; // Persist session with Codex thread ID fallback @@ -178,6 +179,8 @@ export function useSessionPersistence({ const sid = event._sessionId; if (!sid) return; if (sid === activeSessionIdRef.current) return; + // Split view: secondary pane's engine hooks handle their own events + if (visibleSplitSessionIdsRef.current.includes(sid)) return; // Pre-started session: route to background store AND extract MCP statuses if (sid === preStartedSessionIdRef.current) { @@ -200,6 +203,7 @@ export function useSessionPersistence({ const sid = event._sessionId; if (!sid) return; if (sid === activeSessionIdRef.current) return; + if (visibleSplitSessionIdsRef.current.includes(sid)) return; if (sid === draftAcpSessionIdRef.current) return; backgroundStoreRef.current.handleACPEvent(event); }); @@ -207,7 +211,7 @@ export function useSessionPersistence({ // Route permission requests for non-active Claude sessions to the background store const unsubBgPerm = window.claude.onPermissionRequest((data) => { const sid = data._sessionId; - if (!sid || sid === activeSessionIdRef.current || sid === preStartedSessionIdRef.current) return; + if (!sid || sid === activeSessionIdRef.current || visibleSplitSessionIdsRef.current.includes(sid) || sid === preStartedSessionIdRef.current) return; backgroundStoreRef.current.setPermission(sid, { requestId: data.requestId, toolName: data.toolName, @@ -222,7 +226,7 @@ export function useSessionPersistence({ // (auto-respond if the client-side permission behavior allows it) const unsubBgAcpPerm = window.claude.acp.onPermissionRequest((data: ACPPermissionEvent) => { const sid = data._sessionId; - if (!sid || sid === activeSessionIdRef.current) return; + if (!sid || sid === activeSessionIdRef.current || visibleSplitSessionIdsRef.current.includes(sid)) return; if (sid === draftAcpSessionIdRef.current) return; // Auto-respond for background ACP sessions when behavior is configured @@ -248,21 +252,21 @@ export function useSessionPersistence({ // (clears isProcessing so the session doesn't appear stuck when switching back) const unsubBgAcpTurn = window.claude.acp.onTurnComplete((data: ACPTurnCompleteEvent) => { const sid = data._sessionId; - if (!sid || sid === activeSessionIdRef.current) return; + if (!sid || sid === activeSessionIdRef.current || visibleSplitSessionIdsRef.current.includes(sid)) return; backgroundStoreRef.current.handleACPTurnComplete(sid); }); // Route Codex events for non-active sessions to the background store const unsubCodex = window.claude.codex.onEvent((event) => { const sid = event._sessionId; - if (!sid || sid === activeSessionIdRef.current) return; + if (!sid || sid === activeSessionIdRef.current || visibleSplitSessionIdsRef.current.includes(sid)) return; backgroundStoreRef.current.handleCodexEvent(event); }); // Route Codex approval requests for non-active sessions — auto-decline for now const unsubCodexApproval = window.claude.codex.onApprovalRequest((data) => { const sid = data._sessionId; - if (!sid || sid === activeSessionIdRef.current) return; + if (!sid || sid === activeSessionIdRef.current || visibleSplitSessionIdsRef.current.includes(sid)) return; if (data.method === "item/tool/requestUserInput") { backgroundStoreRef.current.setPermission(sid, { requestId: String(data.rpcId), diff --git a/src/hooks/useAppOrchestrator.ts b/src/hooks/useAppOrchestrator.ts index f73d6c5..a415bdf 100644 --- a/src/hooks/useAppOrchestrator.ts +++ b/src/hooks/useAppOrchestrator.ts @@ -10,16 +10,11 @@ import { useBackgroundAgents } from "@/hooks/useBackgroundAgents"; import { useAgentRegistry } from "@/hooks/useAgentRegistry"; import { useAcpAgentAutoUpdate } from "@/hooks/useAcpAgentAutoUpdate"; import { useNotifications } from "@/hooks/useNotifications"; +import { useSplitView } from "@/hooks/useSplitView"; import { APP_SIDEBAR_WIDTH, - getMinChatWidth, - getResizeHandleWidth, - getToolPickerWidth, - ISLAND_LAYOUT_MARGIN, - WINDOWS_FRAME_BUFFER_WIDTH, - MIN_RIGHT_PANEL_WIDTH, - MIN_TOOLS_PANEL_WIDTH, } from "@/lib/layout-constants"; +import { getAppMinimumWidth } from "@/lib/split-layout"; import { resolveModelValue } from "@/lib/model-utils"; import { getStoredProjectGitCwd, resolveProjectForSpace } from "@/lib/space-projects"; import { getTodoItems } from "@/lib/todo-utils"; @@ -30,13 +25,19 @@ import type { NotificationSettings } from "@/types/ui"; export function useAppOrchestrator() { const sidebar = useSidebar(); + const splitView = useSplitView(); const projectManager = useProjectManager(); const spaceManager = useSpaceManager(); const LAST_SESSION_KEY = "harnss-last-session-per-space"; // Read ACP permission behavior early — it's a global setting (same localStorage key as useSettings) // so we can read it before useSettings which depends on manager.activeSession for per-project scoping const acpPermissionBehavior = (localStorage.getItem("harnss-acp-permission-behavior") ?? "ask") as AcpPermissionBehavior; - const manager = useSessionManager(projectManager.projects, acpPermissionBehavior, spaceManager.setActiveSpaceId); + const manager = useSessionManager( + projectManager.projects, + acpPermissionBehavior, + spaceManager.setActiveSpaceId, + splitView.visibleSessionIds, + ); // Derive activeProjectId early so useSettings can scope per-project const activeProjectId = manager.activeSession?.projectId ?? manager.draftProjectId; @@ -718,26 +719,21 @@ export function useAppOrchestrator() { const hasBottomTools = [...settings.activeTools].some((id) => COLUMN_TOOL_IDS.has(id) && settings.bottomTools.has(id)) && !!manager.activeSessionId; // ── Dynamic Electron minimum window width ── - const isIsland = settings.islandLayout; - const minChatWidth = getMinChatWidth(isIsland); - const margins = isIsland ? ISLAND_LAYOUT_MARGIN : 0; - const handleW = getResizeHandleWidth(isIsland); - const pickerW = getToolPickerWidth(isIsland); - // Windows native frame borders consume extra pixels from the content area - const winFrameBuffer = isWindows ? WINDOWS_FRAME_BUFFER_WIDTH : 0; + const isSplitViewEnabled = splitView.enabled && splitView.paneCount > 1; useEffect(() => { - const sidebarW = sidebar.isOpen ? APP_SIDEBAR_WIDTH : 0; - let minW = sidebarW + margins + minChatWidth + winFrameBuffer; - - if (manager.activeSessionId) { - minW += pickerW; - if (hasRightPanel) minW += MIN_RIGHT_PANEL_WIDTH + handleW; - if (hasToolsColumn) minW += MIN_TOOLS_PANEL_WIDTH + handleW; - } - + const minW = getAppMinimumWidth({ + sidebarOpen: sidebar.isOpen, + isIslandLayout: settings.islandLayout, + hasActiveSession: !!manager.activeSessionId, + hasRightPanel, + hasToolsColumn, + isSplitViewEnabled, + splitPaneCount: splitView.paneCount, + isWindows, + }); window.claude.setMinWidth(Math.max(minW, 600)); - }, [sidebar.isOpen, hasRightPanel, hasToolsColumn, manager.activeSessionId, minChatWidth, margins, pickerW, handleW]); + }, [sidebar.isOpen, settings.islandLayout, manager.activeSessionId, hasRightPanel, hasToolsColumn, isSplitViewEnabled, splitView.paneCount]); // When tools column or bottom row becomes visible, fire resize so xterm terminals re-fit useEffect(() => { @@ -868,6 +864,7 @@ export function useAppOrchestrator() { return { // Core managers sidebar, + splitView, projectManager, spaceManager, manager, diff --git a/src/hooks/usePaneResize.ts b/src/hooks/usePaneResize.ts new file mode 100644 index 0000000..f3bf14c --- /dev/null +++ b/src/hooks/usePaneResize.ts @@ -0,0 +1,106 @@ +/** + * usePaneResize — handles drag logic for N-1 split handles in multi-pane split view. + * + * Each handle adjusts the width fractions of the two adjacent panes. + * Uses the same ref + mousemove/mouseup pattern as other resize hooks. + */ + +import { useCallback, useRef, useState } from "react"; +import { + MIN_PANE_WIDTH_FRACTION, + equalWidthFractions, +} from "@/lib/layout-constants"; + +interface UsePaneResizeOptions { + /** Width fractions for all panes (length = pane count). */ + widthFractions: number[]; + /** Setter for width fractions during drag. */ + setWidthFractions: (fractions: number[]) => void; + /** Ref to the container element encompassing all panes. */ + containerRef: React.RefObject; +} + +export function usePaneResize({ + widthFractions, + setWidthFractions, + containerRef, +}: UsePaneResizeOptions) { + const [isResizing, setIsResizing] = useState(false); + const startXRef = useRef(0); + const startFractionsRef = useRef([]); + const containerWidthRef = useRef(0); + const handleIndexRef = useRef(0); + + /** + * Start resizing at a specific handle index. + * Handle 0 sits between panes 0 and 1, handle 1 between panes 1 and 2, etc. + */ + const handleSplitResizeStart = useCallback( + (handleIndex: number, e: React.MouseEvent) => { + e.preventDefault(); + const container = containerRef.current; + if (!container) return; + + setIsResizing(true); + startXRef.current = e.clientX; + startFractionsRef.current = [...widthFractions]; + containerWidthRef.current = container.getBoundingClientRect().width; + handleIndexRef.current = handleIndex; + + const handleMove = (moveEvent: MouseEvent) => { + const deltaX = moveEvent.clientX - startXRef.current; + const deltaFraction = deltaX / containerWidthRef.current; + const idx = handleIndexRef.current; + const fractions = [...startFractionsRef.current]; + + // Adjust the two adjacent panes + let leftNew = fractions[idx] + deltaFraction; + let rightNew = fractions[idx + 1] - deltaFraction; + + // Clamp both to minimum + if (leftNew < MIN_PANE_WIDTH_FRACTION) { + const overflow = MIN_PANE_WIDTH_FRACTION - leftNew; + leftNew = MIN_PANE_WIDTH_FRACTION; + rightNew -= overflow; + } + if (rightNew < MIN_PANE_WIDTH_FRACTION) { + const overflow = MIN_PANE_WIDTH_FRACTION - rightNew; + rightNew = MIN_PANE_WIDTH_FRACTION; + leftNew -= overflow; + } + + // Final clamp + leftNew = Math.max(MIN_PANE_WIDTH_FRACTION, leftNew); + rightNew = Math.max(MIN_PANE_WIDTH_FRACTION, rightNew); + + fractions[idx] = leftNew; + fractions[idx + 1] = rightNew; + + // Normalize all fractions to sum=1 + const sum = fractions.reduce((a, b) => a + b, 0); + setWidthFractions(fractions.map(f => f / sum)); + }; + + const handleUp = () => { + setIsResizing(false); + document.removeEventListener("mousemove", handleMove); + document.removeEventListener("mouseup", handleUp); + }; + + document.addEventListener("mousemove", handleMove); + document.addEventListener("mouseup", handleUp); + }, + [widthFractions, setWidthFractions, containerRef], + ); + + /** Reset all panes to equal widths. */ + const handleSplitDoubleClick = useCallback(() => { + setWidthFractions(equalWidthFractions(widthFractions.length)); + }, [setWidthFractions, widthFractions.length]); + + return { + isResizing, + handleSplitResizeStart, + handleSplitDoubleClick, + }; +} diff --git a/src/hooks/useSessionManager.ts b/src/hooks/useSessionManager.ts index 0761218..a53cbc3 100644 --- a/src/hooks/useSessionManager.ts +++ b/src/hooks/useSessionManager.ts @@ -3,9 +3,6 @@ import type { ChatSession, UIMessage, PermissionRequest, McpServerStatus, McpSer import type { ACPConfigOption, ACPPermissionEvent } from "../types/acp"; import { toMcpStatusState } from "../lib/mcp-utils"; import { toChatSession } from "../lib/session-records"; -import { useClaude } from "./useClaude"; -import { useACP } from "./useACP"; -import { useCodex } from "./useCodex"; import { BackgroundSessionStore } from "../lib/background-session-store"; import { DRAFT_ID, @@ -13,17 +10,25 @@ import { type CodexModelSummary, type InitialMeta, type QueuedMessage, + type SessionPaneBootstrap, type SharedSessionRefs, type SharedSessionSetters, type EngineHooks, } from "./session/types"; +import { useSessionPane, type SessionPaneState } from "./session/useSessionPane"; import { useMessageQueue } from "./session/useMessageQueue"; import { useSessionPersistence } from "./session/useSessionPersistence"; import { useDraftMaterialization } from "./session/useDraftMaterialization"; import { useSessionRevival } from "./session/useSessionRevival"; import { useSessionLifecycle } from "./session/useSessionLifecycle"; -export function useSessionManager(projects: Project[], acpPermissionBehavior: AcpPermissionBehavior = "ask", onSpaceChange?: (spaceId: string) => void) { +export function useSessionManager( + projects: Project[], + acpPermissionBehavior: AcpPermissionBehavior = "ask", + onSpaceChange?: (spaceId: string) => void, + /** Session IDs currently visible in extra split panes. */ + visibleSplitSessionIds: readonly string[] = [], +) { // ── Core state ── const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); @@ -45,6 +50,11 @@ export function useSessionManager(projects: Project[], acpPermissionBehavior: Ac const [codexModelsLoadingMessage, setCodexModelsLoadingMessage] = useState(null); const [queuedCount, setQueuedCount] = useState(0); + // ── Refs needed by extra pane loaders (declared early) ── + const sessionsRef = useRef(sessions); + sessionsRef.current = sessions; + const backgroundStoreRef = useRef(new BackgroundSessionStore()); + // ── Determine active engine ── const activeEngine: EngineId = activeSessionId === DRAFT_ID ? (startOptions.engine ?? "claude") @@ -66,30 +76,26 @@ export function useSessionManager(projects: Project[], acpPermissionBehavior: Ac : !!sessions.find((s) => s.id === activeSessionId)?.planMode) : false; - // ── Engine hooks ── - const claude = useClaude({ sessionId: claudeSessionId, initialMessages: activeEngine === "claude" ? initialMessages : [], initialMeta: activeEngine === "claude" ? initialMeta : null, initialPermission: activeEngine === "claude" ? initialPermission : null }); - const acp = useACP({ - sessionId: acpSessionId, - initialMessages: isACP ? initialMessages : [], - initialConfigOptions: isACP ? initialConfigOptions : undefined, - initialSlashCommands: isACP ? initialSlashCommands : undefined, - initialMeta: isACP ? initialMeta : null, - initialPermission: isACP ? initialPermission : null, - initialRawAcpPermission: isACP ? initialRawAcpPermission : null, + // ── Primary session pane (wraps all three engine hooks) ── + const primaryPane = useSessionPane({ + activeSessionId, + activeEngine, + claudeSessionId, + acpSessionId, + codexSessionId, + codexSessionModel, + codexPlanModeEnabled, + initialMessages, + initialMeta, + initialPermission, + initialConfigOptions, + initialSlashCommands, + initialRawAcpPermission, acpPermissionBehavior, }); - const codex = useCodex({ - sessionId: codexSessionId, - sessionModel: codexSessionModel, - planModeEnabled: codexPlanModeEnabled, - initialMessages: isCodex ? initialMessages : [], - initialMeta: isCodex ? initialMeta : null, - initialPermission: isCodex ? initialPermission : null, - }); - // Pick the active engine's state - const engine = isCodex ? codex : isACP ? acp : claude; - const { messages, totalCost, contextUsage } = engine; + const { claude, acp, codex, engine } = primaryPane; + const { messages, totalCost, contextUsage } = primaryPane; // ── All refs (21+) — kept for stale-closure avoidance ── const liveSessionIdsRef = useRef>(new Set()); @@ -101,8 +107,7 @@ export function useSessionManager(projects: Project[], acpPermissionBehavior: Ac contextUsageRef.current = contextUsage; const activeSessionIdRef = useRef(activeSessionId); activeSessionIdRef.current = activeSessionId; - const sessionsRef = useRef(sessions); - sessionsRef.current = sessions; + // sessionsRef declared above (near extra pane loaders) const projectsRef = useRef(projects); projectsRef.current = projects; const draftProjectIdRef = useRef(draftProjectId); @@ -119,6 +124,9 @@ export function useSessionManager(projects: Project[], acpPermissionBehavior: Ac sessionInfoRef.current = engine.sessionInfo; const pendingPermissionRef = useRef(engine.pendingPermission); pendingPermissionRef.current = engine.pendingPermission; + // Split view: track visible split-pane session IDs for IPC routing gate + const visibleSplitSessionIdsRef = useRef(visibleSplitSessionIds); + visibleSplitSessionIdsRef.current = visibleSplitSessionIds; // Prevent cross-session bleed: skip the first lastMessageAt sync after switching chats. const lastMessageSyncSessionRef = useRef(null); const preStartedSessionIdRef = useRef(null); @@ -146,7 +154,7 @@ export function useSessionManager(projects: Project[], acpPermissionBehavior: Ac // Stable ref for space switching — avoids adding onSpaceChange as a useCallback dependency const onSpaceChangeRef = useRef(onSpaceChange); onSpaceChangeRef.current = onSpaceChange; - const backgroundStoreRef = useRef(new BackgroundSessionStore()); + // backgroundStoreRef declared above (near extra pane loaders) // ── Codex effort helpers (kept in orchestrator — too small to extract) ── const setCodexEffortFromUser = useCallback((effort: string) => { @@ -206,6 +214,7 @@ export function useSessionManager(projects: Project[], acpPermissionBehavior: Ac onSpaceChangeRef, acpPermissionBehaviorRef, currentBranchRef, + visibleSplitSessionIdsRef, }; const setters: SharedSessionSetters = { @@ -362,8 +371,57 @@ export function useSessionManager(projects: Project[], acpPermissionBehavior: Ac currentBranchRef.current = branch; }, []); - // ── Return (identical interface to original) ── + const loadSplitPaneBootstrap = useCallback(async (sessionId: string): Promise => { + const session = sessionsRef.current.find((entry) => entry.id === sessionId); + if (!session) { + return null; + } + + const backgroundState = backgroundStoreRef.current.get(sessionId); + if (backgroundState) { + return { + session, + initialMessages: backgroundState.messages, + initialMeta: { + isProcessing: backgroundState.isProcessing, + isConnected: backgroundState.isConnected, + sessionInfo: backgroundState.sessionInfo, + totalCost: backgroundState.totalCost, + contextUsage: backgroundState.contextUsage, + isCompacting: backgroundState.isCompacting, + }, + initialPermission: backgroundState.pendingPermission, + initialConfigOptions: [], + initialSlashCommands: backgroundState.slashCommands ?? [], + initialRawAcpPermission: backgroundState.rawAcpPermission, + }; + } + + const persistedSession = await window.claude.sessions.load(session.projectId, sessionId); + if (!persistedSession) { + return null; + } + + return { + session, + initialMessages: persistedSession.messages ?? [], + initialMeta: { + isProcessing: false, + isConnected: false, + sessionInfo: null, + totalCost: persistedSession.totalCost ?? 0, + contextUsage: persistedSession.contextUsage ?? null, + }, + initialPermission: null, + initialConfigOptions: [], + initialSlashCommands: [], + initialRawAcpPermission: null, + }; + }, []); + + // ── Return ── return { + primaryPane, sessions, setSessions, activeSessionId, @@ -396,6 +454,7 @@ export function useSessionManager(projects: Project[], acpPermissionBehavior: Ac sendNextId, seedDevExampleConversation, refreshSessions, + loadSplitPaneBootstrap, queuedCount, stop: engine.stop, interrupt: async () => { diff --git a/src/hooks/useSplitDragDrop.ts b/src/hooks/useSplitDragDrop.ts new file mode 100644 index 0000000..06f9e3e --- /dev/null +++ b/src/hooks/useSplitDragDrop.ts @@ -0,0 +1,219 @@ +/** + * useSplitDragDrop — manages drag-from-sidebar-to-split state. + * + * When a session is dragged from the sidebar over the chat area, this hook: + * 1. Detects whether the drag payload is a session + * 2. Calculates which drop position the cursor is over (between/beside panes) + * 3. Provides state for rendering animated drop zones + * + * Drop positions are indices into the pane array: + * 0 = before the first pane + * 1 = after the first pane (between first and second) + * N = after the last pane + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { clearSidebarDragPayload, getSidebarDragPayload } from "@/lib/sidebar-dnd"; + +export interface SplitDragState { + /** Whether a valid session is being dragged over the chat area. */ + isDragging: boolean; + /** Which drop position the cursor is over (index in the pane array where the new pane would be inserted). */ + dropPosition: number | null; + /** Session ID being dragged (for preview). */ + draggedSessionId: string | null; +} + +interface UseSplitDragDropOptions { + /** Current number of panes. */ + paneCount: number; + /** Whether another session can currently be added to split view. */ + canAcceptDrop: boolean; + /** Ref to the container element for measuring positions. */ + containerRef: React.RefObject; + /** Width fractions for current panes. */ + widthFractions: number[]; + /** Callback when a session is dropped at a position. */ + onDrop: (sessionId: string, position: number) => void; + /** Set of session IDs already visible in panes (to prevent duplicate drops). */ + visibleSessionIds: Set; +} + +export function useSplitDragDrop({ + paneCount, + canAcceptDrop, + containerRef, + widthFractions, + onDrop, + visibleSessionIds, +}: UseSplitDragDropOptions) { + const [dragState, setDragState] = useState({ + isDragging: false, + dropPosition: null, + draggedSessionId: null, + }); + + const dragCounterRef = useRef(0); + const cancelDrag = useCallback(() => { + dragCounterRef.current = 0; + clearSidebarDragPayload(); + setDragState({ isDragging: false, dropPosition: null, draggedSessionId: null }); + }, []); + + /** Calculate which drop position the mouse X is closest to. */ + const calcDropPosition = useCallback((clientX: number): number | null => { + const container = containerRef.current; + if (!container) return null; + + const rect = container.getBoundingClientRect(); + const relativeX = clientX - rect.left; + const totalWidth = rect.width; + + if (totalWidth <= 0) return null; + + // Calculate the x-position of each pane boundary + const boundaries: number[] = [0]; // left edge + let cumulative = 0; + for (let i = 0; i < widthFractions.length; i++) { + cumulative += widthFractions[i] * totalWidth; + boundaries.push(cumulative); + } + + // Find the closest boundary to the cursor + // Drop zones are in the edge regions of each pane + const edgeThreshold = Math.min(80, totalWidth / (paneCount * 3)); + + // Check left edge of first pane + if (relativeX < edgeThreshold) return 0; + + // Check right edge of last pane + if (relativeX > totalWidth - edgeThreshold) return paneCount; + + // Check boundaries between panes + for (let i = 1; i < boundaries.length - 1; i++) { + const boundary = boundaries[i]; + if (Math.abs(relativeX - boundary) < edgeThreshold) { + return i; + } + } + + // If cursor is in the middle of a pane, find the closest edge + // (use whichever half of the pane the cursor is in) + for (let i = 0; i < widthFractions.length; i++) { + const paneLeft = boundaries[i]; + const paneRight = boundaries[i + 1]; + if (relativeX >= paneLeft && relativeX <= paneRight) { + const paneMid = (paneLeft + paneRight) / 2; + return relativeX < paneMid ? i : i + 1; + } + } + + return paneCount; // fallback: append to end + }, [containerRef, widthFractions, paneCount]); + + const handleDragEnter = useCallback((e: React.DragEvent) => { + dragCounterRef.current++; + + const payload = getSidebarDragPayload(e.dataTransfer); + if (!payload || payload.kind !== "session") return; + + // Don't allow if already at max panes + if (!canAcceptDrop) return; + + // Don't allow duplicates + if (visibleSessionIds.has(payload.id)) return; + + e.preventDefault(); + const position = calcDropPosition(e.clientX); + + setDragState({ + isDragging: true, + dropPosition: position, + draggedSessionId: payload.id, + }); + }, [canAcceptDrop, calcDropPosition, visibleSessionIds]); + + const handleDragOver = useCallback((e: React.DragEvent) => { + const payload = getSidebarDragPayload(e.dataTransfer); + if (!payload || payload.kind !== "session") return; + if (!canAcceptDrop) return; + if (visibleSessionIds.has(payload.id)) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + + const position = calcDropPosition(e.clientX); + setDragState(prev => { + if (prev.dropPosition === position && prev.isDragging) return prev; + return { isDragging: true, dropPosition: position, draggedSessionId: payload.id }; + }); + }, [canAcceptDrop, calcDropPosition, visibleSessionIds]); + + const handleDragLeave = useCallback((_e: React.DragEvent) => { + dragCounterRef.current--; + if (dragCounterRef.current <= 0) { + cancelDrag(); + } + }, [cancelDrag]); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + + const payload = getSidebarDragPayload(e.dataTransfer); + if (!payload || payload.kind !== "session") { + cancelDrag(); + return; + } + + if (!canAcceptDrop || visibleSessionIds.has(payload.id)) { + cancelDrag(); + return; + } + + const effectivePosition = calcDropPosition(e.clientX) ?? paneCount; + cancelDrag(); + onDrop(payload.id, effectivePosition); + }, [calcDropPosition, cancelDrag, canAcceptDrop, onDrop, paneCount, visibleSessionIds]); + + useEffect(() => { + if (!dragState.isDragging) { + return; + } + + const handleWindowDragEnd = () => { + cancelDrag(); + }; + const handleWindowBlur = () => { + cancelDrag(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + cancelDrag(); + } + }; + + window.addEventListener("dragend", handleWindowDragEnd, true); + window.addEventListener("blur", handleWindowBlur); + window.addEventListener("keydown", handleKeyDown, true); + + return () => { + window.removeEventListener("dragend", handleWindowDragEnd, true); + window.removeEventListener("blur", handleWindowBlur); + window.removeEventListener("keydown", handleKeyDown, true); + }; + }, [cancelDrag, dragState.isDragging]); + + useEffect(() => { + if (dragState.isDragging && !canAcceptDrop) { + cancelDrag(); + } + }, [canAcceptDrop, cancelDrag, dragState.isDragging]); + + return { + dragState, + handleDragEnter, + handleDragOver, + handleDragLeave, + handleDrop, + }; +} diff --git a/src/hooks/useSplitView.ts b/src/hooks/useSplitView.ts new file mode 100644 index 0000000..99566ab --- /dev/null +++ b/src/hooks/useSplitView.ts @@ -0,0 +1,266 @@ +import { useCallback, useMemo, useState } from "react"; +import type { ToolId } from "@/components/ToolPicker"; +import { + DEFAULT_PANE_DRAWER_HEIGHT, + MAX_PANE_DRAWER_HEIGHT, + MIN_PANE_DRAWER_HEIGHT, + clampWidthFractions, + equalWidthFractions, +} from "@/lib/layout-constants"; +import { + type SplitAddRejectionReason, + getSplitAddRejectionReason, +} from "@/lib/split-layout"; + +export interface PaneDrawerState { + open: boolean; + activeTab: ToolId | null; + height: number; +} + +export interface SplitAddSessionResult { + ok: boolean; + reason: SplitAddRejectionReason | null; +} + +interface PruneSessionsResult { + removedSessionIds: string[]; +} + +interface SplitAddSessionInput { + sessionId: string; + activeSessionId: string | null; + maxPaneCount: number; + position?: number; +} + +export interface SplitViewState { + enabled: boolean; + visibleSessionIds: string[]; + paneCount: number; + focusedSessionId: string | null; + widthFractions: number[]; + getDrawerState: (sessionId: string) => PaneDrawerState; + setFocusedSession: (sessionId: string | null) => void; + setWidthFractions: (fractions: number[]) => void; + requestAddSplitSession: (input: SplitAddSessionInput) => SplitAddSessionResult; + removeSplitSession: (sessionId: string) => void; + dismissSplitView: () => void; + toggleToolTab: (sessionId: string, toolId: ToolId) => void; + setDrawerHeight: (sessionId: string, height: number) => void; + pruneSplitSessions: (validSessionIds: ReadonlySet, activeSessionId: string | null) => PruneSessionsResult; + canShowSessionSplitAction: (sessionId: string | null | undefined, activeSessionId: string | null) => boolean; +} + +const DEFAULT_DRAWER_STATE: PaneDrawerState = { + open: false, + activeTab: null, + height: DEFAULT_PANE_DRAWER_HEIGHT, +}; + +function normalizeInsertIndex(position: number | undefined, visiblePaneCount: number): number { + if (position === undefined) { + return visiblePaneCount; + } + + return Math.max(0, Math.min(position, visiblePaneCount)); +} + +function omitDrawerState( + drawerStateByPaneId: Record, + paneIdsToRemove: readonly string[], +): Record { + if (paneIdsToRemove.length === 0) { + return drawerStateByPaneId; + } + + const nextDrawerStateByPaneId = { ...drawerStateByPaneId }; + for (const paneId of paneIdsToRemove) { + delete nextDrawerStateByPaneId[paneId]; + } + return nextDrawerStateByPaneId; +} + +export function useSplitView(): SplitViewState { + const [visibleSessionIds, setVisibleSessionIds] = useState([]); + const [focusedSessionId, setFocusedSessionId] = useState(null); + const [widthFractions, setWidthFractionsState] = useState([1]); + const [drawerStateByPaneId, setDrawerStateByPaneId] = useState>({}); + + const paneCount = visibleSessionIds.length > 0 ? visibleSessionIds.length : 1; + const enabled = visibleSessionIds.length > 1; + + const getDrawerState = useCallback((sessionId: string): PaneDrawerState => { + return drawerStateByPaneId[sessionId] ?? DEFAULT_DRAWER_STATE; + }, [drawerStateByPaneId]); + + const setFocusedSession = useCallback((sessionId: string | null) => { + setFocusedSessionId(sessionId); + }, []); + + const setWidthFractions = useCallback((fractions: number[]) => { + setWidthFractionsState(clampWidthFractions(fractions)); + }, []); + + const requestAddSplitSession = useCallback(({ + sessionId, + activeSessionId, + maxPaneCount, + position, + }: SplitAddSessionInput): SplitAddSessionResult => { + const currentVisibleSessionIds = visibleSessionIds.length > 0 + ? visibleSessionIds + : activeSessionId + ? [activeSessionId] + : []; + const reason = getSplitAddRejectionReason({ + sessionId, + activeSessionId, + visibleSessionIds: currentVisibleSessionIds, + maxPaneCount, + }); + + if (reason) { + return { ok: false, reason }; + } + + const normalizedSessionId = sessionId.trim(); + setVisibleSessionIds((currentSplitSessionIds) => { + const baseVisibleSessionIds = currentSplitSessionIds.length > 0 + ? currentSplitSessionIds + : activeSessionId + ? [activeSessionId] + : []; + + if (baseVisibleSessionIds.includes(normalizedSessionId)) { + return currentSplitSessionIds; + } + + const insertIndex = normalizeInsertIndex(position, baseVisibleSessionIds.length); + const nextVisibleSessionIds = [...baseVisibleSessionIds]; + nextVisibleSessionIds.splice(insertIndex, 0, normalizedSessionId); + setWidthFractionsState(equalWidthFractions(nextVisibleSessionIds.length)); + return nextVisibleSessionIds; + }); + + return { ok: true, reason: null }; + }, [visibleSessionIds]); + + const removeSplitSession = useCallback((sessionId: string) => { + setVisibleSessionIds((currentVisibleSessionIds) => { + if (!currentVisibleSessionIds.includes(sessionId)) { + return currentVisibleSessionIds; + } + + const nextVisibleSessionIds = currentVisibleSessionIds.filter((visibleSessionId) => visibleSessionId !== sessionId); + const nextSplitSessionIds = nextVisibleSessionIds.length > 1 ? nextVisibleSessionIds : []; + const paneIdsToClear = nextSplitSessionIds.length > 0 ? [sessionId] : currentVisibleSessionIds; + + setWidthFractionsState(nextSplitSessionIds.length > 0 ? equalWidthFractions(nextSplitSessionIds.length) : [1]); + setFocusedSessionId((currentFocusedSessionId) => + currentFocusedSessionId !== null && !nextSplitSessionIds.includes(currentFocusedSessionId) + ? null + : currentFocusedSessionId, + ); + setDrawerStateByPaneId((currentDrawerStateByPaneId) => + omitDrawerState(currentDrawerStateByPaneId, paneIdsToClear), + ); + return nextSplitSessionIds; + }); + }, []); + + const dismissSplitView = useCallback(() => { + setVisibleSessionIds([]); + setFocusedSessionId(null); + setWidthFractionsState([1]); + setDrawerStateByPaneId({}); + }, []); + + const toggleToolTab = useCallback((sessionId: string, toolId: ToolId) => { + setDrawerStateByPaneId((currentDrawerStateByPaneId) => { + const currentDrawerState = currentDrawerStateByPaneId[sessionId] ?? DEFAULT_DRAWER_STATE; + if (currentDrawerState.activeTab === toolId) { + return { + ...currentDrawerStateByPaneId, + [sessionId]: { ...currentDrawerState, open: false, activeTab: null }, + }; + } + + return { + ...currentDrawerStateByPaneId, + [sessionId]: { ...currentDrawerState, open: true, activeTab: toolId }, + }; + }); + }, []); + + const setDrawerHeight = useCallback((sessionId: string, height: number) => { + const clampedHeight = Math.max(MIN_PANE_DRAWER_HEIGHT, Math.min(MAX_PANE_DRAWER_HEIGHT, height)); + setDrawerStateByPaneId((currentDrawerStateByPaneId) => { + const currentDrawerState = currentDrawerStateByPaneId[sessionId] ?? DEFAULT_DRAWER_STATE; + return { + ...currentDrawerStateByPaneId, + [sessionId]: { ...currentDrawerState, height: clampedHeight }, + }; + }); + }, []); + + const pruneSplitSessions = useCallback((validSessionIds: ReadonlySet): PruneSessionsResult => { + const removedSessionIds: string[] = []; + + setVisibleSessionIds((currentVisibleSessionIds) => { + const nextVisibleSessionIds = currentVisibleSessionIds.filter((sessionId) => { + const shouldKeep = validSessionIds.has(sessionId); + if (!shouldKeep) { + removedSessionIds.push(sessionId); + } + return shouldKeep; + }); + + if (nextVisibleSessionIds.length === currentVisibleSessionIds.length) { + return currentVisibleSessionIds; + } + + const nextSplitSessionIds = nextVisibleSessionIds.length > 1 ? nextVisibleSessionIds : []; + const paneIdsToClear = nextSplitSessionIds.length > 0 ? removedSessionIds : currentVisibleSessionIds; + + setWidthFractionsState(nextSplitSessionIds.length > 0 ? equalWidthFractions(nextSplitSessionIds.length) : [1]); + setDrawerStateByPaneId((currentDrawerStateByPaneId) => + omitDrawerState(currentDrawerStateByPaneId, paneIdsToClear), + ); + setFocusedSessionId((currentFocusedSessionId) => + currentFocusedSessionId !== null && !nextSplitSessionIds.includes(currentFocusedSessionId) + ? null + : currentFocusedSessionId, + ); + return nextSplitSessionIds; + }); + + return { removedSessionIds }; + }, []); + + const canShowSessionSplitAction = useCallback((sessionId: string | null | undefined, activeSessionId: string | null) => { + const normalizedSessionId = typeof sessionId === "string" ? sessionId.trim() : ""; + if (!normalizedSessionId) { + return false; + } + return normalizedSessionId !== activeSessionId && !visibleSessionIds.includes(normalizedSessionId); + }, [visibleSessionIds]); + + return { + enabled, + visibleSessionIds, + paneCount, + focusedSessionId, + widthFractions, + getDrawerState, + setFocusedSession, + setWidthFractions, + requestAddSplitSession, + removeSplitSession, + dismissSplitView, + toggleToolTab, + setDrawerHeight, + pruneSplitSessions, + canShowSessionSplitAction, + }; +} diff --git a/src/lib/layout-constants.ts b/src/lib/layout-constants.ts index 435b424..3ebd35f 100644 --- a/src/lib/layout-constants.ts +++ b/src/lib/layout-constants.ts @@ -50,3 +50,68 @@ export function getBootstrapMinWindowWidth(platform: string): number { return platform === "win32" ? width + WINDOWS_FRAME_BUFFER_WIDTH : width; } + +// ── Split view constants ── + +/** Maximum number of panes (including the primary pane). */ +export const MAX_SPLIT_PANES = 4; + +/** Maximum number of extra panes (beyond the primary). */ +export const MAX_EXTRA_PANES = MAX_SPLIT_PANES - 1; + +/** Minimum chat pane width in split mode — reduced from 704px to fit multiple panes. */ +export const MIN_CHAT_WIDTH_SPLIT = 400; + +/** Split handle width between panes. */ +export const SPLIT_HANDLE_WIDTH = ISLAND_PANEL_GAP; + +/** Minimum width fraction for any single pane. */ +export const MIN_PANE_WIDTH_FRACTION = 0.15; + +/** Minimum split ratio (prevents either pane from becoming too narrow). */ +export const MIN_SPLIT_RATIO = 0.3; + +/** Maximum split ratio. */ +export const MAX_SPLIT_RATIO = 0.7; + +/** Default split ratio (50/50). */ +export const DEFAULT_SPLIT_RATIO = 0.5; + +/** Minimum height for per-pane tool drawer. */ +export const MIN_PANE_DRAWER_HEIGHT = 120; + +/** Maximum height for per-pane tool drawer. */ +export const MAX_PANE_DRAWER_HEIGHT = 400; + +/** Default height for per-pane tool drawer. */ +export const DEFAULT_PANE_DRAWER_HEIGHT = 200; + +/** Width of the animated drop zone when dragging a session into split view. */ +export const SPLIT_DROP_ZONE_WIDTH = 200; + +/** Minimum window width for split view (sidebar + margin + N panes + handles). */ +export function getMinSplitViewWindowWidth(platform: string, paneCount = 2): number { + const handles = Math.max(0, paneCount - 1) * SPLIT_HANDLE_WIDTH; + const width = + APP_SIDEBAR_WIDTH + + ISLAND_LAYOUT_MARGIN + + MIN_CHAT_WIDTH_SPLIT * paneCount + + handles; + + return platform === "win32" ? width + WINDOWS_FRAME_BUFFER_WIDTH : width; +} + +/** Calculate equal width fractions for N panes. */ +export function equalWidthFractions(count: number): number[] { + if (count <= 0) return [1]; + const fraction = 1 / count; + return Array.from({ length: count }, () => fraction); +} + +/** Clamp width fractions so no pane is below MIN_PANE_WIDTH_FRACTION. */ +export function clampWidthFractions(fractions: number[]): number[] { + if (fractions.length <= 1) return [1]; + const clamped = fractions.map(f => Math.max(MIN_PANE_WIDTH_FRACTION, f)); + const sum = clamped.reduce((a, b) => a + b, 0); + return clamped.map(f => f / sum); // normalize to sum=1 +} diff --git a/src/lib/session-records.ts b/src/lib/session-records.ts index 4a9f78f..4f35a26 100644 --- a/src/lib/session-records.ts +++ b/src/lib/session-records.ts @@ -20,6 +20,7 @@ export function toChatSession( folderId: session.folderId, pinned: session.pinned, branch: session.branch, + agentId: session.agentId, }; } diff --git a/src/lib/split-layout.test.ts b/src/lib/split-layout.test.ts new file mode 100644 index 0000000..98008f9 --- /dev/null +++ b/src/lib/split-layout.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + getAppMinimumWidth, + getMaxVisibleSplitPaneCount, + getRequiredSplitContentWidth, + getSplitAddRejectionReason, +} from "./split-layout"; + +describe("split layout utilities", () => { + it("rejects invalid split add requests", () => { + expect(getSplitAddRejectionReason({ + sessionId: "", + activeSessionId: "session-1", + visibleSessionIds: ["session-1"], + maxPaneCount: 3, + })).toBe("missing-session"); + + expect(getSplitAddRejectionReason({ + sessionId: "session-1", + activeSessionId: "session-1", + visibleSessionIds: ["session-1"], + maxPaneCount: 3, + })).toBe("active-session"); + + expect(getSplitAddRejectionReason({ + sessionId: "session-2", + activeSessionId: "session-1", + visibleSessionIds: ["session-1", "session-2"], + maxPaneCount: 3, + })).toBe("duplicate-session"); + + expect(getSplitAddRejectionReason({ + sessionId: "session-3", + activeSessionId: "session-1", + visibleSessionIds: ["session-1", "session-2", "session-4"], + maxPaneCount: 3, + })).toBe("insufficient-width"); + }); + + it("accepts valid split add requests", () => { + expect(getSplitAddRejectionReason({ + sessionId: "session-2", + activeSessionId: "session-1", + visibleSessionIds: ["session-1"], + maxPaneCount: 3, + })).toBeNull(); + }); + + it("calculates split pane capacity from width", () => { + expect(getMaxVisibleSplitPaneCount(0)).toBe(1); + expect(getMaxVisibleSplitPaneCount(400)).toBe(1); + expect(getMaxVisibleSplitPaneCount(804)).toBe(2); + expect(getMaxVisibleSplitPaneCount(1208)).toBe(3); + }); + + it("includes split pane count in the app minimum width", () => { + expect(getRequiredSplitContentWidth(3)).toBe(1208); + + expect(getAppMinimumWidth({ + sidebarOpen: true, + isIslandLayout: true, + hasActiveSession: true, + hasRightPanel: true, + hasToolsColumn: true, + isSplitViewEnabled: false, + splitPaneCount: 1, + isWindows: false, + })).toBe(1542); + + expect(getAppMinimumWidth({ + sidebarOpen: true, + isIslandLayout: true, + hasActiveSession: true, + hasRightPanel: false, + hasToolsColumn: false, + isSplitViewEnabled: true, + splitPaneCount: 3, + isWindows: false, + })).toBe(1500); + }); +}); diff --git a/src/lib/split-layout.ts b/src/lib/split-layout.ts new file mode 100644 index 0000000..14dc3f5 --- /dev/null +++ b/src/lib/split-layout.ts @@ -0,0 +1,115 @@ +import { + APP_SIDEBAR_WIDTH, + ISLAND_LAYOUT_MARGIN, + MIN_CHAT_WIDTH_SPLIT, + MIN_RIGHT_PANEL_WIDTH, + MIN_TOOLS_PANEL_WIDTH, + SPLIT_HANDLE_WIDTH, + WINDOWS_FRAME_BUFFER_WIDTH, + getMinChatWidth, + getResizeHandleWidth, + getToolPickerWidth, +} from "@/lib/layout-constants"; + +export type SplitAddRejectionReason = + | "missing-session" + | "active-session" + | "duplicate-session" + | "insufficient-width"; + +export interface SplitAddGuardInput { + sessionId: string | null | undefined; + activeSessionId: string | null; + visibleSessionIds: readonly string[]; + maxPaneCount: number; +} + +export interface AppMinimumWidthInput { + sidebarOpen: boolean; + isIslandLayout: boolean; + hasActiveSession: boolean; + hasRightPanel: boolean; + hasToolsColumn: boolean; + isSplitViewEnabled: boolean; + splitPaneCount: number; + isWindows: boolean; +} + +export function getRequiredSplitContentWidth(paneCount: number): number { + if (paneCount <= 1) { + return MIN_CHAT_WIDTH_SPLIT; + } + + return (MIN_CHAT_WIDTH_SPLIT * paneCount) + (SPLIT_HANDLE_WIDTH * (paneCount - 1)); +} + +export function getMaxVisibleSplitPaneCount(availableWidth: number): number { + if (!Number.isFinite(availableWidth) || availableWidth <= 0) { + return 1; + } + + const paneWidthWithHandle = MIN_CHAT_WIDTH_SPLIT + SPLIT_HANDLE_WIDTH; + return Math.max(1, Math.floor((availableWidth + SPLIT_HANDLE_WIDTH) / paneWidthWithHandle)); +} + +export function getSplitAddRejectionReason({ + sessionId, + activeSessionId, + visibleSessionIds, + maxPaneCount, +}: SplitAddGuardInput): SplitAddRejectionReason | null { + const normalizedSessionId = typeof sessionId === "string" ? sessionId.trim() : ""; + if (!normalizedSessionId) { + return "missing-session"; + } + + if (normalizedSessionId === activeSessionId) { + return "active-session"; + } + + if (visibleSessionIds.includes(normalizedSessionId)) { + return "duplicate-session"; + } + + if (visibleSessionIds.length >= maxPaneCount) { + return "insufficient-width"; + } + + return null; +} + +export function getAppMinimumWidth({ + sidebarOpen, + isIslandLayout, + hasActiveSession, + hasRightPanel, + hasToolsColumn, + isSplitViewEnabled, + splitPaneCount, + isWindows, +}: AppMinimumWidthInput): number { + const sidebarWidth = sidebarOpen ? APP_SIDEBAR_WIDTH : 0; + const outerMarginWidth = isIslandLayout ? ISLAND_LAYOUT_MARGIN : 0; + const windowsFrameWidth = isWindows ? WINDOWS_FRAME_BUFFER_WIDTH : 0; + + if (isSplitViewEnabled && splitPaneCount > 1) { + return sidebarWidth + + outerMarginWidth + + getRequiredSplitContentWidth(splitPaneCount) + + windowsFrameWidth; + } + + let minimumWidth = sidebarWidth + outerMarginWidth + getMinChatWidth(isIslandLayout) + windowsFrameWidth; + if (!hasActiveSession) { + return minimumWidth; + } + + minimumWidth += getToolPickerWidth(isIslandLayout); + if (hasRightPanel) { + minimumWidth += MIN_RIGHT_PANEL_WIDTH + getResizeHandleWidth(isIslandLayout); + } + if (hasToolsColumn) { + minimumWidth += MIN_TOOLS_PANEL_WIDTH + getResizeHandleWidth(isIslandLayout); + } + return minimumWidth; +} From 113169249b8d0761a5adbbca8389b571c2b9fea8 Mon Sep 17 00:00:00 2001 From: OpenSource Date: Mon, 23 Mar 2026 03:28:11 +0100 Subject: [PATCH 4/4] feat: tool icons, ANSI rendering, mac background effects, and chat UI polish Add tool glyph system with per-tool icons and colored/monochrome modes, ANSI SGR color rendering for bash output, switchable mac background effects (liquid glass / vibrancy / off), assistant turn duration dividers, diff stats on edit/write tool cards, browser per-tab color scheme, TodoPanel redesign with progress bar, Git panel streamlining, and extracted shared chat layout constants. Expand appearance settings with tool icon, background effect, and edit expansion options. Co-Authored-By: Claude Opus 4.6 (1M context) --- electron/src/main.ts | 142 ++++- electron/src/preload.ts | 39 +- src/components/AgentTranscriptViewer.tsx | 31 +- src/components/AppLayout.tsx | 123 ++++- src/components/BackgroundAgentsPanel.tsx | 180 +++---- src/components/BrowserPanel.tsx | 503 ++++++++++-------- src/components/ChatView.tsx | 42 +- src/components/DiffViewer.tsx | 11 +- src/components/FilePreviewOverlay.tsx | 3 +- src/components/FilesPanel.tsx | 36 +- src/components/InputBar.tsx | 61 ++- src/components/McpPanel.tsx | 2 +- src/components/MessageBubble.tsx | 75 ++- src/components/PanelHeader.tsx | 14 +- src/components/ProjectFilesPanel.tsx | 82 +-- src/components/SettingsView.tsx | 32 +- src/components/SpaceCreator.tsx | 6 + src/components/SummaryBlock.tsx | 5 +- src/components/TabBar.tsx | 59 +- src/components/ThinkingBlock.tsx | 5 +- src/components/TodoPanel.tsx | 202 ++----- src/components/ToolCall.tsx | 81 ++- src/components/ToolGroupBlock.tsx | 169 ++++-- src/components/TurnChangesSummary.tsx | 83 +-- src/components/git/BranchPicker.tsx | 24 +- src/components/git/ChangesSection.tsx | 39 +- src/components/git/CommitInput.tsx | 16 +- src/components/git/FileItem.tsx | 44 +- src/components/git/GitPanel.tsx | 405 +------------- src/components/git/InlineSelector.tsx | 79 ++- src/components/git/RepoSection.tsx | 105 ++-- src/components/lib/ToolGlyph.tsx | 21 + src/components/lib/chat-layout.ts | 8 + src/components/lib/tool-formatting.ts | 7 + src/components/lib/tool-metadata.ts | 23 + .../settings/AppearanceSettings.tsx | 98 +++- src/components/sidebar/BranchSection.tsx | 2 +- src/components/sidebar/FolderSection.tsx | 137 +++-- src/components/sidebar/ProjectSection.tsx | 250 +++++---- src/components/sidebar/SessionItem.tsx | 210 ++++---- src/components/tool-renderers/BashContent.tsx | 87 +-- src/components/tool-renderers/TaskTool.tsx | 7 +- .../tool-renderers/WriteContent.tsx | 117 ++-- src/components/welcome/AppearanceStep.tsx | 19 + src/components/welcome/WelcomeWizard.tsx | 6 + src/components/welcome/shared.ts | 2 + src/hooks/useAppOrchestrator.ts | 108 +++- src/hooks/useSettings.ts | 78 ++- src/hooks/useSpaceTheme.ts | 64 ++- src/hooks/useSplitDragDrop.ts | 34 +- src/hooks/useSplitView.ts | 2 +- src/index.css | 295 ++++++---- src/lib/ansi.tsx | 167 ++++++ src/lib/assistant-turn-divider.test.ts | 90 ++++ src/lib/assistant-turn-divider.ts | 91 ++++ src/lib/diff-stats.ts | 92 ++++ src/lib/layout-constants.ts | 2 +- src/lib/monaco.ts | 26 + src/types/index.ts | 1 + src/types/ui.ts | 1 + src/types/window.d.ts | 8 + 61 files changed, 2946 insertions(+), 1805 deletions(-) create mode 100644 src/components/lib/ToolGlyph.tsx create mode 100644 src/components/lib/chat-layout.ts create mode 100644 src/lib/ansi.tsx create mode 100644 src/lib/assistant-turn-divider.test.ts create mode 100644 src/lib/assistant-turn-divider.ts create mode 100644 src/lib/diff-stats.ts diff --git a/electron/src/main.ts b/electron/src/main.ts index 81f1c09..bc4b7fc 100644 --- a/electron/src/main.ts +++ b/electron/src/main.ts @@ -1,5 +1,5 @@ import { execSync } from "child_process"; -import { app, BrowserWindow, clipboard, globalShortcut, ipcMain, Menu, nativeTheme, session, shell, systemPreferences } from "electron"; +import { app, BrowserWindow, clipboard, globalShortcut, ipcMain, Menu, nativeTheme, session, shell, systemPreferences, webContents } from "electron"; import path from "path"; import http from "http"; import contextMenu from "electron-context-menu"; @@ -63,6 +63,10 @@ if (glassEnabled) { let mainWindow: BrowserWindow | null = null; type NativeThemeSource = "system" | "light" | "dark"; +type MacBackgroundEffect = "liquid-glass" | "vibrancy" | "off"; + +let pendingMacBackgroundEffect: MacBackgroundEffect = "liquid-glass"; +let currentMacBackgroundEffect: MacBackgroundEffect = "off"; function normalizeThemeSource(value: unknown): NativeThemeSource { return value === "light" || value === "dark" || value === "system" @@ -70,6 +74,62 @@ function normalizeThemeSource(value: unknown): NativeThemeSource { : "system"; } +function normalizeMacBackgroundEffect(value: unknown): MacBackgroundEffect { + return value === "liquid-glass" || value === "vibrancy" || value === "off" + ? value + : "liquid-glass"; +} + +function getMacBackgroundEffectSupport(): { liquidGlass: boolean; vibrancy: boolean } { + return { + liquidGlass: glassEnabled, + vibrancy: process.platform === "darwin", + }; +} + +function resolveMacBackgroundEffect(effect: MacBackgroundEffect): MacBackgroundEffect { + const support = getMacBackgroundEffectSupport(); + if (effect === "liquid-glass" && !support.liquidGlass) { + return support.vibrancy ? "vibrancy" : "off"; + } + if (effect === "vibrancy" && !support.vibrancy) { + return "off"; + } + return effect; +} + +function applyMacBackgroundEffect(effect: MacBackgroundEffect): void { + if (process.platform !== "darwin" || !mainWindow || mainWindow.isDestroyed()) return; + + const resolved = resolveMacBackgroundEffect(effect); + pendingMacBackgroundEffect = resolved; + + if (resolved === "vibrancy") { + mainWindow.setVibrancy("under-window", { animationDuration: 120 }); + } else { + mainWindow.setVibrancy(null); + } + + if (resolved === "liquid-glass") { + if (!glassEnabled) { + currentMacBackgroundEffect = "off"; + return; + } + if (mainWindow.webContents.isLoadingMainFrame()) return; + + const glassId = applyGlass(mainWindow.getNativeWindowHandle()); + if (glassId === -1) { + log("GLASS", "addView returned -1 — native addon failed, glass will not be visible"); + } else { + log("GLASS", `Liquid glass applied, viewId=${glassId}`); + } + } else if (currentMacBackgroundEffect === "liquid-glass" && resolved !== "liquid-glass") { + log("GLASS", "Switching away from liquid glass may require reopening the window to fully clear the native view"); + } + + currentMacBackgroundEffect = resolved; +} + function getMainWindow(): BrowserWindow | null { return mainWindow; } @@ -99,8 +159,7 @@ function createWindow(): void { }, }; - if (glassEnabled) { - // macOS Tahoe+ with liquid glass + if (process.platform === "darwin") { windowOptions.titleBarStyle = "hidden"; windowOptions.transparent = true; windowOptions.trafficLightPosition = { x: 19, y: 19 }; @@ -114,15 +173,26 @@ function createWindow(): void { // macOS without glass / Linux windowOptions.titleBarStyle = "hiddenInset"; windowOptions.trafficLightPosition = { x: 19, y: 19 }; - windowOptions.backgroundColor = "#040404"; + windowOptions.backgroundColor = "#141414"; } mainWindow = new BrowserWindow(windowOptions); mainWindow.once("ready-to-show", () => { mainWindow?.show(); + if (process.platform === "darwin") { + applyMacBackgroundEffect(pendingMacBackgroundEffect); + setTimeout(() => applyMacBackgroundEffect(pendingMacBackgroundEffect), 0); + setTimeout(() => applyMacBackgroundEffect(pendingMacBackgroundEffect), 120); + } }); + if (process.platform === "darwin") { + mainWindow.on("focus", () => { + applyMacBackgroundEffect(pendingMacBackgroundEffect); + }); + } + contextMenu({ window: mainWindow, showSearchWithGoogle: false, @@ -147,29 +217,42 @@ function createWindow(): void { void shell.openExternal(url); }); - if (glassEnabled) { - // macOS: apply liquid glass after content loads + if (process.platform === "darwin") { mainWindow.webContents.once("did-finish-load", () => { - const glassId = applyGlass(mainWindow!.getNativeWindowHandle()); - if (glassId === -1) { - log("GLASS", "addView returned -1 — native addon failed, glass will not be visible"); - } else { - log("GLASS", `Liquid glass applied, viewId=${glassId}`); - } + applyMacBackgroundEffect(pendingMacBackgroundEffect); }); - } } // Renderer uses this to decide whether the transparency toggle is available. ipcMain.handle("app:getGlassSupported", () => { - return !!(glassEnabled || process.platform === "win32"); + return process.platform === "darwin" || process.platform === "win32"; +}); + +ipcMain.handle("app:get-mac-background-effect-support", () => { + return getMacBackgroundEffectSupport(); }); ipcMain.on("app:set-theme-source", (_event, themeSource: unknown) => { nativeTheme.themeSource = normalizeThemeSource(themeSource); }); +ipcMain.on("app:set-mac-background-effect", (_event, effect: unknown) => { + const normalized = normalizeMacBackgroundEffect(effect); + pendingMacBackgroundEffect = normalized; + applyMacBackgroundEffect(normalized); +}); + +ipcMain.handle("app:relaunch", () => { + try { + app.relaunch(); + app.quit(); + return { ok: true }; + } catch (err) { + return { ok: false, error: reportError("APP_RELAUNCH", err) }; + } +}); + ipcMain.handle("clipboard:write-text", (_event, text: string) => { try { clipboard.writeText(text); @@ -179,6 +262,37 @@ ipcMain.handle("clipboard:write-text", (_event, text: string) => { } }); +ipcMain.handle("browser:set-color-scheme", async (_event, payload: { targetWebContentsId: number; colorScheme: "light" | "dark" }) => { + try { + const targetId = payload?.targetWebContentsId; + const colorScheme = payload?.colorScheme; + + if (!Number.isInteger(targetId)) { + return { ok: false, error: "Invalid target webContents id" }; + } + if (colorScheme !== "light" && colorScheme !== "dark") { + return { ok: false, error: "Invalid browser color scheme" }; + } + + const target = webContents.fromId(targetId); + if (!target || target.isDestroyed()) { + return { ok: false, error: "Browser target is unavailable" }; + } + + if (!target.debugger.isAttached()) { + target.debugger.attach("1.3"); + } + + await target.debugger.sendCommand("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: colorScheme }], + }); + + return { ok: true }; + } catch (err) { + return { ok: false, error: reportError("BROWSER_COLOR_SCHEME", err) }; + } +}); + // Dynamic minimum window width — renderer calculates based on which panels are open. // Also expands the window if it's currently smaller than the new minimum (e.g. Tasks // panel appeared while at min size), so content never overflows off-screen. diff --git a/electron/src/preload.ts b/electron/src/preload.ts index 2566634..0f0602c 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -18,6 +18,7 @@ interface PreloadGlobals { } type ThemeSource = "system" | "light" | "dark"; +type MacBackgroundEffect = "liquid-glass" | "vibrancy" | "off"; function readStoredThemeSource(storage: PreloadStorage | undefined): ThemeSource { const stored = storage?.getItem("harnss-theme"); @@ -26,6 +27,16 @@ function readStoredThemeSource(storage: PreloadStorage | undefined): ThemeSource : "dark"; } +function readStoredMacBackgroundEffect(storage: PreloadStorage | undefined): MacBackgroundEffect { + const stored = storage?.getItem("harnss-mac-background-effect"); + if (stored === "liquid-glass" || stored === "vibrancy" || stored === "off") { + return stored; + } + + const transparencySetting = storage?.getItem("harnss-transparency") ?? null; + return transparencySetting === "false" ? "off" : "liquid-glass"; +} + // Early setup wrapped in try/catch so contextBridge.exposeInMainWorld always runs // even if DOM isn't ready or something else fails above it. try { @@ -37,16 +48,19 @@ try { // On Windows, glass support does not mean the user has transparency enabled. root?.classList.add(`platform-${process.platform}`); ipcRenderer.send("app:set-theme-source", themeSource); - ipcRenderer.invoke("app:getGlassSupported").then((supported: boolean) => { - if (!supported || !root) return; - - const transparencySetting = globals.localStorage?.getItem("harnss-transparency") ?? null; - const transparencyEnabled = transparencySetting === null || transparencySetting === "true"; - - if (transparencyEnabled) { - root.classList.add("glass-enabled"); - } - }); + const transparencyEnabled = process.platform === "darwin" + ? readStoredMacBackgroundEffect(globals.localStorage) !== "off" + : (globals.localStorage?.getItem("harnss-transparency") ?? null) !== "false"; + const canUseTransparentWindow = process.platform === "darwin" || process.platform === "win32"; + if (canUseTransparentWindow && transparencyEnabled) { + root?.classList.add("glass-enabled"); + } + if (process.platform === "darwin") { + ipcRenderer.send( + "app:set-mac-background-effect", + readStoredMacBackgroundEffect(globals.localStorage), + ); + } // Push stored theme to main process early so glass appearance is correct // before React mounts. Default to "dark" to match useSettings, which falls @@ -63,7 +77,10 @@ try { contextBridge.exposeInMainWorld("claude", { getGlassSupported: () => ipcRenderer.invoke("app:getGlassSupported"), + getMacBackgroundEffectSupport: () => ipcRenderer.invoke("app:get-mac-background-effect-support"), setThemeSource: (themeSource: ThemeSource) => ipcRenderer.send("app:set-theme-source", themeSource), + setMacBackgroundEffect: (effect: MacBackgroundEffect) => ipcRenderer.send("app:set-mac-background-effect", effect), + relaunchApp: () => ipcRenderer.invoke("app:relaunch"), setMinWidth: (width: number) => ipcRenderer.send("app:set-min-width", width), glass: { setTintColor: (tintColor: string | null) => @@ -128,6 +145,8 @@ contextBridge.exposeInMainWorld("claude", { newFile: (filePath: string) => ipcRenderer.invoke("file:new-file", filePath), newFolder: (folderPath: string) => ipcRenderer.invoke("file:new-folder", folderPath), writeClipboardText: (text: string) => ipcRenderer.invoke("clipboard:write-text", text), + setBrowserColorScheme: (targetWebContentsId: number, colorScheme: "light" | "dark") => + ipcRenderer.invoke("browser:set-color-scheme", { targetWebContentsId, colorScheme }), openInEditor: (filePath: string, line?: number, editor?: string) => ipcRenderer.invoke("file:open-in-editor", { filePath, line, editor }), openExternal: (url: string) => ipcRenderer.invoke("shell:open-external", url), showItemInFolder: (filePath: string) => ipcRenderer.invoke("shell:show-item-in-folder", filePath), diff --git a/src/components/AgentTranscriptViewer.tsx b/src/components/AgentTranscriptViewer.tsx index cc702d3..86fc1f8 100644 --- a/src/components/AgentTranscriptViewer.tsx +++ b/src/components/AgentTranscriptViewer.tsx @@ -30,6 +30,7 @@ const REMARK_PLUGINS = [remarkGfm]; interface AgentTranscriptViewerProps { outputFile: string; agentDescription: string; + expandEditToolCallsByDefault: boolean; onClose: () => void; } @@ -70,7 +71,12 @@ type DisplayItem = * using the same ToolCall component as the main chat — full BashContent, * ReadContent, EditContent, etc. */ -export function AgentTranscriptViewer({ outputFile, agentDescription, onClose }: AgentTranscriptViewerProps) { +export function AgentTranscriptViewer({ + outputFile, + agentDescription, + expandEditToolCallsByDefault, + onClose, +}: AgentTranscriptViewerProps) { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -100,7 +106,7 @@ export function AgentTranscriptViewer({ outputFile, agentDescription, onClose }: return ( { if (!open) onClose(); }}> - + @@ -131,7 +137,11 @@ export function AgentTranscriptViewer({ outputFile, agentDescription, onClose }: )} {items.map((item, i) => ( - + ))}
@@ -142,7 +152,13 @@ export function AgentTranscriptViewer({ outputFile, agentDescription, onClose }: // ── Render each display item ── -function TranscriptItem({ item }: { item: DisplayItem }) { +function TranscriptItem({ + item, + expandEditToolCallsByDefault, +}: { + item: DisplayItem; + expandEditToolCallsByDefault: boolean; +}) { switch (item.kind) { case "text": return ; @@ -151,7 +167,12 @@ function TranscriptItem({ item }: { item: DisplayItem }) { case "tool": return (
- +
); } diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index bc7f7cb..59103ce 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -71,9 +71,9 @@ export function AppLayout() { agents, selectedAgent, saveAgent, deleteAgent, handleAgentChange, lockedEngine, lockedAgentId, activeProjectId, activeProjectPath, activeSpaceProject, activeSpaceTerminalCwd, showThinking, - hasProjects, hasRightPanel, hasToolsColumn, hasBottomTools, + hasProjects, isSpaceSwitching, showToolPicker, hasRightPanel, hasToolsColumn, hasBottomTools, activeTodos, bgAgents, hasTodos, hasAgents, availableContextual, - glassSupported, devFillEnabled, jiraBoardEnabled, + glassSupported, macLiquidGlassSupported, liveMacBackgroundEffect, devFillEnabled, jiraBoardEnabled, showSettings, setShowSettings, spaceCreatorOpen, setSpaceCreatorOpen, editingSpace, scrollToMessageId, setScrollToMessageId, @@ -93,11 +93,13 @@ export function AppLayout() { const glassOverlayStyle = useSpaceTheme( spaceManager.activeSpace, resolvedTheme, - glassSupported && settings.transparency, + glassSupported && (isMac ? liveMacBackgroundEffect !== "off" : settings.transparency), + liveMacBackgroundEffect, ); - const isGlassActive = glassSupported && settings.transparency; + const isGlassActive = glassSupported + && (isMac ? liveMacBackgroundEffect !== "off" : settings.transparency); const isLightGlass = isGlassActive && resolvedTheme !== "dark"; - const isNativeGlass = isGlassActive && isMac; + const isNativeGlass = isGlassActive && isMac && liveMacBackgroundEffect === "liquid-glass"; // ── Window focus tracking (subtle veil on macOS liquid glass when unfocused) ── const [windowFocused, setWindowFocused] = useState(true); @@ -547,6 +549,35 @@ Link: ${issue.url}`; ? `linear-gradient(to bottom, color-mix(in oklab, ${chatSurfaceColor} 100%, black 4.5%) 0%, color-mix(in oklab, ${chatSurfaceColor} 97.5%, black 1.75%) 18%, color-mix(in oklab, ${chatSurfaceColor} 93.5%, transparent) 48%, transparent 100%), radial-gradient(138% 88% at 50% 0%, color-mix(in srgb, black ${topFadeShadowOpacity}%, transparent) 0%, transparent 70%)` : `linear-gradient(to bottom, ${chatSurfaceColor} 0%, ${chatSurfaceColor} 34%, color-mix(in oklab, ${chatSurfaceColor} 90.5%, transparent) 60%, transparent 100%), radial-gradient(142% 92% at 50% 0%, color-mix(in srgb, black ${topFadeShadowOpacity}%, transparent) 0%, transparent 72%)`; const bottomFadeBackground = `linear-gradient(to top, ${chatSurfaceColor}, transparent)`; + const activeSessionProject = manager.activeSession + ? projectManager.projects.find((project) => project.id === manager.activeSession?.projectId) ?? null + : null; + const activeSessionSpaceId = activeSessionProject?.spaceId || "default"; + const isCrossSpaceSessionVisible = !!manager.activeSession && activeSessionSpaceId !== spaceManager.activeSpaceId; + const previousRenderedSpaceIdRef = useRef(spaceManager.activeSpaceId); + const [spaceSwitchLayoutCooldown, setSpaceSwitchLayoutCooldown] = useState(false); + const hasSpaceChangedThisRender = previousRenderedSpaceIdRef.current !== spaceManager.activeSpaceId; + + useLayoutEffect(() => { + if (!hasSpaceChangedThisRender) return; + previousRenderedSpaceIdRef.current = spaceManager.activeSpaceId; + setSpaceSwitchLayoutCooldown(true); + }, [hasSpaceChangedThisRender, spaceManager.activeSpaceId]); + + useEffect(() => { + if (!spaceSwitchLayoutCooldown || isSpaceSwitching || isCrossSpaceSessionVisible) { + return; + } + + // Use a 150ms timeout instead of 2 rAF frames to ensure the DOM has fully + // settled (panels mounted/unmounted, flex layout recalculated) before + // re-enabling Framer Motion layout animations. + const timer = setTimeout(() => { + setSpaceSwitchLayoutCooldown(false); + }, 150); + + return () => clearTimeout(timer); + }, [isCrossSpaceSessionVisible, isSpaceSwitching, spaceSwitchLayoutCooldown]); const getPreviewPaneMetrics = useCallback((previewIndex: number) => { const widthPercent = (previewWidthFractions[previewIndex] ?? (1 / previewPaneCount)) * 100; @@ -554,7 +585,12 @@ Link: ${issue.url}`; const handleSharePx = totalHandleWidth / previewPaneCount; return { widthPercent, handleSharePx }; }, [previewPaneCount, previewWidthFractions]); - const shouldAnimateSplitLayout = !paneResize.isResizing; + const shouldAnimateTopRowLayout = !paneResize.isResizing + && !isResizing + && !isSpaceSwitching + && !isCrossSpaceSessionVisible + && !hasSpaceChangedThisRender + && !spaceSwitchLayoutCooldown; const showSinglePaneSplitPreview = !isSplitActive && splitDragDrop.dragState.isDragging && !!manager.activeSessionId; const singlePanePreviewPosition = splitDragDrop.dragState.dropPosition === 0 ? 0 : 1; const singlePanePreviewPaneStyle = useMemo(() => { @@ -585,8 +621,8 @@ Link: ${issue.url}`; return ( { paneRefs.current[displayIndex] = element; }} @@ -643,6 +679,9 @@ Link: ${issue.url}`; autoGroupTools={settings.autoGroupTools} avoidGroupingEdits={settings.avoidGroupingEdits} autoExpandTools={settings.autoExpandTools} + expandEditToolCallsByDefault={settings.expandEditToolCallsByDefault} + showToolIcons={settings.showToolIcons} + coloredToolIcons={settings.coloredToolIcons} extraBottomPadding={!!paneState.pendingPermission} sessionId={sessionId} onRevert={isActiveSessionPane && manager.isConnected && manager.revertFiles ? handleRevert : undefined} @@ -771,7 +810,7 @@ Link: ${issue.url}`; ); - }, [activeProjectPath, activeSpaceProject?.path, activeSpaceTerminalCwd, activeSpaceTerminals.activeTabId, activeSpaceTerminals.tabs, agents, availableContextual, bottomFadeBackground, chatFadeStrength, devFillEnabled, getPreviewPaneMetrics, grabbedElements, handleAgentChange, handleAgentWorktreeChange, handleClaudeModelEffortChange, handleCloseSplitPane, handleComposerClear, handleElementGrab, handleFullRevert, handleModelChange, handlePermissionModeChange, handlePlanModeChange, handleRemoveGrabbedElement, handleRevert, handleSeedDevExampleSpaceData, handleStop, isIsland, lockedAgentId, lockedEngine, manager, paneScrollCallbacks, resolvedTheme, selectedAgent, settings, showThinking, shouldAnimateSplitLayout, sidebar.isOpen, sidebar.toggle, spaceManager.activeSpaceId, spaceTerminals, splitView, titlebarSurfaceColor, topFadeBackground]); + }, [activeProjectPath, activeSpaceProject?.path, activeSpaceTerminalCwd, activeSpaceTerminals.activeTabId, activeSpaceTerminals.tabs, agents, availableContextual, bottomFadeBackground, chatFadeStrength, devFillEnabled, getPreviewPaneMetrics, grabbedElements, handleAgentChange, handleAgentWorktreeChange, handleClaudeModelEffortChange, handleCloseSplitPane, handleComposerClear, handleElementGrab, handleFullRevert, handleModelChange, handlePermissionModeChange, handlePlanModeChange, handleRemoveGrabbedElement, handleRevert, handleSeedDevExampleSpaceData, handleStop, isIsland, isSpaceSwitching, lockedAgentId, lockedEngine, manager, paneScrollCallbacks, resolvedTheme, selectedAgent, settings, showThinking, shouldAnimateTopRowLayout, sidebar.isOpen, sidebar.toggle, spaceManager.activeSpaceId, spaceTerminals, splitView, titlebarSurfaceColor, topFadeBackground]); const { activeTools } = settings; const showCodexAuthDialog = @@ -862,19 +901,28 @@ Link: ${issue.url}`; onIslandLayoutChange={settings.setIslandLayout} islandShine={settings.islandShine} onIslandShineChange={settings.setIslandShine} + macBackgroundEffect={settings.macBackgroundEffect} + onMacBackgroundEffectChange={settings.setMacBackgroundEffect} autoGroupTools={settings.autoGroupTools} onAutoGroupToolsChange={settings.setAutoGroupTools} avoidGroupingEdits={settings.avoidGroupingEdits} onAvoidGroupingEditsChange={settings.setAvoidGroupingEdits} autoExpandTools={settings.autoExpandTools} onAutoExpandToolsChange={settings.setAutoExpandTools} + expandEditToolCallsByDefault={settings.expandEditToolCallsByDefault} + onExpandEditToolCallsByDefaultChange={settings.setExpandEditToolCallsByDefault} transparentToolPicker={settings.transparentToolPicker} onTransparentToolPickerChange={settings.setTransparentToolPicker} coloredSidebarIcons={settings.coloredSidebarIcons} onColoredSidebarIconsChange={settings.setColoredSidebarIcons} + showToolIcons={settings.showToolIcons} + onShowToolIconsChange={settings.setShowToolIcons} + coloredToolIcons={settings.coloredToolIcons} + onColoredToolIconsChange={settings.setColoredToolIcons} transparency={settings.transparency} onTransparencyChange={settings.setTransparency} glassSupported={glassSupported} + macLiquidGlassSupported={macLiquidGlassSupported} sidebarOpen={sidebar.isOpen} onToggleSidebar={sidebar.toggle} onReplayWelcome={handleReplayWelcome} @@ -1007,8 +1055,8 @@ Link: ${issue.url}`; )} { @@ -1088,6 +1136,9 @@ Link: ${issue.url}`; autoGroupTools={settings.autoGroupTools} avoidGroupingEdits={settings.avoidGroupingEdits} autoExpandTools={settings.autoExpandTools} + expandEditToolCallsByDefault={settings.expandEditToolCallsByDefault} + showToolIcons={settings.showToolIcons} + coloredToolIcons={settings.coloredToolIcons} extraBottomPadding={!!manager.pendingPermission} scrollToMessageId={scrollToMessageId} onScrolledToMessage={handleScrolledToMessage} @@ -1175,10 +1226,23 @@ Link: ${issue.url}`; )}
- + {isSpaceSwitching ? ( +
+
+
+
+
+
+
+
+
+
+ ) : ( + + )} )} @@ -1203,8 +1267,8 @@ Link: ${issue.url}`; {hasRightPanel && ( - +
)} @@ -1355,8 +1424,8 @@ Link: ${issue.url}`; return ( void; onStopAgent: (agentId: string, taskId: string) => void; } @@ -56,7 +56,12 @@ function getToolIcon(toolName: string) { return TOOL_ICONS[toolName] ?? Wrench; } -export function BackgroundAgentsPanel({ agents, onDismiss, onStopAgent }: BackgroundAgentsPanelProps) { +export function BackgroundAgentsPanel({ + agents, + expandEditToolCallsByDefault, + onDismiss, + onStopAgent, +}: BackgroundAgentsPanelProps) { const runningCount = agents.filter((a) => a.status === "running" || a.status === "stopping").length; return ( @@ -64,23 +69,23 @@ export function BackgroundAgentsPanel({ agents, onDismiss, onStopAgent }: Backgr {runningCount > 0 && ( - - + + {runningCount} )} -
+
{agents.map((agent) => ( @@ -95,10 +100,12 @@ export function BackgroundAgentsPanel({ agents, onDismiss, onStopAgent }: Backgr function AgentCard({ agent, + expandEditToolCallsByDefault, onDismiss, onStopAgent, }: { agent: BackgroundAgent; + expandEditToolCallsByDefault: boolean; onDismiss: (agentId: string) => void; onStopAgent: (agentId: string, taskId: string) => void; }) { @@ -118,101 +125,87 @@ function AgentCard({ [agent.agentId, agent.taskId, onStopAgent], ); - // Status accent colors - const statusAccent = isStopping - ? "border-s-amber-400/50" - : isRunning - ? "border-s-blue-400/40" - : isCompleted - ? "border-s-emerald-500/40" - : "border-s-red-400/40"; - return ( <> -
+
{/* Header row */} -
+
-
+
- - - {isStopping && Stopping… } + + + {isStopping && Stopping… } {agent.description}
-
+
{isRunning && agent.taskId && ( )} {(isCompleted || isError) && agent.outputFile && ( )} {(isCompleted || isError) && ( )}
- {/* Progress summary — AI-generated description of what the agent is doing */} + {/* Progress summary */} {isActive && agent.progressSummary && ( -
+
{agent.progressSummary}
)} - {/* Current tool — real-time indicator from tool_progress events */} + {/* Current tool inline */} {isRunning && agent.currentTool && ( )} - {/* Collapsed preview — show last activity when collapsed & running */} + {/* Collapsed preview */} {isRunning && !expanded && !agent.currentTool && agent.activity.length > 0 && ( )} {/* Expanded content */} -
- {/* Activity timeline */} +
{agent.activity.length > 0 && ( )} - - {/* Usage metrics bar */} {agent.usage && } - - {/* Result */} {(isCompleted || isError) && agent.result && ( )} @@ -225,6 +218,7 @@ function AgentCard({ setShowTranscript(false)} /> )} @@ -232,19 +226,19 @@ function AgentCard({ ); } -// ── Status icon ── - -function StatusIcon({ status }: { status: BackgroundAgent["status"] }) { - switch (status) { - case "stopping": - return ; - case "running": - return ; - case "completed": - return ; - default: - return ; - } +// ── Status dot (minimal) ── + +function StatusDot({ status }: { status: BackgroundAgent["status"] }) { + const colorClass = + status === "stopping" + ? "bg-amber-400/70" + : status === "running" + ? "bg-blue-400/60 animate-pulse" + : status === "completed" + ? "bg-emerald-500/60" + : "bg-red-400/60"; + + return ; } // ── Current tool badge ── @@ -252,9 +246,9 @@ function StatusIcon({ status }: { status: BackgroundAgent["status"] }) { function CurrentToolBadge({ name, elapsed }: { name: string; elapsed: number }) { const Icon = getToolIcon(name); return ( -
- - {name} +
+ + {name} {Math.round(elapsed)}s
); @@ -264,8 +258,8 @@ function CurrentToolBadge({ name, elapsed }: { name: string; elapsed: number }) function CollapsedPreview({ activity }: { activity: BackgroundAgentActivity }) { return ( -
- {activity.toolName && {activity.toolName} } +
+ {activity.toolName && {activity.toolName} } {activity.summary}
); @@ -276,7 +270,6 @@ function CollapsedPreview({ activity }: { activity: BackgroundAgentActivity }) { function ActivityTimeline({ activities, isRunning }: { activities: BackgroundAgentActivity[]; isRunning: boolean }) { const endRef = useRef(null); - // Auto-scroll to bottom when new activities arrive on running agents useEffect(() => { if (isRunning && endRef.current) { endRef.current.scrollIntoView({ behavior: "smooth", block: "end" }); @@ -284,7 +277,7 @@ function ActivityTimeline({ activities, isRunning }: { activities: BackgroundAge }, [activities.length, isRunning]); return ( -
+
{activities.map((activity, i) => ( ))} @@ -293,7 +286,7 @@ function ActivityTimeline({ activities, isRunning }: { activities: BackgroundAge ); } -// ── Activity item (expandable for tool calls) ── +// ── Activity item ── function ActivityItem({ activity, isLast }: { activity: BackgroundAgentActivity; isLast: boolean }) { const [expanded, setExpanded] = useState(false); @@ -303,18 +296,14 @@ function ActivityItem({ activity, isLast }: { activity: BackgroundAgentActivity; const hasSummary = activity.summary && activity.summary !== activity.toolName; return ( -
+
{expanded && hasSummary && ( -
+
{activity.summary}
)} @@ -337,22 +326,21 @@ function ActivityItem({ activity, isLast }: { activity: BackgroundAgentActivity; if (activity.type === "error") { return ( -
- - {activity.summary} +
+ + {activity.summary}
); } - // text type return ( -
+
{activity.summary}
); } -// ── Usage bar (compact metrics) ── +// ── Usage bar ── function UsageBar({ usage }: { usage: BackgroundAgentUsage }) { const tokens = @@ -365,17 +353,17 @@ function UsageBar({ usage }: { usage: BackgroundAgentUsage }) { : `${Math.round(usage.durationMs / 1000)}s`; return ( -
- - +
+ + {tokens} - - + + {usage.toolUses} - - + + {duration}
@@ -389,29 +377,29 @@ function AgentResult({ result, isError }: { result: string; isError?: boolean }) const isLong = result.length > 200; return ( -
{result}
{isLong && ( )}
diff --git a/src/components/BrowserPanel.tsx b/src/components/BrowserPanel.tsx index abd751d..216b458 100644 --- a/src/components/BrowserPanel.tsx +++ b/src/components/BrowserPanel.tsx @@ -1,13 +1,14 @@ import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type FormEvent } from "react"; import type { GrabbedElement } from "@/types/ui"; import { getInspectorScript, getCleanupScript, GRAB_MARKER } from "@/lib/element-inspector"; -import { capture } from "@/lib/analytics"; +import { capture, reportError } from "@/lib/analytics"; // Electron webview element with navigation methods interface ElectronWebviewElement extends HTMLElement { src: string; getURL(): string; getTitle(): string; + getWebContentsId(): number; loadURL(url: string): Promise; goBack(): void; goForward(): void; @@ -16,6 +17,9 @@ interface ElectronWebviewElement extends HTMLElement { canGoBack(): boolean; canGoForward(): boolean; executeJavaScript(code: string): Promise; + openDevTools(options?: DevToolsOpenOptions): void; + closeDevTools(): void; + isDevToolsOpened(): boolean; } import { @@ -27,13 +31,12 @@ import { Lock, Loader2, Crosshair, - Eye, - Sparkles, Search, ArrowUpRight, + Bug, + Sun, + Moon, } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { TabBar } from "@/components/TabBar"; interface BrowserTab { @@ -42,6 +45,7 @@ interface BrowserTab { title: string; label: string; isLoading: boolean; + colorScheme: BrowserColorScheme; isStartPage?: boolean; } @@ -50,6 +54,13 @@ interface BrowserHistoryEntry { title: string; } +interface DevToolsOpenOptions { + mode?: "detach"; + activate?: boolean; +} + +type BrowserColorScheme = "light" | "dark"; + interface BrowserPanelProps { onElementGrab?: (element: GrabbedElement) => void; } @@ -57,6 +68,10 @@ interface BrowserPanelProps { const BROWSER_HISTORY_KEY = "harnss-browser-history"; const MAX_BROWSER_HISTORY = 100; +function getDefaultBrowserColorScheme(): BrowserColorScheme { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + function normalizeHistoryUrl(raw: string): string | null { const url = raw.trim(); if (!url) return null; @@ -90,6 +105,19 @@ function resolveNavigationInput(input: string): string | null { return url; } +function reorderTabsById(tabs: BrowserTab[], fromTabId: string, toTabId: string): BrowserTab[] { + const fromIndex = tabs.findIndex((tab) => tab.id === fromTabId); + const toIndex = tabs.findIndex((tab) => tab.id === toTabId); + if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) { + return tabs; + } + + const nextTabs = [...tabs]; + const [movedTab] = nextTabs.splice(fromIndex, 1); + nextTabs.splice(toIndex, 0, movedTab); + return nextTabs; +} + const BrowserHeaderIcon = forwardRef>( ({ className, ...rest }, ref) => ( @@ -156,6 +184,7 @@ export function BrowserPanel({ onElementGrab }: BrowserPanelProps) { title: "New Tab", label: "New Tab", isLoading: !isStartPage, + colorScheme: getDefaultBrowserColorScheme(), isStartPage, }; setTabs((prev) => [...prev, tab]); @@ -223,6 +252,10 @@ export function BrowserPanel({ onElementGrab }: BrowserPanelProps) { })); }, []); + const reorderTabs = useCallback((fromTabId: string, toTabId: string) => { + setTabs((prev) => reorderTabsById(prev, fromTabId, toTabId)); + }, []); + return (
{/* Tab bar */} @@ -244,6 +277,7 @@ export function BrowserPanel({ onElementGrab }: BrowserPanelProps) { tabMaxWidth="max-w-24" activeClass="bg-foreground/[0.08] text-foreground/80" inactiveClass="text-foreground/35 hover:text-foreground/55 hover:bg-foreground/[0.04]" + onReorderTabs={reorderTabs} /> {/* Webview content */} @@ -324,121 +358,94 @@ function BrowserStartPage({ onOpen: (value: string) => void; recentHistory: BrowserHistoryEntry[]; }) { - const [isFocused, setIsFocused] = useState(false); - return ( -
- {/* Atmospheric ambient glow — breathes on focus */} -
- +
+
{ e.preventDefault(); + setShowSuggestions(false); + const form = e.currentTarget; + const active = document.activeElement; + if (active instanceof HTMLElement && form.contains(active)) { + active.blur(); + } onOpen(input); }} - className="relative z-10 w-full max-w-md" + className="w-full max-w-sm" > -
- {/* Hero — icon + title */} -
-
- -
-
-

- Browse the web -

-

- Preview, inspect, and grab elements into your conversation -

-
-
- - {/* Search bar — hero element with glow border */} -
- {/* Gradient glow ring — visible on focus */} -
- -
- - -
- setInput(e.target.value)} - onFocus={() => { - setIsFocused(true); + {/* Search bar */} +
+
+ + +
+ { + setInput(e.target.value); + setShowSuggestions(true); + }} + onMouseDown={(e) => { + if (document.activeElement === e.currentTarget) { setShowSuggestions(true); - }} - onBlur={() => { - setIsFocused(false); - window.setTimeout(() => setShowSuggestions(false), 120); - }} - onKeyDown={(e) => { - if (e.key === "Escape") { - setShowSuggestions(false); - (e.target as HTMLInputElement).blur(); - return; - } - if (e.key === "Tab" && completion) { - e.preventDefault(); - setInput(completion); - } - }} - className="w-full bg-transparent text-sm text-foreground/80 outline-none placeholder:text-foreground/20" - placeholder="Search or enter URL…" - spellCheck={false} - autoFocus - /> - {/* Ghost text completion overlay */} - {completion && input.trim() && ( -
- {input} - {completion.slice(input.length)} -
- )} -
- - {/* Tab hint */} + } + }} + onBlur={() => { + window.setTimeout(() => setShowSuggestions(false), 120); + }} + onKeyDown={(e) => { + if (e.key === "Escape") { + setShowSuggestions(false); + (e.target as HTMLInputElement).blur(); + return; + } + if (e.key === "Tab" && completion) { + e.preventDefault(); + setInput(completion); + } + }} + className="w-full bg-transparent text-[12px] text-foreground/80 outline-none placeholder:text-foreground/25" + placeholder="Search or enter URL…" + spellCheck={false} + autoFocus + /> {completion && input.trim() && ( - - Tab - - )} - - {/* Go button — appears when input has content */} - {input.trim() && ( - +
+ {input} + {completion.slice(input.length)} +
)}
- {/* URL suggestions dropdown */} - {showSuggestions && filteredHistory.length > 0 && ( -
- {filteredHistory.map((entry) => ( + {completion && input.trim() && ( + + Tab + + )} + + {input.trim() && ( + + )} +
+ + {/* Suggestions dropdown */} + {showSuggestions && filteredHistory.length > 0 && ( +
+ {filteredHistory.map((entry) => { + let hostname = entry.url; + try { hostname = new URL(entry.url).hostname.replace(/^www\./, ""); } catch { /* keep */ } + return ( - ))} -
- )} -
- - {/* Feature indicators — minimal horizontal list, wraps at narrow widths */} -
- - - Preview - - - - - Inspect - - - - - Use in chat - -
- - {/* Recent sites — letter avatars with hover arrows */} - {recentHistory.length > 0 && ( -
-
- - Recent - -
-
-
- {recentHistory.map((entry) => { - let hostname = entry.url; - let letter = "?"; - try { - hostname = new URL(entry.url).hostname.replace(/^www\./, ""); - letter = hostname.charAt(0).toUpperCase(); - } catch { - /* keep defaults */ - } - return ( - - ); - })} -
+ ); + })}
)}
+ + {/* Recent history */} + {recentHistory.length > 0 && ( +
+
+ Recent +
+
+ {recentHistory.map((entry) => { + let hostname = entry.url; + try { hostname = new URL(entry.url).hostname.replace(/^www\./, ""); } catch { /* keep */ } + return ( + + ); + })} +
+
+ )} +
); } @@ -552,6 +522,7 @@ function WebviewInstance({ const [canGoForward, setCanGoForward] = useState(false); const [isSecure, setIsSecure] = useState(false); const [isDomReady, setIsDomReady] = useState(false); + const [isDevToolsOpen, setIsDevToolsOpen] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false); const withWebview = useCallback( @@ -568,6 +539,18 @@ function WebviewInstance({ [isDomReady], ); + const applyColorScheme = useCallback(async () => { + if (!isDomReady) return; + + const wv = webviewRef.current; + if (!wv) return; + + const result = await window.claude.setBrowserColorScheme(wv.getWebContentsId(), tab.colorScheme); + if (!result.ok) { + throw new Error(result.error ?? "Failed to apply browser color scheme"); + } + }, [isDomReady, tab.colorScheme]); + // Sync URL input when tab url changes externally useEffect(() => { setUrlInput(tab.url); @@ -618,6 +601,13 @@ function WebviewInstance({ }; const onDomReady = () => { setIsDomReady(true); + setIsDevToolsOpen(wv.isDevToolsOpened()); + }; + const onDevToolsOpened = () => { + setIsDevToolsOpen(true); + }; + const onDevToolsClosed = () => { + setIsDevToolsOpen(false); }; // Listen for element grab messages from the injected inspector script @@ -653,6 +643,8 @@ function WebviewInstance({ wv.addEventListener("page-title-updated", onPageTitleUpdated); wv.addEventListener("console-message", onConsoleMessage); wv.addEventListener("dom-ready", onDomReady); + wv.addEventListener("devtools-opened", onDevToolsOpened); + wv.addEventListener("devtools-closed", onDevToolsClosed); return () => { wv.removeEventListener("did-navigate", onDidNavigate); @@ -662,6 +654,8 @@ function WebviewInstance({ wv.removeEventListener("page-title-updated", onPageTitleUpdated); wv.removeEventListener("console-message", onConsoleMessage); wv.removeEventListener("dom-ready", onDomReady); + wv.removeEventListener("devtools-opened", onDevToolsOpened); + wv.removeEventListener("devtools-closed", onDevToolsClosed); }; }, [onUpdateTab, onVisitUrl]); // eslint-disable-line react-hooks/exhaustive-deps @@ -682,6 +676,12 @@ function WebviewInstance({ } }, [inspectMode, withWebview]); + useEffect(() => { + applyColorScheme().catch((err) => { + reportError("BROWSER_COLOR_SCHEME", err, { colorScheme: tab.colorScheme }); + }); + }, [applyColorScheme, tab.colorScheme]); + const handleGoBack = useCallback(() => { withWebview((wv) => wv.goBack()); }, [withWebview]); @@ -700,6 +700,23 @@ function WebviewInstance({ }); }, [tab.isLoading, withWebview]); + const handleToggleColorScheme = useCallback(() => { + onUpdateTab({ colorScheme: tab.colorScheme === "dark" ? "light" : "dark" }); + }, [onUpdateTab, tab.colorScheme]); + + const handleToggleDevTools = useCallback(() => { + withWebview((wv) => { + if (wv.isDevToolsOpened()) { + wv.closeDevTools(); + setIsDevToolsOpen(false); + return; + } + + wv.openDevTools({ mode: "detach", activate: true }); + setIsDevToolsOpen(true); + }); + }, [withWebview]); + const canNavigateControls = isDomReady; const filteredHistory = useMemo(() => { const query = urlInput.trim().toLowerCase(); @@ -724,14 +741,28 @@ function WebviewInstance({ const url = resolveNavigationInput(input); if (!url) return; + const currentUrl = webviewRef.current?.getURL() || tab.url; + if (currentUrl && url === currentUrl) { + setUrlInput(url); + onUpdateTab({ isLoading: true }); + withWebview((wv) => wv.reload(), { requireDomReady: false }); + return; + } + setUrlInput(url); onNavigate(url); }, - [onNavigate], + [onNavigate, onUpdateTab, tab.url, withWebview], ); const handleSubmit = (e: FormEvent) => { e.preventDefault(); + setShowSuggestions(false); + const form = e.currentTarget; + const active = document.activeElement; + if (active instanceof HTMLElement && form.contains(active)) { + active.blur(); + } navigateTo(urlInput); }; @@ -752,57 +783,89 @@ function WebviewInstance({
{/* Navigation bar */}
- +
+ +
+ +
+ + {/* Inspect button */} + - + + - + + - - - - - - {inspectMode ? "Cancel inspect" : "Grab element"} - - + {/* URL bar */}
diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index a7b0aea..6d2fa59 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -12,6 +12,7 @@ import { TurnChangesSummary } from "./TurnChangesSummary"; import { extractTurnSummaries } from "@/lib/turn-changes"; import type { TurnSummary } from "@/lib/turn-changes"; import { computeToolGroups, type ToolGroup, type ToolGroupInfo } from "@/lib/tool-groups"; +import { computeAssistantTurnDividerLabels } from "@/lib/assistant-turn-divider"; import { TextShimmer } from "@/components/ui/text-shimmer"; import { ChatUiStateProvider } from "@/components/chat-ui-state"; import { @@ -23,6 +24,7 @@ import { } from "@/lib/chat-scroll"; import { CHAT_CONTENT_RESIZED_EVENT } from "@/lib/events"; import { estimateRowHeight } from "@/lib/chat-virtualization"; +import { CHAT_ROW_CLASS } from "@/components/lib/chat-layout"; // ── Row model ── @@ -131,7 +133,11 @@ interface ChatMessageRowProps { row: RowDescriptor; showThinking: boolean; autoExpandTools: boolean; + expandEditToolCallsByDefault: boolean; + showToolIcons: boolean; + coloredToolIcons: boolean; animatingGroupKeys: Set; + assistantTurnDividerLabels: Map; continuationIds: Set; sendNextId?: string | null; onRevert?: (checkpointId: string) => void; @@ -144,7 +150,11 @@ const ChatMessageRow = memo(function ChatMessageRow({ row, showThinking, autoExpandTools, + expandEditToolCallsByDefault, + showToolIcons, + coloredToolIcons, animatingGroupKeys, + assistantTurnDividerLabels, continuationIds, sendNextId, onRevert, @@ -154,7 +164,7 @@ const ChatMessageRow = memo(function ChatMessageRow({ }: ChatMessageRowProps) { if (row.kind === "processing") { return ( -
+
@@ -179,6 +189,9 @@ const ChatMessageRow = memo(function ChatMessageRow({ messages={row.group.messages} showThinking={showThinking} autoExpandTools={autoExpandTools} + expandEditToolCallsByDefault={expandEditToolCallsByDefault} + showToolIcons={showToolIcons} + coloredToolIcons={coloredToolIcons} disableCollapseAnimation animate={isNewGroup} /> @@ -204,6 +217,9 @@ const ChatMessageRow = memo(function ChatMessageRow({
@@ -215,6 +231,7 @@ const ChatMessageRow = memo(function ChatMessageRow({ void; @@ -342,7 +366,7 @@ export const ChatView = memo(function ChatView(props: ChatViewProps) { function ChatViewContent({ messages, isProcessing, showThinking, autoGroupTools, avoidGroupingEdits, - autoExpandTools, extraBottomPadding, scrollToMessageId, onScrolledToMessage, + autoExpandTools, expandEditToolCallsByDefault, showToolIcons, coloredToolIcons, extraBottomPadding, scrollToMessageId, onScrolledToMessage, sessionId, onRevert, onFullRevert, onTopScrollProgress, onSendQueuedNow, onUnqueueQueuedMessage, sendNextId, }: ChatViewProps) { @@ -432,6 +456,7 @@ function ChatViewContent({ const prevMsgStructureRef = useRef<{ length: number; lastId: string | undefined; lastToolResultCount: number }>({ length: 0, lastId: undefined, lastToolResultCount: 0 }); const cachedTurnSummaryRef = useRef>(new Map()); const cachedToolGroupsRef = useRef(EMPTY_TOOL_GROUP_INFO); + const cachedAssistantTurnDividersRef = useRef>(new Map()); const prevIsProcessingRef = useRef(isProcessing); const prevAutoGroupRef = useRef(autoGroupTools); const prevAvoidEditRef = useRef(avoidGroupingEdits); @@ -470,6 +495,15 @@ function ChatViewContent({ return map; }, [nonQueuedMessages, isProcessing, structureChanged, msgStructure]); + const assistantTurnDividerLabels = useMemo(() => { + if (!structureChanged && cachedAssistantTurnDividersRef.current.size >= 0) { + return cachedAssistantTurnDividersRef.current; + } + const map = computeAssistantTurnDividerLabels(nonQueuedMessages, isProcessing); + cachedAssistantTurnDividersRef.current = map; + return map; + }, [nonQueuedMessages, isProcessing, structureChanged]); + // ── Tool groups (js-index-maps, js-set-map-lookups) ── const { groups: toolGroups, groupedIndices } = useMemo(() => { if (!autoGroupTools) return EMPTY_TOOL_GROUP_INFO; @@ -851,7 +885,11 @@ function ChatViewContent({ row={row} showThinking={showThinking} autoExpandTools={autoExpandTools} + expandEditToolCallsByDefault={expandEditToolCallsByDefault} + showToolIcons={showToolIcons} + coloredToolIcons={coloredToolIcons} animatingGroupKeys={animatingGroupKeys} + assistantTurnDividerLabels={assistantTurnDividerLabels} continuationIds={continuationIds} sendNextId={sendNextId} onRevert={onRevert} diff --git a/src/components/DiffViewer.tsx b/src/components/DiffViewer.tsx index a80c6b2..06172ca 100644 --- a/src/components/DiffViewer.tsx +++ b/src/components/DiffViewer.tsx @@ -4,7 +4,7 @@ import { OpenInEditorButton } from "./OpenInEditorButton"; import { useResolvedThemeClass } from "@/hooks/useResolvedThemeClass"; import { copyToClipboard } from "@/lib/clipboard"; import { useChatIsScrolling } from "@/components/chat-ui-state"; -import { getMonacoLanguageFromPath } from "@/lib/monaco"; +import { getMonacoLanguageFromPath, disableMonacoDiagnostics } from "@/lib/monaco"; import { parseUnifiedDiffFromUnknown } from "@/lib/unified-diff"; const MonacoDiffEditor = lazy(() => @@ -70,7 +70,7 @@ const MONACO_DIFF_OPTIONS = { horizontalScrollbarSize: 8, alwaysConsumeMouseWheel: false, }, - padding: { top: 8, bottom: 8 }, + padding: { top: 0, bottom: 0 }, } satisfies Record; const fullFileContentCache = new Map(); @@ -382,7 +382,7 @@ export const DiffViewer = memo(function DiffViewer({ const editorSubscriptionsRef = useRef([]); const instanceIdRef = useRef(createDiffViewerInstanceId()); - const fileName = filePath.split("/").pop() ?? filePath; + const fileName = filePath; const monacoLanguage = getMonacoLanguageFromPath(filePath); const diffHeightCacheKey = useMemo( () => buildDiffHeightCacheKey(filePath, oldString, newString, unifiedDiff), @@ -588,9 +588,9 @@ export const DiffViewer = memo(function DiffViewer({ return (
-
+
{fileName} @@ -647,6 +647,7 @@ export const DiffViewer = memo(function DiffViewer({ keepCurrentModifiedModel theme={resolvedTheme === "dark" ? "vs-dark" : "light"} options={MONACO_DIFF_OPTIONS} + beforeMount={disableMonacoDiagnostics} onMount={handleEditorMount} loading={ @@ -249,6 +249,7 @@ const OverlayContent = memo(function OverlayContent({ language={monacoLang} value={content} theme={resolvedTheme === "dark" ? "vs-dark" : "light"} + beforeMount={disableMonacoDiagnostics} options={{ readOnly: true, minimap: { enabled: true }, diff --git a/src/components/FilesPanel.tsx b/src/components/FilesPanel.tsx index c29b661..7c27828 100644 --- a/src/components/FilesPanel.tsx +++ b/src/components/FilesPanel.tsx @@ -1,6 +1,5 @@ import { memo, startTransition, useMemo, useCallback, useEffect, useState } from "react"; import { FileText, Loader2 } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { PanelHeader } from "@/components/PanelHeader"; @@ -114,33 +113,24 @@ export const FilesPanel = memo(function FilesPanel({ return (
- + {files.length > 0 && ( - - {files.length} - + {files.length} )} - {/* Gradient separator below header */} -
-
-
- {enabled && !data ? ( -
- -

- Indexing files... +

+ +

+ Indexing…

) : files.length === 0 ? ( -
-
- -
-

- Files accessed during this session
will appear here +

+ +

+ Accessed files will appear here

) : ( @@ -156,12 +146,10 @@ export const FilesPanel = memo(function FilesPanel({ return (
handleClick(file.path)} > -
- -
+
diff --git a/src/components/InputBar.tsx b/src/components/InputBar.tsx index 4f36479..b2ab2b5 100644 --- a/src/components/InputBar.tsx +++ b/src/components/InputBar.tsx @@ -258,7 +258,7 @@ const CLAUDE_EFFORT_DESCRIPTIONS: Record = { /** Shared className overrides for ghost toolbar buttons in the input bar. * Applied on top of ` +
@@ -1806,7 +1815,7 @@ export const InputBar = memo(function InputBar({ size="icon" variant="ghost" onClick={onStop} - className="h-7 w-7 rounded-full text-muted-foreground hover:text-destructive" + className="h-7 w-7 rounded-full text-muted-foreground transition-colors duration-150 hover:bg-destructive/10 hover:text-destructive" > @@ -1816,7 +1825,7 @@ export const InputBar = memo(function InputBar({ size="icon" onClick={handleSend} disabled={isAwaitingAcpOptions || ((!hasContent && attachments.length === 0 && (!grabbedElements || grabbedElements.length === 0)) || isSending)} - className="h-8 w-8 rounded-full" + className="h-8 w-8 rounded-full shadow-sm transition-all duration-150 hover:shadow-md active:scale-95 disabled:shadow-none disabled:active:scale-100" > diff --git a/src/components/McpPanel.tsx b/src/components/McpPanel.tsx index bf59b14..72bd21d 100644 --- a/src/components/McpPanel.tsx +++ b/src/components/McpPanel.tsx @@ -480,7 +480,7 @@ export const McpPanel = memo(function McpPanel({ projectId, runtimeStatuses, isP {/* Add Server Dialog */} - + Add MCP Server diff --git a/src/components/MessageBubble.tsx b/src/components/MessageBubble.tsx index 60d1c7b..a5a2b90 100644 --- a/src/components/MessageBubble.tsx +++ b/src/components/MessageBubble.tsx @@ -19,6 +19,12 @@ import { ThinkingBlock } from "./ThinkingBlock"; import { CopyButton } from "./CopyButton"; import { ImageLightbox } from "./ImageLightbox"; import { MermaidDiagram } from "./MermaidDiagram"; +import { + CHAT_CONTENT_STACK_CLASS, + CHAT_PROSE_EDGE_CLASS, + CHAT_ROW_CLASS, + CHAT_ROW_WIDTH_CLASS, +} from "@/components/lib/chat-layout"; // Stable references to avoid re-creating on every render const REMARK_PLUGINS = [remarkGfm]; @@ -157,6 +163,7 @@ function renderWithMentions(text: string): ReactNode[] { interface MessageBubbleProps { message: UIMessage; showThinking?: boolean; + assistantTurnDividerLabel?: string; isContinuation?: boolean; /** True when this queued message is the prioritized "send next" item */ isSendNextQueued?: boolean; @@ -173,6 +180,7 @@ interface MessageBubbleProps { export const MessageBubble = memo(function MessageBubble({ message, showThinking = true, + assistantTurnDividerLabel, isContinuation, isSendNextQueued = false, onRevert, @@ -213,7 +221,7 @@ export const MessageBubble = memo(function MessageBubble({ const checkpointId = message.checkpointId; const canRevert = !!checkpointId && (!!onRevert || !!onFullRevert); return ( -
+
@@ -327,35 +335,51 @@ export const MessageBubble = memo(function MessageBubble({ } return ( -
+
-
- {showThinking && message.thinking && ( -
- +
+
+ {assistantTurnDividerLabel ? ( +
+
+ + {assistantTurnDividerLabel} +
- )} - {message.content ? ( -
- - + {showThinking && message.thinking && ( + + )} + {message.content ? ( +
- {message.content} - - + + + {message.content} + + +
+ ) : null}
- ) : null} +
@@ -373,6 +397,7 @@ export const MessageBubble = memo(function MessageBubble({ prev.message.isError === next.message.isError && prev.message.checkpointId === next.message.checkpointId && prev.message.isQueued === next.message.isQueued && + prev.assistantTurnDividerLabel === next.assistantTurnDividerLabel && prev.isSendNextQueued === next.isSendNextQueued && prev.showThinking === next.showThinking && prev.isContinuation === next.isContinuation && diff --git a/src/components/PanelHeader.tsx b/src/components/PanelHeader.tsx index 9db6289..152b653 100644 --- a/src/components/PanelHeader.tsx +++ b/src/components/PanelHeader.tsx @@ -29,21 +29,19 @@ export function PanelHeader({ label, children, separator = true, - className = "px-3 pt-3 pb-2", + className = "px-3 pt-2.5 pb-1.5", iconClass = "text-muted-foreground", }: PanelHeaderProps) { return ( <> -
-
- {iconNode ?? (Icon && )} -
- {label} - {children &&
{children}
} +
+ {iconNode ?? (Icon && )} + {label} + {children &&
{children}
}
{separator && (
-
+
)} diff --git a/src/components/ProjectFilesPanel.tsx b/src/components/ProjectFilesPanel.tsx index 0fdc883..7c4d1f6 100644 --- a/src/components/ProjectFilesPanel.tsx +++ b/src/components/ProjectFilesPanel.tsx @@ -18,9 +18,7 @@ import { FolderPlus, Type, } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { DropdownMenu, DropdownMenuContent, @@ -193,8 +191,9 @@ export const ProjectFilesPanel = memo(function ProjectFilesPanel({ return (
-
-

No project selected

+
+ +

No project selected

); @@ -204,59 +203,54 @@ export const ProjectFilesPanel = memo(function ProjectFilesPanel({
{totalFiles > 0 && ( - - {totalFiles} - + {totalFiles} )} - - - - - -

Refresh files

-
-
+
{/* Search bar */} -
- +
+ handleSearchChange(e.target.value)} - placeholder="Search files..." - className="h-5 w-full bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground/40" + placeholder="Search files…" + className="h-5 w-full bg-transparent text-[11px] text-foreground/75 outline-none placeholder:text-foreground/25" />
+
+
+
{/* Tree content */} {loading && !tree && ( -
- +
+ +

Loading…

)} {error && ( -
-

{error}

+
+

{error}

)} {flatItems.length === 0 && !loading && !error && tree && ( -
-

- {debouncedQuery ? `No files matching "${debouncedQuery}"` : "No files found"} +

+

+ {debouncedQuery ? `No matches for "${debouncedQuery}"` : "No files found"}

)} @@ -397,10 +391,18 @@ const FileTreeRow = memo(function FileTreeRow({ const [renameName, setRenameName] = useState(node.name); // Track cursor position so the menu opens where the user right-clicked. const [menuPos, setMenuPos] = useState({ x: 0, y: 0 }); + const suppressClickUntilRef = useRef(0); const rowRef = useRef(null); + const suppressRowClick = useCallback((durationMs = 150) => { + suppressClickUntilRef.current = Date.now() + durationMs; + }, []); + const handleClick = useCallback( (e: React.MouseEvent) => { + if (Date.now() < suppressClickUntilRef.current) { + return; + } if (isDir) { onToggleDir(node.path); } else { @@ -575,7 +577,15 @@ const FileTreeRow = memo(function FileTreeRow({ {/* Context menu — anchored to a 0×0 element at the cursor position */} - + { + if (!open) { + suppressRowClick(); + } + setMenuOpen(open); + }} + > diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 9905bb5..0ce355d 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -25,7 +25,7 @@ import { PlaceholderSection } from "@/components/settings/PlaceholderSection"; import { AboutSettings } from "@/components/settings/AboutSettings"; import { AnalyticsSettings } from "@/components/settings/AnalyticsSettings"; import { isMac } from "@/lib/utils"; -import type { InstalledAgent, ThemeOption } from "@/types"; +import type { InstalledAgent, MacBackgroundEffect, ThemeOption } from "@/types"; import type { AppSettings } from "@/types/ui"; // ── Section definitions ── @@ -67,19 +67,28 @@ interface SettingsViewProps { onIslandLayoutChange: (enabled: boolean) => void; islandShine: boolean; onIslandShineChange: (enabled: boolean) => void; + macBackgroundEffect: MacBackgroundEffect; + onMacBackgroundEffectChange: (effect: MacBackgroundEffect) => void; autoGroupTools: boolean; onAutoGroupToolsChange: (enabled: boolean) => void; avoidGroupingEdits: boolean; onAvoidGroupingEditsChange: (enabled: boolean) => void; autoExpandTools: boolean; onAutoExpandToolsChange: (enabled: boolean) => void; + expandEditToolCallsByDefault: boolean; + onExpandEditToolCallsByDefaultChange: (enabled: boolean) => void; transparentToolPicker: boolean; onTransparentToolPickerChange: (enabled: boolean) => void; coloredSidebarIcons: boolean; onColoredSidebarIconsChange: (enabled: boolean) => void; + showToolIcons: boolean; + onShowToolIconsChange: (enabled: boolean) => void; + coloredToolIcons: boolean; + onColoredToolIconsChange: (enabled: boolean) => void; transparency: boolean; onTransparencyChange: (enabled: boolean) => void; glassSupported: boolean; + macLiquidGlassSupported: boolean; sidebarOpen?: boolean; onToggleSidebar?: () => void; /** Resets the welcome wizard so it shows again. Dev-only. */ @@ -99,19 +108,28 @@ export const SettingsView = memo(function SettingsView({ onIslandLayoutChange, islandShine, onIslandShineChange, + macBackgroundEffect, + onMacBackgroundEffectChange, autoGroupTools, onAutoGroupToolsChange, avoidGroupingEdits, onAvoidGroupingEditsChange, autoExpandTools, onAutoExpandToolsChange, + expandEditToolCallsByDefault, + onExpandEditToolCallsByDefaultChange, transparentToolPicker, onTransparentToolPickerChange, coloredSidebarIcons, onColoredSidebarIconsChange, + showToolIcons, + onShowToolIconsChange, + coloredToolIcons, + onColoredToolIconsChange, transparency, onTransparencyChange, glassSupported, + macLiquidGlassSupported, sidebarOpen = false, onToggleSidebar, onReplayWelcome, @@ -161,19 +179,29 @@ export const SettingsView = memo(function SettingsView({ onIslandLayoutChange={onIslandLayoutChange} islandShine={islandShine} onIslandShineChange={onIslandShineChange} + macBackgroundEffect={macBackgroundEffect} + onMacBackgroundEffectChange={onMacBackgroundEffectChange} autoGroupTools={autoGroupTools} onAutoGroupToolsChange={onAutoGroupToolsChange} avoidGroupingEdits={avoidGroupingEdits} onAvoidGroupingEditsChange={onAvoidGroupingEditsChange} autoExpandTools={autoExpandTools} onAutoExpandToolsChange={onAutoExpandToolsChange} + expandEditToolCallsByDefault={expandEditToolCallsByDefault} + onExpandEditToolCallsByDefaultChange={onExpandEditToolCallsByDefaultChange} transparentToolPicker={transparentToolPicker} onTransparentToolPickerChange={onTransparentToolPickerChange} coloredSidebarIcons={coloredSidebarIcons} onColoredSidebarIconsChange={onColoredSidebarIconsChange} + showToolIcons={showToolIcons} + onShowToolIconsChange={onShowToolIconsChange} + coloredToolIcons={coloredToolIcons} + onColoredToolIconsChange={onColoredToolIconsChange} transparency={transparency} onTransparencyChange={onTransparencyChange} glassSupported={glassSupported} + isMac={isMac} + macLiquidGlassSupported={macLiquidGlassSupported} /> ); case "notifications": @@ -241,7 +269,7 @@ export const SettingsView = memo(function SettingsView({ default: return null; } - }, [activeSection, appSettings, updateAppSettings, agents, onSaveAgent, onDeleteAgent, theme, onThemeChange, islandLayout, onIslandLayoutChange, islandShine, onIslandShineChange, autoGroupTools, onAutoGroupToolsChange, avoidGroupingEdits, onAvoidGroupingEditsChange, autoExpandTools, onAutoExpandToolsChange, transparentToolPicker, onTransparentToolPickerChange, coloredSidebarIcons, onColoredSidebarIconsChange, transparency, onTransparencyChange, glassSupported, onReplayWelcome]); + }, [activeSection, appSettings, updateAppSettings, agents, onSaveAgent, onDeleteAgent, theme, onThemeChange, islandLayout, onIslandLayoutChange, islandShine, onIslandShineChange, macBackgroundEffect, onMacBackgroundEffectChange, autoGroupTools, onAutoGroupToolsChange, avoidGroupingEdits, onAvoidGroupingEditsChange, autoExpandTools, onAutoExpandToolsChange, expandEditToolCallsByDefault, onExpandEditToolCallsByDefaultChange, transparentToolPicker, onTransparentToolPickerChange, coloredSidebarIcons, onColoredSidebarIconsChange, showToolIcons, onShowToolIconsChange, coloredToolIcons, onColoredToolIconsChange, transparency, onTransparencyChange, glassSupported, macLiquidGlassSupported, onReplayWelcome]); return (
diff --git a/src/components/SpaceCreator.tsx b/src/components/SpaceCreator.tsx index 59d8502..6a1ba37 100644 --- a/src/components/SpaceCreator.tsx +++ b/src/components/SpaceCreator.tsx @@ -2,7 +2,9 @@ import { useState, useEffect, useMemo } from "react"; import { Dialog, DialogContent, + DialogTitle, } from "@/components/ui/dialog"; +import { VisuallyHidden } from "radix-ui"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -140,7 +142,11 @@ export function SpaceCreator({ open, onOpenChange, editingSpace, onSave }: Space + + {isEditing ? "Edit Space" : "New Space"} + {/* ── Hero preview — live color/icon/name preview ── */}
+
{isOpen && hasContent && (
-
+
{message.content} diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 8fb3876..d9c453b 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -3,6 +3,7 @@ * Renders a row of closeable tabs with a header icon/label and a "new tab" button. */ +import { useCallback, useState } from "react"; import { Plus, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import type { LucideIcon } from "lucide-react"; @@ -30,6 +31,8 @@ interface TabBarProps { activeClass?: string; /** Override inactive tab text classes. */ inactiveClass?: string; + /** Optional drag-reorder handler. */ + onReorderTabs?: (fromTabId: string, toTabId: string) => void; } export function TabBar({ @@ -44,39 +47,83 @@ export function TabBar({ tabMaxWidth = "max-w-20", activeClass = "bg-foreground/[0.08] text-foreground/90", inactiveClass = "text-foreground/40 hover:text-foreground/60 hover:bg-foreground/[0.04]", + onReorderTabs, }: TabBarProps) { const hasHeaderLabel = headerLabel.trim().length > 0; + const [draggingTabId, setDraggingTabId] = useState(null); + const [dragOverTabId, setDragOverTabId] = useState(null); + const isDraggable = typeof onReorderTabs === "function" && tabs.length > 1; + + const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => { + if (!onReorderTabs) return; + e.dataTransfer.setData("text/plain", tabId); + e.dataTransfer.effectAllowed = "move"; + setDraggingTabId(tabId); + }, [onReorderTabs]); + + const handleDragOver = useCallback((e: React.DragEvent, tabId: string) => { + if (!onReorderTabs) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOverTabId((currentTabId) => (currentTabId === tabId ? currentTabId : tabId)); + }, [onReorderTabs]); + + const clearDragState = useCallback(() => { + setDraggingTabId(null); + setDragOverTabId(null); + }, []); + + const handleDragLeave = useCallback((tabId: string) => { + setDragOverTabId((currentTabId) => (currentTabId === tabId ? null : currentTabId)); + }, []); + + const handleDrop = useCallback((e: React.DragEvent, toTabId: string) => { + if (!onReorderTabs) return; + e.preventDefault(); + const fromTabId = e.dataTransfer.getData("text/plain"); + clearDragState(); + if (fromTabId && fromTabId !== toTabId) { + onReorderTabs(fromTabId, toTabId); + } + }, [clearDragState, onReorderTabs]); return (
{/* Header icon + label */}
-
- -
+ {hasHeaderLabel && ( - {headerLabel} + {headerLabel} )}
{/* Tabs */} -
+
{tabs.map((tab) => { const isActiveTab = tab.id === activeTabId; + const isDragTarget = dragOverTabId === tab.id && draggingTabId !== tab.id; + const isDragging = draggingTabId === tab.id; return ( {groupContent}
@@ -265,13 +339,13 @@ export const ToolGroupBlock = memo(function ToolGroupBlock({ } return ( -
-
+
+
- + {groupContent} @@ -285,6 +359,9 @@ export const ToolGroupBlock = memo(function ToolGroupBlock({ prev.messages === next.messages && prev.showThinking === next.showThinking && prev.autoExpandTools === next.autoExpandTools && + prev.expandEditToolCallsByDefault === next.expandEditToolCallsByDefault && + prev.showToolIcons === next.showToolIcons && + prev.coloredToolIcons === next.coloredToolIcons && prev.disableCollapseAnimation === next.disableCollapseAnimation && prev.animate === next.animate, ); diff --git a/src/components/TurnChangesSummary.tsx b/src/components/TurnChangesSummary.tsx index 622c966..6a6dd4b 100644 --- a/src/components/TurnChangesSummary.tsx +++ b/src/components/TurnChangesSummary.tsx @@ -4,6 +4,7 @@ import { DiffViewer } from "./DiffViewer"; import { OpenInEditorButton } from "./OpenInEditorButton"; import type { TurnSummary, FileChange } from "@/lib/turn-changes"; import { useChatPersistedState } from "@/components/chat-ui-state"; +import { CHAT_ROW_CLASS, CHAT_ROW_WIDTH_CLASS } from "@/components/lib/chat-layout"; // ── Color/icon mapping (matches FilesPanel conventions) ── @@ -29,7 +30,7 @@ const InlineFileChange = memo(function InlineFileChange({ const dir = dirParts.length > 1 ? dirParts.slice(0, -1).join("/") + "/" : ""; return ( -
+
{/* File row — clickable to expand/collapse */} + {/* Stats pill */} + + {statsText} + - {/* Expanded: file list with inline diffs */} - {isOpen && ( -
- {uniqueFiles.map((change) => ( - toggleFile(change.filePath)} - /> - ))} -
- )} + + + + {/* Expanded: file list with inline diffs */} + {isOpen && ( +
+ {uniqueFiles.map((change) => ( + toggleFile(change.filePath)} + /> + ))} +
+ )} +
); }); diff --git a/src/components/git/BranchPicker.tsx b/src/components/git/BranchPicker.tsx index f2639eb..934671d 100644 --- a/src/components/git/BranchPicker.tsx +++ b/src/components/git/BranchPicker.tsx @@ -15,7 +15,7 @@ function BranchItem({ branch, onSelect }: { branch: GitBranch; onSelect: (name: type="button" onClick={() => onSelect(branch.name)} className={`flex w-full items-center gap-1.5 px-3 py-1 text-[11px] transition-colors hover:bg-foreground/[0.05] cursor-pointer ${ - branch.isCurrent ? "text-foreground/90" : "text-foreground/65" + branch.isCurrent ? "text-foreground/90" : "text-foreground/60" }`} > {branch.isCurrent ? ( @@ -56,7 +56,6 @@ export function BranchPicker({ const [showNewBranch, setShowNewBranch] = useState(false); const branchPickerRef = useRef(null); - // Close branch picker on click outside useEffect(() => { if (!showBranchPicker) return; const handler = (e: MouseEvent) => { @@ -109,24 +108,23 @@ export function BranchPicker({ onClick={() => setShowBranchPicker(!showBranchPicker)} className="flex w-full items-center gap-1.5 rounded-md border border-foreground/[0.08] bg-foreground/[0.03] px-2 py-1 text-[11px] transition-colors hover:border-foreground/[0.12] hover:bg-foreground/[0.05] cursor-pointer" > - - {currentBranch ?? "…"} - + + {currentBranch ?? "…"} + - {/* Branch dropdown */} {showBranchPicker && (
{/* Search */}
- + setBranchFilter(e.target.value)} placeholder="Filter branches…" - className="w-full bg-transparent py-1.5 text-[11px] text-foreground/80 outline-none placeholder:text-foreground/30" + className="w-full bg-transparent py-1.5 text-[11px] text-foreground/75 outline-none placeholder:text-foreground/30" autoFocus />
@@ -144,7 +142,7 @@ export function BranchPicker({ if (e.key === "Escape") { setShowNewBranch(false); setNewBranchName(""); } }} placeholder="New branch name…" - className="min-w-0 flex-1 rounded-md bg-foreground/[0.05] px-2 py-1.5 text-[11px] text-foreground/80 outline-none placeholder:text-foreground/30" + className="min-w-0 flex-1 rounded-md bg-foreground/[0.05] px-2 py-1.5 text-[11px] text-foreground/75 outline-none placeholder:text-foreground/30" autoFocus /> -
+
{onStageAll && ( - - - - -

Stage All

-
+ )} {onUnstageAll && ( - - - - -

Unstage All

-
+ )}
@@ -90,8 +79,8 @@ export function ChangesSection({ {isExpanded && diffContent !== null && } {isExpanded && diffContent === null && ( -
- +
+
)}
diff --git a/src/components/git/CommitInput.tsx b/src/components/git/CommitInput.tsx index c6b58b4..7ebc4f0 100644 --- a/src/components/git/CommitInput.tsx +++ b/src/components/git/CommitInput.tsx @@ -68,7 +68,7 @@ export function CommitInput({ const canCommit = commitMessage.trim().length > 0 && stagedCount > 0; return ( -
+