-
-
- {user?.name ?? "User"}
+
+
+
+
+
+
+
+ {!isMobile && (
+
+ )}
+
+
+
+
-
-
- {!isMobile && (
-
+ {isMobile && (
+
s.status === "running").length
+ }
+ />
)}
-
-
-
-
+
-
- {isMobile && (
-
s.status === "running").length
- }
- />
- )}
-
-
+
diff --git a/apps/web/src/components/features/session/create-session-form.tsx b/apps/web/src/components/features/session/create-session-form.tsx
index 89e4188..9fb1e78 100644
--- a/apps/web/src/components/features/session/create-session-form.tsx
+++ b/apps/web/src/components/features/session/create-session-form.tsx
@@ -1,575 +1,564 @@
import { useState, useEffect } from "react";
import { Icon } from "../../icon";
-import { createInstance, startInstance, type ToolInstance } from "../../../api/sessions";
+import {
+ createInstance,
+ startInstance,
+ type ToolInstance,
+} from "../../../api/sessions";
import type { Project } from "../../../types";
-import { listRepositoryBranches, type GitRepository, type Branch } from "../../../api/git-repositories";
+import {
+ listRepositoryBranches,
+ type GitRepository,
+ type Branch,
+} from "../../../api/git-repositories";
import type { ToolType } from "../../../api/tool-types";
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
-import { listConfigProfiles, type ConfigProfile } from "../../../api/config-profiles";
+import {
+ listConfigProfiles,
+ type ConfigProfile,
+} from "../../../api/config-profiles";
interface CreateSessionFormProps {
- projects: Project[];
- repositories: GitRepository[];
- toolTypes: ToolType[];
- fixedProjectId?: string;
- fixedRepoId?: string;
- projectName?: string;
- repoName?: string;
- showCloneMode?: boolean;
- showFixedFields?: boolean;
- onProjectChange?: (projectId: string) => void;
- onSuccess?: (instance: ToolInstance) => void;
- onCancel?: () => void;
- submitLabel?: string;
- className?: string;
+ projects: Project[];
+ repositories: GitRepository[];
+ toolTypes: ToolType[];
+ fixedProjectId?: string;
+ fixedRepoId?: string;
+ projectName?: string;
+ repoName?: string;
+ showCloneMode?: boolean;
+ showFixedFields?: boolean;
+ onProjectChange?: (projectId: string) => void;
+ onSuccess?: (instance: ToolInstance) => void;
+ onCancel?: () => void;
+ submitLabel?: string;
+ className?: string;
}
export const CreateSessionForm = ({
- projects,
- repositories,
- toolTypes,
- fixedProjectId,
- fixedRepoId,
- projectName,
- repoName,
- showCloneMode = true,
- showFixedFields = true,
- onProjectChange,
- onSuccess,
- onCancel,
- submitLabel = "Create Session",
- className = "",
+ projects,
+ repositories,
+ toolTypes,
+ fixedProjectId,
+ fixedRepoId,
+ projectName,
+ repoName,
+ showCloneMode = true,
+ showFixedFields = true,
+ onProjectChange,
+ onSuccess,
+ onCancel,
+ submitLabel = "Create Session",
+ className = "",
}: CreateSessionFormProps) => {
- const [selectedProject, setSelectedProject] = useState(fixedProjectId || "");
- const [selectedRepo, setSelectedRepo] = useState(fixedRepoId || "");
- const [selectedToolType, setSelectedToolType] = useState("");
- const [displayName, setDisplayName] = useState("");
- const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
- const [branch, setBranch] = useState("main");
- const [sshKeys, setSshKeys] = useState
([]);
- const [configProfiles, setConfigProfiles] = useState([]);
- const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
- const [selectedSshKeyIds, setSelectedSshKeyIds] = useState([]);
-
- const [branches, setBranches] = useState([]);
- const [isLoadingBranches, setIsLoadingBranches] = useState(false);
- const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
- const [newBranchName, setNewBranchName] = useState("");
- const [baseBranch, setBaseBranch] = useState("");
-
- const [status, setStatus] = useState<"idle" | "creating" | "error">("idle");
- const [progress, setProgress] = useState("");
- const [error, setError] = useState(null);
+ const [selectedProject, setSelectedProject] = useState(fixedProjectId || "");
+ const [selectedRepo, setSelectedRepo] = useState(fixedRepoId || "");
+ const [selectedToolType, setSelectedToolType] = useState("");
+ const [displayName, setDisplayName] = useState("");
+ const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
+ const [branch, setBranch] = useState("main");
+ const [sshKeys, setSshKeys] = useState([]);
+ const [configProfiles, setConfigProfiles] = useState([]);
+ const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
+ const [selectedSshKeyIds, setSelectedSshKeyIds] = useState([]);
- // Load SSH keys
- useEffect(() => {
- const loadKeys = async () => {
- try {
- const keys = await listSSHKeys();
- setSshKeys(keys);
- } catch {
- // ignore
- }
- };
- void loadKeys();
- }, []);
+ const [branches, setBranches] = useState([]);
+ const [isLoadingBranches, setIsLoadingBranches] = useState(false);
+ const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
+ const [newBranchName, setNewBranchName] = useState("");
+ const [baseBranch, setBaseBranch] = useState("");
- // Load config profiles when tool type is selected
- useEffect(() => {
- const projectId = fixedProjectId || selectedProject;
- if (!selectedToolType || !projectId) {
- setConfigProfiles([]);
- setSelectedConfigProfile("");
- return;
- }
- const loadProfiles = async () => {
- try {
- const profiles = await listConfigProfiles(projectId, selectedToolType);
- setConfigProfiles(profiles);
- // Auto-select default if available
- const defaultProfile = profiles.find((p) => p.is_default);
- if (defaultProfile) {
- setSelectedConfigProfile(defaultProfile.id);
- }
- } catch {
- // ignore
- }
- };
- void loadProfiles();
- }, [selectedToolType, selectedProject, fixedProjectId]);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState(null);
- // Load branches when selected repo changes
- useEffect(() => {
- const projectId = fixedProjectId || selectedProject;
- if (!selectedRepo || !projectId || !showCloneMode) {
- setBranches([]);
- return;
- }
- const loadBranches = async () => {
- setIsLoadingBranches(true);
- try {
- const response = await listRepositoryBranches(projectId, selectedRepo);
- setBranches(response.branches);
- if (response.default_branch) {
- setBranch(response.default_branch);
- setBaseBranch(response.default_branch);
- }
- } catch {
- // ignore
- } finally {
- setIsLoadingBranches(false);
- }
- };
- void loadBranches();
- }, [selectedRepo, selectedProject, fixedProjectId, showCloneMode]);
+ // Load SSH keys
+ useEffect(() => {
+ const loadKeys = async () => {
+ try {
+ const keys = await listSSHKeys();
+ setSshKeys(keys);
+ } catch {
+ // ignore
+ }
+ };
+ void loadKeys();
+ }, []);
- // Filter repositories by selected project
- const availableRepos = selectedProject
- ? repositories.filter((r) => r.project_id === selectedProject)
- : [];
+ // Load config profiles when tool type is selected
+ useEffect(() => {
+ const projectId = fixedProjectId || selectedProject;
+ if (!selectedToolType || !projectId) {
+ setConfigProfiles([]);
+ setSelectedConfigProfile("");
+ return;
+ }
+ const loadProfiles = async () => {
+ try {
+ const profiles = await listConfigProfiles(projectId, selectedToolType);
+ setConfigProfiles(profiles);
+ // Auto-select default if available
+ const defaultProfile = profiles.find((p) => p.is_default);
+ if (defaultProfile) {
+ setSelectedConfigProfile(defaultProfile.id);
+ }
+ } catch {
+ // ignore
+ }
+ };
+ void loadProfiles();
+ }, [selectedToolType, selectedProject, fixedProjectId]);
- const handleSubmit = async (event: React.FormEvent) => {
- event.preventDefault();
- setError(null);
+ // Load branches when selected repo changes
+ useEffect(() => {
+ const projectId = fixedProjectId || selectedProject;
+ if (!selectedRepo || !projectId || !showCloneMode) {
+ setBranches([]);
+ return;
+ }
+ const loadBranches = async () => {
+ setIsLoadingBranches(true);
+ try {
+ const response = await listRepositoryBranches(projectId, selectedRepo);
+ setBranches(response.branches);
+ if (response.default_branch) {
+ setBranch(response.default_branch);
+ setBaseBranch(response.default_branch);
+ }
+ } catch {
+ // ignore
+ } finally {
+ setIsLoadingBranches(false);
+ }
+ };
+ void loadBranches();
+ }, [selectedRepo, selectedProject, fixedProjectId, showCloneMode]);
- const projectId = fixedProjectId || selectedProject;
- const repoId = fixedRepoId || selectedRepo;
+ // Filter repositories by selected project
+ const availableRepos = selectedProject
+ ? repositories.filter((r) => r.project_id === selectedProject)
+ : [];
- if (!projectId || !repoId || !selectedToolType) {
- setError("Project, repository, and tool type are required");
- return;
- }
+ const resetForm = () => {
+ if (!fixedProjectId) setSelectedProject("");
+ if (!fixedRepoId) setSelectedRepo("");
+ setSelectedToolType("");
+ setDisplayName("");
+ setCloneMode("mount");
+ setBranch("main");
+ setIsCreatingNewBranch(false);
+ setNewBranchName("");
+ setBaseBranch("");
+ setBranches([]);
+ setSelectedSshKeyIds([]);
+ setSelectedConfigProfile("");
+ };
- if (showCloneMode && cloneMode === "clone") {
- const repo = repositories.find((r) => r.id === repoId);
- if (!repo?.ssh_key_id) {
- setError("Repository must have an SSH key assigned for clone mode");
- return;
- }
- }
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setError(null);
- setStatus("creating");
- setProgress("Creating instance...");
+ const projectId = fixedProjectId || selectedProject;
+ const repoId = fixedRepoId || selectedRepo;
- try {
- const instance = await createInstance(
- projectId,
- repoId,
- selectedToolType,
- displayName || undefined,
- showCloneMode ? cloneMode : undefined,
- showCloneMode && cloneMode === "clone"
- ? isCreatingNewBranch
- ? baseBranch
- : branch
- : undefined,
- showCloneMode && cloneMode === "clone" && isCreatingNewBranch
- ? newBranchName
- : undefined,
- selectedConfigProfile || undefined,
- selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined
- );
+ if (!projectId || !repoId || !selectedToolType) {
+ setError("Project, repository, and tool type are required");
+ return;
+ }
- setProgress("Starting container...");
- await startInstance(
- projectId,
- repoId,
- instance.id,
- selectedConfigProfile || undefined,
- selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined
- );
+ if (showCloneMode && cloneMode === "clone") {
+ const repo = repositories.find((r) => r.id === repoId);
+ if (!repo?.ssh_key_id) {
+ setError("Repository must have an SSH key assigned for clone mode");
+ return;
+ }
+ }
- // Reset form
- if (!fixedProjectId) setSelectedProject("");
- if (!fixedRepoId) setSelectedRepo("");
- setSelectedToolType("");
- setDisplayName("");
- setCloneMode("mount");
- setBranch("main");
- setIsCreatingNewBranch(false);
- setNewBranchName("");
- setBaseBranch("");
- setBranches([]);
- setSelectedSshKeyIds([]);
- setStatus("idle");
-
- onSuccess?.(instance);
- } catch {
- setStatus("error");
- setError("Failed to create session");
- setProgress("");
- }
- };
+ setIsSubmitting(true);
- const isSubmitting = status === "creating";
+ try {
+ const instance = await createInstance(
+ projectId,
+ repoId,
+ selectedToolType,
+ displayName || undefined,
+ showCloneMode ? cloneMode : undefined,
+ showCloneMode && cloneMode === "clone"
+ ? isCreatingNewBranch
+ ? baseBranch
+ : branch
+ : undefined,
+ showCloneMode && cloneMode === "clone" && isCreatingNewBranch
+ ? newBranchName
+ : undefined,
+ selectedConfigProfile || undefined,
+ selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
+ );
- // Determine which steps are active/unlocked
- const hasProject = !!(fixedProjectId || selectedProject);
- const hasRepo = !!(fixedRepoId || selectedRepo);
- const hasToolType = !!selectedToolType;
+ await startInstance(
+ projectId,
+ repoId,
+ instance.id,
+ selectedConfigProfile || undefined,
+ selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
+ );
- const renderStep = (
- label: string,
- number: number,
- isActive: boolean,
- isComplete: boolean,
- children: React.ReactNode
- ) => {
- const stepClass = `workflow-step ${isActive ? "active" : ""} ${isComplete ? "complete" : ""}`;
- return (
-
-
- {number}
- {label}
-
-
- {children}
-
-
- );
- };
+ resetForm();
+ onSuccess?.(instance);
+ } catch {
+ setError("Failed to create session");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
- return (
-
- {isSubmitting && (
-
-
-
-
{progress || "Creating session..."}
-
-
- )}
+ const hasProject = !!(fixedProjectId || selectedProject);
+ const hasRepo = !!(fixedRepoId || selectedRepo);
+ const hasToolType = !!selectedToolType;
-
+
+ );
};
diff --git a/apps/web/src/components/features/session/session-card.tsx b/apps/web/src/components/features/session/session-card.tsx
index 58608f8..53d330e 100644
--- a/apps/web/src/components/features/session/session-card.tsx
+++ b/apps/web/src/components/features/session/session-card.tsx
@@ -105,12 +105,7 @@ export function SessionCard({
};
return (
-
- {isBusy && (
-
-
-
- )}
+
diff --git a/apps/web/src/components/features/session/session-progress-panel.tsx b/apps/web/src/components/features/session/session-progress-panel.tsx
new file mode 100644
index 0000000..142da1e
--- /dev/null
+++ b/apps/web/src/components/features/session/session-progress-panel.tsx
@@ -0,0 +1,115 @@
+import { useEffect } from "react";
+import {
+ useSessionOperations,
+ type Operation,
+} from "../../../state/session-operations";
+import { useEventContext } from "../../../state/events";
+import { Icon } from "../../icon";
+
+interface StepConfig {
+ label: string;
+ index: number;
+}
+
+const steps: StepConfig[] = [
+ { label: "Created", index: 1 },
+ { label: "Building", index: 2 },
+ { label: "Starting", index: 3 },
+ { label: "Ready", index: 4 },
+];
+
+function OperationItem({
+ operation,
+ onDismiss,
+}: {
+ operation: Operation;
+ onDismiss: () => void;
+}) {
+ const isDone = operation.status === "success" || operation.status === "error";
+ const isError = operation.status === "error";
+
+ return (
+
+
+
+ {isError ? (
+
+ ) : isDone ? (
+
+ ) : (
+
+ )}
+ {operation.displayName}
+
+ {isDone && (
+
+ )}
+
+
{operation.message}
+
+ {steps.map((step) => {
+ const active = operation.step >= step.index;
+ const current = operation.step === step.index && !isDone;
+ return (
+
+ {step.label}
+
+ );
+ })}
+
+
+ );
+}
+
+export function SessionProgressPanel() {
+ const { operations, updateOperationFromEvent, dismissOperation } =
+ useSessionOperations();
+ const { events } = useEventContext();
+
+ useEffect(() => {
+ if (events.length === 0) return;
+ const latestEvent = events[events.length - 1];
+ updateOperationFromEvent(latestEvent);
+ }, [events, updateOperationFromEvent]);
+
+ const visibleOperations = operations.filter(
+ (op) =>
+ op.status === "pending" ||
+ op.status === "active" ||
+ (op.status === "success" && Date.now() - op.createdAt < 5000) ||
+ op.status === "error",
+ );
+
+ if (visibleOperations.length === 0) return null;
+
+ return (
+
+
+ Operations
+
+
+ {visibleOperations.map((operation) => (
+ dismissOperation(operation.id)}
+ />
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/features/tool/instance-list.tsx b/apps/web/src/components/features/tool/instance-list.tsx
index ee09f47..7496d37 100644
--- a/apps/web/src/components/features/tool/instance-list.tsx
+++ b/apps/web/src/components/features/tool/instance-list.tsx
@@ -17,6 +17,7 @@ import {
} from "../../../api/config-profiles";
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
import { useEventContext } from "../../../state/events";
+import { useSessions } from "../../../state/sessions";
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
@@ -37,6 +38,7 @@ export const InstanceList = ({
toolTypes,
}: InstanceListProps) => {
const navigate = useNavigate();
+ const { refreshSessions } = useSessions();
const [instances, setInstances] = useState
([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
@@ -108,6 +110,7 @@ export const InstanceList = ({
const handleCreateSuccess = async () => {
setShowCreate(false);
await loadInstances();
+ await refreshSessions();
};
const loadConfigProfiles = useCallback(
@@ -196,6 +199,7 @@ export const InstanceList = ({
await deleteInstance(projectId, repoId, instanceId);
// Update state immediately instead of reloading
setInstances((prev) => prev.filter((i) => i.id !== instanceId));
+ await refreshSessions();
} catch {
setError("Failed to delete instance");
} finally {
@@ -245,15 +249,7 @@ export const InstanceList = ({
) : (
{instances.map((instance) => (
-
- {busyInstanceId === instance.id && (
-
-
-
- )}
+
{instance.display_name}
diff --git a/apps/web/src/components/features/tool/tool-starter.tsx b/apps/web/src/components/features/tool/tool-starter.tsx
index e5680f9..95dd48b 100644
--- a/apps/web/src/components/features/tool/tool-starter.tsx
+++ b/apps/web/src/components/features/tool/tool-starter.tsx
@@ -8,6 +8,8 @@ import {
type ConfigProfile,
} from "../../../api/config-profiles";
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
+import { useSessions } from "../../../state/sessions";
+import { useSessionOperations } from "../../../state/session-operations";
import type { Workspace } from "../../../types/workspace";
import type { ToolInstance } from "../../../api/sessions";
@@ -22,6 +24,8 @@ export function ToolStarter({
onStarted,
onCancel,
}: ToolStarterProps) {
+ const { addOrUpdateSession } = useSessions();
+ const { startOperation } = useSessionOperations();
const [toolTypes, setToolTypes] = useState
([]);
const [toolTypesLoading, setToolTypesLoading] = useState(true);
const [toolTypesError, setToolTypesError] = useState(null);
@@ -141,6 +145,21 @@ export function ToolStarter({
selectedProfileId || undefined,
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
);
+ addOrUpdateSession({
+ id: instance.id,
+ display_name: instance.display_name,
+ tool_type_name: instance.tool_type_name,
+ tool_icon: "code",
+ tool_type_interfaces: instance.tool_type_interfaces || [],
+ repository_name: workspace.repo_name,
+ repository_id: workspace.repo_id,
+ project_name: workspace.project_name,
+ project_id: workspace.project_id,
+ workspace_name: workspace.name,
+ status: instance.status || "pending",
+ url: instance.url || null,
+ });
+ startOperation("create", instance.id, instance.display_name);
onStarted(instance);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start tool");
@@ -153,6 +172,9 @@ export function ToolStarter({
displayName,
workspace,
onStarted,
+ toolTypes,
+ addOrUpdateSession,
+ startOperation,
]);
return (
diff --git a/apps/web/src/hooks/use-instance-actions.ts b/apps/web/src/hooks/use-instance-actions.ts
index 2c66bd0..6194912 100644
--- a/apps/web/src/hooks/use-instance-actions.ts
+++ b/apps/web/src/hooks/use-instance-actions.ts
@@ -7,6 +7,8 @@ import {
renameInstance,
} from "../api/sessions";
import type { Session } from "../api/sessions";
+import { useSessions } from "../state/sessions";
+import { useSessionOperations } from "../state/session-operations";
interface UseInstanceActionsOptions {
onRefresh: () => Promise;
@@ -30,6 +32,8 @@ export function useInstanceActions(
options: UseInstanceActionsOptions,
): UseInstanceActionsReturn {
const { onRefresh } = options;
+ const { removeSession } = useSessions();
+ const { startOperation, completeOperation } = useSessionOperations();
const [loadingSessionId, setLoadingSessionId] = useState(null);
const [dirtyDeleteSession, setDirtyDeleteSession] = useState(
null,
@@ -62,6 +66,7 @@ export function useInstanceActions(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
+ startOperation("start", session.id, session.display_name);
try {
await startInstance(
session.project_id,
@@ -70,18 +75,19 @@ export function useInstanceActions(
);
await onRefresh();
} catch {
- // ignore
+ completeOperation(session.id, "start", "error");
} finally {
setLoadingSessionId(null);
}
},
- [loadingSessionId, onRefresh],
+ [loadingSessionId, onRefresh, startOperation, completeOperation],
);
const handleStop = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
+ startOperation("stop", session.id, session.display_name);
try {
await stopInstance(
session.project_id,
@@ -90,18 +96,19 @@ export function useInstanceActions(
);
await onRefresh();
} catch {
- // ignore
+ completeOperation(session.id, "stop", "error");
} finally {
setLoadingSessionId(null);
}
},
- [loadingSessionId, onRefresh],
+ [loadingSessionId, onRefresh, startOperation, completeOperation],
);
const handleDelete = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
+ startOperation("delete", session.id, session.display_name);
try {
await deleteInstance(
session.project_id,
@@ -110,8 +117,10 @@ export function useInstanceActions(
);
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
+ removeSession(session.id);
await onRefresh();
} catch (error) {
+ completeOperation(session.id, "delete", "error");
const axiosError = error as {
response?: {
status?: number;
@@ -130,13 +139,20 @@ export function useInstanceActions(
setLoadingSessionId(null);
}
},
- [loadingSessionId, onRefresh],
+ [
+ loadingSessionId,
+ onRefresh,
+ removeSession,
+ startOperation,
+ completeOperation,
+ ],
);
const handleForceDelete = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
+ startOperation("delete", session.id, session.display_name);
try {
await deleteInstance(
session.project_id,
@@ -146,20 +162,28 @@ export function useInstanceActions(
);
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
+ removeSession(session.id);
await onRefresh();
} catch {
- // ignore
+ completeOperation(session.id, "delete", "error");
} finally {
setLoadingSessionId(null);
}
},
- [loadingSessionId, onRefresh],
+ [
+ loadingSessionId,
+ onRefresh,
+ removeSession,
+ startOperation,
+ completeOperation,
+ ],
);
const handleRecreateTunnel = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
+ startOperation("recreate-tunnel", session.id, session.display_name);
try {
await recreateInstanceTunnel(
session.project_id,
@@ -167,16 +191,18 @@ export function useInstanceActions(
session.id,
);
await onRefresh();
+ completeOperation(session.id, "recreate-tunnel", "success");
} catch (err) {
const message =
(err as { response?: { data?: { detail?: string } } })?.response?.data
?.detail || "Failed to recreate tunnel";
+ completeOperation(session.id, "recreate-tunnel", "error", message);
alert(message);
} finally {
setLoadingSessionId(null);
}
},
- [loadingSessionId, onRefresh],
+ [loadingSessionId, onRefresh, startOperation, completeOperation],
);
const handleRename = useCallback(
diff --git a/apps/web/src/pages/DashboardPage.test.tsx b/apps/web/src/pages/DashboardPage.test.tsx
index 33dcda6..585445f 100644
--- a/apps/web/src/pages/DashboardPage.test.tsx
+++ b/apps/web/src/pages/DashboardPage.test.tsx
@@ -1,8 +1,11 @@
+import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { HomePage } from "./DashboardPage";
+import { SessionsProvider } from "../state/sessions";
+import { SessionOperationsProvider } from "../state/session-operations";
const mockDashboard = vi.fn();
const mockSessions = vi.fn();
@@ -55,7 +58,11 @@ describe("HomePage", () => {
render(
-
+
+
+
+
+
,
);
@@ -73,7 +80,11 @@ describe("HomePage", () => {
render(
-
+
+
+
+
+
,
);
diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx
index 515cc89..80fcec0 100644
--- a/apps/web/src/pages/DashboardPage.tsx
+++ b/apps/web/src/pages/DashboardPage.tsx
@@ -2,15 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
-import {
- getUserSessions,
- checkInstanceHealth,
- type Session as SessionApi,
- type InstanceHealth,
-} from "../api/sessions";
+import { checkInstanceHealth, type InstanceHealth } from "../api/sessions";
import { ErrorState, LoadingState } from "../components/data-states";
import { SessionList } from "../components/features/session/session-list";
import { useInstanceActions } from "../hooks/use-instance-actions";
+import { useSessions } from "../state/sessions";
type HomeStatus = "loading" | "ready" | "error";
@@ -20,13 +16,11 @@ const summaryCards = [
{ label: "Repositories", key: "repositories" },
] as const;
-type SessionView = SessionApi;
-
export const HomePage = () => {
const navigate = useNavigate();
+ const { sessions, refreshSessions } = useSessions();
const [status, setStatus] = useState("loading");
const [summary, setSummary] = useState(null);
- const [sessions, setSessions] = useState([]);
const [tunnelHealth, setTunnelHealth] = useState<
Record
>({});
@@ -35,17 +29,16 @@ export const HomePage = () => {
const loadHome = useCallback(async () => {
setStatus("loading");
try {
- const [dashboard, sessionData] = await Promise.all([
+ const [dashboard] = await Promise.all([
getDashboardSummary(),
- getUserSessions(),
+ refreshSessions(),
]);
setSummary(dashboard);
- setSessions(sessionData as SessionView[]);
setStatus("ready");
} catch {
setStatus("error");
}
- }, []);
+ }, [refreshSessions]);
useEffect(() => {
void loadHome();
@@ -58,7 +51,7 @@ export const HomePage = () => {
handleStop,
handleDelete,
handleRecreateTunnel,
- } = useInstanceActions({ onRefresh: loadHome });
+ } = useInstanceActions({ onRefresh: refreshSessions });
// Poll tunnel health every 30 seconds for running instances
useEffect(() => {
diff --git a/apps/web/src/pages/SessionsPage.tsx b/apps/web/src/pages/SessionsPage.tsx
index c6ac630..c37ad44 100644
--- a/apps/web/src/pages/SessionsPage.tsx
+++ b/apps/web/src/pages/SessionsPage.tsx
@@ -1,46 +1,39 @@
import { useCallback, useEffect, useState } from "react";
-import {
- getUserSessions,
- type Session,
- checkInstanceHealth,
-} from "../api/sessions";
+import { checkInstanceHealth, type InstanceHealth } from "../api/sessions";
import { getUserConfig } from "../api/settings";
import { ErrorState, LoadingState } from "../components/data-states";
import { SessionList } from "../components/features/session/session-list";
import { SessionCard } from "../components/features/session/session-card";
import { useInstanceActions } from "../hooks/use-instance-actions";
-import type { InstanceHealth } from "../api/sessions";
+import { useSessions } from "../state/sessions";
type SessionsStatus = "loading" | "ready" | "error";
export const SessionsPage = () => {
+ const { sessions, isLoading, error, refreshSessions } = useSessions();
const [status, setStatus] = useState("loading");
- const [sessions, setSessions] = useState([]);
const [lastSessionId, setLastSessionId] = useState(null);
const [tunnelHealth, setTunnelHealth] = useState<
Record
>({});
- const loadSessions = useCallback(async () => {
+ const loadPageData = useCallback(async () => {
setStatus("loading");
try {
- const [sessionsData, config] = await Promise.all([
- getUserSessions(),
- getUserConfig(),
- ]);
- setSessions(sessionsData);
+ await refreshSessions();
+ const config = await getUserConfig();
setLastSessionId(config.last_session_id ?? null);
setStatus("ready");
} catch {
setStatus("error");
}
- }, []);
+ }, [refreshSessions]);
useEffect(() => {
- void loadSessions();
- }, [loadSessions]);
+ void loadPageData();
+ }, [loadPageData]);
const {
loadingSessionId,
@@ -54,7 +47,7 @@ export const SessionsPage = () => {
handleRecreateTunnel,
handleRename,
clearDirtyDelete,
- } = useInstanceActions({ onRefresh: loadSessions });
+ } = useInstanceActions({ onRefresh: refreshSessions });
// Poll health every 30 seconds for active web-enabled instances
useEffect(() => {
@@ -100,22 +93,26 @@ export const SessionsPage = () => {
const lastSession = sessions.find((s) => s.id === lastSessionId) ?? null;
+ const isPageLoading = status === "loading" || (status === "ready" && isLoading && sessions.length === 0);
+
return (
Sessions
- {status === "loading" && }
-
{status === "error" && (
void loadSessions()}
+ message={error?.message ?? "Failed to load sessions"}
+ onRetry={() => void loadPageData()}
/>
)}
- {status === "ready" && (
+ {(status === "loading" || isPageLoading) && (
+
+ )}
+
+ {status === "ready" && !isPageLoading && (
<>
{/* Last Session */}
{lastSession && (
diff --git a/apps/web/src/state/notifications.tsx b/apps/web/src/state/notifications.tsx
index 1bb17cb..7a4c4d7 100644
--- a/apps/web/src/state/notifications.tsx
+++ b/apps/web/src/state/notifications.tsx
@@ -84,7 +84,6 @@ export function NotificationProvider({
}
}
// Silently log other errors; next cycle proceeds
- // eslint-disable-next-line no-console
console.error("Notification unread count poll failed", err);
}
}, []);
@@ -111,7 +110,6 @@ export function NotificationProvider({
listIntervalRef.current = null;
}
}
- // eslint-disable-next-line no-console
console.error("Notification list poll failed", err);
} finally {
setIsLoading(false);
diff --git a/apps/web/src/state/session-operations.tsx b/apps/web/src/state/session-operations.tsx
new file mode 100644
index 0000000..63c64db
--- /dev/null
+++ b/apps/web/src/state/session-operations.tsx
@@ -0,0 +1,259 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+
+import type { InstanceEventPayload } from "../types/events";
+
+export type OperationType =
+ | "create"
+ | "start"
+ | "stop"
+ | "restart"
+ | "delete"
+ | "recreate-tunnel";
+
+export type OperationStatus = "pending" | "active" | "success" | "error";
+
+export interface Operation {
+ id: string;
+ type: OperationType;
+ instanceId: string;
+ displayName: string;
+ status: OperationStatus;
+ message: string;
+ step: number;
+ createdAt: number;
+}
+
+export interface SessionOperationsContextType {
+ operations: Operation[];
+ startOperation: (
+ type: OperationType,
+ instanceId: string,
+ displayName: string,
+ ) => string;
+ updateOperationFromEvent: (event: InstanceEventPayload) => void;
+ completeOperation: (
+ instanceId: string,
+ type: OperationType,
+ outcome: "success" | "error",
+ message?: string,
+ ) => void;
+ dismissOperation: (id: string) => void;
+}
+
+const SessionOperationsContext =
+ createContext(undefined);
+
+let operationIdCounter = 0;
+
+function actionLabel(type: OperationType): string {
+ switch (type) {
+ case "create":
+ return "Creating";
+ case "start":
+ return "Starting";
+ case "stop":
+ return "Stopping";
+ case "restart":
+ return "Restarting";
+ case "delete":
+ return "Deleting";
+ case "recreate-tunnel":
+ return "Recreating tunnel";
+ default:
+ return "Working";
+ }
+}
+
+function messageForEvent(
+ type: OperationType,
+ event: InstanceEventPayload,
+): string {
+ if (event.message) return event.message;
+
+ switch (event.event) {
+ case "instance.created":
+ return "Created";
+ case "instance.started":
+ return "Starting container";
+ case "instance.restarted":
+ return "Restarting container";
+ case "instance.stopped":
+ return "Stopped";
+ case "instance.deleted":
+ return "Deleted";
+ case "instance.health_changed":
+ if (event.status === "running") return "Running";
+ if (event.status === "unhealthy") return "Unhealthy";
+ return `Status: ${event.status ?? event.event}`;
+ case "instance.error":
+ return event.message ?? "Error";
+ default:
+ return event.message ?? actionLabel(type);
+ }
+}
+
+function stepForEvent(event: InstanceEventPayload): number {
+ switch (event.event) {
+ case "instance.created":
+ return 1;
+ case "instance.started":
+ case "instance.restarted":
+ return 2;
+ case "instance.health_changed":
+ if (event.status === "running") return 4;
+ if (event.status === "unhealthy") return 4;
+ return 3;
+ case "instance.error":
+ return 4;
+ case "instance.stopped":
+ return 4;
+ case "instance.deleted":
+ return 4;
+ default:
+ return 0;
+ }
+}
+
+export const SessionOperationsProvider = ({
+ children,
+}: {
+ children: ReactNode;
+}) => {
+ const [operations, setOperations] = useState([]);
+
+ const startOperation = useCallback(
+ (type: OperationType, instanceId: string, displayName: string): string => {
+ const id = `op-${++operationIdCounter}`;
+ const operation: Operation = {
+ id,
+ type,
+ instanceId,
+ displayName,
+ status: "pending",
+ message: actionLabel(type),
+ step: 0,
+ createdAt: Date.now(),
+ };
+ setOperations((prev) => [operation, ...prev].slice(0, 20));
+ return id;
+ },
+ [],
+ );
+
+ const updateOperationFromEvent = useCallback(
+ (event: InstanceEventPayload) => {
+ setOperations((prev) => {
+ const matches = prev.filter(
+ (op) => op.instanceId === event.instance_id && op.status !== "success",
+ );
+ if (matches.length === 0) return prev;
+
+ const updated = new Map();
+ for (const op of prev) updated.set(op.id, op);
+
+ for (const op of matches) {
+ const nextStep = stepForEvent(event);
+ const message = messageForEvent(op.type, event);
+ let nextStatus: OperationStatus = op.status;
+
+ if (event.event === "instance.error") {
+ nextStatus = "error";
+ } else if (
+ event.event === "instance.health_changed" &&
+ event.status === "running"
+ ) {
+ nextStatus = "success";
+ } else if (event.event === "instance.deleted") {
+ nextStatus = "success";
+ } else if (event.event === "instance.stopped") {
+ nextStatus = "success";
+ } else if (nextStatus === "pending") {
+ nextStatus = "active";
+ }
+
+ updated.set(op.id, {
+ ...op,
+ status: nextStatus,
+ message,
+ step: Math.max(op.step, nextStep),
+ });
+ }
+
+ return Array.from(updated.values());
+ });
+ },
+ [],
+ );
+
+ const completeOperation = useCallback(
+ (
+ instanceId: string,
+ type: OperationType,
+ outcome: "success" | "error",
+ message?: string,
+ ) => {
+ setOperations((prev) => {
+ const match = prev.find(
+ (op) => op.instanceId === instanceId && op.type === type,
+ );
+ if (!match) return prev;
+ return prev.map((op) =>
+ op.id === match.id
+ ? {
+ ...op,
+ status: outcome,
+ message:
+ message ?? (outcome === "success" ? "Done" : "Failed"),
+ step: 4,
+ }
+ : op,
+ );
+ });
+ },
+ [],
+ );
+
+ const dismissOperation = useCallback((id: string) => {
+ setOperations((prev) => prev.filter((op) => op.id !== id));
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ operations,
+ startOperation,
+ updateOperationFromEvent,
+ completeOperation,
+ dismissOperation,
+ }),
+ [
+ operations,
+ startOperation,
+ updateOperationFromEvent,
+ completeOperation,
+ dismissOperation,
+ ],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useSessionOperations = (): SessionOperationsContextType => {
+ const context = useContext(SessionOperationsContext);
+ if (context === undefined) {
+ throw new Error(
+ "useSessionOperations must be used within a SessionOperationsProvider",
+ );
+ }
+ return context;
+};
diff --git a/apps/web/src/state/sessions.tsx b/apps/web/src/state/sessions.tsx
index 5b0353c..5053269 100644
--- a/apps/web/src/state/sessions.tsx
+++ b/apps/web/src/state/sessions.tsx
@@ -1,35 +1,82 @@
-import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
-export interface Session {
- id: string;
- display_name: string;
- tool_type_name: string;
- tool_icon: string;
- tool_type_interfaces: string[];
- repository_name: string;
- repository_id: string;
- project_name: string;
- project_id: string;
- status: string;
- url: string | null;
-}
+import { getUserSessions } from "../api/sessions";
+import type { Session } from "../api/sessions";
-interface SessionsContextType {
+export interface SessionsContextType {
sessions: Session[];
- setAllSessions: (sessions: Session[]) => void;
+ isLoading: boolean;
+ error: Error | null;
+ refreshSessions: () => Promise;
+ addOrUpdateSession: (session: Session) => void;
+ removeSession: (id: string) => void;
}
const SessionsContext = createContext(undefined);
export const SessionsProvider = ({ children }: { children: ReactNode }) => {
const [sessions, setSessions] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
- const setAllSessions = useCallback((newSessions: Session[]) => {
- setSessions(newSessions);
+ const refreshSessions = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const data = await getUserSessions();
+ setSessions(data);
+ } catch (err) {
+ setError(err instanceof Error ? err : new Error("Failed to load sessions"));
+ } finally {
+ setIsLoading(false);
+ }
}, []);
+ const addOrUpdateSession = useCallback((session: Session) => {
+ setSessions((prev) => {
+ const existing = prev.find((s) => s.id === session.id);
+ if (existing) {
+ return prev.map((s) => (s.id === session.id ? { ...s, ...session } : s));
+ }
+ return [session, ...prev];
+ });
+ }, []);
+
+ const removeSession = useCallback((id: string) => {
+ setSessions((prev) => prev.filter((s) => s.id !== id));
+ }, []);
+
+ useEffect(() => {
+ void refreshSessions();
+ // Poll every 30 seconds to reconcile shared state with the server.
+ const interval = setInterval(() => {
+ void refreshSessions();
+ }, 30000);
+ return () => clearInterval(interval);
+ }, [refreshSessions]);
+
+ const value = useMemo(
+ () => ({
+ sessions,
+ isLoading,
+ error,
+ refreshSessions,
+ addOrUpdateSession,
+ removeSession,
+ }),
+ [sessions, isLoading, error, refreshSessions, addOrUpdateSession, removeSession],
+ );
+
return (
-
+
{children}
);
diff --git a/apps/web/src/styles/pages/sessions.css b/apps/web/src/styles/pages/sessions.css
index b1ce221..ad6711f 100644
--- a/apps/web/src/styles/pages/sessions.css
+++ b/apps/web/src/styles/pages/sessions.css
@@ -141,11 +141,6 @@
padding: var(--space-2);
}
@media (max-width: 767px) {
- .create-session-form .form-row {
- grid-template-columns: 1fr;
- gap: var(--space-3);
- }
-
.create-session-form input,
.create-session-form select,
.create-session-form textarea,
diff --git a/apps/web/src/styles/utilities.css b/apps/web/src/styles/utilities.css
index a84f962..5945f22 100644
--- a/apps/web/src/styles/utilities.css
+++ b/apps/web/src/styles/utilities.css
@@ -383,41 +383,127 @@ a.nav-item,
align-items: center;
}
-.instance-card.busy {
- opacity: 0.7;
+/* ============================================
+ Session Operations Progress Panel
+ ============================================ */
+
+.session-progress-panel {
+ position: fixed;
+ bottom: var(--space-4);
+ right: var(--space-4);
+ width: min(360px, calc(100vw - 2rem));
+ max-height: min(480px, 60vh);
+ overflow-y: auto;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: var(--space-2);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
}
-.instance-busy-overlay {
- position: absolute;
- inset: 0;
+.session-progress-panel-header {
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--border);
+ font-weight: 600;
+ font-size: var(--text-sm);
+}
+
+.session-progress-panel-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ padding: var(--space-3);
+}
+
+.session-operation-item {
+ padding: var(--space-3);
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--space-2);
+}
+
+.session-operation-item.error {
+ border-color: var(--danger);
+}
+
+.session-operation-header {
display: flex;
align-items: center;
- justify-content: center;
+ justify-content: space-between;
gap: var(--space-2);
- background: rgba(var(--bg-rgb, 255, 255, 255), 0.8);
- border-radius: 10px;
- z-index: 1;
+}
+
+.session-operation-title {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+}
+
+.session-operation-title .icon {
+ flex-shrink: 0;
+}
+
+.session-operation-name {
+ font-weight: 600;
font-size: var(--text-sm);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.session-operation-message {
+ margin: var(--space-1) 0 0;
+ font-size: var(--text-xs);
color: var(--muted);
}
-.session-card {
- position: relative;
+.session-operation-steps {
+ display: flex;
+ gap: var(--space-2);
+ margin-top: var(--space-2);
}
-.session-card.busy {
- opacity: 0.7;
+.session-operation-step {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: var(--muted);
+ padding: 2px 6px;
+ border-radius: 4px;
+ background: var(--bg-muted);
}
-.session-busy-overlay {
- position: absolute;
- inset: 0;
+.session-operation-step.active {
+ background: var(--primary);
+ color: var(--primary-fg);
+}
+
+.session-operation-step.current {
+ box-shadow: 0 0 0 1px var(--primary);
+}
+
+.session-operation-dismiss {
+ background: transparent;
+ border: none;
+ color: var(--muted);
+ cursor: pointer;
+ padding: var(--space-1);
display: flex;
align-items: center;
justify-content: center;
- background: rgba(var(--bg-rgb, 255, 255, 255), 0.8);
- border-radius: var(--space-2);
- z-index: 1;
+}
+
+@media (max-width: 767px) {
+ .session-progress-panel {
+ left: var(--space-2);
+ right: var(--space-2);
+ bottom: calc(var(--space-2) + 64px);
+ width: auto;
+ max-height: 35vh;
+ }
}
/* ============================================
@@ -1222,56 +1308,6 @@ a.nav-item,
pointer-events: none;
}
-.create-session-form-wrapper {
- position: relative;
-}
-
-.loading-overlay {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 10;
- background: rgba(255, 254, 249, 0.7);
- border-radius: var(--space-2);
-}
-
-.loading-content {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: var(--space-3);
- padding: var(--space-6);
- background: var(--panel);
- border: 1px solid var(--border);
- border-radius: var(--space-2);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
-}
-
-.loading-content .icon {
- animation: spin 1s linear infinite;
- color: var(--brand);
-}
-
-.loading-content p {
- margin: 0;
- font-size: var(--font-size-base);
- color: var(--muted);
-}
-
-@keyframes spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
.recent-sessions-list {
display: flex;
flex-direction: column;
@@ -1337,65 +1373,6 @@ a.nav-item,
gap: var(--space-4);
}
-/* Workflow Step Styles */
-.workflow-form {
- display: flex;
- flex-direction: column;
- gap: var(--space-6);
-}
-
-.workflow-step {
- opacity: 0.4;
- pointer-events: none;
- transition: opacity 0.2s ease;
-}
-
-.workflow-step.active {
- opacity: 1;
- pointer-events: auto;
-}
-
-.workflow-step.complete {
- opacity: 0.7;
- pointer-events: auto;
-}
-
-.workflow-step-header {
- display: flex;
- align-items: center;
- gap: var(--space-3);
- margin-bottom: var(--space-3);
- font-weight: 600;
- color: var(--text-muted);
-}
-
-.workflow-step.active .workflow-step-header {
- color: var(--text-primary);
-}
-
-.workflow-step-number {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- border-radius: 50%;
- background: var(--bg-muted);
- color: var(--text-muted);
- font-size: 14px;
- font-weight: 600;
-}
-
-.workflow-step.active .workflow-step-number {
- background: var(--primary);
- color: white;
-}
-
-.workflow-step.complete .workflow-step-number {
- background: var(--success);
- color: white;
-}
-
.status-badge {
display: inline-flex;
align-items: center;
diff --git a/openspec/changes/tool-session-progress-and-updates/.openspec.yaml b/openspec/changes/tool-session-progress-and-updates/.openspec.yaml
new file mode 100644
index 0000000..8fe2055
--- /dev/null
+++ b/openspec/changes/tool-session-progress-and-updates/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-06-12
diff --git a/openspec/changes/tool-session-progress-and-updates/.pi-map.index.md b/openspec/changes/tool-session-progress-and-updates/.pi-map.index.md
new file mode 100644
index 0000000..f8e7e2e
--- /dev/null
+++ b/openspec/changes/tool-session-progress-and-updates/.pi-map.index.md
@@ -0,0 +1,37 @@
+# . (index)
+dir: .
+
+## Project Map Protocol
+
+1. Read this protocol and the root `.pi-map.index.md` first.
+2. Use `index:` / `map:` references to open relevant directory indexes and maps.
+3. Load indexes before rich maps during task-start navigation.
+4. Read the local rich map and actual source before editing.
+5. Treat non-empty `## dirty` sections in either artifact as stale.
+6. If source and generated artifacts disagree, trust source.
+7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
+8. After editing source, run `project_map_patch` for each changed file.
+9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
+
+Trust boundary: index routes, map orients, source decides.
+
+## role
+Frontend architecture specification for real-time session progress tracking using Server-Sent Events and shared React state management.
+## parent
+-
+## children
+- specs
+ index: specs/.pi-map.index.md
+ map: specs/.pi-map.md
+## files
+- .openspec.yaml
+- design.md
+- proposal.md
+- tasks.md
+## links
+index: ./.pi-map.index.md
+map: ./.pi-map.md
+## workflows
+-
+## dirty
+-
diff --git a/openspec/changes/tool-session-progress-and-updates/.pi-map.md b/openspec/changes/tool-session-progress-and-updates/.pi-map.md
new file mode 100644
index 0000000..87677b0
--- /dev/null
+++ b/openspec/changes/tool-session-progress-and-updates/.pi-map.md
@@ -0,0 +1,36 @@
+# .
+dir: .
+
+index: ./.pi-map.index.md
+
+## Project Map Protocol
+
+1. Read this protocol and the root `.pi-map.index.md` first.
+2. Use `index:` / `map:` references to open relevant directory indexes and maps.
+3. Load indexes before rich maps during task-start navigation.
+4. Read the local rich map and actual source before editing.
+5. Treat non-empty `## dirty` sections in either artifact as stale.
+6. If source and generated artifacts disagree, trust source.
+7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
+8. After editing source, run `project_map_patch` for each changed file.
+9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
+
+Trust boundary: index routes, map orients, source decides.
+
+## role
+Frontend architecture specification for real-time session progress tracking using Server-Sent Events and shared React state management.
+## files
+- .openspec.yaml | Defines an OpenAPI specification metadata file with schema type and creation date
+- design.md | Design document for frontend-only refactoring of session lifecycle progress tracking and live UI updates using SSE events and shared React context. | dep: React, SSE (useEvents), SessionsContext, SessionOperationsContext, AppShell, DashboardPage, SessionsPage, CreateSessionForm, InstanceList, useInstanceActions, ToolStarter, StartToolFAB, EventToastBridge
+- proposal.md | Proposes a frontend architecture change to add real-time session progress tracking via SSE and shared state across the application. | dep: React, SSE events, state management, AppShell, DashboardPage, SessionsPage, use-instance-actions, CreateSessionForm, SessionCard, InstanceCard
+- tasks.md | A task checklist for refactoring a web application's session management to use shared state, a global progress panel, and removing legacy progress overlays. | dep: React, state/sessions.tsx, state/session-operations.tsx, useEvents, use-instance-actions.ts, AppShell, SessionsPage, DashboardPage, tool-starter.tsx, start-tool-fab.tsx, instance-list.tsx, create-session-form.tsx, session-card.tsx
+## arch
+Documentation-driven design package using OpenAPI metadata, markdown specifications (design/proposal/tasks), and event-driven UI patterns with SSE, shared React context, and global progress panel replacing legacy overlay components.
+## tags
+tsx, session, progress, react, state, design, shared, appshell
+## symbols
+-
+## workflows
+-
+## dirty
+-
diff --git a/openspec/changes/tool-session-progress-and-updates/design.md b/openspec/changes/tool-session-progress-and-updates/design.md
new file mode 100644
index 0000000..1b9ab24
--- /dev/null
+++ b/openspec/changes/tool-session-progress-and-updates/design.md
@@ -0,0 +1,71 @@
+# Design: Tool Session Progress and Live Updates
+
+## Goals / Non-Goals
+
+**Goals:**
+- Provide structured, real-time progress feedback for every tool lifecycle action.
+- Ensure all session lists (nav, dashboard, sessions page) update immediately after create/delete.
+- Remove blocking and card-dimming overlays that hide context and provide no step detail.
+- Keep the change frontend-only, reusing existing SSE and session APIs.
+
+**Non-Goals:**
+- No new backend endpoints or event types.
+- No changes to the actual Docker/container orchestration logic.
+- No redesign of the session card layout beyond action/progress affordances.
+
+## Decisions
+
+### Decision: Global progress panel in the corner
+A fixed panel (bottom-right desktop, bottom sheet style on mobile) lists in-flight operations. Each operation shows:
+- Action icon + session display name
+- Current step label derived from the latest SSE event
+- A compact stepper: Created → Building → Starting → Probing → Ready/Error
+- Dismiss button once settled
+
+This is non-blocking, works across pages, and does not interfere with the modal create flow.
+
+### Decision: SSE event-driven updates
+The panel subscribes to `useEvents`. When an operation is started we record `instanceId` + `action`. Incoming events that match a tracked instance update the operation's message, status, and step. Events handled:
+- `instance.created`, `instance.started`, `instance.restarted` → advance
+- `instance.health_changed` with `status=running` → complete success
+- `instance.error` → complete error
+- `instance.stopped` → complete for stop action
+- `instance.deleted` → complete for delete action
+
+### Decision: Shared session state
+`SessionsContext` is promoted from a nav-only data holder to the authoritative session list:
+- Holds `sessions`, `isLoading`, `error`, `refreshSessions()`.
+- Provides `addOrUpdateSession`, `removeSession` for optimistic updates.
+- `AppShell`, `DashboardPage`, and `SessionsPage` read from this context instead of fetching independently.
+
+### Decision: Optimistic create/delete updates
+- **Create**: after the API returns a pending instance, add it to shared state and start tracking. Subsequent SSE events update its status.
+- **Delete**: remove from shared state as soon as the API succeeds; the progress panel tracks the action until the `instance.deleted` event confirms it.
+- **Other actions**: keep the existing per-action busy flag on the card for button disabled states, but the panel provides the detailed progress.
+
+### Decision: Remove legacy overlays
+- Delete `loading-overlay` and workflow step markup from `CreateSessionForm`.
+- Remove `session-busy-overlay` and `instance-busy-overlay` (the dimming overlays), but keep button disabled states and small inline spinners.
+
+## Risks / Trade-offs
+
+**Risk: Shared context causes extra re-renders**
+→ Mitigation: context value is memoized; lists use the same data they already fetched.
+
+**Risk: SSE events arriving before operation is tracked**
+→ Mitigation: start tracking before calling the create/start API; for deletes the removal is optimistic and the panel reconciles on the event.
+
+**Risk: Duplicate feedback between panel and toasts**
+→ Mitigation: panel shows in-flight steps; toasts remain for terminal success/error only. Existing `EventToastBridge` logic is left largely unchanged.
+
+## Migration Plan
+
+1. Extend `SessionsContext` with loading/error/refresh/update helpers.
+2. Create `SessionOperationsContext` + `SessionProgressPanel` and render it in `AppShell`.
+3. Update `useInstanceActions` to use shared state and start/stop tracking operations.
+4. Update `ToolStarter`/`StartToolFAB` to add pending sessions and start tracking.
+5. Update `CreateSessionForm` to remove overlay and report status to parent.
+6. Update `InstanceList` to refresh shared state after create/delete.
+7. Update `SessionsPage` and `DashboardPage` to consume shared context.
+8. Remove legacy overlay styles.
+9. Run typecheck, lint, and tests.
diff --git a/openspec/changes/tool-session-progress-and-updates/proposal.md b/openspec/changes/tool-session-progress-and-updates/proposal.md
new file mode 100644
index 0000000..a4abde1
--- /dev/null
+++ b/openspec/changes/tool-session-progress-and-updates/proposal.md
@@ -0,0 +1,34 @@
+# Tool Session Progress Indication and Live Updates
+
+## Why
+
+Creating or deleting a tool session currently leaves the UI out of sync. After starting a tool from the floating action button, the new session does not appear on `/sessions` or the dashboard until the user manually refreshes or the 30-second poll fires. The existing progress feedback is also poor: `CreateSessionForm` shows a blocking overlay with only two static messages ("Creating instance…", "Starting container…"), and `SessionCard`/`InstanceCard` dim the whole card with a generic spinner. Users cannot see real backend progress (building, starting, probing, running, error) and receive no confirmation when an action finishes.
+
+## What Changes
+
+- Introduce a **global session progress panel** that tracks all lifecycle actions (create, start, stop, restart, delete, recreate tunnel) using the existing SSE event stream (`instance.created`, `instance.started`, `instance.health_changed`, `instance.error`, `instance.stopped`, `instance.deleted`, `instance.restarted`).
+- Make lists update **immediately** after create/delete by sharing session state across `AppShell`, `DashboardPage`, and `SessionsPage`.
+- Remove the blocking full-screen overlay in `CreateSessionForm` and the card-level busy overlays; replace them with minimal disabled/spinner states and the global panel.
+- Keep existing toast notifications for terminal states (success/error) while the panel handles in-flight progress.
+
+## Capabilities
+
+### New Capabilities
+- `session-progress-panel`: Global, non-blocking progress UI for tool lifecycle actions driven by SSE events.
+- `shared-session-state`: Centralized session list used by navigation, dashboard, and sessions page.
+
+### Modified Capabilities
+- `session-lifecycle-ux`: Delete and stop actions now update shared state immediately; create/start actions add a pending session and show progress.
+- `sessions-hub`: Dashboard and sessions lists reflect new/deleted sessions without manual refresh.
+
+## Impact
+
+- Frontend: new `state/session-operations.tsx`, new `components/features/session/session-progress-panel.tsx`, updates to `state/sessions.tsx`, `AppShell`, `SessionsPage`, `DashboardPage`, `hooks/use-instance-actions.ts`, `components/features/tool/instance-list.tsx`, `components/features/tool/tool-starter.tsx`, `components/features/session/create-session-form.tsx`, `components/features/session/session-card.tsx`, and styles.
+- No API changes: relies on existing lifecycle events and `/users/me/sessions`.
+
+## Quality Gates
+
+- `npm run typecheck`
+- `npm run lint`
+- Existing frontend tests still pass
+- Manual verification: create and delete sessions from the FAB and repository detail page; verify panel updates and lists refresh without manual reload.
diff --git a/openspec/changes/tool-session-progress-and-updates/specs/.pi-map.index.md b/openspec/changes/tool-session-progress-and-updates/specs/.pi-map.index.md
new file mode 100644
index 0000000..b88dd99
--- /dev/null
+++ b/openspec/changes/tool-session-progress-and-updates/specs/.pi-map.index.md
@@ -0,0 +1,23 @@
+# specs (index)
+dir: specs
+
+## role
+Contains specification documents and design artifacts that define system requirements, APIs, and behavioral contracts for the project.
+## parent
+index: ./.pi-map.index.md
+map: ./.pi-map.md
+## children
+- specs/session-lifecycle-ux
+ index: specs/session-lifecycle-ux/.pi-map.index.md
+ map: specs/session-lifecycle-ux/.pi-map.md
+- specs/sessions-hub
+ index: specs/sessions-hub/.pi-map.index.md
+ map: specs/sessions-hub/.pi-map.md
+## files
+## links
+index: specs/.pi-map.index.md
+map: specs/.pi-map.md
+## workflows
+-
+## dirty
+-
diff --git a/openspec/changes/tool-session-progress-and-updates/specs/.pi-map.md b/openspec/changes/tool-session-progress-and-updates/specs/.pi-map.md
new file mode 100644
index 0000000..d9f51e1
--- /dev/null
+++ b/openspec/changes/tool-session-progress-and-updates/specs/.pi-map.md
@@ -0,0 +1,18 @@
+# specs
+dir: specs
+
+index: specs/.pi-map.index.md
+
+## role
+Contains specification documents and design artifacts that define system requirements, APIs, and behavioral contracts for the project.
+## files
+## arch
+Documentation-driven architecture using structured specifications (likely OpenAPI/Protobuf schemas, RFCs, or design docs) to establish interfaces before implementation, serving as the source of truth for cross-service contracts and client generation.
+## tags
+-
+## symbols
+-
+## workflows
+-
+## dirty
+-
diff --git a/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/.pi-map.index.md b/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/.pi-map.index.md
new file mode 100644
index 0000000..ad26a2f
--- /dev/null
+++ b/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/.pi-map.index.md
@@ -0,0 +1,19 @@
+# specs/session-lifecycle-ux (index)
+dir: specs/session-lifecycle-ux
+
+## role
+Defines UI/UX requirements for real-time session lifecycle feedback using server-sent events with non-blocking progress indicators and optimistic updates.
+## parent
+index: specs/.pi-map.index.md
+map: specs/.pi-map.md
+## children
+-
+## files
+- spec.md
+## links
+index: specs/session-lifecycle-ux/.pi-map.index.md
+map: specs/session-lifecycle-ux/.pi-map.md
+## workflows
+-
+## dirty
+-
diff --git a/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/.pi-map.md b/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/.pi-map.md
new file mode 100644
index 0000000..2785c22
--- /dev/null
+++ b/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/.pi-map.md
@@ -0,0 +1,19 @@
+# specs/session-lifecycle-ux
+dir: specs/session-lifecycle-ux
+
+index: specs/session-lifecycle-ux/.pi-map.index.md
+
+## role
+Defines UI/UX requirements for real-time session lifecycle feedback using server-sent events with non-blocking progress indicators and optimistic updates.
+## files
+- spec.md | Specifies UI/UX requirements for non-blocking progress indicators and optimistic updates for session lifecycle actions using SSE events. | dep: SSE, global progress indicator, shared state management, API
+## arch
+Event-driven reactive UX pattern with SSE streaming for asynchronous progress tracking and optimistic state management for immediate user feedback.
+## tags
+sse, spec, specifies, requirements, non, blocking, progress, indicators
+## symbols
+-
+## workflows
+-
+## dirty
+-
diff --git a/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/spec.md b/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/spec.md
new file mode 100644
index 0000000..091cab5
--- /dev/null
+++ b/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/spec.md
@@ -0,0 +1,44 @@
+## ADDED Requirements
+
+### Requirement: Lifecycle actions show structured progress
+The system SHALL display non-blocking, structured progress feedback for every tool lifecycle action (create, start, stop, restart, delete, recreate tunnel).
+
+#### Scenario: Create a new session
+- **WHEN** the user creates a session
+- **THEN** a global progress indicator appears showing the current backend step
+- **AND** the indicator advances through Created, Building, Starting, Probing, and Running/Error based on SSE events
+
+#### Scenario: Delete a session
+- **WHEN** the user deletes a session
+- **THEN** the session disappears from the current list immediately
+- **AND** the global progress indicator shows "Deleting…" until the backend confirms deletion via SSE or API response
+
+#### Scenario: Stop or restart a session
+- **WHEN** the user stops or restarts a session
+- **THEN** the global progress indicator shows the action in progress
+- **AND** the indicator updates when the backend publishes the corresponding SSE event
+
+### Requirement: Remove blocking progress overlays
+The system SHALL NOT dim the entire form or card with a generic spinner while an action is in progress.
+
+#### Scenario: Create session form submission
+- **WHEN** the create session form is submitted
+- **THEN** form controls are disabled
+- **AND** no full-screen overlay blocks the rest of the application
+- **AND** progress is shown in the global progress panel
+
+#### Scenario: Card action in progress
+- **WHEN** a session card action is triggered
+- **THEN** the relevant button is disabled or shows a small inline spinner
+- **AND** the card itself remains fully visible and interactive for other sessions
+
+## MODIFIED Requirements
+
+### Requirement: Deleted sessions disappear from UI immediately
+**FROM:** The system SHALL update the frontend state immediately after a session is successfully deleted.
+**TO:** The system SHALL remove the session from all visible lists optimistically when the delete API call succeeds, and reconcile via shared state.
+
+#### Scenario: Delete session from any page
+- **WHEN** the user deletes a session
+- **THEN** the session is removed from the nav sidebar, dashboard, and sessions page without a reload
+- **AND** a failure re-adds the session to the list and shows an error
diff --git a/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/.pi-map.index.md b/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/.pi-map.index.md
new file mode 100644
index 0000000..ddb9240
--- /dev/null
+++ b/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/.pi-map.index.md
@@ -0,0 +1,19 @@
+# specs/sessions-hub (index)
+dir: specs/sessions-hub
+
+## role
+Defines requirements for a shared global session state system that enables real-time UI synchronization across dashboard, sessions page, and navigation components when sessions change.
+## parent
+index: specs/.pi-map.index.md
+map: specs/.pi-map.md
+## children
+-
+## files
+- spec.md
+## links
+index: specs/sessions-hub/.pi-map.index.md
+map: specs/sessions-hub/.pi-map.md
+## workflows
+-
+## dirty
+-
diff --git a/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/.pi-map.md b/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/.pi-map.md
new file mode 100644
index 0000000..526f2e4
--- /dev/null
+++ b/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/.pi-map.md
@@ -0,0 +1,19 @@
+# specs/sessions-hub
+dir: specs/sessions-hub
+
+index: specs/sessions-hub/.pi-map.index.md
+
+## role
+Defines requirements for a shared global session state system that enables real-time UI synchronization across dashboard, sessions page, and navigation components when sessions change.
+## files
+- spec.md | Defines requirements for implementing a shared global session state across dashboard, sessions page, and navigation to enable immediate UI updates when sessions are created or modified. | dep: SessionsContext, React Context API, sessions API (`/users/me/sessions`)
+## arch
+Specification-driven architecture using a centralized hub pattern with reactive state propagation to decoupled consumers.
+## tags
+sessions, spec, defines, requirements, implementing, shared, global, session
+## symbols
+-
+## workflows
+-
+## dirty
+-
diff --git a/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/spec.md b/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/spec.md
new file mode 100644
index 0000000..b5334d9
--- /dev/null
+++ b/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/spec.md
@@ -0,0 +1,33 @@
+## ADDED Requirements
+
+### Requirement: New sessions appear in lists immediately
+The system SHALL add a newly created session to every session list as soon as the creation API returns.
+
+#### Scenario: Create session from floating button
+- **WHEN** the user starts a tool from the global floating action button
+- **THEN** the modal closes
+- **AND** the new session appears on the dashboard and sessions page with status "pending" or "building"
+- **AND** the active session count in the navigation updates immediately
+
+#### Scenario: Create session from repository detail
+- **WHEN** the user launches a tool inside a repository/workspace detail page
+- **THEN** the new session appears in the repository instance list
+- **AND** the global session lists update without requiring a manual refresh
+
+### Requirement: Global session state is authoritative
+The system SHALL use a single shared session state for the navigation sidebar, dashboard, and sessions page.
+
+#### Scenario: Shared state updated
+- **WHEN** a session is added, removed, or changed
+- **THEN** the navigation badge, dashboard, and sessions page all reflect the change
+- **AND** no page reload is required
+
+## MODIFIED Requirements
+
+### Requirement: Sessions page loads sessions
+**FROM:** Sessions page fetches `/users/me/sessions` independently on mount.
+**TO:** Sessions page reads sessions from the shared `SessionsContext`; the context fetches on mount and exposes a refresh function.
+
+### Requirement: Dashboard page loads sessions
+**FROM:** Dashboard page fetches `/users/me/sessions` independently on mount.
+**TO:** Dashboard page reads sessions from the shared `SessionsContext`.
diff --git a/openspec/changes/tool-session-progress-and-updates/tasks.md b/openspec/changes/tool-session-progress-and-updates/tasks.md
new file mode 100644
index 0000000..57970bf
--- /dev/null
+++ b/openspec/changes/tool-session-progress-and-updates/tasks.md
@@ -0,0 +1,37 @@
+# Tasks: Tool Session Progress and Live Updates
+
+## 1. Shared session state
+
+- [x] 1.1 Extend `state/sessions.tsx` to hold `sessions`, `isLoading`, `error`, and `refreshSessions`, `addOrUpdateSession`, `removeSession` helpers.
+- [x] 1.2 Ensure `AppShell` uses the extended context and no longer needs a separate local load.
+- [x] 1.3 Update `SessionsPage` to read sessions from context and call `refreshSessions` after mutations.
+- [x] 1.4 Update `DashboardPage` to read sessions from context and call `refreshSessions` after mutations.
+
+## 2. Global progress panel
+
+- [x] 2.1 Create `state/session-operations.tsx` with an `Operation` model and `useSessionOperations` hook/API.
+- [x] 2.2 Create `components/features/session/session-progress-panel.tsx` that subscribes to `useEvents`, matches events to tracked operations, and renders a fixed panel.
+- [x] 2.3 Render `SessionProgressPanel` in `AppShell` so it is visible on every page.
+- [x] 2.4 Add minimal styles for the progress panel (desktop corner + mobile bottom bar).
+
+## 3. Integrate actions with progress and shared state
+
+- [x] 3.1 Update `hooks/use-instance-actions.ts` to remove deleted sessions optimistically from shared state, refresh on completion, and track start/stop/restart/delete/recreate-tunnel operations.
+- [x] 3.2 Update `components/features/tool/tool-starter.tsx` to add the new pending session to shared state and start tracking the create/start operation; close modal immediately.
+- [x] 3.3 Update `components/features/tool/start-tool-fab.tsx` to close modal and rely on the panel/toasts for feedback.
+- [x] 3.4 Update `components/features/tool/instance-list.tsx` to refresh shared sessions after create/delete.
+
+## 4. Remove legacy progress overlays
+
+- [x] 4.1 Remove `loading-overlay` and workflow step UI from `components/features/session/create-session-form.tsx`; keep form disabled during submit.
+- [x] 4.2 Remove `session-busy-overlay` from `components/features/session/session-card.tsx`; keep button disabled states and inline spinner.
+- [x] 4.3 Remove `instance-busy-overlay` from `components/features/tool/instance-list.tsx`; keep button disabled states and inline spinner.
+- [x] 4.4 Remove unused overlay CSS classes or repurpose them.
+
+## 5. Verification
+
+- [x] 5.1 Run `npm run typecheck` in `apps/web`.
+- [x] 5.2 Run `npm run lint` in `apps/web`.
+- [x] 5.3 Run frontend tests (`npm test -- --run` or equivalent).
+- [ ] 5.4 Manually verify: create from FAB, create from repository detail, delete from sessions page, stop/restart from dashboard.
+- [x] 5.5 Run `project_map_patch` for every edited source file.