기능: monorepo 구조를 변경하고 web을 제거한다

- apps/web 디렉터리 전체를 삭제하여 web 클라이언트 제거
- Flutter 모바일 앱 구조를 정리하고 모델/스크린/서비스 추가
- agent-ops 규칙 및 로드맵 업데이트
- monorepo 문서 갱신
This commit is contained in:
toki 2026-05-24 21:48:51 +09:00
parent 3a6c9fb627
commit 84c8ac67ed
63 changed files with 844 additions and 3508 deletions

View file

@ -8,7 +8,7 @@ NomadCode는 IOP를 사용하는 개발 워크플로우 셸입니다. 사용자
- `services/core`는 health/readiness endpoint, task API, PostgreSQL persistence, goose migration, sqlc query 생성 구조, River job, IOP OpenAI-compatible Responses client 경로, 향후 A2A agent delegation scaffolding, Plane/Mattermost adapter stub을 포함합니다.
- `apps/mobile`은 NomadCode 제품 UI의 기준 Flutter 앱이며 모바일/데스크톱/선택적 web target, Firebase Messaging, local notifications, HTTP client 의존성을 포함합니다.
- `apps/web`은 이전 React/Vite 운영 콘솔 scaffold입니다. 장기 제품 UI 경로에서는 제외하며, Flutter-first 전환 과정에서 제거 또는 격리 대상으로 둡니다.
- `apps/web`은 이전 React/Vite 운영 콘솔 scaffold였으며, Flutter-first 통합에 따라 완전히 폐기 및 제거되었습니다.
- `packages/contracts`는 향후 OpenAPI, protobuf, generated client, fixture, compatibility asset을 담을 공유 계약 공간입니다.
- 활성 계획과 Milestone 상태는 `agent-ops/roadmap/`에서 관리합니다. README에는 현재 작업 상태를 복사하지 않고 진입 문서만 연결합니다.
@ -81,7 +81,7 @@ flutter run
|------|------|
| `services/core/` | Go backend. orchestration, persistence, scheduling, HTTP API, 외부 service adapter를 담당합니다. |
| `apps/mobile/` | Flutter 기반 제품 UI source of truth, 모바일/데스크톱 클라이언트와 notification 연동 표면입니다. |
| `apps/web/` | 이전 React/Vite 운영 콘솔 scaffold입니다. 장기 제품 경로에서 제거 또는 격리할 대상입니다. |
| `apps/web/` | [Legacy/Retired] React/Vite 운영 콘솔 scaffold입니다. Flutter-first 클라이언트 통합에 따라 완전히 폐기 및 제거되었습니다. |
| `packages/contracts/` | 서비스와 클라이언트가 공유할 API/schema 계약 공간입니다. |
| `docs/` | 아키텍처와 운영 문서입니다. |
| `bin/` | root test, lint, build, dev helper 명령입니다. |

View file

@ -20,7 +20,7 @@ Client Platform Consolidation
## 범위
- Flutter 앱을 NomadCode 제품 UI의 기준 구현으로 정리
- React/Vite 웹 콘솔 scaffold를 제거하거나 제품 경로 밖의 임시/아카이브 표면으로 격리
- React/Vite 웹 콘솔 scaffold를 제거하여 제품 경로 밖으로 격리/폐기 완료
- root dev/test/lint/build entrypoint에서 React web 의존을 제거하거나 명확히 비활성화
- README, docs, roadmap, domain rule에서 web/app 책임 경계를 Flutter-first 기준으로 갱신
- code-server는 React wrapper를 거치지 않고 Flutter 앱에서 직접 열거나 embed하는 표면으로 정리
@ -29,7 +29,7 @@ Client Platform Consolidation
## 필수 기능
- [x] [ui-source] Flutter 앱을 제품 UI source of truth로 선언하고 React web을 기준 구현에서 제외한다.
- [ ] [react-retire] `apps/web` React/Vite scaffold의 제거, 격리, 또는 폐기 경로를 정리한다.
- [x] [react-retire] `apps/web` React/Vite scaffold의 제거, 격리, 또는 폐기 경로를 정리한다.
- [x] [entrypoints] root `bin/*` 개발/검증 entrypoint가 Flutter-first 클라이언트 구조를 반영하도록 한다.
- [x] [docs-rules] README, docs, roadmap, domain rule의 web/app 설명을 Flutter-first 기준으로 맞춘다.
- [ ] [codeserver-direct] code-server, workspace, diff/terminal/editor 표면은 React wrapper 없이 Flutter 앱에서 직접 연결하는 기준을 정한다.
@ -37,9 +37,9 @@ Client Platform Consolidation
## 완료 기준
- [ ] 로드맵과 프로젝트 문서에서 React/Vite 웹 콘솔이 장기 제품 UI 기준 구현으로 설명되지 않는다.
- [x] 로드맵과 프로젝트 문서에서 React/Vite 웹 콘솔이 장기 제품 UI 기준 구현으로 설명되지 않는다.
- [ ] Flutter 앱이 모바일/데스크톱/선택적 web target의 기준 클라이언트로 설명된다.
- [ ] `apps/web`의 처리 방침이 삭제, 격리, 보류 중 하나로 코드와 문서에서 일관된다.
- [x] `apps/web`의 처리 방침이 삭제, 격리, 보류 중 하나로 코드와 문서에서 일관된다.
- [ ] root `bin/test`, `bin/lint`, `bin/build`, `bin/dev`가 React web 제거 이후의 검증 흐름과 충돌하지 않는다.
- [ ] code-server를 React로 감싸고 다시 Flutter로 감싸는 구조를 만들지 않는다는 기준이 문서화되어 있다.
- [ ] 후속 Project Workspace Management UX가 Flutter-first 클라이언트 구조 위에서 진행될 수 있다.

