a0cfbbc2d2
Bug 1 — in-container repo mounting: - docker-compose.yml: added /data/working-copies:/data/working-copies mount to API container so workspace dirs are visible on host filesystem - Dockerfile: create /data/working-copies dir in image Bug 2 — /home/user not writable: - workspace_manager.py: chmod 777 workspace dirs + 666 files after clone and after sync, so any container user can write - manifest_compiler.py: explicit mkdir + chown + chmod 755 for home dir in generated Dockerfile Bug 3 — terminal text shifts left on typing: - terminal.tsx: removed manual term.refresh() after fit (caused reflow) - Track lastSentCols/lastSentRows and only send resize when dimensions actually changed, preventing resize feedback loops Bug 4 — ESC key captured by terminal: - terminal.tsx: attachCustomKeyEventHandler allows ESC to propagate to browser when not in alternate buffer (vim/tmux), so modals/navigation work; ESC still sent to PTY when in vim/tmux alternate screen Quality gates: ruff clean, tsc --noEmit clean, pytest workspaces (9 passed)
202 lines
5.2 KiB
TypeScript
202 lines
5.2 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
|
|
import {
|
|
getUserSessions,
|
|
type Session,
|
|
checkInstanceHealth,
|
|
} from "../api/sessions";
|
|
import { getUserConfig } from "../api/settings";
|
|
import { ErrorState, LoadingState } from "../components/data-states";
|
|
import { SessionList } from "../components/session-list";
|
|
import { SessionCard } from "../components/session-card";
|
|
import { useInstanceActions } from "../hooks/use-instance-actions";
|
|
import type { InstanceHealth } from "../api/sessions";
|
|
|
|
type SessionsStatus = "loading" | "ready" | "error";
|
|
|
|
export const SessionsPage = () => {
|
|
const [status, setStatus] = useState<SessionsStatus>("loading");
|
|
const [sessions, setSessions] = useState<Session[]>([]);
|
|
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
|
|
|
const [tunnelHealth, setTunnelHealth] = useState<
|
|
Record<string, InstanceHealth>
|
|
>({});
|
|
|
|
const loadSessions = useCallback(async () => {
|
|
setStatus("loading");
|
|
try {
|
|
const [sessionsData, config] = await Promise.all([
|
|
getUserSessions(),
|
|
getUserConfig(),
|
|
]);
|
|
setSessions(sessionsData);
|
|
setLastSessionId(config.last_session_id ?? null);
|
|
setStatus("ready");
|
|
} catch {
|
|
setStatus("error");
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadSessions();
|
|
}, [loadSessions]);
|
|
|
|
const {
|
|
loadingSessionId,
|
|
dirtyDeleteSession,
|
|
dirtyDeleteFiles,
|
|
handleOpen,
|
|
handleStart,
|
|
handleStop,
|
|
handleDelete,
|
|
handleForceDelete,
|
|
handleRecreateTunnel,
|
|
clearDirtyDelete,
|
|
} = useInstanceActions({ onRefresh: loadSessions });
|
|
|
|
// Poll health every 30 seconds for active web-enabled instances
|
|
useEffect(() => {
|
|
const checkHealth = async () => {
|
|
const activeSessions = sessions.filter(
|
|
(s) =>
|
|
["running", "starting", "unhealthy", "probing"].includes(s.status) &&
|
|
s.tool_type_interfaces?.includes("web"),
|
|
);
|
|
for (const session of activeSessions) {
|
|
try {
|
|
const health = await checkInstanceHealth(
|
|
session.project_id,
|
|
session.repository_id,
|
|
session.id,
|
|
);
|
|
setTunnelHealth((prev) => ({
|
|
...prev,
|
|
[session.id]: health,
|
|
}));
|
|
} catch {
|
|
setTunnelHealth((prev) => ({
|
|
...prev,
|
|
[session.id]: {
|
|
healthy: false,
|
|
container_status: "unknown",
|
|
container_health: null,
|
|
tunnel_status: "unreachable",
|
|
tunnel_status_code: null,
|
|
probe_status: "unknown",
|
|
last_probe_output: null,
|
|
error: "check failed",
|
|
} as InstanceHealth,
|
|
}));
|
|
}
|
|
}
|
|
};
|
|
|
|
void checkHealth();
|
|
const interval = setInterval(() => void checkHealth(), 30000);
|
|
return () => clearInterval(interval);
|
|
}, [sessions]);
|
|
|
|
const lastSession = sessions.find((s) => s.id === lastSessionId) ?? null;
|
|
|
|
return (
|
|
<section className="stack sessions-page">
|
|
<div className="page-header">
|
|
<h1>Sessions</h1>
|
|
</div>
|
|
|
|
{status === "loading" && <LoadingState message="Loading sessions..." />}
|
|
|
|
{status === "error" && (
|
|
<ErrorState
|
|
message="Failed to load sessions"
|
|
onRetry={() => void loadSessions()}
|
|
/>
|
|
)}
|
|
|
|
{status === "ready" && (
|
|
<>
|
|
{/* Last Session */}
|
|
{lastSession && (
|
|
<div className="last-session-section">
|
|
<h2>Last Session</h2>
|
|
<SessionCard
|
|
session={lastSession}
|
|
onOpen={handleOpen}
|
|
onDelete={handleDelete}
|
|
isBusy={loadingSessionId === lastSession.id}
|
|
tunnelHealth={tunnelHealth[lastSession.id] || null}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Session List */}
|
|
<div className="sessions-list-wrapper">
|
|
<SessionList
|
|
sessions={sessions}
|
|
onOpen={handleOpen}
|
|
onStart={handleStart}
|
|
onStop={handleStop}
|
|
onDelete={handleDelete}
|
|
onRecreateTunnel={handleRecreateTunnel}
|
|
actionBusyId={loadingSessionId}
|
|
tunnelHealth={tunnelHealth}
|
|
/>
|
|
</div>
|
|
|
|
{/* Create Session — use floating button */}
|
|
<div className="create-session-section">
|
|
<h2>Start New Tool</h2>
|
|
<p className="muted">
|
|
Use the floating button (bottom-right) to start a tool in any
|
|
workspace.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Dirty Delete Confirmation Modal */}
|
|
{dirtyDeleteSession && (
|
|
<div className="modal-overlay" onClick={clearDirtyDelete}>
|
|
<div
|
|
className="modal-content"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3>Uncommitted Changes</h3>
|
|
<p>
|
|
The repository{" "}
|
|
<strong>{dirtyDeleteSession.repository_name}</strong> has
|
|
uncommitted changes. Deleting this session will permanently
|
|
lose these changes.
|
|
</p>
|
|
<div className="changed-files-list">
|
|
<h4>Changed files:</h4>
|
|
<ul>
|
|
{dirtyDeleteFiles.map((file, idx) => (
|
|
<li key={idx}>{file}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<div className="modal-actions">
|
|
<button
|
|
className="secondary-button"
|
|
onClick={clearDirtyDelete}
|
|
type="button"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="danger-button"
|
|
onClick={() => void handleForceDelete(dirtyDeleteSession)}
|
|
type="button"
|
|
>
|
|
Force Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
);
|
|
};
|