feat: workspace-first UI refresh - PR-2 workspace detail page

- Add workspace detail page (/workspaces/:id) with 4 tabs:
  - Files: file tree, viewer, editor, git toolbar (commit/push/pull/fetch)
  - Git: branch selector, commit history
  - Tools: instance grid, start tool modal
  - Settings: workspace info read-only
- Add workspace API clients: workspace-files, workspace-git, workspace-instances
- Add hooks: useWorkspaceFiles, useWorkspaceGit, useWorkspaceInstances
- WorkspaceCard links to detail page via router Link
- Add comprehensive CSS for workspace detail layout
- Mobile: bottom tab bar, responsive file tree/split
- TypeScript + eslint clean

Quality gates: tsc --noEmit clean, eslint clean
This commit is contained in:
2026-06-01 17:04:44 +02:00
parent e7587ca9f5
commit 27c77af591
12 changed files with 1366 additions and 33 deletions
+41 -24
View File
@@ -7,7 +7,12 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
from src.auth.dependencies import (
_get_owned_project,
_get_user,
get_current_user_id,
get_db_session,
)
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
@@ -90,7 +95,9 @@ async def list_projects(
"""
user = await _get_user(session, user_id)
result = await session.execute(
select(Project).where(Project.owner_id == user.id).order_by(Project.created_at.desc())
select(Project)
.where(Project.owner_id == user.id)
.order_by(Project.created_at.desc())
)
projects = result.scalars().all()
@@ -113,29 +120,37 @@ async def list_projects(
select(func.count()).where(ToolInstance.workspace_id == ws.id)
)
instance_count = inst_result.scalar() or 0
workspaces.append({
"id": str(ws.id),
"name": ws.name,
"branch": ws.branch,
"status": ws.status,
"instance_count": instance_count,
})
workspaces.append(
{
"id": str(ws.id),
"name": ws.name,
"branch": ws.branch,
"status": ws.status,
"instance_count": instance_count,
}
)
repositories.append({
"id": str(repo.id),
"name": repo.name,
"remote_url": repo.remote_url,
"workspaces": workspaces,
})
repositories.append(
{
"id": str(repo.id),
"name": repo.name,
"remote_url": repo.remote_url,
"workspaces": workspaces,
}
)
enriched.append({
"id": str(project.id),
"name": project.name,
"description": project.description,
"owner_id": str(project.owner_id),
"repositories": repositories,
"created_at": project.created_at.isoformat() if project.created_at else None,
})
enriched.append(
{
"id": str(project.id),
"name": project.name,
"description": project.description,
"owner_id": str(project.owner_id),
"repositories": repositories,
"created_at": project.created_at.isoformat()
if project.created_at
else None,
}
)
return enriched
@@ -226,7 +241,9 @@ async def delete_project(
project = await _get_owned_project(project_id, user_id, session)
# Delete repositories from disk and database
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
result = await session.execute(
select(GitRepository).where(GitRepository.project_id == project_id)
)
repositories = result.scalars().all()
for repo in repositories:
if os.path.exists(repo.path):
+1 -3
View File
@@ -111,9 +111,7 @@ class GitOperations:
if rc != 0:
raise RuntimeError(f"Git add failed: {err}")
rc, _, err = await self._run(
"git", "-C", self.cwd, "commit", "-m", message
)
rc, _, err = await self._run("git", "-C", self.cwd, "commit", "-m", message)
if rc != 0:
raise RuntimeError(f"Git commit failed: {err}")
+45
View File
@@ -0,0 +1,45 @@
/** Workspace file API client. */
import { apiClient } from "./client";
export interface FileEntry {
name: string;
path: string;
type: "file" | "directory";
size?: number;
}
export async function listWorkspaceFiles(
workspaceId: string,
path: string = "",
): Promise<FileEntry[]> {
const response = await apiClient.get<{ entries: FileEntry[] }>(
`/workspaces/${workspaceId}/files/`,
{ params: { path } },
);
return response.data.entries;
}
export async function getWorkspaceFileContent(
workspaceId: string,
path: string,
): Promise<string> {
const response = await apiClient.get<{ content: string }>(
`/workspaces/${workspaceId}/files/content`,
{ params: { path } },
);
return response.data.content;
}
export async function saveWorkspaceFile(
workspaceId: string,
path: string,
content: string,
commitMessage?: string,
): Promise<void> {
await apiClient.post(`/workspaces/${workspaceId}/files/content`, {
path,
content,
message: commitMessage,
});
}
+75
View File
@@ -0,0 +1,75 @@
/** Workspace git API client. */
import { apiClient } from "./client";
export interface GitStatus {
branch: string;
modified: string[];
added: string[];
deleted: string[];
untracked: string[];
ahead: number;
behind: number;
}
export interface Commit {
hash: string;
message: string;
author: string;
date: string;
}
export async function getGitStatus(workspaceId: string): Promise<GitStatus> {
const response = await apiClient.get<GitStatus>(
`/workspaces/${workspaceId}/git/status`,
);
return response.data;
}
export async function getGitBranches(
workspaceId: string,
): Promise<{ branches: string[]; current_branch: string }> {
const response = await apiClient.get<{
branches: string[];
current_branch: string;
}>(`/workspaces/${workspaceId}/git/branches`);
return response.data;
}
export async function gitCommit(
workspaceId: string,
message: string,
): Promise<void> {
await apiClient.post(`/workspaces/${workspaceId}/git/commit`, { message });
}
export async function gitPush(workspaceId: string): Promise<void> {
await apiClient.post(`/workspaces/${workspaceId}/git/push`);
}
export async function gitPull(workspaceId: string): Promise<void> {
await apiClient.post(`/workspaces/${workspaceId}/git/pull`);
}
export async function gitFetch(workspaceId: string): Promise<void> {
await apiClient.post(`/workspaces/${workspaceId}/git/fetch`);
}
export async function gitCheckout(
workspaceId: string,
branch: string,
): Promise<void> {
await apiClient.post(`/workspaces/${workspaceId}/git/checkout`, { branch });
}
export async function getGitHistory(
workspaceId: string,
path?: string,
limit: number = 50,
): Promise<Commit[]> {
const response = await apiClient.get<{ commits: Commit[] }>(
`/workspaces/${workspaceId}/git/history`,
{ params: { path, limit } },
);
return response.data.commits;
}
+30
View File
@@ -0,0 +1,30 @@
/** Workspace instance API client. */
import { apiClient } from "./client";
import type { ToolInstance } from "./sessions";
export async function listWorkspaceInstances(
workspaceId: string,
): Promise<ToolInstance[]> {
const response = await apiClient.get<ToolInstance[]>(
`/workspaces/${workspaceId}/instances/`,
);
return response.data;
}
export async function createWorkspaceInstance(
workspaceId: string,
toolTypeId: string,
displayName?: string,
configProfileId?: string,
): Promise<ToolInstance> {
const response = await apiClient.post<ToolInstance>(
`/workspaces/${workspaceId}/instances/`,
{
tool_type_id: toolTypeId,
display_name: displayName,
config_profile_id: configProfileId,
},
);
return response.data;
}
+9 -6
View File
@@ -1,5 +1,6 @@
/** Card component for displaying a workspace. */
import { Link } from "react-router-dom";
import { Icon } from "./icon";
import type { Workspace } from "../types/workspace";
@@ -27,12 +28,14 @@ export function WorkspaceCard({
return (
<article className={`card workspace-card ${loading ? "loading" : ""}`}>
<div className="workspace-header">
<h4>{workspace.name}</h4>
<span className={`status-badge ${statusClass}`}>
{workspace.status}
</span>
</div>
<Link to={`/workspaces/${workspace.id}`} className="workspace-header-link">
<div className="workspace-header">
<h4>{workspace.name}</h4>
<span className={`status-badge ${statusClass}`}>
{workspace.status}
</span>
</div>
</Link>
<div className="workspace-meta">
<p className="workspace-project">
{workspace.project_name} / {workspace.repo_name}
+68
View File
@@ -0,0 +1,68 @@
/** Hook for workspace file operations. */
import { useCallback, useEffect, useState } from "react";
import {
listWorkspaceFiles,
getWorkspaceFileContent,
saveWorkspaceFile,
type FileEntry,
} from "../api/workspace-files";
export interface UseWorkspaceFilesResult {
entries: FileEntry[];
content: string | null;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
loadFile: (path: string) => Promise<void>;
saveFile: (path: string, content: string, message?: string) => Promise<void>;
}
export function useWorkspaceFiles(
workspaceId: string,
): UseWorkspaceFilesResult {
const [entries, setEntries] = useState<FileEntry[]>([]);
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await listWorkspaceFiles(workspaceId);
setEntries(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load files");
} finally {
setLoading(false);
}
}, [workspaceId]);
const loadFile = useCallback(
async (path: string) => {
try {
const data = await getWorkspaceFileContent(workspaceId, path);
setContent(data);
} catch (err) {
setContent(null);
setError(err instanceof Error ? err.message : "Failed to load file");
}
},
[workspaceId],
);
const saveFile = useCallback(
async (path: string, fileContent: string, message?: string) => {
await saveWorkspaceFile(workspaceId, path, fileContent, message);
await refresh();
},
[workspaceId, refresh],
);
useEffect(() => {
refresh();
}, [refresh]);
return { entries, content, loading, error, refresh, loadFile, saveFile };
}
+109
View File
@@ -0,0 +1,109 @@
/** Hook for workspace git operations. */
import { useCallback, useEffect, useState } from "react";
import {
getGitStatus,
getGitBranches,
gitCommit,
gitPush,
gitPull,
gitFetch,
gitCheckout,
getGitHistory,
type GitStatus,
type Commit,
} from "../api/workspace-git";
export interface UseWorkspaceGitResult {
status: GitStatus | null;
branches: string[];
currentBranch: string;
history: Commit[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
commit: (message: string) => Promise<void>;
push: () => Promise<void>;
pull: () => Promise<void>;
fetch: () => Promise<void>;
checkout: (branch: string) => Promise<void>;
}
export function useWorkspaceGit(workspaceId: string): UseWorkspaceGitResult {
const [status, setStatus] = useState<GitStatus | null>(null);
const [branches, setBranches] = useState<string[]>([]);
const [currentBranch, setCurrentBranch] = useState("");
const [history, setHistory] = useState<Commit[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [statusData, branchesData, historyData] = await Promise.all([
getGitStatus(workspaceId),
getGitBranches(workspaceId),
getGitHistory(workspaceId),
]);
setStatus(statusData);
setBranches(branchesData.branches);
setCurrentBranch(branchesData.current_branch);
setHistory(historyData);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load git data");
} finally {
setLoading(false);
}
}, [workspaceId]);
const commit = useCallback(
async (message: string) => {
await gitCommit(workspaceId, message);
await refresh();
},
[workspaceId, refresh],
);
const push = useCallback(async () => {
await gitPush(workspaceId);
await refresh();
}, [workspaceId, refresh]);
const pull = useCallback(async () => {
await gitPull(workspaceId);
await refresh();
}, [workspaceId, refresh]);
const fetch = useCallback(async () => {
await gitFetch(workspaceId);
await refresh();
}, [workspaceId, refresh]);
const checkout = useCallback(
async (branch: string) => {
await gitCheckout(workspaceId, branch);
await refresh();
},
[workspaceId, refresh],
);
useEffect(() => {
refresh();
}, [refresh]);
return {
status,
branches,
currentBranch,
history,
loading,
error,
refresh,
commit,
push,
pull,
fetch,
checkout,
};
}
@@ -0,0 +1,65 @@
/** Hook for workspace instance operations. */
import { useCallback, useEffect, useState } from "react";
import {
listWorkspaceInstances,
createWorkspaceInstance,
} from "../api/workspace-instances";
import type { ToolInstance } from "../api/sessions";
export interface UseWorkspaceInstancesResult {
instances: ToolInstance[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
create: (
toolTypeId: string,
displayName?: string,
configProfileId?: string,
) => Promise<ToolInstance>;
}
export function useWorkspaceInstances(
workspaceId: string,
): UseWorkspaceInstancesResult {
const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await listWorkspaceInstances(workspaceId);
setInstances(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load instances");
} finally {
setLoading(false);
}
}, [workspaceId]);
const create = useCallback(
async (
toolTypeId: string,
displayName?: string,
configProfileId?: string,
) => {
const instance = await createWorkspaceInstance(
workspaceId,
toolTypeId,
displayName,
configProfileId,
);
await refresh();
return instance;
},
[workspaceId, refresh],
);
useEffect(() => {
refresh();
}, [refresh]);
return { instances, loading, error, refresh, create };
}
+466
View File
@@ -0,0 +1,466 @@
/** Workspace detail page — primary work surface. */
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Icon } from "../components/icon";
import { useWorkspaces } from "../hooks/use-workspaces";
import { useWorkspaceFiles } from "../hooks/use-workspace-files";
import { useWorkspaceGit } from "../hooks/use-workspace-git";
import { useWorkspaceInstances } from "../hooks/use-workspace-instances";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import type { FileEntry } from "../api/workspace-files";
type Tab = "files" | "git" | "tools" | "settings";
export function WorkspaceDetailPage() {
const { workspaceId } = useParams<{ workspaceId: string }>();
const [activeTab, setActiveTab] = useState<Tab>("files");
const isMobile = useMobileViewport();
const { workspaces, loading: wsLoading } = useWorkspaces();
const workspace = workspaces.find((w) => w.id === workspaceId);
if (wsLoading) {
return <div className="loading-state">Loading workspace...</div>;
}
if (!workspace) {
return (
<div className="empty-state">
<h2>Workspace not found</h2>
<p>The workspace you are looking for does not exist.</p>
</div>
);
}
return (
<div className={`workspace-detail ${isMobile ? "mobile" : ""}`}>
<WorkspaceHeader workspace={workspace} />
<TabBar active={activeTab} onChange={setActiveTab} />
<div className="workspace-content">
{activeTab === "files" && <FilesTab workspaceId={workspace.id} />}
{activeTab === "git" && <GitTab workspaceId={workspace.id} />}
{activeTab === "tools" && <ToolsTab workspaceId={workspace.id} />}
{activeTab === "settings" && <SettingsTab workspace={workspace} />}
</div>
{isMobile && <MobileTabBar active={activeTab} onChange={setActiveTab} />}
</div>
);
}
function WorkspaceHeader({
workspace,
}: {
workspace: {
name: string;
repo_name: string;
project_name: string;
branch: string;
};
}) {
return (
<header className="workspace-header">
<div className="workspace-breadcrumb">
<span>{workspace.project_name}</span>
<span className="sep">/</span>
<span>{workspace.repo_name}</span>
<span className="sep">/</span>
<strong>{workspace.name}</strong>
</div>
<div className="workspace-actions">
<span className="branch-badge">
<Icon name="branch" size="sm" /> {workspace.branch}
</span>
</div>
</header>
);
}
function TabBar({
active,
onChange,
}: {
active: Tab;
onChange: (t: Tab) => void;
}) {
const tabs: { id: Tab; label: string; icon: string }[] = [
{ id: "files", label: "Files", icon: "folder" },
{ id: "git", label: "Git", icon: "branch" },
{ id: "tools", label: "Tools", icon: "terminal" },
{ id: "settings", label: "Settings", icon: "settings" },
];
return (
<nav className="tab-bar" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
className={`tab ${active === tab.id ? "active" : ""}`}
onClick={() => onChange(tab.id)}
role="tab"
aria-selected={active === tab.id}
>
<Icon name={tab.icon as "folder" | "branch" | "terminal" | "settings"} size="sm" />
{tab.label}
</button>
))}
</nav>
);
}
function MobileTabBar({
active,
onChange,
}: {
active: Tab;
onChange: (t: Tab) => void;
}) {
const tabs: { id: Tab; label: string; icon: string }[] = [
{ id: "files", label: "Files", icon: "folder" },
{ id: "git", label: "Git", icon: "branch" },
{ id: "tools", label: "Tools", icon: "terminal" },
{ id: "settings", label: "Settings", icon: "settings" },
];
return (
<nav className="mobile-tab-bar" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
className={`mobile-tab ${active === tab.id ? "active" : ""}`}
onClick={() => onChange(tab.id)}
role="tab"
aria-selected={active === tab.id}
>
<Icon name={tab.icon as "folder" | "branch" | "terminal" | "settings"} />
<span>{tab.label}</span>
</button>
))}
</nav>
);
}
/* ─── Files Tab ─── */
function FilesTab({ workspaceId }: { workspaceId: string }) {
const { entries, content, loadFile, saveFile, loading, error } =
useWorkspaceFiles(workspaceId);
const { status, commit, push, pull, fetch } = useWorkspaceGit(workspaceId);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [editContent, setEditContent] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [commitMessage, setCommitMessage] = useState("");
const handleSelect = (entry: FileEntry) => {
if (entry.type === "directory") return;
setSelectedPath(entry.path);
setIsEditing(false);
setEditContent(null);
loadFile(entry.path);
};
const handleEdit = () => {
if (content !== null) {
setEditContent(content);
setIsEditing(true);
}
};
const handleSave = async () => {
if (selectedPath && editContent !== null) {
await saveFile(selectedPath, editContent, commitMessage || undefined);
setIsEditing(false);
setCommitMessage("");
}
};
return (
<div className="files-tab">
{status && (
<div className="git-toolbar">
<div className="git-toolbar-status">
{status.modified.length > 0 && (
<span className="status-modified">
M {status.modified.length}
</span>
)}
{status.added.length > 0 && (
<span className="status-added">A {status.added.length}</span>
)}
{status.deleted.length > 0 && (
<span className="status-deleted">D {status.deleted.length}</span>
)}
{status.untracked.length > 0 && (
<span className="status-untracked">
? {status.untracked.length}
</span>
)}
</div>
<div className="git-toolbar-actions">
<input
type="text"
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
placeholder="Commit message"
/>
<button
onClick={() => commit(commitMessage)}
disabled={!commitMessage}
>
Commit
</button>
<button onClick={push}>Push</button>
<button onClick={pull}>Pull</button>
<button onClick={fetch}>Fetch</button>
</div>
</div>
)}
<div className="files-split">
<div className="file-tree">
{loading && <p className="muted">Loading...</p>}
{error && <p className="error-text">{error}</p>}
{entries.map((entry) => (
<button
key={entry.path}
className={`tree-entry ${entry.type} ${selectedPath === entry.path ? "selected" : ""}`}
onClick={() => handleSelect(entry)}
type="button"
>
<Icon
name={entry.type === "directory" ? "folder" : "file"}
size="sm"
/>
{entry.name}
</button>
))}
</div>
<div className="file-viewer">
{selectedPath ? (
<>
<div className="file-viewer-header">
<span>{selectedPath}</span>
{!isEditing && <button onClick={handleEdit}>Edit</button>}
</div>
{isEditing ? (
<>
<textarea
className="file-editor"
value={editContent || ""}
onChange={(e) => setEditContent(e.target.value)}
/>
<div className="file-editor-actions">
<button onClick={() => setIsEditing(false)}>Cancel</button>
<button onClick={handleSave}>Save</button>
</div>
</>
) : (
<pre className="file-content">{content || "Loading..."}</pre>
)}
</>
) : (
<p className="muted">Select a file to view</p>
)}
</div>
</div>
</div>
);
}
/* ─── Git Tab ─── */
function GitTab({ workspaceId }: { workspaceId: string }) {
const { history, branches, currentBranch, checkout, loading, error } =
useWorkspaceGit(workspaceId);
return (
<div className="git-tab">
<div className="git-tab-header">
<select
value={currentBranch}
onChange={(e) => checkout(e.target.value)}
>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</div>
{loading && <p className="muted">Loading history...</p>}
{error && <p className="error-text">{error}</p>}
<div className="commit-history">
{history.map((commit) => (
<div key={commit.hash} className="commit-row">
<span className="commit-hash">{commit.hash.slice(0, 7)}</span>
<span className="commit-message">{commit.message}</span>
<span className="commit-author">{commit.author}</span>
<span className="commit-date">{commit.date}</span>
</div>
))}
</div>
</div>
);
}
/* ─── Tools Tab ─── */
function ToolsTab({ workspaceId }: { workspaceId: string }) {
const { instances, loading, create } = useWorkspaceInstances(workspaceId);
const [showModal, setShowModal] = useState(false);
return (
<div className="tools-tab">
{loading && <p className="muted">Loading instances...</p>}
{instances.length === 0 ? (
<div className="empty-state-card">
<Icon name="terminal" size="lg" />
<h3>No tools running</h3>
<p>Start a tool to begin coding in this workspace</p>
<button
className="btn btn-primary"
onClick={() => setShowModal(true)}
>
Start Tool
</button>
</div>
) : (
<>
<div className="instances-grid">
{instances.map((instance) => (
<div
key={instance.id}
className={`instance-card ${instance.status}`}
>
<h4>{instance.display_name}</h4>
<span className="status-badge">{instance.status}</span>
{instance.url && (
<a
href={instance.url}
target="_blank"
rel="noopener noreferrer"
>
Open
</a>
)}
</div>
))}
</div>
<button
className="btn btn-primary"
onClick={() => setShowModal(true)}
>
Start Another Tool
</button>
</>
)}
{showModal && (
<StartToolModal
onClose={() => setShowModal(false)}
onStart={async (toolTypeId: string) => {
await create(toolTypeId);
setShowModal(false);
}}
/>
)}
</div>
);
}
/* ─── Settings Tab ─── */
function SettingsTab({
workspace,
}: {
workspace: {
id: string;
name: string;
branch: string;
path: string;
status: string;
created_at: string;
};
}) {
return (
<div className="settings-tab">
<div className="settings-section">
<h3>Workspace Info</h3>
<div className="form-group">
<label>Name</label>
<input type="text" value={workspace.name} readOnly />
</div>
<div className="form-group">
<label>Branch</label>
<input type="text" value={workspace.branch} readOnly />
</div>
<div className="form-group">
<label>Path</label>
<input type="text" value={workspace.path} readOnly />
</div>
<div className="form-group">
<label>Status</label>
<span className={`status-badge ${workspace.status}`}>
{workspace.status}
</span>
</div>
<div className="form-group">
<label>Created</label>
<span>{workspace.created_at}</span>
</div>
</div>
</div>
);
}
/* ─── Start Tool Modal ─── */
function StartToolModal({
onClose,
onStart,
}: {
onClose: () => void;
onStart: (toolTypeId: string) => Promise<void>;
}) {
const [toolTypeId, setToolTypeId] = useState("");
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!toolTypeId) return;
setSubmitting(true);
try {
await onStart(toolTypeId);
} finally {
setSubmitting(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Start Tool</h3>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Tool Type</label>
<select
value={toolTypeId}
onChange={(e) => setToolTypeId(e.target.value)}
>
<option value="">Select...</option>
<option value="code-server">Code Server</option>
<option value="jupyter-notebook">Jupyter Notebook</option>
<option value="terminal">Terminal</option>
</select>
</div>
<div className="form-actions">
<button type="button" onClick={onClose} disabled={submitting}>
Cancel
</button>
<button type="submit" disabled={!toolTypeId || submitting}>
{submitting ? "Starting..." : "Start"}
</button>
</div>
</form>
</div>
</div>
);
}
+2
View File
@@ -17,6 +17,7 @@ import { SSHKeysPage } from "./pages/ssh-keys";
import { ConfigProfilesPage } from "./pages/config-profiles";
import { SessionsPage } from "./pages/sessions";
import { WorkspacesPage } from "./pages/workspaces";
import { WorkspaceDetailPage } from "./pages/workspace-detail";
export const AppRouter = () => {
return (
@@ -59,6 +60,7 @@ export const AppRouter = () => {
</Route>
<Route path="sessions" element={<SessionsPage />} />
<Route path="workspaces" element={<WorkspacesPage />} />
<Route path="workspaces/:workspaceId" element={<WorkspaceDetailPage />} />
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
<Route
path="instances/:instanceId/terminal"
+455
View File
@@ -4867,3 +4867,458 @@ a:active,
transform: translateX(100%);
}
}
/* ─── Workspace Detail Page ─── */
.workspace-detail {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.workspace-header-link {
display: block;
text-decoration: none;
color: inherit;
}
.workspace-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
background: var(--panel);
flex-shrink: 0;
}
.workspace-breadcrumb {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--muted);
font-size: var(--font-size-sm);
}
.workspace-breadcrumb .sep {
color: var(--border);
}
.workspace-breadcrumb strong {
color: var(--ink);
font-size: var(--font-size-lg);
}
.branch-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 999px;
font-size: var(--font-size-sm);
color: var(--muted);
}
/* Tab Bar */
.tab-bar {
display: flex;
gap: var(--space-1);
padding: var(--space-2) var(--space-5);
border-bottom: 1px solid var(--border);
background: var(--panel);
flex-shrink: 0;
overflow-x: auto;
}
.tab {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-4);
border: 1px solid transparent;
border-radius: 10px;
background: none;
color: var(--muted);
font: inherit;
font-size: var(--font-size-sm);
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.tab:hover {
background: var(--bg);
color: var(--ink);
}
.tab.active {
background: var(--brand);
color: var(--primary-fg);
}
/* Mobile Tab Bar */
.mobile-tab-bar {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
justify-content: space-around;
padding: var(--space-2) 0;
background: var(--panel);
border-top: 1px solid var(--border);
z-index: 50;
}
.mobile-tab {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: var(--space-1) var(--space-2);
border: none;
background: none;
color: var(--muted);
font: inherit;
font-size: var(--font-size-xs);
cursor: pointer;
}
.mobile-tab.active {
color: var(--brand);
}
/* Workspace Content */
.workspace-content {
flex: 1;
overflow: hidden;
padding: var(--space-4) var(--space-5);
overflow-y: auto;
}
/* Files Tab */
.files-tab {
display: flex;
flex-direction: column;
gap: var(--space-3);
height: 100%;
}
.git-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
flex-wrap: wrap;
}
.git-toolbar-status {
display: flex;
gap: var(--space-2);
}
.git-toolbar-status span {
padding: var(--space-1) var(--space-2);
border-radius: 6px;
font-size: var(--font-size-xs);
font-weight: 600;
}
.status-modified {
background: var(--warning-light);
color: var(--warning);
}
.status-added {
background: var(--success-light);
color: var(--success);
}
.status-deleted {
background: var(--danger-light);
color: var(--danger);
}
.status-untracked {
background: rgba(107, 114, 128, 0.1);
color: #6b7280;
}
.git-toolbar-actions {
display: flex;
gap: var(--space-2);
align-items: center;
flex-wrap: wrap;
}
.git-toolbar-actions input {
padding: var(--space-1) var(--space-3);
border: 1px solid var(--border);
border-radius: 6px;
font: inherit;
background: var(--panel);
color: var(--ink);
min-width: 180px;
}
.files-split {
display: grid;
grid-template-columns: 260px 1fr;
gap: var(--space-4);
flex: 1;
min-height: 0;
overflow: hidden;
}
.file-tree {
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 10px;
padding: var(--space-3);
background: var(--panel);
}
.tree-entry {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-1) var(--space-2);
border: none;
border-radius: 6px;
background: none;
color: var(--ink);
font: inherit;
font-size: var(--font-size-sm);
text-align: left;
cursor: pointer;
white-space: nowrap;
}
.tree-entry:hover {
background: var(--bg);
}
.tree-entry.selected {
background: var(--brand);
color: var(--primary-fg);
}
.file-viewer {
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel);
overflow: hidden;
}
.file-viewer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3);
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.file-content {
flex: 1;
padding: var(--space-4);
overflow: auto;
margin: 0;
font-family: "IBM Plex Mono", monospace;
font-size: var(--font-size-sm);
line-height: 1.6;
white-space: pre-wrap;
}
.file-editor {
flex: 1;
padding: var(--space-3);
border: none;
font-family: "IBM Plex Mono", monospace;
font-size: var(--font-size-sm);
line-height: 1.6;
resize: none;
background: var(--panel);
color: var(--ink);
}
.file-editor-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-3);
border-top: 1px solid var(--border);
}
/* Git Tab */
.git-tab {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.git-tab-header {
display: flex;
gap: var(--space-3);
align-items: center;
}
.git-tab-header select {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: 6px;
font: inherit;
background: var(--panel);
color: var(--ink);
}
.commit-history {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.commit-row {
display: grid;
grid-template-columns: 60px 1fr 120px 120px;
gap: var(--space-3);
padding: var(--space-3);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
align-items: center;
font-size: var(--font-size-sm);
}
.commit-hash {
font-family: monospace;
color: var(--brand);
}
.commit-message {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.commit-author,
.commit-date {
color: var(--muted);
font-size: var(--font-size-xs);
}
/* Tools Tab */
.tools-tab {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.empty-state-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-10);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
text-align: center;
}
.empty-state-card h3 {
margin: 0;
}
.empty-state-card p {
margin: 0;
color: var(--muted);
}
.instances-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-4);
}
.instance-card {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
}
.instance-card.running {
border-color: var(--success);
}
/* Settings Tab */
.settings-tab {
max-width: 640px;
}
.settings-section {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-5);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
}
.settings-section h3 {
margin: 0;
}
/* Mobile Workspace Detail */
@media (max-width: 767px) {
.workspace-detail.mobile .workspace-content {
padding-bottom: 72px;
}
.mobile-tab-bar {
display: flex;
}
.files-split {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
.commit-row {
grid-template-columns: 1fr;
gap: var(--space-1);
}
.git-toolbar {
flex-direction: column;
align-items: flex-start;
}
}
/* Workspace Card Link */
.workspace-header-link {
display: block;
text-decoration: none;
color: inherit;
margin: -1rem -1rem 0;
padding: 1rem;
}
.workspace-header-link:hover .workspace-header h4 {
color: var(--brand);
}