From 2cf088ddf61f135a3893b6188d5fd49e5af423e3 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:47:04 +0800 Subject: [PATCH] feat: resizable sidebar, issue detail rewrite, package consolidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add drag-to-resize sidebar with localStorage persistence - Rewrite issue detail page with Tiptap rich text editor, due date picker, acceptance criteria - Redesign create-issue modal with pill-based property toolbar and expand/collapse - Consolidate @multica/sdk and @multica/types into apps/web/shared/ - Simplify auth: remove verification codes, PATs, email service (dev-only login) - Add 401 unauthorized handler to redirect expired sessions to login - Fix due date format to send full RFC3339 timestamps - Increase description editor debounce to 1500ms - Remove arbitrary Tailwind values in create-issue modal - Renumber migrations (inbox_actor 012→009), remove unused migrations - UI polish across agents, settings, inbox, knowledge-base pages Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 33 +- .../(dashboard)/_components/app-sidebar.tsx | 31 +- apps/web/app/(dashboard)/agents/page.tsx | 148 ++-- apps/web/app/(dashboard)/inbox/page.tsx | 100 +-- .../app/(dashboard)/issues/[id]/page.test.tsx | 52 +- apps/web/app/(dashboard)/issues/page.test.tsx | 2 +- .../app/(dashboard)/knowledge-base/page.tsx | 25 +- apps/web/app/(dashboard)/settings/page.tsx | 2 +- apps/web/app/globals.css | 6 + apps/web/app/pair/local/page.tsx | 2 +- .../components/common/rich-text-editor.css | 145 ++++ .../components/common/rich-text-editor.tsx | 150 ++++ apps/web/components/ui/resizable.tsx | 2 +- apps/web/components/ui/sidebar.tsx | 83 ++- apps/web/features/auth/store.ts | 2 +- apps/web/features/inbox/store.ts | 2 +- .../features/issues/components/board-card.tsx | 8 +- .../issues/components/board-column.tsx | 52 +- .../features/issues/components/board-view.tsx | 2 +- apps/web/features/issues/components/index.ts | 2 +- .../issues/components/issue-detail.tsx | 555 ++++++++++---- .../issues/components/issues-header.tsx | 6 +- .../issues/components/issues-page.tsx | 8 +- .../features/issues/components/list-row.tsx | 2 +- .../features/issues/components/list-view.tsx | 6 +- .../components/pickers/assignee-picker.tsx | 4 +- .../components/pickers/due-date-picker.tsx | 64 ++ .../issues/components/pickers/index.ts | 1 + .../components/pickers/priority-picker.tsx | 6 +- .../components/pickers/property-picker.tsx | 2 +- .../components/pickers/status-picker.tsx | 6 +- .../issues/components/priority-icon.tsx | 2 +- .../issues/components/status-icon.tsx | 2 +- apps/web/features/issues/config/priority.ts | 2 +- apps/web/features/issues/config/status.ts | 2 +- apps/web/features/issues/store.ts | 2 +- apps/web/features/issues/stores/view-store.ts | 32 +- apps/web/features/modals/create-issue.tsx | 389 ++++++++-- apps/web/features/modals/registry.tsx | 3 +- apps/web/features/realtime/hooks.ts | 2 +- apps/web/features/realtime/provider.tsx | 4 +- .../features/realtime/use-realtime-sync.ts | 4 +- .../skills/components/skills-page.tsx | 154 ++-- apps/web/features/workspace/store.ts | 2 +- apps/web/next.config.ts | 5 - apps/web/package.json | 10 +- .../web/shared/api/client.ts | 18 +- apps/web/shared/{api.ts => api/index.ts} | 8 +- .../src => apps/web/shared/api}/ws-client.ts | 8 +- .../src => apps/web/shared/types}/agent.ts | 0 .../src => apps/web/shared/types}/api.ts | 1 + .../src => apps/web/shared/types}/comment.ts | 0 .../src => apps/web/shared/types}/daemon.ts | 0 .../src => apps/web/shared/types}/events.ts | 0 .../src => apps/web/shared/types}/inbox.ts | 0 .../src => apps/web/shared/types}/index.ts | 16 +- .../src => apps/web/shared/types}/issue.ts | 0 .../web/shared/types}/workspace.ts | 0 apps/web/test/helpers.tsx | 2 +- apps/web/vitest.config.ts | 2 - e2e/fixtures.ts | 3 +- packages/sdk/package.json | 19 - packages/sdk/src/index.ts | 11 - packages/sdk/src/logger.ts | 13 - packages/sdk/tsconfig.json | 7 - packages/types/package.json | 16 - packages/types/tsconfig.json | 7 - packages/ui/src/styles/custom.css | 6 + pnpm-lock.yaml | 682 +++++++++++++++++- server/internal/cli/client.go | 4 + server/internal/handler/issue.go | 12 + server/pkg/db/generated/issue.sql.go | 32 +- server/pkg/db/queries/issue.sql | 4 +- 73 files changed, 2322 insertions(+), 673 deletions(-) create mode 100644 apps/web/components/common/rich-text-editor.css create mode 100644 apps/web/components/common/rich-text-editor.tsx create mode 100644 apps/web/features/issues/components/pickers/due-date-picker.tsx rename packages/sdk/src/api-client.ts => apps/web/shared/api/client.ts (95%) rename apps/web/shared/{api.ts => api/index.ts} (68%) rename {packages/sdk/src => apps/web/shared/api}/ws-client.ts (93%) rename {packages/types/src => apps/web/shared/types}/agent.ts (100%) rename {packages/types/src => apps/web/shared/types}/api.ts (98%) rename {packages/types/src => apps/web/shared/types}/comment.ts (100%) rename {packages/types/src => apps/web/shared/types}/daemon.ts (100%) rename {packages/types/src => apps/web/shared/types}/events.ts (100%) rename {packages/types/src => apps/web/shared/types}/inbox.ts (100%) rename {packages/types/src => apps/web/shared/types}/index.ts (74%) rename {packages/types/src => apps/web/shared/types}/issue.ts (100%) rename {packages/types/src => apps/web/shared/types}/workspace.ts (100%) delete mode 100644 packages/sdk/package.json delete mode 100644 packages/sdk/src/index.ts delete mode 100644 packages/sdk/src/logger.ts delete mode 100644 packages/sdk/tsconfig.json delete mode 100644 packages/types/package.json delete mode 100644 packages/types/tsconfig.json diff --git a/CLAUDE.md b/CLAUDE.md index f0e09605..b6d94bfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,21 +12,20 @@ Multica is an AI-native task management platform — like Linear, but with AI ag ## Architecture -**Polyglot monorepo** — Go backend + TypeScript frontend. +**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) -- `packages/` — Shared TypeScript packages (ui, types, sdk, store, hooks, utils) +- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies ### Web App Structure (`apps/web/`) -The frontend uses a **feature-based architecture** with three layers: +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) +├── shared/ # Cross-feature utilities (api client, types, logger) ``` **`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/`. @@ -41,7 +40,10 @@ apps/web/ | `features/inbox/` | Inbox notification state | `useInboxStore` | | `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` | -**`shared/`** — Code used across multiple features. Currently only `api.ts` (SDK singleton). +**`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 @@ -61,6 +63,7 @@ apps/web/ 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"; @@ -74,8 +77,8 @@ Within a feature, use relative imports. Between features or to shared, use `@/`. ### Data Flow ``` -Browser → ApiClient (SDK) → REST API (Chi handlers) → sqlc queries → PostgreSQL -Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService +Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL +Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService ``` ### Backend Structure (`server/`) @@ -91,12 +94,6 @@ Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskSe - **Database**: 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). -### Key Packages - -- **`@multica/sdk`**: `ApiClient` (REST) and `WSClient` (WebSocket) classes. All backend communication goes through here. -- **`@multica/types`**: Shared domain types + WebSocket event types (issue:created/updated/deleted, task:*, agent:status, comment:*, inbox:new, daemon:*). -- **`@multica/ui`**: shadcn/ui component library with Radix primitives, Tailwind CSS 4, Shiki syntax highlighting for markdown. - ### Multi-tenancy All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace. @@ -117,7 +114,7 @@ make db-down # Stop the shared PostgreSQL container # Frontend pnpm install pnpm dev:web # Next.js dev server (port 3000) -pnpm build # Build all TS packages +pnpm build # Build frontend pnpm typecheck # TypeScript check pnpm test # TS tests (Vitest) @@ -165,10 +162,8 @@ make start-worktree # Start using .env.worktree ## UI/UX Rules -- Prefer `packages/ui` shadcn components over custom implementations. -- **shadcn official components** → `packages/ui/src/components/ui/` — keep this directory clean; install missing components via `npx shadcn add`, do not mix in business code. -- **Shared business components & utils** → `packages/ui/src/components/common/` — reusable project-level UI components (e.g. ActorAvatar) and shared utilities live here. -- **Feature-specific components** → `features//components/` — issue icons, pickers, and other domain-bound UI live inside their feature module, not in `packages/ui`. +- Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`. +- **Feature-specific components** → `features//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. diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx index c3caca71..101b59a2 100644 --- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx +++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx @@ -13,7 +13,6 @@ import { Plus, Check, Sparkles, - Search, SquarePen, } from "lucide-react"; import { WorkspaceAvatar } from "@/features/workspace"; @@ -26,6 +25,7 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, + SidebarRail, } from "@/components/ui/sidebar"; import { DropdownMenu, @@ -155,25 +155,15 @@ export function AppSidebar() { -
- - - - - Search - - - useModalStore.getState().open("create-issue")} - > - - - New issue - -
+ + useModalStore.getState().open("create-issue")} + > + + + New issue + @@ -228,6 +218,7 @@ export function AppSidebar() { + ); } diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index d27e3ee4..c020587b 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import { useDefaultLayout } from "react-resizable-panels"; import { Bot, Cloud, @@ -32,7 +33,7 @@ import type { RuntimeDevice, CreateAgentRequest, UpdateAgentRequest, -} from "@multica/types"; +} from "@/shared/types"; import { Dialog, DialogContent, @@ -41,6 +42,11 @@ import { DialogDescription, DialogFooter, } from "@/components/ui/dialog"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -1134,6 +1140,9 @@ export default function AgentsPage() { const [selectedId, setSelectedId] = useState(""); const [showCreate, setShowCreate] = useState(false); const [runtimes, setRuntimes] = useState([]); + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "multica_agents_layout", + }); useEffect(() => { if (!workspace) { @@ -1191,70 +1200,81 @@ export default function AgentsPage() { } return ( -
- {/* Left column — agent list */} -
-
-

Agents

- + + + {/* Left column — agent list */} +
+
+

Agents

+ +
+ {agents.length === 0 ? ( +
+ +

No agents yet

+ +
+ ) : ( +
+ {agents.map((agent) => ( + setSelectedId(agent.id)} + /> + ))} +
+ )}
- {agents.length === 0 ? ( -
- -

No agents yet

- -
- ) : ( -
- {agents.map((agent) => ( - setSelectedId(agent.id)} - /> - ))} -
- )} -
+ - {/* Right column — agent detail */} -
- {selected ? ( - - ) : ( -
- -

Select an agent to view details

- -
- )} -
+ + + + {/* Right column — agent detail */} +
+ {selected ? ( + + ) : ( +
+ +

Select an agent to view details

+ +
+ )} +
+
{showCreate && ( )} -
+ ); } diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index 32440798..e64194c7 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useMemo } from "react"; +import { useDefaultLayout } from "react-resizable-panels"; import { useInboxStore } from "@/features/inbox"; import { IssueDetail, StatusIcon } from "@/features/issues/components"; import { ActorAvatar } from "@/components/common/actor-avatar"; @@ -13,8 +14,13 @@ import { BookCheck, ListChecks, } from "lucide-react"; -import type { InboxItem, InboxItemType, InboxSeverity } from "@multica/types"; +import type { InboxItem, InboxItemType, InboxSeverity } from "@/shared/types"; import { Button } from "@/components/ui/button"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; import { Skeleton } from "@/components/ui/skeleton"; import { DropdownMenu, @@ -82,25 +88,25 @@ function InboxListItem({ />
- - {item.title} - -
- {item.issue_status && ( - - )} +
{!item.read && ( - + )} + + {item.title} +
+ {item.issue_status && ( + + )}
-

+

{typeLabels[item.type] ?? item.type}

- + {timeAgo(item.created_at)}
@@ -119,6 +125,10 @@ export default function InboxPage() { const storeItems = useInboxStore((s) => s.items); const loading = useInboxStore((s) => s.loading); + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "multica_inbox_layout", + }); + // Sort: severity first, then newest first const items = useMemo(() => { return [...storeItems] @@ -202,40 +212,46 @@ export default function InboxPage() { if (loading) { return ( -
-
-
- -
-
- {Array.from({ length: 5 }).map((_, i) => ( -
- -
- - + + +
+
+ +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + +
-
- ))} + ))} +
-
-
- - -
-
+ + + +
+ + +
+
+ ); } return ( -
+ + {/* Left column — inbox list */} -
+

Inbox

{unreadCount > 0 && ( - + {unreadCount} )} @@ -280,7 +296,7 @@ export default function InboxPage() {

No notifications

) : ( -
+
{items.map((item) => ( )}
- + + + {/* Right column — detail */} -
+
{selected?.issue_id ? ( { handleArchive(selected.id); }} @@ -336,6 +353,7 @@ export default function InboxPage() {
)}
-
+ + ); } diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index c2481acb..4840cfc2 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -1,8 +1,8 @@ -import { Suspense } from "react"; +import { Suspense, forwardRef, useState, useImperativeHandle } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { Issue, Comment } from "@multica/types"; +import type { Issue, Comment } from "@/shared/types"; // Mock next/navigation vi.mock("next/navigation", () => ({ @@ -71,6 +71,39 @@ vi.mock("@/components/ui/calendar", () => ({ Calendar: () => null, })); +// Mock RichTextEditor (Tiptap needs real DOM) +vi.mock("@/components/common/rich-text-editor", () => ({ + RichTextEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => { + const [value, setValue] = useState(defaultValue || ""); + useImperativeHandle(ref, () => ({ + getMarkdown: () => value, + clearContent: () => setValue(""), + focus: () => {}, + })); + return ( +