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/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/__tests__/layout-constants.test.ts b/electron/src/lib/__tests__/layout-constants.test.ts index 140af91..27a0af4 100644 --- a/electron/src/lib/__tests__/layout-constants.test.ts +++ b/electron/src/lib/__tests__/layout-constants.test.ts @@ -39,7 +39,7 @@ describe("layout constants", () => { expect(TOOL_PICKER_WIDTH_ISLAND).toBe(58); expect(RESIZE_HANDLE_WIDTH_ISLAND).toBe(4); expect(WINDOWS_FRAME_BUFFER_WIDTH).toBe(16); - expect(getBootstrapMinWindowWidth("darwin")).toBe(1666); - expect(getBootstrapMinWindowWidth("win32")).toBe(1682); + expect(getBootstrapMinWindowWidth("darwin")).toBe(1542); + expect(getBootstrapMinWindowWidth("win32")).toBe(1558); }); }); 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 4df309b..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"; @@ -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"; @@ -61,6 +62,74 @@ 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" + ? value + : "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; } @@ -90,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 }; @@ -105,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, @@ -138,23 +217,40 @@ 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) => { @@ -166,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. @@ -209,6 +336,7 @@ ipcMain.on("glass:set-theme", (_event, theme: string) => { 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 9c12ae0..0f0602c 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -17,25 +17,50 @@ interface PreloadGlobals { localStorage?: PreloadStorage; } +type ThemeSource = "system" | "light" | "dark"; +type MacBackgroundEffect = "liquid-glass" | "vibrancy" | "off"; + +function readStoredThemeSource(storage: PreloadStorage | undefined): ThemeSource { + const stored = storage?.getItem("harnss-theme"); + return stored === "light" || stored === "dark" || stored === "system" + ? stored + : "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 { const globals = globalThis as typeof globalThis & PreloadGlobals; const root = globals.document?.documentElement; + const themeSource = readStoredThemeSource(globals.localStorage); // Apply platform + glass classes as early as possible (before React mounts). // On Windows, glass support does not mean the user has transparency enabled. root?.classList.add(`platform-${process.platform}`); - 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"); - } - }); + ipcRenderer.send("app:set-theme-source", themeSource); + 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 @@ -52,6 +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) => @@ -111,9 +140,16 @@ 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), + 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), generateTitle: (message: string, cwd?: string, engine?: string, sessionId?: string) => ipcRenderer.invoke("claude:generate-title", { message, cwd, engine, sessionId }), projects: { @@ -132,6 +168,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..9f08423 100644 --- a/shared/lib/session-persistence.ts +++ b/shared/lib/session-persistence.ts @@ -14,6 +14,14 @@ 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; + /** Agent ID — which agent was used for this session. */ + agentId?: string; } /** @@ -45,5 +53,9 @@ 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, + agentId: data.agentId as string | undefined, }; } 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 a615035..59103ce 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"; @@ -38,7 +41,15 @@ import { SettingsView } from "./SettingsView"; import { CodexAuthDialog } from "./CodexAuthDialog"; import { JiraBoardPanel } from "./JiraBoardPanel"; import type { JiraIssue } from "@shared/types/jira"; -import { isMac } from "@/lib/utils"; +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"; @@ -60,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, @@ -76,16 +87,19 @@ export function AppLayout() { handleCreateSpace, handleEditSpace, handleDeleteSpace, handleSaveSpace, handleMoveProjectToSpace, handleSeedDevExampleSpaceData, + splitView, } = o; 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); @@ -179,9 +193,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 +218,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) => { @@ -293,12 +309,14 @@ Link: ${issue.url}`; const isIsland = settings.islandLayout; const minChatWidth = getMinChatWidth(isIsland); const splitGap = isIsland ? RESIZE_HANDLE_WIDTH_ISLAND / 2 : 0.5; + const islandRadius = isWindows ? 8 : ISLAND_RADIUS; + const islandControlRadius = isWindows ? 7 : ISLAND_CONTROL_RADIUS; const islandLayoutVars = isIsland ? { "--island-gap": `${ISLAND_GAP}px`, "--island-panel-gap": `${ISLAND_PANEL_GAP}px`, - "--island-radius": `${ISLAND_RADIUS}px`, - "--island-control-radius": `${ISLAND_CONTROL_RADIUS}px`, + "--island-radius": `${islandRadius}px`, + "--island-control-radius": `${islandControlRadius}px`, "--tool-picker-strip-width": `${TOOL_PICKER_WIDTH_ISLAND - ISLAND_PANEL_GAP}px`, } as React.CSSProperties : undefined; @@ -318,11 +336,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([]); @@ -340,6 +496,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); }, []); @@ -376,6 +549,268 @@ 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; + const totalHandleWidth = (previewPaneCount - 1) * SPLIT_HANDLE_WIDTH; + const handleSharePx = totalHandleWidth / previewPaneCount; + return { widthPercent, handleSharePx }; + }, [previewPaneCount, previewWidthFractions]); + 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(() => { + 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, 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 = @@ -385,7 +820,7 @@ Link: ${issue.url}`; return (
{/* Glass tint overlay — sits behind content, tints the native transparency */} @@ -437,6 +872,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} @@ -446,6 +882,10 @@ Link: ${issue.url}`; onDeleteSpace={handleDeleteSpace} onOpenSettings={() => setShowSettings(true)} agents={agents} + onOpenInSplitView={(sessionId) => { + void requestAddSplitSession(sessionId); + }} + canOpenSessionInSplitView={(sessionId) => splitView.canShowSessionSplitAction(sessionId, manager.activeSessionId)} />
@@ -459,19 +899,30 @@ Link: ${issue.url}`; onThemeChange={settings.setTheme} islandLayout={settings.islandLayout} 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} @@ -480,12 +931,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 ? ( )}
- + {isSpaceSwitching ? ( +
+
+
+
+
+
+
+
+
+
+ ) : ( + + )} )} -
+ - {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. @@ -788,7 +1423,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 && ( -
+ {showToolPicker && ( + -
+ + )} + )}
{/* 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. @@ -1026,6 +1681,8 @@ Link: ${issue.url}`; onAutoGroupToolsChange={settings.setAutoGroupTools} autoExpandTools={settings.autoExpandTools} onAutoExpandToolsChange={settings.setAutoExpandTools} + expandEditToolCallsByDefault={settings.expandEditToolCallsByDefault} + onExpandEditToolCallsByDefaultChange={settings.setExpandEditToolCallsByDefault} transparency={settings.transparency} onTransparencyChange={settings.setTransparency} glassSupported={glassSupported} diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 05aede9..f3b3312 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; @@ -40,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({ @@ -50,6 +61,8 @@ export const AppSidebar = memo(function AppSidebar({ activeSessionId, jiraBoardProjectId, jiraBoardEnabled, + foldersByProject, + organizeByChatBranch, onNewChat, onToggleProjectJiraBoard, onSelectSession, @@ -64,6 +77,13 @@ export const AppSidebar = memo(function AppSidebar({ onNavigateToMessage, onMoveProjectToSpace, onReorderProject, + onPinSession, + onMoveSessionToFolder, + onCreateFolder, + onRenameFolder, + onDeleteFolder, + onPinFolder, + onSetOrganizeByChatBranch, spaces, activeSpaceId, onSelectSpace, @@ -72,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); @@ -219,6 +241,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,7 +275,16 @@ 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} + onOpenInSplitView={onOpenInSplitView} + canOpenSessionInSplitView={canOpenSessionInSplitView} /> ); })} diff --git a/src/components/BackgroundAgentsPanel.tsx b/src/components/BackgroundAgentsPanel.tsx index d2b58d8..d04f9cf 100644 --- a/src/components/BackgroundAgentsPanel.tsx +++ b/src/components/BackgroundAgentsPanel.tsx @@ -1,7 +1,6 @@ import { useState, useCallback, useRef, useEffect } from "react"; import { Bot, - CheckCircle2, Loader2, ChevronRight, ChevronDown, @@ -37,6 +36,7 @@ const REMARK_PLUGINS = [remarkGfm]; interface BackgroundAgentsPanelProps { agents: BackgroundAgent[]; + expandEditToolCallsByDefault: boolean; onDismiss: (agentId: string) => 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/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/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 31fb821..b2ab2b5 100644 --- a/src/components/InputBar.tsx +++ b/src/components/InputBar.tsx @@ -9,7 +9,6 @@ import { } from "react"; import { ArrowUp, - Brain, ChevronDown, Crosshair, File, @@ -101,116 +100,6 @@ function getContextStrokeColor(percent: number): string { // ── Reusable engine control sub-components ── -/** Model selector dropdown — used by Claude and Codex engines */ -function ModelDropdown({ - modelList, - selectedModel, - selectedModelId, - isProcessing, - onModelChange, - onModelEffortChange, - effortOptionsByModel, - activeEffort, - modelsLoading, - modelsLoadingText, -}: { - modelList: Array<{ id: string; label: string; description?: string }>; - selectedModel: { id: string; label: string; description?: string } | undefined; - selectedModelId: string; - isProcessing: boolean; - onModelChange: (id: string) => void; - onModelEffortChange?: (id: string, effort: ClaudeEffort) => void; - effortOptionsByModel?: Partial>; - activeEffort?: ClaudeEffort; - modelsLoading: boolean; - modelsLoadingText: string; -}) { - if (modelsLoading) { - return ( -
- - {modelsLoadingText} -
- ); - } - const selectedEffort = activeEffort && (effortOptionsByModel?.[selectedModelId]?.includes(activeEffort) ?? false) - ? activeEffort - : undefined; - return ( - - - - - - {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, @@ -227,13 +116,11 @@ function PermissionDropdown({ return ( - + + {PERMISSION_MODES.map((m) => { @@ -275,230 +162,88 @@ function PlanModeToggle({ return ( - +

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

); } -/** 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 ( <> - - + ); } @@ -510,6 +255,11 @@ const CLAUDE_EFFORT_DESCRIPTIONS: Record = { max: "Maximum effort", }; +/** Shared className overrides for ghost toolbar buttons in the input bar. + * Applied on top of ` + +
))}
@@ -1641,11 +1388,11 @@ export const InputBar = memo(function InputBar({ {/* Grabbed element previews (from browser inspector) */} {grabbedElements && grabbedElements.length > 0 && ( -
+
{grabbedElements.map((ge) => (
@@ -1664,12 +1411,14 @@ export const InputBar = memo(function InputBar({ )}
- + +
))}
@@ -1687,42 +1436,46 @@ 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,57 +1492,191 @@ export const InputBar = memo(function InputBar({ ) : speech.nativeHint ? ( - + + {speech.nativeHint} ) : 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" : "" - } - > - + + ); + 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))} + + ); + })()} + + + +
{/* Right controls — always visible, never shrink */} -
+
{contextUsage && (() => { const totalInput = contextUsage.inputTokens + contextUsage.cacheReadTokens + contextUsage.cacheCreationTokens; const percent = Math.min(100, (totalInput / contextUsage.contextWindow) * 100); - const radius = 7; + const radius = 8; const circumference = 2 * Math.PI * radius; const dashOffset = circumference - (percent / 100) * circumference; return ( @@ -1931,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 403ceac..7c4d1f6 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,25 @@ 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 +36,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 +66,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 +97,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,12 +152,48 @@ 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 (
-
-

No project selected

+
+ +

No project selected

); @@ -138,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"}

)} @@ -205,14 +265,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 +364,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,11 +378,31 @@ 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 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 { @@ -245,41 +412,246 @@ 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 */} + { + if (!open) { + suppressRowClick(); + } + setMenuOpen(open); + }} + > + + + + + {!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..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 ── @@ -65,19 +65,30 @@ interface SettingsViewProps { onThemeChange: (t: ThemeOption) => void; islandLayout: boolean; 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. */ @@ -95,19 +106,30 @@ export const SettingsView = memo(function SettingsView({ 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, sidebarOpen = false, onToggleSidebar, onReplayWelcome, @@ -155,19 +177,31 @@ export const SettingsView = memo(function SettingsView({ onThemeChange={onThemeChange} islandLayout={islandLayout} 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": @@ -235,7 +269,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, 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 ( -
+