2026-03-28 12:15:34 -05:00
import { useEffect , useMemo , useState } from "react" ;
2026-03-13 17:12:25 -05:00
import { Link , useParams } from "@/lib/router" ;
2026-03-28 12:15:34 -05:00
import { useMutation , useQuery , useQueryClient } from "@tanstack/react-query" ;
import type { ExecutionWorkspace , Project , ProjectWorkspace } from "@paperclipai/shared" ;
import { ArrowLeft , Check , Copy , ExternalLink , Loader2 } from "lucide-react" ;
import { Button } from "@/components/ui/button" ;
import { Separator } from "@/components/ui/separator" ;
import { CopyText } from "../components/CopyText" ;
2026-03-28 16:15:20 -05:00
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog" ;
2026-03-13 17:12:25 -05:00
import { executionWorkspacesApi } from "../api/execution-workspaces" ;
2026-03-28 12:15:34 -05:00
import { issuesApi } from "../api/issues" ;
import { projectsApi } from "../api/projects" ;
import { useBreadcrumbs } from "../context/BreadcrumbContext" ;
import { useCompany } from "../context/CompanyContext" ;
2026-03-13 17:12:25 -05:00
import { queryKeys } from "../lib/queryKeys" ;
2026-03-28 12:15:34 -05:00
import { cn , formatDateTime , issueUrl , projectRouteRef , projectWorkspaceUrl } from "../lib/utils" ;
type WorkspaceFormState = {
name : string ;
cwd : string ;
repoUrl : string ;
baseRef : string ;
branchName : string ;
providerRef : string ;
provisionCommand : string ;
teardownCommand : string ;
cleanupCommand : string ;
2026-03-28 16:46:43 -05:00
inheritRuntime : boolean ;
2026-03-28 12:15:34 -05:00
workspaceRuntime : string ;
} ;
2026-03-13 17:12:25 -05:00
2026-03-16 20:12:22 -05:00
function isSafeExternalUrl ( value : string | null | undefined ) {
if ( ! value ) return false ;
try {
const parsed = new URL ( value ) ;
return parsed . protocol === "http:" || parsed . protocol === "https:" ;
} catch {
return false ;
}
}
2026-03-28 12:15:34 -05:00
function readText ( value : string | null | undefined ) {
return value ? ? "" ;
}
2026-04-04 13:15:46 -05:00
function hasActiveRuntimeServices ( workspace : ExecutionWorkspace | null | undefined ) {
return ( workspace ? . runtimeServices ? ? [ ] ) . some ( ( service ) = > service . status === "starting" || service . status === "running" ) ;
}
2026-03-28 12:15:34 -05:00
function formatJson ( value : Record < string , unknown > | null | undefined ) {
if ( ! value || Object . keys ( value ) . length === 0 ) return "" ;
return JSON . stringify ( value , null , 2 ) ;
}
function normalizeText ( value : string ) {
const trimmed = value . trim ( ) ;
return trimmed . length > 0 ? trimmed : null ;
}
function parseWorkspaceRuntimeJson ( value : string ) {
const trimmed = value . trim ( ) ;
if ( ! trimmed ) return { ok : true as const , value : null as Record < string , unknown > | null } ;
try {
const parsed = JSON . parse ( trimmed ) ;
if ( ! parsed || typeof parsed !== "object" || Array . isArray ( parsed ) ) {
return {
ok : false as const ,
error : "Workspace runtime JSON must be a JSON object." ,
} ;
}
return { ok : true as const , value : parsed as Record < string , unknown > } ;
} catch ( error ) {
return {
ok : false as const ,
error : error instanceof Error ? error . message : "Invalid JSON." ,
} ;
}
}
function formStateFromWorkspace ( workspace : ExecutionWorkspace ) : WorkspaceFormState {
return {
name : workspace.name ,
cwd : readText ( workspace . cwd ) ,
repoUrl : readText ( workspace . repoUrl ) ,
baseRef : readText ( workspace . baseRef ) ,
branchName : readText ( workspace . branchName ) ,
providerRef : readText ( workspace . providerRef ) ,
provisionCommand : readText ( workspace . config ? . provisionCommand ) ,
teardownCommand : readText ( workspace . config ? . teardownCommand ) ,
cleanupCommand : readText ( workspace . config ? . cleanupCommand ) ,
2026-03-28 16:46:43 -05:00
inheritRuntime : ! workspace . config ? . workspaceRuntime ,
2026-03-28 12:15:34 -05:00
workspaceRuntime : formatJson ( workspace . config ? . workspaceRuntime ) ,
} ;
}
function buildWorkspacePatch ( initialState : WorkspaceFormState , nextState : WorkspaceFormState ) {
const patch : Record < string , unknown > = { } ;
const configPatch : Record < string , unknown > = { } ;
const maybeAssign = (
key : keyof Pick < WorkspaceFormState , "name" | "cwd" | "repoUrl" | "baseRef" | "branchName" | "providerRef" > ,
) = > {
if ( initialState [ key ] === nextState [ key ] ) return ;
patch [ key ] = key === "name" ? ( normalizeText ( nextState [ key ] ) ? ? initialState . name ) : normalizeText ( nextState [ key ] ) ;
} ;
maybeAssign ( "name" ) ;
maybeAssign ( "cwd" ) ;
maybeAssign ( "repoUrl" ) ;
maybeAssign ( "baseRef" ) ;
maybeAssign ( "branchName" ) ;
maybeAssign ( "providerRef" ) ;
const maybeAssignConfigText = ( key : keyof Pick < WorkspaceFormState , "provisionCommand" | "teardownCommand" | "cleanupCommand" > ) = > {
if ( initialState [ key ] === nextState [ key ] ) return ;
configPatch [ key ] = normalizeText ( nextState [ key ] ) ;
} ;
maybeAssignConfigText ( "provisionCommand" ) ;
maybeAssignConfigText ( "teardownCommand" ) ;
maybeAssignConfigText ( "cleanupCommand" ) ;
2026-03-28 16:46:43 -05:00
if ( initialState . inheritRuntime !== nextState . inheritRuntime || initialState . workspaceRuntime !== nextState . workspaceRuntime ) {
2026-03-28 12:15:34 -05:00
const parsed = parseWorkspaceRuntimeJson ( nextState . workspaceRuntime ) ;
if ( ! parsed . ok ) throw new Error ( parsed . error ) ;
2026-03-28 16:46:43 -05:00
configPatch . workspaceRuntime = nextState . inheritRuntime ? null : parsed . value ;
2026-03-28 12:15:34 -05:00
}
if ( Object . keys ( configPatch ) . length > 0 ) {
patch . config = configPatch ;
}
return patch ;
}
function validateForm ( form : WorkspaceFormState ) {
const repoUrl = normalizeText ( form . repoUrl ) ;
if ( repoUrl ) {
try {
new URL ( repoUrl ) ;
} catch {
return "Repo URL must be a valid URL." ;
}
}
2026-03-28 16:46:43 -05:00
if ( ! form . inheritRuntime ) {
const runtimeJson = parseWorkspaceRuntimeJson ( form . workspaceRuntime ) ;
if ( ! runtimeJson . ok ) {
return runtimeJson . error ;
}
2026-03-28 12:15:34 -05:00
}
return null ;
}
function Field ( {
label ,
hint ,
children ,
} : {
label : string ;
hint? : string ;
children : React.ReactNode ;
} ) {
return (
< label className = "space-y-1.5" >
2026-03-28 19:28:11 -05:00
< div className = "flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3" >
2026-03-28 12:15:34 -05:00
< span className = "text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground" > { label } < / span >
2026-03-28 19:28:11 -05:00
{ hint ? < span className = "text-[11px] leading-relaxed text-muted-foreground sm:text-right" > { hint } < / span > : null }
2026-03-28 12:15:34 -05:00
< / div >
{ children }
< / label >
) ;
}
2026-03-13 17:12:25 -05:00
function DetailRow ( { label , children } : { label : string ; children : React.ReactNode } ) {
return (
2026-03-28 19:28:11 -05:00
< div className = "flex flex-col gap-1.5 py-1.5 sm:flex-row sm:items-start sm:gap-3" >
< div className = "shrink-0 text-xs text-muted-foreground sm:w-32" > { label } < / div >
2026-03-13 17:12:25 -05:00
< div className = "min-w-0 flex-1 text-sm" > { children } < / div >
< / div >
) ;
}
2026-03-28 12:15:34 -05:00
function StatusPill ( { children , className } : { children : React.ReactNode ; className? : string } ) {
return (
< div className = { cn ( "inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground" , className ) } >
{ children }
< / div >
) ;
}
function MonoValue ( { value , copy } : { value : string ; copy? : boolean } ) {
return (
2026-03-28 19:28:11 -05:00
< div className = "inline-flex max-w-full items-start gap-2" >
2026-03-28 12:15:34 -05:00
< span className = "break-all font-mono text-xs" > { value } < / span >
{ copy ? (
< CopyText text = { value } className = "shrink-0 text-muted-foreground hover:text-foreground" copiedLabel = "Copied" >
< Copy className = "h-3.5 w-3.5" / >
< / CopyText >
) : null }
< / div >
) ;
}
function WorkspaceLink ( {
project ,
workspace ,
} : {
project : Project ;
workspace : ProjectWorkspace ;
} ) {
return < Link to = { projectWorkspaceUrl ( project , workspace . id ) } className = "hover:underline" > { workspace . name } < / Link > ;
}
2026-03-13 17:12:25 -05:00
export function ExecutionWorkspaceDetail() {
const { workspaceId } = useParams < { workspaceId : string } > ( ) ;
2026-03-28 12:15:34 -05:00
const queryClient = useQueryClient ( ) ;
const { setBreadcrumbs } = useBreadcrumbs ( ) ;
const { selectedCompanyId , setSelectedCompanyId } = useCompany ( ) ;
const [ form , setForm ] = useState < WorkspaceFormState | null > ( null ) ;
2026-03-28 16:15:20 -05:00
const [ closeDialogOpen , setCloseDialogOpen ] = useState ( false ) ;
2026-03-28 12:15:34 -05:00
const [ errorMessage , setErrorMessage ] = useState < string | null > ( null ) ;
2026-03-28 16:46:43 -05:00
const [ runtimeActionMessage , setRuntimeActionMessage ] = useState < string | null > ( null ) ;
2026-03-13 17:12:25 -05:00
2026-03-28 12:15:34 -05:00
const workspaceQuery = useQuery ( {
2026-03-13 17:12:25 -05:00
queryKey : queryKeys.executionWorkspaces.detail ( workspaceId ! ) ,
queryFn : ( ) = > executionWorkspacesApi . get ( workspaceId ! ) ,
enabled : Boolean ( workspaceId ) ,
} ) ;
2026-03-28 12:15:34 -05:00
const workspace = workspaceQuery . data ? ? null ;
const projectQuery = useQuery ( {
queryKey : workspace ? [ . . . queryKeys . projects . detail ( workspace . projectId ) , workspace . companyId ] : [ "projects" , "detail" , "__pending__" ] ,
queryFn : ( ) = > projectsApi . get ( workspace ! . projectId , workspace ! . companyId ) ,
enabled : Boolean ( workspace ? . projectId ) ,
} ) ;
const project = projectQuery . data ? ? null ;
const sourceIssueQuery = useQuery ( {
queryKey : workspace?.sourceIssueId ? queryKeys . issues . detail ( workspace . sourceIssueId ) : [ "issues" , "detail" , "__none__" ] ,
queryFn : ( ) = > issuesApi . get ( workspace ! . sourceIssueId ! ) ,
enabled : Boolean ( workspace ? . sourceIssueId ) ,
} ) ;
const sourceIssue = sourceIssueQuery . data ? ? null ;
const derivedWorkspaceQuery = useQuery ( {
queryKey : workspace?.derivedFromExecutionWorkspaceId
? queryKeys . executionWorkspaces . detail ( workspace . derivedFromExecutionWorkspaceId )
: [ "execution-workspaces" , "detail" , "__none__" ] ,
queryFn : ( ) = > executionWorkspacesApi . get ( workspace ! . derivedFromExecutionWorkspaceId ! ) ,
enabled : Boolean ( workspace ? . derivedFromExecutionWorkspaceId ) ,
} ) ;
const derivedWorkspace = derivedWorkspaceQuery . data ? ? null ;
2026-03-28 22:21:24 -05:00
const linkedIssuesQuery = useQuery ( {
queryKey : workspace
? queryKeys . issues . listByExecutionWorkspace ( workspace . companyId , workspace . id )
: [ "issues" , "__execution-workspace__" , "__none__" ] ,
queryFn : ( ) = > issuesApi . list ( workspace ! . companyId , { executionWorkspaceId : workspace ! . id } ) ,
enabled : Boolean ( workspace ? . companyId ) ,
} ) ;
const linkedIssues = linkedIssuesQuery . data ? ? [ ] ;
2026-03-28 12:15:34 -05:00
const linkedProjectWorkspace = useMemo (
( ) = > project ? . workspaces . find ( ( item ) = > item . id === workspace ? . projectWorkspaceId ) ? ? null ,
[ project , workspace ? . projectWorkspaceId ] ,
) ;
2026-03-28 16:46:43 -05:00
const inheritedRuntimeConfig = linkedProjectWorkspace ? . runtimeConfig ? . workspaceRuntime ? ? null ;
const effectiveRuntimeConfig = workspace ? . config ? . workspaceRuntime ? ? inheritedRuntimeConfig ;
const runtimeConfigSource =
workspace ? . config ? . workspaceRuntime
? "execution_workspace"
: inheritedRuntimeConfig
? "project_workspace"
: "none" ;
2026-03-28 12:15:34 -05:00
const initialState = useMemo ( ( ) = > ( workspace ? formStateFromWorkspace ( workspace ) : null ) , [ workspace ] ) ;
const isDirty = Boolean ( form && initialState && JSON . stringify ( form ) !== JSON . stringify ( initialState ) ) ;
const projectRef = project ? projectRouteRef ( project ) : workspace ? . projectId ? ? "" ;
useEffect ( ( ) = > {
if ( ! workspace ? . companyId || workspace . companyId === selectedCompanyId ) return ;
setSelectedCompanyId ( workspace . companyId , { source : "route_sync" } ) ;
} , [ workspace ? . companyId , selectedCompanyId , setSelectedCompanyId ] ) ;
useEffect ( ( ) = > {
if ( ! workspace ) return ;
setForm ( formStateFromWorkspace ( workspace ) ) ;
setErrorMessage ( null ) ;
} , [ workspace ] ) ;
useEffect ( ( ) = > {
if ( ! workspace ) return ;
const crumbs = [
{ label : "Projects" , href : "/projects" } ,
. . . ( project ? [ { label : project.name , href : ` /projects/ ${ projectRef } ` } ] : [ ] ) ,
. . . ( project ? [ { label : "Workspaces" , href : ` /projects/ ${ projectRef } /workspaces ` } ] : [ ] ) ,
{ label : workspace.name } ,
] ;
setBreadcrumbs ( crumbs ) ;
} , [ setBreadcrumbs , workspace , project , projectRef ] ) ;
const updateWorkspace = useMutation ( {
mutationFn : ( patch : Record < string , unknown > ) = > executionWorkspacesApi . update ( workspace ! . id , patch ) ,
onSuccess : ( nextWorkspace ) = > {
queryClient . setQueryData ( queryKeys . executionWorkspaces . detail ( nextWorkspace . id ) , nextWorkspace ) ;
2026-03-28 16:15:20 -05:00
queryClient . invalidateQueries ( { queryKey : queryKeys.executionWorkspaces.closeReadiness ( nextWorkspace . id ) } ) ;
2026-03-28 16:46:43 -05:00
queryClient . invalidateQueries ( { queryKey : queryKeys.executionWorkspaces.workspaceOperations ( nextWorkspace . id ) } ) ;
2026-03-28 12:15:34 -05:00
if ( project ) {
queryClient . invalidateQueries ( { queryKey : queryKeys.projects.detail ( project . id ) } ) ;
queryClient . invalidateQueries ( { queryKey : queryKeys.projects.detail ( project . urlKey ) } ) ;
}
if ( sourceIssue ) {
queryClient . invalidateQueries ( { queryKey : queryKeys.issues.detail ( sourceIssue . id ) } ) ;
}
setErrorMessage ( null ) ;
} ,
onError : ( error ) = > {
setErrorMessage ( error instanceof Error ? error . message : "Failed to save execution workspace." ) ;
} ,
} ) ;
2026-03-28 16:46:43 -05:00
const workspaceOperationsQuery = useQuery ( {
queryKey : queryKeys.executionWorkspaces.workspaceOperations ( workspaceId ! ) ,
queryFn : ( ) = > executionWorkspacesApi . listWorkspaceOperations ( workspaceId ! ) ,
enabled : Boolean ( workspaceId ) ,
} ) ;
const controlRuntimeServices = useMutation ( {
mutationFn : ( action : "start" | "stop" | "restart" ) = >
executionWorkspacesApi . controlRuntimeServices ( workspace ! . id , action ) ,
onSuccess : ( result , action ) = > {
queryClient . setQueryData ( queryKeys . executionWorkspaces . detail ( result . workspace . id ) , result . workspace ) ;
queryClient . invalidateQueries ( { queryKey : queryKeys.executionWorkspaces.workspaceOperations ( result . workspace . id ) } ) ;
queryClient . invalidateQueries ( { queryKey : queryKeys.projects.detail ( result . workspace . projectId ) } ) ;
setErrorMessage ( null ) ;
setRuntimeActionMessage (
action === "stop"
? "Runtime services stopped."
: action === "restart"
? "Runtime services restarted."
: "Runtime services started." ,
) ;
} ,
onError : ( error ) = > {
setRuntimeActionMessage ( null ) ;
setErrorMessage ( error instanceof Error ? error . message : "Failed to control runtime services." ) ;
} ,
} ) ;
2026-03-28 12:15:34 -05:00
if ( workspaceQuery . isLoading ) return < p className = "text-sm text-muted-foreground" > Loading workspace … < / p > ;
if ( workspaceQuery . error ) {
return (
< p className = "text-sm text-destructive" >
{ workspaceQuery . error instanceof Error ? workspaceQuery . error . message : "Failed to load workspace" }
< / p >
) ;
}
if ( ! workspace || ! form || ! initialState ) return null ;
const saveChanges = ( ) = > {
const validationError = validateForm ( form ) ;
if ( validationError ) {
setErrorMessage ( validationError ) ;
return ;
}
2026-03-13 17:12:25 -05:00
2026-03-28 12:15:34 -05:00
let patch : Record < string , unknown > ;
try {
patch = buildWorkspacePatch ( initialState , form ) ;
} catch ( error ) {
setErrorMessage ( error instanceof Error ? error . message : "Failed to build workspace update." ) ;
return ;
}
if ( Object . keys ( patch ) . length === 0 ) return ;
updateWorkspace . mutate ( patch ) ;
} ;
2026-03-13 17:12:25 -05:00
return (
2026-03-28 16:15:20 -05:00
< >
2026-04-05 07:35:33 -05:00
< div className = "mx-auto max-w-5xl space-y-4 sm:space-y-6" >
2026-03-28 16:15:20 -05:00
< div className = "flex flex-wrap items-center gap-3" >
2026-03-28 19:28:11 -05:00
< Button variant = "ghost" size = "sm" asChild >
< Link to = { project ? ` /projects/ ${ projectRef } /workspaces ` : "/projects" } >
< ArrowLeft className = "mr-1 h-4 w-4" / >
Back to all workspaces
< / Link >
< / Button >
< StatusPill > { workspace . mode } < / StatusPill >
< StatusPill > { workspace . providerType } < / StatusPill >
< StatusPill className = { workspace . status === "active" ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : undefined } >
{ workspace . status }
< / StatusPill >
< / div >
2026-03-13 17:12:25 -05:00
2026-04-05 07:35:33 -05:00
< div className = "grid gap-4 sm:gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]" >
< div className = "space-y-4 sm:space-y-6" >
< div className = "rounded-2xl border border-border bg-card p-4 sm:p-5" >
2026-03-28 19:28:11 -05:00
< div className = "flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between" >
< div className = "space-y-2" >
< div className = "text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground" >
Execution workspace
< / div >
2026-04-05 07:35:33 -05:00
< h1 className = "text-xl font-semibold sm:text-2xl" > { workspace . name } < / h1 >
2026-03-28 19:28:11 -05:00
< p className = "max-w-2xl text-sm text-muted-foreground" >
2026-04-05 07:35:33 -05:00
Configure the concrete runtime workspace that Paperclip reuses for this issue flow .
< span className = "hidden sm:inline" > These settings stay
2026-03-28 19:28:11 -05:00
attached to the execution workspace so future runs can keep local paths , repo refs , provisioning , teardown ,
2026-04-05 07:35:33 -05:00
and runtime - service behavior in sync with the actual workspace being reused . < / span >
2026-03-28 19:28:11 -05:00
< / p >
< / div >
< div className = "flex w-full shrink-0 items-center gap-2 sm:w-auto" >
< Button
variant = "outline"
className = "w-full sm:w-auto"
onClick = { ( ) = > setCloseDialogOpen ( true ) }
disabled = { workspace . status === "archived" }
>
{ workspace . status === "cleanup_failed" ? "Retry close" : "Close workspace" }
< / Button >
2026-03-28 12:15:34 -05:00
< / div >
< / div >
2026-03-28 19:28:11 -05:00
< Separator className = "my-5" / >
< div className = "grid gap-4 md:grid-cols-2" >
< Field label = "Workspace name" >
< input
className = "w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
value = { form . name }
onChange = { ( event ) = > setForm ( ( current ) = > current ? { . . . current , name : event.target.value } : current ) }
placeholder = "Execution workspace name"
/ >
< / Field >
< Field label = "Branch name" hint = "Useful for isolated worktrees" >
< input
className = "w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value = { form . branchName }
onChange = { ( event ) = > setForm ( ( current ) = > current ? { . . . current , branchName : event.target.value } : current ) }
placeholder = "PAP-946-workspace"
/ >
< / Field >
2026-03-28 16:15:20 -05:00
< / div >
2026-03-28 12:15:34 -05:00
2026-03-28 19:28:11 -05:00
< div className = "mt-4 grid gap-4 md:grid-cols-2" >
< Field label = "Working directory" >
< input
className = "w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value = { form . cwd }
onChange = { ( event ) = > setForm ( ( current ) = > current ? { . . . current , cwd : event.target.value } : current ) }
placeholder = "/absolute/path/to/workspace"
/ >
< / Field >
< Field label = "Provider path / ref" >
< input
className = "w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value = { form . providerRef }
onChange = { ( event ) = > setForm ( ( current ) = > current ? { . . . current , providerRef : event.target.value } : current ) }
placeholder = "/path/to/worktree or provider ref"
/ >
< / Field >
< / div >
2026-03-28 12:15:34 -05:00
2026-03-28 19:28:11 -05:00
< div className = "mt-4 grid gap-4 md:grid-cols-2" >
< Field label = "Repo URL" >
< input
className = "w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
value = { form . repoUrl }
onChange = { ( event ) = > setForm ( ( current ) = > current ? { . . . current , repoUrl : event.target.value } : current ) }
placeholder = "https://github.com/org/repo"
/ >
< / Field >
< Field label = "Base ref" >
< input
className = "w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value = { form . baseRef }
onChange = { ( event ) = > setForm ( ( current ) = > current ? { . . . current , baseRef : event.target.value } : current ) }
placeholder = "origin/main"
/ >
< / Field >
< / div >
2026-03-28 12:15:34 -05:00
2026-03-28 19:28:11 -05:00
< div className = "mt-4 grid gap-4 md:grid-cols-2" >
< Field label = "Provision command" hint = "Runs when Paperclip prepares this execution workspace" >
< textarea
2026-04-05 07:35:33 -05:00
className = "min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-28"
2026-03-28 19:28:11 -05:00
value = { form . provisionCommand }
onChange = { ( event ) = > setForm ( ( current ) = > current ? { . . . current , provisionCommand : event.target.value } : current ) }
placeholder = "bash ./scripts/provision-worktree.sh"
/ >
< / Field >
< Field label = "Teardown command" hint = "Runs when the execution workspace is archived or cleaned up" >
< textarea
2026-04-05 07:35:33 -05:00
className = "min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-28"
2026-03-28 19:28:11 -05:00
value = { form . teardownCommand }
onChange = { ( event ) = > setForm ( ( current ) = > current ? { . . . current , teardownCommand : event.target.value } : current ) }
placeholder = "bash ./scripts/teardown-worktree.sh"
/ >
< / Field >
< / div >
2026-03-28 12:15:34 -05:00
2026-03-28 19:28:11 -05:00
< div className = "mt-4 grid gap-4" >
< Field label = "Cleanup command" hint = "Workspace-specific cleanup before teardown" >
< textarea
2026-04-05 07:35:33 -05:00
className = "min-h-16 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-24"
2026-03-28 19:28:11 -05:00
value = { form . cleanupCommand }
onChange = { ( event ) = > setForm ( ( current ) = > current ? { . . . current , cleanupCommand : event.target.value } : current ) }
placeholder = "pkill -f vite || true"
/ >
< / Field >
2026-03-28 12:15:34 -05:00
2026-03-28 19:28:11 -05:00
< div className = "rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3" >
< div className = "flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between" >
< div >
< div className = "text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground" >
Runtime config source
< / div >
< p className = "mt-1 text-sm text-muted-foreground" >
{ runtimeConfigSource === "execution_workspace"
? "This execution workspace currently overrides the project workspace runtime config."
: runtimeConfigSource === "project_workspace"
? "This execution workspace is inheriting the project workspace runtime config."
: "No runtime config is currently defined on this execution workspace or its project workspace." }
< / p >
2026-03-28 16:46:43 -05:00
< / div >
2026-03-28 19:28:11 -05:00
< Button
variant = "outline"
className = "w-full sm:w-auto"
size = "sm"
disabled = { ! linkedProjectWorkspace ? . runtimeConfig ? . workspaceRuntime }
onClick = { ( ) = >
setForm ( ( current ) = > current ? {
. . . current ,
inheritRuntime : true ,
workspaceRuntime : "" ,
} : current )
}
>
Reset to inherit
< / Button >
2026-03-28 16:46:43 -05:00
< / div >
< / div >
2026-03-28 19:28:11 -05:00
< Field label = "Runtime services JSON" hint = "Concrete workspace runtime settings for this execution workspace. Leave this inheriting unless you need a one-off override. If you are missing the right commands, ask your CEO to set them up for you." >
< div className = "mb-2 flex items-center gap-2 text-xs text-muted-foreground" >
< input
id = "inherit-runtime-config"
type = "checkbox"
checked = { form . inheritRuntime }
onChange = { ( event ) = >
setForm ( ( current ) = > current ? { . . . current , inheritRuntime : event.target.checked } : current )
}
/ >
< label htmlFor = "inherit-runtime-config" > Inherit project workspace runtime config < / label >
< / div >
< textarea
2026-04-05 07:35:33 -05:00
className = "min-h-32 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60 sm:min-h-48"
2026-03-28 19:28:11 -05:00
value = { form . workspaceRuntime }
onChange = { ( event ) = > setForm ( ( current ) = > current ? { . . . current , workspaceRuntime : event.target.value } : current ) }
disabled = { form . inheritRuntime }
placeholder = { '{\n "services": [\n {\n "name": "web",\n "command": "pnpm dev",\n "port": 3100\n }\n ]\n}' }
2026-03-28 16:46:43 -05:00
/ >
2026-03-28 19:28:11 -05:00
< / Field >
< / div >
2026-03-28 12:15:34 -05:00
2026-03-28 19:28:11 -05:00
< div className = "mt-5 flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center" >
< Button className = "w-full sm:w-auto" disabled = { ! isDirty || updateWorkspace . isPending } onClick = { saveChanges } >
{ updateWorkspace . isPending ? < Loader2 className = "mr-2 h-4 w-4 animate-spin" / > : null }
Save changes
< / Button >
< Button
variant = "outline"
className = "w-full sm:w-auto"
disabled = { ! isDirty || updateWorkspace . isPending }
onClick = { ( ) = > {
setForm ( initialState ) ;
setErrorMessage ( null ) ;
setRuntimeActionMessage ( null ) ;
} }
>
Reset
< / Button >
{ errorMessage ? < p className = "text-sm text-destructive" > { errorMessage } < / p > : null }
{ ! errorMessage && runtimeActionMessage ? < p className = "text-sm text-muted-foreground" > { runtimeActionMessage } < / p > : null }
{ ! errorMessage && ! isDirty ? < p className = "text-sm text-muted-foreground" > No unsaved changes . < / p > : null }
< / div >
2026-03-28 12:15:34 -05:00
< / div >
< / div >
2026-04-05 07:35:33 -05:00
< div className = "space-y-4 sm:space-y-6" >
< div className = "rounded-2xl border border-border bg-card p-4 sm:p-5" >
2026-03-28 19:28:11 -05:00
< div className = "space-y-1" >
< div className = "text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground" > Linked objects < / div >
< h2 className = "text-lg font-semibold" > Workspace context < / h2 >
< / div >
< Separator className = "my-4" / >
< DetailRow label = "Project" >
{ project ? < Link to = { ` /projects/ ${ projectRef } ` } className = "hover:underline" > { project . name } < / Link > : < MonoValue value = { workspace . projectId } / > }
< / DetailRow >
< DetailRow label = "Project workspace" >
{ project && linkedProjectWorkspace ? (
< WorkspaceLink project = { project } workspace = { linkedProjectWorkspace } / >
) : workspace . projectWorkspaceId ? (
< MonoValue value = { workspace . projectWorkspaceId } / >
) : (
"None"
) }
< / DetailRow >
< DetailRow label = "Source issue" >
{ sourceIssue ? (
< Link to = { issueUrl ( sourceIssue ) } className = "hover:underline" >
{ sourceIssue . identifier ? ? sourceIssue . id } · { sourceIssue . title }
< / Link >
) : workspace . sourceIssueId ? (
< MonoValue value = { workspace . sourceIssueId } / >
) : (
"None"
) }
< / DetailRow >
< DetailRow label = "Derived from" >
{ derivedWorkspace ? (
< Link to = { ` /execution-workspaces/ ${ derivedWorkspace . id } ` } className = "hover:underline" >
{ derivedWorkspace . name }
< / Link >
) : workspace . derivedFromExecutionWorkspaceId ? (
< MonoValue value = { workspace . derivedFromExecutionWorkspaceId } / >
) : (
"None"
) }
< / DetailRow >
< DetailRow label = "Workspace ID" >
< MonoValue value = { workspace . id } / >
< / DetailRow >
2026-03-28 12:15:34 -05:00
< / div >
2026-04-05 07:35:33 -05:00
< div className = "rounded-2xl border border-border bg-card p-4 sm:p-5" >
2026-03-28 19:28:11 -05:00
< div className = "space-y-1" >
< div className = "text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground" > Paths and refs < / div >
< h2 className = "text-lg font-semibold" > Concrete location < / h2 >
< / div >
< Separator className = "my-4" / >
< DetailRow label = "Working dir" >
{ workspace . cwd ? < MonoValue value = { workspace . cwd } copy / > : "None" }
< / DetailRow >
< DetailRow label = "Provider ref" >
{ workspace . providerRef ? < MonoValue value = { workspace . providerRef } copy / > : "None" }
< / DetailRow >
< DetailRow label = "Repo URL" >
{ workspace . repoUrl && isSafeExternalUrl ( workspace . repoUrl ) ? (
< div className = "inline-flex max-w-full items-start gap-2" >
< a href = { workspace . repoUrl } target = "_blank" rel = "noreferrer" className = "inline-flex min-w-0 items-center gap-1 break-all hover:underline" >
{ workspace . repoUrl }
< ExternalLink className = "h-3.5 w-3.5 shrink-0" / >
< / a >
< CopyText text = { workspace . repoUrl } className = "shrink-0 text-muted-foreground hover:text-foreground" copiedLabel = "Copied" >
< Copy className = "h-3.5 w-3.5" / >
< / CopyText >
< / div >
) : workspace . repoUrl ? (
< MonoValue value = { workspace . repoUrl } copy / >
) : (
"None"
) }
< / DetailRow >
< DetailRow label = "Base ref" >
{ workspace . baseRef ? < MonoValue value = { workspace . baseRef } copy / > : "None" }
< / DetailRow >
< DetailRow label = "Branch" >
{ workspace . branchName ? < MonoValue value = { workspace . branchName } copy / > : "None" }
< / DetailRow >
< DetailRow label = "Opened" > { formatDateTime ( workspace . openedAt ) } < / DetailRow >
< DetailRow label = "Last used" > { formatDateTime ( workspace . lastUsedAt ) } < / DetailRow >
< DetailRow label = "Cleanup" >
{ workspace . cleanupEligibleAt
? ` ${ formatDateTime ( workspace . cleanupEligibleAt ) } ${ workspace . cleanupReason ? ` · ${ workspace . cleanupReason } ` : "" } `
: "Not scheduled" }
< / DetailRow >
2026-03-28 12:15:34 -05:00
< / div >
2026-03-28 19:28:11 -05:00
2026-04-05 07:35:33 -05:00
< div className = "rounded-2xl border border-border bg-card p-4 sm:p-5" >
2026-03-28 19:28:11 -05:00
< div className = "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between" >
< div className = "space-y-1" >
< div className = "text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground" > Runtime services < / div >
< h2 className = "text-lg font-semibold" > Attached services < / h2 >
< p className = "text-sm text-muted-foreground" >
Source : { runtimeConfigSource === "execution_workspace"
? "execution workspace override"
: runtimeConfigSource === "project_workspace"
? "project workspace default"
: "none" }
< / p >
< / div >
< div className = "flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap" >
< Button
variant = "outline"
size = "sm"
className = "w-full sm:w-auto"
disabled = { controlRuntimeServices . isPending || ! effectiveRuntimeConfig || ! workspace . cwd }
onClick = { ( ) = > controlRuntimeServices . mutate ( "start" ) }
>
{ controlRuntimeServices . isPending ? < Loader2 className = "mr-2 h-3.5 w-3.5 animate-spin" / > : null }
Start
< / Button >
< Button
variant = "outline"
size = "sm"
className = "w-full sm:w-auto"
disabled = { controlRuntimeServices . isPending || ! effectiveRuntimeConfig || ! workspace . cwd }
onClick = { ( ) = > controlRuntimeServices . mutate ( "restart" ) }
>
Restart
< / Button >
< Button
variant = "outline"
size = "sm"
className = "w-full sm:w-auto"
2026-04-04 13:15:46 -05:00
disabled = { controlRuntimeServices . isPending || ! hasActiveRuntimeServices ( workspace ) }
2026-03-28 19:28:11 -05:00
onClick = { ( ) = > controlRuntimeServices . mutate ( "stop" ) }
>
Stop
< / Button >
< / div >
< / div >
< Separator className = "my-4" / >
{ workspace . runtimeServices && workspace . runtimeServices . length > 0 ? (
< div className = "space-y-3" >
{ workspace . runtimeServices . map ( ( service ) = > (
< div key = { service . id } className = "rounded-xl border border-border/80 bg-background px-3 py-2" >
< div className = "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between" >
< div className = "space-y-1" >
< div className = "text-sm font-medium" > { service . serviceName } < / div >
< div className = "text-xs text-muted-foreground" > { service . status } · { service . lifecycle } < / div >
< div className = "space-y-1 text-xs text-muted-foreground" >
{ service . url ? (
< a href = { service . url } target = "_blank" rel = "noreferrer" className = "inline-flex items-center gap-1 hover:underline" >
{ service . url }
< ExternalLink className = "h-3.5 w-3.5" / >
< / a >
) : null }
{ service . port ? < div > Port { service . port } < / div > : null }
{ service . command ? < MonoValue value = { service . command } copy / > : null }
{ service . cwd ? < MonoValue value = { service . cwd } copy / > : null }
< / div >
< / div >
< StatusPill className = "self-start" > { service . healthStatus } < / StatusPill >
< / div >
< / div >
) ) }
2026-03-28 12:15:34 -05:00
< / div >
) : (
2026-03-28 19:28:11 -05:00
< p className = "text-sm text-muted-foreground" >
{ effectiveRuntimeConfig
? "No runtime services are currently running for this execution workspace."
: "No runtime config is defined for this execution workspace yet." }
< / p >
2026-03-28 12:15:34 -05:00
) }
2026-03-28 19:28:11 -05:00
< / div >
2026-03-28 12:15:34 -05:00
2026-04-05 07:35:33 -05:00
< div className = "rounded-2xl border border-border bg-card p-4 sm:p-5" >
2026-03-28 16:46:43 -05:00
< div className = "space-y-1" >
2026-03-28 19:28:11 -05:00
< div className = "text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground" > Recent operations < / div >
< h2 className = "text-lg font-semibold" > Runtime and cleanup logs < / h2 >
2026-03-28 16:46:43 -05:00
< / div >
2026-03-28 19:28:11 -05:00
< Separator className = "my-4" / >
{ workspaceOperationsQuery . isLoading ? (
< p className = "text-sm text-muted-foreground" > Loading workspace operations … < / p >
) : workspaceOperationsQuery . error ? (
< p className = "text-sm text-destructive" >
{ workspaceOperationsQuery . error instanceof Error
? workspaceOperationsQuery . error . message
: "Failed to load workspace operations." }
< / p >
) : workspaceOperationsQuery . data && workspaceOperationsQuery . data . length > 0 ? (
< div className = "space-y-3" >
{ workspaceOperationsQuery . data . slice ( 0 , 6 ) . map ( ( operation ) = > (
< div key = { operation . id } className = "rounded-xl border border-border/80 bg-background px-3 py-2" >
< div className = "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between" >
< div className = "space-y-1" >
< div className = "text-sm font-medium" > { operation . command ? ? operation . phase } < / div >
< div className = "text-xs text-muted-foreground" >
{ formatDateTime ( operation . startedAt ) }
{ operation . finishedAt ? ` → ${ formatDateTime ( operation . finishedAt ) } ` : "" }
< / div >
{ operation . stderrExcerpt ? (
< div className = "whitespace-pre-wrap break-words text-xs text-destructive" > { operation . stderrExcerpt } < / div >
) : operation . stdoutExcerpt ? (
< div className = "whitespace-pre-wrap break-words text-xs text-muted-foreground" > { operation . stdoutExcerpt } < / div >
2026-03-28 12:15:34 -05:00
) : null }
< / div >
2026-03-28 19:28:11 -05:00
< StatusPill className = "self-start" > { operation . status } < / StatusPill >
2026-03-28 12:15:34 -05:00
< / div >
< / div >
2026-03-28 19:28:11 -05:00
) ) }
< / div >
) : (
< p className = "text-sm text-muted-foreground" > No workspace operations have been recorded yet . < / p >
) }
2026-03-28 16:46:43 -05:00
< / div >
2026-03-28 16:15:20 -05:00
< / div >
2026-03-28 12:15:34 -05:00
< / div >
2026-03-28 22:21:24 -05:00
2026-04-05 07:35:33 -05:00
< div className = "rounded-2xl border border-border bg-card p-4 sm:p-5" >
2026-03-28 22:21:24 -05:00
< div className = "flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between" >
< div className = "space-y-1" >
< div className = "text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground" > Linked issues < / div >
< h2 className = "text-lg font-semibold" > Issues using this workspace < / h2 >
< p className = "text-sm text-muted-foreground" >
Any issue attached to this execution workspace appears here so you can review the full session context before reusing or closing it .
< / p >
< / div >
< StatusPill > { linkedIssues . length } linked < / StatusPill >
< / div >
< Separator className = "my-4" / >
{ linkedIssuesQuery . isLoading ? (
< p className = "text-sm text-muted-foreground" > Loading linked issues … < / p >
) : linkedIssuesQuery . error ? (
< p className = "text-sm text-destructive" >
{ linkedIssuesQuery . error instanceof Error
? linkedIssuesQuery . error . message
: "Failed to load linked issues." }
< / p >
) : linkedIssues . length > 0 ? (
2026-04-05 07:35:33 -05:00
< div className = "-mx-1 flex flex-col gap-3 px-1 pb-1 sm:flex-row sm:overflow-x-auto" >
2026-03-28 22:21:24 -05:00
{ linkedIssues . map ( ( issue ) = > (
< Link
key = { issue . id }
to = { issueUrl ( issue ) }
2026-04-05 07:35:33 -05:00
className = "rounded-xl border border-border/80 bg-background px-4 py-3 transition-colors hover:bg-accent/20 sm:min-w-72"
2026-03-28 22:21:24 -05:00
>
< div className = "flex items-start justify-between gap-3" >
< div className = "min-w-0 space-y-1" >
< div className = "font-mono text-xs text-muted-foreground" >
{ issue . identifier ? ? issue . id . slice ( 0 , 8 ) }
< / div >
< div className = "line-clamp-2 text-sm font-medium" > { issue . title } < / div >
< / div >
< StatusPill className = "shrink-0" > { issue . status } < / StatusPill >
< / div >
< div className = "mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground" >
< span className = "uppercase tracking-[0.16em]" > { issue . priority } < / span >
< span > { formatDateTime ( issue . updatedAt ) } < / span >
< / div >
< / Link >
) ) }
< / div >
) : (
< p className = "text-sm text-muted-foreground" > No issues are currently linked to this execution workspace . < / p >
) }
< / div >
2026-03-13 17:12:25 -05:00
< / div >
2026-03-28 16:15:20 -05:00
< ExecutionWorkspaceCloseDialog
workspaceId = { workspace . id }
workspaceName = { workspace . name }
currentStatus = { workspace . status }
open = { closeDialogOpen }
onOpenChange = { setCloseDialogOpen }
onClosed = { ( nextWorkspace ) = > {
queryClient . setQueryData ( queryKeys . executionWorkspaces . detail ( nextWorkspace . id ) , nextWorkspace ) ;
queryClient . invalidateQueries ( { queryKey : queryKeys.executionWorkspaces.closeReadiness ( nextWorkspace . id ) } ) ;
2026-03-28 16:46:43 -05:00
queryClient . invalidateQueries ( { queryKey : queryKeys.executionWorkspaces.workspaceOperations ( nextWorkspace . id ) } ) ;
2026-03-28 16:15:20 -05:00
if ( project ) {
queryClient . invalidateQueries ( { queryKey : queryKeys.projects.detail ( project . id ) } ) ;
queryClient . invalidateQueries ( { queryKey : queryKeys.executionWorkspaces.list ( project . companyId , { projectId : project.id } ) } ) ;
}
if ( sourceIssue ) {
queryClient . invalidateQueries ( { queryKey : queryKeys.issues.detail ( sourceIssue . id ) } ) ;
}
} }
/ >
< / >
2026-03-13 17:12:25 -05:00
) ;
}