Skip to content
Merged
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
19 changes: 14 additions & 5 deletions mobile/src/screens/WorkspaceDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export function WorkspaceDetailScreen({ route, navigation }: any) {
})

const isRunning = isHost ? true : workspace?.status === 'running'
const isCreating = isHost ? false : workspace?.status === 'creating'

const { data: sessionsData, isLoading: sessionsLoading, refetch } = useQuery({
queryKey: ['sessions', name, agentFilter],
Expand Down Expand Up @@ -179,7 +180,7 @@ export function WorkspaceDetailScreen({ route, navigation }: any) {
</TouchableOpacity>
<View style={styles.headerCenter}>
<Text style={[styles.headerTitle, isHost && styles.hostHeaderTitle]}>{displayName}</Text>
<View style={[styles.statusIndicator, { backgroundColor: isHost ? '#f59e0b' : (isRunning ? '#34c759' : '#636366') }]} />
<View style={[styles.statusIndicator, { backgroundColor: isHost ? '#f59e0b' : (isRunning ? '#34c759' : isCreating ? '#ff9f0a' : '#636366') }]} />
</View>
{isHost ? (
<View style={styles.settingsBtn} />
Expand Down Expand Up @@ -275,10 +276,18 @@ export function WorkspaceDetailScreen({ route, navigation }: any) {
)}

{!isRunning && !isHost ? (
<View style={styles.notRunning}>
<Text style={styles.notRunningText}>Workspace is not running</Text>
<Text style={styles.notRunningSubtext}>Start it from settings to view sessions</Text>
</View>
isCreating ? (
<View style={styles.notRunning}>
<ActivityIndicator size="large" color="#ff9f0a" style={{ marginBottom: 16 }} />
<Text style={styles.notRunningText}>Workspace is starting</Text>
<Text style={styles.notRunningSubtext}>Please wait while the container starts up</Text>
</View>
) : (
<View style={styles.notRunning}>
<Text style={styles.notRunningText}>Workspace is not running</Text>
<Text style={styles.notRunningSubtext}>Start it from settings to view sessions</Text>
</View>
)
) : sessionsLoading ? (
<View style={styles.center}>
<ActivityIndicator size="large" color="#0a84ff" />
Expand Down
128 changes: 71 additions & 57 deletions src/workspace/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,10 @@ export class WorkspaceManager {
}

private async syncWorkspaceStatus(workspace: Workspace): Promise<void> {
if (workspace.status === 'creating') {
return;
}

const containerName = getContainerName(workspace.name);

const exists = await docker.containerExists(containerName);
Expand All @@ -432,7 +436,7 @@ export class WorkspaceManager {

const running = await docker.containerRunning(containerName);
const newStatus = running ? 'running' : 'stopped';
if (workspace.status !== newStatus && workspace.status !== 'creating') {
if (workspace.status !== newStatus) {
workspace.status = newStatus;
await this.state.setWorkspace(workspace);
}
Expand Down Expand Up @@ -553,76 +557,86 @@ export class WorkspaceManager {
throw new Error(`Workspace '${name}' not found`);
}

const containerName = getContainerName(name);
const volumeName = `${VOLUME_PREFIX}${name}`;
const exists = await docker.containerExists(containerName);
const previousStatus = workspace.status;
workspace.status = 'creating';
await this.state.setWorkspace(workspace);

if (!exists) {
const volumeExists = await docker.volumeExists(volumeName);
if (!volumeExists) {
throw new Error(
`Container and volume for workspace '${name}' were deleted. ` +
`Please delete this workspace and create a new one.`
);
}
try {
const containerName = getContainerName(name);
const volumeName = `${VOLUME_PREFIX}${name}`;
const exists = await docker.containerExists(containerName);

if (!exists) {
const volumeExists = await docker.volumeExists(volumeName);
if (!volumeExists) {
throw new Error(
`Container and volume for workspace '${name}' were deleted. ` +
`Please delete this workspace and create a new one.`
);
}

const workspaceImage = await ensureWorkspaceImage();
const sshPort = await findAvailablePort(SSH_PORT_RANGE_START, SSH_PORT_RANGE_END);
const workspaceImage = await ensureWorkspaceImage();
const sshPort = await findAvailablePort(SSH_PORT_RANGE_START, SSH_PORT_RANGE_END);

const containerEnv: Record<string, string> = {
...this.config.credentials.env,
};
const containerEnv: Record<string, string> = {
...this.config.credentials.env,
};

if (this.config.agents?.github?.token) {
containerEnv.GITHUB_TOKEN = this.config.agents.github.token;
}
if (this.config.agents?.claude_code?.oauth_token) {
containerEnv.CLAUDE_CODE_OAUTH_TOKEN = this.config.agents.claude_code.oauth_token;
}
if (this.config.agents?.github?.token) {
containerEnv.GITHUB_TOKEN = this.config.agents.github.token;
}
if (this.config.agents?.claude_code?.oauth_token) {
containerEnv.CLAUDE_CODE_OAUTH_TOKEN = this.config.agents.claude_code.oauth_token;
}

if (workspace.repo) {
containerEnv.WORKSPACE_REPO_URL = workspace.repo;
if (workspace.repo) {
containerEnv.WORKSPACE_REPO_URL = workspace.repo;
}

const containerId = await docker.createContainer({
name: containerName,
image: workspaceImage,
hostname: name,
privileged: true,
restartPolicy: 'unless-stopped',
env: containerEnv,
volumes: [{ source: volumeName, target: '/home/workspace', readonly: false }],
ports: [{ hostPort: sshPort, containerPort: 22, protocol: 'tcp' }],
labels: {
'workspace.name': name,
'workspace.managed': 'true',
},
});

workspace.containerId = containerId;
workspace.ports.ssh = sshPort;
await this.state.setWorkspace(workspace);
}

const containerId = await docker.createContainer({
name: containerName,
image: workspaceImage,
hostname: name,
privileged: true,
restartPolicy: 'unless-stopped',
env: containerEnv,
volumes: [{ source: volumeName, target: '/home/workspace', readonly: false }],
ports: [{ hostPort: sshPort, containerPort: 22, protocol: 'tcp' }],
labels: {
'workspace.name': name,
'workspace.managed': 'true',
},
});
const running = await docker.containerRunning(containerName);
if (running) {
workspace.status = 'running';
workspace.lastUsed = new Date().toISOString();
await this.state.setWorkspace(workspace);
return workspace;
}

workspace.containerId = containerId;
workspace.ports.ssh = sshPort;
await this.state.setWorkspace(workspace);
}
await docker.startContainer(containerName);
await docker.waitForContainerReady(containerName);
await this.setupWorkspaceCredentials(containerName, name);

const running = await docker.containerRunning(containerName);
if (running) {
workspace.status = 'running';
workspace.lastUsed = new Date().toISOString();
await this.state.setWorkspace(workspace);
return workspace;
}

await docker.startContainer(containerName);
await docker.waitForContainerReady(containerName);
await this.setupWorkspaceCredentials(containerName, name);

workspace.status = 'running';
workspace.lastUsed = new Date().toISOString();
await this.state.setWorkspace(workspace);

await this.runPostStartScript(containerName);
await this.runPostStartScript(containerName);

return workspace;
return workspace;
} catch (err) {
workspace.status = previousStatus === 'error' ? 'error' : 'stopped';
await this.state.setWorkspace(workspace);
throw err;
}
}

async stop(name: string): Promise<Workspace> {
Expand Down
117 changes: 73 additions & 44 deletions web/src/pages/WorkspaceDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ export function WorkspaceDetail() {

const isRunning = isHostWorkspace ? (hostInfo?.enabled ?? false) : workspace?.status === 'running'
const isError = isHostWorkspace ? false : workspace?.status === 'error'
const isCreating = isHostWorkspace ? false : workspace?.status === 'creating'
const displayName = isHostWorkspace ? (hostInfo?.hostname || 'Host') : workspace?.name

const tabs = isHostWorkspace
Expand All @@ -366,50 +367,66 @@ export function WorkspaceDetail() {
{ id: 'settings' as const, label: 'Settings', icon: Settings },
]

const renderStartPrompt = () => (
<div className="flex-1 flex flex-col items-center justify-center">
<div className={cn(
"h-16 w-16 rounded-full flex items-center justify-center mb-6",
isError ? "bg-destructive/10" : "bg-muted/50"
)}>
{isError ? (
<AlertTriangle className="h-8 w-8 text-destructive" />
) : (
<Square className="h-8 w-8 text-muted-foreground" />
)}
</div>
<p className="text-xl font-medium mb-2">
{isError ? 'Workspace needs recovery' : 'Workspace is stopped'}
</p>
<p className="text-muted-foreground mb-6 text-center max-w-md">
{isError
? 'The container was deleted externally. Click below to recreate it with existing data.'
: 'Start the workspace to access this feature'}
</p>
{startMutation.error && (
<div className="mb-4 px-4 py-2 bg-destructive/10 border border-destructive/30 rounded-lg text-destructive text-sm max-w-md text-center">
{(startMutation.error as Error).message || 'Failed to start workspace'}
const renderStartPrompt = () => {
if (isCreating) {
return (
<div className="flex-1 flex flex-col items-center justify-center">
<div className="h-16 w-16 rounded-full flex items-center justify-center mb-6 bg-amber-500/10">
<Loader2 className="h-8 w-8 text-amber-500 animate-spin" />
</div>
<p className="text-xl font-medium mb-2">Workspace is starting</p>
<p className="text-muted-foreground mb-6 text-center max-w-md">
Please wait while the workspace container starts up. This may take a moment if the Docker image is being downloaded.
</p>
</div>
)}
<Button
size="lg"
onClick={() => startMutation.mutate()}
disabled={startMutation.isPending}
>
{startMutation.isPending ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{isError ? 'Recovering...' : 'Starting...'}
</>
) : (
<>
{isError ? <RefreshCw className="mr-2 h-5 w-5" /> : <Play className="mr-2 h-5 w-5" />}
{isError ? 'Recover Workspace' : 'Start Workspace'}
</>
)
}

return (
<div className="flex-1 flex flex-col items-center justify-center">
<div className={cn(
"h-16 w-16 rounded-full flex items-center justify-center mb-6",
isError ? "bg-destructive/10" : "bg-muted/50"
)}>
{isError ? (
<AlertTriangle className="h-8 w-8 text-destructive" />
) : (
<Square className="h-8 w-8 text-muted-foreground" />
)}
</div>
<p className="text-xl font-medium mb-2">
{isError ? 'Workspace needs recovery' : 'Workspace is stopped'}
</p>
<p className="text-muted-foreground mb-6 text-center max-w-md">
{isError
? 'The container was deleted externally. Click below to recreate it with existing data.'
: 'Start the workspace to access this feature'}
</p>
{startMutation.error && (
<div className="mb-4 px-4 py-2 bg-destructive/10 border border-destructive/30 rounded-lg text-destructive text-sm max-w-md text-center">
{(startMutation.error as Error).message || 'Failed to start workspace'}
</div>
)}
</Button>
</div>
)
<Button
size="lg"
onClick={() => startMutation.mutate()}
disabled={startMutation.isPending}
>
{startMutation.isPending ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{isError ? 'Recovering...' : 'Starting...'}
</>
) : (
<>
{isError ? <RefreshCw className="mr-2 h-5 w-5" /> : <Play className="mr-2 h-5 w-5" />}
{isError ? 'Recover Workspace' : 'Start Workspace'}
</>
)}
</Button>
</div>
)
}

return (
<div className="flex flex-col h-full">
Expand All @@ -425,6 +442,8 @@ export function WorkspaceDetail() {
</Badge>
) : isRunning ? (
<span className="h-2 w-2 rounded-full bg-success animate-pulse flex-shrink-0" title="Running" />
) : isCreating ? (
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse flex-shrink-0" title="Starting" />
) : isError ? (
<Badge variant="destructive" className="text-xs flex-shrink-0">error</Badge>
) : (
Expand All @@ -443,6 +462,16 @@ export function WorkspaceDetail() {
<Square className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">{stopMutation.isPending ? 'Stopping...' : 'Stop'}</span>
</Button>
) : isCreating ? (
<Button
variant="ghost"
size="sm"
disabled
className="h-9 px-2 sm:px-3 flex-shrink-0 text-amber-600"
>
<Loader2 className="h-4 w-4 sm:mr-1 animate-spin" />
<span className="hidden sm:inline">Starting...</span>
</Button>
) : (
<Button
variant="ghost"
Expand Down Expand Up @@ -654,8 +683,8 @@ export function WorkspaceDetail() {
<CardContent className="space-y-3">
<div className="flex justify-between items-center py-2 border-b border-border/50">
<span className="text-sm text-muted-foreground">Status</span>
<Badge variant={isRunning ? 'success' : isError ? 'destructive' : 'muted'}>
{isRunning ? 'running' : isError ? 'error' : 'stopped'}
<Badge variant={isRunning ? 'success' : isError ? 'destructive' : isCreating ? 'secondary' : 'muted'} className={isCreating ? 'bg-amber-500/10 text-amber-600 border-amber-500/20' : ''}>
{isRunning ? 'running' : isError ? 'error' : isCreating ? 'starting' : 'stopped'}
</Badge>
</div>
<div className="flex justify-between items-center py-2 border-b border-border/50">
Expand Down
12 changes: 11 additions & 1 deletion web/src/pages/WorkspaceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function StatCard({ value, label, accent }: { value: number | string; label: str
function WorkspaceRow({ workspace, onClick }: { workspace: WorkspaceInfo; onClick: () => void }) {
const isRunning = workspace.status === 'running'
const isError = workspace.status === 'error'
const isCreating = workspace.status === 'creating'

return (
<button
Expand All @@ -40,11 +41,15 @@ function WorkspaceRow({ workspace, onClick }: { workspace: WorkspaceInfo; onClic
"block h-2.5 w-2.5 rounded-full",
isRunning && "bg-emerald-500",
isError && "bg-destructive",
!isRunning && !isError && "bg-muted-foreground/40"
isCreating && "bg-amber-500",
!isRunning && !isError && !isCreating && "bg-muted-foreground/40"
)} />
{isRunning && (
<span className="absolute inset-0 h-2.5 w-2.5 rounded-full bg-emerald-500 animate-ping opacity-75" />
)}
{isCreating && (
<span className="absolute inset-0 h-2.5 w-2.5 rounded-full bg-amber-500 animate-ping opacity-75" />
)}
</div>

<div className="flex-1 min-w-0">
Expand All @@ -55,6 +60,11 @@ function WorkspaceRow({ workspace, onClick }: { workspace: WorkspaceInfo; onClic
Running
</span>
)}
{isCreating && (
<span className="text-[10px] uppercase tracking-wider text-amber-600 dark:text-amber-400 font-medium">
Starting
</span>
)}
{isError && (
<span className="text-[10px] uppercase tracking-wider text-destructive font-medium">
Error
Expand Down