diff --git a/apps/desktop/src/store/coding-status.test.ts b/apps/desktop/src/store/coding-status.test.ts new file mode 100644 index 00000000000..02d9e843bb6 --- /dev/null +++ b/apps/desktop/src/store/coding-status.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { HermesRepoStatus } from '@/global' + +import { $repoStatus, refreshRepoStatus } from './coding-status' +import { $currentCwd } from './session' + +const sampleStatus: HermesRepoStatus = { + branch: 'feature/login', + defaultBranch: 'main', + detached: false, + ahead: 1, + behind: 0, + staged: 1, + unstaged: 2, + untracked: 0, + conflicted: 0, + changed: 3, + added: 12, + removed: 4, + files: [] +} + +function stubProbe(impl: (cwd: string) => Promise) { + ;(window as unknown as { hermesDesktop?: unknown }).hermesDesktop = { git: { repoStatus: impl } } +} + +describe('refreshRepoStatus', () => { + beforeEach(() => { + $repoStatus.set(null) + $currentCwd.set('') + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop + }) + + afterEach(() => { + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop + }) + + it('populates $repoStatus from the probe for an explicit cwd', async () => { + stubProbe(async () => sampleStatus) + await refreshRepoStatus('/repo') + expect($repoStatus.get()).toEqual(sampleStatus) + }) + + it('falls back to the active session cwd when none is passed', async () => { + const probe = vi.fn(async () => sampleStatus) + stubProbe(probe) + $currentCwd.set('/active/repo') + await refreshRepoStatus() + expect(probe).toHaveBeenCalledWith('/active/repo') + }) + + it('clears status when there is no cwd', async () => { + stubProbe(async () => sampleStatus) + $repoStatus.set(sampleStatus) + await refreshRepoStatus(' ') + expect($repoStatus.get()).toBeNull() + }) + + it('clears status when the probe is unavailable (remote backend)', async () => { + $repoStatus.set(sampleStatus) + await refreshRepoStatus('/repo') + expect($repoStatus.get()).toBeNull() + }) + + it('clears status when the probe throws', async () => { + stubProbe(async () => { + throw new Error('not a repo') + }) + $repoStatus.set(sampleStatus) + await refreshRepoStatus('/repo') + expect($repoStatus.get()).toBeNull() + }) +}) diff --git a/apps/desktop/src/store/coding-status.ts b/apps/desktop/src/store/coding-status.ts new file mode 100644 index 00000000000..f3ea71a3642 --- /dev/null +++ b/apps/desktop/src/store/coding-status.ts @@ -0,0 +1,165 @@ +import { atom, computed } from 'nanostores' + +import type { HermesGitWorktree, HermesRepoStatus } from '@/global' + +import { $worktreeRefreshToken } from './projects' +import { $busy, $currentCwd } from './session' +import { $workspaceChangeTick } from './workspace-events' + +// Live working-tree status for the active session's cwd — the data backbone of +// the composer coding rail. It's the same "cheaply re-read git truth at the +// right moments" model as the sidebar worktree probe: a single bounded +// `git status --porcelain=v2` per refresh, driven by structural edges (cwd +// change, turn settle, window focus, worktree mutation), never per-token and +// never touching the conversation/system-prompt cache. + +export const $repoStatus = atom(null) +export const $repoStatusLoading = atom(false) + +// The repo's real worktrees (for the coding rail's "jump to a worktree" menu). +// Refreshed on the same edges as the status probe; empty off a repo. +export const $repoWorktrees = atom([]) +const REPO_STATUS_REFRESH_DEBOUNCE_MS = 100 + +export type RepoChangeKind = 'added' | 'conflicted' | 'modified' + +// Absolute file path → its git change kind, for VS Code-style file-tree tinting. +// Reuses the same bounded $repoStatus probe (capped file list); git reports +// repo-root-relative paths, so we join them onto the active cwd. Deletions never +// appear — the file is gone from disk, so there's no tree row to tint. +export const $repoChangeByPath = computed([$repoStatus, $currentCwd], (status, cwd) => { + const map = new Map() + const root = (cwd || '').replace(/[/\\]+$/, '') + + if (!status || !root) { + return map + } + + for (const file of status.files) { + const kind: RepoChangeKind = file.conflicted ? 'conflicted' : file.untracked ? 'added' : 'modified' + map.set(`${root}/${file.path}`, kind) + } + + return map +}) + +async function loadWorktrees(target: string): Promise { + const list = window.hermesDesktop?.git?.worktreeList + + if (!list) { + $repoWorktrees.set([]) + + return + } + + try { + const worktrees = await list(target) + + if (inflightCwd === target) { + $repoWorktrees.set(worktrees) + } + } catch { + if (inflightCwd === target) { + $repoWorktrees.set([]) + } + } +} + +// Coalesce overlapping probes: many triggers can fire around a turn boundary +// (busy flip + worktree token + focus), but only the latest cwd matters. +let inflightCwd: null | string = null +let repoStatusRefreshSeq = 0 +let repoStatusRefreshTimer: ReturnType | null = null + +const normalizeCwd = (cwd?: null | string): null | string => cwd?.trim() || null + +/** + * Re-probe the working tree for `cwd` (defaults to the active session's cwd). + * Best-effort: a non-repo, a remote backend, or a missing probe clears the + * status so the rail hides rather than showing stale data. + */ +export async function refreshRepoStatus(cwd?: null | string): Promise { + const target = normalizeCwd(cwd ?? $currentCwd.get()) + const probe = window.hermesDesktop?.git?.repoStatus + const seq = (repoStatusRefreshSeq += 1) + + if (!target || !probe) { + $repoStatus.set(null) + $repoWorktrees.set([]) + + return + } + + inflightCwd = target + $repoStatusLoading.set(true) + + try { + const status = await probe(target) + + // Drop the result if the cwd moved on while we were probing (a fast session + // switch) — the newer probe owns the atom. + if (seq === repoStatusRefreshSeq && inflightCwd === target) { + $repoStatus.set(status) + + // Worktrees only matter inside a repo; clear them otherwise. + if (status) { + void loadWorktrees(target) + } else { + $repoWorktrees.set([]) + } + } + } catch { + if (seq === repoStatusRefreshSeq && inflightCwd === target) { + $repoStatus.set(null) + $repoWorktrees.set([]) + } + } finally { + if (seq === repoStatusRefreshSeq && inflightCwd === target) { + $repoStatusLoading.set(false) + } + } +} + +function scheduleRepoStatusRefresh(cwd?: null | string): void { + if (repoStatusRefreshTimer) { + clearTimeout(repoStatusRefreshTimer) + } + + repoStatusRefreshTimer = setTimeout(() => { + repoStatusRefreshTimer = null + void refreshRepoStatus(cwd) + }, REPO_STATUS_REFRESH_DEBOUNCE_MS) +} + +// ── Triggers ───────────────────────────────────────────────────────────────── +// Wired once at module load (mirrors projects.ts's module-scope subscriptions). +// Each is a structural edge where the working tree may have changed under us. + +// The active session's cwd changed (session switch / new chat) → re-probe. +$currentCwd.subscribe(cwd => scheduleRepoStatusRefresh(cwd)) + +// A worktree add/remove (desktop op, or the agent's out-of-band git in a settled +// turn / a window refocus — both already bump this token) → re-probe. +$worktreeRefreshToken.subscribe(() => scheduleRepoStatusRefresh()) + +// A file-mutating tool finished (event-driven, not polled) → re-probe so the +// rail's branch/+/- move exactly when the agent touches the tree. +$workspaceChangeTick.subscribe(() => scheduleRepoStatusRefresh()) + +// A turn settling is the backstop for changes no tool diff announced (e.g. a +// raw `git` in the terminal): one final refresh when the agent goes idle. +let prevBusy = $busy.get() + +$busy.subscribe(busy => { + if (prevBusy && !busy) { + scheduleRepoStatusRefresh() + } + + prevBusy = busy +}) + +// External changes while the window was away (an outside terminal) — refresh on +// refocus, the git-GUI standard. +if (typeof window !== 'undefined') { + window.addEventListener('focus', () => scheduleRepoStatusRefresh()) +} diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index 8caeb8b47ab..834bbd1101d 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -1,21 +1,17 @@ import { atom, computed, type ReadableAtom } from 'nanostores' -import { - arraysEqual, - insertUniqueId, - persistBoolean, - persistStringArray, - storedBoolean, - storedStringArray -} from '@/lib/storage' +import { Codecs, persistentAtom } from '@/lib/persisted' +import { arraysEqual, insertUniqueId } from '@/lib/storage' import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride, togglePane } from './panes' export const SIDEBAR_DEFAULT_WIDTH = 237 export const SIDEBAR_MAX_WIDTH = 360 -// Open at the same width as the sessions sidebar so the two rails match. +// Open at the same width as the sessions sidebar so the two rails match, but +// allow shrinking well below that (~30% under the old 14rem floor) for users who +// want a narrow tree. export const FILE_BROWSER_DEFAULT_WIDTH = `${SIDEBAR_DEFAULT_WIDTH}px` -export const FILE_BROWSER_MIN_WIDTH = '14rem' +export const FILE_BROWSER_MIN_WIDTH = '10rem' export const FILE_BROWSER_MAX_WIDTH = '20rem' export const SIDEBAR_SESSIONS_PAGE_SIZE = 50 @@ -28,7 +24,12 @@ const SIDEBAR_SESSION_ORDER_STORAGE_KEY = 'hermes.desktop.sessionOrder' const SIDEBAR_SESSION_ORDER_MANUAL_STORAGE_KEY = 'hermes.desktop.sessionOrder.manual' const SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceOrder' const SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceParentOrder' +const SIDEBAR_PROJECT_ORDER_STORAGE_KEY = 'hermes.desktop.projectOrder' +const SIDEBAR_WORKSPACE_COLLAPSED_STORAGE_KEY = 'hermes.desktop.workspaceCollapsed' +const SIDEBAR_DISMISSED_AUTO_PROJECTS_STORAGE_KEY = 'hermes.desktop.dismissedAutoProjects' +const SIDEBAR_DISMISSED_WORKTREES_STORAGE_KEY = 'hermes.desktop.dismissedWorktrees' const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped' +const RIGHT_RAIL_ACTIVE_TAB_STORAGE_KEY = 'hermes.desktop.rightRailActiveTab' export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar' export const FILE_BROWSER_PANE_ID = 'file-browser' @@ -51,7 +52,13 @@ export const $fileBrowserOpen: ReadableAtom = computed( states => states[FILE_BROWSER_PANE_ID]?.open ?? false ) -export const $rightRailActiveTabId = atom(RIGHT_RAIL_PREVIEW_TAB_ID) +// Persisted so a relaunch reopens the same rail tab. A restored file-tab id with +// no matching tab is reconciled back to the preview tab in the preview store. +export const $rightRailActiveTabId = persistentAtom( + RIGHT_RAIL_ACTIVE_TAB_STORAGE_KEY, + RIGHT_RAIL_PREVIEW_TAB_ID, + { decode: raw => raw as RightRailTabId, encode: tabId => tabId } +) export const $sidebarWidth: ReadableAtom = computed($paneStates, states => { const override = states[CHAT_SIDEBAR_PANE_ID]?.widthOverride @@ -59,13 +66,29 @@ export const $sidebarWidth: ReadableAtom = computed($paneStates, states return typeof override === 'number' ? override : SIDEBAR_DEFAULT_WIDTH }) -export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY)) -export const $sidebarSessionOrderIds = atom(storedStringArray(SIDEBAR_SESSION_ORDER_STORAGE_KEY)) -export const $sidebarSessionOrderManual = atom(storedBoolean(SIDEBAR_SESSION_ORDER_MANUAL_STORAGE_KEY, false)) -export const $sidebarWorkspaceOrderIds = atom(storedStringArray(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY)) +export const $pinnedSessionIds = persistentAtom(SIDEBAR_PINNED_STORAGE_KEY, [] as string[], Codecs.stringArray) +export const $sidebarSessionOrderIds = persistentAtom(SIDEBAR_SESSION_ORDER_STORAGE_KEY, [] as string[], Codecs.stringArray) +export const $sidebarSessionOrderManual = persistentAtom(SIDEBAR_SESSION_ORDER_MANUAL_STORAGE_KEY, false, Codecs.bool) +export const $sidebarWorkspaceOrderIds = persistentAtom(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY, [] as string[], Codecs.stringArray) // Order of the top-level repo "parent" groups in the worktree tree (worktrees // within a parent reuse $sidebarWorkspaceOrderIds). -export const $sidebarWorkspaceParentOrderIds = atom(storedStringArray(SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY)) +export const $sidebarWorkspaceParentOrderIds = persistentAtom(SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY, [] as string[], Codecs.stringArray) +// Manual drag-order of projects in the overview. Empty = the deterministic +// default sort (active first, explicit before auto, by recency); once the user +// drags a project their order wins (orderByIds surfaces new projects on top). +export const $sidebarProjectOrderIds = persistentAtom(SIDEBAR_PROJECT_ORDER_STORAGE_KEY, [] as string[], Codecs.stringArray) +// Repo/worktree nodes that the user has explicitly COLLAPSED. Absent = open, so +// a project's folders auto-open when you enter it (and persist your collapses +// across reloads). Keyed by stable node id (repo root / worktree path). +export const $sidebarWorkspaceCollapsedIds = persistentAtom(SIDEBAR_WORKSPACE_COLLAPSED_STORAGE_KEY, [] as string[], Codecs.stringArray) +// Auto-derived (git-repo) projects the user has dismissed ("deleted") from the +// overview. Keyed by repo-root path; persisted so they stay hidden. Explicit +// projects are deleted for real instead — this only declutters the auto tier. +export const $dismissedAutoProjectIds = persistentAtom(SIDEBAR_DISMISSED_AUTO_PROJECTS_STORAGE_KEY, [] as string[], Codecs.stringArray) +// Worktree rows removed from the UI after a `git worktree remove`. The on-disk +// dir is gone but historical sessions still reference its path, so we hide the +// row by id (worktree path) to keep "remove" feeling real. +export const $dismissedWorktreeIds = persistentAtom(SIDEBAR_DISMISSED_WORKTREES_STORAGE_KEY, [] as string[], Codecs.stringArray) export const $sidebarPinsOpen = atom(true) // Set by the PaneShell hover-reveal overlay while the sidebar is collapsed; kept // true the whole time it's a floating overlay (not just while shown) so the @@ -76,29 +99,52 @@ export const $sidebarRecentsOpen = atom(true) // Cron-job sessions live in their own section below recents, collapsed by // default (it only renders at all when cron sessions exist) so the // scheduler's `[IMPORTANT: …]` first-message previews don't spam recents. -export const $sidebarCronOpen = atom(storedBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, false)) +export const $sidebarCronOpen = persistentAtom(SIDEBAR_CRON_OPEN_STORAGE_KEY, false, Codecs.bool) // Messaging platform sections collapse by default (they can be numerous and // tall). We persist the ids the user has *explicitly expanded*, so the default // stays collapsed unless they've opened a platform before. -export const $sidebarMessagingOpenIds = atom(storedStringArray(SIDEBAR_MESSAGING_OPEN_STORAGE_KEY)) -export const $sidebarAgentsGrouped = atom(storedBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, false)) +export const $sidebarMessagingOpenIds = persistentAtom(SIDEBAR_MESSAGING_OPEN_STORAGE_KEY, [] as string[], Codecs.stringArray) +export const $sidebarAgentsGrouped = persistentAtom(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, false, Codecs.bool) // When true, the sessions sidebar moves to the right and the file browser + // preview rail move to the left — a mirror of the default layout. -export const $panesFlipped = atom(storedBoolean(PANES_FLIPPED_STORAGE_KEY, false)) +export const $panesFlipped = persistentAtom(PANES_FLIPPED_STORAGE_KEY, false, Codecs.bool) export const $isSidebarResizing = atom(false) export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE) -$pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids])) -$sidebarCronOpen.subscribe(open => persistBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, open)) -$sidebarMessagingOpenIds.subscribe(ids => persistStringArray(SIDEBAR_MESSAGING_OPEN_STORAGE_KEY, [...ids])) -$sidebarSessionOrderIds.subscribe(ids => persistStringArray(SIDEBAR_SESSION_ORDER_STORAGE_KEY, [...ids])) -$sidebarSessionOrderManual.subscribe(manual => persistBoolean(SIDEBAR_SESSION_ORDER_MANUAL_STORAGE_KEY, manual)) -$sidebarWorkspaceOrderIds.subscribe(ids => persistStringArray(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY, [...ids])) -$sidebarWorkspaceParentOrderIds.subscribe(ids => - persistStringArray(SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY, [...ids]) -) -$sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped)) -$panesFlipped.subscribe(flipped => persistBoolean(PANES_FLIPPED_STORAGE_KEY, flipped)) +// Toggle a repo/worktree node's persisted collapse state (absent = open). +export function toggleWorkspaceNodeCollapsed(id: string): void { + const current = $sidebarWorkspaceCollapsedIds.get() + + $sidebarWorkspaceCollapsedIds.set(current.includes(id) ? current.filter(nodeId => nodeId !== id) : [...current, id]) +} + +// Dismiss ("delete") an auto-derived project from the overview. +export function dismissAutoProject(id: string): void { + const current = $dismissedAutoProjectIds.get() + + if (!current.includes(id)) { + $dismissedAutoProjectIds.set([...current, id]) + } +} + +// Hide a worktree row after it's been removed via git. +export function dismissWorktree(id: string): void { + const current = $dismissedWorktreeIds.get() + + if (!current.includes(id)) { + $dismissedWorktreeIds.set([...current, id]) + } +} + +// A hidden worktree becomes visible again as soon as the user explicitly starts +// or opens work there (for example, selecting an already-checked-out branch). +export function restoreWorktree(id: string): void { + const current = $dismissedWorktreeIds.get() + + if (current.includes(id)) { + $dismissedWorktreeIds.set(current.filter(worktreeId => worktreeId !== id)) + } +} export function setSidebarWidth(width: number) { const bounded = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_DEFAULT_WIDTH, width)) @@ -121,6 +167,16 @@ export function setFileBrowserOpen(open: boolean) { setPaneOpen(FILE_BROWSER_PANE_ID, open) } +// "Reveal this file in the file-browser tree" — an absolute path the tree +// subscribes to, expanding ancestor folders and selecting/scrolling to it. Reset +// to null by the tree once consumed. +export const $revealInTreeRequest = atom(null) + +export function revealFileInTree(path: string): void { + setFileBrowserOpen(true) + $revealInTreeRequest.set(path) +} + // Hotkey → focus the sessions search field. Opens the sidebar first, then lets // the field (which only mounts when the sidebar is open) subscribe + focus. export const SESSION_SEARCH_FOCUS_EVENT = 'hermes:focus-session-search' @@ -193,6 +249,12 @@ export function setSidebarWorkspaceParentOrderIds(ids: string[]) { } } +export function setSidebarProjectOrderIds(ids: string[]) { + if (!arraysEqual($sidebarProjectOrderIds.get(), ids)) { + $sidebarProjectOrderIds.set(ids) + } +} + export function setSidebarResizing(resizing: boolean) { $isSidebarResizing.set(resizing) } diff --git a/apps/desktop/src/store/panes.ts b/apps/desktop/src/store/panes.ts index bb7b54e7c0c..12775e58e8e 100644 --- a/apps/desktop/src/store/panes.ts +++ b/apps/desktop/src/store/panes.ts @@ -56,27 +56,20 @@ function load(): Record { return {} } -// widthOverride is in-memory only — phase 2 can add per-pane persistWidth opt-in. +// Persists both open state and resize width; load() validates each snapshot. function persist(states: Record) { if (typeof window === 'undefined') { return } - const minimal: Record = {} - - for (const [id, s] of Object.entries(states)) { - minimal[id] = { open: s.open } - } - try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(minimal)) + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(states)) } catch { // Storage failures are nonfatal. } } export const $paneStates = atom>(load()) -export const $paneHoverRevealSuppressed = atom(false) $paneStates.subscribe(persist) @@ -144,4 +137,3 @@ export function setPaneWidthOverride(id: string, width: number | undefined) { export const clearPaneWidthOverride = (id: string) => setPaneWidthOverride(id, undefined) export const getPaneStateSnapshot = (id: string) => $paneStates.get()[id] -export const setPaneHoverRevealSuppressed = (suppressed: boolean) => $paneHoverRevealSuppressed.set(suppressed) diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 4c8ffc3540f..d6f9f95d658 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -144,12 +144,13 @@ export const $activeGatewayProfile = atom('default') // / default, so single-profile users are unaffected. export const $newChatProfile = atom(null) -// Bumped whenever the profile context actually changes (switch or create). The -// chat controller subscribes and drops to a fresh new-session draft, so the -// session you were in doesn't stay sticky across a profile switch. +// Bumped whenever the open session should be dropped for a fresh new-session +// draft: a profile switch/create (below), or deleting the project that owns the +// currently-open session (store/projects). The chat controller subscribes and +// resets to the intro draft, so we never strand the user in an orphaned view. export const $freshSessionRequest = atom(0) -function requestFreshSession(): void { +export function requestFreshSession(): void { $freshSessionRequest.set($freshSessionRequest.get() + 1) } diff --git a/apps/desktop/src/store/projects.test.ts b/apps/desktop/src/store/projects.test.ts new file mode 100644 index 00000000000..3e379f8fafa --- /dev/null +++ b/apps/desktop/src/store/projects.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { + $projectScope, + $worktreeRefreshToken, + ALL_PROJECTS, + enterProject, + exitProjectScope, + refreshWorktrees +} from './projects' + +describe('project scope', () => { + beforeEach(() => { + window.localStorage.clear() + $projectScope.set(ALL_PROJECTS) + }) + + it('defaults to ALL_PROJECTS', () => { + expect($projectScope.get()).toBe(ALL_PROJECTS) + }) + + it('enterProject scopes the sidebar to the project id', () => { + // setActiveProject fires best-effort (no gateway in test → it rejects and is + // swallowed); the synchronous scope change is what matters here. + enterProject('p_123') + expect($projectScope.get()).toBe('p_123') + }) + + it('exitProjectScope returns to the overview', () => { + enterProject('p_123') + exitProjectScope() + expect($projectScope.get()).toBe(ALL_PROJECTS) + }) + + it('entering the synthetic No-project bucket still scopes (no active pin)', () => { + enterProject('__no_project__') + expect($projectScope.get()).toBe('__no_project__') + }) + + it('persists the scope to localStorage', () => { + enterProject('p_abc') + expect(window.localStorage.getItem('hermes.desktop.projectScope')).toBe('p_abc') + }) +}) + +describe('worktree refresh', () => { + it('refreshWorktrees bumps the probe token so useRepoWorktreeMap refetches', () => { + const before = $worktreeRefreshToken.get() + refreshWorktrees() + expect($worktreeRefreshToken.get()).toBe(before + 1) + }) +}) diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts new file mode 100644 index 00000000000..e90e588f624 --- /dev/null +++ b/apps/desktop/src/store/projects.ts @@ -0,0 +1,754 @@ +import { atom } from 'nanostores' + +import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups' +import type { HermesGitBranch } from '@/global' +import { persistentAtom } from '@/lib/persisted' +import { activeGateway, ensureActiveGatewayOpen } from '@/store/gateway' +import { setSidebarAgentsGrouped } from '@/store/layout' +import { requestFreshSession } from '@/store/profile' +import { $selectedStoredSessionId, $sessions, workspaceCwdForNewSession } from '@/store/session' +import type { ProjectInfo, ProjectsPayload } from '@/types/hermes' + +// First-class, per-profile Projects (named, multi-folder workspaces). State is +// served by the live gateway's `projects.*` JSON-RPC methods, which wrap the +// per-profile projects.db store. The sidebar groups sessions by project folder +// membership; these atoms are the renderer's cached view. + +export const $projects = atom([]) +export const $activeProjectId = atom(null) + +// The authoritative project -> repo -> lane tree (overview), served by +// `projects.tree`. Lanes carry counts + structure; per-project session rows are +// fetched lazily on drill-in via `fetchProjectSessions`. This is the single +// source of project membership — the desktop no longer derives it. +export const $projectTree = atom([]) +export const $projectTreeLoading = atom(false) + +// Client-side cache eviction (Apollo-style optimistic layer): ids the user just +// deleted/archived. The backend tree is a snapshot that still lists them until +// its next refresh, so the render-time overlay strips these so the tree matches +// the live `$sessions` cache exactly — same as the flat Recents list. Pruned on +// refresh once the server snapshot has caught up. +export const $removedSessionIds = atom>(new Set()) + +export function tombstoneSessions(ids: Array): void { + const next = new Set($removedSessionIds.get()) + const before = next.size + + for (const id of ids) { + const trimmed = id?.trim() + + if (trimmed) { + next.add(trimmed) + } + } + + if (next.size !== before) { + $removedSessionIds.set(next) + } +} + +export function untombstoneSessions(ids: Array): void { + const current = $removedSessionIds.get() + + if (!current.size) { + return + } + + const next = new Set(current) + + for (const id of ids) { + const trimmed = id?.trim() + + if (trimmed) { + next.delete(trimmed) + } + } + + if (next.size !== current.size) { + $removedSessionIds.set(next) + } +} + +// True while the disk scan is in flight (drives the "finding repos" hint). +export const $reposScanning = atom(false) + +// ── Project scope (the "you're inside a project" view, mirroring profile scope)─ +// The sidebar's grouped view is a project switcher: ALL_PROJECTS shows the +// project overview (a list you drill into), and a concrete id means you've +// "entered" that project so only its worktrees/branches/sessions show. This is +// pure view state (localStorage), distinct from the durable active-project +// pointer in projects.db — though entering a project also makes it active so new +// chats land there, exactly as selecting a profile does. +export const ALL_PROJECTS = '__all_projects__' + +const PROJECT_SCOPE_KEY = 'hermes.desktop.projectScope' + +export const $projectScope = persistentAtom(PROJECT_SCOPE_KEY, ALL_PROJECTS, { + decode: raw => raw || ALL_PROJECTS, + encode: value => value || ALL_PROJECTS +}) + +// Enter a project: scope the sidebar to it and make it the active project +// (best-effort — the durable pointer is nice-to-have, the view scope is the +// point). Never opens a session. +export function enterProject(id: string): void { + $projectScope.set(id) + + // Only explicit, persisted projects (ids are `p_`) become active. Auto + // projects (ids are filesystem paths) and the "No project" bucket have no + // durable row to pin, so they're view-scope only. + if (id.startsWith('p_')) { + void setActiveProject(id).catch(() => undefined) + } +} + +export function exitProjectScope(): void { + $projectScope.set(ALL_PROJECTS) +} + +// The cwd a NEW chat should start in. The "active project" is just an atom +// ($projectScope) — so when you're inside a project, a new session (cmd-n, the +// trunk "+") starts at that project's root (its primary repo = the default-branch +// checkout) instead of inheriting whatever unrelated worktree the live cwd +// drifted into. Outside a project it falls back to the plain default (detached), +// so a bare new chat shows no branch. +export function resolveNewSessionCwd(): string { + const scope = $projectScope.get() + + if (scope !== ALL_PROJECTS) { + const project = $projectTree.get().find(node => node.id === scope) + const cwd = (project?.path || project?.repos.find(repo => repo.path)?.path || '').trim() + + if (cwd) { + return cwd + } + } + + return workspaceCwdForNewSession() +} + +const underPath = (parent: string, child: string): boolean => + child === parent || child.startsWith(parent.endsWith('/') ? parent : `${parent}/`) + +// The project (explicit or auto) that owns `cwd`, by longest path match across +// the live tree. Null when no project covers it (it'll surface as a fresh +// auto-project on the next tree refresh). +export function projectIdForCwd(cwd: string): null | string { + let best: null | string = null + let bestLen = -1 + + for (const project of $projectTree.get()) { + // Match project + repo roots AND each worktree-lane path: a linked worktree + // (e.g. a sibling `repo-retry`) lives OUTSIDE the repo root, so root-prefix + // matching alone would miss it — but it's still part of the project. + const paths = [ + project.path, + ...project.repos.flatMap(repo => [repo.path, ...repo.groups.map(group => group.path)]) + ] + + for (const path of paths) { + const p = (path || '').trim() + + if (p && underPath(p, cwd) && p.length > bestLen) { + bestLen = p.length + best = project.id + } + } + } + + return best +} + +// The active session's agent relocated itself (created/entered another repo or +// worktree via the terminal — backend re-anchors its cwd and emits session.info). +// Re-pull projects + tree so a freshly created/auto project and the relocated +// session row show live, then follow the view into the session's new project +// (from the overview or a now-stale project alike). Caller gates this on a real +// same-session cwd move, so a plain session switch never reaches here. +export async function followActiveSessionCwd(cwd: string): Promise { + const target = cwd.trim() + + if (!target) { + return + } + + await Promise.all([refreshProjects(), refreshProjectTree()]) + + // Resolve only after the refresh, so a just-created/auto project is in the tree. + const projectId = projectIdForCwd(target) + + if (projectId) { + // The Projects tree only renders in grouped mode, so flip the sidebar into + // it — otherwise following from the flat Sessions list would change scope + // invisibly. Then drill into the thread's project. + setSidebarAgentsGrouped(true) + + if (projectId !== $projectScope.get()) { + enterProject(projectId) + } + } +} + +// Issue a request on whichever gateway is currently active, reconnecting once +// if the socket dropped. Projects are per-profile, so they intentionally follow +// the active gateway just like the session list does. +async function gatewayRequest(method: string, params: Record = {}): Promise { + let gateway = activeGateway() + + if (!gateway || gateway.connectionState !== 'open') { + gateway = await ensureActiveGatewayOpen() + } + + if (!gateway) { + throw new Error('Hermes gateway is not connected') + } + + return gateway.request(method, params) +} + +function applyPayload(payload: ProjectsPayload): void { + $projects.set(payload.projects ?? []) + $activeProjectId.set(payload.active_id ?? null) +} + +// Pull the full project list + active pointer. Best-effort: a failure (gateway +// not up yet) leaves the cached atoms intact so the sidebar doesn't flicker. +export async function refreshProjects(): Promise { + try { + applyPayload(await gatewayRequest('projects.list')) + } catch { + // Backend may not be ready; keep the last known list. + } +} + +interface ProjectTreePayload { + projects: SidebarProjectTree[] + active_id: null | string + scoped_session_ids: string[] +} + +// Pull the authoritative project tree (overview structure + counts + preview +// sessions + the scoped-session-id set). Best-effort: a failure leaves the +// cached tree intact so the sidebar doesn't flicker. +export async function refreshProjectTree(): Promise { + $projectTreeLoading.set(true) + + try { + const res = await gatewayRequest('projects.tree', { preview_limit: 3 }) + // The flat Sessions list shows everything; scoped ids are only used here to + // reconcile the optimistic eviction layer against what the server still lists. + const scoped = new Set(res.scoped_session_ids ?? []) + + $projectTree.set(res.projects ?? []) + $activeProjectId.set(res.active_id ?? null) + + // Reconcile the optimistic eviction layer against the fresh snapshot: keep + // evicting ids the server still lists (delete in flight) and drop the rest + // (server caught up), so the set can't grow unbounded across a long session. + const tombstones = $removedSessionIds.get() + + if (tombstones.size) { + const pending = new Set([...tombstones].filter(id => scoped.has(id))) + + if (pending.size !== tombstones.size) { + $removedSessionIds.set(pending) + } + } + } catch { + // Backend may not be ready; keep the last known tree. + } finally { + $projectTreeLoading.set(false) + } +} + +// Fully hydrated lanes (repo -> lane -> session rows) for one project, fetched +// when the user enters it. Same backend grouping as `projects.tree`, so ids and +// membership match exactly. +export async function fetchProjectSessions(projectId: string): Promise { + try { + const res = await gatewayRequest<{ project: SidebarProjectTree | null }>('projects.project_sessions', { + project_id: projectId + }) + + return res.project ?? null + } catch { + return null + } +} + +// One filesystem scan per app run: the heavy disk walk happens once, the result +// is cached in the backend, and later opens read the cache. Desktop-only (needs +// the native crawler); elsewhere discovery falls back to session-derived repos. +let didScanRepos = false + +export async function scanAndRecordRepos(force = false): Promise { + const scan = window.hermesDesktop?.git?.scanRepos + + if (!scan || (didScanRepos && !force)) { + return + } + + didScanRepos = true + $reposScanning.set(true) + + try { + const repos = await scan([]) + await gatewayRequest('projects.record_repos', { repos }) + // The disk scan may surface new zero-session repos; refold them into the tree. + await refreshProjectTree() + } catch { + didScanRepos = false // let a later open retry a failed scan + } finally { + $reposScanning.set(false) + } +} + +export interface CreateProjectInput { + name: string + folders?: string[] + primaryPath?: string + slug?: string + description?: string + icon?: string + color?: string + boardSlug?: string + use?: boolean + // Free-text project idea; written to IDEA.md at the primary folder on create. + idea?: string +} + +// Generate a project idea via the stateless llm.oneshot RPC (inherits the live +// session's model when one exists). Returns "" on failure so the caller can just +// leave the field untouched. The "🎲" affordance in the new-project dialog. +export async function generateProjectIdea(name: string): Promise { + try { + const res = await gatewayRequest<{ text: string }>('llm.oneshot', { + instructions: + 'You generate a single, concrete project idea as a short IDEA.md body: a one-line summary, ' + + 'then 3-5 bullet goals. No preamble, no code fences, under 120 words.', + input: name.trim() ? `Project name: ${name.trim()}` : 'Surprise me with a fun project.', + temperature: 1.0 + }) + + return (res.text || '').trim() + } catch { + return '' + } +} + +// Write IDEA.md to a project's primary folder (desktop only, best-effort). Local +// fs write is hardened in the electron main; a remote backend / missing bridge +// just skips it. +async function writeProjectIdea(folder: null | string | undefined, idea: string): Promise { + const dir = (folder || '').trim() + const body = idea.trim() + const write = window.hermesDesktop?.writeTextFile + + if (!dir || !body || !write) { + return + } + + try { + await write(`${dir.replace(/[/\\]+$/, '')}/IDEA.md`, body.endsWith('\n') ? body : `${body}\n`) + } catch { + // Best-effort: the project is created regardless of whether IDEA.md lands. + } +} + +// ── Optimistic cache layer ─────────────────────────────────────────────────── +// The project cache (list + tree + active pointer) mutates instantly on user +// action; the write reconciles in the background and rolls the whole cache back +// on failure — the same Apollo-style layer the session list uses. + +interface ProjectsSnapshot { + projects: ProjectInfo[] + tree: SidebarProjectTree[] + active: null | string +} + +const snapshotProjects = (): ProjectsSnapshot => ({ + projects: $projects.get(), + tree: $projectTree.get(), + active: $activeProjectId.get() +}) + +const restoreProjects = ({ projects, tree, active }: ProjectsSnapshot): void => { + $projects.set(projects) + $projectTree.set(tree) + $activeProjectId.set(active) +} + +// Await an already-applied optimistic write; restore the snapshot if it throws. +async function persistOrRollback(snap: ProjectsSnapshot, write: () => Promise): Promise { + try { + await write() + } catch (err) { + restoreProjects(snap) + throw err + } +} + +const reconcileProjects = (): void => { + void refreshProjects() + void refreshProjectTree() +} + +// Map a ProjectInfo (list shape) onto a minimal overview tree node so a created +// project paints instantly. The backend seeds each folder as an (empty) repo, so +// the next tree refresh fills in repos/counts; this is just the optimistic stub. +function projectInfoToTreeNode(project: ProjectInfo): SidebarProjectTree { + return { + id: project.id, + label: project.name || project.id, + path: project.primary_path ?? project.folders?.[0]?.path ?? null, + color: project.color ?? null, + icon: project.icon ?? null, + isAuto: false, + repos: [], + sessionCount: 0, + previewSessions: [] + } +} + +export async function createProject(input: CreateProjectInput): Promise { + const res = await gatewayRequest<{ project: ProjectInfo | null }>('projects.create', { + name: input.name, + folders: input.folders ?? [], + primary_path: input.primaryPath, + slug: input.slug, + description: input.description, + icon: input.icon, + color: input.color, + board_slug: input.boardSlug, + use: input.use ?? false + }) + + // Not optimistic (the create awaits the RPC first, so there's nothing to roll + // back): apply the server's row into the cached list + tree at once, so it + // (and an entered scope) shows without waiting on the background refreshes + // that reconcile counts/repos. + const created = res.project + + if (created) { + if (input.idea) { + void writeProjectIdea(created.primary_path ?? created.folders?.[0]?.path ?? input.primaryPath, input.idea) + } + + if (!$projects.get().some(proj => proj.id === created.id)) { + $projects.set([...$projects.get(), created]) + } + + if (!$projectTree.get().some(node => node.id === created.id)) { + $projectTree.set([projectInfoToTreeNode(created), ...$projectTree.get()]) + } + + if (input.use) { + $activeProjectId.set(created.id) + } + } + + reconcileProjects() + + return created +} + +export async function renameProject(id: string, name: string): Promise { + await updateProject(id, { name }) +} + +// Patch top-level project fields (name / appearance). Optimistic: the cached +// tree + list update instantly so a color/icon/name change has no round-trip +// lag; only a failed write reconciles from the server. +export async function updateProject( + id: string, + patch: { name?: string; color?: null | string; icon?: null | string } +): Promise { + const snap = snapshotProjects() + + $projectTree.set( + snap.tree.map(node => + node.id === id + ? { + ...node, + ...(patch.name !== undefined && { label: patch.name }), + ...(patch.color !== undefined && { color: patch.color }), + ...(patch.icon !== undefined && { icon: patch.icon }) + } + : node + ) + ) + $projects.set(snap.projects.map(proj => (proj.id === id ? { ...proj, ...patch } : proj))) + + // Backend treats null/undefined as "leave unchanged"; "" clears (stores NULL). + // Map explicit null → "" so "no color"/"no icon" actually clear. + await persistOrRollback(snap, () => + gatewayRequest('projects.update', { + id, + ...patch, + ...(patch.color === null && { color: '' }), + ...(patch.icon === null && { icon: '' }) + }) + ) +} + +export async function addProjectFolder( + id: string, + path: string, + opts: { label?: string; isPrimary?: boolean } = {} +): Promise { + const snap = snapshotProjects() + const trimmed = path.trim() + + // Optimistic: append the folder to the cached project + reflect a primary-path + // change on its tree node, so the dialog closes onto an updated row. The folder + // -> repo seeding (and session regrouping) is backend-computed, so the + // background refresh fills repos in; a failure rolls the cache back. + if (trimmed) { + const folder = { path: trimmed, label: opts.label ?? null, is_primary: opts.isPrimary ?? false, added_at: 0 } + + $projects.set( + snap.projects.map(proj => { + if (proj.id !== id || proj.folders?.some(f => f.path === trimmed)) { + return proj + } + + const folders = opts.isPrimary + ? [folder, ...proj.folders.map(f => ({ ...f, is_primary: false }))] + : [...proj.folders, folder] + + return { ...proj, folders, ...(opts.isPrimary && { primary_path: trimmed }) } + }) + ) + + if (opts.isPrimary) { + $projectTree.set(snap.tree.map(node => (node.id === id ? { ...node, path: trimmed } : node))) + } + } + + await persistOrRollback(snap, () => + gatewayRequest('projects.add_folder', { id, path, label: opts.label, is_primary: opts.isPrimary ?? false }) + ) + reconcileProjects() +} + +// True when the session currently open in the main pane belongs to `projectId`. +// Used so deleting a project you have a session open from kicks you back to the +// intro draft instead of stranding you in a now-orphaned view. +function openSessionBelongsToProject(projectId: string, projects: ProjectInfo[]): boolean { + const openId = $selectedStoredSessionId.get() + + if (!openId) { + return false + } + + const open = $sessions.get().find(s => s.id === openId || s._lineage_root_id === openId) + + return Boolean(open && liveSessionProjectId(open, projects) === projectId) +} + +// Optimistic: drop the project from the cached tree + list the instant it's +// clicked (the entered-scope effect exits if you deleted the project you were +// inside), reconciling from the server payload. A failed delete restores both. +export async function deleteProject(id: string): Promise { + const snap = snapshotProjects() + // Capture membership BEFORE removal — the project's folders (which determine + // ownership) are gone once it's dropped from the cache. + const kickToIntro = openSessionBelongsToProject(id, snap.projects) + + $projects.set(snap.projects.filter(project => project.id !== id)) + $projectTree.set(snap.tree.filter(node => node.id !== id)) + + if (snap.active === id) { + $activeProjectId.set(null) + } + + // The open session's project is gone — reset to the intro draft (the session + // itself survives; it just falls back to Recents). + if (kickToIntro) { + requestFreshSession() + } + + await persistOrRollback(snap, async () => { + applyPayload(await gatewayRequest('projects.delete', { id })) + }) + void refreshProjectTree() +} + +export async function setActiveProject(id: null | string): Promise { + const res = await gatewayRequest<{ active_id: null | string }>('projects.set_active', { id }) + $activeProjectId.set(res.active_id ?? null) +} + +// ── Project management dialog ──────────────────────────────────────────────── +// A single dialog mounted in the sidebar reads this atom, so a project node's +// menu can open create / rename / add-folder flows without prop threading +// (mirrors $profileCreateRequest). +export interface ProjectDialogState { + mode: 'add-folder' | 'create' | 'rename' + projectId?: string + name?: string +} + +export const $projectDialog = atom(null) + +export function openProjectCreate(): void { + $projectDialog.set({ mode: 'create' }) +} + +export function openProjectRename(project: { id: string; name: string }): void { + $projectDialog.set({ mode: 'rename', name: project.name, projectId: project.id }) +} + +export function openProjectAddFolder(project: { id: string; name: string }): void { + $projectDialog.set({ mode: 'add-folder', name: project.name, projectId: project.id }) +} + +export function closeProjectDialog(): void { + $projectDialog.set(null) +} + +// ── Git-driven worktrees ("Start work") ───────────────────────────────────── +// Bumped after a `git worktree add`/`remove` so the sidebar's worktree-list +// probe (useRepoWorktreeMap) refetches and the new/removed lane shows at once, +// instead of waiting for the next scope change. +export const $worktreeRefreshToken = atom(0) +const bumpWorktrees = () => $worktreeRefreshToken.set($worktreeRefreshToken.get() + 1) + +// Re-run the visual `git worktree list` probe without the heavy projects.tree +// scan. Desktop-initiated add/remove already bumps the token inline; this is for +// OUT-OF-BAND changes the renderer can't see: the agent runs `git worktree +// add/remove` in the terminal during a turn, or an external terminal mutates the +// repo while the window was away. The probe is per-repo and bounded, so the +// caller (a settled turn / window refocus) can re-sync the worktree lanes +// cheaply, the same way a git GUI refreshes its tree on focus. +export function refreshWorktrees(): void { + bumpWorktrees() +} + +// Spin up a fresh worktree the lightest way (`git worktree add -b`) under the +// repo, returning where Hermes should start working. Git is the source of +// truth; the caller starts a session in the returned path. +export async function startWorkInRepo( + repoPath: string, + options?: { name?: string; branch?: string; base?: string; existingBranch?: string } +): Promise { + const git = window.hermesDesktop?.git + + if (!git || !repoPath) { + return null + } + + const result = await git.worktreeAdd(repoPath, options) + bumpWorktrees() + + return { branch: result.branch, path: result.path } +} + +// Local branches for the composer's "convert a branch into a worktree" picker. +// Empty on a remote backend / non-repo (the Electron probe can't run). +export async function listRepoBranches(repoPath: string): Promise { + const git = window.hermesDesktop?.git + + if (!git?.branchList || !repoPath) { + return [] + } + + return git.branchList(repoPath) +} + +export async function switchBranchInRepo(repoPath: string, branch: string): Promise { + const git = window.hermesDesktop?.git + + if (!git || !repoPath || !branch.trim()) { + return + } + + await git.branchSwitch(repoPath, branch) + bumpWorktrees() +} + +// A composer-driven "branch off into a new worktree" hand-off. The composer +// owns the typed draft; the chat controller owns session lifecycle. The composer +// creates the worktree (startWorkInRepo), then fires this so the controller opens +// a fresh session in that worktree and prefills the draft that kicked off the +// task. A monotonic token lets a rapid second request re-fire the controller's +// effect even if the path repeats. +export interface StartWorkSessionRequest { + draft?: string + path: string + token: number +} + +export const $startWorkSessionRequest = atom(null) + +// Keyboard-driven "spin up a new worktree" intent. The composer's coding rail +// owns the name dialog (it has the active repo + branch context), so a global +// hotkey just bumps this token; the rail opens its branch-off dialog in +// response. A monotonic token re-fires even on repeat presses. No-ops off a +// repo (the rail isn't mounted), which is the right "nothing to branch" outcome. +export const $newWorktreeRequest = atom(0) + +export function requestNewWorktree(): void { + $newWorktreeRequest.set($newWorktreeRequest.get() + 1) +} + +let startWorkToken = 0 + +export function requestStartWorkSession(path: string, draft?: string): void { + const target = path.trim() + + if (!target) { + return + } + + startWorkToken += 1 + $startWorkSessionRequest.set({ draft: draft?.trim() || undefined, path: target, token: startWorkToken }) +} + +export async function removeWorktreePath( + repoPath: string, + worktreePath: string, + options?: { force?: boolean } +): Promise { + const git = window.hermesDesktop?.git + + if (!git) { + return + } + + await git.worktreeRemove(repoPath, worktreePath, options) + bumpWorktrees() +} + +// Reveal a project/worktree path in the OS file manager (git-GUI standard). +export async function revealPath(path: null | string): Promise { + if (path) { + await window.hermesDesktop?.revealPath?.(path) + } +} + +// Copy a path to the clipboard (git-GUI standard). +export async function copyPath(path: null | string): Promise { + if (path) { + await window.hermesDesktop?.writeClipboard?.(path) + } +} + +// Open the native directory picker (reuses the Electron default-project-dir +// chooser). Returns the chosen absolute path, or null when cancelled. +export async function pickProjectFolder(): Promise { + const pick = window.hermesDesktop?.settings?.pickDefaultProjectDir + + if (!pick) { + return null + } + + try { + const result = await pick() + + return result.canceled ? null : result.dir + } catch { + return null + } +} diff --git a/apps/desktop/src/store/workspace-events.ts b/apps/desktop/src/store/workspace-events.ts new file mode 100644 index 00000000000..174427a2859 --- /dev/null +++ b/apps/desktop/src/store/workspace-events.ts @@ -0,0 +1,60 @@ +import { atom } from 'nanostores' + +// Event-driven "the working tree changed" signal — the smart replacement for +// polling. The agent only mutates files by running a tool, so the message +// stream's `tool.complete` (esp. ones carrying an inline_diff) is the precise +// trigger. Surfaces that mirror the filesystem / git state — the coding rail, +// the review pane, the file tree — subscribe to this tick and refresh, so they +// move exactly when the agent acts and stay idle otherwise. + +export const $workspaceChangeTick = atom(0) + +// Throttle so a burst of edits in one turn coalesces: fire on the leading edge +// for instant feedback, then at most once per window (a trailing fire catches +// the last edit of the burst). +const MIN_INTERVAL_MS = 500 +let lastFired = 0 +let trailing: null | ReturnType = null + +function fire(): void { + lastFired = Date.now() + $workspaceChangeTick.set($workspaceChangeTick.get() + 1) +} + +export function notifyWorkspaceChanged(): void { + const since = Date.now() - lastFired + + if (since >= MIN_INTERVAL_MS) { + if (trailing) { + clearTimeout(trailing) + trailing = null + } + + fire() + } else if (!trailing) { + trailing = setTimeout(() => { + trailing = null + fire() + }, MIN_INTERVAL_MS - since) + } +} + +// Tool names that can touch the working tree (everything else — read_file, +// search, web — never does, so it shouldn't trigger a refresh). NB: no bare +// `file` token — it matched the read-only `read_file` / `search_files` / +// `list_files`, firing a git probe on the single most common tool. Real file +// writers carry a verb (`write_file`, `apply_patch`, …) or an inline_diff. +const MUTATING_TOOL_RE = + /terminal|shell|exec|bash|command|write|edit|patch|replace|apply|create|delete|remove|move|rename|mkdir|format/i + +/** True when a finished tool may have changed files (carries a diff, or its + * name implies a filesystem/terminal mutation). */ +export function toolMayMutateFiles(payload: { name?: unknown; tool?: unknown; inline_diff?: unknown }): boolean { + if (typeof payload.inline_diff === 'string' && payload.inline_diff.trim()) { + return true + } + + const name = String(payload.name ?? payload.tool ?? '') + + return MUTATING_TOOL_RE.test(name) +}