View file

@ -56,7 +56,7 @@ Project Workspace Management UX
## 작업 컨텍스트
- 관련 경로: `apps/mobile/`, `packages/contracts/`, `services/core/`
- 전환 참고 경로: `apps/web/`
- 전환 참고 경로: [Legacy/Retired] `apps/web/` (제거됨)
- 선행 작업: Flutter-first Client Consolidation, External Integration
- 후속 작업: 확인 필요
- 확인 필요: `구현 잠금`의 결정 필요 항목 참고.

View file

@ -18,7 +18,7 @@ NomadCode 런타임 사이의 공유 계약 도메인이다. API schema, event s
- `services/core/internal/http/` — API implementation은 core 도메인이다.
- `apps/mobile/lib/` — mobile-specific client/service code는 mobile 도메인이다.
- `apps/web/src/api/` — legacy web-specific API client wrapper는 web 도메인이다.
- `apps/web/` — [Legacy/Retired] 이전 React/Vite web scaffold 경로는 완전히 제거되어 제외 대상입니다.
## 주요 구성 요소
@ -27,7 +27,7 @@ NomadCode 런타임 사이의 공유 계약 도메인이다. API schema, event s
## 유지할 패턴
- 계약 변경은 additive를 우선한다.
- breaking change는 core와 Flutter 앱 반영을 같은 branch에서 조율한다. legacy web은 제거 전 필요한 경우에만 함께 조율한다.
- breaking change는 core와 Flutter 앱 반영을 같은 branch에서 조율한다.
- schema가 추가되면 sample payload나 compatibility note를 함께 둔다.
- generated client가 생기면 생성 명령과 source schema를 문서화한다.
@ -35,7 +35,7 @@ NomadCode 런타임 사이의 공유 계약 도메인이다. API schema, event s
- **core**: contracts는 API shape의 source-of-truth이고 core는 이를 구현한다.
- **mobile**: mobile은 contracts를 소비하되 platform behavior는 mobile 도메인에서 다룬다.
- **web**: legacy web은 제거 전 필요한 경우에만 contracts를 소비하거나 generated client를 감싼다.
- **web**: [Legacy/Retired] React web 도메인은 완전히 제거되었습니다.
- **workspace-ops**: contracts 생성/검증 명령이 root script가 되면 workspace-ops와 함께 갱신한다.
## 금지 사항

View file

@ -31,7 +31,7 @@ NomadCode의 백엔드 오케스트레이션 도메인이다. workflow, scheduli
## 제외 경로
- `apps/mobile/` — Flutter-first 제품 클라이언트이며 core 내부 패키지를 직접 참조하지 않는다.
- `apps/web/`이전 React/Vite scaffold이며 core 구현을 직접 소유하지 않는다.
- `apps/web/`[Legacy/Retired] React/Vite scaffold 경로는 완전히 제거되어 제외 대상입니다.
- `packages/contracts/` — 공유 API/schema 계약의 소유 영역이다.
## 주요 구성 요소
@ -71,7 +71,7 @@ NomadCode의 백엔드 오케스트레이션 도메인이다. workflow, scheduli
- 외부 서비스 호출은 `internal/adapters/<provider>` 아래에 격리한다.
- 환경 변수 기본값과 alias는 `internal/config`에서 관리하고 README의 실행 예시와 어긋나지 않게 유지한다.
- scheduler 변경은 가능한 한 job 단위 테스트를 추가한다.
- HTTP API 변경은 Flutter 앱 영향과 contracts 반영 필요성을 함께 판단한다. legacy web 영향은 제거 전 필요한 경우에만 확인한다.
- HTTP API 변경은 Flutter 앱 영향과 contracts 반영 필요성을 함께 판단한다.
- Plane 관련 작업에서는 board state와 agent 내부 실행 상태를 같은 workflow state로 섞지 않는다. `testing`은 agent가 자체 검증과 리뷰 루프를 끝낸 뒤 사용자 테스트를 기다리는 상태로 사용한다.
- Plane/Jira work item 본문(description)은 작업 요구사항과 맥락의 원본으로 취급하고, agent 실행 상태를 매번 갱신하는 저장소로 사용하지 않는다.
@ -79,7 +79,7 @@ NomadCode의 백엔드 오케스트레이션 도메인이다. workflow, scheduli
- **contracts**: API shape가 클라이언트와 공유되면 contracts에 먼저 표현하거나 문서화한다.
- **mobile**: mobile은 public API와 notification/auth 계약만 의존한다.
- **web**: legacy web은 제거 전 필요한 경우에만 HTTP/API client를 통해 core와 통신한다. core 내부 Go package를 전제하지 않는다.
- **web**: [Legacy/Retired] React web 도메인은 완전히 제거되었습니다.
- **workspace-ops**: root helper 변경은 workspace-ops 소유이고, core-local bin 변경만 core 소유다.
## 금지 사항

View file

@ -22,7 +22,7 @@ NomadCode의 Flutter-first 제품 UI 도메인이다. 모바일/데스크톱/선
## 제외 경로
- `services/core/` — backend orchestration/API implementation.
- `apps/web/`이전 React/Vite browser console scaffold.
- `apps/web/`[Legacy/Retired] React/Vite scaffold 경로는 완전히 제거되어 제외 대상입니다.
- `packages/contracts/` — shared API/schema contracts.
## 주요 구성 요소
@ -50,7 +50,7 @@ NomadCode의 Flutter-first 제품 UI 도메인이다. 모바일/데스크톱/선
- **core**: mobile은 core API/notification 계약에 의존하되 core 내부 구현을 알지 않는다.
- **contracts**: mobile이 사용하는 API/event shape는 contracts 후보로 올린다.
- **web**: React/Vite web은 legacy scaffold이며 Flutter 제품 UI를 복제하지 않는다.
- **web**: [Legacy/Retired] React web 도메인은 완전히 제거되었습니다.
- **workspace-ops**: root scripts와 agent rules는 mobile 내부에 복제하지 않는다.
## 금지 사항

View file

@ -1,66 +0,0 @@
---
domain: web
last_rule_review_commit: e3b5dae725db62dd7a47c563abcce557e0557551
last_rule_updated_at: 2026-05-21
---
# web
## 목적 / 책임
NomadCode의 이전 React/Vite 브라우저 운영 콘솔 scaffold 도메인이다. Flutter-first 전환 이후 장기 제품 UI 경로에서는 제외하며, 이 도메인의 작업은 제거, 격리, 또는 legacy 상태 보존에 한정한다.
## 포함 경로
- `apps/web/src/app/` — React app과 router 조립.
- `apps/web/src/routes/` — 페이지 단위 route.
- `apps/web/src/layout/` — shell, navigation, sidebar.
- `apps/web/src/components/` — agent, project, settings, common UI components.
- `apps/web/src/stores/` — Zustand 기반 client state.
- `apps/web/src/api/` — backend API client.
- `apps/web/src/styles/` — global style.
- `apps/web/index.html`, `apps/web/.env.example` — Vite HTML entry와 환경 변수 예시.
- `apps/web/package.json`, `apps/web/package-lock.json`, `apps/web/vite.config.ts`, `apps/web/tsconfig.json` — web build/tooling.
## 제외 경로
- `services/core/` — API implementation과 persistence는 core 도메인이다.
- `apps/mobile/` — Flutter 제품 UI source of truth는 mobile 도메인이다.
- `packages/contracts/` — shared schema/source-of-truth는 contracts 도메인이다.
## 주요 구성 요소
- `src/app/App.tsx` — QueryClient와 router provider 조립.
- `src/app/router.tsx` — web route 구성.
- `src/layout/AppShell.tsx` — 전체 shell.
- `src/layout/AgentSidebar.tsx` — agent context/sidebar 영역.
- `src/routes/AgentPage.tsx` — agent workspace page.
- `src/routes/ProjectsPage.tsx` — project workspace page.
- `src/routes/SettingsPage.tsx` — integration settings page.
- `src/stores/agentStore.ts` — agent interaction state.
- `src/stores/layoutStore.ts` — shell navigation/sidebar state.
- `src/stores/projectStore.ts` — project workspace state.
- `src/api/client.ts` — API 호출 공통 client.
- `src/components/settings/IntegrationStatus.tsx` — integration 상태 표시와 테스트 액션 UI.
## 유지할 패턴
- 새 제품 기능을 React web에 추가하지 않는다.
- 제거 전 legacy 유지가 필요한 경우 기존 `src/routes`, `src/layout`, `src/components`, `src/api`, `src/stores` 구조를 유지한다.
- API 호출 형태가 바뀌면 Flutter 앱과 contracts/core 경계를 우선 확인하고, legacy web 변경은 필요한 최소 범위로 제한한다.
- legacy scaffold를 직접 수정할 때는 `npm run check`가 통과하도록 TypeScript type을 유지한다.
## 다른 도메인과의 경계
- **core**: web은 core의 HTTP/API surface만 사용한다.
- **contracts**: 반복되는 request/response shape는 contracts 후보로 올린다.
- **mobile**: Flutter 앱이 제품 UI source of truth이며, web은 제품 UI 구현을 복제하지 않는다.
- **workspace-ops**: root `bin/*`나 monorepo 문서는 web 변경과 분리한다.
## 금지 사항
- core 내부 구조를 web 컴포넌트에 하드코딩하지 않는다.
- 새 제품 화면을 React web에 추가하지 않는다.
- API mock과 실제 API shape를 장기간 다르게 유지하지 않는다.
- 공통 UI component에 page-specific side effect를 넣지 않는다.
- 대규모 visual/style 변경과 API behavior 변경을 한 커밋에 섞지 않는다.

