feat(core/inbox): migrate inbox to TanStack Query (Phase 2)
- Create core/inbox/ with queries, mutations, ws-updaters - Migrate inbox page: useQuery + mutation hooks replace useInboxStore + api.* - Migrate sidebar unread badge to read from TQ cache - Delete useInboxStore (127 lines) — inbox has no client-only state - Remove inbox deps from workspace store (hydrate + switch) - Fix WS sync: use useQueryClient() instead of getQueryClient() singleton to ensure WS handlers write to the same QueryClient instance that components read from (singleton is unreliable under Next.js HMR) - Add onInboxIssueStatusChanged for issue status sync in inbox items Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
06fa65d4b5
commit
1d812bd446
10 changed files with 291 additions and 206 deletions
|
|
@ -42,7 +42,9 @@ import {
|
|||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@core/hooks";
|
||||
import { inboxListOptions, deduplicateInboxItems } from "@core/inbox/queries";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
|
||||
const primaryNav = [
|
||||
|
|
@ -73,7 +75,9 @@ export function AppSidebar() {
|
|||
const workspaces = useWorkspaceStore((s) => s.workspaces);
|
||||
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
|
||||
|
||||
const unreadCount = useInboxStore((s) => s.unreadCount());
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: inboxItems = [] } = useQuery(inboxListOptions(wsId));
|
||||
const unreadCount = deduplicateInboxItems(inboxItems).filter((i) => !i.read).length;
|
||||
|
||||
const logout = () => {
|
||||
router.push("/");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
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 { STATUS_CONFIG, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
|
|
@ -33,7 +46,6 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
|
@ -235,8 +247,9 @@ export default function InboxPage() {
|
|||
window.history.replaceState(null, "", url);
|
||||
}, []);
|
||||
|
||||
const items = useInboxStore((s) => s.dedupedItems());
|
||||
const loading = useInboxStore((s) => s.loading);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
|
||||
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
|
||||
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
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 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
|
||||
const handleSelect = async (item: InboxItem) => {
|
||||
const handleSelect = (item: InboxItem) => {
|
||||
setSelectedKey(item.issue_id ?? item.id);
|
||||
if (!item.read) {
|
||||
useInboxStore.getState().markRead(item.id);
|
||||
try {
|
||||
await api.markInboxRead(item.id);
|
||||
} catch {
|
||||
// Rollback: refetch to get server truth
|
||||
useInboxStore.getState().fetch();
|
||||
toast.error("Failed to mark as read");
|
||||
}
|
||||
markReadMutation.mutate(item.id, {
|
||||
onError: () => toast.error("Failed to mark as read"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await api.archiveInbox(id);
|
||||
useInboxStore.getState().archive(id);
|
||||
const archived = items.find((i) => i.id === id);
|
||||
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
|
||||
} catch {
|
||||
toast.error("Failed to archive");
|
||||
}
|
||||
const handleArchive = (id: string) => {
|
||||
const archived = items.find((i) => i.id === id);
|
||||
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
|
||||
archiveMutation.mutate(id, {
|
||||
onError: () => toast.error("Failed to archive"),
|
||||
});
|
||||
};
|
||||
|
||||
// Batch operations
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
useInboxStore.getState().markAllRead();
|
||||
await api.markAllInboxRead();
|
||||
} catch {
|
||||
toast.error("Failed to mark all as read");
|
||||
useInboxStore.getState().fetch();
|
||||
}
|
||||
const handleMarkAllRead = () => {
|
||||
markAllReadMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to mark all as read"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveAll = async () => {
|
||||
try {
|
||||
useInboxStore.getState().archiveAll();
|
||||
setSelectedKey("");
|
||||
await api.archiveAllInbox();
|
||||
} catch {
|
||||
toast.error("Failed to archive all");
|
||||
useInboxStore.getState().fetch();
|
||||
}
|
||||
const handleArchiveAll = () => {
|
||||
setSelectedKey("");
|
||||
archiveAllMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to archive all"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveAllRead = async () => {
|
||||
try {
|
||||
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
|
||||
useInboxStore.getState().archiveAllRead();
|
||||
if (readKeys.includes(selectedKey)) setSelectedKey("");
|
||||
await api.archiveAllReadInbox();
|
||||
} catch {
|
||||
toast.error("Failed to archive read items");
|
||||
useInboxStore.getState().fetch();
|
||||
}
|
||||
const handleArchiveAllRead = () => {
|
||||
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
|
||||
if (readKeys.includes(selectedKey)) setSelectedKey("");
|
||||
archiveAllReadMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to archive read items"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveCompleted = async () => {
|
||||
try {
|
||||
await api.archiveCompletedInbox();
|
||||
setSelectedKey("");
|
||||
await useInboxStore.getState().fetch();
|
||||
} catch {
|
||||
toast.error("Failed to archive completed");
|
||||
}
|
||||
const handleArchiveCompleted = () => {
|
||||
setSelectedKey("");
|
||||
archiveCompletedMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to archive completed"),
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
|
|
|
|||
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";
|
||||
104
apps/web/core/inbox/mutations.ts
Normal file
104
apps/web/core/inbox/mutations.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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) });
|
||||
}
|
||||
|
|
@ -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,
|
||||
}));
|
||||
|
|
@ -1,20 +1,21 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { WSClient } from "@/shared/api";
|
||||
import { toast } from "sonner";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
import { api } from "@/shared/api";
|
||||
import { getQueryClient } from "@core/query-client";
|
||||
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 type {
|
||||
MemberAddedPayload,
|
||||
WorkspaceDeletedPayload,
|
||||
|
|
@ -39,6 +40,7 @@ const logger = createLogger("realtime-sync");
|
|||
* by individual components via useWSEvent — not here.
|
||||
*/
|
||||
export function useRealtimeSync(ws: WSClient | null) {
|
||||
const qc = useQueryClient();
|
||||
// Main sync: onAny → refreshMap with debounce
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
|
|
@ -49,7 +51,10 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
]);
|
||||
|
||||
const refreshMap: Record<string, () => void> = {
|
||||
inbox: () => void useInboxStore.getState().fetch(),
|
||||
inbox: () => {
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) onInboxInvalidate(qc, wsId);
|
||||
},
|
||||
agent: () => void useWorkspaceStore.getState().refreshAgents(),
|
||||
member: () => void useWorkspaceStore.getState().refreshMembers(),
|
||||
workspace: () => {
|
||||
|
|
@ -98,30 +103,34 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
|
||||
const { issue } = p as IssueUpdatedPayload;
|
||||
if (!issue?.id) return;
|
||||
if (issue.status) {
|
||||
useInboxStore.getState().updateIssueStatus(issue.id, issue.status);
|
||||
}
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) onIssueUpdated(getQueryClient(), wsId, issue);
|
||||
if (wsId) {
|
||||
onIssueUpdated(qc, wsId, issue);
|
||||
if (issue.status) {
|
||||
onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const unsubIssueCreated = ws.on("issue:created", (p) => {
|
||||
const { issue } = p as IssueCreatedPayload;
|
||||
if (!issue) return;
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) onIssueCreated(getQueryClient(), wsId, issue);
|
||||
if (wsId) onIssueCreated(qc, wsId, issue);
|
||||
});
|
||||
|
||||
const unsubIssueDeleted = ws.on("issue:deleted", (p) => {
|
||||
const { issue_id } = p as IssueDeletedPayload;
|
||||
if (!issue_id) return;
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) onIssueDeleted(getQueryClient(), wsId, issue_id);
|
||||
if (wsId) onIssueDeleted(qc, wsId, issue_id);
|
||||
});
|
||||
|
||||
const unsubInboxNew = ws.on("inbox:new", (p) => {
|
||||
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) ---
|
||||
|
|
@ -169,7 +178,7 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
timers.forEach(clearTimeout);
|
||||
timers.clear();
|
||||
};
|
||||
}, [ws]);
|
||||
}, [ws, qc]);
|
||||
|
||||
// Reconnect → refetch all data to recover missed events
|
||||
useEffect(() => {
|
||||
|
|
@ -180,10 +189,10 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
try {
|
||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (wsId) {
|
||||
getQueryClient().invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
|
||||
}
|
||||
await Promise.all([
|
||||
useInboxStore.getState().fetch(),
|
||||
useWorkspaceStore.getState().refreshAgents(),
|
||||
useWorkspaceStore.getState().refreshMembers(),
|
||||
useWorkspaceStore.getState().refreshSkills(),
|
||||
|
|
@ -194,5 +203,5 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
});
|
||||
|
||||
return unsub;
|
||||
}, [ws]);
|
||||
}, [ws, qc]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { create } from "zustand";
|
||||
import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useRuntimeStore } from "@/features/runtimes";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
|
|
@ -87,7 +86,6 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
|||
return [] as Agent[];
|
||||
}),
|
||||
api.listSkills().catch(() => [] as Skill[]),
|
||||
useInboxStore.getState().fetch().catch(() => {}),
|
||||
]);
|
||||
logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length);
|
||||
set({ members: nextMembers, agents: nextAgents, skills: nextSkills });
|
||||
|
|
@ -109,8 +107,7 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
|||
localStorage.setItem("multica_workspace_id", ws.id);
|
||||
|
||||
// Clear stale data across stores before hydrating.
|
||||
// Issue cache is managed by TanStack Query (keyed by wsId, auto-refetches).
|
||||
useInboxStore.getState().setItems([]);
|
||||
// Issue + inbox caches are managed by TanStack Query (keyed by wsId, auto-refetches).
|
||||
useRuntimeStore.getState().setRuntimes([]);
|
||||
set({ workspace: ws, members: [], agents: [], skills: [] });
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue