init: 프로젝트 초기 구조 설정

This commit is contained in:
toki 2026-05-21 09:40:37 +09:00
commit fc8265f475
39 changed files with 3402 additions and 0 deletions

1
.env.example Normal file
View file

@ -0,0 +1 @@
VITE_NOMADCODE_API_BASE_URL=http://localhost:8080

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
node_modules/
dist/
.vite/
*.tsbuildinfo
.env
.env.local
.env.*.local
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*

49
README.md Normal file
View file

@ -0,0 +1,49 @@
# nomadcode-web
`nomadcode-web` is the initial React web frontend scaffold for NomadCode Server. It gives Agent Chat, Project Manage, and Settings pages a small but expandable layout.
## Stack
- React
- TypeScript
- Vite
- TanStack Router
- TanStack Query
- Zustand
- Plain CSS
## Run
```bash
npm install
npm run dev
```
## Environment
```bash
VITE_NOMADCODE_API_BASE_URL=http://localhost:8080
```
The default matches the current `nomadcode-core` local server.
## Pages
- `/agent`
- `/projects`
- `/settings`
## Mock and Stub Areas
- Agent messages are local mock state.
- Project actions log or open placeholder links.
- Settings integrations show mock configured/status values.
- Plane, Mattermost, IOP, and Agent Integrator calls are not implemented.
- WebSocket and proto-socket runtime flows are not implemented.
## Next Steps
- Connect task creation/listing to `nomadcode-core` `/api/tasks`.
- Add server-backed settings persistence.
- Add real project metadata loading.
- Add authenticated API access when Core auth is finalized.

12
index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NomadCode Web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1174
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

32
package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "nomadcode-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"check": "tsc --noEmit",
"verify": "npm run check && npm run build"
},
"engines": {
"node": ">=20.9.0",
"npm": ">=10"
},
"dependencies": {
"@tanstack/react-query": "^5.100.11",
"@tanstack/react-router": "^1.170.6",
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"zustand": "^5.0.13"
},
"devDependencies": {
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"typescript": "^6.0.3",
"vite": "^8.0.13"
}
}

36
src/api/client.ts Normal file
View file

@ -0,0 +1,36 @@
const DEFAULT_API_BASE_URL = "http://localhost:8080";
export function getApiBaseUrl(): string {
const configured = import.meta.env.VITE_NOMADCODE_API_BASE_URL;
return (configured || DEFAULT_API_BASE_URL).replace(/\/+$/, "");
}
export function buildApiUrl(path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `${getApiBaseUrl()}${normalizedPath}`;
}
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(buildApiUrl(path), {
headers: {
"Content-Type": "application/json",
...init?.headers,
},
...init,
});
if (!response.ok) {
throw new Error(`NomadCode API request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
export async function getServerHealth(): Promise<{ ok: boolean; label: string }> {
try {
const response = await fetch(buildApiUrl("/healthz"));
return { ok: response.ok, label: response.ok ? "ok" : "unavailable" };
} catch {
return { ok: false, label: "offline" };
}
}

88
src/api/projects.ts Normal file
View file

@ -0,0 +1,88 @@
export type ProjectStatus = "ready" | "active" | "paused" | "archived";
export interface Project {
id: string;
name: string;
description: string;
workspacePath: string;
codeServerUrl: string;
repoUrl: string;
planeProject: string;
outlineDoc: string;
defaultAgent: string;
status: ProjectStatus;
lastOpenedAt: string;
}
export const mockProjects: Project[] = [
{
id: "nomadcode-web",
name: "nomadcode-web",
description: "React web frontend for NomadCode Server.",
workspacePath: "/config/workspace/nomadcode-web",
codeServerUrl: "http://localhost:8081/?folder=/config/workspace/nomadcode-web",
repoUrl: "https://github.com/nomadcode/nomadcode-web",
planeProject: "NC-WEB",
outlineDoc: "NomadCode Web UI",
defaultAgent: "codex-local",
status: "active",
lastOpenedAt: "2026-05-21T00:00:00Z",
},
{
id: "nomadcode-core",
name: "nomadcode-core",
description: "Go server for task APIs, workflow state, and async agent jobs.",
workspacePath: "/config/workspace/nomadcode-core",
codeServerUrl: "http://localhost:8081/?folder=/config/workspace/nomadcode-core",
repoUrl: "https://github.com/nomadcode/nomadcode-core",
planeProject: "NC-CORE",
outlineDoc: "NomadCode Core Server",
defaultAgent: "codex-local",
status: "ready",
lastOpenedAt: "2026-05-20T14:30:00Z",
},
{
id: "iop",
name: "iop",
description: "Inference Operations Platform skeleton for edge, node, and control plane flows.",
workspacePath: "/config/workspace/iop",
codeServerUrl: "http://localhost:8081/?folder=/config/workspace/iop",
repoUrl: "https://github.com/nomadcode/iop",
planeProject: "IOP",
outlineDoc: "IOP Control Plane",
defaultAgent: "codex-local",
status: "ready",
lastOpenedAt: "2026-05-19T09:10:00Z",
},
{
id: "proto-socket",
name: "proto-socket",
description: "Protocol buffer socket library with TypeScript, Go, Dart, Kotlin, and Python implementations.",
workspacePath: "/config/workspace/proto-socket",
codeServerUrl: "http://localhost:8081/?folder=/config/workspace/proto-socket",
repoUrl: "https://github.com/nomadcode/proto-socket",
planeProject: "PROTO",
outlineDoc: "Proto Socket Wire Protocol",
defaultAgent: "codex-local",
status: "paused",
lastOpenedAt: "2026-05-18T16:45:00Z",
},
];
export async function listProjectsPlaceholder(): Promise<Project[]> {
return mockProjects;
}
export function openWorkspacePlaceholder(project: Project): void {
console.log("[projects] open workspace placeholder", project.workspacePath);
window.open(project.codeServerUrl, "_blank", "noopener,noreferrer");
}
export function openCodeServerPlaceholder(project: Project): void {
console.log("[projects] open code-server placeholder", project.codeServerUrl);
window.open(project.codeServerUrl, "_blank", "noopener,noreferrer");
}
export function viewRecentJobsPlaceholder(project: Project): void {
console.log("[projects] view recent jobs placeholder", project.id);
}

68
src/api/settings.ts Normal file
View file

@ -0,0 +1,68 @@
import { getApiBaseUrl, getServerHealth } from "./client";
export type IntegrationStatusLabel = "connected" | "offline" | "mock" | "unknown";
export interface IntegrationSettings {
key: string;
name: string;
enabled: boolean;
endpointLabel: string;
tokenConfigured?: boolean;
status: IntegrationStatusLabel;
}
export interface SettingsSnapshot {
server: {
apiBaseUrl: string;
connectionStatus: IntegrationStatusLabel;
};
integrations: IntegrationSettings[];
}
export async function getSettingsSnapshot(): Promise<SettingsSnapshot> {
const health = await getServerHealth();
return {
server: {
apiBaseUrl: getApiBaseUrl(),
connectionStatus: health.ok ? "connected" : "offline",
},
integrations: [
{
key: "mattermost",
name: "Mattermost",
enabled: true,
endpointLabel: "https://mattermost.example.local",
tokenConfigured: true,
status: "mock",
},
{
key: "plane",
name: "Plane",
enabled: true,
endpointLabel: "https://plane.example.local",
tokenConfigured: false,
status: "mock",
},
{
key: "iop",
name: "IOP",
enabled: true,
endpointLabel: "http://localhost:9080",
status: "mock",
},
{
key: "agent-integrator",
name: "Agent Integrator",
enabled: false,
endpointLabel: "http://localhost:19080",
status: "unknown",
},
],
};
}
export async function testIntegrationPlaceholder(key: string): Promise<{ ok: boolean; label: string }> {
console.log("[settings] test integration placeholder", key);
return { ok: true, label: "mock" };
}

41
src/api/tasks.ts Normal file
View file

@ -0,0 +1,41 @@
import { apiFetch } from "./client";
export type TaskStatus = "pending" | "queued" | "running" | "completed" | "failed" | "canceled";
export interface NomadTask {
id: string;
title: string;
source: string;
status: TaskStatus;
payload?: unknown;
result?: unknown;
error?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface CreateTaskInput {
title: string;
source: string;
payload: unknown;
}
export async function listTasks(limit = 20): Promise<NomadTask[]> {
return apiFetch<NomadTask[]>(`/api/tasks?limit=${limit}`);
}
export async function createTaskPlaceholder(input: CreateTaskInput): Promise<{ id: string; status: TaskStatus }> {
console.log("[tasks] create task placeholder", input);
return {
id: `task-${Date.now()}`,
status: "pending",
};
}
export async function enqueueTaskPlaceholder(taskId: string): Promise<{ id: string; status: TaskStatus }> {
console.log("[tasks] enqueue task placeholder", taskId);
return {
id: taskId,
status: "queued",
};
}

26
src/app/App.tsx Normal file
View file

@ -0,0 +1,26 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import { useMemo } from "react";
import { router } from "./router";
export function App() {
const queryClient = useMemo(
() =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
}),
[],
);
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}

47
src/app/router.tsx Normal file
View file

@ -0,0 +1,47 @@
import { createRootRoute, createRoute, createRouter, Navigate } from "@tanstack/react-router";
import { AppShell } from "../layout/AppShell";
import { AgentPage } from "../routes/AgentPage";
import { ProjectsPage } from "../routes/ProjectsPage";
import { SettingsPage } from "../routes/SettingsPage";
const rootRoute = createRootRoute({
component: AppShell,
});
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: () => <Navigate to="/agent" replace />,
});
const agentRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/agent",
component: AgentPage,
});
const projectsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/projects",
component: ProjectsPage,
});
const settingsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/settings",
component: SettingsPage,
});
const routeTree = rootRoute.addChildren([indexRoute, agentRoute, projectsRoute, settingsRoute]);
export const router = createRouter({
routeTree,
defaultPreload: "intent",
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}

View file

@ -0,0 +1,24 @@
import type { AgentContext } from "../../stores/agentStore";
interface AgentContextCardProps {
context: AgentContext;
}
export function AgentContextCard({ context }: AgentContextCardProps) {
return (
<div className="agent-context">
<div>
<span>Page</span>
<strong>{context.currentPage || "unknown"}</strong>
</div>
<div>
<span>Project</span>
<strong>{context.selectedProject || "none"}</strong>
</div>
<div>
<span>Workspace</span>
<strong>{context.activeWorkspace || "none"}</strong>
</div>
</div>
);
}

View file

@ -0,0 +1,37 @@
import { Send } from "lucide-react";
import { FormEvent, useState } from "react";
import { Button } from "../common/Button";
interface AgentInputProps {
placeholder?: string;
onSend: (message: string) => void;
}
export function AgentInput({ placeholder = "Ask the agent to work on this context", onSend }: AgentInputProps) {
const [message, setMessage] = useState("");
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const nextMessage = message.trim();
if (!nextMessage) {
return;
}
onSend(nextMessage);
setMessage("");
}
return (
<form className="agent-input" onSubmit={handleSubmit}>
<textarea
rows={3}
value={message}
placeholder={placeholder}
onChange={(event) => setMessage(event.target.value)}
/>
<Button type="submit" size="sm" icon={<Send size={15} aria-hidden="true" />}>
Send
</Button>
</form>
);
}

View file

@ -0,0 +1,21 @@
import type { AgentMessage } from "../../stores/agentStore";
interface AgentMessageListProps {
messages: AgentMessage[];
}
export function AgentMessageList({ messages }: AgentMessageListProps) {
return (
<div className="agent-messages">
{messages.map((message) => (
<article className={`agent-message agent-message--${message.role}`} key={message.id}>
<div className="agent-message__meta">
<span>{message.role}</span>
<time>{new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</time>
</div>
<p>{message.content}</p>
</article>
))}
</div>
);
}

View file

@ -0,0 +1,21 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
type ButtonSize = "sm" | "md" | "icon";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: ReactNode;
}
export function Button({ className = "", variant = "primary", size = "md", icon, children, ...props }: ButtonProps) {
const classes = ["button", `button--${variant}`, `button--${size}`, className].filter(Boolean).join(" ");
return (
<button className={classes} {...props}>
{icon}
{children ? <span>{children}</span> : null}
</button>
);
}

View file

@ -0,0 +1,20 @@
import type { HTMLAttributes, ReactNode } from "react";
interface CardProps extends HTMLAttributes<HTMLDivElement> {
title?: string;
meta?: ReactNode;
}
export function Card({ className = "", title, meta, children, ...props }: CardProps) {
return (
<div className={["card", className].filter(Boolean).join(" ")} {...props}>
{(title || meta) && (
<div className="card__header">
{title ? <h3>{title}</h3> : <span />}
{meta}
</div>
)}
{children}
</div>
);
}

View file

@ -0,0 +1,13 @@
interface EmptyStateProps {
title: string;
description: string;
}
export function EmptyState({ title, description }: EmptyStateProps) {
return (
<div className="empty-state">
<h3>{title}</h3>
<p>{description}</p>
</div>
);
}

View file

@ -0,0 +1,55 @@
import { Bot, Briefcase, ExternalLink, History } from "lucide-react";
import type { Project } from "../../api/projects";
import { Button } from "../common/Button";
import { Card } from "../common/Card";
interface ProjectCardProps {
project: Project;
isSelected: boolean;
onSelect: () => void;
onOpenWorkspace: () => void;
onOpenCodeServer: () => void;
onCreateTask: () => void;
onViewJobs: () => void;
}
export function ProjectCard({
project,
isSelected,
onSelect,
onOpenWorkspace,
onOpenCodeServer,
onCreateTask,
onViewJobs,
}: ProjectCardProps) {
return (
<Card className={isSelected ? "project-card is-selected" : "project-card"}>
<button className="project-card__select" type="button" onClick={onSelect}>
<span className={`status-dot status-dot--${project.status}`} aria-hidden="true" />
<span>
<strong>{project.name}</strong>
<small>{project.description}</small>
</span>
</button>
<div className="project-card__meta">
<span>{project.defaultAgent}</span>
<span>{project.planeProject}</span>
</div>
<div className="project-card__actions">
<Button type="button" size="sm" icon={<Briefcase size={15} aria-hidden="true" />} onClick={onOpenWorkspace}>
Open Workspace
</Button>
<Button type="button" size="sm" variant="secondary" icon={<ExternalLink size={15} aria-hidden="true" />} onClick={onOpenCodeServer}>
code-server
</Button>
<Button type="button" size="sm" variant="secondary" icon={<Bot size={15} aria-hidden="true" />} onClick={onCreateTask}>
Agent Task
</Button>
<Button type="button" size="sm" variant="ghost" icon={<History size={15} aria-hidden="true" />} onClick={onViewJobs}>
Jobs
</Button>
</div>
</Card>
);
}

View file

@ -0,0 +1,63 @@
import { Bot, FileText, Folder, GitBranch, Link as LinkIcon } from "lucide-react";
import type { Project } from "../../api/projects";
import { EmptyState } from "../common/EmptyState";
interface ProjectDetailProps {
project?: Project;
}
export function ProjectDetail({ project }: ProjectDetailProps) {
if (!project) {
return <EmptyState title="No project selected" description="Choose a project to inspect workspace links and defaults." />;
}
return (
<aside className="project-detail">
<div className="project-detail__header">
<span className={`status-dot status-dot--${project.status}`} aria-hidden="true" />
<div>
<h2>{project.name}</h2>
<p>{project.description}</p>
</div>
</div>
<dl className="detail-list">
<div>
<dt>
<Folder size={15} aria-hidden="true" />
Workspace
</dt>
<dd>{project.workspacePath}</dd>
</div>
<div>
<dt>
<LinkIcon size={15} aria-hidden="true" />
code-server
</dt>
<dd>{project.codeServerUrl}</dd>
</div>
<div>
<dt>
<GitBranch size={15} aria-hidden="true" />
Repository
</dt>
<dd>{project.repoUrl}</dd>
</div>
<div>
<dt>
<FileText size={15} aria-hidden="true" />
Outline
</dt>
<dd>{project.outlineDoc}</dd>
</div>
<div>
<dt>
<Bot size={15} aria-hidden="true" />
Default Agent
</dt>
<dd>{project.defaultAgent}</dd>
</div>
</dl>
</aside>
);
}

View file

@ -0,0 +1,42 @@
import { X } from "lucide-react";
import type { Project } from "../../api/projects";
import { Button } from "../common/Button";
interface WorkspaceTabsProps {
projects: Project[];
activeWorkspaceIds: string[];
selectedProjectId: string;
onSelect: (projectId: string) => void;
onClose: (projectId: string) => void;
}
export function WorkspaceTabs({ projects, activeWorkspaceIds, selectedProjectId, onSelect, onClose }: WorkspaceTabsProps) {
const activeProjects = activeWorkspaceIds
.map((id) => projects.find((project) => project.id === id))
.filter((project): project is Project => Boolean(project));
if (activeProjects.length === 0) {
return <div className="workspace-tabs workspace-tabs--empty">No active workspace tabs</div>;
}
return (
<div className="workspace-tabs" role="tablist" aria-label="Active workspaces">
{activeProjects.map((project) => (
<div className={project.id === selectedProjectId ? "workspace-tab is-active" : "workspace-tab"} key={project.id}>
<button type="button" role="tab" aria-selected={project.id === selectedProjectId} onClick={() => onSelect(project.id)}>
{project.name}
</button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`Close ${project.name}`}
icon={<X size={14} aria-hidden="true" />}
onClick={() => onClose(project.id)}
/>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,40 @@
import { PlugZap } from "lucide-react";
import type { IntegrationSettings } from "../../api/settings";
import { testIntegrationPlaceholder } from "../../api/settings";
import { Button } from "../common/Button";
interface IntegrationStatusProps {
integration: IntegrationSettings;
}
export function IntegrationStatus({ integration }: IntegrationStatusProps) {
return (
<div className="integration-row">
<div>
<div className="integration-row__title">
<strong>{integration.name}</strong>
<span className={`status-pill status-pill--${integration.status}`}>{integration.status}</span>
</div>
<p>{integration.endpointLabel}</p>
{typeof integration.tokenConfigured === "boolean" ? (
<small>Token configured: {integration.tokenConfigured ? "yes" : "no"}</small>
) : null}
</div>
<div className="integration-row__actions">
<span className={integration.enabled ? "enabled-label" : "enabled-label is-muted"}>
{integration.enabled ? "Enabled" : "Disabled"}
</span>
<Button
type="button"
variant="secondary"
size="sm"
icon={<PlugZap size={15} aria-hidden="true" />}
onClick={() => void testIntegrationPlaceholder(integration.key)}
>
Test
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,19 @@
import type { ReactNode } from "react";
interface SettingsSectionProps {
title: string;
description?: string;
children: ReactNode;
}
export function SettingsSection({ title, description, children }: SettingsSectionProps) {
return (
<section className="settings-section">
<header>
<h2>{title}</h2>
{description ? <p>{description}</p> : null}
</header>
<div className="settings-section__body">{children}</div>
</section>
);
}

View file

@ -0,0 +1,38 @@
import { Link } from "@tanstack/react-router";
import { Bot, FolderKanban, Settings } from "lucide-react";
import { useLayoutStore, type ActiveNav } from "../stores/layoutStore";
const navItems: Array<{ id: ActiveNav; label: string; to: string; icon: typeof Bot }> = [
{ id: "agent", label: "Agent", to: "/agent", icon: Bot },
{ id: "projects", label: "Projects", to: "/projects", icon: FolderKanban },
{ id: "settings", label: "Settings", to: "/settings", icon: Settings },
];
export function ActivityBar() {
const activeNav = useLayoutStore((state) => state.activeNav);
const setActiveNav = useLayoutStore((state) => state.setActiveNav);
return (
<nav className="activity-bar" aria-label="Primary">
<div className="activity-bar__brand">NC</div>
<div className="activity-bar__items">
{navItems.map((item) => {
const Icon = item.icon;
return (
<Link
className={activeNav === item.id ? "activity-bar__item is-active" : "activity-bar__item"}
key={item.id}
to={item.to}
aria-label={item.label}
title={item.label}
onClick={() => setActiveNav(item.id)}
>
<Icon size={22} aria-hidden="true" />
</Link>
);
})}
</div>
</nav>
);
}

View file

@ -0,0 +1,37 @@
import { PanelRightClose } from "lucide-react";
import { AgentContextCard } from "../components/agent/AgentContextCard";
import { AgentInput } from "../components/agent/AgentInput";
import { AgentMessageList } from "../components/agent/AgentMessageList";
import { Button } from "../components/common/Button";
import { useAgentStore } from "../stores/agentStore";
import { useLayoutStore } from "../stores/layoutStore";
export function AgentSidebar() {
const messages = useAgentStore((state) => state.messages);
const currentContext = useAgentStore((state) => state.currentContext);
const sendMessage = useAgentStore((state) => state.sendMessage);
const toggleAgentSidebar = useLayoutStore((state) => state.toggleAgentSidebar);
return (
<aside className="agent-sidebar" aria-label="Agent sidebar">
<header className="agent-sidebar__header">
<div>
<p>Global Agent</p>
<h2>Context Chat</h2>
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Close agent sidebar"
icon={<PanelRightClose size={18} aria-hidden="true" />}
onClick={toggleAgentSidebar}
/>
</header>
<AgentContextCard context={currentContext} />
<AgentMessageList messages={messages} />
<AgentInput onSend={(message) => sendMessage(message, currentContext)} />
</aside>
);
}

61
src/layout/AppShell.tsx Normal file
View file

@ -0,0 +1,61 @@
import { Outlet, useRouterState } from "@tanstack/react-router";
import { useEffect } from "react";
import { useAgentStore } from "../stores/agentStore";
import { useLayoutStore, type ActiveNav } from "../stores/layoutStore";
import { useProjectStore } from "../stores/projectStore";
import { ActivityBar } from "./ActivityBar";
import { AgentSidebar } from "./AgentSidebar";
import { MobileNav } from "./MobileNav";
import { TopBar } from "./TopBar";
function navFromPath(pathname: string): ActiveNav {
if (pathname.startsWith("/projects")) {
return "projects";
}
if (pathname.startsWith("/settings")) {
return "settings";
}
return "agent";
}
export function AppShell() {
const pathname = useRouterState({ select: (state) => state.location.pathname });
const activeNav = useLayoutStore((state) => state.activeNav);
const setActiveNav = useLayoutStore((state) => state.setActiveNav);
const isAgentSidebarOpen = useLayoutStore((state) => state.isAgentSidebarOpen);
const setCurrentContext = useAgentStore((state) => state.setCurrentContext);
const projects = useProjectStore((state) => state.projects);
const selectedProjectId = useProjectStore((state) => state.selectedProjectId);
const activeWorkspaceIds = useProjectStore((state) => state.activeWorkspaceIds);
const selectedProject = projects.find((project) => project.id === selectedProjectId);
const activeWorkspaceId = activeWorkspaceIds[activeWorkspaceIds.length - 1];
const activeWorkspace = projects.find((project) => project.id === activeWorkspaceId);
useEffect(() => {
setActiveNav(navFromPath(pathname));
}, [pathname, setActiveNav]);
useEffect(() => {
setCurrentContext({
currentPage: activeNav,
selectedProject: selectedProject?.name,
activeWorkspace: activeWorkspace?.name,
});
}, [activeNav, activeWorkspace?.name, selectedProject?.name, setCurrentContext]);
return (
<div className={isAgentSidebarOpen ? "app-shell has-agent-sidebar" : "app-shell"}>
<ActivityBar />
<div className="app-shell__content">
<TopBar />
<main className="main-view">
<Outlet />
</main>
</div>
{isAgentSidebarOpen ? <AgentSidebar /> : null}
<MobileNav />
</div>
);
}

34
src/layout/MobileNav.tsx Normal file
View file

@ -0,0 +1,34 @@
import { Link } from "@tanstack/react-router";
import { Bot, FolderKanban, Settings } from "lucide-react";
import { useLayoutStore, type ActiveNav } from "../stores/layoutStore";
const navItems: Array<{ id: ActiveNav; label: string; to: string; icon: typeof Bot }> = [
{ id: "agent", label: "Agent", to: "/agent", icon: Bot },
{ id: "projects", label: "Projects", to: "/projects", icon: FolderKanban },
{ id: "settings", label: "Settings", to: "/settings", icon: Settings },
];
export function MobileNav() {
const activeNav = useLayoutStore((state) => state.activeNav);
const setActiveNav = useLayoutStore((state) => state.setActiveNav);
return (
<nav className="mobile-nav" aria-label="Mobile primary">
{navItems.map((item) => {
const Icon = item.icon;
return (
<Link
className={activeNav === item.id ? "mobile-nav__item is-active" : "mobile-nav__item"}
key={item.id}
to={item.to}
onClick={() => setActiveNav(item.id)}
>
<Icon size={18} aria-hidden="true" />
<span>{item.label}</span>
</Link>
);
})}
</nav>
);
}

51
src/layout/TopBar.tsx Normal file
View file

@ -0,0 +1,51 @@
import { useQuery } from "@tanstack/react-query";
import { Bot, PanelRightClose, PanelRightOpen } from "lucide-react";
import { getServerHealth } from "../api/client";
import { Button } from "../components/common/Button";
import { useLayoutStore } from "../stores/layoutStore";
const pageTitles = {
agent: "Agent",
projects: "Projects",
settings: "Settings",
};
export function TopBar() {
const activeNav = useLayoutStore((state) => state.activeNav);
const isAgentSidebarOpen = useLayoutStore((state) => state.isAgentSidebarOpen);
const toggleAgentSidebar = useLayoutStore((state) => state.toggleAgentSidebar);
const healthQuery = useQuery({
queryKey: ["server-health"],
queryFn: getServerHealth,
staleTime: 10_000,
});
const health = healthQuery.data ?? { ok: false, label: "checking" };
return (
<header className="top-bar">
<div>
<p>NomadCode Web</p>
<h1>{pageTitles[activeNav]}</h1>
</div>
<div className="top-bar__actions">
<span className={health.ok ? "connection-badge is-online" : "connection-badge"}>
<span aria-hidden="true" />
Core {health.label}
</span>
<Button
type="button"
variant="secondary"
size="sm"
icon={isAgentSidebarOpen ? <PanelRightClose size={16} aria-hidden="true" /> : <PanelRightOpen size={16} aria-hidden="true" />}
onClick={toggleAgentSidebar}
>
<span className="hide-on-small">{isAgentSidebarOpen ? "Hide Agent" : "Show Agent"}</span>
<span className="show-on-small">Agent</span>
</Button>
<Button type="button" variant="ghost" size="icon" aria-label="Agent quick status" icon={<Bot size={18} aria-hidden="true" />} />
</div>
</header>
);
}

11
src/main.tsx Normal file
View file

@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./app/App";
import "./styles/global.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

80
src/routes/AgentPage.tsx Normal file
View file

@ -0,0 +1,80 @@
import { CheckCircle2, Clock, PlayCircle, XCircle } from "lucide-react";
import { AgentInput } from "../components/agent/AgentInput";
import { AgentMessageList } from "../components/agent/AgentMessageList";
import { Card } from "../components/common/Card";
import { useAgentStore } from "../stores/agentStore";
const recentSessions = [
"Review nomadcode-core task enqueue flow",
"Prepare web scaffold structure",
"Check IOP control plane portal direction",
];
const taskGroups = [
{
title: "In Progress",
icon: PlayCircle,
items: ["Wire project context into sidebar", "Shape task create payload"],
},
{
title: "Completed",
icon: CheckCircle2,
items: ["Core API endpoint scan", "Workspace mock inventory"],
},
{
title: "Failed",
icon: XCircle,
items: ["No failed mock jobs"],
},
];
export function AgentPage() {
const messages = useAgentStore((state) => state.messages);
const currentContext = useAgentStore((state) => state.currentContext);
const sendMessage = useAgentStore((state) => state.sendMessage);
return (
<div className="page page--agent">
<section className="page-hero">
<div>
<p>Agent Chat</p>
<h2>Request work with the current workspace context attached.</h2>
</div>
<AgentInput placeholder="Create, inspect, or summarize work for the agent" onSend={(message) => sendMessage(message, currentContext)} />
</section>
<div className="agent-page-grid">
<Card title="Recent Sessions">
<ul className="simple-list">
{recentSessions.map((session) => (
<li key={session}>
<Clock size={15} aria-hidden="true" />
<span>{session}</span>
</li>
))}
</ul>
</Card>
<Card title="Conversation Preview">
<AgentMessageList messages={messages.slice(-4)} />
</Card>
</div>
<div className="status-grid">
{taskGroups.map((group) => {
const Icon = group.icon;
return (
<Card key={group.title} title={group.title} meta={<Icon size={18} aria-hidden="true" />}>
<ul className="compact-list">
{group.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</Card>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,70 @@
import { createTaskPlaceholder } from "../api/tasks";
import { openCodeServerPlaceholder, openWorkspacePlaceholder, viewRecentJobsPlaceholder } from "../api/projects";
import { ProjectCard } from "../components/projects/ProjectCard";
import { ProjectDetail } from "../components/projects/ProjectDetail";
import { WorkspaceTabs } from "../components/projects/WorkspaceTabs";
import { useAgentStore } from "../stores/agentStore";
import { useProjectStore } from "../stores/projectStore";
export function ProjectsPage() {
const projects = useProjectStore((state) => state.projects);
const selectedProjectId = useProjectStore((state) => state.selectedProjectId);
const activeWorkspaceIds = useProjectStore((state) => state.activeWorkspaceIds);
const selectProject = useProjectStore((state) => state.selectProject);
const openWorkspace = useProjectStore((state) => state.openWorkspace);
const closeWorkspace = useProjectStore((state) => state.closeWorkspace);
const sendMessage = useAgentStore((state) => state.sendMessage);
const selectedProject = projects.find((project) => project.id === selectedProjectId);
return (
<div className="page page--projects">
<section className="page-header-row">
<div>
<p>Project Manage</p>
<h2>Workspaces for AI development tasks.</h2>
</div>
</section>
<WorkspaceTabs
projects={projects}
activeWorkspaceIds={activeWorkspaceIds}
selectedProjectId={selectedProjectId}
onSelect={selectProject}
onClose={closeWorkspace}
/>
<div className="projects-layout">
<section className="project-list" aria-label="Projects">
{projects.map((project) => (
<ProjectCard
key={project.id}
project={project}
isSelected={project.id === selectedProjectId}
onSelect={() => selectProject(project.id)}
onOpenWorkspace={() => {
openWorkspace(project.id);
openWorkspacePlaceholder(project);
}}
onOpenCodeServer={() => openCodeServerPlaceholder(project)}
onCreateTask={() => {
selectProject(project.id);
sendMessage(`Create an agent task for ${project.name}.`);
void createTaskPlaceholder({
title: `Agent task for ${project.name}`,
source: "web",
payload: {
projectId: project.id,
workspacePath: project.workspacePath,
},
});
}}
onViewJobs={() => viewRecentJobsPlaceholder(project)}
/>
))}
</section>
<ProjectDetail project={selectedProject} />
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
import { useQuery } from "@tanstack/react-query";
import { getSettingsSnapshot } from "../api/settings";
import { Card } from "../components/common/Card";
import { IntegrationStatus } from "../components/settings/IntegrationStatus";
import { SettingsSection } from "../components/settings/SettingsSection";
export function SettingsPage() {
const settingsQuery = useQuery({
queryKey: ["settings-snapshot"],
queryFn: getSettingsSnapshot,
staleTime: 10_000,
});
const snapshot = settingsQuery.data;
return (
<div className="page page--settings">
<section className="page-header-row">
<div>
<p>Settings</p>
<h2>Connection defaults for NomadCode infrastructure.</h2>
</div>
</section>
<SettingsSection title="Server" description="NomadCode Core is the current source for health and task APIs.">
<Card>
<dl className="settings-dl">
<div>
<dt>API Base URL</dt>
<dd>{snapshot?.server.apiBaseUrl ?? "http://localhost:8080"}</dd>
</div>
<div>
<dt>Connection Status</dt>
<dd>
<span className={`status-pill status-pill--${snapshot?.server.connectionStatus ?? "unknown"}`}>
{snapshot?.server.connectionStatus ?? "checking"}
</span>
</dd>
</div>
</dl>
</Card>
</SettingsSection>
<SettingsSection title="Integrations" description="Tokens are represented only as configured flags in this scaffold.">
<div className="integration-list">
{(snapshot?.integrations ?? []).map((integration) => (
<IntegrationStatus key={integration.key} integration={integration} />
))}
{!snapshot && <Card>Loading mock settings...</Card>}
</div>
</SettingsSection>
</div>
);
}

85
src/stores/agentStore.ts Normal file
View file

@ -0,0 +1,85 @@
import { create } from "zustand";
export type AgentRole = "user" | "agent" | "system";
export interface AgentContext {
currentPage: string;
selectedProject?: string;
activeWorkspace?: string;
}
export interface AgentMessage {
id: string;
role: AgentRole;
content: string;
createdAt: string;
}
interface AgentState {
messages: AgentMessage[];
currentContext: AgentContext;
setCurrentContext: (context: AgentContext) => void;
sendMessage: (content: string, context?: AgentContext) => void;
}
function makeId(prefix: string): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return `${prefix}-${crypto.randomUUID()}`;
}
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export const useAgentStore = create<AgentState>((set, get) => ({
currentContext: {
currentPage: "agent",
},
messages: [
{
id: "system-welcome",
role: "system",
content: "Agent sidebar is running in local mock mode.",
createdAt: "2026-05-21T00:00:00Z",
},
{
id: "agent-welcome",
role: "agent",
content: "Tell me what to do in the current workspace. I will keep the context attached.",
createdAt: "2026-05-21T00:01:00Z",
},
],
setCurrentContext: (currentContext) => set({ currentContext }),
sendMessage: (content, context) => {
const trimmed = content.trim();
if (!trimmed) {
return;
}
const activeContext = context ?? get().currentContext;
const now = Date.now();
const contextLabel = [
activeContext.currentPage,
activeContext.selectedProject,
activeContext.activeWorkspace,
]
.filter(Boolean)
.join(" / ");
const userMessage: AgentMessage = {
id: makeId("user"),
role: "user",
content: trimmed,
createdAt: new Date(now).toISOString(),
};
const agentMessage: AgentMessage = {
id: makeId("agent"),
role: "agent",
content: `Mock response queued for context: ${contextLabel || "none"}.`,
createdAt: new Date(now + 250).toISOString(),
};
set((state) => ({
messages: [...state.messages, userMessage, agentMessage],
}));
},
}));

17
src/stores/layoutStore.ts Normal file
View file

@ -0,0 +1,17 @@
import { create } from "zustand";
export type ActiveNav = "agent" | "projects" | "settings";
interface LayoutState {
activeNav: ActiveNav;
isAgentSidebarOpen: boolean;
setActiveNav: (nav: ActiveNav) => void;
toggleAgentSidebar: () => void;
}
export const useLayoutStore = create<LayoutState>((set) => ({
activeNav: "agent",
isAgentSidebarOpen: true,
setActiveNav: (activeNav) => set({ activeNav }),
toggleAgentSidebar: () => set((state) => ({ isAgentSidebarOpen: !state.isAgentSidebarOpen })),
}));

View file

@ -0,0 +1,35 @@
import { create } from "zustand";
import { mockProjects, type Project } from "../api/projects";
interface ProjectState {
projects: Project[];
selectedProjectId: string;
activeWorkspaceIds: string[];
selectProject: (projectId: string) => void;
openWorkspace: (projectId: string) => void;
closeWorkspace: (projectId: string) => void;
}
export const useProjectStore = create<ProjectState>((set) => ({
projects: mockProjects,
selectedProjectId: mockProjects[0]?.id ?? "",
activeWorkspaceIds: [mockProjects[0]?.id ?? ""].filter(Boolean),
selectProject: (selectedProjectId) => set({ selectedProjectId }),
openWorkspace: (projectId) =>
set((state) => ({
selectedProjectId: projectId,
activeWorkspaceIds: state.activeWorkspaceIds.includes(projectId)
? state.activeWorkspaceIds
: [...state.activeWorkspaceIds, projectId],
})),
closeWorkspace: (projectId) =>
set((state) => {
const activeWorkspaceIds = state.activeWorkspaceIds.filter((id) => id !== projectId);
return {
activeWorkspaceIds,
selectedProjectId:
state.selectedProjectId === projectId ? activeWorkspaceIds[activeWorkspaceIds.length - 1] ?? state.projects[0]?.id ?? "" : state.selectedProjectId,
};
}),
}));

861
src/styles/global.css Normal file
View file

@ -0,0 +1,861 @@
:root {
color-scheme: dark;
--bg: #111318;
--panel: #171a21;
--panel-strong: #1e232c;
--panel-soft: #242936;
--activity: #0c0e12;
--border: #2d3340;
--border-strong: #3a4352;
--text: #eef2f8;
--muted: #9ca7b8;
--subtle: #6f7a8c;
--primary: #5fb3f3;
--primary-strong: #8cd1ff;
--accent: #56d6a5;
--warning: #e7bd65;
--danger: #f07b7b;
--shadow: 0 18px 40px rgb(0 0 0 / 24%);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
margin: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
textarea,
input {
font: inherit;
}
button {
color: inherit;
}
a {
color: inherit;
text-decoration: none;
}
.app-shell {
display: grid;
min-height: 100vh;
grid-template-columns: 64px minmax(0, 1fr);
background: var(--bg);
}
.app-shell.has-agent-sidebar {
grid-template-columns: 64px minmax(0, 1fr) 370px;
}
.app-shell__content {
display: flex;
min-width: 0;
flex-direction: column;
}
.activity-bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
height: 100vh;
flex-direction: column;
align-items: center;
gap: 18px;
border-right: 1px solid var(--border);
background: var(--activity);
padding: 14px 8px;
}
.activity-bar__brand {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--panel-strong);
color: var(--primary-strong);
font-size: 13px;
font-weight: 800;
}
.activity-bar__items {
display: grid;
gap: 8px;
}
.activity-bar__item {
display: grid;
width: 46px;
height: 46px;
place-items: center;
border-radius: 8px;
color: var(--muted);
}
.activity-bar__item:hover,
.activity-bar__item.is-active {
background: var(--panel-soft);
color: var(--text);
}
.top-bar {
display: flex;
min-height: 68px;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--border);
background: rgb(17 19 24 / 94%);
padding: 12px 18px;
}
.top-bar p,
.page-hero p,
.page-header-row p,
.agent-sidebar__header p {
margin: 0 0 3px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.top-bar h1,
.agent-sidebar__header h2,
.project-detail__header h2 {
margin: 0;
font-size: 19px;
line-height: 1.2;
}
.top-bar__actions {
display: flex;
align-items: center;
gap: 10px;
}
.main-view {
min-width: 0;
flex: 1;
overflow: auto;
padding: 22px;
}
.page {
display: grid;
gap: 18px;
max-width: 1260px;
margin: 0 auto;
}
.page-hero,
.page-header-row {
display: grid;
gap: 18px;
align-items: end;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
padding: 20px;
}
.page-hero {
grid-template-columns: minmax(0, 1fr) minmax(300px, 520px);
}
.page-hero h2,
.page-header-row h2 {
max-width: 740px;
margin: 0;
font-size: 28px;
line-height: 1.2;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
max-width: 100%;
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
font-weight: 700;
line-height: 1;
white-space: nowrap;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.button--md {
min-height: 38px;
padding: 0 14px;
font-size: 14px;
}
.button--sm {
min-height: 32px;
padding: 0 11px;
font-size: 13px;
}
.button--icon {
width: 34px;
height: 34px;
padding: 0;
}
.button--primary {
background: var(--primary);
color: #06111a;
}
.button--secondary {
border-color: var(--border-strong);
background: var(--panel-soft);
color: var(--text);
}
.button--ghost {
background: transparent;
color: var(--muted);
}
.button--danger {
background: var(--danger);
color: #160707;
}
.button:hover {
filter: brightness(1.08);
}
.card {
min-width: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
padding: 16px;
}
.card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.card__header h3 {
margin: 0;
font-size: 16px;
}
.agent-input {
display: grid;
gap: 10px;
}
.agent-input textarea {
width: 100%;
resize: vertical;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: #10131a;
color: var(--text);
line-height: 1.45;
padding: 12px;
}
.agent-input textarea:focus {
border-color: var(--primary);
outline: none;
}
.agent-messages {
display: grid;
gap: 10px;
min-width: 0;
}
.agent-message {
border: 1px solid var(--border);
border-radius: 8px;
background: #141821;
padding: 10px;
}
.agent-message--user {
border-color: rgb(95 179 243 / 42%);
background: rgb(95 179 243 / 10%);
}
.agent-message--system {
background: rgb(86 214 165 / 8%);
}
.agent-message__meta {
display: flex;
justify-content: space-between;
gap: 10px;
color: var(--subtle);
font-size: 12px;
text-transform: uppercase;
}
.agent-message p {
margin: 7px 0 0;
color: var(--text);
line-height: 1.45;
overflow-wrap: anywhere;
}
.agent-context {
display: grid;
gap: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: #11151d;
padding: 12px;
}
.agent-context div {
display: grid;
gap: 2px;
}
.agent-context span {
color: var(--subtle);
font-size: 12px;
}
.agent-context strong {
min-width: 0;
overflow-wrap: anywhere;
font-size: 13px;
}
.agent-sidebar {
display: flex;
min-width: 0;
height: 100vh;
flex-direction: column;
gap: 14px;
border-left: 1px solid var(--border);
background: var(--panel);
padding: 14px;
}
.agent-sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.agent-sidebar .agent-messages {
min-height: 0;
flex: 1;
overflow: auto;
padding-right: 2px;
}
.agent-page-grid {
display: grid;
grid-template-columns: minmax(260px, 0.7fr) minmax(320px, 1.3fr);
gap: 18px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.simple-list,
.compact-list {
display: grid;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.simple-list li {
display: flex;
align-items: flex-start;
gap: 9px;
color: var(--muted);
line-height: 1.4;
}
.compact-list li {
border: 1px solid var(--border);
border-radius: 8px;
background: #121620;
color: var(--muted);
padding: 10px;
}
.projects-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 18px;
align-items: start;
}
.project-list {
display: grid;
gap: 12px;
}
.project-card {
display: grid;
gap: 12px;
}
.project-card.is-selected {
border-color: rgb(95 179 243 / 60%);
box-shadow: 0 0 0 1px rgb(95 179 243 / 20%);
}
.project-card__select {
display: flex;
width: 100%;
align-items: flex-start;
gap: 10px;
border: 0;
background: transparent;
padding: 0;
text-align: left;
cursor: pointer;
}
.project-card__select strong,
.project-card__select small {
display: block;
overflow-wrap: anywhere;
}
.project-card__select strong {
font-size: 16px;
}
.project-card__select small {
margin-top: 4px;
color: var(--muted);
line-height: 1.4;
}
.project-card__meta,
.project-card__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.project-card__meta span {
border: 1px solid var(--border);
border-radius: 8px;
background: #121620;
color: var(--muted);
font-size: 12px;
padding: 5px 8px;
}
.project-detail {
position: sticky;
top: 86px;
display: grid;
gap: 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
padding: 16px;
}
.project-detail__header {
display: flex;
align-items: flex-start;
gap: 10px;
}
.project-detail__header p {
margin: 5px 0 0;
color: var(--muted);
line-height: 1.45;
}
.detail-list,
.settings-dl {
display: grid;
gap: 12px;
margin: 0;
}
.detail-list div,
.settings-dl div {
display: grid;
gap: 5px;
}
.detail-list dt,
.settings-dl dt {
display: flex;
align-items: center;
gap: 7px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.detail-list dd,
.settings-dl dd {
margin: 0;
overflow-wrap: anywhere;
color: var(--text);
line-height: 1.45;
}
.workspace-tabs {
display: flex;
gap: 8px;
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 8px;
background: #11151d;
padding: 8px;
}
.workspace-tabs--empty {
color: var(--subtle);
font-size: 13px;
}
.workspace-tab {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 2px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
overflow: hidden;
}
.workspace-tab.is-active {
border-color: rgb(95 179 243 / 60%);
background: rgb(95 179 243 / 12%);
}
.workspace-tab > button:first-child {
max-width: 220px;
border: 0;
background: transparent;
cursor: pointer;
overflow: hidden;
padding: 8px 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-section {
display: grid;
gap: 12px;
}
.settings-section header h2 {
margin: 0;
font-size: 18px;
}
.settings-section header p {
margin: 5px 0 0;
color: var(--muted);
}
.settings-section__body,
.integration-list {
display: grid;
gap: 10px;
}
.integration-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
padding: 14px;
}
.integration-row__title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.integration-row p,
.integration-row small {
display: block;
margin: 5px 0 0;
color: var(--muted);
overflow-wrap: anywhere;
}
.integration-row__actions {
display: flex;
align-items: center;
gap: 10px;
}
.enabled-label {
color: var(--accent);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.enabled-label.is-muted {
color: var(--subtle);
}
.connection-badge,
.status-pill {
display: inline-flex;
align-items: center;
gap: 7px;
border: 1px solid var(--border);
border-radius: 8px;
background: #121620;
color: var(--muted);
font-size: 12px;
font-weight: 800;
padding: 6px 8px;
text-transform: uppercase;
white-space: nowrap;
}
.connection-badge span {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--warning);
}
.connection-badge.is-online span {
background: var(--accent);
}
.status-pill--connected {
color: var(--accent);
}
.status-pill--offline,
.status-pill--failed {
color: var(--danger);
}
.status-pill--mock,
.status-pill--unknown {
color: var(--warning);
}
.status-dot {
flex: 0 0 auto;
width: 10px;
height: 10px;
margin-top: 5px;
border-radius: 999px;
background: var(--subtle);
}
.status-dot--active,
.status-dot--ready {
background: var(--accent);
}
.status-dot--paused {
background: var(--warning);
}
.status-dot--archived {
background: var(--subtle);
}
.empty-state {
border: 1px dashed var(--border-strong);
border-radius: 8px;
color: var(--muted);
padding: 20px;
text-align: center;
}
.empty-state h3 {
margin: 0 0 6px;
color: var(--text);
}
.empty-state p {
margin: 0;
}
.mobile-nav,
.show-on-small {
display: none;
}
@media (max-width: 1120px) {
.app-shell.has-agent-sidebar {
grid-template-columns: 64px minmax(0, 1fr) 330px;
}
.projects-layout {
grid-template-columns: 1fr;
}
.project-detail {
position: static;
}
}
@media (max-width: 840px) {
.app-shell,
.app-shell.has-agent-sidebar {
display: block;
padding-bottom: 62px;
}
.activity-bar {
display: none;
}
.top-bar {
position: sticky;
top: 0;
z-index: 20;
}
.top-bar__actions {
gap: 6px;
}
.main-view {
padding: 14px;
}
.page-hero {
grid-template-columns: 1fr;
}
.agent-page-grid,
.status-grid {
grid-template-columns: 1fr;
}
.agent-sidebar {
position: fixed;
inset: 0;
z-index: 40;
height: auto;
border-left: 0;
box-shadow: var(--shadow);
}
.mobile-nav {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 30;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-top: 1px solid var(--border);
background: var(--activity);
}
.mobile-nav__item {
display: grid;
min-height: 58px;
place-items: center;
gap: 3px;
color: var(--muted);
font-size: 12px;
}
.mobile-nav__item.is-active {
color: var(--primary-strong);
}
.hide-on-small {
display: none;
}
.show-on-small {
display: inline;
}
}
@media (max-width: 560px) {
.top-bar {
align-items: flex-start;
padding: 10px 12px;
}
.top-bar h1 {
font-size: 17px;
}
.connection-badge {
display: none;
}
.page-hero,
.page-header-row,
.card,
.project-detail,
.integration-row {
padding: 13px;
}
.page-hero h2,
.page-header-row h2 {
font-size: 22px;
}
.integration-row {
grid-template-columns: 1fr;
}
.integration-row__actions {
justify-content: space-between;
}
.workspace-tab > button:first-child {
max-width: 150px;
}
}

1
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

21
tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "vite.config.ts"],
"references": []
}

6
vite.config.ts Normal file
View file

@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});