View file

@ -27,11 +27,11 @@ NomadCode 원레포 운영 도메인이다. 루트 helper, 문서, ignore 정책
- `agent-ops/rules/common/` — agentic-framework에서 동기화되는 공통 rules.
- `agent-ops/skills/common/` — agentic-framework에서 동기화되는 공통 skills.
- `services/core/`, `apps/web/`, `apps/mobile/`, `packages/contracts/` — 각 제품 도메인 소유 코드.
- `services/core/`, `apps/web/` (Legacy/Retired), `apps/mobile/`, `packages/contracts/` — 각 제품 도메인 소유 코드.
## 주요 구성 요소
- `bin/test` — core/web/mobile 검증 entrypoint.
- `bin/test` — core/mobile 검증 entrypoint.
- `bin/lint` — vet/check/analyze entrypoint.
- `bin/build` — build entrypoint.
- `bin/dev` — development entrypoint 안내.

View file

@ -13,7 +13,6 @@ NomadCode는 AI 병렬 작업 운용을 위한 원레포다. 백엔드 오케스
- `services/core/` — Go 기반 백엔드, workflow, scheduler, persistence, 외부 adapter.
- `apps/mobile/` — Flutter 기반 제품 UI source of truth, 모바일/데스크톱 클라이언트와 알림 연동.
- `apps/web/` — 이전 React/Vite 운영 콘솔 scaffold. Flutter-first 전환에서 제거 또는 격리 대상.
- `packages/contracts/` — 서비스와 클라이언트가 공유하는 API/schema 계약.
- `bin/` — 원레포 공통 test/build/lint/dev entrypoint.
- `docs/` — 아키텍처와 운영 문서.
@ -30,7 +29,6 @@ NomadCode는 AI 병렬 작업 운용을 위한 원레포다. 백엔드 오케스
- 원레포 루트에서 가능한 작업은 `bin/test`, `bin/lint`, `bin/build`, `bin/dev`를 우선 사용한다.
- cross-boundary 변경은 계약, 서비스, 클라이언트 반영을 같은 브랜치에서 닫는다.
- `services/core` 변경은 `go test ./...` 또는 `bin/test`로 확인한다.
- `apps/web`는 장기 제품 UI 경로에서 제외한다. 제거 전 legacy scaffold를 직접 변경할 때만 `npm run check` 또는 `npm run verify`로 확인한다.
- `apps/mobile` 변경은 `flutter test`와 필요 시 `flutter analyze --no-fatal-infos`로 확인한다.
- `packages/contracts`가 실제 계약을 갖기 전에는 API 형태를 서비스/클라이언트에 중복 고정하지 말고 계약 후보를 문서화한다.
- 하위 앱/서비스에 별도 `agent-ops`나 AI entry 파일을 만들지 않는다. 루트 `agent-ops`와 루트 entry 파일만 사용한다.
@ -41,7 +39,6 @@ NomadCode는 AI 병렬 작업 운용을 위한 원레포다. 백엔드 오케스
| 경로 패턴 | 도메인 | rules.md |
|----------|--------|----------|
| `services/core/**` | core | `agent-ops/rules/project/domain/core/rules.md` |
| `apps/web/**` | web | `agent-ops/rules/project/domain/web/rules.md` |
| `apps/mobile/**` | mobile | `agent-ops/rules/project/domain/mobile/rules.md` |
| `packages/contracts/**` | contracts | `agent-ops/rules/project/domain/contracts/rules.md` |
| `bin/**`, `docs/**`, `README.md`, `.gitignore`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `opencode.json`, `agent-ops/.version`, `agent-ops/bin/**`, `agent-ops/roadmap/**`, `agent-ops/rules/project/**` | workspace-ops | `agent-ops/rules/project/domain/workspace-ops/rules.md` |

View file

@ -6,9 +6,12 @@ import 'package:flutter/material.dart';
import 'services/mattermost_auth_service.dart';
import 'services/push_notification_background.dart';
import 'services/push_notification_service.dart';
import 'services/workspace_launcher.dart';
import 'screens/workspace_home_page.dart';
final _navigatorKey = GlobalKey<NavigatorState>();
final _pushService = PushNotificationService();
final _launcher = WorkspaceLauncher();
Future<void> _applyFullscreenMode() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
@ -50,7 +53,11 @@ class MyApp extends StatelessWidget {
title: 'nomadcode-app',
navigatorKey: _navigatorKey,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const MyHomePage(title: 'nomadcode-app'),
builder: (context, child) {
@ -111,10 +118,10 @@ class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: const Center(
child: Text('nomadcode-app'),
),
return WorkspaceHomePage(
onLaunchCodeServer: (project) async {
return _launcher.openCodeServer(project);
},
);
}
}

View file

@ -0,0 +1,50 @@
enum ProjectStatus {
active,
paused,
pending,
}
class ProjectWorkspace {
final String id;
final String name;
final String description;
final ProjectStatus status;
final String codeServerUrl;
final int tasksCount;
const ProjectWorkspace({
required this.id,
required this.name,
required this.description,
required this.status,
required this.codeServerUrl,
required this.tasksCount,
});
}
const List<ProjectWorkspace> mockProjectWorkspaces = [
ProjectWorkspace(
id: 'nomadcode',
name: 'NomadCode Core',
description: 'AI 병렬 작업 운용을 위한 원레포 백엔드 및 모바일 클라이언트',
status: ProjectStatus.active,
codeServerUrl: 'http://localhost:8080/?folder=/config/workspace/nomadcode',
tasksCount: 3,
),
ProjectWorkspace(
id: 'iop',
name: 'IOP Bridge',
description: 'RAG 및 MCP 연동을 위한 대규모 자율형 LLM 오케스트레이션 게이트웨이',
status: ProjectStatus.active,
codeServerUrl: 'http://localhost:8080/?folder=/config/workspace/iop',
tasksCount: 1,
),
ProjectWorkspace(
id: 'proto-socket',
name: 'Proto Socket',
description: '고성능 전이중 RPC 채널 및 웹소켓 메시징 엔진',
status: ProjectStatus.paused,
codeServerUrl: 'http://localhost:8080/?folder=/config/workspace/proto-socket',
tasksCount: 0,
),
];

View file

@ -0,0 +1,442 @@
import 'package:flutter/material.dart';
import '../models/project_workspace.dart';
class WorkspaceHomePage extends StatefulWidget {
final Future<bool> Function(ProjectWorkspace)? onLaunchCodeServer;
const WorkspaceHomePage({
super.key,
this.onLaunchCodeServer,
});
@override
State<WorkspaceHomePage> createState() => _WorkspaceHomePageState();
}
class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
ProjectWorkspace? _selectedProject;
@override
void initState() {
super.initState();
// Default to the first active workspace
if (mockProjectWorkspaces.isNotEmpty) {
_selectedProject = mockProjectWorkspaces.first;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isLargeScreen = MediaQuery.of(context).size.width > 600;
return Scaffold(
backgroundColor: const Color(0xFF0F0F1A), // Sleek deep dark background
appBar: AppBar(
title: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.deepPurpleAccent.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.blur_on, color: Colors.deepPurpleAccent, size: 24),
),
const SizedBox(width: 12),
const Text(
'NomadCode Console',
style: TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
color: Colors.white,
),
),
],
),
backgroundColor: const Color(0xFF16162A),
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.refresh, color: Colors.white70),
onPressed: () {
// Refresh state placeholder
},
),
],
),
body: isLargeScreen ? _buildDualPaneLayout(theme) : _buildSinglePaneLayout(theme),
);
}
Widget _buildDualPaneLayout(ThemeData theme) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Left Panel: Workspaces list
Expanded(
flex: 2,
child: Container(
decoration: const BoxDecoration(
color: Color(0xFF131326),
border: Border(
right: BorderSide(color: Color(0xFF1E1E38), width: 1),
),
),
child: _buildWorkspaceList(),
),
),
// Right Panel: Selected Workspace Details
Expanded(
flex: 3,
child: _selectedProject == null
? const Center(
child: Text(
'Select a workspace to view details',
style: TextStyle(color: Colors.white38),
),
)
: _buildWorkspaceDetails(theme, _selectedProject!),
),
],
);
}
Widget _buildSinglePaneLayout(ThemeData theme) {
return Column(
children: [
Expanded(
child: _buildWorkspaceList(),
),
if (_selectedProject != null)
Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Color(0xFF16162A),
border: Border(
top: BorderSide(color: Color(0xFF1E1E38), width: 1),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
_selectedProject!.name,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
_buildLaunchButton(_selectedProject!),
],
),
],
),
),
],
);
}
Widget _buildWorkspaceList() {
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
itemCount: mockProjectWorkspaces.length,
itemBuilder: (context, index) {
final project = mockProjectWorkspaces[index];
final isSelected = _selectedProject?.id == project.id;
return Card(
color: isSelected ? const Color(0xFF21213D) : const Color(0xFF181830),
elevation: isSelected ? 4 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: isSelected ? Colors.deepPurpleAccent : const Color(0xFF26264A),
width: 1.5,
),
),
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
setState(() {
_selectedProject = project;
});
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isSelected
? Colors.deepPurpleAccent.withValues(alpha: 0.15)
: const Color(0xFF232345),
shape: BoxShape.circle,
),
child: Icon(
project.status == ProjectStatus.active
? Icons.code
: Icons.pause_circle_outline,
color: project.status == ProjectStatus.active
? Colors.cyanAccent
: Colors.amberAccent,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
project.name,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(
project.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white60,
fontSize: 13,
),
),
],
),
),
const SizedBox(width: 8),
if (project.tasksCount > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.deepPurpleAccent.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${project.tasksCount} Tasks',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
);
},
);
}
Widget _buildWorkspaceDetails(ThemeData theme, ProjectWorkspace project) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
project.name,
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: project.status == ProjectStatus.active
? Colors.cyanAccent.withValues(alpha: 0.15)
: Colors.amberAccent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
project.status.name.toUpperCase(),
style: TextStyle(
color: project.status == ProjectStatus.active
? Colors.cyanAccent
: Colors.amberAccent,
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.0,
),
),
),
],
),
),
_buildLaunchButton(project),
],
),
const SizedBox(height: 24),
const Divider(color: Color(0xFF26264A)),
const SizedBox(height: 16),
const Text(
'DESCRIPTION',
style: TextStyle(
color: Colors.white38,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1.0,
),
),
const SizedBox(height: 8),
Text(
project.description,
style: const TextStyle(
color: Colors.white70,
fontSize: 15,
height: 1.5,
),
),
const SizedBox(height: 32),
const Text(
'ACTIVE TASKS',
style: TextStyle(
color: Colors.white38,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1.0,
),
),
const SizedBox(height: 16),
if (project.tasksCount == 0)
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFF14142B),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF222244)),
),
child: const Row(
children: [
Icon(Icons.check_circle_outline, color: Colors.greenAccent),
SizedBox(width: 16),
Text(
'No active tasks. All tasks are idle.',
style: TextStyle(color: Colors.white54, fontSize: 14),
),
],
),
)
else
Column(
children: List.generate(
project.tasksCount,
(i) => Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF181835),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF2B2B52)),
),
child: Row(
children: [
const Icon(Icons.sync, color: Colors.deepPurpleAccent),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Task #${1000 + i + (project.name.hashCode % 100)}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 4),
const Text(
'Running workflow core optimizations...',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.cyanAccent.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'running',
style: TextStyle(
color: Colors.cyanAccent,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
),
],
),
);
}
Widget _buildLaunchButton(ProjectWorkspace project) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurpleAccent,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 6,
shadowColor: Colors.deepPurpleAccent.withValues(alpha: 0.4),
),
icon: const Icon(Icons.rocket_launch, size: 20),
label: const Text(
'Open Workspace',
style: TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
onPressed: () async {
if (widget.onLaunchCodeServer != null) {
final success = await widget.onLaunchCodeServer!(project);
if (success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Launching ${project.name} workspace...'),
behavior: SnackBarBehavior.floating,
),
);
}
}
},
);
}
}

View file

@ -0,0 +1,12 @@
import 'package:url_launcher/url_launcher.dart';
import '../models/project_workspace.dart';
class WorkspaceLauncher {
Future<bool> openCodeServer(ProjectWorkspace project) async {
final uri = Uri.parse(project.codeServerUrl);
if (await canLaunchUrl(uri)) {
return launchUrl(uri, mode: LaunchMode.externalApplication);
}
return false;
}
}

View file

@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}

View file

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -8,9 +8,11 @@ import Foundation
import firebase_core
import firebase_messaging
import flutter_local_notifications
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View file

@ -349,6 +349,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
vector_math:
dependency: transitive
description:
@ -391,4 +455,4 @@ packages:
version: "6.6.1"
sdks:
dart: ">=3.11.0 <4.0.0"
flutter: ">=3.22.0"
flutter: ">=3.38.0"

View file

@ -45,6 +45,7 @@ dependencies:
# Local notifications (foreground display)
flutter_local_notifications: ^18.0.1
url_launcher: ^6.3.2
dev_dependencies:
flutter_test:

View file

@ -1,18 +1,39 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nomadcode_app/main.dart';
import 'package:nomadcode_app/screens/workspace_home_page.dart';
import 'package:nomadcode_app/models/project_workspace.dart';
void main() {
testWidgets('App renders project name', (WidgetTester tester) async {
testWidgets('App renders console dashboard title', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('nomadcode-app'), findsOneWidget);
expect(find.text('NomadCode Console'), findsOneWidget);
expect(find.text('NomadCode Core'), findsNWidgets(2));
expect(find.text('IOP Bridge'), findsOneWidget);
});
testWidgets('WorkspaceHomePage triggers launcher callback on button tap', (WidgetTester tester) async {
ProjectWorkspace? launchedProject;
await tester.pumpWidget(
MaterialApp(
home: WorkspaceHomePage(
onLaunchCodeServer: (project) async {
launchedProject = project;
return true;
},
),
),
);
final buttonFinder = find.widgetWithText(ElevatedButton, 'Open Workspace');
expect(buttonFinder, findsOneWidget);
await tester.tap(buttonFinder);
await tester.pumpAndSettle();
expect(launchedProject, isNotNull);
expect(launchedProject!.id, equals('nomadcode'));
});
}

View file

@ -7,8 +7,11 @@
#include "generated_plugin_registrant.h"
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View file

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
firebase_core
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

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

13
apps/web/.gitignore vendored
View file

@ -1,13 +0,0 @@
node_modules/
dist/
.vite/
*.tsbuildinfo
.env
.env.local
.env.*.local
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
# Agent-Ops Private Rules
agent-ops/rules/private/

View file

@ -1,49 +0,0 @@
# 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.

View file

@ -1,12 +0,0 @@
<!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>

File diff suppressed because it is too large Load diff

View file

@ -1,32 +0,0 @@
{
"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"
}
}

View file

@ -1,36 +0,0 @@
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" };
}
}

View file

@ -1,88 +0,0 @@
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);
}

View file

@ -1,68 +0,0 @@
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" };
}

View file

@ -1,41 +0,0 @@
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",
};
}

View file

@ -1,26 +0,0 @@
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>
);
}

View file

@ -1,47 +0,0 @@
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

@ -1,24 +0,0 @@
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

@ -1,37 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,20 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,55 +0,0 @@
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

@ -1,63 +0,0 @@
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

@ -1,42 +0,0 @@
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

@ -1,40 +0,0 @@
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

@ -1,19 +0,0 @@
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

@ -1,38 +0,0 @@
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

@ -1,37 +0,0 @@
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>
);
}

View file

@ -1,61 +0,0 @@
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>
);
}

View file

@ -1,34 +0,0 @@
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>
);
}

View file

@ -1,51 +0,0 @@
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>
);
}

View file

@ -1,11 +0,0 @@
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>,
);

View file

@ -1,80 +0,0 @@
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

@ -1,70 +0,0 @@
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

@ -1,55 +0,0 @@
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>
);
}

View file

@ -1,85 +0,0 @@
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],
}));
},
}));

View file

@ -1,17 +0,0 @@
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

@ -1,35 +0,0 @@
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,
};
}),
}));

View file

@ -1,861 +0,0 @@
: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;
}
}

View file

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

View file

@ -1,21 +0,0 @@
{
"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": []
}

View file

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

View file

@ -4,7 +4,7 @@ The workspace is organized by runtime boundary rather than by task.
- `services/core` owns orchestration, persistence, scheduling, and external model/service adapters.
- `apps/mobile` owns the Flutter-first product UI across mobile, desktop, and optional web targets.
- `apps/web` is the previous React/Vite console scaffold and is slated for removal or isolation from the product path.
- `apps/web` was the previous React/Vite console scaffold, which has been removed as part of the Flutter-first client consolidation.
- `packages/contracts` owns schemas shared across clients and services.
- `agent-ops` owns root agent routing, domain rules, and repeatable task workflows.

View file

@ -3,3 +3,9 @@
Shared API contracts live here.
Use this package for OpenAPI specs, protobuf files, generated clients, fixtures, and compatibility notes that need to be consumed by more than one NomadCode runtime.
---
## Compatibility Notes & Candidates
- [Flutter/Core API Contract Candidates](notes/flutter-core-api-candidates.md): 이 문서는 실제 schema/generator 도입 전에 Flutter 앱과 core 간에 공유할 API/schema 후보를 기록해둔 compatibility note입니다.

View file

@ -0,0 +1,196 @@
# Flutter/Core API Contract Candidates
이 문서는 source schema가 아니라 Flutter-first 전환 중 공유 계약 후보를 모으는 compatibility note다.
실제 Go core 구현(`services/core`)과 향후 작성될 Flutter 클라이언트(`apps/mobile`) 간의 인터페이스 정합성을 보장하기 위한 임시 계약 후보이며, 단일 source-of-truth 스키마가 정의되기 전까지의 과도기적 스펙을 정의한다.
## API 안정성 레벨 정의
- **안정 (Stable)**: 인터페이스와 데이터 포맷이 고정되어 있어 즉시 연동에 사용할 수 있음.
- **후보 (Candidate)**: 연동 후보 스펙으로, 실연동 과정에서 필드가 추가되거나 변경될 수 있음.
---
## 1. 공통 및 인프라 API
### 1.1 `GET /healthz`
- **설명**: 서비스 헬스 체크.
- **인증**: 없음 (Public)
- **안정성 수준**: 안정 (Stable)
- **Response Shape** (Status 200 OK):
```json
{
"status": "ok"
}
```
### 1.2 `GET /readyz`
- **설명**: 서비스 및 데이터베이스 준비 상태 체크.
- **인증**: Basic Auth
- **안정성 수준**: 안정 (Stable)
- **Response Shape** (Status 200 OK):
```json
{
"status": "ready"
}
```
*(Status 503 Service Unavailable)*:
```json
{
"error": "database is not ready"
}
```
---
## 2. 태스크 관리 API (Tasks API)
### 2.1 `POST /api/tasks`
- **설명**: 새로운 태스크 생성.
- **인증**: Basic Auth
- **안정성 수준**: 안정 (Stable)
- **Request Shape**:
```json
{
"title": "Task Title",
"source": "source_identifier",
"payload": {},
"metadata": {},
"external": {
"provider": "plane",
"id": "external-task-id",
"url": "https://...",
"metadata": {}
}
}
```
- **Response Shape** (Status 201 Created):
```json
{
"id": "task-uuid-string",
"status": "pending",
"external_provider": "plane",
"external_id": "external-task-id"
}
```
### 2.2 `GET /api/tasks`
- **설명**: 태스크 목록 조회.
- **인증**: Basic Auth
- **안정성 수준**: 안정 (Stable)
- **Query Parameters**:
- `limit` (Optional, 기본값 20, 양의 정수)
- **Response Shape** (Status 200 OK):
```json
[
{
"id": "task-uuid-string",
"title": "Task Title",
"source": "source_identifier",
"status": "pending",
"payload": {},
"result": {},
"error": null,
"created_at": "2026-05-24T12:00:00Z",
"updated_at": "2026-05-24T12:00:00Z",
"external_provider": "plane",
"external_id": "external-task-id",
"external_url": "https://...",
"external_metadata": {},
"metadata": {}
}
]
```
### 2.3 `GET /api/tasks/{id}`
- **설명**: 단일 태스크 세부 정보 조회.
- **인증**: Basic Auth
- **안정성 수준**: 안정 (Stable)
- **Response Shape** (Status 200 OK):
```json
{
"id": "task-uuid-string",
"title": "Task Title",
"source": "source_identifier",
"status": "pending",
"payload": {},
"result": {},
"error": null,
"created_at": "2026-05-24T12:00:00Z",
"updated_at": "2026-05-24T12:00:00Z",
"external_provider": "plane",
"external_id": "external-task-id",
"external_url": "https://...",
"external_metadata": {},
"metadata": {}
}
```
### 2.4 `POST /api/tasks/{id}/enqueue`
- **설명**: 태스크를 큐에 진입시켜 실행 대기 상태로 변경.
- **인증**: Basic Auth
- **안정성 수준**: 안정 (Stable)
- **Response Shape** (Status 200 OK):
```json
{
"id": "task-uuid-string",
"status": "queued"
}
```
---
## 3. 외부 연동 API (Integrations API)
### 3.1 `POST /api/integrations/plane/tasks`
- **설명**: Plane 워크아이템 정보를 기반으로 새로운 태스크 생성.
- **인증**: Basic Auth
- **안정성 수준**: 안정 (Stable)
- **Request Shape**:
```json
{
"workspace_slug": "my-workspace",
"project_id": "project-uuid-string",
"work_item_id": "work-item-id-string",
"state_id": "state-uuid-string",
"external_url": "https://...",
"comment": "Optional comment"
}
```
- **Response Shape** (Status 201 Created):
```json
{
"id": "task-uuid-string",
"status": "pending",
"external_provider": "plane",
"external_id": "work-item-id-string"
}
```
---
## 4. Workspace / Project Metadata 계약 후보
> [!IMPORTANT]
> **Workspace/Project Metadata 상태**: 현재 Go core 백엔드에 이와 관련한 별도의 Source-of-Truth 저장소나 전용 테이블 구조가 존재하지 않으며, API 명세 또한 완전히 확정되지 않았습니다. 아래는 Flutter 앱과 코어 간 향후 연동을 대비한 **계약 후보 필드 목록**입니다.
### 4.1 Workspace Metadata 후보 스펙
- **설명**: 에이전트 작업 공간(Workspace)에 대한 설정 및 상태 메타데이터.
- **후보 필드**:
- `workspace_id`: 작업 공간 고유 식별자 (UUID)
- `workspace_slug`: 작업 공간의 URL 친화적 식별자 (예: `nomadcode-dev`)
- `name`: 작업 공간 표시 이름 (예: `NomadCode Dev Workspace`)
- `owner_id`: 소유자 고유 식별자
- `created_at`: 생성 시각 (ISO 8601)
- `updated_at`: 최종 변경 시각 (ISO 8601)
- `status`: 현재 상태 (예: `active`, `archived`, `suspended`)
- `settings`: 작업 공간에 특화된 동적 설정 JSON 오브젝트 (예: 자동 스케줄링 옵션, 알림 채널 정보 등)
### 4.2 Project Metadata 후보 스펙
- **설명**: 작업 공간 내 세부 프로젝트(Project) 정보.
- **후보 필드**:
- `project_id`: 프로젝트 고유 식별자 (UUID)
- `workspace_id`: 해당 프로젝트가 속한 Workspace 식별자
- `name`: 프로젝트 표시 이름 (예: `Flutter client consolidation`)
- `description`: 프로젝트 세부 설명
- `provider`: 연동 플랫폼 식별자 (예: `plane`, `github`)
- `external_project_id`: 연동 플랫폼 측 프로젝트 ID
- `status`: 프로젝트 상태 (예: `active`, `paused`)