기능: monorepo 구조를 변경하고 web을 제거한다
- apps/web 디렉터리 전체를 삭제하여 web 클라이언트 제거 - Flutter 모바일 앱 구조를 정리하고 모델/스크린/서비스 추가 - agent-ops 규칙 및 로드맵 업데이트 - monorepo 문서 갱신
This commit is contained in:
parent
3a6c9fb627
commit
84c8ac67ed
63 changed files with 844 additions and 3508 deletions
|
|
@ -8,7 +8,7 @@ NomadCode는 IOP를 사용하는 개발 워크플로우 셸입니다. 사용자
|
|||
|
||||
- `services/core`는 health/readiness endpoint, task API, PostgreSQL persistence, goose migration, sqlc query 생성 구조, River job, IOP OpenAI-compatible Responses client 경로, 향후 A2A agent delegation scaffolding, Plane/Mattermost adapter stub을 포함합니다.
|
||||
- `apps/mobile`은 NomadCode 제품 UI의 기준 Flutter 앱이며 모바일/데스크톱/선택적 web target, Firebase Messaging, local notifications, HTTP client 의존성을 포함합니다.
|
||||
- `apps/web`은 이전 React/Vite 운영 콘솔 scaffold입니다. 장기 제품 UI 경로에서는 제외하며, Flutter-first 전환 과정에서 제거 또는 격리 대상으로 둡니다.
|
||||
- `apps/web`은 이전 React/Vite 운영 콘솔 scaffold였으며, Flutter-first 통합에 따라 완전히 폐기 및 제거되었습니다.
|
||||
- `packages/contracts`는 향후 OpenAPI, protobuf, generated client, fixture, compatibility asset을 담을 공유 계약 공간입니다.
|
||||
- 활성 계획과 Milestone 상태는 `agent-ops/roadmap/`에서 관리합니다. README에는 현재 작업 상태를 복사하지 않고 진입 문서만 연결합니다.
|
||||
|
||||
|
|
@ -81,7 +81,7 @@ flutter run
|
|||
|------|------|
|
||||
| `services/core/` | Go backend. orchestration, persistence, scheduling, HTTP API, 외부 service adapter를 담당합니다. |
|
||||
| `apps/mobile/` | Flutter 기반 제품 UI source of truth, 모바일/데스크톱 클라이언트와 notification 연동 표면입니다. |
|
||||
| `apps/web/` | 이전 React/Vite 운영 콘솔 scaffold입니다. 장기 제품 경로에서 제거 또는 격리할 대상입니다. |
|
||||
| `apps/web/` | [Legacy/Retired] React/Vite 운영 콘솔 scaffold입니다. Flutter-first 클라이언트 통합에 따라 완전히 폐기 및 제거되었습니다. |
|
||||
| `packages/contracts/` | 서비스와 클라이언트가 공유할 API/schema 계약 공간입니다. |
|
||||
| `docs/` | 아키텍처와 운영 문서입니다. |
|
||||
| `bin/` | root test, lint, build, dev helper 명령입니다. |
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ Client Platform Consolidation
|
|||
## 범위
|
||||
|
||||
- Flutter 앱을 NomadCode 제품 UI의 기준 구현으로 정리
|
||||
- React/Vite 웹 콘솔 scaffold를 제거하거나 제품 경로 밖의 임시/아카이브 표면으로 격리
|
||||
- React/Vite 웹 콘솔 scaffold를 제거하여 제품 경로 밖으로 격리/폐기 완료
|
||||
- root dev/test/lint/build entrypoint에서 React web 의존을 제거하거나 명확히 비활성화
|
||||
- README, docs, roadmap, domain rule에서 web/app 책임 경계를 Flutter-first 기준으로 갱신
|
||||
- code-server는 React wrapper를 거치지 않고 Flutter 앱에서 직접 열거나 embed하는 표면으로 정리
|
||||
|
|
@ -29,7 +29,7 @@ Client Platform Consolidation
|
|||
## 필수 기능
|
||||
|
||||
- [x] [ui-source] Flutter 앱을 제품 UI source of truth로 선언하고 React web을 기준 구현에서 제외한다.
|
||||
- [ ] [react-retire] `apps/web` React/Vite scaffold의 제거, 격리, 또는 폐기 경로를 정리한다.
|
||||
- [x] [react-retire] `apps/web` React/Vite scaffold의 제거, 격리, 또는 폐기 경로를 정리한다.
|
||||
- [x] [entrypoints] root `bin/*` 개발/검증 entrypoint가 Flutter-first 클라이언트 구조를 반영하도록 한다.
|
||||
- [x] [docs-rules] README, docs, roadmap, domain rule의 web/app 설명을 Flutter-first 기준으로 맞춘다.
|
||||
- [ ] [codeserver-direct] code-server, workspace, diff/terminal/editor 표면은 React wrapper 없이 Flutter 앱에서 직접 연결하는 기준을 정한다.
|
||||
|
|
@ -37,9 +37,9 @@ Client Platform Consolidation
|
|||
|
||||
## 완료 기준
|
||||
|
||||
- [ ] 로드맵과 프로젝트 문서에서 React/Vite 웹 콘솔이 장기 제품 UI 기준 구현으로 설명되지 않는다.
|
||||
- [x] 로드맵과 프로젝트 문서에서 React/Vite 웹 콘솔이 장기 제품 UI 기준 구현으로 설명되지 않는다.
|
||||
- [ ] Flutter 앱이 모바일/데스크톱/선택적 web target의 기준 클라이언트로 설명된다.
|
||||
- [ ] `apps/web`의 처리 방침이 삭제, 격리, 보류 중 하나로 코드와 문서에서 일관된다.
|
||||
- [x] `apps/web`의 처리 방침이 삭제, 격리, 보류 중 하나로 코드와 문서에서 일관된다.
|
||||
- [ ] root `bin/test`, `bin/lint`, `bin/build`, `bin/dev`가 React web 제거 이후의 검증 흐름과 충돌하지 않는다.
|
||||
- [ ] code-server를 React로 감싸고 다시 Flutter로 감싸는 구조를 만들지 않는다는 기준이 문서화되어 있다.
|
||||
- [ ] 후속 Project Workspace Management UX가 Flutter-first 클라이언트 구조 위에서 진행될 수 있다.
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ Project Workspace Management UX
|
|||
## 작업 컨텍스트
|
||||
|
||||
- 관련 경로: `apps/mobile/`, `packages/contracts/`, `services/core/`
|
||||
- 전환 참고 경로: `apps/web/`
|
||||
- 전환 참고 경로: [Legacy/Retired] `apps/web/` (제거됨)
|
||||
- 선행 작업: Flutter-first Client Consolidation, External Integration
|
||||
- 후속 작업: 확인 필요
|
||||
- 확인 필요: `구현 잠금`의 결정 필요 항목 참고.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ NomadCode 런타임 사이의 공유 계약 도메인이다. API schema, event s
|
|||
|
||||
- `services/core/internal/http/` — API implementation은 core 도메인이다.
|
||||
- `apps/mobile/lib/` — mobile-specific client/service code는 mobile 도메인이다.
|
||||
- `apps/web/src/api/` — legacy web-specific API client wrapper는 web 도메인이다.
|
||||
- `apps/web/` — [Legacy/Retired] 이전 React/Vite web scaffold 경로는 완전히 제거되어 제외 대상입니다.
|
||||
|
||||
## 주요 구성 요소
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ NomadCode 런타임 사이의 공유 계약 도메인이다. API schema, event s
|
|||
## 유지할 패턴
|
||||
|
||||
- 계약 변경은 additive를 우선한다.
|
||||
- breaking change는 core와 Flutter 앱 반영을 같은 branch에서 조율한다. legacy web은 제거 전 필요한 경우에만 함께 조율한다.
|
||||
- breaking change는 core와 Flutter 앱 반영을 같은 branch에서 조율한다.
|
||||
- schema가 추가되면 sample payload나 compatibility note를 함께 둔다.
|
||||
- generated client가 생기면 생성 명령과 source schema를 문서화한다.
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ NomadCode 런타임 사이의 공유 계약 도메인이다. API schema, event s
|
|||
|
||||
- **core**: contracts는 API shape의 source-of-truth이고 core는 이를 구현한다.
|
||||
- **mobile**: mobile은 contracts를 소비하되 platform behavior는 mobile 도메인에서 다룬다.
|
||||
- **web**: legacy web은 제거 전 필요한 경우에만 contracts를 소비하거나 generated client를 감싼다.
|
||||
- **web**: [Legacy/Retired] React web 도메인은 완전히 제거되었습니다.
|
||||
- **workspace-ops**: contracts 생성/검증 명령이 root script가 되면 workspace-ops와 함께 갱신한다.
|
||||
|
||||
## 금지 사항
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ NomadCode의 백엔드 오케스트레이션 도메인이다. workflow, scheduli
|
|||
## 제외 경로
|
||||
|
||||
- `apps/mobile/` — Flutter-first 제품 클라이언트이며 core 내부 패키지를 직접 참조하지 않는다.
|
||||
- `apps/web/` — 이전 React/Vite scaffold이며 core 구현을 직접 소유하지 않는다.
|
||||
- `apps/web/` — [Legacy/Retired] React/Vite scaffold 경로는 완전히 제거되어 제외 대상입니다.
|
||||
- `packages/contracts/` — 공유 API/schema 계약의 소유 영역이다.
|
||||
|
||||
## 주요 구성 요소
|
||||
|
|
@ -71,7 +71,7 @@ NomadCode의 백엔드 오케스트레이션 도메인이다. workflow, scheduli
|
|||
- 외부 서비스 호출은 `internal/adapters/<provider>` 아래에 격리한다.
|
||||
- 환경 변수 기본값과 alias는 `internal/config`에서 관리하고 README의 실행 예시와 어긋나지 않게 유지한다.
|
||||
- scheduler 변경은 가능한 한 job 단위 테스트를 추가한다.
|
||||
- HTTP API 변경은 Flutter 앱 영향과 contracts 반영 필요성을 함께 판단한다. legacy web 영향은 제거 전 필요한 경우에만 확인한다.
|
||||
- HTTP API 변경은 Flutter 앱 영향과 contracts 반영 필요성을 함께 판단한다.
|
||||
- Plane 관련 작업에서는 board state와 agent 내부 실행 상태를 같은 workflow state로 섞지 않는다. `testing`은 agent가 자체 검증과 리뷰 루프를 끝낸 뒤 사용자 테스트를 기다리는 상태로 사용한다.
|
||||
- Plane/Jira work item 본문(description)은 작업 요구사항과 맥락의 원본으로 취급하고, agent 실행 상태를 매번 갱신하는 저장소로 사용하지 않는다.
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ NomadCode의 백엔드 오케스트레이션 도메인이다. workflow, scheduli
|
|||
|
||||
- **contracts**: API shape가 클라이언트와 공유되면 contracts에 먼저 표현하거나 문서화한다.
|
||||
- **mobile**: mobile은 public API와 notification/auth 계약만 의존한다.
|
||||
- **web**: legacy web은 제거 전 필요한 경우에만 HTTP/API client를 통해 core와 통신한다. core 내부 Go package를 전제하지 않는다.
|
||||
- **web**: [Legacy/Retired] React web 도메인은 완전히 제거되었습니다.
|
||||
- **workspace-ops**: root helper 변경은 workspace-ops 소유이고, core-local bin 변경만 core 소유다.
|
||||
|
||||
## 금지 사항
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ NomadCode의 Flutter-first 제품 UI 도메인이다. 모바일/데스크톱/선
|
|||
## 제외 경로
|
||||
|
||||
- `services/core/` — backend orchestration/API implementation.
|
||||
- `apps/web/` — 이전 React/Vite browser console scaffold.
|
||||
- `apps/web/` — [Legacy/Retired] React/Vite scaffold 경로는 완전히 제거되어 제외 대상입니다.
|
||||
- `packages/contracts/` — shared API/schema contracts.
|
||||
|
||||
## 주요 구성 요소
|
||||
|
|
@ -50,7 +50,7 @@ NomadCode의 Flutter-first 제품 UI 도메인이다. 모바일/데스크톱/선
|
|||
|
||||
- **core**: mobile은 core API/notification 계약에 의존하되 core 내부 구현을 알지 않는다.
|
||||
- **contracts**: mobile이 사용하는 API/event shape는 contracts 후보로 올린다.
|
||||
- **web**: React/Vite web은 legacy scaffold이며 Flutter 제품 UI를 복제하지 않는다.
|
||||
- **web**: [Legacy/Retired] React web 도메인은 완전히 제거되었습니다.
|
||||
- **workspace-ops**: root scripts와 agent rules는 mobile 내부에 복제하지 않는다.
|
||||
|
||||
## 금지 사항
|
||||
|
|
|
|||
|
|
@ -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 변경을 한 커밋에 섞지 않는다.
|
||||
|
|
@ -27,11 +27,11 @@ NomadCode 원레포 운영 도메인이다. 루트 helper, 문서, ignore 정책
|
|||
|
||||
- `agent-ops/rules/common/` — agentic-framework에서 동기화되는 공통 rules.
|
||||
- `agent-ops/skills/common/` — agentic-framework에서 동기화되는 공통 skills.
|
||||
- `services/core/`, `apps/web/`, `apps/mobile/`, `packages/contracts/` — 각 제품 도메인 소유 코드.
|
||||
- `services/core/`, `apps/web/` (Legacy/Retired), `apps/mobile/`, `packages/contracts/` — 각 제품 도메인 소유 코드.
|
||||
|
||||
## 주요 구성 요소
|
||||
|
||||
- `bin/test` — core/web/mobile 검증 entrypoint.
|
||||
- `bin/test` — core/mobile 검증 entrypoint.
|
||||
- `bin/lint` — vet/check/analyze entrypoint.
|
||||
- `bin/build` — build entrypoint.
|
||||
- `bin/dev` — development entrypoint 안내.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ NomadCode는 AI 병렬 작업 운용을 위한 원레포다. 백엔드 오케스
|
|||
|
||||
- `services/core/` — Go 기반 백엔드, workflow, scheduler, persistence, 외부 adapter.
|
||||
- `apps/mobile/` — Flutter 기반 제품 UI source of truth, 모바일/데스크톱 클라이언트와 알림 연동.
|
||||
- `apps/web/` — 이전 React/Vite 운영 콘솔 scaffold. Flutter-first 전환에서 제거 또는 격리 대상.
|
||||
- `packages/contracts/` — 서비스와 클라이언트가 공유하는 API/schema 계약.
|
||||
- `bin/` — 원레포 공통 test/build/lint/dev entrypoint.
|
||||
- `docs/` — 아키텍처와 운영 문서.
|
||||
|
|
@ -30,7 +29,6 @@ NomadCode는 AI 병렬 작업 운용을 위한 원레포다. 백엔드 오케스
|
|||
- 원레포 루트에서 가능한 작업은 `bin/test`, `bin/lint`, `bin/build`, `bin/dev`를 우선 사용한다.
|
||||
- cross-boundary 변경은 계약, 서비스, 클라이언트 반영을 같은 브랜치에서 닫는다.
|
||||
- `services/core` 변경은 `go test ./...` 또는 `bin/test`로 확인한다.
|
||||
- `apps/web`는 장기 제품 UI 경로에서 제외한다. 제거 전 legacy scaffold를 직접 변경할 때만 `npm run check` 또는 `npm run verify`로 확인한다.
|
||||
- `apps/mobile` 변경은 `flutter test`와 필요 시 `flutter analyze --no-fatal-infos`로 확인한다.
|
||||
- `packages/contracts`가 실제 계약을 갖기 전에는 API 형태를 서비스/클라이언트에 중복 고정하지 말고 계약 후보를 문서화한다.
|
||||
- 하위 앱/서비스에 별도 `agent-ops`나 AI entry 파일을 만들지 않는다. 루트 `agent-ops`와 루트 entry 파일만 사용한다.
|
||||
|
|
@ -41,7 +39,6 @@ NomadCode는 AI 병렬 작업 운용을 위한 원레포다. 백엔드 오케스
|
|||
| 경로 패턴 | 도메인 | rules.md |
|
||||
|----------|--------|----------|
|
||||
| `services/core/**` | core | `agent-ops/rules/project/domain/core/rules.md` |
|
||||
| `apps/web/**` | web | `agent-ops/rules/project/domain/web/rules.md` |
|
||||
| `apps/mobile/**` | mobile | `agent-ops/rules/project/domain/mobile/rules.md` |
|
||||
| `packages/contracts/**` | contracts | `agent-ops/rules/project/domain/contracts/rules.md` |
|
||||
| `bin/**`, `docs/**`, `README.md`, `.gitignore`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `opencode.json`, `agent-ops/.version`, `agent-ops/bin/**`, `agent-ops/roadmap/**`, `agent-ops/rules/project/**` | workspace-ops | `agent-ops/rules/project/domain/workspace-ops/rules.md` |
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ import 'package:flutter/material.dart';
|
|||
import 'services/mattermost_auth_service.dart';
|
||||
import 'services/push_notification_background.dart';
|
||||
import 'services/push_notification_service.dart';
|
||||
import 'services/workspace_launcher.dart';
|
||||
import 'screens/workspace_home_page.dart';
|
||||
|
||||
final _navigatorKey = GlobalKey<NavigatorState>();
|
||||
final _pushService = PushNotificationService();
|
||||
final _launcher = WorkspaceLauncher();
|
||||
|
||||
Future<void> _applyFullscreenMode() async {
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
|
|
@ -50,7 +53,11 @@ class MyApp extends StatelessWidget {
|
|||
title: 'nomadcode-app',
|
||||
navigatorKey: _navigatorKey,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.deepPurple,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const MyHomePage(title: 'nomadcode-app'),
|
||||
builder: (context, child) {
|
||||
|
|
@ -111,10 +118,10 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: const Center(
|
||||
child: Text('nomadcode-app'),
|
||||
),
|
||||
return WorkspaceHomePage(
|
||||
onLaunchCodeServer: (project) async {
|
||||
return _launcher.openCodeServer(project);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
apps/mobile/lib/models/project_workspace.dart
Normal file
50
apps/mobile/lib/models/project_workspace.dart
Normal 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,
|
||||
),
|
||||
];
|
||||
442
apps/mobile/lib/screens/workspace_home_page.dart
Normal file
442
apps/mobile/lib/screens/workspace_home_page.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
12
apps/mobile/lib/services/workspace_launcher.dart
Normal file
12
apps/mobile/lib/services/workspace_launcher.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,10 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ import Foundation
|
|||
import firebase_core
|
||||
import firebase_messaging
|
||||
import flutter_local_notifications
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -349,6 +349,70 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.30"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -391,4 +455,4 @@ packages:
|
|||
version: "6.6.1"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: ">=3.22.0"
|
||||
flutter: ">=3.38.0"
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ dependencies:
|
|||
|
||||
# Local notifications (foreground display)
|
||||
flutter_local_notifications: ^18.0.1
|
||||
url_launcher: ^6.3.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -1,18 +1,39 @@
|
|||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:nomadcode_app/main.dart';
|
||||
import 'package:nomadcode_app/screens/workspace_home_page.dart';
|
||||
import 'package:nomadcode_app/models/project_workspace.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App renders project name', (WidgetTester tester) async {
|
||||
testWidgets('App renders console dashboard title', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
expect(find.text('nomadcode-app'), findsOneWidget);
|
||||
expect(find.text('NomadCode Console'), findsOneWidget);
|
||||
expect(find.text('NomadCode Core'), findsNWidgets(2));
|
||||
expect(find.text('IOP Bridge'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('WorkspaceHomePage triggers launcher callback on button tap', (WidgetTester tester) async {
|
||||
ProjectWorkspace? launchedProject;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkspaceHomePage(
|
||||
onLaunchCodeServer: (project) async {
|
||||
launchedProject = project;
|
||||
return true;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final buttonFinder = find.widgetWithText(ElevatedButton, 'Open Workspace');
|
||||
expect(buttonFinder, findsOneWidget);
|
||||
|
||||
await tester.tap(buttonFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(launchedProject, isNotNull);
|
||||
expect(launchedProject!.id, equals('nomadcode'));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@
|
|||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
firebase_core
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
VITE_NOMADCODE_API_BASE_URL=http://localhost:8080
|
||||
13
apps/web/.gitignore
vendored
13
apps/web/.gitignore
vendored
|
|
@ -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/
|
||||
|
|
@ -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.
|
||||
|
|
@ -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>
|
||||
1174
apps/web/package-lock.json
generated
1174
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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" };
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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" };
|
||||
}
|
||||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>,
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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],
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
|
@ -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 })),
|
||||
}));
|
||||
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
1
apps/web/src/vite-env.d.ts
vendored
1
apps/web/src/vite-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
|
|
@ -4,7 +4,7 @@ The workspace is organized by runtime boundary rather than by task.
|
|||
|
||||
- `services/core` owns orchestration, persistence, scheduling, and external model/service adapters.
|
||||
- `apps/mobile` owns the Flutter-first product UI across mobile, desktop, and optional web targets.
|
||||
- `apps/web` is the previous React/Vite console scaffold and is slated for removal or isolation from the product path.
|
||||
- `apps/web` was the previous React/Vite console scaffold, which has been removed as part of the Flutter-first client consolidation.
|
||||
- `packages/contracts` owns schemas shared across clients and services.
|
||||
- `agent-ops` owns root agent routing, domain rules, and repeatable task workflows.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,3 +3,9 @@
|
|||
Shared API contracts live here.
|
||||
|
||||
Use this package for OpenAPI specs, protobuf files, generated clients, fixtures, and compatibility notes that need to be consumed by more than one NomadCode runtime.
|
||||
|
||||
---
|
||||
|
||||
## Compatibility Notes & Candidates
|
||||
- [Flutter/Core API Contract Candidates](notes/flutter-core-api-candidates.md): 이 문서는 실제 schema/generator 도입 전에 Flutter 앱과 core 간에 공유할 API/schema 후보를 기록해둔 compatibility note입니다.
|
||||
|
||||
|
|
|
|||
196
packages/contracts/notes/flutter-core-api-candidates.md
Normal file
196
packages/contracts/notes/flutter-core-api-candidates.md
Normal 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`)
|
||||
Loading…
Reference in a new issue