mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
feat(desktop): add project and coding stores
This commit is contained in:
parent
344415892f
commit
74352a1e61
8 changed files with 1205 additions and 45 deletions
74
apps/desktop/src/store/coding-status.test.ts
Normal file
74
apps/desktop/src/store/coding-status.test.ts
Normal file
|
|
@ -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<HermesRepoStatus | null>) {
|
||||
;(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()
|
||||
})
|
||||
})
|
||||
165
apps/desktop/src/store/coding-status.ts
Normal file
165
apps/desktop/src/store/coding-status.ts
Normal file
|
|
@ -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<HermesRepoStatus | null>(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<HermesGitWorktree[]>([])
|
||||
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<string, RepoChangeKind>()
|
||||
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<void> {
|
||||
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<typeof setTimeout> | 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<void> {
|
||||
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())
|
||||
}
|
||||
|
|
@ -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<boolean> = computed(
|
|||
states => states[FILE_BROWSER_PANE_ID]?.open ?? false
|
||||
)
|
||||
|
||||
export const $rightRailActiveTabId = atom<RightRailTabId>(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<RightRailTabId>(
|
||||
RIGHT_RAIL_ACTIVE_TAB_STORAGE_KEY,
|
||||
RIGHT_RAIL_PREVIEW_TAB_ID,
|
||||
{ decode: raw => raw as RightRailTabId, encode: tabId => tabId }
|
||||
)
|
||||
|
||||
export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states => {
|
||||
const override = states[CHAT_SIDEBAR_PANE_ID]?.widthOverride
|
||||
|
|
@ -59,13 +66,29 @@ export const $sidebarWidth: ReadableAtom<number> = 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<string[]>(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 | string>(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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,27 +56,20 @@ function load(): Record<string, PaneStateSnapshot> {
|
|||
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<string, PaneStateSnapshot>) {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const minimal: Record<string, { open: boolean }> = {}
|
||||
|
||||
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<Record<string, PaneStateSnapshot>>(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)
|
||||
|
|
|
|||
|
|
@ -144,12 +144,13 @@ export const $activeGatewayProfile = atom<string>('default')
|
|||
// / default, so single-profile users are unaffected.
|
||||
export const $newChatProfile = atom<string | null>(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)
|
||||
}
|
||||
|
||||
|
|
|
|||
52
apps/desktop/src/store/projects.test.ts
Normal file
52
apps/desktop/src/store/projects.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
754
apps/desktop/src/store/projects.ts
Normal file
754
apps/desktop/src/store/projects.ts
Normal file
|
|
@ -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<ProjectInfo[]>([])
|
||||
export const $activeProjectId = atom<null | string>(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<SidebarProjectTree[]>([])
|
||||
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<Set<string>>(new Set())
|
||||
|
||||
export function tombstoneSessions(ids: Array<null | string | undefined>): 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<null | string | undefined>): 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<string>(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_<hex>`) 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<void> {
|
||||
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<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
let gateway = activeGateway()
|
||||
|
||||
if (!gateway || gateway.connectionState !== 'open') {
|
||||
gateway = await ensureActiveGatewayOpen()
|
||||
}
|
||||
|
||||
if (!gateway) {
|
||||
throw new Error('Hermes gateway is not connected')
|
||||
}
|
||||
|
||||
return gateway.request<T>(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<void> {
|
||||
try {
|
||||
applyPayload(await gatewayRequest<ProjectsPayload>('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<void> {
|
||||
$projectTreeLoading.set(true)
|
||||
|
||||
try {
|
||||
const res = await gatewayRequest<ProjectTreePayload>('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<SidebarProjectTree | null> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void>): Promise<void> {
|
||||
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<ProjectInfo | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ProjectsPayload>('projects.delete', { id }))
|
||||
})
|
||||
void refreshProjectTree()
|
||||
}
|
||||
|
||||
export async function setActiveProject(id: null | string): Promise<void> {
|
||||
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 | ProjectDialogState>(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<null | { path: string; branch: string }> {
|
||||
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<HermesGitBranch[]> {
|
||||
const git = window.hermesDesktop?.git
|
||||
|
||||
if (!git?.branchList || !repoPath) {
|
||||
return []
|
||||
}
|
||||
|
||||
return git.branchList(repoPath)
|
||||
}
|
||||
|
||||
export async function switchBranchInRepo(repoPath: string, branch: string): Promise<void> {
|
||||
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<StartWorkSessionRequest | null>(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<void> {
|
||||
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<void> {
|
||||
if (path) {
|
||||
await window.hermesDesktop?.revealPath?.(path)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy a path to the clipboard (git-GUI standard).
|
||||
export async function copyPath(path: null | string): Promise<void> {
|
||||
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<null | string> {
|
||||
const pick = window.hermesDesktop?.settings?.pickDefaultProjectDir
|
||||
|
||||
if (!pick) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pick()
|
||||
|
||||
return result.canceled ? null : result.dir
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
60
apps/desktop/src/store/workspace-events.ts
Normal file
60
apps/desktop/src/store/workspace-events.ts
Normal file
|
|
@ -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<typeof setTimeout> = 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue