기능: 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을 포함합니다. - `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/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을 담을 공유 계약 공간입니다. - `packages/contracts`는 향후 OpenAPI, protobuf, generated client, fixture, compatibility asset을 담을 공유 계약 공간입니다.
- 활성 계획과 Milestone 상태는 `agent-ops/roadmap/`에서 관리합니다. README에는 현재 작업 상태를 복사하지 않고 진입 문서만 연결합니다. - 활성 계획과 Milestone 상태는 `agent-ops/roadmap/`에서 관리합니다. README에는 현재 작업 상태를 복사하지 않고 진입 문서만 연결합니다.
@ -81,7 +81,7 @@ flutter run
|------|------| |------|------|
| `services/core/` | Go backend. orchestration, persistence, scheduling, HTTP API, 외부 service adapter를 담당합니다. | | `services/core/` | Go backend. orchestration, persistence, scheduling, HTTP API, 외부 service adapter를 담당합니다. |
| `apps/mobile/` | Flutter 기반 제품 UI source of truth, 모바일/데스크톱 클라이언트와 notification 연동 표면입니다. | | `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 계약 공간입니다. | | `packages/contracts/` | 서비스와 클라이언트가 공유할 API/schema 계약 공간입니다. |
| `docs/` | 아키텍처와 운영 문서입니다. | | `docs/` | 아키텍처와 운영 문서입니다. |
| `bin/` | root test, lint, build, dev helper 명령입니다. | | `bin/` | root test, lint, build, dev helper 명령입니다. |

View file

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

View file

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

View file

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

View file

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

View file

@ -13,7 +13,6 @@ NomadCode는 AI 병렬 작업 운용을 위한 원레포다. 백엔드 오케스
- `services/core/` — Go 기반 백엔드, workflow, scheduler, persistence, 외부 adapter. - `services/core/` — Go 기반 백엔드, workflow, scheduler, persistence, 외부 adapter.
- `apps/mobile/` — Flutter 기반 제품 UI source of truth, 모바일/데스크톱 클라이언트와 알림 연동. - `apps/mobile/` — Flutter 기반 제품 UI source of truth, 모바일/데스크톱 클라이언트와 알림 연동.
- `apps/web/` — 이전 React/Vite 운영 콘솔 scaffold. Flutter-first 전환에서 제거 또는 격리 대상.
- `packages/contracts/` — 서비스와 클라이언트가 공유하는 API/schema 계약. - `packages/contracts/` — 서비스와 클라이언트가 공유하는 API/schema 계약.
- `bin/` — 원레포 공통 test/build/lint/dev entrypoint. - `bin/` — 원레포 공통 test/build/lint/dev entrypoint.
- `docs/` — 아키텍처와 운영 문서. - `docs/` — 아키텍처와 운영 문서.
@ -30,7 +29,6 @@ NomadCode는 AI 병렬 작업 운용을 위한 원레포다. 백엔드 오케스
- 원레포 루트에서 가능한 작업은 `bin/test`, `bin/lint`, `bin/build`, `bin/dev`를 우선 사용한다. - 원레포 루트에서 가능한 작업은 `bin/test`, `bin/lint`, `bin/build`, `bin/dev`를 우선 사용한다.
- cross-boundary 변경은 계약, 서비스, 클라이언트 반영을 같은 브랜치에서 닫는다. - cross-boundary 변경은 계약, 서비스, 클라이언트 반영을 같은 브랜치에서 닫는다.
- `services/core` 변경은 `go test ./...` 또는 `bin/test`로 확인한다. - `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`로 확인한다. - `apps/mobile` 변경은 `flutter test`와 필요 시 `flutter analyze --no-fatal-infos`로 확인한다.
- `packages/contracts`가 실제 계약을 갖기 전에는 API 형태를 서비스/클라이언트에 중복 고정하지 말고 계약 후보를 문서화한다. - `packages/contracts`가 실제 계약을 갖기 전에는 API 형태를 서비스/클라이언트에 중복 고정하지 말고 계약 후보를 문서화한다.
- 하위 앱/서비스에 별도 `agent-ops`나 AI entry 파일을 만들지 않는다. 루트 `agent-ops`와 루트 entry 파일만 사용한다. - 하위 앱/서비스에 별도 `agent-ops`나 AI entry 파일을 만들지 않는다. 루트 `agent-ops`와 루트 entry 파일만 사용한다.
@ -41,7 +39,6 @@ NomadCode는 AI 병렬 작업 운용을 위한 원레포다. 백엔드 오케스
| 경로 패턴 | 도메인 | rules.md | | 경로 패턴 | 도메인 | rules.md |
|----------|--------|----------| |----------|--------|----------|
| `services/core/**` | core | `agent-ops/rules/project/domain/core/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` | | `apps/mobile/**` | mobile | `agent-ops/rules/project/domain/mobile/rules.md` |
| `packages/contracts/**` | contracts | `agent-ops/rules/project/domain/contracts/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` | | `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/mattermost_auth_service.dart';
import 'services/push_notification_background.dart'; import 'services/push_notification_background.dart';
import 'services/push_notification_service.dart'; import 'services/push_notification_service.dart';
import 'services/workspace_launcher.dart';
import 'screens/workspace_home_page.dart';
final _navigatorKey = GlobalKey<NavigatorState>(); final _navigatorKey = GlobalKey<NavigatorState>();
final _pushService = PushNotificationService(); final _pushService = PushNotificationService();
final _launcher = WorkspaceLauncher();
Future<void> _applyFullscreenMode() async { Future<void> _applyFullscreenMode() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
@ -50,7 +53,11 @@ class MyApp extends StatelessWidget {
title: 'nomadcode-app', title: 'nomadcode-app',
navigatorKey: _navigatorKey, navigatorKey: _navigatorKey,
theme: ThemeData( 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'), home: const MyHomePage(title: 'nomadcode-app'),
builder: (context, child) { builder: (context, child) {
@ -111,10 +118,10 @@ class _MyHomePageState extends State<MyHomePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return WorkspaceHomePage(
body: const Center( onLaunchCodeServer: (project) async {
child: Text('nomadcode-app'), 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 "generated_plugin_registrant.h"
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { 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 list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -8,9 +8,11 @@ import Foundation
import firebase_core import firebase_core
import firebase_messaging import firebase_messaging
import flutter_local_notifications import flutter_local_notifications
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) 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" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -391,4 +455,4 @@ packages:
version: "6.6.1" version: "6.6.1"
sdks: sdks:
dart: ">=3.11.0 <4.0.0" 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) # Local notifications (foreground display)
flutter_local_notifications: ^18.0.1 flutter_local_notifications: ^18.0.1
url_launcher: ^6.3.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -1,18 +1,39 @@
// This is a basic Flutter widget test. import 'package:flutter/material.dart';
//
// 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_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:nomadcode_app/main.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() { void main() {
testWidgets('App renders project name', (WidgetTester tester) async { testWidgets('App renders console dashboard title', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp()); 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 "generated_plugin_registrant.h"
#include <firebase_core/firebase_core_plugin_c_api.h> #include <firebase_core/firebase_core_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
FirebaseCorePluginCApiRegisterWithRegistrar( FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }

View file

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
firebase_core firebase_core
url_launcher_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST 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. - `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/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. - `packages/contracts` owns schemas shared across clients and services.
- `agent-ops` owns root agent routing, domain rules, and repeatable task workflows. - `agent-ops` owns root agent routing, domain rules, and repeatable task workflows.

View file

@ -3,3 +3,9 @@
Shared API contracts live here. 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. 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`)