Files
headquarter/apps/web/src/pages/sessions.tsx
T
alex a0cfbbc2d2 fix: container mount permissions, terminal shift, ESC capture
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)
2026-06-01 22:36:31 +02:00

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>
);
};