2026-08-09 11:35:42 -07:00
import { useEffect , useMemo , useState } from "react" ;
import { useShallow } from "zustand/react/shallow" ;
2026-08-27 09:09:46 -07:00
import { projectRemovalIsClean } from "../../../lib/types" ;
2026-08-09 11:35:42 -07:00
import { useAppState } from "../../../store/appState" ;
import { useProjectActions } from "../../../hooks/useProjectActions" ;
import { useProjects } from "../../../hooks/useProjects" ;
import { useProjectSave } from "../../../hooks/useSaveState" ;
2026-08-09 18:19:12 -07:00
import { useContainerMigration } from "../../../hooks/useContainerMigration" ;
2026-08-09 11:35:42 -07:00
import { ProjectStatusIndicator } from "../../ui/StatusIndicator" ;
import Button from "../../ui/Button" ;
import OverflowMenu from "../../ui/OverflowMenu" ;
import ConfirmRemoveModal from "../ConfirmRemoveModal" ;
2026-08-09 12:02:35 -07:00
import ConfirmResetModal from "../ConfirmResetModal" ;
2026-08-09 18:19:12 -07:00
import MigrateContainerModal from "../MigrateContainerModal" ;
2026-08-09 11:35:42 -07:00
import OverviewTab from "./OverviewTab" ;
import SessionsTab from "./SessionsTab" ;
import AutomationTab from "./AutomationTab" ;
import ConfigTab from "./ConfigTab" ;
import FilesTab from "./FilesTab" ;
2026-08-09 16:55:28 -07:00
import BrowserTab from "./BrowserTab" ;
2026-08-09 11:35:42 -07:00
import { formatUptime } from "./format" ;
2026-08-27 09:47:45 -07:00
import { describeLeftovers , leftoverPronoun , leftoverVerb } from "./removalReport" ;
2026-08-09 11:35:42 -07:00
const TABS = [
{ id : "overview" , label : "Overview" },
{ id : "sessions" , label : "Sessions" },
{ id : "automation" , label : "Automation" },
{ id : "config" , label : "Config" },
{ id : "files" , label : "Files" },
2026-08-09 16:55:28 -07:00
{ id : "browser" , label : "Browser" },
2026-08-09 11:35:42 -07:00
] as const ;
export type ProjectHomeTabId = ( typeof TABS )[ number ][ "id" ];
interface Props {
projectId : string ;
active : boolean ;
}
/**
* The project promoted from a sidebar card to a first-class main-area view.
* Everything that used to spray out of `ProjectCard` as a modal lives here.
*/
export default function ProjectHome ({ projectId , active } : Props ) {
const { projects , remove } = useProjects ();
const project = projects . find (( p ) => p . id === projectId );
const [ tab , setTab ] = useState < ProjectHomeTabId >( "overview" );
2026-08-11 11:13:21 -07:00
// Somewhere else asked for this project on a particular sub-tab — currently
// "I opened a page in the container's browser, show me it". Consumed once, so
// it cannot fight the user's own clicking afterwards.
const pendingHomeTab = useAppState (( s ) => s . pendingHomeTab );
useEffect (() => {
if ( pendingHomeTab ? . projectId !== projectId ) return ;
setTab ( pendingHomeTab . tab as ProjectHomeTabId );
useAppState . getState (). clearPendingHomeTab ();
}, [ pendingHomeTab , projectId ]);
2026-08-09 11:35:42 -07:00
const [ confirmRemove , setConfirmRemove ] = useState ( false );
2026-08-09 12:02:35 -07:00
const [ confirmReset , setConfirmReset ] = useState ( false );
2026-08-09 18:19:12 -07:00
const [ showMigration , setShowMigration ] = useState ( false );
2026-08-09 11:35:42 -07:00
const { runningSince , progress } = useAppState (
useShallow (( s ) => ({
runningSince : s.runningSince [ projectId ],
progress : s.containerProgress [ projectId ],
})),
);
// Re-render once a minute so the uptime line stays honest.
const [, setTick ] = useState ( 0 );
useEffect (() => {
if ( ! active || runningSince === undefined ) return ;
const timer = setInterval (() => setTick (( t ) => t + 1 ), 60 _000 );
return () => clearInterval ( timer );
}, [ active , runningSince ]);
const actions = useProjectActions (
project ?? ({ id : projectId , name : "" , container_id : null } as never ),
);
const { save , saveState } = useProjectSave (
project ?? ({ id : projectId , name : "" } as never ),
);
2026-08-09 18:19:12 -07:00
// Owned here, not in the modal: the run outlives the dialog, and the Overview
// banner has to keep showing progress and the report after it is dismissed.
const migration = useContainerMigration (
project ?? ({ id : projectId , name : "" , container_id : null } as never ),
);
2026-08-09 11:35:42 -07:00
const uptime = useMemo (() => formatUptime ( runningSince ), [ runningSince ]);
if ( ! project ) {
return (
< div className = { `h-full flex items-center justify-center ${ active ? "" : "hidden" } ` }>
< p className = "text-[13px] text-[var(--text-secondary)]" >
This project is no longer available .
</ p >
</ div >
);
}
const isRunning = project . status === "running" ;
const isTransitioning =
project . status === "starting" || project . status === "stopping" ;
const isStopped = project . status === "stopped" || project . status === "error" ;
2026-08-09 18:19:12 -07:00
// Rebuilding on a new base swaps the container out, so it gates exactly like
// Reset does — with the extra condition that there is a container to migrate.
// An interrupted migration is excluded too: its action is Resume, on the
// Overview banner, not a fresh pre-flight.
2026-08-09 19:35:39 -07:00
//
// `probeSettled` is the fourth condition and it is not cosmetic. The probe
// takes ~6 s, and until it lands every delta the pre-flight renders reads as
// empty — so the dialog would tell the user there was nothing to copy while
// the backend was told not to copy anything.
2026-08-09 18:19:12 -07:00
const canMigrate =
isStopped &&
! actions . busy &&
! migration . running &&
! migration . interrupted &&
2026-08-09 19:35:39 -07:00
migration . probeSettled &&
2026-08-09 18:19:12 -07:00
!! project . container_id ;
2026-08-09 11:35:42 -07:00
return (
< div className = { `flex flex-col h-full min-h-0 ${ active ? "" : "hidden" } ` }>
{ /* Header */ }
< header className = "flex-shrink-0 px-4 pt-3 pb-2 border-b border-[var(--border-color)]" >
< div className = "flex items-start justify-between gap-4 flex-wrap" >
< div className = "min-w-0" >
< h1 className = "text-base font-semibold text-[var(--text-primary)] truncate" >
{ project . name }
</ h1 >
< div className = "mt-0.5 flex items-center gap-2 text-xs" >
< ProjectStatusIndicator status = { project . status } />
{ isRunning && uptime && (
< span className = "text-[var(--text-secondary)]" > · { uptime }</ span >
)}
{ isTransitioning && progress && (
< span className = "text-[var(--warning)] truncate" > · { progress }</ span >
)}
</ div >
</ div >
< div className = "flex items-center gap-1.5 flex-wrap" >
{ isRunning ? (
< Button
size = "md"
variant = "primary"
disabled = { actions . busy }
onClick = { actions . openClaudeTerminal }
>
Open Claude Terminal
</ Button >
) : (
< Button
size = "md"
variant = "primary"
disabled = { actions . busy || isTransitioning }
onClick = { actions . handleStart }
>
Start
</ Button >
)}
{ isRunning && (
<>
< Button size = "md" onClick = { actions . openShell }>
Shell
</ Button >
< Button size = "md" onClick = {() => setTab ( "files" )}>
Files
</ Button >
< Button size = "md" disabled = { actions . busy } onClick = { actions . handleStop }>
Stop
</ Button >
</>
)}
{ isTransitioning && (
< Button size = "md" variant = "danger" onClick = { actions . handleStop }>
Force stop
</ Button >
)}
< OverflowMenu
items = {[
{
label : actions.backingUp ? "Backing up…" : "Back up container" ,
onSelect : actions.handleBackup ,
disabled : actions.backingUp || ! project . container_id ,
},
2026-08-09 18:19:12 -07:00
{
label : "Update container base…" ,
onSelect : () => setShowMigration ( true ),
disabled : ! canMigrate ,
},
2026-08-09 11:35:42 -07:00
{
2026-08-09 12:02:35 -07:00
label : "Reset container…" ,
onSelect : () => setConfirmReset ( true ),
2026-08-09 11:35:42 -07:00
disabled : ! isStopped || actions . busy ,
2026-08-09 12:02:35 -07:00
danger : true ,
2026-08-09 11:35:42 -07:00
},
{
label : "Remove project…" ,
onSelect : () => setConfirmRemove ( true ),
danger : true ,
},
]}
/>
</ div >
</ div >
{ /* Tabs */ }
< div role = "tablist" aria-label = "Project sections" className = "flex gap-1 mt-3 -mb-2" >
{ TABS . map (( t ) => (
< button
key = { t . id }
type = "button"
role = "tab"
id = { `project-tab- ${ projectId } - ${ t . id } ` }
aria-selected = { tab === t . id }
aria-controls = { `project-panel- ${ projectId } - ${ t . id } ` }
onClick = {() => setTab ( t . id )}
className = { `px-3 h-8 text-[13px] font-medium rounded-t-[var(--radius-control)] border-b-2 transition-colors ${
tab === t . id
? "text-[var(--text-primary)] border-[var(--accent)]"
: "text-[var(--text-secondary)] border-transparent hover:text-[var(--text-primary)]"
} ` }
>
{ t . label }
</ button >
))}
</ div >
</ header >
{ /* Panel */ }
< div
role = "tabpanel"
id = { `project-panel- ${ projectId } - ${ tab } ` }
aria-labelledby = { `project-tab- ${ projectId } - ${ tab } ` }
className = "flex-1 min-h-0 overflow-y-auto"
>
{ tab === "overview" && (
< OverviewTab
project = { project }
save = { save }
saveState = { saveState }
actions = { actions }
onOpenTab = { setTab }
2026-08-09 18:19:12 -07:00
migration = { migration }
canMigrate = { canMigrate }
onOpenMigration = {() => setShowMigration ( true )}
2026-08-09 11:35:42 -07:00
/>
)}
{ tab === "sessions" && < SessionsTab project = { project } actions = { actions } />}
{ tab === "automation" && < AutomationTab project = { project } />}
{ tab === "config" && (
< ConfigTab project = { project } save = { save } saveState = { saveState } />
)}
{ tab === "files" && < FilesTab project = { project } />}
2026-08-09 16:55:28 -07:00
{ tab === "browser" && (
< BrowserTab project = { project } active = { active && tab === "browser" } />
)}
2026-08-09 11:35:42 -07:00
</ div >
2026-08-09 18:19:12 -07:00
{ showMigration && (
< MigrateContainerModal
projectName = { project . name }
staleness = { migration . staleness }
migration = { migration }
// Closing is not cancelling — the run keeps going and the Overview
// banner keeps reporting it.
onClose = {() => setShowMigration ( false )}
/>
)}
2026-08-09 12:02:35 -07:00
{ confirmReset && (
< ConfirmResetModal
projectName = { project . name }
onCancel = {() => setConfirmReset ( false )}
onConfirm = {() => {
setConfirmReset ( false );
actions . handleReset ();
}}
/>
)}
2026-08-09 11:35:42 -07:00
{ confirmRemove && (
< ConfirmRemoveModal
projectName = { project . name }
onCancel = {() => setConfirmRemove ( false )}
onConfirm = { async () => {
setConfirmRemove ( false );
try {
2026-08-27 08:18:41 -07:00
const report = await remove ( project . id );
2026-08-27 08:36:27 -07:00
if ( ! projectRemovalIsClean ( report )) {
2026-08-27 09:09:46 -07:00
const verb = leftoverVerb ( report );
2026-08-27 08:36:27 -07:00
if ( report . retry_scheduled ) {
useAppState . getState (). pushToast ({
kind : "info" ,
2026-08-27 09:09:46 -07:00
message : `“ ${ project . name } ” was removed, but Triple-C could not confirm all its Docker resources were removed` ,
detail : `Triple-C could not confirm ${ describeLeftovers ( report ) } ${ verb } removed. It will check again the next time it starts.` ,
2026-08-27 08:36:27 -07:00
});
} else {
// The pending-cleanup record itself failed to save — no
// retry will happen, so this must not promise one.
useAppState . getState (). pushToast ({
kind : "error" ,
2026-08-27 09:09:46 -07:00
message : `“ ${ project . name } ” was removed, but Triple-C could not confirm its Docker resources were removed` ,
2026-08-27 09:47:45 -07:00
detail : `Triple-C could not confirm ${ describeLeftovers ( report ) } ${ verb } removed, and could not record this for a retry. You may need to remove ${ leftoverPronoun ( report ) } manually (\`docker rm\` / \`docker rmi\` / \`docker volume rm\`).` ,
2026-08-27 08:36:27 -07:00
});
}
2026-08-27 08:18:41 -07:00
}
2026-08-09 11:35:42 -07:00
} catch ( e ) {
useAppState . getState (). pushToast ({
kind : "error" ,
message : `Could not remove “ ${ project . name } ”` ,
detail : String ( e ),
});
}
}}
/>
)}
</ div >
);
}