Merge pull request #472 from multica-ai/feat/tanstack-query-migration
feat(web): migrate server state from Zustand to TanStack Query (Phase 0-4)
This commit is contained in:
commit
efe131591f
63 changed files with 4243 additions and 1206 deletions
67
CLAUDE.md
67
CLAUDE.md
|
|
@ -24,65 +24,94 @@ The frontend uses a **feature-based architecture** with four layers:
|
||||||
```
|
```
|
||||||
apps/web/
|
apps/web/
|
||||||
├── app/ # Routing layer (thin shells — import from features/)
|
├── app/ # Routing layer (thin shells — import from features/)
|
||||||
├── features/ # Business logic, organized by domain
|
├── core/ # Headless business logic (TanStack Query, zero JSX, zero react-dom)
|
||||||
|
├── features/ # UI business components, organized by domain
|
||||||
├── shared/ # Cross-feature utilities (api client, types, logger)
|
├── 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/`.
|
**`app/`** — Next.js App Router pages. Route files should be thin: import and re-export from `features/`. Layout components and route-specific glue (redirects, auth guards) live here. Shared layout components (e.g. `app-sidebar`) stay in `app/(dashboard)/_components/`.
|
||||||
|
|
||||||
**`features/`** — Domain modules, each with its own components, hooks, stores, and config:
|
**`core/`** — Headless business logic. Query key factories, `queryOptions`, mutation hooks, WS cache updaters. **No JSX, no react-dom.** Designed for future extraction to `packages/core/` in a monorepo.
|
||||||
|
|
||||||
|
| Module | Purpose | Key exports |
|
||||||
|
|---|---|---|
|
||||||
|
| `core/issues/` | Issue queries, mutations, WS updaters | `issueListOptions`, `useUpdateIssue`, `onIssueUpdated` |
|
||||||
|
| `core/inbox/` | Inbox queries, mutations, WS updaters | `inboxListOptions`, `useMarkInboxRead` |
|
||||||
|
| `core/workspace/` | Member/agent/skill queries, workspace mutations | `memberListOptions`, `agentListOptions` |
|
||||||
|
| `core/runtimes/` | Runtime queries | `runtimeListOptions` |
|
||||||
|
| `core/query-client.ts` | QueryClient factory | `createQueryClient` |
|
||||||
|
| `core/provider.tsx` | QueryClientProvider wrapper | `QueryProvider` |
|
||||||
|
| `core/hooks.ts` | Shared hooks | `useWorkspaceId` |
|
||||||
|
|
||||||
|
**`features/`** — Domain modules with UI components, client-only stores, and config:
|
||||||
|
|
||||||
| Feature | Purpose | Exports |
|
| Feature | Purpose | Exports |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
|
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
|
||||||
| `features/workspace/` | Workspace, members, agents | `useWorkspaceStore`, `useActorName` |
|
| `features/workspace/` | Workspace identity + UI | `useWorkspaceStore` (client-only: workspace/workspaces), `useActorName` |
|
||||||
| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config |
|
| `features/issues/` | Issue UI components + client state | `useIssueStore` (client-only: activeIssueId), icons, pickers, config |
|
||||||
| `features/inbox/` | Inbox notification state | `useInboxStore` |
|
|
||||||
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
|
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
|
||||||
| `features/modals/` | Modal registry and state | Modal store and components |
|
| `features/modals/` | Modal registry and state | Modal store and components |
|
||||||
| `features/skills/` | Skill management | Skill components |
|
| `features/skills/` | Skill management | Skill components |
|
||||||
|
|
||||||
**`shared/`** — Code used across multiple features:
|
**`shared/`** — Code used across multiple features (will migrate to `core/` in Phase 5):
|
||||||
- `shared/api/` — `ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton.
|
- `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/types/` — Domain types (Issue, Agent, Workspace, etc.) and WebSocket event types.
|
||||||
- `shared/logger.ts` — Logger utility.
|
- `shared/logger.ts` — Logger utility.
|
||||||
|
|
||||||
### State Management
|
### State Management
|
||||||
|
|
||||||
- **Zustand** for global client state — one store per feature domain (`features/auth/store.ts`, `features/workspace/store.ts`, `features/issues/store.ts`, `features/inbox/store.ts`).
|
- **TanStack Query** for all server state — issues, inbox, members, agents, skills, runtimes. Query definitions live in `core/<domain>/queries.ts`, mutations in `core/<domain>/mutations.ts`.
|
||||||
|
- **Zustand** for client-only state — UI selections (`activeIssueId`), view filters, modal state, workspace identity, navigation. No API calls in Zustand stores.
|
||||||
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
|
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
|
||||||
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
|
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
|
||||||
- Do not use React Context for data that can be a zustand store.
|
|
||||||
|
|
||||||
**Store conventions:**
|
**TanStack Query conventions:**
|
||||||
- One store per feature domain. Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`.
|
- `staleTime: Infinity` — WS events handle cache freshness, no polling or refetch-on-focus.
|
||||||
|
- WS events trigger `queryClient.invalidateQueries()` (preferred) or `queryClient.setQueryData()` for granular updates.
|
||||||
|
- All workspace-scoped query keys include `wsId` — workspace switch automatically uses new cache.
|
||||||
|
- Mutations use `onMutate` for optimistic updates + `onError` for rollback + `onSettled` for invalidation.
|
||||||
|
- Components access QueryClient via `useQueryClient()` hook. Non-React contexts (e.g. Tiptap plugin callbacks) receive QueryClient via closure from the parent React component — never use module-level singletons.
|
||||||
|
|
||||||
|
**Zustand store conventions:**
|
||||||
|
- Stores hold only client state (UI selections, persisted preferences). Zero `api.*` calls in stores.
|
||||||
|
- Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`.
|
||||||
- Stores must not call `useRouter` or any React hooks — keep navigation in components.
|
- Stores must not call `useRouter` or any React hooks — keep navigation in components.
|
||||||
- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks).
|
- `useWorkspaceStore` manages workspace identity (`workspace`, `workspaces`, `api.setWorkspaceId`, localStorage). Server data (members, agents, skills) is in TanStack Query, not the store.
|
||||||
- Dependency direction: `workspace` → `auth`, `realtime` → `auth`, `issues` → `workspace`. Never reverse.
|
|
||||||
|
|
||||||
### Import Aliases
|
### Import Aliases
|
||||||
|
|
||||||
Use `@/` alias (maps to `apps/web/`):
|
Use `@/` alias (maps to `apps/web/`) and `@core/` alias (maps to `apps/web/core/`):
|
||||||
```typescript
|
```typescript
|
||||||
|
// Core (headless business logic)
|
||||||
|
import { issueListOptions, issueKeys } from "@core/issues/queries";
|
||||||
|
import { useUpdateIssue, useCreateIssue } from "@core/issues/mutations";
|
||||||
|
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
|
||||||
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
|
||||||
|
// Shared (api client, types)
|
||||||
import { api } from "@/shared/api";
|
import { api } from "@/shared/api";
|
||||||
import type { Issue } from "@/shared/types";
|
import type { Issue } from "@/shared/types";
|
||||||
|
|
||||||
|
// Features (UI components, client stores)
|
||||||
import { useAuthStore } from "@/features/auth";
|
import { useAuthStore } from "@/features/auth";
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
import { useWorkspaceStore } from "@/features/workspace";
|
||||||
import { useIssueStore } from "@/features/issues";
|
|
||||||
import { useInboxStore } from "@/features/inbox";
|
|
||||||
import { useWSEvent } from "@/features/realtime";
|
import { useWSEvent } from "@/features/realtime";
|
||||||
import { StatusIcon } from "@/features/issues/components";
|
import { StatusIcon } from "@/features/issues/components";
|
||||||
```
|
```
|
||||||
|
|
||||||
Within a feature, use relative imports. Between features or to shared, use `@/`.
|
Within a feature, use relative imports. Between features or to shared, use `@/`. For core modules, use `@core/`.
|
||||||
|
|
||||||
### Data Flow
|
### Data Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
|
Browser → useQuery (core/) → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
|
||||||
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
|
Browser ← useQuery cache ← invalidateQueries ← WS event handlers ← WSClient ← Hub.Broadcast()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Mutations: `useMutation (core/)` → optimistic cache update → API call → onSettled invalidation.
|
||||||
|
WS events: `use-realtime-sync.ts` → `queryClient.invalidateQueries()` for most events, `setQueryData()` for granular issue/inbox updates.
|
||||||
|
|
||||||
### Backend Structure (`server/`)
|
### Backend Structure (`server/`)
|
||||||
|
|
||||||
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate`
|
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate`
|
||||||
|
|
@ -177,7 +206,7 @@ make start-worktree # Start using .env.worktree
|
||||||
- Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`.
|
- 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.
|
- **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`).
|
- 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.
|
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Server data goes through TanStack Query (`core/`), client-only shared state through Zustand, React Context only for connection lifecycle.
|
||||||
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
|
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
|
||||||
- When unsure about interaction or state design, ask — the user will provide direction.
|
- When unsure about interaction or state design, ask — the user will provide direction.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
|
|
@ -42,7 +43,9 @@ import {
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||||
import { useAuthStore } from "@/features/auth";
|
import { useAuthStore } from "@/features/auth";
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
import { useWorkspaceStore } from "@/features/workspace";
|
||||||
import { useInboxStore } from "@/features/inbox";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { inboxKeys, deduplicateInboxItems } from "@core/inbox/queries";
|
||||||
|
import { api } from "@/shared/api";
|
||||||
import { useModalStore } from "@/features/modals";
|
import { useModalStore } from "@/features/modals";
|
||||||
|
|
||||||
const primaryNav = [
|
const primaryNav = [
|
||||||
|
|
@ -73,7 +76,16 @@ export function AppSidebar() {
|
||||||
const workspaces = useWorkspaceStore((s) => s.workspaces);
|
const workspaces = useWorkspaceStore((s) => s.workspaces);
|
||||||
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
|
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
|
||||||
|
|
||||||
const unreadCount = useInboxStore((s) => s.unreadCount());
|
const wsId = workspace?.id;
|
||||||
|
const { data: inboxItems = [] } = useQuery({
|
||||||
|
queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"],
|
||||||
|
queryFn: () => api.listInbox(),
|
||||||
|
enabled: !!wsId,
|
||||||
|
});
|
||||||
|
const unreadCount = React.useMemo(
|
||||||
|
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
|
||||||
|
[inboxItems],
|
||||||
|
);
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,11 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { api } from "@/shared/api";
|
import { api } from "@/shared/api";
|
||||||
import { useAuthStore } from "@/features/auth";
|
import { useAuthStore } from "@/features/auth";
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
import { useWorkspaceStore } from "@/features/workspace";
|
||||||
import { useRuntimeStore } from "@/features/runtimes";
|
import { runtimeListOptions } from "@core/runtimes/queries";
|
||||||
import { useIssueStore } from "@/features/issues";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import { issueListOptions } from "@core/issues/queries";
|
||||||
|
import { skillListOptions, agentListOptions, workspaceKeys } from "@core/workspace/queries";
|
||||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||||
|
|
||||||
|
|
@ -431,8 +434,9 @@ function SkillsTab({
|
||||||
}: {
|
}: {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
}) {
|
}) {
|
||||||
const workspaceSkills = useWorkspaceStore((s) => s.skills);
|
const qc = useQueryClient();
|
||||||
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
|
const wsId = useWorkspaceId();
|
||||||
|
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [showPicker, setShowPicker] = useState(false);
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
|
||||||
|
|
@ -444,7 +448,7 @@ function SkillsTab({
|
||||||
try {
|
try {
|
||||||
const newIds = [...agent.skills.map((s) => s.id), skillId];
|
const newIds = [...agent.skills.map((s) => s.id), skillId];
|
||||||
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
||||||
await refreshAgents();
|
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Failed to add skill");
|
toast.error(e instanceof Error ? e.message : "Failed to add skill");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -458,7 +462,7 @@ function SkillsTab({
|
||||||
try {
|
try {
|
||||||
const newIds = agent.skills.filter((s) => s.id !== skillId).map((s) => s.id);
|
const newIds = agent.skills.filter((s) => s.id !== skillId).map((s) => s.id);
|
||||||
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
||||||
await refreshAgents();
|
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Failed to remove skill");
|
toast.error(e instanceof Error ? e.message : "Failed to remove skill");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -591,7 +595,8 @@ function SkillsTab({
|
||||||
function TasksTab({ agent }: { agent: Agent }) {
|
function TasksTab({ agent }: { agent: Agent }) {
|
||||||
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const issues = useIssueStore((s) => s.issues);
|
const wsId = useWorkspaceId();
|
||||||
|
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -1065,21 +1070,17 @@ function AgentDetail({
|
||||||
export default function AgentsPage() {
|
export default function AgentsPage() {
|
||||||
const isLoading = useAuthStore((s) => s.isLoading);
|
const isLoading = useAuthStore((s) => s.isLoading);
|
||||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||||
const agents = useWorkspaceStore((s) => s.agents);
|
const qc = useQueryClient();
|
||||||
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
|
const wsId = useWorkspaceId();
|
||||||
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||||
const [selectedId, setSelectedId] = useState<string>("");
|
const [selectedId, setSelectedId] = useState<string>("");
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const runtimes = useRuntimeStore((s) => s.runtimes);
|
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||||
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
|
|
||||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||||
id: "multica_agents_layout",
|
id: "multica_agents_layout",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (workspace) fetchRuntimes();
|
|
||||||
}, [workspace, fetchRuntimes]);
|
|
||||||
|
|
||||||
const filteredAgents = useMemo(
|
const filteredAgents = useMemo(
|
||||||
() => showArchived ? agents.filter((a) => !!a.archived_at) : agents.filter((a) => !a.archived_at),
|
() => showArchived ? agents.filter((a) => !!a.archived_at) : agents.filter((a) => !a.archived_at),
|
||||||
[agents, showArchived],
|
[agents, showArchived],
|
||||||
|
|
@ -1096,14 +1097,14 @@ export default function AgentsPage() {
|
||||||
|
|
||||||
const handleCreate = async (data: CreateAgentRequest) => {
|
const handleCreate = async (data: CreateAgentRequest) => {
|
||||||
const agent = await api.createAgent(data);
|
const agent = await api.createAgent(data);
|
||||||
await refreshAgents();
|
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||||
setSelectedId(agent.id);
|
setSelectedId(agent.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
|
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
|
||||||
try {
|
try {
|
||||||
await api.updateAgent(id, data as UpdateAgentRequest);
|
await api.updateAgent(id, data as UpdateAgentRequest);
|
||||||
await refreshAgents();
|
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||||
toast.success("Agent updated");
|
toast.success("Agent updated");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Failed to update agent");
|
toast.error(e instanceof Error ? e.message : "Failed to update agent");
|
||||||
|
|
@ -1114,7 +1115,7 @@ export default function AgentsPage() {
|
||||||
const handleArchive = async (id: string) => {
|
const handleArchive = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await api.archiveAgent(id);
|
await api.archiveAgent(id);
|
||||||
await refreshAgents();
|
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||||
toast.success("Agent archived");
|
toast.success("Agent archived");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
|
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
|
||||||
|
|
@ -1124,7 +1125,7 @@ export default function AgentsPage() {
|
||||||
const handleRestore = async (id: string) => {
|
const handleRestore = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await api.restoreAgent(id);
|
await api.restoreAgent(id);
|
||||||
await refreshAgents();
|
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||||
toast.success("Agent restored");
|
toast.success("Agent restored");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Failed to restore agent");
|
toast.error(e instanceof Error ? e.message : "Failed to restore agent");
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,22 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useDefaultLayout } from "react-resizable-panels";
|
import { useDefaultLayout } from "react-resizable-panels";
|
||||||
import { useInboxStore } from "@/features/inbox";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import {
|
||||||
|
inboxListOptions,
|
||||||
|
deduplicateInboxItems,
|
||||||
|
} from "@core/inbox/queries";
|
||||||
|
import {
|
||||||
|
useMarkInboxRead,
|
||||||
|
useArchiveInbox,
|
||||||
|
useMarkAllInboxRead,
|
||||||
|
useArchiveAllInbox,
|
||||||
|
useArchiveAllReadInbox,
|
||||||
|
useArchiveCompletedInbox,
|
||||||
|
} from "@core/inbox/mutations";
|
||||||
import { IssueDetail, StatusIcon, PriorityIcon } from "@/features/issues/components";
|
import { IssueDetail, StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||||
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@/features/issues/config";
|
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||||
import { useActorName } from "@/features/workspace";
|
import { useActorName } from "@/features/workspace";
|
||||||
|
|
@ -33,7 +46,6 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { api } from "@/shared/api";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
@ -235,8 +247,9 @@ export default function InboxPage() {
|
||||||
window.history.replaceState(null, "", url);
|
window.history.replaceState(null, "", url);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const items = useInboxStore((s) => s.dedupedItems());
|
const wsId = useWorkspaceId();
|
||||||
const loading = useInboxStore((s) => s.loading);
|
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
|
||||||
|
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
|
||||||
|
|
||||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||||
id: "multica_inbox_layout",
|
id: "multica_inbox_layout",
|
||||||
|
|
@ -245,74 +258,58 @@ export default function InboxPage() {
|
||||||
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
|
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
|
||||||
const unreadCount = items.filter((i) => !i.read).length;
|
const unreadCount = items.filter((i) => !i.read).length;
|
||||||
|
|
||||||
|
const markReadMutation = useMarkInboxRead();
|
||||||
|
const archiveMutation = useArchiveInbox();
|
||||||
|
const markAllReadMutation = useMarkAllInboxRead();
|
||||||
|
const archiveAllMutation = useArchiveAllInbox();
|
||||||
|
const archiveAllReadMutation = useArchiveAllReadInbox();
|
||||||
|
const archiveCompletedMutation = useArchiveCompletedInbox();
|
||||||
|
|
||||||
// Click-to-read: select + auto-mark-read
|
// Click-to-read: select + auto-mark-read
|
||||||
const handleSelect = async (item: InboxItem) => {
|
const handleSelect = (item: InboxItem) => {
|
||||||
setSelectedKey(item.issue_id ?? item.id);
|
setSelectedKey(item.issue_id ?? item.id);
|
||||||
if (!item.read) {
|
if (!item.read) {
|
||||||
useInboxStore.getState().markRead(item.id);
|
markReadMutation.mutate(item.id, {
|
||||||
try {
|
onError: () => toast.error("Failed to mark as read"),
|
||||||
await api.markInboxRead(item.id);
|
});
|
||||||
} catch {
|
|
||||||
// Rollback: refetch to get server truth
|
|
||||||
useInboxStore.getState().fetch();
|
|
||||||
toast.error("Failed to mark as read");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchive = async (id: string) => {
|
const handleArchive = (id: string) => {
|
||||||
try {
|
const archived = items.find((i) => i.id === id);
|
||||||
await api.archiveInbox(id);
|
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
|
||||||
useInboxStore.getState().archive(id);
|
archiveMutation.mutate(id, {
|
||||||
const archived = items.find((i) => i.id === id);
|
onError: () => toast.error("Failed to archive"),
|
||||||
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
|
});
|
||||||
} catch {
|
|
||||||
toast.error("Failed to archive");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Batch operations
|
// Batch operations
|
||||||
const handleMarkAllRead = async () => {
|
const handleMarkAllRead = () => {
|
||||||
try {
|
markAllReadMutation.mutate(undefined, {
|
||||||
useInboxStore.getState().markAllRead();
|
onError: () => toast.error("Failed to mark all as read"),
|
||||||
await api.markAllInboxRead();
|
});
|
||||||
} catch {
|
|
||||||
toast.error("Failed to mark all as read");
|
|
||||||
useInboxStore.getState().fetch();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchiveAll = async () => {
|
const handleArchiveAll = () => {
|
||||||
try {
|
setSelectedKey("");
|
||||||
useInboxStore.getState().archiveAll();
|
archiveAllMutation.mutate(undefined, {
|
||||||
setSelectedKey("");
|
onError: () => toast.error("Failed to archive all"),
|
||||||
await api.archiveAllInbox();
|
});
|
||||||
} catch {
|
|
||||||
toast.error("Failed to archive all");
|
|
||||||
useInboxStore.getState().fetch();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchiveAllRead = async () => {
|
const handleArchiveAllRead = () => {
|
||||||
try {
|
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
|
||||||
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
|
if (readKeys.includes(selectedKey)) setSelectedKey("");
|
||||||
useInboxStore.getState().archiveAllRead();
|
archiveAllReadMutation.mutate(undefined, {
|
||||||
if (readKeys.includes(selectedKey)) setSelectedKey("");
|
onError: () => toast.error("Failed to archive read items"),
|
||||||
await api.archiveAllReadInbox();
|
});
|
||||||
} catch {
|
|
||||||
toast.error("Failed to archive read items");
|
|
||||||
useInboxStore.getState().fetch();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchiveCompleted = async () => {
|
const handleArchiveCompleted = () => {
|
||||||
try {
|
setSelectedKey("");
|
||||||
await api.archiveCompletedInbox();
|
archiveCompletedMutation.mutate(undefined, {
|
||||||
setSelectedKey("");
|
onError: () => toast.error("Failed to archive completed"),
|
||||||
await useInboxStore.getState().fetch();
|
});
|
||||||
} catch {
|
|
||||||
toast.error("Failed to archive completed");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "rea
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
|
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import type { Issue, Comment, TimelineEntry } from "@/shared/types";
|
import type { Issue, Comment, TimelineEntry } from "@/shared/types";
|
||||||
|
|
||||||
// Mock next/navigation
|
// Mock next/navigation
|
||||||
|
|
@ -62,34 +63,11 @@ vi.mock("@/features/workspace", () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock issue store — supply a stable full issue object so storeIssue
|
// Mock issue store — only client state remains (activeIssueId)
|
||||||
// doesn't create a new reference each render (avoids infinite effect loop)
|
|
||||||
// and has all required fields for rendering.
|
|
||||||
const stableStoreIssues = vi.hoisted(() => [
|
|
||||||
{
|
|
||||||
id: "issue-1",
|
|
||||||
workspace_id: "ws-1",
|
|
||||||
number: 1,
|
|
||||||
identifier: "TES-1",
|
|
||||||
title: "Implement authentication",
|
|
||||||
description: "Add JWT auth to the backend",
|
|
||||||
status: "in_progress",
|
|
||||||
priority: "high",
|
|
||||||
assignee_type: "member",
|
|
||||||
assignee_id: "user-1",
|
|
||||||
creator_type: "member",
|
|
||||||
creator_id: "user-1",
|
|
||||||
parent_issue_id: null,
|
|
||||||
position: 0,
|
|
||||||
due_date: "2026-06-01T00:00:00Z",
|
|
||||||
created_at: "2026-01-15T00:00:00Z",
|
|
||||||
updated_at: "2026-01-20T00:00:00Z",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
vi.mock("@/features/issues", () => ({
|
vi.mock("@/features/issues", () => ({
|
||||||
useIssueStore: Object.assign(
|
useIssueStore: Object.assign(
|
||||||
(selector: (s: any) => any) => selector({ issues: stableStoreIssues }),
|
(selector: (s: any) => any) => selector({ activeIssueId: null }),
|
||||||
{ getState: () => ({ issues: stableStoreIssues, addIssue: vi.fn(), updateIssue: vi.fn(), removeIssue: vi.fn() }) },
|
{ getState: () => ({ activeIssueId: null, setActiveIssue: vi.fn() }) },
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -235,14 +213,26 @@ const mockTimeline: TimelineEntry[] = [
|
||||||
|
|
||||||
import IssueDetailPage from "./page";
|
import IssueDetailPage from "./page";
|
||||||
|
|
||||||
|
function createTestQueryClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false, gcTime: 0 },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// React 19 use(Promise) needs the promise to resolve within act + Suspense
|
// React 19 use(Promise) needs the promise to resolve within act + Suspense
|
||||||
async function renderPage(id = "issue-1") {
|
async function renderPage(id = "issue-1") {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
let result: ReturnType<typeof render>;
|
let result: ReturnType<typeof render>;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
result = render(
|
result = render(
|
||||||
<Suspense fallback={<div>Suspense loading...</div>}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<IssueDetailPage params={Promise.resolve({ id })} />
|
<Suspense fallback={<div>Suspense loading...</div>}>
|
||||||
</Suspense>,
|
<IssueDetailPage params={Promise.resolve({ id })} />
|
||||||
|
</Suspense>
|
||||||
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return result!;
|
return result!;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import type { Issue } from "@/shared/types";
|
import type { Issue } from "@/shared/types";
|
||||||
|
|
||||||
// Mock next/navigation
|
// Mock next/navigation
|
||||||
|
|
@ -61,36 +62,28 @@ vi.mock("sonner", () => ({
|
||||||
|
|
||||||
// Mock api
|
// Mock api
|
||||||
const mockUpdateIssue = vi.fn();
|
const mockUpdateIssue = vi.fn();
|
||||||
|
const mockListIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ issues: [], total: 0 }));
|
||||||
|
|
||||||
vi.mock("@/shared/api", () => ({
|
vi.mock("@/shared/api", () => ({
|
||||||
api: {
|
api: {
|
||||||
listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }),
|
listIssues: (...args: any[]) => mockListIssues(...args),
|
||||||
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
|
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the issue store
|
// Mock issue store — only client state remains
|
||||||
let mockStoreState: {
|
const mockIssueClientState = { activeIssueId: null, setActiveIssue: vi.fn() };
|
||||||
issues: Issue[];
|
|
||||||
loading: boolean;
|
|
||||||
fetch: () => Promise<void>;
|
|
||||||
setIssues: (issues: Issue[]) => void;
|
|
||||||
addIssue: (issue: Issue) => void;
|
|
||||||
updateIssue: (id: string, updates: Partial<Issue>) => void;
|
|
||||||
removeIssue: (id: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock("@/features/issues/store", () => ({
|
vi.mock("@/features/issues/store", () => ({
|
||||||
useIssueStore: Object.assign(
|
useIssueStore: Object.assign(
|
||||||
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
|
(selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState),
|
||||||
{ getState: () => mockStoreState },
|
{ getState: () => mockIssueClientState },
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/features/issues", () => ({
|
vi.mock("@/features/issues", () => ({
|
||||||
useIssueStore: Object.assign(
|
useIssueStore: Object.assign(
|
||||||
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
|
(selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState),
|
||||||
{ getState: () => mockStoreState },
|
{ getState: () => mockIssueClientState },
|
||||||
),
|
),
|
||||||
StatusIcon: () => null,
|
StatusIcon: () => null,
|
||||||
PriorityIcon: () => null,
|
PriorityIcon: () => null,
|
||||||
|
|
@ -282,90 +275,80 @@ const mockIssues: Issue[] = [
|
||||||
|
|
||||||
import IssuesPage from "./page";
|
import IssuesPage from "./page";
|
||||||
|
|
||||||
|
function renderWithQuery(ui: React.ReactElement) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } });
|
||||||
|
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
describe("IssuesPage", () => {
|
describe("IssuesPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockStoreState = {
|
mockListIssues.mockResolvedValue({ issues: [], total: 0 });
|
||||||
issues: [],
|
|
||||||
loading: true,
|
|
||||||
fetch: vi.fn(),
|
|
||||||
setIssues: vi.fn(),
|
|
||||||
addIssue: vi.fn(),
|
|
||||||
updateIssue: vi.fn(),
|
|
||||||
removeIssue: vi.fn(),
|
|
||||||
};
|
|
||||||
mockViewState.viewMode = "board";
|
mockViewState.viewMode = "board";
|
||||||
mockViewState.statusFilters = [];
|
mockViewState.statusFilters = [];
|
||||||
mockViewState.priorityFilters = [];
|
mockViewState.priorityFilters = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows loading state initially", () => {
|
it("shows loading state initially", () => {
|
||||||
mockStoreState.loading = true;
|
renderWithQuery(<IssuesPage />);
|
||||||
mockStoreState.issues = [];
|
|
||||||
render(<IssuesPage />);
|
|
||||||
expect(screen.getAllByRole("generic").some(el => el.getAttribute("data-slot") === "skeleton")).toBe(true);
|
expect(screen.getAllByRole("generic").some(el => el.getAttribute("data-slot") === "skeleton")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders issues in board view after loading", async () => {
|
it("renders issues in board view after loading", async () => {
|
||||||
mockStoreState.loading = false;
|
// issueListOptions makes 2 calls: open_only + closed page. Return issues for open, empty for closed.
|
||||||
mockStoreState.issues = mockIssues;
|
mockListIssues.mockImplementation((params: any) =>
|
||||||
|
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
|
||||||
|
);
|
||||||
|
|
||||||
render(<IssuesPage />);
|
renderWithQuery(<IssuesPage />);
|
||||||
|
|
||||||
expect(screen.getByText("Implement auth")).toBeInTheDocument();
|
await screen.findByText("Implement auth");
|
||||||
expect(screen.getByText("Design landing page")).toBeInTheDocument();
|
expect(screen.getByText("Design landing page")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Write tests")).toBeInTheDocument();
|
expect(screen.getByText("Write tests")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders board columns", async () => {
|
it("renders board columns", async () => {
|
||||||
mockStoreState.loading = false;
|
mockListIssues.mockImplementation((params: any) =>
|
||||||
mockStoreState.issues = mockIssues;
|
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
|
||||||
|
);
|
||||||
|
|
||||||
render(<IssuesPage />);
|
renderWithQuery(<IssuesPage />);
|
||||||
|
|
||||||
expect(screen.getAllByText("Backlog").length).toBeGreaterThanOrEqual(1);
|
await screen.findByText("Backlog");
|
||||||
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getAllByText("In Review").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText("In Review").length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows workspace breadcrumb", () => {
|
it("shows workspace breadcrumb", async () => {
|
||||||
mockStoreState.loading = false;
|
renderWithQuery(<IssuesPage />);
|
||||||
mockStoreState.issues = [];
|
|
||||||
|
|
||||||
render(<IssuesPage />);
|
await screen.findByText("Issues");
|
||||||
|
|
||||||
expect(screen.getByText("Issues")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows scope buttons", () => {
|
it("shows scope buttons", async () => {
|
||||||
mockStoreState.loading = false;
|
renderWithQuery(<IssuesPage />);
|
||||||
mockStoreState.issues = [];
|
|
||||||
|
|
||||||
render(<IssuesPage />);
|
await screen.findByText("All");
|
||||||
|
|
||||||
expect(screen.getByText("All")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Members")).toBeInTheDocument();
|
expect(screen.getByText("Members")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Agents")).toBeInTheDocument();
|
expect(screen.getByText("Agents")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows filter and display icon buttons", () => {
|
it("shows filter and display icon buttons", async () => {
|
||||||
mockStoreState.loading = false;
|
mockListIssues.mockImplementation((params: any) =>
|
||||||
mockStoreState.issues = mockIssues;
|
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
|
||||||
|
);
|
||||||
|
|
||||||
render(<IssuesPage />);
|
renderWithQuery(<IssuesPage />);
|
||||||
|
|
||||||
// Filter and Display are now icon-only buttons, verify they render as buttons
|
await screen.findByText("Implement auth");
|
||||||
const buttons = screen.getAllByRole("button");
|
const buttons = screen.getAllByRole("button");
|
||||||
expect(buttons.length).toBeGreaterThan(0);
|
expect(buttons.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows empty board view when no issues exist", () => {
|
it("shows empty board view when no issues exist", () => {
|
||||||
mockStoreState.loading = false;
|
renderWithQuery(<IssuesPage />);
|
||||||
mockStoreState.issues = [];
|
|
||||||
|
|
||||||
render(<IssuesPage />);
|
|
||||||
|
|
||||||
// Should still render the board/list view, not a "no issues" message
|
// Should still render the board/list view, not a "no issues" message
|
||||||
expect(screen.queryByText("No matching issues")).not.toBeInTheDocument();
|
expect(screen.queryByText("No matching issues")).not.toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,11 @@ import {
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAuthStore } from "@/features/auth";
|
import { useAuthStore } from "@/features/auth";
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
import { useWorkspaceStore } from "@/features/workspace";
|
||||||
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import { memberListOptions, workspaceKeys } from "@core/workspace/queries";
|
||||||
import { api } from "@/shared/api";
|
import { api } from "@/shared/api";
|
||||||
|
|
||||||
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown; description: string }> = {
|
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown; description: string }> = {
|
||||||
|
|
@ -140,8 +143,9 @@ function MemberRow({
|
||||||
export function MembersTab() {
|
export function MembersTab() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||||
const members = useWorkspaceStore((s) => s.members);
|
const qc = useQueryClient();
|
||||||
const refreshMembers = useWorkspaceStore((s) => s.refreshMembers);
|
const wsId = useWorkspaceId();
|
||||||
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||||
|
|
||||||
const [inviteEmail, setInviteEmail] = useState("");
|
const [inviteEmail, setInviteEmail] = useState("");
|
||||||
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
|
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
|
||||||
|
|
@ -168,7 +172,7 @@ export function MembersTab() {
|
||||||
});
|
});
|
||||||
setInviteEmail("");
|
setInviteEmail("");
|
||||||
setInviteRole("member");
|
setInviteRole("member");
|
||||||
await refreshMembers();
|
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||||
toast.success("Member added");
|
toast.success("Member added");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Failed to add member");
|
toast.error(e instanceof Error ? e.message : "Failed to add member");
|
||||||
|
|
@ -182,7 +186,7 @@ export function MembersTab() {
|
||||||
setMemberActionId(memberId);
|
setMemberActionId(memberId);
|
||||||
try {
|
try {
|
||||||
await api.updateMember(workspace.id, memberId, { role });
|
await api.updateMember(workspace.id, memberId, { role });
|
||||||
await refreshMembers();
|
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||||
toast.success("Role updated");
|
toast.success("Role updated");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Failed to update member");
|
toast.error(e instanceof Error ? e.message : "Failed to update member");
|
||||||
|
|
@ -201,7 +205,7 @@ export function MembersTab() {
|
||||||
setMemberActionId(member.id);
|
setMemberActionId(member.id);
|
||||||
try {
|
try {
|
||||||
await api.deleteMember(workspace.id, member.id);
|
await api.deleteMember(workspace.id, member.id);
|
||||||
await refreshMembers();
|
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||||
toast.success("Member removed");
|
toast.success("Member removed");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Failed to remove member");
|
toast.error(e instanceof Error ? e.message : "Failed to remove member");
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,19 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAuthStore } from "@/features/auth";
|
import { useAuthStore } from "@/features/auth";
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
import { useWorkspaceStore } from "@/features/workspace";
|
||||||
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import { memberListOptions } from "@core/workspace/queries";
|
||||||
import { api } from "@/shared/api";
|
import { api } from "@/shared/api";
|
||||||
import type { WorkspaceRepo } from "@/shared/types";
|
import type { WorkspaceRepo } from "@/shared/types";
|
||||||
|
|
||||||
export function RepositoriesTab() {
|
export function RepositoriesTab() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||||
const members = useWorkspaceStore((s) => s.members);
|
const wsId = useWorkspaceId();
|
||||||
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||||
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
|
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
|
||||||
|
|
||||||
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);
|
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,18 @@ import {
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAuthStore } from "@/features/auth";
|
import { useAuthStore } from "@/features/auth";
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
import { useWorkspaceStore } from "@/features/workspace";
|
||||||
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import { memberListOptions } from "@core/workspace/queries";
|
||||||
import { api } from "@/shared/api";
|
import { api } from "@/shared/api";
|
||||||
|
|
||||||
export function WorkspaceTab() {
|
export function WorkspaceTab() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||||
const members = useWorkspaceStore((s) => s.members);
|
const wsId = useWorkspaceId();
|
||||||
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||||
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
|
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
|
||||||
const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace);
|
const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace);
|
||||||
const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace);
|
const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { QueryProvider } from "@core/provider";
|
||||||
import { AuthInitializer } from "@/features/auth";
|
import { AuthInitializer } from "@/features/auth";
|
||||||
import { WSProvider } from "@/features/realtime";
|
import { WSProvider } from "@/features/realtime";
|
||||||
import { ModalRegistry } from "@/features/modals";
|
import { ModalRegistry } from "@/features/modals";
|
||||||
|
|
@ -67,11 +68,13 @@ export default async function RootLayout({
|
||||||
>
|
>
|
||||||
<body className="h-full overflow-hidden">
|
<body className="h-full overflow-hidden">
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthInitializer>
|
<QueryProvider>
|
||||||
<WSProvider>{children}</WSProvider>
|
<AuthInitializer>
|
||||||
</AuthInitializer>
|
<WSProvider>{children}</WSProvider>
|
||||||
<ModalRegistry />
|
</AuthInitializer>
|
||||||
<Toaster />
|
<ModalRegistry />
|
||||||
|
<Toaster />
|
||||||
|
</QueryProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Users } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
||||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
|
||||||
|
|
||||||
interface MentionHoverCardProps {
|
interface MentionHoverCardProps {
|
||||||
type: string;
|
type: string;
|
||||||
|
|
@ -13,8 +15,9 @@ interface MentionHoverCardProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
|
function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
|
||||||
const members = useWorkspaceStore((s) => s.members);
|
const wsId = useWorkspaceId();
|
||||||
const agents = useWorkspaceStore((s) => s.agents);
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||||
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||||
|
|
||||||
if (type === "all") {
|
if (type === "all") {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
17
apps/web/core/hooks.ts
Normal file
17
apps/web/core/hooks.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useWorkspaceStore } from "@/features/workspace";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current workspace ID.
|
||||||
|
*
|
||||||
|
* Bridge hook: reads from Zustand workspace store now.
|
||||||
|
* Phase 3 will switch to core/workspace/store.ts — signature stays the same.
|
||||||
|
*/
|
||||||
|
export function useWorkspaceId(): string {
|
||||||
|
const workspaceId = useWorkspaceStore((s) => s.workspace?.id);
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new Error("useWorkspaceId: no workspace selected");
|
||||||
|
}
|
||||||
|
return workspaceId;
|
||||||
|
}
|
||||||
16
apps/web/core/inbox/index.ts
Normal file
16
apps/web/core/inbox/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export {
|
||||||
|
inboxKeys,
|
||||||
|
inboxListOptions,
|
||||||
|
deduplicateInboxItems,
|
||||||
|
} from "./queries";
|
||||||
|
|
||||||
|
export {
|
||||||
|
useMarkInboxRead,
|
||||||
|
useArchiveInbox,
|
||||||
|
useMarkAllInboxRead,
|
||||||
|
useArchiveAllInbox,
|
||||||
|
useArchiveAllReadInbox,
|
||||||
|
useArchiveCompletedInbox,
|
||||||
|
} from "./mutations";
|
||||||
|
|
||||||
|
export { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "./ws-updaters";
|
||||||
113
apps/web/core/inbox/mutations.ts
Normal file
113
apps/web/core/inbox/mutations.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/shared/api";
|
||||||
|
import { inboxKeys } from "./queries";
|
||||||
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import type { InboxItem } from "@/shared/types";
|
||||||
|
|
||||||
|
export function useMarkInboxRead() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const wsId = useWorkspaceId();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.markInboxRead(id),
|
||||||
|
onMutate: async (id) => {
|
||||||
|
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
|
||||||
|
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
|
||||||
|
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
|
||||||
|
old?.map((item) => (item.id === id ? { ...item, read: true } : item)),
|
||||||
|
);
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (_err, _id, ctx) => {
|
||||||
|
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArchiveInbox() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const wsId = useWorkspaceId();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.archiveInbox(id),
|
||||||
|
onMutate: async (id) => {
|
||||||
|
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
|
||||||
|
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
|
||||||
|
// Archive all items for the same issue (same behavior as store)
|
||||||
|
const target = prev?.find((i) => i.id === id);
|
||||||
|
const issueId = target?.issue_id;
|
||||||
|
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
|
||||||
|
old?.map((item) =>
|
||||||
|
item.id === id || (issueId && item.issue_id === issueId)
|
||||||
|
? { ...item, archived: true }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (_err, _id, ctx) => {
|
||||||
|
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkAllInboxRead() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const wsId = useWorkspaceId();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.markAllInboxRead(),
|
||||||
|
onMutate: async () => {
|
||||||
|
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
|
||||||
|
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
|
||||||
|
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
|
||||||
|
old?.map((item) =>
|
||||||
|
!item.archived ? { ...item, read: true } : item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (_err, _vars, ctx) => {
|
||||||
|
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArchiveAllInbox() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const wsId = useWorkspaceId();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.archiveAllInbox(),
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArchiveAllReadInbox() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const wsId = useWorkspaceId();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.archiveAllReadInbox(),
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArchiveCompletedInbox() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const wsId = useWorkspaceId();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.archiveCompletedInbox(),
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
43
apps/web/core/inbox/queries.ts
Normal file
43
apps/web/core/inbox/queries.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/shared/api";
|
||||||
|
import type { InboxItem } from "@/shared/types";
|
||||||
|
|
||||||
|
export const inboxKeys = {
|
||||||
|
all: (wsId: string) => ["inbox", wsId] as const,
|
||||||
|
list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function inboxListOptions(wsId: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: inboxKeys.list(wsId),
|
||||||
|
queryFn: () => api.listInbox(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicate inbox items by issue_id (one entry per issue, Linear-style).
|
||||||
|
* Exported for consumers to use in useMemo — not in queryOptions select
|
||||||
|
* (to avoid new array references on every cache update).
|
||||||
|
*/
|
||||||
|
export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
|
||||||
|
const active = items.filter((i) => !i.archived);
|
||||||
|
const groups = new Map<string, InboxItem[]>();
|
||||||
|
for (const item of active) {
|
||||||
|
const key = item.issue_id ?? item.id;
|
||||||
|
const group = groups.get(key) ?? [];
|
||||||
|
group.push(item);
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
const merged: InboxItem[] = [];
|
||||||
|
for (const group of groups.values()) {
|
||||||
|
group.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||||
|
);
|
||||||
|
if (group[0]) merged.push(group[0]);
|
||||||
|
}
|
||||||
|
return merged.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/web/core/inbox/ws-updaters.ts
Normal file
30
apps/web/core/inbox/ws-updaters.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { inboxKeys } from "./queries";
|
||||||
|
import type { InboxItem, IssueStatus } from "@/shared/types";
|
||||||
|
|
||||||
|
export function onInboxNew(
|
||||||
|
qc: QueryClient,
|
||||||
|
wsId: string,
|
||||||
|
_item: InboxItem,
|
||||||
|
) {
|
||||||
|
// Use invalidateQueries instead of setQueryData — triggers a refetch that
|
||||||
|
// reliably notifies all observers. The inbox list is small so this is cheap.
|
||||||
|
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onInboxIssueStatusChanged(
|
||||||
|
qc: QueryClient,
|
||||||
|
wsId: string,
|
||||||
|
issueId: string,
|
||||||
|
status: IssueStatus,
|
||||||
|
) {
|
||||||
|
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
|
||||||
|
old?.map((i) =>
|
||||||
|
i.issue_id === issueId ? { ...i, issue_status: status } : i,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
|
||||||
|
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||||
|
}
|
||||||
3
apps/web/core/index.ts
Normal file
3
apps/web/core/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { createQueryClient } from "./query-client";
|
||||||
|
export { QueryProvider } from "./provider";
|
||||||
|
export { useWorkspaceId } from "./hooks";
|
||||||
28
apps/web/core/issues/index.ts
Normal file
28
apps/web/core/issues/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
export {
|
||||||
|
issueKeys,
|
||||||
|
issueListOptions,
|
||||||
|
issueDetailOptions,
|
||||||
|
issueTimelineOptions,
|
||||||
|
issueReactionsOptions,
|
||||||
|
issueSubscribersOptions,
|
||||||
|
} from "./queries";
|
||||||
|
|
||||||
|
export {
|
||||||
|
useCreateIssue,
|
||||||
|
useUpdateIssue,
|
||||||
|
useDeleteIssue,
|
||||||
|
useBatchUpdateIssues,
|
||||||
|
useBatchDeleteIssues,
|
||||||
|
useCreateComment,
|
||||||
|
useUpdateComment,
|
||||||
|
useDeleteComment,
|
||||||
|
useToggleCommentReaction,
|
||||||
|
useToggleIssueReaction,
|
||||||
|
useToggleIssueSubscriber,
|
||||||
|
} from "./mutations";
|
||||||
|
|
||||||
|
export {
|
||||||
|
onIssueCreated,
|
||||||
|
onIssueUpdated,
|
||||||
|
onIssueDeleted,
|
||||||
|
} from "./ws-updaters";
|
||||||
500
apps/web/core/issues/mutations.ts
Normal file
500
apps/web/core/issues/mutations.ts
Normal file
|
|
@ -0,0 +1,500 @@
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/shared/api";
|
||||||
|
import { issueKeys } from "./queries";
|
||||||
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import type { Issue, IssueReaction } from "@/shared/types";
|
||||||
|
import type {
|
||||||
|
CreateIssueRequest,
|
||||||
|
UpdateIssueRequest,
|
||||||
|
ListIssuesResponse,
|
||||||
|
} from "@/shared/types";
|
||||||
|
import type { TimelineEntry, IssueSubscriber, Reaction } from "@/shared/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Issue CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useCreateIssue() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const wsId = useWorkspaceId();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateIssueRequest) => api.createIssue(data),
|
||||||
|
onSuccess: (newIssue) => {
|
||||||
|
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||||
|
old
|
||||||
|
? { ...old, issues: [...old.issues, newIssue], total: old.total + 1 }
|
||||||
|
: old,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateIssue() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const wsId = useWorkspaceId();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) =>
|
||||||
|
api.updateIssue(id, data),
|
||||||
|
onMutate: ({ id, ...data }) => {
|
||||||
|
// Fire-and-forget cancelQueries — keeps onMutate synchronous so the
|
||||||
|
// cache update happens in the same tick as mutate(). Awaiting would
|
||||||
|
// yield to the event loop, letting @dnd-kit reset its visual state
|
||||||
|
// before the optimistic update lands.
|
||||||
|
qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||||
|
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||||
|
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
|
||||||
|
|
||||||
|
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||||
|
old
|
||||||
|
? {
|
||||||
|
...old,
|
||||||
|
issues: old.issues.map((i) =>
|
||||||
|
i.id === id ? { ...i, ...data } : i,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: old,
|
||||||
|
);
|
||||||
|
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), (old) =>
|
||||||
|
old ? { ...old, ...data } : old,
|
||||||
|
);
|
||||||
|
return { prevList, prevDetail, id };
|
||||||
|
},
|
||||||
|
onError: (_err, _vars, ctx) => {
|
||||||
|
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||||
|
if (ctx?.prevDetail)
|
||||||
|
qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail);
|
||||||
|
},
|
||||||
|
onSettled: (_data, _err, vars) => {
|
||||||
|
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
|
||||||
|
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteIssue() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const wsId = useWorkspaceId();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.deleteIssue(id),
|
||||||
|
onMutate: async (id) => {
|
||||||
|
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||||
|
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||||
|
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||||
|
old
|
||||||
|
? {
|
||||||
|
...old,
|
||||||
|
issues: old.issues.filter((i) => i.id !== id),
|
||||||
|
total: old.total - 1,
|
||||||
|
}
|
||||||
|
: old,
|
||||||
|
);
|
||||||
|
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
|
||||||
|
return { prevList };
|
||||||
|
},
|
||||||
|
onError: (_err, _id, ctx) => {
|
||||||
|
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBatchUpdateIssues() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const wsId = useWorkspaceId();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
ids,
|
||||||
|
updates,
|
||||||
|
}: {
|
||||||
|
ids: string[];
|
||||||
|
updates: UpdateIssueRequest;
|
||||||
|
}) => api.batchUpdateIssues(ids, updates),
|
||||||
|
onMutate: async ({ ids, updates }) => {
|
||||||
|
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||||
|
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||||
|
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||||
|
old
|
||||||
|
? {
|
||||||
|
...old,
|
||||||
|
issues: old.issues.map((i) =>
|
||||||
|
ids.includes(i.id) ? { ...i, ...updates } : i,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: old,
|
||||||
|
);
|
||||||
|
return { prevList };
|
||||||
|
},
|
||||||
|
onError: (_err, _vars, ctx) => {
|
||||||
|
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBatchDeleteIssues() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const wsId = useWorkspaceId();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
|
||||||
|
onMutate: async (ids) => {
|
||||||
|
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||||
|
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||||
|
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||||
|
old
|
||||||
|
? {
|
||||||
|
...old,
|
||||||
|
issues: old.issues.filter((i) => !ids.includes(i.id)),
|
||||||
|
total: old.total - ids.length,
|
||||||
|
}
|
||||||
|
: old,
|
||||||
|
);
|
||||||
|
return { prevList };
|
||||||
|
},
|
||||||
|
onError: (_err, _ids, ctx) => {
|
||||||
|
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Comments / Timeline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useCreateComment(issueId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
content,
|
||||||
|
type,
|
||||||
|
parentId,
|
||||||
|
attachmentIds,
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
type?: string;
|
||||||
|
parentId?: string;
|
||||||
|
attachmentIds?: string[];
|
||||||
|
}) => api.createComment(issueId, content, type, parentId, attachmentIds),
|
||||||
|
onSuccess: (comment) => {
|
||||||
|
qc.setQueryData<TimelineEntry[]>(
|
||||||
|
issueKeys.timeline(issueId),
|
||||||
|
(old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
const entry: TimelineEntry = {
|
||||||
|
type: "comment",
|
||||||
|
id: comment.id,
|
||||||
|
actor_type: comment.author_type,
|
||||||
|
actor_id: comment.author_id,
|
||||||
|
content: comment.content,
|
||||||
|
parent_id: comment.parent_id,
|
||||||
|
comment_type: comment.type,
|
||||||
|
reactions: comment.reactions ?? [],
|
||||||
|
attachments: comment.attachments ?? [],
|
||||||
|
created_at: comment.created_at,
|
||||||
|
updated_at: comment.updated_at,
|
||||||
|
};
|
||||||
|
if (old.some((e) => e.id === comment.id)) return old;
|
||||||
|
return [...old, entry];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateComment(issueId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ commentId, content }: { commentId: string; content: string }) =>
|
||||||
|
api.updateComment(commentId, content),
|
||||||
|
onMutate: async ({ commentId, content }) => {
|
||||||
|
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||||
|
const prev = qc.getQueryData<TimelineEntry[]>(issueKeys.timeline(issueId));
|
||||||
|
qc.setQueryData<TimelineEntry[]>(
|
||||||
|
issueKeys.timeline(issueId),
|
||||||
|
(old) =>
|
||||||
|
old?.map((e) => (e.id === commentId ? { ...e, content } : e)),
|
||||||
|
);
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (_err, _vars, ctx) => {
|
||||||
|
if (ctx?.prev)
|
||||||
|
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteComment(issueId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (commentId: string) => api.deleteComment(commentId),
|
||||||
|
onMutate: async (commentId) => {
|
||||||
|
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||||
|
const prev = qc.getQueryData<TimelineEntry[]>(issueKeys.timeline(issueId));
|
||||||
|
|
||||||
|
// Cascade: collect all child comment IDs
|
||||||
|
const toRemove = new Set<string>([commentId]);
|
||||||
|
if (prev) {
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const e of prev) {
|
||||||
|
if (e.parent_id && toRemove.has(e.parent_id) && !toRemove.has(e.id)) {
|
||||||
|
toRemove.add(e.id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
qc.setQueryData<TimelineEntry[]>(
|
||||||
|
issueKeys.timeline(issueId),
|
||||||
|
(old) => old?.filter((e) => !toRemove.has(e.id)),
|
||||||
|
);
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (_err, _id, ctx) => {
|
||||||
|
if (ctx?.prev)
|
||||||
|
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToggleCommentReaction(issueId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
commentId,
|
||||||
|
emoji,
|
||||||
|
existing,
|
||||||
|
}: {
|
||||||
|
commentId: string;
|
||||||
|
emoji: string;
|
||||||
|
existing: Reaction | undefined;
|
||||||
|
}) => {
|
||||||
|
if (existing) {
|
||||||
|
await api.removeReaction(commentId, emoji);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return api.addReaction(commentId, emoji);
|
||||||
|
},
|
||||||
|
onMutate: async ({ commentId, emoji, existing }) => {
|
||||||
|
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||||
|
const prev = qc.getQueryData<TimelineEntry[]>(issueKeys.timeline(issueId));
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Remove
|
||||||
|
qc.setQueryData<TimelineEntry[]>(
|
||||||
|
issueKeys.timeline(issueId),
|
||||||
|
(old) =>
|
||||||
|
old?.map((e) =>
|
||||||
|
e.id === commentId
|
||||||
|
? {
|
||||||
|
...e,
|
||||||
|
reactions: (e.reactions ?? []).filter(
|
||||||
|
(r) => r.id !== existing.id,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Add temp
|
||||||
|
const tempReaction: Reaction = {
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
comment_id: commentId,
|
||||||
|
actor_type: "",
|
||||||
|
actor_id: "",
|
||||||
|
emoji,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
qc.setQueryData<TimelineEntry[]>(
|
||||||
|
issueKeys.timeline(issueId),
|
||||||
|
(old) =>
|
||||||
|
old?.map((e) =>
|
||||||
|
e.id === commentId
|
||||||
|
? { ...e, reactions: [...(e.reactions ?? []), tempReaction] }
|
||||||
|
: e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onSuccess: (reaction, { commentId }) => {
|
||||||
|
if (reaction) {
|
||||||
|
// Replace temp with real
|
||||||
|
qc.setQueryData<TimelineEntry[]>(
|
||||||
|
issueKeys.timeline(issueId),
|
||||||
|
(old) =>
|
||||||
|
old?.map((e) =>
|
||||||
|
e.id === commentId
|
||||||
|
? {
|
||||||
|
...e,
|
||||||
|
reactions: (e.reactions ?? []).map((r) =>
|
||||||
|
r.id.startsWith("temp-") && r.emoji === reaction.emoji
|
||||||
|
? reaction
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (_err, _vars, ctx) => {
|
||||||
|
if (ctx?.prev)
|
||||||
|
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Issue-level Reactions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useToggleIssueReaction(issueId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
emoji,
|
||||||
|
existing,
|
||||||
|
}: {
|
||||||
|
emoji: string;
|
||||||
|
existing: IssueReaction | undefined;
|
||||||
|
}) => {
|
||||||
|
if (existing) {
|
||||||
|
await api.removeIssueReaction(issueId, emoji);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return api.addIssueReaction(issueId, emoji);
|
||||||
|
},
|
||||||
|
onMutate: async ({ emoji, existing }) => {
|
||||||
|
await qc.cancelQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||||
|
const prev = qc.getQueryData<IssueReaction[]>(issueKeys.reactions(issueId));
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
qc.setQueryData<IssueReaction[]>(
|
||||||
|
issueKeys.reactions(issueId),
|
||||||
|
(old) => old?.filter((r) => r.id !== existing.id),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const temp: IssueReaction = {
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
issue_id: issueId,
|
||||||
|
actor_type: "",
|
||||||
|
actor_id: "",
|
||||||
|
emoji,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
qc.setQueryData<IssueReaction[]>(
|
||||||
|
issueKeys.reactions(issueId),
|
||||||
|
(old) => [...(old ?? []), temp],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onSuccess: (reaction) => {
|
||||||
|
if (reaction) {
|
||||||
|
qc.setQueryData<IssueReaction[]>(
|
||||||
|
issueKeys.reactions(issueId),
|
||||||
|
(old) =>
|
||||||
|
old?.map((r) =>
|
||||||
|
r.id.startsWith("temp-") && r.emoji === reaction.emoji
|
||||||
|
? reaction
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (_err, _vars, ctx) => {
|
||||||
|
if (ctx?.prev)
|
||||||
|
qc.setQueryData(issueKeys.reactions(issueId), ctx.prev);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Issue Subscribers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useToggleIssueSubscriber(issueId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
userId,
|
||||||
|
userType,
|
||||||
|
subscribed,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
userType: "member" | "agent";
|
||||||
|
subscribed: boolean;
|
||||||
|
}) => {
|
||||||
|
if (subscribed) {
|
||||||
|
await api.unsubscribeFromIssue(issueId, userId, userType);
|
||||||
|
} else {
|
||||||
|
await api.subscribeToIssue(issueId, userId, userType);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMutate: async ({ userId, userType, subscribed }) => {
|
||||||
|
await qc.cancelQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||||
|
const prev = qc.getQueryData<IssueSubscriber[]>(
|
||||||
|
issueKeys.subscribers(issueId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (subscribed) {
|
||||||
|
qc.setQueryData<IssueSubscriber[]>(
|
||||||
|
issueKeys.subscribers(issueId),
|
||||||
|
(old) =>
|
||||||
|
old?.filter(
|
||||||
|
(s) => !(s.user_id === userId && s.user_type === userType),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const temp: IssueSubscriber = {
|
||||||
|
issue_id: issueId,
|
||||||
|
user_type: userType,
|
||||||
|
user_id: userId,
|
||||||
|
reason: "manual",
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
qc.setQueryData<IssueSubscriber[]>(
|
||||||
|
issueKeys.subscribers(issueId),
|
||||||
|
(old) => {
|
||||||
|
if (
|
||||||
|
old?.some(
|
||||||
|
(s) => s.user_id === userId && s.user_type === userType,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return old;
|
||||||
|
return [...(old ?? []), temp];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (_err, _vars, ctx) => {
|
||||||
|
if (ctx?.prev)
|
||||||
|
qc.setQueryData(issueKeys.subscribers(issueId), ctx.prev);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
70
apps/web/core/issues/queries.ts
Normal file
70
apps/web/core/issues/queries.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/shared/api";
|
||||||
|
|
||||||
|
export const issueKeys = {
|
||||||
|
all: (wsId: string) => ["issues", wsId] as const,
|
||||||
|
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
|
||||||
|
detail: (wsId: string, id: string) =>
|
||||||
|
[...issueKeys.all(wsId), "detail", id] as const,
|
||||||
|
timeline: (issueId: string) => ["issues", "timeline", issueId] as const,
|
||||||
|
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
|
||||||
|
subscribers: (issueId: string) =>
|
||||||
|
["issues", "subscribers", issueId] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLOSED_PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CACHE SHAPE NOTE: The raw cache stores ListIssuesResponse ({ issues, total }),
|
||||||
|
* but `select` transforms it to Issue[] for consumers. Mutations and ws-updaters
|
||||||
|
* must use setQueryData<ListIssuesResponse>(...) — NOT setQueryData<Issue[]>.
|
||||||
|
*
|
||||||
|
* Fetches all open issues + first page of closed issues (matching main's pagination strategy).
|
||||||
|
*/
|
||||||
|
export function issueListOptions(wsId: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: issueKeys.list(wsId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const [openRes, closedRes] = await Promise.all([
|
||||||
|
api.listIssues({ open_only: true }),
|
||||||
|
api.listIssues({ status: "done", limit: CLOSED_PAGE_SIZE, offset: 0 }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
issues: [...openRes.issues, ...closedRes.issues],
|
||||||
|
total: openRes.total + closedRes.total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
select: (data) => data.issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function issueDetailOptions(wsId: string, id: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: issueKeys.detail(wsId, id),
|
||||||
|
queryFn: () => api.getIssue(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function issueTimelineOptions(issueId: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: issueKeys.timeline(issueId),
|
||||||
|
queryFn: () => api.listTimeline(issueId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function issueReactionsOptions(issueId: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: issueKeys.reactions(issueId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const issue = await api.getIssue(issueId);
|
||||||
|
return issue.reactions ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function issueSubscribersOptions(issueId: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: issueKeys.subscribers(issueId),
|
||||||
|
queryFn: () => api.listIssueSubscribers(issueId),
|
||||||
|
});
|
||||||
|
}
|
||||||
56
apps/web/core/issues/ws-updaters.ts
Normal file
56
apps/web/core/issues/ws-updaters.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { issueKeys } from "./queries";
|
||||||
|
import type { Issue } from "@/shared/types";
|
||||||
|
import type { ListIssuesResponse } from "@/shared/types";
|
||||||
|
|
||||||
|
export function onIssueCreated(
|
||||||
|
qc: QueryClient,
|
||||||
|
wsId: string,
|
||||||
|
issue: Issue,
|
||||||
|
) {
|
||||||
|
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||||
|
old && !old.issues.some((i) => i.id === issue.id)
|
||||||
|
? { ...old, issues: [...old.issues, issue], total: old.total + 1 }
|
||||||
|
: old,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onIssueUpdated(
|
||||||
|
qc: QueryClient,
|
||||||
|
wsId: string,
|
||||||
|
issue: Partial<Issue> & { id: string },
|
||||||
|
) {
|
||||||
|
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||||
|
old
|
||||||
|
? {
|
||||||
|
...old,
|
||||||
|
issues: old.issues.map((i) =>
|
||||||
|
i.id === issue.id ? { ...i, ...issue } : i,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: old,
|
||||||
|
);
|
||||||
|
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
|
||||||
|
old ? { ...old, ...issue } : old,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onIssueDeleted(
|
||||||
|
qc: QueryClient,
|
||||||
|
wsId: string,
|
||||||
|
issueId: string,
|
||||||
|
) {
|
||||||
|
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||||
|
old
|
||||||
|
? {
|
||||||
|
...old,
|
||||||
|
issues: old.issues.filter((i) => i.id !== issueId),
|
||||||
|
total: old.total - 1,
|
||||||
|
}
|
||||||
|
: old,
|
||||||
|
);
|
||||||
|
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
||||||
|
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||||
|
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||||
|
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||||
|
}
|
||||||
17
apps/web/core/provider.tsx
Normal file
17
apps/web/core/provider.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
|
import { createQueryClient } from "./query-client";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [queryClient] = useState(createQueryClient);
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/web/core/query-client.ts
Normal file
18
apps/web/core/query-client.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export function createQueryClient(): QueryClient {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
1
apps/web/core/runtimes/index.ts
Normal file
1
apps/web/core/runtimes/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { runtimeKeys, runtimeListOptions } from "./queries";
|
||||||
14
apps/web/core/runtimes/queries.ts
Normal file
14
apps/web/core/runtimes/queries.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/shared/api";
|
||||||
|
|
||||||
|
export const runtimeKeys = {
|
||||||
|
all: (wsId: string) => ["runtimes", wsId] as const,
|
||||||
|
list: (wsId: string) => [...runtimeKeys.all(wsId), "list"] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function runtimeListOptions(wsId: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: runtimeKeys.list(wsId),
|
||||||
|
queryFn: () => api.listRuntimes({ workspace_id: wsId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
13
apps/web/core/workspace/index.ts
Normal file
13
apps/web/core/workspace/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
export {
|
||||||
|
workspaceKeys,
|
||||||
|
workspaceListOptions,
|
||||||
|
memberListOptions,
|
||||||
|
agentListOptions,
|
||||||
|
skillListOptions,
|
||||||
|
} from "./queries";
|
||||||
|
|
||||||
|
export {
|
||||||
|
useCreateWorkspace,
|
||||||
|
useLeaveWorkspace,
|
||||||
|
useDeleteWorkspace,
|
||||||
|
} from "./mutations";
|
||||||
34
apps/web/core/workspace/mutations.ts
Normal file
34
apps/web/core/workspace/mutations.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/shared/api";
|
||||||
|
import { workspaceKeys } from "./queries";
|
||||||
|
|
||||||
|
export function useCreateWorkspace() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { name: string; slug: string; description?: string }) =>
|
||||||
|
api.createWorkspace(data),
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLeaveWorkspace() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (workspaceId: string) => api.leaveWorkspace(workspaceId),
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteWorkspace() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (workspaceId: string) => api.deleteWorkspace(workspaceId),
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
39
apps/web/core/workspace/queries.ts
Normal file
39
apps/web/core/workspace/queries.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/shared/api";
|
||||||
|
|
||||||
|
export const workspaceKeys = {
|
||||||
|
all: (wsId: string) => ["workspaces", wsId] as const,
|
||||||
|
list: () => ["workspaces", "list"] as const,
|
||||||
|
members: (wsId: string) => ["workspaces", wsId, "members"] as const,
|
||||||
|
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
|
||||||
|
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function workspaceListOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: workspaceKeys.list(),
|
||||||
|
queryFn: () => api.listWorkspaces(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function memberListOptions(wsId: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: workspaceKeys.members(wsId),
|
||||||
|
queryFn: () => api.listMembers(wsId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentListOptions(wsId: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: workspaceKeys.agents(wsId),
|
||||||
|
queryFn: () =>
|
||||||
|
api.listAgents({ workspace_id: wsId, include_archived: true }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skillListOptions(wsId: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: workspaceKeys.skills(wsId),
|
||||||
|
queryFn: () => api.listSkills(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
import { useEditor, EditorContent } from "@tiptap/react";
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { createEditorExtensions } from "./extensions";
|
import { createEditorExtensions } from "./extensions";
|
||||||
import { uploadAndInsertFile } from "./extensions/file-upload";
|
import { uploadAndInsertFile } from "./extensions/file-upload";
|
||||||
import { preprocessMarkdown } from "./utils/preprocess";
|
import { preprocessMarkdown } from "./utils/preprocess";
|
||||||
|
|
@ -94,6 +95,8 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||||
onBlurRef.current = onBlur;
|
onBlurRef.current = onBlur;
|
||||||
onUploadFileRef.current = onUploadFile;
|
onUploadFileRef.current = onUploadFile;
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
editable,
|
editable,
|
||||||
|
|
@ -102,6 +105,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||||
extensions: createEditorExtensions({
|
extensions: createEditorExtensions({
|
||||||
editable,
|
editable,
|
||||||
placeholder: placeholderText,
|
placeholder: placeholderText,
|
||||||
|
queryClient,
|
||||||
onSubmitRef,
|
onSubmitRef,
|
||||||
onUploadFileRef,
|
onUploadFileRef,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ const ImageExtension = Image.extend({
|
||||||
export interface EditorExtensionsOptions {
|
export interface EditorExtensionsOptions {
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
queryClient?: import("@tanstack/react-query").QueryClient;
|
||||||
onSubmitRef?: RefObject<(() => void) | undefined>;
|
onSubmitRef?: RefObject<(() => void) | undefined>;
|
||||||
onUploadFileRef?: RefObject<
|
onUploadFileRef?: RefObject<
|
||||||
((file: File) => Promise<UploadResult | null>) | undefined
|
((file: File) => Promise<UploadResult | null>) | undefined
|
||||||
|
|
@ -107,7 +108,7 @@ export function createEditorExtensions(
|
||||||
Markdown,
|
Markdown,
|
||||||
BaseMentionExtension.configure({
|
BaseMentionExtension.configure({
|
||||||
HTMLAttributes: { class: "mention" },
|
HTMLAttributes: { class: "mention" },
|
||||||
...(editable ? { suggestion: createMentionSuggestion() } : {}),
|
...(editable && options.queryClient ? { suggestion: createMentionSuggestion(options.queryClient) } : {}),
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,11 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import { ReactRenderer } from "@tiptap/react";
|
import { ReactRenderer } from "@tiptap/react";
|
||||||
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
|
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
|
||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
import { useWorkspaceStore } from "@/features/workspace";
|
||||||
import { useIssueStore } from "@/features/issues";
|
import { issueKeys } from "@core/issues/queries";
|
||||||
|
import { workspaceKeys } from "@core/workspace/queries";
|
||||||
|
import type { Issue, ListIssuesResponse, MemberWithUser, Agent } from "@/shared/types";
|
||||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||||
import { StatusIcon } from "@/features/issues/components/status-icon";
|
import { StatusIcon } from "@/features/issues/components/status-icon";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -210,14 +213,19 @@ function MentionRow({
|
||||||
// Suggestion config factory
|
// Suggestion config factory
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function createMentionSuggestion(): Omit<
|
export function createMentionSuggestion(qc: QueryClient): Omit<
|
||||||
SuggestionOptions<MentionItem>,
|
SuggestionOptions<MentionItem>,
|
||||||
"editor"
|
"editor"
|
||||||
> {
|
> {
|
||||||
return {
|
return {
|
||||||
items: ({ query }) => {
|
items: ({ query }) => {
|
||||||
const { members, agents } = useWorkspaceStore.getState();
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||||
const { issues } = useIssueStore.getState();
|
const members: MemberWithUser[] = wsId ? qc.getQueryData(workspaceKeys.members(wsId)) ?? [] : [];
|
||||||
|
const agents: Agent[] = wsId ? qc.getQueryData(workspaceKeys.agents(wsId)) ?? [] : [];
|
||||||
|
const issues: Issue[] = wsId
|
||||||
|
? qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? []
|
||||||
|
: [];
|
||||||
|
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
|
|
||||||
// Show "All members" option when query is empty or matches "all"
|
// Show "All members" option when query is empty or matches "all"
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,9 @@
|
||||||
|
|
||||||
import { NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewWrapper } from "@tiptap/react";
|
||||||
import type { NodeViewProps } from "@tiptap/react";
|
import type { NodeViewProps } from "@tiptap/react";
|
||||||
import { useIssueStore } from "@/features/issues/store";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { issueListOptions } from "@core/issues/queries";
|
||||||
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
import { StatusIcon } from "@/features/issues/components/status-icon";
|
import { StatusIcon } from "@/features/issues/components/status-icon";
|
||||||
|
|
||||||
export function MentionView({ node }: NodeViewProps) {
|
export function MentionView({ node }: NodeViewProps) {
|
||||||
|
|
@ -48,7 +50,9 @@ function IssueMention({
|
||||||
issueId: string;
|
issueId: string;
|
||||||
fallbackLabel?: string;
|
fallbackLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
|
const wsId = useWorkspaceId();
|
||||||
|
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
||||||
|
const issue = issues.find((i) => i.id === issueId);
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -1 +1,13 @@
|
||||||
export { useInboxStore } from "./store";
|
// Inbox server state is managed by TanStack Query.
|
||||||
|
// See core/inbox/ for queries, mutations, and WS updaters.
|
||||||
|
export {
|
||||||
|
inboxKeys,
|
||||||
|
inboxListOptions,
|
||||||
|
deduplicateInboxItems,
|
||||||
|
useMarkInboxRead,
|
||||||
|
useArchiveInbox,
|
||||||
|
useMarkAllInboxRead,
|
||||||
|
useArchiveAllInbox,
|
||||||
|
useArchiveAllReadInbox,
|
||||||
|
useArchiveCompletedInbox,
|
||||||
|
} from "@core/inbox";
|
||||||
|
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { create } from "zustand";
|
|
||||||
import type { InboxItem, IssueStatus } from "@/shared/types";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { api } from "@/shared/api";
|
|
||||||
import { createLogger } from "@/shared/logger";
|
|
||||||
|
|
||||||
const logger = createLogger("inbox-store");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicate inbox items by issue_id (one entry per issue, Linear-style),
|
|
||||||
* keep latest, sort by time DESC.
|
|
||||||
* Memoized by reference — returns the same array if `items` hasn't changed.
|
|
||||||
*/
|
|
||||||
let _prevItems: InboxItem[] = [];
|
|
||||||
let _prevDeduped: InboxItem[] = [];
|
|
||||||
|
|
||||||
function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
|
|
||||||
if (items === _prevItems) return _prevDeduped;
|
|
||||||
_prevItems = items;
|
|
||||||
|
|
||||||
const active = items.filter((i) => !i.archived);
|
|
||||||
const groups = new Map<string, InboxItem[]>();
|
|
||||||
active.forEach((item) => {
|
|
||||||
const key = item.issue_id ?? item.id;
|
|
||||||
const group = groups.get(key) ?? [];
|
|
||||||
group.push(item);
|
|
||||||
groups.set(key, group);
|
|
||||||
});
|
|
||||||
const merged: InboxItem[] = [];
|
|
||||||
groups.forEach((group) => {
|
|
||||||
const sorted = group.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
|
||||||
);
|
|
||||||
if (sorted[0]) merged.push(sorted[0]);
|
|
||||||
});
|
|
||||||
_prevDeduped = merged.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
|
||||||
);
|
|
||||||
return _prevDeduped;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InboxState {
|
|
||||||
items: InboxItem[];
|
|
||||||
loading: boolean;
|
|
||||||
fetch: () => Promise<void>;
|
|
||||||
setItems: (items: InboxItem[]) => void;
|
|
||||||
addItem: (item: InboxItem) => void;
|
|
||||||
markRead: (id: string) => void;
|
|
||||||
archive: (id: string) => void;
|
|
||||||
markAllRead: () => void;
|
|
||||||
archiveAll: () => void;
|
|
||||||
archiveAllRead: () => void;
|
|
||||||
updateIssueStatus: (issueId: string, status: IssueStatus) => void;
|
|
||||||
dedupedItems: () => InboxItem[];
|
|
||||||
unreadCount: () => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useInboxStore = create<InboxState>((set, get) => ({
|
|
||||||
items: [],
|
|
||||||
loading: true,
|
|
||||||
|
|
||||||
fetch: async () => {
|
|
||||||
logger.debug("fetch start");
|
|
||||||
const isInitialLoad = get().items.length === 0;
|
|
||||||
if (isInitialLoad) set({ loading: true });
|
|
||||||
try {
|
|
||||||
const data = await api.listInbox();
|
|
||||||
logger.info("fetched", data.length, "items");
|
|
||||||
set({ items: data, loading: false });
|
|
||||||
} catch (err) {
|
|
||||||
logger.error("fetch failed", err);
|
|
||||||
toast.error("Failed to load inbox");
|
|
||||||
if (isInitialLoad) set({ loading: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setItems: (items) => set({ items }),
|
|
||||||
addItem: (item) =>
|
|
||||||
set((s) => ({
|
|
||||||
items: s.items.some((i) => i.id === item.id)
|
|
||||||
? s.items
|
|
||||||
: [item, ...s.items],
|
|
||||||
})),
|
|
||||||
markRead: (id) =>
|
|
||||||
set((s) => ({
|
|
||||||
items: s.items.map((i) => (i.id === id ? { ...i, read: true } : i)),
|
|
||||||
})),
|
|
||||||
archive: (id) =>
|
|
||||||
set((s) => {
|
|
||||||
const target = s.items.find((i) => i.id === id);
|
|
||||||
const issueId = target?.issue_id;
|
|
||||||
return {
|
|
||||||
items: s.items.map((i) =>
|
|
||||||
i.id === id || (issueId && i.issue_id === issueId)
|
|
||||||
? { ...i, archived: true }
|
|
||||||
: i,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
markAllRead: () =>
|
|
||||||
set((s) => ({
|
|
||||||
items: s.items.map((i) => (!i.archived ? { ...i, read: true } : i)),
|
|
||||||
})),
|
|
||||||
archiveAll: () =>
|
|
||||||
set((s) => ({
|
|
||||||
items: s.items.map((i) => (!i.archived ? { ...i, archived: true } : i)),
|
|
||||||
})),
|
|
||||||
archiveAllRead: () =>
|
|
||||||
set((s) => ({
|
|
||||||
items: s.items.map((i) =>
|
|
||||||
i.read && !i.archived ? { ...i, archived: true } : i
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
updateIssueStatus: (issueId, status) =>
|
|
||||||
set((s) => ({
|
|
||||||
items: s.items.map((i) =>
|
|
||||||
i.issue_id === issueId ? { ...i, issue_status: status } : i
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
dedupedItems: () => deduplicateInboxItems(get().items),
|
|
||||||
unreadCount: () =>
|
|
||||||
get().dedupedItems().filter((i) => !i.read).length,
|
|
||||||
}));
|
|
||||||
|
|
@ -21,9 +21,8 @@ import {
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import type { UpdateIssueRequest } from "@/shared/types";
|
import type { UpdateIssueRequest } from "@/shared/types";
|
||||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||||
import { useIssueStore } from "@/features/issues/store";
|
|
||||||
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
||||||
import { api } from "@/shared/api";
|
import { useBatchUpdateIssues, useBatchDeleteIssues } from "@core/issues/mutations";
|
||||||
import { StatusIcon } from "./status-icon";
|
import { StatusIcon } from "./status-icon";
|
||||||
import { PriorityIcon } from "./priority-icon";
|
import { PriorityIcon } from "./priority-icon";
|
||||||
import { AssigneePicker } from "./pickers";
|
import { AssigneePicker } from "./pickers";
|
||||||
|
|
@ -37,42 +36,31 @@ export function BatchActionToolbar() {
|
||||||
const [priorityOpen, setPriorityOpen] = useState(false);
|
const [priorityOpen, setPriorityOpen] = useState(false);
|
||||||
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const batchUpdate = useBatchUpdateIssues();
|
||||||
|
const batchDelete = useBatchDeleteIssues();
|
||||||
|
const loading = batchUpdate.isPending || batchDelete.isPending;
|
||||||
|
|
||||||
if (count === 0) return null;
|
if (count === 0) return null;
|
||||||
|
|
||||||
const ids = Array.from(selectedIds);
|
const ids = Array.from(selectedIds);
|
||||||
|
|
||||||
const handleBatchUpdate = async (updates: Partial<UpdateIssueRequest>) => {
|
const handleBatchUpdate = async (updates: Partial<UpdateIssueRequest>) => {
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
await api.batchUpdateIssues(ids, updates);
|
await batchUpdate.mutateAsync({ ids, updates });
|
||||||
for (const id of ids) {
|
|
||||||
useIssueStore.getState().updateIssue(id, updates);
|
|
||||||
}
|
|
||||||
toast.success(`Updated ${count} issue${count > 1 ? "s" : ""}`);
|
toast.success(`Updated ${count} issue${count > 1 ? "s" : ""}`);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to update issues");
|
toast.error("Failed to update issues");
|
||||||
useIssueStore.getState().fetch().catch(console.error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBatchDelete = async () => {
|
const handleBatchDelete = async () => {
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
await api.batchDeleteIssues(ids);
|
await batchDelete.mutateAsync(ids);
|
||||||
for (const id of ids) {
|
|
||||||
useIssueStore.getState().removeIssue(id);
|
|
||||||
}
|
|
||||||
clear();
|
clear();
|
||||||
toast.success(`Deleted ${count} issue${count > 1 ? "s" : ""}`);
|
toast.success(`Deleted ${count} issue${count > 1 ? "s" : ""}`);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to delete issues");
|
toast.error("Failed to delete issues");
|
||||||
useIssueStore.getState().fetch().catch(console.error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
|
||||||
setDeleteOpen(false);
|
setDeleteOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,14 @@
|
||||||
|
|
||||||
import { useCallback, memo } from "react";
|
import { useCallback, memo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||||
|
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Issue, UpdateIssueRequest } from "@/shared/types";
|
import type { Issue, UpdateIssueRequest } from "@/shared/types";
|
||||||
import { CalendarDays } from "lucide-react";
|
import { CalendarDays } from "lucide-react";
|
||||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||||
import { api } from "@/shared/api";
|
import { useUpdateIssue } from "@core/issues/mutations";
|
||||||
import { useIssueStore } from "@/features/issues/store";
|
|
||||||
import { PriorityIcon } from "./priority-icon";
|
import { PriorityIcon } from "./priority-icon";
|
||||||
import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
|
import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
|
||||||
import { PRIORITY_CONFIG } from "@/features/issues/config";
|
import { PRIORITY_CONFIG } from "@/features/issues/config";
|
||||||
|
|
@ -46,16 +46,15 @@ export const BoardCardContent = memo(function BoardCardContent({
|
||||||
const storeProperties = useViewStore((s) => s.cardProperties);
|
const storeProperties = useViewStore((s) => s.cardProperties);
|
||||||
const priorityCfg = PRIORITY_CONFIG[issue.priority];
|
const priorityCfg = PRIORITY_CONFIG[issue.priority];
|
||||||
|
|
||||||
|
const updateIssueMutation = useUpdateIssue();
|
||||||
const handleUpdate = useCallback(
|
const handleUpdate = useCallback(
|
||||||
(updates: Partial<UpdateIssueRequest>) => {
|
(updates: Partial<UpdateIssueRequest>) => {
|
||||||
const prev = { ...issue };
|
updateIssueMutation.mutate(
|
||||||
useIssueStore.getState().updateIssue(issue.id, updates);
|
{ id: issue.id, ...updates },
|
||||||
api.updateIssue(issue.id, updates).catch(() => {
|
{ onError: () => toast.error("Failed to update issue") },
|
||||||
useIssueStore.getState().updateIssue(issue.id, prev);
|
);
|
||||||
toast.error("Failed to update issue");
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[issue],
|
[issue.id, updateIssueMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showPriority = storeProperties.priority;
|
const showPriority = storeProperties.priority;
|
||||||
|
|
@ -168,6 +167,12 @@ export const BoardCardContent = memo(function BoardCardContent({
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
|
||||||
|
const { isSorting, wasDragging } = args;
|
||||||
|
if (isSorting || wasDragging) return false;
|
||||||
|
return defaultAnimateLayoutChanges(args);
|
||||||
|
};
|
||||||
|
|
||||||
export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: { issue: Issue }) {
|
export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: { issue: Issue }) {
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
|
|
@ -179,6 +184,7 @@ export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: {
|
||||||
} = useSortable({
|
} = useSortable({
|
||||||
id: issue.id,
|
id: issue.id,
|
||||||
data: { status: issue.status },
|
data: { status: issue.status },
|
||||||
|
animateLayoutChanges,
|
||||||
});
|
});
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
|
|
|
||||||
|
|
@ -15,32 +15,31 @@ import {
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { STATUS_CONFIG } from "@/features/issues/config";
|
import { STATUS_CONFIG } from "@/features/issues/config";
|
||||||
import { useModalStore } from "@/features/modals";
|
import { useModalStore } from "@/features/modals";
|
||||||
import { useViewStore, useViewStoreApi } from "@/features/issues/stores/view-store-context";
|
import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
|
||||||
import { sortIssues } from "@/features/issues/utils/sort";
|
|
||||||
import { StatusIcon } from "./status-icon";
|
import { StatusIcon } from "./status-icon";
|
||||||
import { DraggableBoardCard } from "./board-card";
|
import { DraggableBoardCard } from "./board-card";
|
||||||
|
|
||||||
export function BoardColumn({
|
export function BoardColumn({
|
||||||
status,
|
status,
|
||||||
issues,
|
issueIds,
|
||||||
|
issueMap,
|
||||||
}: {
|
}: {
|
||||||
status: IssueStatus;
|
status: IssueStatus;
|
||||||
issues: Issue[];
|
issueIds: string[];
|
||||||
|
issueMap: Map<string, Issue>;
|
||||||
}) {
|
}) {
|
||||||
const cfg = STATUS_CONFIG[status];
|
const cfg = STATUS_CONFIG[status];
|
||||||
const { setNodeRef, isOver } = useDroppable({ id: status });
|
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||||
const viewStoreApi = useViewStoreApi();
|
const viewStoreApi = useViewStoreApi();
|
||||||
const sortBy = useViewStore((s) => s.sortBy);
|
|
||||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
|
||||||
|
|
||||||
const sortedIssues = useMemo(
|
// Resolve IDs to Issue objects, preserving parent-provided order
|
||||||
() => sortIssues(issues, sortBy, sortDirection),
|
const resolvedIssues = useMemo(
|
||||||
[issues, sortBy, sortDirection]
|
() =>
|
||||||
);
|
issueIds.flatMap((id) => {
|
||||||
|
const issue = issueMap.get(id);
|
||||||
const sortedIds = useMemo(
|
return issue ? [issue] : [];
|
||||||
() => sortedIssues.map((i) => i.id),
|
}),
|
||||||
[sortedIssues]
|
[issueIds, issueMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -53,7 +52,7 @@ export function BoardColumn({
|
||||||
{cfg.label}
|
{cfg.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{issues.length}
|
{issueIds.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -97,12 +96,12 @@ export function BoardColumn({
|
||||||
isOver ? "bg-accent/60" : ""
|
isOver ? "bg-accent/60" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}>
|
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
||||||
{sortedIssues.map((issue) => (
|
{resolvedIssues.map((issue) => (
|
||||||
<DraggableBoardCard key={issue.id} issue={issue} />
|
<DraggableBoardCard key={issue.id} issue={issue} />
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
{issues.length === 0 && (
|
{issueIds.length === 0 && (
|
||||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||||
No issues
|
No issues
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
|
|
@ -12,7 +12,9 @@ import {
|
||||||
type CollisionDetection,
|
type CollisionDetection,
|
||||||
type DragStartEvent,
|
type DragStartEvent,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
|
type DragOverEvent,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
|
import { arrayMove } from "@dnd-kit/sortable";
|
||||||
import { Eye, MoreHorizontal } from "lucide-react";
|
import { Eye, MoreHorizontal } from "lucide-react";
|
||||||
import type { Issue, IssueStatus } from "@/shared/types";
|
import type { Issue, IssueStatus } from "@/shared/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -23,7 +25,9 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
|
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
|
||||||
import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
|
import { useViewStoreApi, useViewStore } from "@/features/issues/stores/view-store-context";
|
||||||
|
import type { SortField, SortDirection } from "@/features/issues/stores/view-store";
|
||||||
|
import { sortIssues } from "@/features/issues/utils/sort";
|
||||||
import { StatusIcon } from "./status-icon";
|
import { StatusIcon } from "./status-icon";
|
||||||
import { BoardColumn } from "./board-column";
|
import { BoardColumn } from "./board-column";
|
||||||
import { BoardCardContent } from "./board-card";
|
import { BoardCardContent } from "./board-card";
|
||||||
|
|
@ -44,13 +48,47 @@ const kanbanCollision: CollisionDetection = (args) => {
|
||||||
return closestCenter(args);
|
return closestCenter(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Compute a float position to place an item at `targetIndex` within `siblings`. */
|
/** Build column ID arrays from TQ issue data, respecting current sort. */
|
||||||
function computePosition(siblings: Issue[], targetIndex: number): number {
|
function buildColumns(
|
||||||
if (siblings.length === 0) return 0;
|
issues: Issue[],
|
||||||
if (targetIndex <= 0) return siblings[0]!.position - 1;
|
visibleStatuses: IssueStatus[],
|
||||||
if (targetIndex >= siblings.length)
|
sortBy: SortField,
|
||||||
return siblings[siblings.length - 1]!.position + 1;
|
sortDirection: SortDirection,
|
||||||
return (siblings[targetIndex - 1]!.position + siblings[targetIndex]!.position) / 2;
|
): Record<IssueStatus, string[]> {
|
||||||
|
const cols = {} as Record<IssueStatus, string[]>;
|
||||||
|
for (const status of visibleStatuses) {
|
||||||
|
const sorted = sortIssues(
|
||||||
|
issues.filter((i) => i.status === status),
|
||||||
|
sortBy,
|
||||||
|
sortDirection,
|
||||||
|
);
|
||||||
|
cols[status] = sorted.map((i) => i.id);
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute a float position for `activeId` based on its neighbors in `ids`. */
|
||||||
|
function computePosition(ids: string[], activeId: string, issueMap: Map<string, Issue>): number {
|
||||||
|
const idx = ids.indexOf(activeId);
|
||||||
|
if (idx === -1) return 0;
|
||||||
|
const getPos = (id: string) => issueMap.get(id)?.position ?? 0;
|
||||||
|
if (ids.length === 1) return issueMap.get(activeId)?.position ?? 0;
|
||||||
|
if (idx === 0) return getPos(ids[1]!) - 1;
|
||||||
|
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
|
||||||
|
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find which column (status) contains a given ID (issue or column droppable). */
|
||||||
|
function findColumn(
|
||||||
|
columns: Record<IssueStatus, string[]>,
|
||||||
|
id: string,
|
||||||
|
visibleStatuses: IssueStatus[],
|
||||||
|
): IssueStatus | null {
|
||||||
|
if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus;
|
||||||
|
for (const [status, ids] of Object.entries(columns)) {
|
||||||
|
if (ids.includes(id)) return status as IssueStatus;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BoardView({
|
export function BoardView({
|
||||||
|
|
@ -70,7 +108,52 @@ export function BoardView({
|
||||||
newPosition?: number
|
newPosition?: number
|
||||||
) => void;
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const sortBy = useViewStore((s) => s.sortBy);
|
||||||
|
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||||
|
|
||||||
|
// --- Drag state ---
|
||||||
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
||||||
|
const isDraggingRef = useRef(false);
|
||||||
|
|
||||||
|
// --- Local columns state ---
|
||||||
|
// Between drags: follows TQ via useEffect.
|
||||||
|
// During drag: local-only, driven by onDragOver/onDragEnd.
|
||||||
|
const [columns, setColumns] = useState<Record<IssueStatus, string[]>>(() =>
|
||||||
|
buildColumns(issues, visibleStatuses, sortBy, sortDirection),
|
||||||
|
);
|
||||||
|
const columnsRef = useRef(columns);
|
||||||
|
columnsRef.current = columns;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDraggingRef.current) {
|
||||||
|
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||||
|
}
|
||||||
|
}, [issues, visibleStatuses, sortBy, sortDirection]);
|
||||||
|
|
||||||
|
// After a cross-column move, lock for one animation frame so dnd-kit's
|
||||||
|
// collision detection can stabilize before processing the next move.
|
||||||
|
// Without this, collision oscillates: A→B→A→B… until React bails out.
|
||||||
|
const recentlyMovedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
recentlyMovedRef.current = false;
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, [columns]);
|
||||||
|
|
||||||
|
// --- Issue map ---
|
||||||
|
// Frozen during drag so BoardColumn/DraggableBoardCard props stay
|
||||||
|
// referentially stable even if a TQ refetch lands mid-drag.
|
||||||
|
const issueMap = useMemo(() => {
|
||||||
|
const map = new Map<string, Issue>();
|
||||||
|
for (const issue of issues) map.set(issue.id, issue);
|
||||||
|
return map;
|
||||||
|
}, [issues]);
|
||||||
|
|
||||||
|
const issueMapRef = useRef(issueMap);
|
||||||
|
if (!isDraggingRef.current) {
|
||||||
|
issueMapRef.current = issueMap;
|
||||||
|
}
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
|
|
@ -78,89 +161,100 @@ export function BoardView({
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pre-sort issues by position per status for position calculations
|
|
||||||
const issuesByStatus = useMemo(() => {
|
|
||||||
const map: Record<string, Issue[]> = {};
|
|
||||||
for (const status of visibleStatuses) {
|
|
||||||
map[status] = issues
|
|
||||||
.filter((i) => i.status === status)
|
|
||||||
.sort((a, b) => a.position - b.position);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [issues, visibleStatuses]);
|
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
const handleDragStart = useCallback(
|
||||||
(event: DragStartEvent) => {
|
(event: DragStartEvent) => {
|
||||||
const issue = issues.find((i) => i.id === event.active.id);
|
isDraggingRef.current = true;
|
||||||
if (issue) setActiveIssue(issue);
|
const issue = issueMapRef.current.get(event.active.id as string) ?? null;
|
||||||
|
setActiveIssue(issue);
|
||||||
},
|
},
|
||||||
[issues]
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback(
|
||||||
|
(event: DragOverEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || recentlyMovedRef.current) return;
|
||||||
|
|
||||||
|
const activeId = active.id as string;
|
||||||
|
const overId = over.id as string;
|
||||||
|
|
||||||
|
setColumns((prev) => {
|
||||||
|
const activeCol = findColumn(prev, activeId, visibleStatuses);
|
||||||
|
const overCol = findColumn(prev, overId, visibleStatuses);
|
||||||
|
if (!activeCol || !overCol || activeCol === overCol) return prev;
|
||||||
|
|
||||||
|
recentlyMovedRef.current = true;
|
||||||
|
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
|
||||||
|
const newIds = [...prev[overCol]!];
|
||||||
|
const overIndex = newIds.indexOf(overId);
|
||||||
|
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
|
||||||
|
newIds.splice(insertIndex, 0, activeId);
|
||||||
|
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[visibleStatuses],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(event: DragEndEvent) => {
|
(event: DragEndEvent) => {
|
||||||
setActiveIssue(null);
|
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over || active.id === over.id) return;
|
isDraggingRef.current = false;
|
||||||
|
setActiveIssue(null);
|
||||||
|
|
||||||
const issueId = active.id as string;
|
const resetColumns = () =>
|
||||||
const currentIssue = issues.find((i) => i.id === issueId);
|
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||||
if (!currentIssue) return;
|
|
||||||
|
|
||||||
// Determine target status
|
if (!over) {
|
||||||
let targetStatus: IssueStatus;
|
resetColumns();
|
||||||
let overIsColumn = false;
|
return;
|
||||||
|
|
||||||
if (visibleStatuses.includes(over.id as IssueStatus)) {
|
|
||||||
targetStatus = over.id as IssueStatus;
|
|
||||||
overIsColumn = true;
|
|
||||||
} else {
|
|
||||||
const targetIssue = issues.find((i) => i.id === over.id);
|
|
||||||
if (!targetIssue) return;
|
|
||||||
targetStatus = targetIssue.status;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get sorted siblings in the target column (excluding the dragged item)
|
const activeId = active.id as string;
|
||||||
const siblings = (issuesByStatus[targetStatus] ?? []).filter(
|
const overId = over.id as string;
|
||||||
(i) => i.id !== issueId
|
|
||||||
);
|
|
||||||
|
|
||||||
// Compute new position
|
const cols = columnsRef.current;
|
||||||
let newPosition: number;
|
const activeCol = findColumn(cols, activeId, visibleStatuses);
|
||||||
|
const overCol = findColumn(cols, overId, visibleStatuses);
|
||||||
|
if (!activeCol || !overCol) {
|
||||||
|
resetColumns();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (overIsColumn) {
|
// Same-column reorder
|
||||||
// Dropped on empty area of column → append to end
|
let finalColumns = cols;
|
||||||
newPosition = computePosition(siblings, siblings.length);
|
if (activeCol === overCol) {
|
||||||
} else {
|
const ids = cols[activeCol]!;
|
||||||
// Dropped on a specific card → insert at that card's index
|
const oldIndex = ids.indexOf(activeId);
|
||||||
const overIndex = siblings.findIndex((i) => i.id === over.id);
|
const newIndex = ids.indexOf(overId);
|
||||||
if (overIndex === -1) {
|
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||||
newPosition = computePosition(siblings, siblings.length);
|
const reordered = arrayMove(ids, oldIndex, newIndex);
|
||||||
} else {
|
finalColumns = { ...cols, [activeCol]: reordered };
|
||||||
const isSameColumn = currentIssue.status === targetStatus;
|
setColumns(finalColumns);
|
||||||
const overIssuePosition = siblings[overIndex]!.position;
|
|
||||||
|
|
||||||
if (isSameColumn && currentIssue.position < overIssuePosition) {
|
|
||||||
// Moving down → insert after the over card
|
|
||||||
newPosition = computePosition(siblings, overIndex + 1);
|
|
||||||
} else {
|
|
||||||
// Moving up or cross-column → insert before the over card
|
|
||||||
newPosition = computePosition(siblings, overIndex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if nothing changed
|
const finalCol = findColumn(finalColumns, activeId, visibleStatuses);
|
||||||
|
if (!finalCol) {
|
||||||
|
resetColumns();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = issueMapRef.current;
|
||||||
|
const finalIds = finalColumns[finalCol]!;
|
||||||
|
const newPosition = computePosition(finalIds, activeId, map);
|
||||||
|
const currentIssue = map.get(activeId);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
currentIssue.status === targetStatus &&
|
currentIssue &&
|
||||||
|
currentIssue.status === finalCol &&
|
||||||
currentIssue.position === newPosition
|
currentIssue.position === newPosition
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMoveIssue(issueId, targetStatus, newPosition);
|
onMoveIssue(activeId, finalCol, newPosition);
|
||||||
},
|
},
|
||||||
[issues, issuesByStatus, onMoveIssue, visibleStatuses]
|
[issues, visibleStatuses, sortBy, sortDirection, onMoveIssue],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -168,6 +262,7 @@ export function BoardView({
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={kanbanCollision}
|
collisionDetection={kanbanCollision}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
|
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
|
||||||
|
|
@ -175,7 +270,8 @@ export function BoardView({
|
||||||
<BoardColumn
|
<BoardColumn
|
||||||
key={status}
|
key={status}
|
||||||
status={status}
|
status={status}
|
||||||
issues={issues.filter((i) => i.status === status)}
|
issueIds={columns[status] ?? []}
|
||||||
|
issueMap={issueMapRef.current}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -187,9 +283,9 @@ export function BoardView({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DragOverlay>
|
<DragOverlay dropAnimation={null}>
|
||||||
{activeIssue ? (
|
{activeIssue ? (
|
||||||
<div className="w-[280px] rotate-1 cursor-grabbing opacity-95 shadow-md">
|
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
|
||||||
<BoardCardContent issue={activeIssue} />
|
<BoardCardContent issue={activeIssue} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -63,10 +63,13 @@ import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent
|
||||||
import { CommentCard } from "./comment-card";
|
import { CommentCard } from "./comment-card";
|
||||||
import { CommentInput } from "./comment-input";
|
import { CommentInput } from "./comment-input";
|
||||||
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
|
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
|
||||||
import { api } from "@/shared/api";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAuthStore } from "@/features/auth";
|
import { useAuthStore } from "@/features/auth";
|
||||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||||
import { useIssueStore } from "@/features/issues";
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import { issueListOptions, issueDetailOptions } from "@core/issues/queries";
|
||||||
|
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
|
||||||
|
import { useUpdateIssue, useDeleteIssue } from "@core/issues/mutations";
|
||||||
import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline";
|
import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline";
|
||||||
import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions";
|
import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions";
|
||||||
import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers";
|
import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers";
|
||||||
|
|
@ -175,12 +178,13 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||||
const members = useWorkspaceStore((s) => s.members);
|
|
||||||
const agents = useWorkspaceStore((s) => s.agents);
|
|
||||||
const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role;
|
|
||||||
|
|
||||||
// Issue navigation
|
// Issue navigation — read from TQ list cache
|
||||||
const allIssues = useIssueStore((s) => s.issues);
|
const wsId = useWorkspaceId();
|
||||||
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||||
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||||
|
const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role;
|
||||||
|
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
|
||||||
const currentIndex = allIssues.findIndex((i) => i.id === id);
|
const currentIndex = allIssues.findIndex((i) => i.id === id);
|
||||||
const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null;
|
const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null;
|
||||||
const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null;
|
const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null;
|
||||||
|
|
@ -200,38 +204,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||||
const didHighlightRef = useRef<string | null>(null);
|
const didHighlightRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Single source of truth: read issue directly from global store
|
// Issue data from TQ — uses detail query, seeded from list cache if available
|
||||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === id)) ?? null;
|
const { data: issue = null, isLoading: issueLoading } = useQuery({
|
||||||
const [issueLoading, setIssueLoading] = useState(!issue);
|
...issueDetailOptions(wsId, id),
|
||||||
|
initialData: () => allIssues.find((i) => i.id === id),
|
||||||
// If issue isn't in the store yet, fetch and upsert it.
|
});
|
||||||
// loadedIdRef tracks which issue was already loaded — if it disappears
|
|
||||||
// from the store (workspace switch clears all issues), skip refetch.
|
|
||||||
const loadedIdRef = useRef<string | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (issue) {
|
|
||||||
loadedIdRef.current = id;
|
|
||||||
setIssueLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Issue was loaded for this id but vanished → store cleared (workspace switch)
|
|
||||||
if (loadedIdRef.current === id) {
|
|
||||||
loadedIdRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Issue not in store → fetch it
|
|
||||||
setIssueLoading(true);
|
|
||||||
api
|
|
||||||
.getIssue(id)
|
|
||||||
.then((iss) => {
|
|
||||||
useIssueStore.getState().addIssue(iss);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
toast.error("Failed to load issue");
|
|
||||||
})
|
|
||||||
.finally(() => setIssueLoading(false));
|
|
||||||
}, [id, !!issue]);
|
|
||||||
|
|
||||||
// Custom hooks — encapsulate timeline, reactions, subscribers
|
// Custom hooks — encapsulate timeline, reactions, subscribers
|
||||||
const {
|
const {
|
||||||
|
|
@ -283,18 +260,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
scrollContainerRef.current?.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: "smooth" });
|
scrollContainerRef.current?.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: "smooth" });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Issue field updates — write directly to the global store (single source of truth)
|
// Issue field updates via TQ mutation (optimistic update + rollback in mutation hook)
|
||||||
|
const updateIssueMutation = useUpdateIssue();
|
||||||
const handleUpdateField = useCallback(
|
const handleUpdateField = useCallback(
|
||||||
(updates: Partial<UpdateIssueRequest>) => {
|
(updates: Partial<UpdateIssueRequest>) => {
|
||||||
if (!issue) return;
|
if (!issue) return;
|
||||||
const prev = { ...issue };
|
updateIssueMutation.mutate(
|
||||||
useIssueStore.getState().updateIssue(id, updates);
|
{ id, ...updates },
|
||||||
api.updateIssue(id, updates).catch(() => {
|
{ onError: () => toast.error("Failed to update issue") },
|
||||||
useIssueStore.getState().updateIssue(id, prev);
|
);
|
||||||
toast.error("Failed to update issue");
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[issue, id],
|
[issue, id, updateIssueMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||||
|
|
@ -303,11 +279,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
[uploadWithToast, id],
|
[uploadWithToast, id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const deleteIssueMutation = useDeleteIssue();
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
await api.deleteIssue(issue!.id);
|
await deleteIssueMutation.mutateAsync(issue!.id);
|
||||||
useIssueStore.getState().removeIssue(issue!.id);
|
|
||||||
toast.success("Issue deleted");
|
toast.success("Issue deleted");
|
||||||
if (onDelete) onDelete();
|
if (onDelete) onDelete();
|
||||||
else router.push("/issues");
|
else router.push("/issues");
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useIssueStore } from "@/features/issues/store";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { issueListOptions } from "@core/issues/queries";
|
||||||
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
import { StatusIcon } from "./status-icon";
|
import { StatusIcon } from "./status-icon";
|
||||||
|
|
||||||
interface IssueMentionCardProps {
|
interface IssueMentionCardProps {
|
||||||
|
|
@ -11,7 +13,9 @@ interface IssueMentionCardProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
|
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
|
||||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
|
const wsId = useWorkspaceId();
|
||||||
|
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
||||||
|
const issue = issues.find((i) => i.id === issueId);
|
||||||
|
|
||||||
if (!issue) {
|
if (!issue) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,9 @@ import {
|
||||||
PRIORITY_CONFIG,
|
PRIORITY_CONFIG,
|
||||||
} from "@/features/issues/config";
|
} from "@/features/issues/config";
|
||||||
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
|
||||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||||
import {
|
import {
|
||||||
useIssueViewStore,
|
useIssueViewStore,
|
||||||
|
|
@ -155,8 +157,9 @@ function ActorSubContent({
|
||||||
noAssigneeCount?: number;
|
noAssigneeCount?: number;
|
||||||
}) {
|
}) {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const members = useWorkspaceStore((s) => s.members);
|
const wsId = useWorkspaceId();
|
||||||
const agents = useWorkspaceStore((s) => s.agents);
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||||
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||||
const query = search.toLowerCase();
|
const query = search.toLowerCase();
|
||||||
const filteredMembers = members.filter((m) =>
|
const filteredMembers = members.filter((m) =>
|
||||||
m.name.toLowerCase().includes(query),
|
m.name.toLowerCase().includes(query),
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { toast } from "sonner";
|
||||||
import { ChevronRight, ListTodo } from "lucide-react";
|
import { ChevronRight, ListTodo } from "lucide-react";
|
||||||
import type { IssueStatus } from "@/shared/types";
|
import type { IssueStatus } from "@/shared/types";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useIssueStore } from "@/features/issues/store";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store";
|
import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store";
|
||||||
import { useIssuesScopeStore } from "@/features/issues/stores/issues-scope-store";
|
import { useIssuesScopeStore } from "@/features/issues/stores/issues-scope-store";
|
||||||
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
|
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
|
||||||
|
|
@ -13,7 +13,9 @@ import { filterIssues } from "@/features/issues/utils/filter";
|
||||||
import { BOARD_STATUSES } from "@/features/issues/config";
|
import { BOARD_STATUSES } from "@/features/issues/config";
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
import { useWorkspaceStore } from "@/features/workspace";
|
||||||
import { WorkspaceAvatar } from "@/features/workspace";
|
import { WorkspaceAvatar } from "@/features/workspace";
|
||||||
import { api } from "@/shared/api";
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import { issueListOptions } from "@core/issues/queries";
|
||||||
|
import { useUpdateIssue } from "@core/issues/mutations";
|
||||||
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
||||||
import { IssuesHeader } from "./issues-header";
|
import { IssuesHeader } from "./issues-header";
|
||||||
import { BoardView } from "./board-view";
|
import { BoardView } from "./board-view";
|
||||||
|
|
@ -21,8 +23,9 @@ import { ListView } from "./list-view";
|
||||||
import { BatchActionToolbar } from "./batch-action-toolbar";
|
import { BatchActionToolbar } from "./batch-action-toolbar";
|
||||||
|
|
||||||
export function IssuesPage() {
|
export function IssuesPage() {
|
||||||
const allIssues = useIssueStore((s) => s.issues);
|
const wsId = useWorkspaceId();
|
||||||
const loading = useIssueStore((s) => s.loading);
|
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
|
||||||
|
|
||||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||||
const scope = useIssuesScopeStore((s) => s.scope);
|
const scope = useIssuesScopeStore((s) => s.scope);
|
||||||
const viewMode = useIssueViewStore((s) => s.viewMode);
|
const viewMode = useIssueViewStore((s) => s.viewMode);
|
||||||
|
|
@ -64,6 +67,7 @@ export function IssuesPage() {
|
||||||
return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s));
|
return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s));
|
||||||
}, [visibleStatuses]);
|
}, [visibleStatuses]);
|
||||||
|
|
||||||
|
const updateIssueMutation = useUpdateIssue();
|
||||||
const handleMoveIssue = useCallback(
|
const handleMoveIssue = useCallback(
|
||||||
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
|
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
|
||||||
// Auto-switch to manual sort so drag ordering is preserved
|
// Auto-switch to manual sort so drag ordering is preserved
|
||||||
|
|
@ -78,14 +82,12 @@ export function IssuesPage() {
|
||||||
};
|
};
|
||||||
if (newPosition !== undefined) updates.position = newPosition;
|
if (newPosition !== undefined) updates.position = newPosition;
|
||||||
|
|
||||||
useIssueStore.getState().updateIssue(issueId, updates);
|
updateIssueMutation.mutate(
|
||||||
|
{ id: issueId, ...updates },
|
||||||
api.updateIssue(issueId, updates).catch(() => {
|
{ onError: () => toast.error("Failed to move issue") },
|
||||||
toast.error("Failed to move issue");
|
);
|
||||||
useIssueStore.getState().fetch().catch(console.error);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[]
|
[updateIssueMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Lock, UserMinus } from "lucide-react";
|
import { Lock, UserMinus } from "lucide-react";
|
||||||
import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@/shared/types";
|
import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@/shared/types";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAuthStore } from "@/features/auth";
|
import { useAuthStore } from "@/features/auth";
|
||||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
import { useActorName } from "@/features/workspace";
|
||||||
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
|
||||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||||
import {
|
import {
|
||||||
PropertyPicker,
|
PropertyPicker,
|
||||||
|
|
@ -44,8 +47,9 @@ export function AssigneePicker({
|
||||||
const setOpen = controlledOnOpenChange ?? setInternalOpen;
|
const setOpen = controlledOnOpenChange ?? setInternalOpen;
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const members = useWorkspaceStore((s) => s.members);
|
const wsId = useWorkspaceId();
|
||||||
const agents = useWorkspaceStore((s) => s.agents);
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||||
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||||
const { getActorName } = useActorName();
|
const { getActorName } = useActorName();
|
||||||
|
|
||||||
const currentMember = members.find((m) => m.user_id === user?.id);
|
const currentMember = members.find((m) => m.user_id === user?.id);
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,29 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { IssueReaction } from "@/shared/types";
|
import type { IssueReaction } from "@/shared/types";
|
||||||
import type {
|
import type {
|
||||||
IssueReactionAddedPayload,
|
IssueReactionAddedPayload,
|
||||||
IssueReactionRemovedPayload,
|
IssueReactionRemovedPayload,
|
||||||
} from "@/shared/types";
|
} from "@/shared/types";
|
||||||
import { api } from "@/shared/api";
|
import { issueReactionsOptions, issueKeys } from "@core/issues/queries";
|
||||||
import { toast } from "sonner";
|
import { useToggleIssueReaction } from "@core/issues/mutations";
|
||||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||||
|
|
||||||
export function useIssueReactions(issueId: string, userId?: string) {
|
export function useIssueReactions(issueId: string, userId?: string) {
|
||||||
const [reactions, setReactions] = useState<IssueReaction[]>([]);
|
const qc = useQueryClient();
|
||||||
const [loading, setLoading] = useState(true);
|
const { data: reactions = [], isLoading: loading } = useQuery(
|
||||||
|
issueReactionsOptions(issueId),
|
||||||
|
);
|
||||||
|
|
||||||
// Initial fetch
|
const toggleMutation = useToggleIssueReaction(issueId);
|
||||||
useEffect(() => {
|
|
||||||
setReactions([]);
|
|
||||||
setLoading(true);
|
|
||||||
api
|
|
||||||
.getIssue(issueId)
|
|
||||||
.then((iss) => setReactions(iss.reactions ?? []))
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
toast.error("Failed to load reactions");
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [issueId]);
|
|
||||||
|
|
||||||
// Reconnect recovery
|
// Reconnect recovery
|
||||||
useWSReconnect(
|
useWSReconnect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
api.getIssue(issueId).then((iss) => setReactions(iss.reactions ?? [])).catch(console.error);
|
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||||
}, [issueId]),
|
}, [qc, issueId]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- WS event handlers ---
|
// --- WS event handlers ---
|
||||||
|
|
@ -43,13 +34,18 @@ export function useIssueReactions(issueId: string, userId?: string) {
|
||||||
(payload: unknown) => {
|
(payload: unknown) => {
|
||||||
const { reaction, issue_id } = payload as IssueReactionAddedPayload;
|
const { reaction, issue_id } = payload as IssueReactionAddedPayload;
|
||||||
if (issue_id !== issueId) return;
|
if (issue_id !== issueId) return;
|
||||||
if (reaction.actor_type === "member" && reaction.actor_id === userId) return;
|
if (reaction.actor_type === "member" && reaction.actor_id === userId)
|
||||||
setReactions((prev) => {
|
return;
|
||||||
if (prev.some((r) => r.id === reaction.id)) return prev;
|
qc.setQueryData<IssueReaction[]>(
|
||||||
return [...prev, reaction];
|
issueKeys.reactions(issueId),
|
||||||
});
|
(old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
if (old.some((r) => r.id === reaction.id)) return old;
|
||||||
|
return [...old, reaction];
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[issueId, userId],
|
[qc, issueId, userId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -60,13 +56,20 @@ export function useIssueReactions(issueId: string, userId?: string) {
|
||||||
const p = payload as IssueReactionRemovedPayload;
|
const p = payload as IssueReactionRemovedPayload;
|
||||||
if (p.issue_id !== issueId) return;
|
if (p.issue_id !== issueId) return;
|
||||||
if (p.actor_type === "member" && p.actor_id === userId) return;
|
if (p.actor_type === "member" && p.actor_id === userId) return;
|
||||||
setReactions((prev) =>
|
qc.setQueryData<IssueReaction[]>(
|
||||||
prev.filter(
|
issueKeys.reactions(issueId),
|
||||||
(r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id),
|
(old) =>
|
||||||
),
|
old?.filter(
|
||||||
|
(r) =>
|
||||||
|
!(
|
||||||
|
r.emoji === p.emoji &&
|
||||||
|
r.actor_type === p.actor_type &&
|
||||||
|
r.actor_id === p.actor_id
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[issueId, userId],
|
[qc, issueId, userId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -76,36 +79,14 @@ export function useIssueReactions(issueId: string, userId?: string) {
|
||||||
async (emoji: string) => {
|
async (emoji: string) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
const existing = reactions.find(
|
const existing = reactions.find(
|
||||||
(r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId,
|
(r) =>
|
||||||
|
r.emoji === emoji &&
|
||||||
|
r.actor_type === "member" &&
|
||||||
|
r.actor_id === userId,
|
||||||
);
|
);
|
||||||
if (existing) {
|
toggleMutation.mutate({ emoji, existing });
|
||||||
setReactions((prev) => prev.filter((r) => r.id !== existing.id));
|
|
||||||
try {
|
|
||||||
await api.removeIssueReaction(issueId, emoji);
|
|
||||||
} catch {
|
|
||||||
setReactions((prev) => [...prev, existing]);
|
|
||||||
toast.error("Failed to remove reaction");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const temp: IssueReaction = {
|
|
||||||
id: `temp-${Date.now()}`,
|
|
||||||
issue_id: issueId,
|
|
||||||
actor_type: "member",
|
|
||||||
actor_id: userId,
|
|
||||||
emoji,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
setReactions((prev) => [...prev, temp]);
|
|
||||||
try {
|
|
||||||
const reaction = await api.addIssueReaction(issueId, emoji);
|
|
||||||
setReactions((prev) => prev.map((r) => (r.id === temp.id ? reaction : r)));
|
|
||||||
} catch {
|
|
||||||
setReactions((prev) => prev.filter((r) => r.id !== temp.id));
|
|
||||||
toast.error("Failed to add reaction");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[issueId, userId, reactions],
|
[userId, reactions, toggleMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { reactions, loading, toggleReaction };
|
return { reactions, loading, toggleReaction };
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,29 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { IssueSubscriber } from "@/shared/types";
|
import type { IssueSubscriber } from "@/shared/types";
|
||||||
import type {
|
import type {
|
||||||
SubscriberAddedPayload,
|
SubscriberAddedPayload,
|
||||||
SubscriberRemovedPayload,
|
SubscriberRemovedPayload,
|
||||||
} from "@/shared/types";
|
} from "@/shared/types";
|
||||||
import { api } from "@/shared/api";
|
import { issueSubscribersOptions, issueKeys } from "@core/issues/queries";
|
||||||
import { toast } from "sonner";
|
import { useToggleIssueSubscriber } from "@core/issues/mutations";
|
||||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||||
|
|
||||||
export function useIssueSubscribers(issueId: string, userId?: string) {
|
export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||||
const [subscribers, setSubscribers] = useState<IssueSubscriber[]>([]);
|
const qc = useQueryClient();
|
||||||
const [loading, setLoading] = useState(true);
|
const { data: subscribers = [], isLoading: loading } = useQuery(
|
||||||
|
issueSubscribersOptions(issueId),
|
||||||
|
);
|
||||||
|
|
||||||
// Initial fetch
|
const toggleMutation = useToggleIssueSubscriber(issueId);
|
||||||
useEffect(() => {
|
|
||||||
setSubscribers([]);
|
|
||||||
setLoading(true);
|
|
||||||
api
|
|
||||||
.listIssueSubscribers(issueId)
|
|
||||||
.then((subs) => setSubscribers(subs))
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
toast.error("Failed to load subscribers");
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [issueId]);
|
|
||||||
|
|
||||||
// Reconnect recovery
|
// Reconnect recovery
|
||||||
useWSReconnect(
|
useWSReconnect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
api.listIssueSubscribers(issueId).then(setSubscribers).catch(console.error);
|
qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||||
}, [issueId]),
|
}, [qc, issueId]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- WS event handlers ---
|
// --- WS event handlers ---
|
||||||
|
|
@ -43,21 +34,31 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||||
(payload: unknown) => {
|
(payload: unknown) => {
|
||||||
const p = payload as SubscriberAddedPayload;
|
const p = payload as SubscriberAddedPayload;
|
||||||
if (p.issue_id !== issueId) return;
|
if (p.issue_id !== issueId) return;
|
||||||
setSubscribers((prev) => {
|
qc.setQueryData<IssueSubscriber[]>(
|
||||||
if (prev.some((s) => s.user_id === p.user_id && s.user_type === p.user_type)) return prev;
|
issueKeys.subscribers(issueId),
|
||||||
return [
|
(old) => {
|
||||||
...prev,
|
if (!old) return old;
|
||||||
{
|
if (
|
||||||
issue_id: p.issue_id,
|
old.some(
|
||||||
user_type: p.user_type as "member" | "agent",
|
(s) =>
|
||||||
user_id: p.user_id,
|
s.user_id === p.user_id && s.user_type === p.user_type,
|
||||||
reason: p.reason as IssueSubscriber["reason"],
|
)
|
||||||
created_at: new Date().toISOString(),
|
)
|
||||||
},
|
return old;
|
||||||
];
|
return [
|
||||||
});
|
...old,
|
||||||
|
{
|
||||||
|
issue_id: p.issue_id,
|
||||||
|
user_type: p.user_type as "member" | "agent",
|
||||||
|
user_id: p.user_id,
|
||||||
|
reason: p.reason as IssueSubscriber["reason"],
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[issueId],
|
[qc, issueId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -67,11 +68,16 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||||
(payload: unknown) => {
|
(payload: unknown) => {
|
||||||
const p = payload as SubscriberRemovedPayload;
|
const p = payload as SubscriberRemovedPayload;
|
||||||
if (p.issue_id !== issueId) return;
|
if (p.issue_id !== issueId) return;
|
||||||
setSubscribers((prev) =>
|
qc.setQueryData<IssueSubscriber[]>(
|
||||||
prev.filter((s) => !(s.user_id === p.user_id && s.user_type === p.user_type)),
|
issueKeys.subscribers(issueId),
|
||||||
|
(old) =>
|
||||||
|
old?.filter(
|
||||||
|
(s) =>
|
||||||
|
!(s.user_id === p.user_id && s.user_type === p.user_type),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[issueId],
|
[qc, issueId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -82,50 +88,29 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleSubscriber = useCallback(
|
const toggleSubscriber = useCallback(
|
||||||
async (subUserId: string, userType: "member" | "agent", currentlySubscribed: boolean) => {
|
async (
|
||||||
if (currentlySubscribed) {
|
subUserId: string,
|
||||||
// Optimistic remove + rollback on error
|
userType: "member" | "agent",
|
||||||
const removed = subscribers.find(
|
currentlySubscribed: boolean,
|
||||||
(s) => s.user_id === subUserId && s.user_type === userType,
|
) => {
|
||||||
);
|
toggleMutation.mutate({
|
||||||
setSubscribers((prev) =>
|
userId: subUserId,
|
||||||
prev.filter((s) => !(s.user_id === subUserId && s.user_type === userType)),
|
userType,
|
||||||
);
|
subscribed: currentlySubscribed,
|
||||||
try {
|
});
|
||||||
await api.unsubscribeFromIssue(issueId, subUserId, userType);
|
|
||||||
} catch {
|
|
||||||
if (removed) setSubscribers((prev) => [...prev, removed]);
|
|
||||||
toast.error("Failed to update subscriber");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Optimistic add
|
|
||||||
const tempSub: IssueSubscriber = {
|
|
||||||
issue_id: issueId,
|
|
||||||
user_type: userType,
|
|
||||||
user_id: subUserId,
|
|
||||||
reason: "manual" as const,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
setSubscribers((prev) => {
|
|
||||||
if (prev.some((s) => s.user_id === subUserId && s.user_type === userType)) return prev;
|
|
||||||
return [...prev, tempSub];
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await api.subscribeToIssue(issueId, subUserId, userType);
|
|
||||||
} catch {
|
|
||||||
setSubscribers((prev) =>
|
|
||||||
prev.filter((s) => !(s.user_id === subUserId && s.user_type === userType && s.reason === "manual")),
|
|
||||||
);
|
|
||||||
toast.error("Failed to update subscriber");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[issueId, subscribers],
|
[toggleMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleSubscribe = useCallback(() => {
|
const toggleSubscribe = useCallback(() => {
|
||||||
if (userId) toggleSubscriber(userId, "member", isSubscribed);
|
if (userId) toggleSubscriber(userId, "member", isSubscribed);
|
||||||
}, [userId, isSubscribed, toggleSubscriber]);
|
}, [userId, isSubscribed, toggleSubscriber]);
|
||||||
|
|
||||||
return { subscribers, loading, isSubscribed, toggleSubscribe, toggleSubscriber };
|
return {
|
||||||
|
subscribers,
|
||||||
|
loading,
|
||||||
|
isSubscribed,
|
||||||
|
toggleSubscribe,
|
||||||
|
toggleSubscriber,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import type { Comment, TimelineEntry } from "@/shared/types";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Comment, TimelineEntry, Reaction } from "@/shared/types";
|
||||||
import type {
|
import type {
|
||||||
CommentCreatedPayload,
|
CommentCreatedPayload,
|
||||||
CommentUpdatedPayload,
|
CommentUpdatedPayload,
|
||||||
|
|
@ -10,7 +11,13 @@ import type {
|
||||||
ReactionAddedPayload,
|
ReactionAddedPayload,
|
||||||
ReactionRemovedPayload,
|
ReactionRemovedPayload,
|
||||||
} from "@/shared/types";
|
} from "@/shared/types";
|
||||||
import { api } from "@/shared/api";
|
import { issueTimelineOptions, issueKeys } from "@core/issues/queries";
|
||||||
|
import {
|
||||||
|
useCreateComment,
|
||||||
|
useUpdateComment,
|
||||||
|
useDeleteComment,
|
||||||
|
useToggleCommentReaction,
|
||||||
|
} from "@core/issues/mutations";
|
||||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
|
@ -30,29 +37,22 @@ function commentToTimelineEntry(c: Comment): TimelineEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIssueTimeline(issueId: string, userId?: string) {
|
export function useIssueTimeline(issueId: string, userId?: string) {
|
||||||
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
|
const qc = useQueryClient();
|
||||||
|
const { data: timeline = [], isLoading: loading } = useQuery(
|
||||||
|
issueTimelineOptions(issueId),
|
||||||
|
);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// Initial fetch + reset on id change
|
const createCommentMutation = useCreateComment(issueId);
|
||||||
useEffect(() => {
|
const updateCommentMutation = useUpdateComment(issueId);
|
||||||
setTimeline([]);
|
const deleteCommentMutation = useDeleteComment(issueId);
|
||||||
setLoading(true);
|
const toggleReactionMutation = useToggleCommentReaction(issueId);
|
||||||
api
|
|
||||||
.listTimeline(issueId)
|
|
||||||
.then((entries) => setTimeline(entries))
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
toast.error("Failed to load activity");
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [issueId]);
|
|
||||||
|
|
||||||
// Reconnect recovery
|
// Reconnect recovery
|
||||||
useWSReconnect(
|
useWSReconnect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
api.listTimeline(issueId).then(setTimeline).catch(console.error);
|
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||||
}, [issueId]),
|
}, [qc, issueId]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- WS event handlers ---
|
// --- WS event handlers ---
|
||||||
|
|
@ -63,13 +63,21 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||||
(payload: unknown) => {
|
(payload: unknown) => {
|
||||||
const { comment } = payload as CommentCreatedPayload;
|
const { comment } = payload as CommentCreatedPayload;
|
||||||
if (comment.issue_id !== issueId) return;
|
if (comment.issue_id !== issueId) return;
|
||||||
if (comment.author_type === "member" && comment.author_id === userId) return;
|
if (
|
||||||
setTimeline((prev) => {
|
comment.author_type === "member" &&
|
||||||
if (prev.some((e) => e.id === comment.id)) return prev;
|
comment.author_id === userId
|
||||||
return [...prev, commentToTimelineEntry(comment)];
|
)
|
||||||
});
|
return;
|
||||||
|
qc.setQueryData<TimelineEntry[]>(
|
||||||
|
issueKeys.timeline(issueId),
|
||||||
|
(old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
if (old.some((e) => e.id === comment.id)) return old;
|
||||||
|
return [...old, commentToTimelineEntry(comment)];
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[issueId, userId],
|
[qc, issueId, userId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -79,12 +87,16 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||||
(payload: unknown) => {
|
(payload: unknown) => {
|
||||||
const { comment } = payload as CommentUpdatedPayload;
|
const { comment } = payload as CommentUpdatedPayload;
|
||||||
if (comment.issue_id === issueId) {
|
if (comment.issue_id === issueId) {
|
||||||
setTimeline((prev) =>
|
qc.setQueryData<TimelineEntry[]>(
|
||||||
prev.map((e) => (e.id === comment.id ? commentToTimelineEntry(comment) : e)),
|
issueKeys.timeline(issueId),
|
||||||
|
(old) =>
|
||||||
|
old?.map((e) =>
|
||||||
|
e.id === comment.id ? commentToTimelineEntry(comment) : e,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[issueId],
|
[qc, issueId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -94,23 +106,31 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||||
(payload: unknown) => {
|
(payload: unknown) => {
|
||||||
const { comment_id, issue_id } = payload as CommentDeletedPayload;
|
const { comment_id, issue_id } = payload as CommentDeletedPayload;
|
||||||
if (issue_id === issueId) {
|
if (issue_id === issueId) {
|
||||||
setTimeline((prev) => {
|
qc.setQueryData<TimelineEntry[]>(
|
||||||
const idsToRemove = new Set<string>([comment_id]);
|
issueKeys.timeline(issueId),
|
||||||
let added = true;
|
(old) => {
|
||||||
while (added) {
|
if (!old) return old;
|
||||||
added = false;
|
const idsToRemove = new Set<string>([comment_id]);
|
||||||
for (const e of prev) {
|
let added = true;
|
||||||
if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) {
|
while (added) {
|
||||||
idsToRemove.add(e.id);
|
added = false;
|
||||||
added = true;
|
for (const e of old) {
|
||||||
|
if (
|
||||||
|
e.parent_id &&
|
||||||
|
idsToRemove.has(e.parent_id) &&
|
||||||
|
!idsToRemove.has(e.id)
|
||||||
|
) {
|
||||||
|
idsToRemove.add(e.id);
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return old.filter((e) => !idsToRemove.has(e.id));
|
||||||
return prev.filter((e) => !idsToRemove.has(e.id));
|
},
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[issueId],
|
[qc, issueId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -122,12 +142,16 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||||
if (p.issue_id !== issueId) return;
|
if (p.issue_id !== issueId) return;
|
||||||
const entry = p.entry;
|
const entry = p.entry;
|
||||||
if (!entry || !entry.id) return;
|
if (!entry || !entry.id) return;
|
||||||
setTimeline((prev) => {
|
qc.setQueryData<TimelineEntry[]>(
|
||||||
if (prev.some((e) => e.id === entry.id)) return prev;
|
issueKeys.timeline(issueId),
|
||||||
return [...prev, entry];
|
(old) => {
|
||||||
});
|
if (!old) return old;
|
||||||
|
if (old.some((e) => e.id === entry.id)) return old;
|
||||||
|
return [...old, entry];
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[issueId],
|
[qc, issueId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -137,17 +161,23 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||||
(payload: unknown) => {
|
(payload: unknown) => {
|
||||||
const { reaction, issue_id } = payload as ReactionAddedPayload;
|
const { reaction, issue_id } = payload as ReactionAddedPayload;
|
||||||
if (issue_id !== issueId) return;
|
if (issue_id !== issueId) return;
|
||||||
if (reaction.actor_type === "member" && reaction.actor_id === userId) return;
|
if (
|
||||||
setTimeline((prev) =>
|
reaction.actor_type === "member" &&
|
||||||
prev.map((e) => {
|
reaction.actor_id === userId
|
||||||
if (e.id !== reaction.comment_id) return e;
|
)
|
||||||
const existing = e.reactions ?? [];
|
return;
|
||||||
if (existing.some((r) => r.id === reaction.id)) return e;
|
qc.setQueryData<TimelineEntry[]>(
|
||||||
return { ...e, reactions: [...existing, reaction] };
|
issueKeys.timeline(issueId),
|
||||||
}),
|
(old) =>
|
||||||
|
old?.map((e) => {
|
||||||
|
if (e.id !== reaction.comment_id) return e;
|
||||||
|
const existing = e.reactions ?? [];
|
||||||
|
if (existing.some((r) => r.id === reaction.id)) return e;
|
||||||
|
return { ...e, reactions: [...existing, reaction] };
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[issueId, userId],
|
[qc, issueId, userId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -158,19 +188,26 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||||
const p = payload as ReactionRemovedPayload;
|
const p = payload as ReactionRemovedPayload;
|
||||||
if (p.issue_id !== issueId) return;
|
if (p.issue_id !== issueId) return;
|
||||||
if (p.actor_type === "member" && p.actor_id === userId) return;
|
if (p.actor_type === "member" && p.actor_id === userId) return;
|
||||||
setTimeline((prev) =>
|
qc.setQueryData<TimelineEntry[]>(
|
||||||
prev.map((e) => {
|
issueKeys.timeline(issueId),
|
||||||
if (e.id !== p.comment_id) return e;
|
(old) =>
|
||||||
return {
|
old?.map((e) => {
|
||||||
...e,
|
if (e.id !== p.comment_id) return e;
|
||||||
reactions: (e.reactions ?? []).filter(
|
return {
|
||||||
(r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id),
|
...e,
|
||||||
),
|
reactions: (e.reactions ?? []).filter(
|
||||||
};
|
(r) =>
|
||||||
}),
|
!(
|
||||||
|
r.emoji === p.emoji &&
|
||||||
|
r.actor_type === p.actor_type &&
|
||||||
|
r.actor_id === p.actor_id
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[issueId, userId],
|
[qc, issueId, userId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -181,10 +218,9 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||||
if (!content.trim() || submitting || !userId) return;
|
if (!content.trim() || submitting || !userId) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const comment = await api.createComment(issueId, content, undefined, undefined, attachmentIds);
|
await createCommentMutation.mutateAsync({
|
||||||
setTimeline((prev) => {
|
content,
|
||||||
if (prev.some((e) => e.id === comment.id)) return prev;
|
attachmentIds,
|
||||||
return [...prev, commentToTimelineEntry(comment)];
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to send comment");
|
toast.error("Failed to send comment");
|
||||||
|
|
@ -192,147 +228,61 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[issueId, userId],
|
[userId, submitting, createCommentMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitReply = useCallback(
|
const submitReply = useCallback(
|
||||||
async (parentId: string, content: string, attachmentIds?: string[]) => {
|
async (parentId: string, content: string, attachmentIds?: string[]) => {
|
||||||
if (!content.trim() || !userId) return;
|
if (!content.trim() || !userId) return;
|
||||||
try {
|
try {
|
||||||
const comment = await api.createComment(issueId, content, "comment", parentId, attachmentIds);
|
await createCommentMutation.mutateAsync({
|
||||||
setTimeline((prev) => {
|
content,
|
||||||
if (prev.some((e) => e.id === comment.id)) return prev;
|
type: "comment",
|
||||||
return [...prev, commentToTimelineEntry(comment)];
|
parentId,
|
||||||
|
attachmentIds,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to send reply");
|
toast.error("Failed to send reply");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[issueId, userId],
|
[userId, createCommentMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const editComment = useCallback(
|
const editComment = useCallback(
|
||||||
async (commentId: string, content: string) => {
|
async (commentId: string, content: string) => {
|
||||||
// Optimistic: update content immediately
|
|
||||||
let prevContent: string | undefined;
|
|
||||||
setTimeline((prev) =>
|
|
||||||
prev.map((e) => {
|
|
||||||
if (e.id !== commentId) return e;
|
|
||||||
prevContent = e.content;
|
|
||||||
return { ...e, content, updated_at: new Date().toISOString() };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
const updated = await api.updateComment(commentId, content);
|
await updateCommentMutation.mutateAsync({ commentId, content });
|
||||||
setTimeline((prev) =>
|
|
||||||
prev.map((e) => (e.id === updated.id ? commentToTimelineEntry(updated) : e)),
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
// Rollback
|
|
||||||
if (prevContent !== undefined) {
|
|
||||||
setTimeline((prev) =>
|
|
||||||
prev.map((e) => (e.id === commentId ? { ...e, content: prevContent! } : e)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
toast.error("Failed to update comment");
|
toast.error("Failed to update comment");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[updateCommentMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteComment = useCallback(
|
const deleteComment = useCallback(
|
||||||
async (commentId: string) => {
|
async (commentId: string) => {
|
||||||
// Capture entries for rollback
|
|
||||||
let removedEntries: TimelineEntry[] = [];
|
|
||||||
setTimeline((prev) => {
|
|
||||||
const idsToRemove = new Set<string>([commentId]);
|
|
||||||
let added = true;
|
|
||||||
while (added) {
|
|
||||||
added = false;
|
|
||||||
for (const e of prev) {
|
|
||||||
if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) {
|
|
||||||
idsToRemove.add(e.id);
|
|
||||||
added = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
removedEntries = prev.filter((e) => idsToRemove.has(e.id));
|
|
||||||
return prev.filter((e) => !idsToRemove.has(e.id));
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
await api.deleteComment(commentId);
|
await deleteCommentMutation.mutateAsync(commentId);
|
||||||
} catch {
|
} catch {
|
||||||
// Rollback: re-add removed entries
|
|
||||||
setTimeline((prev) => [...prev, ...removedEntries]);
|
|
||||||
toast.error("Failed to delete comment");
|
toast.error("Failed to delete comment");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[deleteCommentMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleReaction = useCallback(
|
const toggleReaction = useCallback(
|
||||||
async (commentId: string, emoji: string) => {
|
async (commentId: string, emoji: string) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
const entry = timeline.find((e) => e.id === commentId);
|
const entry = timeline.find((e) => e.id === commentId);
|
||||||
const existing = (entry?.reactions ?? []).find(
|
const existing: Reaction | undefined = (entry?.reactions ?? []).find(
|
||||||
(r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId,
|
(r) =>
|
||||||
|
r.emoji === emoji &&
|
||||||
|
r.actor_type === "member" &&
|
||||||
|
r.actor_id === userId,
|
||||||
);
|
);
|
||||||
if (existing) {
|
toggleReactionMutation.mutate({ commentId, emoji, existing });
|
||||||
setTimeline((prev) =>
|
|
||||||
prev.map((e) => {
|
|
||||||
if (e.id !== commentId) return e;
|
|
||||||
return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== existing.id) };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await api.removeReaction(commentId, emoji);
|
|
||||||
} catch {
|
|
||||||
setTimeline((prev) =>
|
|
||||||
prev.map((e) => {
|
|
||||||
if (e.id !== commentId) return e;
|
|
||||||
return { ...e, reactions: [...(e.reactions ?? []), existing] };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
toast.error("Failed to remove reaction");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const tempReaction = {
|
|
||||||
id: `temp-${Date.now()}`,
|
|
||||||
comment_id: commentId,
|
|
||||||
actor_type: "member",
|
|
||||||
actor_id: userId,
|
|
||||||
emoji,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
setTimeline((prev) =>
|
|
||||||
prev.map((e) => {
|
|
||||||
if (e.id !== commentId) return e;
|
|
||||||
return { ...e, reactions: [...(e.reactions ?? []), tempReaction] };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
const reaction = await api.addReaction(commentId, emoji);
|
|
||||||
setTimeline((prev) =>
|
|
||||||
prev.map((e) => {
|
|
||||||
if (e.id !== commentId) return e;
|
|
||||||
return {
|
|
||||||
...e,
|
|
||||||
reactions: (e.reactions ?? []).map((r) => (r.id === tempReaction.id ? reaction : r)),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
setTimeline((prev) =>
|
|
||||||
prev.map((e) => {
|
|
||||||
if (e.id !== commentId) return e;
|
|
||||||
return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== tempReaction.id) };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
toast.error("Failed to add reaction");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[userId, timeline],
|
[userId, timeline, toggleReactionMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,97 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { Issue } from "@/shared/types";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { api } from "@/shared/api";
|
|
||||||
import { createLogger } from "@/shared/logger";
|
|
||||||
|
|
||||||
const logger = createLogger("issue-store");
|
interface IssueClientState {
|
||||||
|
|
||||||
const CLOSED_PAGE_SIZE = 50;
|
|
||||||
|
|
||||||
interface IssueState {
|
|
||||||
issues: Issue[];
|
|
||||||
loading: boolean;
|
|
||||||
activeIssueId: string | null;
|
activeIssueId: string | null;
|
||||||
hasMoreClosed: boolean;
|
|
||||||
closedOffset: number;
|
|
||||||
fetch: () => Promise<void>;
|
|
||||||
fetchMoreClosed: () => Promise<void>;
|
|
||||||
setIssues: (issues: Issue[]) => void;
|
|
||||||
addIssue: (issue: Issue) => void;
|
|
||||||
updateIssue: (id: string, updates: Partial<Issue>) => void;
|
|
||||||
removeIssue: (id: string) => void;
|
|
||||||
setActiveIssue: (id: string | null) => void;
|
setActiveIssue: (id: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useIssueStore = create<IssueState>((set, get) => ({
|
export const useIssueStore = create<IssueClientState>((set) => ({
|
||||||
issues: [],
|
|
||||||
loading: true,
|
|
||||||
activeIssueId: null,
|
activeIssueId: null,
|
||||||
hasMoreClosed: false,
|
|
||||||
closedOffset: 0,
|
|
||||||
|
|
||||||
fetch: async () => {
|
|
||||||
logger.debug("fetch start");
|
|
||||||
const isInitialLoad = get().issues.length === 0;
|
|
||||||
if (isInitialLoad) set({ loading: true });
|
|
||||||
try {
|
|
||||||
// Phase 1: fetch ALL open issues (no limit)
|
|
||||||
// Phase 2: fetch first page of closed issues
|
|
||||||
const [openRes, closedRes] = await Promise.all([
|
|
||||||
api.listIssues({ open_only: true }),
|
|
||||||
api.listIssues({ status: "done", limit: CLOSED_PAGE_SIZE, offset: 0 }),
|
|
||||||
]);
|
|
||||||
const allIssues = [...openRes.issues, ...closedRes.issues];
|
|
||||||
logger.info("fetched", openRes.issues.length, "open +", closedRes.issues.length, "closed issues");
|
|
||||||
set({
|
|
||||||
issues: allIssues,
|
|
||||||
loading: false,
|
|
||||||
hasMoreClosed: closedRes.issues.length >= CLOSED_PAGE_SIZE,
|
|
||||||
closedOffset: CLOSED_PAGE_SIZE,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error("fetch failed", err);
|
|
||||||
toast.error("Failed to load issues");
|
|
||||||
if (isInitialLoad) set({ loading: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
fetchMoreClosed: async () => {
|
|
||||||
const { closedOffset } = get();
|
|
||||||
try {
|
|
||||||
const res = await api.listIssues({
|
|
||||||
status: "done",
|
|
||||||
limit: CLOSED_PAGE_SIZE,
|
|
||||||
offset: closedOffset,
|
|
||||||
});
|
|
||||||
set((s) => ({
|
|
||||||
issues: [
|
|
||||||
...s.issues,
|
|
||||||
...res.issues.filter((ni) => !s.issues.some((ei) => ei.id === ni.id)),
|
|
||||||
],
|
|
||||||
closedOffset: closedOffset + CLOSED_PAGE_SIZE,
|
|
||||||
hasMoreClosed: res.issues.length >= CLOSED_PAGE_SIZE,
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
|
||||||
logger.error("fetchMoreClosed failed", err);
|
|
||||||
toast.error("Failed to load more issues");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setIssues: (issues) => set({ issues }),
|
|
||||||
addIssue: (issue) =>
|
|
||||||
set((s) => ({
|
|
||||||
issues: s.issues.some((i) => i.id === issue.id)
|
|
||||||
? s.issues
|
|
||||||
: [...s.issues, issue],
|
|
||||||
})),
|
|
||||||
updateIssue: (id, updates) =>
|
|
||||||
set((s) => ({
|
|
||||||
issues: s.issues.map((i) => (i.id === id ? { ...i, ...updates } : i)),
|
|
||||||
})),
|
|
||||||
removeIssue: (id) =>
|
|
||||||
set((s) => ({ issues: s.issues.filter((i) => i.id !== id) })),
|
|
||||||
setActiveIssue: (id) => set({ activeIssueId: id }),
|
setActiveIssue: (id) => set({ activeIssueId: id }),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,11 @@ import { TitleEditor } from "@/features/editor";
|
||||||
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||||
import { useIssueStore } from "@/features/issues";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
|
||||||
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
|
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
|
||||||
import { api } from "@/shared/api";
|
import { useCreateIssue } from "@core/issues/mutations";
|
||||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||||
|
|
@ -68,8 +70,9 @@ function PillButton({
|
||||||
export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?: Record<string, unknown> | null }) {
|
export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?: Record<string, unknown> | null }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
|
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
|
||||||
const members = useWorkspaceStore((s) => s.members);
|
const wsId = useWorkspaceId();
|
||||||
const agents = useWorkspaceStore((s) => s.agents);
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||||
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||||
const { getActorName } = useActorName();
|
const { getActorName } = useActorName();
|
||||||
|
|
||||||
const draft = useIssueDraftStore((s) => s.draft);
|
const draft = useIssueDraftStore((s) => s.draft);
|
||||||
|
|
@ -125,11 +128,12 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||||
};
|
};
|
||||||
const updateDueDate = (v: string | null) => { setDueDate(v); setDraft({ dueDate: v }); };
|
const updateDueDate = (v: string | null) => { setDueDate(v); setDraft({ dueDate: v }); };
|
||||||
|
|
||||||
|
const createIssueMutation = useCreateIssue();
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!title.trim() || submitting) return;
|
if (!title.trim() || submitting) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const issue = await api.createIssue({
|
const issue = await createIssueMutation.mutateAsync({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
|
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
|
||||||
status,
|
status,
|
||||||
|
|
@ -139,7 +143,6 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||||
due_date: dueDate || undefined,
|
due_date: dueDate || undefined,
|
||||||
attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined,
|
attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined,
|
||||||
});
|
});
|
||||||
useIssueStore.getState().addIssue(issue);
|
|
||||||
clearDraft();
|
clearDraft();
|
||||||
onClose();
|
onClose();
|
||||||
toast.custom((t) => (
|
toast.custom((t) => (
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import type { IssueStatus } from "@/shared/types";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useAuthStore } from "@/features/auth";
|
import { useAuthStore } from "@/features/auth";
|
||||||
import { useWorkspaceStore, WorkspaceAvatar } from "@/features/workspace";
|
import { useWorkspaceStore, WorkspaceAvatar } from "@/features/workspace";
|
||||||
import { useIssueStore } from "@/features/issues/store";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { agentListOptions } from "@core/workspace/queries";
|
||||||
import { filterIssues } from "@/features/issues/utils/filter";
|
import { filterIssues } from "@/features/issues/utils/filter";
|
||||||
import { BOARD_STATUSES } from "@/features/issues/config";
|
import { BOARD_STATUSES } from "@/features/issues/config";
|
||||||
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
|
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
|
||||||
|
|
@ -17,16 +18,18 @@ import { BoardView } from "@/features/issues/components/board-view";
|
||||||
import { ListView } from "@/features/issues/components/list-view";
|
import { ListView } from "@/features/issues/components/list-view";
|
||||||
import { BatchActionToolbar } from "@/features/issues/components/batch-action-toolbar";
|
import { BatchActionToolbar } from "@/features/issues/components/batch-action-toolbar";
|
||||||
import { registerViewStoreForWorkspaceSync } from "@/features/issues/stores/view-store";
|
import { registerViewStoreForWorkspaceSync } from "@/features/issues/stores/view-store";
|
||||||
import { api } from "@/shared/api";
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import { issueListOptions } from "@core/issues/queries";
|
||||||
|
import { useUpdateIssue } from "@core/issues/mutations";
|
||||||
import { myIssuesViewStore } from "../stores/my-issues-view-store";
|
import { myIssuesViewStore } from "../stores/my-issues-view-store";
|
||||||
import { MyIssuesHeader } from "./my-issues-header";
|
import { MyIssuesHeader } from "./my-issues-header";
|
||||||
|
|
||||||
export function MyIssuesPage() {
|
export function MyIssuesPage() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||||
const agents = useWorkspaceStore((s) => s.agents);
|
const wsId = useWorkspaceId();
|
||||||
const allIssues = useIssueStore((s) => s.issues);
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||||
const loading = useIssueStore((s) => s.loading);
|
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
|
||||||
|
|
||||||
const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode);
|
const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode);
|
||||||
const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters);
|
const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters);
|
||||||
|
|
@ -105,6 +108,7 @@ export function MyIssuesPage() {
|
||||||
return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s));
|
return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s));
|
||||||
}, [visibleStatuses]);
|
}, [visibleStatuses]);
|
||||||
|
|
||||||
|
const updateIssueMutation = useUpdateIssue();
|
||||||
const handleMoveIssue = useCallback(
|
const handleMoveIssue = useCallback(
|
||||||
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
|
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
|
||||||
const viewState = myIssuesViewStore.getState();
|
const viewState = myIssuesViewStore.getState();
|
||||||
|
|
@ -118,16 +122,12 @@ export function MyIssuesPage() {
|
||||||
};
|
};
|
||||||
if (newPosition !== undefined) updates.position = newPosition;
|
if (newPosition !== undefined) updates.position = newPosition;
|
||||||
|
|
||||||
useIssueStore.getState().updateIssue(issueId, updates);
|
updateIssueMutation.mutate(
|
||||||
|
{ id: issueId, ...updates },
|
||||||
api.updateIssue(issueId, updates).catch(() => {
|
{ onError: () => toast.error("Failed to move issue") },
|
||||||
toast.error("Failed to move issue");
|
);
|
||||||
api.listIssues({ limit: 200 }).then((res) => {
|
|
||||||
useIssueStore.getState().setIssues(res.issues);
|
|
||||||
}).catch(console.error);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[],
|
[updateIssueMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { WSClient } from "@/shared/api";
|
import type { WSClient } from "@/shared/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useIssueStore } from "@/features/issues";
|
|
||||||
import { useInboxStore } from "@/features/inbox";
|
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
import { useWorkspaceStore } from "@/features/workspace";
|
||||||
import { useAuthStore } from "@/features/auth";
|
import { useAuthStore } from "@/features/auth";
|
||||||
import { createLogger } from "@/shared/logger";
|
import { createLogger } from "@/shared/logger";
|
||||||
import { api } from "@/shared/api";
|
import { issueKeys } from "@core/issues/queries";
|
||||||
|
import {
|
||||||
|
onIssueCreated,
|
||||||
|
onIssueUpdated,
|
||||||
|
onIssueDeleted,
|
||||||
|
} from "@core/issues/ws-updaters";
|
||||||
|
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "@core/inbox/ws-updaters";
|
||||||
|
import { inboxKeys } from "@core/inbox/queries";
|
||||||
|
import { workspaceKeys } from "@core/workspace/queries";
|
||||||
import type {
|
import type {
|
||||||
MemberAddedPayload,
|
MemberAddedPayload,
|
||||||
WorkspaceDeletedPayload,
|
WorkspaceDeletedPayload,
|
||||||
|
|
@ -33,33 +40,31 @@ const logger = createLogger("realtime-sync");
|
||||||
* by individual components via useWSEvent — not here.
|
* by individual components via useWSEvent — not here.
|
||||||
*/
|
*/
|
||||||
export function useRealtimeSync(ws: WSClient | null) {
|
export function useRealtimeSync(ws: WSClient | null) {
|
||||||
|
const qc = useQueryClient();
|
||||||
// Main sync: onAny → refreshMap with debounce
|
// Main sync: onAny → refreshMap with debounce
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ws) return;
|
if (!ws) return;
|
||||||
|
|
||||||
// Event types handled by specific handlers below — skip generic refresh
|
|
||||||
const specificEvents = new Set([
|
|
||||||
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const refreshMap: Record<string, () => void> = {
|
const refreshMap: Record<string, () => void> = {
|
||||||
inbox: () => void useInboxStore.getState().fetch(),
|
inbox: () => {
|
||||||
agent: () => void useWorkspaceStore.getState().refreshAgents(),
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||||
member: () => void useWorkspaceStore.getState().refreshMembers(),
|
if (wsId) onInboxInvalidate(qc, wsId);
|
||||||
workspace: () => {
|
},
|
||||||
// Lightweight: only re-fetch workspace list, don't hydrate everything.
|
agent: () => {
|
||||||
// workspace:deleted is handled by a precise side-effect handler below.
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||||
api.listWorkspaces().then((wsList) => {
|
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||||
const current = useWorkspaceStore.getState().workspace;
|
},
|
||||||
const updated = current
|
member: () => {
|
||||||
? wsList.find((w) => w.id === current.id)
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||||
: null;
|
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||||
if (updated) useWorkspaceStore.getState().updateWorkspace(updated);
|
},
|
||||||
}).catch((err) => {
|
workspace: () => {
|
||||||
logger.error("workspace refresh failed", err);
|
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||||
});
|
},
|
||||||
|
skill: () => {
|
||||||
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||||
|
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||||
},
|
},
|
||||||
skill: () => void useWorkspaceStore.getState().refreshSkills(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
@ -75,6 +80,11 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Event types handled by specific handlers below — skip generic refresh
|
||||||
|
const specificEvents = new Set([
|
||||||
|
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
|
||||||
|
]);
|
||||||
|
|
||||||
const unsubAny = ws.onAny((msg) => {
|
const unsubAny = ws.onAny((msg) => {
|
||||||
const myUserId = useAuthStore.getState().user?.id;
|
const myUserId = useAuthStore.getState().user?.id;
|
||||||
if (msg.actor_id && msg.actor_id === myUserId) {
|
if (msg.actor_id && msg.actor_id === myUserId) {
|
||||||
|
|
@ -88,29 +98,40 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Specific event handlers (granular updates, no full refetch) ---
|
// --- Specific event handlers (granular updates, no full refetch) ---
|
||||||
|
// NOTE: ws.on() passes msg.payload (no actor_id). Self-event suppression
|
||||||
|
// requires WSClient changes to expose actor_id — tracked as separate task.
|
||||||
|
|
||||||
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
|
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
|
||||||
const { issue } = p as IssueUpdatedPayload;
|
const { issue } = p as IssueUpdatedPayload;
|
||||||
if (!issue?.id) return;
|
if (!issue?.id) return;
|
||||||
useIssueStore.getState().updateIssue(issue.id, issue);
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||||
if (issue.status) {
|
if (wsId) {
|
||||||
useInboxStore.getState().updateIssueStatus(issue.id, issue.status);
|
onIssueUpdated(qc, wsId, issue);
|
||||||
|
if (issue.status) {
|
||||||
|
onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubIssueCreated = ws.on("issue:created", (p) => {
|
const unsubIssueCreated = ws.on("issue:created", (p) => {
|
||||||
const { issue } = p as IssueCreatedPayload;
|
const { issue } = p as IssueCreatedPayload;
|
||||||
if (issue) useIssueStore.getState().addIssue(issue);
|
if (!issue) return;
|
||||||
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||||
|
if (wsId) onIssueCreated(qc, wsId, issue);
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubIssueDeleted = ws.on("issue:deleted", (p) => {
|
const unsubIssueDeleted = ws.on("issue:deleted", (p) => {
|
||||||
const { issue_id } = p as IssueDeletedPayload;
|
const { issue_id } = p as IssueDeletedPayload;
|
||||||
if (issue_id) useIssueStore.getState().removeIssue(issue_id);
|
if (!issue_id) return;
|
||||||
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||||
|
if (wsId) onIssueDeleted(qc, wsId, issue_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubInboxNew = ws.on("inbox:new", (p) => {
|
const unsubInboxNew = ws.on("inbox:new", (p) => {
|
||||||
const { item } = p as InboxNewPayload;
|
const { item } = p as InboxNewPayload;
|
||||||
if (item) useInboxStore.getState().addItem(item);
|
if (!item) return;
|
||||||
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||||
|
if (wsId) onInboxNew(qc, wsId, item);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Side-effect handlers (toast, navigation) ---
|
// --- Side-effect handlers (toast, navigation) ---
|
||||||
|
|
@ -158,7 +179,7 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||||
timers.forEach(clearTimeout);
|
timers.forEach(clearTimeout);
|
||||||
timers.clear();
|
timers.clear();
|
||||||
};
|
};
|
||||||
}, [ws]);
|
}, [ws, qc]);
|
||||||
|
|
||||||
// Reconnect → refetch all data to recover missed events
|
// Reconnect → refetch all data to recover missed events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -167,18 +188,20 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||||
const unsub = ws.onReconnect(async () => {
|
const unsub = ws.onReconnect(async () => {
|
||||||
logger.info("reconnected, refetching all data");
|
logger.info("reconnected, refetching all data");
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||||
useIssueStore.getState().fetch(),
|
if (wsId) {
|
||||||
useInboxStore.getState().fetch(),
|
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
||||||
useWorkspaceStore.getState().refreshAgents(),
|
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
|
||||||
useWorkspaceStore.getState().refreshMembers(),
|
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||||
useWorkspaceStore.getState().refreshSkills(),
|
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||||
]);
|
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||||
|
}
|
||||||
|
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("reconnect refetch failed", e);
|
logger.error("reconnect refetch failed", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [ws]);
|
}, [ws, qc]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { Server } from "lucide-react";
|
import { Server } from "lucide-react";
|
||||||
import { useDefaultLayout } from "react-resizable-panels";
|
import { useDefaultLayout } from "react-resizable-panels";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
|
|
@ -10,38 +11,35 @@ import {
|
||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useAuthStore } from "@/features/auth";
|
import { useAuthStore } from "@/features/auth";
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import { runtimeListOptions, runtimeKeys } from "@core/runtimes/queries";
|
||||||
import { useWSEvent } from "@/features/realtime";
|
import { useWSEvent } from "@/features/realtime";
|
||||||
import { useRuntimeStore } from "../store";
|
|
||||||
import { RuntimeList } from "./runtime-list";
|
import { RuntimeList } from "./runtime-list";
|
||||||
import { RuntimeDetail } from "./runtime-detail";
|
import { RuntimeDetail } from "./runtime-detail";
|
||||||
|
|
||||||
export default function RuntimesPage() {
|
export default function RuntimesPage() {
|
||||||
const isLoading = useAuthStore((s) => s.isLoading);
|
const isLoading = useAuthStore((s) => s.isLoading);
|
||||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
const wsId = useWorkspaceId();
|
||||||
const runtimes = useRuntimeStore((s) => s.runtimes);
|
const qc = useQueryClient();
|
||||||
const selectedId = useRuntimeStore((s) => s.selectedId);
|
const { data: runtimes = [], isLoading: fetching } = useQuery(runtimeListOptions(wsId));
|
||||||
const fetching = useRuntimeStore((s) => s.fetching);
|
const [selectedId, setSelectedId] = useState("");
|
||||||
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
|
|
||||||
const setSelectedId = useRuntimeStore((s) => s.setSelectedId);
|
|
||||||
|
|
||||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||||
id: "multica_runtimes_layout",
|
id: "multica_runtimes_layout",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (workspace) fetchRuntimes();
|
|
||||||
}, [workspace, fetchRuntimes]);
|
|
||||||
|
|
||||||
// Re-fetch on daemon register/deregister events.
|
// Re-fetch on daemon register/deregister events.
|
||||||
// Heartbeat events are not broadcast over WS, so no handler needed.
|
|
||||||
const handleDaemonEvent = useCallback(() => {
|
const handleDaemonEvent = useCallback(() => {
|
||||||
fetchRuntimes();
|
qc.invalidateQueries({ queryKey: runtimeKeys.list(wsId) });
|
||||||
}, [fetchRuntimes]);
|
}, [qc, wsId]);
|
||||||
|
|
||||||
useWSEvent("daemon:register", handleDaemonEvent);
|
useWSEvent("daemon:register", handleDaemonEvent);
|
||||||
|
|
||||||
const selected = runtimes.find((r) => r.id === selectedId) ?? null;
|
// Auto-select first runtime if nothing selected
|
||||||
|
const effectiveSelectedId = selectedId && runtimes.some((r) => r.id === selectedId)
|
||||||
|
? selectedId
|
||||||
|
: runtimes[0]?.id ?? "";
|
||||||
|
const selected = runtimes.find((r) => r.id === effectiveSelectedId) ?? null;
|
||||||
|
|
||||||
if (isLoading || fetching) {
|
if (isLoading || fetching) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -95,7 +93,7 @@ export default function RuntimesPage() {
|
||||||
>
|
>
|
||||||
<RuntimeList
|
<RuntimeList
|
||||||
runtimes={runtimes}
|
runtimes={runtimes}
|
||||||
selectedId={selectedId}
|
selectedId={effectiveSelectedId}
|
||||||
onSelect={setSelectedId}
|
onSelect={setSelectedId}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1 @@
|
||||||
export { RuntimesPage } from "./components";
|
export { RuntimesPage } from "./components";
|
||||||
export { useRuntimeStore } from "./store";
|
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { create } from "zustand";
|
|
||||||
import type { AgentRuntime } from "@/shared/types";
|
|
||||||
import { api } from "@/shared/api";
|
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
|
||||||
|
|
||||||
interface RuntimeState {
|
|
||||||
runtimes: AgentRuntime[];
|
|
||||||
selectedId: string;
|
|
||||||
fetching: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RuntimeActions {
|
|
||||||
fetchRuntimes: () => Promise<void>;
|
|
||||||
setSelectedId: (id: string) => void;
|
|
||||||
/** Patch a single runtime in-place (e.g. status/last_seen_at from WS event). */
|
|
||||||
patchRuntime: (id: string, updates: Partial<AgentRuntime>) => void;
|
|
||||||
/** Replace the full runtimes list (used on daemon:register events). */
|
|
||||||
setRuntimes: (runtimes: AgentRuntime[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type RuntimeStore = RuntimeState & RuntimeActions;
|
|
||||||
|
|
||||||
export const useRuntimeStore = create<RuntimeStore>((set, get) => ({
|
|
||||||
// State
|
|
||||||
runtimes: [],
|
|
||||||
selectedId: "",
|
|
||||||
fetching: true,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
fetchRuntimes: async () => {
|
|
||||||
const workspace = useWorkspaceStore.getState().workspace;
|
|
||||||
if (!workspace) return;
|
|
||||||
try {
|
|
||||||
const data = await api.listRuntimes({ workspace_id: workspace.id });
|
|
||||||
const { selectedId } = get();
|
|
||||||
set({
|
|
||||||
runtimes: data,
|
|
||||||
fetching: false,
|
|
||||||
// Auto-select first if nothing selected
|
|
||||||
selectedId: selectedId && data.some((r) => r.id === selectedId)
|
|
||||||
? selectedId
|
|
||||||
: data[0]?.id ?? "",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
set({ fetching: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setSelectedId: (id) => set({ selectedId: id }),
|
|
||||||
|
|
||||||
patchRuntime: (id, updates) => {
|
|
||||||
set((state) => ({
|
|
||||||
runtimes: state.runtimes.map((r) =>
|
|
||||||
r.id === id ? { ...r, ...updates } : r,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
setRuntimes: (runtimes) => {
|
|
||||||
const { selectedId } = get();
|
|
||||||
set({
|
|
||||||
runtimes,
|
|
||||||
selectedId: selectedId && runtimes.some((r) => r.id === selectedId)
|
|
||||||
? selectedId
|
|
||||||
: runtimes[0]?.id ?? "",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
@ -33,8 +33,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { api } from "@/shared/api";
|
import { api } from "@/shared/api";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAuthStore } from "@/features/auth";
|
import { useAuthStore } from "@/features/auth";
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import { skillListOptions, workspaceKeys } from "@core/workspace/queries";
|
||||||
|
|
||||||
import { FileTree } from "./file-tree";
|
import { FileTree } from "./file-tree";
|
||||||
import { FileViewer } from "./file-viewer";
|
import { FileViewer } from "./file-viewer";
|
||||||
|
|
@ -346,6 +348,8 @@ function SkillDetail({
|
||||||
onUpdate: (id: string, data: UpdateSkillRequest) => Promise<void>;
|
onUpdate: (id: string, data: UpdateSkillRequest) => Promise<void>;
|
||||||
onDelete: (id: string) => Promise<void>;
|
onDelete: (id: string) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const wsId = useWorkspaceId();
|
||||||
const [name, setName] = useState(skill.name);
|
const [name, setName] = useState(skill.name);
|
||||||
const [description, setDescription] = useState(skill.description);
|
const [description, setDescription] = useState(skill.description);
|
||||||
const [content, setContent] = useState(skill.content);
|
const [content, setContent] = useState(skill.content);
|
||||||
|
|
@ -370,12 +374,12 @@ function SkillDetail({
|
||||||
setSelectedPath(SKILL_MD);
|
setSelectedPath(SKILL_MD);
|
||||||
setLoadingFiles(true);
|
setLoadingFiles(true);
|
||||||
api.getSkill(skill.id).then((full) => {
|
api.getSkill(skill.id).then((full) => {
|
||||||
useWorkspaceStore.getState().upsertSkill(full);
|
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||||
setFiles((full.files ?? []).map((f) => ({ path: f.path, content: f.content })));
|
setFiles((full.files ?? []).map((f) => ({ path: f.path, content: f.content })));
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
toast.error(e instanceof Error ? e.message : "Failed to load skill files");
|
toast.error(e instanceof Error ? e.message : "Failed to load skill files");
|
||||||
}).finally(() => setLoadingFiles(false));
|
}).finally(() => setLoadingFiles(false));
|
||||||
}, [skill.id]);
|
}, [skill.id, qc, wsId]);
|
||||||
|
|
||||||
// Build the virtual file map
|
// Build the virtual file map
|
||||||
const fileMap = useMemo(() => buildFileMap(content, files), [content, files]);
|
const fileMap = useMemo(() => buildFileMap(content, files), [content, files]);
|
||||||
|
|
@ -610,10 +614,9 @@ function SkillDetail({
|
||||||
|
|
||||||
export default function SkillsPage() {
|
export default function SkillsPage() {
|
||||||
const isLoading = useAuthStore((s) => s.isLoading);
|
const isLoading = useAuthStore((s) => s.isLoading);
|
||||||
const skills = useWorkspaceStore((s) => s.skills);
|
const qc = useQueryClient();
|
||||||
const refreshSkills = useWorkspaceStore((s) => s.refreshSkills);
|
const wsId = useWorkspaceId();
|
||||||
const upsertSkill = useWorkspaceStore((s) => s.upsertSkill);
|
const { data: skills = [] } = useQuery(skillListOptions(wsId));
|
||||||
const removeSkill = useWorkspaceStore((s) => s.removeSkill);
|
|
||||||
const [selectedId, setSelectedId] = useState<string>("");
|
const [selectedId, setSelectedId] = useState<string>("");
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||||
|
|
@ -628,22 +631,22 @@ export default function SkillsPage() {
|
||||||
|
|
||||||
const handleCreate = async (data: CreateSkillRequest) => {
|
const handleCreate = async (data: CreateSkillRequest) => {
|
||||||
const skill = await api.createSkill(data);
|
const skill = await api.createSkill(data);
|
||||||
upsertSkill(skill);
|
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||||
setSelectedId(skill.id);
|
setSelectedId(skill.id);
|
||||||
toast.success("Skill created");
|
toast.success("Skill created");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = async (url: string) => {
|
const handleImport = async (url: string) => {
|
||||||
const skill = await api.importSkill({ url });
|
const skill = await api.importSkill({ url });
|
||||||
upsertSkill(skill);
|
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||||
setSelectedId(skill.id);
|
setSelectedId(skill.id);
|
||||||
toast.success("Skill imported");
|
toast.success("Skill imported");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async (id: string, data: UpdateSkillRequest) => {
|
const handleUpdate = async (id: string, data: UpdateSkillRequest) => {
|
||||||
try {
|
try {
|
||||||
const updated = await api.updateSkill(id, data);
|
await api.updateSkill(id, data);
|
||||||
upsertSkill(updated);
|
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||||
toast.success("Skill saved");
|
toast.success("Skill saved");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Failed to save skill");
|
toast.error(e instanceof Error ? e.message : "Failed to save skill");
|
||||||
|
|
@ -658,7 +661,7 @@ export default function SkillsPage() {
|
||||||
const remaining = skills.filter((s) => s.id !== id);
|
const remaining = skills.filter((s) => s.id !== id);
|
||||||
setSelectedId(remaining[0]?.id ?? "");
|
setSelectedId(remaining[0]?.id ?? "");
|
||||||
}
|
}
|
||||||
removeSkill(id);
|
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||||
toast.success("Skill deleted");
|
toast.success("Skill deleted");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Failed to delete skill");
|
toast.error(e instanceof Error ? e.message : "Failed to delete skill");
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useWorkspaceStore } from "./store";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useWorkspaceId } from "@core/hooks";
|
||||||
|
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
|
||||||
|
|
||||||
export function useActorName() {
|
export function useActorName() {
|
||||||
const members = useWorkspaceStore((s) => s.members);
|
const wsId = useWorkspaceId();
|
||||||
const agents = useWorkspaceStore((s) => s.agents);
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||||
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||||
|
|
||||||
const getMemberName = (userId: string) => {
|
const getMemberName = (userId: string) => {
|
||||||
const m = members.find((m) => m.user_id === userId);
|
const m = members.find((m) => m.user_id === userId);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types";
|
import type { Workspace } from "@/shared/types";
|
||||||
import { useIssueStore } from "@/features/issues";
|
|
||||||
import { useInboxStore } from "@/features/inbox";
|
|
||||||
import { useRuntimeStore } from "@/features/runtimes";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { api } from "@/shared/api";
|
import { api } from "@/shared/api";
|
||||||
import { createLogger } from "@/shared/logger";
|
import { createLogger } from "@/shared/logger";
|
||||||
|
|
@ -14,30 +11,21 @@ const logger = createLogger("workspace-store");
|
||||||
interface WorkspaceState {
|
interface WorkspaceState {
|
||||||
workspace: Workspace | null;
|
workspace: Workspace | null;
|
||||||
workspaces: Workspace[];
|
workspaces: Workspace[];
|
||||||
members: MemberWithUser[];
|
|
||||||
agents: Agent[];
|
|
||||||
skills: Skill[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspaceActions {
|
interface WorkspaceActions {
|
||||||
hydrateWorkspace: (
|
hydrateWorkspace: (
|
||||||
wsList: Workspace[],
|
wsList: Workspace[],
|
||||||
preferredWorkspaceId?: string | null,
|
preferredWorkspaceId?: string | null,
|
||||||
) => Promise<Workspace | null>;
|
) => Workspace | null;
|
||||||
switchWorkspace: (workspaceId: string) => Promise<void>;
|
switchWorkspace: (workspaceId: string) => void;
|
||||||
refreshWorkspaces: () => Promise<Workspace[]>;
|
refreshWorkspaces: () => Promise<Workspace[]>;
|
||||||
refreshMembers: () => Promise<void>;
|
updateWorkspace: (ws: Workspace) => void;
|
||||||
updateAgent: (id: string, updates: Partial<Agent>) => void;
|
|
||||||
refreshAgents: () => Promise<void>;
|
|
||||||
refreshSkills: () => Promise<void>;
|
|
||||||
upsertSkill: (skill: Skill) => void;
|
|
||||||
removeSkill: (id: string) => void;
|
|
||||||
createWorkspace: (data: {
|
createWorkspace: (data: {
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}) => Promise<Workspace>;
|
}) => Promise<Workspace>;
|
||||||
updateWorkspace: (ws: Workspace) => void;
|
|
||||||
leaveWorkspace: (workspaceId: string) => Promise<void>;
|
leaveWorkspace: (workspaceId: string) => Promise<void>;
|
||||||
deleteWorkspace: (workspaceId: string) => Promise<void>;
|
deleteWorkspace: (workspaceId: string) => Promise<void>;
|
||||||
clearWorkspace: () => void;
|
clearWorkspace: () => void;
|
||||||
|
|
@ -49,12 +37,9 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||||
// State
|
// State
|
||||||
workspace: null,
|
workspace: null,
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
members: [],
|
|
||||||
agents: [],
|
|
||||||
skills: [],
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
hydrateWorkspace: async (wsList, preferredWorkspaceId) => {
|
hydrateWorkspace: (wsList, preferredWorkspaceId) => {
|
||||||
set({ workspaces: wsList });
|
set({ workspaces: wsList });
|
||||||
|
|
||||||
const nextWorkspace =
|
const nextWorkspace =
|
||||||
|
|
@ -67,56 +52,35 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||||
if (!nextWorkspace) {
|
if (!nextWorkspace) {
|
||||||
api.setWorkspaceId(null);
|
api.setWorkspaceId(null);
|
||||||
localStorage.removeItem("multica_workspace_id");
|
localStorage.removeItem("multica_workspace_id");
|
||||||
set({ workspace: null, members: [], agents: [], skills: [] });
|
set({ workspace: null });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
api.setWorkspaceId(nextWorkspace.id);
|
api.setWorkspaceId(nextWorkspace.id);
|
||||||
localStorage.setItem("multica_workspace_id", nextWorkspace.id);
|
localStorage.setItem("multica_workspace_id", nextWorkspace.id);
|
||||||
set({ workspace: nextWorkspace });
|
set({ workspace: nextWorkspace });
|
||||||
|
|
||||||
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
|
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
|
||||||
const [nextMembers, nextAgents, nextSkills] = await Promise.all([
|
|
||||||
api.listMembers(nextWorkspace.id).catch((e) => {
|
// Members, agents, skills, issues, inbox are all managed by TanStack Query.
|
||||||
logger.error("failed to load members", e);
|
// They auto-fetch when components mount with the workspace ID in their query key.
|
||||||
toast.error("Failed to load members");
|
|
||||||
return [] as MemberWithUser[];
|
|
||||||
}),
|
|
||||||
api.listAgents({ workspace_id: nextWorkspace.id, include_archived: true }).catch((e) => {
|
|
||||||
logger.error("failed to load agents", e);
|
|
||||||
toast.error("Failed to load agents");
|
|
||||||
return [] as Agent[];
|
|
||||||
}),
|
|
||||||
api.listSkills().catch(() => [] as Skill[]),
|
|
||||||
useIssueStore.getState().fetch().catch(() => {}),
|
|
||||||
useInboxStore.getState().fetch().catch(() => {}),
|
|
||||||
]);
|
|
||||||
logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length);
|
|
||||||
set({ members: nextMembers, agents: nextAgents, skills: nextSkills });
|
|
||||||
|
|
||||||
return nextWorkspace;
|
return nextWorkspace;
|
||||||
},
|
},
|
||||||
|
|
||||||
switchWorkspace: async (workspaceId) => {
|
switchWorkspace: (workspaceId) => {
|
||||||
logger.info("switching to", workspaceId);
|
logger.info("switching to", workspaceId);
|
||||||
const { workspaces, hydrateWorkspace } = get();
|
const { workspaces, hydrateWorkspace } = get();
|
||||||
const ws = workspaces.find((item) => item.id === workspaceId);
|
const ws = workspaces.find((item) => item.id === workspaceId);
|
||||||
if (!ws) return;
|
if (!ws) return;
|
||||||
|
|
||||||
// Switch identity FIRST — api client, localStorage, and the
|
|
||||||
// workspace object in this store — so that any in-flight refetch
|
|
||||||
// (e.g. triggered by a WS event during the async gap) already
|
|
||||||
// targets the new workspace.
|
|
||||||
api.setWorkspaceId(ws.id);
|
api.setWorkspaceId(ws.id);
|
||||||
localStorage.setItem("multica_workspace_id", ws.id);
|
localStorage.setItem("multica_workspace_id", ws.id);
|
||||||
|
|
||||||
// Clear ALL stale data across every store before hydrating.
|
// All data caches (issues, inbox, members, agents, skills, runtimes)
|
||||||
useIssueStore.getState().setIssues([]);
|
// are managed by TanStack Query, keyed by wsId — auto-refetch on switch.
|
||||||
useInboxStore.getState().setItems([]);
|
set({ workspace: ws });
|
||||||
useRuntimeStore.getState().setRuntimes([]);
|
|
||||||
set({ workspace: ws, members: [], agents: [], skills: [] });
|
|
||||||
|
|
||||||
await hydrateWorkspace(workspaces, ws.id);
|
hydrateWorkspace(workspaces, ws.id);
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshWorkspaces: async () => {
|
refreshWorkspaces: async () => {
|
||||||
|
|
@ -124,7 +88,7 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||||
const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
|
const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
|
||||||
try {
|
try {
|
||||||
const wsList = await api.listWorkspaces();
|
const wsList = await api.listWorkspaces();
|
||||||
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
|
hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
|
||||||
return wsList;
|
return wsList;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("failed to refresh workspaces", e);
|
logger.error("failed to refresh workspaces", e);
|
||||||
|
|
@ -133,77 +97,6 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshMembers: async () => {
|
|
||||||
const { workspace } = get();
|
|
||||||
if (!workspace) return;
|
|
||||||
try {
|
|
||||||
const members = await api.listMembers(workspace.id);
|
|
||||||
set({ members });
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("failed to refresh members", e);
|
|
||||||
toast.error("Failed to refresh members");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
updateAgent: (id, updates) =>
|
|
||||||
set((s) => ({
|
|
||||||
agents: s.agents.map((a) => (a.id === id ? { ...a, ...updates } : a)),
|
|
||||||
})),
|
|
||||||
|
|
||||||
refreshAgents: async () => {
|
|
||||||
const { workspace } = get();
|
|
||||||
if (!workspace) return;
|
|
||||||
try {
|
|
||||||
const agents = await api.listAgents({ workspace_id: workspace.id, include_archived: true });
|
|
||||||
set({ agents });
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("failed to refresh agents", e);
|
|
||||||
toast.error("Failed to refresh agents");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
refreshSkills: async () => {
|
|
||||||
const { workspace, skills: existing } = get();
|
|
||||||
if (!workspace) return;
|
|
||||||
try {
|
|
||||||
const fetched = await api.listSkills();
|
|
||||||
// listSkills doesn't include files — preserve files from existing entries
|
|
||||||
const filesById = new Map(
|
|
||||||
existing.filter((s) => s.files?.length).map((s) => [s.id, s.files]),
|
|
||||||
);
|
|
||||||
const merged = fetched.map((s) => ({
|
|
||||||
...s,
|
|
||||||
files: s.files ?? filesById.get(s.id) ?? [],
|
|
||||||
}));
|
|
||||||
set({ skills: merged });
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("failed to refresh skills", e);
|
|
||||||
toast.error("Failed to refresh skills");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
upsertSkill: (skill) => {
|
|
||||||
set((state) => {
|
|
||||||
const idx = state.skills.findIndex((s) => s.id === skill.id);
|
|
||||||
if (idx >= 0) {
|
|
||||||
const next = [...state.skills];
|
|
||||||
next[idx] = skill;
|
|
||||||
return { skills: next };
|
|
||||||
}
|
|
||||||
return { skills: [...state.skills, skill] };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
removeSkill: (id) => {
|
|
||||||
set((state) => ({ skills: state.skills.filter((s) => s.id !== id) }));
|
|
||||||
},
|
|
||||||
|
|
||||||
createWorkspace: async (data) => {
|
|
||||||
const ws = await api.createWorkspace(data);
|
|
||||||
set((state) => ({ workspaces: [...state.workspaces, ws] }));
|
|
||||||
return ws;
|
|
||||||
},
|
|
||||||
|
|
||||||
updateWorkspace: (ws) => {
|
updateWorkspace: (ws) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
workspace: state.workspace?.id === ws.id ? ws : state.workspace,
|
workspace: state.workspace?.id === ws.id ? ws : state.workspace,
|
||||||
|
|
@ -213,13 +106,19 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createWorkspace: async (data) => {
|
||||||
|
const ws = await api.createWorkspace(data);
|
||||||
|
set((state) => ({ workspaces: [...state.workspaces, ws] }));
|
||||||
|
return ws;
|
||||||
|
},
|
||||||
|
|
||||||
leaveWorkspace: async (workspaceId) => {
|
leaveWorkspace: async (workspaceId) => {
|
||||||
await api.leaveWorkspace(workspaceId);
|
await api.leaveWorkspace(workspaceId);
|
||||||
const { workspace, hydrateWorkspace } = get();
|
const { workspace, hydrateWorkspace } = get();
|
||||||
const wsList = await api.listWorkspaces();
|
const wsList = await api.listWorkspaces();
|
||||||
const preferredWorkspaceId =
|
const preferredWorkspaceId =
|
||||||
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
|
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
|
||||||
await hydrateWorkspace(wsList, preferredWorkspaceId);
|
hydrateWorkspace(wsList, preferredWorkspaceId);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteWorkspace: async (workspaceId) => {
|
deleteWorkspace: async (workspaceId) => {
|
||||||
|
|
@ -228,11 +127,11 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||||
const wsList = await api.listWorkspaces();
|
const wsList = await api.listWorkspaces();
|
||||||
const preferredWorkspaceId =
|
const preferredWorkspaceId =
|
||||||
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
|
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
|
||||||
await hydrateWorkspace(wsList, preferredWorkspaceId);
|
hydrateWorkspace(wsList, preferredWorkspaceId);
|
||||||
},
|
},
|
||||||
|
|
||||||
clearWorkspace: () => {
|
clearWorkspace: () => {
|
||||||
api.setWorkspaceId(null);
|
api.setWorkspaceId(null);
|
||||||
set({ workspace: null, workspaces: [], members: [], agents: [], skills: [] });
|
set({ workspace: null, workspaces: [] });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,12 @@
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@floating-ui/dom": "^1.7.6",
|
"@floating-ui/dom": "^1.7.6",
|
||||||
|
"@tanstack/react-query": "^5.96.2",
|
||||||
|
"@tanstack/react-query-devtools": "^5.96.2",
|
||||||
"@tiptap/extension-code-block-lowlight": "^3.22.1",
|
"@tiptap/extension-code-block-lowlight": "^3.22.1",
|
||||||
"@tiptap/extension-image": "^3.22.1",
|
"@tiptap/extension-image": "^3.22.1",
|
||||||
"@tiptap/extension-link": "^3.22.1",
|
"@tiptap/extension-link": "^3.22.1",
|
||||||
"@tiptap/extension-mention": "^3.22.1",
|
"@tiptap/extension-mention": "^3.22.1",
|
||||||
"@tiptap/suggestion": "^3.22.1",
|
|
||||||
"@tiptap/extension-placeholder": "^3.22.1",
|
"@tiptap/extension-placeholder": "^3.22.1",
|
||||||
"@tiptap/extension-table": "^3.22.1",
|
"@tiptap/extension-table": "^3.22.1",
|
||||||
"@tiptap/extension-table-cell": "^3.22.1",
|
"@tiptap/extension-table-cell": "^3.22.1",
|
||||||
|
|
@ -33,6 +34,7 @@
|
||||||
"@tiptap/pm": "^3.22.1",
|
"@tiptap/pm": "^3.22.1",
|
||||||
"@tiptap/react": "^3.22.1",
|
"@tiptap/react": "^3.22.1",
|
||||||
"@tiptap/starter-kit": "^3.22.1",
|
"@tiptap/starter-kit": "^3.22.1",
|
||||||
|
"@tiptap/suggestion": "^3.22.1",
|
||||||
"@types/linkify-it": "^5.0.0",
|
"@types/linkify-it": "^5.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,6 @@ export const mockAuthValue: Record<string, any> = {
|
||||||
leaveWorkspace: vi.fn(),
|
leaveWorkspace: vi.fn(),
|
||||||
deleteWorkspace: vi.fn(),
|
deleteWorkspace: vi.fn(),
|
||||||
refreshWorkspaces: vi.fn(),
|
refreshWorkspaces: vi.fn(),
|
||||||
refreshMembers: vi.fn(),
|
|
||||||
refreshAgents: vi.fn(),
|
|
||||||
getMemberName: (userId: string) => {
|
getMemberName: (userId: string) => {
|
||||||
const m = mockMembers.find((m) => m.user_id === userId);
|
const m = mockMembers.find((m) => m.user_id === userId);
|
||||||
return m?.name ?? "Unknown";
|
return m?.name ?? "Unknown";
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./*"
|
"./*"
|
||||||
|
],
|
||||||
|
"@core/*": [
|
||||||
|
"./core/*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "."),
|
"@": path.resolve(__dirname, "."),
|
||||||
|
"@core": path.resolve(__dirname, "core"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
1772
docs/plans/2026-04-07-tanstack-query-migration.md
Normal file
1772
docs/plans/2026-04-07-tanstack-query-migration.md
Normal file
File diff suppressed because it is too large
Load diff
511
docs/plans/2026-04-08-board-dnd-rewrite.md
Normal file
511
docs/plans/2026-04-08-board-dnd-rewrite.md
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
# Board DnD Rewrite — dnd-kit Multi-Container Sortable
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Rewrite the Kanban board drag-and-drop to use dnd-kit's multi-container sortable pattern correctly — onDragOver for live cross-column movement, local state during drag, insertion indicators, and smooth animations.
|
||||||
|
|
||||||
|
**Architecture:** Replace the current "TQ-cache-driven + pendingMove patch" with a "local-state-driven during drag, TQ sync on drop" model. During drag, a local `columns` state (Record<IssueStatus, string[]>) controls which IDs each SortableContext sees. onDragOver moves IDs between columns in real-time. onDragEnd computes final position and fires the mutation. Between drags, local state follows TQ data via useEffect.
|
||||||
|
|
||||||
|
**Tech Stack:** @dnd-kit/core ^6.3.1, @dnd-kit/sortable ^10.0.0, @dnd-kit/utilities ^3.2.2, TanStack Query, React useState
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State (files to modify)
|
||||||
|
|
||||||
|
| File | Current Role | Change |
|
||||||
|
|------|-------------|--------|
|
||||||
|
| `features/issues/components/board-view.tsx` | DndContext + onDragEnd only + pendingMove | **Rewrite**: local columns state, onDragOver, onDragEnd, improved DragOverlay |
|
||||||
|
| `features/issues/components/board-column.tsx` | Receives Issue[], sorts internally, useDroppable | **Rewrite**: receives sorted Issue[] from parent, no internal sorting, insertion indicator |
|
||||||
|
| `features/issues/components/board-card.tsx` | useSortable with defaults | **Modify**: custom animateLayoutChanges |
|
||||||
|
| `features/issues/components/issues-page.tsx` | handleMoveIssue callback | **Minor**: adjust callback signature |
|
||||||
|
|
||||||
|
Files NOT changed: `mutations.ts`, `ws-updaters.ts`, `use-realtime-sync.ts`, `view-store.ts`, `sort.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Rewrite board-view.tsx — Local State + onDragOver + onDragEnd
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rewrite: `apps/web/features/issues/components/board-view.tsx`
|
||||||
|
|
||||||
|
This is the core task. The entire DnD orchestration logic changes.
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Local state: maps status → ordered array of issue IDs
|
||||||
|
// This is the ONLY source of truth for card positions during drag
|
||||||
|
type Columns = Record<IssueStatus, string[]>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 1: Replace pendingMove with local columns state
|
||||||
|
|
||||||
|
Remove `pendingMove` + `displayIssues` + the clearing useEffect. Replace with:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Build columns from TQ issues + view sort settings
|
||||||
|
function buildColumns(
|
||||||
|
issues: Issue[],
|
||||||
|
visibleStatuses: IssueStatus[],
|
||||||
|
sortBy: SortField,
|
||||||
|
sortDirection: SortDirection,
|
||||||
|
): Columns {
|
||||||
|
const cols: Columns = {} as Columns;
|
||||||
|
for (const status of visibleStatuses) {
|
||||||
|
const sorted = sortIssues(
|
||||||
|
issues.filter((i) => i.status === status),
|
||||||
|
sortBy,
|
||||||
|
sortDirection,
|
||||||
|
);
|
||||||
|
cols[status] = sorted.map((i) => i.id);
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In the component:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const sortBy = useViewStore((s) => s.sortBy);
|
||||||
|
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||||
|
|
||||||
|
// Local columns state — follows TQ between drags, local during drag
|
||||||
|
const [columns, setColumns] = useState<Columns>(() =>
|
||||||
|
buildColumns(issues, visibleStatuses, sortBy, sortDirection)
|
||||||
|
);
|
||||||
|
const isDragging = useRef(false);
|
||||||
|
|
||||||
|
// Sync from TQ when NOT dragging
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDragging.current) {
|
||||||
|
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||||
|
}
|
||||||
|
}, [issues, visibleStatuses, sortBy, sortDirection]);
|
||||||
|
```
|
||||||
|
|
||||||
|
`issueMap` for O(1) lookup (needed by BoardColumn to get Issue objects from IDs):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const issueMap = useMemo(() => {
|
||||||
|
const map = new Map<string, Issue>();
|
||||||
|
for (const issue of issues) map.set(issue.id, issue);
|
||||||
|
return map;
|
||||||
|
}, [issues]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Implement findColumn helper
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** Find which column (status) contains a given ID (issue or column). */
|
||||||
|
function findColumn(columns: Columns, id: string, visibleStatuses: IssueStatus[]): IssueStatus | null {
|
||||||
|
// Is it a column ID itself?
|
||||||
|
if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus;
|
||||||
|
// Search columns for the item
|
||||||
|
for (const [status, ids] of Object.entries(columns)) {
|
||||||
|
if (ids.includes(id)) return status as IssueStatus;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Implement onDragStart
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
|
isDragging.current = true;
|
||||||
|
const issue = issueMap.get(event.active.id as string) ?? null;
|
||||||
|
setActiveIssue(issue);
|
||||||
|
}, [issueMap]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Implement onDragOver — the key missing piece
|
||||||
|
|
||||||
|
This fires continuously during drag. When the pointer crosses into a different column or hovers over a different card, we move the dragged ID in local state. This makes SortableContext aware of the new item → cards shift to make room.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const activeId = active.id as string;
|
||||||
|
const overId = over.id as string;
|
||||||
|
|
||||||
|
const activeCol = findColumn(columns, activeId, visibleStatuses);
|
||||||
|
const overCol = findColumn(columns, overId, visibleStatuses);
|
||||||
|
if (!activeCol || !overCol || activeCol === overCol) return;
|
||||||
|
|
||||||
|
// Cross-column move: remove from old column, insert into new column
|
||||||
|
setColumns((prev) => {
|
||||||
|
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
|
||||||
|
const newIds = [...prev[overCol]!];
|
||||||
|
|
||||||
|
// Insert position: if over a card, insert at that index; if over column, append
|
||||||
|
const overIndex = newIds.indexOf(overId);
|
||||||
|
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
|
||||||
|
newIds.splice(insertIndex, 0, activeId);
|
||||||
|
|
||||||
|
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
|
||||||
|
});
|
||||||
|
}, [columns, visibleStatuses]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Implement onDragEnd — persist to server
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
isDragging.current = false;
|
||||||
|
setActiveIssue(null);
|
||||||
|
|
||||||
|
if (!over) {
|
||||||
|
// Cancelled — reset to TQ state
|
||||||
|
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeId = active.id as string;
|
||||||
|
const overId = over.id as string;
|
||||||
|
|
||||||
|
const activeCol = findColumn(columns, activeId, visibleStatuses);
|
||||||
|
const overCol = findColumn(columns, overId, visibleStatuses);
|
||||||
|
if (!activeCol || !overCol) return;
|
||||||
|
|
||||||
|
// Same column reorder
|
||||||
|
if (activeCol === overCol) {
|
||||||
|
const ids = columns[activeCol]!;
|
||||||
|
const oldIndex = ids.indexOf(activeId);
|
||||||
|
const newIndex = ids.indexOf(overId);
|
||||||
|
if (oldIndex !== newIndex) {
|
||||||
|
const reordered = arrayMove(ids, oldIndex, newIndex);
|
||||||
|
setColumns((prev) => ({ ...prev, [activeCol]: reordered }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute final position from the local column order
|
||||||
|
const finalCol = findColumn(columns, activeId, visibleStatuses);
|
||||||
|
if (!finalCol) return;
|
||||||
|
|
||||||
|
// After potential same-col reorder, re-read columns
|
||||||
|
// (for same-col we just did setColumns above, but it's async;
|
||||||
|
// however we can compute from the intended final order)
|
||||||
|
let finalIds: string[];
|
||||||
|
if (activeCol === overCol) {
|
||||||
|
const ids = columns[activeCol]!;
|
||||||
|
const oldIndex = ids.indexOf(activeId);
|
||||||
|
const newIndex = ids.indexOf(overId);
|
||||||
|
finalIds = oldIndex !== newIndex ? arrayMove(ids, oldIndex, newIndex) : ids;
|
||||||
|
} else {
|
||||||
|
finalIds = columns[finalCol]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPosition = computePosition(finalIds, activeId, issues);
|
||||||
|
const currentIssue = issueMap.get(activeId);
|
||||||
|
|
||||||
|
// Skip if nothing changed
|
||||||
|
if (currentIssue && currentIssue.status === finalCol && currentIssue.position === newPosition) return;
|
||||||
|
|
||||||
|
onMoveIssue(activeId, finalCol, newPosition);
|
||||||
|
}, [columns, issues, visibleStatuses, sortBy, sortDirection, issueMap, onMoveIssue]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Update computePosition to work with ID arrays
|
||||||
|
|
||||||
|
The current `computePosition` takes `Issue[]` and a target index. Rewrite to take `string[]` (IDs) + the active ID + the issue map:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** Compute a float position for `activeId` based on its neighbors in `ids`. */
|
||||||
|
function computePosition(ids: string[], activeId: string, allIssues: Issue[]): number {
|
||||||
|
const idx = ids.indexOf(activeId);
|
||||||
|
if (idx === -1) return 0;
|
||||||
|
|
||||||
|
const getPos = (id: string) => allIssues.find((i) => i.id === id)?.position ?? 0;
|
||||||
|
|
||||||
|
if (ids.length === 1) return 0;
|
||||||
|
if (idx === 0) return getPos(ids[1]!) - 1;
|
||||||
|
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
|
||||||
|
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 7: Update DragOverlay styling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{activeIssue ? (
|
||||||
|
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
|
||||||
|
<BoardCardContent issue={activeIssue} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
```
|
||||||
|
|
||||||
|
Key change: `dropAnimation={null}` prevents the overlay from animating back to origin on drop — the card is already in the right position via local state.
|
||||||
|
|
||||||
|
### Step 8: Wire it all together
|
||||||
|
|
||||||
|
Pass `columns` + `issueMap` to `BoardColumn` instead of `issues`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{visibleStatuses.map((status) => (
|
||||||
|
<BoardColumn
|
||||||
|
key={status}
|
||||||
|
status={status}
|
||||||
|
issueIds={columns[status] ?? []}
|
||||||
|
issueMap={issueMap}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 9: Run typecheck
|
||||||
|
|
||||||
|
Run: `pnpm typecheck`
|
||||||
|
Expected: May have errors in board-column.tsx (prop changes) — that's Task 2.
|
||||||
|
|
||||||
|
### Step 10: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/web/features/issues/components/board-view.tsx
|
||||||
|
git commit -m "refactor(board): rewrite DnD with local state + onDragOver for live cross-column sorting"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Rewrite board-column.tsx — Receive IDs + issueMap, Add Insertion Indicator
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rewrite: `apps/web/features/issues/components/board-column.tsx`
|
||||||
|
|
||||||
|
### Step 1: Change props from `issues: Issue[]` to `issueIds: string[]` + `issueMap: Map<string, Issue>`
|
||||||
|
|
||||||
|
The column no longer does its own sorting — the parent provides IDs in the correct order. The column just resolves IDs to Issue objects and renders them.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function BoardColumn({
|
||||||
|
status,
|
||||||
|
issueIds,
|
||||||
|
issueMap,
|
||||||
|
}: {
|
||||||
|
status: IssueStatus;
|
||||||
|
issueIds: string[];
|
||||||
|
issueMap: Map<string, Issue>;
|
||||||
|
}) {
|
||||||
|
const cfg = STATUS_CONFIG[status];
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||||
|
const viewStoreApi = useViewStoreApi();
|
||||||
|
|
||||||
|
// Resolve IDs to Issue objects (IDs are already sorted by parent)
|
||||||
|
const resolvedIssues = useMemo(
|
||||||
|
() => issueIds.flatMap((id) => {
|
||||||
|
const issue = issueMap.get(id);
|
||||||
|
return issue ? [issue] : [];
|
||||||
|
}),
|
||||||
|
[issueIds, issueMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex w-[280px] shrink-0 flex-col rounded-xl ${cfg.columnBg} p-2`}>
|
||||||
|
<div className="mb-2 flex items-center justify-between px-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-semibold ${cfg.badgeBg} ${cfg.badgeText}`}>
|
||||||
|
<StatusIcon status={status} className="h-3 w-3" inheritColor />
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{issueIds.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Right: add + menu — keep as-is */}
|
||||||
|
<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={() => viewStoreApi.getState().hideStatus(status)}>
|
||||||
|
<EyeOff className="size-3.5" />
|
||||||
|
Hide column
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>Add issue</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`min-h-[200px] flex-1 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
|
||||||
|
isOver ? "bg-accent/60" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
||||||
|
{resolvedIssues.map((issue) => (
|
||||||
|
<DraggableBoardCard key={issue.id} issue={issue} />
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
{issueIds.length === 0 && (
|
||||||
|
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||||
|
No issues
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key changes:
|
||||||
|
- No more `useViewStore` for sort — parent handles sorting
|
||||||
|
- No more internal `sortIssues` call
|
||||||
|
- Uses `issueIds` for SortableContext (already in correct order)
|
||||||
|
- Count shows `issueIds.length` instead of `issues.length`
|
||||||
|
|
||||||
|
### Step 2: Run typecheck
|
||||||
|
|
||||||
|
Run: `pnpm typecheck`
|
||||||
|
Expected: PASS (or errors in issues-page.tsx — Task 4)
|
||||||
|
|
||||||
|
### Step 3: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/web/features/issues/components/board-column.tsx
|
||||||
|
git commit -m "refactor(board): BoardColumn receives sorted IDs from parent, no internal sorting"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Modify board-card.tsx — Custom animateLayoutChanges
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/web/features/issues/components/board-card.tsx`
|
||||||
|
|
||||||
|
### Step 1: Add custom animateLayoutChanges
|
||||||
|
|
||||||
|
When a card is dragged across containers, dnd-kit triggers a layout animation on the "entering" card. The default `defaultAnimateLayoutChanges` animates this, causing a jarring jump. We disable animation for the frame when `wasDragging` is true (the card just landed in a new container).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||||
|
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||||
|
|
||||||
|
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
|
||||||
|
const { isSorting, wasDragging } = args;
|
||||||
|
if (isSorting || wasDragging) return false;
|
||||||
|
return defaultAnimateLayoutChanges(args);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Update useSortable call:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: issue.id,
|
||||||
|
data: { status: issue.status },
|
||||||
|
animateLayoutChanges,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Run typecheck
|
||||||
|
|
||||||
|
Run: `pnpm typecheck`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Step 3: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/web/features/issues/components/board-card.tsx
|
||||||
|
git commit -m "refactor(board): custom animateLayoutChanges to prevent jarring cross-column animation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Adjust issues-page.tsx — Minor Callback Cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/web/features/issues/components/issues-page.tsx`
|
||||||
|
|
||||||
|
### Step 1: Update handleMoveIssue
|
||||||
|
|
||||||
|
The callback shape stays the same (`issueId, newStatus, newPosition`), but the auto-switch-to-manual-sort logic should move into board-view or stay here. Keep it here for now since it's a view-level concern.
|
||||||
|
|
||||||
|
No functional change needed — the `onMoveIssue` prop signature is unchanged. Just verify that `BoardView`'s new props are correct:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<BoardView
|
||||||
|
issues={issues}
|
||||||
|
allIssues={scopedIssues}
|
||||||
|
visibleStatuses={visibleStatuses}
|
||||||
|
hiddenStatuses={hiddenStatuses}
|
||||||
|
onMoveIssue={handleMoveIssue}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
`BoardView` still receives `issues` (filtered+scoped from TQ) and `onMoveIssue`. The internal state management changes are encapsulated.
|
||||||
|
|
||||||
|
### Step 2: Run full typecheck + test
|
||||||
|
|
||||||
|
Run: `pnpm typecheck && pnpm test`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Step 3: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/web/features/issues/components/issues-page.tsx
|
||||||
|
git commit -m "refactor(board): verify issues-page props match new BoardView interface"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Manual QA Checklist
|
||||||
|
|
||||||
|
After all code changes, verify these scenarios in the browser:
|
||||||
|
|
||||||
|
1. **Same-column reorder**: Drag a card up/down within one column → cards shift to make room during drag → drop → position persists after refresh
|
||||||
|
2. **Cross-column move**: Drag card from Todo to In Progress → card appears in target column DURING drag → target column cards shift → drop → status + position persist
|
||||||
|
3. **Drop on empty column**: Drag card to an empty column → card lands there
|
||||||
|
4. **Cancel drag**: Start dragging, press Escape → card returns to original position, no mutation fired
|
||||||
|
5. **Rapid sequential drags**: Drag card A, drop, immediately drag card B → no flicker or stale state
|
||||||
|
6. **WebSocket update during drag**: Have another user change an issue → board updates correctly after drag ends (not during)
|
||||||
|
7. **Sort mode switch**: Drag should auto-switch to "Manual" sort → verify after drag, sort dropdown shows "Manual"
|
||||||
|
8. **DragOverlay**: Dragged card should have visible shadow, slight rotation, slight scale up
|
||||||
|
9. **Hidden columns panel**: Still shows correct counts, "Show column" still works
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Architecture Change
|
||||||
|
|
||||||
|
```
|
||||||
|
BEFORE (broken):
|
||||||
|
TQ cache → issues prop → displayIssues (with pendingMove patch) → BoardColumn sorts internally
|
||||||
|
onDragEnd → pendingMove + mutate → TQ updates → useEffect clears pendingMove
|
||||||
|
Problem: dual optimistic update, fire-and-forget cancelQueries race, no onDragOver
|
||||||
|
|
||||||
|
AFTER (correct):
|
||||||
|
TQ cache → issues prop → buildColumns() → local columns state (when not dragging)
|
||||||
|
onDragStart → isDragging=true, freeze local state
|
||||||
|
onDragOver → move IDs between columns in local state → SortableContext sees new items → cards shift
|
||||||
|
onDragEnd → compute position from local order → mutate → isDragging=false → TQ catches up → local follows
|
||||||
|
Problem: none — single source of truth during drag (local), single source of truth between drags (TQ)
|
||||||
|
```
|
||||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
|
|
@ -75,6 +75,12 @@ importers:
|
||||||
'@floating-ui/dom':
|
'@floating-ui/dom':
|
||||||
specifier: ^1.7.6
|
specifier: ^1.7.6
|
||||||
version: 1.7.6
|
version: 1.7.6
|
||||||
|
'@tanstack/react-query':
|
||||||
|
specifier: ^5.96.2
|
||||||
|
version: 5.96.2(react@19.2.3)
|
||||||
|
'@tanstack/react-query-devtools':
|
||||||
|
specifier: ^5.96.2
|
||||||
|
version: 5.96.2(@tanstack/react-query@5.96.2(react@19.2.3))(react@19.2.3)
|
||||||
'@tiptap/extension-code-block-lowlight':
|
'@tiptap/extension-code-block-lowlight':
|
||||||
specifier: ^3.22.1
|
specifier: ^3.22.1
|
||||||
version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/extension-code-block@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)(highlight.js@11.11.1)(lowlight@3.3.0)
|
version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/extension-code-block@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||||
|
|
@ -1288,6 +1294,23 @@ packages:
|
||||||
'@tailwindcss/postcss@4.2.2':
|
'@tailwindcss/postcss@4.2.2':
|
||||||
resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==}
|
resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==}
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.96.2':
|
||||||
|
resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==}
|
||||||
|
|
||||||
|
'@tanstack/query-devtools@5.96.2':
|
||||||
|
resolution: {integrity: sha512-vBTB1Qhbm3nHSbEUtQwks/EdcAtFfEapr1WyBW4w2ExYKuXVi3jIxUIHf5MlSltiHuL7zNyUuanqT/7sI2sb6g==}
|
||||||
|
|
||||||
|
'@tanstack/react-query-devtools@5.96.2':
|
||||||
|
resolution: {integrity: sha512-nTFKLGuTOFvmFRvcyZ3ArWC/DnMNPoBh6h/2yD6rsf7TCTJCQt+oUWOp2uKPTIuEPtF/vN9Kw5tl5mD1Kbposw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tanstack/react-query': ^5.96.2
|
||||||
|
react: ^18 || ^19
|
||||||
|
|
||||||
|
'@tanstack/react-query@5.96.2':
|
||||||
|
resolution: {integrity: sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18 || ^19
|
||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -4917,6 +4940,21 @@ snapshots:
|
||||||
postcss: 8.5.8
|
postcss: 8.5.8
|
||||||
tailwindcss: 4.2.2
|
tailwindcss: 4.2.2
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.96.2': {}
|
||||||
|
|
||||||
|
'@tanstack/query-devtools@5.96.2': {}
|
||||||
|
|
||||||
|
'@tanstack/react-query-devtools@5.96.2(@tanstack/react-query@5.96.2(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-devtools': 5.96.2
|
||||||
|
'@tanstack/react-query': 5.96.2(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
|
||||||
|
'@tanstack/react-query@5.96.2(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-core': 5.96.2
|
||||||
|
react: 19.2.3
|
||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.0
|
'@babel/code-frame': 7.29.0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue