Merge remote-tracking branch 'origin/dev' into NevilleQingNY/merge-dev-to-main
# Conflicts: # apps/web/features/issues/components/issue-detail.tsx # apps/web/features/issues/components/issues-header.tsx # apps/web/features/issues/components/pickers/assignee-picker.tsx
This commit is contained in:
commit
815f16ddd6
12 changed files with 399 additions and 45 deletions
278
AGENTS.md
278
AGENTS.md
|
|
@ -1,16 +1,274 @@
|
||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
This file provides guidance to AI agents when working with code in this repository.
|
||||||
`apps/web/` contains the Next.js 16 frontend: routes live in `app/`, reusable UI in `components/`, feature code in `features/`, test utilities in `test/`, and static assets in `public/`. `server/` contains the Go backend: entry points are in `cmd/{server,multica,migrate}`, application logic lives in `internal/`, migrations are in `migrations/`, and SQL lives under `pkg/db/queries/` with generated sqlc output in `pkg/db/generated/`. `e2e/` holds Playwright coverage. `scripts/` and the root `Makefile` drive local setup and verification.
|
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Project Context
|
||||||
Use `make setup` for first-time setup: it installs dependencies, ensures PostgreSQL is running, and applies migrations. Use `make start` to run backend and frontend together with `.env` or `.env.worktree`. For single-surface work, use `pnpm dev:web` for the frontend and `make dev` for the Go server. Run `pnpm test` for Vitest, `make test` for Go tests, and `make check` for the full pipeline: typecheck, frontend unit tests, Go tests, then Playwright. After changing SQL in `server/pkg/db/queries/*.sql`, run `make sqlc`.
|
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
|
||||||
TypeScript in `apps/web` uses 2-space indentation, double quotes, and semicolons. Prefer PascalCase for React components, camelCase for hooks and helpers, and colocated test files such as `page.test.tsx`. Go code should stay `gofmt`-clean and use domain-oriented filenames like `issue.go` or `cmd_issue.go`. Do not hand-edit generated code in `server/pkg/db/generated/`.
|
|
||||||
|
|
||||||
## Testing Guidelines
|
- Agents can be assigned issues, create issues, comment, and change status
|
||||||
Frontend unit tests use Vitest with Testing Library and shared setup from `apps/web/test/`. End-to-end tests live in `e2e/*.spec.ts`; `make check` will start missing services automatically, while direct Playwright runs expect the app to already be running. Backend tests use Go’s standard `*_test.go` pattern. Add or update tests whenever you change handlers, CLI commands, daemon behavior, or SQL-backed flows.
|
- Supports local (daemon) and cloud agent runtimes
|
||||||
|
- Built for 2-10 person AI-native teams
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
## Architecture
|
||||||
Recent history follows conventional commits with scopes, for example `feat(web): ...`, `fix(cli): ...`, `refactor(daemon): ...`, `test(cli): ...`, and `docs: ...`. Keep PRs focused and include a short description, linked issue or PR number when relevant, screenshots for UI work, and notes for migrations, env changes, or CLI surface changes. Before opening a PR, run `make check` or the relevant frontend/backend subset.
|
|
||||||
|
**Go backend + standalone Next.js frontend.**
|
||||||
|
|
||||||
|
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
|
||||||
|
- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies
|
||||||
|
- `e2e/` — Playwright end-to-end tests
|
||||||
|
- `scripts/` and root `Makefile` — local setup and verification
|
||||||
|
|
||||||
|
### Web App Structure (`apps/web/`)
|
||||||
|
|
||||||
|
The frontend uses a **feature-based architecture** with four layers:
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web/
|
||||||
|
├── app/ # Routing layer (thin shells — import from features/)
|
||||||
|
├── features/ # Business logic, organized by domain
|
||||||
|
├── shared/ # Cross-feature utilities (api client, types, logger)
|
||||||
|
├── test/ # Shared test utilities and setup
|
||||||
|
├── public/ # Static assets
|
||||||
|
```
|
||||||
|
|
||||||
|
**`app/`** — Next.js App Router pages. Route files should be thin: import and re-export from `features/`. Layout components and route-specific glue (redirects, auth guards) live here. Shared layout components (e.g. `app-sidebar`) stay in `app/(dashboard)/_components/`.
|
||||||
|
|
||||||
|
**`features/`** — Domain modules, each with its own components, hooks, stores, and config:
|
||||||
|
|
||||||
|
| Feature | Purpose | Exports |
|
||||||
|
|---|---|---|
|
||||||
|
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
|
||||||
|
| `features/workspace/` | Workspace, members, agents | `useWorkspaceStore`, `useActorName` |
|
||||||
|
| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config |
|
||||||
|
| `features/inbox/` | Inbox notification state | `useInboxStore` |
|
||||||
|
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
|
||||||
|
| `features/modals/` | Modal registry and state | Modal store and components |
|
||||||
|
| `features/skills/` | Skill management | Skill components |
|
||||||
|
|
||||||
|
**`shared/`** — Code used across multiple features:
|
||||||
|
- `shared/api/` — `ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton.
|
||||||
|
- `shared/types/` — Domain types (Issue, Agent, Workspace, etc.) and WebSocket event types.
|
||||||
|
- `shared/logger.ts` — Logger utility.
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
- **Zustand** for global client state — one store per feature domain (`features/auth/store.ts`, `features/workspace/store.ts`, `features/issues/store.ts`, `features/inbox/store.ts`).
|
||||||
|
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
|
||||||
|
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
|
||||||
|
- Do not use React Context for data that can be a zustand store.
|
||||||
|
|
||||||
|
**Store conventions:**
|
||||||
|
- One store per feature domain. Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`.
|
||||||
|
- Stores must not call `useRouter` or any React hooks — keep navigation in components.
|
||||||
|
- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks).
|
||||||
|
- Dependency direction: `workspace` → `auth`, `realtime` → `auth`, `issues` → `workspace`. Never reverse.
|
||||||
|
|
||||||
|
### Import Aliases
|
||||||
|
|
||||||
|
Use `@/` alias (maps to `apps/web/`):
|
||||||
|
```typescript
|
||||||
|
import { api } from "@/shared/api";
|
||||||
|
import type { Issue } from "@/shared/types";
|
||||||
|
import { useAuthStore } from "@/features/auth";
|
||||||
|
import { useWorkspaceStore } from "@/features/workspace";
|
||||||
|
import { useIssueStore } from "@/features/issues";
|
||||||
|
import { useInboxStore } from "@/features/inbox";
|
||||||
|
import { useWSEvent } from "@/features/realtime";
|
||||||
|
import { StatusIcon } from "@/features/issues/components";
|
||||||
|
```
|
||||||
|
|
||||||
|
Within a feature, use relative imports. Between features or to shared, use `@/`.
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
|
||||||
|
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Structure (`server/`)
|
||||||
|
|
||||||
|
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate`
|
||||||
|
- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon, etc.). Each handler holds `Queries`, `DB`, `Hub`, and `TaskService`.
|
||||||
|
- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients. Server broadcasts events; inbound WS message routing is still TODO.
|
||||||
|
- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256). Middleware sets `X-User-ID` and `X-User-Email` headers. Login creates user on-the-fly if not found.
|
||||||
|
- **Task lifecycle** (`internal/service/task.go`): Orchestrates agent work — enqueue → claim → start → complete/fail. Syncs issue status automatically and broadcasts WS events at each transition.
|
||||||
|
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels.
|
||||||
|
- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider.
|
||||||
|
- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting.
|
||||||
|
- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services.
|
||||||
|
- **Logging** (`internal/logger/`): Structured logging via slog. `LOG_LEVEL` env var controls level (debug, info, warn, error).
|
||||||
|
- **Database**: PostgreSQL with pgvector extension (`pgvector/pgvector:pg17`). sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`.
|
||||||
|
- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model).
|
||||||
|
|
||||||
|
### Multi-tenancy
|
||||||
|
|
||||||
|
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
|
||||||
|
|
||||||
|
### Agent Assignees
|
||||||
|
|
||||||
|
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One-click setup & run
|
||||||
|
make setup # First-time: ensure shared DB, create app DB, migrate
|
||||||
|
make start # Start backend + frontend together
|
||||||
|
make stop # Stop app processes for the current checkout
|
||||||
|
make db-down # Stop the shared PostgreSQL container
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
pnpm install
|
||||||
|
pnpm dev:web # Next.js dev server (port 3000)
|
||||||
|
pnpm build # Build frontend
|
||||||
|
pnpm typecheck # TypeScript check
|
||||||
|
pnpm lint # ESLint via Next.js
|
||||||
|
pnpm test # TS tests (Vitest)
|
||||||
|
|
||||||
|
# Backend (Go)
|
||||||
|
make dev # Run Go server (port 8080)
|
||||||
|
make daemon # Run local daemon
|
||||||
|
make build # Build server + CLI binaries to server/bin/
|
||||||
|
make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config")
|
||||||
|
make test # Go tests
|
||||||
|
make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/
|
||||||
|
make migrate-up # Run database migrations
|
||||||
|
make migrate-down # Rollback migrations
|
||||||
|
|
||||||
|
# Run a single Go test
|
||||||
|
cd server && go test ./internal/handler/ -run TestName
|
||||||
|
|
||||||
|
# Run a single TS test
|
||||||
|
pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts
|
||||||
|
|
||||||
|
# Run a single E2E test (requires backend + frontend running)
|
||||||
|
pnpm exec playwright test e2e/tests/specific-test.spec.ts
|
||||||
|
|
||||||
|
# Infrastructure
|
||||||
|
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
|
||||||
|
make db-down # Stop shared PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI Requirements
|
||||||
|
|
||||||
|
CI runs on Node 22 and Go 1.24 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
|
||||||
|
|
||||||
|
### Worktree Support
|
||||||
|
|
||||||
|
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make worktree-env # Generate .env.worktree with unique DB/ports
|
||||||
|
make setup-worktree # Setup using .env.worktree
|
||||||
|
make start-worktree # Start using .env.worktree
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coding Rules
|
||||||
|
|
||||||
|
- TypeScript strict mode is enabled; keep types explicit.
|
||||||
|
- TypeScript in `apps/web` uses 2-space indentation, double quotes, and semicolons.
|
||||||
|
- Prefer PascalCase for React components, camelCase for hooks and helpers, and colocated test files such as `page.test.tsx`.
|
||||||
|
- Go code follows standard Go conventions (gofmt, go vet). Use domain-oriented filenames like `issue.go` or `cmd_issue.go`.
|
||||||
|
- Do not hand-edit generated code in `server/pkg/db/generated/`.
|
||||||
|
- Keep comments in code **English only**.
|
||||||
|
- Prefer existing patterns/components over introducing parallel abstractions.
|
||||||
|
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
|
||||||
|
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
|
||||||
|
- Treat compatibility code as a maintenance cost, not a default safety mechanism. Avoid "just in case" branches that make the codebase harder to reason about.
|
||||||
|
- Avoid broad refactors unless required by the task.
|
||||||
|
|
||||||
|
## UI/UX Rules
|
||||||
|
|
||||||
|
- Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`.
|
||||||
|
- **Feature-specific components** → `features/<domain>/components/` — issue icons, pickers, and other domain-bound UI live inside their feature module.
|
||||||
|
- Use shadcn design tokens for styling (e.g. `bg-primary`, `text-muted-foreground`, `text-destructive`). Avoid hardcoded color values (e.g. `text-red-500`, `bg-gray-100`).
|
||||||
|
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Prefer zustand stores for shared state over React Context.
|
||||||
|
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
|
||||||
|
- When unsure about interaction or state design, ask — the user will provide direction.
|
||||||
|
|
||||||
|
## Testing Rules
|
||||||
|
|
||||||
|
- **TypeScript**: Vitest with Testing Library. Shared test setup lives in `apps/web/test/`. Mock external/third-party dependencies only.
|
||||||
|
- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
|
||||||
|
- End-to-end tests live in `e2e/*.spec.ts`; `make check` will start missing services automatically, while direct Playwright runs expect the app to already be running.
|
||||||
|
- Add or update tests whenever you change handlers, CLI commands, daemon behavior, or SQL-backed flows.
|
||||||
|
|
||||||
|
## Commit & Pull Request Rules
|
||||||
|
|
||||||
|
- Use atomic commits grouped by logical intent.
|
||||||
|
- Conventional format with scopes:
|
||||||
|
- `feat(web): ...`, `feat(cli): ...`
|
||||||
|
- `fix(web): ...`, `fix(cli): ...`
|
||||||
|
- `refactor(daemon): ...`
|
||||||
|
- `test(cli): ...`
|
||||||
|
- `docs: ...`
|
||||||
|
- `chore(scope): ...`
|
||||||
|
- Keep PRs focused and include a short description, linked issue or PR number when relevant, screenshots for UI work, and notes for migrations, env changes, or CLI surface changes.
|
||||||
|
- Before opening a PR, run `make check` or the relevant frontend/backend subset.
|
||||||
|
|
||||||
|
## Minimum Pre-Push Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make check # Runs all checks: typecheck, unit tests, Go tests, E2E
|
||||||
|
```
|
||||||
|
|
||||||
|
Run verification only when the user explicitly asks for it.
|
||||||
|
|
||||||
|
For targeted checks when requested:
|
||||||
|
```bash
|
||||||
|
pnpm typecheck # TypeScript type errors only
|
||||||
|
pnpm test # TS unit tests only (Vitest)
|
||||||
|
make test # Go tests only
|
||||||
|
pnpm exec playwright test # E2E only (requires backend + frontend running)
|
||||||
|
```
|
||||||
|
|
||||||
|
## AI Agent Verification Loop
|
||||||
|
|
||||||
|
After writing or modifying code, always run the full verification pipeline:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make check
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs all checks in sequence:
|
||||||
|
1. TypeScript typecheck (`pnpm typecheck`)
|
||||||
|
2. TypeScript unit tests (`pnpm test`)
|
||||||
|
3. Go tests (`go test ./...`)
|
||||||
|
4. E2E tests (auto-starts backend + frontend if needed, runs Playwright)
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
- Write code to satisfy the requirement
|
||||||
|
- Run `make check`
|
||||||
|
- If any step fails, read the error output, fix the code, and re-run `make check`
|
||||||
|
- Repeat until all checks pass
|
||||||
|
- Only then consider the task complete
|
||||||
|
|
||||||
|
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
|
||||||
|
|
||||||
|
## E2E Test Patterns
|
||||||
|
|
||||||
|
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { loginAsDefault, createTestApi } from "./helpers";
|
||||||
|
import type { TestApiClient } from "./fixtures";
|
||||||
|
|
||||||
|
let api: TestApiClient;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
api = await createTestApi(); // logged-in API client
|
||||||
|
await loginAsDefault(page); // browser session
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await api.cleanup(); // delete any data created during the test
|
||||||
|
});
|
||||||
|
|
||||||
|
test("example", async ({ page }) => {
|
||||||
|
const issue = await api.createIssue("Test Issue"); // create via API
|
||||||
|
await page.goto(`/issues/${issue.id}`); // test via UI
|
||||||
|
// api.cleanup() in afterEach removes the issue
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ import { useAuthStore } from "@/features/auth";
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
import { useWorkspaceStore } from "@/features/workspace";
|
||||||
import { useRuntimeStore } from "@/features/runtimes";
|
import { useRuntimeStore } from "@/features/runtimes";
|
||||||
import { useIssueStore } from "@/features/issues";
|
import { useIssueStore } from "@/features/issues";
|
||||||
|
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export { StatusIcon } from "./status-icon";
|
export { StatusIcon } from "./status-icon";
|
||||||
export { PriorityIcon } from "./priority-icon";
|
export { PriorityIcon } from "./priority-icon";
|
||||||
export { StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
|
export { StatusPicker, PriorityPicker, AssigneePicker, canAssignAgent, DueDatePicker } from "./pickers";
|
||||||
export { IssueDetail } from "./issue-detail";
|
export { IssueDetail } from "./issue-detail";
|
||||||
export { IssuesPage } from "./issues-page";
|
export { IssuesPage } from "./issues-page";
|
||||||
export { CommentCard } from "./comment-card";
|
export { CommentCard } from "./comment-card";
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
|
||||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||||
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
|
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
|
||||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||||
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker } from "@/features/issues/components";
|
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components";
|
||||||
import { CommentCard } from "./comment-card";
|
import { CommentCard } from "./comment-card";
|
||||||
import { CommentInput } from "./comment-input";
|
import { CommentInput } from "./comment-input";
|
||||||
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
|
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
|
||||||
|
|
@ -173,8 +173,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||||
const members = useWorkspaceStore((s) => s.members);
|
const members = useWorkspaceStore((s) => s.members);
|
||||||
const agents = useWorkspaceStore((s) => s.agents);
|
const agents = useWorkspaceStore((s) => s.agents);
|
||||||
const currentMember = members.find((m) => m.user_id === user?.id);
|
const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role;
|
||||||
const memberRole = currentMember?.role;
|
|
||||||
|
|
||||||
// Issue navigation
|
// Issue navigation
|
||||||
const allIssues = useIssueStore((s) => s.issues);
|
const allIssues = useIssueStore((s) => s.issues);
|
||||||
|
|
@ -427,12 +426,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
{issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground">✓</span>}
|
{issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground">✓</span>}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
{agents.filter((a) => {
|
{agents.filter((a) => canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
|
||||||
if (a.visibility !== "private") return true;
|
|
||||||
if (a.owner_id === user?.id) return true;
|
|
||||||
if (memberRole === "owner" || memberRole === "admin") return true;
|
|
||||||
return false;
|
|
||||||
}).map((a) => (
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={a.id}
|
key={a.id}
|
||||||
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
|
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,6 @@ function ActorSubContent({
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const members = useWorkspaceStore((s) => s.members);
|
const members = useWorkspaceStore((s) => s.members);
|
||||||
const agents = useWorkspaceStore((s) => s.agents);
|
const agents = useWorkspaceStore((s) => s.agents);
|
||||||
|
|
||||||
const query = search.toLowerCase();
|
const query = search.toLowerCase();
|
||||||
const filteredMembers = members.filter((m) =>
|
const filteredMembers = members.filter((m) =>
|
||||||
m.name.toLowerCase().includes(query),
|
m.name.toLowerCase().includes(query),
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
PickerEmpty,
|
PickerEmpty,
|
||||||
} from "./property-picker";
|
} from "./property-picker";
|
||||||
|
|
||||||
function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
|
export function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
|
||||||
if (agent.visibility !== "private") return true;
|
if (agent.visibility !== "private") return true;
|
||||||
if (agent.owner_id === userId) return true;
|
if (agent.owner_id === userId) return true;
|
||||||
if (memberRole === "owner" || memberRole === "admin") return true;
|
if (memberRole === "owner" || memberRole === "admin") return true;
|
||||||
|
|
@ -76,10 +76,10 @@ export function AssigneePicker({
|
||||||
}}
|
}}
|
||||||
width="w-52"
|
width="w-52"
|
||||||
align={align}
|
align={align}
|
||||||
triggerRender={triggerRender}
|
|
||||||
searchable
|
searchable
|
||||||
searchPlaceholder="Assign to..."
|
searchPlaceholder="Assign to..."
|
||||||
onSearchChange={setFilter}
|
onSearchChange={setFilter}
|
||||||
|
triggerRender={triggerRender}
|
||||||
trigger={
|
trigger={
|
||||||
customTrigger ? customTrigger : assigneeType && assigneeId ? (
|
customTrigger ? customTrigger : assigneeType && assigneeId ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export { PropertyPicker, PickerItem, PickerSection, PickerEmpty } from "./property-picker";
|
export { PropertyPicker, PickerItem, PickerSection, PickerEmpty } from "./property-picker";
|
||||||
export { StatusPicker } from "./status-picker";
|
export { StatusPicker } from "./status-picker";
|
||||||
export { PriorityPicker } from "./priority-picker";
|
export { PriorityPicker } from "./priority-picker";
|
||||||
export { AssigneePicker } from "./assignee-picker";
|
export { AssigneePicker, canAssignAgent } from "./assignee-picker";
|
||||||
export { DueDatePicker } from "./due-date-picker";
|
export { DueDatePicker } from "./due-date-picker";
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ func init() {
|
||||||
issueCreateCmd.Flags().String("parent", "", "Parent issue ID")
|
issueCreateCmd.Flags().String("parent", "", "Parent issue ID")
|
||||||
issueCreateCmd.Flags().String("due-date", "", "Due date (RFC3339 format)")
|
issueCreateCmd.Flags().String("due-date", "", "Due date (RFC3339 format)")
|
||||||
issueCreateCmd.Flags().String("output", "json", "Output format: table or json")
|
issueCreateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||||
|
issueCreateCmd.Flags().StringSlice("attachment", nil, "File path(s) to attach (can be specified multiple times)")
|
||||||
|
|
||||||
// issue update
|
// issue update
|
||||||
issueUpdateCmd.Flags().String("title", "", "New title")
|
issueUpdateCmd.Flags().String("title", "", "New title")
|
||||||
|
|
@ -147,6 +148,7 @@ func init() {
|
||||||
// issue comment add
|
// issue comment add
|
||||||
issueCommentAddCmd.Flags().String("content", "", "Comment content (required)")
|
issueCommentAddCmd.Flags().String("content", "", "Comment content (required)")
|
||||||
issueCommentAddCmd.Flags().String("parent", "", "Parent comment ID (reply to a specific comment)")
|
issueCommentAddCmd.Flags().String("parent", "", "Parent comment ID (reply to a specific comment)")
|
||||||
|
issueCommentAddCmd.Flags().StringSlice("attachment", nil, "File path(s) to attach (can be specified multiple times)")
|
||||||
issueCommentAddCmd.Flags().String("output", "json", "Output format: table or json")
|
issueCommentAddCmd.Flags().String("output", "json", "Output format: table or json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,7 +277,13 @@ func runIssueCreate(cmd *cobra.Command, _ []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
// Use a longer timeout when attachments are present (file uploads can be slow).
|
||||||
|
timeout := 15 * time.Second
|
||||||
|
attachments, _ := cmd.Flags().GetStringSlice("attachment")
|
||||||
|
if len(attachments) > 0 {
|
||||||
|
timeout = 60 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
body := map[string]any{"title": title}
|
body := map[string]any{"title": title}
|
||||||
|
|
@ -308,6 +316,19 @@ func runIssueCreate(cmd *cobra.Command, _ []string) error {
|
||||||
return fmt.Errorf("create issue: %w", err)
|
return fmt.Errorf("create issue: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload attachments and link them to the newly created issue.
|
||||||
|
issueID := strVal(result, "id")
|
||||||
|
for _, filePath := range attachments {
|
||||||
|
data, readErr := os.ReadFile(filePath)
|
||||||
|
if readErr != nil {
|
||||||
|
return fmt.Errorf("read attachment %s: %w", filePath, readErr)
|
||||||
|
}
|
||||||
|
if _, uploadErr := client.UploadFile(ctx, data, filePath, issueID); uploadErr != nil {
|
||||||
|
return fmt.Errorf("upload attachment %s: %w", filePath, uploadErr)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "Uploaded %s\n", filePath)
|
||||||
|
}
|
||||||
|
|
||||||
output, _ := cmd.Flags().GetString("output")
|
output, _ := cmd.Flags().GetString("output")
|
||||||
if output == "table" {
|
if output == "table" {
|
||||||
headers := []string{"ID", "TITLE", "STATUS", "PRIORITY"}
|
headers := []string{"ID", "TITLE", "STATUS", "PRIORITY"}
|
||||||
|
|
@ -540,19 +561,45 @@ func runIssueCommentAdd(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
issueID := args[0]
|
||||||
|
|
||||||
|
// Use a longer timeout when attachments are present (file uploads can be slow).
|
||||||
|
timeout := 15 * time.Second
|
||||||
|
attachments, _ := cmd.Flags().GetStringSlice("attachment")
|
||||||
|
if len(attachments) > 0 {
|
||||||
|
timeout = 60 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
// Upload attachments and collect their IDs.
|
||||||
|
var attachmentIDs []string
|
||||||
|
for _, filePath := range attachments {
|
||||||
|
data, readErr := os.ReadFile(filePath)
|
||||||
|
if readErr != nil {
|
||||||
|
return fmt.Errorf("read attachment %s: %w", filePath, readErr)
|
||||||
|
}
|
||||||
|
id, uploadErr := client.UploadFile(ctx, data, filePath, issueID)
|
||||||
|
if uploadErr != nil {
|
||||||
|
return fmt.Errorf("upload attachment %s: %w", filePath, uploadErr)
|
||||||
|
}
|
||||||
|
attachmentIDs = append(attachmentIDs, id)
|
||||||
|
fmt.Fprintf(os.Stderr, "Uploaded %s\n", filePath)
|
||||||
|
}
|
||||||
|
|
||||||
body := map[string]any{"content": content}
|
body := map[string]any{"content": content}
|
||||||
if parentID, _ := cmd.Flags().GetString("parent"); parentID != "" {
|
if parentID, _ := cmd.Flags().GetString("parent"); parentID != "" {
|
||||||
body["parent_id"] = parentID
|
body["parent_id"] = parentID
|
||||||
}
|
}
|
||||||
|
if len(attachmentIDs) > 0 {
|
||||||
|
body["attachment_ids"] = attachmentIDs
|
||||||
|
}
|
||||||
var result map[string]any
|
var result map[string]any
|
||||||
if err := client.PostJSON(ctx, "/api/issues/"+args[0]+"/comments", body, &result); err != nil {
|
if err := client.PostJSON(ctx, "/api/issues/"+issueID+"/comments", body, &result); err != nil {
|
||||||
return fmt.Errorf("add comment: %w", err)
|
return fmt.Errorf("add comment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "Comment added to issue %s.\n", truncateID(args[0]))
|
fmt.Fprintf(os.Stderr, "Comment added to issue %s.\n", truncateID(issueID))
|
||||||
|
|
||||||
output, _ := cmd.Flags().GetString("output")
|
output, _ := cmd.Flags().GetString("output")
|
||||||
if output == "table" {
|
if output == "table" {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -156,6 +158,60 @@ func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any)
|
||||||
return json.NewDecoder(resp.Body).Decode(out)
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadFile uploads a file via multipart form to /api/upload-file.
|
||||||
|
// It returns the attachment ID from the server response.
|
||||||
|
func (c *APIClient) UploadFile(ctx context.Context, fileData []byte, filename string, issueID string) (string, error) {
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
|
||||||
|
part, err := writer.CreateFormFile("file", filepath.Base(filename))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create form file: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(fileData); err != nil {
|
||||||
|
return "", fmt.Errorf("write file data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if issueID != "" {
|
||||||
|
if err := writer.WriteField("issue_id", issueID); err != nil {
|
||||||
|
return "", fmt.Errorf("write issue_id field: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
return "", fmt.Errorf("close multipart writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/upload-file", &body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
c.setHeaders(req)
|
||||||
|
|
||||||
|
resp, err := c.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
return "", fmt.Errorf("upload file returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respData)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", fmt.Errorf("decode upload response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, _ := result["id"].(string)
|
||||||
|
if id == "" {
|
||||||
|
return "", fmt.Errorf("upload response missing attachment id")
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
// HealthCheck hits the /health endpoint and returns the response body.
|
// HealthCheck hits the /health endpoint and returns the response body.
|
||||||
func (c *APIClient) HealthCheck(ctx context.Context) (string, error) {
|
func (c *APIClient) HealthCheck(ctx context.Context) (string, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil)
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,9 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||||
h.linkAttachmentsByIDs(r.Context(), comment.ID, issue.ID, req.AttachmentIDs)
|
h.linkAttachmentsByIDs(r.Context(), comment.ID, issue.ID, req.AttachmentIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := commentToResponse(comment, nil, nil)
|
// Fetch linked attachments so the response includes them.
|
||||||
|
groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID})
|
||||||
|
resp := commentToResponse(comment, nil, groupedAtt[uuidToString(comment.ID)])
|
||||||
slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...)
|
slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...)
|
||||||
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{
|
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{
|
||||||
"comment": resp,
|
"comment": resp,
|
||||||
|
|
@ -208,11 +210,6 @@ func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.I
|
||||||
// are already the issue's assignee (handled by on_comment), and agents with
|
// are already the issue's assignee (handled by on_comment), and agents with
|
||||||
// on_mention trigger disabled.
|
// on_mention trigger disabled.
|
||||||
func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue, comment db.Comment, authorType, authorID string) {
|
func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue, comment db.Comment, authorType, authorID string) {
|
||||||
// Don't trigger on terminal statuses.
|
|
||||||
if issue.Status == "done" || issue.Status == "cancelled" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mentions := util.ParseMentions(comment.Content)
|
mentions := util.ParseMentions(comment.Content)
|
||||||
for _, m := range mentions {
|
for _, m := range mentions {
|
||||||
if m.Type != "agent" {
|
if m.Type != "agent" {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ type IssueResponse struct {
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
Reactions []IssueReactionResponse `json:"reactions,omitempty"`
|
Reactions []IssueReactionResponse `json:"reactions,omitempty"`
|
||||||
|
Attachments []AttachmentResponse `json:"attachments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type agentTriggerSnapshot struct {
|
type agentTriggerSnapshot struct {
|
||||||
|
|
@ -142,6 +143,18 @@ func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch issue-level attachments.
|
||||||
|
attachments, err := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{
|
||||||
|
IssueID: issue.ID,
|
||||||
|
WorkspaceID: issue.WorkspaceID,
|
||||||
|
})
|
||||||
|
if err == nil && len(attachments) > 0 {
|
||||||
|
resp.Attachments = make([]AttachmentResponse, len(attachments))
|
||||||
|
for i, a := range attachments {
|
||||||
|
resp.Attachments[i] = h.attachmentToResponse(a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -162,8 +162,6 @@ func (s *TaskService) ClaimTask(ctx context.Context, agentID pgtype.UUID) (*db.A
|
||||||
|
|
||||||
// ClaimTaskForRuntime claims the next runnable task for a runtime while
|
// ClaimTaskForRuntime claims the next runnable task for a runtime while
|
||||||
// still respecting each agent's max_concurrent_tasks limit.
|
// still respecting each agent's max_concurrent_tasks limit.
|
||||||
// Tasks whose issues are in a terminal status (done/cancelled) are
|
|
||||||
// automatically cancelled and skipped.
|
|
||||||
func (s *TaskService) ClaimTaskForRuntime(ctx context.Context, runtimeID pgtype.UUID) (*db.AgentTaskQueue, error) {
|
func (s *TaskService) ClaimTaskForRuntime(ctx context.Context, runtimeID pgtype.UUID) (*db.AgentTaskQueue, error) {
|
||||||
tasks, err := s.Queries.ListPendingTasksByRuntime(ctx, runtimeID)
|
tasks, err := s.Queries.ListPendingTasksByRuntime(ctx, runtimeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -172,15 +170,6 @@ func (s *TaskService) ClaimTaskForRuntime(ctx context.Context, runtimeID pgtype.
|
||||||
|
|
||||||
triedAgents := map[string]struct{}{}
|
triedAgents := map[string]struct{}{}
|
||||||
for _, candidate := range tasks {
|
for _, candidate := range tasks {
|
||||||
// Skip tasks whose issues have reached a terminal status.
|
|
||||||
if issue, err := s.Queries.GetIssue(ctx, candidate.IssueID); err == nil {
|
|
||||||
if issue.Status == "done" || issue.Status == "cancelled" {
|
|
||||||
slog.Info("skipping task for terminal issue", "task_id", util.UUIDToString(candidate.ID), "issue_status", issue.Status)
|
|
||||||
_ = s.Queries.CancelAgentTasksByIssue(ctx, candidate.IssueID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
agentKey := util.UUIDToString(candidate.AgentID)
|
agentKey := util.UUIDToString(candidate.AgentID)
|
||||||
if _, seen := triedAgents[agentKey]; seen {
|
if _, seen := triedAgents[agentKey]; seen {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue