feat: resizable sidebar, issue detail rewrite, package consolidation
- Add drag-to-resize sidebar with localStorage persistence - Rewrite issue detail page with Tiptap rich text editor, due date picker, acceptance criteria - Redesign create-issue modal with pill-based property toolbar and expand/collapse - Consolidate @multica/sdk and @multica/types into apps/web/shared/ - Simplify auth: remove verification codes, PATs, email service (dev-only login) - Add 401 unauthorized handler to redirect expired sessions to login - Fix due date format to send full RFC3339 timestamps - Increase description editor debounce to 1500ms - Remove arbitrary Tailwind values in create-issue modal - Renumber migrations (inbox_actor 012→009), remove unused migrations - UI polish across agents, settings, inbox, knowledge-base pages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a997bcfec0
commit
2cf088ddf6
73 changed files with 2322 additions and 673 deletions
33
CLAUDE.md
33
CLAUDE.md
|
|
@ -12,21 +12,20 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
|
|||
|
||||
## Architecture
|
||||
|
||||
**Polyglot monorepo** — Go backend + TypeScript frontend.
|
||||
**Go backend + standalone Next.js frontend.**
|
||||
|
||||
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
|
||||
- `apps/web/` — Next.js 16 frontend (App Router)
|
||||
- `packages/` — Shared TypeScript packages (ui, types, sdk, store, hooks, utils)
|
||||
- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies
|
||||
|
||||
### Web App Structure (`apps/web/`)
|
||||
|
||||
The frontend uses a **feature-based architecture** with three layers:
|
||||
The frontend uses a **feature-based architecture** with four layers:
|
||||
|
||||
```
|
||||
apps/web/
|
||||
├── app/ # Routing layer (thin shells — import from features/)
|
||||
├── features/ # Business logic, organized by domain
|
||||
├── shared/ # Cross-feature utilities (api client)
|
||||
├── shared/ # Cross-feature utilities (api client, types, logger)
|
||||
```
|
||||
|
||||
**`app/`** — Next.js App Router pages. Route files should be thin: import and re-export from `features/`. Layout components and route-specific glue (redirects, auth guards) live here. Shared layout components (e.g. `app-sidebar`) stay in `app/(dashboard)/_components/`.
|
||||
|
|
@ -41,7 +40,10 @@ apps/web/
|
|||
| `features/inbox/` | Inbox notification state | `useInboxStore` |
|
||||
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
|
||||
|
||||
**`shared/`** — Code used across multiple features. Currently only `api.ts` (SDK singleton).
|
||||
**`shared/`** — Code used across multiple features:
|
||||
- `shared/api/` — `ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton.
|
||||
- `shared/types/` — Domain types (Issue, Agent, Workspace, etc.) and WebSocket event types.
|
||||
- `shared/logger.ts` — Logger utility.
|
||||
|
||||
### State Management
|
||||
|
||||
|
|
@ -61,6 +63,7 @@ apps/web/
|
|||
Use `@/` alias (maps to `apps/web/`):
|
||||
```typescript
|
||||
import { api } from "@/shared/api";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
|
|
@ -74,8 +77,8 @@ Within a feature, use relative imports. Between features or to shared, use `@/`.
|
|||
### Data Flow
|
||||
|
||||
```
|
||||
Browser → ApiClient (SDK) → REST API (Chi handlers) → sqlc queries → PostgreSQL
|
||||
Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
|
||||
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
|
||||
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
|
||||
```
|
||||
|
||||
### Backend Structure (`server/`)
|
||||
|
|
@ -91,12 +94,6 @@ Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskSe
|
|||
- **Database**: sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`.
|
||||
- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model).
|
||||
|
||||
### Key Packages
|
||||
|
||||
- **`@multica/sdk`**: `ApiClient` (REST) and `WSClient` (WebSocket) classes. All backend communication goes through here.
|
||||
- **`@multica/types`**: Shared domain types + WebSocket event types (issue:created/updated/deleted, task:*, agent:status, comment:*, inbox:new, daemon:*).
|
||||
- **`@multica/ui`**: shadcn/ui component library with Radix primitives, Tailwind CSS 4, Shiki syntax highlighting for markdown.
|
||||
|
||||
### Multi-tenancy
|
||||
|
||||
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
|
||||
|
|
@ -117,7 +114,7 @@ make db-down # Stop the shared PostgreSQL container
|
|||
# Frontend
|
||||
pnpm install
|
||||
pnpm dev:web # Next.js dev server (port 3000)
|
||||
pnpm build # Build all TS packages
|
||||
pnpm build # Build frontend
|
||||
pnpm typecheck # TypeScript check
|
||||
pnpm test # TS tests (Vitest)
|
||||
|
||||
|
|
@ -165,10 +162,8 @@ make start-worktree # Start using .env.worktree
|
|||
|
||||
## UI/UX Rules
|
||||
|
||||
- Prefer `packages/ui` shadcn components over custom implementations.
|
||||
- **shadcn official components** → `packages/ui/src/components/ui/` — keep this directory clean; install missing components via `npx shadcn add`, do not mix in business code.
|
||||
- **Shared business components & utils** → `packages/ui/src/components/common/` — reusable project-level UI components (e.g. ActorAvatar) and shared utilities live here.
|
||||
- **Feature-specific components** → `features/<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.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
Plus,
|
||||
Check,
|
||||
Sparkles,
|
||||
Search,
|
||||
SquarePen,
|
||||
} from "lucide-react";
|
||||
import { WorkspaceAvatar } from "@/features/workspace";
|
||||
|
|
@ -26,6 +25,7 @@ import {
|
|||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -155,25 +155,15 @@ export function AppSidebar() {
|
|||
</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>
|
||||
|
||||
|
|
@ -228,6 +218,7 @@ export function AppSidebar() {
|
|||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Suspense } from "react";
|
||||
import { Suspense, forwardRef, useState, useImperativeHandle } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Issue, Comment } from "@multica/types";
|
||||
import type { Issue, Comment } from "@/shared/types";
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
|
|
@ -71,6 +71,39 @@ vi.mock("@/components/ui/calendar", () => ({
|
|||
Calendar: () => null,
|
||||
}));
|
||||
|
||||
// Mock RichTextEditor (Tiptap needs real DOM)
|
||||
vi.mock("@/components/common/rich-text-editor", () => ({
|
||||
RichTextEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => value,
|
||||
clearContent: () => setValue(""),
|
||||
focus: () => {},
|
||||
}));
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
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 +267,12 @@ describe("IssueDetailPage", () => {
|
|||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Leave a comment..."),
|
||||
"New test comment",
|
||||
);
|
||||
const commentInput = screen.getByPlaceholderText("Leave a comment...");
|
||||
await user.type(commentInput, "New test comment");
|
||||
|
||||
const form = screen
|
||||
.getByPlaceholderText("Leave a comment...")
|
||||
.closest("form")!;
|
||||
const submitBtn = form.querySelector(
|
||||
'button[type="submit"]',
|
||||
) as HTMLElement;
|
||||
// Find the Send button (sibling of the editor wrapper)
|
||||
const commentSection = commentInput.closest(".flex.items-start")!;
|
||||
const submitBtn = commentSection.querySelector("button")!;
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut } from "lucide-react";
|
||||
import type { MemberWithUser, MemberRole } from "@multica/types";
|
||||
import type { MemberWithUser, MemberRole } from "@/shared/types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
145
apps/web/components/common/rich-text-editor.css
Normal file
145
apps/web/components/common/rich-text-editor.css
Normal 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);
|
||||
}
|
||||
150
apps/web/components/common/rich-text-editor.tsx
Normal file
150
apps/web/components/common/rich-text-editor.tsx
Normal 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 };
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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`}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { IssueStatus } from "@multica/types";
|
||||
import type { IssueStatus } from "@/shared/types";
|
||||
import { STATUS_CONFIG } from "@/features/issues/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { IssuePriority } from "@multica/types";
|
||||
import type { IssuePriority } from "@/shared/types";
|
||||
|
||||
export const PRIORITY_ORDER: IssuePriority[] = [
|
||||
"urgent",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { IssueStatus } from "@multica/types";
|
||||
import type { IssueStatus } from "@/shared/types";
|
||||
|
||||
export const STATUS_ORDER: IssueStatus[] = [
|
||||
"backlog",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [] }),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useDefaultLayout } from "react-resizable-panels";
|
||||
import {
|
||||
Sparkles,
|
||||
Plus,
|
||||
|
|
@ -11,7 +12,7 @@ import {
|
|||
AlertCircle,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { Skill, CreateSkillRequest, UpdateSkillRequest } from "@multica/types";
|
||||
import type { Skill, CreateSkillRequest, UpdateSkillRequest } from "@/shared/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -20,6 +21,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 { Textarea } from "@/components/ui/textarea";
|
||||
|
|
@ -423,6 +429,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) {
|
||||
|
|
@ -469,73 +478,84 @@ 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
|
||||
|
|
@ -543,6 +563,6 @@ export default function SkillsPage() {
|
|||
onCreate={handleCreate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: [
|
||||
"@multica/sdk",
|
||||
"@multica/types",
|
||||
"@multica/utils",
|
||||
],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ import type {
|
|||
CreateSkillRequest,
|
||||
UpdateSkillRequest,
|
||||
SetAgentSkillsRequest,
|
||||
} from "@multica/types";
|
||||
import { type SDKLogger, noopLogger } from "./logger";
|
||||
} from "@/shared/types";
|
||||
import { type Logger, noopLogger } from "@/shared/logger";
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
|
|
@ -35,9 +35,9 @@ export class ApiClient {
|
|||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
private workspaceId: string | null = null;
|
||||
private logger: SDKLogger;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(baseUrl: string, options?: { logger?: SDKLogger }) {
|
||||
constructor(baseUrl: string, options?: { logger?: Logger }) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.logger = options?.logger ?? noopLogger;
|
||||
}
|
||||
|
|
@ -75,6 +75,16 @@ export class ApiClient {
|
|||
});
|
||||
|
||||
if (!res.ok) {
|
||||
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 };
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ export interface CreateIssueRequest {
|
|||
parent_issue_id?: string;
|
||||
acceptance_criteria?: string[];
|
||||
context_refs?: string[];
|
||||
due_date?: string;
|
||||
}
|
||||
|
||||
export interface UpdateIssueRequest {
|
||||
|
|
@ -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,
|
||||
|
|
@ -17,10 +17,10 @@ export type {
|
|||
CreateSkillRequest,
|
||||
UpdateSkillRequest,
|
||||
SetAgentSkillsRequest,
|
||||
} 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";
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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() {},
|
||||
};
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@
|
|||
|
||||
@theme inline {
|
||||
--font-mono: var(--font-mono);
|
||||
--color-brand: var(--brand);
|
||||
--color-brand-foreground: var(--brand-foreground);
|
||||
--color-canvas: var(--canvas);
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
|
|
@ -12,6 +14,8 @@
|
|||
}
|
||||
|
||||
:root {
|
||||
--brand: oklch(0.55 0.16 255);
|
||||
--brand-foreground: oklch(0.985 0 0);
|
||||
--canvas: oklch(0.95 0.002 286);
|
||||
|
||||
/* Scrollbar */
|
||||
|
|
@ -31,6 +35,8 @@
|
|||
}
|
||||
|
||||
.dark {
|
||||
--brand: oklch(0.65 0.16 255);
|
||||
--brand-foreground: oklch(0.985 0 0);
|
||||
--canvas: oklch(0.2 0.005 286);
|
||||
|
||||
--scrollbar-thumb: oklch(1 0 0 / 15%);
|
||||
|
|
|
|||
682
pnpm-lock.yaml
generated
682
pnpm-lock.yaml
generated
|
|
@ -75,15 +75,24 @@ importers:
|
|||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.3)
|
||||
'@multica/sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/sdk
|
||||
'@multica/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/types
|
||||
'@multica/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/utils
|
||||
'@tiptap/extension-link':
|
||||
specifier: ^3.20.5
|
||||
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-placeholder':
|
||||
specifier: ^3.20.5
|
||||
version: 3.20.5(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-typography':
|
||||
specifier: ^3.20.5
|
||||
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/pm':
|
||||
specifier: ^3.20.5
|
||||
version: 3.20.5
|
||||
'@tiptap/react':
|
||||
specifier: ^3.20.5
|
||||
version: 3.20.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@tiptap/starter-kit':
|
||||
specifier: ^3.20.5
|
||||
version: 3.20.5
|
||||
'@types/linkify-it':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
|
|
@ -113,7 +122,7 @@ importers:
|
|||
version: 1.0.1(react@19.2.3)
|
||||
next:
|
||||
specifier: ^16.1.6
|
||||
version: 16.2.0(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
version: 16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
|
|
@ -153,6 +162,9 @@ importers:
|
|||
tailwind-merge:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
tiptap-markdown:
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.0(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
tw-animate-css:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
|
|
@ -197,22 +209,6 @@ importers:
|
|||
specifier: ^4.1.0
|
||||
version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
|
||||
|
||||
packages/sdk:
|
||||
dependencies:
|
||||
'@multica/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/types:
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/ui:
|
||||
dependencies:
|
||||
'@base-ui/react':
|
||||
|
|
@ -1092,6 +1088,9 @@ packages:
|
|||
react-redux:
|
||||
optional: true
|
||||
|
||||
'@remirror/core-constants@3.0.0':
|
||||
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.10':
|
||||
resolution: {integrity: sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
|
@ -1355,6 +1354,165 @@ packages:
|
|||
peerDependencies:
|
||||
'@testing-library/dom': '>=7.21.4'
|
||||
|
||||
'@tiptap/core@3.20.5':
|
||||
resolution: {integrity: sha512-Pkjd41UJ4F6Z8cPV+gEvqnt1VhY2g66xMjbpxREs0ECA5jRezCNKSZcc2pueQRTMtmn1SaSzGM9U/ifhVlVYOA==}
|
||||
peerDependencies:
|
||||
'@tiptap/pm': ^3.20.5
|
||||
|
||||
'@tiptap/extension-blockquote@3.20.5':
|
||||
resolution: {integrity: sha512-0wU6H/MWWes0rGzgSW6MMU6YDs/3ofUDkqmqCqmb+Siu1ZD0bpzOYpBtujgOYDY8moB9+zCE3G9HSYGcmZxHew==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
|
||||
'@tiptap/extension-bold@3.20.5':
|
||||
resolution: {integrity: sha512-hraiiWkF58n8Jy0Wl3OGwjCTrGWwZZxez/IlexrzKQ/nMFdjDpensZucWwu59zhAM9fqZwGSLDtCFuak03WKnA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
|
||||
'@tiptap/extension-bubble-menu@3.20.5':
|
||||
resolution: {integrity: sha512-6FsASu4o32bp3FzBVb5N2ERjrBy83DtJQAGv9/ycYqsgv2kq9DNlhvtNI7GPiTW7a73ZcImjIX+jEWrARbzOlQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
|
||||
'@tiptap/extension-bullet-list@3.20.5':
|
||||
resolution: {integrity: sha512-MT3321R6F8AoVUEMJ5RiI0PQMenwvtmrSXoO1ehPCWq5TrSJLyXeZMJvZU+1CgfXk4XQU70RN78ib5+Zg+/FCg==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.5
|
||||
|
||||
'@tiptap/extension-code-block@3.20.5':
|
||||
resolution: {integrity: sha512-0YZnqfqZ1IjzKBM4aezw8j3LZWJFEfs4+mbizHNlnZSYpKzpESYLeaLWGO5SpqF9Z8tmYmSoCaf0fqi5LwgdIA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
|
||||
'@tiptap/extension-code@3.20.5':
|
||||
resolution: {integrity: sha512-jBZK/CfdMvg1gkNK/zNAk02IExpBPwUfNLRPiJvGhReL2Q73naKxZGQGp+5Lej9VaeFB70UKuRma/iIzuZbgsA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
|
||||
'@tiptap/extension-document@3.20.5':
|
||||
resolution: {integrity: sha512-BpNGHtOTAjjs/6QbkrafMTlaJqb0gsPngFzd5rB0csxx7rYRE9nIEY+oZ44qMw161+2YB4u20L17SX2mUJANBw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
|
||||
'@tiptap/extension-dropcursor@3.20.5':
|
||||
resolution: {integrity: sha512-/lDG9OjvAv0ynmgFH17mt/GUeGT5bqu0iPW8JMgaRqlKawk+uUIv5SF5WkXS4SwxXih+hXdPEQD3PWZnxlQxAQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.20.5
|
||||
|
||||
'@tiptap/extension-floating-menu@3.20.5':
|
||||
resolution: {integrity: sha512-mTzBNUeAocinrxa5xV+5hGnnNCQB0pVI1GSBwUTHwdB7jNwBqfKAILmtLZONgmhxKWLmGa6WCA59sk+yDI+N0A==}
|
||||
peerDependencies:
|
||||
'@floating-ui/dom': ^1.0.0
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
|
||||
'@tiptap/extension-gapcursor@3.20.5':
|
||||
resolution: {integrity: sha512-H+bRr+mqU/DQq1vfoMlppK1o+RbfSKYBMIcAMHWOez+C96MWfj5bhooVU2HLtl4XGmQxKGr3oEOCKDPdtRNThg==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.20.5
|
||||
|
||||
'@tiptap/extension-hard-break@3.20.5':
|
||||
resolution: {integrity: sha512-+aILNDO7BsXf0IJ4/0BYh570usFK3Q1t/ZQd8zhHuO2ATeWeDVu1x2F+ouFS4X8fmoCcioMzw15aoz93GET6kQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
|
||||
'@tiptap/extension-heading@3.20.5':
|
||||
resolution: {integrity: sha512-zXxuIrCSpzgXzRxgCbRE8DZ/NFuinVaniE3pp/9LYAWgRlsAyko8pI2XrVvzzXmDQqRGi2HrNVkNy1yutUWSWQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
|
||||
'@tiptap/extension-horizontal-rule@3.20.5':
|
||||
resolution: {integrity: sha512-4UtpUHg8cRzxWjJUGtni5VnXYbhsO7ygf1H1pr4Rv63XMBg9lfYDeSwByIuVy9biEFP7eGEFnezzb5Zlh1btmQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
|
||||
'@tiptap/extension-italic@3.20.5':
|
||||
resolution: {integrity: sha512-7bZCgdJVTvhR5vSmNgFQbGvgRoC6m26KcUpHqWiKA95kLL5Wk4YlMCIqdiDpvJ1eakeFEvDcGZvFLg5+1NiQ+w==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
|
||||
'@tiptap/extension-link@3.20.5':
|
||||
resolution: {integrity: sha512-0PukrSYnHX2CrGSThlKfQWxpPWmL7QAvdpDUraKknGvVNSH7tUjchTshy5JdLrn/SQAU92REowRCB6zzCNEFjA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
|
||||
'@tiptap/extension-list-item@3.20.5':
|
||||
resolution: {integrity: sha512-pFJCGLIDEin1Xn6B3ctbrZvtYyALARE56ya4SmaNfnl+Hww5MfkRR40obbwYD3byA1yOpr+bECy+I2clQqzTDw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.5
|
||||
|
||||
'@tiptap/extension-list-keymap@3.20.5':
|
||||
resolution: {integrity: sha512-rmrQgOrUb0jKtFzVUfT0UNEST2sGM2Ve4lOl+1luh66RW6TD+gvgMk/qo12/Kffl9PUiqz8oYfk2qXCwFb6Bug==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.5
|
||||
|
||||
'@tiptap/extension-list@3.20.5':
|
||||
resolution: {integrity: sha512-s+Y8Q7Orq+WQiwgFB/VPMYZe+6EAR2F69xCpvOynlzTInLO4cF6QpXomuGEYAZxLHe8ZBmeIaR7y8MH/OgjrDw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
|
||||
'@tiptap/extension-ordered-list@3.20.5':
|
||||
resolution: {integrity: sha512-Y/RIE3AxUNYAFKGMM5FLlTVKxxBvOh4JlLp/qYsOCY2nJdH0Jopl2FpfBYc4xoJwFSk8BELJ4Ow0adcYb15ksg==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.5
|
||||
|
||||
'@tiptap/extension-paragraph@3.20.5':
|
||||
resolution: {integrity: sha512-mwuhwmff67IpGfOViyRvUC14IlkpsOnB+hSExVnq5+hCntjt/Cr2Z8GGOgzHeIM2FIS0UqX9Lv/b6ttUg4+Now==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
|
||||
'@tiptap/extension-placeholder@3.20.5':
|
||||
resolution: {integrity: sha512-PcZJbzJ8j+YcRdYWFjmFFVnOOx3nETA0pzMj9fXADi28vNABnrWLwsHAseh3I5QfLmywKQb9SpTSTU2LxQgBoA==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.20.5
|
||||
|
||||
'@tiptap/extension-strike@3.20.5':
|
||||
resolution: {integrity: sha512-uwhvmfS4ciGYJRLUg0AHbWsprMCwyWVWd2RXOLRm0ZQeWkvzonPXZhJvzIhIgsFkPLj/dsN5t0+LdiK4UQMnyA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
|
||||
'@tiptap/extension-text@3.20.5':
|
||||
resolution: {integrity: sha512-DMa9g5cH2d/Gx1KXtV7txTxaa6FBqgG8glmfug+N93VMb8sEZR1Yu1az++yAep4SGGq9GWIGZCUS3H6W66et6Q==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
|
||||
'@tiptap/extension-typography@3.20.5':
|
||||
resolution: {integrity: sha512-eZJq5K7cwO1211nZ+MjXs+GeVD2HPFUr11wcZ0zTKlpRSq7yA3zidSOaBJOJ3zJ3iVbis2Ja9XVgv5aEsgMriw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
|
||||
'@tiptap/extension-underline@3.20.5':
|
||||
resolution: {integrity: sha512-HMhr5KIAqZsEhlN8RxKHr/ql1a8OvBa9fLf69IwUVFolBcDExHWUtaEV/axYVRQJvvIy2oKGJxlJWDZ4hkotHQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
|
||||
'@tiptap/extensions@3.20.5':
|
||||
resolution: {integrity: sha512-c4am6SznqfMnbUNSh4MvufiD7cMLdqL1BArok22uBgSWkS1sB9RVBYe8+x0jrOkk0UPEVlzDHbQ+nU+WmIyS2Q==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
|
||||
'@tiptap/pm@3.20.5':
|
||||
resolution: {integrity: sha512-yJhDa7Chx2EqJMX/jlewBv0za7slf1dKHWYve1XaApuVHEkxl0Ul3EDbwnx316vIITkuFW/pWSwkSsAplyBeCw==}
|
||||
|
||||
'@tiptap/react@3.20.5':
|
||||
resolution: {integrity: sha512-in37o1Eo7JCflcHyK/SDfgkJBgX0LRN3LMk+NdLPTerRnC0zhGLQlpfBL4591TLTOUQde7QIrLv98smYO2mj+w==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tiptap/starter-kit@3.20.5':
|
||||
resolution: {integrity: sha512-L5E2TCGK0EiwmGIlwMsiwNTW1TLbfPF1Dsji4bSKRJnPbccZIMCB6qdId8v/Z+QGm85NVcBHeruQrDlKDddXBA==}
|
||||
|
||||
'@ts-morph/common@0.27.0':
|
||||
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
|
||||
|
||||
|
|
@ -1439,12 +1597,27 @@ packages:
|
|||
'@types/hast@3.0.4':
|
||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||
|
||||
'@types/linkify-it@3.0.5':
|
||||
resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==}
|
||||
|
||||
'@types/linkify-it@5.0.0':
|
||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||
|
||||
'@types/markdown-it@13.0.9':
|
||||
resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||
|
||||
'@types/mdurl@1.0.5':
|
||||
resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==}
|
||||
|
||||
'@types/mdurl@2.0.0':
|
||||
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
|
||||
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
|
|
@ -1743,6 +1916,9 @@ packages:
|
|||
typescript:
|
||||
optional: true
|
||||
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -1935,6 +2111,10 @@ packages:
|
|||
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@6.0.1:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
|
@ -1971,6 +2151,10 @@ packages:
|
|||
escape-html@1.0.3:
|
||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||
|
||||
escape-string-regexp@4.0.0:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
escape-string-regexp@5.0.0:
|
||||
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -2029,6 +2213,10 @@ packages:
|
|||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-equals@5.4.0:
|
||||
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
|
@ -2488,6 +2676,9 @@ packages:
|
|||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
|
||||
linkifyjs@4.3.2:
|
||||
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
|
||||
|
||||
log-symbols@6.0.0:
|
||||
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -2514,6 +2705,13 @@ packages:
|
|||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
markdown-it-task-lists@2.1.1:
|
||||
resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
|
||||
|
||||
markdown-it@14.1.1:
|
||||
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
|
||||
hasBin: true
|
||||
|
||||
markdown-table@3.0.4:
|
||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||
|
||||
|
|
@ -2569,6 +2767,9 @@ packages:
|
|||
mdn-data@2.27.1:
|
||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||
|
||||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
media-typer@1.1.0:
|
||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -2816,6 +3017,9 @@ packages:
|
|||
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
orderedmap@2.1.1:
|
||||
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||
|
||||
outvariant@1.4.3:
|
||||
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
|
||||
|
||||
|
|
@ -2920,10 +3124,72 @@ packages:
|
|||
property-information@7.1.0:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
|
||||
prosemirror-changeset@2.4.0:
|
||||
resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==}
|
||||
|
||||
prosemirror-collab@1.3.1:
|
||||
resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==}
|
||||
|
||||
prosemirror-commands@1.7.1:
|
||||
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
|
||||
|
||||
prosemirror-dropcursor@1.8.2:
|
||||
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
|
||||
|
||||
prosemirror-gapcursor@1.4.1:
|
||||
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
|
||||
|
||||
prosemirror-history@1.5.0:
|
||||
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
|
||||
|
||||
prosemirror-inputrules@1.5.1:
|
||||
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
|
||||
|
||||
prosemirror-keymap@1.2.3:
|
||||
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
|
||||
|
||||
prosemirror-markdown@1.13.4:
|
||||
resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==}
|
||||
|
||||
prosemirror-menu@1.3.0:
|
||||
resolution: {integrity: sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==}
|
||||
|
||||
prosemirror-model@1.25.4:
|
||||
resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==}
|
||||
|
||||
prosemirror-schema-basic@1.2.4:
|
||||
resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==}
|
||||
|
||||
prosemirror-schema-list@1.5.1:
|
||||
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
|
||||
|
||||
prosemirror-state@1.4.4:
|
||||
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
|
||||
|
||||
prosemirror-trailing-node@3.0.0:
|
||||
resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==}
|
||||
peerDependencies:
|
||||
prosemirror-model: ^1.22.1
|
||||
prosemirror-state: ^1.4.2
|
||||
prosemirror-view: ^1.33.8
|
||||
|
||||
prosemirror-transform@1.11.0:
|
||||
resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==}
|
||||
|
||||
prosemirror-view@1.41.7:
|
||||
resolution: {integrity: sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
punycode.js@2.3.1:
|
||||
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -3094,6 +3360,9 @@ packages:
|
|||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
rope-sequence@1.3.4:
|
||||
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||
|
||||
router@2.2.0:
|
||||
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
|
@ -3313,6 +3582,11 @@ packages:
|
|||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tiptap-markdown@0.9.0:
|
||||
resolution: {integrity: sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.0.1
|
||||
|
||||
tldts-core@7.0.27:
|
||||
resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==}
|
||||
|
||||
|
|
@ -3553,6 +3827,9 @@ packages:
|
|||
jsdom:
|
||||
optional: true
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -4427,6 +4704,8 @@ snapshots:
|
|||
react: 19.2.3
|
||||
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1)
|
||||
|
||||
'@remirror/core-constants@3.0.0': {}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.10':
|
||||
optional: true
|
||||
|
||||
|
|
@ -4628,6 +4907,191 @@ snapshots:
|
|||
dependencies:
|
||||
'@testing-library/dom': 10.4.1
|
||||
|
||||
'@tiptap/core@3.20.5(@tiptap/pm@3.20.5)':
|
||||
dependencies:
|
||||
'@tiptap/pm': 3.20.5
|
||||
|
||||
'@tiptap/extension-blockquote@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-bold@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-bubble-menu@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
optional: true
|
||||
|
||||
'@tiptap/extension-bullet-list@3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-code-block@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
|
||||
'@tiptap/extension-code@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-document@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-dropcursor@3.20.5(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-floating-menu@3.20.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
optional: true
|
||||
|
||||
'@tiptap/extension-gapcursor@3.20.5(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-hard-break@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-heading@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-horizontal-rule@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
|
||||
'@tiptap/extension-italic@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-link@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
linkifyjs: 4.3.2
|
||||
|
||||
'@tiptap/extension-list-item@3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-list-keymap@3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
|
||||
'@tiptap/extension-ordered-list@3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-paragraph@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-placeholder@3.20.5(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-strike@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-text@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-typography@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extension-underline@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
|
||||
'@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
|
||||
'@tiptap/pm@3.20.5':
|
||||
dependencies:
|
||||
prosemirror-changeset: 2.4.0
|
||||
prosemirror-collab: 1.3.1
|
||||
prosemirror-commands: 1.7.1
|
||||
prosemirror-dropcursor: 1.8.2
|
||||
prosemirror-gapcursor: 1.4.1
|
||||
prosemirror-history: 1.5.0
|
||||
prosemirror-inputrules: 1.5.1
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-markdown: 1.13.4
|
||||
prosemirror-menu: 1.3.0
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-schema-basic: 1.2.4
|
||||
prosemirror-schema-list: 1.5.1
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-tables: 1.8.5
|
||||
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7)
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.7
|
||||
|
||||
'@tiptap/react@3.20.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
fast-equals: 5.4.0
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@tiptap/extension-bubble-menu': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-floating-menu': 3.20.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
transitivePeerDependencies:
|
||||
- '@floating-ui/dom'
|
||||
|
||||
'@tiptap/starter-kit@3.20.5':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-blockquote': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-bold': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-bullet-list': 3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-code': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-code-block': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-document': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-dropcursor': 3.20.5(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-gapcursor': 3.20.5(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-hard-break': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-heading': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-horizontal-rule': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-italic': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-link': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-list': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-list-item': 3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-list-keymap': 3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-ordered-list': 3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-paragraph': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-strike': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-text': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-underline': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extensions': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
|
||||
'@ts-morph/common@0.27.0':
|
||||
dependencies:
|
||||
fast-glob: 3.3.3
|
||||
|
|
@ -4704,12 +5168,28 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/linkify-it@3.0.5': {}
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
'@types/markdown-it@13.0.9':
|
||||
dependencies:
|
||||
'@types/linkify-it': 3.0.5
|
||||
'@types/mdurl': 1.0.5
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
dependencies:
|
||||
'@types/linkify-it': 5.0.0
|
||||
'@types/mdurl': 2.0.0
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/mdurl@1.0.5': {}
|
||||
|
||||
'@types/mdurl@2.0.0': {}
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/node@25.5.0':
|
||||
|
|
@ -4977,6 +5457,8 @@ snapshots:
|
|||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
|
|
@ -5130,6 +5612,8 @@ snapshots:
|
|||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.0
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
env-paths@2.2.1: {}
|
||||
|
|
@ -5154,6 +5638,8 @@ snapshots:
|
|||
|
||||
escape-html@1.0.3: {}
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
escape-string-regexp@5.0.0: {}
|
||||
|
||||
esprima@4.0.1: {}
|
||||
|
|
@ -5245,6 +5731,8 @@ snapshots:
|
|||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-equals@5.4.0: {}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
|
|
@ -5686,6 +6174,8 @@ snapshots:
|
|||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
linkifyjs@4.3.2: {}
|
||||
|
||||
log-symbols@6.0.0:
|
||||
dependencies:
|
||||
chalk: 5.6.2
|
||||
|
|
@ -5709,6 +6199,17 @@ snapshots:
|
|||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
markdown-it-task-lists@2.1.1: {}
|
||||
|
||||
markdown-it@14.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
entities: 4.5.0
|
||||
linkify-it: 5.0.0
|
||||
mdurl: 2.0.0
|
||||
punycode.js: 2.3.1
|
||||
uc.micro: 2.1.0
|
||||
|
||||
markdown-table@3.0.4: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
|
@ -5868,6 +6369,8 @@ snapshots:
|
|||
|
||||
mdn-data@2.27.1: {}
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
||||
merge-descriptors@2.0.0: {}
|
||||
|
|
@ -6128,7 +6631,7 @@ snapshots:
|
|||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
next@16.2.0(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
next@16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
'@next/env': 16.2.0
|
||||
'@swc/helpers': 0.5.15
|
||||
|
|
@ -6137,7 +6640,7 @@ snapshots:
|
|||
postcss: 8.4.31
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
styled-jsx: 5.1.6(react@19.2.3)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.2.0
|
||||
'@next/swc-darwin-x64': 16.2.0
|
||||
|
|
@ -6225,6 +6728,8 @@ snapshots:
|
|||
string-width: 7.2.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
orderedmap@2.1.1: {}
|
||||
|
||||
outvariant@1.4.3: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
|
|
@ -6324,11 +6829,116 @@ snapshots:
|
|||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
prosemirror-changeset@2.4.0:
|
||||
dependencies:
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
prosemirror-collab@1.3.1:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
|
||||
prosemirror-commands@1.7.1:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
prosemirror-dropcursor@1.8.2:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.7
|
||||
|
||||
prosemirror-gapcursor@1.4.1:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-view: 1.41.7
|
||||
|
||||
prosemirror-history@1.5.0:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.7
|
||||
rope-sequence: 1.3.4
|
||||
|
||||
prosemirror-inputrules@1.5.1:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
prosemirror-keymap@1.2.3:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
prosemirror-markdown@1.13.4:
|
||||
dependencies:
|
||||
'@types/markdown-it': 14.1.2
|
||||
markdown-it: 14.1.1
|
||||
prosemirror-model: 1.25.4
|
||||
|
||||
prosemirror-menu@1.3.0:
|
||||
dependencies:
|
||||
crelt: 1.0.6
|
||||
prosemirror-commands: 1.7.1
|
||||
prosemirror-history: 1.5.0
|
||||
prosemirror-state: 1.4.4
|
||||
|
||||
prosemirror-model@1.25.4:
|
||||
dependencies:
|
||||
orderedmap: 2.1.1
|
||||
|
||||
prosemirror-schema-basic@1.2.4:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
|
||||
prosemirror-schema-list@1.5.1:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
prosemirror-state@1.4.4:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.7
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.7
|
||||
|
||||
prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7):
|
||||
dependencies:
|
||||
'@remirror/core-constants': 3.0.0
|
||||
escape-string-regexp: 4.0.0
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-view: 1.41.7
|
||||
|
||||
prosemirror-transform@1.11.0:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
|
||||
prosemirror-view@1.41.7:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
punycode.js@2.3.1: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qs@6.15.0:
|
||||
|
|
@ -6549,6 +7159,8 @@ snapshots:
|
|||
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.10
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.10
|
||||
|
||||
rope-sequence@1.3.4: {}
|
||||
|
||||
router@2.2.0:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
|
|
@ -6803,10 +7415,12 @@ snapshots:
|
|||
dependencies:
|
||||
inline-style-parser: 0.2.7
|
||||
|
||||
styled-jsx@5.1.6(react@19.2.3):
|
||||
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.3
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.29.0
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
|
|
@ -6833,6 +7447,14 @@ snapshots:
|
|||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
tiptap-markdown@0.9.0(@tiptap/core@3.20.5(@tiptap/pm@3.20.5)):
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@types/markdown-it': 13.0.9
|
||||
markdown-it: 14.1.1
|
||||
markdown-it-task-lists: 2.1.1
|
||||
prosemirror-markdown: 1.13.4
|
||||
|
||||
tldts-core@7.0.27: {}
|
||||
|
||||
tldts@7.0.27:
|
||||
|
|
@ -7052,6 +7674,8 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import (
|
|||
|
||||
// APIClient is a REST client for the Multica server API.
|
||||
// Used by ctrl subcommands (agent, runtime, status, etc.).
|
||||
//
|
||||
// TODO: Add Authorization header support. Agent routes (/api/agents/...)
|
||||
// require JWT auth via middleware.Auth, but this client currently sends
|
||||
// no auth token. CLI agent commands will fail with 401 until this is added.
|
||||
type APIClient struct {
|
||||
BaseURL string
|
||||
WorkspaceID string
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ type CreateIssueRequest struct {
|
|||
ParentIssueID *string `json:"parent_issue_id"`
|
||||
AcceptanceCriteria []any `json:"acceptance_criteria"`
|
||||
ContextRefs []any `json:"context_refs"`
|
||||
DueDate *string `json:"due_date"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -215,6 +216,16 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
parentIssueID = parseUUID(*req.ParentIssueID)
|
||||
}
|
||||
|
||||
var dueDate pgtype.Timestamptz
|
||||
if req.DueDate != nil && *req.DueDate != "" {
|
||||
t, err := time.Parse(time.RFC3339, *req.DueDate)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid due_date format, expected RFC3339")
|
||||
return
|
||||
}
|
||||
dueDate = pgtype.Timestamptz{Time: t, Valid: true}
|
||||
}
|
||||
|
||||
issue, err := h.Queries.CreateIssue(r.Context(), db.CreateIssueParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
Title: req.Title,
|
||||
|
|
@ -229,6 +240,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
AcceptanceCriteria: ac,
|
||||
ContextRefs: cr,
|
||||
Position: 0,
|
||||
DueDate: dueDate,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
||||
|
|
|
|||
|
|
@ -16,26 +16,27 @@ INSERT INTO issue (
|
|||
workspace_id, title, description, status, priority,
|
||||
assignee_type, assignee_id, creator_type, creator_id,
|
||||
parent_issue_id, acceptance_criteria, context_refs,
|
||||
position
|
||||
position, due_date
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
|
||||
) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateIssueParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Title string `json:"title"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
AssigneeType pgtype.Text `json:"assignee_type"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
CreatorType string `json:"creator_type"`
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
|
||||
AcceptanceCriteria []byte `json:"acceptance_criteria"`
|
||||
ContextRefs []byte `json:"context_refs"`
|
||||
Position float64 `json:"position"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Title string `json:"title"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
AssigneeType pgtype.Text `json:"assignee_type"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
CreatorType string `json:"creator_type"`
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
|
||||
AcceptanceCriteria []byte `json:"acceptance_criteria"`
|
||||
ContextRefs []byte `json:"context_refs"`
|
||||
Position float64 `json:"position"`
|
||||
DueDate pgtype.Timestamptz `json:"due_date"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) {
|
||||
|
|
@ -53,6 +54,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue
|
|||
arg.AcceptanceCriteria,
|
||||
arg.ContextRefs,
|
||||
arg.Position,
|
||||
arg.DueDate,
|
||||
)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ INSERT INTO issue (
|
|||
workspace_id, title, description, status, priority,
|
||||
assignee_type, assignee_id, creator_type, creator_id,
|
||||
parent_issue_id, acceptance_criteria, context_refs,
|
||||
position
|
||||
position, due_date
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
|
||||
) RETURNING *;
|
||||
|
||||
-- name: UpdateIssue :one
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue