merge: resolve conflicts after merging main

Adapt runtime features (usage tracking, ping, heartbeat) to main's
multi-workspace architecture. Update frontend imports from @multica/types
to @/shared/types after the package consolidation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiayuan 2026-03-26 18:37:56 +08:00
commit 6ee034c6e9
151 changed files with 3664 additions and 6579 deletions

View file

@ -29,17 +29,8 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Restore Turbo cache
uses: actions/cache@v5
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
turbo-${{ runner.os }}-
- name: Build, type check, and test
run: pnpm turbo build typecheck test
run: pnpm build && pnpm typecheck && pnpm test
backend:
runs-on: ubuntu-latest

1
.gitignore vendored
View file

@ -7,7 +7,6 @@ dist
# build outputs
.next
out
.turbo
build
bin
dist-electron

View file

@ -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, 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/`.
@ -43,7 +42,10 @@ apps/web/
| `features/modals/` | Modal registry and state | Modal store and components |
| `features/skills/` | Skill management | Skill components |
**`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
@ -63,6 +65,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";
@ -76,8 +79,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/`)
@ -94,13 +97,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.
- **`@multica/utils`**: Shared utility functions used across apps and packages.
### Multi-tenancy
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
@ -121,7 +117,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)
@ -170,10 +166,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/<domain>/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/<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.

View file

@ -119,7 +119,7 @@ dev:
cd server && go run ./cmd/server
daemon:
cd server && MULTICA_REPOS_ROOT="${MULTICA_REPOS_ROOT:-$(abspath .)}" go run ./cmd/multica daemon
cd server && go run ./cmd/multica daemon
cli:
cd server && go run ./cmd/multica $(ARGS)

100
README.md
View file

@ -100,6 +100,79 @@ That keeps one Docker container and one volume, while still isolating schema and
| `make setup-main` / `make start-main` | Force use of the shared main `.env` |
| `make setup-worktree` / `make start-worktree` | Force use of isolated `.env.worktree` |
## CLI (`multica`)
The CLI manages authentication, workspace configuration, and the local agent daemon.
### Install
```bash
brew tap multica-ai/tap
brew install multica-cli
```
Or build from source:
```bash
make build
cp server/bin/multica /usr/local/bin/multica # or ~/.local/bin/multica
```
### Authentication
```bash
multica auth login # Open browser to authenticate (one-click if already logged in)
multica auth login --token # Paste a personal access token manually
multica auth status # Show current auth status
multica auth logout # Remove stored token
```
Credentials are saved to `~/.multica/config.json`.
### Workspaces
```bash
multica workspace list # List all workspaces you belong to
```
### Daemon Watch List
The daemon monitors one or more workspaces for tasks. Manage which workspaces are watched:
```bash
multica workspace watch <workspace-id> # Add a workspace to the watch list
multica workspace unwatch <workspace-id> # Remove a workspace from the watch list
multica workspace list # Show all workspaces (watched ones marked with *)
```
The watch list is stored in `~/.multica/config.json`. Changes are picked up by a running daemon within 5 seconds (hot-reload).
### Local Agent Daemon
The daemon polls watched workspaces for tasks and executes them using locally installed AI agents (Claude Code, Codex).
```bash
# 1. Authenticate
multica auth login
# 2. Add workspaces to watch
multica workspace watch <workspace-id>
# 3. Start the daemon
multica daemon
```
The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. When a task is claimed, it creates an isolated execution environment, runs the agent, and reports results back to the server.
### Other Commands
```bash
multica agent list # List agents in the current workspace
multica runtime list # List registered runtimes
multica config show # Show CLI configuration
multica version # Show CLI version
```
## Environment Variables
See [`.env.example`](.env.example) for all available variables:
@ -110,31 +183,14 @@ See [`.env.example`](.env.example) for all available variables:
- `PORT` — Backend server port (default: 8080)
- `FRONTEND_PORT` / `FRONTEND_ORIGIN` — Frontend port and browser origin
- `JWT_SECRET` — JWT signing secret
- `MULTICA_APP_URL` — Browser origin used when generating local runtime pairing links
- `MULTICA_DAEMON_CONFIG` — Optional path for the daemon's persisted local config
- `MULTICA_WORKSPACE_ID` — Optional dev override for the workspace id; normal usage should rely on browser pairing instead
- `MULTICA_DAEMON_ID` / `MULTICA_DAEMON_DEVICE_NAME` — Stable daemon identity for local runtime registration
- `MULTICA_CODEX_PATH` / `MULTICA_CODEX_MODEL` — Codex executable and optional model override for local task execution
- `MULTICA_CODEX_WORKDIR` — Default working directory used by the local Codex runtime
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` — Google OAuth (optional)
- `MULTICA_APP_URL` — Browser origin for CLI login callback (default: `http://localhost:3000`)
- `MULTICA_DAEMON_ID` / `MULTICA_DAEMON_DEVICE_NAME` — Stable daemon identity for runtime registration
- `MULTICA_CLAUDE_PATH` / `MULTICA_CLAUDE_MODEL` — Claude Code executable and optional model override
- `MULTICA_CODEX_PATH` / `MULTICA_CODEX_MODEL` — Codex executable and optional model override
- `MULTICA_WORKSPACES_ROOT` — Base directory for agent execution environments (default: `~/multica_workspaces`)
- `NEXT_PUBLIC_API_URL` — Frontend → backend API URL
- `NEXT_PUBLIC_WS_URL` — Frontend → backend WebSocket URL
## Local Codex Daemon
The local daemon currently supports one local runtime type: `codex`.
1. Start the daemon with `make daemon`.
2. If the daemon does not already know its workspace, it prints a pairing link in the terminal.
3. Open that link in the browser, sign in, and choose the workspace that should own the local Codex runtime.
4. The daemon stores the approved workspace locally in `MULTICA_DAEMON_CONFIG` or `~/.multica/daemon.json`.
5. The daemon registers the local Codex runtime via `/api/daemon/register`.
6. Create an agent in Multica and bind it to that runtime.
7. Assign an issue to the agent and move the issue to `todo`.
8. The daemon claims the task, runs `codex exec`, and reports the final comment back to the issue.
For local development you can still set `MULTICA_WORKSPACE_ID` directly to skip pairing, but that should be treated as a debug shortcut rather than the normal flow.
## Local Development Notes
- `make setup`, `make start`, `make dev`, and `make test` now require an env file. They fail fast if `.env` or `.env.worktree` is missing.

View file

@ -17,7 +17,6 @@ vi.mock("@/features/auth", () => ({
selector({
sendCode: mockSendCode,
verifyCode: mockVerifyCode,
isLoading: false,
}),
}));
@ -34,6 +33,9 @@ vi.mock("@/features/workspace", () => ({
vi.mock("@/shared/api", () => ({
api: {
listWorkspaces: vi.fn().mockResolvedValue([]),
verifyCode: vi.fn(),
setToken: vi.fn(),
getMe: vi.fn(),
},
}));
@ -44,14 +46,14 @@ describe("LoginPage", () => {
vi.clearAllMocks();
});
it("renders email form with heading and button", () => {
it("renders login form with email input and continue button", () => {
render(<LoginPage />);
expect(screen.getByText("Multica")).toBeInTheDocument();
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /continue/i })
screen.getByRole("button", { name: "Continue" })
).toBeInTheDocument();
});
@ -59,25 +61,21 @@ describe("LoginPage", () => {
const user = userEvent.setup();
render(<LoginPage />);
await user.click(screen.getByRole("button", { name: /continue/i }));
await user.click(screen.getByRole("button", { name: "Continue" }));
expect(mockSendCode).not.toHaveBeenCalled();
});
it("calls sendCode on submit and shows code step", async () => {
it("calls sendCode with email on submit", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: /continue/i }));
await user.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(mockSendCode).toHaveBeenCalledWith("test@multica.ai");
});
await waitFor(() => {
expect(screen.getByText("Check your email")).toBeInTheDocument();
});
});
it("shows 'Sending code...' while submitting", async () => {
@ -86,40 +84,36 @@ describe("LoginPage", () => {
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: /continue/i }));
await user.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(screen.getByText("Sending code...")).toBeInTheDocument();
});
});
it("shows verification code step after sending code", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(screen.getByText("Check your email")).toBeInTheDocument();
});
});
it("shows error when sendCode fails", async () => {
mockSendCode.mockRejectedValueOnce(new Error("Network error"));
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: /continue/i }));
await user.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(screen.getByText("Network error")).toBeInTheDocument();
});
});
it("shows back button on code step", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
expect(screen.getByText("Check your email")).toBeInTheDocument();
});
expect(
screen.getByRole("button", { name: /back/i })
).toBeInTheDocument();
});
});

View file

@ -21,6 +21,28 @@ import {
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import type { User } from "@/shared/types";
function validateCliCallback(cliCallback: string): boolean {
try {
const cbUrl = new URL(cliCallback);
if (cbUrl.protocol !== "http:") return false;
if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1")
return false;
return true;
} catch {
return false;
}
}
function redirectToCliCallback(
cliCallback: string,
token: string,
cliState: string
) {
const separator = cliCallback.includes("?") ? "&" : "?";
window.location.href = `${cliCallback}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(cliState)}`;
}
function LoginPageContent() {
const router = useRouter();
@ -29,12 +51,38 @@ function LoginPageContent() {
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const searchParams = useSearchParams();
const [step, setStep] = useState<"email" | "code">("email");
const [step, setStep] = useState<"email" | "code" | "cli_confirm">("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [existingUser, setExistingUser] = useState<User | null>(null);
// Check for existing session when CLI callback is present.
useEffect(() => {
const cliCallback = searchParams.get("cli_callback");
if (!cliCallback) return;
const token = localStorage.getItem("multica_token");
if (!token) return;
if (!validateCliCallback(cliCallback)) return;
// Verify the existing token is still valid.
api.setToken(token);
api
.getMe()
.then((user) => {
setExistingUser(user);
setStep("cli_confirm");
})
.catch(() => {
// Token expired/invalid — clear and fall through to normal login.
api.setToken(null);
localStorage.removeItem("multica_token");
});
}, [searchParams]);
useEffect(() => {
if (cooldown <= 0) return;
@ -42,6 +90,15 @@ function LoginPageContent() {
return () => clearTimeout(timer);
}, [cooldown]);
const handleCliAuthorize = async () => {
const cliCallback = searchParams.get("cli_callback");
const token = localStorage.getItem("multica_token");
if (!cliCallback || !token) return;
const cliState = searchParams.get("cli_state") || "";
setSubmitting(true);
redirectToCliCallback(cliCallback, token, cliState);
};
const handleSendCode = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!email) {
@ -57,7 +114,9 @@ function LoginPageContent() {
setCooldown(10);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to send code. Make sure the server is running."
err instanceof Error
? err.message
: "Failed to send code. Make sure the server is running."
);
} finally {
setSubmitting(false);
@ -72,29 +131,14 @@ function LoginPageContent() {
try {
const cliCallback = searchParams.get("cli_callback");
if (cliCallback) {
// CLI browser login: verify code, get JWT, redirect to CLI callback.
// Only allow http://localhost callbacks to prevent open redirect / JWT theft.
try {
const cbUrl = new URL(cliCallback);
if (cbUrl.protocol !== "http:") {
setError("Invalid callback URL");
setSubmitting(false);
return;
}
if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1") {
setError("Invalid callback URL");
setSubmitting(false);
return;
}
} catch {
if (!validateCliCallback(cliCallback)) {
setError("Invalid callback URL");
setSubmitting(false);
return;
}
const { token } = await api.verifyCode(email, value);
const cliState = searchParams.get("cli_state") || "";
const separator = cliCallback.includes("?") ? "&" : "?";
window.location.href = `${cliCallback}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(cliState)}`;
redirectToCliCallback(cliCallback, token, cliState);
return;
}
@ -126,6 +170,46 @@ function LoginPageContent() {
}
};
// CLI confirm step: user is already logged in, just authorize.
if (step === "cli_confirm" && existingUser) {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Authorize CLI</CardTitle>
<CardDescription>
Allow the CLI to access Multica as{" "}
<span className="font-medium text-foreground">
{existingUser.email}
</span>
?
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Button
onClick={handleCliAuthorize}
disabled={submitting}
className="w-full"
size="lg"
>
{submitting ? "Authorizing..." : "Authorize"}
</Button>
<Button
variant="ghost"
className="w-full"
onClick={() => {
setExistingUser(null);
setStep("email");
}}
>
Use a different account
</Button>
</CardContent>
</Card>
</div>
);
}
if (step === "code") {
return (
<div className="flex min-h-screen items-center justify-center">

View file

@ -14,7 +14,6 @@ import {
Plus,
Check,
Sparkles,
Search,
SquarePen,
} from "lucide-react";
import { WorkspaceAvatar } from "@/features/workspace";
@ -27,6 +26,7 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar";
import {
DropdownMenu,
@ -157,25 +157,15 @@ export function AppSidebar() {
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
<Search className="size-4" />
</TooltipTrigger>
<TooltipContent side="bottom">Search</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
className="flex h-7 w-7 items-center justify-center rounded-lg bg-background text-foreground shadow-sm hover:bg-accent"
onClick={() => useModalStore.getState().open("create-issue")}
>
<SquarePen className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="bottom">New issue</TooltipContent>
</Tooltip>
</div>
<Tooltip>
<TooltipTrigger
className="flex h-7 w-7 items-center justify-center rounded-lg bg-background text-foreground shadow-sm hover:bg-accent"
onClick={() => useModalStore.getState().open("create-issue")}
>
<SquarePen className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="bottom">New issue</TooltipContent>
</Tooltip>
</div>
</SidebarHeader>
@ -230,6 +220,7 @@ export function AppSidebar() {
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
);
}

View file

@ -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<string>("");
const [showCreate, setShowCreate] = useState(false);
const [runtimes, setRuntimes] = useState<RuntimeDevice[]>([]);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_agents_layout",
});
useEffect(() => {
if (!workspace) {
@ -1191,70 +1200,81 @@ export default function AgentsPage() {
}
return (
<div className="flex flex-1 min-h-0">
{/* Left column — agent list */}
<div className="w-72 shrink-0 overflow-y-auto border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Agents</h1>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowCreate(true)}
>
<Plus className="h-4 w-4 text-muted-foreground" />
</Button>
<ResizablePanelGroup
orientation="horizontal"
className="flex-1 min-h-0"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel id="list" defaultSize={280} minSize={240} maxSize={400} groupResizeBehavior="preserve-pixel-size">
{/* Left column — agent list */}
<div className="overflow-y-auto h-full border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Agents</h1>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowCreate(true)}
>
<Plus className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
{agents.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Bot className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">No agents yet</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Agent
</Button>
</div>
) : (
<div className="divide-y">
{agents.map((agent) => (
<AgentListItem
key={agent.id}
agent={agent}
isSelected={agent.id === selectedId}
onClick={() => setSelectedId(agent.id)}
/>
))}
</div>
)}
</div>
{agents.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Bot className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">No agents yet</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Agent
</Button>
</div>
) : (
<div className="divide-y">
{agents.map((agent) => (
<AgentListItem
key={agent.id}
agent={agent}
isSelected={agent.id === selectedId}
onClick={() => setSelectedId(agent.id)}
/>
))}
</div>
)}
</div>
</ResizablePanel>
{/* Right column — agent detail */}
<div className="flex-1 overflow-hidden">
{selected ? (
<AgentDetail
agent={selected}
runtimes={runtimes}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Bot className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-3 text-sm">Select an agent to view details</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Agent
</Button>
</div>
)}
</div>
<ResizableHandle />
<ResizablePanel id="detail" minSize="50%">
{/* Right column — agent detail */}
<div className="flex-1 overflow-hidden h-full">
{selected ? (
<AgentDetail
agent={selected}
runtimes={runtimes}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Bot className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-3 text-sm">Select an agent to view details</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Agent
</Button>
</div>
)}
</div>
</ResizablePanel>
{showCreate && (
<CreateAgentDialog
@ -1263,6 +1283,6 @@ export default function AgentsPage() {
onCreate={handleCreate}
/>
)}
</div>
</ResizablePanelGroup>
);
}

View file

@ -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({
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span
className={`truncate text-sm ${!item.read ? "font-medium" : "text-muted-foreground"}`}
>
{item.title}
</span>
<div className="flex items-center gap-1.5 shrink-0">
{item.issue_status && (
<StatusIcon status={item.issue_status} className="h-3.5 w-3.5" />
)}
<div className="flex min-w-0 items-center gap-1.5">
{!item.read && (
<span className="h-2 w-2 rounded-full bg-primary" />
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-brand" />
)}
<span
className={`truncate text-sm ${!item.read ? "font-medium" : "text-muted-foreground"}`}
>
{item.title}
</span>
</div>
{item.issue_status && (
<StatusIcon status={item.issue_status} className="h-3.5 w-3.5 shrink-0" />
)}
</div>
<div className="mt-0.5 flex items-center justify-between gap-2">
<p className="truncate text-xs text-muted-foreground">
<p className={`truncate text-xs ${item.read ? "text-muted-foreground/60" : "text-muted-foreground"}`}>
{typeLabels[item.type] ?? item.type}
</p>
<span className="shrink-0 text-xs text-muted-foreground">
<span className={`shrink-0 text-xs ${item.read ? "text-muted-foreground/60" : "text-muted-foreground"}`}>
{timeAgo(item.created_at)}
</span>
</div>
@ -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 (
<div className="flex flex-1 min-h-0">
<div className="w-80 shrink-0 border-r">
<div className="flex h-12 items-center border-b px-4">
<Skeleton className="h-5 w-16" />
</div>
<div className="space-y-1 p-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
<Skeleton className="h-7 w-7 shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="list" defaultSize={320} minSize={240} maxSize={480} groupResizeBehavior="preserve-pixel-size">
<div className="overflow-y-auto border-r h-full">
<div className="flex h-12 items-center border-b px-4">
<Skeleton className="h-5 w-16" />
</div>
<div className="space-y-1 p-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
<Skeleton className="h-7 w-7 shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
</div>
))}
))}
</div>
</div>
</div>
<div className="flex-1 p-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="mt-4 h-4 w-32" />
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="detail" minSize="40%">
<div className="p-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="mt-4 h-4 w-32" />
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}
return (
<div className="flex flex-1 min-h-0">
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="list" defaultSize={320} minSize={240} maxSize={480} groupResizeBehavior="preserve-pixel-size">
{/* Left column — inbox list */}
<div className="w-80 shrink-0 overflow-y-auto border-r">
<div className="overflow-y-auto border-r h-full">
<div className="flex h-12 items-center justify-between border-b px-4">
<div className="flex items-center gap-2">
<h1 className="text-sm font-semibold">Inbox</h1>
{unreadCount > 0 && (
<span className="rounded-full bg-primary px-1.5 py-0.5 text-xs font-medium text-primary-foreground">
<span className="text-xs text-muted-foreground">
{unreadCount}
</span>
)}
@ -280,7 +296,7 @@ export default function InboxPage() {
<p className="text-sm">No notifications</p>
</div>
) : (
<div className="divide-y">
<div>
{items.map((item) => (
<InboxListItem
key={item.id}
@ -292,13 +308,14 @@ export default function InboxPage() {
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="detail" minSize="40%">
{/* Right column — detail */}
<div className="flex flex-col flex-1 min-h-0">
<div className="flex flex-col min-h-0 h-full">
{selected?.issue_id ? (
<IssueDetail
issueId={selected.issue_id}
showBreadcrumb={false}
onDelete={() => {
handleArchive(selected.id);
}}
@ -336,6 +353,7 @@ export default function InboxPage() {
</div>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}

View file

@ -1,8 +1,8 @@
import { Suspense } from "react";
import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react";
import { render, screen, waitFor, act, fireEvent } 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,41 @@ 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 valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
getMarkdown: () => valueRef.current,
clearContent: () => { valueRef.current = ""; setValue(""); },
focus: () => {},
}));
return (
<textarea
value={value}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onUpdate?.(e.target.value);
}}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
onSubmit?.();
}
}}
placeholder={placeholder}
data-testid="rich-text-editor"
/>
);
}),
}));
// Mock Markdown renderer
vi.mock("@/components/markdown", () => ({
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
}));
// Mock api
const mockGetIssue = vi.hoisted(() => vi.fn());
const mockListComments = vi.hoisted(() => vi.fn());
@ -234,17 +269,21 @@ describe("IssueDetailPage", () => {
).toBeInTheDocument();
});
await user.type(
screen.getByPlaceholderText("Leave a comment..."),
"New test comment",
);
const commentInput = screen.getByPlaceholderText("Leave a comment...");
const form = screen
.getByPlaceholderText("Leave a comment...")
.closest("form")!;
const submitBtn = form.querySelector(
'button[type="submit"]',
) as HTMLElement;
// Use fireEvent to update the textarea value and trigger onUpdate
await act(async () => {
fireEvent.change(commentInput, { target: { value: "New test comment" } });
});
// Wait for button to be enabled after commentEmpty state update
const allButtons = screen.getAllByRole("button");
const submitBtn = allButtons.find(
(btn) => btn.querySelector(".lucide-arrow-up") !== null,
)!;
await waitFor(() => {
expect(submitBtn).not.toBeDisabled();
});
await user.click(submitBtn);
await waitFor(() => {

View file

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Issue } from "@multica/types";
import type { Issue } from "@/shared/types";
// Mock next/navigation
vi.mock("next/navigation", () => ({

View file

@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useDefaultLayout } from "react-resizable-panels";
import {
FileText,
Plus,
@ -9,6 +10,11 @@ import {
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@ -280,6 +286,9 @@ export default function KnowledgeBasePage() {
const [documents] = useState<KBDocument[]>([]);
const [selectedId, setSelectedId] = useState<string>("");
const [search, setSearch] = useState("");
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_kb_layout",
});
const filtered = search
? documents.filter((d) =>
@ -290,10 +299,11 @@ export default function KnowledgeBasePage() {
const selected = documents.find((d) => d.id === selectedId) ?? null;
return (
<div className="flex flex-1 min-h-0">
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="list" defaultSize={280} minSize={240} maxSize={400} groupResizeBehavior="preserve-pixel-size">
{/* Left: Document list */}
<div className="w-72 shrink-0 overflow-y-auto border-r">
<div className="flex h-11 items-center justify-between border-b px-4">
<div className="overflow-y-auto h-full border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Knowledge Base</h1>
<Button variant="ghost" size="icon-xs">
<Plus className="h-4 w-4 text-muted-foreground" />
@ -331,15 +341,20 @@ export default function KnowledgeBasePage() {
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="detail" minSize="50%">
{/* Right: Document content */}
{selected ? (
<DocDetail doc={selected} />
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Select a document
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}

View file

@ -2,7 +2,7 @@
import { useEffect, useState, useCallback } from "react";
import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut, Key, Copy, Check } from "lucide-react";
import type { MemberWithUser, MemberRole, PersonalAccessToken } from "@multica/types";
import type { MemberWithUser, MemberRole, PersonalAccessToken } from "@/shared/types";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";

View file

@ -29,6 +29,8 @@
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-canvas: var(--canvas);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
@ -86,6 +88,8 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--brand: oklch(0.55 0.16 255);
--brand-foreground: oklch(0.985 0 0);
--canvas: oklch(0.95 0.002 286);
--success: oklch(0.55 0.16 145);
--warning: oklch(0.75 0.16 85);
@ -127,6 +131,8 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--brand: oklch(0.65 0.16 255);
--brand-foreground: oklch(0.985 0 0);
--canvas: oklch(0.2 0.005 286);
--success: oklch(0.65 0.15 145);
--warning: oklch(0.70 0.16 85);

View file

@ -3,7 +3,7 @@
import Link from "next/link";
import { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import type { DaemonPairingSession } from "@multica/types";
import type { DaemonPairingSession } from "@/shared/types";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {

View file

@ -0,0 +1,145 @@
/* Rich text editor: ProseMirror styles using shadcn design tokens */
.rich-text-editor.ProseMirror {
color: var(--foreground);
caret-color: var(--foreground);
}
.rich-text-editor.ProseMirror:focus {
outline: none;
}
/* Placeholder */
.rich-text-editor .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--muted-foreground);
pointer-events: none;
height: 0;
}
/* Headings */
.rich-text-editor h1 {
font-size: 1.125rem;
font-weight: 700;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.rich-text-editor h2 {
font-size: 1rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.rich-text-editor h3 {
font-size: 0.875rem;
font-weight: 600;
margin-top: 0.75rem;
margin-bottom: 0.25rem;
line-height: 1.4;
}
/* Paragraphs */
.rich-text-editor p {
margin-top: 0.375rem;
margin-bottom: 0.375rem;
line-height: 1.625;
}
/* First child should not have top margin */
.rich-text-editor > *:first-child {
margin-top: 0;
}
/* Last child should not have bottom margin */
.rich-text-editor > *:last-child {
margin-bottom: 0;
}
/* Lists */
.rich-text-editor ul {
list-style-type: disc;
padding-inline-start: 1.25rem;
margin: 0.375rem 0;
}
.rich-text-editor ol {
list-style-type: decimal;
padding-inline-start: 1.25rem;
margin: 0.375rem 0;
}
.rich-text-editor li {
margin: 0.125rem 0;
line-height: 1.625;
}
.rich-text-editor li::marker {
color: var(--muted-foreground);
}
/* Inline code */
.rich-text-editor code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8em;
background: var(--muted);
color: var(--foreground);
padding: 0.15em 0.35em;
border-radius: calc(var(--radius) * 0.6);
}
/* Code blocks */
.rich-text-editor pre {
background: var(--muted);
border-radius: var(--radius);
padding: 0.75rem 1rem;
margin: 0.5rem 0;
overflow-x: auto;
}
.rich-text-editor pre code {
background: none;
padding: 0;
font-size: 0.8125rem;
line-height: 1.6;
}
/* Blockquotes */
.rich-text-editor blockquote {
border-left: 2px solid var(--border);
padding-left: 0.75rem;
margin: 0.5rem 0;
color: var(--muted-foreground);
}
/* Horizontal rules */
.rich-text-editor hr {
border: none;
border-top: 1px solid var(--border);
margin: 1rem 0;
}
/* Links */
.rich-text-editor a {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
}
/* Strong / emphasis */
.rich-text-editor strong {
font-weight: 600;
}
.rich-text-editor em {
font-style: italic;
}
.rich-text-editor s {
text-decoration: line-through;
color: var(--muted-foreground);
}

View file

@ -0,0 +1,150 @@
"use client";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
import { Markdown } from "tiptap-markdown";
import { Extension } from "@tiptap/core";
import { cn } from "@/lib/utils";
import "./rich-text-editor.css";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface RichTextEditorProps {
defaultValue?: string;
onUpdate?: (markdown: string) => void;
placeholder?: string;
editable?: boolean;
className?: string;
debounceMs?: number;
onSubmit?: () => void;
}
interface RichTextEditorRef {
getMarkdown: () => string;
clearContent: () => void;
focus: () => void;
}
// ---------------------------------------------------------------------------
// Submit shortcut extension (Mod+Enter)
// ---------------------------------------------------------------------------
function createSubmitExtension(onSubmit: () => void) {
return Extension.create({
name: "submitShortcut",
addKeyboardShortcuts() {
return {
"Mod-Enter": () => {
onSubmit();
return true;
},
};
},
});
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
function RichTextEditor(
{
defaultValue = "",
onUpdate,
placeholder: placeholderText = "",
editable = true,
className,
debounceMs = 300,
onSubmit,
},
ref,
) {
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const onUpdateRef = useRef(onUpdate);
const onSubmitRef = useRef(onSubmit);
// Helper to get markdown from tiptap-markdown storage
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getEditorMarkdown = (ed: any): string =>
ed?.storage?.markdown?.getMarkdown?.() ?? "";
// Keep refs in sync without recreating editor
onUpdateRef.current = onUpdate;
onSubmitRef.current = onSubmit;
const editor = useEditor({
immediatelyRender: false,
editable,
content: defaultValue,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Placeholder.configure({
placeholder: placeholderText,
}),
Link.configure({
openOnClick: false,
autolink: true,
HTMLAttributes: {
class: "text-primary hover:underline cursor-pointer",
},
}),
Typography,
Markdown.configure({
html: false,
transformPastedText: true,
transformCopiedText: true,
}),
createSubmitExtension(() => onSubmitRef.current?.()),
],
onUpdate: ({ editor: ed }) => {
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdateRef.current?.(getEditorMarkdown(ed));
}, debounceMs);
},
editorProps: {
attributes: {
class: cn("rich-text-editor text-sm outline-none", className),
},
},
});
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
useImperativeHandle(ref, () => ({
getMarkdown: () => getEditorMarkdown(editor),
clearContent: () => {
editor?.commands.clearContent();
},
focus: () => {
editor?.commands.focus();
},
}));
if (!editor) return null;
return <EditorContent editor={editor} />;
},
);
export { RichTextEditor, type RichTextEditorProps, type RichTextEditorRef };

View file

@ -35,7 +35,7 @@ function ResizableHandle({
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
"relative flex w-0 items-center justify-center before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors hover:before:bg-foreground/15 data-[separator=active]:before:bg-foreground/15 after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2 focus-visible:outline-hidden aria-[orientation=horizontal]:h-0 aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:before:inset-x-0 aria-[orientation=horizontal]:before:inset-y-auto aria-[orientation=horizontal]:before:h-px aria-[orientation=horizontal]:before:w-full aria-[orientation=horizontal]:before:translate-x-0 aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-2 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2",
className
)}
{...props}

View file

@ -27,7 +27,10 @@ import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_DEFAULT = 256
const SIDEBAR_WIDTH_MIN = 200
const SIDEBAR_WIDTH_MAX = 360
const SIDEBAR_WIDTH_STORAGE_KEY = "sidebar_width"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
@ -40,6 +43,10 @@ type SidebarContextProps = {
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
width: number
setWidth: (width: number) => void
isResizing: boolean
setIsResizing: (v: boolean) => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
@ -69,6 +76,18 @@ function SidebarProvider({
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const [width, _setWidth] = React.useState(SIDEBAR_WIDTH_DEFAULT)
const [isResizing, setIsResizing] = React.useState(false)
React.useEffect(() => {
const stored = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)
if (stored) _setWidth(Number(stored))
}, [])
const setWidth = React.useCallback((w: number) => {
const clamped = Math.max(SIDEBAR_WIDTH_MIN, Math.min(SIDEBAR_WIDTH_MAX, w))
_setWidth(clamped)
localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(clamped))
}, [])
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
@ -122,8 +141,12 @@ function SidebarProvider({
openMobile,
setOpenMobile,
toggleSidebar,
width,
setWidth,
isResizing,
setIsResizing,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, width, setWidth, isResizing, setIsResizing]
)
return (
@ -132,7 +155,7 @@ function SidebarProvider({
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width": `${width}px`,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
@ -162,7 +185,7 @@ function Sidebar({
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { isMobile, state, openMobile, setOpenMobile, isResizing } = useSidebar()
if (collapsible === "none") {
return (
@ -218,7 +241,8 @@ function Sidebar({
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"relative w-(--sidebar-width) bg-transparent",
!isResizing && "transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
@ -230,7 +254,8 @@ function Sidebar({
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
!isResizing && "transition-[left,right,width] duration-200 ease-linear",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
@ -278,7 +303,45 @@ function SidebarTrigger({
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
const { toggleSidebar, setWidth, setIsResizing } = useSidebar()
const didDragRef = React.useRef(false)
const dragRef = React.useRef<{ startX: number; startWidth: number } | null>(null)
const onMouseDown = React.useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
didDragRef.current = false
const sidebarEl = (e.target as HTMLElement).closest("[data-slot='sidebar']")
const containerEl = sidebarEl?.querySelector("[data-slot='sidebar-container']")
if (!containerEl) return
dragRef.current = { startX: e.clientX, startWidth: containerEl.getBoundingClientRect().width }
setIsResizing(true)
const onMouseMove = (ev: MouseEvent) => {
if (!dragRef.current) return
didDragRef.current = true
const delta = ev.clientX - dragRef.current.startX
setWidth(dragRef.current.startWidth + delta)
}
const onMouseUp = () => {
dragRef.current = null
setIsResizing(false)
document.removeEventListener("mousemove", onMouseMove)
document.removeEventListener("mouseup", onMouseUp)
document.body.style.cursor = ""
document.body.style.userSelect = ""
}
document.addEventListener("mousemove", onMouseMove)
document.addEventListener("mouseup", onMouseUp)
document.body.style.cursor = "col-resize"
document.body.style.userSelect = "none"
},
[setWidth, setIsResizing]
)
const handleClick = React.useCallback(() => {
if (!didDragRef.current) toggleSidebar()
}, [toggleSidebar])
return (
<button
@ -286,12 +349,12 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
onClick={handleClick}
onMouseDown={onMouseDown}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"in-data-[side=left]:cursor-col-resize in-data-[side=right]:cursor-col-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",

View file

@ -1,7 +1,7 @@
"use client";
import { create } from "zustand";
import type { User } from "@multica/types";
import type { User } from "@/shared/types";
import { api } from "@/shared/api";
interface AuthState {

View file

@ -1,7 +1,7 @@
"use client";
import { create } from "zustand";
import type { InboxItem, IssueStatus } from "@multica/types";
import type { InboxItem, IssueStatus } from "@/shared/types";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";

View file

@ -3,7 +3,8 @@
import Link from "next/link";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Issue } from "@multica/types";
import type { Issue } from "@/shared/types";
import { CalendarDays } from "lucide-react";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { PriorityIcon } from "./priority-icon";
@ -21,7 +22,7 @@ export function BoardCardContent({ issue }: { issue: Issue }) {
<PriorityIcon priority={issue.priority} />
<span>{issue.id.slice(0, 8)}</span>
</div>
<p className="mt-1.5 text-sm leading-snug">{issue.title}</p>
<p className="mt-1.5 text-sm leading-snug line-clamp-2">{issue.title}</p>
<div className="mt-2.5 flex items-center justify-between">
<div className="flex items-center gap-2">
{issue.assignee_type && issue.assignee_id && (
@ -33,7 +34,8 @@ export function BoardCardContent({ issue }: { issue: Issue }) {
)}
</div>
{issue.due_date && (
<span className="text-xs text-muted-foreground">
<span className={`flex items-center gap-1 text-xs ${new Date(issue.due_date) < new Date() ? "text-destructive" : "text-muted-foreground"}`}>
<CalendarDays className="size-3" />
{formatDate(issue.due_date)}
</span>
)}

View file

@ -1,8 +1,18 @@
"use client";
import { EyeOff, MoreHorizontal, Plus } from "lucide-react";
import { useDroppable } from "@dnd-kit/core";
import type { Issue, IssueStatus } from "@multica/types";
import type { Issue, IssueStatus } from "@/shared/types";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { STATUS_CONFIG } from "@/features/issues/config";
import { useModalStore } from "@/features/modals";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
import { StatusIcon } from "./status-icon";
import { DraggableBoardCard } from "./board-card";
@ -17,11 +27,41 @@ export function BoardColumn({
const { setNodeRef, isOver } = useDroppable({ id: status });
return (
<div className="flex min-w-52 flex-1 flex-col">
<div className="mb-2 flex items-center gap-2 px-1">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-xs font-medium">{cfg.label}</span>
<span className="text-xs text-muted-foreground">{issues.length}</span>
<div className="flex w-64 shrink-0 flex-col">
<div className="mb-2 flex items-center justify-between px-1">
{/* Left: icon + label + count */}
<div className="flex items-center gap-2">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-xs font-medium">{cfg.label}</span>
<span className="text-xs text-muted-foreground">{issues.length}</span>
</div>
{/* Right: add + menu */}
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground">
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => useIssueViewStore.getState().hideStatus(status)}>
<EyeOff className="size-3.5" />
Hide column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
onClick={() => useModalStore.getState().open("create-issue", { status })}
>
<Plus className="size-3.5" />
</Button>
</div>
</div>
<div
ref={setNodeRef}

View file

@ -13,7 +13,7 @@ import {
type DragStartEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import type { Issue, IssueStatus } from "@multica/types";
import type { Issue, IssueStatus } from "@/shared/types";
import { BoardColumn } from "./board-column";
import { BoardCardContent } from "./board-card";

View file

@ -1,5 +1,5 @@
export { StatusIcon } from "./status-icon";
export { PriorityIcon } from "./priority-icon";
export { StatusPicker, PriorityPicker, AssigneePicker } from "./pickers";
export { StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
export { IssueDetail } from "./issue-detail";
export { IssuesPage } from "./issues-page";

View file

@ -1,14 +1,20 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowUp,
Bot,
Calendar,
ChevronRight,
Link2,
MoreHorizontal,
PanelRight,
Pencil,
Send,
Trash2,
UserMinus,
X,
} from "lucide-react";
import { toast } from "sonner";
@ -21,31 +27,41 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from "@/components/ui/dropdown-menu";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { Markdown } from "@/components/markdown";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { ActorAvatar } from "@/components/common/actor-avatar";
import type { Issue, Comment, UpdateIssueRequest } from "@multica/types";
import { StatusPicker, PriorityPicker, AssigneePicker } from "@/features/issues/components";
import type { Issue, Comment, UpdateIssueRequest, IssueStatus, IssuePriority } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useActorName } from "@/features/workspace";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useWSEvent } from "@/features/realtime";
import { useIssueStore } from "@/features/issues";
import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload } from "@multica/types";
import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload } from "@/shared/types";
// ---------------------------------------------------------------------------
// Helpers
@ -82,69 +98,15 @@ function PropRow({
children: React.ReactNode;
}) {
return (
<div className="flex min-h-8 items-center gap-3 rounded-md px-2 -mx-2 hover:bg-accent/50 transition-colors">
<span className="w-20 shrink-0 text-sm text-muted-foreground">{label}</span>
<div className="flex min-w-0 flex-1 items-center justify-end gap-1.5 text-sm">
<div className="flex min-h-8 items-center gap-2 rounded-md px-2 -mx-2 hover:bg-accent/50 transition-colors">
<span className="w-16 shrink-0 text-xs text-muted-foreground">{label}</span>
<div className="flex min-w-0 flex-1 items-center gap-1.5 text-sm truncate">
{children}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Due Date Picker
// ---------------------------------------------------------------------------
function DueDatePicker({
dueDate,
onUpdate,
}: {
dueDate: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
}) {
const [open, setOpen] = useState(false);
const date = dueDate ? new Date(dueDate) : undefined;
const isOverdue = date ? date < new Date() : false;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors">
{date ? (
<span className={isOverdue ? "text-destructive" : ""}>
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</span>
) : (
<span className="text-muted-foreground">None</span>
)}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
selected={date}
onSelect={(d: Date | undefined) => {
onUpdate({ due_date: d ? d.toISOString() : null });
setOpen(false);
}}
/>
{date && (
<div className="border-t px-3 py-2">
<Button
variant="ghost"
size="xs"
onClick={() => {
onUpdate({ due_date: null });
setOpen(false);
}}
className="text-muted-foreground hover:text-foreground"
>
Clear date
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}
// ---------------------------------------------------------------------------
// Acceptance Criteria Editor
@ -310,7 +272,6 @@ function ContextRefsEditor({
interface IssueDetailProps {
issueId: string;
showBreadcrumb?: boolean;
onDelete?: () => void;
}
@ -318,23 +279,30 @@ interface IssueDetailProps {
// IssueDetail
// ---------------------------------------------------------------------------
export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailProps) {
export function IssueDetail({ issueId, onDelete }: IssueDetailProps) {
const id = issueId;
const router = useRouter();
const user = useAuthStore((s) => s.user);
const { getActorName } = useActorName();
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const { getActorName, getActorInitials } = useActorName();
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_issue_detail_layout",
});
const sidebarRef = usePanelRef();
const [sidebarOpen, setSidebarOpen] = useState(true);
const [issue, setIssue] = useState<Issue | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [commentText, setCommentText] = useState("");
const [commentEmpty, setCommentEmpty] = useState(true);
const commentEditorRef = useRef<RichTextEditorRef>(null);
const [submitting, setSubmitting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
const [editingDesc, setEditingDesc] = useState(false);
const [descDraft, setDescDraft] = useState("");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// Watch the global issue store for real-time updates from other users/agents
const storeIssue = useIssueStore((s) => s.issues.find((i) => i.id === id));
@ -358,10 +326,9 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
.finally(() => setLoading(false));
}, [id]);
const handleSubmitComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!commentText.trim() || submitting || !user) return;
const content = commentText.trim();
const handleSubmitComment = async () => {
const content = commentEditorRef.current?.getMarkdown()?.trim();
if (!content || submitting || !user) return;
const tempId = "temp-" + Date.now();
const tempComment: Comment = {
id: tempId,
@ -374,7 +341,8 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
updated_at: new Date().toISOString(),
};
setComments((prev) => [...prev, tempComment]);
setCommentText("");
commentEditorRef.current?.clearContent();
setCommentEmpty(true);
setSubmitting(true);
try {
const comment = await api.createComment(id, content);
@ -490,28 +458,186 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
}
return (
<div className="flex flex-1 min-h-0">
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="content" minSize="50%">
{/* LEFT: Content area */}
<div className="flex-1 overflow-y-auto">
<div className="flex h-full flex-col">
{/* Header bar */}
{showBreadcrumb !== false && (
<div className="sticky top-0 z-10 flex h-11 items-center justify-between border-b bg-background px-6 text-sm">
<div className="flex items-center gap-1.5">
<Link
href="/issues"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Issues
</Link>
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
<span className="truncate text-muted-foreground">{issue.id.slice(0, 8)}</span>
</div>
<AlertDialog>
<AlertDialogTrigger
render={<Button variant="ghost" size="icon-xs" className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive" />}
>
<Trash2 className="h-4 w-4" />
</AlertDialogTrigger>
<div className="flex h-12 shrink-0 items-center justify-between border-b bg-background px-4 text-sm">
<div className="flex items-center gap-1.5">
<Link
href="/issues"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Issues
</Link>
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
<span className="truncate text-muted-foreground">{issue.id.slice(0, 8)}</span>
</div>
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-xs" className="text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-auto">
{/* Status */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
Status
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{ALL_STATUSES.map((s) => (
<DropdownMenuItem
key={s}
onClick={() => handleUpdateField({ status: s })}
>
<StatusIcon status={s} className="h-3.5 w-3.5" />
{STATUS_CONFIG[s].label}
{issue.status === s && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Priority */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PriorityIcon priority={issue.priority} />
Priority
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{PRIORITY_ORDER.map((p) => (
<DropdownMenuItem
key={p}
onClick={() => handleUpdateField({ priority: p })}
>
<PriorityIcon priority={p} />
{PRIORITY_CONFIG[p].label}
{issue.priority === p && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Assignee */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<UserMinus className="h-3.5 w-3.5" />
Assignee
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() => handleUpdateField({ assignee_type: null, assignee_id: null })}
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
Unassigned
{!issue.assignee_type && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
{members.map((m) => (
<DropdownMenuItem
key={m.user_id}
onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}
>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
{m.name}
{issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
{agents.map((a) => (
<DropdownMenuItem
key={a.id}
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
{a.name}
{issue.assignee_type === "agent" && issue.assignee_id === a.id && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Due date */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Calendar className="h-3.5 w-3.5" />
Due date
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => handleUpdateField({ due_date: new Date().toISOString() })}>
Today
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
const d = new Date(); d.setDate(d.getDate() + 1);
handleUpdateField({ due_date: d.toISOString() });
}}>
Tomorrow
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
const d = new Date(); d.setDate(d.getDate() + 7);
handleUpdateField({ due_date: d.toISOString() });
}}>
Next week
</DropdownMenuItem>
{issue.due_date && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleUpdateField({ due_date: null })}>
Clear date
</DropdownMenuItem>
</>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
{/* Copy link */}
<DropdownMenuItem onClick={() => {
navigator.clipboard.writeText(window.location.href);
toast.success("Link copied");
}}>
<Link2 className="h-3.5 w-3.5" />
Copy link
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Delete */}
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete issue
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={sidebarOpen ? "secondary" : "ghost"}
size="icon-xs"
className={sidebarOpen ? "" : "text-muted-foreground"}
onClick={() => {
const panel = sidebarRef.current;
if (!panel) return;
if (panel.isCollapsed()) panel.expand();
else panel.collapse();
}}
>
<PanelRight className="h-4 w-4" />
</Button>
</div>
{/* Delete confirmation dialog (controlled by state) */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete issue</AlertDialogTitle>
@ -532,9 +658,9 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
</AlertDialogContent>
</AlertDialog>
</div>
)}
{/* Content */}
{/* Content — scrollable */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-3xl px-8 py-8">
<div className="mb-1 text-sm text-muted-foreground">{issue.id.slice(0, 8)}</div>
@ -566,33 +692,13 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
</h1>
)}
{editingDesc ? (
<Textarea
autoFocus
value={descDraft}
onChange={(e) => setDescDraft(e.target.value)}
onBlur={() => {
handleUpdateField({ description: descDraft.trim() || undefined });
setEditingDesc(false);
}}
onKeyDown={(e) => {
if (e.key === "Escape") setEditingDesc(false);
}}
rows={4}
className="mt-5 text-sm leading-relaxed resize-none"
/>
) : (
<div
className="mt-5 text-sm leading-relaxed whitespace-pre-wrap cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1"
onClick={() => { setDescDraft(issue.description || ""); setEditingDesc(true); }}
>
{issue.description ? (
<span className="text-foreground/85">{issue.description}</span>
) : (
<span className="text-muted-foreground">Add description...</span>
)}
</div>
)}
<RichTextEditor
defaultValue={issue.description || ""}
placeholder="Add description..."
onUpdate={(md) => handleUpdateField({ description: md || undefined })}
debounceMs={1500}
className="mt-5"
/>
<div className="space-y-4 mt-4">
<AcceptanceCriteriaEditor
@ -670,8 +776,8 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
/>
</form>
) : (
<div className="mt-2 pl-9.5 text-sm leading-relaxed text-foreground/85 whitespace-pre-wrap">
{comment.content}
<div className="mt-2 pl-9.5 text-sm leading-relaxed text-foreground/85">
<Markdown mode="minimal">{comment.content}</Markdown>
</div>
)}
</div>
@ -680,63 +786,197 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
</div>
{/* Comment input */}
<form onSubmit={handleSubmitComment} className="mt-2 border-t pt-4">
<div className="flex items-center gap-2">
<Input
type="text"
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
<div className="mt-4 rounded-md border bg-muted/30">
<div className="min-h-20 max-h-48 overflow-y-auto px-3 py-2">
<RichTextEditor
ref={commentEditorRef}
placeholder="Leave a comment..."
className="flex-1 text-sm"
onUpdate={(md) => setCommentEmpty(!md.trim())}
onSubmit={handleSubmitComment}
debounceMs={100}
/>
</div>
<div className="flex items-center justify-end px-2 pb-2">
<Button
type="submit"
size="icon"
disabled={!commentText.trim() || submitting}
size="icon-xs"
disabled={commentEmpty || submitting}
onClick={handleSubmitComment}
>
<Send className="h-3.5 w-3.5" />
<ArrowUp className="h-3.5 w-3.5" />
</Button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel
id="sidebar"
defaultSize={320}
minSize={260}
maxSize={420}
collapsible
groupResizeBehavior="preserve-pixel-size"
panelRef={sidebarRef}
onResize={(size) => setSidebarOpen(size.inPixels > 0)}
>
{/* RIGHT: Properties sidebar */}
<div className="w-60 shrink-0 overflow-y-auto border-l">
<div className="overflow-y-auto border-l h-full">
<div className="p-4">
<div className="mb-2 text-xs font-medium text-muted-foreground">
Properties
</div>
<div className="space-y-0.5">
{/* Status */}
<PropRow label="Status">
<StatusPicker status={issue.status} onUpdate={handleUpdateField} />
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{STATUS_CONFIG[issue.status].label}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
<DropdownMenuRadioGroup value={issue.status} onValueChange={(v) => handleUpdateField({ status: v as IssueStatus })}>
{ALL_STATUSES.map((s) => (
<DropdownMenuRadioItem key={s} value={s}>
<StatusIcon status={s} className="h-3.5 w-3.5" />
{STATUS_CONFIG[s].label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Priority */}
<PropRow label="Priority">
<PriorityPicker priority={issue.priority} onUpdate={handleUpdateField} />
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<PriorityIcon priority={issue.priority} className="shrink-0" />
<span className="truncate">{PRIORITY_CONFIG[issue.priority].label}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
<DropdownMenuRadioGroup value={issue.priority} onValueChange={(v) => handleUpdateField({ priority: v as IssuePriority })}>
{PRIORITY_ORDER.map((p) => (
<DropdownMenuRadioItem key={p} value={p}>
<PriorityIcon priority={p} />
{PRIORITY_CONFIG[p].label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Assignee */}
<PropRow label="Assignee">
<AssigneePicker
assigneeType={issue.assignee_type}
assigneeId={issue.assignee_id}
onUpdate={handleUpdateField}
/>
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
{issue.assignee_type && issue.assignee_id ? (
<>
<div className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] size-4 ${
issue.assignee_type === "agent" ? "bg-info/10 text-info" : "bg-muted text-muted-foreground"
}`}>
{issue.assignee_type === "agent" ? <Bot className="size-2.5" /> : getActorInitials(issue.assignee_type, issue.assignee_id)}
</div>
<span className="truncate">{getActorName(issue.assignee_type, issue.assignee_id)}</span>
</>
) : (
<span className="text-muted-foreground">Unassigned</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuItem onClick={() => handleUpdateField({ assignee_type: null, assignee_id: null })}>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
Unassigned
</DropdownMenuItem>
{members.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Members</DropdownMenuLabel>
{members.map((m) => (
<DropdownMenuItem key={m.user_id} onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
{m.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</>
)}
{agents.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Agents</DropdownMenuLabel>
{agents.map((a) => (
<DropdownMenuItem key={a.id} onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
{a.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Due date */}
<PropRow label="Due date">
<DueDatePicker dueDate={issue.due_date} onUpdate={handleUpdateField} />
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<Calendar className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{issue.due_date ? (
<span className={new Date(issue.due_date) < new Date() ? "text-destructive" : ""}>
{shortDate(issue.due_date)}
</span>
) : (
<span className="text-muted-foreground">None</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-auto">
<DropdownMenuItem onClick={() => handleUpdateField({ due_date: new Date().toISOString() })}>
Today
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
const d = new Date(); d.setDate(d.getDate() + 1);
handleUpdateField({ due_date: d.toISOString() });
}}>
Tomorrow
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
const d = new Date(); d.setDate(d.getDate() + 7);
handleUpdateField({ due_date: d.toISOString() });
}}>
Next week
</DropdownMenuItem>
{issue.due_date && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleUpdateField({ due_date: null })}>
Clear date
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Created by */}
<PropRow label="Created by">
<ActorAvatar
actorType={issue.creator_type}
actorId={issue.creator_id}
size={18}
/>
<span>{getActorName(issue.creator_type, issue.creator_id)}</span>
<span className="truncate">{getActorName(issue.creator_type, issue.creator_id)}</span>
</PropRow>
</div>
@ -750,6 +990,7 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
</div>
</div>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}

View file

@ -44,7 +44,7 @@ export function IssuesHeader() {
const togglePriorityFilter = useIssueViewStore((s) => s.togglePriorityFilter);
return (
<div className="flex shrink-0 items-center justify-between px-4 py-2">
<div className="flex h-12 shrink-0 items-center justify-between px-4">
<div className="flex items-center gap-2">
{/* Status filter */}
<DropdownMenu>
@ -72,7 +72,7 @@ export function IssuesHeader() {
{ALL_STATUSES.map((s) => (
<DropdownMenuCheckboxItem
key={s}
checked={statusFilters.includes(s)}
checked={statusFilters.length === 0 || statusFilters.includes(s)}
onCheckedChange={() => toggleStatusFilter(s)}
>
<StatusIcon status={s} className="h-3.5 w-3.5" />
@ -109,7 +109,7 @@ export function IssuesHeader() {
{PRIORITY_ORDER.map((p) => (
<DropdownMenuCheckboxItem
key={p}
checked={priorityFilters.includes(p)}
checked={priorityFilters.length === 0 || priorityFilters.includes(p)}
onCheckedChange={() => togglePriorityFilter(p)}
>
<PriorityIcon priority={p} />

View file

@ -3,7 +3,7 @@
import { useCallback, useMemo } from "react";
import { toast } from "sonner";
import { ChevronRight } from "lucide-react";
import type { IssueStatus } from "@multica/types";
import type { IssueStatus } from "@/shared/types";
import { Skeleton } from "@/components/ui/skeleton";
import { useIssueStore } from "@/features/issues/store";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
@ -68,11 +68,11 @@ export function IssuesPage() {
if (loading) {
return (
<div className="flex flex-1 min-h-0 flex-col">
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2">
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex shrink-0 items-center justify-between border-b px-4 py-2">
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-24" />
</div>
@ -92,7 +92,7 @@ export function IssuesPage() {
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Header 1: Workspace breadcrumb */}
<div className="flex shrink-0 items-center gap-1.5 border-b px-4 py-2">
<div className="flex h-12 shrink-0 items-center gap-1.5 border-b px-4">
<WorkspaceAvatar name={workspace?.name ?? "W"} size="sm" />
<span className="text-sm text-muted-foreground">
{workspace?.name ?? "Workspace"}

View file

@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import type { Issue } from "@multica/types";
import type { Issue } from "@/shared/types";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { StatusIcon } from "./status-icon";
import { PriorityIcon } from "./priority-icon";

View file

@ -1,6 +1,6 @@
"use client";
import type { Issue } from "@multica/types";
import type { Issue } from "@/shared/types";
import { STATUS_ORDER, STATUS_CONFIG } from "@/features/issues/config";
import { StatusIcon } from "./status-icon";
import { ListRow } from "./list-row";
@ -9,14 +9,14 @@ export function ListView({ issues }: { issues: Issue[] }) {
const groupOrder = STATUS_ORDER.filter((s) => s !== "cancelled");
return (
<div className="overflow-y-auto">
<div className="flex-1 min-h-0 overflow-y-auto">
{groupOrder.map((status) => {
const cfg = STATUS_CONFIG[status];
const filtered = issues.filter((i) => i.status === status);
if (filtered.length === 0) return null;
return (
<div key={status}>
<div className="flex h-8 items-center gap-2 border-b px-4">
<div className="flex h-12 items-center gap-2 border-b px-4">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-xs font-medium">{cfg.label}</span>
<span className="text-xs text-muted-foreground">

View file

@ -2,7 +2,7 @@
import { useState } from "react";
import { Bot, UserMinus } from "lucide-react";
import type { IssueAssigneeType, UpdateIssueRequest } from "@multica/types";
import type { IssueAssigneeType, UpdateIssueRequest } from "@/shared/types";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import {
PropertyPicker,
@ -69,7 +69,7 @@ export function AssigneePicker({
getActorInitials(assigneeType, assigneeId)
)}
</div>
<span>{triggerLabel}</span>
<span className="truncate">{triggerLabel}</span>
</>
) : (
<span className="text-muted-foreground">Unassigned</span>

View file

@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
import { CalendarDays } from "lucide-react";
import type { UpdateIssueRequest } from "@/shared/types";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
export function DueDatePicker({
dueDate,
onUpdate,
}: {
dueDate: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
}) {
const [open, setOpen] = useState(false);
const date = dueDate ? new Date(dueDate) : undefined;
const isOverdue = date ? date < new Date() : false;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors">
<CalendarDays className="h-3.5 w-3.5 text-muted-foreground" />
{date ? (
<span className={isOverdue ? "text-destructive" : ""}>
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</span>
) : (
<span className="text-muted-foreground">Due date</span>
)}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={(d: Date | undefined) => {
onUpdate({ due_date: d ? d.toISOString() : null });
setOpen(false);
}}
/>
{date && (
<div className="border-t px-3 py-2">
<Button
variant="ghost"
size="xs"
onClick={() => {
onUpdate({ due_date: null });
setOpen(false);
}}
className="text-muted-foreground hover:text-foreground"
>
Clear date
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}

View file

@ -2,3 +2,4 @@ export { PropertyPicker, PickerItem, PickerSection, PickerEmpty } from "./proper
export { StatusPicker } from "./status-picker";
export { PriorityPicker } from "./priority-picker";
export { AssigneePicker } from "./assignee-picker";
export { DueDatePicker } from "./due-date-picker";

View file

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import type { IssuePriority, UpdateIssueRequest } from "@multica/types";
import type { IssuePriority, UpdateIssueRequest } from "@/shared/types";
import { PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { PriorityIcon } from "../priority-icon";
import { PropertyPicker, PickerItem } from "./property-picker";
@ -23,8 +23,8 @@ export function PriorityPicker({
width="w-44"
trigger={
<>
<PriorityIcon priority={priority} />
<span>{cfg.label}</span>
<PriorityIcon priority={priority} className="shrink-0" />
<span className="truncate">{cfg.label}</span>
</>
}
>

View file

@ -48,7 +48,7 @@ export function PropertyPicker({
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors">
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
{trigger}
</PopoverTrigger>
<PopoverContent align={align} className={`${width} gap-0 p-0`}>

View file

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import type { IssueStatus, UpdateIssueRequest } from "@multica/types";
import type { IssueStatus, UpdateIssueRequest } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
import { StatusIcon } from "../status-icon";
import { PropertyPicker, PickerItem } from "./property-picker";
@ -23,8 +23,8 @@ export function StatusPicker({
width="w-44"
trigger={
<>
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span>{cfg.label}</span>
<StatusIcon status={status} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{cfg.label}</span>
</>
}
>

View file

@ -1,4 +1,4 @@
import type { IssuePriority } from "@multica/types";
import type { IssuePriority } from "@/shared/types";
import { PRIORITY_CONFIG } from "@/features/issues/config";
export function PriorityIcon({

View file

@ -1,4 +1,4 @@
import type { IssueStatus } from "@multica/types";
import type { IssueStatus } from "@/shared/types";
import { STATUS_CONFIG } from "@/features/issues/config";
// ---------------------------------------------------------------------------

View file

@ -1,4 +1,4 @@
import type { IssuePriority } from "@multica/types";
import type { IssuePriority } from "@/shared/types";
export const PRIORITY_ORDER: IssuePriority[] = [
"urgent",

View file

@ -1,4 +1,4 @@
import type { IssueStatus } from "@multica/types";
import type { IssueStatus } from "@/shared/types";
export const STATUS_ORDER: IssueStatus[] = [
"backlog",

View file

@ -1,7 +1,7 @@
"use client";
import { create } from "zustand";
import type { Issue } from "@multica/types";
import type { Issue } from "@/shared/types";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";

View file

@ -2,7 +2,8 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { IssueStatus, IssuePriority } from "@multica/types";
import type { IssueStatus, IssuePriority } from "@/shared/types";
import { ALL_STATUSES, PRIORITY_ORDER } from "@/features/issues/config";
export type ViewMode = "board" | "list";
@ -13,6 +14,7 @@ interface IssueViewState {
setViewMode: (mode: ViewMode) => void;
toggleStatusFilter: (status: IssueStatus) => void;
togglePriorityFilter: (priority: IssuePriority) => void;
hideStatus: (status: IssueStatus) => void;
clearFilters: () => void;
}
@ -25,16 +27,30 @@ export const useIssueViewStore = create<IssueViewState>()(
setViewMode: (mode) => set({ viewMode: mode }),
toggleStatusFilter: (status) =>
set((state) => ({
statusFilters: state.statusFilters.includes(status)
set((state) => {
if (state.statusFilters.length === 0) {
return { statusFilters: ALL_STATUSES.filter((s) => s !== status) };
}
const next = state.statusFilters.includes(status)
? state.statusFilters.filter((s) => s !== status)
: [...state.statusFilters, status],
})),
: [...state.statusFilters, status];
return { statusFilters: next.length >= ALL_STATUSES.length ? [] : next };
}),
togglePriorityFilter: (priority) =>
set((state) => ({
priorityFilters: state.priorityFilters.includes(priority)
set((state) => {
if (state.priorityFilters.length === 0) {
return { priorityFilters: PRIORITY_ORDER.filter((p) => p !== priority) };
}
const next = state.priorityFilters.includes(priority)
? state.priorityFilters.filter((p) => p !== priority)
: [...state.priorityFilters, priority],
: [...state.priorityFilters, priority];
return { priorityFilters: next.length >= PRIORITY_ORDER.length ? [] : next };
}),
hideStatus: (status) =>
set((state) => ({
statusFilters: state.statusFilters.length === 0
? ALL_STATUSES.filter((s) => s !== status)
: state.statusFilters.filter((s) => s !== status),
})),
clearFilters: () => set({ statusFilters: [], priorityFilters: [] }),
}),

View file

@ -1,51 +1,110 @@
"use client";
import { useState } from "react";
import { useState, useRef } from "react";
import { Bot, CalendarDays, ChevronRight, Maximize2, Minimize2, UserMinus, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import type { IssueStatus, IssuePriority, IssueAssigneeType } from "@multica/types";
import { STATUS_CONFIG, ALL_STATUSES, PRIORITY_CONFIG, PRIORITY_ORDER } from "@/features/issues/config";
import type { IssueStatus, IssuePriority, IssueAssigneeType } from "@/shared/types";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { StatusIcon, PriorityIcon, AssigneePicker } from "@/features/issues/components";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useIssueStore } from "@/features/issues";
import { api } from "@/shared/api";
export function CreateIssueModal({ onClose }: { onClose: () => void }) {
// ---------------------------------------------------------------------------
// Pill trigger — shared rounded-full button style for toolbar
// ---------------------------------------------------------------------------
function PillButton({
children,
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type="button"
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
"hover:bg-accent/60 transition-colors cursor-pointer",
className,
)}
{...props}
>
{children}
</button>
);
}
// ---------------------------------------------------------------------------
// CreateIssueModal
// ---------------------------------------------------------------------------
export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?: Record<string, unknown> | null }) {
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const { getActorName, getActorInitials } = useActorName();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState<IssueStatus>("todo");
const descEditorRef = useRef<RichTextEditorRef>(null);
const [status, setStatus] = useState<IssueStatus>((data?.status as IssueStatus) || "todo");
const [priority, setPriority] = useState<IssuePriority>("none");
const [submitting, setSubmitting] = useState(false);
const [assigneeType, setAssigneeType] = useState<IssueAssigneeType | undefined>();
const [assigneeId, setAssigneeId] = useState<string | undefined>();
const [dueDate, setDueDate] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
// Assignee popover
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeFilter, setAssigneeFilter] = useState("");
// Due date popover
const [dueDateOpen, setDueDateOpen] = useState(false);
const assigneeQuery = assigneeFilter.toLowerCase();
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(assigneeQuery));
const filteredAgents = agents.filter((a) => a.name.toLowerCase().includes(assigneeQuery));
const assigneeLabel =
assigneeType && assigneeId
? getActorName(assigneeType, assigneeId)
: "Assignee";
const dueDateObj = dueDate ? new Date(dueDate) : undefined;
const handleSubmit = async () => {
if (!title.trim()) return;
if (!title.trim() || submitting) return;
setSubmitting(true);
try {
const issue = await api.createIssue({
title: title.trim(),
description: description.trim() || undefined,
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
status,
priority,
assignee_type: assigneeType,
assignee_id: assigneeId,
due_date: dueDate || undefined,
});
useIssueStore.getState().addIssue(issue);
onClose();
@ -58,12 +117,44 @@ export function CreateIssueModal({ onClose }: { onClose: () => void }) {
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New Issue</DialogTitle>
<DialogDescription className="sr-only">Create a new issue for the workspace.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<DialogContent
showCloseButton={false}
className={cn(
"p-0 gap-0 flex flex-col overflow-hidden",
"!top-1/2 !left-1/2 !-translate-x-1/2",
"!transition-all !duration-300 !ease-out",
isExpanded
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
)}
>
<DialogTitle className="sr-only">New Issue</DialogTitle>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">{workspaceName}</span>
<ChevronRight className="size-3 text-muted-foreground/50" />
<span className="font-medium">New issue</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</button>
<button
onClick={onClose}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Title */}
<div className="px-5 pb-2 shrink-0">
<Input
autoFocus
type="text"
@ -76,55 +167,211 @@ export function CreateIssueModal({ onClose }: { onClose: () => void }) {
}
}}
placeholder="Issue title"
className="border-none shadow-none px-0 text-lg font-semibold focus-visible:ring-0"
/>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add description..."
rows={3}
className="resize-none"
/>
<div className="flex items-center gap-3 flex-wrap">
<Select value={status} onValueChange={(v) => setStatus(v as IssueStatus)}>
<SelectTrigger size="sm" className="text-xs">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{ALL_STATUSES.map((s) => (
<SelectItem key={s} value={s}>{STATUS_CONFIG[s].label}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={priority} onValueChange={(v) => setPriority(v as IssuePriority)}>
<SelectTrigger size="sm" className="text-xs">
<PriorityIcon priority={priority} />
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIORITY_ORDER.map((p) => (
<SelectItem key={p} value={p}>{PRIORITY_CONFIG[p].label}</SelectItem>
))}
</SelectContent>
</Select>
<AssigneePicker
assigneeType={assigneeType ?? null}
assigneeId={assigneeId ?? null}
onUpdate={(updates) => {
setAssigneeType(updates.assignee_type ?? undefined);
setAssigneeId(updates.assignee_id ?? undefined);
}}
/>
</div>
</div>
<DialogFooter>
<Button
onClick={handleSubmit}
disabled={!title.trim() || submitting}
>
{/* Description — takes remaining space */}
<div className="flex-1 min-h-0 overflow-y-auto px-5">
<RichTextEditor
ref={descEditorRef}
placeholder="Add description..."
/>
</div>
{/* Property toolbar */}
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
{/* Status */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<StatusIcon status={status} className="size-3.5" />
<span>{STATUS_CONFIG[status].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{ALL_STATUSES.map((s) => (
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
<StatusIcon status={s} className="size-3.5" />
<span>{STATUS_CONFIG[s].label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Priority */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<PriorityIcon priority={priority} />
<span>{PRIORITY_CONFIG[priority].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PRIORITY_ORDER.map((p) => (
<DropdownMenuItem key={p} onClick={() => setPriority(p)}>
<PriorityIcon priority={p} />
<span>{PRIORITY_CONFIG[p].label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Assignee — Popover for search support */}
<Popover open={assigneeOpen} onOpenChange={(v) => { setAssigneeOpen(v); if (!v) setAssigneeFilter(""); }}>
<PopoverTrigger
render={
<PillButton>
{assigneeType && assigneeId ? (
<>
<div
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] size-4",
assigneeType === "agent" ? "bg-info/10 text-info" : "bg-muted text-muted-foreground",
)}
>
{assigneeType === "agent" ? <Bot className="size-2.5" /> : getActorInitials(assigneeType, assigneeId)}
</div>
<span>{assigneeLabel}</span>
</>
) : (
<span className="text-muted-foreground">Assignee</span>
)}
</PillButton>
}
/>
<PopoverContent align="start" className="w-52 p-0">
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={assigneeFilter}
onChange={(e) => setAssigneeFilter(e.target.value)}
placeholder="Assign to..."
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="p-1 max-h-60 overflow-y-auto">
{/* Unassigned */}
<button
type="button"
onClick={() => {
setAssigneeType(undefined);
setAssigneeId(undefined);
setAssigneeOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Unassigned</span>
</button>
{/* Members */}
{filteredMembers.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Members</div>
{filteredMembers.map((m) => (
<button
type="button"
key={m.user_id}
onClick={() => {
setAssigneeType("member");
setAssigneeId(m.user_id);
setAssigneeOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
<span>{m.name}</span>
</button>
))}
</>
)}
{/* Agents */}
{filteredAgents.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Agents</div>
{filteredAgents.map((a) => (
<button
type="button"
key={a.id}
onClick={() => {
setAssigneeType("agent");
setAssigneeId(a.id);
setAssigneeOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
<span>{a.name}</span>
</button>
))}
</>
)}
{filteredMembers.length === 0 && filteredAgents.length === 0 && assigneeFilter && (
<div className="px-2 py-3 text-center text-sm text-muted-foreground">No results</div>
)}
</div>
</PopoverContent>
</Popover>
{/* Due date */}
<Popover open={dueDateOpen} onOpenChange={setDueDateOpen}>
<PopoverTrigger
render={
<PillButton>
<CalendarDays className="size-3.5 text-muted-foreground" />
{dueDateObj ? (
<span>{dueDateObj.toLocaleDateString("en-US", { month: "short", day: "numeric" })}</span>
) : (
<span className="text-muted-foreground">Due date</span>
)}
</PillButton>
}
/>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dueDateObj}
onSelect={(d: Date | undefined) => {
setDueDate(d ? d.toISOString() : null);
setDueDateOpen(false);
}}
/>
{dueDateObj && (
<div className="border-t px-3 py-2">
<Button
variant="ghost"
size="xs"
onClick={() => {
setDueDate(null);
setDueDateOpen(false);
}}
className="text-muted-foreground hover:text-foreground"
>
Clear date
</Button>
</div>
)}
</PopoverContent>
</Popover>
</div>
{/* Footer */}
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Issue"}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);

View file

@ -6,13 +6,14 @@ import { CreateIssueModal } from "./create-issue";
export function ModalRegistry() {
const modal = useModalStore((s) => s.modal);
const data = useModalStore((s) => s.data);
const close = useModalStore((s) => s.close);
switch (modal) {
case "create-workspace":
return <CreateWorkspaceModal onClose={close} />;
case "create-issue":
return <CreateIssueModal onClose={close} />;
return <CreateIssueModal onClose={close} data={data} />;
default:
return null;
}

View file

@ -1,7 +1,7 @@
"use client";
import { useEffect } from "react";
import type { WSEventType } from "@multica/types";
import type { WSEventType } from "@/shared/types";
import { useWS } from "./provider";
type EventHandler = (payload: unknown) => void;

View file

@ -9,8 +9,8 @@ import {
useCallback,
type ReactNode,
} from "react";
import { WSClient } from "@multica/sdk";
import type { WSEventType } from "@multica/types";
import { WSClient } from "@/shared/api";
import type { WSEventType } from "@/shared/types";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { createLogger } from "@/shared/logger";

View file

@ -1,7 +1,7 @@
"use client";
import { useEffect } from "react";
import type { WSClient } from "@multica/sdk";
import type { WSClient } from "@/shared/api";
import { toast } from "sonner";
import { useIssueStore } from "@/features/issues";
import { useInboxStore } from "@/features/inbox";
@ -22,7 +22,7 @@ import type {
MemberAddedPayload,
MemberUpdatedPayload,
MemberRemovedPayload,
} from "@multica/types";
} from "@/shared/types";
const logger = createLogger("realtime-sync");

View file

@ -13,7 +13,7 @@ import {
XCircle,
Zap,
} from "lucide-react";
import type { AgentRuntime, RuntimeUsage, RuntimePingStatus } from "@multica/types";
import type { AgentRuntime, RuntimeUsage, RuntimePingStatus } from "@/shared/types";
import { Button } from "@/components/ui/button";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";

View file

@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useDefaultLayout } from "react-resizable-panels";
import {
Sparkles,
Plus,
@ -10,8 +11,9 @@ import {
FolderOpen,
AlertCircle,
X,
Download,
} from "lucide-react";
import type { Skill, CreateSkillRequest, UpdateSkillRequest } from "@multica/types";
import type { Skill, CreateSkillRequest, UpdateSkillRequest } from "@/shared/types";
import {
Dialog,
DialogContent,
@ -20,10 +22,16 @@ 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 { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
@ -36,22 +44,47 @@ import { useWSEvent } from "@/features/realtime";
function CreateSkillDialog({
onClose,
onCreate,
onImport,
}: {
onClose: () => void;
onCreate: (data: CreateSkillRequest) => Promise<void>;
onImport: (url: string) => Promise<void>;
}) {
const [tab, setTab] = useState<"create" | "import">("create");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [creating, setCreating] = useState(false);
const [importUrl, setImportUrl] = useState("");
const [loading, setLoading] = useState(false);
const [importError, setImportError] = useState("");
const handleSubmit = async () => {
const detectedSource = (() => {
const url = importUrl.trim().toLowerCase();
if (url.includes("clawhub.ai")) return "clawhub" as const;
if (url.includes("skills.sh")) return "skills.sh" as const;
return null;
})();
const handleCreate = async () => {
if (!name.trim()) return;
setCreating(true);
setLoading(true);
try {
await onCreate({ name: name.trim(), description: description.trim() });
onClose();
} catch {
setCreating(false);
setLoading(false);
}
};
const handleImport = async () => {
if (!importUrl.trim()) return;
setLoading(true);
setImportError("");
try {
await onImport(importUrl.trim());
onClose();
} catch (err) {
setImportError(err instanceof Error ? err.message : "Import failed");
setLoading(false);
}
};
@ -59,42 +92,94 @@ function CreateSkillDialog({
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Skill</DialogTitle>
<DialogTitle>Add Skill</DialogTitle>
<DialogDescription>
Create a reusable skill that can be assigned to agents.
Create a new skill or import from ClawHub / Skills.sh.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label className="text-xs text-muted-foreground">Name</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Code Review, Bug Triage"
className="mt-1"
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Description</Label>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of what this skill does"
className="mt-1"
/>
</div>
</div>
<Tabs value={tab} onValueChange={(v) => setTab(v as "create" | "import")}>
<TabsList className="w-full">
<TabsTrigger value="create" className="flex-1">
<Plus className="mr-1.5 h-3 w-3" />
Create
</TabsTrigger>
<TabsTrigger value="import" className="flex-1">
<Download className="mr-1.5 h-3 w-3" />
Import
</TabsTrigger>
</TabsList>
<TabsContent value="create" className="space-y-4 mt-4">
<div>
<Label className="text-xs text-muted-foreground">Name</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Code Review, Bug Triage"
className="mt-1"
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Description</Label>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of what this skill does"
className="mt-1"
/>
</div>
</TabsContent>
<TabsContent value="import" className="space-y-4 mt-4">
<div>
<Label className="text-xs text-muted-foreground">Skill URL</Label>
<Input
autoFocus
type="text"
value={importUrl}
onChange={(e) => { setImportUrl(e.target.value); setImportError(""); }}
placeholder="https://clawhub.ai/owner/skill-name"
className="mt-1"
onKeyDown={(e) => e.key === "Enter" && handleImport()}
/>
<div className="flex items-center gap-2 mt-1.5">
{detectedSource ? (
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-2 py-0.5 text-xs font-medium">
{detectedSource === "clawhub" ? "ClawHub" : "Skills.sh"}
</span>
) : (
<p className="text-xs text-muted-foreground">
Supports <span className="font-medium">clawhub.ai</span> and <span className="font-medium">skills.sh</span>
</p>
)}
</div>
</div>
{importError && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{importError}
</div>
)}
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={creating || !name.trim()}>
{creating ? "Creating..." : "Create"}
</Button>
{tab === "create" ? (
<Button onClick={handleCreate} disabled={loading || !name.trim()}>
{loading ? "Creating..." : "Create"}
</Button>
) : (
<Button onClick={handleImport} disabled={loading || !importUrl.trim()}>
<Download className="mr-1.5 h-3 w-3" />
{loading ? "Importing..." : "Import"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
@ -423,6 +508,9 @@ export default function SkillsPage() {
const removeSkill = useWorkspaceStore((s) => s.removeSkill);
const [selectedId, setSelectedId] = useState<string>("");
const [showCreate, setShowCreate] = useState(false);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_skills_layout",
});
useEffect(() => {
if (skills.length > 0 && !selectedId) {
@ -444,6 +532,12 @@ export default function SkillsPage() {
setSelectedId(skill.id);
};
const handleImport = async (url: string) => {
const skill = await api.importSkill({ url });
upsertSkill(skill);
setSelectedId(skill.id);
};
const handleUpdate = async (id: string, data: UpdateSkillRequest) => {
const updated = await api.updateSkill(id, data);
upsertSkill(updated);
@ -469,80 +563,92 @@ export default function SkillsPage() {
}
return (
<div className="flex flex-1 min-h-0">
{/* Left column — skill list */}
<div className="w-72 shrink-0 overflow-y-auto border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Skills</h1>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowCreate(true)}
>
<Plus className="h-4 w-4 text-muted-foreground" />
</Button>
<ResizablePanelGroup
orientation="horizontal"
className="flex-1 min-h-0"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel id="list" defaultSize={280} minSize={240} maxSize={400} groupResizeBehavior="preserve-pixel-size">
{/* Left column — skill list */}
<div className="overflow-y-auto h-full border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Skills</h1>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowCreate(true)}
>
<Plus className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
{skills.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Sparkles className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">No skills yet</p>
<p className="mt-1 text-xs text-muted-foreground text-center">
Skills define reusable instructions for agents.
</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Skill
</Button>
</div>
) : (
<div className="divide-y">
{skills.map((skill) => (
<SkillListItem
key={skill.id}
skill={skill}
isSelected={skill.id === selectedId}
onClick={() => setSelectedId(skill.id)}
/>
))}
</div>
)}
</div>
{skills.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Sparkles className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">No skills yet</p>
<p className="mt-1 text-xs text-muted-foreground text-center">
Skills define reusable instructions for agents.
</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Skill
</Button>
</div>
) : (
<div className="divide-y">
{skills.map((skill) => (
<SkillListItem
key={skill.id}
skill={skill}
isSelected={skill.id === selectedId}
onClick={() => setSelectedId(skill.id)}
/>
))}
</div>
)}
</div>
</ResizablePanel>
{/* Right column — skill detail */}
<div className="flex-1 overflow-hidden">
{selected ? (
<SkillDetail
key={selected.id}
skill={selected}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Sparkles className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-3 text-sm">Select a skill to view details</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Skill
</Button>
</div>
)}
</div>
<ResizableHandle />
<ResizablePanel id="detail" minSize="50%">
{/* Right column — skill detail */}
<div className="flex-1 overflow-hidden h-full">
{selected ? (
<SkillDetail
key={selected.id}
skill={selected}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Sparkles className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-3 text-sm">Select a skill to view details</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Skill
</Button>
</div>
)}
</div>
</ResizablePanel>
{showCreate && (
<CreateSkillDialog
onClose={() => setShowCreate(false)}
onCreate={handleCreate}
onImport={handleImport}
/>
)}
</div>
</ResizablePanelGroup>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { create } from "zustand";
import type { Workspace, MemberWithUser, Agent, Skill } from "@multica/types";
import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types";
import { useIssueStore } from "@/features/issues";
import { useInboxStore } from "@/features/inbox";
import { api } from "@/shared/api";

View file

@ -1,11 +1,6 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: [
"@multica/sdk",
"@multica/types",
"@multica/utils",
],
};
export default nextConfig;

View file

@ -16,9 +16,12 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@multica/sdk": "workspace:*",
"@multica/types": "workspace:*",
"@multica/utils": "workspace:*",
"@tiptap/extension-link": "^3.20.5",
"@tiptap/extension-placeholder": "^3.20.5",
"@tiptap/extension-typography": "^3.20.5",
"@tiptap/pm": "^3.20.5",
"@tiptap/react": "^3.20.5",
"@tiptap/starter-kit": "^3.20.5",
"@types/linkify-it": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -42,6 +45,7 @@
"shiki": "^3.21.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tiptap-markdown": "^0.9.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"zustand": "catalog:"

View file

@ -28,8 +28,8 @@ import type {
CreatePersonalAccessTokenResponse,
RuntimeUsage,
RuntimePing,
} from "@multica/types";
import { type SDKLogger, noopLogger } from "./logger";
} from "@/shared/types";
import { type Logger, noopLogger } from "@/shared/logger";
export interface LoginResponse {
token: string;
@ -40,10 +40,9 @@ export class ApiClient {
private baseUrl: string;
private token: string | null = null;
private workspaceId: string | null = null;
private logger: SDKLogger;
private onUnauthorized: (() => void) | null = null;
private logger: Logger;
constructor(baseUrl: string, options?: { logger?: SDKLogger }) {
constructor(baseUrl: string, options?: { logger?: Logger }) {
this.baseUrl = baseUrl;
this.logger = options?.logger ?? noopLogger;
}
@ -56,10 +55,6 @@ export class ApiClient {
this.workspaceId = id;
}
setOnUnauthorized(callback: (() => void) | null) {
this.onUnauthorized = callback;
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
const rid = crypto.randomUUID().slice(0, 8);
const start = Date.now();
@ -85,9 +80,16 @@ export class ApiClient {
});
if (!res.ok) {
if (res.status === 401 && this.onUnauthorized) {
this.onUnauthorized();
if (res.status === 401 && typeof window !== "undefined") {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
this.token = null;
this.workspaceId = null;
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
let message = `API error: ${res.status} ${res.statusText}`;
try {
const data = await res.json() as { error?: string };
@ -112,8 +114,8 @@ export class ApiClient {
}
// Auth
async sendCode(email: string): Promise<{ message: string }> {
return this.fetch("/auth/send-code", {
async sendCode(email: string): Promise<void> {
await this.fetch("/auth/send-code", {
method: "POST",
body: JSON.stringify({ email }),
});
@ -386,6 +388,13 @@ export class ApiClient {
await this.fetch(`/api/skills/${id}`, { method: "DELETE" });
}
async importSkill(data: { url: string }): Promise<Skill> {
return this.fetch("/api/skills/import", {
method: "POST",
body: JSON.stringify(data),
});
}
async listAgentSkills(agentId: string): Promise<Skill[]> {
return this.fetch(`/api/agents/${agentId}/skills`);
}

View file

@ -1,5 +1,9 @@
import { ApiClient } from "@multica/sdk";
import { createLogger } from "./logger";
import { createLogger } from "@/shared/logger";
import { ApiClient } from "./client";
export { ApiClient } from "./client";
export type { LoginResponse } from "./client";
export { WSClient } from "./ws-client";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
@ -16,13 +20,4 @@ if (typeof window !== "undefined") {
api.setWorkspaceId(wsId);
}
api.setOnUnauthorized(() => {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
api.setToken(null);
api.setWorkspaceId(null);
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
});
}

View file

@ -1,5 +1,5 @@
import type { WSMessage, WSEventType } from "@multica/types";
import { type SDKLogger, noopLogger } from "./logger";
import type { WSMessage, WSEventType } from "@/shared/types";
import { type Logger, noopLogger } from "@/shared/logger";
type EventHandler = (payload: unknown) => void;
@ -12,9 +12,9 @@ export class WSClient {
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private hasConnectedBefore = false;
private onReconnectCallbacks = new Set<() => void>();
private logger: SDKLogger;
private logger: Logger;
constructor(url: string, options?: { logger?: SDKLogger }) {
constructor(url: string, options?: { logger?: Logger }) {
this.baseUrl = url;
this.logger = options?.logger ?? noopLogger;
}

View file

@ -12,6 +12,7 @@ export interface CreateIssueRequest {
parent_issue_id?: string;
acceptance_criteria?: string[];
context_refs?: string[];
due_date?: string;
}
export interface UpdateIssueRequest {

View file

@ -1,4 +1,4 @@
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue.js";
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue";
export type {
Agent,
AgentStatus,
@ -20,10 +20,10 @@ export type {
RuntimeUsage,
RuntimePing,
RuntimePingStatus,
} from "./agent.js";
export type { Workspace, Member, MemberRole, User, MemberWithUser } from "./workspace.js";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox.js";
export type { Comment, CommentType, CommentAuthorType } from "./comment.js";
export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon.js";
export type * from "./events.js";
export type * from "./api.js";
} from "./agent";
export type { Workspace, Member, MemberRole, User, MemberWithUser } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type { Comment, CommentType, CommentAuthorType } from "./comment";
export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon";
export type * from "./events";
export type * from "./api";

View file

@ -1,7 +1,7 @@
import React from "react";
import { vi } from "vitest";
import { render, type RenderOptions } from "@testing-library/react";
import type { User, Workspace, MemberWithUser, Agent } from "@multica/types";
import type { User, Workspace, MemberWithUser, Agent } from "@/shared/types";
// Mock user
export const mockUser: User = {

View file

@ -1,14 +1,49 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
},
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"noEmit": true,
"allowJs": true,
"incremental": true
},
"include": ["next-env.d.ts", "src", "app", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"src",
"app",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View file

@ -13,8 +13,6 @@ export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
"@multica/types": path.resolve(__dirname, "../../packages/types/src"),
"@multica/sdk": path.resolve(__dirname, "../../packages/sdk/src"),
},
},
});

View file

@ -1,8 +1,7 @@
/**
* TestApiClient lightweight API helper for E2E test data setup/teardown.
*
* Uses raw fetch (no dependency on @multica/sdk build) so E2E tests
* have zero build-time coupling to monorepo packages.
* Uses raw fetch so E2E tests have zero build-time coupling to the web app.
*/
import pg from "pg";

View file

@ -4,12 +4,12 @@
"private": true,
"type": "module",
"scripts": {
"dev:web": "turbo run dev --filter=@multica/web",
"build": "turbo run build",
"typecheck": "turbo run typecheck",
"test": "turbo run test",
"lint": "turbo run lint",
"clean": "turbo run clean && rm -rf node_modules"
"dev:web": "pnpm --filter @multica/web dev",
"build": "pnpm --filter @multica/web build",
"typecheck": "pnpm --filter @multica/web typecheck",
"test": "pnpm --filter @multica/web test",
"lint": "pnpm --filter @multica/web lint",
"clean": "pnpm --filter @multica/web clean && rm -rf node_modules"
},
"packageManager": "pnpm@10.28.2",
"pnpm": {
@ -26,7 +26,6 @@
"@types/node": "catalog:",
"@types/pg": "^8.20.0",
"pg": "^8.20.0",
"turbo": "^2.5.0",
"typescript": "catalog:"
}
}

View file

@ -1,19 +0,0 @@
{
"name": "@multica/sdk",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@multica/types": "workspace:*"
},
"devDependencies": {
"typescript": "catalog:"
}
}

View file

@ -1,11 +0,0 @@
export { ApiClient } from "./api-client";
export type { LoginResponse } from "./api-client";
export { WSClient } from "./ws-client";
export { noopLogger } from "./logger";
export type { SDKLogger } from "./logger";
export interface ContentBlock {
type: "text" | "image" | "tool_use" | "tool_result";
text?: string;
[key: string]: unknown;
}

View file

@ -1,13 +0,0 @@
export interface SDKLogger {
debug(msg: string, ...data: unknown[]): void;
info(msg: string, ...data: unknown[]): void;
warn(msg: string, ...data: unknown[]): void;
error(msg: string, ...data: unknown[]): void;
}
export const noopLogger: SDKLogger = {
debug() {},
info() {},
warn() {},
error() {},
};

View file

@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}

View file

@ -1,16 +0,0 @@
{
"name": "@multica/types",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "catalog:"
}
}

View file

@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}

View file

@ -1,23 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@multica/ui/components",
"utils": "@multica/ui/lib/utils",
"hooks": "@multica/ui/hooks",
"lib": "@multica/ui/lib",
"ui": "@multica/ui/components/ui"
},
"rtl": false,
"menuColor": "inverted-translucent",
"menuAccent": "subtle"
}

View file

@ -1,40 +0,0 @@
{
"name": "@multica/ui",
"version": "0.2.0",
"private": true,
"type": "module",
"exports": {
"./globals.css": "./src/styles/globals.css",
"./postcss.config": "./postcss.config.mjs",
"./lib/*": "./src/lib/*.ts",
"./components/*": "./src/components/*.tsx",
"./components/ui/*": "./src/components/ui/*.tsx",
"./components/common/*": "./src/components/common/*.tsx",
"./components/markdown": "./src/components/markdown/index.ts",
"./hooks/*": "./src/hooks/*.ts"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "catalog:",
"next-themes": "^0.4.6",
"react": "catalog:",
"react-day-picker": "^9.14.0",
"react-dom": "catalog:",
"react-markdown": "^10.1.0",
"shiki": "^3.21.0",
"sonner": "^2.0.7",
"tailwind-merge": "catalog:",
"tailwindcss": "catalog:"
},
"devDependencies": {
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"shadcn": "^4.1.0",
"tw-animate-css": "^1.4.0",
"typescript": "catalog:"
}
}

View file

@ -1,6 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: { "@tailwindcss/postcss": {} },
};
export default config;

View file

@ -1,44 +0,0 @@
import { Bot } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
interface ActorAvatarProps {
actorType: string;
actorId: string;
size?: number;
getName?: (type: string, id: string) => string;
getInitials?: (type: string, id: string) => string;
className?: string;
}
function ActorAvatar({
actorType,
actorId,
size = 20,
getName,
getInitials,
className,
}: ActorAvatarProps) {
const name = getName?.(actorType, actorId);
const initials = getInitials?.(actorType, actorId);
const isAgent = actorType === "agent";
return (
<div
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full font-medium",
isAgent ? "bg-info/10 text-info" : "bg-muted text-muted-foreground",
className
)}
style={{ width: size, height: size, fontSize: size * 0.45 }}
title={name}
>
{isAgent ? (
<Bot style={{ width: size * 0.55, height: size * 0.55 }} />
) : (
initials
)}
</div>
);
}
export { ActorAvatar, type ActorAvatarProps };

View file

@ -1,30 +0,0 @@
"use client";
import { Spinner } from "@multica/ui/components/spinner";
import { cn } from "@multica/ui/lib/utils";
export type LoadingVariant = "generating" | "streaming";
interface LoadingIndicatorProps {
variant: LoadingVariant;
className?: string;
}
const VARIANT_TEXT: Record<LoadingVariant, string> = {
generating: "Generating...",
streaming: "Streaming...",
};
/**
* Unified loading indicator for chat.
* Use "generating" when waiting for AI response (no content yet).
* Use "streaming" when content is actively being received.
*/
export function LoadingIndicator({ variant, className }: LoadingIndicatorProps) {
return (
<div className={cn("flex items-center gap-2 py-1 text-muted-foreground", className)}>
<Spinner className="text-xs" />
<span className="text-xs">{VARIANT_TEXT[variant]}</span>
</div>
);
}

View file

@ -1,250 +0,0 @@
import * as React from 'react'
import { codeToHtml, bundledLanguages, type BundledLanguage } from 'shiki'
import { cn } from '@multica/ui/lib/utils'
export interface CodeBlockProps {
code: string
language?: string
className?: string
/**
* Render mode affects code block styling:
* - 'terminal': Minimal, keeps control chars visible
* - 'minimal': Clean code, basic styling
* - 'full': Rich styling with background, copy button, etc.
*/
mode?: 'terminal' | 'minimal' | 'full'
}
// Map common aliases to Shiki language names
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
js: 'javascript',
ts: 'typescript',
py: 'python',
sh: 'bash',
zsh: 'bash',
yml: 'yaml',
rb: 'ruby',
rs: 'rust',
kt: 'kotlin',
'objective-c': 'objc',
objc: 'objc'
}
// Simple LRU cache for highlighted code
const highlightCache = new Map<string, string>()
const CACHE_MAX_SIZE = 200
function getCacheKey(code: string, lang: string): string {
return `${lang}:${code}`
}
function isValidLanguage(lang: string): lang is BundledLanguage {
const normalized = LANGUAGE_ALIASES[lang] || lang
return normalized in bundledLanguages
}
/**
* CodeBlock - Syntax highlighted code block using Shiki
*
* Uses Shiki dual themes with CSS variables for light/dark switching.
* No JS-based dark mode detection needed theme switching is handled
* entirely via CSS (see globals.css for .shiki/.dark .shiki rules).
*
* @see https://shiki.style/guide/dual-themes
*/
export function CodeBlock({
code,
language = 'text',
className,
mode = 'full'
}: CodeBlockProps): React.JSX.Element {
const [highlighted, setHighlighted] = React.useState<string | null>(null)
const [isLoading, setIsLoading] = React.useState(true)
const [copied, setCopied] = React.useState(false)
// Resolve language alias - keep as string to allow 'text' fallback
const langLower = language.toLowerCase()
const resolvedLang: string = LANGUAGE_ALIASES[langLower] || langLower
React.useEffect(() => {
let cancelled = false
async function highlight(): Promise<void> {
const cacheKey = getCacheKey(code, resolvedLang)
const cached = highlightCache.get(cacheKey)
if (cached) {
if (!cancelled) {
setHighlighted(cached)
setIsLoading(false)
}
return
}
try {
// Use valid language or fallback to plaintext
const lang = isValidLanguage(resolvedLang) ? resolvedLang : 'text'
// Dual themes: Shiki outputs CSS variables for both themes in one pass.
// CSS handles switching via .dark selector (see globals.css).
const html = await codeToHtml(code, {
lang,
themes: {
light: 'github-light',
dark: 'github-dark',
},
defaultColor: false,
})
// Cache the result
if (highlightCache.size >= CACHE_MAX_SIZE) {
const firstKey = highlightCache.keys().next().value
if (firstKey) highlightCache.delete(firstKey)
}
highlightCache.set(cacheKey, html)
if (!cancelled) {
setHighlighted(html)
setIsLoading(false)
}
} catch (error) {
// Fallback to plain text on error
console.warn(`Shiki highlighting failed for language "${resolvedLang}":`, error)
if (!cancelled) {
setHighlighted(null)
setIsLoading(false)
}
}
}
highlight()
return () => {
cancelled = true
}
}, [code, resolvedLang])
const handleCopy = React.useCallback(async () => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy code:', err)
}
}, [code])
// Terminal mode: raw monospace with minimal styling
if (mode === 'terminal') {
return (
<pre className={cn('font-mono text-sm whitespace-pre-wrap', className)}>
<code>{code}</code>
</pre>
)
}
// Minimal mode: just syntax highlighting, no chrome
if (mode === 'minimal') {
if (isLoading || !highlighted) {
return (
<pre className={cn('font-mono text-sm whitespace-pre-wrap', className)}>
<code>{code}</code>
</pre>
)
}
return (
<div
className={cn(
'font-mono text-sm [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_code]:!bg-transparent',
className
)}
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
)
}
// Full mode: rich styling with header and copy button
return (
<div
className={cn(
'relative group rounded-lg overflow-hidden border bg-muted/30 mb-4 last:mb-0',
className
)}
>
{/* Language label + copy button */}
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 border-b text-xs">
<span className="text-muted-foreground font-medium uppercase tracking-wide">
{resolvedLang !== 'text' ? resolvedLang : 'plain text'}
</span>
<button
onClick={handleCopy}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
aria-label="Copy code"
>
{copied ? (
<svg
className="w-4 h-4 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
)}
</button>
</div>
{/* Code content */}
<div className="p-3 overflow-x-auto">
{isLoading || !highlighted ? (
<pre className="font-mono text-sm whitespace-pre-wrap break-all">
<code>{code}</code>
</pre>
) : (
<div
className="font-mono text-sm [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_code]:!bg-transparent"
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
)}
</div>
</div>
)
}
/**
* InlineCode - Styled inline code span
* Features: subtle background (3%), subtle border (5%), 75% opacity text
*/
export function InlineCode({
children,
className
}: {
children: React.ReactNode
className?: string
}): React.JSX.Element {
return (
<code
className={cn(
'px-1.5 py-0.5 rounded bg-foreground/[0.03] border border-foreground/[0.05] font-mono text-sm text-foreground/75',
className
)}
>
{children}
</code>
)
}

View file

@ -1,301 +0,0 @@
import * as React from 'react'
import ReactMarkdown, { type Components } from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import remarkGfm from 'remark-gfm'
import { cn } from '@multica/ui/lib/utils'
import { CodeBlock, InlineCode } from './CodeBlock'
import { preprocessLinks } from './linkify'
/**
* Render modes for markdown content:
*
* - 'terminal': Raw output with minimal formatting, control chars visible
* Best for: Debug output, raw logs, when you want to see exactly what's there
*
* - 'minimal': Clean rendering with syntax highlighting but no extra chrome
* Best for: Chat messages, inline content, when you want readability without clutter
*
* - 'full': Rich rendering with beautiful tables, styled code blocks, proper typography
* Best for: Documentation, long-form content, when presentation matters
*/
export type RenderMode = 'terminal' | 'minimal' | 'full'
export interface MarkdownProps {
children: string
/**
* Render mode controlling formatting level
* @default 'minimal'
*/
mode?: RenderMode
className?: string
/**
* Message ID for memoization (optional)
* When provided, memoizes parsed blocks to avoid re-parsing during streaming
*/
id?: string
/**
* Callback when a URL is clicked
*/
onUrlClick?: (url: string) => void
/**
* Callback when a file path is clicked
*/
onFileClick?: (path: string) => void
}
// File path detection regex - matches paths starting with /, ~/, or ./
const FILE_PATH_REGEX =
/^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i
/**
* Create custom components based on render mode
*/
function createComponents(
mode: RenderMode,
onUrlClick?: (url: string) => void,
onFileClick?: (path: string) => void
): Partial<Components> {
const baseComponents: Partial<Components> = {
// Links: Make clickable with callbacks
a: ({ href, children }) => {
const handleClick = (e: React.MouseEvent): void => {
e.preventDefault()
if (href) {
// Check if it's a file path
if (FILE_PATH_REGEX.test(href) && onFileClick) {
onFileClick(href)
} else if (onUrlClick) {
onUrlClick(href)
} else {
// Default: open in new window
window.open(href, '_blank', 'noopener,noreferrer')
}
}
}
return (
<a
href={href}
onClick={handleClick}
className="text-primary hover:underline cursor-pointer"
>
{children}
</a>
)
}
}
// Terminal mode: minimal formatting
if (mode === 'terminal') {
return {
...baseComponents,
// No special code handling - just monospace
code: ({ children }) => <code className="font-mono">{children}</code>,
pre: ({ children }) => <pre className="font-mono whitespace-pre-wrap my-2">{children}</pre>,
// Minimal paragraph spacing
p: ({ children }) => <p className="my-1">{children}</p>,
// Simple lists
ul: ({ children }) => <ul className="list-disc list-inside my-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside my-1">{children}</ol>,
li: ({ children }) => <li className="my-0.5">{children}</li>,
// Plain tables
table: ({ children }) => <table className="my-2 font-mono text-sm">{children}</table>,
th: ({ children }) => <th className="text-left pr-4">{children}</th>,
td: ({ children }) => <td className="pr-4">{children}</td>
}
}
// Minimal mode: clean with syntax highlighting
if (mode === 'minimal') {
return {
...baseComponents,
// Inline code
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '')
const isBlock =
'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line
// Block code - use CodeBlock with full mode
if (match || isBlock) {
const code = String(children).replace(/\n$/, '')
return <CodeBlock code={code} language={match?.[1]} mode="full" className="my-1" />
}
// Inline code
return <InlineCode>{children}</InlineCode>
},
pre: ({ children }) => <>{children}</>,
// Comfortable paragraph spacing
p: ({ children }) => <p className="my-2 leading-relaxed">{children}</p>,
// Styled lists
ul: ({ children }) => (
<ul className="my-2 space-y-1 ps-[16px] pe-2 list-disc marker:text-muted-foreground">
{children}
</ul>
),
ol: ({ children }) => <ol className="my-2 space-y-1 pl-6 list-decimal">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
// Clean tables
table: ({ children }) => (
<div className="my-3 overflow-x-auto">
<table className="min-w-full text-sm">{children}</table>
</div>
),
thead: ({ children }) => <thead className="border-b">{children}</thead>,
th: ({ children }) => (
<th className="text-left py-2 px-3 font-semibold text-muted-foreground">{children}</th>
),
td: ({ children }) => <td className="py-2 px-3 border-b border-border/50">{children}</td>,
// Headings - H1/H2 same size, differentiated by weight
h1: ({ children }) => <h1 className="font-sans text-base font-bold mt-5 mb-3">{children}</h1>,
h2: ({ children }) => (
<h2 className="font-sans text-base font-semibold mt-4 mb-3">{children}</h2>
),
h3: ({ children }) => (
<h3 className="font-sans text-sm font-semibold mt-4 mb-2">{children}</h3>
),
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-muted-foreground/30 pl-3 my-2 text-muted-foreground italic">
{children}
</blockquote>
),
// Horizontal rules
hr: () => <hr className="my-4 border-border" />,
// Strong/emphasis
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>
}
}
// Full mode: rich styling
return {
...baseComponents,
// Full code blocks with copy button
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '')
const isBlock =
'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line
if (match || isBlock) {
const code = String(children).replace(/\n$/, '')
return <CodeBlock code={code} language={match?.[1]} mode="full" className="my-1" />
}
return <InlineCode>{children}</InlineCode>
},
pre: ({ children }) => <>{children}</>,
// Rich paragraph spacing
p: ({ children }) => <p className="my-3 leading-relaxed">{children}</p>,
// Styled lists
ul: ({ children }) => (
<ul className="my-3 space-y-1.5 ps-[16px] pe-2 list-disc marker:text-muted-foreground">
{children}
</ul>
),
ol: ({ children }) => <ol className="my-3 space-y-1.5 pl-6 list-decimal">{children}</ol>,
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
// Beautiful tables
table: ({ children }) => (
<div className="my-4 overflow-x-auto rounded-md border">
<table className="min-w-full divide-y divide-border">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted/50">{children}</thead>,
tbody: ({ children }) => <tbody className="divide-y divide-border">{children}</tbody>,
th: ({ children }) => <th className="text-left py-3 px-4 font-semibold text-sm">{children}</th>,
td: ({ children }) => <td className="py-3 px-4 text-sm">{children}</td>,
tr: ({ children }) => <tr className="hover:bg-muted/30 transition-colors">{children}</tr>,
// Rich headings
h1: ({ children }) => <h1 className="font-sans text-base font-bold mt-7 mb-4">{children}</h1>,
h2: ({ children }) => (
<h2 className="font-sans text-base font-semibold mt-6 mb-3">{children}</h2>
),
h3: ({ children }) => <h3 className="font-sans text-sm font-semibold mt-5 mb-3">{children}</h3>,
h4: ({ children }) => <h4 className="text-sm font-semibold mt-3 mb-1">{children}</h4>,
// Styled blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-foreground/30 bg-muted/30 pl-4 pr-3 py-2 my-3 rounded-r-md">
{children}
</blockquote>
),
// Task lists (GFM)
input: ({ type, checked }) => {
if (type === 'checkbox') {
return (
<input
type="checkbox"
checked={checked}
readOnly
className="mr-2 rounded border-muted-foreground"
/>
)
}
return <input type={type} />
},
// Horizontal rules
hr: () => <hr className="my-6 border-border" />,
// Strong/emphasis
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
del: ({ children }) => <del className="line-through text-muted-foreground">{children}</del>
}
}
/**
* Markdown - Customizable markdown renderer with multiple render modes
*
* Features:
* - Three render modes: terminal, minimal, full
* - Syntax highlighting via Shiki
* - GFM support (tables, task lists, strikethrough)
* - Clickable links and file paths
* - Memoization for streaming performance
*/
export function Markdown({
children,
mode = 'minimal',
className,
onUrlClick,
onFileClick
}: MarkdownProps): React.JSX.Element {
const components = React.useMemo(
() => createComponents(mode, onUrlClick, onFileClick),
[mode, onUrlClick, onFileClick]
)
// Preprocess to convert raw URLs and file paths to markdown links
const processedContent = React.useMemo(() => preprocessLinks(children), [children])
return (
<div className={cn('markdown-content break-words', className)}>
<ReactMarkdown
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
rehypePlugins={[rehypeRaw]}
components={components}
>
{processedContent}
</ReactMarkdown>
</div>
)
}
/**
* MemoizedMarkdown - Optimized for streaming scenarios
*
* Splits content into blocks and memoizes each block separately,
* so only new/changed blocks re-render during streaming.
*/
export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => {
// If id is provided, use it for memoization
if (prevProps.id && nextProps.id) {
return (
prevProps.id === nextProps.id &&
prevProps.children === nextProps.children &&
prevProps.mode === nextProps.mode
)
}
// Otherwise compare content and mode
return prevProps.children === nextProps.children && prevProps.mode === nextProps.mode
})
MemoizedMarkdown.displayName = 'MemoizedMarkdown'

View file

@ -1,225 +0,0 @@
import * as React from 'react'
import { Markdown, type RenderMode } from './Markdown'
export interface StreamingMarkdownProps {
content: string
isStreaming: boolean
mode?: RenderMode
className?: string
onUrlClick?: (url: string) => void
onFileClick?: (path: string) => void
}
interface Block {
content: string
isCodeBlock: boolean
}
/**
* djb2 hash (XOR variant) by Daniel J. Bernstein.
* Used to generate stable React keys for completed content blocks.
*
* - 5381: empirically chosen initial value that produces fewer collisions
* - (hash << 5) + hash: equivalent to hash * 33
* - ^ charCode: XOR variant, favored by Bernstein over additive version
* - >>> 0: convert to unsigned 32-bit integer
*
* Not cryptographic just fast with good distribution for short strings.
* @see http://www.cse.yorku.ca/~oz/hash.html
*/
function simpleHash(str: string): string {
let hash = 5381
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) ^ str.charCodeAt(i)
}
return (hash >>> 0).toString(36)
}
/**
* Split content into blocks (paragraphs and code blocks)
*
* Block boundaries:
* - Double newlines (paragraph separators)
* - Code fences (```)
*
* This is intentionally simple - just string scanning, no regex per line.
*/
function splitIntoBlocks(content: string): Block[] {
const blocks: Block[] = []
const lines = content.split('\n')
let currentBlock = ''
let inCodeBlock = false
let inMathBlock = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? ''
// Check for code fence (``` at start of line, optionally followed by language)
if (line.startsWith('```')) {
if (!inCodeBlock) {
// Starting a code block - flush current paragraph first
if (currentBlock.trim()) {
blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
currentBlock = ''
}
inCodeBlock = true
currentBlock = line + '\n'
} else {
// Ending a code block
currentBlock += line
blocks.push({ content: currentBlock, isCodeBlock: true })
currentBlock = ''
inCodeBlock = false
}
} else if (inCodeBlock) {
// Inside code block - append line
currentBlock += line + '\n'
// Check for display math fence ($$)
} else if (line.trim() === '$$') {
if (!inMathBlock) {
// Starting a math block - flush current paragraph first
if (currentBlock.trim()) {
blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
currentBlock = ''
}
inMathBlock = true
currentBlock = line + '\n'
} else {
// Ending a math block
currentBlock += line
blocks.push({ content: currentBlock, isCodeBlock: false })
currentBlock = ''
inMathBlock = false
}
} else if (inMathBlock) {
// Inside math block - append line (don't split on blank lines)
currentBlock += line + '\n'
} else if (line === '') {
// Empty line outside code block = paragraph boundary
if (currentBlock.trim()) {
blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
currentBlock = ''
}
} else {
// Regular text line
if (currentBlock) {
currentBlock += '\n' + line
} else {
currentBlock = line
}
}
}
// Flush remaining content
if (currentBlock) {
blocks.push({
content: inCodeBlock || inMathBlock ? currentBlock : currentBlock.trim(),
isCodeBlock: inCodeBlock
})
}
return blocks
}
/**
* Memoized block component
*
* Only re-renders if content or mode changes.
* The key is assigned by the parent based on content hash,
* so identical content won't even attempt to render.
*/
const MemoizedBlock = React.memo(
function Block({
content,
mode,
className,
onUrlClick,
onFileClick
}: {
content: string
mode: RenderMode
className?: string
onUrlClick?: (url: string) => void
onFileClick?: (path: string) => void
}) {
return (
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick}>
{content}
</Markdown>
)
},
(prev, next) => {
// Only re-render if content actually changed
return prev.content === next.content && prev.mode === next.mode && prev.className === next.className
}
)
MemoizedBlock.displayName = 'MemoizedBlock'
/**
* StreamingMarkdown - Optimized markdown renderer for streaming content
*
* Splits content into blocks (paragraphs, code blocks) and memoizes each block
* independently. Only the last (active) block re-renders during streaming.
*
* Key insight: Completed blocks get a content-hash as their React key.
* Same content = same key = React skips re-render entirely.
*
* @example
* Content: "Hello\n\n```js\ncode\n```\n\nMore..."
*
* Block 1: "Hello" -> key="block-abc123" -> memoized
* Block 2: "```js\ncode\n```" -> key="block-xyz789" -> memoized
* Block 3: "More..." -> key="active-2" -> re-renders
*/
export function StreamingMarkdown({
content,
isStreaming,
mode = 'minimal',
className,
onUrlClick,
onFileClick
}: StreamingMarkdownProps): React.JSX.Element {
// Split into blocks - memoized to avoid recomputation
// Must be called unconditionally to satisfy Rules of Hooks
const blocks = React.useMemo(
() => (isStreaming ? splitIntoBlocks(content) : []),
[content, isStreaming]
)
// Not streaming - use simple Markdown (no block splitting needed)
if (!isStreaming) {
return (
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick}>
{content}
</Markdown>
)
}
// Empty content - return null, let parent handle loading indicator
if (blocks.length === 0) {
return <></>
}
return (
<>
{blocks.map((block, i) => {
const isLastBlock = i === blocks.length - 1
// Complete blocks use content hash as key -> stable identity -> memoized
// Last block uses "active" prefix -> always re-renders on content change
const key = isLastBlock ? `active-${i}` : `block-${i}-${simpleHash(block.content)}`
return (
<MemoizedBlock
key={key}
content={block.content}
mode={mode}
className={className}
onUrlClick={onUrlClick}
onFileClick={onFileClick}
/>
)
})}
</>
)
}

View file

@ -1,4 +0,0 @@
export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from './Markdown'
export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
export { preprocessLinks, detectLinks, hasLinks } from './linkify'

View file

@ -1,215 +0,0 @@
import LinkifyIt from 'linkify-it'
/**
* Linkify - URL and file path detection for markdown preprocessing
*
* Uses linkify-it (12M downloads/week) for battle-tested URL detection,
* plus custom regex for local file paths.
*/
// Initialize linkify-it with default settings (fuzzy URLs, emails enabled)
const linkify = new LinkifyIt()
// File path regex - detects /path, ~/path, ./path with common extensions
// Matches paths that start with /, ~/, or ./ followed by path chars and a file extension
const FILE_PATH_REGEX =
/(?:^|[\s([{<])((\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma|dockerfile|makefile|gitignore))(?=[\s)\]}.,;:!?>]|$)/gi
interface DetectedLink {
type: 'url' | 'email' | 'file'
text: string
url: string
start: number
end: number
}
interface CodeRange {
start: number
end: number
}
/**
* Find all code block and inline code ranges in text
* These ranges should be excluded from link detection
*/
function findCodeRanges(text: string): CodeRange[] {
const ranges: CodeRange[] = []
// Find fenced code blocks (```...```)
const fencedRegex = /```[\s\S]*?```/g
let match
while ((match = fencedRegex.exec(text)) !== null) {
ranges.push({ start: match.index, end: match.index + match[0].length })
}
// Find display math blocks ($$...$$)
const displayMathRegex = /\$\$[\s\S]*?\$\$/g
while ((match = displayMathRegex.exec(text)) !== null) {
const pos = match.index
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
if (!insideOther) {
ranges.push({ start: pos, end: pos + match[0].length })
}
}
// Find inline math ($...$)
const inlineMathRegex = /(?<!\$)\$(?!\$)([^\$\n]+)\$(?!\$)/g
while ((match = inlineMathRegex.exec(text)) !== null) {
const pos = match.index
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
if (!insideOther) {
ranges.push({ start: pos, end: pos + match[0].length })
}
}
// Find inline code (`...`)
// But skip escaped backticks and code inside fenced blocks
const inlineRegex = /(?<!`)`(?!`)([^`\n]+)`(?!`)/g
while ((match = inlineRegex.exec(text)) !== null) {
const pos = match.index
// Check if this is inside a fenced block or math block
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
if (!insideOther) {
ranges.push({ start: pos, end: pos + match[0].length })
}
}
return ranges
}
/**
* Check if a position is inside any code range
*/
function isInsideCode(pos: number, ranges: CodeRange[]): boolean {
return ranges.some((r) => pos >= r.start && pos < r.end)
}
/**
* Check if a link at given position is already a markdown link
* Looks for patterns like [text](url) or [text][ref]
*/
function isAlreadyLinked(text: string, linkStart: number, linkEnd: number): boolean {
// Check if preceded by ]( which indicates we're inside a markdown link href
// Pattern: [text](URL) - we're checking if URL is our link
const before = text.slice(Math.max(0, linkStart - 2), linkStart)
if (before.endsWith('](')) return true
// Check if preceded by ][ for reference links
if (before.endsWith('][')) return true
// Check if the link text is wrapped in []
// Pattern: [URL](href) - URL is being used as link text
const charBefore = text[linkStart - 1]
const charAfter = text[linkEnd]
if (charBefore === '[' && charAfter === ']') return true
return false
}
/**
* Check if ranges overlap
*/
function rangesOverlap(
a: { start: number; end: number },
b: { start: number; end: number }
): boolean {
return a.start < b.end && b.start < a.end
}
/**
* Detect all links (URLs, emails, file paths) in text
*/
export function detectLinks(text: string): DetectedLink[] {
const links: DetectedLink[] = []
// 1. Detect URLs and emails with linkify-it
const urlMatches = linkify.match(text) || []
for (const match of urlMatches) {
links.push({
type: match.schema === 'mailto:' ? 'email' : 'url',
text: match.text,
url: match.url,
start: match.index,
end: match.lastIndex
})
}
// 2. Detect file paths with custom regex
// Reset regex state
FILE_PATH_REGEX.lastIndex = 0
let fileMatch
while ((fileMatch = FILE_PATH_REGEX.exec(text)) !== null) {
const path = fileMatch[1]
if (!path) continue // Skip if no capture group
// Calculate actual start position (after any leading whitespace/punctuation)
const fullMatch = fileMatch[0]
const pathOffset = fullMatch.indexOf(path)
const start = fileMatch.index + pathOffset
// Check for overlaps with URL matches (URLs take precedence)
const pathRange = { start, end: start + path.length }
const overlapsUrl = links.some((link) => rangesOverlap(pathRange, link))
if (overlapsUrl) continue
links.push({
type: 'file',
text: path,
url: path, // File paths are passed as-is to onFileClick handler
start,
end: start + path.length
})
}
// Sort by position
return links.sort((a, b) => a.start - b.start)
}
/**
* Preprocess text to convert raw URLs and file paths into markdown links
* Skips code blocks and already-linked content
*/
export function preprocessLinks(text: string): string {
// Quick check - if no potential links, return early
if (!linkify.pretest(text) && !/[~/.]\//.test(text)) {
return text
}
const codeRanges = findCodeRanges(text)
const links = detectLinks(text)
if (links.length === 0) return text
// Build result, converting raw links to markdown links
let result = ''
let lastIndex = 0
for (const link of links) {
// Skip if inside code block
if (isInsideCode(link.start, codeRanges)) continue
// Skip if already a markdown link
if (isAlreadyLinked(text, link.start, link.end)) continue
// Add text before this link
result += text.slice(lastIndex, link.start)
// Convert to markdown link
result += `[${link.text}](${link.url})`
lastIndex = link.end
}
// Add remaining text
result += text.slice(lastIndex)
return result
}
/**
* Test if text contains any detectable links
* Useful for optimization - skip preprocessing if no links present
*/
export function hasLinks(text: string): boolean {
return linkify.pretest(text) || /[~/.]\/[\w]/.test(text)
}

View file

@ -1,105 +0,0 @@
import { useState, useEffect } from "react";
import { cn } from "@multica/ui/lib/utils";
interface MulticaIconProps extends React.ComponentProps<"span"> {
/**
* If true, play a one-time entrance spin animation.
*/
animate?: boolean;
/**
* If true, disable hover spin animation.
*/
noSpin?: boolean;
/**
* If true, show a border around the icon.
*/
bordered?: boolean;
/**
* Size of the bordered icon: "sm" (default), "md", "lg"
*/
size?: "sm" | "md" | "lg";
}
const borderedSizes = {
sm: { wrapper: "p-1.5", icon: "size-3.5" },
md: { wrapper: "p-2", icon: "size-4" },
lg: { wrapper: "p-2.5", icon: "size-5" },
};
/**
* Pure CSS 8-pointed asterisk icon matching the Multica logo.
* Uses currentColor so it adapts to light/dark themes automatically.
* Clip-path polygon traced from the original SVG path coordinates.
*/
export function MulticaIcon({
className,
animate = false,
noSpin = false,
bordered = false,
size = "sm",
...props
}: MulticaIconProps) {
const [entranceDone, setEntranceDone] = useState(!animate);
useEffect(() => {
if (!animate) return;
const timer = setTimeout(() => setEntranceDone(true), 600);
return () => clearTimeout(timer);
}, [animate]);
const clipPath = `polygon(
45% 62.1%, 45% 100%, 55% 100%, 55% 62.1%,
81.8% 88.9%, 88.9% 81.8%, 62.1% 55%, 100% 55%,
100% 45%, 62.1% 45%, 88.9% 18.2%, 81.8% 11.1%,
55% 37.9%, 55% 0%, 45% 0%, 45% 37.9%,
18.2% 11.1%, 11.1% 18.2%, 37.9% 45%, 0% 45%,
0% 55%, 37.9% 55%, 11.1% 81.8%, 18.2% 88.9%
)`;
if (bordered) {
const sizeConfig = borderedSizes[size];
return (
<span
className={cn(
"inline-flex items-center justify-center border border-border rounded-md",
sizeConfig.wrapper,
className
)}
aria-hidden="true"
{...props}
>
<span
className={cn(
"block",
sizeConfig.icon,
!entranceDone && "animate-entrance-spin",
entranceDone && !noSpin && "hover:animate-spin"
)}
>
<span
className="block size-full bg-current"
style={{ clipPath }}
/>
</span>
</span>
);
}
return (
<span
className={cn(
"inline-block size-[1em]",
!entranceDone && "animate-entrance-spin",
entranceDone && !noSpin && "hover:animate-spin",
className
)}
aria-hidden="true"
{...props}
>
<span
className="block size-full bg-current"
style={{ clipPath }}
/>
</span>
);
}

View file

@ -1,47 +0,0 @@
/**
* Spinner 3x3 grid pulse for **active processing / execution** states.
*
* Use when the system is actively doing work or waiting for human action
* (streaming content, generating responses, awaiting approval).
* For passive content-loading states, use `<Loading />` instead.
*
* Inherits color from `currentColor` (use Tailwind `text-*`).
* Scales with font-size (use Tailwind `text-*` for size).
*/
import { cn } from "@multica/ui/lib/utils"
export interface SpinnerProps {
/** Additional className for styling (color via text-*, size via Tailwind text-*) */
className?: string
}
const DELAYS = [0.2, 0.3, 0.4, 0.1, 0.2, 0.3, 0, 0.1, 0.2]
const cubeStyle: React.CSSProperties = {
backgroundColor: "currentColor",
animation: "spinner-grid 1.3s infinite ease-in-out",
transform: "scale3d(0.5, 0.5, 1)",
}
export function Spinner({ className }: SpinnerProps) {
return (
<span
className={cn(className)}
role="status"
aria-label="Loading"
style={{
display: "inline-grid",
gridTemplateColumns: "repeat(3, 1fr)",
width: "1em",
height: "1em",
gap: "0.08em",
}}
>
{DELAYS.map((delay, i) => (
<span key={i} style={{ ...cubeStyle, animationDelay: `${delay}s` }} />
))}
<style>{`@keyframes spinner-grid{0%,70%,100%{transform:scale3d(.5,.5,1)}35%{transform:scale3d(0,0,1)}}`}</style>
</span>
)
}

View file

@ -1,11 +0,0 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View file

@ -1,40 +0,0 @@
"use client"
import { useTheme } from "next-themes"
import { Sun, Moon, Monitor } from "lucide-react"
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@multica/ui/components/ui/dropdown-menu"
import { SidebarMenuButton } from "@multica/ui/components/ui/sidebar"
export function ThemeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton>
<Sun className="dark:hidden" />
<Moon className="hidden dark:block" />
<span>Theme</span>
</SidebarMenuButton>
}
/>
<DropdownMenuContent side="top" align="start">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun /> Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon /> Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor /> System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -1,187 +0,0 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@multica/ui/lib/utils"
import { Button } from "@multica/ui/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View file

@ -1,52 +0,0 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@multica/ui/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View file

@ -1,60 +0,0 @@
"use client"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@multica/ui/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -1,221 +0,0 @@
"use client"
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@multica/ui/lib/utils"
import { Button, buttonVariants } from "@multica/ui/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View file

@ -1,103 +0,0 @@
import * as React from "react"
import { cn } from "@multica/ui/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -1,29 +0,0 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@multica/ui/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View file

@ -1,21 +0,0 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View file

@ -1,300 +0,0 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@multica/ui/lib/utils"
import { Button } from "@multica/ui/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@multica/ui/components/ui/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"dark group/combobox-content max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
className
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}

View file

@ -1,160 +0,0 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@multica/ui/lib/utils"
import { Button } from "@multica/ui/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -1,274 +0,0 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@multica/ui/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"dark z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
className
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"dark w-auto min-w-[96px] rounded-lg p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
className
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

Some files were not shown because too many files have changed in this diff Show more