init: 프로젝트 초기 구조 설정
This commit is contained in:
commit
fc8265f475
39 changed files with 3402 additions and 0 deletions
1
.env.example
Normal file
1
.env.example
Normal file
|
|
@ -0,0 +1 @@
|
|||
VITE_NOMADCODE_API_BASE_URL=http://localhost:8080
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal 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
49
README.md
Normal 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
12
index.html
Normal 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
1174
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal 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
36
src/api/client.ts
Normal 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
88
src/api/projects.ts
Normal 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
68
src/api/settings.ts
Normal 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
41
src/api/tasks.ts
Normal 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
26
src/app/App.tsx
Normal 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
47
src/app/router.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
24
src/components/agent/AgentContextCard.tsx
Normal file
24
src/components/agent/AgentContextCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/agent/AgentInput.tsx
Normal file
37
src/components/agent/AgentInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/components/agent/AgentMessageList.tsx
Normal file
21
src/components/agent/AgentMessageList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/components/common/Button.tsx
Normal file
21
src/components/common/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/components/common/Card.tsx
Normal file
20
src/components/common/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/components/common/EmptyState.tsx
Normal file
13
src/components/common/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/projects/ProjectCard.tsx
Normal file
55
src/components/projects/ProjectCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/components/projects/ProjectDetail.tsx
Normal file
63
src/components/projects/ProjectDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/components/projects/WorkspaceTabs.tsx
Normal file
42
src/components/projects/WorkspaceTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/settings/IntegrationStatus.tsx
Normal file
40
src/components/settings/IntegrationStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/components/settings/SettingsSection.tsx
Normal file
19
src/components/settings/SettingsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/layout/ActivityBar.tsx
Normal file
38
src/layout/ActivityBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/layout/AgentSidebar.tsx
Normal file
37
src/layout/AgentSidebar.tsx
Normal 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
61
src/layout/AppShell.tsx
Normal 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
34
src/layout/MobileNav.tsx
Normal 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
51
src/layout/TopBar.tsx
Normal 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
11
src/main.tsx
Normal 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
80
src/routes/AgentPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/routes/ProjectsPage.tsx
Normal file
70
src/routes/ProjectsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/routes/SettingsPage.tsx
Normal file
55
src/routes/SettingsPage.tsx
Normal 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
85
src/stores/agentStore.ts
Normal 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
17
src/stores/layoutStore.ts
Normal 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 })),
|
||||
}));
|
||||
35
src/stores/projectStore.ts
Normal file
35
src/stores/projectStore.ts
Normal 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
861
src/styles/global.css
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal 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
6
vite.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
Loading…
Reference in a new issue