refactor: centralize types and extract seed data (Task 1.1)

- Create types/ directory with canonical domain type definitions
  - session.ts, tool-instance.ts, tool-type.ts, git-repository.ts
  - config-folder.ts, tool-config.ts, project.ts, user.ts, api-response.ts
- Move inline types from api modules to types/ with backward-compatible re-exports
- Update all consumers (pages, components, state) to import from types/
- Extract seed_builtin_tool_types from main.py to seeds/builtin_tool_types.py
- Ensure Session, ToolInstance, ToolType, GitRepository defined exactly once

Quality gates: tsc (pass), eslint (pass), Python syntax (pass)
Refs: repo-restructure Task 1.1
This commit is contained in:
Developer
2026-06-02 18:58:50 +00:00
parent ee1fa6bee5
commit 985ca538e3
26 changed files with 3622 additions and 3210 deletions
+8 -8
View File
@@ -24,7 +24,7 @@ export const getConfigFolder = async (id: string): Promise<ConfigFolder> => {
};
export const createConfigFolder = async (
data: CreateConfigFolderRequest
data: CreateConfigFolderRequest,
): Promise<ConfigFolder> => {
const response = await apiClient.post<ConfigFolder>("/config-folders", data);
return response.data;
@@ -32,11 +32,11 @@ export const createConfigFolder = async (
export const updateConfigFolder = async (
id: string,
data: UpdateConfigFolderRequest
data: UpdateConfigFolderRequest,
): Promise<ConfigFolder> => {
const response = await apiClient.put<ConfigFolder>(
`/config-folders/${id}`,
data
data,
);
return response.data;
};
@@ -48,11 +48,11 @@ export const deleteConfigFolder = async (id: string): Promise<void> => {
export const addProjectOverride = async (
id: string,
projectId: string,
data: ProjectOverrideRequest
data: ProjectOverrideRequest,
): Promise<ConfigFolder> => {
const response = await apiClient.post<ConfigFolder>(
`/config-folders/${id}/overrides/${projectId}`,
data
data,
);
return response.data;
};
@@ -60,18 +60,18 @@ export const addProjectOverride = async (
export const updateProjectOverride = async (
id: string,
projectId: string,
data: ProjectOverrideRequest
data: ProjectOverrideRequest,
): Promise<ConfigFolder> => {
const response = await apiClient.put<ConfigFolder>(
`/config-folders/${id}/overrides/${projectId}`,
data
data,
);
return response.data;
};
export const deleteProjectOverride = async (
id: string,
projectId: string
projectId: string,
): Promise<void> => {
await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`);
};
+39 -27
View File
@@ -23,24 +23,34 @@ export type {
} from "../types/git-repository";
export async function parseGitUrl(url: string): Promise<URLParseResult> {
const response = await apiClient.post("/projects/repositories/parse-url", { url });
const response = await apiClient.post("/projects/repositories/parse-url", {
url,
});
return response.data;
}
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
export async function listRepositories(
projectId: string,
): Promise<GitRepository[]> {
const response = await apiClient.get(`/projects/${projectId}/repositories`);
return response.data;
}
export async function createRepository(
projectId: string,
data: GitRepositoryCreate
data: GitRepositoryCreate,
): Promise<GitRepository> {
const response = await apiClient.post(`/projects/${projectId}/repositories`, data);
const response = await apiClient.post(
`/projects/${projectId}/repositories`,
data,
);
return response.data;
}
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
export async function deleteRepository(
projectId: string,
repoId: string,
): Promise<void> {
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
}
@@ -48,34 +58,36 @@ export async function getRepositoryHistory(
projectId: string,
repoId: string,
branch?: string,
limit?: number
limit?: number,
): Promise<CommitHistoryResponse> {
const searchParams = new URLSearchParams();
if (branch) searchParams.set("branch", branch);
if (limit) searchParams.set("limit", String(limit));
const queryString = searchParams.toString();
const params = queryString ? `?${queryString}` : "";
const response = await apiClient.get(`/projects/${projectId}/repositories/${repoId}/history${params}`);
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/history${params}`,
);
return response.data;
}
export async function getCommitDetail(
projectId: string,
repoId: string,
commitHash: string
commitHash: string,
): Promise<CommitDetail> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/commits/${commitHash}`
`/projects/${projectId}/repositories/${repoId}/commits/${commitHash}`,
);
return response.data;
}
export async function getRepositoryStatus(
projectId: string,
repoId: string
repoId: string,
): Promise<GitStatus> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/status`
`/projects/${projectId}/repositories/${repoId}/status`,
);
return response.data;
}
@@ -84,11 +96,11 @@ export async function createBranch(
projectId: string,
repoId: string,
name: string,
baseBranch: string = "HEAD"
baseBranch: string = "HEAD",
): Promise<{ message: string; branch: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/branches`,
{ name, base_branch: baseBranch }
{ name, base_branch: baseBranch },
);
return response.data;
}
@@ -97,10 +109,10 @@ export async function deleteBranch(
projectId: string,
repoId: string,
branchName: string,
force: boolean = false
force: boolean = false,
): Promise<{ message: string }> {
const response = await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/branches/${branchName}?force=${force}`
`/projects/${projectId}/repositories/${repoId}/branches/${branchName}?force=${force}`,
);
return response.data;
}
@@ -108,11 +120,11 @@ export async function deleteBranch(
export async function checkoutBranch(
projectId: string,
repoId: string,
branch: string
branch: string,
): Promise<{ message: string; branch: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/checkout`,
{ branch }
{ branch },
);
return response.data;
}
@@ -121,21 +133,21 @@ export async function commitChanges(
projectId: string,
repoId: string,
message: string,
files?: string[]
files?: string[],
): Promise<CommitResponse> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/commit`,
{ message, files }
{ message, files },
);
return response.data;
}
export async function fetchRepository(
projectId: string,
repoId: string
repoId: string,
): Promise<{ message: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/fetch`
`/projects/${projectId}/repositories/${repoId}/fetch`,
);
return response.data;
}
@@ -143,11 +155,11 @@ export async function fetchRepository(
export async function pullRepository(
projectId: string,
repoId: string,
branch?: string
branch?: string,
): Promise<{ message: string }> {
const params = branch ? `?branch=${branch}` : "";
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/pull${params}`
`/projects/${projectId}/repositories/${repoId}/pull${params}`,
);
return response.data;
}
@@ -155,11 +167,11 @@ export async function pullRepository(
export async function pushRepository(
projectId: string,
repoId: string,
branch?: string
branch?: string,
): Promise<{ message: string }> {
const params = branch ? `?branch=${branch}` : "";
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/push${params}`
`/projects/${projectId}/repositories/${repoId}/push${params}`,
);
return response.data;
}
@@ -169,11 +181,11 @@ export async function mergeBranches(
repoId: string,
sourceBranch: string,
targetBranch?: string,
message?: string
message?: string,
): Promise<MergeResponse> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/merge`,
{ source_branch: sourceBranch, target_branch: targetBranch, message }
{ source_branch: sourceBranch, target_branch: targetBranch, message },
);
return response.data;
}
+16 -16
View File
@@ -7,10 +7,10 @@ export type { ToolInstance } from "../types/tool-instance";
export async function listInstances(
projectId: string,
repoId: string
repoId: string,
): Promise<ToolInstance[]> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances`
`/projects/${projectId}/repositories/${repoId}/instances`,
);
return response.data.instances;
}
@@ -19,14 +19,14 @@ export async function createInstance(
projectId: string,
repoId: string,
toolTypeId: string,
displayName?: string
displayName?: string,
): Promise<ToolInstance> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances`,
{
tool_type_id: toolTypeId,
display_name: displayName,
}
},
);
return response.data;
}
@@ -34,10 +34,10 @@ export async function createInstance(
export async function startInstance(
projectId: string,
repoId: string,
instanceId: string
instanceId: string,
): Promise<{ status: string; url?: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
);
return response.data;
}
@@ -45,10 +45,10 @@ export async function startInstance(
export async function stopInstance(
projectId: string,
repoId: string,
instanceId: string
instanceId: string,
): Promise<{ status: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`,
);
return response.data;
}
@@ -56,10 +56,10 @@ export async function stopInstance(
export async function restartInstance(
projectId: string,
repoId: string,
instanceId: string
instanceId: string,
): Promise<{ status: string; url?: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
);
return response.data;
}
@@ -67,10 +67,10 @@ export async function restartInstance(
export async function deleteInstance(
projectId: string,
repoId: string,
instanceId: string
instanceId: string,
): Promise<void> {
await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
);
}
@@ -82,10 +82,10 @@ export async function getUserSessions(): Promise<Session[]> {
export async function checkInstanceHealth(
projectId: string,
repoId: string,
instanceId: string
instanceId: string,
): Promise<{ healthy: boolean; status_code: number | null; error?: string }> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`,
);
return response.data;
}
@@ -93,10 +93,10 @@ export async function checkInstanceHealth(
export async function recreateInstanceTunnel(
projectId: string,
repoId: string,
instanceId: string
instanceId: string,
): Promise<{ status: string; url?: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`,
);
return response.data;
}
+11 -8
View File
@@ -5,32 +5,35 @@ export type { ToolConfig, CreateToolConfigRequest } from "../types/tool-config";
export const listToolConfigs = async (
tool_type_id?: string,
project_id?: string
project_id?: string,
): Promise<ToolConfig[]> => {
const params = new URLSearchParams();
if (tool_type_id) params.append("tool_type_id", tool_type_id);
if (project_id) params.append("project_id", project_id);
const response = await apiClient.get<{ configs: ToolConfig[] }>(
`/tool-configs?${params.toString()}`
`/tool-configs?${params.toString()}`,
);
return response.data.configs;
};
export const createToolConfig = async (
data: CreateToolConfigRequest
data: CreateToolConfigRequest,
): Promise<ToolConfig> => {
const response = await apiClient.post<{ configs: ToolConfig[] }>("/tool-configs", data);
const response = await apiClient.post<{ configs: ToolConfig[] }>(
"/tool-configs",
data,
);
return response.data.configs[0];
};
export const updateToolConfig = async (
id: string,
data: CreateToolConfigRequest
data: CreateToolConfigRequest,
): Promise<ToolConfig> => {
const response = await apiClient.put<{ configs: ToolConfig[] }>(
`/tool-configs/${id}`,
data
data,
);
return response.data.configs[0];
};
@@ -40,10 +43,10 @@ export const deleteToolConfig = async (id: string): Promise<void> => {
};
export const getToolConfigDefaults = async (
toolTypeId: string
toolTypeId: string,
): Promise<ToolConfig> => {
const response = await apiClient.get<ToolConfig>(
`/tool-configs/defaults/${toolTypeId}`
`/tool-configs/defaults/${toolTypeId}`,
);
return response.data;
};
+24 -6
View File
@@ -1,7 +1,16 @@
import { apiClient } from "./client";
import type { ToolType, CreateToolTypeRequest, UpdateToolTypeRequest } from "../types/tool-type";
import type {
ToolType,
CreateToolTypeRequest,
UpdateToolTypeRequest,
} from "../types/tool-type";
export type { ReadinessProbe, ToolType, CreateToolTypeRequest, UpdateToolTypeRequest } from "../types/tool-type";
export type {
ReadinessProbe,
ToolType,
CreateToolTypeRequest,
UpdateToolTypeRequest,
} from "../types/tool-type";
export const listToolTypes = async (): Promise<ToolType[]> => {
const response = await apiClient.get<ToolType[]>("/tool-types");
@@ -13,12 +22,17 @@ export const getToolType = async (id: string): Promise<ToolType> => {
return response.data;
};
export const createToolType = async (data: CreateToolTypeRequest): Promise<ToolType> => {
export const createToolType = async (
data: CreateToolTypeRequest,
): Promise<ToolType> => {
const response = await apiClient.post<ToolType>("/tool-types", data);
return response.data;
};
export const updateToolType = async (id: string, data: UpdateToolTypeRequest): Promise<ToolType> => {
export const updateToolType = async (
id: string,
data: UpdateToolTypeRequest,
): Promise<ToolType> => {
const response = await apiClient.put<ToolType>(`/tool-types/${id}`, data);
return response.data;
};
@@ -27,7 +41,11 @@ export const deleteToolType = async (id: string): Promise<void> => {
await apiClient.delete(`/tool-types/${id}`);
};
export const validateToolType = async (id: string): Promise<{ valid: boolean; errors?: string[] }> => {
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(`/tool-types/${id}/validate`);
export const validateToolType = async (
id: string,
): Promise<{ valid: boolean; errors?: string[] }> => {
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(
`/tool-types/${id}/validate`,
);
return response.data;
};
+11 -5
View File
@@ -13,14 +13,15 @@ const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
{ to: "/", label: "Home", icon: "dashboard" },
{ to: "/projects", label: "Projects", icon: "projects" },
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
{ to: "/settings", label: "Settings", icon: "settings" }
{ to: "/settings", label: "Settings", icon: "settings" },
];
const ACTIVE_STATUSES = ["running", "building", "pending"];
const SessionItem = ({ session }: { session: Session }) => {
const isRunning = session.status === "running";
const displayName = session.display_name || session.tool_type_name || "Unnamed Session";
const displayName =
session.display_name || session.tool_type_name || "Unnamed Session";
return (
<a
@@ -87,12 +88,16 @@ export const AppShell = () => {
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => {
const isHome = item.to === "/";
const activeCount = sessions.filter((s) => s.status === "running").length;
const activeCount = sessions.filter(
(s) => s.status === "running",
).length;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
className={({ isActive }) =>
isActive ? "nav-item nav-item-active" : "nav-item"
}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
@@ -104,7 +109,8 @@ export const AppShell = () => {
);
})}
{sessions.filter((s) => ACTIVE_STATUSES.includes(s.status)).length > 0 && (
{sessions.filter((s) => ACTIVE_STATUSES.includes(s.status)).length >
0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Live sessions</div>
+48 -19
View File
@@ -14,7 +14,8 @@ import {
stopInstance,
} from "../api/sessions";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
interface InstanceListProps {
projectId: string;
@@ -22,7 +23,11 @@ interface InstanceListProps {
toolTypes: ToolType[];
}
export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps) => {
export const InstanceList = ({
projectId,
repoId,
toolTypes,
}: InstanceListProps) => {
const navigate = useNavigate();
const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(false);
@@ -35,7 +40,9 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
// Health check state
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
const [healthStatus, setHealthStatus] = useState<
Record<string, { healthy: boolean; lastCheck: number }>
>({});
const loadInstances = useCallback(async () => {
setLoading(true);
@@ -55,21 +62,27 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
// Health check polling
useEffect(() => {
const runningInstances = instances.filter(i => i.status === "running" && i.url?.startsWith("http"));
const runningInstances = instances.filter(
(i) => i.status === "running" && i.url?.startsWith("http"),
);
if (runningInstances.length === 0) return;
const checkHealth = async () => {
for (const instance of runningInstances) {
try {
const health = await checkInstanceHealth(projectId, repoId, instance.id);
setHealthStatus(prev => ({
const health = await checkInstanceHealth(
projectId,
repoId,
instance.id,
);
setHealthStatus((prev) => ({
...prev,
[instance.id]: { healthy: health.healthy, lastCheck: Date.now() }
[instance.id]: { healthy: health.healthy, lastCheck: Date.now() },
}));
} catch {
setHealthStatus(prev => ({
setHealthStatus((prev) => ({
...prev,
[instance.id]: { healthy: false, lastCheck: Date.now() }
[instance.id]: { healthy: false, lastCheck: Date.now() },
}));
}
}
@@ -87,7 +100,12 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
if (!selectedToolType) return;
setError(null);
try {
await createInstance(projectId, repoId, selectedToolType, displayName || undefined);
await createInstance(
projectId,
repoId,
selectedToolType,
displayName || undefined,
);
setShowCreate(false);
setSelectedToolType("");
setDisplayName("");
@@ -130,7 +148,7 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
try {
await deleteInstance(projectId, repoId, instanceId);
// Update state immediately instead of reloading
setInstances(prev => prev.filter(i => i.id !== instanceId));
setInstances((prev) => prev.filter((i) => i.id !== instanceId));
} catch {
setError("Failed to delete instance");
}
@@ -181,9 +199,7 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
</button>
</div>
{error && (
<div className="error-message">{error}</div>
)}
{error && <div className="error-message">{error}</div>}
{loading ? (
<p className="muted">Loading instances...</p>
@@ -194,7 +210,11 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
{instances.map((instance) => (
<div key={instance.id} className="instance-card">
<div className="instance-info">
<div className="instance-name">{instance.display_name || instance.tool_type_name || "Unnamed Instance"}</div>
<div className="instance-name">
{instance.display_name ||
instance.tool_type_name ||
"Unnamed Instance"}
</div>
<div className="instance-meta">
<span
className="status-dot"
@@ -210,10 +230,16 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
</div>
</div>
<div className="instance-actions">
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
{instance.status === "running" &&
instance.url &&
instance.tool_type_interfaces.includes("web") && (
<>
<a
href={instance.url.startsWith("http") ? instance.url : `${API_BASE_URL}${instance.url}`}
href={
instance.url.startsWith("http")
? instance.url
: `${API_BASE_URL}${instance.url}`
}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
@@ -234,10 +260,13 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
)}
</>
)}
{instance.status === "running" && instance.tool_type_interfaces.includes("terminal") && (
{instance.status === "running" &&
instance.tool_type_interfaces.includes("terminal") && (
<button
className="secondary-button small"
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
onClick={() =>
navigate(`/instances/${instance.id}/terminal`)
}
type="button"
>
<Icon name="terminal" size="sm" />
@@ -36,7 +36,8 @@ export const RepositoriesSettingsTab: React.FC = () => {
const handleDelete = async (repoId: string) => {
if (!projectId) return;
if (!window.confirm("Are you sure you want to delete this repository?")) return;
if (!window.confirm("Are you sure you want to delete this repository?"))
return;
try {
await deleteRepository(projectId, repoId);
setRepositories((current) => current.filter((r) => r.id !== repoId));
@@ -1,11 +1,19 @@
import { useEffect, useRef, useState } from "react";
import type { GitRepositoryCreate, URLParseResult } from "../types/git-repository";
import type {
GitRepositoryCreate,
URLParseResult,
} from "../types/git-repository";
import { createRepository, parseGitUrl } from "../api/git_repositories";
import { Icon } from "./icon";
type CreateMode = "clone" | "blank";
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
type UrlValidationStatus =
| "idle"
| "validating"
| "valid"
| "needs-parsing"
| "invalid";
interface RepositoryCreateDialogProps {
projectId: string;
@@ -15,7 +23,13 @@ interface RepositoryCreateDialogProps {
onCreated: () => Promise<void> | void;
}
export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => {
export const RepositoryCreateDialog = ({
projectId,
open,
title,
onClose,
onCreated,
}: RepositoryCreateDialogProps) => {
const [createMode, setCreateMode] = useState<CreateMode>("clone");
const [formName, setFormName] = useState("");
const [owner, setOwner] = useState("");
@@ -129,7 +143,9 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
} catch (error: unknown) {
const response = error as { response?: { data?: { detail?: string } } };
const detail = response.response?.data?.detail;
setFormError(typeof detail === "string" ? detail : "Failed to create repository");
setFormError(
typeof detail === "string" ? detail : "Failed to create repository",
);
}
};
@@ -161,7 +177,8 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
<div className="dialog">
<h3>{title}</h3>
<p className="muted">
Clone an existing repository from git.commumedia.org, or create a blank bare repo here.
Clone an existing repository from git.commumedia.org, or create a
blank bare repo here.
</p>
<form onSubmit={handleSubmit} className="stack">
<div className="form-field">
@@ -213,7 +230,10 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
placeholder="repo-name"
/>
</label>
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
<p className="muted">
SSH target: git@git.commumedia.org:{owner || "owner"}/
{repoName || "repo"}.git
</p>
<button
type="button"
className="secondary-button small"
@@ -234,20 +254,26 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
className={getUrlInputClass()}
/>
{urlValidation.status === "validating" && (
<span className="validation-status validating">Validating...</span>
<span className="validation-status validating">
Validating...
</span>
)}
{urlValidation.status === "valid" && (
<span className="validation-status valid">
<Icon name="success" size="sm" /> Valid git URL
</span>
)}
{urlValidation.status === "needs-parsing" && urlValidation.result && (
{urlValidation.status === "needs-parsing" &&
urlValidation.result && (
<div className="url-suggestion">
<span className="validation-status warning">
<Icon name="warning" size="sm" /> This looks like a browser URL
<Icon name="warning" size="sm" /> This looks like a
browser URL
</span>
<div className="suggestion-actions">
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
<span className="suggested-url">
Suggested: {urlValidation.result.base_url}
</span>
<button
type="button"
className="secondary-button small"
@@ -278,13 +304,19 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
</div>
)}
<div className="dialog-actions">
<button className="secondary-button" onClick={handleClose} type="button">
<button
className="secondary-button"
onClick={handleClose}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
<Icon name="add" size="sm" />
{createMode === "clone" ? "Clone Repository" : "Create Blank Repository"}
{createMode === "clone"
? "Clone Repository"
: "Create Blank Repository"}
</button>
</div>
</form>
+179 -37
View File
@@ -2,7 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel } from "../api/sessions";
import {
createInstance,
getUserSessions,
startInstance,
stopInstance,
deleteInstance,
recreateInstanceTunnel,
} from "../api/sessions";
import { listProjects } from "../api/projects";
import { listRepositories } from "../api/git_repositories";
import { listToolTypes } from "../api/tool_types";
@@ -35,14 +42,17 @@ export const HomePage = () => {
const [selectedRepo, setSelectedRepo] = useState("");
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">(
"idle",
);
const [actionBusy, setActionBusy] = useState<string | null>(null);
const safeSessions = Array.isArray(sessions) ? sessions : [];
const loadHome = useCallback(async () => {
setStatus("loading");
try {
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([
const [dashboard, sessionData, projectData, toolTypeData] =
await Promise.all([
getDashboardSummary(),
getUserSessions(),
listProjects(),
@@ -81,13 +91,19 @@ export const HomePage = () => {
}, [selectedProject]);
const activeSessions = useMemo(
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)),
[safeSessions]
() =>
safeSessions.filter((session) =>
["running", "building", "pending"].includes(session.status),
),
[safeSessions],
);
const recentSessions = useMemo(
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5),
[safeSessions]
() =>
safeSessions
.filter((session) => ["stopped", "error"].includes(session.status))
.slice(0, 5),
[safeSessions],
);
const handleCreate = async (event: React.FormEvent) => {
@@ -96,7 +112,12 @@ export const HomePage = () => {
setSaveState("saving");
try {
const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined);
const instance = await createInstance(
selectedProject,
selectedRepo,
selectedToolType,
displayName || undefined,
);
await startInstance(selectedProject, selectedRepo, instance.id);
await updateUserConfig({ last_session_id: instance.id });
setDisplayName("");
@@ -135,7 +156,11 @@ export const HomePage = () => {
const handleDelete = async (session: SessionView) => {
setActionBusy(session.id);
try {
await deleteInstance(session.project_id, session.repository_id, session.id);
await deleteInstance(
session.project_id,
session.repository_id,
session.id,
);
await loadHome();
} finally {
setActionBusy(null);
@@ -145,7 +170,11 @@ export const HomePage = () => {
const handleRecreateTunnel = async (session: SessionView) => {
setActionBusy(session.id);
try {
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
await recreateInstanceTunnel(
session.project_id,
session.repository_id,
session.id,
);
await loadHome();
} finally {
setActionBusy(null);
@@ -158,11 +187,26 @@ export const HomePage = () => {
<div className="stack-sm">
<p className="eyebrow">Workspace overview</p>
<h1>Home</h1>
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p>
<p className="muted">
Open sessions, available projects, and the fastest path back into
work.
</p>
</div>
<div className="home-hero-actions">
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button>
<button
className="primary-button"
type="button"
onClick={() => navigate("/projects")}
>
New Project
</button>
<button
className="secondary-button"
type="button"
onClick={() => navigate("/settings")}
>
Settings
</button>
</div>
</header>
@@ -171,7 +215,11 @@ export const HomePage = () => {
{status === "error" && (
<div className="card stack">
<p>Unable to load your workspace overview.</p>
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
<button
className="secondary-button"
type="button"
onClick={() => void loadHome()}
>
<Icon name="refresh" size="sm" />
Retry
</button>
@@ -211,25 +259,48 @@ export const HomePage = () => {
<div className="stack-sm">
<div className="row row-tight">
<h3>{session.display_name}</h3>
<span className={`status-badge ${session.status}`}>{session.status}</span>
<span className={`status-badge ${session.status}`}>
{session.status}
</span>
</div>
<p className="muted">{session.project_name} · {session.repository_name}</p>
<p className="muted">
{session.project_name} · {session.repository_name}
</p>
<p className="muted">{session.tool_type_name}</p>
</div>
<div className="session-actions">
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
<button
className="secondary-button small"
type="button"
onClick={() => handleOpen(session)}
>
<Icon name="external" size="sm" />
Open
</button>
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
<button
className="ghost-button small"
type="button"
onClick={() => void handleRecreateTunnel(session)}
disabled={actionBusy === session.id}
>
<Icon name="refresh" size="sm" />
Tunnel
</button>
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
<button
className="ghost-button small"
type="button"
onClick={() => void handleStop(session)}
disabled={actionBusy === session.id}
>
<Icon name="stop" size="sm" />
Stop
</button>
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
<button
className="ghost-button small danger-text"
type="button"
onClick={() => void handleDelete(session)}
disabled={actionBusy === session.id}
>
<Icon name="delete" size="sm" />
Delete
</button>
@@ -246,19 +317,34 @@ export const HomePage = () => {
<p className="eyebrow">Available projects</p>
<h2>{projects.length}</h2>
</div>
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
<button
className="secondary-button"
type="button"
onClick={() => navigate("/projects")}
>
View all
</button>
</div>
{projects.length === 0 ? (
<p className="muted">No projects yet.</p>
) : (
<div className="home-project-grid">
{projects.map((project) => (
<article className="card project-card home-project-card" key={project.id}>
<article
className="card project-card home-project-card"
key={project.id}
>
<div className="stack-sm">
<h3>{project.name}</h3>
{project.description && <p className="muted">{project.description}</p>}
{project.description && (
<p className="muted">{project.description}</p>
)}
</div>
<button className="ghost-button small" type="button" onClick={() => navigate(`/projects/${project.id}`)}>
<button
className="ghost-button small"
type="button"
onClick={() => navigate(`/projects/${project.id}`)}
>
Open Workspace
</button>
</article>
@@ -278,35 +364,81 @@ export const HomePage = () => {
<div className="form-row">
<label className="form-field">
Project
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}>
<select
value={selectedProject}
onChange={(event) => {
setSelectedProject(event.target.value);
setSelectedRepo("");
}}
>
<option value="">Select project...</option>
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</label>
<label className="form-field">
Repository
<select value={selectedRepo} onChange={(event) => setSelectedRepo(event.target.value)} disabled={!selectedProject}>
<select
value={selectedRepo}
onChange={(event) => setSelectedRepo(event.target.value)}
disabled={!selectedProject}
>
<option value="">Select repository...</option>
{repositories.map((repo) => <option key={repo.id} value={repo.id}>{repo.name}</option>)}
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.name}
</option>
))}
</select>
</label>
<label className="form-field">
Tool type
<select value={selectedToolType} onChange={(event) => setSelectedToolType(event.target.value)}>
<select
value={selectedToolType}
onChange={(event) =>
setSelectedToolType(event.target.value)
}
>
<option value="">Select tool...</option>
{toolTypes.map((tool) => <option key={tool.id} value={tool.id}>{tool.display_name}</option>)}
{toolTypes.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.display_name}
</option>
))}
</select>
</label>
</div>
<label className="form-field">
Display name
<input type="text" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="My Development Environment" />
<input
type="text"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder="My Development Environment"
/>
</label>
<div className="form-actions">
<button className="primary-button" type="submit" disabled={saveState === "saving"}>
{saveState === "saving" ? <><Icon name="loading" size="sm" /> Creating...</> : <><Icon name="add" size="sm" /> Create Session</>}
<button
className="primary-button"
type="submit"
disabled={saveState === "saving"}
>
{saveState === "saving" ? (
<>
<Icon name="loading" size="sm" /> Creating...
</>
) : (
<>
<Icon name="add" size="sm" /> Create Session
</>
)}
</button>
{saveState === "error" && <span className="error-text">Failed to create session</span>}
{saveState === "error" && (
<span className="error-text">Failed to create session</span>
)}
</div>
</form>
</section>
@@ -323,10 +455,20 @@ export const HomePage = () => {
{recentSessions.map((session) => (
<article className="recent-session-item" key={session.id}>
<div className="recent-session-info">
<span className="recent-session-name">{session.display_name}</span>
<span className="muted">{session.project_name} · {session.tool_type_name}</span>
<span className="recent-session-name">
{session.display_name}
</span>
<span className="muted">
{session.project_name} · {session.tool_type_name}
</span>
</div>
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
<button
className="ghost-button small"
type="button"
onClick={() => handleOpen(session)}
>
Open
</button>
</article>
))}
</div>
+21 -8
View File
@@ -2,10 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import type { GitRepository } from "../types/git-repository";
import {
deleteRepository,
listRepositories,
} from "../api/git_repositories";
import { deleteRepository, listRepositories } from "../api/git_repositories";
import { Icon } from "../components/icon";
import { RepositoryCreateDialog } from "../components/repository-create-dialog";
@@ -53,7 +50,11 @@ export const GitRepositoriesPage = () => {
<section className="stack">
<div className="page-header">
<h1>Repositories</h1>
<button className="primary-button" onClick={() => setShowCreate(true)} type="button">
<button
className="primary-button"
onClick={() => setShowCreate(true)}
type="button"
>
<Icon name="add" size="sm" />
New Repository
</button>
@@ -64,14 +65,22 @@ export const GitRepositoriesPage = () => {
{status === "error" && (
<div className="card stack">
<p>Failed to load repositories</p>
<button className="secondary-button" onClick={() => void loadRepositories()} type="button">
<button
className="secondary-button"
onClick={() => void loadRepositories()}
type="button"
>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
{isEmpty && <p className="muted">No repositories yet. Create your first repository above.</p>}
{isEmpty && (
<p className="muted">
No repositories yet. Create your first repository above.
</p>
)}
{status === "ready" && repositories.length > 0 && (
<div className="repository-list">
@@ -87,7 +96,11 @@ export const GitRepositoriesPage = () => {
<div className="repository-actions">
<button
className="secondary-button small"
onClick={() => navigate(`/projects/${projectId}/repositories/${repo.id}/history`)}
onClick={() =>
navigate(
`/projects/${projectId}/repositories/${repo.id}/history`,
)
}
type="button"
>
History
+23 -15
View File
@@ -48,7 +48,7 @@ export const RepoWorkspace = () => {
const [project, setProject] = useState<Project | null>(null);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
searchParams.get("repo")
searchParams.get("repo"),
);
const [branches, setBranches] = useState<string[]>([]);
const [currentBranch, setCurrentBranch] = useState<string>("main");
@@ -95,9 +95,11 @@ export const RepoWorkspace = () => {
if (!projectId || !selectedRepoId) return;
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${selectedRepoId}/branches`
`/projects/${projectId}/repositories/${selectedRepoId}/branches`,
);
const branchList = response.data.branches.map(
(b: { name: string }) => b.name,
);
const branchList = response.data.branches.map((b: { name: string }) => b.name);
setBranches(branchList);
const defaultBranch = response.data.default_branch;
if (defaultBranch) {
@@ -152,15 +154,10 @@ export const RepoWorkspace = () => {
return (
<section className="repo-workspace">
{project && (
<WorkspaceHeader
project={project}
currentRepo={selectedRepo || null}
/>
<WorkspaceHeader project={project} currentRepo={selectedRepo || null} />
)}
{status === "loading" && (
<p className="muted">Loading repositories...</p>
)}
{status === "loading" && <p className="muted">Loading repositories...</p>}
{status === "error" && (
<div className="card stack">
@@ -245,7 +242,9 @@ export const RepoWorkspace = () => {
untracked={gitStatus.untracked}
onCommit={() => {
void loadGitStatus();
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
window.dispatchEvent(
new CustomEvent("refresh-file-tree"),
);
}}
/>
)}
@@ -301,7 +300,7 @@ const FileBrowser = ({
branch,
path,
},
}
},
);
setEntries(response.data.entries || []);
} catch {
@@ -361,7 +360,11 @@ const FileBrowser = ({
return (
<div className="file-tree">
{path && (
<button className="tree-entry tree-up" onClick={navigateUp} type="button">
<button
className="tree-entry tree-up"
onClick={navigateUp}
type="button"
>
<Icon name="folder" size="sm" /> ..
</button>
)}
@@ -369,7 +372,8 @@ const FileBrowser = ({
<p className="muted">No files in this repository yet.</p>
)}
{entries.map((entry) => {
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
const fileStatus =
entry.type === "file" ? getFileStatus(entry.path) : null;
return (
<button
key={entry.path}
@@ -377,7 +381,11 @@ const FileBrowser = ({
onClick={() => handleEntryClick(entry)}
type="button"
>
<Icon name={entry.type === "directory" ? "folder" : "file"} size="sm" /> {entry.name}
<Icon
name={entry.type === "directory" ? "folder" : "file"}
size="sm"
/>{" "}
{entry.name}
{fileStatus && (
<span className={`file-status-indicator ${fileStatus}`}>
{fileStatus === "modified" && "M"}
+97 -32
View File
@@ -42,7 +42,12 @@ export const SessionsPage = () => {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
const [tunnelHealth, setTunnelHealth] = useState<Record<string, { healthy: boolean; status_code: number | null; error?: string }>>({});
const [tunnelHealth, setTunnelHealth] = useState<
Record<
string,
{ healthy: boolean; status_code: number | null; error?: string }
>
>({});
const [recreatingId, setRecreatingId] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
@@ -92,14 +97,14 @@ export const SessionsPage = () => {
useEffect(() => {
const checkHealth = async () => {
const runningSessions = sessions.filter(
(s) => s.status === "running" && s.url
(s) => s.status === "running" && s.url,
);
for (const session of runningSessions) {
try {
const health = await checkInstanceHealth(
session.project_id,
session.repository_id,
session.id
session.id,
);
setTunnelHealth((prev) => ({
...prev,
@@ -108,7 +113,11 @@ export const SessionsPage = () => {
} catch {
setTunnelHealth((prev) => ({
...prev,
[session.id]: { healthy: false, status_code: null, error: "check failed" },
[session.id]: {
healthy: false,
status_code: null,
error: "check failed",
},
}));
}
}
@@ -137,18 +146,24 @@ export const SessionsPage = () => {
}, [selectedProject]);
const activeSessions = useMemo(
() => sessions.filter((s) => ["running", "building", "pending"].includes(s.status)),
[sessions]
() =>
sessions.filter((s) =>
["running", "building", "pending"].includes(s.status),
),
[sessions],
);
const recentSessions = useMemo(
() => sessions.filter((s) => ["stopped", "error"].includes(s.status)).slice(0, 5),
[sessions]
() =>
sessions
.filter((s) => ["stopped", "error"].includes(s.status))
.slice(0, 5),
[sessions],
);
const lastSession = useMemo(
() => sessions.find((s) => s.id === lastSessionId) ?? null,
[sessions, lastSessionId]
[sessions, lastSessionId],
);
const handleCreate = async (e: React.FormEvent) => {
@@ -166,7 +181,7 @@ export const SessionsPage = () => {
selectedProject,
selectedRepo,
selectedToolType,
displayName || undefined
displayName || undefined,
);
// Auto-start the instance
@@ -185,7 +200,11 @@ export const SessionsPage = () => {
}
};
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
const handleStop = async (
sessionId: string,
projectId: string,
repoId: string,
) => {
try {
await stopInstance(projectId, repoId, sessionId);
setStopConfirmId(null);
@@ -195,7 +214,11 @@ export const SessionsPage = () => {
}
};
const handleDelete = async (sessionId: string, projectId: string, repoId: string) => {
const handleDelete = async (
sessionId: string,
projectId: string,
repoId: string,
) => {
try {
await deleteInstance(projectId, repoId, sessionId);
setDeleteConfirmId(null);
@@ -212,7 +235,7 @@ export const SessionsPage = () => {
await recreateInstanceTunnel(
session.project_id,
session.repository_id,
session.id
session.id,
);
// Refresh sessions to get new URL
await loadSessions();
@@ -225,7 +248,7 @@ export const SessionsPage = () => {
const handleOpen = (session: Session) => {
if (session.url) {
window.open(session.url, '_blank', 'noopener,noreferrer');
window.open(session.url, "_blank", "noopener,noreferrer");
} else if (session.tool_type_interfaces?.includes("terminal")) {
navigate(`/instances/${session.id}/terminal`);
} else {
@@ -253,7 +276,11 @@ export const SessionsPage = () => {
{status === "error" && (
<div className="card stack">
<p>Failed to load sessions</p>
<button className="secondary-button" onClick={() => void loadSessions()} type="button">
<button
className="secondary-button"
onClick={() => void loadSessions()}
type="button"
>
<Icon name="refresh" size="sm" />
Retry
</button>
@@ -268,18 +295,29 @@ export const SessionsPage = () => {
<h2>Last Session</h2>
<div className="card last-session-card">
<div className="last-session-info">
<h3>{lastSession.display_name || lastSession.tool_type_name || "Unnamed Session"}</h3>
<h3>
{lastSession.display_name ||
lastSession.tool_type_name ||
"Unnamed Session"}
</h3>
<p className="muted">
{lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name}
{lastSession.tool_type_name} · {lastSession.project_name} ·{" "}
{lastSession.repository_name}
</p>
{lastSession.url && (
<p className="session-url">
<a href={lastSession.url} target="_blank" rel="noopener noreferrer">
<a
href={lastSession.url}
target="_blank"
rel="noopener noreferrer"
>
{lastSession.url}
</a>
</p>
)}
<span className={`status-badge ${lastSession.status}`}>{lastSession.status}</span>
<span className={`status-badge ${lastSession.status}`}>
{lastSession.status}
</span>
</div>
<div className="last-session-actions">
{lastSession.url ? (
@@ -293,7 +331,11 @@ export const SessionsPage = () => {
Open
</a>
) : (
<button className="primary-button" onClick={handleResumeLast} type="button">
<button
className="primary-button"
onClick={handleResumeLast}
type="button"
>
<Icon name="play" size="sm" />
Resume
</button>
@@ -318,20 +360,33 @@ export const SessionsPage = () => {
{activeSessions.map((session) => (
<div className="card session-card" key={session.id}>
<div className="session-info">
<h4>{session.display_name || session.tool_type_name || "Unnamed Session"}</h4>
<h4>
{session.display_name ||
session.tool_type_name ||
"Unnamed Session"}
</h4>
<p className="muted">
{session.tool_type_name} · {session.project_name}
</p>
{session.url && (
<p className="session-url">
<a href={session.url} target="_blank" rel="noopener noreferrer">
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
>
{session.url}
</a>
</p>
)}
<span className={`status-badge ${session.status}`}>{session.status}</span>
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
<span className="status-badge error">tunnel error</span>
<span className={`status-badge ${session.status}`}>
{session.status}
</span>
{tunnelHealth[session.id] &&
!tunnelHealth[session.id].healthy && (
<span className="status-badge error">
tunnel error
</span>
)}
</div>
<div className="session-actions">
@@ -355,7 +410,8 @@ export const SessionsPage = () => {
Open
</button>
)}
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
{tunnelHealth[session.id] &&
!tunnelHealth[session.id].healthy && (
<button
className="secondary-button small"
onClick={() => void handleRecreateTunnel(session)}
@@ -363,7 +419,9 @@ export const SessionsPage = () => {
disabled={recreatingId === session.id}
>
<Icon name="refresh" size="sm" />
{recreatingId === session.id ? "Recreating..." : "Recreate Tunnel"}
{recreatingId === session.id
? "Recreating..."
: "Recreate Tunnel"}
</button>
)}
{stopConfirmId === session.id ? (
@@ -375,7 +433,7 @@ export const SessionsPage = () => {
void handleStop(
session.id,
session.project_id,
session.repository_id
session.repository_id,
)
}
type="button"
@@ -408,7 +466,7 @@ export const SessionsPage = () => {
void handleDelete(
session.id,
session.project_id,
session.repository_id
session.repository_id,
)
}
type="button"
@@ -447,7 +505,11 @@ export const SessionsPage = () => {
{recentSessions.map((session) => (
<div className="recent-session-item" key={session.id}>
<div className="recent-session-info">
<span className="recent-session-name">{session.display_name || session.tool_type_name || "Unnamed Session"}</span>
<span className="recent-session-name">
{session.display_name ||
session.tool_type_name ||
"Unnamed Session"}
</span>
<span className="muted">
{session.tool_type_name} · {session.project_name}
</span>
@@ -479,7 +541,7 @@ export const SessionsPage = () => {
void handleDelete(
session.id,
session.project_id,
session.repository_id
session.repository_id,
)
}
type="button"
@@ -513,7 +575,10 @@ export const SessionsPage = () => {
{/* Create Session */}
<div className="create-session-section">
<h2>Create New Session</h2>
<form onSubmit={handleCreate} className="card stack create-session-form">
<form
onSubmit={handleCreate}
className="card stack create-session-form"
>
<div className="form-row">
<label className="form-field">
Project
+50 -14
View File
@@ -28,7 +28,9 @@ export const ToolConfigsPage = () => {
config_type: "env",
file_path: "",
});
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [saveStatus, setSaveStatus] = useState<
"idle" | "saving" | "saved" | "error"
>("idle");
const loadData = useCallback(async () => {
try {
@@ -60,7 +62,8 @@ export const ToolConfigsPage = () => {
key: formData.key,
value: formData.value,
config_type: formData.config_type,
file_path: formData.config_type === "file" ? formData.file_path : undefined,
file_path:
formData.config_type === "file" ? formData.file_path : undefined,
};
if (editingConfig) {
@@ -102,7 +105,7 @@ export const ToolConfigsPage = () => {
};
const filteredConfigs = configs.filter(
(c) => c.tool_type_id === selectedToolType
(c) => c.tool_type_id === selectedToolType,
);
const selectedTool = toolTypes.find((t) => t.id === selectedToolType);
@@ -126,7 +129,11 @@ export const ToolConfigsPage = () => {
</div>
<div className="card stack">
<p>Failed to load configurations</p>
<button className="secondary-button" onClick={() => void loadData()} type="button">
<button
className="secondary-button"
onClick={() => void loadData()}
type="button"
>
<Icon name="refresh" size="sm" />
Retry
</button>
@@ -142,7 +149,11 @@ export const ToolConfigsPage = () => {
<p className="eyebrow">Settings</p>
<h1>Tool Configurations</h1>
</div>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
<button
className="secondary-button"
type="button"
onClick={() => navigate("/settings")}
>
Back to settings
</button>
<p className="muted">
@@ -171,21 +182,30 @@ export const ToolConfigsPage = () => {
</select>
{selectedTool && (
<p className="muted" style={{ marginTop: "0.5rem" }}>
Category: {selectedTool.category} · Interfaces: {selectedTool.interfaces?.join(", ")}
Category: {selectedTool.category} · Interfaces:{" "}
{selectedTool.interfaces?.join(", ")}
</p>
)}
</div>
{/* Config List */}
<div className="card stack">
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<div
className="row"
style={{ justifyContent: "space-between", alignItems: "center" }}
>
<h2>Configuration Variables</h2>
<button
className="primary-button small"
onClick={() => {
setShowForm(true);
setEditingConfig(null);
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
setFormData({
key: "",
value: "",
config_type: "env",
file_path: "",
});
}}
type="button"
>
@@ -210,14 +230,20 @@ export const ToolConfigsPage = () => {
}}
>
<div>
<div className="row" style={{ gap: "0.5rem", alignItems: "center" }}>
<div
className="row"
style={{ gap: "0.5rem", alignItems: "center" }}
>
<code style={{ fontWeight: 600 }}>{config.key}</code>
<span
className="badge"
style={{
fontSize: "0.7rem",
textTransform: "uppercase",
background: config.config_type === "env" ? "var(--color-info)" : "var(--color-warning)",
background:
config.config_type === "env"
? "var(--color-info)"
: "var(--color-warning)",
color: "white",
padding: "0.125rem 0.5rem",
borderRadius: "9999px",
@@ -226,7 +252,10 @@ export const ToolConfigsPage = () => {
{config.config_type}
</span>
</div>
<p className="muted" style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}>
<p
className="muted"
style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}
>
{config.config_type === "file" && config.file_path
? `File: ${config.file_path}`
: "Environment variable"}
@@ -265,7 +294,9 @@ export const ToolConfigsPage = () => {
id="config-key"
type="text"
value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, key: e.target.value })
}
placeholder="e.g., OPENAI_API_KEY"
className="form-input"
required
@@ -309,7 +340,9 @@ export const ToolConfigsPage = () => {
<textarea
id="config-value"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, value: e.target.value })
}
placeholder={
formData.config_type === "env"
? "Enter value..."
@@ -321,7 +354,10 @@ export const ToolConfigsPage = () => {
/>
</div>
<div className="row" style={{ gap: "0.5rem", justifyContent: "flex-end" }}>
<div
className="row"
style={{ gap: "0.5rem", justifyContent: "flex-end" }}
>
<button
type="button"
className="secondary-button"
+42 -11
View File
@@ -1,7 +1,11 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import type { ToolType, CreateToolTypeRequest, UpdateToolTypeRequest } from "../types/tool-type";
import type {
ToolType,
CreateToolTypeRequest,
UpdateToolTypeRequest,
} from "../types/tool-type";
import {
createToolType,
deleteToolType,
@@ -128,7 +132,8 @@ export const ToolTypesPage = () => {
await loadToolTypes();
} catch (err) {
const axiosError = err as { response?: { data?: { detail?: string } } };
const detail = axiosError?.response?.data?.detail || "Failed to save tool type";
const detail =
axiosError?.response?.data?.detail || "Failed to save tool type";
setFormError(detail);
}
};
@@ -171,7 +176,13 @@ export const ToolTypesPage = () => {
<h1>Tool Types</h1>
</div>
<div className="row">
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Back to settings</button>
<button
className="secondary-button"
type="button"
onClick={() => navigate("/settings")}
>
Back to settings
</button>
<button onClick={openCreate}>
<Icon name="add" size="sm" />
Create Tool Type
@@ -189,18 +200,25 @@ export const ToolTypesPage = () => {
<h3>{toolType.display_name}</h3>
{toolType.is_builtin && <span className="badge">Built-in</span>}
</div>
<p className="text-secondary">{toolType.description || "No description"}</p>
<p className="text-secondary">
{toolType.description || "No description"}
</p>
<div className="tool-type-meta">
<span>Port: {toolType.default_port || "N/A"}</span>
{toolType.interfaces?.length > 0 && (
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
)}
{toolType.category && <span>Category: {toolType.category}</span>}
{toolType.category && (
<span>Category: {toolType.category}</span>
)}
</div>
<div className="card-actions">
{!toolType.is_builtin && (
<>
<button onClick={() => openEdit(toolType)} className="button-secondary">
<button
onClick={() => openEdit(toolType)}
className="button-secondary"
>
<Icon name="edit" size="sm" />
Edit
</button>
@@ -220,7 +238,10 @@ export const ToolTypesPage = () => {
<div className="dialog">
<p>Delete tool type "{toolType.display_name}"?</p>
<div className="dialog-actions">
<button onClick={() => handleDelete(toolType.id)} className="button-danger">
<button
onClick={() => handleDelete(toolType.id)}
className="button-danger"
>
<Icon name="delete" size="sm" />
Delete
</button>
@@ -240,7 +261,9 @@ export const ToolTypesPage = () => {
{dialogMode !== "none" && (
<div className="dialog-overlay">
<div className="dialog">
<h2>{dialogMode === "create" ? "Create Tool Type" : "Edit Tool Type"}</h2>
<h2>
{dialogMode === "create" ? "Create Tool Type" : "Edit Tool Type"}
</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Name (unique identifier)</label>
@@ -294,7 +317,9 @@ export const ToolTypesPage = () => {
if (e.target.checked) {
setFormInterfaces([...formInterfaces, "web"]);
} else {
setFormInterfaces(formInterfaces.filter((i) => i !== "web"));
setFormInterfaces(
formInterfaces.filter((i) => i !== "web"),
);
}
}}
/>
@@ -308,7 +333,9 @@ export const ToolTypesPage = () => {
if (e.target.checked) {
setFormInterfaces([...formInterfaces, "terminal"]);
} else {
setFormInterfaces(formInterfaces.filter((i) => i !== "terminal"));
setFormInterfaces(
formInterfaces.filter((i) => i !== "terminal"),
);
}
}}
/>
@@ -364,7 +391,11 @@ export const ToolTypesPage = () => {
</>
)}
</button>
<button type="button" onClick={closeDialog} className="button-secondary">
<button
type="button"
onClick={closeDialog}
className="button-secondary"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
+10 -2
View File
@@ -1,4 +1,10 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useState,
type ReactNode,
} from "react";
import type { Session } from "../types/session";
export type { Session } from "../types/session";
@@ -8,7 +14,9 @@ interface SessionsContextType {
setAllSessions: (sessions: Session[]) => void;
}
const SessionsContext = createContext<SessionsContextType | undefined>(undefined);
const SessionsContext = createContext<SessionsContextType | undefined>(
undefined,
);
export const SessionsProvider = ({ children }: { children: ReactNode }) => {
const [sessions, setSessions] = useState<Session[]>([]);
+4 -1
View File
@@ -5,7 +5,10 @@ export interface ConfigFolder {
description: string | null;
mount_path: string;
files: Record<string, string>;
project_overrides: Record<string, { mount_path?: string; files?: Record<string, string> }> | null;
project_overrides: Record<
string,
{ mount_path?: string; files?: Record<string, string> }
> | null;
is_active: boolean;
created_at: string;
updated_at: string;
+6 -1
View File
@@ -1,5 +1,10 @@
export type { ApiResponse, PaginatedResponse } from "./api-response";
export type { ConfigFolder, CreateConfigFolderRequest, UpdateConfigFolderRequest, ProjectOverrideRequest } from "./config-folder";
export type {
ConfigFolder,
CreateConfigFolderRequest,
UpdateConfigFolderRequest,
ProjectOverrideRequest,
} from "./config-folder";
export type {
CommitDetail,
CommitHistoryEntry,