Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/main/ipc/repos/repo-ipc-arg-schemas.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { ProjectUpdate } from '../../../shared/rpc-contract/project-runtime-params'
import { isTuiAgent } from '../../../shared/tui-agent-config'
import { TaskSourceContextSchema } from '../../../shared/task-source-context-schema'
import { WorkspaceLinkedItemSchema } from '../../../shared/workspace-linked-item-schema'
Expand Down Expand Up @@ -60,7 +61,8 @@ const LocalWindowsRuntimePreferenceIpcArgs = z.discriminatedUnion('kind', [
export const ProjectUpdateIpcArgs = z.object({
projectId: z.string().min(1),
updates: z.object({
localWindowsRuntimePreference: LocalWindowsRuntimePreferenceIpcArgs.optional()
localWindowsRuntimePreference: LocalWindowsRuntimePreferenceIpcArgs.optional(),
coordination: ProjectUpdate.shape.updates.shape.coordination
})
})

Expand Down
13 changes: 13 additions & 0 deletions src/main/orca-profiles/profile-project-state-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ function makeState(overrides: Partial<PersistedState>): PersistedState {
}

describe('rebuildRepoBackedProjectState', () => {
it('preserves saved project context when rebuilding transferred profile state', () => {
const coordination = { goal: 'Ship', instructions: 'Review first', revision: 3 }
const rebuilt = rebuildRepoBackedProjectState(
makeState({
repos: [makeRepo()],
projects: [makeProject({ id: 'repo:r1', sourceRepoIds: ['r1'], coordination })]
})
)
expect(
rebuilt.projects?.find((project) => project.sourceRepoIds.includes('r1'))?.coordination
).toEqual(coordination)
})

it('carries project state and independent setups across a repo remote identity change', () => {
const originProjectId = 'git:git.example.com/acme/app'
const rebuilt = rebuildRepoBackedProjectState(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { applyProjectCoordinationUpdate } from '../../../shared/project-coordination'
import type {
Project,
ProjectHostSetup,
Expand Down Expand Up @@ -87,6 +88,12 @@ export class ProjectHostPersistenceOperations {
if (!project) {
return null
}
if (updates.coordination) {
project.coordination = applyProjectCoordinationUpdate(
project.coordination,
updates.coordination
)
}
if ('localWindowsRuntimePreference' in updates) {
for (const repoId of project.sourceRepoIds) {
this.operations.bumpLocalWorktreeScanGeneration(repoId)
Expand Down
7 changes: 7 additions & 0 deletions src/main/runtime/rpc/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ProjectCoordinationConflictError } from '../../../shared/project-coordination'
import { describe, expect, it } from 'vitest'
import { mapRuntimeError } from './errors'
import {
Expand All @@ -24,6 +25,12 @@ class LineageError extends Error {
}

describe('mapRuntimeError', () => {
it('preserves a project context revision conflict across RPC', () => {
expect(
mapRuntimeError('request', { runtimeId: 'host' }, new ProjectCoordinationConflictError())
).toMatchObject({ ok: false, error: { code: 'project_coordination_conflict' } })
})

it('preserves the stable skill failure category and retryability across RPC', () => {
expect(
mapRuntimeError(
Expand Down
2 changes: 2 additions & 0 deletions src/main/runtime/rpc/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../../shared/automation-owne
import { ARCHIVE_HOOK_FAILED_REMOVAL_CODE } from '../../../shared/worktree/archive-hook-removal-gate'
import { NESTED_WORKER_DEPTH_EXCEEDED_CODE } from '../../../shared/nested-worker-depth'
import { WORKTREE_CREATE_COLLISION_CODE } from '../../../shared/new-workspace/worktree-create-collision'
import { PROJECT_COORDINATION_CONFLICT_CODE } from '../../../shared/project-coordination'

export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess {
return {
Expand Down Expand Up @@ -83,6 +84,7 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
const COMPUTER_PASSTHROUGH_CODES: ReadonlySet<string> = new Set(Object.values(COMPUTER_ERROR_CODES))
const LINEAR_PASSTHROUGH_CODES: ReadonlySet<string> = new Set(LINEAR_ERROR_CODES)
const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
PROJECT_COORDINATION_CONFLICT_CODE,
WORKTREE_CREATE_COLLISION_CODE,
'worktree_id_requires_full_path',
'run_not_found',
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/src/app-shell/AppWorkspaceShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { RightSidebarToggle, TitlebarMainStrip } from './TitlebarMainStrip'
import type { AppChromeLayout } from './use-app-chrome-layout'
import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel'

const ManagementOverviewPage = lazy(
() => import('../components/management-overview/ManagementOverviewPage')
)
const ProjectHomePage = lazy(() => import('../components/project-home/ProjectHomePage'))
const Landing = lazy(() => import('../components/Landing'))
const WorktreeCreationPanel = lazy(
() => import('../components/worktree-creation/WorktreeCreationPanel')
Expand Down Expand Up @@ -68,6 +72,8 @@ function ActivePage({ layout }: { layout: AppChromeLayout }): React.JSX.Element
const { activeView, activeWorktreeId, activePendingCreationId, creationLayoutActive } = layout
return (
<>
{activeView === 'project-home' ? <ProjectHomePage /> : null}
{activeView === 'management-overview' ? <ManagementOverviewPage /> : null}
{activeView === 'settings' ? <Settings /> : null}
{activeView === 'skills' ? <SkillsPage /> : null}
{activeView === 'artifacts' ? <ArtifactsPage /> : null}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { isWebClientLocation } from '@/lib/web-client-location'
import { LayoutDashboard } from 'lucide-react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'

export function ManagementOverviewNav(): React.JSX.Element | null {
const active = useAppStore((state) => state.activeView === 'management-overview')
if (isWebClientLocation()) {
return null
}
return (
<button
type="button"
aria-current={active ? 'page' : undefined}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
active
? 'bg-worktree-sidebar-accent text-worktree-sidebar-accent-foreground'
: 'text-worktree-sidebar-foreground/60 hover:bg-worktree-sidebar-foreground/8'
)}
onClick={() => useAppStore.getState().setActiveView('management-overview')}
>
<LayoutDashboard className="size-4 shrink-0" />
{translate('managementOverview.title', 'Overview')}
</button>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { translate } from '@/i18n/i18n'
import { isWebClientLocation } from '@/lib/web-client-location'
import { useLiveDashboardSnapshot } from '../dashboard/useLiveDashboardSnapshot'
import { revealDashboardAgent } from '../dashboard/reveal-dashboard-agent'
import { AgentKanbanBoard } from '../dashboard-popout/AgentKanbanBoard'
import { overviewHosts } from './overview-hosts'
import { OverviewProjects } from './OverviewProjects'
import { OverviewRuns } from './OverviewRuns'

function OverviewAgents(): React.JSX.Element {
const snapshot = useLiveDashboardSnapshot()
return (
<AgentKanbanBoard
snapshot={snapshot}
containerClassName="h-full w-full"
onAckAgent={(key) => useAppStore.getState().acknowledgeAgents([key])}
onRevealAgent={revealDashboardAgent}
/>
)
}

export default function ManagementOverviewPage(): React.JSX.Element {
const environments = useAppStore((state) => state.runtimeEnvironments)
const statuses = useAppStore((state) => state.runtimeStatusByEnvironmentId)
const sshLabels = useAppStore((state) => state.sshTargetLabels)
const sshStates = useAppStore((state) => state.sshConnectionStates)
const profile = useAppStore((state) => state.activeOrcaProfileId)
const switching = useAppStore((state) => state.orcaProfileSwitching)
const hosts = overviewHosts({
runtimeEnvironments: environments,
runtimeStatusByEnvironmentId: statuses,
sshTargetLabels: sshLabels,
sshConnectionStates: sshStates
})
const scope = JSON.stringify([
profile,
hosts.map((host) => [host.key, host.pairingRevision, host.state])
])
if (isWebClientLocation()) {
return (
<p className="p-6 text-sm text-muted-foreground">
{translate(
'managementOverview.desktop',
'Open Overview in the desktop app to see all connected hosts.'
)}
</p>
)
}
if (switching) {
return (
<p role="status" className="p-6 text-sm">
{translate('managementOverview.switching', 'Switching profile…')}
</p>
)
}
return (
<div className="h-full overflow-auto scrollbar-sleek">
<div className="mx-auto max-w-5xl space-y-6 p-6">
<header className="space-y-3">
<h1 className="text-xl font-semibold">
{translate('managementOverview.title', 'Overview')}
</h1>
<p className="text-sm text-muted-foreground">
{translate(
'managementOverview.description',
'Projects and execution across your connected hosts. Plan and triage work in Linear.'
)}
</p>
<div className="flex flex-wrap gap-2">
<Button onClick={() => useAppStore.getState().openTaskPage({ taskSource: 'linear' })}>
{translate('managementOverview.linear', 'Open Linear work')}
</Button>
<Button variant="outline" onClick={() => useAppStore.getState().openAutomationsPage()}>
{translate('managementOverview.automations', 'Automations')}
</Button>
<Button
variant="outline"
onClick={() =>
useAppStore.getState().openSettingsTarget({ pane: 'stats', repoId: null })
}
>
{translate('managementOverview.usage', 'Usage')}
</Button>
</div>
</header>
{hosts.some((host) => host.state !== 'connected') ? (
<p role="status" className="text-sm text-muted-foreground">
{translate(
'managementOverview.incomplete',
'Some hosts are unavailable. Projects and agent activity may be incomplete; check Hosts for connection status.'
)}
</p>
) : null}
<Tabs defaultValue="projects">
<TabsList variant="line">
<TabsTrigger value="projects">
{translate('managementOverview.projects', 'Projects')}
</TabsTrigger>
<TabsTrigger value="agents">
{translate('managementOverview.agents', 'Agents')}
</TabsTrigger>
<TabsTrigger value="runs">{translate('managementOverview.runs', 'Runs')}</TabsTrigger>
<TabsTrigger value="hosts">
{translate('managementOverview.hosts', 'Hosts')}
</TabsTrigger>
</TabsList>
<TabsContent value="projects">
<OverviewProjects hosts={hosts} />
</TabsContent>
<TabsContent value="agents">
<OverviewAgents />
</TabsContent>
<TabsContent value="runs">
<OverviewRuns key={scope} hosts={hosts} />
</TabsContent>
<TabsContent value="hosts">
<div className="divide-y divide-border">
{hosts.map((host) => (
<div key={host.key} className="flex items-center justify-between gap-3 py-3">
<div>
<p className="text-sm font-medium">{host.label}</p>
<p className="text-xs text-muted-foreground">{host.stateLabel}</p>
</div>
</div>
))}
</div>
<p className="my-3 text-sm text-muted-foreground">
{translate(
'managementOverview.coverage',
'Unavailable hosts may still have running work. Their current execution state is unverifiable.'
)}
</p>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() =>
useAppStore.getState().openSettingsTarget({ pane: 'servers', repoId: null })
}
>
{translate('managementOverview.manageHosts', 'Manage servers')}
</Button>
<Button
variant="outline"
onClick={() =>
useAppStore.getState().openSettingsTarget({ pane: 'ssh', repoId: null })
}
>
{translate('managementOverview.manageSsh', 'Manage SSH connections')}
</Button>
</div>
</TabsContent>
</Tabs>
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useState } from 'react'
import { useAppStore } from '@/store'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
import type { OverviewHost } from './overview-hosts'

export function OverviewProjects({ hosts }: { hosts: OverviewHost[] }): React.JSX.Element {
const repos = useAppStore((state) => state.repos)
const folders = useAppStore((state) => state.folderWorkspaces)
const [query, setQuery] = useState('')
const matches = (name: string) =>
name.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())
const shownRepos = repos.filter((repo) => matches(repo.displayName))
const shownFolders = folders.filter((folder) => !folder.isArchived && matches(folder.name))
return (
<section className="space-y-4">
<Button variant="outline" onClick={() => void useAppStore.getState().addRepo()}>
{translate('managementOverview.addProject', 'Add project')}
</Button>
<Input
aria-label={translate('managementOverview.searchProjects', 'Search projects and folders')}
placeholder={translate('managementOverview.searchProjects', 'Search projects and folders')}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{!shownRepos.length && !shownFolders.length ? (
<p className="text-sm text-muted-foreground">
{translate('managementOverview.noProjects', 'No matching projects or folders.')}
</p>
) : null}
<div className="divide-y divide-border">
{shownRepos.map((repo) => {
const host = getRepoExecutionHostId(repo)
return (
<div
key={`${host}:${repo.id}:${repo.path}`}
className="flex flex-wrap items-center justify-between gap-3 py-3"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{repo.displayName}</p>
<p className="break-all text-xs text-muted-foreground">
{hosts.find((entry) => entry.key === host)?.label ?? host} · {repo.path}
</p>
</div>
<Button
variant="outline"
onClick={() => useAppStore.getState().openProjectHome(repo)}
>
{translate('projectHome.title', 'Project home')}
</Button>
</div>
)
})}
{shownFolders.map((folder) => {
const workspace = folderWorkspaceToWorktree(folder)
return (
<div
key={`${workspace.hostId}:${folder.id}`}
className="flex flex-wrap items-center justify-between gap-3 py-3"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{folder.name}</p>
<p className="break-all text-xs text-muted-foreground">
{hosts.find((entry) => entry.key === workspace.hostId)?.label ?? workspace.hostId}{' '}
· {folder.folderPath}
</p>
</div>
<Button
variant="outline"
onClick={() =>
activateAndRevealWorkspace(workspace.id, { executionHostId: workspace.hostId })
}
>
{translate('managementOverview.openFolder', 'Open folder')}
</Button>
</div>
)
})}
</div>
</section>
)
}
Loading