diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bba72f8b1..35859ca1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -355,9 +355,15 @@ importers: '@tanstack/react-query-persist-client': specifier: 5.103.2 version: 5.103.2(@tanstack/react-query@5.103.2(react@19.3.0))(react@19.3.0) + '@tanstack/react-store': + specifier: 0.11.2 + version: 0.11.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tanstack/react-virtual': specifier: ^3.14.13 version: 3.14.13(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/store': + specifier: 0.11.2 + version: 0.11.2 '@tauri-apps/plugin-clipboard-manager': specifier: ^2.3.3 version: 2.3.3 @@ -544,9 +550,6 @@ importers: zod: specifier: ^4.6.5 version: 4.6.5 - zustand: - specifier: ^4.5.7 - version: 4.5.7(@types/react@19.3.0)(react@19.3.0) devDependencies: '@alook/test-utils': specifier: workspace:* @@ -3723,12 +3726,21 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-store@0.11.2': + resolution: {integrity: sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-virtual@3.14.13': resolution: {integrity: sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/store@0.11.2': + resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==} + '@tanstack/virtual-core@3.17.11': resolution: {integrity: sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==} @@ -12813,12 +12825,21 @@ snapshots: '@tanstack/query-core': 5.103.2 react: 19.3.0 + '@tanstack/react-store@0.11.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@tanstack/store': 0.11.2 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + use-sync-external-store: 1.6.0(react@19.3.0) + '@tanstack/react-virtual@3.14.13(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@tanstack/virtual-core': 3.17.11 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) + '@tanstack/store@0.11.2': {} + '@tanstack/virtual-core@3.17.11': {} '@tauri-apps/api@2.11.1': {} diff --git a/src/app/knip.json b/src/app/knip.json index 50cdbca26..2efbe6e10 100644 --- a/src/app/knip.json +++ b/src/app/knip.json @@ -12,8 +12,7 @@ "predev", "printf", "pbcopy", - "xclip", - "taskkill" + "xclip" ], "vitest": { "entry": ["test/**/*.test.ts"] diff --git a/src/web/blog/src/app/blog/(index)/recent-posts.tsx b/src/web/blog/src/app/blog/(index)/recent-posts.tsx index 592fa70b6..048240fcd 100644 --- a/src/web/blog/src/app/blog/(index)/recent-posts.tsx +++ b/src/web/blog/src/app/blog/(index)/recent-posts.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useMemo, useState, type Ref } from "react"; +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useEffect, useMemo, type Ref } from "react"; import Image from "next/image"; import Link from "next/link"; import { @@ -51,7 +52,7 @@ export function replaceRecentPostsTopicUrl(topicId: string) { } export function RecentPosts({ posts, topics }: RecentPostsProps) { - const [selectedTopicId, setSelectedTopicId] = useState(allTopicsId); + const [selectedTopicId, setSelectedTopicId] = useAtom(useCreateAtom(allTopicsId)); const breakpoint = useBreakpoint(); const hoverCapable = useHoverCapable(); const topicIds = useMemo(() => new Set(topics.map((topic) => topic.id)), [topics]); @@ -77,7 +78,7 @@ export function RecentPosts({ posts, topics }: RecentPostsProps) { useEffect(() => { return subscribeToRecentPostsTopicHash(topicIds, setSelectedTopicId); - }, [topicIds]); + }, [setSelectedTopicId, topicIds]); const selectTopic = (topicId: string) => { setSelectedTopicId(topicId); diff --git a/src/web/knip.json b/src/web/knip.json index a195a4856..41dca1e77 100644 --- a/src/web/knip.json +++ b/src/web/knip.json @@ -18,13 +18,13 @@ "blog/src/app/globals.css", "blog/custom-worker.ts", "blog/mdx-components.tsx", - "blog/next.config.ts", "blog/open-next.config.ts", "blog/scripts/**/*.ts", + "blog/src/content/**/*.mdx", "auth/index.ts", "scripts/**/*.{ts,mjs}" ], - "project": ["src/**/*.{ts,tsx}", "blog/**/*.{ts,tsx}", "auth/**/*.{ts,tsx,mts}", "scripts/**/*.{ts,mjs}", "test-runtime/**/*.ts"], + "project": ["src/**/*.{ts,tsx,css,mdx}", "blog/**/*.{ts,tsx,css,mdx}", "auth/**/*.{ts,tsx,mts}", "scripts/**/*.{ts,mjs}", "test-runtime/**/*.ts"], "ignoreDependencies": [ "@tailwindcss/node", "cloudflare", @@ -34,9 +34,6 @@ "rehype-slug", "shiki" ], - "ignoreBinaries": [ - "lsof" - ], "ignore": [ "src/components/ui/**", "src/app/(app)/workspaces/client.tsx" diff --git a/src/web/package.json b/src/web/package.json index b2bae9286..ea92f9b85 100644 --- a/src/web/package.json +++ b/src/web/package.json @@ -72,7 +72,9 @@ "@tanstack/react-db": "0.4.1", "@tanstack/react-query": "5.103.2", "@tanstack/react-query-persist-client": "5.103.2", + "@tanstack/react-store": "0.11.2", "@tanstack/react-virtual": "^3.14.13", + "@tanstack/store": "0.11.2", "@tauri-apps/plugin-clipboard-manager": "^2.3.3", "@tiptap/extension-image": "^3.31.3", "@tiptap/extension-link": "^3.31.3", @@ -134,8 +136,7 @@ "tailwind-merge": "^3.7.0", "tw-animate-css": "^1.4.0", "unique-names-generator": "^4.7.1", - "zod": "^4.6.5", - "zustand": "^4.5.7" + "zod": "^4.6.5" }, "devDependencies": { "@alook/test-utils": "workspace:*", diff --git a/src/web/scripts/dev-zone-processes.test.ts b/src/web/scripts/dev-zone-processes.test.ts index fe127e192..fc032804a 100644 --- a/src/web/scripts/dev-zone-processes.test.ts +++ b/src/web/scripts/dev-zone-processes.test.ts @@ -36,10 +36,10 @@ describe("development zone Worker processes", () => { ]) }) - it("keeps main and ws-do in one exact Wrangler runtime for E2E", () => { + it("keeps main, ws-do and queue consumer in one exact Wrangler runtime for E2E", () => { expect(exactWorkerDevArgs( "/wrangler/bin/wrangler.js", - ["wrangler.toml", "../ws-do/wrangler.toml"], + ["wrangler.toml", "../ws-do/wrangler.toml", "../queue-worker/wrangler.toml"], LOCAL_WORKER_ENDPOINTS.main, )).toEqual([ "/wrangler/bin/wrangler.js", @@ -48,6 +48,8 @@ describe("development zone Worker processes", () => { "wrangler.toml", "--config", "../ws-do/wrangler.toml", + "--config", + "../queue-worker/wrangler.toml", "--local", "--persist-to", ".wrangler/state", diff --git a/src/web/scripts/dev-zones.mjs b/src/web/scripts/dev-zones.mjs index 4b3922aaf..480a3c8af 100644 --- a/src/web/scripts/dev-zones.mjs +++ b/src/web/scripts/dev-zones.mjs @@ -93,7 +93,7 @@ async function waitForHttpOk(url, timeoutMs = 60_000) { } const mainWorkerConfigs = withWsDo - ? ["wrangler.toml", "../ws-do/wrangler.toml"] + ? ["wrangler.toml", "../ws-do/wrangler.toml", "../queue-worker/wrangler.toml"] : ["wrangler.toml"] const workerCommand = wranglerEntry ? process.execPath : packageManagerCli const workerArgs = (configPaths, endpoint) => wranglerEntry diff --git a/src/web/src/app/(app)/invite/[token]/page.tsx b/src/web/src/app/(app)/invite/[token]/page.tsx index 10c8632c0..0f8a1044f 100644 --- a/src/web/src/app/(app)/invite/[token]/page.tsx +++ b/src/web/src/app/(app)/invite/[token]/page.tsx @@ -1,9 +1,12 @@ "use client"; -import { useEffect, useState } from "react"; + +import { useQuery, useMutation, type Query } from "@tanstack/react-query"; +import { applicationKey, useApplicationOwner, runApplicationRequest } from "@/lib/application-owner"; +import { useApplicationViewSource } from "@/hooks/use-application-view-source"; import { useParams, useRouter } from "next/navigation"; import { toast } from "sonner"; -import { getInviteInfo, acceptInvite, type InviteInfo } from "@/lib/api"; +import { getInviteInfo, acceptInvite } from "@/lib/api"; import { trackInviteAccepted } from "@/lib/analytics"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; @@ -14,35 +17,35 @@ export default function InvitePage() { const { token } = useParams<{ token: string }>(); const router = useRouter(); - const [state, setState] = useState("loading"); - const [info, setInfo] = useState(null); - const [errorMsg, setErrorMsg] = useState(""); - - useEffect(() => { - if (!token) return; - getInviteInfo(token) - .then((data) => { - setInfo(data); - setState("ready"); - }) - .catch((err) => { - setErrorMsg(err instanceof Error ? err.message : "Invalid or expired invite link"); - setState("error"); - }); - }, [token]); + const owner = useApplicationOwner(); + const source = useApplicationViewSource(`workspace-invite:${token}`); + const query = useQuery({ queryKey: applicationKey(owner, "workspace-invite", token), enabled: !!token, retry: false, + queryFn: ({ signal }) => runApplicationRequest(owner, (options) => getInviteInfo(token, options), signal) }); + const command = useMutation({ mutationKey: applicationKey(owner, "workspace-invite", token, "accept"), gcTime: 0, + mutationFn: async ({ original, resources }: { original: ReturnType; resources: Query[] }) => { + original.assert(); + const result = await acceptInvite(token, { authenticationAccount: owner.userId, signal: original.signal, assertActive: original.assert, onUnauthorized: async () => { original.assert(); return false; } }); + original.assert(); + await owner.queryClient.invalidateQueries({ queryKey: applicationKey(owner, "workspaces"), predicate: (query) => resources.includes(query) }, { cancelRefetch: false }); + original.assert(); + return result; + } }); + const info = query.data; + const error = command.error ?? query.error; + const errorMsg = error instanceof Error ? error.message : "Invalid or expired invite link"; + const state: State = command.isPending ? "accepting" : command.isSuccess ? "done" : error ? "error" : query.isPending ? "loading" : "ready"; const handleAccept = async () => { - if (!token) return; - setState("accepting"); + if (!token || owner.queryClient.isMutating({ mutationKey: applicationKey(owner, "workspace-invite", token, "accept"), exact: true })) return; + const original = source.capture(); try { - const result = await acceptInvite(token); + const result = await command.mutateAsync({ original, resources: owner.queryClient.getQueryCache().findAll({ queryKey: applicationKey(owner, "workspaces") }) }); + original.assert(); trackInviteAccepted({ workspace_id: result.workspace_id ?? "" }); - setState("done"); toast.success(`Joined ${info?.workspace_name ?? "workspace"}`); router.replace(`/w/${result.workspace_slug}/home`); - } catch (err) { - setErrorMsg(err instanceof Error ? err.message : "Failed to join workspace"); - setState("error"); + } catch { + try { original.assert(); } catch { return; } } }; diff --git a/src/web/src/app/(app)/layout.dom.test.tsx b/src/web/src/app/(app)/layout.dom.test.tsx new file mode 100644 index 000000000..cb9b8e017 --- /dev/null +++ b/src/web/src/app/(app)/layout.dom.test.tsx @@ -0,0 +1,52 @@ +import { useLayoutEffect } from "react" +import "fake-indexeddb/auto" +import React from "react" +import { get, set } from "idb-keyval" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { act, render, waitFor } from "@/test/react-dom-harness" +import AppLayout from "./layout" +import { useApplicationOwner, type ApplicationOwner } from "@/lib/application-owner" +import { clearAllPersistedCaches, createIdbPersister } from "@/lib/query-persister" + +const input = vi.hoisted(() => ({ serverId: "A", sdk: { data: { user: { id: "A" } }, isPending: false, error: null } })) +vi.mock("@/lib/session", () => ({ getSession: async () => ({ user: { id: input.serverId } }) })) +vi.mock("@/lib/auth-client", () => { const sessionSDK = { useSession: () => input.sdk }; return { ...sessionSDK, currentSessionViewer: () => { const value = sessionSDK.useSession(); return !value || value.isPending || value.error ? undefined : value.data?.user.id ?? null } } }) +vi.mock("next/navigation", () => ({ usePathname: () => "/w/test", useRouter: () => ({ refresh: vi.fn(), replace: vi.fn() }), redirect: vi.fn() })) +vi.mock("@/components/authenticated-native-oauth-cleanup", () => ({ AuthenticatedNativeOauthCleanup: () => null })) +vi.mock("@/components/signup-tracker", () => ({ SignupTracker: () => null })) +vi.mock("@/components/signin-tracker", () => ({ SigninTracker: () => null })) +vi.mock("@/components/daemon-update-notice", () => ({ DaemonUpdateNotice: () => null })) +const owners: Record = {} +function Probe() { const owner = useApplicationOwner(); useLayoutEffect(() => { owners[owner.userId] = owner }); return {owner.userId} } +async function layout(id: string) { input.serverId = id; return AppLayout({ children: }) } +beforeEach(async () => { await clearAllPersistedCaches(); input.sdk = { data: { user: { id: "A" } }, isPending: false, error: null } }) +afterEach(async () => { + await act(async () => { + for (const owner of Object.values(owners)) owner.queryClient.clear(); for (const key of Object.keys(owners)) delete owners[key] + }) +}) + +describe("actual authenticated AppLayout identity boundary", () => { + it("retires A when immutable public SDK output and the server layout account change in one commit", async () => { + const view = render(await layout("A")) + await waitFor(() => expect(owners.A?.sessionViewer()).toBe("A")) + const old = owners.A, a = createIdbPersister("A", "application"), b = createIdbPersister("B", "application") + await act(async () => { await a.restoreClient(); }) ; await act(async () => { await b.restoreClient(); }) ; await act(async () => { await set("alook:qc:v2:A:client", "old A"); }) ; await act(async () => { await set("alook:qc:v2:B:client", "old B") }) ; + const next = await layout("B") + act(() => { input.sdk = { data: { user: { id: "B" } }, isPending: false, error: null }; view.rerender(next) }) + await waitFor(() => expect(owners.B?.sessionViewer()).toBe("B")) + expect(old.lifecycle.get().active).toBe(false) + await waitFor(async () => expect(await a.isCurrent()).toBe(false)) + expect(await get("alook:qc:v2:A:client")).toBeUndefined() + expect(await get("alook:qc:v2:B:client")).toBe("old B"); expect(await b.isCurrent()).toBe(true) + expect(owners.B.lifecycle.get().active).toBe(true) + }) + it("preserves A disk when the same-viewer layout leaves an ordinary route", async () => { + const view = render(await layout("A")) + await waitFor(() => expect(owners.A?.sessionViewer()).toBe("A")) + const a = createIdbPersister("A", "application"); await act(async () => { await a.restoreClient(); }) ; await act(async () => { await set("alook:qc:v2:A:client", "old A") }) ; + act(() => view.rerender(

Public route

)) + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) }) + expect(await a.isCurrent()).toBe(true); expect(await get("alook:qc:v2:A:client")).toBe("old A") + }) +}) diff --git a/src/web/src/app/(app)/layout.tsx b/src/web/src/app/(app)/layout.tsx index 8d7ef3d6c..f7a1cbf2c 100644 --- a/src/web/src/app/(app)/layout.tsx +++ b/src/web/src/app/(app)/layout.tsx @@ -1,3 +1,4 @@ +import { ApplicationQueryProvider } from "@/lib/application-owner"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { getSession } from "@/lib/session"; @@ -20,6 +21,7 @@ export default async function AppLayout({ if (!session) redirect("/sign-in"); return ( + @@ -27,5 +29,6 @@ export default async function AppLayout({ {children} + ); } diff --git a/src/web/src/app/(app)/studio/new/client.tsx b/src/web/src/app/(app)/studio/new/client.tsx index 309580dbd..fce059ffd 100644 --- a/src/web/src/app/(app)/studio/new/client.tsx +++ b/src/web/src/app/(app)/studio/new/client.tsx @@ -1,12 +1,20 @@ "use client"; -import { useState, useCallback, useEffect } from "react"; +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useCallback, useEffect, useMemo } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { WorkspaceProvider, useWorkspaceOwner, captureWorkspaceOwner, assertWorkspaceOwner, workspaceRequestOptions, runWorkspaceRequest } from "@/contexts/workspace-context"; +import { useWorkspaceViewSource } from "@/hooks/workspace/use-workspace-view-source"; +import { apiFetch } from "@/lib/api/client"; +import { applicationKey } from "@/lib/application-owner"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Loader2, CheckCircle2, LogOut, ArrowLeft, LayoutGrid } from "lucide-react"; import { toast } from "sonner"; -import { signOut } from "@/lib/auth-client"; -import { clearAllCache } from "@/lib/chat-cache"; +import { toastApiError } from "@/lib/api/client"; +import { useApplicationSignOut } from "@/hooks/use-application-sign-out"; +import { assertApplicationOwner, captureApplicationOwner } from "@/lib/application-owner"; +import { useApplicationOwner } from "@/lib/application-owner"; import { trackWorkspaceCreated, trackOnboardingCompleted, trackAgentCreated } from "@/lib/analytics"; import { PublicLayout } from "@/components/public-layout"; @@ -25,7 +33,11 @@ import { useUserWs } from "@/lib/use-user-ws"; import { ConnectMachineSteps } from "@/components/connect-machine-steps"; import type { TemplatePreset } from "@/lib/templates"; -export function StudioOnboardingClient({ +export function StudioOnboardingClient(props: { workspaceId: string; workspaceSlug: string; initialTemplate?: TemplatePreset }) { + return ; +} + +function StudioOnboardingInner({ workspaceId, workspaceSlug, initialTemplate, @@ -34,258 +46,187 @@ export function StudioOnboardingClient({ workspaceSlug: string; initialTemplate?: TemplatePreset; }) { + const applicationOwner = useApplicationOwner(); + const logout = useApplicationSignOut(); + const onLogout = async () => { + const token = captureApplicationOwner(applicationOwner); + try { if (await logout.mutateAsync()) router.push("/sign-in"); } + catch (error) { toastApiError(error, "Failed to log out", () => assertApplicationOwner(token)); } + }; const router = useRouter(); - const [runtimes, setRuntimes] = useState([]); - const [loadingRuntimes, setLoadingRuntimes] = useState(true); - const [scenarioId, setScenarioId] = useState( - initialTemplate ? initialTemplate.baseScenario : null, - ); - const [members, setMembers] = useState([]); - const [creating, setCreating] = useState(false); - - const [generatedToken, setGeneratedToken] = useState(""); - const [generatingToken, setGeneratingToken] = useState(false); - const [machineRegistered, setMachineRegistered] = useState(false); - const [daemonOnline, setDaemonOnline] = useState(false); - - const onlineRuntimes = runtimes.filter((r) => r.status === "online"); + const owner = useWorkspaceOwner(); + const source = useWorkspaceViewSource(owner, "studio-onboarding", true); + const runtimesKey = owner.key("runtimes"); + const runtimesQuery = useQuery({ queryKey: runtimesKey, + queryFn: ({ signal }) => runWorkspaceRequest(owner, (options) => listRuntimes(workspaceId, options), signal), + refetchInterval: 30_000, refetchIntervalInBackground: false, + }); + const runtimes = runtimesQuery.data ?? []; + const loadingRuntimes = runtimesQuery.isPending; + const [scenarioId, setScenarioId] = useAtom(useCreateAtom(initialTemplate ? initialTemplate.baseScenario : null)); + const [memberDrafts, setMembers] = useAtom(useCreateAtom([])); + const [registeredDaemonId, setRegisteredDaemonId] = useAtom(useCreateAtom(null)); + const handlesQuery = useQuery({ + queryKey: owner.key("studio-handles", memberDrafts.map(({ uid, name }) => ({ uid, name }))), + enabled: memberDrafts.length > 0, retry: false, gcTime: 0, + queryFn: ({ signal }) => runWorkspaceRequest(owner, (options) => apiFetch<{ uid: string; handle: string }[]>("/api/studios/check-handles", { + ...options, method: "POST", body: JSON.stringify({ members: memberDrafts.map(({ uid, name }) => ({ uid, name })) }), + }), signal), + }); + const members = useMemo(() => memberDrafts.map((member) => ({ + ...member, emailHandle: handlesQuery.data?.find((row) => row.uid === member.uid)?.handle, + })), [memberDrafts, handlesQuery.data]); + const tokenQuery = useQuery({ queryKey: owner.key("studio-pairing"), enabled: false, gcTime: 0, retry: false, + queryFn: ({ signal }) => runWorkspaceRequest(owner, (options) => createMachineToken("cli", workspaceId, options), signal), + }); + const generatedToken = tokenQuery.data?.token ?? ""; + const generatingToken = tokenQuery.isFetching; + const onlineRuntimes = useMemo(() => (runtimesQuery.data ?? []).filter((runtime) => runtime.status === "online"), [runtimesQuery.data]); const hasOnlineRuntime = onlineRuntimes.length > 0; - const computerConnected = hasOnlineRuntime || (machineRegistered && daemonOnline); - - useEffect(() => { - listRuntimes(workspaceId) - .then((rts) => { - setRuntimes(rts); - if (rts.some((r) => r.status === "online")) { - setMachineRegistered(true); - setDaemonOnline(true); - } - }) - .catch(() => {}) - .finally(() => setLoadingRuntimes(false)); - }, [workspaceId]); + const daemonOnline = registeredDaemonId ? runtimes.some((runtime) => runtime.daemon_id === registeredDaemonId && runtime.status === "online") : hasOnlineRuntime; + const machineRegistered = !!registeredDaemonId || hasOnlineRuntime; + const computerConnected = hasOnlineRuntime; - const handleWsMessage = useCallback((msg: WsMessage) => { - if (msg.type === "runtime.registered" && msg.workspaceId === workspaceId) { - setMachineRegistered(true); - setDaemonOnline(true); - listRuntimes(workspaceId).then(setRuntimes).catch(() => {}); - } else if (msg.type === "runtime.status" && msg.workspaceId === workspaceId) { - setMachineRegistered(true); - if (msg.status === "online") setDaemonOnline(true); - else setDaemonOnline(false); + const handleWsMessage = useCallback((message: WsMessage) => { + try { source.assertActive(); } catch { return; } + if (message.type === "runtime.registered" && message.workspaceId === workspaceId) { + setRegisteredDaemonId(message.daemonId); + void owner.queryClient.invalidateQueries({ queryKey: runtimesKey, exact: true }); + } else if (message.type === "runtime.status" && (!message.workspaceId || message.workspaceId === workspaceId)) { + setRegisteredDaemonId(message.daemonId); + void owner.queryClient.invalidateQueries({ queryKey: runtimesKey, exact: true }); } - }, [workspaceId]); + }, [workspaceId, source, setRegisteredDaemonId, owner.queryClient, runtimesKey]); - useUserWs(handleWsMessage); + useUserWs(handleWsMessage, { onReconnect: () => { try { source.assertActive(); } catch { return; } return owner.queryClient.invalidateQueries({ queryKey: runtimesKey, exact: true }); } }); - const handleGenerateToken = useCallback(async () => { - setGeneratingToken(true); + const handleGenerateToken = async () => { + const assert = source.assertActive; + assert(); try { - const res = await createMachineToken("cli", workspaceId); - setGeneratedToken(res.token); - } catch { - toast.error("Failed to generate token"); - } finally { - setGeneratingToken(false); + const result = await tokenQuery.refetch({ cancelRefetch: false, throwOnError: true }); + assert(); + return result.data; + } catch (error) { + try { assert(); } catch { return; } + if (!(error instanceof DOMException && error.name === "AbortError")) toast.error("Failed to generate token"); } - }, [workspaceId]); + }; useEffect(() => { const firstOnline = onlineRuntimes[0]?.id; if (!firstOnline) return; - setMembers((prev) => { - if (prev.length === 0) return prev; - const needsUpdate = prev.some((m) => !m.runtimeId); - if (!needsUpdate) return prev; - return prev.map((m) => m.runtimeId ? m : { ...m, runtimeId: firstOnline }); - }); - }, [onlineRuntimes]); - - const resolveHandles = useCallback(async (members: { uid: string; name: string }[]) => { - try { - const res = await fetch("/api/studios/check-handles", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ members }), - }); - if (!res.ok) return null; - return (await res.json()) as { uid: string; handle: string }[]; - } catch { - return null; - } - }, []); + setMembers((previous) => previous.some((member) => !member.runtimeId) ? previous.map((member) => member.runtimeId ? member : { ...member, runtimeId: firstOnline }) : previous); + }, [onlineRuntimes, setMembers]); useEffect(() => { - if (!initialTemplate || loadingRuntimes) return; - if (members.length > 0) return; + if (!initialTemplate || loadingRuntimes || memberDrafts.length > 0) return; const generated = shuffleMembers(initialTemplate.members.length); const defaultRuntimeId = onlineRuntimes[0]?.id || ""; - const newMembers = initialTemplate.members.map((m, i) => ({ - uid: generated[i].uid, - name: generated[i].name, - role: m.role, - description: m.description, - instructions: m.instructions, - avatarUrl: generated[i].avatarUrl, - runtimeId: defaultRuntimeId, - relationship: m.relationship, - })); - setMembers(newMembers); - resolveHandles(newMembers.map((m) => ({ uid: m.uid, name: m.name }))).then((handles) => { - if (handles) { - setMembers((prev) => - prev.map((m) => { - const h = handles.find((h) => h.uid === m.uid); - return h ? { ...m, emailHandle: h.handle } : m; - }), - ); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialTemplate, loadingRuntimes]); + setMembers(initialTemplate.members.map((member, index) => ({ + uid: generated[index].uid, name: generated[index].name, role: member.role, + description: member.description, instructions: member.instructions, avatarUrl: generated[index].avatarUrl, + runtimeId: defaultRuntimeId, relationship: member.relationship, + }))); + }, [initialTemplate, loadingRuntimes, memberDrafts.length, onlineRuntimes, setMembers]); - const handleScenarioSelect = async (id: ScenarioId) => { + const handleScenarioSelect = (id: ScenarioId) => { setScenarioId(id); - const preset = SCENARIO_PRESETS.find((s) => s.id === id)!; + const preset = SCENARIO_PRESETS.find((scenario) => scenario.id === id)!; const generated = shuffleMembers(preset.members.length); const defaultRuntimeId = onlineRuntimes[0]?.id || ""; - const newMembers = preset.members.map((m, i) => ({ - uid: generated[i].uid, - name: generated[i].name, - role: m.role, - description: m.description, - instructions: m.instructions, - avatarUrl: generated[i].avatarUrl, - runtimeId: defaultRuntimeId, - relationship: m.relationship, - })); - setMembers(newMembers); - const handles = await resolveHandles(newMembers.map((m) => ({ uid: m.uid, name: m.name }))); - if (handles) { - setMembers((prev) => prev.map((m) => { - const h = handles.find((h) => h.uid === m.uid); - return h ? { ...m, emailHandle: h.handle } : m; - })); - } + setMembers(preset.members.map((member, index) => ({ + uid: generated[index].uid, name: generated[index].name, role: member.role, + description: member.description, instructions: member.instructions, avatarUrl: generated[index].avatarUrl, + runtimeId: defaultRuntimeId, relationship: member.relationship, + }))); }; - const handleShuffle = async () => { - const generated = shuffleMembers(members.length); - const newMembers = members.map((m, i) => ({ ...m, uid: generated[i].uid, name: generated[i].name, avatarUrl: generated[i].avatarUrl, emailHandle: undefined })); - setMembers(newMembers); - const handles = await resolveHandles(newMembers.map((m) => ({ uid: m.uid, name: m.name }))); - if (handles) { - setMembers((prev) => prev.map((m) => { - const h = handles.find((h) => h.uid === m.uid); - return h ? { ...m, emailHandle: h.handle } : m; - })); - } + const handleShuffle = () => { + const generated = shuffleMembers(memberDrafts.length); + setMembers((previous) => previous.map((member, index) => ({ ...member, uid: generated[index].uid, name: generated[index].name, avatarUrl: generated[index].avatarUrl }))); }; const handleAssignRuntime = (memberIndex: number, runtimeId: string) => { - setMembers((prev) => - prev.map((m, i) => (i === memberIndex ? { ...m, runtimeId } : m)), - ); + setMembers((previous) => previous.map((member, index) => index === memberIndex ? { ...member, runtimeId } : member)); }; - const handleCreate = async () => { - // Pre-flight diagnostics — surface exactly which gate is blocking instead of - // silently disabling the button. Helps pin down the "Computer connected but - // Launch disabled" state that doesn't reproduce locally. - if (!scenarioId) { - toast.error("Pick a focus area first"); - return; - } - if (members.length === 0) { - toast.error("Add at least one team member"); - return; - } - if (!computerConnected) { - toast.error("Connect a computer first — no runtime is online yet"); - return; - } - - let readyMembers = members; - if (members.some((m) => !m.runtimeId)) { - // A member has no runtime assigned while the machine reads as connected — - // the exact divergence we're chasing. Try to self-heal by re-fetching the - // runtime list, then report precisely if it's still empty. - const fresh = await listRuntimes(workspaceId).catch(() => [] as Runtime[]); - setRuntimes(fresh); - const freshOnline = fresh.filter((r) => r.status === "online"); - const firstOnline = freshOnline[0]?.id; - if (!firstOnline) { - console.warn("[studio/new] Launch blocked — no online runtime to assign", { - workspaceId, - machineRegistered, - daemonOnline, - hasOnlineRuntime, - fetched: fresh.length, - online: freshOnline.length, - runtimes: fresh.map((r) => ({ id: r.id, provider: r.provider, status: r.status })), + const createCommand = useMutation({ gcTime: 0, mutationKey: owner.key("studio-create"), scope: { id: JSON.stringify(owner.key("studio-create")) }, + mutationFn: async ({ scenario, members, token }: { scenario: ScenarioId; members: TeamMember[]; token: ReturnType }) => { + const assert = () => assertWorkspaceOwner(token); + assert(); + let readyMembers = members; + if (members.some((member) => !member.runtimeId)) { + const fresh = await owner.queryClient.fetchQuery({ queryKey: runtimesKey, + queryFn: ({ signal }) => listRuntimes(workspaceId, workspaceRequestOptions(token, signal)), staleTime: 0, }); - toast.error( - `No online runtime to assign — machine reads connected but runtime list is ${fresh.length === 0 ? "empty" : "all offline"} (online: ${freshOnline.length}, fetched: ${fresh.length}, registered: ${machineRegistered}, daemonOnline: ${daemonOnline})`, - ); - return; + assert(); + const firstOnline = fresh.find((runtime) => runtime.status === "online")?.id; + if (!firstOnline) throw new Error("No online runtime to assign — connect a computer and try again"); + readyMembers = members.map((member) => member.runtimeId ? member : { ...member, runtimeId: firstOnline }); } - readyMembers = members.map((m) => (m.runtimeId ? m : { ...m, runtimeId: firstOnline })); - setMembers(readyMembers); - } - - setCreating(true); + const options = workspaceRequestOptions(token); + try { + const data = await apiFetch<{ workspace: { slug: string }; leader_agent_id: string }>("/api/studios", { + ...options, method: "POST", headers: { "X-Workspace-ID": workspaceId }, + body: JSON.stringify({ scenario, members: readyMembers.map((member) => ({ + name: member.name, role: member.role, runtime_id: member.runtimeId, description: member.description, + instructions: member.instructions, avatar_url: member.avatarUrl || null, email_handle: member.emailHandle || undefined, + relationship: member.relationship || undefined, + })) }), + }); + assert(); + await apiFetch(`/api/workspaces/${workspaceId}/onboarded`, { ...options, method: "POST" }).catch((error) => { assert(); if (error instanceof DOMException && error.name === "AbortError") throw error; }); + assert(); + await owner.queryClient.invalidateQueries({ queryKey: applicationKey(applicationOwner, "workspaces") }); + assert(); + return data; + } catch (error) { assert(); throw error; } + }, + }); + const creating = createCommand.isPending; + const handleCreate = async () => { + if (!scenarioId) { toast.error("Pick a focus area first"); return; } + if (!members.length) { toast.error("Add at least one team member"); return; } + if (!computerConnected) { toast.error("Connect a computer first — no runtime is online yet"); return; } + const assert = source.assertActive; + const token = captureWorkspaceOwner(owner); + const scenario = scenarioId; + const team = members; + assert(); try { - const res = await fetch("/api/studios", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Workspace-ID": workspaceId, - }, - body: JSON.stringify({ - name: undefined, - scenario: scenarioId, - members: readyMembers.map((m) => ({ - name: m.name, - role: m.role, - runtime_id: m.runtimeId, - description: m.description, - instructions: m.instructions, - avatar_url: m.avatarUrl || null, - email_handle: m.emailHandle || undefined, - relationship: m.relationship || undefined, - })), - }), - }); - - if (!res.ok) { - const errBody = (await res.json()) as { error?: string; details?: string[] }; - const detail = errBody.details?.length ? `: ${errBody.details.join(", ")}` : ""; - throw new Error(`${errBody.error || "Failed to create company"}${detail}`); - } - - const data = (await res.json()) as { workspace: { slug: string }; leader_agent_id: string }; - await fetch(`/api/workspaces/${workspaceId}/onboarded`, { method: "POST" }).catch(() => {}); + const data = await createCommand.mutateAsync({ scenario, members: team, token }); + assert(); trackWorkspaceCreated("onboarding"); - trackOnboardingCompleted({ - template_used: scenarioId ?? undefined, - agent_count: members.length, - }); - for (let i = 0; i < members.length; i++) { - trackAgentCreated({ - is_first_agent: i === 0, - has_email: !!members[i].emailHandle, - }); - } + trackOnboardingCompleted({ template_used: scenario, agent_count: team.length }); + team.forEach((member, index) => trackAgentCreated({ is_first_agent: index === 0, has_email: !!member.emailHandle })); toast.success("Company created!"); router.push(`/w/${data.workspace.slug}/agents/${data.leader_agent_id}`); - } catch (e) { - toast.error(e instanceof Error ? e.message : "Failed to create company"); - setCreating(false); + } catch (error) { + try { assert(); } catch { return; } + if (!(error instanceof DOMException && error.name === "AbortError")) toast.error(error instanceof Error ? error.message : "Failed to create company"); } }; + + const finishCommand = useMutation({ mutationKey: owner.key("studio-finish"), gcTime: 0, + mutationFn: async (intent: ReturnType & { resources: import("@tanstack/react-query").Query[] }) => { + intent.assert(); + await apiFetch(`/api/workspaces/${workspaceId}/onboarded`, { ...workspaceRequestOptions(intent.token, intent.signal, intent.assert), method: "POST" }); + intent.assert(); + await owner.queryClient.invalidateQueries({ predicate: (query) => intent.resources.includes(query) }); + intent.assert(); + router.push(`/w/${workspaceSlug}/home`); + }, + onError: (error, intent) => { try { intent.assert(); } catch { return; } toastApiError(error, "Failed to finish onboarding", intent.assert); }, + }); + const finishOnboarding = () => { + if (owner.queryClient.isMutating({ mutationKey: owner.key("studio-finish"), exact: true })) return; + const intent = source.capture(); intent.assert(); + finishCommand.mutate({ ...intent, resources: owner.queryClient.getQueryCache().findAll({ queryKey: applicationKey(applicationOwner, "workspaces") }) }); + }; + if (!scenarioId) { return ( { await clearAllCache(); signOut({ fetchOptions: { onSuccess: () => router.push("/sign-in") } }); }} + onClick={onLogout} > Sign out @@ -331,10 +272,8 @@ export function StudioOnboardingClient({
diff --git a/src/web/src/app/(app)/w/[slug]/agents/[id]/files/page.tsx b/src/web/src/app/(app)/w/[slug]/agents/[id]/files/page.tsx index 1858d8739..439ab4e62 100644 --- a/src/web/src/app/(app)/w/[slug]/agents/[id]/files/page.tsx +++ b/src/web/src/app/(app)/w/[slug]/agents/[id]/files/page.tsx @@ -1,17 +1,19 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { useAgentContext } from "@/contexts/agent-context"; -import { useWorkspace } from "@/contexts/workspace-context"; -import { requestWorkspaceBrowse } from "@/lib/api"; +import { useWorkspaceOwner } from "@/contexts/workspace-context"; +import { workspaceFileOptions } from "@/hooks/workspace/file-query-options"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { ScrollArea } from "@/components/ui/scroll-area"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; import { useIsMobile } from "@/hooks/use-mobile"; import { Streamdown } from "streamdown"; -import type { WsMessage, WorkspaceFileEntry } from "@alook/shared"; +import type { WorkspaceFileEntry } from "@alook/shared"; import { ArrowLeft, ChevronRight, @@ -24,203 +26,39 @@ import { Loader2, } from "lucide-react"; -// --- Tree node state --- - interface TreeNode { entry: WorkspaceFileEntry; - children: TreeNode[] | null; // null = not loaded - loading: boolean; - expanded: boolean; } export default function AgentFilesPage() { const params = useParams(); - const agentId = params.id as string; - const { workspaceId } = useWorkspace(); + return ; +} + +function AgentFilesSurface({ agentId }: { agentId: string }) { + const owner = useWorkspaceOwner(); + const { workspaceId } = owner; const { subscribeWs, runtimes, agents } = useAgentContext(); const isMobile = useIsMobile(); - - const agent = agents.find((a) => a.id === agentId); - const runtime = agent ? runtimes.find((r) => r.id === agent.runtime_id) : null; + const agent = agents.find((row) => row.id === agentId); + const runtime = agent ? runtimes.find((row) => row.id === agent.runtime_id) : null; const isOnline = runtime?.status === "online"; - - // Tree state: top-level entries + nested children per directory - const [rootNodes, setRootNodes] = useState([]); - const [rootLoading, setRootLoading] = useState(true); - const [rootError, setRootError] = useState(null); - - // File viewer state - const [selectedFile, setSelectedFile] = useState(null); - const [fileContent, setFileContent] = useState(null); - const [fileBinary, setFileBinary] = useState(false); - const [fileLoading, setFileLoading] = useState(false); - const [fileError, setFileError] = useState(null); - const [viewMode, setViewMode] = useState<"raw" | "preview">("preview"); - - // Pending request tracking: map requestId -> { type, path, timer } - const pendingRef = useRef }>>(new Map()); - + const [selectedFile, setSelectedFile] = useAtom(useCreateAtom(null)); + const [viewMode, setViewMode] = useAtom(useCreateAtom<"raw" | "preview">("preview")); + const rootQuery = useQuery({ ...workspaceFileOptions(owner, agentId, runtime?.id ?? null, "tree", ".", subscribeWs), enabled: isOnline }); + const fileQuery = useQuery({ ...workspaceFileOptions(owner, agentId, runtime?.id ?? null, "read", selectedFile ?? "", subscribeWs), enabled: isOnline && !!selectedFile }); + const rootNodes = (rootQuery.data?.entries ?? []).map((entry) => ({ entry })); + const rootLoading = isOnline && rootQuery.isPending; + const rootError = rootQuery.error?.message ?? null; + const fileContent = fileQuery.data?.content ?? null; + const fileBinary = fileQuery.data?.isBinary ?? false; + const fileLoading = !!selectedFile && fileQuery.isPending; + const fileError = fileQuery.error?.message ?? null; const workspacesRoot = runtime?.metadata?.workspaces_root; const rootLabel = `${workspacesRoot || "~/.alook/workspaces"}/${workspaceId}/${agentId}/workdir`; + const requestFile = useCallback((path: string) => { setSelectedFile(path); setViewMode("preview"); }, [setSelectedFile, setViewMode]); + const handleCopyPath = () => { navigator.clipboard.writeText(rootLabel).catch(() => {}); }; - const REQUEST_TIMEOUT_MS = 15_000; - - const clearPending = useCallback((requestId: string) => { - const entry = pendingRef.current.get(requestId); - if (entry) { - clearTimeout(entry.timer); - pendingRef.current.delete(requestId); - } - }, []); - - const setNodeLoading = useCallback((path: string, loading: boolean) => { - setRootNodes((prev) => updateNodeRecursive(prev, path, (n) => ({ ...n, loading }))); - }, []); - - // --- Request helpers --- - - const requestTree = useCallback( - async (path: string) => { - try { - const { request_id } = await requestWorkspaceBrowse(agentId, workspaceId, "tree", path); - const timer = setTimeout(() => { - if (pendingRef.current.has(request_id)) { - pendingRef.current.delete(request_id); - if (path === ".") { - setRootError("Request timed out — daemon may be offline"); - setRootLoading(false); - } else { - setNodeLoading(path, false); - } - } - }, REQUEST_TIMEOUT_MS); - pendingRef.current.set(request_id, { type: "tree", path, timer }); - return request_id; - } catch { - return null; - } - }, - [agentId, workspaceId, setNodeLoading], - ); - - const requestFile = useCallback( - async (path: string) => { - setFileLoading(true); - setFileError(null); - setFileContent(null); - setFileBinary(false); - setSelectedFile(path); - setViewMode("preview"); - try { - const { request_id } = await requestWorkspaceBrowse(agentId, workspaceId, "read", path); - const timer = setTimeout(() => { - if (pendingRef.current.has(request_id)) { - pendingRef.current.delete(request_id); - setFileError("Request timed out — daemon may be offline"); - setFileLoading(false); - } - }, REQUEST_TIMEOUT_MS); - pendingRef.current.set(request_id, { type: "read", path, timer }); - } catch { - setFileError("Failed to request file"); - setFileLoading(false); - } - }, - [agentId, workspaceId], - ); - - // --- Load root on mount + cleanup timers --- - - useEffect(() => { - setRootLoading(true); - setRootError(null); - requestTree("."); - const pending = pendingRef.current; - return () => { - for (const entry of pending.values()) clearTimeout(entry.timer); - pending.clear(); - }; - }, [requestTree]); - - // --- Update tree node helper --- - - const updateNodeChildren = useCallback( - (path: string, children: WorkspaceFileEntry[]) => { - const childNodes: TreeNode[] = children.map((e) => ({ - entry: e, - children: null, - loading: false, - expanded: false, - })); - - if (path === ".") { - setRootNodes(childNodes); - setRootLoading(false); - return; - } - - setRootNodes((prev) => updateNodeRecursive(prev, path, (n) => ({ ...n, children: childNodes, loading: false, expanded: true }))); - }, - [], - ); - - // --- WS handler --- - - useEffect(() => { - return subscribeWs((msg: WsMessage) => { - if (msg.type !== "workspace.files" || msg.agentId !== agentId) return; - - const pending = pendingRef.current.get(msg.requestId); - if (!pending) return; - clearPending(msg.requestId); - - if (pending.type === "tree") { - if (msg.result.error) { - if (pending.path === ".") { - setRootError(msg.result.error); - setRootLoading(false); - } else { - setNodeLoading(pending.path, false); - } - } else { - updateNodeChildren(pending.path, msg.result.entries ?? []); - } - } - - if (pending.type === "read") { - if (msg.result.error) { - setFileError(msg.result.error); - } else { - setFileContent(msg.result.content ?? null); - setFileBinary(msg.result.isBinary ?? false); - } - setFileLoading(false); - } - }); - }, [subscribeWs, agentId, updateNodeChildren, setNodeLoading, clearPending]); - - // --- Toggle directory --- - - const toggleDir = useCallback( - (path: string, node: TreeNode) => { - if (node.expanded) { - // Collapse - setRootNodes((prev) => updateNodeRecursive(prev, path, (n) => ({ ...n, expanded: false }))); - return; - } - // Expand: load if needed - if (node.children === null) { - setNodeLoading(path, true); - requestTree(path); - } - setRootNodes((prev) => updateNodeRecursive(prev, path, (n) => ({ ...n, expanded: true }))); - }, - [requestTree, setNodeLoading], - ); - - const handleCopyPath = () => { - navigator.clipboard.writeText(rootLabel).catch(() => {}); - }; // --- Offline --- @@ -269,11 +107,12 @@ export default function AgentFilesPage() {
{rootNodes.map((node) => ( ))} @@ -291,7 +130,7 @@ export default function AgentFilesPage() {
{isMobile && ( - {node.expanded && node.children && node.children.map((child) => ( + {expanded && children.data?.entries?.map((child) => ( ))} - {node.expanded && node.children && node.children.length === 0 && ( + {expanded && children.data?.entries?.length === 0 && (
empty
)} + {expanded && children.error &&
{children.error.message}
} ); } @@ -457,21 +305,6 @@ function TreeNodeRow({ ); } -// --- Recursive tree update helper --- - -function updateNodeRecursive( - nodes: TreeNode[], - targetPath: string, - updater: (node: TreeNode) => TreeNode, -): TreeNode[] { - return nodes.map((node) => { - if (node.entry.path === targetPath) return updater(node); - if (node.children && targetPath.startsWith(node.entry.path + "/")) { - return { ...node, children: updateNodeRecursive(node.children, targetPath, updater) }; - } - return node; - }); -} // --- Misc helpers --- diff --git a/src/web/src/app/(app)/w/[slug]/agents/[id]/layout.tsx b/src/web/src/app/(app)/w/[slug]/agents/[id]/layout.tsx index ca7d07c68..df79c0cde 100644 --- a/src/web/src/app/(app)/w/[slug]/agents/[id]/layout.tsx +++ b/src/web/src/app/(app)/w/[slug]/agents/[id]/layout.tsx @@ -1,10 +1,13 @@ "use client"; -import { useState, useEffect, type ReactNode } from "react"; +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { modelCatalogOptions } from "@/hooks/workspace/settings-query-options"; import { useParams, useRouter, usePathname, useSearchParams } from "next/navigation"; import Link from "next/link"; import { useAgentContext } from "@/contexts/agent-context"; -import { useWorkspace } from "@/contexts/workspace-context"; +import { useWorkspaceOwner } from "@/contexts/workspace-context"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { AgentEditForm } from "@/components/agent-edit-form"; @@ -20,13 +23,14 @@ import { DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; -import { fetchModelOptions } from "@/lib/api"; + export default function AgentDetailLayout({ children }: { children: ReactNode }) { const params = useParams(); const router = useRouter(); const pathname = usePathname(); - const { slug } = useWorkspace(); + const owner = useWorkspaceOwner(); + const { slug } = owner; const searchParams = useSearchParams(); const agentId = params.id as string; const isActivityView = !!searchParams.get("conv"); @@ -48,15 +52,11 @@ export default function AgentDetailLayout({ children }: { children: ReactNode }) const { activeTaskCounts } = useAgentContext(); const taskCount = activeTaskCounts[agentId] ?? 0; - const [editing, setEditing] = useState(false); - const [saving, setSaving] = useState(false); - const [agentConfirmOpen, setAgentConfirmOpen] = useState(false); - const [agentDeleting, setAgentDeleting] = useState(false); - const [modelOptions, setModelOptions] = useState>({}); - - useEffect(() => { - fetchModelOptions().then(setModelOptions).catch(() => {}); - }, []); + const [editing, setEditing] = useAtom(useCreateAtom(false)); + const [saving, setSaving] = useAtom(useCreateAtom(false)); + const [agentConfirmOpen, setAgentConfirmOpen] = useAtom(useCreateAtom(false)); + const [agentDeleting, setAgentDeleting] = useAtom(useCreateAtom(false)); + const modelOptions = useQuery(modelCatalogOptions(owner)).data ?? {}; return ( <> diff --git a/src/web/src/app/(app)/w/[slug]/agents/[id]/meetings/page.tsx b/src/web/src/app/(app)/w/[slug]/agents/[id]/meetings/page.tsx index 1385ccb7e..cdf8f052c 100644 --- a/src/web/src/app/(app)/w/[slug]/agents/[id]/meetings/page.tsx +++ b/src/web/src/app/(app)/w/[slug]/agents/[id]/meetings/page.tsx @@ -1,8 +1,12 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useEffect } from "react"; import { useParams } from "next/navigation"; -import { useWorkspace } from "@/contexts/workspace-context"; +import { useWorkspaceOwner } from "@/contexts/workspace-context"; +import { useWorkspaceMeetings } from "@/hooks/workspace/use-workspace-meetings"; +import { useWorkspaceViewSource } from "@/hooks/workspace/use-workspace-view-source"; +import { isAbortError } from "@/lib/errors"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Sheet, SheetContent, SheetBody, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"; @@ -11,7 +15,6 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Video, Plus, Square, Check, Clock, AlertCircle, Loader2, Trash2, ShieldCheck } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; -import { listMeetings, createMeeting, stopMeeting, approveMeeting, deleteMeeting } from "@/lib/api"; import type { MeetingSession } from "@alook/shared"; const STATUS_CONFIG: Record = { @@ -42,107 +45,76 @@ function formatTime(iso: string | null) { export default function AgentMeetingsPage() { const params = useParams(); - const agentId = params.id as string; - const { workspaceId } = useWorkspace(); - - const [meetings, setMeetings] = useState([]); - const [loading, setLoading] = useState(true); - const [createOpen, setCreateOpen] = useState(false); - const [creating, setCreating] = useState(false); - const [deleteTarget, setDeleteTarget] = useState(null); - const [deleting, setDeleting] = useState(false); - - // Create form state - const [meetUrl, setMeetUrl] = useState(""); - const [meetTitle, setMeetTitle] = useState(""); - const [meetParticipants, setMeetParticipants] = useState(""); - - - const loadMeetings = useCallback(async () => { - try { - const data = await listMeetings(agentId, workspaceId); - setMeetings(data); - } catch { - toast.error("Failed to load meetings"); - } finally { - setLoading(false); - } - }, [agentId, workspaceId]); - - useEffect(() => { - let cancelled = false; - listMeetings(agentId, workspaceId) - .then((data) => { if (!cancelled) { setMeetings(data); setLoading(false); } }) - .catch(() => { if (!cancelled) { toast.error("Failed to load meetings"); setLoading(false); } }); - return () => { cancelled = true; }; - }, [agentId, workspaceId]); + const owner = useWorkspaceOwner(); + return ; +} - // Auto-refresh for active meetings +function AgentMeetingsView({ agentId }: { agentId: string }) { + const owner = useWorkspaceOwner(); + const resource = useWorkspaceMeetings(agentId); + const meetings = resource.data ?? []; + const loading = resource.isPending; + const [createOpen, setCreateOpen] = useAtom(useCreateAtom(false)); + const [deleteId, setDeleteId] = useAtom(useCreateAtom(null)); + const deleteTarget = meetings.find((meeting) => meeting.id === deleteId); + const creating = resource.mutation.isPending && resource.mutation.variables?.kind === "create"; + const deleting = resource.mutation.isPending && resource.mutation.variables?.kind === "delete"; + const [meetUrl, setMeetUrl] = useAtom(useCreateAtom("")); + const [meetTitle, setMeetTitle] = useAtom(useCreateAtom("")); + const [meetParticipants, setMeetParticipants] = useAtom(useCreateAtom("")); + const source = useWorkspaceViewSource(owner, `meetings:${agentId}`, true); + const creation = useWorkspaceViewSource(owner, `meetings:${agentId}:create`, createOpen); + const deletion = useWorkspaceViewSource(owner, `meetings:${agentId}:delete:${deleteId}`, deleteId !== null); useEffect(() => { - const hasActive = meetings.some((m) => m.status === "joining" || m.status === "recording"); - if (!hasActive) return; - const interval = setInterval(loadMeetings, 5000); - return () => clearInterval(interval); - }, [meetings, loadMeetings]); - + if (!resource.error || isAbortError(resource.error)) return; + try { source.assertActive() } catch { return; } + toast.error("Failed to load meetings"); + }, [resource.error, source, source.assertActive]); const handleCreate = async () => { - if (!meetUrl) return; - setCreating(true); + if (!meetUrl || resource.isCommandPending()) return; + const assertUI = creation.assertActive; try { - const participants = meetParticipants - .split(/[,\n]/) - .map((s) => s.trim()) - .filter(Boolean); - await createMeeting(agentId, workspaceId, { - meetingUrl: meetUrl, - title: meetTitle || undefined, - participants: participants.length > 0 ? participants : undefined, - }); + assertUI(); + const participants = meetParticipants.split(/[,\n]/).map((value) => value.trim()).filter(Boolean); + await resource.mutation.mutateAsync({ kind: "create", data: { meetingUrl: meetUrl, title: meetTitle || undefined, participants: participants.length ? participants : undefined }, assertUI }); + assertUI(); toast.success("Meeting created"); setCreateOpen(false); - setMeetUrl(""); - setMeetTitle(""); - setMeetParticipants(""); - loadMeetings(); - } catch (e: unknown) { - toast.error(e instanceof Error ? e.message : "Failed to create meeting"); - } finally { - setCreating(false); - } - }; - - const handleStop = async (meeting: MeetingSession) => { - try { - await stopMeeting(agentId, meeting.id, workspaceId); - toast.success("Meeting stopped"); - loadMeetings(); - } catch { - toast.error("Failed to stop meeting"); + setMeetUrl(""); setMeetTitle(""); setMeetParticipants(""); + void resource.refetch(); + } catch (error) { + try { assertUI() } catch { return; } + if (!isAbortError(error)) toast.error(error instanceof Error ? error.message : "Failed to create meeting"); } }; - - const handleApprove = async (meeting: MeetingSession) => { + const changeMeeting = async (kind: "stop" | "approve", meeting: MeetingSession) => { + if (resource.isCommandPending()) return; + const assertUI = source.assertActive; try { - await approveMeeting(agentId, meeting.id, workspaceId); - toast.success("Meeting approved"); - loadMeetings(); - } catch { - toast.error("Failed to approve meeting"); + assertUI(); + await resource.mutation.mutateAsync({ kind, id: meeting.id, assertUI }); + assertUI(); + toast.success(kind === "stop" ? "Meeting stopped" : "Meeting approved"); + void resource.refetch(); + } catch (error) { + try { assertUI() } catch { return; } + if (!isAbortError(error)) toast.error(kind === "stop" ? "Failed to stop meeting" : "Failed to approve meeting"); } }; - + const handleStop = (meeting: MeetingSession) => changeMeeting("stop", meeting); + const handleApprove = (meeting: MeetingSession) => changeMeeting("approve", meeting); const handleDelete = async () => { - if (!deleteTarget) return; - setDeleting(true); + if (!deleteId || resource.isCommandPending()) return; + const assertUI = deletion.assertActive; try { - await deleteMeeting(agentId, deleteTarget.id, workspaceId); - toast.success("Meeting deleted"); - setDeleteTarget(null); - loadMeetings(); - } catch { - toast.error("Failed to delete meeting"); - } finally { - setDeleting(false); + assertUI(); + await resource.mutation.mutateAsync({ kind: "delete", id: deleteId, assertUI }); + assertUI(); + toast.success("Meeting deleted"); setDeleteId(null); + void resource.refetch(); + } catch (error) { + try { assertUI() } catch { return; } + if (!isAbortError(error)) toast.error("Failed to delete meeting"); } }; @@ -233,8 +205,9 @@ export default function AgentMeetingsPage() { @@ -303,8 +276,8 @@ export default function AgentMeetingsPage() { {/* Delete Confirmation */} !open && setDeleteTarget(null)} + open={deleteId !== null} + onOpenChange={(open) => !open && setDeleteId(null)} title="Delete meeting" description={`Remove "${deleteTarget?.title || "this meeting"}" and its transcript?`} loading={deleting} diff --git a/src/web/src/app/(app)/w/[slug]/agents/new/page.tsx b/src/web/src/app/(app)/w/[slug]/agents/new/page.tsx index 9d90b157f..6dbdcd5ee 100644 --- a/src/web/src/app/(app)/w/[slug]/agents/new/page.tsx +++ b/src/web/src/app/(app)/w/[slug]/agents/new/page.tsx @@ -1,9 +1,12 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { useRef, useCallback, type ComponentProps } from "react"; import { useRouter } from "next/navigation"; import { useAgentContext } from "@/contexts/agent-context"; -import { useWorkspace } from "@/contexts/workspace-context"; +import { assertWorkspaceOwner, captureWorkspaceOwner, runWorkspaceRequest, useWorkspaceOwner } from "@/contexts/workspace-context"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { isAbortError } from "@/lib/errors"; +import { runApplicationRequest } from "@/lib/application-owner"; import { AgentCreateForm } from "@/components/agent-create-form"; import { fetchModelOptions, createEmailAccount } from "@/lib/api"; import { toast } from "sonner"; @@ -13,9 +16,13 @@ import { Button } from "@/components/ui/button"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +type CreateAgentInput = Parameters["onSave"]>[0]; +const EMPTY_MODELS: Record = {}; + export default function CreateAgentPage() { const router = useRouter(); - const { slug, workspaceId } = useWorkspace(); + const workspace = useWorkspaceOwner(); + const { slug, workspaceId } = workspace; const { agents, runtimes, @@ -23,13 +30,46 @@ export default function CreateAgentPage() { getFirstOnlineRuntimeId, } = useAgentContext(); - const [saving, setSaving] = useState(false); - const [modelOptions, setModelOptions] = useState>({}); const startTourRef = useRef<(() => void) | null>(null); - - useEffect(() => { - fetchModelOptions().then(setModelOptions).catch(() => {}); - }, []); + const modelQuery = useQuery({ + queryKey: ["application", workspace.application.userId, "model-options"], + queryFn: ({ signal }) => runApplicationRequest(workspace.application, (options) => fetchModelOptions(options), signal), + }); + const modelOptions = modelQuery.data ?? EMPTY_MODELS; + const create = useMutation({ + mutationKey: workspace.key("agents", "create-with-email"), + mutationFn: async ({ data, token, agentCount }: { data: CreateAgentInput; token: ReturnType; agentCount: number }) => { + assertWorkspaceOwner(token); + const agent = await handleCreateAgent({ + name: data.name, + description: data.description || undefined, + instructions: data.instructions || undefined, + runtime_id: data.runtime_id, + email_handle: data.email_handle || undefined, + runtime_config: data.runtime_config, + avatar_url: data.avatar_url, + }); + assertWorkspaceOwner(token); + if (!agent) return false; + trackAgentCreated({ is_first_agent: agentCount === 0, has_email: !!data.email_handle || !!data.custom_email }); + if (agentCount === 1) trackSecondAgentCreated({ total_agents: 2 }); + if (data.custom_email) { + try { + await runWorkspaceRequest(workspace, (options) => createEmailAccount(agent.id, data.custom_email!, workspaceId, options)); + assertWorkspaceOwner(token); + toast.success("Custom email connected"); + trackCustomEmailConnected({ email_domain: data.custom_email.emailAddress?.split("@")[1] ?? "" }); + } catch (error) { + assertWorkspaceOwner(token); + if (isAbortError(error)) throw error; + toast.error(error instanceof Error ? error.message : "Failed to connect custom email"); + } + } + assertWorkspaceOwner(token); + router.push(`/w/${slug}/agents/${agent.id}/chat`); + return true; + }, + }); const handleTourReady = useCallback((startTour: () => void) => { startTourRef.current = startTour; @@ -62,44 +102,12 @@ export default function CreateAgentPage() { modelOptions={modelOptions} guided={agents.length === 0} onTourReady={handleTourReady} - saving={saving} + saving={create.isPending} onCancel={() => router.back()} onSave={async (data) => { - setSaving(true); - try { - const agent = await handleCreateAgent({ - name: data.name, - description: data.description || undefined, - instructions: data.instructions || undefined, - runtime_id: data.runtime_id, - email_handle: data.email_handle || undefined, - runtime_config: data.runtime_config, - avatar_url: data.avatar_url, - }); - if (agent) { - trackAgentCreated({ - is_first_agent: agents.length === 0, - has_email: !!data.email_handle || !!data.custom_email, - }); - if (agents.length === 1) { - trackSecondAgentCreated({ total_agents: 2 }); - } - if (data.custom_email) { - try { - await createEmailAccount(agent.id, data.custom_email, workspaceId); - toast.success("Custom email connected"); - const domain = data.custom_email.emailAddress?.split("@")[1] ?? ""; - trackCustomEmailConnected({ email_domain: domain }); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to connect custom email"); - } - } - router.push(`/w/${slug}/agents/${agent.id}/chat`); - } - return !!agent; - } finally { - setSaving(false); - } + const token = captureWorkspaceOwner(workspace); + try { return await create.mutateAsync({ data, token, agentCount: agents.length }); } + catch (error) { if (isAbortError(error)) return false; throw error; } }} /> diff --git a/src/web/src/app/(app)/w/[slug]/calendar/page.tsx b/src/web/src/app/(app)/w/[slug]/calendar/page.tsx index 59c2108b0..d66cfdcbc 100644 --- a/src/web/src/app/(app)/w/[slug]/calendar/page.tsx +++ b/src/web/src/app/(app)/w/[slug]/calendar/page.tsx @@ -1,11 +1,16 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useQuery, useMutation, skipToken, type QueryKey } from "@tanstack/react-query"; +import { useWorkspaceViewSource } from "@/hooks/workspace/use-workspace-view-source"; +import { captureApplicationOwner, assertApplicationOwner } from "@/lib/application-owner"; +import { isAbortError } from "@/lib/errors"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { useAgentContext } from "@/contexts/agent-context"; -import { useWorkspace } from "@/contexts/workspace-context"; +import { useWorkspaceOwner } from "@/contexts/workspace-context"; import { Button } from "@/components/ui/button"; import { Plus } from "lucide-react"; @@ -68,8 +73,9 @@ function gridRangeIso(year: number, month: number) { } export default function CalendarPage() { - const { workspaceId } = useWorkspace(); - const { agents } = useAgentContext(); + const owner = useWorkspaceOwner(); + const { workspaceId } = owner; + const { agents, subscribeWs } = useAgentContext(); const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -82,39 +88,34 @@ export default function CalendarPage() { : now.getMonth(); const initialView = parseCalendarView(searchParams.get("view")); - const [year, setYear] = useState(initialYear); - const [month, setMonth] = useState(initialMonth); - const [view, setView] = useState(initialView); - const [selectedAgents, setSelectedAgents] = useState>(() => { + const [year, setYear] = useAtom(useCreateAtom(initialYear)); + const [month, setMonth] = useAtom(useCreateAtom(initialMonth)); + const [view, setView] = useAtom(useCreateAtom(initialView)); + const [selectedAgents, setSelectedAgents] = useAtom(useCreateAtom>(useMemo>(() => { const param = searchParams.get("agents"); if (!param) return new Set(); return new Set(param.split(",").filter(Boolean)); - }); + }, [searchParams]))); // Default focus to today when the current view contains today, otherwise the // 1st of the viewed month — keeps at least one day cell in the tab order. - const [focusedDate, setFocusedDate] = useState(() => { + const [focusedDate, setFocusedDate] = useAtom(useCreateAtom(useMemo(() => { const t = new Date(); if (t.getFullYear() === initialYear && t.getMonth() === initialMonth) return t; return new Date(initialYear, initialMonth, 1); - }); - const [weekAnchor, setWeekAnchor] = useState(() => { + }, [initialMonth, initialYear]))); + const [weekAnchor, setWeekAnchor] = useAtom(useCreateAtom(useMemo(() => { if (initialView === "week" && searchParams.has("d")) { const d = Number(searchParams.get("d")); return getWeekStart(new Date(initialYear, initialMonth, d)); } return getWeekStart(new Date()); - }); - const [openPopoverKey, setOpenPopoverKey] = useState(null); - - const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(true); - const [createOpen, setCreateOpen] = useState(false); - const [createDefault, setCreateDefault] = useState(); - const [detail, setDetail] = useState(null); - const [detailOpen, setDetailOpen] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [submittingEdit, setSubmittingEdit] = useState(false); - const [deletingId, setDeletingId] = useState(null); + }, [initialMonth, initialView, initialYear, searchParams]))); + const [openPopoverKey, setOpenPopoverKey] = useAtom(useCreateAtom(null)); + + const [createOpen, setCreateOpen] = useAtom(useCreateAtom(false)); + const [createDefault, setCreateDefault] = useAtom(useCreateAtom(undefined)); + const [detailSelection, setDetailSelection] = useAtom(useCreateAtom<{ key: QueryKey; id: string; occurrence: string | null } | null>(null)); + const [detailOpen, setDetailOpen] = useAtom(useCreateAtom(false)); const syncUrl = useCallback( ( @@ -137,25 +138,39 @@ export default function CalendarPage() { [pathname, router] ); - const fetchEvents = useCallback(async () => { - const { from, to } = - view === "week" - ? weekRangeIso(weekAnchor) - : gridRangeIso(year, month); - setLoading(true); - try { - const list = await listCalendarEvents(workspaceId, { from, to }); - setEvents(list); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to load events"); - } finally { - setLoading(false); - } - }, [workspaceId, year, month, view, weekAnchor]); - + const range = useMemo(() => view === "week" ? weekRangeIso(weekAnchor) : gridRangeIso(year, month), [view, weekAnchor, year, month]); + const source = useWorkspaceViewSource(owner, `calendar:${range.from}:${range.to}:${detailSelection?.id ?? ""}:${detailSelection?.occurrence ?? ""}:${detailOpen}:${createOpen}`, true); + const eventsKey = useMemo(() => owner.key("calendar", "range", range.from, range.to), [owner, range.from, range.to]); + const eventsQuery = useQuery({ queryKey: eventsKey, queryFn: ({ signal }) => listCalendarEvents(workspaceId, range, source.request(signal)) }); + const events = eventsQuery.data ?? EMPTY_EVENTS; + const loading = eventsQuery.isPending; + const detail = useQuery({ queryKey: detailSelection?.key ?? owner.key("calendar", "range", "__none__"), queryFn: detailSelection ? ({ signal, queryKey }) => listCalendarEvents(workspaceId, { from: String(queryKey[6]), to: String(queryKey[7]) }, source.request(signal)) : skipToken, enabled: false, + select: (rows: CalendarEvent[]) => rows.find((row) => row.id === detailSelection?.id && (row.occurrence_at ?? null) === detailSelection?.occurrence) ?? null, + }).data ?? null; + const fetchEvents = useCallback(() => owner.queryClient.invalidateQueries({ queryKey: eventsKey, exact: true }), [owner, eventsKey]); useEffect(() => { - fetchEvents(); - }, [fetchEvents]); + if (!eventsQuery.error || eventsQuery.isFetching || isAbortError(eventsQuery.error)) return; + try { source.assertActive(); } catch { return; } + toast.error(eventsQuery.error instanceof Error ? eventsQuery.error.message : "Failed to load events"); + }, [eventsQuery.error, eventsQuery.isFetching, source, source.assertActive]); + useEffect(() => subscribeWs((message) => { + try { source.assertActive(); } catch { return; } + if (message.type.startsWith("calendar.")) void owner.queryClient.invalidateQueries({ queryKey: owner.key("calendar") }); + }), [owner, subscribeWs, source.assertActive, source]); + const calendarMutation = useMutation({ mutationFn: ({ operation }: { operation: () => Promise; kind: "create" | "update" | "delete"; id?: string; assertActive: () => void }) => operation() }); + const currentMutation = calendarMutation.isPending && calendarMutation.variables.assertActive === source.assertActive; + const submitting = currentMutation && calendarMutation.variables.kind === "create"; + const submittingEdit = currentMutation && calendarMutation.variables.kind === "update"; + const deletingId = currentMutation && calendarMutation.variables.kind === "delete" ? calendarMutation.variables.id : null; + const mutateCalendar = calendarMutation.mutateAsync; + const settleFacts = () => { + const token = captureApplicationOwner(owner.application); + const resource = owner.queryClient.getQueryCache().find({ queryKey: eventsKey, exact: true }); + return () => { + try { assertApplicationOwner(token); } catch { return; } + if (resource && owner.queryClient.getQueryCache().find({ queryKey: eventsKey, exact: true }) === resource) void owner.queryClient.invalidateQueries({ queryKey: eventsKey, exact: true, refetchType: "none" }); + }; + }; const visibleEvents = useMemo(() => { if (selectedAgents.size === 0) return events; @@ -169,7 +184,7 @@ export default function CalendarPage() { setYear(ny); setFocusedDate(new Date(ny, nm, 1)); syncUrl(ny, nm, selectedAgents, view); - }, [month, year, selectedAgents, view, syncUrl]); + }, [month, year, setMonth, setYear, setFocusedDate, syncUrl, selectedAgents, view]); const handleNext = useCallback(() => { const nm = month === 11 ? 0 : month + 1; @@ -178,7 +193,7 @@ export default function CalendarPage() { setYear(ny); setFocusedDate(new Date(ny, nm, 1)); syncUrl(ny, nm, selectedAgents, view); - }, [month, year, selectedAgents, view, syncUrl]); + }, [month, year, setMonth, setYear, setFocusedDate, syncUrl, selectedAgents, view]); const handlePrevWeek = useCallback(() => { const prev = new Date(weekAnchor); @@ -186,7 +201,7 @@ export default function CalendarPage() { setWeekAnchor(prev); setFocusedDate(prev); syncUrl(prev.getFullYear(), prev.getMonth(), selectedAgents, "week", prev); - }, [weekAnchor, selectedAgents, syncUrl]); + }, [weekAnchor, setWeekAnchor, setFocusedDate, syncUrl, selectedAgents]); const handleNextWeek = useCallback(() => { const next = new Date(weekAnchor); @@ -194,7 +209,7 @@ export default function CalendarPage() { setWeekAnchor(next); setFocusedDate(next); syncUrl(next.getFullYear(), next.getMonth(), selectedAgents, "week", next); - }, [weekAnchor, selectedAgents, syncUrl]); + }, [weekAnchor, setWeekAnchor, setFocusedDate, syncUrl, selectedAgents]); const handleToggleAgent = (agentId: string) => { const next = new Set(selectedAgents); @@ -211,7 +226,7 @@ export default function CalendarPage() { }; const handleSelectEvent = (ev: CalendarEvent) => { - setDetail(ev); + setDetailSelection({ key: eventsKey, id: ev.id, occurrence: ev.occurrence_at ?? null }); setDetailOpen(true); }; @@ -230,7 +245,7 @@ export default function CalendarPage() { syncUrl(ny, nm, selectedAgents, view); } }, - [selectedAgents, view, syncUrl] + [setYear, setMonth, setFocusedDate, view, setWeekAnchor, syncUrl, selectedAgents] ); const handleJumpToToday = useCallback(() => { @@ -243,7 +258,7 @@ export default function CalendarPage() { } else { jumpToDate(today); } - }, [jumpToDate, view, selectedAgents, syncUrl]); + }, [view, setWeekAnchor, setFocusedDate, syncUrl, selectedAgents, jumpToDate]); const handleViewChange = useCallback( (v: CalendarView) => { @@ -262,7 +277,7 @@ export default function CalendarPage() { syncUrl(year, month, selectedAgents, v); } }, - [year, month, selectedAgents, syncUrl, focusedDate, view, weekAnchor] + [setView, view, focusedDate, setWeekAnchor, syncUrl, year, month, selectedAgents, weekAnchor, setYear, setMonth] ); const handleCreate = async (values: { @@ -273,9 +288,11 @@ export default function CalendarPage() { repeat_interval?: string; repeat_stop_date?: string; }) => { - setSubmitting(true); + const assertActive = source.assertActive; const settle = settleFacts(); + assertActive(); try { - const created = await createCalendarEvent(values, workspaceId); + const created = await mutateCalendar({ kind: "create", assertActive, operation: async () => { await owner.queryClient.cancelQueries({ queryKey: owner.key("calendar", "range") }); assertActive(); return createCalendarEvent(values, workspaceId, { assertActive }); } }) as CalendarEvent; + assertActive(); trackCalendarEventCreated({ agent_id: values.agent_id, is_recurring: !!values.repeat_interval, @@ -289,16 +306,18 @@ export default function CalendarPage() { createdAt >= new Date(from).getTime() && createdAt <= new Date(to).getTime() ) { - setEvents((prev) => [...prev, created]); + owner.queryClient.setQueryData(eventsKey, (prev) => [...prev ?? [], created]); } setCreateOpen(false); toast.success("Event created"); } catch (err) { + if (isAbortError(err)) return; + try { assertActive(); } catch { return; } toast.error( err instanceof Error ? err.message : "Failed to create event" ); } finally { - setSubmitting(false); + settle(); } }; @@ -306,21 +325,26 @@ export default function CalendarPage() { event: CalendarEvent, patch: UpdateCalendarEventRequest ) => { - setSubmittingEdit(true); + const assertActive = source.assertActive; const settle = settleFacts(); + assertActive(); try { - await updateCalendarEvent(event.id, patch, workspaceId); + await mutateCalendar({ kind: "update", id: event.id, assertActive, operation: async () => { await owner.queryClient.cancelQueries({ queryKey: owner.key("calendar", "range") }); assertActive(); return updateCalendarEvent(event.id, patch, workspaceId, { assertActive }); } }); + assertActive(); // Recurring split may have created a detached row and advanced the // parent — simplest to re-fetch the visible range rather than merge. await fetchEvents(); + assertActive(); setDetailOpen(false); - setDetail(null); + setDetailSelection(null); toast.success("Event updated"); } catch (err) { + if (isAbortError(err)) return; + try { assertActive(); } catch { return; } toast.error( err instanceof Error ? err.message : "Failed to update event" ); } finally { - setSubmittingEdit(false); + settle(); } }; @@ -328,26 +352,31 @@ export default function CalendarPage() { event: CalendarEvent, args?: { scope?: "this" | "following"; occurrence_at?: string } ) => { - setDeletingId(event.id); + const assertActive = source.assertActive; const settle = settleFacts(); + assertActive(); try { - await deleteCalendarEvent(event.id, workspaceId, args); + await mutateCalendar({ kind: "delete", id: event.id, assertActive, operation: async () => { await owner.queryClient.cancelQueries({ queryKey: owner.key("calendar", "range") }); assertActive(); return deleteCalendarEvent(event.id, workspaceId, args, { assertActive }); } }); + assertActive(); if (args?.scope) { // Scoped deletes may keep the parent row alive (advance scheduled_at, // append exception, clip repeat_stop_at) — refetch so the grid shows // the new series state instead of optimistically removing the row. await fetchEvents(); + assertActive(); } else { - setEvents((prev) => prev.filter((e) => e.id !== event.id)); + owner.queryClient.setQueryData(eventsKey, (prev) => prev?.filter((e) => e.id !== event.id)); } setDetailOpen(false); - setDetail(null); + setDetailSelection(null); toast.success("Event deleted"); } catch (err) { + if (isAbortError(err)) return; + try { assertActive(); } catch { return; } toast.error( err instanceof Error ? err.message : "Failed to delete event" ); } finally { - setDeletingId(null); + settle(); } }; @@ -487,7 +516,7 @@ export default function CalendarPage() { }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [view, year, month, focusedDate, jumpToDate, hiddenCountForDate, weekAnchor, selectedAgents, syncUrl]); + }, [view, year, month, focusedDate, jumpToDate, hiddenCountForDate, weekAnchor, selectedAgents, syncUrl, setWeekAnchor, setFocusedDate, setCreateDefault, setCreateOpen, setOpenPopoverKey]); // When focusedDate changes, move DOM focus onto that cell — but only if // focus is already inside the grid (don't steal focus on page load). @@ -618,3 +647,5 @@ export default function CalendarPage() { ); } + +const EMPTY_EVENTS: CalendarEvent[] = []; diff --git a/src/web/src/app/(app)/w/[slug]/flags/page.tsx b/src/web/src/app/(app)/w/[slug]/flags/page.tsx index 142625ae4..2f3e1f500 100644 --- a/src/web/src/app/(app)/w/[slug]/flags/page.tsx +++ b/src/web/src/app/(app)/w/[slug]/flags/page.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useCallback, useRef } from "react"; import { useWorkspace } from "@/contexts/workspace-context"; -import { listFlaggedItems, unflagMessage as apiUnflagMessage, type FlaggedItem } from "@/lib/api"; -import { useFlagCount } from "@/contexts/flag-count-context"; +import { type FlaggedItem } from "@/lib/api"; +import { useUnflagWorkspaceMessage, useWorkspaceFlags } from "@/hooks/workspace/use-inbox"; import { useAgentChatSheet } from "@/contexts/agent-chat-sheet-context"; import { Skeleton } from "@/components/ui/skeleton"; import { Button } from "@/components/ui/button"; @@ -12,7 +12,6 @@ import { AgentAvatar } from "@/components/avatar"; import { relativeTime } from "@/lib/time"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; -const FLAG_LIMIT = 30; function FlagRow({ item, @@ -99,80 +98,17 @@ function SkeletonRow({ promptWidth }: { promptWidth: string }) { } export default function FlagsPage() { - const { slug, workspaceId } = useWorkspace(); - const { decrement, increment } = useFlagCount(); + const { slug } = useWorkspace(); const { openAgentChat } = useAgentChatSheet(); - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const [hasMore, setHasMore] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - + const { items, isPending: loading, hasNextPage: hasMore, isFetchingNextPage: loadingMore, fetchNextPage } = useWorkspaceFlags(); + const remove = useUnflagWorkspaceMessage(); const scrollRef = useRef(null); - const isFetchingRef = useRef(false); - - const loadInitial = useCallback(async () => { - setLoading(true); - try { - const result = await listFlaggedItems(workspaceId, { limit: FLAG_LIMIT }); - setItems(result.items); - setHasMore(result.has_more); - } catch { - // silently fail - } finally { - setLoading(false); - } - }, [workspaceId]); - - useEffect(() => { - loadInitial(); - }, [loadInitial]); - - const loadMore = useCallback(async () => { - if (isFetchingRef.current || !hasMore || items.length === 0) return; - isFetchingRef.current = true; - setLoadingMore(true); - try { - const oldest = items[items.length - 1]; - const result = await listFlaggedItems(workspaceId, { - limit: FLAG_LIMIT, - before: oldest.flagged_at, - }); - if (result.items.length === 0) { - setHasMore(false); - return; - } - setHasMore(result.has_more); - setItems((prev) => { - const existingIds = new Set(prev.map((i) => i.id)); - const unique = result.items.filter((i) => !existingIds.has(i.id)); - return [...prev, ...unique]; - }); - } finally { - isFetchingRef.current = false; - setLoadingMore(false); - } - }, [workspaceId, items, hasMore]); - const handleScroll = useCallback(() => { const el = scrollRef.current; - if (!el) return; - const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 200; - if (!loadingMore && hasMore && nearBottom) { - loadMore(); - } - }, [loadMore, loadingMore, hasMore]); - - const handleUnflag = useCallback(async (item: FlaggedItem) => { - setItems((prev) => prev.filter((i) => i.id !== item.id)); - decrement(); - try { - await apiUnflagMessage(workspaceId, item.message_id); - } catch { - setItems((prev) => [...prev, item].sort((a, b) => b.flagged_at.localeCompare(a.flagged_at))); - increment(); - } - }, [workspaceId, decrement, increment]); + if (el && el.scrollHeight - el.scrollTop - el.clientHeight < 200 && !loadingMore && hasMore) void fetchNextPage(); + }, [fetchNextPage, loadingMore, hasMore]); + const handleUnflag = (item: FlaggedItem) => remove.mutate(item.message_id); return (
diff --git a/src/web/src/app/(app)/w/[slug]/home/page.tsx b/src/web/src/app/(app)/w/[slug]/home/page.tsx index 6a5d10cbe..655ba9a9e 100644 --- a/src/web/src/app/(app)/w/[slug]/home/page.tsx +++ b/src/web/src/app/(app)/w/[slug]/home/page.tsx @@ -1,6 +1,8 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { ReactFlow, @@ -33,7 +35,8 @@ import { import type { Agent, AgentLink } from "@alook/shared"; import { cn } from "@/lib/utils"; import { useAgentContext } from "@/contexts/agent-context"; -import { useWorkspace } from "@/contexts/workspace-context"; +import { useWorkspace, useWorkspaceOwner, captureWorkspaceOwner, assertWorkspaceOwner, workspaceRequestOptions, runWorkspaceRequest } from "@/contexts/workspace-context"; +import { useWorkspaceViewSource } from "@/hooks/workspace/use-workspace-view-source"; import { useIsMobile } from "@/hooks/use-mobile"; import { useAgentChatSheet } from "@/contexts/agent-chat-sheet-context"; import { Button } from "@/components/ui/button"; @@ -53,7 +56,7 @@ import { } from "@/lib/api"; import { toast } from "sonner"; import { cliCmd } from "@/lib/utils"; -import { ApiError } from "@/lib/errors"; +import { ApiError, isAbortError } from "@/lib/errors"; import { trackAgentLinkCreated, trackCanvasLayoutChanged } from "@/lib/analytics"; import { AgentNode, type AgentNodeData } from "@/components/canvas/agent-node"; import { LinkEdge } from "@/components/canvas/link-edge"; @@ -109,34 +112,56 @@ function savePositions(workspaceId: string, nodes: Node[]) { function AgentCanvas({ onAgentClick }: { onAgentClick?: (agent: Agent) => void }) { const router = useRouter(); const { agents, runtimes, loading, activeTaskCounts, agentLinks, pendingNewAgent, clearPendingNewAgent } = useAgentContext(); - const { slug, workspaceId } = useWorkspace(); + const owner = useWorkspaceOwner(); + const { slug, workspaceId } = owner; + const source = useWorkspaceViewSource(owner, "canvas", true); const { zoomIn, zoomOut, fitView } = useReactFlow(); const canvasRef = useRef(null); + const canvasScope = `${owner.application.userId}:${workspaceId}`; + const layoutTimers = useRef(new Set>()); + const laterLayout = useCallback((callback: () => void, delay: number) => { + const assertView = source.assertActive; + const timer = setTimeout(() => { + layoutTimers.current.delete(timer); + try { assertView(); } catch { return; } + callback(); + }, delay); + layoutTimers.current.add(timer); + }, [source.assertActive]); + useEffect(() => { + const timers = layoutTimers.current; + return () => { for (const timer of timers) clearTimeout(timer); timers.clear(); }; + }, []); - const [nodes, setNodes] = useState([]); - const [edges, setEdges] = useState([]); - const [links, setLinks] = useState([]); + const geometry = useCreateAtom([]); + const [nodeGeometry, setGeometry] = useAtom(geometry); + const nodes = useMemo(() => nodeGeometry.flatMap((node, index) => { + const agent = agents.find((row) => row.id === node.id); + return agent ? [{ ...node, data: { agent, runtimes, activeTaskCount: activeTaskCounts[node.id] ?? 0, slug, index } satisfies AgentNodeData }] : []; + }), [nodeGeometry, agents, runtimes, activeTaskCounts, slug]); + const setNodes = useCallback((update: Node[] | ((nodes: Node[]) => Node[])) => setGeometry((current) => { + const next = typeof update === "function" ? update(current) : update; + return next.map(({ data: _data, ...node }) => ({ ...node, data: {} })); + }), [setGeometry]); + const links = agentLinks; const linksLoaded = !loading; - const [sidecarLink, setSidecarLink] = useState(null); - const [sidecarOpen, setSidecarOpen] = useState(false); - const [showHint, setShowHint] = useState(false); - const [layoutType, setLayoutType] = useState(() => loadLayoutType(workspaceId)); + const [sidecarLinkId, setSidecarLinkId] = useAtom(useCreateAtom(null)); + const sidecarLink = links.find((link) => link.id === sidecarLinkId) ?? null; + const [sidecarOpen, setSidecarOpen] = useAtom(useCreateAtom(false)); + const showHint = linksLoaded && links.length === 0; + const [layoutType, setLayoutType] = useAtom(useCreateAtom("tree")); const initialLayoutDone = useRef(false); - const handleMap = useRef>({}); + const [handles, setHandles] = useAtom(useCreateAtom>({})); + const [edgeView, setEdgeView] = useAtom(useCreateAtom>({})); + useEffect(() => { setLayoutType(loadLayoutType(`${owner.application.userId}:${workspaceId}`)); }, [owner, workspaceId, setLayoutType]); - // Sync links from context - useEffect(() => { - if (!loading) setLinks(agentLinks); - }, [agentLinks, loading]); - - // Build edges from links - useEffect(() => { - if (!linksLoaded) return; + const edges = useMemo(() => { + if (!linksLoaded) return []; const nodeMap = new Map(nodes.map((n) => [n.id, n.position])); const newEdges: Edge[] = links.map((link) => { - const stored = handleMap.current[link.id]; + const stored = handles[link.id]; let sourceHandle = stored?.sourceHandle; let targetHandle = stored?.targetHandle; @@ -171,23 +196,23 @@ function AgentCanvas({ onAgentClick }: { onAgentClick?: (agent: Agent) => void } onEdgeClick: (edgeId: string) => { const l = links.find((lk) => lk.id === edgeId); if (l) { - setSidecarLink(l); + setSidecarLinkId(l.id); setSidecarOpen(true); } }, }, selected: sidecarLink?.id === link.id && sidecarOpen, + ...edgeView[link.id], }; }); - setEdges(newEdges); - setShowHint(newEdges.length === 0); - }, [links, linksLoaded, sidecarLink, sidecarOpen, nodes]); + return newEdges; + }, [links, linksLoaded, sidecarLink, sidecarOpen, nodes, handles, edgeView, setSidecarLinkId, setSidecarOpen]); // Build nodes from agents useEffect(() => { if (loading || agents.length === 0) return; - const saved = loadPositions(workspaceId); + const saved = { ...loadPositions(canvasScope), ...Object.fromEntries(geometry.get().map((node) => [node.id, node.position])) }; // Filter out stale positions const agentIds = new Set(agents.map((a) => a.id)); const validPositions: Record = {}; @@ -230,7 +255,7 @@ function AgentCanvas({ onAgentClick }: { onAgentClick?: (agent: Agent) => void } newNodes = getAutoLayout(newNodes, currentEdges, layoutType); if (!initialLayoutDone.current) { initialLayoutDone.current = true; - setTimeout(() => fitView({ padding: 0.4, duration: 400 }), 50); + laterLayout(() => fitView({ padding: 0.4, duration: 400 }), 50); } if (pendingNewAgent) clearPendingNewAgent(); } @@ -281,96 +306,110 @@ function AgentCanvas({ onAgentClick }: { onAgentClick?: (agent: Agent) => void } return { ...n, position: { x: cx + 350 + siblingIndex * 300, y: cy } }; }); - savePositions(workspaceId, newNodes); - setTimeout(() => fitView({ padding: 0.4, duration: 400, includeHiddenNodes: false }), 50); + savePositions(canvasScope, newNodes); + laterLayout(() => fitView({ padding: 0.4, duration: 400, includeHiddenNodes: false }), 50); if (pendingNewAgent) clearPendingNewAgent(); } setNodes(newNodes); - }, [agents, runtimes, activeTaskCounts, slug, loading, workspaceId, linksLoaded, links, fitView, layoutType, pendingNewAgent, clearPendingNewAgent]); + }, [agents, runtimes, activeTaskCounts, slug, loading, canvasScope, linksLoaded, links, fitView, layoutType, pendingNewAgent, clearPendingNewAgent, geometry, setNodes, laterLayout]); const onNodesChange = useCallback((changes: NodeChange[]) => { setNodes((nds) => applyNodeChanges(changes, nds)); - }, []); + }, [setNodes]); const onEdgesChange = useCallback((changes: EdgeChange[]) => { - setEdges((eds) => applyEdgeChanges(changes, eds)); - }, []); + const updated = applyEdgeChanges(changes, edges); + setEdgeView(Object.fromEntries(updated.map((edge) => [edge.id, { selected: edge.selected, hidden: edge.hidden }]))); + }, [edges, setEdgeView]); const onNodeDragStop = useCallback(() => { setNodes((nds) => { - savePositions(workspaceId, nds); + savePositions(canvasScope, nds); return nds; }); - }, [workspaceId]); - - const onConnect: OnConnect = useCallback( - async (connection: Connection) => { - if (!connection.source || !connection.target) return; + }, [canvasScope, setNodes]); + + const linkKey = owner.key("agent-links"); + const linkCommand = useMutation({ mutationKey: owner.key("agent-link-command"), scope: { id: JSON.stringify(owner.key("agent-link-command")) }, + mutationFn: async ({ action, token }: { action: { kind: "create"; source: string; target: string } | { kind: "update"; id: string; instruction: string } | { kind: "delete"; id: string }; token: ReturnType }) => { + const assert = () => assertWorkspaceOwner(token), qc = owner.queryClient; + assert(); + await qc.cancelQueries({ queryKey: linkKey, exact: true }); + assert(); + const resource = qc.getQueryCache().find({ queryKey: linkKey, exact: true }), baseline = resource?.state.data as AgentLink[] | undefined; + const original = () => resource && qc.getQueryCache().find({ queryKey: linkKey, exact: true }) === resource; + const options = workspaceRequestOptions(token); + let id: string; try { - const created = await createAgentLink( - { - source_agent_id: connection.source, - target_agent_id: connection.target, - }, - workspaceId, - ); - trackAgentLinkCreated({ - source_agent: connection.source, - target_agent: connection.target, - }); - if (connection.sourceHandle || connection.targetHandle) { - handleMap.current[created.id] = { - sourceHandle: connection.sourceHandle ?? "right", - targetHandle: connection.targetHandle ?? "target-left", - }; - } - setLinks((prev) => [...prev, created]); - setShowHint(false); - } catch (e) { - if (e instanceof ApiError) { - if (e.status === 409) toast.error("Link already exists"); - else if (e.status === 400) toast.error("Can't link an agent to itself"); - else toast.error("Failed to create link"); + if (action.kind === "create") { + const confirmed = await createAgentLink({ source_agent_id: action.source, target_agent_id: action.target }, workspaceId, options); + assert(); + id = confirmed.id; + if (original() && resource!.state.data === baseline) qc.setQueryData(linkKey, (rows) => [...(rows ?? []).filter((row) => row.id !== confirmed.id), confirmed]); + } else if (action.kind === "update") { + const confirmed = await updateAgentLink(action.id, { instruction: action.instruction }, workspaceId, options); + assert(); + id = action.id; + const old = baseline?.find((row) => row.id === action.id); + if (original()) qc.setQueryData(linkKey, (rows) => rows?.map((row) => row.id === action.id && row.instruction === old?.instruction ? { ...row, instruction: confirmed.instruction } : row)); } else { - toast.error("Failed to create link"); + await deleteAgentLink(action.id, workspaceId, options); + assert(); + id = action.id; + if (original() && resource!.state.data === baseline) qc.setQueryData(linkKey, (rows) => rows?.filter((row) => row.id !== action.id)); } - } + await qc.invalidateQueries({ queryKey: linkKey, exact: true }); + assert(); + return { id }; + } catch (error) { assert(); throw error; } }, - [workspaceId], - ); - - const handleSidecarSave = useCallback( - async (id: string, instruction: string) => { - try { - const updated = await updateAgentLink(id, { instruction }, workspaceId); - setLinks((prev) => prev.map((l) => (l.id === id ? updated : l))); - } catch { - toast.error("Failed to update link"); - } - }, - [workspaceId], - ); + }); + const onConnect: OnConnect = useCallback(async (connection: Connection) => { + if (!connection.source || !connection.target) return; + const assertView = source.assertActive; + try { + const created = await linkCommand.mutateAsync({ action: { kind: "create", source: connection.source, target: connection.target }, token: captureWorkspaceOwner(owner) }); + assertView(); + trackAgentLinkCreated({ source_agent: connection.source, target_agent: connection.target }); + if (connection.sourceHandle || connection.targetHandle) setHandles((current) => ({ ...current, [created.id]: { sourceHandle: connection.sourceHandle ?? "right", targetHandle: connection.targetHandle ?? "target-left" } })); + } catch (error) { + try { assertView(); } catch { return; } + if (isAbortError(error)) return; + if (error instanceof ApiError && error.status === 409) toast.error("Link already exists"); + else if (error instanceof ApiError && error.status === 400) toast.error("Can't link an agent to itself"); + else toast.error("Failed to create link"); + } + }, [source.assertActive, linkCommand, owner, setHandles]); + const handleSidecarSave = useCallback(async (id: string, instruction: string) => { + const assertView = source.assertActive; + try { + await linkCommand.mutateAsync({ action: { kind: "update", id, instruction }, token: captureWorkspaceOwner(owner) }); + assertView(); + } catch (error) { + try { assertView(); } catch { return; } + if (!isAbortError(error)) toast.error("Failed to update link"); + } + }, [source.assertActive, linkCommand, owner]); + const handleSidecarDelete = useCallback(async (id: string) => { + const assertView = source.assertActive; + try { + await linkCommand.mutateAsync({ action: { kind: "delete", id }, token: captureWorkspaceOwner(owner) }); + assertView(); + setSidecarOpen(false); + } catch (error) { + try { assertView(); } catch { return; } + if (!isAbortError(error)) toast.error("Failed to delete link"); + } + }, [source.assertActive, linkCommand, owner, setSidecarOpen]); - const handleSidecarDelete = useCallback( - async (id: string) => { - try { - await deleteAgentLink(id, workspaceId); - setLinks((prev) => prev.filter((l) => l.id !== id)); - setSidecarOpen(false); - } catch { - toast.error("Failed to delete link"); - } - }, - [workspaceId], - ); const handleLayoutChange = useCallback((newLayout: LayoutType) => { trackCanvasLayoutChanged({ layout_type: newLayout }); setLayoutType(newLayout); - saveLayoutType(workspaceId, newLayout); + saveLayoutType(canvasScope, newLayout); try { - localStorage.removeItem(storageKey(workspaceId)); + localStorage.removeItem(storageKey(canvasScope)); } catch {} const currentEdges: Edge[] = links.map((link) => ({ id: link.id, @@ -383,20 +422,20 @@ function AgentCanvas({ onAgentClick }: { onAgentClick?: (agent: Agent) => void } ...n, style: { ...n.style, transition: "transform 300ms ease-out" }, })); - setTimeout(() => { + laterLayout(() => { setNodes((nds) => { const final = nds.map((n) => ({ ...n, style: { ...n.style, transition: undefined }, })); - savePositions(workspaceId, final); + savePositions(canvasScope, final); return final; }); fitView({ padding: 0.4, duration: 400 }); }, 350); return animated; }); - }, [links, workspaceId, fitView]); + }, [links, canvasScope, fitView, setNodes, setLayoutType, laterLayout]); const onNodeClick = useCallback( (_event: React.MouseEvent, node: Node) => { @@ -408,7 +447,7 @@ function AgentCanvas({ onAgentClick }: { onAgentClick?: (agent: Agent) => void } router.push(`/w/${slug}/agents/${nodeData.agent.id}`); } }, - [router, slug, onAgentClick], + [setSidecarOpen, onAgentClick, router, slug], ); const connectionLineStyle = useMemo( @@ -633,21 +672,24 @@ function MobileAgentList({ onAgentClick }: { onAgentClick?: (agent: Agent) => vo } function ConnectComputerCard({ workspaceId }: { workspaceId: string }) { - const [token, setToken] = useState(""); - const [loading, setLoading] = useState(true); - - useEffect(() => { - createMachineToken("cli", workspaceId) - .then((res) => setToken(res.token)) - .catch(() => toast.error("Failed to generate token")) - .finally(() => setLoading(false)); - }, [workspaceId]); - - const command = `${cliCmd()} register --token ${token}`; - - const copy = () => { - navigator.clipboard.writeText(command); - toast.success("Copied to clipboard"); + const owner = useWorkspaceOwner(); + const source = useWorkspaceViewSource(owner, "connect-computer", true); + const pairing = useQuery({ queryKey: owner.key("machine-pairing", "cli"), gcTime: 0, staleTime: Infinity, retry: false, + queryFn: ({ signal }) => runWorkspaceRequest(owner, (options) => createMachineToken("cli", workspaceId, options), signal), + }); + const command = `${cliCmd()} register --token ${pairing.data?.token ?? ""}`; + + const copy = async () => { + const assertView = source.assertActive; + try { + assertView(); + await navigator.clipboard.writeText(command); + assertView(); + toast.success("Copied to clipboard"); + } catch (error) { + try { assertView(); } catch { return; } + if (!isAbortError(error)) toast.error("Failed to copy command"); + } }; return ( @@ -656,10 +698,12 @@ function ConnectComputerCard({ workspaceId }: { workspaceId: string }) {

Run this command in your terminal to link your machine.

- {loading ? ( + {pairing.isPending ? (
Generating token...
+ ) : pairing.isError ? ( + ) : ( <>
(`issue-recent-agent-id-${workspaceId}`, ""); - const [draft, setDraft] = useLocalStorage<{ title: string; description: string; agentId: string }>(`issue-draft-${workspaceId}`, { title: "", description: "", agentId: "" }); - const [showCompleted, setShowCompleted] = useLocalStorage("issues-show-completed", true); - const [issues, setIssues] = useState([]); - const issuesRef = useRef([]); - useEffect(() => { issuesRef.current = issues; }); - const [loading, setLoading] = useState(true); - const [creating, setCreating] = useState(false); - const [sheetOpen, setSheetOpen] = useState(false); - const [selectedId, setSelectedId] = useState(null); - const [detail, setDetail] = useState<{ issue: Issue & { trace_id?: string | null }; messages: Message[]; comments: IssueComment[]; artifacts: Artifact[] } | null>(null); - const [detailLoading, setDetailLoading] = useState(false); - const [activeTask, setActiveTask] = useState(null); - const [traceTasks, setTraceTasks] = useState(null); - const [activeDragId, setActiveDragId] = useState(null); - const [artifactSheetOpen, setArtifactSheetOpen] = useState(false); - const [selectedArtifact, setSelectedArtifact] = useState(null); - const pendingStatusUpdate = useRef(null); - const refreshSeqRef = useRef(0); - const selectedIdRef = useRef(null); + const owner = useWorkspaceOwner(); + const { workspaceId, slug } = owner; + const { agents, loading: agentsLoading } = useAgentContext(); + const scope = owner.application.userId + ":" + workspaceId; + const [recentAgentId, setRecentAgentId] = useLocalStorage("issue-recent-agent-id-" + scope, ""); + const [draft, setDraft] = useLocalStorage<{ title: string; description: string; agentId: string }>("issue-draft-" + scope, { title: "", description: "", agentId: "" }); + const [showCompleted, setShowCompleted] = useLocalStorage("issues-show-completed-" + owner.application.userId, true); + const [sheetOpen, setSheetOpen] = useAtom(useCreateAtom(false)); + const selection = useCreateAtom(null); + const [selectedId, setSelectedId] = useAtom(selection); + const [activeDragId, setActiveDragId] = useAtom(useCreateAtom(null)); + const [artifactSheetOpen, setArtifactSheetOpen] = useAtom(useCreateAtom(false)); + const [selectedArtifactId, setSelectedArtifactId] = useAtom(useCreateAtom(null)); + const source = useWorkspaceViewSource(owner, JSON.stringify(["issue-board", selectedId, sheetOpen]), true); + const listQuery = useQuery(workspaceIssueListOptions(owner)); + const issueQuery = useQuery({ ...workspaceIssueOptions(owner, sheetOpen ? selectedId ?? "__none__" : "__none__"), enabled: sheetOpen && !!selectedId }); + const { mutateAsync: mutateIssue } = useIssueCommand(owner); + const pending = usePendingIssueChanges(owner); + const projectIssue = useCallback((issue: IssueListItem) => pending.reduce((current, action) => action.kind === "update" && action.id === current.id ? { ...current, ...action.patch } : current, issue), [pending]); + const issues = useMemo(() => (listQuery.data ?? []).filter((row) => !pending.some((action) => action.kind === "delete" && action.id === row.id)).map(projectIssue), [listQuery.data, pending, projectIssue]); + const detail = useMemo(() => issueQuery.data ? { ...issueQuery.data, issue: { ...issueQuery.data.issue, ...projectIssue(issueQuery.data.issue) } } : null, [issueQuery.data, projectIssue]); + const detailLoading = issueQuery.isPending && !!selectedId && sheetOpen; + const taskId = detail?.issue.latest_task_id; + const traceId = detail?.issue.trace_id; + const activeTask = useQuery({ ...workspaceTaskOptions(owner, taskId ?? "__none__"), enabled: sheetOpen && !!taskId }).data ?? null; + const traceTasks = useQuery({ ...workspaceTraceOptions(owner, traceId ?? "__none__"), enabled: sheetOpen && !!traceId }).data?.tasks ?? null; + const loading = listQuery.isPending; + const creating = pending.some((action) => action.kind === "create"); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); - const agentsById = useMemo(() => new Map(agents.map(a => [a.id, a])), [agents]); - + const agentsById = useMemo(() => new Map(agents.map((agent) => [agent.id, agent])), [agents]); const issueArtifacts = useMemo(() => detail?.artifacts ?? [], [detail?.artifacts]); - const { versionMap: artifactVersionMap, duplicateFilenames: artifactDuplicateFilenames } = useMemo( - () => computeArtifactVersions(issueArtifacts), - [issueArtifacts], - ); - - const previewArtifact = useCallback((artifact: Artifact) => { - setSelectedArtifact(artifact); - setArtifactSheetOpen(true); - }, []); + const selectedArtifact = issueArtifacts.find((artifact) => artifact.id === selectedArtifactId) ?? null; + const { versionMap: artifactVersionMap, duplicateFilenames: artifactDuplicateFilenames } = useMemo(() => computeArtifactVersions(issueArtifacts), [issueArtifacts]); + const previewArtifact = useCallback((artifact: Artifact) => { setSelectedArtifactId(artifact.id); setArtifactSheetOpen(true); }, [setSelectedArtifactId, setArtifactSheetOpen]); const handleArtifactClick = useArtifactClick(workspaceId, previewArtifact); - async function reload() { - setLoading(true); - try { - const [active, completed] = await Promise.all([ - listIssues(workspaceId, { terminal: false }), - listIssues(workspaceId, { terminal: true }), - ]); - setIssues([...active, ...completed]); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to load issues"); - } finally { - setLoading(false); - } - } - - async function silentReload() { - try { - const [active, completed] = await Promise.all([ - listIssues(workspaceId, { terminal: false }), - listIssues(workspaceId, { terminal: true }), - ]); - setIssues([...active, ...completed]); - } catch { - // silent — background refresh, errors are transient - } - } - - const silentReloadTimerRef = useRef | null>(null); - const needsDetailRefreshRef = useRef(false); - - function debouncedSilentReload() { - if (silentReloadTimerRef.current) clearTimeout(silentReloadTimerRef.current); - silentReloadTimerRef.current = setTimeout(() => { - silentReloadTimerRef.current = null; - silentReload().catch(() => {}); - if (needsDetailRefreshRef.current) { - if (selectedIdRef.current) { - refreshIssue(selectedIdRef.current).catch(() => {}); - } - needsDetailRefreshRef.current = false; - } - }, 300); - } - - useEffect(() => { - reload(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspaceId]); - - async function openIssue(issueId: string) { - setSelectedId(issueId); - selectedIdRef.current = issueId; + const openIssue = useCallback((id: string) => { + source.assertActive(); + setSelectedId(id); setSheetOpen(true); - setDetailLoading(true); - setTraceTasks(null); - try { - const res = await getIssue(workspaceId, issueId); - setDetail(res); - if (res.issue.trace_id) { - getTrace(res.issue.trace_id, workspaceId) - .then(t => setTraceTasks(t.tasks)) - .catch(() => setTraceTasks(null)); - } - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to load issue"); - } finally { - setDetailLoading(false); - } - } - - async function refreshIssue(issueId: string) { - const seq = ++refreshSeqRef.current; - try { - const res = await getIssue(workspaceId, issueId); - if (seq !== refreshSeqRef.current || selectedIdRef.current !== issueId) return; - setDetail(res); - if (res.issue.trace_id) { - getTrace(res.issue.trace_id, workspaceId) - .then(t => { if (seq === refreshSeqRef.current && selectedIdRef.current === issueId) setTraceTasks(t.tasks); }) - .catch(() => {}); - } - const taskId = res.issue.latest_task_id; - if (taskId) { - getTask(taskId, workspaceId) - .then(task => { if (seq === refreshSeqRef.current && selectedIdRef.current === issueId) setActiveTask(task); }) - .catch(() => {}); - } - } catch (err: unknown) { - if (err && typeof err === "object" && "status" in err && (err as { status: number }).status === 404) { - handleSheetOpenChange(false); - } - } - } - - const detailConvId = detail?.issue.conversation_id ?? null; - const detailConvIdRef = useRef(null); - useEffect(() => { detailConvIdRef.current = detailConvId; }); - const detailTaskId = detail?.issue.latest_task_id ?? null; - const detailTaskIdRef = useRef(null); - useEffect(() => { detailTaskIdRef.current = detailTaskId; }); - const detailAgentId = detail?.issue.agent_id ?? null; - const detailAgentIdRef = useRef(null); - useEffect(() => { detailAgentIdRef.current = detailAgentId; }); - - useEffect(() => { - if (!detailTaskId) { setActiveTask(null); return; } - let cancelled = false; - getTask(detailTaskId, workspaceId).then((task) => { - if (!cancelled) setActiveTask(task); - }).catch(() => {}); - return () => { cancelled = true; }; - }, [detailTaskId, workspaceId]); - + }, [source, setSelectedId, setSheetOpen]); + const handleSheetOpenChange = useCallback((open: boolean) => { + setSheetOpen(open); + if (!open) setSelectedId(null); + }, [setSheetOpen, setSelectedId]); useEffect(() => { - const unsub = subscribeWs((msg: WsMessage) => { - if (msg.type === "conversation.message" && detailConvIdRef.current && msg.conversationId === detailConvIdRef.current) { - setDetail((prev) => { - if (!prev) return prev; - if (prev.messages.some((m) => m.id === msg.message.id)) return prev; - return { ...prev, messages: [...prev.messages, msg.message] }; - }); - if (msg.message.role === "event" && msg.message.content.startsWith("Issue status changed:") && !pendingStatusUpdate.current) { - const match = msg.message.content.match(/-> (\w+)/); - if (match) { - const newStatus = match[1] as Issue["status"]; - setDetail((prev) => prev ? { ...prev, issue: { ...prev.issue, status: newStatus } } : prev); - setIssues((prev) => prev.map((i) => i.conversation_id === detailConvIdRef.current ? { ...i, status: newStatus, updated_at: msg.message.created_at } : i)); - } - } - } - if (msg.type === "conversation.message" - && msg.message.role === "event" - && msg.message.content.startsWith("Issue status changed:") - && !pendingStatusUpdate.current - && msg.conversationId !== detailConvIdRef.current) { - const match = msg.message.content.match(/-> (\w+)/); - if (match) { - const newStatus = match[1] as Issue["status"]; - setIssues((prev) => prev.map((i) => - i.conversation_id === msg.conversationId - ? { ...i, status: newStatus, updated_at: msg.message.created_at } - : i - )); - } - } - if (msg.type === "issue.comment" && selectedIdRef.current === msg.issueId) { - setDetail((prev) => { - if (!prev) return prev; - if (prev.comments.some((c) => c.id === msg.comment.id)) return prev; - return { ...prev, comments: [...prev.comments, msg.comment] }; - }); - } - if (msg.type === "task.updated" && (msg.status === "running" || msg.status === "completed" || msg.status === "failed")) { - if (pendingStatusUpdate.current) return; - - const currentIssues = issuesRef.current; - - if (currentIssues.length === 0) { - debouncedSilentReload(); - return; - } - - const issueAgentIds = new Set(currentIssues.map(i => i.agent_id).filter((id): id is string => !!id)); - const issueTaskIds = new Set(currentIssues.map(i => i.latest_task_id).filter((id): id is string => !!id)); - - if (!issueAgentIds.has(msg.agentId) && !issueTaskIds.has(msg.taskId)) return; - - if (msg.taskId === detailTaskIdRef.current || msg.agentId === detailAgentIdRef.current) { - needsDetailRefreshRef.current = true; - } - - debouncedSilentReload(); - } - }); - return () => { - unsub(); - if (silentReloadTimerRef.current) { - clearTimeout(silentReloadTimerRef.current); - silentReloadTimerRef.current = null; - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [subscribeWs]); - - // --- Sheet handlers --- + if (!issueQuery.error || isAbortError(issueQuery.error) || isCancelledError(issueQuery.error)) return; + try { source.assertActive(); } catch { return; } + if (issueQuery.error instanceof ApiError && issueQuery.error.status === 404) handleSheetOpenChange(false); + else toast.error(issueQuery.error instanceof Error ? issueQuery.error.message : "Failed to load issue"); + }, [issueQuery.error, source.assertActive, handleSheetOpenChange, source]); const handleCreate = useCallback(async (values: { agent_id?: string; title: string; description: string }) => { - setCreating(true); + const assertView = source.assertActive; try { - const res = await createIssue(workspaceId, { - agent_id: values.agent_id, - title: values.title, - description: values.description, - }); + assertView(); + const issue = await mutateIssue({ action: { kind: "create", values }, token: captureWorkspaceOwner(owner), assertActive: Object.assign(() => assertView(), { signal: source.signal }) }); + assertView(); + if (!issue) return; trackIssueCreated({ agent_id: values.agent_id ?? "" }); - setIssues((prev) => [res.issue, ...prev]); if (values.agent_id) setRecentAgentId(values.agent_id); setDraft({ title: "", description: "", agentId: "" }); - setSelectedId(res.issue.id); - selectedIdRef.current = res.issue.id; - openIssue(res.issue.id); + setSelectedId(issue.id); + setSheetOpen(true); toast.success("Issue created"); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to create issue"); - } finally { - setCreating(false); + } catch (error) { + try { assertView(); } catch { return; } + if (!isAbortError(error)) toast.error(error instanceof Error ? error.message : "Failed to create issue"); } - }, [workspaceId]); // eslint-disable-line react-hooks/exhaustive-deps + }, [owner, source, mutateIssue, setRecentAgentId, setDraft, setSelectedId, setSheetOpen]); - const handleUpdate = useCallback(async (issueId: string, patch: { title?: string; description?: string }) => { + const handleUpdate = useCallback(async (id: string, patch: { title?: string; description?: string }) => { + const assertView = source.assertActive; try { - const updated = await updateIssue(workspaceId, issueId, patch); - setIssues((prev) => prev.map((i) => i.id === issueId ? { ...i, ...patch, updated_at: updated.updated_at } : i)); - setDetail((prev) => prev && prev.issue.id === issueId ? { ...prev, issue: { ...prev.issue, ...patch, updated_at: updated.updated_at } } : prev); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to update issue"); + assertView(); + await mutateIssue({ action: { kind: "update", id, patch }, token: captureWorkspaceOwner(owner), assertActive: Object.assign(() => assertView(), { signal: source.signal }) }); + assertView(); + } catch (error) { + try { assertView(); } catch { return; } + if (!isAbortError(error)) toast.error(error instanceof Error ? error.message : "Failed to update issue"); } - }, [workspaceId]); + }, [owner, source, mutateIssue]); - const handleStatusChange = useCallback(async (issueId: string, newStatus: string) => { - const issue = issues.find((i) => i.id === issueId); - if (!issue) return; - const oldStatus = issue.status; - if (newStatus === oldStatus) return; - - trackIssueStatusChanged({ from: oldStatus, to: newStatus, method: "button" }); - - const now = new Date().toISOString(); - setIssues((prev) => prev.map((i) => i.id === issueId ? { ...i, status: newStatus as Issue["status"], updated_at: now } : i)); - if (detail?.issue.id === issueId) { - setDetail((prev) => prev ? { ...prev, issue: { ...prev.issue, status: newStatus as Issue["status"], updated_at: now } } : prev); - } - - pendingStatusUpdate.current = issueId; + const handleStatusChange = useCallback(async (id: string, status: string, method: "button" | "drag" = "button") => { + const issue = issues.find((row) => row.id === id); + if (!issue || issue.status === status) return; + const assertView = source.assertActive; try { - await updateIssue(workspaceId, issueId, { status: newStatus as Issue["status"] }); - } catch (err) { - setIssues((prev) => prev.map((i) => i.id === issueId ? { ...i, status: oldStatus } : i)); - if (detail?.issue.id === issueId) { - setDetail((prev) => prev ? { ...prev, issue: { ...prev.issue, status: oldStatus } } : prev); - } - toast.error(err instanceof Error ? err.message : "Failed to update issue status"); - } finally { - pendingStatusUpdate.current = null; + assertView(); + trackIssueStatusChanged({ from: issue.status, to: status, method }); + await mutateIssue({ action: { kind: "update", id, patch: { status: status as Issue["status"] } }, token: captureWorkspaceOwner(owner), assertActive: Object.assign(() => assertView(), { signal: source.signal }) }); + assertView(); + } catch (error) { + try { assertView(); } catch { return; } + if (!isAbortError(error)) toast.error(error instanceof Error ? error.message : "Failed to update issue status"); } - }, [workspaceId, issues, detail]); + }, [owner, issues, source, mutateIssue]); - - const handleDeleteIssue = useCallback(async (issueId: string) => { + const handleDeleteIssue = useCallback(async (id: string) => { + const assertView = source.assertActive; try { - await deleteIssue(workspaceId, issueId); - setIssues((prev) => prev.filter((i) => i.id !== issueId)); - if (selectedId === issueId) { - setSelectedId(null); - selectedIdRef.current = null; - setSheetOpen(false); - setDetail(null); - } + assertView(); + await mutateIssue({ action: { kind: "delete", id }, token: captureWorkspaceOwner(owner), assertActive: Object.assign(() => assertView(), { signal: source.signal }) }); + assertView(); + if (selection.get() === id) handleSheetOpenChange(false); toast.success("Issue deleted"); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to delete issue"); - } - }, [workspaceId, selectedId]); - - const handleSheetOpenChange = useCallback((open: boolean) => { - setSheetOpen(open); - if (!open) { - setSelectedId(null); - selectedIdRef.current = null; - setDetail(null); - setActiveTask(null); - setTraceTasks(null); + } catch (error) { + try { assertView(); } catch { return; } + if (!isAbortError(error)) toast.error(error instanceof Error ? error.message : "Failed to delete issue"); } - }, []); - - // --- DnD handlers --- - - function handleDragStart(event: DragStartEvent) { - setActiveDragId(event.active.id as string); - } + }, [owner, source, mutateIssue, selection, handleSheetOpenChange]); + function handleDragStart(event: DragStartEvent) { setActiveDragId(event.active.id as string); } async function handleDragEnd(event: DragEndEvent) { setActiveDragId(null); const { active, over } = event; if (!over) return; - - const issueId = active.id as string; - const targetColId = over.id as string; - const issue = issues.find((i) => i.id === issueId); - if (!issue) return; - - const targetCol = COLUMNS.find((c) => c.id === targetColId); - if (!targetCol) return; - - if ((targetCol.statuses as readonly string[]).includes(issue.status)) return; - - if (issue.status === "todo" && !issue.agent_id && targetColId !== "todo" && targetColId !== "completed") { - toast.error("Assign an agent first to run this issue"); - return; - } - - const newStatus = targetColId === "completed" ? "done" : targetColId; - const oldStatus = issue.status; - - trackIssueStatusChanged({ from: oldStatus, to: newStatus, method: "drag" }); - - const now = new Date().toISOString(); - setIssues((prev) => prev.map((i) => i.id === issueId ? { ...i, status: newStatus as Issue["status"], updated_at: now } : i)); - if (detail?.issue.id === issueId) { - setDetail((prev) => prev ? { ...prev, issue: { ...prev.issue, status: newStatus as Issue["status"], updated_at: now } } : prev); - } - - pendingStatusUpdate.current = issueId; - try { - await updateIssue(workspaceId, issueId, { status: newStatus as Issue["status"] }); - } catch (err) { - setIssues((prev) => prev.map((i) => i.id === issueId ? { ...i, status: oldStatus } : i)); - if (detail?.issue.id === issueId) { - setDetail((prev) => prev ? { ...prev, issue: { ...prev.issue, status: oldStatus } } : prev); - } - toast.error(err instanceof Error ? err.message : "Failed to update issue status"); - } finally { - pendingStatusUpdate.current = null; - } + const issue = issues.find((row) => row.id === active.id), column = COLUMNS.find((row) => row.id === over.id); + if (!issue || !column || (column.statuses as readonly string[]).includes(issue.status)) return; + if (issue.status === "todo" && !issue.agent_id && column.id !== "todo" && column.id !== "completed") { toast.error("Assign an agent first to run this issue"); return; } + await handleStatusChange(issue.id, column.id === "completed" ? "done" : column.id, "drag"); } - const boardLoading = loading || agentsLoading; - const selectedIssue = selectedId ? issues.find((i) => i.id === selectedId) ?? null : null; + const selectedIssue = selectedId ? detail?.issue ?? issues.find((i) => i.id === selectedId) ?? null : null; return (
@@ -594,7 +359,7 @@ export default function IssuesPage() {

Issues

- @@ -798,7 +563,7 @@ export default function IssuesPage() { onUpdate={handleUpdate} onStatusChange={handleStatusChange} onCommented={() => selectedId && openIssue(selectedId)} - onDispatched={(id) => { silentReload(); openIssue(id); }} + onDispatched={openIssue} onArtifactClick={handleArtifactClick} /> @@ -806,7 +571,7 @@ export default function IssuesPage() { open={artifactSheetOpen} onOpenChange={(v) => { setArtifactSheetOpen(v); - if (!v) setTimeout(() => setSelectedArtifact(null), 300); + if (!v) setSelectedArtifactId(null); }} artifacts={issueArtifacts} workspaceId={workspaceId} diff --git a/src/web/src/app/(app)/w/[slug]/runtimes/page.tsx b/src/web/src/app/(app)/w/[slug]/runtimes/page.tsx index fa87739f4..abb6cf907 100644 --- a/src/web/src/app/(app)/w/[slug]/runtimes/page.tsx +++ b/src/web/src/app/(app)/w/[slug]/runtimes/page.tsx @@ -1,6 +1,8 @@ "use client"; -import { useState, useMemo, useRef, useEffect, useCallback } from "react"; +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo, useRef, useEffect, useCallback } from "react"; import { useSearchParams, useRouter, usePathname } from "next/navigation"; import { useAgentContext } from "@/contexts/agent-context"; import { Button } from "@/components/ui/button"; @@ -29,15 +31,21 @@ import type { AgentRuntime as Runtime } from "@alook/shared"; import { semverGte } from "@alook/shared"; import { cliCmd, getAppMode } from "@/lib/utils"; import { ProviderLogo } from "@/components/provider-logo"; -import { triggerRuntimeUpdate, triggerRuntimeRescan, fetchLatestCliVersion } from "@/lib/api"; +import { createMachineToken } from "@/lib/api"; +import { captureWorkspaceOwner, runWorkspaceRequest, useWorkspaceOwner } from "@/contexts/workspace-context"; +import { useWorkspaceViewSource } from "@/hooks/workspace/use-workspace-view-source"; +import { useRuntimeCommand, usePendingRuntimeCommands } from "@/hooks/workspace/use-runtime-command"; +import { latestCliVersionOptions } from "@/hooks/workspace/settings-query-options"; import { Loader2, RefreshCw } from "lucide-react"; import { ConnectMachineSteps } from "@/components/connect-machine-steps"; import { trackRuntimeConnected } from "@/lib/analytics"; export default function RuntimesPage() { - const { agents, runtimes, loading, handleGenerateToken, handleDeleteMachine, subscribeWs, workspaceId } = + const { runtimes, loading, handleDeleteMachine, subscribeWs, workspaceId } = useAgentContext(); + const owner = useWorkspaceOwner(); + const pageSource = useWorkspaceViewSource(owner, "runtimes", true); const searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname(); @@ -45,31 +53,34 @@ export default function RuntimesPage() { const isMobileApp = mode === "mobile"; const hideNewMachine = isMobileApp; - const [sheetOpen, setSheetOpen] = useState(() => searchParams.has("connect")); - const [generatedToken, setGeneratedToken] = useState(""); - const [generatingToken, setGeneratingToken] = useState(false); - const [registeredDaemonId, setRegisteredDaemonId] = useState(null); - const [daemonOnline, setDaemonOnline] = useState(false); + const initialSheetOpen = useMemo(() => searchParams.has("connect"), [searchParams]); + const [sheetOpen, setSheetOpen] = useAtom(useCreateAtom(initialSheetOpen)); + const registeredDaemon = useCreateAtom(null); + const [registeredDaemonId, setRegisteredDaemonId] = useAtom(registeredDaemon); + const sheetSource = useWorkspaceViewSource(owner, "runtime-pairing", sheetOpen); + const pairing = useQuery({ + queryKey: owner.key("machine-pairing", "runtime-page", sheetOpen ? "open" : "closed"), + enabled: false, gcTime: 0, retry: false, + queryFn: ({ signal }) => runWorkspaceRequest(owner, (options) => createMachineToken("cli", workspaceId, options), signal), + }); + const generatedToken = sheetOpen ? pairing.data?.token ?? "" : ""; + const generatingToken = pairing.isFetching; + const daemonOnline = runtimes.some((runtime) => runtime.daemon_id === registeredDaemonId && runtime.status === "online"); + const latestCliVersion = useQuery(latestCliVersionOptions(owner)).data?.version ?? null; + const runtimeCommand = useRuntimeCommand(owner); + const pendingCommands = usePendingRuntimeCommands(owner); + const updatingDaemons = new Set(pendingCommands.filter((intent) => intent.kind === "update").map((intent) => runtimes.find((runtime) => runtime.id === intent.id)?.daemon_id ?? intent.id)); + const rescanningDaemons = new Set(pendingCommands.filter((intent) => intent.kind === "rescan").map((intent) => runtimes.find((runtime) => runtime.id === intent.id)?.daemon_id ?? intent.id)); - const [latestCliVersion, setLatestCliVersion] = useState(null); - const [updatingDaemons, setUpdatingDaemons] = useState>(new Set()); - const [rescanningDaemons, setRescanningDaemons] = useState>(new Set()); - - const [confirmOpen, setConfirmOpen] = useState(false); - const [confirmTitle, setConfirmTitle] = useState(""); - const [confirmDescription, setConfirmDescription] = useState(""); - const [confirmLabel, setConfirmLabel] = useState("Remove"); - const [confirmLoadingLabel, setConfirmLoadingLabel] = useState(undefined); - const [confirmVariant, setConfirmVariant] = useState<"destructive" | "default">("destructive"); - const [confirmLoading, setConfirmLoading] = useState(false); + const [confirmOpen, setConfirmOpen] = useAtom(useCreateAtom(false)); + const [confirmTitle, setConfirmTitle] = useAtom(useCreateAtom("")); + const [confirmDescription, setConfirmDescription] = useAtom(useCreateAtom("")); + const [confirmLabel, setConfirmLabel] = useAtom(useCreateAtom("Remove")); + const [confirmLoadingLabel, setConfirmLoadingLabel] = useAtom(useCreateAtom(undefined)); + const [confirmVariant, setConfirmVariant] = useAtom(useCreateAtom<"destructive" | "default">("destructive")); + const [confirmLoading, setConfirmLoading] = useAtom(useCreateAtom(false)); const confirmAction = useRef<(() => Promise) | null>(null); - useEffect(() => { - fetchLatestCliVersion() - .then((data) => setLatestCliVersion(data.version)) - .catch(() => {}); - }, []); - // Clean up ?connect query param after initial open useEffect(() => { if (searchParams.has("connect")) { @@ -78,15 +89,11 @@ export default function RuntimesPage() { }, [searchParams, router, pathname]); // Listen for registration + online events while the sheet is open. - const sheetOpenRef = useRef(sheetOpen); - useEffect(() => { sheetOpenRef.current = sheetOpen; }, [sheetOpen]); - const agentsRef = useRef(agents); - useEffect(() => { agentsRef.current = agents; }, [agents]); - const registeredDaemonIdRef = useRef(registeredDaemonId); - useEffect(() => { registeredDaemonIdRef.current = registeredDaemonId; }, [registeredDaemonId]); useEffect(() => { + if (!sheetOpen) return; + const assert = sheetSource.assertActive; return subscribeWs((msg) => { - if (!sheetOpenRef.current) return; + try { assert(); } catch { return; } if (msg.type === "runtime.registered" && msg.workspaceId === workspaceId) { setRegisteredDaemonId(msg.daemonId); } @@ -94,22 +101,19 @@ export default function RuntimesPage() { msg.type === "runtime.status" && msg.workspaceId === workspaceId && msg.status === "online" && - registeredDaemonIdRef.current && - msg.daemonId === registeredDaemonIdRef.current + registeredDaemon.get() && + msg.daemonId === registeredDaemon.get() ) { trackRuntimeConnected({ runtime_type: "desktop" }); setSheetOpen(false); - setGeneratedToken(""); setRegisteredDaemonId(null); - setDaemonOnline(false); toast.success("Machine connected"); - if (agentsRef.current.length === 0) { - const slug = pathname.split("/")[2]; - router.push(`/w/${slug}/agents/new`); + if (owner.queryClient.getQueryData(owner.key("agents"))?.length === 0) { + router.push(`/w/${owner.slug}/agents/new`); } } }); - }, [subscribeWs, workspaceId, pathname, router]); + }, [subscribeWs, workspaceId, owner, router, sheetOpen, sheetSource.assertActive, registeredDaemon, setRegisteredDaemonId, setSheetOpen]); const openConfirm = ( title: string, @@ -128,82 +132,69 @@ export default function RuntimesPage() { const handleConfirm = async () => { if (!confirmAction.current) return; + const assert = pageSource.assertActive; + const action = confirmAction.current; + assert(); setConfirmLoading(true); try { - await confirmAction.current(); + await action(); + } catch (error) { + try { assert(); } catch { return; } + if (!(error instanceof DOMException && error.name === "AbortError")) toast.error("Failed to update machine"); } finally { - setConfirmLoading(false); - setConfirmOpen(false); - confirmAction.current = null; + try { + assert(); + if (confirmAction.current === action) { + setConfirmLoading(false); + setConfirmOpen(false); + confirmAction.current = null; + } + } catch {} } }; const onGenerateToken = useCallback(async () => { - setGeneratingToken(true); + const assert = sheetSource.assertActive; try { - const token = await handleGenerateToken(); - if (token) setGeneratedToken(token); - } finally { - setGeneratingToken(false); + assert(); + await pairing.refetch({ throwOnError: true }); + assert(); + } catch (error) { + try { assert(); } catch { return; } + if (!(error instanceof DOMException && error.name === "AbortError")) toast.error("Failed to generate token"); } - }, [handleGenerateToken]); + }, [pairing, sheetSource.assertActive]); - const handleUpdate = async (runtimeId: string, daemonId: string) => { - setUpdatingDaemons((prev) => new Set(prev).add(daemonId)); + const handleUpdate = async (runtimeId: string) => { + const assert = pageSource.assertActive; + const token = captureWorkspaceOwner(owner); try { - await triggerRuntimeUpdate(runtimeId, workspaceId); + assert(); + await runtimeCommand.mutateAsync({ kind: "update", id: runtimeId, token, assertActive: Object.assign(() => assert(), { signal: pageSource.signal }) }); + assert(); toast.success("Update triggered"); - } catch { + } catch (error) { + try { assert(); } catch { return; } + if (error instanceof DOMException && error.name === "AbortError") return; toast.error("Failed to trigger update"); - setUpdatingDaemons((prev) => { - const next = new Set(prev); - next.delete(daemonId); - return next; - }); } }; - const handleRescan = async (runtimeId: string, daemonId: string) => { - setRescanningDaemons((prev) => new Set(prev).add(daemonId)); + const handleRescan = async (runtimeId: string) => { + const assert = pageSource.assertActive; + const token = captureWorkspaceOwner(owner); try { - await triggerRuntimeRescan(runtimeId, workspaceId); + assert(); + await runtimeCommand.mutateAsync({ kind: "rescan", id: runtimeId, token, assertActive: Object.assign(() => assert(), { signal: pageSource.signal }) }); + assert(); toast.success("Rescan triggered — daemon will restart to detect runtimes"); - } catch { + } catch (error) { + try { assert(); } catch { return; } + if (error instanceof DOMException && error.name === "AbortError") return; toast.error("Failed to trigger rescan"); - setRescanningDaemons((prev) => { - const next = new Set(prev); - next.delete(daemonId); - return next; - }); } }; - // Derive effective optimistic sets: clear once server-side flag is gone AND runtime refreshed - const effectiveUpdatingDaemons = useMemo(() => { - if (updatingDaemons.size === 0) return updatingDaemons; - const still = new Set(); - for (const id of updatingDaemons) { - const rt = runtimes.find((r) => (r.daemon_id || r.id) === id); - // Keep optimistic state until runtime data confirms update is done (flag cleared) - if (!rt || rt.pending_update_version) { - still.add(id); - } - } - return still; - }, [runtimes, updatingDaemons]); - - const effectiveRescanningDaemons = useMemo(() => { - if (rescanningDaemons.size === 0) return rescanningDaemons; - const still = new Set(); - for (const rt of runtimes) { - const key = rt.daemon_id || rt.id; - if (rescanningDaemons.has(key) && rt.pending_rescan) { - still.add(key); - } - } - return still; - }, [runtimes, rescanningDaemons]); - // Group runtimes by machine const machines = new Map< string, @@ -239,7 +230,7 @@ export default function RuntimesPage() {
{/* Skeleton card grid */} -
+
{Array.from({ length: 3 }).map((_, i) => (
{ - setGeneratedToken(""); setRegisteredDaemonId(null); setSheetOpen(true); }} @@ -294,7 +284,7 @@ export default function RuntimesPage() { )}
-
+
{runtimes.length === 0 ? (
@@ -308,7 +298,7 @@ export default function RuntimesPage() { size="sm" className="mt-4 glow-border" onClick={() => { - setGeneratedToken(""); + setRegisteredDaemonId(null); setSheetOpen(true); }} > @@ -355,7 +345,7 @@ export default function RuntimesPage() {
{(() => { - const isUpdating = !!machine.pendingUpdateVersion || effectiveUpdatingDaemons.has(daemonId); + const isUpdating = !!machine.pendingUpdateVersion || updatingDaemons.has(daemonId); const needsUpdate = machine.status === "online" && latestCliVersion && (!machine.cliVersion || !semverGte(machine.cliVersion, latestCliVersion)); if (isUpdating) { return ( @@ -374,7 +364,7 @@ export default function RuntimesPage() { onClick={() => openConfirm( "Update daemon", `This will update the daemon on "${displayName}" to the latest CLI version. The daemon will restart during the update.`, - async () => { await handleUpdate(machine.runtimes[0].id, daemonId); }, + async () => { await handleUpdate(machine.runtimes[0].id); }, { label: "Update", loadingLabel: "Updating...", variant: "default" } )} > @@ -385,7 +375,7 @@ export default function RuntimesPage() { return null; })()} {(() => { - const isRescanning = machine.pendingRescan || effectiveRescanningDaemons.has(daemonId); + const isRescanning = machine.pendingRescan || rescanningDaemons.has(daemonId); if (machine.status !== "online") return null; if (isRescanning) { return ( @@ -403,7 +393,7 @@ export default function RuntimesPage() { onClick={() => openConfirm( "Rescan runtimes", `This will restart the daemon on "${displayName}" to re-detect available runtimes (Claude Code, Codex, OpenCode).`, - async () => { await handleRescan(machine.runtimes[0].id, daemonId); }, + async () => { await handleRescan(machine.runtimes[0].id); }, { label: "Rescan", loadingLabel: "Triggering...", variant: "default" } )} > @@ -457,9 +447,17 @@ export default function RuntimesPage() { render={
{ - navigator.clipboard.writeText(`${cliCmd()} daemon start`); - toast.success("Copied to clipboard"); + onClick={async () => { + const assert = pageSource.assertActive; + try { + assert(); + await navigator.clipboard.writeText(`${cliCmd()} daemon start`); + assert(); + toast.success("Copied to clipboard"); + } catch (error) { + try { assert(); } catch { return; } + if (!(error instanceof DOMException && error.name === "AbortError")) toast.error("Failed to copy command"); + } }} /> } @@ -485,9 +483,7 @@ export default function RuntimesPage() { open={sheetOpen} onOpenChange={(open) => { setSheetOpen(open); - if (!open) { - setGeneratedToken(""); - } + if (!open) setRegisteredDaemonId(null); }} > diff --git a/src/web/src/app/(app)/w/[slug]/settings/general-tab.tsx b/src/web/src/app/(app)/w/[slug]/settings/general-tab.tsx index 957f2642e..543907b9a 100644 --- a/src/web/src/app/(app)/w/[slug]/settings/general-tab.tsx +++ b/src/web/src/app/(app)/w/[slug]/settings/general-tab.tsx @@ -1,17 +1,16 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useAtom, useCreateAtom } from "@tanstack/react-store"; + import { toast } from "sonner"; import { useRouter } from "next/navigation"; import { sanitizeSlug } from "@alook/shared"; -import { useWorkspace } from "@/contexts/workspace-context"; -import { useSession } from "@/lib/auth-client"; -import { - listMembers, - updateWorkspace, - deleteWorkspace, - listWorkspaces, -} from "@/lib/api"; +import { useWorkspaceOwner, captureWorkspaceOwner, assertWorkspaceOwner, workspaceRequestOptions } from "@/contexts/workspace-context"; +import { useWorkspaceViewSource } from "@/hooks/workspace/use-workspace-view-source"; +import { useQuery, useMutation, type Query } from "@tanstack/react-query"; +import { applicationWorkspacesOptions, workspaceMembersOptions } from "@/hooks/workspace/settings-query-options"; + +import { updateWorkspace, deleteWorkspace, listWorkspaces } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -24,103 +23,103 @@ import { import { trackSettingsUpdated } from "@/lib/analytics"; export function GeneralTab() { - const { workspaceId, slug } = useWorkspace(); - const session = useSession(); + const owner = useWorkspaceOwner(); + const { workspaceId, slug } = owner; + const source = useWorkspaceViewSource(owner, "workspace-general", true); const router = useRouter(); - - const [memberRole, setMemberRole] = useState(""); - const [loading, setLoading] = useState(true); - - const [workspaceName, setWorkspaceName] = useState(""); - const [workspaceSlug, setWorkspaceSlug] = useState(""); - const [savedWorkspaceName, setSavedWorkspaceName] = useState(""); - const [savedWorkspaceSlug, setSavedWorkspaceSlug] = useState(""); - const [savingWorkspace, setSavingWorkspace] = useState(false); - const [workspaceErrors, setWorkspaceErrors] = useState({}); - - const [deleteConfirm, setDeleteConfirm] = useState(""); - const [deleting, setDeleting] = useState(false); - + const membersQuery = useQuery(workspaceMembersOptions(owner)); + const workspacesQuery = useQuery(applicationWorkspacesOptions(owner.application)); + const current = workspacesQuery.data?.find((workspace) => workspace.id === workspaceId); + const memberRole = membersQuery.data?.find((member) => member.user_id === owner.application.userId)?.role ?? ""; + const loading = membersQuery.isPending || workspacesQuery.isPending; + const [nameDraft, setWorkspaceName] = useAtom(useCreateAtom(null)); + const [slugDraft, setWorkspaceSlug] = useAtom(useCreateAtom(null)); + const workspaceName = nameDraft ?? current?.name ?? ""; + const workspaceSlug = slugDraft ?? current?.slug ?? ""; + const savedWorkspaceName = current?.name ?? ""; + const savedWorkspaceSlug = current?.slug ?? ""; + const [workspaceErrors, setWorkspaceErrors] = useAtom(useCreateAtom({})); + const [deleteConfirm, setDeleteConfirm] = useAtom(useCreateAtom("")); + type OriginalIntent = { token: ReturnType; view: ReturnType; resources: Query[] }; + const native = useMutation({ mutationKey: owner.key("workspace-settings-command"), scope: { id: JSON.stringify(owner.key("workspace-settings-command")) }, gcTime: 0, + mutationFn: async ({ action, token, view, resources }: { action: { kind: "update"; name: string; slug: string } | { kind: "delete"; name: string }; token: ReturnType } & OriginalIntent) => { + const assert = () => { assertWorkspaceOwner(token, view.signal); view.assert(); }; + const key = applicationWorkspacesOptions(owner.application).queryKey; + assert(); + const original = resources.find((query) => JSON.stringify(query.queryKey) === JSON.stringify(key)); + if (original && owner.queryClient.getQueryCache().find({ queryKey: key, exact: true }) === original) await owner.queryClient.cancelQueries({ queryKey: key, exact: true }); + assert(); + const before = owner.queryClient.getQueryData>>(key)?.find((row) => row.id === workspaceId); + const writes = original?.state.dataUpdateCount; + const options = workspaceRequestOptions(token, view.signal, assert); + try { + const updated = action.kind === "update" ? await updateWorkspace(workspaceId, { name: action.name, slug: action.slug }, options) + : (await deleteWorkspace(workspaceId, action.name, options), null); + assert(); + if (original && owner.queryClient.getQueryCache().find({ queryKey: key, exact: true }) === original && original.state.dataUpdateCount === writes) { + owner.queryClient.setQueryData>>(key, (rows) => action.kind === "delete" ? rows?.filter((row) => row.id !== workspaceId) + : rows?.map((row) => row.id !== workspaceId ? row : { ...row, name: row.name === before?.name ? updated!.name : row.name, slug: row.slug === before?.slug ? updated!.slug : row.slug })); + } + if (action.kind === "delete") for (const query of resources.filter((query) => owner.key().every((part, index) => Object.is(part, query.queryKey[index])))) { + if (owner.queryClient.getQueryCache().find({ queryKey: query.queryKey, exact: true }) === query) owner.queryClient.removeQueries({ queryKey: query.queryKey, exact: true }); + } + if (original && owner.queryClient.getQueryCache().find({ queryKey: key, exact: true }) === original) await owner.queryClient.invalidateQueries({ queryKey: key, exact: true }, { cancelRefetch: false }); + assert(); + return updated && original && owner.queryClient.getQueryCache().find({ queryKey: key, exact: true }) === original ? owner.queryClient.getQueryData>>(key)?.find((row) => row.id === workspaceId) ?? null : null; + } catch (error) { assert(); throw error; } + }, + }); + const capture = (input: Omit[0], "view" | "resources">) => { const view = source.capture(); view.assert(); assertWorkspaceOwner(input.token, view.signal); return { ...input, view, resources: [...new Set([...owner.queryClient.getQueryCache().findAll({ queryKey: owner.key() }), ...owner.queryClient.getQueryCache().findAll({ queryKey: applicationWorkspacesOptions(owner.application).queryKey, exact: true })])] }; }; + const command = { ...native, mutate: (input: Omit[0], "view" | "resources">) => native.mutate(capture(input)), mutateAsync: (input: Omit[0], "view" | "resources">) => native.mutateAsync(capture(input)) }; + const savingWorkspace = command.isPending && command.variables?.action.kind === "update"; + const deleting = command.isPending && command.variables?.action.kind === "delete"; const isOwner = memberRole === "owner"; - - const fetchData = useCallback(async () => { - setLoading(true); - try { - const currentUserId = session.data?.user?.id; - const [members, workspaces] = await Promise.all([ - listMembers(workspaceId), - listWorkspaces(), - ]); - - const me = members.find((m) => m.user_id === currentUserId); - setMemberRole(me?.role ?? ""); - - const ws = workspaces.find((w) => w.id === workspaceId); - if (ws) { - setWorkspaceName(ws.name); - setWorkspaceSlug(ws.slug); - setSavedWorkspaceName(ws.name); - setSavedWorkspaceSlug(ws.slug); - } - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to load workspace info"); - } finally { - setLoading(false); - } - }, [workspaceId, session.data?.user?.id]); - - useEffect(() => { - if (session.data) fetchData(); - }, [fetchData, session.data]); - - const isWorkspaceDirty = - workspaceName !== savedWorkspaceName || workspaceSlug !== savedWorkspaceSlug; + const isWorkspaceDirty = workspaceName !== savedWorkspaceName || workspaceSlug !== savedWorkspaceSlug; const handleSaveWorkspace = async () => { + if (owner.queryClient.isMutating({ mutationKey: owner.key("workspace-settings-command"), exact: true })) return; const cleanSlug = sanitizeSlug(workspaceSlug); setWorkspaceSlug(cleanSlug); - - const nextErrors = validateWorkspaceForm({ - name: workspaceName, - slug: cleanSlug, - }); + const values = { name: workspaceName.trim(), slug: cleanSlug }; + const nextErrors = validateWorkspaceForm(values); setWorkspaceErrors(nextErrors); if (hasWorkspaceFormErrors(nextErrors)) return; - - setSavingWorkspace(true); + const assert = source.capture().assert; + assert(); try { - const updated = await updateWorkspace(workspaceId, { - name: workspaceName.trim(), - slug: cleanSlug, - }); + const updated = await command.mutateAsync({ action: { kind: "update", ...values }, token: captureWorkspaceOwner(owner) }); + assert(); trackSettingsUpdated({ setting_tab: "general" }); - setSavedWorkspaceName(updated.name); - setSavedWorkspaceSlug(updated.slug); + setWorkspaceName((value) => value?.trim() === values.name ? null : value); + setWorkspaceSlug((value) => value === cleanSlug ? null : value); toast.success("Workspace updated"); - if (updated.slug !== slug) { - router.replace(`/w/${updated.slug}/settings`); - } - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to update workspace"); - } finally { - setSavingWorkspace(false); + if (updated && updated.slug !== slug) router.replace(`/w/${updated.slug}/settings`); + } catch (error) { + try { assert(); } catch { return; } + if (!(error instanceof DOMException && error.name === "AbortError")) toast.error(error instanceof Error ? error.message : "Failed to update workspace"); } }; const handleDelete = async () => { + if (owner.queryClient.isMutating({ mutationKey: owner.key("workspace-settings-command"), exact: true })) return; if (deleteConfirm !== savedWorkspaceName) return; - setDeleting(true); + const assert = source.capture().assert; + assert(); try { - await deleteWorkspace(workspaceId, savedWorkspaceName); + await command.mutateAsync({ action: { kind: "delete", name: savedWorkspaceName }, token: captureWorkspaceOwner(owner) }); + assert(); toast.success("Workspace deleted"); router.replace("/workspaces"); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to delete workspace"); - } finally { - setDeleting(false); + } catch (error) { + try { assert(); } catch { return; } + if (!(error instanceof DOMException && error.name === "AbortError")) toast.error(error instanceof Error ? error.message : "Failed to delete workspace"); } }; + if (membersQuery.isError || workspacesQuery.isError) { + return

Could not load workspace settings.

; + } + if (loading) { return (
@@ -178,7 +177,7 @@ export function GeneralTab() { setWorkspaceErrors((prev) => ({ ...prev, slug: undefined })); } }} - onBlur={() => setWorkspaceSlug((s) => sanitizeSlug(s))} + onBlur={() => setWorkspaceSlug(sanitizeSlug(workspaceSlug))} placeholder="workspace-slug" aria-invalid={Boolean(workspaceErrors.slug)} aria-describedby={workspaceErrors.slug ? "workspace-slug-error" : undefined} diff --git a/src/web/src/app/(app)/w/[slug]/settings/instruction-tab.tsx b/src/web/src/app/(app)/w/[slug]/settings/instruction-tab.tsx index 87532ddfa..15e144eca 100644 --- a/src/web/src/app/(app)/w/[slug]/settings/instruction-tab.tsx +++ b/src/web/src/app/(app)/w/[slug]/settings/instruction-tab.tsx @@ -1,8 +1,13 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { createStore, useSelector } from "@tanstack/react-store"; +import { useQuery, useMutation, type Query } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useLayoutEffect } from "react"; import { toast } from "sonner"; -import { useWorkspace } from "@/contexts/workspace-context"; +import { Button } from "@/components/ui/button"; +import { isAbortError } from "@/lib/errors"; +import { useWorkspaceOwner, captureWorkspaceOwner, assertWorkspaceOwner, workspaceRequestOptions, runWorkspaceRequest } from "@/contexts/workspace-context"; +import { useWorkspaceViewSource } from "@/hooks/workspace/use-workspace-view-source"; import { getMemberMe, updateMemberMe } from "@/lib/api"; import { MarkdownEditor } from "@/components/ui/markdown-editor"; import { Skeleton } from "@/components/ui/skeleton"; @@ -38,107 +43,80 @@ function UsageRing({ ratio, size = 16, stroke = 1.5 }: { ratio: number; size?: n } export function InstructionTab() { - const { workspaceId } = useWorkspace(); - const [value, setValue] = useState(""); - const [savedValue, setSavedValue] = useState(""); - const [loading, setLoading] = useState(true); - - const valueRef = useRef(value); - useEffect(() => { - valueRef.current = value; - }, [value]); - const savedValueRef = useRef(savedValue); - useEffect(() => { - savedValueRef.current = savedValue; - }, [savedValue]); - const debounceRef = useRef | null>(null); - const savingRef = useRef(false); - - const fetchInstruction = useCallback(async () => { - setLoading(true); - try { - const data = await getMemberMe(workspaceId); - setValue(data.global_instruction); - setSavedValue(data.global_instruction); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to load settings"); - } finally { - setLoading(false); - } - }, [workspaceId]); - - useEffect(() => { - fetchInstruction(); - }, [fetchInstruction]); - - const scheduleSaveRef = useRef<() => void>(() => {}); - - const flushSave = useCallback(async () => { - if (savingRef.current) return; - const current = valueRef.current; - if (current === savedValueRef.current) return; - savingRef.current = true; - try { - const data = await updateMemberMe(workspaceId, current); - setSavedValue(data.global_instruction); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to save"); - } finally { - savingRef.current = false; - if (valueRef.current !== savedValueRef.current) { - scheduleSaveRef.current(); - } - } - }, [workspaceId]); - - const scheduleSave = useCallback(() => { - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(flushSave, DEBOUNCE_MS); - }, [flushSave]); - - useEffect(() => { - scheduleSaveRef.current = scheduleSave; - }, [scheduleSave]); - - const handleChange = useCallback( - (next: string) => { - setValue(next); - scheduleSave(); + const owner = useWorkspaceOwner(); + const { workspaceId } = owner; + const source = useWorkspaceViewSource(owner, "workspace-instruction", true); + const key = owner.key("member-instruction"); + const query = useQuery({ queryKey: key, queryFn: ({ signal }) => runWorkspaceRequest(owner, (options) => getMemberMe(workspaceId, options), signal) }); + type SaveIntent = { value: string; token: ReturnType; assertView: () => void; signal: AbortSignal; resource: Query | undefined }; + const draft = useMemo(() => { + const draftKey = JSON.stringify(owner.key("instruction-draft")); + const create = () => createStore<{ value: string | null; pending: SaveIntent | null }>({ value: null, pending: null }); + const saved = owner.application.preferences.get().localValues.get(draftKey) as ReturnType | undefined; + if (saved) return saved; + const store = create(); + owner.application.preferences.setState((state) => ({ ...state, localValues: new Map(state.localValues).set(draftKey, store) })); + return store; + }, [owner]); + const edits = useSelector(draft, (state) => state); + const value = edits.value ?? query.data?.global_instruction ?? ""; + const loading = query.isPending; + const command = useMutation({ mutationKey: [...key, "command"], scope: { id: JSON.stringify(key) }, gcTime: 0, + mutationFn: async ({ value, token, assertView, signal, resource: original }: SaveIntent) => { + const assert = () => { assertWorkspaceOwner(token, signal); assertView(); }; + assert(); + if (original && owner.queryClient.getQueryCache().find({ queryKey: key, exact: true }) === original) await owner.queryClient.cancelQueries({ queryKey: key, exact: true }); + assert(); + const before = original?.state.data as { global_instruction: string } | undefined; + const writes = original?.state.dataUpdateCount; + try { + const data = await updateMemberMe(workspaceId, value, { ...workspaceRequestOptions(token, signal, assert), keepalive: true }); + assert(); + if (original && owner.queryClient.getQueryCache().find({ queryKey: key, exact: true }) === original && original.state.dataUpdateCount === writes) { + owner.queryClient.setQueryData<{ global_instruction: string }>(key, (current) => current?.global_instruction === before?.global_instruction ? data : current); + } + return data; + } catch (error) { assert(); throw error; } }, - [scheduleSave], - ); - + onSuccess: (_data, intent) => { + try { intent.assertView(); } catch { return; } + const confirmed = owner.queryClient.getQueryData<{ global_instruction: string }>(key)?.global_instruction; + draft.setState((state) => state.value === intent.value && confirmed === intent.value ? { ...state, value: null } : state); + }, + onError: (error, intent) => { + try { intent.assertView(); } catch { return; } + if (!isAbortError(error)) toast.error(error instanceof Error ? error.message : "Failed to save"); + }, + }); + const mutateInstruction = command.mutate; + const flush = useCallback(() => { + const intent = draft.get().pending; + if (!intent) return; + try { intent.assertView(); assertWorkspaceOwner(intent.token, intent.signal); } catch { return; } + draft.setState((state) => state.pending === intent ? { ...state, pending: null } : state); + mutateInstruction(intent); + }, [draft, mutateInstruction]); + useEffect(() => { + if (!edits.pending) return; + const timer = setTimeout(flush, DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [edits.pending, flush]); + const handleChange = (value: string) => { + source.assertActive(); + draft.setState((state) => ({ value, pending: { + value, token: state.pending?.token ?? captureWorkspaceOwner(owner), assertView: state.pending?.assertView ?? source.assertActive, signal: state.pending?.signal ?? source.signal, resource: state.pending?.resource ?? owner.queryClient.getQueryCache().find({ queryKey: key, exact: true }), + } })); + }; useEffect(() => { - const onBeforeUnload = () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - if (valueRef.current !== savedValueRef.current) { - const params = new URLSearchParams({ workspace_id: workspaceId }); - fetch(`/api/members/me?${params}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ global_instruction: valueRef.current }), - keepalive: true, - }); - } - }; + const onBeforeUnload = () => flush(); window.addEventListener("beforeunload", onBeforeUnload); return () => window.removeEventListener("beforeunload", onBeforeUnload); - }, [workspaceId]); - + }, [flush]); + useLayoutEffect(() => () => draft.setState((state) => ({ ...state, pending: null })), [draft]); useEffect(() => { - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - if (valueRef.current !== savedValueRef.current) { - const params = new URLSearchParams({ workspace_id: workspaceId }); - fetch(`/api/members/me?${params}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ global_instruction: valueRef.current }), - keepalive: true, - }); - } - }; - }, [workspaceId]); + if (query.data?.global_instruction !== edits.value || edits.pending || command.isPending) return; + draft.setState((state) => state.value === query.data?.global_instruction ? { ...state, value: null } : state); + }, [query.data, edits.value, edits.pending, command.isPending, draft]); const ratio = value.length / MAX_LENGTH; @@ -151,6 +129,11 @@ export function InstructionTab() { ); } + if (query.isError && !query.data) return
+

Couldn’t load instructions.

+ +
; + return (
@@ -164,6 +147,7 @@ export function InstructionTab() { />
+ {edits.value !== null && !edits.pending && !command.isPending && }

This instruction is prepended to every agent's individual instruction. diff --git a/src/web/src/app/(app)/w/[slug]/settings/members-tab.dom.test.ts b/src/web/src/app/(app)/w/[slug]/settings/members-tab.dom.test.ts index e6c9282e3..1793c13c9 100644 --- a/src/web/src/app/(app)/w/[slug]/settings/members-tab.dom.test.ts +++ b/src/web/src/app/(app)/w/[slug]/settings/members-tab.dom.test.ts @@ -1,14 +1,20 @@ +import "fake-indexeddb/auto" import React from "react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { render, waitFor } from "@/test/react-dom-harness" +import { render as renderLeaf, waitFor } from "@/test/react-dom-harness" +import { ApplicationQueryProvider } from "@/lib/application-owner" +import { WorkspaceProvider } from "@/contexts/workspace-context" import { serializeBeamSeed } from "@/lib/avatar/seed-url" import { MembersTab } from "./members-tab" +const render: typeof renderLeaf = (node, options) => renderLeaf(node, { ...options, wrapper: ({ children }) => React.createElement(ApplicationQueryProvider, { userId: "user_beam" }, React.createElement(WorkspaceProvider, { workspaceId: "workspace_1", slug: "workspace" }, children)) }) + const mocks = vi.hoisted(() => ({ listMembers: vi.fn(), listInvites: vi.fn(), avatars: [] as Array<{ src?: string | null; seed?: string | null; size?: number }>, })) +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) })) vi.mock("@/components/avatar", async (importOriginal) => { const actual = await importOriginal() @@ -21,13 +27,13 @@ vi.mock("@/components/avatar", async (importOriginal) => { } }) -vi.mock("@/contexts/workspace-context", () => ({ +vi.mock("@/contexts/workspace-context", async (importOriginal) => ({ ...await importOriginal(), useWorkspace: () => ({ workspaceId: "workspace_1" }), })) -vi.mock("@/lib/auth-client", () => ({ +vi.mock("@/lib/auth-client", () => { const sessionSDK = { useSession: () => ({ data: { user: { id: "user_beam" } } }), -})) +}; return { ...sessionSDK, currentSessionViewer: () => { const value = sessionSDK.useSession(); return !value || value.isPending || value.error ? undefined : value.data?.user.id ?? null } } }) vi.mock("@/lib/api", () => ({ listMembers: mocks.listMembers, diff --git a/src/web/src/app/(app)/w/[slug]/settings/members-tab.tsx b/src/web/src/app/(app)/w/[slug]/settings/members-tab.tsx index add08e18e..7a960a058 100644 --- a/src/web/src/app/(app)/w/[slug]/settings/members-tab.tsx +++ b/src/web/src/app/(app)/w/[slug]/settings/members-tab.tsx @@ -1,19 +1,14 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; + +import { useQuery, useMutation, type Query } from "@tanstack/react-query"; import { toast } from "sonner"; import { Copy, Trash2, Plus, UserMinus } from "lucide-react"; -import { useWorkspace } from "@/contexts/workspace-context"; -import { useSession } from "@/lib/auth-client"; -import { - listMembers, - removeMember, - listInvites, - createInvite, - revokeInvite, - type MemberEntry, - type InviteEntry, -} from "@/lib/api"; +import { useWorkspaceOwner, captureWorkspaceOwner, assertWorkspaceOwner, workspaceRequestOptions } from "@/contexts/workspace-context"; +import { workspaceMembersOptions, workspaceInvitesOptions } from "@/hooks/workspace/settings-query-options"; +import { useWorkspaceViewSource } from "@/hooks/workspace/use-workspace-view-source"; +import { isAbortError } from "@/lib/errors"; +import { removeMember, createInvite, revokeInvite, type MemberEntry, type InviteEntry } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; @@ -27,84 +22,112 @@ function getInviteLink(token: string) { } export function MembersTab() { - const { workspaceId } = useWorkspace(); - const session = useSession(); - - const currentUserId = session.data?.user?.id; - - const [members, setMembers] = useState([]); - const [invites, setInvites] = useState([]); - const [loading, setLoading] = useState(true); - const [generatingInvite, setGeneratingInvite] = useState(false); - - const isOwner = members.find((m) => m.user_id === currentUserId)?.role === "owner"; - - const fetchData = useCallback(async () => { - setLoading(true); - try { - const [membersData, invitesData] = await Promise.all([ - listMembers(workspaceId), - listInvites(workspaceId).catch(() => [] as InviteEntry[]), - ]); - setMembers(membersData); - setInvites(invitesData); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to load members"); - } finally { - setLoading(false); - } - }, [workspaceId]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - + const owner = useWorkspaceOwner(); + const { workspaceId } = owner; + const source = useWorkspaceViewSource(owner, "workspace-members", true); + const currentUserId = owner.application.userId; + const memberQuery = useQuery(workspaceMembersOptions(owner)); + const members = memberQuery.data ?? []; + const isOwner = members.find((member) => member.user_id === currentUserId)?.role === "owner"; + const invitesQuery = useQuery({ ...workspaceInvitesOptions(owner), enabled: isOwner, subscribed: isOwner, gcTime: 0 }); + const invites = isOwner ? invitesQuery.data ?? [] : []; + const loading = memberQuery.isPending || isOwner && invitesQuery.isPending; + type OriginalIntent = { token: ReturnType; view: ReturnType; resources: Query[] }; + const native = useMutation({ mutationKey: owner.key("workspace-members-command"), scope: { id: JSON.stringify(owner.key("workspace-members-command")) }, gcTime: 0, + mutationFn: async ({ action, token, view, resources }: { action: { kind: "create-invite" } | { kind: "revoke-invite" | "remove-member"; id: string }; token: ReturnType } & OriginalIntent) => { + const assert = () => { assertWorkspaceOwner(token, view.signal); view.assert(); }, qc = owner.queryClient; + const key = action.kind === "remove-member" ? workspaceMembersOptions(owner).queryKey : workspaceInvitesOptions(owner).queryKey; + assert(); + const resource = resources.find((query) => JSON.stringify(query.queryKey) === JSON.stringify(key)); + if (resource && qc.getQueryCache().find({ queryKey: key, exact: true }) === resource) await qc.cancelQueries({ queryKey: key, exact: true }); + assert(); + const writes = resource?.state.dataUpdateCount; + const qualified = () => resource && qc.getQueryCache().find({ queryKey: key, exact: true }) === resource && resource.state.dataUpdateCount === writes; + const options = workspaceRequestOptions(token, view.signal, assert); + let invite: InviteEntry | undefined; + try { + if (action.kind === "create-invite") { + invite = await createInvite(workspaceId, options); + assert(); + if (qualified()) { const row = invite; qc.setQueryData(key, (rows) => [...(rows ?? []).filter((entry) => entry.id !== row.id), row]); } + } else if (action.kind === "revoke-invite") { + await revokeInvite(workspaceId, action.id, options); + assert(); + if (qualified()) qc.setQueryData(key, (rows) => rows?.filter((entry) => entry.id !== action.id)); + } else { + await removeMember(workspaceId, action.id, options); + assert(); + if (qualified()) qc.setQueryData(key, (rows) => rows?.filter((entry) => entry.id !== action.id)); + } + assert(); + if (resource && qc.getQueryCache().find({ queryKey: key, exact: true }) === resource) await qc.invalidateQueries({ queryKey: key, exact: true }, { cancelRefetch: false }); + assert(); + return invite; + } catch (error) { assert(); throw error; } + }, + }); + const capture = (input: Omit[0], "view" | "resources">) => { const view = source.capture(); view.assert(); assertWorkspaceOwner(input.token, view.signal); return { ...input, view, resources: [...new Set([workspaceMembersOptions(owner).queryKey, workspaceInvitesOptions(owner).queryKey].flatMap((queryKey) => owner.queryClient.getQueryCache().findAll({ queryKey, exact: true })))] }; }; + const command = { ...native, mutate: (input: Omit[0], "view" | "resources">) => native.mutate(capture(input)), mutateAsync: (input: Omit[0], "view" | "resources">) => native.mutateAsync(capture(input)) }; + const generatingInvite = command.isPending && command.variables?.action.kind === "create-invite"; const handleGenerateInvite = async () => { - setGeneratingInvite(true); + if (owner.queryClient.isMutating({ mutationKey: owner.key("workspace-members-command"), exact: true })) return; + const assertView = source.capture().assert; + assertView(); try { - const invite = await createInvite(workspaceId); + const invite = await command.mutateAsync({ action: { kind: "create-invite" }, token: captureWorkspaceOwner(owner) }); + assertView(); + if (!invite) return; trackTeamMemberInvited({ workspace_id: workspaceId }); - setInvites((prev) => [...prev, invite]); - const link = getInviteLink(invite.token); - await navigator.clipboard.writeText(link); + await navigator.clipboard.writeText(getInviteLink(invite.token)); + assertView(); toast.success("Invite link copied to clipboard"); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to generate invite"); - } finally { - setGeneratingInvite(false); + } catch (error) { + try { assertView(); } catch { return; } + if (!isAbortError(error)) toast.error(error instanceof Error ? error.message : "Failed to generate invite"); } }; - const handleCopyInvite = async (token: string) => { - const link = getInviteLink(token); + const assertView = source.capture().assert; + assertView(); try { - await navigator.clipboard.writeText(link); + await navigator.clipboard.writeText(getInviteLink(token)); + assertView(); toast.success("Invite link copied"); } catch { + try { assertView(); } catch { return; } toast.error("Failed to copy link"); } }; - - const handleRevokeInvite = async (inviteId: string) => { + const handleRevokeInvite = async (id: string) => { + const assertView = source.capture().assert; + assertView(); try { - await revokeInvite(workspaceId, inviteId); - setInvites((prev) => prev.filter((i) => i.id !== inviteId)); + await command.mutateAsync({ action: { kind: "revoke-invite", id }, token: captureWorkspaceOwner(owner) }); + assertView(); toast.success("Invite revoked"); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to revoke invite"); + } catch (error) { + try { assertView(); } catch { return; } + if (!isAbortError(error)) toast.error(error instanceof Error ? error.message : "Failed to revoke invite"); } }; - - const handleRemoveMember = async (memberId: string) => { + const handleRemoveMember = async (id: string) => { + const assertView = source.capture().assert; + assertView(); try { - await removeMember(workspaceId, memberId); - setMembers((prev) => prev.filter((m) => m.id !== memberId)); + await command.mutateAsync({ action: { kind: "remove-member", id }, token: captureWorkspaceOwner(owner) }); + assertView(); toast.success("Member removed"); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to remove member"); + } catch (error) { + try { assertView(); } catch { return; } + if (!isAbortError(error)) toast.error(error instanceof Error ? error.message : "Failed to remove member"); } }; + + if (memberQuery.isError || isOwner && invitesQuery.isError) { + return

Could not load workspace members.

; + } + if (loading) { return (
diff --git a/src/web/src/app/(app)/w/[slug]/settings/notification-tab.tsx b/src/web/src/app/(app)/w/[slug]/settings/notification-tab.tsx index 8ab48b880..468abe7b3 100644 --- a/src/web/src/app/(app)/w/[slug]/settings/notification-tab.tsx +++ b/src/web/src/app/(app)/w/[slug]/settings/notification-tab.tsx @@ -1,60 +1,65 @@ "use client"; -import { useEffect, useState } from "react"; +import { useSelector } from "@tanstack/react-store"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { applicationKey, useApplicationOwner } from "@/lib/application-owner"; +import { useApplicationViewSource } from "@/hooks/use-application-view-source"; import { toast } from "sonner"; import { Checkbox } from "@/components/ui/checkbox"; import { Switch } from "@/components/ui/switch"; -import { - isNotificationSupported, - getNotificationEnabled, - setNotificationEnabled, - getNotificationEvents, - setNotificationEvents, - requestNotificationPermission, - NOTIFICATION_EVENTS, - NOTIFICATION_EVENT_LABELS, - type NotificationEvent, -} from "@/lib/browser-notification"; +import { isNotificationSupported, setNotificationEnabled, setNotificationEvents, requestNotificationPermission, NOTIFICATION_EVENTS, NOTIFICATION_EVENT_LABELS, type NotificationEvent } from "@/lib/browser-notification"; export function NotificationTab() { - const [notifEnabled, setNotifEnabled] = useState(false); - const [notifEvents, setNotifEvents] = useState([...NOTIFICATION_EVENTS]); - const [notifSupported, setNotifSupported] = useState(true); - const [notifDenied, setNotifDenied] = useState(false); - - useEffect(() => { - setNotifSupported(isNotificationSupported()); - setNotifEnabled(getNotificationEnabled()); - setNotifEvents(getNotificationEvents()); - if (isNotificationSupported()) { - setNotifDenied(Notification.permission === "denied"); - } - }, []); + const owner = useApplicationOwner(); + const source = useApplicationViewSource("workspace-notifications"); + const preferences = useSelector(owner.preferences, (state) => state.browserNotifications); + const notifEnabled = preferences.enabled; + const notifEvents = preferences.events; + const permission = useQuery({ + queryKey: applicationKey(owner, "browser-notification-permission"), + queryFn: () => ({ supported: isNotificationSupported(), denied: isNotificationSupported() && Notification.permission === "denied" }), + staleTime: 0, + }); + const notifSupported = permission.data?.supported ?? true; + const notifDenied = permission.data?.denied ?? false; + const command = useMutation({ mutationKey: applicationKey(owner, "browser-notification-permission", "request"), + mutationFn: async (original: ReturnType) => { + original.assert(); + const granted = await requestNotificationPermission(); + original.assert(); + owner.queryClient.setQueryData(applicationKey(owner, "browser-notification-permission"), { supported: isNotificationSupported(), denied: !granted }); + if (granted) { + setNotificationEnabled(true, owner.userId); + owner.preferences.setState((state) => ({ ...state, browserNotifications: { ...state.browserNotifications, enabled: true } })); + } + return granted; + }, + }); const handleToggleNotification = async (checked: boolean) => { + const original = source.capture(); + original.assert(); if (!checked) { - setNotifEnabled(false); - setNotificationEnabled(false); + setNotificationEnabled(false, owner.userId); + owner.preferences.setState((state) => ({ ...state, browserNotifications: { ...state.browserNotifications, enabled: false } })); return; } - const granted = await requestNotificationPermission(); - if (granted) { - setNotifEnabled(true); - setNotificationEnabled(true); - } else { - setNotifDenied(true); - toast.error("Notification permission denied. Please enable it in browser settings."); + try { + const granted = await command.mutateAsync(original); + original.assert(); + if (!granted) toast.error("Notification permission denied. Please enable it in browser settings."); + } catch (error) { + try { original.assert(); } catch { return; } + if (!(error instanceof DOMException && error.name === "AbortError")) toast.error(error instanceof Error ? error.message : "Failed to enable notifications"); } }; const handleToggleEvent = (event: NotificationEvent) => { - setNotifEvents((prev) => { - const next = prev.includes(event) - ? prev.filter((e) => e !== event) - : [...prev, event]; - setNotificationEvents(next); - return next; - }); + source.capture().assert(); + const current = owner.preferences.get().browserNotifications; + const events = current.events.includes(event) ? current.events.filter((value) => value !== event) : [...current.events, event]; + setNotificationEvents(events, owner.userId); + owner.preferences.setState((state) => ({ ...state, browserNotifications: { ...state.browserNotifications, events } })); }; if (!notifSupported) { @@ -80,7 +85,7 @@ export function NotificationTab() {
{notifDenied && !notifEnabled && ( diff --git a/src/web/src/app/(app)/w/[slug]/settings/page.tsx b/src/web/src/app/(app)/w/[slug]/settings/page.tsx index 25fcdc2fa..5d9afbd8d 100644 --- a/src/web/src/app/(app)/w/[slug]/settings/page.tsx +++ b/src/web/src/app/(app)/w/[slug]/settings/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState } from "react"; +import { useAtom, useCreateAtom } from "@tanstack/react-store"; + import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; @@ -23,7 +24,7 @@ const TABS = [ type TabId = (typeof TABS)[number]["id"]; export default function SettingsPage() { - const [activeTab, setActiveTab] = useState("general"); + const [activeTab, setActiveTab] = useAtom(useCreateAtom("general")); return ( <> diff --git a/src/web/src/app/(app)/w/[slug]/settings/pet-tab.tsx b/src/web/src/app/(app)/w/[slug]/settings/pet-tab.tsx index e82ad1cd0..d9f473cd5 100644 --- a/src/web/src/app/(app)/w/[slug]/settings/pet-tab.tsx +++ b/src/web/src/app/(app)/w/[slug]/settings/pet-tab.tsx @@ -1,63 +1,27 @@ "use client"; -import { useEffect, useState } from "react"; import { Sparkles } from "lucide-react"; import { CLOUD_CODE_MONSTER_PET_PRESETS, getCloudCodeMonsterPreset, - readCloudCodeMonsterPetPresetId, + useCloudCodeMonsterPetPresetId, writeCloudCodeMonsterPetPresetId, } from "@/components/home-pet/cloud-code-monster-pet-presets"; -import { CLOUD_CODE_MONSTER_PRESET_CHANGED_EVENT } from "@/components/home-pet/cloud-code-monster-pet-constants"; import { CloudCodeMonsterPresetPreview } from "@/components/home-pet/cloud-code-monster-pet-pixel-parts"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { - readHomePetSettings, + useHomePetSettings, writeHomePetSettings, } from "@/lib/home-pet-settings"; import { cn } from "@/lib/utils"; export function PetTab() { - const [enabled, setEnabled] = useState(false); - const [selectedPresetId, setSelectedPresetId] = useState( - CLOUD_CODE_MONSTER_PET_PRESETS[0]!.id - ); + const { enabled } = useHomePetSettings(); + const selectedPresetId = useCloudCodeMonsterPetPresetId(); const selectedPreset = getCloudCodeMonsterPreset(selectedPresetId); - - useEffect(() => { - const settings = readHomePetSettings(); - setEnabled(settings.enabled); - setSelectedPresetId(readCloudCodeMonsterPetPresetId()); - - const handlePresetChange = (event: Event) => { - const nextPresetId = (event as CustomEvent<{ presetId?: string }>).detail - ?.presetId; - setSelectedPresetId( - nextPresetId - ? getCloudCodeMonsterPreset(nextPresetId).id - : readCloudCodeMonsterPetPresetId() - ); - }; - - window.addEventListener( - CLOUD_CODE_MONSTER_PRESET_CHANGED_EVENT, - handlePresetChange - ); - - return () => { - window.removeEventListener( - CLOUD_CODE_MONSTER_PRESET_CHANGED_EVENT, - handlePresetChange - ); - }; - }, []); - - const handleEnabledChange = (checked: boolean) => { - setEnabled(checked); - writeHomePetSettings({ enabled: checked }); - }; + const handleEnabledChange = (checked: boolean) => writeHomePetSettings({ enabled: checked }); return (
@@ -109,10 +73,9 @@ export function PetTab() { type="button" aria-pressed={isSelected} onClick={() => { - const nextPresetId = writeCloudCodeMonsterPetPresetId( + writeCloudCodeMonsterPetPresetId( preset.id ); - setSelectedPresetId(nextPresetId); }} className={cn( "group grid h-28 min-w-0 grid-rows-[1fr_auto] rounded-md border bg-card/60 p-2 text-left transition-all hover:border-foreground/20 hover:bg-accent/55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70", diff --git a/src/web/src/app/(app)/w/[slug]/settings/usages-tab.tsx b/src/web/src/app/(app)/w/[slug]/settings/usages-tab.tsx index e206004f1..5c446e07d 100644 --- a/src/web/src/app/(app)/w/[slug]/settings/usages-tab.tsx +++ b/src/web/src/app/(app)/w/[slug]/settings/usages-tab.tsx @@ -1,10 +1,10 @@ "use client"; -import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAgentContext } from "@/contexts/agent-context"; -import { useWorkspace } from "@/contexts/workspace-context"; +import { useWorkspaceOwner, runWorkspaceRequest } from "@/contexts/workspace-context"; import { Skeleton } from "@/components/ui/skeleton"; -import { getWorkspaceOverview, type WorkspaceOverview } from "@/lib/api"; +import { getWorkspaceOverview } from "@/lib/api"; import { QuickStatsRow } from "../home/_components/quick-stats"; import { TaskHealth } from "../home/_components/task-health"; import { EmailSummary } from "../home/_components/email-summary"; @@ -13,24 +13,12 @@ import { TeamAccess } from "../home/_components/team-access"; export function UsagesTab() { const { agents, runtimes, loading, activeTaskCounts } = useAgentContext(); - const { workspaceId } = useWorkspace(); - const [overview, setOverview] = useState(null); - const [overviewLoading, setOverviewLoading] = useState(false); - - useEffect(() => { - if (loading) return; - let cancelled = false; - setOverviewLoading(true); - getWorkspaceOverview(workspaceId) - .then((data) => { - if (!cancelled) setOverview(data); - }) - .catch(() => {}) - .finally(() => { - if (!cancelled) setOverviewLoading(false); - }); - return () => { cancelled = true; }; - }, [loading, workspaceId]); + const owner = useWorkspaceOwner(); + const overviewQuery = useQuery({ queryKey: owner.key("overview"), enabled: !loading, + queryFn: ({ signal }) => runWorkspaceRequest(owner, (options) => getWorkspaceOverview(owner.workspaceId, options), signal), + }); + const overview = overviewQuery.data; + const overviewLoading = overviewQuery.isPending; if (overviewLoading || !overview) { return ( diff --git a/src/web/src/app/(app)/w/[slug]/traces/[traceId]/page.tsx b/src/web/src/app/(app)/w/[slug]/traces/[traceId]/page.tsx index c9510e9cf..cc9559cf6 100644 --- a/src/web/src/app/(app)/w/[slug]/traces/[traceId]/page.tsx +++ b/src/web/src/app/(app)/w/[slug]/traces/[traceId]/page.tsx @@ -1,11 +1,14 @@ "use client"; -import { useState, useEffect } from "react"; +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { workspaceTraceOptions } from "@/hooks/workspace/issue-query-options"; +import { useWorkspaceViewSource } from "@/hooks/workspace/use-workspace-view-source"; import { useParams } from "next/navigation"; import Link from "next/link"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; -import { useWorkspace } from "@/contexts/workspace-context"; -import { getTrace, type TraceTask } from "@/lib/api"; +import { useWorkspaceOwner } from "@/contexts/workspace-context"; +import type { TraceTask } from "@/lib/api"; import { trackThreadViewed } from "@/lib/analytics"; import { Skeleton } from "@/components/ui/skeleton"; import { ArrowLeft } from "lucide-react"; @@ -153,26 +156,21 @@ function TaskNode({ node, slug }: { node: TreeNode; slug: string }) { export default function TraceDetailPage() { const params = useParams(); - const { slug, workspaceId } = useWorkspace(); + const owner = useWorkspaceOwner(); + const { slug } = owner; const traceId = params.traceId as string; - - const [tasks, setTasks] = useState([]); - const [channel, setChannel] = useState("default"); - const [loading, setLoading] = useState(true); - + const source = useWorkspaceViewSource(owner, traceId, true); + const resource = useQuery(workspaceTraceOptions(owner, traceId)); + const tasks = resource.data?.tasks ?? []; + const channel = resource.data?.channel ?? "default"; + const loading = resource.isPending; useEffect(() => { - setLoading(true); - getTrace(traceId, workspaceId) - .then((data) => { - setTasks(data.tasks); - setChannel(data.channel); - const agentIds = new Set(data.tasks.map((t: TraceTask) => t.agent_id)); - const rootStatus = data.tasks.find((t: TraceTask) => !t.parent_task_id)?.status ?? "unknown"; - trackThreadViewed({ agent_count: agentIds.size, status: rootStatus }); - }) - .catch(() => { }) - .finally(() => setLoading(false)); - }, [traceId, workspaceId]); + if (!resource.data) return; + try { source.assertActive() } catch { return; } + const agentIds = new Set(resource.data.tasks.map((task) => task.agent_id)); + const rootStatus = resource.data.tasks.find((task) => !task.parent_task_id)?.status ?? "unknown"; + trackThreadViewed({ agent_count: agentIds.size, status: rootStatus }); + }, [resource.data, resource.dataUpdatedAt, source, source.assertActive]); const tree = buildTree(tasks); const flat = flattenTree(tree); diff --git a/src/web/src/app/(app)/w/[slug]/traces/page.tsx b/src/web/src/app/(app)/w/[slug]/traces/page.tsx index 2f1fcc0a3..2c46762df 100644 --- a/src/web/src/app/(app)/w/[slug]/traces/page.tsx +++ b/src/web/src/app/(app)/w/[slug]/traces/page.tsx @@ -1,12 +1,15 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useCallback, useRef } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useAgentContext } from "@/contexts/agent-context"; +import { useWorkspaceViewSource } from "@/hooks/workspace/use-workspace-view-source"; +import { workspaceTracesOptions } from "@/hooks/workspace/trace-query-options"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; -import { useWorkspace } from "@/contexts/workspace-context"; +import { useWorkspaceOwner } from "@/contexts/workspace-context"; import { useChannel } from "@/contexts/channel-context"; -import { listTraces, listAgents, type TraceListItem } from "@/lib/api"; -import type { Agent } from "@alook/shared"; +import type { TraceListItem } from "@/lib/api"; import { Skeleton } from "@/components/ui/skeleton"; import { Select, @@ -20,8 +23,6 @@ import { AgentAvatar } from "@/components/avatar"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; -const TRACE_LIMIT = 30; - const STATUS_OPTIONS = [ { label: "All", value: "all" }, { label: "Active", value: "active" }, @@ -150,97 +151,34 @@ function SkeletonRow({ promptWidth }: { promptWidth: string }) { export default function TracesPage() { const router = useRouter(); const searchParams = useSearchParams(); - const { slug, workspaceId } = useWorkspace(); + const owner = useWorkspaceOwner(); + const { slug } = owner; const { channels } = useChannel(); - - const [traces, setTraces] = useState([]); - const [loading, setLoading] = useState(true); - const [hasMore, setHasMore] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - const [agents, setAgents] = useState([]); - + const { agents } = useAgentContext(); const scrollRef = useRef(null); - const isFetchingRef = useRef(false); - const statusFilter = searchParams.get("status") ?? "active"; const agentFilter = searchParams.get("agentId") ?? ""; const channelFilter = searchParams.get("channel") ?? ""; - - useEffect(() => { - listAgents(workspaceId).then(setAgents).catch(() => {}); - }, [workspaceId]); - - const loadInitial = useCallback(async () => { - setLoading(true); - try { - const result = await listTraces(workspaceId, { - limit: TRACE_LIMIT, - status: statusFilter === "all" ? undefined : statusFilter || undefined, - multiAgent: true, - agentId: agentFilter || undefined, - channel: channelFilter || undefined, - }); - const seen = new Set(); - const deduped = result.traces.filter((t) => { - if (seen.has(t.trace_id)) return false; - seen.add(t.trace_id); - return true; - }); - setTraces(deduped); - setHasMore(result.has_more); - } catch { - // silently fail - } finally { - setLoading(false); - } - }, [workspaceId, statusFilter, agentFilter, channelFilter]); - - useEffect(() => { - loadInitial(); - }, [loadInitial]); - - const loadOlderTraces = useCallback(async () => { - if (isFetchingRef.current || !hasMore || traces.length === 0) return; - isFetchingRef.current = true; - setLoadingMore(true); - try { - const oldest = traces[traces.length - 1]; - const result = await listTraces(workspaceId, { - limit: TRACE_LIMIT, - before: oldest.started_at, - status: statusFilter === "all" ? undefined : statusFilter || undefined, - multiAgent: true, - agentId: agentFilter || undefined, - channel: channelFilter || undefined, - }); - if (result.traces.length === 0) { - setHasMore(false); - return; - } - setHasMore(result.has_more); - setTraces((prev) => { - const existingIds = new Set(prev.map((t) => t.trace_id)); - const unique = result.traces.filter((t) => { - if (existingIds.has(t.trace_id)) return false; - existingIds.add(t.trace_id); - return true; - }); - return [...prev, ...unique]; - }); - } finally { - isFetchingRef.current = false; - setLoadingMore(false); - } - }, [workspaceId, traces, hasMore, statusFilter, agentFilter, channelFilter]); - - const handleScroll = useCallback(() => { + const source = useWorkspaceViewSource(owner, JSON.stringify([statusFilter, agentFilter, channelFilter]), true); + const resource = useInfiniteQuery(workspaceTracesOptions(owner, { status: statusFilter, agentId: agentFilter, channel: channelFilter })); + const seen = new Set(); + const traces = (resource.data?.pages ?? []).flatMap((page) => page.traces).filter((trace) => { + if (seen.has(trace.trace_id)) return false; + seen.add(trace.trace_id); return true; + }); + const loading = resource.isPending; + const hasMore = resource.hasNextPage; + const loadingMore = resource.isFetchingNextPage; + const loadInitial = async () => { + try { source.assertActive(); await resource.refetch(); } catch {} + }; + const loadOlderTraces = async () => { + try { source.assertActive(); await resource.fetchNextPage({ cancelRefetch: false }); } catch {} + }; + const handleScroll = () => { const el = scrollRef.current; - if (!el) return; - const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 200; - if (!loadingMore && hasMore && nearBottom) { - loadOlderTraces(); - } - }, [loadOlderTraces, loadingMore, hasMore]); + if (el && !loadingMore && hasMore && el.scrollHeight - el.scrollTop - el.clientHeight < 200) void loadOlderTraces(); + }; const updateFilter = useCallback( (key: string, value: string) => { diff --git a/src/web/src/app/(app)/w/[slug]/unread/page.tsx b/src/web/src/app/(app)/w/[slug]/unread/page.tsx index 5942697d6..c27de8aca 100644 --- a/src/web/src/app/(app)/w/[slug]/unread/page.tsx +++ b/src/web/src/app/(app)/w/[slug]/unread/page.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useSelector } from "@tanstack/react-store"; +import { useApplicationOwner } from "@/lib/application-owner"; +import { useCallback, useRef } from "react"; import { useWorkspace } from "@/contexts/workspace-context"; -import { useAgentContext } from "@/contexts/agent-context"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; -import { listInboxItems, markAllInboxRead, type InboxItem } from "@/lib/api"; +import { type InboxItem } from "@/lib/api"; import { useInboxCount } from "@/contexts/inbox-count-context"; import { useAgentChatSheet } from "@/contexts/agent-chat-sheet-context"; import { Skeleton } from "@/components/ui/skeleton"; @@ -18,13 +19,11 @@ import { INBOX_FILTER_TYPES, INBOX_FILTER_LABELS, MANDATORY_INBOX_TYPES, - getInboxFilterTypes, setInboxFilterTypes, type InboxFilterType, } from "@/lib/inbox-filter"; -import type { WsMessage } from "@alook/shared"; +import { useMarkAllInboxRead, useWorkspaceInbox } from "@/hooks/workspace/use-inbox"; -const INBOX_LIMIT = 30; function StatusDot({ status }: { status: string | null }) { const colorClass = @@ -114,122 +113,24 @@ function SkeletonRow({ promptWidth }: { promptWidth: string }) { } export default function InboxPage() { - const { slug, workspaceId } = useWorkspace(); - const { subscribeWs } = useAgentContext(); - const { count, refresh: refreshInboxCount, decrement: decrementInboxCount } = useInboxCount(); + const { slug } = useWorkspace(); + const { decrement: decrementInboxCount } = useInboxCount(); const { openAgentChat } = useAgentChatSheet(); - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const [hasMore, setHasMore] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - const [filterTypes, setFilterTypes] = useState(getInboxFilterTypes); - + const { items, isPending: loading, hasNextPage: hasMore, isFetchingNextPage: loadingMore, fetchNextPage } = useWorkspaceInbox(); + const markAll = useMarkAllInboxRead(); + const application = useApplicationOwner(); + const filterTypes = useSelector(application.preferences, (state) => state.inboxFilterTypes); const scrollRef = useRef(null); - const isFetchingRef = useRef(false); - const filterTypesRef = useRef(filterTypes); - useEffect(() => { filterTypesRef.current = filterTypes; }); - - const loadInitial = useCallback(async (opts?: { silent?: boolean }) => { - if (!opts?.silent) setLoading(true); - try { - const result = await listInboxItems(workspaceId, { limit: INBOX_LIMIT, types: filterTypesRef.current }); - setItems(result.items); - setHasMore(result.has_more); - } catch { - // silently fail - } finally { - setLoading(false); - } - }, [workspaceId]); - - const refreshInboxCountRef = useRef(refreshInboxCount); - useEffect(() => { refreshInboxCountRef.current = refreshInboxCount; }); - - useEffect(() => { - loadInitial(); - }, [loadInitial]); - - useEffect(() => { - refreshInboxCountRef.current(); - }, []); - - useEffect(() => { - return subscribeWs((msg: WsMessage) => { - if (msg.type === "task.updated" && (msg.status === "completed" || msg.status === "failed")) { - loadInitial({ silent: true }); - } - }); - }, [subscribeWs, loadInitial]); - - const lastSeenCountRef = useRef(null); - - useEffect(() => { - if (lastSeenCountRef.current !== null && count > lastSeenCountRef.current) { - const timer = setTimeout(() => loadInitial({ silent: true }), 1500); - lastSeenCountRef.current = count; - return () => clearTimeout(timer); - } - lastSeenCountRef.current = count; - }, [count, loadInitial]); - - const loadMore = useCallback(async () => { - if (isFetchingRef.current || !hasMore || items.length === 0) return; - isFetchingRef.current = true; - setLoadingMore(true); - try { - const oldest = items[items.length - 1]; - const result = await listInboxItems(workspaceId, { - limit: INBOX_LIMIT, - before: oldest.latest_response_at, - types: filterTypesRef.current, - }); - if (result.items.length === 0) { - setHasMore(false); - return; - } - setHasMore(result.has_more); - setItems((prev) => { - const existingIds = new Set(prev.map((i) => i.id)); - const unique = result.items.filter((i) => !existingIds.has(i.id)); - return [...prev, ...unique]; - }); - } finally { - isFetchingRef.current = false; - setLoadingMore(false); - } - }, [workspaceId, items, hasMore]); - const handleScroll = useCallback(() => { const el = scrollRef.current; - if (!el) return; - const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 200; - if (!loadingMore && hasMore && nearBottom) { - loadMore(); - } - }, [loadMore, loadingMore, hasMore]); - - const handleMarkAllRead = useCallback(async () => { - setItems([]); - setHasMore(false); - try { - await markAllInboxRead(workspaceId); - refreshInboxCount(); - } catch { - loadInitial(); - } - }, [workspaceId, loadInitial, refreshInboxCount]); - - const handleFilterToggle = useCallback((type: InboxFilterType, checked: boolean) => { - const next = checked - ? [...filterTypesRef.current, type] - : filterTypesRef.current.filter((t) => t !== type); - setFilterTypes(next); - setInboxFilterTypes(next); - filterTypesRef.current = next; - loadInitial(); - refreshInboxCount(); - }, [loadInitial, refreshInboxCount]); + if (el && el.scrollHeight - el.scrollTop - el.clientHeight < 200 && !loadingMore && hasMore) void fetchNextPage(); + }, [fetchNextPage, loadingMore, hasMore]); + const handleMarkAllRead = () => markAll.mutate(); + const handleFilterToggle = (type: InboxFilterType, checked: boolean) => { + const next = checked ? [...filterTypes, type] : filterTypes.filter((value) => value !== type); + setInboxFilterTypes(application, next); + }; const activeFilterCount = filterTypes.length - MANDATORY_INBOX_TYPES.length; @@ -292,7 +193,7 @@ export default function InboxPage() {
{loading ? ( <> diff --git a/src/web/src/app/(app)/workspaces/client.tsx b/src/web/src/app/(app)/workspaces/client.tsx index 019c709d7..2b626f116 100644 --- a/src/web/src/app/(app)/workspaces/client.tsx +++ b/src/web/src/app/(app)/workspaces/client.tsx @@ -1,48 +1,63 @@ "use client" -import { useState } from "react" +import { useMutation, useQuery, type Query } from "@tanstack/react-query" +import type { Workspace } from "@alook/shared" +import { applicationWorkspacesOptions } from "@/hooks/workspace/settings-query-options" +import { useApplicationViewSource } from "@/hooks/use-application-view-source" import { useRouter } from "next/navigation" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { GradientBackground } from "@/components/gradient-background" import { Logo } from "@/components/logo" import { Plus, ArrowRight, LogOut, Loader2 } from "lucide-react" -import { signOut } from "@/lib/auth-client" -import { clearAllCache } from "@/lib/chat-cache" -import { toast } from "sonner" - -interface WorkspaceItem { - id: string - name: string - slug: string -} +import { useApplicationSignOut } from "@/hooks/use-application-sign-out" +import { useApplicationOwner } from "@/lib/application-owner"; +import { assertApplicationOwner, captureApplicationOwner, invalidateApplicationAuthentication } from "@/lib/application-owner" +import { apiFetch, toastApiError } from "@/lib/api/client" export function WorkspaceListClient({ - workspaces, + workspaces: initialWorkspaces, }: { - workspaces: WorkspaceItem[] + workspaces: Workspace[] }) { + const applicationOwner = useApplicationOwner(); + const logout = useApplicationSignOut(); const router = useRouter() - const [creating, setCreating] = useState(false) - - const handleNewWorkspace = async () => { - setCreating(true) - try { - const res = await fetch("/api/workspaces", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "Personal", slug: "" }), + const source = useApplicationViewSource("workspace-list") + const options = applicationWorkspacesOptions(applicationOwner) + const resource = useQuery({ ...options, initialData: initialWorkspaces, initialDataUpdatedAt: 0 }) + const workspaces = resource.data + type Intent = { original: ReturnType; resource: Query | undefined } + const command = useMutation({ mutationKey: [...options.queryKey, "create"], gcTime: 0, + mutationFn: async ({ original, resource }: Intent) => { + original.assert() + const current = () => resource && applicationOwner.queryClient.getQueryCache().find({ queryKey: options.queryKey, exact: true }) === resource + if (current()) await applicationOwner.queryClient.cancelQueries({ queryKey: options.queryKey, exact: true }) + original.assert() + const result = await apiFetch("/api/workspaces", { + method: "POST", body: JSON.stringify({ name: "Personal", slug: "" }), + authenticationAccount: applicationOwner.userId, signal: original.signal, assertActive: original.assert, + onUnauthorized: () => { original.assert(); return invalidateApplicationAuthentication(original.token, original.signal) }, }) - if (!res.ok) { - const err = (await res.json().catch(() => ({}))) as { error?: string } - throw new Error(err.error || "Failed to create workspace") + original.assert() + if (current()) { + await applicationOwner.queryClient.cancelQueries({ queryKey: options.queryKey, exact: true }) + original.assert() + if (current()) applicationOwner.queryClient.setQueryData(options.queryKey, (rows) => rows ? [...rows.filter((row) => row.id !== result.id), result] : rows) } - const data = (await res.json()) as { id: string; slug: string } + return result + }, + }) + const creating = command.isPending + const handleNewWorkspace = async () => { + const original = source.capture() + original.assert() + if (applicationOwner.queryClient.getMutationCache().findAll({ mutationKey: [...options.queryKey, "create"], status: "pending" }).length) return + try { + const data = await command.mutateAsync({ original, resource: applicationOwner.queryClient.getQueryCache().find({ queryKey: options.queryKey, exact: true }) }) + original.assert() router.push(`/studio/new?workspace_id=${data.id}`) - } catch (e) { - toast.error(e instanceof Error ? e.message : "Failed to create workspace") - setCreating(false) - } + } catch (error) { toastApiError(error, "Failed to create workspace", original.assert) } } return ( @@ -54,9 +69,9 @@ export function WorkspaceListClient({ size="sm" className="absolute top-4 right-4 text-muted-foreground" onClick={async () => { - await clearAllCache() - await signOut() - router.push("/sign-in") + const token = captureApplicationOwner(applicationOwner) + try { if (await logout.mutateAsync()) router.push("/sign-in") } + catch (error) { toastApiError(error, "Failed to log out", () => assertApplicationOwner(token)) } }} > diff --git a/src/web/src/app/(auth)/sign-in/sign-in-client.tsx b/src/web/src/app/(auth)/sign-in/sign-in-client.tsx index d3ec211c4..744a3f06a 100644 --- a/src/web/src/app/(auth)/sign-in/sign-in-client.tsx +++ b/src/web/src/app/(auth)/sign-in/sign-in-client.tsx @@ -1,6 +1,7 @@ "use client" -import { useEffect, useState } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useEffect } from "react" import { useSearchParams } from "next/navigation" import { signIn, signUp, authClient } from "@/lib/auth-client" import { parseRetryAfterSeconds } from "@/lib/retry-after" @@ -48,14 +49,14 @@ function SignInForm({ accountDeleted: boolean appleEnabled: boolean }) { - const [email, setEmail] = useState("") - const [emailError, setEmailError] = useState("") - const [otpError, setOtpError] = useState("") - const [loading, setLoading] = useState(false) + const [email, setEmail] = useAtom(useCreateAtom("")) + const [emailError, setEmailError] = useAtom(useCreateAtom("")) + const [otpError, setOtpError] = useAtom(useCreateAtom("")) + const [loading, setLoading] = useAtom(useCreateAtom(false)) - const [code, setCode] = useState("") - const [step, setStep] = useState<"email" | "code">("email") - const [retryAfter, setRetryAfter] = useState(null) + const [code, setCode] = useAtom(useCreateAtom("")) + const [step, setStep] = useAtom(useCreateAtom<"email" | "code">("email")) + const [retryAfter, setRetryAfter] = useAtom(useCreateAtom(null)) useEffect(() => { if (retryAfter == null) return @@ -63,7 +64,7 @@ function SignInForm({ setRetryAfter((v) => (v == null || v <= 1 ? null : v - 1)) }, 1000) return () => clearTimeout(id) - }, [retryAfter]) + }, [retryAfter, setRetryAfter]) const rateLimitHandler = { onError: (ctx: { response: Response }) => { @@ -297,9 +298,9 @@ const galleryScenes: { scene: LandingScene; label: string; description: string } ] function ProductGallery() { - const [active, setActive] = useState(0) - const [enabled, setEnabled] = useState(false) - const [autoAdvance, setAutoAdvance] = useState(true) + const [active, setActive] = useAtom(useCreateAtom(0)) + const [enabled, setEnabled] = useAtom(useCreateAtom(false)) + const [autoAdvance, setAutoAdvance] = useAtom(useCreateAtom(true)) const activeScene = galleryScenes[active] useEffect(() => { @@ -316,7 +317,7 @@ function ProductGallery() { viewport.removeEventListener("change", sync) reducedMotion.removeEventListener("change", sync) } - }, []) + }, [setAutoAdvance, setEnabled]) useEffect(() => { if (!enabled || !autoAdvance) return @@ -324,7 +325,7 @@ function ProductGallery() { setActive((index) => (index + 1) % galleryScenes.length) }, sceneDurationMs(activeScene.scene)) return () => window.clearTimeout(timeout) - }, [activeScene.scene, autoAdvance, enabled]) + }, [activeScene.scene, autoAdvance, enabled, setActive]) if (!enabled) return null diff --git a/src/web/src/app/(auth)/sign-in/social-sign-in.tsx b/src/web/src/app/(auth)/sign-in/social-sign-in.tsx index 1269e1666..e00af49ca 100644 --- a/src/web/src/app/(auth)/sign-in/social-sign-in.tsx +++ b/src/web/src/app/(auth)/sign-in/social-sign-in.tsx @@ -1,6 +1,8 @@ "use client" -import { useEffect, useRef, useState } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; + +import { useEffect, useRef } from "react" import { isTauri, safeRedirectPath, type NativeOauthProvider } from "@alook/shared" import { SiGithub, SiGoogle } from "@icons-pack/react-simple-icons" import Image from "next/image" @@ -31,7 +33,7 @@ export function SocialSignIn({ appleEnabled?: boolean }) { const controller = useRef | null>(null) - const [native, setNative] = useState(null) + const [native, setNative] = useAtom(useCreateAtom(null)) const lastProvider = useRef("github") useEffect(() => { if (!isTauri()) return @@ -39,7 +41,7 @@ export function SocialSignIn({ controller.current = instance void instance.connect() return () => { instance.dispose(); if (controller.current === instance) controller.current = null } - }, []) + }, [setNative]) const begin = (provider: NativeOauthProvider) => { lastProvider.current = provider if (!isTauri()) { void signIn.social({ provider, callbackURL: postLoginUrl }); return } diff --git a/src/web/src/app/c/QueryProvider.dom.test.ts b/src/web/src/app/c/QueryProvider.dom.test.ts index 58af02249..0943da31a 100644 --- a/src/web/src/app/c/QueryProvider.dom.test.ts +++ b/src/web/src/app/c/QueryProvider.dom.test.ts @@ -1,221 +1,137 @@ -import { beforeEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import React from "react" -import { act, render } from "@/test/react-dom-harness" -import { useCommunityWsStore } from "@/stores/community/ws" +import { QueryClient, useQueryClient } from "@tanstack/react-query" +import { act, render, waitFor } from "@/test/react-dom-harness" +import { useCommunityRuntime } from "@/stores/community/runtime" +import { getCommunityDbRegistry } from "@/lib/community-db/collections" +import { createCommunityQueryOwner } from "@/test/community-query-owner" import { communityKeys } from "@/lib/query-keys" -const queryClient = vi.hoisted(() => ({ - id: "query-client", - invalidateQueries: vi.fn(() => Promise.resolve()), - getQueryData: vi.fn(() => undefined), - removeQueries: vi.fn(), - getQueryCache: vi.fn(() => ({ - subscribe: vi.fn(() => () => {}), - getAll: vi.fn(() => []), - })), -})) -const createQueryClient = vi.hoisted(() => vi.fn(() => queryClient)) +let queryClient: QueryClient +const createQueryClient = vi.hoisted(() => vi.fn()) const setReconcileScheduler = vi.hoisted(() => vi.fn()) -const getAccountUnreadProjection = vi.hoisted(() => vi.fn(() => ({ - setReconcileScheduler, -}))) +const getAccountUnreadProjection = vi.hoisted(() => vi.fn(() => ({ setReconcileScheduler }))) const disposeAccountUnreadProjection = vi.hoisted(() => vi.fn()) -const registryCleanup = vi.hoisted(() => vi.fn(() => Promise.resolve())) -const captureRestoredCollections = vi.hoisted(() => vi.fn()) const restoreResult = vi.hoisted(() => ({ current: "success" as "success" | "error" })) - +vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), replace: vi.fn() }) })) +vi.mock("@/lib/auth-client", () => ({ useSession: () => ({ isPending: true, data: null, error: null }), currentSessionViewer: () => undefined })) vi.mock("@tanstack/react-query-devtools", () => ({ ReactQueryDevtools: () => null })) vi.mock("@tanstack/react-query-persist-client", async () => { - const { useEffect } = await import("react") - return { - PersistQueryClientProvider: ({ - children, - onSuccess, - onError, - }: { - children: React.ReactNode - onSuccess: () => void - onError: () => void - }) => { - useEffect(() => { - if (restoreResult.current === "success") onSuccess() - else onError() - }, [onError, onSuccess]) - return children - }, - } + const { useEffect, useRef, useState } = await import("react") + const { QueryClientProvider, IsRestoringProvider } = await import("@tanstack/react-query") + return { PersistQueryClientProvider: ({ children, client, onSuccess, onError }: { + children: React.ReactNode; client: QueryClient; onSuccess: () => void; onError: () => void + }) => { + const done = useRef(false), [restored, setRestored] = useState(false) + useEffect(() => { + if (done.current) return + done.current = true + if (restoreResult.current === "success") onSuccess(); else onError() + setRestored(true) + }, [onSuccess, onError]) + return React.createElement(QueryClientProvider, { client }, React.createElement(IsRestoringProvider, { value: !restored }, children)) + } } }) vi.mock("@/lib/query-client", () => ({ createQueryClient })) -vi.mock("@/lib/query-persister", () => ({ - createIdbPersister: vi.fn(() => ({ id: "persister" })), - PERSIST_BUSTER: "test", - PERSIST_MAX_AGE_MS: 1, - shouldPersistQuery: vi.fn(() => false), -})) -vi.mock("@/lib/community-db/collections", () => ({ - createCommunityDbRegistry: vi.fn(() => ({ - id: "community-db", - captureRestoredCollections, - hasRestoredCollection: vi.fn(() => false), - cleanup: registryCleanup, - })), - registerCommunityDbRegistry: vi.fn(() => () => {}), -})) -vi.mock("@/lib/community-db/projections", () => ({ - CommunityDbProvider: ({ children }: { children: React.ReactNode }) => children, +vi.mock("@/lib/query-persister", async (importOriginal) => ({ ...await importOriginal(), + createIdbPersister: () => ({ persistClient: vi.fn(), restoreClient: vi.fn(), removeClient: vi.fn(), retireAccount: vi.fn(), isCurrent: async () => true }), })) -vi.mock("@/lib/community-db/sync", () => ({ - installCommunityDbSync: vi.fn(() => () => {}), -})) -vi.mock("@/hooks/community/community-ws/read-state-reconciliation", () => ({ - disposeAccountReadStateReconciliation: vi.fn(), -})) -vi.mock("@/hooks/community/read-coordinator", () => ({ disposeReadCoordinator: vi.fn() })) -vi.mock("@/hooks/community/account-unread-projection", () => ({ - disposeAccountUnreadProjection, - getAccountUnreadProjection, -})) - +vi.mock("@/lib/community-db/sync", async (importOriginal) => ({ ...await importOriginal(), installCommunityDbSync: () => () => undefined })) +vi.mock("@/hooks/community/account-unread-projection", async (importOriginal) => ({ ...await importOriginal(), getAccountUnreadProjection, disposeAccountUnreadProjection })) import { QueryProvider } from "./QueryProvider" -const originalActivateProfileAccount = useCommunityWsStore.getState().activateProfileAccount - beforeEach(() => { - useCommunityWsStore.setState({ activateProfileAccount: originalActivateProfileAccount }) - useCommunityWsStore.getState().reset() - createQueryClient.mockClear() - setReconcileScheduler.mockClear() - getAccountUnreadProjection.mockClear() - disposeAccountUnreadProjection.mockClear() - registryCleanup.mockClear() - captureRestoredCollections.mockClear() + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + createQueryClient.mockReset(); createQueryClient.mockReturnValue(queryClient) + setReconcileScheduler.mockClear(); getAccountUnreadProjection.mockClear(); disposeAccountUnreadProjection.mockClear() restoreResult.current = "success" - queryClient.invalidateQueries.mockClear() }) +afterEach(async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); await getCommunityDbRegistry(queryClient)?.cleanup(); queryClient.clear() + }) +}) + +function mount(userId: string, children: React.ReactNode = "content", strict = false) { + const tree = React.createElement(QueryProvider, { userId }, children) + return render(strict ? React.createElement(React.StrictMode, null, tree) : tree) +} describe("QueryProvider profile account lifecycle", () => { - it("activates the restored account after render", async () => { - const store = useCommunityWsStore.getState() - store.activateProfileAccount("viewer-a") - const activateProfileAccountSpy = vi.fn(store.activateProfileAccount) - useCommunityWsStore.setState({ activateProfileAccount: activateProfileAccountSpy }) - const observedViewerIds: Array = [] + it("cancels retirement during Strict Mode replay and cleans up the final owner once", async () => { + vi.useFakeTimers() + try { + const renderer = mount("viewer-strict", "content", true) + const registry = getCommunityDbRegistry(queryClient)!, cleanup = vi.spyOn(registry, "cleanup") + await act(async () => vi.advanceTimersByTimeAsync(0)) + expect(cleanup).not.toHaveBeenCalled() + act(() => renderer.unmount()) + expect(cleanup).not.toHaveBeenCalled() + await act(async () => vi.advanceTimersByTimeAsync(0)) + expect(cleanup).toHaveBeenCalledOnce() + expect(registry.runtime.lifecycle.get().active).toBe(false) + } finally { vi.useRealTimers() } + }) + it("owns the new account before restore while the prior owner remains isolated", async () => { + const previous = await createCommunityQueryOwner("viewer-a") + const observed: string[] = [] function Probe() { - observedViewerIds.push(useCommunityWsStore.getState().profileViewerId) + const runtime = useCommunityRuntime(), client = useQueryClient() + React.useLayoutEffect(() => { observed.push(runtime.ws.get().profileViewerId!); expect(client).toBe(queryClient) }, [client, runtime]) return null } - - const renderer = render(React.createElement( - QueryProvider, - { userId: "viewer-b" }, - React.createElement(Probe), - )) - - expect(observedViewerIds).toEqual(["viewer-a"]) - await act(async () => { await Promise.resolve() }) - expect(activateProfileAccountSpy).toHaveBeenCalledWith("viewer-b") + const renderer = mount("viewer-b", React.createElement(Probe)) + await waitFor(() => expect(observed).toContain("viewer-b")) + expect(previous.registry.runtime.ws.get().profileViewerId).toBe("viewer-a") + expect(getCommunityDbRegistry(queryClient)?.accountId).toBe("viewer-b") act(() => renderer.unmount()) }) - it("revalidates durable projections after restore", async () => { - const store = useCommunityWsStore.getState() - store.activateProfileAccount("viewer-b") - - const renderer = render(React.createElement( - QueryProvider, - { userId: "viewer-b" }, - React.createElement("span", null, "content"), - )) - await act(async () => { await Promise.resolve() }) - - expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: communityKeys.folders(), - exact: true, - refetchType: "active", - }) - expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: communityKeys.dms(), - exact: true, - refetchType: "active", - }) + const invalidate = vi.spyOn(queryClient, "invalidateQueries") + const renderer = mount("viewer-b") + await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: communityKeys.folders(), exact: true, refetchType: "active" })) + expect(invalidate).toHaveBeenCalledWith({ queryKey: communityKeys.dms(), exact: true, refetchType: "active" }) act(() => renderer.unmount()) }) - it("confirms the new viewer and empty canonical baseline after restore failure", async () => { restoreResult.current = "error" - const store = useCommunityWsStore.getState() - store.activateProfileAccount("viewer-old") - const activateProfileAccountSpy = vi.fn(store.activateProfileAccount) - useCommunityWsStore.setState({ activateProfileAccount: activateProfileAccountSpy }) - - const renderer = render(React.createElement( - QueryProvider, - { userId: "viewer-new" }, - React.createElement("span", null, "content"), - )) - await act(async () => { await Promise.resolve() }) - - expect(captureRestoredCollections).toHaveBeenCalledOnce() - expect(activateProfileAccountSpy).toHaveBeenCalledWith("viewer-new") - expect(useCommunityWsStore.getState().profileViewerId).toBe("viewer-new") - expect(queryClient.invalidateQueries).not.toHaveBeenCalled() + const invalidate = vi.spyOn(queryClient, "invalidateQueries") + const renderer = mount("viewer-new") + const registry = getCommunityDbRegistry(queryClient)! + await registry.ready + expect(registry.runtime.ws.get().profileViewerId).toBe("viewer-new") + expect([...registry.collections.friendships.values()]).toEqual([]) + expect(registry.hasRestoredCollection("friendships")).toBe(false) + expect(invalidate).not.toHaveBeenCalled() act(() => renderer.unmount()) }) - it("routes projection reconciliation through the canonical source prefixes", async () => { - const renderer = render(React.createElement( - QueryProvider, - { userId: "viewer-c" }, - React.createElement("span", null, "content"), - )) - + const invalidate = vi.spyOn(queryClient, "invalidateQueries"), renderer = mount("viewer-c") expect(getAccountUnreadProjection).toHaveBeenCalledWith(queryClient, "viewer-c") const reconcile = setReconcileScheduler.mock.calls.at(-1)?.[0] expect(reconcile).toBeTypeOf("function") + invalidate.mockClear() await act(async () => reconcile()) - expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: communityKeys.inboxUnreads(), - exact: true, - }) - expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: communityKeys.inboxMentions(), - exact: true, - }) - expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: communityKeys.dms(), - exact: true, - }) - expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: communityKeys.servers(), - exact: true, - }) - const detailPredicate = queryClient.invalidateQueries.mock.calls - .map(([filters]) => filters.predicate) - .find((predicate) => typeof predicate === "function") - expect(detailPredicate).toBeTypeOf("function") - expect(detailPredicate({ queryKey: communityKeys.server("server-1") })).toBe(true) - expect(detailPredicate({ queryKey: communityKeys.members("server-1") })).toBe(false) - expect(detailPredicate({ queryKey: communityKeys.channelRefDirectory() })).toBe(false) - expect(detailPredicate({ queryKey: communityKeys.server("__none__") })).toBe(false) - expect(detailPredicate({ queryKey: communityKeys.server("__pending__") })).toBe(false) + for (const key of [communityKeys.inboxUnreads(), communityKeys.inboxMentions(), communityKeys.dms(), communityKeys.servers()]) expect(invalidate).toHaveBeenCalledWith({ queryKey: key, exact: true }) + const predicate = invalidate.mock.calls.map(([filters]) => filters?.predicate).find((value) => typeof value === "function")! + const query = (queryKey: readonly unknown[]) => ({ queryKey } as Parameters[0]) + expect(predicate(query(communityKeys.server("server-1")))).toBe(true) + for (const key of [communityKeys.members("server-1"), communityKeys.channelRefDirectory(), communityKeys.server("__none__"), communityKeys.server("__pending__")]) expect(predicate(query(key))).toBe(false) act(() => renderer.unmount()) }) - - it("does not manually destroy collections while descendant live queries release", async () => { + it("releases collections after descendant subscriptions release on real retirement", async () => { vi.useFakeTimers() try { - const renderer = render(React.createElement( - QueryProvider, - { userId: "viewer-d" }, - React.createElement("span", null, "content"), - )) - + let descendantReleased = false + function Child() { React.useLayoutEffect(() => () => { descendantReleased = true }, []); return null } + const renderer = mount("viewer-d", React.createElement(Child)) + const registry = getCommunityDbRegistry(queryClient)!, cleanup = vi.spyOn(registry, "cleanup") act(() => renderer.unmount()) - await act(async () => vi.runAllTimersAsync()) - - expect(registryCleanup).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } + expect(descendantReleased).toBe(true) + expect(cleanup).not.toHaveBeenCalled() + await act(async () => vi.advanceTimersByTimeAsync(0)) + expect(cleanup).toHaveBeenCalledOnce() + expect(queryClient.getQueryCache().getAll()).toEqual([]) + } finally { vi.useRealTimers() } }) }) diff --git a/src/web/src/app/c/QueryProvider.lifecycle.dom.test.tsx b/src/web/src/app/c/QueryProvider.lifecycle.dom.test.tsx new file mode 100644 index 000000000..54b1a605d --- /dev/null +++ b/src/web/src/app/c/QueryProvider.lifecycle.dom.test.tsx @@ -0,0 +1,115 @@ +import { useLayoutEffect } from "react" +import "fake-indexeddb/auto" +import React from "react" +import { useIsRestoring, useMutation, useQueryClient } from "@tanstack/react-query" +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest" +import { act, render, waitFor } from "@/test/react-dom-harness" +import { QueryProvider } from "./QueryProvider" +import { getCommunityDbRegistry, type CommunityDbRegistry } from "@/lib/community-db/collections" +import { useCommunityMutationOrigin } from "@/hooks/community/community-origin" +import { clearAllPersistedCaches, createIdbPersister, CACHE_INVALIDATION_STORAGE_KEY } from "@/lib/query-persister" +import { useAccountSignOut } from "@/hooks/community/use-account-sign-out" +import { useApplicationOwner, type ApplicationOwner } from "@/lib/application-owner" +import { FileDownloadButton } from "@/components/file-download-button" +const identity = vi.hoisted(() => ({ id: "A", signOut: vi.fn(), download: vi.fn(), success: vi.fn(), error: vi.fn() })) +vi.mock("@/lib/auth-client", () => { const sessionSDK = { useSession: () => ({ data: { user: { id: identity.id } }, isPending: false, error: null }), signOut: (...args: unknown[]) => identity.signOut(...args) }; return { ...sessionSDK, signOutWithOrigin: async (assertActive: () => void, ...args: unknown[]) => { assertActive(); const result = await sessionSDK.signOut(...args); assertActive(); return result }, currentSessionViewer: () => { const value = sessionSDK.useSession(); return !value || value.isPending || value.error ? undefined : value.data?.user.id ?? null } } }) +vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: vi.fn(), refresh: vi.fn() }) })) +vi.mock("@tanstack/react-query-devtools", () => ({ ReactQueryDevtools: () => null })) +vi.mock("@/lib/file-save", async (importOriginal) => ({ ...await importOriginal(), downloadUrl: identity.download })) +vi.mock("sonner", () => ({ toast: { success: identity.success, error: identity.error } })) +let registry: CommunityDbRegistry +let restoring: boolean +let application: ApplicationOwner +let command: () => Promise +let logout: () => Promise +let complete: (response: Response) => void +let assign: ReturnType, reload: ReturnType +function Probe() { + const currentRestoring = useIsRestoring(); useLayoutEffect(() => { restoring = currentRestoring }) + const client = useQueryClient(), currentRegistry = getCommunityDbRegistry(client)!; useLayoutEffect(() => { registry = currentRegistry }) + const currentApplication = useApplicationOwner(); useLayoutEffect(() => { application = currentApplication }) + const origin = useCommunityMutationOrigin(); const mutation = useMutation({ mutationFn: () => origin.fetch("/api/community/private") }); useLayoutEffect(() => { command = () => mutation.mutateAsync() }) + const signOut = useAccountSignOut(); useLayoutEffect(() => { logout = () => signOut.mutateAsync() }) + return
{currentRegistry.accountId}
+} +function App({ id = identity.id, download = false }: { id?: string; download?: boolean }) { return {download && Get file} } +const payload = { timestamp: Date.now(), buster: "v3", clientState: { queries: [], mutations: [] } } +beforeEach(async () => { + await act(async () => { await clearAllPersistedCaches(); }) ; identity.id = "A"; identity.signOut.mockReset(); identity.download.mockReset(); identity.success.mockReset(); identity.error.mockReset(); registry = undefined as unknown as CommunityDbRegistry + assign = vi.fn(); reload = vi.fn(); const real = window + vi.stubGlobal("window", new Proxy(real, { get(target, key) { return key === "location" ? { origin: "https://alook.test", assign, reload } : Reflect.get(target, key, target) } })) + vi.stubGlobal("fetch", vi.fn(() => new Promise((resolve) => { complete = resolve }))) +}) +afterEach(async () => { + await act(async () => { + vi.unstubAllGlobals(); registry?.queryClient.clear() + }) +}) +describe("actual native community account provider", () => { + it("the actual community entry owns file IO with the same client and account lifetime", async () => { + identity.download.mockResolvedValue({ status: "saved" }) + const mounted = render() + await waitFor(() => expect(registry?.authenticationView.get().active).toBe(true)) + expect(application.queryClient).toBe(registry.queryClient) + expect(application.lifecycle).toBe(registry.runtime.lifecycle) + await act(async () => mounted.getByRole("button").click()) + await waitFor(() => expect(mounted.getByRole("status")).toHaveTextContent("Saved")) + expect(identity.download).toHaveBeenCalledWith("/private/file", "a.pdf", expect.objectContaining({ signal: expect.any(AbortSignal), assertActive: expect.any(Function) })) + expect(identity.success).toHaveBeenCalledOnce() + }) + it("an account replacement cancels held native file IO and suppresses its saved receipt", async () => { + let finish!: (value: { status: "saved" }) => void + identity.download.mockImplementation(() => new Promise((resolve) => { finish = resolve })) + const mounted = render() + await waitFor(() => expect(registry?.authenticationView.get().active).toBe(true)) + const old = application + await act(async () => mounted.getByRole("button").click()) + await waitFor(() => expect(identity.download).toHaveBeenCalledOnce()) + const options = identity.download.mock.calls[0]![2] as { signal: AbortSignal; assertActive: () => void } + identity.id = "B" + await act(async () => mounted.rerender()) + await waitFor(() => expect(application.userId).toBe("B")) + await waitFor(() => expect(options.signal.aborted).toBe(true)) + expect(old.lifecycle.get().active).toBe(false) + expect(() => options.assertActive()).toThrow() + await act(async () => finish({ status: "saved" })) + expect(mounted.getByRole("status")).toHaveTextContent("") + expect(application.queryClient).not.toBe(old.queryClient) + expect(identity.success).not.toHaveBeenCalled() + expect(identity.error).not.toHaveBeenCalled() + }) + it("current 401 retires canonical native facts and both disk domains before navigation", async () => { + render(); await waitFor(() => expect(registry?.authenticationView.get().active).toBe(true)); await waitFor(() => expect(restoring).toBe(false)); await act(async () => { await registry.preload() }) + const old = registry, community = createIdbPersister("A"), application = createIdbPersister("A", "application"); await act(async () => { await community.persistClient(payload); }) ; await act(async () => { await application.persistClient(payload) }) + let done!: ReturnType; await act(async () => { done = command().catch((error) => error) }) ; await waitFor(() => expect(complete).toBeTypeOf("function")); await act(async () => { complete(new Response("{}", { status: 401 })); await done }) + await waitFor(() => expect(assign).toHaveBeenCalledOnce()); expect(old.runtime.lifecycle.get().active).toBe(false); expect(old.queryClient.getQueryCache().getAll()).toHaveLength(0) + expect(await community.isCurrent()).toBe(false); expect(await application.isCurrent()).toBe(false); expect(reload).not.toHaveBeenCalled() + }) + it("direct provider identity change creates B rather than reusing A's native instances", async () => { + const mounted = render(); await waitFor(() => expect(registry?.authenticationView.get().active).toBe(true)); await waitFor(() => expect(restoring).toBe(false)); await act(async () => { await registry.preload() }); const old = registry + let done!: ReturnType; await act(async () => { done = command().catch((error) => error) }) ; await waitFor(() => expect(complete).toBeTypeOf("function")); const oldResponse = complete + identity.id = "B"; await act(async () => mounted.rerender()); await waitFor(() => expect(registry?.accountId).toBe("B")); await waitFor(() => expect(restoring).toBe(false)); await act(async () => { await registry.preload() }) + await act(async () => registry.queryClient.setQueryData(["current"], { private: "B" })); await act(async () => oldResponse(new Response("{}", { status: 401 }))); expect(await done).toMatchObject({ name: "AbortError" }) + expect(registry.queryClient).not.toBe(old.queryClient); expect(registry.runtime.ui).not.toBe(old.runtime.ui); expect(registry.runtime.lifecycle.get().active).toBe(true); expect(registry.queryClient.getQueryData(["current"])).toEqual({ private: "B" }); expect(assign).not.toHaveBeenCalled(); expect(reload).not.toHaveBeenCalled() + }) + it("all-device invalidation retires this native account and releases its DB collections", async () => { + render(); await waitFor(() => expect(registry?.authenticationView.get().active).toBe(true)); await waitFor(() => expect(restoring).toBe(false)); await act(async () => { await registry.preload() }); const old = registry + await act(async () => old.queryClient.setQueryData(["private"], { private: "A" })); await act(async () => clearAllPersistedCaches()) + await waitFor(() => expect(old.runtime.lifecycle.get().active).toBe(false)); await waitFor(() => expect(old.queryClient.getQueryCache().getAll()).toHaveLength(0)); expect(reload).toHaveBeenCalledOnce() + }) + it("an untrusted storage hint cannot retire a durably eligible native owner", async () => { + render(); await waitFor(() => expect(registry?.authenticationView.get().active).toBe(true)); await waitFor(() => expect(restoring).toBe(false)); await act(async () => { await registry.preload() }) + await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: CACHE_INVALIDATION_STORAGE_KEY, newValue: "hint" }))); await act(async () => { await new Promise((done) => setTimeout(done, 0)) }) + expect(registry.runtime.lifecycle.get().active).toBe(true); expect(reload).not.toHaveBeenCalled() + }) + it("native community logout confirms SDK success then clears its original account", async () => { + identity.signOut.mockImplementation(async (options) => { options.fetchOptions.onRequest(); options.fetchOptions.onSuccess(); return { data: { success: true } } }) + render(); await waitFor(() => expect(registry?.authenticationView.get().active).toBe(true)); await waitFor(() => expect(restoring).toBe(false)); await act(async () => { await registry.preload() }); const original = registry, disk = createIdbPersister("A"); await act(async () => { await disk.persistClient(payload) }) + let navigate: boolean | undefined; await act(async () => { navigate = await logout() }); expect(navigate).toBe(true); expect(original.runtime.lifecycle.get().active).toBe(false); expect(await disk.isCurrent()).toBe(false) + }) + it("current SDK logout failure keeps the native account and facts active", async () => { + identity.signOut.mockResolvedValue({ error: { message: "Cannot log out" } }) + render(); await waitFor(() => expect(registry?.authenticationView.get().active).toBe(true)); await waitFor(() => expect(restoring).toBe(false)); await act(async () => { await registry.preload() }); await act(async () => registry.queryClient.setQueryData(["private"], { private: "A" })) + await act(async () => { await expect(logout()).rejects.toThrow("Cannot log out") }); expect(registry.runtime.lifecycle.get().active).toBe(true); expect(registry.queryClient.getQueryData(["private"])).toEqual({ private: "A" }) + }) +}) diff --git a/src/web/src/app/c/QueryProvider.restore-order.dom.test.tsx b/src/web/src/app/c/QueryProvider.restore-order.dom.test.tsx index cc4ced7aa..96f12497a 100644 --- a/src/web/src/app/c/QueryProvider.restore-order.dom.test.tsx +++ b/src/web/src/app/c/QueryProvider.restore-order.dom.test.tsx @@ -1,17 +1,21 @@ -import { beforeEach, describe, expect, it, vi } from "vitest" +import { useToggleReactionApi } from "@/hooks/community/mutations/message-reactions" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import React from "react" +import { runInNewContext } from "node:vm" import { dehydrate, QueryClient, + useIsRestoring, useQueryClient, } from "@tanstack/react-query" -import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client" +import type { PersistedClient } from "@tanstack/react-query-persist-client" import { act, render, screen, waitFor } from "@/test/react-dom-harness" import { communityKeys } from "@/lib/query-keys" -import { PERSIST_BUSTER } from "@/lib/query-persister" +import { PERSIST_BUSTER, type QualifiedPersister } from "@/lib/query-persister" import { CurrentUserProvider } from "@/contexts/community/current-user" import { useCanonicalMessagesById, + useOptionalCommunityDbRegistry, useDmProjection, useTrustedRestoredPrimary, } from "@/lib/community-db/projections" @@ -20,6 +24,13 @@ const persister = vi.hoisted(() => ({ persistClient: vi.fn(() => Promise.resolve()), restoreClient: vi.fn(), removeClient: vi.fn(() => Promise.resolve()), + isCurrent: vi.fn(async () => true), + retireAccount: vi.fn(async () => {}), +})) +const session = vi.hoisted(() => ({ viewer: "restore-order-viewer" })) +vi.mock("@/lib/auth-client", () => ({ + useSession: () => ({ data: { user: { id: session.viewer } }, isPending: false, error: null }), + currentSessionViewer: () => session.viewer, })) vi.mock("@tanstack/react-query-devtools", () => ({ ReactQueryDevtools: () => null })) @@ -27,19 +38,81 @@ vi.mock("@/lib/query-persister", async (importOriginal) => { const actual = await importOriginal() return { ...actual, - createIdbPersister: vi.fn(() => persister as Persister), + createIdbPersister: vi.fn(() => persister satisfies QualifiedPersister), } }) import { QueryProvider } from "./QueryProvider" +import { CommunityRestoreBoundary } from "@/components/community/shell/community-restore-bootstrap" +import { createCommunityDbRegistry, getCommunityDbRegistry } from "@/lib/community-db/collections" +import { getCommunityRuntime, useCommunityRuntime } from "@/stores/community/runtime" +import { applyTypingIndicator } from "@/hooks/community/community-ws/typing" +import { useCommunityStore } from "@/stores/community" +import { apiFetchProfiles } from "@/lib/community/profile-seed" +import { useChannelRouteModel } from "@/hooks/community/use-channel-route-model" +vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: vi.fn() }) })) +vi.mock("@/hooks/community/use-servers", async (importOriginal) => ({ + ...await importOriginal(), + useServer: () => ({ server: { id: "strict-server", categories: [{ id: "strict-category", channels: [{ id: "strict-route", type: "text" }] }] } }), +})) +vi.mock("@/hooks/community/use-child-channel-meta", () => ({ + useChildChannelMeta: () => ({ isVerified: true, isFetching: false, isError: false, refetch: vi.fn() }), +})) + +afterEach(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) }) beforeEach(() => { + session.viewer = "restore-order-viewer" persister.persistClient.mockClear() persister.restoreClient.mockReset() persister.removeClient.mockClear() + persister.isCurrent.mockClear() + persister.retireAccount.mockClear() }) describe("QueryProvider persistence ordering", () => { + it.each([0, 1_000])("preserves the original server snapshot timestamp through legacy position migration (%s)", async (updatedAt) => { + const client = new QueryClient() + const key = communityKeys.communityDbCollection("restore-order-viewer", "servers") + client.setQueryData(key, [{ id: "legacy-server", name: "Legacy", discriminator: "0001", description: "", ownerId: "restore-order-viewer", icon: null, official: false, isOwner: true, unread: false, mentions: 0 }], { updatedAt }) + const registry = createCommunityDbRegistry(client, "restore-order-viewer") + registry.captureRestoredCollections() + expect(client.getQueryState(key)?.dataUpdatedAt).toBe(updatedAt) + expect(client.getQueryData(key)).toEqual([expect.objectContaining({ id: "legacy-server", position: 0 })]) + expect(registry.hasRestoredCollection("servers")).toBe(updatedAt > 0) + expect(registry.hasRestoredData()).toBe(updatedAt > 0) + await registry.cleanup() + client.clear() + }) + it.each([false, true])("distinguishes the SSR self profile from an actual restored profile (%s)", async (hasDiskSnapshot) => { + const viewerId = "restore-order-viewer" + const profileKey = communityKeys.communityDbCollection(viewerId, "profiles") + const seed = new QueryClient() + seed.setQueryData(profileKey, [{ userId: viewerId, name: "Disk viewer", discriminator: "0001", avatar: "D", avatarVersion: 1 }], { updatedAt: Date.now() - 60_000 }) + persister.restoreClient.mockResolvedValue(hasDiskSnapshot ? { buster: PERSIST_BUSTER, timestamp: Date.now(), clientState: dehydrate(seed) } : undefined) + const mark = vi.spyOn(performance, "mark") + mark.mockClear() + let registry!: NonNullable> + let restoring = true + function Probe() { + const current = useOptionalCommunityDbRegistry()! + const pending = useIsRestoring() + React.useLayoutEffect(() => { registry = current; restoring = pending }) + return null + } + const renderer = render( + + ) + await waitFor(() => expect(restoring).toBe(false)) + expect(registry.hasRestoredData()).toBe(hasDiskSnapshot) + expect(registry.hasRestoredCollection("profiles")).toBe(hasDiskSnapshot) + await waitFor(() => expect(registry.collections.profiles.get(viewerId)?.name).toBe(hasDiskSnapshot ? "Disk viewer" : "SSR viewer")) + expect(mark.mock.calls.filter(([name]) => name === "alook:restore:first-cached-paint")).toHaveLength(hasDiskSnapshot ? 1 : 0) + mark.mockRestore() + renderer.unmount() + await waitFor(() => expect(registry.collections.profiles.status).toBe("cleaned-up")) + seed.clear() + }) it("hydrates canonical rows before collection preload can publish an empty snapshot", async () => { const viewerId = "restore-order-viewer" const message = { @@ -120,9 +193,10 @@ describe("QueryProvider persistence ordering", () => { resolveRestore = resolve })) - const mounted = { client: null as QueryClient | null } + const mounted = { client: null as QueryClient | null, registry: null as ReturnType } function Probe() { mounted.client = useQueryClient() + mounted.registry = useOptionalCommunityDbRegistry() const trustedRestoredPrimary = useTrustedRestoredPrimary() const messages = useCanonicalMessagesById() const dms = useDmProjection() @@ -166,5 +240,150 @@ describe("QueryProvider persistence ordering", () => { expect(mounted.client?.getQueryData(canonicalKey)).toEqual([message]) act(() => renderer.unmount()) + await waitFor(() => { + expect(mounted.registry?.collections.messages.status).toBe("cleaned-up") + expect(mounted.registry?.collections.messages.size).toBe(0) + expect(mounted.client?.getQueryData(canonicalKey)).toBeUndefined() + }) + }) +}) + +describe("QueryProvider real DbClient Strict Mode ownership", () => { + it("receives foreign-realm preload cancellation through the actual provider", async () => { + session.viewer = "foreign-cancel" + persister.restoreClient.mockResolvedValue(undefined) + const cancellation: unknown = runInNewContext('Object.assign(new Error("cancelled"), { name: "AbortError" })') + expect(cancellation).not.toBeInstanceOf(Error) + const report = vi.spyOn(console, "error").mockImplementation(() => {}) + let registry!: NonNullable> + let preload!: ReturnType + function Probe() { + registry = useOptionalCommunityDbRegistry()! + React.useLayoutEffect(() => { + preload = vi.spyOn(registry, "preload").mockRejectedValueOnce(cancellation) + }, []) + return null + } + try { + const renderer = render() + await waitFor(() => expect(preload).toHaveBeenCalledOnce()) + await act(async () => { await Promise.resolve() }) + expect(report).not.toHaveBeenCalledWith("Community collection preload failed", cancellation) + act(() => renderer.unmount()) + await waitFor(() => expect(registry.collections.messages.status).toBe("cleaned-up")) + expect(registry.queryClient.getQueryCache().getAll()).toHaveLength(0) + } finally { report.mockRestore() } + }) + + it("receives cancellation and cleanup promises while reporting ordinary preload and cleanup failures", async () => { + session.viewer = "failure-receiver" + persister.restoreClient.mockResolvedValue(undefined) + const cancellation: unknown = runInNewContext('Object.assign(new Error("cancelled"), { name: "AbortError" })') + const preloadFailure = new Error("ordinary preload failure") + const cleanupFailure = new Error("ordinary cleanup failure") + const report = vi.spyOn(console, "error").mockImplementation(() => {}) + let registry!: NonNullable> + function Probe() { + registry = useOptionalCommunityDbRegistry()! + React.useLayoutEffect(() => { + vi.spyOn(registry, "preload").mockRejectedValueOnce(preloadFailure) + const cancel = registry.queryClient.cancelQueries.bind(registry.queryClient) + vi.spyOn(registry.queryClient, "cancelQueries").mockImplementation(async (...args) => { + await cancel(...args) + throw cancellation + }) + const cleanupRegistry = registry.cleanup.bind(registry) + vi.spyOn(registry, "cleanup").mockImplementation(async () => { + await cleanupRegistry() + throw cleanupFailure + }) + }, []) + return null + } + try { + const renderer = render() + await waitFor(() => expect(report).toHaveBeenCalledWith("Community collection preload failed", preloadFailure)) + act(() => renderer.unmount()) + await waitFor(() => expect(report).toHaveBeenCalledWith("Community collection cleanup failed", cleanupFailure)) + expect(report).not.toHaveBeenCalledWith("Community query cancellation failed", cancellation) + expect(registry.collections.messages.status).toBe("cleaned-up") + expect(registry.queryClient.getQueryCache().getAll()).toHaveLength(0) + } finally { report.mockRestore() } + }) + + it("keeps child layout on the retained registry and cleans captured resources on final exit", async () => { + session.viewer = "strict-viewer" + persister.restoreClient.mockResolvedValue(undefined) + const seen: Array<{ same: boolean; runtimeSame: boolean }> = [] + const cleanupReads: boolean[] = [] + const requestOwners: Array> = [] + const requestResults: Array = [] + const fetch = vi.fn((_input: RequestInfo | URL) => Promise.resolve(new Response(JSON.stringify({ id: "strict-peer", name: "Strict response" }), { status: 200 }))) + vi.stubGlobal("fetch", fetch) + let original: ReturnType = null + let client: QueryClient | null = null + let layoutRuns = 0 + let reactToMessage!: ReturnType + const revoke = vi.fn() + const originalRevoke = URL.revokeObjectURL + URL.revokeObjectURL = revoke + function RouteConsumer() { + reactToMessage = useToggleReactionApi() + useChannelRouteModel("strict-server", "strict-server", "strict-route", "strict-viewer") + const qc = useQueryClient() + const registry = useOptionalCommunityDbRegistry()! + const runtime = useCommunityRuntime() + const selected = useCommunityStore((state) => state.currentChannelId) + React.useLayoutEffect(() => { + layoutRuns++ + client = qc + original = registry + seen.push({ same: getCommunityDbRegistry(qc) === registry, runtimeSame: getCommunityRuntime(qc) === runtime }) + runtime.ui.actions.setCurrentChannelId("strict-route") + requestOwners.push(registry) + const controller = new AbortController() + void apiFetchProfiles<{ id: string; name: string }>("/api/community/users/strict-peer/profile", (profile) => [{ id: profile.id, identityAbout: { name: profile.name } }], { signal: controller.signal }, registry) + .then((data) => { requestResults.push(data.name) }, (error: unknown) => { requestResults.push((error as { name: string }).name) }) + return () => { controller.abort(); runtime.ui.actions.setCurrentChannelId(null) } + }, [qc, registry, runtime]) + React.useEffect(() => () => { + cleanupReads.push(runtime === registry.runtime) + runtime.ui.actions.unsubscribe() + }, [registry, runtime]) + return {selected} + } + try { + const renderer = render() + await waitFor(() => expect(screen.getByTestId("strict-route").textContent).toBe("strict-route")) + await waitFor(() => expect(requestResults).toEqual(["AbortError", "Strict response"])) + expect(requestOwners).toEqual([original, original]) + expect(fetch.mock.calls.filter(([input]) => String(input).includes("/users/strict-peer/profile"))).toHaveLength(1) + expect(original!.collections.profiles.get("strict-peer")?.name).toBe("Strict response") + expect(layoutRuns).toBe(2) + expect(seen).toEqual([{ same: true, runtimeSame: true }, { same: true, runtimeSame: true }]) + expect(original!.runtime.lifecycle.get().active).toBe(true) + expect(original!.collections.messages.status).not.toBe("cleaned-up") + await act(async () => { + applyTypingIndicator(client!, "ch:strict-route", "peer", "Peer") + reactToMessage({ messageId: "strict-message", emoji: "👍", userId: "strict-viewer", currentMe: false }) + original!.runtime.messageStream.actions.accept({ kind: "dm", id: "strict-dm" }, { + nonce: "strict-nonce", tempId: "strict-temp", + message: { type: "chat", authorId: "strict-viewer", content: "pending", createdAt: new Date().toISOString() }, + localUploads: [{ file: new File(["x"], "preview.png"), previewObjectUrl: "blob:strict-preview" }], + }) + }) + const originalRequests = fetch.mock.calls.map(([input]) => String(input)) + act(() => renderer.unmount()) + await waitFor(() => expect(original!.runtime.lifecycle.get().active).toBe(false)) + await waitFor(() => expect(original!.collections.messages.status).toBe("cleaned-up")) + expect(getCommunityDbRegistry(client!)).toBeNull() + expect(cleanupReads).toEqual([true, true]) + expect(original!.runtime.ui.get().typingTimers.size).toBe(0) + await waitFor(() => expect(client!.isMutating({ mutationKey: ["community", "message-reaction"] })).toBe(0)) + expect(fetch.mock.calls.filter(([input]) => String(input).includes("/users/strict-peer/profile"))).toHaveLength(1) + expect(fetch.mock.calls.map(([input]) => String(input))).toEqual(originalRequests) + expect(original!.runtime.messageStream.get().entries.size).toBe(0) + expect(revoke).toHaveBeenCalledWith("blob:strict-preview") + } finally { URL.revokeObjectURL = originalRevoke; vi.unstubAllGlobals() } }) }) diff --git a/src/web/src/app/c/QueryProvider.tsx b/src/web/src/app/c/QueryProvider.tsx index a2c09efd0..cc6035aa3 100644 --- a/src/web/src/app/c/QueryProvider.tsx +++ b/src/web/src/app/c/QueryProvider.tsx @@ -1,10 +1,11 @@ "use client" -import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react" +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react" import { useIsRestoring, type QueryClient } from "@tanstack/react-query" import { ReactQueryDevtools } from "@tanstack/react-query-devtools" import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client" import { createQueryClient } from "@/lib/query-client" +import { isAbortError } from "@/lib/errors" import { createIdbPersister, PERSIST_BUSTER, @@ -13,7 +14,6 @@ import { } from "@/lib/query-persister" import { disposeAccountReadStateReconciliation } from "@/hooks/community/community-ws/read-state-reconciliation" import { disposeReadCoordinator } from "@/hooks/community/read-coordinator" -import { useCommunityWsStore } from "@/stores/community/ws" import { disposeAccountUnreadProjection, getAccountUnreadProjection, @@ -27,8 +27,15 @@ import { registerCommunityDbRegistry, type CommunityDbRegistry, } from "@/lib/community-db/collections" +import { useSelector } from "@tanstack/react-store" +import { useSession, currentSessionViewer } from "@/lib/auth-client" +import { useRouter } from "next/navigation" +import { usePersistedAccountLifecycle } from "@/lib/use-persisted-account-lifecycle" +import { retireCommunityAccount } from "@/lib/community/account-cache-lifecycle" import { CommunityDbProvider } from "@/lib/community-db/projections" import { installCommunityDbSync } from "@/lib/community-db/sync" +import { profileSchema } from "@/lib/community-db/schema" +import type { CurrentUser } from "@/contexts/community/current-user" function createRestoreGate() { let release!: () => void @@ -57,12 +64,18 @@ function CommunityDbRuntime({ } const unregisterCommunityDb = registerCommunityDbRegistry(registry) return () => { - unregisterCommunityDb() disposeTimer.current = setTimeout(() => { disposeTimer.current = null + unregisterCommunityDb() + void queryClient.cancelQueries().catch((error: unknown) => { + if (!isAbortError(error)) console.error("Community query cancellation failed", error) + }) disposeReadCoordinator(queryClient) disposeAccountReadStateReconciliation(queryClient) disposeAccountUnreadProjection(queryClient) + void registry.cleanup().finally(() => queryClient.clear()).catch((error: unknown) => { + if (!isAbortError(error)) console.error("Community collection cleanup failed", error) + }) }, 0) } }, [queryClient, registry]) @@ -85,22 +98,42 @@ function CommunityDbRuntime({ * The client is held in `useState(() => createQueryClient())` so React * strict-mode double-invoke in dev doesn't discard queries between mounts and * so each SSR request gets its own instance rather than sharing a - * module-scoped singleton across users. Coexists with `` - * during the God-context migration — later steps move state into TanStack - * Query and Zustand, then delete the old provider. + * module-scoped singleton across users. Collections remain alive with this + * account owner and are cleaned up after its descendants release. * * `userId` scopes the IndexedDB namespace so account switches never surface * the previous session's cached message list. Passing `null` (pre-auth) hits * an "anon" namespace that never carries real content. */ -export function QueryProvider({ +export function QueryProvider(props: { children: ReactNode; userId: string | null; initialUser?: CurrentUser }) { + const session = useSession() + return +} + +function ScopedQueryProvider({ children, userId, + initialUser, + session, + sessionViewer, }: { children: ReactNode userId: string | null + initialUser?: CurrentUser + session: ReturnType + sessionViewer: () => string | null | undefined }) { - const [queryClient] = useState(() => createQueryClient()) + const [queryClient] = useState(() => { + const client = createQueryClient() + if (initialUser && initialUser.id === userId) { + client.setQueryData(communityKeys.communityDbCollection(userId, "profiles"), [profileSchema.parse({ + userId, name: initialUser.name, discriminator: initialUser.discriminator ?? "", + avatar: initialUser.avatar, avatarVersion: initialUser.avatarVersion ?? 0, + aboutMe: initialUser.aboutMe, statusEmoji: initialUser.statusEmoji, statusText: initialUser.statusText, + })], { updatedAt: 0 }) + } + return client + }) const [restoreGate] = useState(createRestoreGate) const [communityDb] = useState(() => createCommunityDbRegistry(queryClient, userId, { waitForRestore: restoreGate.ready, @@ -132,12 +165,46 @@ export function QueryProvider({ // whole community subtree unmounts and the shell re-renders with the new // id, so we don't need to reactively rebuild the persister mid-session. const [persister] = useState(() => createIdbPersister(userId)) + const active = useSelector(communityDb.runtime.lifecycle, (state) => state.active) + const router = useRouter() + useLayoutEffect(() => { communityDb.bindAuthentication(sessionViewer, persister.retireAccount) }, [communityDb, sessionViewer, persister]) + const identityRetired = useRef(false) + const identityChanged = !session.isPending && !session.error && session.data?.user.id !== userId + useLayoutEffect(() => { + communityDb.authenticationView.setState((state) => ({ ...state, active: true })) + return () => { + communityDb.authenticationView.setState((state) => ({ active: false, generation: state.generation + 1 })) + const viewer = communityDb.sessionViewer() + if (viewer !== undefined && viewer !== communityDb.accountId) { + retireCommunityAccount(communityDb) + restoreGate.release() + void communityDb.retireDisk().catch(() => undefined) + } + } + }, [communityDb, restoreGate]) + useLayoutEffect(() => { + if (!identityChanged || identityRetired.current) return + identityRetired.current = true + restoreGate.release() + retireCommunityAccount(communityDb) + void communityDb.retireDisk().catch(() => undefined) + if (session.data?.user.id) router.refresh() + else router.replace("/sign-in") + }, [communityDb, identityChanged, restoreGate, router, session.data?.user.id, userId]) + const onRetired = useCallback(() => { + if (!communityDb.runtime.lifecycle.get().active) return + retireCommunityAccount(communityDb) + window.location.reload() + }, [communityDb]) + usePersistedAccountLifecycle(persister, onRetired) const isDev = process.env.NODE_ENV !== "production" const settleRestoredAccount = () => { + restoreGate.release() + if (!communityDb.runtime.lifecycle.get().active) { queryClient.clear(); return } communityDb.captureRestoredCollections() - const profiles = useCommunityWsStore.getState() + const profiles = communityDb.runtime.ws.get() if (profiles.profileViewerId !== userId) { - profiles.activateProfileAccount(userId) + communityDb.runtime.ws.actions.activateProfileAccount(userId) } } @@ -149,6 +216,7 @@ export function QueryProvider({ // false. Freeze which canonical collections came from that restore so // later network results can never be misclassified as persisted. settleRestoredAccount() + if (!communityDb.runtime.lifecycle.get().active) return void Promise.all([ queryClient.invalidateQueries({ queryKey: communityKeys.servers(), @@ -196,14 +264,14 @@ export function QueryProvider({ }, }} > - {children} {isDev ? : null} - + : null} ) } diff --git a/src/web/src/app/c/channels/layout.route-memory.dom.test.ts b/src/web/src/app/c/channels/layout.route-memory.dom.test.ts index 2d2ad3146..518e12bc7 100644 --- a/src/web/src/app/c/channels/layout.route-memory.dom.test.ts +++ b/src/web/src/app/c/channels/layout.route-memory.dom.test.ts @@ -1,6 +1,13 @@ import { createElement, useLayoutEffect } from "react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { act, render } from "@/test/react-dom-harness" +import { act } from "@/test/react-dom-harness" +import { render as renderDom } from "@/test/react-dom-harness" +import { QueryClient } from "@tanstack/react-query" +import { CommunityTestProvider } from "@/test/community-owner-fixture" +import { createCommunityQueryOwner } from "@/test/community-query-owner" +import { getCommunityDbRegistry } from "@/lib/community-db/collections" +import { ingestServers } from "@/lib/community-db/sync" +import { registerOwnerServerDeleteRoute, type OwnerServerDeleteRouteToken } from "@/lib/community/eject-server" const mocks = vi.hoisted(() => ({ replace: vi.fn(), @@ -15,7 +22,7 @@ const mocks = vi.hoisted(() => ({ claimNavigation: vi.fn(), registerRoute: vi.fn(), routeProtected: { current: false }, - routeToken: {}, + routeToken: {} as OwnerServerDeleteRouteToken, clearLastChannel: vi.fn(), communityServerId: vi.fn(), useServer: vi.fn(), @@ -28,10 +35,10 @@ const mocks = vi.hoisted(() => ({ serverListFetching: { current: false }, serverListLiveAuthoritative: { current: true }, serverAccessRevoked: { current: false }, - queryClient: { getQueryData: vi.fn(), fetchQuery: vi.fn() }, + queryClient: null as unknown as QueryClient, })) -vi.mock("@tanstack/react-query", () => ({ +vi.mock("@tanstack/react-query", async (importOriginal) => ({ ...await importOriginal(), useQueryClient: () => mocks.queryClient, })) @@ -57,12 +64,13 @@ vi.mock("@/components/community/shell/shell-frame", () => ({ extraDialogs?: React.ReactNode ownerDeleteRouteScope?: { serverId: string - token: object + token: OwnerServerDeleteRouteToken } }) => { useLayoutEffect(() => { if (!ownerDeleteRouteScope) return - mocks.registerRoute( + registerOwnerServerDeleteRoute( + mocks.queryClient, ownerDeleteRouteScope.serverId, ownerDeleteRouteScope.token, ) @@ -93,13 +101,14 @@ vi.mock("@/components/community/settings/server-settings", () => ({ })) vi.mock("@/components/community/image-crop-dialog", () => ({ ImageCropDialog: () => null })) vi.mock("@/lib/community/image-crop", () => ({ validateIconSourceFile: () => ({ ok: true }) })) -vi.mock("@alook/shared", () => ({ +vi.mock("@alook/shared", async (importOriginal) => ({ ...await importOriginal(), canManageServer: () => false, isForum: () => false, notifLevelDisplay: (value: string) => value, -})) + })) vi.mock("@/lib/community/profile-read", () => ({ readCommunityProfile: vi.fn() })) -vi.mock("@/stores/community", () => { +vi.mock("@/stores/community", async (importOriginal) => { + const actual = await importOriginal(); const state = { setCurrentServerId: mocks.setCurrentServerId, uiHandlers: { @@ -108,6 +117,7 @@ vi.mock("@/stores/community", () => { }, } return { + ...actual, useCommunityStore: { getState: () => state }, useCurrentChannelId: () => null, useCurrentChannelMeta: () => null, @@ -135,7 +145,7 @@ vi.mock("@/hooks/community/use-server-members", () => ({ loadMore: vi.fn(), searchMembers: vi.fn(), }), })) -vi.mock("@/lib/community/eject-server", () => ({ +vi.mock("@/lib/community/eject-server", async (importOriginal) => ({ ...await importOriginal(), claimOwnerServerDeleteNavigation: (...args: unknown[]) => mocks.claimNavigation(...args), consumeVoluntaryLeave: vi.fn(), createOwnerServerDeleteRouteToken: () => mocks.routeToken, @@ -151,7 +161,7 @@ vi.mock("@/lib/community/eject-server", () => ({ }, registerOwnerServerDeleteRoute: (...args: unknown[]) => mocks.registerRoute(...args), runAuthoritativeServerEject: (args: Record) => mocks.runEject(args), -})) + })) vi.mock("@/lib/community/last-channel", () => ({ clearLastChannel: (...args: unknown[]) => mocks.clearLastChannel(...args), getLastChannel: (serverId: string) => mocks.lastChannels.get(serverId) ?? null, @@ -167,7 +177,7 @@ vi.mock("@/hooks/community/use-forum-sidebar-threads", () => ({ resolveForumSidebarRouteCandidate: () => null, useForumSidebarThreads: () => ({ threads: [], parentUnread: {} }), })) -vi.mock("@/stores/community/ws", () => ({ +vi.mock("@/stores/community/ws", async (importOriginal) => ({ ...await importOriginal(), useCommunityWsStore: (selector: (state: { profilesByUserId: Map revokedServerIds: Set @@ -182,7 +192,8 @@ vi.mock("@/hooks/community/use-notification-settings", () => ({ resolveServerNotificationDisplayLevel: () => "default", useNotificationSettings: () => ({ server: {}, channel: {} }), })) -vi.mock("@/hooks/community/mutations", () => { +vi.mock("@/hooks/community/mutations", async () => { + const actual = await vi.importActual("@/lib/community/eject-server") const mutation = () => ({ mutate: vi.fn(), mutateAsync: vi.fn() }) return { useCreateChannel: mutation, @@ -195,7 +206,7 @@ vi.mock("@/hooks/community/mutations", () => { useReorderCategories: mutation, useReorderChannels: mutation, useDeleteServer: (callbacks: { - routeToken: object + routeToken: OwnerServerDeleteRouteToken onSuccess: ( args: { serverId: string }, result: { needsNavigation: boolean }, @@ -203,12 +214,15 @@ vi.mock("@/hooks/community/mutations", () => { onError: (error: Error, args: { serverId: string }) => void }) => ({ mutate: (args: { serverId: string }) => { + actual.beginOwnerServerDelete(mocks.queryClient, args.serverId, callbacks.routeToken) mocks.routeProtected.current = true mocks.deleteServer(args, { onSuccess: (needsNavigation = true) => { + actual.commitOwnerServerDelete(mocks.queryClient, args.serverId, callbacks.routeToken) callbacks.onSuccess(args, { needsNavigation }) }, onError: (error: Error) => { + actual.cancelOwnerServerDelete(mocks.queryClient, args.serverId, callbacks.routeToken) mocks.routeProtected.current = false callbacks.onError(error, args) }, @@ -226,8 +240,20 @@ vi.mock("@/hooks/community/mutations", () => { import ServerLayout from "./layout" +function render(node: React.ReactNode) { + ingestServers(getCommunityDbRegistry(mocks.queryClient)!, { servers: mocks.servers.current.map(({ id }) => ({ id, name: id, unread: false, mentions: 0 })) }) + return renderDom(node, { wrapper: ({ children }) => createElement(CommunityTestProvider, { client: mocks.queryClient, userId: "viewer-1", retainOwner: true }, children) }) +} + describe("ServerLayout deletion routing", () => { - beforeEach(() => { + beforeEach(async () => { + const { client, registry } = await createCommunityQueryOwner("viewer-1") + mocks.queryClient = client + const actual = await vi.importActual("@/lib/community/eject-server") + mocks.routeToken = actual.createOwnerServerDeleteRouteToken(client) + registry.runtime.ui.actions.registerUiHandlers({ cancelPendingNavigation: mocks.cancelPendingNavigation, navigatePath: mocks.navigatePath }) + vi.spyOn(client, "getQueryData") + vi.spyOn(client, "fetchQuery") window.history.replaceState({}, "", "/c/channels/missing-server/missing-channel") mocks.replace.mockClear() mocks.navigatePath.mockClear() @@ -256,14 +282,14 @@ describe("ServerLayout deletion routing", () => { mocks.serverListFetching.current = false mocks.serverListLiveAuthoritative.current = true mocks.serverAccessRevoked.current = false - mocks.queryClient.getQueryData.mockImplementation((key: unknown[]) => { + vi.mocked(mocks.queryClient.getQueryData).mockImplementation((key: unknown[]) => { if (key.length === 2 && key[1] === "servers") { return { servers: mocks.servers.current } } return mocks.serverDetails.get(String(key.at(-1))) }) - mocks.queryClient.fetchQuery.mockReset() - mocks.queryClient.fetchQuery.mockImplementation(({ queryKey }: { queryKey: unknown[] }) => ( + vi.mocked(mocks.queryClient.fetchQuery).mockReset() + vi.mocked(mocks.queryClient.fetchQuery).mockImplementation(({ queryKey }: { queryKey: unknown[] }) => ( Promise.resolve(mocks.serverDetails.get(String(queryKey.at(-1)))) )) mocks.runEject.mockReturnValue(false) @@ -302,7 +328,7 @@ describe("ServerLayout deletion routing", () => { })) expect(mocks.cancelPendingNavigation).toHaveBeenCalledTimes(1) expect(mocks.replace).toHaveBeenCalledWith("/c/me/machines") - expect(mocks.registerRoute).toHaveBeenCalledWith("missing-server", mocks.routeToken) + expect(mocks.registerRoute).toHaveBeenCalledWith(mocks.queryClient, "missing-server", mocks.routeToken) }) it("keeps restored absence non-authoritative until the current client settles a live list", () => { @@ -371,6 +397,7 @@ describe("ServerLayout deletion routing", () => { await act(async () => callbacks.onSuccess()) expect(mocks.claimNavigation).toHaveBeenCalledExactlyOnceWith( + mocks.queryClient, "missing-server", mocks.routeToken, "/c/channels/surviving-server/channel-remembered", @@ -395,6 +422,7 @@ describe("ServerLayout deletion routing", () => { await act(async () => callbacks.onSuccess()) expect(mocks.claimNavigation).toHaveBeenCalledExactlyOnceWith( + mocks.queryClient, "missing-server", mocks.routeToken, "/c/me", @@ -461,7 +489,7 @@ describe("ServerLayout deletion routing", () => { render(createElement(ServerLayout, null, createElement("div"))) - expect(mocks.registerRoute).toHaveBeenCalledWith("missing-server", mocks.routeToken) + expect(mocks.registerRoute).toHaveBeenCalledWith(mocks.queryClient, "missing-server", mocks.routeToken) expect(mocks.runEject).toHaveBeenLastCalledWith(expect.objectContaining({ ownerDeleteRouteProtected: false, })) @@ -496,11 +524,11 @@ describe("ServerLayout deletion routing", () => { expect(mocks.replace).not.toHaveBeenCalled() const error = new Error("delete failed") - await act(async () => callbacks.onError(error)) mocks.servers.current = [ { id: "missing-server" }, { id: "surviving-server" }, ] + await act(async () => callbacks.onError(error)) rendered.rerender(createElement(ServerLayout, null, createElement("div"))) expect(mocks.replace).not.toHaveBeenCalled() expect(mocks.toastApiError).toHaveBeenCalledWith(error, "Failed to delete server") diff --git a/src/web/src/app/c/channels/layout.tsx b/src/web/src/app/c/channels/layout.tsx index 230c273b6..e33d60e13 100644 --- a/src/web/src/app/c/channels/layout.tsx +++ b/src/web/src/app/c/channels/layout.tsx @@ -1,6 +1,9 @@ "use client" +import { useAtom, useCreateAtom, useSelector } from "@tanstack/react-store"; +import { getCommunityRuntime } from "@/stores/community/runtime" -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react" + +import { useCallback, useEffect, useMemo, useRef, type ReactNode } from "react" import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation" import { useQueryClient } from "@tanstack/react-query" import { toast } from "sonner" @@ -24,18 +27,12 @@ import { validateIconSourceFile } from "@/lib/community/image-crop" import type { SettingsSection } from "@/components/community/settings/settings-types" import { canManageServer, isForum, notifLevelDisplay, type ChannelType } from "@alook/shared" import { readCommunityProfile } from "@/lib/community/profile-read" -import { - useCommunityStore, - useCurrentChannelId, - useCurrentChannelMeta, -} from "@/stores/community" +import { useCurrentChannelId, useCurrentChannelMeta } from "@/stores/community" import { useCurrentUser } from "@/contexts/community/current-user" import { useServer, useServers, serverProjectedQueryFn, - type ServerDetail, - type ServersResponse, } from "@/hooks/community/use-servers" import { useServerMembers } from "@/hooks/community/use-server-members" import { @@ -52,6 +49,7 @@ import { } from "@/lib/community/last-channel" import { communityKeys } from "@/lib/query-keys" import { usePresence } from "@/hooks/community/use-server-panels" +import { useCommunityViewSource } from "@/hooks/community/use-community-view-source" import { resolveForumSidebarRouteCandidate, useForumSidebarThreads, @@ -92,28 +90,25 @@ export default function ServerLayout({ children }: { children: ReactNode }) { const pathname = usePathname() const serverId = decodeURIComponent(params.serverId) const routeChannelId = params.channelId ? decodeURIComponent(params.channelId) : null + const queryClient = useQueryClient() const ownerDeleteRouteIdentity = useMemo( () => ({ serverId, - token: createOwnerServerDeleteRouteToken(), + token: createOwnerServerDeleteRouteToken(queryClient), }), - [serverId], + [queryClient, serverId], ) const ownerDeleteRouteToken = ownerDeleteRouteIdentity.token - const ownerDeleteRouteProtected = isOwnerServerDeleteRouteProtected( - serverId, - ownerDeleteRouteToken, - ) + const ownerDeleteRouteProtected = useSelector(getCommunityRuntime(queryClient).serverEject, (state) => state.transactions.has(serverId) || state.tombstones.get(ownerDeleteRouteToken) === serverId) const hasChannel = !!routeChannelId const breakpoint = useBreakpoint() const router = useRouter() - const queryClient = useQueryClient() const communityDb = useOptionalCommunityDbRegistry() const trustedRestoredPrimary = useTrustedRestoredPrimary() const cancelPendingNavigation = useCallback(() => { - useCommunityStore.getState().uiHandlers.cancelPendingNavigation?.() - }, []) + getCommunityRuntime(queryClient).ui.get().uiHandlers.cancelPendingNavigation?.() + }, [queryClient]) const currentUser = useCurrentUser() const serverAccessRevoked = useCommunityWsStore( (state) => state.revokedServerIds.has(serverId), @@ -197,29 +192,13 @@ export default function ServerLayout({ children }: { children: ReactNode }) { const serversList = useServers() const serverDestination = useCallback(async (id: string) => { const lastChannel = getLastChannel(id) - let detail = queryClient.getQueryData(communityKeys.server(id)) - const canonicalChannelIds = communityDb - ? Array.from(communityDb.collections.channels.values()) - .filter((channel) => channel.serverId === id && channel.type !== "thread" && !channel.pending) - .map((channel) => channel.id) - : [] - if (!detail && !lastChannel && canonicalChannelIds.length === 0) { + const read = () => communityDb ? Array.from(communityDb.collections.channels.values()).filter((channel) => channel.serverId === id && channel.type !== "thread" && !channel.pending).map((channel) => channel.id) : [] + if (!communityDb?.collections.servers.get(id)?.detailComplete && !lastChannel && read().length === 0) { try { - detail = await queryClient.fetchQuery({ - queryKey: communityKeys.server(id), - queryFn: serverProjectedQueryFn(queryClient, id), - staleTime: Infinity, - }) - } catch { - // The server landing route remains the safe fallback if its detail - // cannot be resolved. Its ordinary loader owns retry/error handling. - } + await queryClient.fetchQuery({ queryKey: communityKeys.server(id), queryFn: ({ signal }) => serverProjectedQueryFn(queryClient, id, signal)(), staleTime: Infinity }) + } catch {} } - const channelIds = detail?.categories.flatMap((category) => - category.channels - .filter((channel) => !channel.pending) - .map((channel) => channel.id), - ) ?? canonicalChannelIds + const channelIds = read() return pickServerLandingHref(id, channelIds, lastChannel) }, [communityDb, queryClient]) @@ -240,14 +219,12 @@ export default function ServerLayout({ children }: { children: ReactNode }) { clearLastChannel(deletedServerId) if (!needsNavigation) return void (async () => { - const servers = queryClient.getQueryData( - communityKeys.servers(), - )?.servers ?? [] - const survivor = servers.find((server) => server.id !== deletedServerId) + const allowed = new Set([...communityDb!.collections.serverMemberships.values()].filter((row) => row.viewer && row.userId === currentUser.id).map((row) => row.serverId)) + const survivor = [...communityDb!.collections.servers.values()].filter((server) => allowed.has(server.id)).sort((a, b) => (a.position ?? 0) - (b.position ?? 0)).find((server) => server.id !== deletedServerId) const destination = survivor ? await serverDestination(survivor.id) : "/c/me" - if (!claimOwnerServerDeleteNavigation( + if (!claimOwnerServerDeleteNavigation(queryClient, deletedServerId, ownerDeleteRouteToken, destination, @@ -266,8 +243,8 @@ export default function ServerLayout({ children }: { children: ReactNode }) { const revokeInviteMut = useRevokeInvite() useEffect(() => { - useCommunityStore.getState().setCurrentServerId(serverId) - }, [serverId]) + getCommunityRuntime(queryClient).ui.actions.setCurrentServerId(serverId) + }, [queryClient, serverId]) // Eject when the URL is scoped to a server the viewer isn't in. Covers // four triggers with one effect: @@ -299,8 +276,8 @@ export default function ServerLayout({ children }: { children: ReactNode }) { isSuccess: serverAccessRevoked || (serversList.isSuccess && serversList.isLiveAuthoritative), isFetching: serverAccessRevoked ? false : serversList.isFetching, - ownerDeleteRouteProtected: isOwnerServerDeleteRouteProtected(serverId), - consumeVoluntaryLeave, + ownerDeleteRouteProtected: isOwnerServerDeleteRouteProtected(queryClient, serverId), + consumeVoluntaryLeave: (id) => consumeVoluntaryLeave(queryClient, id), clearLastChannel, toast, accountId: currentUser.id, @@ -310,7 +287,7 @@ export default function ServerLayout({ children }: { children: ReactNode }) { router.replace(destination) }, }) - }, [cancelPendingNavigation, currentUser.id, pathname, serverAccessRevoked, serverId, serversList.isLiveAuthoritative, serversList.isSuccess, serversList.isFetching, serversList.servers, router, searchParams]) + }, [cancelPendingNavigation, currentUser.id, pathname, serverAccessRevoked, serverId, serversList.isLiveAuthoritative, serversList.isSuccess, serversList.isFetching, serversList.servers, router, searchParams, queryClient]) // Reset the guard when the URL changes to a NEW server id — otherwise // navigating server → dangling-server → server would leave the ref // latched and skip the eject. @@ -318,10 +295,11 @@ export default function ServerLayout({ children }: { children: ReactNode }) { ejectedRef.current = false }, [serverId]) - const [serverSettingsOpen, setServerSettingsOpen] = useState(false) - const [settingsSection, setSettingsSection] = useState("overview") - const [invitePopoverOpen, setInvitePopoverOpen] = useState(false) - const [pendingIconCrop, setPendingIconCrop] = useState<{ src: string; fileName: string } | null>(null) + const [serverSettingsOpen, setServerSettingsOpen] = useAtom(useCreateAtom(false)) + const settingsView = useCommunityViewSource("server-settings:" + serverId, serverSettingsOpen) + const [settingsSection, setSettingsSection] = useAtom(useCreateAtom("overview")) + const [invitePopoverOpen, setInvitePopoverOpen] = useAtom(useCreateAtom(false)) + const [pendingIconCrop, setPendingIconCrop] = useAtom(useCreateAtom<{ src: string; fileName: string } | null>(null)) // Close server-scoped dialogs when the user navigates to another server — // without this, settings for server A would remain open after switching @@ -330,7 +308,7 @@ export default function ServerLayout({ children }: { children: ReactNode }) { setServerSettingsOpen(false) setSettingsSection("overview") setInvitePopoverOpen(false) - }, [serverId]) + }, [serverId, setInvitePopoverOpen, setServerSettingsOpen, setSettingsSection]) // Open the dialog the instant we see the flag — this only touches local // state, so it can't race with the sibling default-channel page's own @@ -341,7 +319,7 @@ export default function ServerLayout({ children }: { children: ReactNode }) { useEffect(() => { if (searchParams.get("settings") === "1") setServerSettingsOpen(true) if (searchParams.get("invite") === "1") setInvitePopoverOpen(true) - }, [searchParams]) + }, [searchParams, setInvitePopoverOpen, setServerSettingsOpen]) useEffect(() => { // These flags land on the bare `/c/channels/:serverId` URL @@ -400,25 +378,25 @@ export default function ServerLayout({ children }: { children: ReactNode }) { // itself must leave account unread state untouched. markSwitch("channel", id) cancelPendingNavigation() - useCommunityStore.getState().uiHandlers.navigatePath?.(channelHref(serverId, id)) - }, [cancelPendingNavigation, serverId]) + getCommunityRuntime(queryClient).ui.get().uiHandlers.navigatePath?.(channelHref(serverId, id)) + }, [cancelPendingNavigation, queryClient, serverId]) const setActiveForumThread = useCallback((_parentId: string, id: string) => { markSwitch("channel", id) cancelPendingNavigation() - useCommunityStore.getState().uiHandlers.navigatePath?.( + getCommunityRuntime(queryClient).ui.get().uiHandlers.navigatePath?.( channelHref(serverId, id), ) - }, [cancelPendingNavigation, serverId]) + }, [cancelPendingNavigation, queryClient, serverId]) const onSidebarOpenSettings = useCallback((section?: SettingsSection) => { if (section) setSettingsSection(section) setServerSettingsOpen(true) - }, []) + }, [setServerSettingsOpen, setSettingsSection]) const onRailOpenActiveInvite = useCallback(() => { setInvitePopoverOpen(true) - }, []) + }, [setInvitePopoverOpen]) const onBlockedCreate = useCallback(() => { toast("Only admins can create channels in a private category") @@ -501,25 +479,14 @@ export default function ServerLayout({ children }: { children: ReactNode }) { serverId, invitePopoverOpen, onInvitePopoverOpenChange: sidebarHintOnly ? undefined : setInvitePopoverOpen, - }), [ - currentServer, sidebarHintOnly, - currentChannelMeta?.parentChannelId, - currentChannelId, isAdmin, currentUser.id, setActiveChannel, - forumThreadsByParent, activeForumThreadId, setActiveForumThread, - onSidebarOpenSettings, onBlockedCreate, mutedChannels, - onCreateChannelInSidebar, onCreateCategoryInSidebar, onRenameChannel, - onDeleteChannelInSidebar, onDeleteCategoryInSidebar, onUpdateCategoryInSidebar, - onReorderCategoriesInSidebar, onReorderChannelsInSidebar, - onMoveChannelInSidebar, onBlockedMove, - serverId, invitePopoverOpen, - ]) + }), [currentServer?.name, currentServer?.icon, currentServer?.official, currentChannelMeta?.parentChannelId, currentChannelId, isAdmin, currentUser.id, setActiveChannel, forumThreadsByParent, activeForumThreadId, setActiveForumThread, sidebarHintOnly, onSidebarOpenSettings, onBlockedCreate, mutedChannels, onCreateChannelInSidebar, onCreateCategoryInSidebar, onRenameChannel, onDeleteChannelInSidebar, onDeleteCategoryInSidebar, onUpdateCategoryInSidebar, onReorderCategoriesInSidebar, onReorderChannelsInSidebar, onMoveChannelInSidebar, onBlockedMove, serverId, invitePopoverOpen, setInvitePopoverOpen]) const openProfile = (name: string, e: React.MouseEvent, discriminator?: string, userId?: string) => { // Delegate to the shell's registered openProfile via the community store. - useCommunityStore.getState().uiHandlers.openProfile?.(name, e, discriminator, userId) + getCommunityRuntime(queryClient).ui.get().uiHandlers.openProfile?.(name, e, discriminator, userId) } - const closeSettings = () => { setServerSettingsOpen(false); setSettingsSection("overview") } + const closeSettings = () => { settingsView.retire(); setServerSettingsOpen(false); setSettingsSection("overview") } const sidebar = useCallback((opts: { noHeader?: boolean } = {}) => ( { - kickMemberMut.mutate({ serverId, memberId }, { - onSuccess: () => toast("Member kicked"), - onError: (e) => toastApiError(e, "Failed to kick member"), + const assert = settingsView.capture() + kickMemberMut.mutate({ serverId, memberId, assertActive: assert }, { + onSuccess: () => { try { assert(); toast("Member kicked") } catch {} }, + onError: (e) => toastApiError(e, "Failed to kick member", assert), }) }} onSetRole={(memberId, role) => { - setMemberRoleMut.mutate({ serverId, memberId, role }, { - onSuccess: () => toast("Role updated"), - onError: (e) => toastApiError(e, "Failed to update role"), + const assert = settingsView.capture() + setMemberRoleMut.mutate({ serverId, memberId, role, assertActive: assert }, { + onSuccess: () => { try { assert(); toast("Role updated") } catch {} }, + onError: (e) => toastApiError(e, "Failed to update role", assert), }) }} - onRevokeInvite={(code) => revokeInviteMut.mutate({ serverId, code }, { - onSuccess: () => toast("Invite revoked"), - onError: (e) => toastApiError(e, "Failed to revoke invite"), - })} - onCopyInvite={(code) => { navigator.clipboard?.writeText(`${window.location.origin}/c/invite/${code}`); toast("Invite copied") }} + onRevokeInvite={(code) => { + const assert = settingsView.capture() + revokeInviteMut.mutate({ serverId, code, assertActive: assert }, { + onSuccess: () => { try { assert(); toast("Invite revoked") } catch {} }, + onError: (e) => toastApiError(e, "Failed to revoke invite", assert), + }) + }} + onCopyInvite={(code) => { + const assert = settingsView.capture() + assert() + void navigator.clipboard.writeText(`${window.location.origin}/c/invite/${code}`).then(() => { try { assert(); toast("Invite copied") } catch {} }, (error) => toastApiError(error, "Couldn't copy invite", assert)) + }} onDeleteServer={async () => { closeSettings() deleteServerMut.mutate({ serverId }) diff --git a/src/web/src/app/c/community-layout-client.tsx b/src/web/src/app/c/community-layout-client.tsx index a24b4ab5c..c80733ec3 100644 --- a/src/web/src/app/c/community-layout-client.tsx +++ b/src/web/src/app/c/community-layout-client.tsx @@ -47,8 +47,10 @@ export function CommunityLayoutClient({ return ( - - {children} + + + {children} + ) } diff --git a/src/web/src/app/c/community-shell.identity.dom.test.ts b/src/web/src/app/c/community-shell.identity.dom.test.ts index b444c3007..0b0e12555 100644 --- a/src/web/src/app/c/community-shell.identity.dom.test.ts +++ b/src/web/src/app/c/community-shell.identity.dom.test.ts @@ -1,238 +1,135 @@ -import { beforeEach, describe, expect, it, vi } from "vitest" -import React, { useState } from "react" -import { act, render } from "@/test/react-dom-harness" +import "fake-indexeddb/auto" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import React from "react" +import { act, render, waitFor } from "@/test/react-dom-harness" +import { useQueryClient } from "@tanstack/react-query" +import { getCommunityDbRegistry, type CommunityDbRegistry } from "@/lib/community-db/collections" import { useCommunityWsStore } from "@/stores/community/ws" +import { clearAllPersistedCaches } from "@/lib/query-persister" +import { communityKeys } from "@/lib/query-keys" -type TestUser = { - id: string - name: string - email: string - avatar: string -} - -let activeUser: TestUser = { id: "user-a", name: "A", email: "a@example.com", avatar: "A" } -let providerInstance = 0 -const renderedProviders: Array<{ userId: string | null; instance: number }> = [] -const apiFetchProfiles = vi.hoisted(() => vi.fn(() => Promise.resolve({}))) -const useNotificationSettings = vi.hoisted(() => vi.fn(() => ({ data: undefined }))) - -vi.mock("./QueryProvider", () => ({ - QueryProvider: ({ children, userId }: { children: React.ReactNode; userId: string | null }) => { - const [instance] = useState(() => ++providerInstance) - renderedProviders.push({ userId, instance }) - return children - }, +const fixtures = vi.hoisted(() => ({ + user: { id: "user-a", name: "A", email: "a@example.test", avatar: "A" }, + fetch: vi.fn(), notifications: vi.fn(), })) -vi.mock("@/contexts/community/current-user", () => ({ - CurrentUserProvider: ({ children }: { children: React.ReactNode }) => children, - useCurrentUser: () => activeUser, +vi.mock("@/lib/auth-client", () => ({ + useSession: () => ({ data: { user: fixtures.user }, isPending: false }), + currentSessionViewer: () => fixtures.user.id, })) +vi.mock("next/navigation", () => ({ usePathname: () => "/c/me", useRouter: () => ({ replace: vi.fn(), refresh: vi.fn() }) })) +vi.mock("@tanstack/react-query-devtools", () => ({ ReactQueryDevtools: () => null })) vi.mock("@/hooks/community/use-community-ws", () => ({ useCommunityWs: vi.fn() })) -vi.mock("@/hooks/community/use-notification-settings", () => ({ useNotificationSettings })) -vi.mock("@/hooks/community/use-account-attention", () => ({ - useAccountAttention: vi.fn(), - useAccountAttentionScopeHydration: vi.fn(), -})) -vi.mock("@/lib/community/profile-seed", () => ({ - apiFetchProfiles: (...args: unknown[]) => apiFetchProfiles(...args), -})) +vi.mock("@/hooks/community/use-notification-settings", () => ({ useNotificationSettings: fixtures.notifications })) +vi.mock("@/hooks/community/use-account-attention", () => ({ useAccountAttention: vi.fn() })) +vi.mock("@/hooks/community/use-native-system-notifications", () => ({ useNativeSystemNotifications: vi.fn() })) +vi.mock("@/lib/api/client", () => ({ apiFetch: (...args: unknown[]) => fixtures.fetch(...args) })) vi.mock("@/components/perf/perf-trace-bootstrap", () => ({ PerfTraceBootstrap: () => null })) -vi.mock("@/components/daemon-update-notice", () => ({ - CommunityDaemonUpdateNotice: () => null, -})) -vi.mock("@/components/community/onboarding/community-onboarding-form", () => ({ - CommunityOnboardingForm: () => null, -})) -vi.mock("@/components/community/shell/community-ws-reconnect-overlay", () => ({ - CommunityWsReconnectBoundary: ({ children }: { children: React.ReactNode }) => children, -})) -vi.mock("@/components/community/shell/community-restore-bootstrap", () => ({ - CommunityRestoreBoundary: ({ children }: { children: React.ReactNode }) => React.createElement( - "div", - { "data-testid": "restore-boundary" }, - children, - ), -})) -vi.mock("next/navigation", () => ({ - usePathname: () => "/c/me", -})) -vi.mock("@/components/community/shell/community-session-pending-frame", () => ({ - CommunitySessionPendingFrame: ({ pathname }: { pathname: string }) => React.createElement( - "div", - { "data-testid": "session-pending", "data-pathname": pathname }, - ), -})) -vi.mock("@/components/community/shell/owner-server-delete-route-guard", () => ({ - OwnerServerDeleteRouteGuard: () => null, -})) - +vi.mock("@/components/community/onboarding/community-onboarding-form", () => ({ CommunityOnboardingForm: () => null })) +vi.mock("@/components/community/shell/community-ws-reconnect-overlay", () => ({ CommunityWsReconnectBoundary: ({ children }: { children: React.ReactNode }) => children })) +vi.mock("@/components/community/shell/community-restore-bootstrap", () => ({ CommunityRestoreBoundary: ({ children }: { children: React.ReactNode }) => children })) +vi.mock("@/components/community/shell/owner-server-delete-route-guard", () => ({ OwnerServerDeleteRouteGuard: () => null })) import { CommunityShell } from "./community-shell" -beforeEach(() => { - useCommunityWsStore.getState().reset() - activeUser = { id: "user-a", name: "A", email: "a@example.com", avatar: "A" } - providerInstance = 0 - renderedProviders.length = 0 - apiFetchProfiles.mockReset() - apiFetchProfiles.mockResolvedValue({}) - useNotificationSettings.mockClear() +const registries = new Map() +const seen: Array<{ viewer: string | null; presence: string[] }> = [] +const self = (id: string) => ({ id, name: "Jane Roe", discriminator: "4242", aboutMe: "hello", avatar: "new-avatar", avatarVersion: 3, statusEmoji: null, statusText: "" }) +function Probe() { + const client = useQueryClient() + const registry = getCommunityDbRegistry(client)! + registries.set(registry.accountId!, registry) + const viewer = useCommunityWsStore((state) => state.profileViewerId) + const presence = useCommunityWsStore((state) => state.presenceByUserId) + seen.push({ viewer, presence: [...presence.keys()] }) + return React.createElement("span", null, "content") +} +const tree = () => React.createElement(CommunityShell, { currentUser: fixtures.user }, React.createElement(Probe)) +beforeEach(async () => { + vi.stubGlobal("ResizeObserver", class { + observe() {} + unobserve() {} + disconnect() {} + }) + vi.stubGlobal("IntersectionObserver", class { + observe() {} + unobserve() {} + disconnect() {} + }) + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: false, media: query, onchange: null, + addEventListener() {}, removeEventListener() {}, + addListener() {}, removeListener() {}, dispatchEvent: () => true, + })) + await clearAllPersistedCaches() + fixtures.user = { id: "user-a", name: "A", email: "a@example.test", avatar: "A" } + fixtures.fetch.mockReset().mockImplementation(() => Promise.resolve(self(fixtures.user.id))) + fixtures.notifications.mockClear() + registries.clear(); seen.length = 0 }) - -describe("CommunityShell identity boundary", () => { - it("keeps restore lifecycle ownership outside the account activation frame", () => { - const activateProfileAccount = useCommunityWsStore.getState().activateProfileAccount - useCommunityWsStore.setState({ - profileViewerId: null, - activateProfileAccount: vi.fn(() => 0), - }) - const renderer = render(React.createElement( - CommunityShell, - { currentUser: activeUser }, - React.createElement("span", null, "content"), - )) - - const pending = renderer.getByTestId("session-pending") - expect(renderer.getByTestId("restore-boundary").contains(pending)).toBe(true) - - act(() => renderer.unmount()) - useCommunityWsStore.setState({ activateProfileAccount }) +afterEach(() => { vi.unstubAllGlobals() }) + +describe("CommunityShell actual identity and bootstrap boundary", () => { + it("remounts the native query boundary when the signed-in user changes", async () => { + const view = render(tree()) + await waitFor(() => expect(registries.has("user-a")).toBe(true)) + const original = registries.get("user-a")! + fixtures.user = { id: "user-b", name: "B", email: "b@example.test", avatar: "B" } + view.rerender(tree()) + await waitFor(() => expect(registries.has("user-b")).toBe(true)) + expect(registries.get("user-b")!.queryClient).not.toBe(original.queryClient) + await waitFor(() => expect(original.runtime.lifecycle.get().active).toBe(false)) }) - it("remounts the in-memory query boundary when the signed-in user changes", () => { - const renderer = render(React.createElement( - CommunityShell, - { currentUser: activeUser }, - React.createElement("span", null, "content"), - )) - expect(renderedProviders.at(-1)).toEqual({ userId: "user-a", instance: 1 }) - - activeUser = { id: "user-b", name: "B", email: "b@example.com", avatar: "B" } - act(() => { - renderer.rerender(React.createElement( - CommunityShell, - { currentUser: activeUser }, - React.createElement("span", null, "content"), - )) - }) - expect(renderedProviders.at(-1)).toEqual({ userId: "user-b", instance: 2 }) - renderer.unmount() + it("never mounts B against A's profile presence", async () => { + const view = render(tree()) + await waitFor(() => expect(registries.has("user-a")).toBe(true)) + act(() => registries.get("user-a")!.runtime.ws.actions.setPresence("user-a", "online")) + seen.length = 0 + fixtures.user = { id: "user-b", name: "B", email: "b@example.test", avatar: "B" } + view.rerender(tree()) + await waitFor(() => expect(seen.length).toBeGreaterThan(0)) + expect(seen.every((row) => row.viewer === "user-b" && row.presence.length === 0)).toBe(true) }) - it("does not mount a new account subtree against the previous account profile state", () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined) - const renderedProfileStates: Array<{ viewerId: string | null; presenceIds: string[] }> = [] - function ProfileStateProbe() { - const viewerId = useCommunityWsStore((state) => state.profileViewerId) - const presence = useCommunityWsStore((state) => state.presenceByUserId) - renderedProfileStates.push({ - viewerId, - presenceIds: [...presence.keys()], - }) - return null - } - - const profiles = useCommunityWsStore.getState() - profiles.activateProfileAccount("user-a") - profiles.setPresence("user-a", "online") - const accountAEpoch = useCommunityWsStore.getState().profileAccountEpoch - - const renderer = render(React.createElement( - CommunityShell, - { currentUser: activeUser }, - React.createElement(ProfileStateProbe), - )) - renderedProfileStates.length = 0 - - activeUser = { id: "user-b", name: "B", email: "b@example.com", avatar: "B" } - act(() => { - renderer.rerender(React.createElement( - CommunityShell, - { currentUser: activeUser }, - React.createElement(ProfileStateProbe), - )) - }) - - expect(renderedProfileStates.length).toBeGreaterThan(0) - expect(renderedProfileStates).toEqual( - renderedProfileStates.map(() => ({ viewerId: "user-b", presenceIds: [] })), - ) - expect(useCommunityWsStore.getState()).toMatchObject({ - profileViewerId: "user-b", - profileAccountEpoch: accountAEpoch + 1, - }) - expect(consoleError.mock.calls.flat().join("\n")).not.toContain("while rendering a different component") - act(() => renderer.unmount()) - consoleError.mockRestore() + it("deduplicates self-profile bootstrap under StrictMode without a render-phase update", async () => { + const report = vi.spyOn(console, "error").mockImplementation(() => undefined) + try { + render(React.createElement(React.StrictMode, null, tree())) + await waitFor(() => expect(fixtures.fetch).toHaveBeenCalledOnce()) + await waitFor(() => expect(registries.get("user-a")!.collections.profiles.get("user-a")?.aboutMe).toBe("hello")) + expect(report.mock.calls.flat().join("\n")).not.toMatch(/Cannot update a component|while rendering a different component/) + } finally { report.mockRestore() } }) - it("reactivates a cleared account without a render-phase update in StrictMode", () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined) - const renderer = render(React.createElement( - React.StrictMode, - null, - React.createElement( - CommunityShell, - { currentUser: activeUser }, - React.createElement("span", null, "content"), - ), - )) - expect(useCommunityWsStore.getState().profileViewerId).toBe("user-a") - - act(() => { - useCommunityWsStore.getState().activateProfileAccount(null) - }) - expect(useCommunityWsStore.getState().profileViewerId).toBe("user-a") - const consoleOutput = consoleError.mock.calls.flat().join("\n") - expect(consoleOutput).not.toContain("Cannot update a component") - expect(consoleOutput).not.toContain("while rendering a different component") - - act(() => renderer.unmount()) - consoleError.mockRestore() + it("loads self-profile fields into canonical DB while Query retains only the ID", async () => { + render(tree()) + await waitFor(() => expect(registries.get("user-a")?.collections.profiles.get("user-a")?.avatarVersion).toBe(3)) + const registry = registries.get("user-a")! + expect(registry.collections.profiles.get("user-a")).toMatchObject({ name: "Jane Roe", discriminator: "4242", aboutMe: "hello", avatar: "new-avatar", avatarVersion: 3, statusEmoji: null, statusText: "" }) + expect(registry.queryClient.getQueryData(communityKeys.selfProfile())).toEqual({ id: "user-a" }) + expect(fixtures.fetch).toHaveBeenCalledWith("/api/community/users/me/profile", expect.objectContaining({ signal: expect.any(AbortSignal), authenticationAccount: "user-a" })) }) - it("loads the canonical self profile through the typed seeding boundary", async () => { - const renderer = render(React.createElement( - CommunityShell, - { currentUser: activeUser }, - React.createElement("span", null, "content"), - )) - await act(async () => { await Promise.resolve() }) - - expect(apiFetchProfiles).toHaveBeenCalledWith( - "/api/community/users/me/profile", - expect.any(Function), - ) - const mapProfile = apiFetchProfiles.mock.calls[0]![1] as (profile: Record) => unknown - expect(mapProfile({ - id: "user-a", - name: "Jane Roe", - discriminator: "4242", - aboutMe: "hello", - avatar: "new-avatar", - avatarVersion: 3, - statusEmoji: null, - statusText: "", - })).toEqual([{ - id: "user-a", - identityAbout: { - name: "Jane Roe", - discriminator: "4242", - aboutMe: "hello", - }, - avatar: { avatar: "new-avatar", avatarVersion: 3 }, - status: { statusEmoji: null, statusText: "" }, - }]) - renderer.unmount() + it("hydrates notification policy at the community root", async () => { + render(tree()) + await waitFor(() => expect(fixtures.notifications).toHaveBeenCalled()) + expect(registries.get("user-a")).toBeDefined() }) - it("hydrates account notification policy at the community root", () => { - const renderer = render(React.createElement( - CommunityShell, - { currentUser: activeUser }, - React.createElement("span", null, "content"), - )) - - expect(useNotificationSettings).toHaveBeenCalledOnce() - act(() => renderer.unmount()) + it("physically cancels A's held self-profile and rejects its late payload after B mounts", async () => { + let finish!: (data: ReturnType) => void + fixtures.fetch.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve })) + const view = render(tree()) + await waitFor(() => expect(fixtures.fetch).toHaveBeenCalledOnce()) + const original = registries.get("user-a")! + const signal = fixtures.fetch.mock.calls[0][1].signal as AbortSignal + fixtures.user = { id: "user-b", name: "B", email: "b@example.test", avatar: "B" } + view.rerender(tree()) + await waitFor(() => expect(signal.aborted).toBe(true)) + await waitFor(() => expect(registries.get("user-b")?.collections.profiles.get("user-b")?.aboutMe).toBe("hello")) + await act(async () => { finish({ ...self("user-a"), aboutMe: "stale A" }); await Promise.resolve() }) + const current = registries.get("user-b")! + expect(current.collections.profiles.get("user-b")?.aboutMe).toBe("hello") + expect(current.collections.profiles.get("user-a")).toBeUndefined() + await waitFor(() => expect(original.queryClient.getQueryCache().getAll()).toHaveLength(0)) }) }) diff --git a/src/web/src/app/c/community-shell.tsx b/src/web/src/app/c/community-shell.tsx index b92ab9bf1..0f42c9a31 100644 --- a/src/web/src/app/c/community-shell.tsx +++ b/src/web/src/app/c/community-shell.tsx @@ -1,6 +1,13 @@ "use client" +import { getCommunityDbRegistry } from "@/lib/community-db/collections" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useAtom, useCreateAtom } from "@tanstack/react-store" +import { communityKeys } from "@/lib/query-keys" -import { useEffect, useLayoutEffect, type ReactNode } from "react" +import { useCommunityRuntime } from "@/stores/community/runtime" + + +import { useLayoutEffect, type ReactNode } from "react" import { usePathname } from "next/navigation" import { apiFetchProfiles } from "@/lib/community/profile-seed" import { QueryProvider } from "./QueryProvider" @@ -42,7 +49,7 @@ export function CommunityShell({ children: ReactNode }) { return ( - + @@ -61,14 +68,17 @@ function ProfileAccountBoundary({ children: ReactNode viewerId: string }) { + const communityRuntime = useCommunityRuntime() + const [hydrated, setHydrated] = useAtom(useCreateAtom(false)) const activeViewerId = useCommunityWsStore((state) => state.profileViewerId) const pathname = usePathname() useLayoutEffect(() => { if (activeViewerId !== viewerId) { - useCommunityWsStore.getState().activateProfileAccount(viewerId) + communityRuntime.ws.actions.activateProfileAccount(viewerId) } - }, [activeViewerId, viewerId]) - return activeViewerId === viewerId + setHydrated(true) + }, [activeViewerId, communityRuntime.ws.actions, setHydrated, viewerId]) + return hydrated && activeViewerId === viewerId ? children : } @@ -82,6 +92,7 @@ function ProfileAccountBoundary({ * effect — needed so the "Edit profile" dialog opens with the current value. */ function CommunityBootstrap({ children }: { children: ReactNode }) { + const profileQueryClient = useQueryClient() const currentUser = useCurrentUser() useNotificationSettings() @@ -95,9 +106,11 @@ function CommunityBootstrap({ children }: { children: ReactNode }) { // Hydrate the live public profile. The auth session can retain a stale image // after an avatar upload, while this self endpoint reads the canonical user // row alongside the community profile fields. - const currentUserId = currentUser.id - useEffect(() => { - apiFetchProfiles<{ id: string; aboutMe: string; avatar: string; avatarVersion: number; discriminator: string; name: string; statusEmoji: string | null; statusText: string }>( + const registry = getCommunityDbRegistry(profileQueryClient) + useQuery({ + queryKey: communityKeys.selfProfile(), + queryFn: async ({ signal }) => { + const profile = await apiFetchProfiles<{ id: string; aboutMe: string; avatar: string; avatarVersion: number; discriminator: string; name: string; statusEmoji: string | null; statusText: string }>( "/api/community/users/me/profile", (profile) => [{ id: profile.id, @@ -114,10 +127,13 @@ function CommunityBootstrap({ children }: { children: ReactNode }) { statusEmoji: profile.statusEmoji, statusText: profile.statusText, }, - }], - ) - .catch(() => { }) - }, [currentUserId]) + }], { signal }, registry, + ) + return { id: profile.id } + }, + enabled: registry?.accountId === currentUser.id, + staleTime: 5 * 60_000, + }) return ( <> diff --git a/src/web/src/app/c/invite/[token]/invite-accept-client.tsx b/src/web/src/app/c/invite/[token]/invite-accept-client.tsx index 14e7db6cd..647b0baac 100644 --- a/src/web/src/app/c/invite/[token]/invite-accept-client.tsx +++ b/src/web/src/app/c/invite/[token]/invite-accept-client.tsx @@ -1,6 +1,10 @@ "use client" -import { useCallback, useEffect, useRef, useState } from "react" +import { createStore } from "@tanstack/react-store"; +import { useCallback, useEffect, useMemo } from "react" +import { useQuery, useMutation } from "@tanstack/react-query" +import { PublicQueryProvider, applicationKey, runApplicationRequest } from "@/lib/application-owner" +import { useApplicationViewSource } from "@/hooks/use-application-view-source" import { useRouter, useSearchParams } from "next/navigation" import { toast } from "sonner" import { CircleAlert } from "lucide-react" @@ -35,71 +39,63 @@ function isDeadInvite(err: unknown): boolean { * Fetches invite info, displays server preview, and joins on button click. */ export function InviteAcceptClient({ token }: { token: string }) { + return +} + +function InviteAcceptInner({ token }: { token: string }) { const router = useRouter() const searchParams = useSearchParams() - const [info, setInfo] = useState(null) - const [loading, setLoading] = useState(true) - const [joining, setJoining] = useState(false) - const [error, setError] = useState<{ message: string; dead: boolean } | null>(null) - - useEffect(() => { - async function fetchInfo() { + const source = useApplicationViewSource(`community-invite:${token}`) + const { owner } = source + const query = useQuery({ queryKey: applicationKey(owner, "community-invite", token), retry: false, + queryFn: ({ signal }) => runApplicationRequest(owner, (options) => apiFetch(`/api/community/invites/${token}/info`, { + ...options, onUnauthorized: async () => { options.assertActive?.(); return false }, + }), signal), + }) + const info = query.data + const loading = query.isPending + const error = query.error ? { message: query.error instanceof Error ? query.error.message : "This invite is no longer valid", dead: isDeadInvite(query.error) } : null + const command = useMutation({ mutationKey: applicationKey(owner, "community-invite", token, "join"), + scope: { id: JSON.stringify(applicationKey(owner, "community-invite", token, "join")) }, + mutationFn: async (original: ReturnType) => { + original.assert() try { - const data = await apiFetch(`/api/community/invites/${token}/info`) - setInfo(data) - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "This invite is no longer valid" - setError({ message, dead: isDeadInvite(err) }) - } finally { - setLoading(false) - } - } - fetchInfo() - }, [token]) - + const res = await fetch(`/api/community/invites/${token}/join`, { + signal: original.signal, + method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, + }) + original.assert() + if (res.status === 401) return { kind: "sign-in" as const } + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string } | null + original.assert() + throw new Error(body?.error || "Couldn't join the server — try the invite again") + } + const result = await res.json() as { serverId: string } + original.assert() + return { kind: "joined" as const, serverId: result.serverId } + } catch (error) { original.assert(); throw error } + }, + }) + const joining = command.isPending + const mutateJoin = command.mutateAsync const handleJoin = useCallback(async () => { - setJoining(true) - // Raw fetch (not the shared `apiFetch`): apiFetch has a GLOBAL 401 handler - // that hard-navigates to a bare `/sign-in` (no redirect param) before we - // can react — which would drop the visitor at the default post-login page - // (/workspaces → /studio/new) instead of back here. Preview-first needs the - // 401 to route to sign-in WITH a return path, so we handle status ourselves. + const original = source.capture() try { - const res = await fetch(`/api/community/invites/${token}/join`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - }) - - // Not signed in → bounce to sign-in carrying an `autojoin=1` return path - // so the join resumes automatically once they're back. The visitor clicks - // Join exactly once. - if (res.status === 401) { + const result = await mutateJoin(original) + original.assert() + if (result.kind === "sign-in") { const returnTo = `/c/invite/${token}?autojoin=1` router.push(`/sign-in?redirect=${encodeURIComponent(returnTo)}`) return } - - if (!res.ok) { - const body = (await res.json().catch(() => null)) as { error?: string } | null - toast(body?.error || "Couldn't join the server — try the invite again") - setJoining(false) - return - } - - const result = (await res.json()) as { serverId: string } toast("Joined server") - // Navigate into the joined server. This page renders standalone (outside - // CommunityShell / its QueryProvider), so there's no community query cache - // to invalidate here — arriving at /c/channels mounts the shell fresh and - // its server list fetches on mount. router.push(`/c/channels/${result.serverId}`) - } catch { - toast("Couldn't join the server — try the invite again") - setJoining(false) + } catch (error) { + try { original.assert() } catch { return } + if (!(error instanceof DOMException && error.name === "AbortError")) toast(error instanceof Error ? error.message : "Couldn't join the server — try the invite again") } - }, [token, router]) - + }, [source, mutateJoin, router, token]) // Returning from the login wall (`?autojoin=1`) → resume the Join the visitor // already clicked. A ref latch fires it EXACTLY once per landing: a bare // `!joining` guard isn't enough because `info`'s reference can change (a @@ -107,13 +103,13 @@ export function InviteAcceptClient({ token }: { token: string }) { // has settled, double-firing the join (a real member-row insert). The ref // flips synchronously on first fire and never resets, so re-renders are inert. const autojoin = searchParams.get("autojoin") === "1" - const autojoinFired = useRef(false) + const autojoinIntent = useMemo(() => ({ scope: [owner, token], store: createStore(null) }), [owner, token]).store useEffect(() => { - if (autojoin && info && !autojoinFired.current) { - autojoinFired.current = true - handleJoin() + if (autojoin && info && autojoinIntent.get() !== source.signal) { + autojoinIntent.setState(() => source.signal) + void handleJoin() } - }, [autojoin, info, handleJoin]) + }, [autojoin, info, handleJoin, source.signal, autojoinIntent]) if (loading) { return ( diff --git a/src/web/src/app/c/me/[dmId]/page.dismissal.dom.test.ts b/src/web/src/app/c/me/[dmId]/page.dismissal.dom.test.ts index 1858329b6..4e4fee4be 100644 --- a/src/web/src/app/c/me/[dmId]/page.dismissal.dom.test.ts +++ b/src/web/src/app/c/me/[dmId]/page.dismissal.dom.test.ts @@ -1,5 +1,6 @@ import React from "react" -import { render } from "@/test/react-dom-harness" +import { } from "@/test/react-dom-harness" +import { renderCommunity as render } from "@/test/community-owner-harness" import { beforeEach, describe, expect, it, vi } from "vitest" import { DmView } from "@/components/community/channels/dm-view" @@ -66,7 +67,7 @@ vi.mock("@/components/community/messages/conversation-footer-shell", () => ({ ConversationFooterShell: ({ children }: { children: React.ReactNode }) => children, ConversationFooterSlotProvider: ({ children }: { children: React.ReactNode }) => children, })) -vi.mock("@/stores/community", () => ({ +vi.mock("@/stores/community", async (importOriginal) => ({ ...await importOriginal(), useCommunityStore: { getState: () => mockStore }, useUiHandlers: () => ({}), useTypingUsersForScope: () => [], @@ -127,7 +128,7 @@ vi.mock("@/hooks/community/mutations", () => ({ vi.mock("@/hooks/community/use-dm-message-sender", () => ({ useDmMessageSender: () => ({ accept: vi.fn(), retry: vi.fn() }), })) -vi.mock("@/stores/community/message-stream", () => ({ +vi.mock("@/stores/community/message-stream", async (importOriginal) => ({ ...await importOriginal(), useMessageStreamStore: { getState: () => ({ dispatch: vi.fn() }) }, })) vi.mock("@/contexts/community/current-user", () => ({ @@ -149,7 +150,7 @@ vi.mock("@/hooks/community/use-notification-settings", () => ({ })) vi.mock("@/lib/api/client", () => ({ toastApiError: vi.fn() })) vi.mock("@/lib/community/reply-content", () => ({ displayReplyContent: () => "" })) -vi.mock("@/lib/community-db/projections", () => ({ +vi.mock("@/lib/community-db/projections", async (importOriginal) => ({ ...await importOriginal(), useCanonicalProfilesByUserId: () => new Map(), useReadStateProjection: () => null, })) diff --git a/src/web/src/app/c/me/layout.route-memory.dom.test.ts b/src/web/src/app/c/me/layout.route-memory.dom.test.ts index 55a809d65..e611351eb 100644 --- a/src/web/src/app/c/me/layout.route-memory.dom.test.ts +++ b/src/web/src/app/c/me/layout.route-memory.dom.test.ts @@ -1,9 +1,11 @@ import { createElement } from "react" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import type { QueryClient } from "@tanstack/react-query" +import { CommunityTestProvider as QueryClientProvider } from "@/test/community-owner-fixture" import { beforeEach, describe, expect, it, vi } from "vitest" import { act, render } from "@/test/react-dom-harness" import { communityKeys } from "@/lib/query-keys" -import { getFriendRequestActionController } from "@/hooks/community/use-friend-request-action-state" +import { createCommunityQueryOwner } from "@/test/community-query-owner" +import { ingestAttentionSnapshot, publishCommunityFriendDecision, captureCommunityLiveSnapshotToken } from "@/lib/community-db/sync" const mocks = vi.hoisted(() => ({ pathname: "/c/me/friends", @@ -59,13 +61,15 @@ vi.mock("@/components/community/channels/dm-sidebar", () => ({ }, ), })) -vi.mock("@/stores/community", () => { +vi.mock("@/stores/community", async (importOriginal) => { + const actual = await importOriginal(); const state = { setCurrentServerId: vi.fn(), setCurrentChannelId: vi.fn(), uiHandlers: { cancelPendingNavigation: mocks.cancelPendingNavigation }, } return { + ...actual, useCommunityStore: { getState: () => state }, useCurrentChannelId: () => null, } @@ -80,7 +84,7 @@ vi.mock("@/hooks/community/use-friends", () => ({ useFriends: () => ({ blocked: [], pending: mocks.pending }), useFriendsPresence: vi.fn(), })) -vi.mock("@/stores/community/ws", () => ({ +vi.mock("@/stores/community/ws", async (importOriginal) => ({ ...await importOriginal(), useCommunityWsStore: (selector: (state: { profilesByUserId: Map }) => unknown) => selector({ profilesByUserId: new Map() }), })) @@ -109,16 +113,18 @@ vi.mock("@/lib/community/last-community-route", () => ({ import MeLayout from "./layout" -function renderLayout(queryClient = new QueryClient()) { +let layoutClient: QueryClient +function renderLayout(queryClient = layoutClient) { return render(createElement( QueryClientProvider, - { client: queryClient }, + { client: queryClient, userId: "viewer-1" }, createElement(MeLayout, null, createElement("div")), )) } describe("MeLayout route memory", () => { - beforeEach(() => { + beforeEach(async () => { + layoutClient = (await createCommunityQueryOwner("viewer-1")).client mocks.pathname = "/c/me/friends" mocks.dmId = undefined mocks.dmStatus = "idle" @@ -154,8 +160,8 @@ describe("MeLayout route memory", () => { it("mounts each target DM while Next children stay on the neutral root, without saving last in layout", () => { mocks.pathname = "/c/me/dm-a" mocks.dmId = "dm-a" - const client = new QueryClient() - const tree = () => createElement(QueryClientProvider, { client }, + const client = layoutClient + const tree = () => createElement(QueryClientProvider, { client, userId: "viewer-1" }, createElement(MeLayout, null, createElement("div", { "data-testid": "neutral-leaf" }, "Loading your space"))) const rendered = render(tree()) expect(rendered.getByTestId("target-dm").getAttribute("data-channel-id")).toBe("dm-a") @@ -191,9 +197,7 @@ describe("MeLayout route memory", () => { kind: "incoming" as const, } mocks.pending = [request] - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }) + const { client: queryClient, registry } = await createCommunityQueryOwner("viewer-1") queryClient.setQueryData(communityKeys.friends(), { friends: [], blocked: [], @@ -204,7 +208,11 @@ describe("MeLayout route memory", () => { servers: [], dms: [], }) - getFriendRequestActionController(queryClient).publishTerminal(request.id) + ingestAttentionSnapshot(registry, { + scopes: [], items: [{ id: `friend_request:${request.id}`, kind: "friend_request", sourceId: request.id, scopeId: null, messageId: null, actorUserId: request.userId, createdAt: "2026-09-12T00:00:00.000Z" }], + included: { servers: [], channels: [], dms: [], messages: [], profiles: [{ userId: request.userId, name: request.name, discriminator: "0001", avatar: request.avatar, avatarVersion: 1 }] }, limit: 100, truncated: false, + }) + publishCommunityFriendDecision(queryClient, request.id, "reject", { token: captureCommunityLiveSnapshotToken(queryClient), signal: undefined }) const rendered = renderLayout(queryClient) await act(async () => { @@ -215,7 +223,7 @@ describe("MeLayout route memory", () => { staleTime: 0, }), queryClient.fetchQuery({ - queryKey: communityKeys.inboxUnreads(), + queryKey: communityKeys.accountAttention(), queryFn: async () => { throw new Error("inbox refresh failed") }, staleTime: 0, }), diff --git a/src/web/src/app/c/me/layout.tsx b/src/web/src/app/c/me/layout.tsx index c16fffec8..622fc558e 100644 --- a/src/web/src/app/c/me/layout.tsx +++ b/src/web/src/app/c/me/layout.tsx @@ -1,4 +1,6 @@ "use client" +import { useCommunityRuntime } from "@/stores/community/runtime" + import { useCallback, useEffect, useMemo, type ReactNode } from "react" import { @@ -9,7 +11,7 @@ import { import { ShellFrame } from "@/components/community/shell/shell-frame" import { DmRoute } from "@/components/community/channels/dm-route" import { DmSidebar } from "@/components/community/channels/dm-sidebar" -import { useCommunityStore, useCurrentChannelId } from "@/stores/community" +import { useCurrentChannelId } from "@/stores/community" import { useDms } from "@/hooks/community/use-dms" import { useFriends, useFriendsPresence } from "@/hooks/community/use-friends" import { useInboxUnreads } from "@/hooks/community/use-inbox" @@ -23,6 +25,7 @@ import { commitLastCommunityRoute } from "@/lib/community/last-community-route" // DM-side layout. The DM subtree has no server settings, no channel sidebar, // and no `[serverId]` param — everything is scoped to the current user. export default function MeLayout({ children }: { children: ReactNode }) { + const communityRuntime = useCommunityRuntime() const pathname = usePathname() const currentUser = useCurrentUser() const params = useParams<{ dmId?: string }>() @@ -42,8 +45,8 @@ export default function MeLayout({ children }: { children: ReactNode }) { // null` is the canonical "no server focused" state — no need for a "@me" // sentinel string. useEffect(() => { - useCommunityStore.getState().setCurrentServerId(null) - }, []) + communityRuntime.ui.actions.setCurrentServerId(null) + }, [communityRuntime.ui.actions]) // Seed online friends into the global profile map. The endpoint is a subset // of the full WS audience, so it only patches the ids it explicitly returns. @@ -63,23 +66,23 @@ export default function MeLayout({ children }: { children: ReactNode }) { // Navigation is intentionally read-neutral. The visible-row observer owns // both optimistic clearing and the durable cursor write. const enterDm = useCallback((id: string) => { - useCommunityStore.getState().uiHandlers.navigatePath?.(`/c/me/${id}`) - }, []) + communityRuntime.ui.get().uiHandlers.navigatePath?.(`/c/me/${id}`) + }, [communityRuntime.ui]) const onShowFriends = useCallback(() => { - useCommunityStore.getState().setCurrentChannelId(null) - useCommunityStore.getState().uiHandlers.navigatePath?.("/c/me/friends") - }, []) + communityRuntime.ui.actions.setCurrentChannelId(null) + communityRuntime.ui.get().uiHandlers.navigatePath?.("/c/me/friends") + }, [communityRuntime.ui]) const onShowMachines = useCallback(() => { - useCommunityStore.getState().setCurrentChannelId(null) - useCommunityStore.getState().uiHandlers.navigatePath?.("/c/me/machines") - }, []) + communityRuntime.ui.actions.setCurrentChannelId(null) + communityRuntime.ui.get().uiHandlers.navigatePath?.("/c/me/machines") + }, [communityRuntime.ui]) const onShowBots = useCallback(() => { - useCommunityStore.getState().setCurrentChannelId(null) - useCommunityStore.getState().uiHandlers.navigatePath?.("/c/me/bots") - }, []) + communityRuntime.ui.actions.setCurrentChannelId(null) + communityRuntime.ui.get().uiHandlers.navigatePath?.("/c/me/bots") + }, [communityRuntime.ui]) const blockedUserIds = useMemo( () => new Set(blocked.map((b) => b.userId ?? b.id)), diff --git a/src/web/src/app/c/me/page.dom.test.ts b/src/web/src/app/c/me/page.dom.test.ts index 027c39995..c85345303 100644 --- a/src/web/src/app/c/me/page.dom.test.ts +++ b/src/web/src/app/c/me/page.dom.test.ts @@ -1,4 +1,8 @@ -import { createElement } from "react" +import { createElement, useEffect } from "react" +import { QueryClient } from "@tanstack/react-query" +import { CommunityTestProvider } from "@/test/community-owner-fixture" +import { createCommunityDbRegistry } from "@/lib/community-db/collections" +import { useCommunityRuntime } from "@/stores/community/runtime" import { beforeEach, describe, expect, it, vi } from "vitest" import { render, screen } from "@/test/react-dom-harness" @@ -19,9 +23,6 @@ vi.mock("@/lib/community/last-me-location", () => ({ getLastMeLeaf: () => mocks.lastLeaf, pickMeLandingLocation: (leaf: string | null) => `/c/me/${leaf ?? "friends"}`, })) -vi.mock("@/lib/community/eject-server", () => ({ - isOwnerServerDeleteMeRootLanding: () => mocks.ownerDeleteRootLanding, -})) vi.mock("@/components/community/shell/community-pending-frame", () => ({ CommunityPendingFrame: ({ href }: { href: string }) => createElement("div", { "data-testid": "pending-frame", @@ -31,6 +32,23 @@ vi.mock("@/components/community/shell/community-pending-frame", () => ({ import MeListPage from "./page" +function StartOnboarding() { + const runtime = useCommunityRuntime() + useEffect(() => { + runtime.ui.setState((state) => ({ ...state, onboardingState: { status: "active", stage: "harness" } })) + }, [runtime]) + return null +} + +function renderPage(startOnboarding = false) { + const client = new QueryClient() + const registry = createCommunityDbRegistry(client, "viewer") + registry.runtime.serverEject.setState((state) => ({ ...state, meRootLanding: mocks.ownerDeleteRootLanding })) + return render(createElement(CommunityTestProvider, { client }, + startOnboarding ? createElement(StartOnboarding) : null, + createElement(MeListPage))) +} + describe("MeListPage", () => { beforeEach(() => { mocks.breakpoint = "unknown" @@ -42,7 +60,7 @@ describe("MeListPage", () => { it.each(["unknown", "mobile"] as const)("keeps %s on the canonical list root", (breakpoint) => { mocks.breakpoint = breakpoint - const rendered = render(createElement(MeListPage)) + const rendered = renderPage() expect(mocks.replace).not.toHaveBeenCalled() expect(rendered.container).toBeEmptyDOMElement() }) @@ -50,7 +68,7 @@ describe("MeListPage", () => { it("replaces desktop with remembered leaf while keeping its pending module mounted", () => { mocks.breakpoint = "desktop" mocks.lastLeaf = "dm-last" - render(createElement(MeListPage)) + renderPage() expect(mocks.replace).toHaveBeenCalledTimes(1) expect(mocks.replace).toHaveBeenCalledWith("/c/me/dm-last") expect(screen.getByTestId("pending-frame")).toHaveAttribute("data-href", "/c/me/dm-last") @@ -58,7 +76,7 @@ describe("MeListPage", () => { it("defaults desktop to Friends", () => { mocks.breakpoint = "desktop" - render(createElement(MeListPage)) + renderPage() expect(mocks.replace).toHaveBeenCalledWith("/c/me/friends") expect(screen.getByTestId("pending-frame")).toHaveAttribute("data-href", "/c/me/friends") }) @@ -66,7 +84,7 @@ describe("MeListPage", () => { it("keeps an explicit owner-delete landing on the canonical root", () => { mocks.breakpoint = "desktop" mocks.ownerDeleteRootLanding = true - const rendered = render(createElement(MeListPage)) + const rendered = renderPage() expect(mocks.replace).not.toHaveBeenCalled() expect(rendered.container).toBeEmptyDOMElement() }) @@ -74,8 +92,13 @@ describe("MeListPage", () => { it("keeps desktop on the canonical root while onboarding is active", () => { mocks.breakpoint = "desktop" mocks.onboarding = { status: "active", stage: "harness" } - const rendered = render(createElement(MeListPage)) + const rendered = renderPage() expect(mocks.replace).not.toHaveBeenCalled() expect(rendered.container).toBeEmptyDOMElement() }) + it("does not overwrite onboarding started by an earlier sibling effect", () => { + mocks.breakpoint = "desktop" + renderPage(true) + expect(mocks.replace).not.toHaveBeenCalled() + }) }) diff --git a/src/web/src/app/c/me/page.tsx b/src/web/src/app/c/me/page.tsx index 0ad751545..c840da2dd 100644 --- a/src/web/src/app/c/me/page.tsx +++ b/src/web/src/app/c/me/page.tsx @@ -6,22 +6,28 @@ import { useBreakpoint } from "@/hooks/use-mobile" import { CommunityPendingFrame } from "@/components/community/shell/community-pending-frame" import { useCommunityOnboarding } from "@/lib/community-onboarding" import { getLastMeLeaf, pickMeLandingLocation } from "@/lib/community/last-me-location" -import { isOwnerServerDeleteMeRootLanding } from "@/lib/community/eject-server" +import { useSelector } from "@tanstack/react-store" +import { useQueryClient } from "@tanstack/react-query" +import { getCommunityRuntime } from "@/stores/community/runtime" export default function MeListPage() { const router = useRouter() const breakpoint = useBreakpoint() const onboarding = useCommunityOnboarding() + const queryClient = useQueryClient() + const runtime = getCommunityRuntime(queryClient) + const ownerDeleteRootLanding = useSelector(runtime.serverEject, (state) => state.meRootLanding) const destination = breakpoint === "desktop" && !onboarding - && !isOwnerServerDeleteMeRootLanding() + && !ownerDeleteRootLanding ? pickMeLandingLocation(getLastMeLeaf()) : null useEffect(() => { if (!destination) return + if (runtime.ui.get().onboardingState || runtime.serverEject.get().meRootLanding) return router.replace(destination) - }, [destination, router]) + }, [destination, router, runtime]) return destination ? : null } diff --git a/src/web/src/app/device/page.tsx b/src/web/src/app/device/page.tsx index a18adc5a4..798f93baf 100644 --- a/src/web/src/app/device/page.tsx +++ b/src/web/src/app/device/page.tsx @@ -1,6 +1,10 @@ "use client" -import { Suspense, useEffect, useState, useRef } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { Suspense, useEffect } from "react" +import { useQuery, useMutation } from "@tanstack/react-query" +import { PublicQueryProvider, applicationKey, captureApplicationOwner, assertApplicationOwner } from "@/lib/application-owner" +import { useApplicationViewSource } from "@/hooks/use-application-view-source" import { useSearchParams, useRouter } from "next/navigation" import { authClient, useSession } from "@/lib/auth-client" import { Button } from "@/components/ui/button" @@ -14,7 +18,7 @@ type Step = "loading" | "code" | "approve" | "done" | "denied" export default function DeviceAuthPage() { return ( - + ) } @@ -25,85 +29,63 @@ function DeviceAuthPageInner() { const { data: session, isPending } = useSession() const urlCode = searchParams.get("user_code") || "" - const [userCode, setUserCode] = useState(urlCode) - const [step, setStep] = useState(urlCode ? "loading" : "code") - const [error, setError] = useState("") - const [loading, setLoading] = useState(false) - const autoVerified = useRef(false) + const [userCode, setUserCode] = useAtom(useCreateAtom(urlCode)) + const [verificationCode, setVerificationCode] = useAtom(useCreateAtom(urlCode.trim())) + const source = useApplicationViewSource(`device:${verificationCode}`) + const { owner } = source + const verification = useQuery({ + queryKey: applicationKey(owner, "device-verification", verificationCode), + enabled: !!verificationCode && !!session && !isPending, subscribed: !!verificationCode && !!session && !isPending, retry: false, gcTime: 0, + queryFn: async ({ signal }) => { + const token = captureApplicationOwner(owner) + assertApplicationOwner(token, signal) + try { + const res = await authClient.device({ query: { user_code: verificationCode }, fetchOptions: { signal } }) + assertApplicationOwner(token, signal) + if (res.error) throw new Error(res.error.error_description || "Invalid or expired code") + return true + } catch (error) { assertApplicationOwner(token, signal); throw error } + }, + }) + const command = useMutation({ + mutationKey: applicationKey(owner, "device-decision"), + scope: { id: JSON.stringify(applicationKey(owner, "device-decision")) }, + mutationFn: async ({ kind, code, original }: { kind: "approve" | "deny"; code: string; original: ReturnType }) => { + original.assert() + try { + const options = { signal: original.signal, onRequest: () => original.assert(), onSuccess: () => original.assert(), onError: () => original.assert() } + const res = kind === "approve" ? await authClient.device.approve({ userCode: code, fetchOptions: options }) : await authClient.device.deny({ userCode: code, fetchOptions: options }) + original.assert() + if (res.error) throw new Error(res.error.error_description || `Failed to ${kind} device`) + return kind + } catch (error) { original.assert(); throw error } + }, + }) + const currentDecision = command.variables?.code === verificationCode ? command : null + const loading = verification.isFetching || !!currentDecision?.isPending + const failure = currentDecision?.error ?? verification.error + const error = failure instanceof DOMException && failure.name === "AbortError" ? "" : failure instanceof Error ? failure.message : "" + const step: Step = currentDecision?.isSuccess ? currentDecision.data === "approve" ? "done" : "denied" + : verification.isFetching ? "loading" : verification.isSuccess ? "approve" : "code" useEffect(() => { if (!isPending && !session) { + const original = source.capture() + original.assert() const callbackUrl = `/device${userCode ? `?user_code=${encodeURIComponent(userCode)}` : ""}` router.push(`/sign-in?redirect=${encodeURIComponent(callbackUrl)}`) } - }, [isPending, session, router, userCode]) - - useEffect(() => { - if (!urlCode || !session || autoVerified.current) return - autoVerified.current = true - - async function autoVerify() { - try { - const res = await authClient.device({ query: { user_code: urlCode.trim() } }) - if (res.error) { - setError(res.error.error_description || "Invalid or expired code") - setStep("code") - } else { - setStep("approve") - } - } catch { - setError("Failed to verify code") - setStep("code") - } - } - - autoVerify() - }, [urlCode, session]) + }, [isPending, session, router, userCode, source]) - async function handleVerifyCode(e: React.FormEvent) { + const handleVerifyCode = (e: React.FormEvent) => { e.preventDefault() - setError("") - setLoading(true) - try { - const res = await authClient.device({ query: { user_code: userCode.trim() } }) - if (res.error) { - setError(res.error.error_description || "Invalid or expired code") - } else { - setStep("approve") - } - } catch { - setError("Failed to verify code") - } - setLoading(false) - } - - async function handleApprove() { - setError("") - setLoading(true) - try { - const res = await authClient.device.approve({ userCode: userCode.trim() }) - if (res.error) { - setError(res.error.error_description || "Failed to approve") - } else { - setStep("done") - } - } catch { - setError("Failed to approve device") - } - setLoading(false) - } - - async function handleDeny() { - setError("") - setLoading(true) - try { - await authClient.device.deny({ userCode: userCode.trim() }) - setStep("denied") - } catch { - setError("Failed to deny device") - } - setLoading(false) + const code = userCode.trim() + command.reset() + if (code === verificationCode) void verification.refetch({ cancelRefetch: false }) + else setVerificationCode(code) } + const handleApprove = () => command.mutate({ kind: "approve", code: verificationCode, original: source.capture() }) + const handleDeny = () => command.mutate({ kind: "deny", code: verificationCode, original: source.capture() }) if (isPending || !session) { return null diff --git a/src/web/src/app/pricing/pricing-client.dom.test.tsx b/src/web/src/app/pricing/pricing-client.dom.test.tsx index e6ab1ac91..44e867057 100644 --- a/src/web/src/app/pricing/pricing-client.dom.test.tsx +++ b/src/web/src/app/pricing/pricing-client.dom.test.tsx @@ -11,7 +11,7 @@ const state = vi.hoisted(() => ({ billing: {} as Record, pricingView: vi.fn(), pricingCta: vi.fn(), })) -vi.mock("@/lib/auth-client", () => ({ useSession: () => state.session })) +vi.mock("@/lib/auth-client", () => { const sessionSDK = { useSession: () => state.session }; return { ...sessionSDK, currentSessionViewer: () => { const value = sessionSDK.useSession(); return !value || value.isPending || value.error ? undefined : value.data?.user.id ?? null } } }) vi.mock("next/navigation", () => ({ useRouter: () => ({ push: state.push }), useSearchParams: () => state.search })) vi.mock("@/lib/api/client", () => ({ apiFetch: state.api })) vi.mock("@/hooks/community/use-billing", () => ({ useBilling: () => ({ ...state.billing, checkout: state.checkout, portal: state.portal, refresh: state.refresh }) })) diff --git a/src/web/src/app/pricing/pricing-client.tsx b/src/web/src/app/pricing/pricing-client.tsx index 44644a9a3..9867beb8d 100644 --- a/src/web/src/app/pricing/pricing-client.tsx +++ b/src/web/src/app/pricing/pricing-client.tsx @@ -1,12 +1,14 @@ "use client" -import { useEffect, useRef, useState, type MutableRefObject } from "react" +import { useEffect, useRef, type MutableRefObject } from "react" import { useRouter, useSearchParams } from "next/navigation" -import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query" +import { useQuery } from "@tanstack/react-query" import { PublicPricingSchema, type PublicPricing, type BillingOffer } from "@alook/shared" import { useSession } from "@/lib/auth-client" import { apiFetch } from "@/lib/api/client" import { useBilling } from "@/hooks/community/use-billing" +import { PublicQueryProvider, useApplicationOwner } from "@/lib/application-owner" +import { useAtom, useCreateAtom } from "@tanstack/react-store" import { FounderPlanChangeDialog } from "@/components/community/billing/founder-plan-change-dialog" import { toAnalyticsCurrentPlan, toAnalyticsPlanId, trackPricingCtaClick, trackPricingView, type PricingCtaAction } from "@/lib/analytics" import { PricingView } from "./pricing-view" @@ -25,9 +27,12 @@ function PricingContent({ signedIn, sessionPending, sessionError, viewTrackedRef staleTime: 30_000, retry: false, }) - const billing = useBilling(null, signedIn) + const owner = useApplicationOwner() + const billing = useBilling(null, signedIn, owner) const summary = signedIn ? billing.data : undefined - const [founderOffer, setFounderOffer] = useState(null) + const [founderOfferId, setFounderOfferId] = useAtom(useCreateAtom(null)) + const founderOffer = summary?.offers.find((offer) => offer.priceId === founderOfferId) ?? null + const setFounderOffer = (offer: BillingOffer | null) => setFounderOfferId(offer?.priceId ?? null) const paymentOffer = (plan: string) => summary?.offers.find((item) => item.plan.id === plan) const offer = (plan: string) => paymentOffer(plan) ?? catalog.data?.offers.find((item) => item.plan.id === plan) const selected = offer(search.get("plan") ?? "") @@ -121,8 +126,7 @@ function PricingSession({ signedIn, sessionPending, sessionError, viewTrackedRef sessionError: boolean viewTrackedRef: MutableRefObject }) { - const [client] = useState(() => new QueryClient()) - return + return } export default function PricingClient() { diff --git a/src/web/src/app/templates/client.tsx b/src/web/src/app/templates/client.tsx index 2b6302ae9..1d30f1199 100644 --- a/src/web/src/app/templates/client.tsx +++ b/src/web/src/app/templates/client.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useEffect, useRef } from "react"; import Link from "next/link"; import { PublicLayout } from "@/components/public-layout"; import { TemplateCard } from "./_components/template-card"; @@ -18,7 +19,7 @@ export function TemplatesClient({ isLoggedIn: boolean; workspaceId?: string; }) { - const [activeCategory, setActiveCategory] = useState<"All" | TemplateCategory>("All"); + const [activeCategory, setActiveCategory] = useAtom(useCreateAtom<"All" | TemplateCategory>("All")); const tracked = useRef(false); useEffect(() => { diff --git a/src/web/src/components/agent-chat/agent-chat-view.tsx b/src/web/src/components/agent-chat/agent-chat-view.tsx index 1d2689a6a..291a9b6be 100644 --- a/src/web/src/components/agent-chat/agent-chat-view.tsx +++ b/src/web/src/components/agent-chat/agent-chat-view.tsx @@ -1,35 +1,19 @@ "use client"; import { useArtifactClick } from "@/components/use-artifact-click"; -import React, { - useEffect, - useLayoutEffect, - useState, - useRef, - useCallback, - useMemo, -} from "react"; +import { useMutation, useIsMutating } from "@tanstack/react-query"; +import { captureQueryReceipt, isQueryReceiptCurrent, type QueryReceipt } from "@/lib/query-receipt"; +import { captureChatIntent, assertChatIntent, runChatIntentRequest } from "@/hooks/workspace/use-chat-data"; +import { isAbortError } from "@/lib/errors"; +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useMountedClock } from "@/hooks/use-mounted-clock"; +import { useEffect, useCallback, useMemo } from "react"; import { useParams, useSearchParams } from "next/navigation"; -import { useWorkspace } from "@/contexts/workspace-context"; +import { useWorkspaceOwner, useWorkspace } from "@/contexts/workspace-context"; import { Button } from "@/components/ui/button"; import { TaskStream } from "@/components/task-stream"; -import { - getTask, - updateIssue, - getAgentSkills, - cancelActiveTask, - getThreadSummaries, - createThread, - conversationInit, -} from "@/lib/api"; -import { useLatest } from "@/components/agent-chat/chat-message-utils"; -import type { - Artifact, - Issue, - Message, - SkillEntry, - WsMessage, -} from "@alook/shared"; +import { cancelActiveTask, createThread } from "@/lib/api"; +import type { Artifact, Issue, SkillEntry } from "@alook/shared"; import { useAgentContext } from "@/contexts/agent-context"; import { useInboxCount } from "@/contexts/inbox-count-context"; import { useChannel } from "@/contexts/channel-context"; @@ -52,9 +36,11 @@ import { Square, X, } from "lucide-react"; +import { useChatThreadResources } from "@/hooks/workspace/use-chat-thread-resources"; import { useAgentChat } from "@/hooks/use-agent-chat"; import { useMessageFlags } from "@/hooks/use-message-flags"; import { useChatSheets } from "@/hooks/use-chat-sheets"; +import { useChatArtifactSelection } from "@/hooks/workspace/use-chat-artifact-selection"; import { useFileAttachments } from "@/hooks/use-file-attachments"; import { useTextSelectionQuote } from "@/hooks/use-text-selection-quote"; import { useSlashCommand } from "@/hooks/use-slash-command"; @@ -104,6 +90,7 @@ export function AgentChatView({ }) { const params = useParams(); const searchParams = useSearchParams(); + const workspaceOwner = useWorkspaceOwner(); const { workspaceId, slug } = useWorkspace(); const { agents, @@ -116,6 +103,7 @@ export function AgentChatView({ const { refresh: refreshInboxCount } = useInboxCount(); const { activeChannel, + readActiveChannel, loading: channelLoading, setAgentId: setChannelAgentId, } = useChannel(); @@ -140,46 +128,20 @@ export function AgentChatView({ ? propTargetConvId : searchParams.get("conv"); - const [input, setInput] = useState(() => { + const inputAtom = useCreateAtom(useMemo(() => { if (typeof window === "undefined") return ""; return ( localStorage.getItem( - `chat-draft:${agentId}:${targetConvId ?? "default"}`, + `chat-draft:${workspaceOwner.application.userId}:${workspaceId}:${agentId}:${targetConvId ?? "default"}`, ) ?? "" ); - }); - const [composerFocused, setComposerFocused] = useState(false); - const { - artifactSheetOpen, - setArtifactSheetOpen, - selectedArtifact, - setSelectedArtifact, - emailSheetOpen, - setEmailSheetOpen, - selectedEmailId, - setSelectedEmailId, - calendarEventSheetOpen, - setCalendarEventSheetOpen, - selectedCalendarEventId, - setSelectedCalendarEventId, - issueSheetOpen, - setIssueSheetOpen, - selectedIssueId, - setSelectedIssueId, - issueDetail, - setIssueDetail, - issueDetailLoading, - issueTraceTasks, - issueActiveTask, - setIssueActiveTask, - openIssue, - issueConvId, - issueTaskId, - } = useChatSheets(workspaceId); - const [lightboxArtifact, setLightboxArtifact] = useState(null); - const [lightboxLocalUrl, setLightboxLocalUrl] = useState<{ url: string; filename: string } | null>(null); + }, [agentId, targetConvId, workspaceId, workspaceOwner.application.userId])); + const [input, setInput] = useAtom(inputAtom); + const [composerFocused, setComposerFocused] = useAtom(useCreateAtom(false)); + const [lightboxLocalUrl, setLightboxLocalUrl] = useAtom(useCreateAtom<{ url: string; filename: string } | null>(null)); const { pendingFiles, + readPendingFiles, setPendingFiles, fileInputRef, addPendingFiles, @@ -191,15 +153,15 @@ export function AgentChatView({ handleDragOver, handleDrop, } = useFileAttachments(); - const [caretIndex, setCaretIndex] = useState(null); - const [renderNow] = useState(() => Date.now()); + const [caretIndex, setCaretIndex] = useAtom(useCreateAtom(null)); + const [renderNow] = useMountedClock(); - const [quotedMessage, setQuotedMessage] = useState<{ id: string; excerpt: string } | null>(() => { + const quotedMessageAtom = useCreateAtom<{ id: string; excerpt: string } | null>(useMemo<{ id: string; excerpt: string } | null>(() => { if (typeof window === "undefined") return null; try { const meta = JSON.parse( localStorage.getItem( - `chat-draft-meta:${agentId}:${targetConvId ?? "default"}`, + `chat-draft-meta:${workspaceOwner.application.userId}:${workspaceId}:${agentId}:${targetConvId ?? "default"}`, ) ?? "null", ); const q = meta?.quote; @@ -207,39 +169,21 @@ export function AgentChatView({ } catch { return null; } - }); - const [isMultiLine, setIsMultiLine] = useState(false); - const { flaggedIds, setFlaggedIds, handleToggleFlag } = - useMessageFlags(workspaceId); + }, [agentId, targetConvId, workspaceId, workspaceOwner.application.userId])); + const [quotedMessage, setQuotedMessage] = useAtom(quotedMessageAtom); + const [isMultiLine, setIsMultiLine] = useAtom(useCreateAtom(false)); // Thread state const { openAgentChat } = useAgentChatSheet(); - const [threadSummaries, setThreadSummaries] = useState>(new Map()); - const [threadRootMessage, setThreadRootMessage] = useState(null); useEffect(() => { setChannelAgentId(agentId); }, [agentId, setChannelAgentId]); - // Component-owned ref written by the hook's load effect; read by the - // draft-meta persist effect below to gate the first write until restore runs. - const draftMetaRestoredRef = useRef(false); - - // Reader-refs for the externally-owned values the orchestration reads inside - // long-lived callbacks (handleSend). useLatest keeps them fresh without - // churning callback identity. `activeSkillRef` is a manual ref synced below, - // because `slashCommand` is defined after `useAgentChat` (it needs the - // hook-created `composerRef`). - const inputRef = useLatest(input); - const quotedMessageRef = useLatest(quotedMessage); - const pendingFilesRef = useLatest(pendingFiles); - const activeSkillRef = useRef(null); - // Holds the `slashCommand` instance (created after this hook call, since it - // needs the hook's `composerRef`) so the hook's setActiveSkill/clearActiveSkill - // wrappers can reach it from inside the load effect. - const slashCommandRef = useRef | null>( - null, - ); + const draftRestored = useCreateAtom(false); + const activeSkillNameAtom = useCreateAtom(useMemo(() => { + try { return JSON.parse(localStorage.getItem(`chat-draft-meta:${workspaceOwner.application.userId}:${workspaceId}:${agentId}:${targetConvId ?? "default"}`) ?? "null")?.skill?.name ?? null; } catch { return null; } + }, [agentId, targetConvId, workspaceId, workspaceOwner.application.userId])); const chat = useAgentChat( { @@ -251,24 +195,23 @@ export function AgentChatView({ workspaceId, agents, activeChannel, + readActiveChannel, channelLoading, subscribeWs, subscribeReconnect, refreshInboxCount, }, { - setFlaggedIds, setPendingFiles, setInput, setQuotedMessage, - setActiveSkill: (skill: SkillEntry | null) => - slashCommandRef.current?.setActiveSkill(skill), - clearActiveSkill: () => slashCommandRef.current?.clearActiveSkill(), - inputRef, - quotedMessageRef, - pendingFilesRef, - activeSkillRef, - draftMetaRestoredRef, + setActiveSkill: (skill: SkillEntry | null) => activeSkillNameAtom.set(skill?.name ?? null), + clearActiveSkill: () => activeSkillNameAtom.set(null), + readInput: inputAtom.get, + readQuotedMessage: quotedMessageAtom.get, + readPendingFiles, + readActiveSkill: () => workspaceOwner.queryClient.getQueryData(workspaceOwner.key("agent-skills", agentId))?.find((skill) => skill.name === activeSkillNameAtom.get()) ?? null, + markDraftRestored: () => draftRestored.set(true), }, ); @@ -303,51 +246,60 @@ export function AgentChatView({ handleNap, } = chat; + const { + artifactSheetOpen, + setArtifactSheetOpen, + selectedArtifact, + setSelectedArtifact, + emailSheetOpen, + setEmailSheetOpen, + selectedEmailId, + setSelectedEmailId, + calendarEventSheetOpen, + setCalendarEventSheetOpen, + selectedCalendarEventId, + setSelectedCalendarEventId, + issueSheetOpen, + setIssueSheetOpen, + selectedIssueId, + issueDetail, + issueDetailLoading, + issueTraceTasks, + issueActiveTask, + openIssue, + updateSelectedIssue, + } = useChatSheets(workspaceOwner, chat.chatView); + const [lightboxArtifact, setLightboxArtifact] = useChatArtifactSelection(workspaceOwner, chat.chatView, selectedIssueId); + + const { flaggedIds, handleToggleFlag } = useMessageFlags(workspaceOwner, chat.chatView, conversation?.id ?? null); + + const chatActionKey = useMemo(() => workspaceOwner.key("chat", "action", crypto.randomUUID()), [workspaceOwner]); + type ChatAction = { original: ReturnType; conversationId: string; resources: Map } & ({ kind: "thread"; messageId: string } | { kind: "stop" }); + const chatAction = useMutation({ mutationKey: chatActionKey, gcTime: 0, scope: { id: JSON.stringify(chatActionKey) }, + mutationFn: async (action: ChatAction) => { + assertChatIntent(action.original); + return runChatIntentRequest(action.original, async (options) => { + if (action.kind === "thread") return createThread(action.conversationId, action.messageId, "", workspaceId, options); + const task = await cancelActiveTask(action.conversationId, workspaceId, options); + assertChatIntent(action.original); + const key = workspaceOwner.key("chat", "task", task.id), ticket = action.resources.get(JSON.stringify(key)); + if (ticket ? isQueryReceiptCurrent(ticket) : !workspaceOwner.queryClient.getQueryCache().find({ queryKey: key, exact: true })) workspaceOwner.queryClient.setQueryData(key, task); + return task; + }); + }, + }); + const stopping = useIsMutating({ mutationKey: chatActionKey, exact: true, predicate: (mutation) => (mutation.state.variables as ChatAction).kind === "stop" }) > 0; + const captureChatAction = () => { + const original = captureChatIntent(workspaceOwner, chat.chatView); assertChatIntent(original); + return { original, resources: new Map(workspaceOwner.queryClient.getQueryCache().findAll({ queryKey: workspaceOwner.key("chat", "task") }).map((query) => [JSON.stringify(query.queryKey), captureQueryReceipt(workspaceOwner.queryClient, query.queryKey)])) }; + }; + const { versionMap, duplicateFilenames } = useMemo( () => computeArtifactVersions(agentArtifacts), [agentArtifacts], ); - // Fetch root message for thread conversations - useEffect(() => { - if (!conversation?.parent_message_id || !workspaceId) { - setThreadRootMessage(null); - return; - } - conversationInit(conversation.id, workspaceId) - .then((data) => { - if (data.root_message) setThreadRootMessage(data.root_message); - }) - .catch(() => {}); - }, [conversation?.id, conversation?.parent_message_id, workspaceId]); - - const fetchThreadSummaries = useCallback(() => { - if (!conversation?.id || !workspaceId) return; - getThreadSummaries(conversation.id, workspaceId) - .then((data) => { - const map = new Map(); - for (const s of data.thread_summaries) { - map.set(s.parent_message_id, { - thread_id: s.thread_id, - reply_count: s.reply_count, - last_reply_at: s.last_reply_at, - thread_title: s.thread_title, - }); - } - setThreadSummaries(map); - }) - .catch(() => {}); - }, [conversation?.id, workspaceId]); - - useEffect(() => { fetchThreadSummaries(); }, [fetchThreadSummaries]); - - useEffect(() => { - return subscribeWs((msg: WsMessage) => { - if (msg.type === "thread.created" || msg.type === "thread.reply") { - fetchThreadSummaries(); - } - }); - }, [subscribeWs, fetchThreadSummaries]); + const { threadRootMessage, threadSummaries, readThreadSummary, summariesKey, agentSkills } = useChatThreadResources(workspaceOwner, chat.chatView, conversation, agentId, subscribeWs); const agentAvatarUrl = useMemo( () => agents.find((a) => a.id === agentId)?.avatar_url ?? null, @@ -361,57 +313,31 @@ export function AgentChatView({ const url = URL.createObjectURL(file); setLightboxLocalUrl({ url, filename: file.name }); }, - [], + [setLightboxLocalUrl], ); const previewArtifact = useCallback((artifact: Artifact) => { setSelectedArtifact(artifact); setArtifactSheetOpen(true); }, [setSelectedArtifact, setArtifactSheetOpen]); - const handleArtifactClick = useArtifactClick(workspaceId, previewArtifact, setLightboxArtifact); - const handleIssueArtifactClick = useArtifactClick(workspaceId, previewArtifact, setLightboxArtifact); + const handleArtifactClick = useArtifactClick(workspaceId, previewArtifact, setLightboxArtifact, JSON.stringify([agentId, targetConvId, activeChannel, selectedIssueId])); + const handleIssueArtifactClick = useArtifactClick(workspaceId, previewArtifact, setLightboxArtifact, JSON.stringify([agentId, targetConvId, activeChannel, selectedIssueId])); // Editor plain text + caret, reported up from the composer, drive the // slash-command popup (mentions are handled natively inside the composer). - const [editorText, setEditorText] = useState(""); + const [editorText, setEditorText] = useAtom(useCreateAtom("")); const otherAgents = useMemo( () => agents.filter((a) => a.id !== agentId), [agents, agentId], ); - // Slash command skills — fetch from D1 on mount - const [agentSkills, setAgentSkills] = useState([]); - const skillsFetchedRef = useRef(false); - - useEffect(() => { - if (skillsFetchedRef.current) return; - skillsFetchedRef.current = true; - getAgentSkills(agentId, workspaceId) - .then((res) => setAgentSkills(res.skills as SkillEntry[])) - .catch(() => { }); - }, [agentId, workspaceId]); - - const [initialActiveSkill] = useState(() => { - if (typeof window === "undefined") return null; - try { - const meta = JSON.parse( - localStorage.getItem( - `chat-draft-meta:${agentId}:${targetConvId ?? "default"}`, - ) ?? "null", - ); - return meta?.skill ?? null; - } catch { - return null; - } - }); - const slashCommand = useSlashCommand({ input: editorText, caretIndex, skills: agentSkills, onInputChange: () => composerRef.current?.clear(), - initialActiveSkill, + activeSkillNameAtom, getAnchorPos: useCallback( (triggerStart: number) => composerRef.current?.coordsAtTextIndex(triggerStart) ?? null, @@ -424,20 +350,6 @@ export function AgentChatView({ }, []), }); - // Bridge `slashCommand` (defined here, after `useAgentChat` because it needs - // the hook-created `composerRef`) back to the orchestration: a ref the hook's - // setActiveSkill/clearActiveSkill wrappers call, and a useLatest-style sync of - // `activeSkill` into the `activeSkillRef` the hook reads inside handleSend. - // MUST be a layout effect, not a passive one: the hook's load effect (passive) - // runs on first mount and calls setActiveSkill to restore a saved skill draft. - // React fires all layout effects of a commit before any passive effect, so this - // populates slashCommandRef before the load effect reads it — a passive sync - // here would still be null then and silently drop the restore. - useLayoutEffect(() => { - slashCommandRef.current = slashCommand; - activeSkillRef.current = slashCommand.activeSkill; - }); - useEffect(() => { if (agentSkills.length === 0 || !slashCommand.activeSkill) return; const exists = agentSkills.some( @@ -448,17 +360,17 @@ export function AgentChatView({ }, [agentSkills]); useEffect(() => { - const key = `chat-draft:${agentId}:${targetConvId ?? "default"}`; + const key = `chat-draft:${workspaceOwner.application.userId}:${workspaceId}:${agentId}:${targetConvId ?? "default"}`; if (input) { localStorage.setItem(key, input); } else { localStorage.removeItem(key); } - }, [input, agentId, targetConvId]); + }, [input, agentId, targetConvId, workspaceOwner.application.userId, workspaceId]); useEffect(() => { - if (!draftMetaRestoredRef.current) return; - const key = `chat-draft-meta:${agentId}:${targetConvId ?? "default"}`; + if (!draftRestored.get()) return; + const key = `chat-draft-meta:${workspaceOwner.application.userId}:${workspaceId}:${agentId}:${targetConvId ?? "default"}`; const meta: { skill?: { name: string; description: string } | null; quote?: { id: string; excerpt: string } | null; @@ -477,7 +389,7 @@ export function AgentChatView({ } else { localStorage.removeItem(key); } - }, [slashCommand.activeSkill, quotedMessage, agentId, targetConvId]); + }, [slashCommand.activeSkill, quotedMessage, agentId, targetConvId, draftRestored, workspaceOwner.application.userId, workspaceId]); useEffect(() => { if (!sending) { @@ -530,105 +442,34 @@ export function AgentChatView({ openAgentChat(agId, { conversationId: convId }); }, [openAgentChat]); - const threadSummariesRef = useLatest(threadSummaries); - const conversationRef = useLatest(conversation); - const fetchThreadSummariesRef = useLatest(fetchThreadSummaries); - - const handleReplyInThread = useCallback(async (msgId: string) => { - const summary = threadSummariesRef.current.get(msgId); - if (summary?.thread_id) { - openAgentChat(agentId, { conversationId: summary.thread_id }); - } else if (conversationRef.current) { - try { - const result = await createThread(conversationRef.current.id, msgId, "", workspaceId); - openAgentChat(agentId, { conversationId: result.conversation.id }); - fetchThreadSummariesRef.current(); - } catch {} + const handleReplyInThread = async (msgId: string) => { + const action = captureChatAction(), current = chat.readConversation(); + const summary = readThreadSummary(msgId); + if (summary?.thread_id) { openAgentChat(agentId, { conversationId: summary.thread_id }); return; } + if (!current || workspaceOwner.queryClient.isMutating({ mutationKey: chatActionKey, exact: true })) return; + const summaryResource = workspaceOwner.queryClient.getQueryCache().find({ queryKey: summariesKey, exact: true }); + try { + const result = await chatAction.mutateAsync({ ...action, kind: "thread", conversationId: current.id, messageId: msgId }) as Awaited>; + assertChatIntent(action.original); + if (summaryResource && workspaceOwner.queryClient.getQueryCache().find({ queryKey: summariesKey, exact: true }) === summaryResource) void workspaceOwner.queryClient.invalidateQueries({ queryKey: summariesKey, exact: true }).catch(() => undefined); + openAgentChat(agentId, { conversationId: result.conversation.id }); + } catch (error) { + try { assertChatIntent(action.original); } catch { return; } + if (!isAbortError(error)) toast.error("Failed to create thread"); } - // eslint-disable-next-line react-hooks/exhaustive-deps -- refs are stable - }, [openAgentChat, agentId, workspaceId]); - - useEffect(() => { - if (!issueSheetOpen || !selectedIssueId) return; + }; - return subscribeWs((msg: WsMessage) => { - if ( - msg.type === "task.updated" && - issueTaskId && - msg.taskId === issueTaskId - ) { - getTask(issueTaskId, workspaceId) - .then((task) => setIssueActiveTask(task)) - .catch(() => { }); - } - if ( - msg.type === "conversation.message" && - issueConvId && - msg.conversationId === issueConvId - ) { - setIssueDetail((prev) => { - if (!prev) return prev; - if (prev.messages.some((m) => m.id === msg.message.id)) return prev; - return { ...prev, messages: [...prev.messages, msg.message] }; - }); - if ( - msg.message.role === "event" && - msg.message.content.startsWith("Issue status changed:") - ) { - const match = msg.message.content.match(/-> (\w+)/); - if (match) { - setIssueDetail((prev) => - prev - ? { - ...prev, - issue: { - ...prev.issue, - status: match[1] as Issue["status"], - }, - } - : prev, - ); - } - } - } - if (msg.type === "issue.comment" && msg.issueId === selectedIssueId) { - setIssueDetail((prev) => { - if (!prev) return prev; - if (prev.comments.some((c) => c.id === msg.comment.id)) return prev; - return { ...prev, comments: [...prev.comments, msg.comment] }; - }); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps -- setState fns are stable - }, [ - issueSheetOpen, - selectedIssueId, - issueConvId, - issueTaskId, - workspaceId, - subscribeWs, - ]); - - const [menuOpen, setMenuOpen] = useState(false); - const [stopping, setStopping] = useState(false); - - const isTaskActive = - !!activeTask && - !["completed", "failed", "cancelled", "superseded"].includes( - activeTask.status, - ); - - const handleStop = useCallback(async () => { - if (!conversation?.id || stopping) return; - setStopping(true); - try { - await cancelActiveTask(conversation.id, workspaceId); - } catch { - toast.error("Failed to stop the task"); - } finally { - setStopping(false); + const [menuOpen, setMenuOpen] = useAtom(useCreateAtom(false)); + const isTaskActive = !!activeTask && !["completed", "failed", "cancelled", "superseded"].includes(activeTask.status); + const handleStop = async () => { + const action = captureChatAction(), current = chat.readConversation(); + if (!current || workspaceOwner.queryClient.isMutating({ mutationKey: chatActionKey, exact: true, predicate: (mutation) => (mutation.state.variables as ChatAction).kind === "stop" })) return; + try { await chatAction.mutateAsync({ ...action, kind: "stop", conversationId: current.id }); } + catch (error) { + try { assertChatIntent(action.original); } catch { return; } + if (!isAbortError(error)) toast.error("Failed to stop the task"); } - }, [conversation?.id, workspaceId, stopping]); + }; // Rotating capability-hint placeholder for the idle, empty composer. Freezes // on focus/typing, resumes on empty blur; never rotates while a task is @@ -1292,10 +1133,7 @@ export function AgentChatView({ open={artifactSheetOpen} onOpenChange={(v) => { setArtifactSheetOpen(v); - if (!v) - setTimeout(() => { - setSelectedArtifact(null); - }, 300); + }} artifacts={selectedArtifact ? [selectedArtifact] : agentArtifacts} workspaceId={workspaceId} @@ -1308,7 +1146,7 @@ export function AgentChatView({ open={emailSheetOpen} onOpenChange={(v) => { setEmailSheetOpen(v); - if (!v) setTimeout(() => setSelectedEmailId(null), 300); + }} emailId={selectedEmailId} workspaceId={workspaceId} @@ -1319,7 +1157,7 @@ export function AgentChatView({ open={calendarEventSheetOpen} onOpenChange={(v) => { setCalendarEventSheetOpen(v); - if (!v) setTimeout(() => setSelectedCalendarEventId(null), 300); + }} calendarEventId={selectedCalendarEventId} workspaceId={workspaceId} @@ -1329,12 +1167,7 @@ export function AgentChatView({ open={issueSheetOpen} onOpenChange={(v) => { setIssueSheetOpen(v); - if (!v) - setTimeout(() => { - setSelectedIssueId(null); - setIssueDetail(null); - setIssueActiveTask(null); - }, 300); + }} agents={agents} issue={issueDetail?.issue ?? null} @@ -1353,46 +1186,8 @@ export function AgentChatView({ traceTasks={issueTraceTasks} slug={slug} workspaceId={workspaceId} - onUpdate={async (issueId, patch) => { - try { - const updated = await updateIssue(workspaceId, issueId, patch); - setIssueDetail((prev) => - prev && prev.issue.id === issueId - ? { - ...prev, - issue: { - ...prev.issue, - ...patch, - updated_at: updated.updated_at, - }, - } - : prev, - ); - } catch (err) { - toast.error( - err instanceof Error ? err.message : "Failed to update issue", - ); - } - }} - onStatusChange={async (issueId, status) => { - try { - await updateIssue(workspaceId, issueId, { - status: status as Issue["status"], - }); - setIssueDetail((prev) => - prev && prev.issue.id === issueId - ? { - ...prev, - issue: { ...prev.issue, status: status as Issue["status"] }, - } - : prev, - ); - } catch (err) { - toast.error( - err instanceof Error ? err.message : "Failed to update status", - ); - } - }} + onUpdate={updateSelectedIssue} + onStatusChange={(issueId, status) => updateSelectedIssue(issueId, { status: status as Issue["status"] })} onCommented={() => { if (selectedIssueId) openIssue(selectedIssueId); }} diff --git a/src/web/src/components/agent-chat/artifact-sheet.tsx b/src/web/src/components/agent-chat/artifact-sheet.tsx index 44dddbfab..ddf791d4a 100644 --- a/src/web/src/components/agent-chat/artifact-sheet.tsx +++ b/src/web/src/components/agent-chat/artifact-sheet.tsx @@ -1,7 +1,8 @@ "use client"; import { FileDownloadButton } from "@/components/file-download-button" -import React, { useState, useEffect } from "react"; +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useEffect, useRef } from "react"; import { useSheetResize, SheetResizeHandle } from "@/components/ui/sheet-resize-handle"; import { Sheet, @@ -37,7 +38,9 @@ const MAX_WIDTH_RATIO = 0.8; const DEFAULT_WIDTH = 448; export function ArtifactSheet({ open, onOpenChange, artifacts, workspaceId, initialArtifact = null, versionMap, duplicateFilenames }: ArtifactSheetProps) { - const [selectedArtifact, setSelectedArtifact] = useState(null); + const [selectedId, setSelectedId] = useAtom(useCreateAtom(null)); + const selectedArtifact = artifacts.find((row) => row.id === selectedId) ?? (initialArtifact?.id === selectedId ? initialArtifact : null); + const closeTimer = useRef | null>(null); const { width, onPointerDown, onPointerMove, onPointerUp } = useSheetResize({ defaultWidth: DEFAULT_WIDTH, minWidth: MIN_WIDTH, @@ -45,16 +48,17 @@ export function ArtifactSheet({ open, onOpenChange, artifacts, workspaceId, init }); useEffect(() => { - if (open && initialArtifact) { - setSelectedArtifact(initialArtifact); - } - }, [open, initialArtifact]); + if (open && closeTimer.current !== null) { clearTimeout(closeTimer.current); closeTimer.current = null; } + if (open && initialArtifact) setSelectedId(initialArtifact.id); + }, [open, initialArtifact, setSelectedId]); + + useEffect(() => () => { if (closeTimer.current !== null) clearTimeout(closeTimer.current); }, []); const handleOpenChange = (v: boolean) => { onOpenChange(v); if (!v) { // Defer clearing selected artifact until sheet closing animation completes - setTimeout(() => setSelectedArtifact(null), 300); + closeTimer.current = setTimeout(() => { closeTimer.current = null; setSelectedId(null); }, 300); } }; @@ -129,7 +133,7 @@ export function ArtifactSheet({ open, onOpenChange, artifacts, workspaceId, init {artifacts.map((a) => ( + )} ) : ( diff --git a/src/web/src/components/agent-form-fields.tsx b/src/web/src/components/agent-form-fields.tsx index 9f8277260..f1fc8884f 100644 --- a/src/web/src/components/agent-form-fields.tsx +++ b/src/web/src/components/agent-form-fields.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useEffect } from "react"; +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -27,22 +28,13 @@ import { isValidHandle, isPublic, isPrivate } from "@alook/shared"; import type { AgentRuntime as Runtime } from "@alook/shared"; import { cn } from "@/lib/utils"; import { InfoIcon, XIcon, Dices, ChevronDown } from "lucide-react"; -import { useWorkspace } from "@/contexts/workspace-context"; -import { - listWhitelist, - addWhitelistEmail, - removeWhitelistEmail, - listAgentAccess, - grantAgentAccess, - revokeAgentAccess, - listMembers, - listAgents, - updateAgent as updateAgentApi, - type WhitelistEntry, - type AgentAccessEntry, - type MemberEntry, -} from "@/lib/api"; -import { ApiError } from "@/lib/errors"; +import { useWorkspaceOwner, captureWorkspaceOwner } from "@/contexts/workspace-context"; +import { useAgentContext } from "@/contexts/agent-context"; +import { useWorkspaceViewSource } from "@/hooks/workspace/use-workspace-view-source"; +import { agentWhitelistOptions, agentAccessOptions, workspaceMembersOptions } from "@/hooks/workspace/settings-query-options"; +import { useAgentPermissionCommand, usePendingAgentPermissions } from "@/hooks/workspace/use-agent-permission-command"; +import { type AgentAccessEntry } from "@/lib/api"; +import { isAbortError } from "@/lib/errors"; import { toast } from "sonner"; import { Switch } from "@/components/ui/switch"; import { AutoResizeTextarea } from "@/components/ui/auto-resize-textarea"; @@ -270,7 +262,7 @@ export function GeneralFields({ // --- Advanced Section (collapsible) --- function AdvancedSection({ children }: { children: React.ReactNode }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useAtom(useCreateAtom(false)); return (
- diff --git a/src/web/src/components/community/bots/edit-bot-sheet.dom.test.ts b/src/web/src/components/community/bots/edit-bot-sheet.dom.test.ts index 17f8cf4f2..2d8bc37e7 100644 --- a/src/web/src/components/community/bots/edit-bot-sheet.dom.test.ts +++ b/src/web/src/components/community/bots/edit-bot-sheet.dom.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import React from "react" -import { act, fireEvent, render } from "@/test/react-dom-harness" +import { act, fireEvent } from "@/test/react-dom-harness" +import { renderCommunity as render } from "@/test/community-owner-harness" import type { BotSummary } from "@/hooks/community/use-bots" // Online-only UI contract (Ruthann #1274/#1280 / Shelly gate): diff --git a/src/web/src/components/community/bots/edit-bot-sheet.tsx b/src/web/src/components/community/bots/edit-bot-sheet.tsx index 4ecbcc80a..804fb4fe9 100644 --- a/src/web/src/components/community/bots/edit-bot-sheet.tsx +++ b/src/web/src/components/community/bots/edit-bot-sheet.tsx @@ -1,6 +1,12 @@ "use client" -import { useEffect, useMemo, useRef, useState } from "react" +import { createStore, useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useQueryClient } from "@tanstack/react-query" +import { communityKeys } from "@/lib/query-keys" +import { isAbortError } from "@/lib/errors" + +import { useEffect, useMemo } from "react" +import { useCommunityViewSource } from "@/hooks/community/use-community-view-source" import { toast } from "sonner" import { toastApiError } from "@/lib/api/client" import { CommunitySheet } from "@/components/community/shell/community-sheet" @@ -50,18 +56,18 @@ export function EditBotSheet({ open: boolean onOpenChange: (open: boolean) => void }) { - const [name, setName] = useState(bot?.name ?? "") - const [description, setDescription] = useState(bot?.description ?? "") - const [model, setModel] = useState(bot?.modelName ?? null) - const [reasoningEffort, setReasoningEffort] = useState( - bot?.reasoningEffort ?? null, - ) - const [runtime, setRuntime] = useState(bot?.runtime ?? "") - const [confirmProviderSwitch, setConfirmProviderSwitch] = useState(false) - const [nameError, setNameError] = useState(undefined) - const [avatarDraft, setAvatarDraft] = useState(() => - bot ? draftFromBot(bot) : { kind: "procedural", image: serializeBeamSeed("initial") }, - ) + const source = useCommunityViewSource("edit-bot:" + (bot?.id ?? "none"), open && !!bot) + const client = useQueryClient() + const handleOpenChange = (next: boolean) => { if (!next) source.retire(); onOpenChange(next) } + const [name, setName] = useAtom(useCreateAtom(bot?.name ?? "")) + const [description, setDescription] = useAtom(useCreateAtom(bot?.description ?? "")) + const [model, setModel] = useAtom(useCreateAtom(bot?.modelName ?? null)) + const [reasoningEffort, setReasoningEffort] = useAtom(useCreateAtom(bot?.reasoningEffort ?? null)) + const [runtime, setRuntime] = useAtom(useCreateAtom(bot?.runtime ?? "")) + const [confirmProviderSwitch, setConfirmProviderSwitch] = useAtom(useCreateAtom(false)) + const [nameError, setNameError] = useAtom(useCreateAtom(undefined)) +const [avatarDraft, setAvatarDraft] = useAtom(useCreateAtom(((): AvatarDraft => + bot ? draftFromBot(bot) : { kind: "procedural", image: serializeBeamSeed("initial") })())) const update = useUpdateBot() const uploadBotAvatar = useUploadBotAvatar() const { machines } = useMachines() @@ -78,11 +84,11 @@ export function EditBotSheet({ // (keyed by id, not by every `bot` reference change — the parent's // `editingBot` never resets to null, so this only re-fires when the user // actually picks a different bot to edit). - const syncedForRef = useRef(null) + const initialization = useMemo(() => ({ scope: [source.signal], store: createStore(false) }), [source.signal]).store useEffect(() => { if (!open || !bot) return - if (syncedForRef.current === bot.id) return - syncedForRef.current = bot.id + if (initialization.get()) return + initialization.setState(() => true) setName(bot.name) setDescription(bot.description ?? "") setModel(bot.modelName ?? null) @@ -91,7 +97,7 @@ export function EditBotSheet({ setAvatarDraft(draftFromBot(bot)) setNameError(undefined) setConfirmProviderSwitch(false) - }, [open, bot]) + }, [open, bot, initialization, setName, setDescription, setModel, setReasoningEffort, setRuntime, setAvatarDraft, setNameError, setConfirmProviderSwitch]) function updateName(value: string) { setName(value) @@ -104,7 +110,9 @@ export function EditBotSheet({ } async function performSubmit() { - if (!bot) return + if (!bot || ["update-command", "avatar-command"].some((kind) => client.isMutating({ mutationKey: [...communityKeys.bots(), kind], exact: true, predicate: (mutation) => (mutation.state.variables as { input?: { assertActive?: { signal: AbortSignal } } }).input?.assertActive?.signal === source.signal }) > 0)) return + const assert = source.capture() + assert() if (!name.trim()) { setNameError("Name is required") return @@ -122,9 +130,10 @@ export function EditBotSheet({ // name/description update resolves, inside the same try block, so a // failed field update never triggers an upload. const result = await update.mutateAsync({ + assertActive: assert, id: bot.id, name: name.trim(), - description: description.trim() || undefined, + description: description.trim(), image: avatarDraft.kind === "procedural" ? avatarDraft.image : undefined, // Only send `model` when it actually changed, so an unrelated edit // never triggers a stop-and-rewake. @@ -132,13 +141,17 @@ export function EditBotSheet({ ...(runtimeChanged ? { runtime } : {}), ...(reasoningEffortChanged ? { reasoningEffort } : {}), }) + assert() let avatarFailed = false if (avatarDraft.kind === "photo" && avatarDraft.file) { try { - await uploadBotAvatar.mutateAsync({ botId: bot.id, file: avatarDraft.file }) + await uploadBotAvatar.mutateAsync({ botId: bot.id, file: avatarDraft.file, assertActive: assert }) + assert() } catch (e) { + try { assert() } catch { return } + if (isAbortError(e)) return avatarFailed = true - toastApiError(e, "Bot updated, but the avatar photo failed to upload") + toastApiError(e, "Bot updated, but the avatar photo failed to upload", assert) } } if (!avatarFailed) { @@ -159,9 +172,11 @@ export function EditBotSheet({ toast.success("Bot updated") } } - onOpenChange(false) + assert() + handleOpenChange(false) } catch (e) { - toastApiError(e, "Update failed") + if (isAbortError(e)) return + toastApiError(e, "Update failed", assert) } } @@ -182,7 +197,7 @@ export function EditBotSheet({ <> Cancel - diff --git a/src/web/src/components/community/bots/model-field.tsx b/src/web/src/components/community/bots/model-field.tsx index 594c5bfdc..845a69b7b 100644 --- a/src/web/src/components/community/bots/model-field.tsx +++ b/src/web/src/components/community/bots/model-field.tsx @@ -1,6 +1,7 @@ "use client" -import { useEffect, useMemo, useState } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useEffect, useMemo } from "react" import { modelSelectState, modelNameFromSelect, @@ -50,9 +51,9 @@ export function ModelField({ const modelIds = useMemo(() => models.map((model) => model.id), [models]) const seed = useMemo(() => modelSelectState(modelIds, value), [modelIds, value]) - const [selectValue, setSelectValue] = useState(seed.selectValue) - const [customName, setCustomName] = useState(seed.customName) - const [filterQuery, setFilterQuery] = useState("") + const [selectValue, setSelectValue] = useAtom(useCreateAtom(seed.selectValue)) + const [customName, setCustomName] = useAtom(useCreateAtom(seed.customName)) + const [filterQuery, setFilterQuery] = useAtom(useCreateAtom("")) // Re-seed whenever the selected machine/runtime snapshot or stored value // changes. This is required even when the stored string is unchanged: a @@ -61,7 +62,7 @@ export function ModelField({ setSelectValue(seed.selectValue) setCustomName(seed.customName) setFilterQuery("") - }, [seed]) + }, [seed, setCustomName, setFilterQuery, setSelectValue]) const isCustom = selectValue === MODEL_SELECT_CUSTOM const defaultLabel = runtime ? `Default (${runtime.id}'s own default)` : "Default" diff --git a/src/web/src/components/community/channels/channel-header.title-dialog.dom.test.ts b/src/web/src/components/community/channels/channel-header.title-dialog.dom.test.ts index c8cdcf69a..4d29670df 100644 --- a/src/web/src/components/community/channels/channel-header.title-dialog.dom.test.ts +++ b/src/web/src/components/community/channels/channel-header.title-dialog.dom.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest" import React from "react" -import { fireEvent, render, screen } from "@/test/react-dom-harness" +import { act, fireEvent, render, screen, waitFor } from "@/test/react-dom-harness" vi.mock("../settings/create-dialog-shell", async () => { const ReactModule = await import("react") @@ -79,14 +79,13 @@ describe("ChannelHeader — forum title dialog", () => { expect(onRename).toHaveBeenCalledOnce() expect(screen.getByRole("button", { name: "Save" })).toBeDisabled() - rejectRename(new Error("save failed")) - await vi.waitFor(() => expect(screen.getByRole("button", { name: "Save" })).toBeEnabled()) + await act(async () => { rejectRename(new Error("save failed")) }) + await waitFor(() => expect(screen.getByRole("button", { name: "Save" })).toBeEnabled()) expect(screen.getByRole("textbox", { name: "Post title" })).toHaveValue("New title") fireEvent.click(screen.getByRole("button", { name: "Save" })) expect(onRename).toHaveBeenCalledTimes(2) - resolveRename() - await Promise.resolve() + await act(async () => { resolveRename() }) renderer.unmount() }) diff --git a/src/web/src/components/community/channels/channel-header.tsx b/src/web/src/components/community/channels/channel-header.tsx index 89d5f245c..ed929ed4b 100644 --- a/src/web/src/components/community/channels/channel-header.tsx +++ b/src/web/src/components/community/channels/channel-header.tsx @@ -1,6 +1,7 @@ "use client" -import { useEffect, useRef, useState, type ReactNode } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useEffect, useRef, type ReactNode } from "react" import type { LucideIcon } from "lucide-react" import { Bell, BellOff, Pin, Users, MessagesSquare, ChevronLeft, Check, Pencil, MoreHorizontal } from "lucide-react" import { NOTIF_LEVELS, USE_SERVER_DEFAULT, type NotifLevel } from "@alook/shared" @@ -268,16 +269,16 @@ function HeaderRename({ label, onRename, titleMode = false, className = "" }: { titleMode?: boolean className?: string }) { - const [open, setOpen] = useState(false) - const [draft, setDraft] = useState(label) - const [saving, setSaving] = useState(false) + const [open, setOpen] = useAtom(useCreateAtom(false)) + const [draft, setDraft] = useAtom(useCreateAtom(label)) + const [saving, setSaving] = useAtom(useCreateAtom(false)) const savingRef = useRef(false) // Keep the draft mirror in sync with the upstream label whenever the dialog // is closed — covers WS-driven renames and channel switches (the parent // component is reused across channelId changes). useEffect(() => { if (!open) setDraft(label) - }, [label, open]) + }, [label, open, setDraft]) const draftPreview = previewSlug(draft) const validDraft = titleMode ? Boolean(draft.trim()) : Boolean(draftPreview.slug) const save = async () => { diff --git a/src/web/src/components/community/channels/channel-route.text-scroll-target.dom.test.ts b/src/web/src/components/community/channels/channel-route.text-scroll-target.dom.test.ts index 2ec621abe..ac91b7f6f 100644 --- a/src/web/src/components/community/channels/channel-route.text-scroll-target.dom.test.ts +++ b/src/web/src/components/community/channels/channel-route.text-scroll-target.dom.test.ts @@ -1,5 +1,6 @@ import React from "react" -import { act, fireEvent, render, screen } from "@/test/react-dom-harness" +import { act, fireEvent, screen } from "@/test/react-dom-harness" +import { renderCommunity as render } from "@/test/community-owner-harness" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { ChannelRoute } from "./channel-route" import { ForumChannelSurface } from "./forum-channel-surface" @@ -89,8 +90,7 @@ vi.mock("next/navigation", () => ({ usePathname: () => "/c/channels/server_1/channel_1", useSearchParams: () => new URLSearchParams(mockSearchParams.value), })) -vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({}) })) -vi.mock("@/lib/community/conversation-navigation-proof", () => ({ +vi.mock("@/lib/community/conversation-navigation-proof", async (importOriginal) => ({ ...await importOriginal(), useConversationNavigationGate: () => ({ required: false, allowed: mockNavigationGate.allowed }), })) vi.mock("sonner", () => ({ toast: vi.fn() })) @@ -143,13 +143,13 @@ vi.mock("@/components/community/shell/community-panel", () => ({ CommunityPanel: vi.mock("@/components/community/messages/message-context-sheet", () => ({ MessageContextSheet: () => null })) vi.mock("@/components/community/messages/thread-opener", () => ({ ThreadOpener: () => null })) vi.mock("@/components/community/members/add-members-dialog", () => ({ AddMembersDialog: () => null })) -vi.mock("@alook/shared", () => ({ +vi.mock("@alook/shared", async (importOriginal) => ({ ...await importOriginal(), canManageServer: mockCanManageServer, devWsDoPort: () => 8789, isForum: () => false, deriveThreadName: () => "thread", USE_SERVER_DEFAULT: "default", -})) + })) vi.mock("@/lib/community/last-channel", () => ({ setLastChannel: (...args: unknown[]) => mockSetLastChannel(...args), clearLastChannel: (...args: unknown[]) => mockClearLastChannel(...args), @@ -160,7 +160,8 @@ vi.mock("@/lib/community/last-community-route", () => ({ mockCommitLastCommunityRoute(accountId, `/c/channels/${serverId}/${channelId}`) }, })) -vi.mock("@/stores/community", () => { +vi.mock("@/stores/community", async (importOriginal) => { + const actual = await importOriginal(); const state = { pendingReply: null, setPendingReply: vi.fn(), @@ -171,6 +172,7 @@ vi.mock("@/stores/community", () => { { getState: () => state }, ) return { + ...actual, useCommunityStore, useCurrentChannelId: () => mockCurrentChannelId.value, useUiHandlers: () => mockUiHandlers, @@ -259,7 +261,7 @@ vi.mock("@/hooks/community/use-channel-message-feed", () => ({ vi.mock("@/hooks/community/use-notification-settings", () => ({ useNotificationSettings: () => ({ channel: {} }), })) -vi.mock("@/stores/community/ws", () => ({ +vi.mock("@/stores/community/ws", async (importOriginal) => ({ ...await importOriginal(), useOnlineUserIds: () => new Set(), useCommunityWsStore: (selector: (state: { userStatuses: Map }) => unknown) => selector({ userStatuses: new Map() }), @@ -382,11 +384,11 @@ describe("ChannelRoute message surface ownership", () => { }) }) - it("forwards the route lifecycle and canonical child tuple to the opener gate", () => { + it("forwards the route lifecycle and canonical child tuple to the opener gate", async () => { configureThreadRoute() mockRouteModel.routeLifecycle = "pending" mockedUseChannelMessageFeed.mockReturnValue(feed()) - act(() => { + await act(async () => { render(React.createElement(ChannelRoute, { serverId: "server_1", serverParam: "server_1", @@ -406,7 +408,7 @@ describe("ChannelRoute message surface ownership", () => { it.each([ ["pending", true], ["ready", false], - ] as const)("keeps %s metadata on its known forum skeleton while access=%s", (routeLifecycle, accessAllowed) => { + ] as const)("keeps %s metadata on its known forum skeleton while access=%s", async (routeLifecycle, accessAllowed) => { Object.assign(mockRouteModel, { channel: { id: "channel_1", name: "cached-forum", type: "forum" }, isForum: true, @@ -417,7 +419,7 @@ describe("ChannelRoute message surface ownership", () => { mockedUseChannelMessageFeed.mockReturnValue(feed()) let renderer!: ReturnType - act(() => { + await act(async () => { renderer = render(React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1", @@ -523,14 +525,14 @@ describe("ChannelRoute message surface ownership", () => { await act(async () => renderer.unmount()) }) - it("keeps an authoritative split route structural until the effect-owned pointer commits", () => { + it("keeps an authoritative split route structural until the effect-owned pointer commits", async () => { configureThreadRoute() mockSplitMode.value = "split" mockCurrentChannelId.value = null mockedUseChannelMessageFeed.mockReturnValue(feed()) let renderer!: ReturnType - act(() => { + await act(async () => { renderer = render(React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1", @@ -626,9 +628,9 @@ describe("ChannelRoute message surface ownership", () => { ) }) - it("commits and dismisses only a ready top-level channel for the active account", () => { + it("commits and dismisses only a ready top-level channel for the active account", async () => { mockedUseChannelMessageFeed.mockReturnValue(feed()) - act(() => { + await act(async () => { render(React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1", @@ -694,11 +696,11 @@ describe("ChannelRoute message surface ownership", () => { ["owner", true], ["admin", true], ["member", false], - ] as const)("maps the %s role to the live text-channel Pin capability", (role, allowed) => { + ] as const)("maps the %s role to the live text-channel Pin capability", async (role, allowed) => { mockMemberViewModel.myRole = role mockedUseChannelMessageFeed.mockReturnValue(feed()) - act(() => { + await act(async () => { render(React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1", @@ -711,11 +713,11 @@ describe("ChannelRoute message surface ownership", () => { else expect(onPin).toBeUndefined() }) - it("defers child msg cleanup until the handoff nonce has its own cleanup", () => { + it("defers child msg cleanup until the handoff nonce has its own cleanup", async () => { mockSearchParams.value = "inboxThreadOpener=nonce-1&msg=m_target&keep=1" mockedUseChannelMessageFeed.mockReturnValue(feed()) let renderer!: ReturnType - act(() => { + await act(async () => { renderer = render(React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1", @@ -736,14 +738,14 @@ describe("ChannelRoute message surface ownership", () => { ) }) - it("composes a wide child route as parent + thread with fullscreen and close actions", () => { + it("composes a wide child route as parent + thread with fullscreen and close actions", async () => { configureThreadRoute() mockSplitMode.value = "split" mockSearchParams.value = "keep=1" mockedUseChannelMessageFeed.mockReturnValue(feed()) let renderer!: ReturnType - act(() => { + await act(async () => { renderer = render(React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1", @@ -771,7 +773,7 @@ describe("ChannelRoute message surface ownership", () => { expect(mockUiHandlers.replacePath).toHaveBeenCalledWith("/c/channels/server_1/parent_1") }) - it("uses the same split parent contract for a forum post", () => { + it("uses the same split parent contract for a forum post", async () => { configureThreadRoute() mockSplitMode.value = "split" mockRouteModel.parent = { id: "parent_1", name: "questions", type: "forum" } @@ -781,7 +783,7 @@ describe("ChannelRoute message surface ownership", () => { }] mockedUseChannelMessageFeed.mockReturnValue(feed()) - act(() => { + await act(async () => { render(React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1", @@ -798,12 +800,12 @@ describe("ChannelRoute message surface ownership", () => { vi.clearAllMocks() }) - it("keeps the route anchor until MessageList reports a successful jump", () => { + it("keeps the route anchor until MessageList reports a successful jump", async () => { let surfaceFeed = feed({ messages: [{ id: "m_unrelated" }] }) mockedUseChannelMessageFeed.mockImplementation(() => surfaceFeed) let renderer: ReturnType - act(() => { + await act(async () => { renderer = render( React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1" }), ) @@ -831,12 +833,12 @@ describe("ChannelRoute message surface ownership", () => { expect(mockedMessageList.mock.calls.at(-1)?.[0].scrollToMessageId).toBeNull() }) - it("clears the route anchor only after the authoritative anchor request errors", () => { + it("clears the route anchor only after the authoritative anchor request errors", async () => { let surfaceFeed = feed({ messages: [{ id: "m_unrelated" }] }) mockedUseChannelMessageFeed.mockImplementation(() => surfaceFeed) let renderer: ReturnType - act(() => { + await act(async () => { renderer = render( React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1" }), ) @@ -852,14 +854,14 @@ describe("ChannelRoute message surface ownership", () => { expect(mockedMessageList.mock.calls.at(-1)?.[0].scrollToMessageId).toBeNull() }) - it("does not initialize an active message feed for a forum route", () => { + it("does not initialize an active message feed for a forum route", async () => { Object.assign(mockRouteModel, { channel: { id: "channel_1", name: "forum", type: "forum" }, isForum: true, }) mockedUseChannelMessageFeed.mockImplementation(() => feed()) - act(() => { + await act(async () => { render( React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1" }), ) @@ -874,11 +876,11 @@ describe("ChannelRoute message surface ownership", () => { }), undefined) }) - it("initializes only the child feed for a thread route", () => { + it("initializes only the child feed for a thread route", async () => { configureThreadRoute() mockedUseChannelMessageFeed.mockImplementation(() => feed({ anchorInCache: true })) - act(() => { + await act(async () => { render( React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1" }), ) @@ -895,12 +897,12 @@ describe("ChannelRoute message surface ownership", () => { ) }) - it("replaces directly to the verified parent from the child Back control", () => { + it("replaces directly to the verified parent from the child Back control", async () => { configureThreadRoute() mockBreakpoint.value = "mobile" mockedUseChannelMessageFeed.mockImplementation(() => feed({ anchorInCache: true })) - act(() => { + await act(async () => { render( React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1" }), ) @@ -913,11 +915,11 @@ describe("ChannelRoute message surface ownership", () => { expect(mockUiHandlers.goBackMobile).not.toHaveBeenCalled() }) - it("replaces directly to the canonical server root from a top-level channel", () => { + it("replaces directly to the canonical server root from a top-level channel", async () => { mockBreakpoint.value = "mobile" mockedUseChannelMessageFeed.mockImplementation(() => feed()) - act(() => { + await act(async () => { render( React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1" }), ) @@ -930,13 +932,13 @@ describe("ChannelRoute message surface ownership", () => { expect(mockUiHandlers.goBackMobile).not.toHaveBeenCalled() }) - it("keeps a child target through warm cache and 5000ms until MessageList consumes it", () => { + it("keeps a child target through warm cache and 5000ms until MessageList consumes it", async () => { configureThreadRoute() let childFeed = feed({ messages: [{ id: "m_unrelated" }], anchorInCache: true }) mockedUseChannelMessageFeed.mockImplementation(() => childFeed) let renderer: ReturnType - act(() => { + await act(async () => { renderer = render( React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1" }), ) @@ -955,13 +957,13 @@ describe("ChannelRoute message surface ownership", () => { expect(mockedMessageList.mock.calls.at(-1)?.[0].scrollToMessageId).toBeNull() }) - it("clears a missing child target only after the child feed errors", () => { + it("clears a missing child target only after the child feed errors", async () => { configureThreadRoute() let childFeed = feed({ messages: [{ id: "m_unrelated" }], anchorInCache: true }) mockedUseChannelMessageFeed.mockImplementation(() => childFeed) let renderer: ReturnType - act(() => { + await act(async () => { renderer = render( React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1" }), ) diff --git a/src/web/src/components/community/channels/channel-route.tsx b/src/web/src/components/community/channels/channel-route.tsx index 832668a4d..c174d09a5 100644 --- a/src/web/src/components/community/channels/channel-route.tsx +++ b/src/web/src/components/community/channels/channel-route.tsx @@ -1,6 +1,9 @@ "use client" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { getCommunityRuntime } from "@/stores/community/runtime" -import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react" + +import { useCallback, useEffect, useLayoutEffect, useMemo } from "react" import { useRouter, useSearchParams } from "next/navigation" import { toastApiError } from "@/lib/api/client" import { ChannelHeaderSkeleton, type ChannelNotifLevel } from "@/components/community/channels/channel-header" @@ -21,11 +24,7 @@ import { clearLastChannel } from "@/lib/community/last-channel" import { commitCommunityChannelRoute } from "@/lib/community/last-community-route" import { resolveChannelDisplayName } from "@/lib/community/channel-display-name" import { toChannelRefCandidate } from "@/lib/community/channel-ref-extension" -import { - useCommunityStore, - useCurrentChannelId, - useUiHandlers, -} from "@/stores/community" +import { useCurrentChannelId, useUiHandlers } from "@/stores/community" import { useCurrentUser } from "@/contexts/community/current-user" import { useChannelRouteModel } from "@/hooks/community/use-channel-route-model" import { useForumOpenerHint } from "@/hooks/community/use-forum-opener-hint" @@ -65,7 +64,7 @@ export function ChannelRoute({ serverParam, channelId }: { // re-reads this. The param is stripped from the URL right after (below) so a // refresh/back doesn't re-trigger the jump; this frozen copy still drives the // anchor + scroll for this mount. - const [jumpTargetId] = useState(() => searchParams.get("msg")) +const [jumpTargetId] = useAtom(useCreateAtom((() => searchParams.get("msg"))())) const queryClient = useQueryClient() const accessEpoch = useCommunityWsStore((state) => state.accessEpoch) const navigationGate = useConversationNavigationGate( @@ -87,10 +86,10 @@ export function ChannelRoute({ serverParam, channelId }: { isForumPostChild, isNotifyUnit, } = routeModel - const [topLevelRouteOwnership, setTopLevelRouteOwnership] = useState(() => ({ +const [topLevelRouteOwnership, setTopLevelRouteOwnership] = useAtom(useCreateAtom((() => ({ channelId, wasTopLevel: channelInServer !== null, - })) + }))())) const routeWasTopLevel = topLevelRouteOwnership.channelId === channelId && topLevelRouteOwnership.wasTopLevel useLayoutEffect(() => { @@ -103,7 +102,7 @@ export function ChannelRoute({ serverParam, channelId }: { } return ownership }) - }, [channelId, channelInServer]) + }, [channelId, channelInServer, setTopLevelRouteOwnership]) const forumPostOpener = useForumOpenerHint( serverId, currentChannelMeta?.parentMessageId, @@ -201,10 +200,10 @@ export function ChannelRoute({ serverParam, channelId }: { }, [channelId, jumpTargetId, router, searchParams, serverParam]) const enterThread = useCallback((id: string) => { - useCommunityStore.getState().uiHandlers.navigatePath?.( + getCommunityRuntime(queryClient).ui.get().uiHandlers.navigatePath?.( channelHref(serverParam, id), ) - }, [serverParam]) + }, [queryClient, serverParam]) const openThreadFullscreen = useCallback(() => { uiHandlers.cancelPendingNavigation?.() const params = new URLSearchParams(searchParams.toString()) diff --git a/src/web/src/components/community/channels/channel-sidebar-tree-owner.tsx b/src/web/src/components/community/channels/channel-sidebar-tree-owner.tsx index 6ed61949f..4abefff58 100644 --- a/src/web/src/components/community/channels/channel-sidebar-tree-owner.tsx +++ b/src/web/src/components/community/channels/channel-sidebar-tree-owner.tsx @@ -1,6 +1,8 @@ "use client" -import { useEffect, useState } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; + +import { useEffect } from "react" import type { Category } from "@/lib/community/models/navigation" import { ChannelSidebar, @@ -42,13 +44,13 @@ export function ChannelSidebarRevealBoundary({ trustedRestoredPrimary, ...scopeProps }: ChannelSidebarRevealBoundaryProps) { - const [revealed, setRevealed] = useState(primaryReady && trustedRestoredPrimary) + const [revealed, setRevealed] = useAtom(useCreateAtom(primaryReady && trustedRestoredPrimary)) useEffect(() => { if (primaryReady && (trustedRestoredPrimary || !forumProjectionMissing)) { setRevealed(true) } - }, [forumProjectionMissing, primaryReady, trustedRestoredPrimary]) + }, [forumProjectionMissing, primaryReady, setRevealed, trustedRestoredPrimary]) return ( (null) + const [dialog, setDialog] = useAtom(useCreateAtom(null)) const withMute = (ch: Channel): Channel => mutedChannels && ch.id in mutedChannels ? { ...ch, muted: mutedChannels[ch.id] } : ch const hasActiveSidebarThread = !!activeThreadId && Object.values(forumThreadsByParent) .some((threads) => threads.some((thread) => thread.id === activeThreadId)) @@ -247,7 +248,7 @@ export const ChannelSidebar = memo(function ChannelSidebar({ canReorder={isAdmin} onClick={() => setActiveChannel(ch.id)} onEdit={isAdmin ? () => setDialog({ kind: "edit-channel", id: ch.id, categoryId: noneCatId, name: ch.name, type: ch.type ?? "text" }) : undefined} - onDelete={isAdmin ? () => { removeChannel(ch.id); onDeleteChannel?.(ch.id) } : undefined} + onDelete={isAdmin ? () => onDeleteChannel?.(ch.id) : undefined} /> {childRows(ch.id)} @@ -287,7 +288,7 @@ export const ChannelSidebar = memo(function ChannelSidebar({ canReorder={isAdmin} onClick={() => setActiveChannel(ch.id)} onEdit={canManageChannel ? () => setDialog({ kind: "edit-channel", id: ch.id, categoryId: id, name: ch.name, type: ch.type ?? "text" }) : undefined} - onDelete={canManageChannel ? () => { removeChannel(ch.id); onDeleteChannel?.(ch.id) } : undefined} + onDelete={canManageChannel ? () => onDeleteChannel?.(ch.id) : undefined} onManageMembers={(catPrivate[id] && canManageChannel) ? () => setDialog({ kind: "manage-members", channelId: ch.id, channelName: ch.name }) : undefined} /> {childRows(ch.id)} @@ -401,7 +402,6 @@ export const ChannelSidebar = memo(function ChannelSidebar({ isPrivate={!!catPrivate[dialog.categoryId]} onClose={() => setDialog(null)} onSave={(nextName) => { - renameCategory(dialog.categoryId, nextName) onUpdateCategory?.(dialog.categoryId, { name: nextName }) }} /> diff --git a/src/web/src/components/community/channels/dm-route.dom.test.ts b/src/web/src/components/community/channels/dm-route.dom.test.ts index 7cabfece3..222b71303 100644 --- a/src/web/src/components/community/channels/dm-route.dom.test.ts +++ b/src/web/src/components/community/channels/dm-route.dom.test.ts @@ -1,8 +1,21 @@ -import React from "react" +import React, { useLayoutEffect, type PropsWithChildren } from "react" +import { useCommunityRuntime } from "@/stores/community/runtime" import { beforeEach, describe, expect, it, vi } from "vitest" -import { render, screen, setupUser } from "@/test/react-dom-harness" +import { screen, setupUser } from "@/test/react-dom-harness" +import { renderCommunity } from "@/test/community-owner-harness" import { DmRoute } from "./dm-route" +function RouteUi({ children }: PropsWithChildren) { + const runtime = useCommunityRuntime() + useLayoutEffect(() => { + runtime.ui.actions.registerUiHandlers({ cancelPendingNavigation: mocks.cancel }) + }, [runtime]) + return children +} +function render(node: React.ReactNode) { + return renderCommunity(node, { wrapper: RouteUi }) +} + const mocks = vi.hoisted(() => ({ verification: { status: "pending", retrying: false, retry: vi.fn() }, breakpoint: "desktop", @@ -14,9 +27,8 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: mocks.replace } vi.mock("@/contexts/community/current-user", () => ({ useCurrentUser: () => ({ id: "viewer" }) })) vi.mock("@/hooks/use-mobile", () => ({ useBreakpoint: () => mocks.breakpoint })) vi.mock("@/hooks/community/use-dm-route-verification", () => ({ useDmRouteVerification: () => mocks.verification })) -vi.mock("@/stores/community", () => ({ useCommunityStore: { getState: () => ({ uiHandlers: { cancelPendingNavigation: mocks.cancel } }) } })) -vi.mock("@/lib/community-db/projections", () => ({ useOptionalCommunityDbRegistry: () => ({}) })) -vi.mock("@/lib/community-db/sync", () => ({ purgeCommunityChannel: mocks.purge })) +vi.mock("@/stores/community", async (importOriginal) => ({ ...await importOriginal(), useCommunityStore: { getState: () => ({ uiHandlers: { cancelPendingNavigation: mocks.cancel } }) } })) +vi.mock("@/lib/community-db/sync", async (importOriginal) => ({ ...await importOriginal(), purgeCommunityChannel: mocks.purge })) vi.mock("@/lib/community/last-me-location", () => ({ ME_ROOT: "/c/me", getLastMeLeaf: () => mocks.last, clearLastMeLocation: mocks.clear })) vi.mock("@/lib/community/last-community-route", () => ({ COMMUNITY_COLD_ENTRY_FALLBACK: "/c/me/machines", @@ -66,7 +78,7 @@ describe("DM target main owns identity, retry and fallback", () => { mocks.verification.status = "missing" mocks.cold = cold render(React.createElement(DmRoute, { dmId: "dm-a" })) - expect(mocks.purge).toHaveBeenCalledExactlyOnceWith({}, "dm-a") + expect(mocks.purge).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ accountId: "viewer" }), "dm-a") expect(mocks.clear).toHaveBeenCalledOnce() expect(mocks.cancel).toHaveBeenCalledOnce() expect(mocks.consume).toHaveBeenCalledExactlyOnceWith("viewer", "/c/me/dm-a") diff --git a/src/web/src/components/community/channels/dm-route.tsx b/src/web/src/components/community/channels/dm-route.tsx index af236a9b4..f5e4880a3 100644 --- a/src/web/src/components/community/channels/dm-route.tsx +++ b/src/web/src/components/community/channels/dm-route.tsx @@ -8,7 +8,7 @@ import { DmLoadingFrame } from "./dm-loading-frame" import { useDmRouteVerification } from "@/hooks/community/use-dm-route-verification" import { useBreakpoint } from "@/hooks/use-mobile" import { useCurrentUser } from "@/contexts/community/current-user" -import { useCommunityStore } from "@/stores/community" +import { useCommunityRuntime } from "@/stores/community/runtime" import { useOptionalCommunityDbRegistry } from "@/lib/community-db/projections" import { purgeCommunityChannel } from "@/lib/community-db/sync" import { clearLastMeLocation, getLastMeLeaf, ME_ROOT } from "@/lib/community/last-me-location" @@ -18,6 +18,7 @@ import { } from "@/lib/community/last-community-route" export function DmRoute({ dmId }: { dmId: string }) { + const runtime = useCommunityRuntime() const router = useRouter() const currentUser = useCurrentUser() const breakpoint = useBreakpoint() @@ -35,9 +36,9 @@ export function DmRoute({ dmId }: { dmId: string }) { : ME_ROOT if (registry) purgeCommunityChannel(registry, dmId) if (getLastMeLeaf() === dmId) clearLastMeLocation() - useCommunityStore.getState().uiHandlers.cancelPendingNavigation?.() + runtime.ui.get().uiHandlers.cancelPendingNavigation?.() router.replace(destination) - }, [currentUser.id, dmId, registry, router, verification.status]) + }, [currentUser.id, dmId, registry, router, runtime, verification.status]) if (verification.status === "error") { return (initialSeq) + const [contextSheetSeq, setContextSheetSeq] = useAtom(useCreateAtom(initialSeq)) // DM composer has no "current server" — flatten every member server's // channels into one cross-server candidate list so a `/`-ref can be // dropped into a DM (see plan community-channel-ref.md §6). - const [channelRefDirectoryEnabled, setChannelRefDirectoryEnabled] = useState(false) + const [channelRefDirectoryEnabled, setChannelRefDirectoryEnabled] = useAtom(useCreateAtom(false)) const { directory: channelRefDirectory, isResolved: channelRefDirectoryResolved, @@ -216,11 +217,7 @@ export function DmView({ dmId }: { dmId: string }) { return } if (channelRefDirectoryError) void refetchChannelRefDirectory() - }, [ - channelRefDirectoryEnabled, - channelRefDirectoryError, - refetchChannelRefDirectory, - ]) + }, [channelRefDirectoryEnabled, channelRefDirectoryError, refetchChannelRefDirectory, setChannelRefDirectoryEnabled]) // Anchor of the "New" divider: the first non-self message after // `lastReadMessageId` inside the currently-loaded window. Mirrors the // channel-view logic exactly — see channel page for why we skip past @@ -293,26 +290,26 @@ export function DmView({ dmId }: { dmId: string }) { // pill via the `jumpToSeq` UI-handler. The DM view has no in-place scroll // target prop (unlike the channel page), so always open the context sheet, // which resolves seq→id and shows the message with surrounding context. - const jumpToSeq = useCallback((seq: number) => setContextSheetSeq(seq), []) + const jumpToSeq = useCallback((seq: number) => setContextSheetSeq(seq), [setContextSheetSeq]) useEffect(() => { - useCommunityStore.getState().registerUiHandlers({ jumpToSeq }) - return () => useCommunityStore.getState().registerUiHandlers({ jumpToSeq: undefined }) - }, [jumpToSeq]) + communityRuntime.ui.actions.registerUiHandlers({ jumpToSeq }) + return () => communityRuntime.ui.actions.registerUiHandlers({ jumpToSeq: undefined }) + }, [communityRuntime, jumpToSeq]) useEffect(() => { - useCommunityStore.getState().setCurrentChannelId(dmId) - communityWsSubscribe({ dmConversationId: dmId }) + communityRuntime.ui.actions.setCurrentChannelId(dmId) + communityWsSubscribe(communityRuntime, { dmConversationId: dmId }) return () => { - useCommunityStore.getState().setCurrentChannelId(null) - communityWsUnsubscribe() + communityRuntime.ui.actions.setCurrentChannelId(null) + communityWsUnsubscribe(communityRuntime) } - }, [dmId]) + }, [communityRuntime, dmId]) - const [replyTo, setReplyTo] = useState<{ id: string; authorName: string; text: string } | null>(null) + const [replyTo, setReplyTo] = useAtom(useCreateAtom<{ id: string; authorName: string; text: string } | null>(null)) useEffect(() => { setReplyTo(null) - }, [dmId]) + }, [communityRuntime, dmId, setReplyTo]) const openProfile: OpenProfile = (name, e, discriminator, userId) => { @@ -337,11 +334,11 @@ export function DmView({ dmId }: { dmId: string }) { }, [friends, currentUser.id, currentUser.name, dm]) const advanceOnboardingAfterSend = useCallback(() => { - const state = readCommunityOnboardingState() + const state = readCommunityOnboardingState(communityRuntime) if (state?.status === "active" && state.stage === "dm" && state.dmId === dmId) { - advanceCommunityOnboarding("dm", "server") + advanceCommunityOnboarding(communityRuntime, "dm", "server") } - }, [dmId]) + }, [communityRuntime, dmId]) const messageActions = useMemo(() => ({ onToggleReaction: (id: string, emoji: string) => @@ -375,7 +372,7 @@ export function DmView({ dmId }: { dmId: string }) { onDismiss: (id: string) => { const m = messages.find((x) => x.id === id) if (!m?.clientNonce) return - useMessageStreamStore.getState().dispatch( + communityRuntime.messageStream.actions.dispatch( { kind: "dm", id: dmId }, { type: "dismissFailed", nonce: m.clientNonce }, ) @@ -386,7 +383,7 @@ export function DmView({ dmId }: { dmId: string }) { onPreviewAttachment: (attachment: FileAttachment) => { uiHandlers.previewAttachment?.(attachment) }, - }), [toggleReaction, addReaction, toggleMark, dmId, currentUser.id, messages, retryDmMessage, uiHandlers, advanceOnboardingAfterSend]) + }), [toggleReaction, dmId, currentUser.id, addReaction, messages, setReplyTo, toggleMark, retryDmMessage, advanceOnboardingAfterSend, communityRuntime.messageStream.actions, uiHandlers]) // DM endpoint ignores mentionType. Replies are supported — the backend // persists replyToId for DMs too. @@ -406,12 +403,12 @@ export function DmView({ dmId }: { dmId: string }) { void receipt.committed.then((result) => { if (result.ok) advanceOnboardingAfterSend() }) - communityWsEndTyping({ channelId: dmId }) + communityWsEndTyping(communityRuntime, { channelId: dmId }) setReplyTo(null) return true } - const handleTyping = () => { communityWsSendTyping({ channelId: dmId }) } + const handleTyping = () => { communityWsSendTyping(communityRuntime, { channelId: dmId }) } // The DM row owns header/composer identity. Until it is known, keep the // complete DM frame pending; once known, read-state and message fetching diff --git a/src/web/src/components/community/channels/forum-channel-surface.dom.test.ts b/src/web/src/components/community/channels/forum-channel-surface.dom.test.ts index 9729a6acc..29f1bd6ae 100644 --- a/src/web/src/components/community/channels/forum-channel-surface.dom.test.ts +++ b/src/web/src/components/community/channels/forum-channel-surface.dom.test.ts @@ -1,5 +1,6 @@ import React from "react" -import { act, render } from "@/test/react-dom-harness" +import { act } from "@/test/react-dom-harness" +import { renderCommunity as render } from "@/test/community-owner-harness" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { ChannelHeader } from "./channel-header" import { CommunityPanel } from "../shell/community-panel" @@ -144,6 +145,7 @@ describe("ForumChannelSurface ownership", () => { forumProps.onDeletePost?.(ownPost) }) expect(mocks.updatePostTags).toHaveBeenCalledWith({ + assertActive: expect.any(Function), serverId: "srv_1", forumChannelId: "forum_1", threadId: "post_1", @@ -152,6 +154,7 @@ describe("ForumChannelSurface ownership", () => { tags: ["shipped"], }, expect.any(Object)) expect(mocks.deleteForumThread).toHaveBeenCalledWith({ + assertActive: expect.any(Function), forumChannelId: "forum_1", serverId: "srv_1", threadId: "post_1", diff --git a/src/web/src/components/community/channels/forum-channel-surface.tsx b/src/web/src/components/community/channels/forum-channel-surface.tsx index 03cd34230..2177ec5dc 100644 --- a/src/web/src/components/community/channels/forum-channel-surface.tsx +++ b/src/web/src/components/community/channels/forum-channel-surface.tsx @@ -1,7 +1,10 @@ "use client" -import { useCallback, useEffect, useState, type ReactNode } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; + +import { useCallback, useEffect, type ReactNode } from "react" import { toastApiError } from "@/lib/api/client" +import { useCommunityViewSource } from "@/hooks/community/use-community-view-source" import { canManageServer, type CommunityRole as Role } from "@alook/shared" import type { OpenProfile } from "@/components/community/social/profile-types" import type { RightPanel } from "@/components/community/shell/panel-types" @@ -52,10 +55,11 @@ export function ForumChannelSurface({ onOpenProfile: OpenProfile embedded?: boolean }) { - const [panelState, setPanelState] = useState<{ channelId: string; panel: RightPanel }>({ + const source = useCommunityViewSource(`forum-surface:${serverId}:${channelId}`) + const [panelState, setPanelState] = useAtom(useCreateAtom<{ channelId: string; panel: RightPanel }>({ channelId, panel: null, - }) + })) const rightPanel = panelState.channelId === channelId ? panelState.panel : null const createForumThreadMut = useCreateForumThread() const updatePostTagsMut = useUpdatePostTags() @@ -67,23 +71,26 @@ export function ForumChannelSurface({ ? current : { channelId, panel: null }, ) - }, [channelId]) + }, [channelId, setPanelState]) const togglePanel = useCallback((panel: Exclude) => { setPanelState((current) => ({ channelId, panel: current.channelId === channelId && current.panel === panel ? null : panel, })) - }, [channelId]) + }, [channelId, setPanelState]) const createForumThread = useCallback(async (post: NewForumThread) => { + post.assertActive?.() const data = await createForumThreadMut.mutateAsync({ nonce: post.nonce, + assertActive: post.assertActive, channelId, name: post.name, content: post.content, attachments: post.attachments, mentionType: post.mentionType, }) + post.assertActive?.() onOpenPost(data.threadId) }, [channelId, createForumThreadMut, onOpenPost]) const canManage = canManageServer(viewerRole) @@ -115,8 +122,10 @@ export function ForumChannelSurface({ canEditPostTags={(post) => canManage || post.authorId === viewer.id} savingTagsFor={updatePostTagsMut.isPending ? updatePostTagsMut.variables?.threadId ?? null : null} onEditPostTags={async (post, tags) => { + const assertActive = source.capture() await updatePostTagsMut.mutateAsync( { + assertActive, serverId, forumChannelId: channelId, threadId: post.id, @@ -124,20 +133,22 @@ export function ForumChannelSurface({ previousTags: post.tags ?? [], tags, }, - { onError: (error) => toastApiError(error, "Failed to update tags") }, + { onError: (error) => toastApiError(error, "Failed to update tags", assertActive) }, ) }} canDeletePost={(post) => canManage || post.authorId === viewer.id} deletingPost={deleteForumThreadMut.isPending ? deleteForumThreadMut.variables?.threadId ?? null : null} onDeletePost={(post) => { + const assertActive = source.capture() deleteForumThreadMut.mutate( { + assertActive, serverId, forumChannelId: channelId, threadId: post.id, openerMessageId: post.openerMessageId, }, - { onError: (error) => toastApiError(error, "Failed to delete post") }, + { onError: (error) => toastApiError(error, "Failed to delete post", assertActive) }, ) }} /> diff --git a/src/web/src/components/community/channels/forum-surface.tsx b/src/web/src/components/community/channels/forum-surface.tsx index 5f405d6e3..ad0ad5ea0 100644 --- a/src/web/src/components/community/channels/forum-surface.tsx +++ b/src/web/src/components/community/channels/forum-surface.tsx @@ -1,6 +1,7 @@ "use client" -import { useState } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; + import type { NewForumThread } from "../messages/create-forum-thread" import type { ComposerProps } from "../messages/composer" import type { ForumThread } from "@/lib/community/models/message" @@ -26,7 +27,7 @@ export function ForumSurface({ serverId, forumChannelId, ...props }: { }) { const feed = useForumFeed(serverId, forumChannelId) const readState = useChannelReadStateSnapshot(forumChannelId) - const [scrollRootEl, setScrollRootEl] = useState(null) + const [scrollRootEl, setScrollRootEl] = useAtom(useCreateAtom(null)) useTimelineReadObserver({ channelId: forumChannelId, messages: feed.posts.flatMap((post) => ( diff --git a/src/web/src/components/community/channels/forum-view.tsx b/src/web/src/components/community/channels/forum-view.tsx index 10e736120..89d950d82 100644 --- a/src/web/src/components/community/channels/forum-view.tsx +++ b/src/web/src/components/community/channels/forum-view.tsx @@ -1,6 +1,7 @@ "use client" -import { useCallback, useLayoutEffect, useRef, useState } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useCallback, useLayoutEffect, useRef } from "react" import { useVirtualizer } from "@tanstack/react-virtual" import { COMMUNITY_VIRTUALIZER_REACT_OPTIONS } from "@/hooks/community/virtualizer-react-options" import { Archive, ArchiveRestore, MessagesSquare, ListChevronsUpDown, Plus, Tag, Trash2 } from "lucide-react" @@ -98,7 +99,7 @@ function ForumPostTitle({ name, postId, seq }: { name: string; postId: string; s const hostRef = useRef(null) const measureRef = useRef(null) const measureNameRef = useRef(null) - const [rendered, setRendered] = useState({ name, truncated: false }) +const [rendered, setRendered] = useAtom(useCreateAtom({ name, truncated: false })) useLayoutEffect(() => { const host = hostRef.current @@ -143,7 +144,7 @@ function ForumPostTitle({ name, postId, seq }: { name: string; postId: string; s const observer = new ResizeObserver(recompute) observer.observe(host) return () => observer.disconnect() - }, [name, seq]) + }, [name, seq, setRendered]) const renderSequence = (measurement = false) => seq === undefined ? null : ( void }) { const profilesByUserId = useCanonicalProfilesByUserId() - const [composing, setComposing] = useState(false) - const [deletingFor, setDeletingFor] = useState(null) + const [composing, setComposing] = useAtom(useCreateAtom(false)) + const [deletingForId, setDeletingForId] = useAtom(useCreateAtom(null)) + const deletingFor = posts.find((post) => post.id === deletingForId) ?? null + const setDeletingFor = (post: ForumThread | null) => setDeletingForId(post?.id ?? null) const newPostTriggerRef = useRef(null) const scrollRef = useRef(null) const bindScrollRoot = useCallback((node: HTMLDivElement | null) => { @@ -294,6 +297,7 @@ export function ForumView({ onCreatePost={async (post) => { if (!onCreatePost) return await onCreatePost(post) + try { post.assertActive?.() } catch { return } closeCompose() }} /> diff --git a/src/web/src/components/community/channels/post-tag-dialog.tsx b/src/web/src/components/community/channels/post-tag-dialog.tsx index 16f4b6779..d10a1b849 100644 --- a/src/web/src/components/community/channels/post-tag-dialog.tsx +++ b/src/web/src/components/community/channels/post-tag-dialog.tsx @@ -1,5 +1,6 @@ "use client" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; /* Hallmark · component: tag editor · genre: modern-minimal · theme: DESIGN.md * states: default · hover · focus · active · disabled · loading · error-retain · success-close * contrast: pass (40–41) · responsive: pass (320 / 375 / 414 / 768) @@ -10,7 +11,6 @@ import { useEffect, useLayoutEffect, useRef, - useState, type ReactElement, type RefObject, } from "react" @@ -170,11 +170,11 @@ export function PostTagDialog({ const shell: ResolvedShell | null = breakpoint === "unknown" ? null : breakpoint const activeShellRef = useRef(shell) - const [open, setOpen] = useState(false) - const [baseline, setBaseline] = useState(current) - const [selected, setSelected] = useState(current) - const [draft, setDraft] = useState("") - const [committing, setCommitting] = useState(false) + const [open, setOpen] = useAtom(useCreateAtom(false)) + const [baseline, setBaseline] = useAtom(useCreateAtom(current)) + const [selected, setSelected] = useAtom(useCreateAtom(current)) + const [draft, setDraft] = useAtom(useCreateAtom("")) + const [committing, setCommitting] = useAtom(useCreateAtom(false)) const triggerRef = useRef(null) const surfaceRef = useRef(null) const inputRef = useRef(null) @@ -194,7 +194,7 @@ export function PostTagDialog({ setBaseline(current) setSelected(current) } - }, [current, open]) + }, [current, open, setBaseline, setSelected]) useEffect(() => { const previousShell = previousShellRef.current diff --git a/src/web/src/components/community/channels/sortable-category.tsx b/src/web/src/components/community/channels/sortable-category.tsx index 5b10d9a68..b238c8793 100644 --- a/src/web/src/components/community/channels/sortable-category.tsx +++ b/src/web/src/components/community/channels/sortable-category.tsx @@ -1,6 +1,7 @@ "use client" -import { useState } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; + import type React from "react" import { ChevronDown, Plus, Settings, Lock, Trash2 } from "lucide-react" import { useSortable } from "@dnd-kit/sortable" @@ -32,7 +33,7 @@ export function SortableCategory({ id: catDndId, name, open, onToggle, onAddChan pending?: boolean children: React.ReactNode }) { - const [confirmingDelete, setConfirmingDelete] = useState(false) + const [confirmingDelete, setConfirmingDelete] = useAtom(useCreateAtom(false)) const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging, isOver, activeIndex, index } = useSortable({ id: catDndId, data: { kind: "category" }, disabled: !canReorder }) const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1, zIndex: isDragging ? 10 : undefined } const showLine = isOver && !isDragging diff --git a/src/web/src/components/community/channels/sortable-channel.tsx b/src/web/src/components/community/channels/sortable-channel.tsx index 2e043dca8..240fa5859 100644 --- a/src/web/src/components/community/channels/sortable-channel.tsx +++ b/src/web/src/components/community/channels/sortable-channel.tsx @@ -1,6 +1,7 @@ "use client" -import { useState } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; + import { BellOff, Loader2, Pencil, Trash2, Users } from "lucide-react" import { EntityIcon } from "../entity-icon" import { useSortable } from "@dnd-kit/sortable" @@ -47,7 +48,7 @@ export function SortableChannel({ ch, active, onClick, onEdit, onDelete, onManag onManageMembers?: () => void canReorder?: boolean }) { - const [confirmingDelete, setConfirmingDelete] = useState(false) + const [confirmingDelete, setConfirmingDelete] = useAtom(useCreateAtom(false)) const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver, activeIndex, index } = useSortable({ id: ch.id, disabled: !canReorder }) const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1, zIndex: isDragging ? 10 : undefined } const showLine = isOver && !isDragging diff --git a/src/web/src/components/community/channels/text-channel-surface.dom.test.ts b/src/web/src/components/community/channels/text-channel-surface.dom.test.ts index b56f08a2f..b13da77a8 100644 --- a/src/web/src/components/community/channels/text-channel-surface.dom.test.ts +++ b/src/web/src/components/community/channels/text-channel-surface.dom.test.ts @@ -1,5 +1,6 @@ import React from "react" -import { act, fireEvent, render } from "@/test/react-dom-harness" +import { act, fireEvent } from "@/test/react-dom-harness" +import { renderCommunity as render } from "@/test/community-owner-harness" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { MessageChannelController } from "../messages/message-channel-controller" import type { MessageChannelControllerValue } from "../messages/message-channel-controller" @@ -10,7 +11,6 @@ import { ConversationFooterShell } from "../messages/conversation-footer-shell" import { MessageList } from "../messages/message-list" import { useChannelMessageFeed } from "@/hooks/community/use-channel-message-feed" import { buildAttachmentUploadFormData } from "@/hooks/community/mutations/uploads" -import { useMessageStreamStore } from "@/stores/community/message-stream" const mutationMocks = vi.hoisted(() => ({ sendMessage: vi.fn(), @@ -64,11 +64,12 @@ vi.mock("@/components/community/shell/community-panel", () => ({ vi.mock("@/components/community/messages/message-context-sheet", () => ({ MessageContextSheet: vi.fn(() => null), })) -vi.mock("@alook/shared", () => ({ +vi.mock("@alook/shared", async (importOriginal) => ({ ...await importOriginal(), deriveThreadName: () => "thread", MAX_ATTACHMENT_THUMBNAIL_SIZE_BYTES: 512 * 1024, -})) -vi.mock("@/stores/community", () => { + })) +vi.mock("@/stores/community", async (importOriginal) => { + const actual = await importOriginal(); const state = { pendingReply: null, setPendingReply: vi.fn(), @@ -79,6 +80,7 @@ vi.mock("@/stores/community", () => { { getState: () => state }, ) return { + ...actual, useCommunityStore, useTypingUsersForScope: () => [], useTypingNamesForScope: () => ({}), @@ -177,7 +179,7 @@ function ControllerProbe({ controller }: { controller: MessageChannelControllerV describe("MessageChannelController scroll target ownership", () => { beforeEach(() => { vi.useFakeTimers() - useMessageStreamStore.getState().resetAll() + }) afterEach(() => { @@ -185,20 +187,20 @@ describe("MessageChannelController scroll target ownership", () => { vi.clearAllMocks() }) - it("passes the route anchor through while the feed is loading", () => { + it("passes the route anchor through while the feed is loading", async () => { let renderer: ReturnType - act(() => { + await act(async () => { renderer = render(renderController(feed({ isLoading: true }))) }) expect(renderer!.container.querySelector("div")).toHaveAttribute("data-scroll-target", "m_target") }) - it("keeps a loaded target until MessageList reports consumption", () => { + it("keeps a loaded target until MessageList reports consumption", async () => { let renderer: ReturnType - act(() => { + await act(async () => { renderer = render(renderController(feed({ messages: [{ id: "m_target" }] }))) }) @@ -209,11 +211,11 @@ describe("MessageChannelController scroll target ownership", () => { expect(renderer!.container.querySelector("div")).not.toHaveAttribute("data-scroll-target") }) - it("keeps a missing target across warm cache until the anchor request errors", () => { + it("keeps a missing target across warm cache until the anchor request errors", async () => { let messageFeed = feed({ messages: [{ id: "m_unrelated" }] }) let renderer: ReturnType - act(() => { + await act(async () => { renderer = render(renderController(messageFeed)) }) @@ -223,14 +225,19 @@ describe("MessageChannelController scroll target ownership", () => { expect(renderer!.container.querySelector("div")).not.toHaveAttribute("data-scroll-target") }) - it("does not start a visual highlight timer for a loaded target", () => { + it("does not start a visual highlight timer for a loaded target", async () => { + const schedule = vi.spyOn(globalThis, "setTimeout") let renderer: ReturnType - act(() => { + await act(async () => { renderer = render(renderController(feed({ messages: [{ id: "m_target" }] }))) }) - expect(vi.getTimerCount()).toBe(0) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + expect(schedule.mock.calls.some(([, delay]) => delay === 1_600)).toBe(false) + act(() => vi.advanceTimersByTime(5_000)) + expect(renderer!.container.querySelector("div")).toHaveAttribute("data-scroll-target", "m_target") act(() => renderer!.unmount()) + schedule.mockRestore() }) it("keeps message action references stable while calling the latest surface callbacks", async () => { @@ -240,7 +247,7 @@ describe("MessageChannelController scroll target ownership", () => { const latestOpenPinned = vi.fn() let renderer: ReturnType - act(() => { + await act(async () => { renderer = render(renderController( feed({ messages: [{ id: "m_target", content: "first" }] }), "m_target", @@ -271,12 +278,12 @@ describe("MessageChannelController scroll target ownership", () => { expect(firstOpenThread).not.toHaveBeenCalled() }) - it("updates latest action callbacks before descendant passive effects run", () => { + it("updates latest action callbacks before descendant passive effects run", async () => { const firstOpenPinned = vi.fn() const latestOpenPinned = vi.fn() let renderer: ReturnType - act(() => { + await act(async () => { renderer = render(renderController( feed({ messages: [{ id: "m_target" }] }), "m_target", @@ -312,7 +319,7 @@ describe("MessageChannelController scroll target ownership", () => { mutationMocks.sendMessage.mockResolvedValueOnce({ message: { id: "server_thumb", seq: 10 } }) let renderer: ReturnType - act(() => { + await act(async () => { renderer = render(renderController(feed())) }) const firstController = latestController @@ -353,11 +360,11 @@ describe("TextChannelSurface header hierarchy", () => { vi.clearAllMocks() }) - it("forwards the explicit mobile parent Back callback", () => { + it("forwards the explicit mobile parent Back callback", async () => { const onNavigateParent = vi.fn() mockedUseChannelMessageFeed.mockReturnValue(feed()) - act(() => { + await act(async () => { render(React.createElement(TextChannelSurface, { channelId: "channel_1", serverId: "server_1", @@ -387,7 +394,7 @@ describe("TextChannelSurface header hierarchy", () => { expect(headerProps).not.toHaveProperty("onBack") }) - it("keeps pinned reads available while gating live and preview Pin actions", () => { + it("keeps pinned reads available while gating live and preview Pin actions", async () => { mockedUseChannelMessageFeed.mockReturnValue(feed()) const baseProps = { channelId: "channel_1", @@ -410,7 +417,7 @@ describe("TextChannelSurface header hierarchy", () => { } let renderer: ReturnType - act(() => { + await act(async () => { renderer = render(React.createElement(TextChannelSurface, { ...baseProps, canManagePins: true, diff --git a/src/web/src/components/community/channels/text-channel-surface.tsx b/src/web/src/components/community/channels/text-channel-surface.tsx index 952cf3794..0f77041dd 100644 --- a/src/web/src/components/community/channels/text-channel-surface.tsx +++ b/src/web/src/components/community/channels/text-channel-surface.tsx @@ -1,6 +1,7 @@ "use client" -import { useEffect, useState, type ComponentProps, type ReactNode } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useEffect, type ComponentProps, type ReactNode } from "react" import { useBreakpoint } from "@/hooks/use-mobile" import { useChannelMessageFeed } from "@/hooks/community/use-channel-message-feed" import { ChannelHeader, type ChannelNotifLevel } from "@/components/community/channels/channel-header" @@ -72,7 +73,7 @@ export function TextChannelSurface({ embedded?: boolean }) { const breakpoint = useBreakpoint() - const [rightPanel, setRightPanel] = useState(null) + const [rightPanel, setRightPanel] = useAtom(useCreateAtom(null)) const mentionInsertion = useAuthorMentionInsertion({ members: composerMembers, viewerUserId: viewer.id, @@ -88,7 +89,7 @@ export function TextChannelSurface({ }) useEffect(() => { setRightPanel(null) - }, [channelId]) + }, [channelId, setRightPanel]) const togglePanel = (panel: Exclude) => { setRightPanel((current) => current === panel ? null : panel) @@ -197,6 +198,8 @@ export function TextChannelSurface({ pinned={feed.pinned} pinnedLoading={feed.pinnedLoading} searchResults={controller.searchResults} + searchError={controller.searchError} + searchLoading={controller.searchLoading} searchQuery={controller.searchQuery} threads={feed.threads} threadsLoading={feed.threadsLoading} diff --git a/src/web/src/components/community/channels/thread-channel-surface.tsx b/src/web/src/components/community/channels/thread-channel-surface.tsx index 475bc0ac0..4d7a9cb97 100644 --- a/src/web/src/components/community/channels/thread-channel-surface.tsx +++ b/src/web/src/components/community/channels/thread-channel-surface.tsx @@ -1,6 +1,8 @@ "use client" -import { useCallback, useEffect, useState, type ComponentProps, type ReactNode } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; + +import { useCallback, useEffect, type ComponentProps, type ReactNode } from "react" import { useRouter } from "next/navigation" import { toastApiError, apiFetch } from "@/lib/api/client" import { useBreakpoint } from "@/hooks/use-mobile" @@ -97,8 +99,8 @@ export function ThreadChannelSurface({ }) { const router = useRouter() const breakpoint = useBreakpoint() - const [rightPanel, setRightPanel] = useState(null) - const [localName, setLocalName] = useState(null) + const [rightPanel, setRightPanel] = useAtom(useCreateAtom(null)) + const [localName, setLocalName] = useAtom(useCreateAtom(null)) const mentionInsertion = useAuthorMentionInsertion({ members: composerMembers, viewerUserId: viewer.id, @@ -131,12 +133,12 @@ export function ThreadChannelSurface({ useEffect(() => { setRightPanel(null) setLocalName(null) - }, [channelId]) + }, [channelId, setLocalName, setRightPanel]) const togglePanel = useCallback((panel: Exclude) => { setRightPanel((current) => current === panel ? null : panel) - }, []) - const openPinned = useCallback(() => setRightPanel("pinned"), []) + }, [setRightPanel]) + const openPinned = useCallback(() => setRightPanel("pinned"), [setRightPanel]) const rename = parentIsForum && parentChannelId && parentMessageId && childCreatorId === viewer.id ? async (name: string) => { try { @@ -305,6 +307,8 @@ export function ThreadChannelSurface({ pinned={controller.feed.pinned} pinnedLoading={controller.feed.pinnedLoading} searchResults={controller.searchResults} + searchError={controller.searchError} + searchLoading={controller.searchLoading} searchQuery={controller.searchQuery} threads={controller.feed.threads} threadsLoading={controller.feed.threadsLoading} diff --git a/src/web/src/components/community/channels/thread-split-view.tsx b/src/web/src/components/community/channels/thread-split-view.tsx index eda16ac2b..4751c7be6 100644 --- a/src/web/src/components/community/channels/thread-split-view.tsx +++ b/src/web/src/components/community/channels/thread-split-view.tsx @@ -1,7 +1,7 @@ +"use client" /* Hallmark · modern-minimal · focused/utilitarian · inherited neutral palette * macrostructure: parallel-conversation-panel · pre-emit: P5 H5 E4 S5 R5 V4 · slop: pass */ -"use client" import type { ReactNode, RefCallback } from "react" import { useDefaultLayout } from "react-resizable-panels" diff --git a/src/web/src/components/community/channels/use-channel-tree.dom.test.ts b/src/web/src/components/community/channels/use-channel-tree.dom.test.ts index e77a34c3e..55d35fc3a 100644 --- a/src/web/src/components/community/channels/use-channel-tree.dom.test.ts +++ b/src/web/src/components/community/channels/use-channel-tree.dom.test.ts @@ -181,6 +181,44 @@ describe("useChannelTree opaque category drag callbacks", () => { category("9qsh2k", [ch("b1"), ch("b2")]), ] + it("previews a same-privacy category move and settles the destination order", async () => { + const moving = { ...ch("a1"), name: "Canonical channel", unread: true } + const hook = await renderTreeHook([ + category("5vg7bs", [moving, ch("a2")]), + category("9qsh2k", [ch("b1"), ch("b2")]), + ]) + + await hook.drag((tree) => tree.onDragOver(dragEvent("a1", "b2"))) + + expect(hook.current.order["5vg7bs"].map((channel) => channel.id)).toEqual(["a2"]) + expect(hook.current.order["9qsh2k"].map((channel) => channel.id)).toEqual(["b1", "a1", "b2"]) + expect(hook.current.order["9qsh2k"][1]).toEqual(moving) + const preview = hook.current.order + await hook.drag((tree) => tree.onDragOver(dragEvent("a1", "b2"))) + expect(hook.current.order).toBe(preview) + + await hook.drag((tree) => tree.onDragEnd(dragEvent("a1", "b2"))) + + expect(hook.current.order["5vg7bs"].map((channel) => channel.id)).toEqual(["a2"]) + expect(hook.current.order["9qsh2k"].map((channel) => channel.id)).toEqual(["b1", "b2", "a1"]) + expect(hook.current.order["9qsh2k"][2]).toEqual(moving) + expect(hook.current.catOrder).toEqual(["5vg7bs", "9qsh2k"]) + }) + + it("rejects a category move across the privacy boundary", async () => { + const hook = await renderTreeHook([ + { ...category("5vg7bs", [ch("a1")]), private: true }, + category("9qsh2k", [ch("b1")]), + ]) + const before = hook.current.order + + await hook.drag((tree) => tree.onDragOver(dragEvent("a1", "b1"))) + + expect(hook.current.order).toBe(before) + expect(hook.current.order["5vg7bs"].map((channel) => channel.id)).toEqual(["a1"]) + expect(hook.current.order["9qsh2k"].map((channel) => channel.id)).toEqual(["b1"]) + }) + it("ignores category drag-over instead of moving a channel", async () => { const hook = await renderTreeHook(categories) const before = hook.current.order diff --git a/src/web/src/components/community/channels/use-channel-tree.ts b/src/web/src/components/community/channels/use-channel-tree.ts index b43091639..fad95b36b 100644 --- a/src/web/src/components/community/channels/use-channel-tree.ts +++ b/src/web/src/components/community/channels/use-channel-tree.ts @@ -1,6 +1,8 @@ "use client" -import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react" + +import { useAtom, useCreateAtom } from "@tanstack/react-store" +import { useCallback, useMemo } from "react" import { arrayMove } from "@dnd-kit/sortable" import type { DragEndEvent } from "@dnd-kit/core" import type { Category, Channel } from "@/lib/community/models/navigation" @@ -11,36 +13,6 @@ export const catId = (id: string) => id export type ChannelOrder = Record -function channelTreeIdentity(categories: Category[]): string { - return JSON.stringify(categories.map((category) => [ - category.id, - category.name, - Boolean(category.private), - Boolean(category.pending), - category.creatorId ?? null, - category.channels.map((channel) => [ - channel.id, - channel.name, - Boolean(channel.unread), - channel.pending ?? null, - channel.creatorId ?? null, - channel.type ?? null, - ]), - ])) -} - -function replaceRecordIfChanged( - current: Record, - next: Record, -): Record { - const keys = Object.keys(next) - if (keys.length === Object.keys(current).length && - keys.every((key) => current[key] === next[key])) { - return current - } - return next -} - /** Which category currently holds a channel id (or the category itself if `id` is an order key). */ export function catOf(id: string, order: ChannelOrder): string | undefined { if (Object.hasOwn(order, id)) return id @@ -79,13 +51,6 @@ export function reorderChannelsWithin(order: ChannelOrder, activeId: string, ove return { ...order, [cat]: arrayMove(order[cat], from, to) } } -/** Remove a channel by id from whichever category holds it. Pure. */ -function removeChannelFrom(order: ChannelOrder, id: string): ChannelOrder { - const cat = catOf(id, order) - if (!cat) return order - return { ...order, [cat]: order[cat].filter((c) => c.id !== id) } -} - /** * Merge metadata-only field changes (`unread`, `name`) from the incoming * `categories` prop into the existing per-category `order` state, keyed by @@ -145,154 +110,44 @@ export function reorderCategories(catOrder: string[], activeCatId: string, overC * sort among themselves, channels sort across categories. */ export function useChannelTree(categories: Category[]) { - const categoriesIdentity = channelTreeIdentity(categories) - const [collapsed, setCollapsed] = useState>(new Set()) - const [catOrder, setCatOrder] = useState(() => categories.map((c) => c.id)) - const [order, setOrder] = useState(() => - Object.fromEntries(categories.map((c) => [c.id, c.channels])), - ) - // id → name lookup for display - const [catNames, setCatNames] = useState>(() => - Object.fromEntries(categories.map((c) => [c.id, c.name])), - ) - // per-category privacy — default public; private restricts channel creation to admins - const [catPrivate, setCatPrivate] = useState>(() => - Object.fromEntries(categories.map((c) => [c.id, !!c.private])), - ) - // per-category optimistic-pending flag — a category being created (temp id). - // Non-interactive until the create resolves. - const [catPending, setCatPending] = useState>(() => - Object.fromEntries(categories.map((c) => [c.id, !!c.pending])), - ) - // per-category creator ID - const [catCreators, setCatCreators] = useState>(() => - Object.fromEntries(categories.map((c) => [c.id, c.creatorId ?? null])), - ) - - // Sync state when categories change from API (initial load or server switch) - const prevCatsRef = useRef({ categories, identity: categoriesIdentity }) - const reconcileCategories = useEffectEvent(() => { - const previous = prevCatsRef.current - if (previous.identity === categoriesIdentity) return - const prev = previous.categories - prevCatsRef.current = { categories, identity: categoriesIdentity } - // Server-detail cleared on route change — collapse our derived state so the - // sidebar's loading branch can render the skeleton instead of stale rows. - if (categories.length === 0) { - if (prev.length === 0) return - setCatOrder([]) - setOrder({}) - setCatNames({}) - setCatPrivate({}) - setCatPending({}) - setCatCreators({}) - return - } - // Compare both category IDs and channel IDs to detect any change - const prevKey = prev.map((c) => `${c.id}:${c.channels.map((ch) => ch.id).join(",")}`).join("|") - const nextKey = categories.map((c) => `${c.id}:${c.channels.map((ch) => ch.id).join(",")}`).join("|") - if (prevKey === nextKey) { - // Id sets are unchanged, but metadata fields (`unread`, `name`) may - // have changed underneath — e.g. a WS-driven cache patch or a refetch - // that only flips a flag. Merge those while preserving drag order and - // collapse state. - setOrder((prevOrder) => { - const { next, changed } = mergeChannelMetadata(prevOrder, categories) - return changed ? next : prevOrder - }) - setCatNames((current) => replaceRecordIfChanged( - current, - Object.fromEntries(categories.map((category) => [category.id, category.name])), - )) - setCatPrivate((current) => replaceRecordIfChanged( - current, - Object.fromEntries(categories.map((category) => [category.id, !!category.private])), - )) - setCatPending((current) => replaceRecordIfChanged( - current, - Object.fromEntries(categories.map((category) => [ - category.id, - category.pending === undefined ? (current[category.id] ?? false) : category.pending, - ])), - )) - setCatCreators((current) => replaceRecordIfChanged( - current, - Object.fromEntries(categories.map((category) => [category.id, category.creatorId ?? null])), - )) - return + const [collapsed, setCollapsed] = useAtom(useCreateAtom>(new Set())); + const layout = useCreateAtom<{ identity: string; categories: string[]; channels: Record } | null>(null); + const [preview, setPreview] = useAtom(layout); + const identity = JSON.stringify(categories.map((category) => [category.id, category.channels.map((channel) => channel.id)])); + const channelFacts = JSON.stringify(categories.flatMap((category) => category.channels)); + const canonicalChannels = useMemo(() => new Map((JSON.parse(channelFacts) as Channel[]).map((channel) => [channel.id, channel])), [channelFacts]); + const baseOrder = useMemo(() => Object.fromEntries(JSON.parse(identity) as Array<[string, string[]]>), [identity]); + const current = useMemo(() => preview?.identity === identity ? preview : { identity, categories: Object.keys(baseOrder), channels: baseOrder }, [preview, identity, baseOrder]); + const catOrder = current.categories; + const order = useMemo(() => Object.fromEntries(Object.entries(current.channels).map(([id, ids]) => [id, ids.flatMap((channelId) => { + const channel = canonicalChannels.get(channelId); + return channel ? [channel] : []; + })])), [current.channels, canonicalChannels]); + const catNames = Object.fromEntries(categories.map((category) => [category.id, category.name])); + const catPrivate = Object.fromEntries(categories.map((category) => [category.id, !!category.private])); + const catPending = Object.fromEntries(categories.map((category) => [category.id, !!category.pending])); + const catCreators = Object.fromEntries(categories.map((category) => [category.id, category.creatorId ?? null])); + const toggleCat = useCallback((id: string) => setCollapsed((previous) => { + const next = new Set(previous); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }), [setCollapsed]); + const projectOrder = (next: ChannelOrder) => Object.fromEntries(Object.entries(next).map(([id, channels]) => [id, channels.map((channel) => channel.id)])); + const onDragOver = ({ active, over }: DragEndEvent) => { + if (!over || catOrder.includes(String(active.id))) return; + const from = catOf(String(active.id), order), to = catOf(String(over.id), order); + if (from && to && from !== to && catPrivate[from] !== catPrivate[to]) return; + const next = moveChannelAcrossCategories(order, String(active.id), String(over.id)); + if (next !== order) setPreview({ ...current, channels: projectOrder(next) }); + }; + const onDragEnd = ({ active, over }: DragEndEvent) => { + if (over && active.id !== over.id) { + const activeId = String(active.id), overId = String(over.id); + if (catOrder.includes(activeId) && catOrder.includes(overId)) setPreview({ ...current, categories: reorderCategories(catOrder, activeId, overId) }); + else if (!catOrder.includes(activeId)) setPreview({ ...current, channels: projectOrder(reorderChannelsWithin(order, activeId, overId)) }); } - setCatOrder(categories.map((c) => c.id)) - setOrder(Object.fromEntries(categories.map((c) => [c.id, c.channels]))) - setCatNames(Object.fromEntries(categories.map((c) => [c.id, c.name]))) - setCatPrivate(Object.fromEntries(categories.map((c) => [c.id, !!c.private]))) - setCatPending(Object.fromEntries(categories.map((c) => [c.id, !!c.pending]))) - setCatCreators(Object.fromEntries(categories.map((c) => [c.id, c.creatorId ?? null]))) - }) - useEffect(() => { - reconcileCategories() - }, [categoriesIdentity]) - - const toggleCat = useCallback((id: string) => - setCollapsed((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }), []) - - const removeChannel = useCallback((id: string) => - setOrder((prev) => removeChannelFrom(prev, id)), []) - const renameChannel = useCallback((id: string, name: string) => - setOrder((prev) => { - const cat = catOf(id, prev) - if (!cat) return prev - return { ...prev, [cat]: prev[cat].map((c) => c.id === id ? { ...c, name } : c) } - }), []) - // Category delete is driven by the query cache (useDeleteCategory's optimistic - // onMutate/onError), so the tree resettles from `categories` — no local - // removal helper (a local one had no rollback path; see the mutation hook). - const renameCategory = useCallback((id: string, name: string) => - setCatNames((prev) => (prev[id] === name ? prev : { ...prev, [id]: name })), []) - - const onDragOver = useCallback((e: DragEndEvent) => { - const { active, over } = e - if (!over || catOrder.includes(String(active.id))) return // category drags handled on drop - setOrder((prev) => { - const fromCat = catOf(String(active.id), prev) - const toCat = catOf(String(over.id), prev) - // Never let a channel cross a public↔private boundary during the drag — - // visibility would silently widen/tighten. Same-class cross-category - // moves still follow the cursor. `onDragEnd` toasts on a blocked attempt. - if (fromCat && toCat && fromCat !== toCat && - !!catPrivate[fromCat] !== !!catPrivate[toCat]) { - return prev - } - return moveChannelAcrossCategories(prev, String(active.id), String(over.id)) - }) - }, [catOrder, catPrivate]) - - const onDragEnd = useCallback((e: DragEndEvent) => { - const { active, over } = e - if (!over || active.id === over.id) return - const activeIsCategory = catOrder.includes(String(active.id)) - const overIsCategory = catOrder.includes(String(over.id)) - if (activeIsCategory && overIsCategory) { - setCatOrder((prev) => reorderCategories(prev, String(active.id), String(over.id))) - return - } - if (activeIsCategory) return - setOrder((prev) => reorderChannelsWithin(prev, String(active.id), String(over.id))) - }, [catOrder]) - - return useMemo(() => ({ - collapsed, catOrder, order, catNames, catPrivate, catPending, catCreators, - toggleCat, removeChannel, renameChannel, - renameCategory, onDragOver, onDragEnd, - }), [ - collapsed, catOrder, order, catNames, catPrivate, catPending, catCreators, - toggleCat, removeChannel, renameChannel, - renameCategory, onDragOver, onDragEnd, - ]) + }; + return { collapsed, catOrder, order, catNames, catPrivate, catPending, catCreators, toggleCat, onDragOver, onDragEnd }; } - export type ChannelTree = ReturnType diff --git a/src/web/src/components/community/image-crop-dialog.tsx b/src/web/src/components/community/image-crop-dialog.tsx index a0fe32f5c..64f6cb2f4 100644 --- a/src/web/src/components/community/image-crop-dialog.tsx +++ b/src/web/src/components/community/image-crop-dialog.tsx @@ -1,6 +1,7 @@ "use client" -import { useCallback, useState } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useCallback } from "react" import Cropper, { type Area } from "react-easy-crop" import { toast } from "sonner" import { getErrorMessage } from "@/lib/api/client" @@ -35,14 +36,14 @@ export function ImageCropDialog({ onCancel: () => void onCropped: (file: File) => void }) { - const [crop, setCrop] = useState({ x: 0, y: 0 }) - const [zoom, setZoom] = useState(1) - const [cropPixels, setCropPixels] = useState(null) - const [saving, setSaving] = useState(false) + const [crop, setCrop] = useAtom(useCreateAtom({ x: 0, y: 0 })) + const [zoom, setZoom] = useAtom(useCreateAtom(1)) + const [cropPixels, setCropPixels] = useAtom(useCreateAtom(null)) + const [saving, setSaving] = useAtom(useCreateAtom(false)) const onCropComplete = useCallback((_area: Area, areaPixels: Area) => { setCropPixels(areaPixels) - }, []) + }, [setCropPixels]) const save = async () => { if (!cropPixels) return diff --git a/src/web/src/components/community/machines/guide-me-avatar-motion.tsx b/src/web/src/components/community/machines/guide-me-avatar-motion.tsx index 927ba030c..e90560075 100644 --- a/src/web/src/components/community/machines/guide-me-avatar-motion.tsx +++ b/src/web/src/components/community/machines/guide-me-avatar-motion.tsx @@ -1,6 +1,7 @@ "use client" -import { useEffect, useRef, useState, type AnimationEvent, type RefObject } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useEffect, useRef, type AnimationEvent, type RefObject } from "react" import { GeneratedAvatar } from "@/components/avatar" import { tid } from "@/lib/community/testids" @@ -123,7 +124,7 @@ export function GuideMeAvatarMotion({ }) { const introRef = useRef(null) const landingRef = useRef(null) - const [ready, setReady] = useState(false) + const [ready, setReady] = useAtom(useCreateAtom(false)) useEffect(() => { if (!intro) { @@ -159,7 +160,7 @@ export function GuideMeAvatarMotion({ cancelStablePaint() if (reducedTimer !== undefined) window.clearTimeout(reducedTimer) } - }, [intro, onIntroComplete, stageRef]) + }, [intro, onIntroComplete, setReady, stageRef]) const handleAnimationEnd = (event: AnimationEvent) => { if ( diff --git a/src/web/src/components/community/machines/machine-list.dom.test.ts b/src/web/src/components/community/machines/machine-list.dom.test.ts index 0a2c9e768..69343b7b9 100644 --- a/src/web/src/components/community/machines/machine-list.dom.test.ts +++ b/src/web/src/components/community/machines/machine-list.dom.test.ts @@ -1,6 +1,7 @@ import React from "react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { act, fireEvent, render, waitFor } from "@/test/react-dom-harness" +import { act, fireEvent, waitFor } from "@/test/react-dom-harness" +import { renderCommunity as render } from "@/test/community-owner-harness" const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), @@ -53,10 +54,6 @@ vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(), })) -vi.mock("@tanstack/react-query", () => ({ - useQueryClient: () => ({ setQueryData: vi.fn(), invalidateQueries: vi.fn(), fetchQuery: mocks.fetchQuery }), -})) - vi.mock("@/components/ui/button", () => ({ Button: ({ children, ...props }: React.PropsWithChildren>) => React.createElement("button", props, children), @@ -83,12 +80,12 @@ vi.mock("./machine-card", () => ({ })) vi.mock("./pair-machine-sheet", () => ({ PairMachineSheet: ({ open, onLimitReached }: { open: boolean; onLimitReached: () => void }) => open ? React.createElement("button", { "data-testid": "test-pair-sheet", onClick: onLimitReached }, "Simulate server limit") : null })) vi.mock("@/components/community/onboarding-tiles/connect-tile", () => ({ ConnectTile: () => null })) -vi.mock("@/hooks/community/use-machines", () => ({ - machinesQueryFn: vi.fn(), +vi.mock("@/hooks/community/use-machines", async (importOriginal) => ({ ...await importOriginal(), + machinesQueryFn: mocks.fetchQuery, useMachines: () => ({ machines: mocks.machines.current, isLoading: mocks.machinesLoading.current, data: { machineCapacity: mocks.capacity.current } }), })) vi.mock("@/hooks/community/use-bots", () => ({ useBots: () => ({ bots: [] }) })) -vi.mock("@/stores/community", () => ({ +vi.mock("@/stores/community", async (importOriginal) => ({ ...await importOriginal(), usePendingMachineTokenId: () => null, useCommunityStore: { getState: () => ({ setPendingMachineTokenId: vi.fn() }) }, })) @@ -172,6 +169,7 @@ describe("machine daemon update UI", () => { it("refreshes the allowance before showing a server rejection", async () => { mocks.fetchQuery.mockImplementation(async () => { mocks.capacity.current = { ...mocks.capacity.current, limit: 5, ownedCount: 5 } + return { machines: mocks.machines.current, machineCapacity: mocks.capacity.current } }) const view = render(React.createElement(MachineList)) fireEvent.click(view.getByRole("button", { name: "Connect a machine" })) @@ -221,7 +219,7 @@ describe("machine daemon update UI", () => { fireEvent.click(renderer.getByTestId(tid.onboardingStart)) expect(mocks.startOnboarding).toHaveBeenCalledOnce() - expect(mocks.startOnboarding).toHaveBeenCalledWith({ guideAvatarSeed: expect.any(String) }) + expect(mocks.startOnboarding).toHaveBeenCalledWith(expect.objectContaining({ ui: expect.any(Object), lifecycle: expect.any(Object) }), { guideAvatarSeed: expect.any(String) }) }) it("loads Community update eligibility from the daemon package endpoint", async () => { diff --git a/src/web/src/components/community/machines/machine-list.tsx b/src/web/src/components/community/machines/machine-list.tsx index 1eb93f993..0eb38c1ae 100644 --- a/src/web/src/components/community/machines/machine-list.tsx +++ b/src/web/src/components/community/machines/machine-list.tsx @@ -1,10 +1,15 @@ "use client" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { getCommunityRuntime, useCommunityRuntime } from "@/stores/community/runtime" -import { useCallback, useEffect, useRef, useState, type ReactNode } from "react" + +import { useCallback, useEffect, useRef, type ReactNode } from "react" import { useRouter, useSearchParams } from "next/navigation" import { toast } from "sonner" import { ChevronLeft } from "lucide-react" -import { useQueryClient } from "@tanstack/react-query" +import { useQueryClient, useQuery, useMutation } from "@tanstack/react-query" +import { useCommunityMutationOrigin } from "@/hooks/community/community-origin" +import { useCommunityViewSource } from "@/hooks/community/use-community-view-source" import type { AlookMode, CommunityMachineSummary } from "@alook/shared" import { isPresenceOnline, @@ -32,7 +37,7 @@ import { PairMachineSheet, type PairMachineSheetMode } from "./pair-machine-shee import { ConnectTile } from "@/components/community/onboarding-tiles/connect-tile" import { useMachines, machinesQueryFn, replaceMachines, type MachinesResponse } from "@/hooks/community/use-machines" import { useBots } from "@/hooks/community/use-bots" -import { useCommunityStore, usePendingMachineTokenId } from "@/stores/community" +import { usePendingMachineTokenId } from "@/stores/community" import { communityKeys } from "@/lib/query-keys" import { tid } from "@/lib/community/testids" import { @@ -204,10 +209,13 @@ export function MachineUpdateDialog({ export function MachineList({ onBack }: { onBack?: () => void } = {}) { const router = useRouter() const searchParams = useSearchParams() + const communityRuntime = useCommunityRuntime() const queryClient = useQueryClient() + const origin = useCommunityMutationOrigin() + const source = useCommunityViewSource("machine-list") const { machines, data: machinesData, isLoading: machinesLoading } = useMachines() const capacity = machinesData?.machineCapacity ?? null - const [limitOpen, setLimitOpen] = useState(false) + const [limitOpen, setLimitOpen] = useAtom(useCreateAtom(false)) const viewPlan = useCallback(() => { const next = new URL(window.location.href) next.searchParams.delete("billing") @@ -216,37 +224,42 @@ export function MachineList({ onBack }: { onBack?: () => void } = {}) { }, []) const { bots } = useBots() const pendingMachineTokenId = usePendingMachineTokenId() - const [pairOpen, setPairOpen] = useState(false) - const [pairMode, setPairMode] = useState({ kind: "pair" }) - const [pendingTokenId, setPendingTokenId] = useState(null) - const [connectedHostname, setConnectedHostname] = useState(null) - const [confirmDelete, setConfirmDelete] = useState(null) - const [confirmUpdate, setConfirmUpdate] = useState(null) - const [latestVersion, setLatestVersion] = useState(null) - const [guideAvatarSeed, setGuideAvatarSeed] = useState("alook-guide") - const [guideIntroActive, setGuideIntroActive] = useState(true) + const [pairOpen, setPairOpen] = useAtom(useCreateAtom(false)) + const [pairMode, setPairMode] = useAtom(useCreateAtom({ kind: "pair" })) + const [pendingTokenId, setPendingTokenId] = useAtom(useCreateAtom(null)) + const [connectedHostname, setConnectedHostname] = useAtom(useCreateAtom(null)) + const [deleteMachineId, setDeleteMachineId] = useAtom(useCreateAtom(null)) + const [updateMachineId, setUpdateMachineId] = useAtom(useCreateAtom(null)) + const confirmDelete = machines.find((machine) => machine.id === deleteMachineId) ?? null + const confirmUpdate = machines.find((machine) => machine.id === updateMachineId) ?? null + const setConfirmDelete = (machine: CommunityMachineSummary | null) => setDeleteMachineId(machine?.id ?? null) + const setConfirmUpdate = (machine: CommunityMachineSummary | null) => setUpdateMachineId(machine?.id ?? null) + const [guideAvatarSeed, setGuideAvatarSeed] = useAtom(useCreateAtom("alook-guide")) + const [guideIntroActive, setGuideIntroActive] = useAtom(useCreateAtom(true)) const emptyStageRef = useRef(null) const onboardingState = useCommunityOnboarding() const controllerMode = getAppMode() + const versionQuery = useQuery({ queryKey: ["community", "latest-daemon-version"], enabled: controllerMode !== "dev", + staleTime: 300_000, queryFn: ({ signal }) => origin.run((options) => fetchLatestDaemonVersion({ ...options, signal })), + }) + const latestVersion = versionQuery.data?.version ?? null + const machineCommand = useMutation({ mutationKey: ["community", "machine-command"], scope: { id: "community-machine-command" }, + mutationFn: async ({ kind, id, token, assert }: { kind: "delete" | "update"; id: string; token: ReturnType["token"]; assert: ReturnType }) => { + origin.assert(token) + const key = communityKeys.machines() + const original = queryClient.getQueryCache().find({ queryKey: key, exact: true }) + await origin.request(token, `/api/community/machines/${id}${kind === "update" ? "/update" : ""}`, { method: kind === "delete" ? "DELETE" : "POST", assertActive: assert }) + origin.assert(token) + if (kind === "delete" && original && queryClient.getQueryCache().find({ queryKey: key, exact: true }) === original) queryClient.setQueryData(key, (current) => current ? replaceMachines(current, current.machines.filter((row) => row.id !== id)) : current) + await queryClient.invalidateQueries({ queryKey: key, exact: true }) + origin.assert(token) + }, + }) useEffect(() => { setGuideAvatarSeed(`alook-guide-${crypto.randomUUID()}`) - }, []) + }, [setGuideAvatarSeed]) - useEffect(() => { - if (controllerMode === "dev") return - let active = true - void fetchLatestDaemonVersion() - .then(({ version }) => { - if (active) setLatestVersion(version) - }) - .catch(() => { - if (active) setLatestVersion(null) - }) - return () => { - active = false - } - }, [controllerMode]) // When the WS layer announces a machine for our pending token, flip the sheet. useEffect(() => { @@ -260,7 +273,7 @@ export function MachineList({ onBack }: { onBack?: () => void } = {}) { if (justConnected && !connectedHostname) { setConnectedHostname(justConnected.hostname || "machine") } - }, [machines, pendingMachineTokenId, pendingTokenId, connectedHostname]) + }, [machines, pendingMachineTokenId, pendingTokenId, connectedHostname, setConnectedHostname]) const openPair = useCallback(() => { setPairMode({ kind: "pair" }) @@ -275,9 +288,9 @@ export function MachineList({ onBack }: { onBack?: () => void } = {}) { } setPendingTokenId(null) setConnectedHostname(null) - useCommunityStore.getState().setPendingMachineTokenId(null) + getCommunityRuntime(queryClient).ui.actions.setPendingMachineTokenId(null) setPairOpen(true) - }, [capacity, queryClient]) + }, [capacity, queryClient, setConnectedHostname, setLimitOpen, setPairMode, setPairOpen, setPendingTokenId]) const openReconnect = useCallback((machine: CommunityMachineSummary) => { setPairMode({ @@ -287,9 +300,9 @@ export function MachineList({ onBack }: { onBack?: () => void } = {}) { }) setPendingTokenId(null) setConnectedHostname(null) - useCommunityStore.getState().setPendingMachineTokenId(null) + getCommunityRuntime(queryClient).ui.actions.setPendingMachineTokenId(null) setPairOpen(true) - }, []) + }, [queryClient, setConnectedHostname, setPairMode, setPairOpen, setPendingTokenId]) // Deep-link from BotList's "Bring online" button (`?reconnect=`) // — auto-open the same reconnect Sheet MachineCard's "Reconnect…" opens. @@ -313,56 +326,46 @@ export function MachineList({ onBack }: { onBack?: () => void } = {}) { if (!open) { setPendingTokenId(null) setConnectedHostname(null) - useCommunityStore.getState().setPendingMachineTokenId(null) + getCommunityRuntime(queryClient).ui.actions.setPendingMachineTokenId(null) } - }, []) + }, [queryClient, setConnectedHostname, setPairOpen, setPendingTokenId]) - const onConfirmDelete = useCallback(async () => { + const onConfirmDelete = async () => { if (!confirmDelete) return - const id = confirmDelete.id + const id = confirmDelete.id, assert = source.capture(), token = origin.begin().token + assert() setConfirmDelete(null) - try { - // Never sends `cascade: true` — deleting a machine with live bots on - // it is blocked (see the dialog below, which swaps the destructive - // action out for "Manage bots" whenever `botsToDelete` is non-empty). - // This call only fires for a machine we already know has zero bots. - await apiFetch(`/api/community/machines/${id}`, { method: "DELETE" }) - // Optimistically drop the row from the machines cache. WS - // `machine.removed` fans out and reconciles, but the same-tab actor - // should see it disappear immediately. - queryClient.setQueryData( - communityKeys.machines(), - (prev) => - prev ? replaceMachines(prev, prev.machines.filter((m) => m.id !== id)) : prev, - ) - void queryClient.invalidateQueries({ queryKey: communityKeys.machines() }) - } catch (err) { - // MACHINE_HAS_BOTS can still happen here despite the client-side - // guard above — e.g. a bot was created on this machine from another - // tab between opening the dialog and confirming. - const message = - err instanceof Error && err.message === "MACHINE_HAS_BOTS" - ? "This machine now has bots on it — delete or move them first" - : err instanceof Error && err.message - ? err.message - : "Couldn't delete the machine" - toast.error(message) + try { await machineCommand.mutateAsync({ kind: "delete", id, token, assert }) } + catch (error) { + try { assert() } catch { return } + if (error instanceof DOMException && error.name === "AbortError") return + toast.error(error instanceof Error && error.message === "MACHINE_HAS_BOTS" + ? "This machine now has bots on it — delete or move them first" : getErrorMessage(error, "Couldn't delete the machine")) } - }, [confirmDelete, queryClient]) + } const handleSetPendingTokenId = useCallback((tokenId: string | null) => { setPendingTokenId(tokenId) - useCommunityStore.getState().setPendingMachineTokenId(tokenId) - }, []) + getCommunityRuntime(queryClient).ui.actions.setPendingMachineTokenId(tokenId) + }, [queryClient, setPendingTokenId]) - const onConfirmUpdate = useCallback((machine: CommunityMachineSummary) => { + const onConfirmUpdate = async (machine: CommunityMachineSummary) => { + const assert = source.capture(), token = origin.begin().token + assert() setConfirmUpdate(null) - void requestMachineUpdate(machine.id) - }, []) + try { + await machineCommand.mutateAsync({ kind: "update", id: machine.id, token, assert }) + assert() + toast.success("Update requested — the machine will restart and reconnect") + } catch (error) { + try { assert() } catch { return } + if (!(error instanceof DOMException && error.name === "AbortError")) toast.error(getErrorMessage(error, "Couldn't request the daemon update")) + } + } const finishGuideIntro = useCallback(() => { setGuideIntroActive(false) - }, []) + }, [setGuideIntroActive]) const botsToDelete = confirmDelete ? bots.filter((b) => b.machineId === confirmDelete.id) @@ -372,14 +375,18 @@ export function MachineList({ onBack }: { onBack?: () => void } = {}) { const limitDialog = const usage = const onLimitReached = useCallback(async () => { + const assert = source.capture() + assert() closePair(false) try { await queryClient.fetchQuery({ queryKey: communityKeys.machines(), queryFn: machinesQueryFn, staleTime: 0 }) + assert() setLimitOpen(true) } catch { + try { assert() } catch { return } toast.error("Couldn’t refresh your machine allowance. Refresh and try again.") } - }, [closePair, queryClient]) + }, [closePair, queryClient, setLimitOpen, source]) if (machinesLoading) { return @@ -422,7 +429,7 @@ export function MachineList({ onBack }: { onBack?: () => void } = {}) { @@ -538,7 +545,7 @@ export function MachineList({ onBack }: { onBack?: () => void } = {}) { onClick={() => { const machineId = confirmDelete?.id setConfirmDelete(null) - useCommunityStore.getState().uiHandlers.navigatePath?.( + getCommunityRuntime(queryClient).ui.get().uiHandlers.navigatePath?.( machineId ? `/c/me/bots?machineId=${machineId}` : "/c/me/bots" diff --git a/src/web/src/components/community/machines/pair-machine-sheet.dom.test.ts b/src/web/src/components/community/machines/pair-machine-sheet.dom.test.ts index 2d39ba18b..c2a102649 100644 --- a/src/web/src/components/community/machines/pair-machine-sheet.dom.test.ts +++ b/src/web/src/components/community/machines/pair-machine-sheet.dom.test.ts @@ -1,6 +1,7 @@ import React from "react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { fireEvent, render, waitFor } from "@/test/react-dom-harness" +import { fireEvent, waitFor } from "@/test/react-dom-harness" +import { renderCommunity as render } from "@/test/community-owner-harness" const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), diff --git a/src/web/src/components/community/machines/pair-machine-sheet.tsx b/src/web/src/components/community/machines/pair-machine-sheet.tsx index e7516ec7f..93b2d894c 100644 --- a/src/web/src/components/community/machines/pair-machine-sheet.tsx +++ b/src/web/src/components/community/machines/pair-machine-sheet.tsx @@ -1,12 +1,16 @@ "use client" -import { useCallback, useEffect, useRef, useState } from "react" +import { useCreateAtom } from "@tanstack/react-store"; +import { useQuery, useMutation } from "@tanstack/react-query" +import { useCommunityMutationOrigin } from "@/hooks/community/community-origin" +import { useCommunityViewSource } from "@/hooks/community/use-community-view-source" +import { useCallback, useEffect } from "react" import { toast } from "sonner" import { Copy, Loader2, RefreshCw, TerminalIcon } from "lucide-react" import { isDesktop, isTauri, tauriInvoke } from "@alook/shared" import { CommunitySheet } from "@/components/community/shell/community-sheet" import { Button } from "@/components/ui/button" -import { apiFetch, toastApiError } from "@/lib/api/client" +import { toastApiError } from "@/lib/api/client" import { tid } from "@/lib/community/testids" import { isLocalServiceEnvironment, WS_DO_PORT_DEFAULT } from "@/lib/utils" import { websocketUrl } from "@/lib/websocket-url" @@ -68,138 +72,106 @@ export function PairMachineSheet({ onLimitReached?: () => void }) { const isReconnect = mode.kind === "reconnect" - const [generating, setGenerating] = useState(false) - const [generationError, setGenerationError] = useState(null) - const [connecting, setConnecting] = useState(false) - const [started, setStarted] = useState(false) - const [checkingRuntime, setCheckingRuntime] = useState(false) - const [runtimeCapability, setRuntimeCapability] = useState(null) - const [launchError, setLaunchError] = useState(null) - const generatedForKey = useRef(null) - const connectingRef = useRef(false) + const origin = useCommunityMutationOrigin() + const openKey = mode.kind === "reconnect" ? `reconnect:${mode.machineId}` : "pair" + const source = useCommunityViewSource(`machine-pair:${openKey}`, open) const desktopNative = isTauri() && isDesktop() - + const generation = useMutation({ + gcTime: 0, + mutationKey: ["community", "machine-pair", openKey], + mutationFn: async ({ token, assert }: { token: ReturnType["token"]; assert: ReturnType }) => { + assert() + const endpoint = mode.kind === "reconnect" ? `/api/community/machines/${mode.machineId}/reconnect` : "/api/community/machines/pair" + const result = await origin.request<{ tokenId: string; expiresAt: string }>(token, endpoint, { method: "POST", signal: assert.signal, assertActive: assert }) + assert() + return result + }, + }) + const generating = generation.isPending && generation.variables?.assert.signal === source.signal + const generationError = generation.variables?.assert.signal === source.signal && generation.isError && !(generation.error instanceof DOMException && generation.error.name === "AbortError") ? "Couldn't generate a key — try again." : null + const mutateGeneration = generation.mutateAsync const generate = useCallback(async () => { - setGenerating(true) - setGenerationError(null) + const assert = source.capture(), token = origin.begin().token + assert() try { - const endpoint = - mode.kind === "reconnect" - ? `/api/community/machines/${mode.machineId}/reconnect` - : "/api/community/machines/pair" - const res = await apiFetch<{ tokenId: string; expiresAt: string }>( - endpoint, - { method: "POST" } - ) - setPendingTokenId(res.tokenId) - } catch (err) { - if (err instanceof Error && err.message === "MACHINE_LIMIT_REACHED" && onLimitReached) { - onLimitReached() - return - } - const message = "Couldn't generate a key — try again." - setGenerationError(message) - toastApiError(err, message) - console.error(err) - } finally { - setGenerating(false) + const result = await mutateGeneration({ token, assert }) + assert() + setPendingTokenId(result.tokenId) + } catch (error) { + try { assert() } catch { return } + if (error instanceof Error && error.message === "MACHINE_LIMIT_REACHED" && onLimitReached) { onLimitReached(); return } + toastApiError(error, "Couldn't generate a key — try again.", assert) } - }, [setPendingTokenId, mode, onLimitReached]) - - // Auto-generate the key when the sheet opens. Track per-open so re-opens or - // mode swaps trigger a fresh mint. - const openKey = open - ? mode.kind === "reconnect" - ? `reconnect:${mode.machineId}` - : "pair" - : null + }, [mutateGeneration, source, origin, setPendingTokenId, onLimitReached]) + const generatedForKey = useCreateAtom<{ key: string; signal: AbortSignal } | null>(null) useEffect(() => { - if (!openKey) { - generatedForKey.current = null - return - } - if (generatedForKey.current === openKey) return - generatedForKey.current = openKey - setStarted(false) + if (!open) { generatedForKey.set(null); return } + const previous = generatedForKey.get() + if (previous?.key === openKey && previous.signal === source.signal) return + generatedForKey.set({ key: openKey, signal: source.signal }) setPendingTokenId(null) void generate() - }, [openKey, generate, setPendingTokenId]) - - useEffect(() => { - if (!open || !desktopNative) { - setCheckingRuntime(false) - setRuntimeCapability(null) - return - } - let active = true - setCheckingRuntime(true) - setRuntimeCapability(null) - setLaunchError(null) - void tauriInvoke("daemon_runtime_capability") - .then((capability) => { - if (active) setRuntimeCapability(capability) - }) - .catch((error) => { - if (!active) return - setRuntimeCapability({ - available: false, - reason: nativeErrorMessage( - error, - "Alook couldn't check Node.js and npm on this computer.", - ), - nodeVersion: null, - }) - }) - .finally(() => { - if (active) setCheckingRuntime(false) - }) - return () => { - active = false - } - }, [open, desktopNative]) - - const command = pendingTokenId - ? buildPairCommand( - pendingTokenId, - mode.kind === "reconnect" ? mode.machineId : undefined, - ) - : "" - - const copyCommand = useCallback(async () => { + }, [open, openKey, setPendingTokenId, source.signal, generatedForKey, generate]) + const capability = useQuery({ + queryKey: ["community", "daemon-runtime-capability"], enabled: open && desktopNative, subscribed: open && desktopNative, + gcTime: 0, retry: false, + queryFn: async ({ signal }) => { + const token = origin.begin().token + origin.assert(token) + try { + const result = await tauriInvoke("daemon_runtime_capability") + origin.assert(token) + if (signal.aborted) throw new DOMException("Cancelled native runtime check", "AbortError") + return result + } catch (error) { + origin.assert(token) + if (signal.aborted) throw new DOMException("Cancelled native runtime check", "AbortError") + throw error + } + }, + }) + const runtimeCapability = capability.data ?? (capability.error ? { + available: false, reason: nativeErrorMessage(capability.error, "Alook couldn't check Node.js and npm on this computer."), nodeVersion: null, + } : null) + const checkingRuntime = open && desktopNative && capability.isPending + const launch = useMutation({ + gcTime: 0, + mutationKey: ["community", "machine-pair", openKey, "launch"], + mutationFn: async ({ key, machineId, assert }: { key: string; machineId: string | null; assert: ReturnType }) => { + assert() + const result = await tauriInvoke<{ success: boolean; message: string }>("daemon_pair", { machineKey: key, machineId }) + assert() + if (!result.success) throw new Error(result.message || "The daemon did not start") + return { key } + }, + }) + const currentLaunch = launch.variables?.assert.signal === source.signal + const connecting = currentLaunch && launch.isPending + const started = currentLaunch && launch.isSuccess && launch.data.key === pendingTokenId + const launchError = currentLaunch && launch.error && !(launch.error instanceof DOMException && launch.error.name === "AbortError") + ? nativeErrorMessage(launch.error, "Couldn't start the daemon. Run the command below in a terminal instead.") : null + const command = pendingTokenId ? buildPairCommand(pendingTokenId, mode.kind === "reconnect" ? mode.machineId : undefined) : "" + const copyCommand = async () => { if (!command) return + const assert = source.capture() + assert() + try { await navigator.clipboard.writeText(command); assert(); toast.success("Command copied") } + catch (error) { try { assert() } catch { return }; if (!(error instanceof DOMException && error.name === "AbortError")) toast.error("Copy failed") } + } + const connectDesktop = async () => { + if (!pendingTokenId || connecting || !runtimeCapability?.available) return + const assert = source.capture() + assert() try { - await navigator.clipboard.writeText(command) - toast.success("Command copied") - } catch { - toast.error("Copy failed") - } - }, [command]) - - const connectDesktop = useCallback(async () => { - if (!pendingTokenId || connectingRef.current || !runtimeCapability?.available) return - connectingRef.current = true - setConnecting(true) - setLaunchError(null) - try { - const result = await tauriInvoke<{ success: boolean; message: string }>("daemon_pair", { - machineKey: pendingTokenId, - machineId: mode.kind === "reconnect" ? mode.machineId : null, - }) - if (!result.success) throw new Error(result.message || "The daemon did not start") - setStarted(true) + await launch.mutateAsync({ key: pendingTokenId, machineId: mode.kind === "reconnect" ? mode.machineId : null, assert }) + assert() toast.success(isReconnect ? "Machine reconnected" : "This computer is connecting") } catch (error) { - const message = nativeErrorMessage( - error, - "Couldn't start the daemon. Run the command below in a terminal instead.", - ) - setLaunchError(message) - toast.error(message) - } finally { - connectingRef.current = false - setConnecting(false) + try { assert() } catch { return } + if (!(error instanceof DOMException && error.name === "AbortError")) toast.error(nativeErrorMessage(error, "Couldn't start the daemon. Run the command below in a terminal instead.")) } - }, [pendingTokenId, runtimeCapability?.available, isReconnect, mode]) + } + return ( ({ apiFetch: vi.fn(), toastError: vi.fn() })) +vi.mock("@/lib/api/client", async (importOriginal) => ({ + ...await importOriginal(), + apiFetch: (...args: unknown[]) => mocks.apiFetch(...args), +})) +vi.mock("sonner", () => ({ toast: Object.assign(vi.fn(), { error: mocks.toastError }) })) + +beforeEach(() => { + mocks.apiFetch.mockReset() + mocks.toastError.mockReset() +}) + +function deferred() { + let resolve!: () => void + let reject!: (error: Error) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} + +const parentMembers: ChannelMember[] = ["viewer", "bob", "carol"].map((userId) => ({ + id: `member_${userId}`, userId, + name: userId === "viewer" ? "Viewer" : userId === "bob" ? "Bob" : "Carol", + discriminator: "0042", avatar: userId, avatarVersion: 0, sub: "", + role: userId === "viewer" ? "owner" : "member", status: "offline", + statusEmoji: null, statusText: "", source: userId === "viewer" ? "admin" : "explicit", + isCreator: userId === "viewer", +})) + +function Harness({ channelId = "thread" }: { channelId?: string }) { + const model = useChannelMemberViewModel({ + serverId: "server", channelId, channelName: channelId, + currentServer: { categories: [{ channels: [ + { id: "parent", type: "text" }, { id: "thread", type: "thread" }, { id: "other", type: "thread" }, + ] }] }, + channelInServer: { creatorId: "viewer" }, + currentChannelMeta: { name: channelId, parentChannelId: "parent", creatorId: "viewer" }, + isChildChannel: true, isNotifyUnit: true, currentUser: { id: "viewer" }, + }) + return <> + + {model.manageMembersDialog} + +} + +async function fixture() { + const owner = await createCommunityQueryOwner() + const roster = new Map>([ + ["thread", new Set(["viewer"])], ["other", new Set(["viewer"])], + ]) + const requests: Array<{ channelId: string; userId: string; signal: AbortSignal }> = [] + let nextGate = deferred() + mocks.apiFetch.mockImplementation((url: string, options?: RequestInit) => { + if (url.startsWith("/api/community/servers/server/members")) { + return Promise.resolve({ members: parentMembers, hasMore: false, limit: 50, total: 3 }) + } + if (url === "/api/community/channels/parent/members") { + return Promise.resolve({ members: parentMembers }) + } + const match = /^\/api\/community\/channels\/(thread|other)\/(members|participants)$/.exec(url) + if (!match) throw new Error(`Unexpected request: ${url}`) + const channelId = match[1] + if (options?.method === "POST") { + const { userId } = JSON.parse(String(options.body)) as { userId: string } + const gate = nextGate + requests.push({ channelId, userId, signal: options.signal as AbortSignal }) + return gate.promise.then(() => { + if (!options.signal?.aborted) roster.get(channelId)!.add(userId) + return { success: true } + }) + } + return Promise.resolve({ members: parentMembers + .filter((member) => roster.get(channelId)!.has(member.userId)) + .map((member) => ({ ...member, source: member.userId === "viewer" ? "spoke" : "added" })) }) + }) + const element = (channelId = "thread") => + + + const rendered = render(element()) + const open = async () => { + fireEvent.click(screen.getByRole("button", { name: "Open picker" })) + await waitFor(() => expect(screen.getByRole("dialog")).toBeTruthy()) + await waitFor(() => expect(within(screen.getByRole("dialog")).getByText("Carol", { exact: true })).toBeTruthy()) + return within(screen.getByRole("dialog")) + } + const row = (name: string) => { + const container = within(screen.getByRole("dialog")).getByText(name, { exact: true }).parentElement! + return { container, button: within(container).getByRole("button") as HTMLButtonElement } + } + return { ...owner, rendered, element, requests, open, row, + gate: () => nextGate, + newGate: () => { nextGate = deferred(); return nextGate }, + } +} + +describe("AddMembersDialog native membership lifecycle", () => { + it("retains the admitted pending row during canonical optimistic insertion and removes it on success", async () => { + const f = await fixture() + await f.open() + fireEvent.click(f.row("Bob").button) + await waitFor(() => { + expect(f.requests).toHaveLength(1) + expect(f.registry.collections.channelMemberships.has(channelMembershipKey("thread", "bob", "notify"))).toBe(true) + expect(f.row("Bob").button.disabled).toBe(true) + expect(f.row("Bob").container.querySelector("svg.animate-spin")).not.toBeNull() + expect(f.row("Carol").button.disabled).toBe(false) + }) + fireEvent.click(f.row("Bob").button) + expect(f.requests).toHaveLength(1) + await act(async () => { f.gate().resolve(); await Promise.resolve() }) + await waitFor(() => expect(within(screen.getByRole("dialog")).queryByText("Bob", { exact: true })).toBeNull()) + expect(f.registry.collections.channelMemberships.has(channelMembershipKey("thread", "bob", "notify"))).toBe(true) + expect(f.requests).toHaveLength(1) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it("rolls back a failed Add and permits one new successful Retry", async () => { + const f = await fixture() + await f.open() + fireEvent.click(f.row("Bob").button) + await waitFor(() => { expect(f.requests).toHaveLength(1); expect(f.row("Bob").button.disabled).toBe(true) }) + await act(async () => { f.gate().reject(new Error("controlled Add failure")); await Promise.resolve() }) + await waitFor(() => { + expect(f.row("Bob").button.disabled).toBe(false) + expect(f.registry.collections.channelMemberships.has(channelMembershipKey("thread", "bob", "notify"))).toBe(false) + expect(mocks.toastError).toHaveBeenCalledTimes(1) + }) + f.newGate() + fireEvent.click(f.row("Bob").button) + await waitFor(() => { expect(f.requests).toHaveLength(2); expect(f.row("Bob").button.disabled).toBe(true) }) + await act(async () => { f.gate().resolve(); await Promise.resolve() }) + await waitFor(() => expect(within(screen.getByRole("dialog")).queryByText("Bob", { exact: true })).toBeNull()) + expect(f.requests.map(({ userId }) => userId)).toEqual(["bob", "bob"]) + }) + + it("does not borrow a retired pending intent after closing and returning through another scope", async () => { + const f = await fixture() + await f.open() + fireEvent.click(f.row("Bob").button) + await waitFor(() => { expect(f.requests).toHaveLength(1); expect(f.row("Bob").button.disabled).toBe(true) }) + fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Close" })) + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()) + expect(f.requests[0].signal.aborted).toBe(true) + f.rendered.rerender(f.element("other")) + await f.open() + expect(f.row("Bob").button.disabled).toBe(false) + fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Close" })) + f.rendered.rerender(f.element("thread")) + const replacement = await f.open() + expect(replacement.queryByText("Bob", { exact: true })).toBeNull() + expect(f.row("Carol").button.disabled).toBe(false) + expect(screen.getByRole("dialog").querySelector("svg.animate-spin")).toBeNull() + await act(async () => { f.gate().resolve(); await Promise.resolve() }) + await waitFor(() => { + expect(f.row("Bob").button.disabled).toBe(false) + expect(f.registry.collections.channelMemberships.has(channelMembershipKey("thread", "bob", "notify"))).toBe(false) + }) + expect(f.requests).toHaveLength(1) + expect(mocks.toastError).not.toHaveBeenCalled() + }) +}) diff --git a/src/web/src/components/community/members/add-members-dialog.test.ts b/src/web/src/components/community/members/add-members-dialog.test.ts index bc1599e85..72fa46c17 100644 --- a/src/web/src/components/community/members/add-members-dialog.test.ts +++ b/src/web/src/components/community/members/add-members-dialog.test.ts @@ -46,7 +46,7 @@ describe("AddMemberRow", () => { expect(source).toContain("error: queryState.error") expect(source).toContain('errorMessage="Couldn\'t load people."') expect(source).toContain('emptyMessage="Everyone is already here."') - expect(source).toContain("onRetry={queryState.retry}") + expect(source).toContain("onRetry={() => { source.capture()(); queryState.retry?.() }}") }) }) diff --git a/src/web/src/components/community/members/add-members-dialog.tsx b/src/web/src/components/community/members/add-members-dialog.tsx index a4400aaf8..49bb8cc19 100644 --- a/src/web/src/components/community/members/add-members-dialog.tsx +++ b/src/web/src/components/community/members/add-members-dialog.tsx @@ -1,7 +1,11 @@ "use client" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useMutation, useMutationState, useQueryClient } from "@tanstack/react-query" +import { useCommunityViewSource } from "@/hooks/community/use-community-view-source" +import type { MemberOriginalView } from "./member-management-types" import type React from "react" -import { useMemo, useState } from "react" +import { useMemo } from "react" import { Search, Loader2 } from "lucide-react" import { Dialog, DialogContent } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" @@ -88,6 +92,7 @@ export async function runAdd( * parent-channel members not yet participating) and supplies `onAdd`. */ export function AddMembersDialog({ + scopeId = "members", title, subtitle, candidates, @@ -95,37 +100,65 @@ export function AddMembersDialog({ onAdd, onClose, }: { + scopeId?: string title: string subtitle: string candidates: AddableCandidate[] queryState: PeoplePickerAsyncState - onAdd: (userId: string) => Promise | void + onAdd: (userId: string, assert?: MemberOriginalView) => Promise | void onClose: () => void }) { - const [query, setQuery] = useState("") + const client = useQueryClient() + const source = useCommunityViewSource("add-members:" + scopeId) + const key = ["community", "member-picker", scopeId] + type Intent = { userId: string; candidate: AddableCandidate; assert: ReturnType } + const command = useMutation({ + mutationKey: key, + gcTime: 0, + mutationFn: async ({ userId, assert }: Intent) => { assert(); await onAdd(userId, assert); assert() }, + onError: (error, intent) => toastApiError(error, "Couldn't add member", intent.assert), + }) + const [query, setQuery] = useAtom(useCreateAtom("")) // In-flight adds, keyed by userId — each row spins independently, so adding // multiple people in a row doesn't disable the others. - const [addingIds, setAddingIds] = useState>(new Set()) + const attempts = useMutationState({ filters: { mutationKey: key }, select: (mutation) => ({ intent: mutation.state.variables as Intent | undefined, status: mutation.state.status }) }) + const addingIds = new Set(attempts.filter((attempt) => attempt.intent?.assert.signal === source.signal && (attempt.status === "pending" || attempt.status === "success")).map((attempt) => attempt.intent!.userId)) + const visibleCandidates = useMemo(() => { + const rows = new Map(candidates.map((candidate) => [candidate.userId, candidate])) + for (const { intent, status } of attempts) { + if (status === "pending" && intent?.assert.signal === source.signal && intent.candidate && !rows.has(intent.userId)) { + rows.set(intent.userId, intent.candidate) + } + } + return [...rows.values()] + }, [candidates, attempts, source.signal]) const filtered = useMemo(() => { const q = query.trim().toLowerCase() - if (!q) return candidates - return candidates.filter((m) => (m.name ?? "").toLowerCase().includes(q)) - }, [candidates, query]) + if (!q) return visibleCandidates + return visibleCandidates.filter((m) => (m.name ?? "").toLowerCase().includes(q)) + }, [visibleCandidates, query]) const pickerState = resolvePeoplePickerViewState({ resolved: queryState.resolved, loading: queryState.loading, error: queryState.error, - sourceCount: candidates.length, + sourceCount: visibleCandidates.length, visibleCount: filtered.length, query, }) - const add = (userId: string) => void runAdd(userId, onAdd, setAddingIds) + const add = (userId: string) => { + const candidate = candidates.find((row) => row.userId === userId) + if (!candidate) return + const assert = source.capture() + const attempts = client.getMutationCache().findAll({ mutationKey: key }) + if (attempts.some((mutation) => { const intent = mutation.state.variables as Intent | undefined; return intent?.userId === userId && intent.assert.signal === assert.signal && (mutation.state.status === "pending" || mutation.state.status === "success") })) return + command.mutate({ userId, candidate: { ...candidate }, assert }) + } return ( - { if (!o) onClose() }}> + { if (!o) { source.retire(); onClose() } }}> @@ -145,7 +178,7 @@ export function AddMembersDialog({ errorMessage="Couldn't load people." emptyMessage="Everyone is already here." retrying={queryState.retrying} - onRetry={queryState.retry} + onRetry={() => { source.capture()(); queryState.retry?.() }} > {filtered.map((m) => ( ({ useSetMemberRole: () => ({ mutate: mocks.setMemberRole }), useKickMember: () => ({ mutateAsync: mocks.kickMember }), })) -vi.mock("@/lib/community-db/projections", () => ({ +vi.mock("@/lib/community-db/projections", async (importOriginal) => ({ ...await importOriginal(), useCanonicalProfilesByUserId: () => { const rows = [ ...mocks.serverMembers, @@ -249,8 +250,8 @@ describe("useChannelMemberViewModel", () => { }) expect(mocks.serverMemberArgs.at(-1)).toBeNull() - expect(mockedUseChannelMembers).toHaveBeenNthCalledWith(1, "channel_1", false) - expect(mockedUseChannelMembers).toHaveBeenNthCalledWith(2, "", false) + expect(mockedUseChannelMembers).toHaveBeenNthCalledWith(1, "channel_1", false, "server_1", "access") + expect(mockedUseChannelMembers).toHaveBeenNthCalledWith(2, "", false, "server_1", "access") expect(mockedUseAddableMembers).toHaveBeenCalledWith( "server_1", "channel_1", @@ -284,6 +285,7 @@ describe("useChannelMemberViewModel", () => { const resolver = latestModel().resolveUserName expect(resolver("alice_1")).toBe("Alice") + mocks.serverMembers = mocks.serverMembers.map((row) => row.userId === "alice_1" ? { ...row, status: "online", statusEmoji: "🌱", statusText: "Focused" } : row) mocks.onlineUserIds = new Set(["alice_1"]) mocks.userStatuses = new Map([["alice_1", { emoji: "🌱", text: "Focused" }]]) act(() => { @@ -341,7 +343,7 @@ describe("useChannelMemberViewModel", () => { }) await latestModel().memberPanelProps.manageContext?.onRemove("alice_1") - expect(mocks.removeChannelMember).toHaveBeenCalledWith("alice_1") + expect(mocks.removeChannelMember).toHaveBeenCalledWith({ userId: "alice_1", assertActive: expect.any(Function) }) expect(mocks.removeThreadParticipant).not.toHaveBeenCalled() expect(latestModel().memberPanelProps.manageContext).toEqual(expect.objectContaining({ viewerUserId: "viewer_1", @@ -372,8 +374,8 @@ describe("useChannelMemberViewModel", () => { }))) }) - expect(mockedUseChannelMembers).toHaveBeenCalledWith("thread_1", true) - expect(mockedUseChannelMembers).toHaveBeenCalledWith("parent_1", true) + expect(mockedUseChannelMembers).toHaveBeenCalledWith("thread_1", true, "server_1", "notify") + expect(mockedUseChannelMembers).toHaveBeenCalledWith("parent_1", true, "server_1", "access") expect(latestModel().memberPanelProps.members.map((row) => row.userId)).toEqual(["viewer_1", "alice_1"]) expect(latestModel().memberPanelProps.members.every((row) => row.source === undefined)).toBe(true) expect(latestModel().composerMembers.map((row) => row.userId)).toEqual(["alice_1", "bob_1"]) @@ -393,8 +395,8 @@ describe("useChannelMemberViewModel", () => { })) await dialogProps.onAdd("bob_1") await latestModel().memberPanelProps.manageContext?.onRemove("alice_1") - expect(mocks.addThreadParticipant).toHaveBeenCalledWith("bob_1") - expect(mocks.removeThreadParticipant).toHaveBeenCalledWith("alice_1") + expect(mocks.addThreadParticipant).toHaveBeenCalledWith({ userId: "bob_1", assertActive: expect.any(Function) }) + expect(mocks.removeThreadParticipant).toHaveBeenCalledWith({ userId: "alice_1", assertActive: expect.any(Function) }) expect(mocks.addChannelMember).not.toHaveBeenCalled() expect(mocks.removeChannelMember).not.toHaveBeenCalled() }) @@ -549,10 +551,11 @@ describe("useChannelMemberViewModel", () => { latestModel().memberPanelProps.onSetRole?.("member_alice", "admin") await latestModel().memberPanelProps.onKickMember?.("member_alice") expect(mocks.setMemberRole).toHaveBeenCalledWith({ + assertActive: expect.any(Function), serverId: "server_1", memberId: "member_alice", role: "admin", }, expect.any(Object)) - expect(mocks.kickMember).toHaveBeenCalledWith({ serverId: "server_1", memberId: "member_alice" }) + expect(mocks.kickMember).toHaveBeenCalledWith({ serverId: "server_1", memberId: "member_alice", assertActive: expect.any(Function) }) }) }) diff --git a/src/web/src/components/community/members/channel-member-view-model.tsx b/src/web/src/components/community/members/channel-member-view-model.tsx index 9bd29f13f..23b49f90b 100644 --- a/src/web/src/components/community/members/channel-member-view-model.tsx +++ b/src/web/src/components/community/members/channel-member-view-model.tsx @@ -1,6 +1,8 @@ "use client" -import { useCallback, useEffect, useMemo, useState, type ComponentProps, type ReactNode } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; + +import { useCallback, useEffect, useMemo, type ComponentProps, type ReactNode } from "react" import { toast } from "sonner" import { isForum, type CommunityRole as Role } from "@alook/shared" import { AddMembersDialog } from "@/components/community/members/add-members-dialog" @@ -17,11 +19,13 @@ import { useRemoveChannelMember, } from "@/hooks/community/use-channel-members" import { useServerMembers } from "@/hooks/community/use-server-members" +import { useCommunityViewSource } from "@/hooks/community/use-community-view-source" import { useAddThreadParticipant, useRemoveThreadParticipant, } from "@/hooks/community/use-thread-participants" import { useKickMember, useSetMemberRole } from "@/hooks/community/mutations" +import type { MemberOriginalView } from "./member-management-types" import { useCanonicalProfilesByUserId } from "@/lib/community-db/projections" type MentionCandidateSource = NonNullable @@ -31,6 +35,7 @@ type PanelProps = ComponentProps export type ChannelMemberPanelProps = Pick< PanelProps, | "members" + | "memberScopeId" | "membersLoading" | "membersLoadingMore" | "membersHasMore" @@ -91,8 +96,13 @@ export function useChannelMemberViewModel({ myRole: Role | undefined } { const membersHook = useServerMembers(accessAllowed && currentServer ? serverId : null) - const profilesByUserId = useCanonicalProfilesByUserId() - const [memberUi, setMemberUi] = useState({ channelId, query: "", dialogOpen: false }) + const source = useCommunityViewSource(`channel-members:${serverId}:${channelId}`, accessAllowed) + const originalView = useCallback((caller?: MemberOriginalView) => { + const local = source.capture() + return Object.assign(() => { local(); caller?.() }, { signal: caller?.signal ?? local.signal }) + }, [source]) + const members = membersHook.members + const [memberUi, setMemberUi] = useAtom(useCreateAtom({ channelId, query: "", dialogOpen: false })) const memberQuery = memberUi.channelId === channelId ? memberUi.query : "" const manageMembersOpen = memberUi.channelId === channelId && memberUi.dialogOpen @@ -102,27 +112,7 @@ export function useChannelMemberViewModel({ ? current : { channelId, query: "", dialogOpen: false }, ) - }, [channelId]) - - const members = useMemo( - () => membersHook.members.map((member) => { - const canonical = profilesByUserId.get(member.userId) - const profile = canonical - ? readCommunityProfile(canonical, member.userId) - : { ...member, presence: member.status } - return { - ...member, - name: profile.name, - discriminator: profile.discriminator, - avatar: profile.avatar, - avatarVersion: profile.avatarVersion, - status: member.userId === currentUser.id ? "online" as const : profile.presence, - statusEmoji: profile.statusEmoji, - statusText: profile.statusText, - } - }), - [currentUser.id, membersHook.members, profilesByUserId], - ) + }, [channelId, setMemberUi]) const currentChannelPrivate = useMemo(() => { const anchorId = isChildChannel @@ -137,11 +127,15 @@ export function useChannelMemberViewModel({ const channelMembersHook = useChannelMembers( channelId, accessAllowed && (isNotifyUnit || (currentChannelPrivate && !isNotifyUnit)), + serverId, + isNotifyUnit ? "notify" : "access", ) const parentChannelId = isNotifyUnit ? currentChannelMeta?.parentChannelId ?? null : null const parentChannelMembersHook = useChannelMembers( parentChannelId ?? "", accessAllowed && !!parentChannelId, + serverId, + "access", ) const participantMembersData = channelMembersHook.data const parentMembersData = parentChannelMembersHook.data @@ -206,6 +200,8 @@ export function useChannelMemberViewModel({ ) const setMemberRoleMut = useSetMemberRole() const kickMemberMut = useKickMember() + const profileIds = useMemo(() => [...new Set([...members.map((member) => member.userId), ...channelMembersHook.members.map((member) => member.userId), ...parentChannelMembersHook.members.map((member) => member.userId), ...addableMembers.map((member) => member.userId)])], [members, channelMembersHook.members, parentChannelMembersHook.members, addableMembers]) + const profilesByUserId = useCanonicalProfilesByUserId(profileIds) const panelMembers = useMemo(() => { const query = memberQuery.trim().toLowerCase() @@ -215,7 +211,7 @@ export function useChannelMemberViewModel({ userId: string sub: string isCreator?: boolean - source?: "explicit" | "inherited" | "admin" + source?: Member["source"] }): Member => { const profile = readCommunityProfile( profilesByUserId.get(member.userId), @@ -328,12 +324,13 @@ export function useChannelMemberViewModel({ viewerUserId: currentUser.id, viewerIsCreator: viewerIsUnitCreator, unitLabel: currentChannelMeta?.name ?? channelName, - onLeave: (userId: string) => removeMember.mutateAsync(userId), - onRemove: (userId: string) => removeMember.mutateAsync(userId), + onLeave: (userId: string, caller?: MemberOriginalView) => removeMember.mutateAsync({ userId, assertActive: originalView(caller) }), + onRemove: (userId: string, caller?: MemberOriginalView) => removeMember.mutateAsync({ userId, assertActive: originalView(caller) }), } : undefined const memberPanelProps: ChannelMemberPanelProps = { + memberScopeId: `${serverId}:${channelId}`, members: panelMembers, membersLoading: scopedDrawer ? channelMembersHook.isLoading : membersHook.loading, membersLoadingMore: scopedDrawer ? false : membersHook.loadingMore, @@ -348,13 +345,18 @@ export function useChannelMemberViewModel({ manageContext, myRole, onSetRole: (memberId: string, role: Role) => { - setMemberRoleMut.mutate({ serverId, memberId, role }, { - onSuccess: () => toast("Role updated"), - onError: (error) => toastApiError(error, "Failed to update role"), + const assert = source.capture() + assert() + setMemberRoleMut.mutate({ serverId, memberId, role, assertActive: assert }, { + onSuccess: () => { try { assert() } catch { return }; toast("Role updated") }, + onError: (error) => toastApiError(error, "Failed to update role", assert), }) }, - onKickMember: (memberId: string) => - kickMemberMut.mutateAsync({ serverId, memberId }).then(() => toast("Member kicked")), + onKickMember: (memberId: string, caller?: MemberOriginalView) => { + const assert = originalView(caller) + assert() + return kickMemberMut.mutateAsync({ serverId, memberId, assertActive: assert }).then(() => { assert(); toast("Member kicked") }) + }, } const manageMembersDialog = useMemo(() => { @@ -368,6 +370,7 @@ export function useChannelMemberViewModel({ .map((member) => ({ userId: member.userId, name: member.name ?? null, avatar: member.avatar })) return ( addThreadParticipantMut.mutateAsync(userId)} + onAdd={(userId, caller) => addThreadParticipantMut.mutateAsync({ userId, assertActive: originalView(caller) })} onClose={() => setMemberUi({ channelId, query: memberQuery, dialogOpen: false })} /> ) @@ -390,6 +393,7 @@ export function useChannelMemberViewModel({ })) return ( { void refetchAddableMembers() }, }} - onAdd={(userId) => addChannelMemberMut.mutateAsync(userId)} + onAdd={(userId, caller) => addChannelMemberMut.mutateAsync({ userId, assertActive: originalView(caller) })} onClose={() => setMemberUi({ channelId, query: memberQuery, dialogOpen: false })} /> ) - }, [ - addChannelMemberMut, - addThreadParticipantMut, - addableMembers, - addableMembersData, - addableMembersError, - addableMembersFetching, - addableMembersLoading, - channelName, - channelId, - currentChannelMeta, - currentUser.id, - isNotifyUnit, - manageMembersOpen, - memberQuery, - parentMembersData, - participantMembersData, - participantCandidatesError, - participantCandidatesLoading, - participantCandidatesResolved, - participantCandidatesRetrying, - refetchAddableMembers, - retryParticipantCandidates, - ]) + }, [manageMembersOpen, isNotifyUnit, addableMembers, serverId, channelId, channelName, addableMembersData, addableMembersLoading, addableMembersError, addableMembersFetching, participantMembersData, parentMembersData, currentChannelMeta?.name, participantCandidatesResolved, participantCandidatesLoading, participantCandidatesError, participantCandidatesRetrying, retryParticipantCandidates, currentUser.id, addThreadParticipantMut, originalView, setMemberUi, memberQuery, refetchAddableMembers, addChannelMemberMut]) + const memberNames = JSON.stringify(membersHook.members.map(({ userId, id, name }) => ({ userId, id, name }))) const resolveUserName = useMemo( - () => makeUserNameResolver(membersHook.members), - [membersHook.members], + () => makeUserNameResolver(JSON.parse(memberNames) as Array<{ userId: string; id: string; name: string }>), + [memberNames], ) return { diff --git a/src/web/src/components/community/members/member-list.tsx b/src/web/src/components/community/members/member-list.tsx index e3091eff4..3969adf95 100644 --- a/src/web/src/components/community/members/member-list.tsx +++ b/src/web/src/components/community/members/member-list.tsx @@ -1,6 +1,9 @@ "use client" -import { useEffect, useMemo, useRef, useState } from "react" +import { createStore, useSelector } from "@tanstack/react-store" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useCommunityViewSource } from "@/hooks/community/use-community-view-source" +import { useCallback, useEffect, useMemo, useRef } from "react" import { useVirtualizer } from "@tanstack/react-virtual" import { Shield, UserMinus, Check, Search, UserPlus, LogOut } from "lucide-react" import { @@ -16,7 +19,7 @@ import { hasStatus } from "../social/status-presets" import { tid } from "@/lib/community/testids" import type { Member } from "@/lib/community/models/people" import type { OpenProfile } from "@/components/community/social/profile-types" -import type { MemberManageContext } from "@/components/community/members/member-management-types" +import type { MemberManageContext, MemberOriginalView } from "@/components/community/members/member-management-types" import { canManageServer, isPresenceOffline, @@ -28,7 +31,7 @@ import { import { MemberIdentityRow } from "./member-identity-row" // The Leave/Remove confirm flow the row menu opens (private channel/post/thread). -type ManageConfirm = { kind: "leave" | "remove"; member: Member } +type ManageConfirm = { kind: "leave" | "remove"; memberId: string } const SETTABLE_ROLES: Role[] = ["admin", "member"] @@ -96,6 +99,7 @@ function flattenGroups(members: Member[]): FlatItem[] { } export function MemberList({ + scopeId = "members", members, loading, hasMore, @@ -109,6 +113,7 @@ export function MemberList({ onSetRole, onKick, }: { + scopeId?: string members: Member[] loading?: boolean hasMore?: boolean @@ -124,27 +129,53 @@ export function MemberList({ myRole?: Role onOpenProfile?: OpenProfile onSetRole?: (memberId: string, role: Role) => void - onKick?: (memberId: string) => Promise | void + onKick?: (memberId: string, assert?: MemberOriginalView) => Promise | void }) { - // Kick target stores BOTH the display name (for the confirm title) and the - // member row id (what the DELETE route keys on — passing the name here was a - // latent 404 bug on the community right-panel kick path). - const [kickTarget, setKickTarget] = useState<{ name: string; memberId: string } | null>(null) - const [kicking, setKicking] = useState(false) - // Leave/Remove confirm (private channel/post/thread roster). - const [manageConfirm, setManageConfirm] = useState(null) - const [actioning, setActioning] = useState(false) - const [query, setQuery] = useState("") + const source = useCommunityViewSource("member-list:" + scopeId) + const client = useQueryClient() + const commandKey = ["community", "member-list-command", scopeId] + const ui = useMemo(() => ({ scope: [scopeId, source.signal], store: createStore({ kickTargetId: null as string | null, manageConfirm: null as ManageConfirm | null, query: "" }) }), [scopeId, source.signal]).store + const kickTargetId = useSelector(ui, (state) => state.kickTargetId) + const manageConfirm = useSelector(ui, (state) => state.manageConfirm) + const query = useSelector(ui, (state) => state.query) + const setKickTargetId = useCallback((kickTargetId: string | null) => ui.setState((state) => ({ ...state, kickTargetId })), [ui]) + const setManageConfirm = useCallback((manageConfirm: ManageConfirm | null) => ui.setState((state) => ({ ...state, manageConfirm })), [ui]) + const setQuery = (query: string) => ui.setState((state) => ({ ...state, query })) + const kickTarget = members.find((member) => member.id === kickTargetId) + const manageTarget = members.find((member) => member.id === manageConfirm?.memberId) + type Intent = { kind: "kick" | "leave" | "remove"; memberId: string; userId: string; work: (id: string, assert?: MemberOriginalView) => Promise | void; assert: ReturnType } + const command = useMutation({ + mutationKey: commandKey, + gcTime: 0, + mutationFn: async (intent: Intent) => { + intent.assert() + await intent.work(intent.kind === "kick" ? intent.memberId : intent.userId, intent.assert) + intent.assert() + ui.setState((state) => intent.kind === "kick" + ? state.kickTargetId === intent.memberId ? { ...state, kickTargetId: null } : state + : state.manageConfirm?.memberId === intent.memberId && state.manageConfirm.kind === intent.kind ? { ...state, manageConfirm: null } : state) + }, + onError: (error, intent) => toastApiError(error, intent.kind === "kick" ? "Failed to kick member" : intent.kind === "leave" ? "Failed to leave" : "Failed to remove", intent.assert), + }) + const kicking = command.isPending && command.variables?.kind === "kick" && command.variables.assert.signal === source.signal + const actioning = command.isPending && command.variables?.kind !== "kick" && command.variables?.assert.signal === source.signal const canManage = canManageServer(myRole) + const pending = useCallback(() => client.getMutationCache().findAll({ mutationKey: ["community", "member-list-command", scopeId], exact: true, status: "pending" }).some((mutation) => (mutation.state.variables as Intent).assert.signal === source.signal), [client, scopeId, source.signal]) + useEffect(() => { + if (pending()) return + if (kickTargetId && !kickTarget) setKickTargetId(null) + if (manageConfirm && !manageTarget) setManageConfirm(null) + }, [kickTargetId, kickTarget, manageConfirm, manageTarget, command.status, pending, setKickTargetId, setManageConfirm]) // Debounced search — 200ms mirrors the hook's own debounce for consistency // (the hook debounces the network call; this debounces the callback fire // so we don't spam the hook on every keystroke). useEffect(() => { if (!onSearch) return - const t = setTimeout(() => onSearch(query), 200) + const assert = source.capture() + const t = setTimeout(() => { try { assert(); onSearch(query) } catch {} }, 200) return () => clearTimeout(t) - }, [query, onSearch]) + }, [query, onSearch, source.signal, source]) const items = useMemo(() => flattenGroups(members), [members]) const duplicateNames = useMemo(() => computeDuplicateNames(members), [members]) @@ -193,25 +224,17 @@ export function MemberList({ return ( <> { if (!o && !kicking) setKickTarget(null) }} - title={`Kick ${kickTarget?.name}?`} + open={!!kickTargetId} + onOpenChange={(o) => { if (!o && !kicking) setKickTargetId(null) }} + title={`Kick ${kickTarget?.name ?? "this member"}?`} description="They will be removed from this server but can rejoin with an invite." confirmLabel="Kick" loadingLabel="Kicking…" loading={kicking} confirmVariant="destructive" - onConfirm={async () => { - if (!kickTarget) return - setKicking(true) - try { - await onKick?.(kickTarget.memberId) - setKickTarget(null) - } catch (e) { - toastApiError(e, "Failed to kick member") - } finally { - setKicking(false) - } + onConfirm={() => { + if (!kickTarget || !onKick || pending()) return + command.mutate({ kind: "kick", memberId: kickTarget.id, userId: kickTarget.userId, work: onKick, assert: source.capture() }) }} /> {manageConfirm && ( @@ -221,7 +244,7 @@ export function MemberList({ title={ manageConfirm.kind === "leave" ? `Leave /${manageContext?.unitLabel ?? ""}?` - : `Remove ${manageConfirm.member.name}?` + : `Remove ${manageTarget?.name ?? "this member"}?` } description={ manageConfirm.kind === "leave" @@ -232,18 +255,10 @@ export function MemberList({ loadingLabel={manageConfirm.kind === "leave" ? "Leaving…" : "Removing…"} loading={actioning} confirmVariant="destructive" - onConfirm={async () => { - if (!manageContext) return - setActioning(true) - try { - const fn = manageConfirm.kind === "leave" ? manageContext.onLeave : manageContext.onRemove - await fn(manageConfirm.member.userId) - setManageConfirm(null) - } catch (e) { - toastApiError(e, manageConfirm.kind === "leave" ? "Failed to leave" : "Failed to remove") - } finally { - setActioning(false) - } + onConfirm={() => { + if (!manageContext || !manageTarget || pending()) return + const work = manageConfirm.kind === "leave" ? manageContext.onLeave : manageContext.onRemove + command.mutate({ kind: manageConfirm.kind, memberId: manageTarget.id, userId: manageTarget.userId, work, assert: source.capture() }) }} /> )} @@ -306,8 +321,8 @@ export function MemberList({ showDiscriminator={duplicateNames.has(item.member.name.toLowerCase())} onOpenProfile={onOpenProfile} onSetRole={onSetRole} - onKick={(mem) => setKickTarget({ name: mem.name, memberId: mem.id })} - onRequestManage={(kind, mem) => setManageConfirm({ kind, member: mem })} + onKick={(mem) => setKickTargetId(mem.id)} + onRequestManage={(kind, mem) => setManageConfirm({ kind, memberId: mem.id })} manageContext={manageContext} /> )} diff --git a/src/web/src/components/community/members/member-management-types.ts b/src/web/src/components/community/members/member-management-types.ts index 788d1d95b..6fd1fe513 100644 --- a/src/web/src/components/community/members/member-management-types.ts +++ b/src/web/src/components/community/members/member-management-types.ts @@ -3,6 +3,7 @@ // non-creator) / Remove (viewer is the unit creator, on other explicit members) // instead of the server-scoped Role/Kick menu. Remove is CREATOR-ONLY on every // unit (admins have no content privilege). The creator's own row is locked. +export type MemberOriginalView = (() => void) & { signal: AbortSignal } export type MemberManageContext = { viewerUserId: string // The viewer created this unit → their own row shows no Leave (owners keep @@ -14,6 +15,6 @@ export type MemberManageContext = { // Return a promise so the confirm dialog can show a loading state until the // remove settles. Resolved value is ignored (mutateAsync resolves to the API // payload) — the dialog only awaits settlement. - onLeave: (userId: string) => Promise | void - onRemove: (userId: string) => Promise | void + onLeave: (userId: string, assert?: MemberOriginalView) => Promise | void + onRemove: (userId: string, assert?: MemberOriginalView) => Promise | void } diff --git a/src/web/src/components/community/messages/attachment-card.dom.test.ts b/src/web/src/components/community/messages/attachment-card.dom.test.ts index c66dc685b..722710a73 100644 --- a/src/web/src/components/community/messages/attachment-card.dom.test.ts +++ b/src/web/src/components/community/messages/attachment-card.dom.test.ts @@ -1,9 +1,9 @@ import React, { type ReactElement } from "react" -import { act, fireEvent, render as rtlRender } from "@/test/react-dom-harness" +import { act, fireEvent, waitFor } from "@/test/react-dom-harness" +import { renderCommunity as rtlRender } from "@/test/community-owner-harness" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { AttachmentCard } from "./attachment-card" import type { FileAttachment } from "@/lib/community/models/message" -import { resetAttachmentDownloadsForTest } from "@/lib/community/attachment-download" const rtlContainer = document.createElement("div") function render(element: ReactElement) { @@ -23,9 +23,8 @@ function attachment(overrides: Partial = {}): FileAttachment { } describe("AttachmentCard", () => { - beforeEach(() => resetAttachmentDownloadsForTest()) + beforeEach(() => {}) afterEach(() => { - resetAttachmentDownloadsForTest() vi.unstubAllGlobals() }) @@ -67,9 +66,9 @@ describe("AttachmentCard", () => { expect(button).toHaveAttribute("aria-label", "Download 报告.zip") await act(async () => { fireEvent.click(button) - await Promise.resolve() - await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) }) + await waitFor(() => expect(renderer!.getByRole("status")).toHaveTextContent("Download started"), { container: rtlContainer }) expect(fetch).toHaveBeenCalledWith("/attachments/a1", { credentials: "same-origin", signal: expect.any(AbortSignal) }) expect(anchor).toEqual(expect.objectContaining({ href: "blob:file", download: "报告.zip" })) expect(anchor.click).toHaveBeenCalledOnce() @@ -171,6 +170,7 @@ describe("AttachmentCard", () => { fireEvent.click(buttons[0]!) fireEvent.click(buttons[1]!) }) + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) }) expect(renderer!.getAllByRole("status").map((node) => node.textContent)) .toEqual(["Downloading…", "Downloading…"]) expect(renderer!.getAllByRole("button").every((button) => !(button as HTMLButtonElement).disabled)) @@ -178,12 +178,13 @@ describe("AttachmentCard", () => { await Promise.resolve() expect(fetchMock).toHaveBeenCalledOnce() - streamController.enqueue(new TextEncoder().encode("complete")) - streamController.close() await act(async () => { - await Promise.resolve() - await Promise.resolve() + streamController.enqueue(new TextEncoder().encode("complete")) + streamController.close() + await new Promise((resolve) => setTimeout(resolve, 0)) }) + await waitFor(() => expect(renderer!.getAllByRole("status").map((node) => node.textContent)) + .toEqual(["Download started", "Download started"]), { container: rtlContainer }) expect(anchor.click).toHaveBeenCalledOnce() expect(renderer!.getAllByRole("status").map((node) => node.textContent)) .toEqual(["Download started", "Download started"]) diff --git a/src/web/src/components/community/messages/attachment-preview-sheet.dom.test.ts b/src/web/src/components/community/messages/attachment-preview-sheet.dom.test.ts index a596705d1..75ab88749 100644 --- a/src/web/src/components/community/messages/attachment-preview-sheet.dom.test.ts +++ b/src/web/src/components/community/messages/attachment-preview-sheet.dom.test.ts @@ -1,13 +1,13 @@ import React from "react" import { afterEach, describe, expect, it, vi } from "vitest" -import { act, fireEvent, render } from "@/test/react-dom-harness" +import { act, fireEvent, waitFor } from "@/test/react-dom-harness" +import { renderCommunity as render } from "@/test/community-owner-harness" import { AttachmentPreviewSheet, readAttachmentBytes, readAttachmentText, } from "./attachment-preview-sheet" import type { FileAttachment } from "@/lib/community/models/message" -import { resetAttachmentDownloadsForTest } from "@/lib/community/attachment-download" import { MAX_PDF_ATTACHMENT_PREVIEW_BYTES } from "@/lib/community/attachment-presentation" const dynamicMock = vi.hoisted(() => ({ @@ -100,13 +100,12 @@ function file(overrides: Partial = {}): FileAttachment { async function flush(): Promise { await act(async () => { - await Promise.resolve() - await Promise.resolve() + if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(0) + else await new Promise((resolve) => setTimeout(resolve, 0)) }) } afterEach(() => { - resetAttachmentDownloadsForTest() vi.restoreAllMocks() vi.unstubAllGlobals() }) @@ -169,7 +168,6 @@ describe("AttachmentPreviewSheet", () => { }) it("fetches private Markdown, renders it safely, and exposes metadata/download", async () => { - resetAttachmentDownloadsForTest() const fetchMock = vi.fn() .mockResolvedValueOnce(new Response("# Hello", { status: 200 })) .mockResolvedValueOnce(new Response("download bytes", { status: 200 })) @@ -189,7 +187,7 @@ describe("AttachmentPreviewSheet", () => { })) await flush() expect(fetchMock).toHaveBeenCalledWith("/attachments/a1", expect.objectContaining({ credentials: "same-origin", signal: expect.any(AbortSignal) })) - expect(renderer.container.querySelector("[data-markdown]")?.textContent).toBe("# Hello") + await waitFor(() => expect(renderer.container.querySelector("[data-markdown]")?.textContent).toBe("# Hello")) const download = renderer.container.querySelector( '[data-testid="community-attachment-preview-download"]', )! @@ -205,12 +203,13 @@ describe("AttachmentPreviewSheet", () => { await Promise.resolve() }) expect(fetchMock).toHaveBeenNthCalledWith(2, "/attachments/a1", { credentials: "same-origin", signal: expect.any(AbortSignal) }) + await waitFor(() => expect(click).toHaveBeenCalledOnce()) expect(anchor).toEqual(expect.objectContaining({ href: "blob:preview", download: "notes.md" })) expect(click).toHaveBeenCalledOnce() - expect(renderer.container.querySelector( + await waitFor(() => expect(renderer.container.querySelector( '[data-testid="community-attachment-preview-download"]', )?.textContent) - .toContain("Download started") + .toContain("Download started")) }) it("aborts the old request on switch and never paints its stale result", async () => { @@ -238,7 +237,7 @@ describe("AttachmentPreviewSheet", () => { requests[1]!.resolve(new Response("second", { status: 200 })) await flush() - expect(renderer.container.querySelector("pre")?.textContent).toBe("second") + await waitFor(() => expect(renderer.container.querySelector("pre")?.textContent).toBe("second")) requests[0]!.resolve(new Response("stale", { status: 200 })) await flush() @@ -264,8 +263,8 @@ describe("AttachmentPreviewSheet", () => { expect(fetchMock).toHaveBeenCalledWith("/attachments/a1", expect.objectContaining({ credentials: "same-origin", })) - expect(renderer.container.querySelector('[data-testid="community-pdf-preview"]') - ?.getAttribute("data-byte-length")).toBe("4") + await waitFor(() => expect(renderer.container.querySelector('[data-testid="community-pdf-preview"]') + ?.getAttribute("data-byte-length")).toBe("4")) expect(renderer.container.querySelector( '[data-testid="community-attachment-preview-content"]', )?.className) @@ -333,8 +332,8 @@ describe("AttachmentPreviewSheet", () => { })) await flush() - expect([...renderer.container.querySelectorAll("p")] - .some((node) => node.textContent?.includes("403"))).toBe(true) + await waitFor(() => expect([...renderer.container.querySelectorAll("p")] + .some((node) => node.textContent?.includes("403"))).toBe(true)) expect(renderer.container.querySelector( '[data-testid="community-attachment-preview-download"]', )).not.toBeNull() @@ -348,6 +347,7 @@ describe("AttachmentPreviewSheet", () => { onOpenChange: vi.fn(), })) await flush() + await waitFor(() => expect(renderer.container.querySelector('[data-code-language="typescript"]')?.textContent).toBe("const answer = 42")) const preview = renderer.container.querySelector('[data-code-language="typescript"]')! expect(preview.textContent).toBe("const answer = 42") expect(renderer.container.querySelector( diff --git a/src/web/src/components/community/messages/attachment-preview-sheet.tsx b/src/web/src/components/community/messages/attachment-preview-sheet.tsx index 72c1c9cb4..2e846058a 100644 --- a/src/web/src/components/community/messages/attachment-preview-sheet.tsx +++ b/src/web/src/components/community/messages/attachment-preview-sheet.tsx @@ -1,6 +1,7 @@ "use client" -import { useEffect, useState } from "react" +import { useQuery } from "@tanstack/react-query" +import { applicationKey, assertApplicationOwner, captureApplicationOwner, useApplicationOwner } from "@/lib/application-owner" import dynamic from "next/dynamic" import { CircleCheck, Download, Loader2, RefreshCw } from "lucide-react" import { buttonVariants } from "@/components/ui/button" @@ -111,6 +112,7 @@ export async function readAttachmentBytes( } if (!response.body) { const bytes = new Uint8Array(await response.arrayBuffer()) + if (signal?.aborted) throw new DOMException("Aborted", "AbortError") if (bytes.byteLength > maxBytes) throw new Error("This file is too large to preview") return bytes } @@ -122,6 +124,7 @@ export async function readAttachmentBytes( while (true) { if (signal?.aborted) throw new DOMException("Aborted", "AbortError") const { done, value } = await reader.read() + if (signal?.aborted) throw new DOMException("Aborted", "AbortError") if (done) break byteLength += value.byteLength if (byteLength > maxBytes) { @@ -138,6 +141,7 @@ export async function readAttachmentBytes( } return bytes } finally { + if (signal?.aborted) await reader.cancel().catch(() => undefined) reader.releaseLock() } } @@ -151,67 +155,36 @@ export function AttachmentPreviewSheet({ open: boolean onOpenChange: (open: boolean) => void }) { - const [selected, setSelected] = useState(attachment) - const [preview, setPreview] = useState(IDLE_STATE) - useEffect(() => { - if (attachment) setSelected(attachment) - }, [attachment]) - - useEffect(() => { - if (!open || !selected) { - setPreview(IDLE_STATE) - return - } - const presentation = resolveAttachmentPresentation(selected.name, selected.contentType) - if (!presentation.previewKind) { - setPreview({ status: "error", content: null, error: "This file type can’t be previewed" }) - return - } - const maxBytes = presentation.previewKind === "pdf" - ? MAX_PDF_ATTACHMENT_PREVIEW_BYTES - : MAX_TEXT_ATTACHMENT_PREVIEW_BYTES - if (selected.sizeBytes !== undefined && selected.sizeBytes > maxBytes) { - setPreview({ status: "error", content: null, error: "This file is too large to preview" }) - return - } - - const controller = new AbortController() - let active = true - setPreview({ status: "loading", content: null, error: null }) - fetch(selected.url, { credentials: "same-origin", signal: controller.signal }) - .then(async (response): Promise => presentation.previewKind === "pdf" - ? { - status: "ready", - kind: "pdf", - content: await readAttachmentBytes(response, maxBytes, controller.signal), - error: null, - } - : { - status: "ready", - kind: "text", - content: await readAttachmentText(response, maxBytes, controller.signal), - error: null, - }) - .then((content) => { - if (active) setPreview(content) - }) - .catch((error: unknown) => { - if (!active || controller.signal.aborted) return - setPreview({ - status: "error", - content: null, - error: error instanceof Error ? error.message : "Couldn’t load this attachment", - }) - }) - return () => { - active = false - controller.abort() - } - }, [open, selected]) + const owner = useApplicationOwner() + const selected = attachment + const presentation = selected ? resolveAttachmentPresentation(selected.name, selected.contentType) : null + const maxBytes = presentation?.previewKind === "pdf" ? MAX_PDF_ATTACHMENT_PREVIEW_BYTES : MAX_TEXT_ATTACHMENT_PREVIEW_BYTES + const unavailable = !presentation?.previewKind ? "This file type can’t be previewed" + : selected?.sizeBytes !== undefined && selected.sizeBytes > maxBytes ? "This file is too large to preview" : null + const content = useQuery({ + queryKey: applicationKey(owner, "attachment-preview", open ? selected?.url ?? null : null, presentation?.previewKind ?? null, maxBytes), + enabled: open && selected !== null && unavailable === null, gcTime: 0, staleTime: Infinity, retry: false, + queryFn: async ({ signal }) => { + const token = captureApplicationOwner(owner) + const assert = () => assertApplicationOwner(token, signal) + assert() + if (!selected || !presentation?.previewKind) throw new Error("No attachment selected") + try { + const response = await fetch(selected.url, { credentials: "same-origin", signal }) + assert() + const result = presentation.previewKind === "pdf" + ? { status: "ready" as const, kind: "pdf" as const, content: await readAttachmentBytes(response, maxBytes, signal), error: null } + : { status: "ready" as const, kind: "text" as const, content: await readAttachmentText(response, maxBytes, signal), error: null } + assert() + return result + } catch (error) { assert(); throw error } + }, + }, owner.queryClient) + const preview: PreviewState = !open || !selected ? IDLE_STATE + : unavailable ? { status: "error", content: null, error: unavailable } + : content.isError ? { status: "error", content: null, error: content.error instanceof Error ? content.error.message : "Couldn’t load this attachment" } + : content.data ?? { status: "loading", content: null, error: null } - const presentation = selected - ? resolveAttachmentPresentation(selected.name, selected.contentType) - : null const size = selected ? formatAttachmentSize(selected.sizeBytes) || selected.size : "" diff --git a/src/web/src/components/community/messages/code-preview.dom.test.ts b/src/web/src/components/community/messages/code-preview.dom.test.ts index 6b2f3c4a8..6abaff346 100644 --- a/src/web/src/components/community/messages/code-preview.dom.test.ts +++ b/src/web/src/components/community/messages/code-preview.dom.test.ts @@ -1,5 +1,6 @@ import React from "react" -import { act, fireEvent, render } from "@/test/react-dom-harness" +import { act, fireEvent, waitFor } from "@/test/react-dom-harness" +import { renderCommunity as render } from "@/test/community-owner-harness" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import type { CodeHighlightResult } from "@/lib/community/code-highlight" import { tid } from "@/lib/community/testids" @@ -26,8 +27,8 @@ function deferred() { async function flush(): Promise { await act(async () => { - await Promise.resolve() - await Promise.resolve() + if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(0) + else await new Promise((resolve) => setTimeout(resolve, 0)) }) } @@ -190,7 +191,7 @@ describe("CodePreview", () => { reason: null, }) await flush() - expect(renderer!.container.querySelector("code span")).toHaveTextContent("new-token") + await waitFor(() => expect(renderer!.container.querySelector("code span")).toHaveTextContent("new-token")) first.resolve({ kind: "highlighted", diff --git a/src/web/src/components/community/messages/code-preview.tsx b/src/web/src/components/community/messages/code-preview.tsx index f3125f964..5d0bd1af5 100644 --- a/src/web/src/components/community/messages/code-preview.tsx +++ b/src/web/src/components/community/messages/code-preview.tsx @@ -1,6 +1,9 @@ "use client" -import { useEffect, useRef, useState, type CSSProperties } from "react" +import { useQuery } from "@tanstack/react-query" +import { useCommunityViewSource } from "@/hooks/community/use-community-view-source" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useEffect, useRef, type CSSProperties } from "react" import { Check, Copy, WrapText } from "lucide-react" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" @@ -83,41 +86,38 @@ export function CodePreview({ content: string language: ShikiLanguage | null }) { - const [highlight, setHighlight] = useState({ kind: "plain", lines: null, reason: null }) - const [highlighting, setHighlighting] = useState(false) - const [wrap, setWrap] = useState(false) - const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle") + const source = useCommunityViewSource(`code-preview:${language ?? "plain"}:${content}`) + const query = useQuery({ queryKey: ["community", "code-highlight", language, content], gcTime: 0, staleTime: Infinity, retry: false, + queryFn: async ({ signal }) => { + const result = await highlightCode(content, language) + if (signal.aborted) throw new DOMException("Retired code preview", "AbortError") + return result + }, + }) + const highlight: CodeHighlightResult = query.data ?? { kind: "plain", lines: null, reason: null } + const highlighting = query.isFetching + const [wrap, setWrap] = useAtom(useCreateAtom(false)) + const [copyState, setCopyState] = useAtom(useCreateAtom<"idle" | "copied" | "failed">("idle")) const copyTimer = useRef | null>(null) useEffect(() => { - let active = true - setHighlight({ kind: "plain", lines: null, reason: null }) - setHighlighting(language !== null) setCopyState("idle") - if (copyTimer.current) clearTimeout(copyTimer.current) - void highlightCode(content, language).then((result) => { - if (!active) return - setHighlight(result) - setHighlighting(false) - }) - return () => { - active = false - } - }, [content, language]) - - useEffect(() => () => { - if (copyTimer.current) clearTimeout(copyTimer.current) - }, []) + return () => { if (copyTimer.current) { clearTimeout(copyTimer.current); copyTimer.current = null } } + }, [content, language, setCopyState]) async function copyContent(): Promise { + const original = source.capture() + original() try { await navigator.clipboard.writeText(content) + original() setCopyState("copied") } catch { + try { original() } catch { return } setCopyState("failed") } if (copyTimer.current) clearTimeout(copyTimer.current) - copyTimer.current = setTimeout(() => setCopyState("idle"), 2_000) + copyTimer.current = setTimeout(() => { try { original(); setCopyState("idle") } catch {} }, 2_000) } const fallbackLines = sourceLines(content) diff --git a/src/web/src/components/community/messages/composer-accessory-rail.dom.test.ts b/src/web/src/components/community/messages/composer-accessory-rail.dom.test.ts index e6fe135b0..ddcb05ed5 100644 --- a/src/web/src/components/community/messages/composer-accessory-rail.dom.test.ts +++ b/src/web/src/components/community/messages/composer-accessory-rail.dom.test.ts @@ -7,11 +7,11 @@ import { fireEvent, render, type RenderResult } from "@/test/react-dom-harness" import { ComposerAccessoryRail, MessageSelectionFooter } from "./composer-accessory-rail" vi.mock("@/components/ui/number-ticker", () => ({ - NumberTicker: ({ value }: { value: number }) => React.createElement("ticker", { value }), + NumberTicker: ({ value }: { value: number }) => React.createElement("mock-ticker", { value }), })) vi.mock("@/components/ui/tooltip", () => ({ - Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement("tooltip", null, children), + Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement("mock-tooltip", null, children), TooltipTrigger: ({ render: trigger, children, diff --git a/src/web/src/components/community/messages/composer-suggestion-popups.dom.test.ts b/src/web/src/components/community/messages/composer-suggestion-popups.dom.test.ts index 3a2bb7ce0..398bbdff4 100644 --- a/src/web/src/components/community/messages/composer-suggestion-popups.dom.test.ts +++ b/src/web/src/components/community/messages/composer-suggestion-popups.dom.test.ts @@ -15,7 +15,7 @@ vi.mock("@/lib/community/popup-scroll", () => ({ nextListScrollTop: (...args: unknown[]) => mocks.nextScrollTop(...args), })) vi.mock("../avatar", () => ({ - Avatar: (props: Record) => createElement("span", { ...props, "data-avatar": "" }), + Avatar: ({ className }: Record) => createElement("span", { className, "data-avatar": "" }), })) vi.mock("../channels/channel-icon", () => ({ ChannelIcon: (props: Record) => diff --git a/src/web/src/components/community/messages/composer.send-lifecycle.dom.test.ts b/src/web/src/components/community/messages/composer.send-lifecycle.dom.test.ts index 8301dbcb3..aea8aad34 100644 --- a/src/web/src/components/community/messages/composer.send-lifecycle.dom.test.ts +++ b/src/web/src/components/community/messages/composer.send-lifecycle.dom.test.ts @@ -1,6 +1,9 @@ -import React from "react" +import React, { useLayoutEffect } from "react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { act, render } from "@/test/react-dom-harness" +import { act } from "@/test/react-dom-harness" +import { renderCommunity as render } from "@/test/community-owner-harness" +import { useCommunityRuntime, type CommunityRuntime } from "@/stores/community/runtime" +import { acceptChannelMessage, runAcceptedMessageIntent } from "./message-channel-controller-send" const mocks = vi.hoisted(() => ({ useEditor: vi.fn(), @@ -57,6 +60,10 @@ vi.mock("./composer-ordered-list", () => ({ serializeComposerDocument: (...args: unknown[]) => mocks.serializeDocument(...args), })) +vi.mock("@/hooks/community/mutations", async (importOriginal) => ({ + ...await importOriginal(), + sendNonce: () => "nonce_collision", +})) import { Composer, type ComposerProps } from "./composer" import type { PendingFile } from "@/hooks/use-file-attachments" @@ -201,6 +208,70 @@ describe("Composer committed send lifecycle", () => { expect(transferPendingFiles).toHaveBeenCalledOnce() }) + it("preserves the latest Composer draft when the real acceptor rejects a duplicate nonce", async () => { + let runtime!: CommunityRuntime + const messageScope = { kind: "channel" as const, id: "channel_1", serverId: "server_1" } + const viewer = { id: "viewer", name: "Viewer", avatar: "V" } + const replyTo = { id: "reply_1", authorName: "Latest reply", text: "Original target" } + const upload = vi.fn() + const post = vi.fn() + const clearReply = vi.fn() + const runner = vi.fn((nonce: string) => runAcceptedMessageIntent({ + runtime, messageScope, nonce, uploadFileAsync: upload, sendMessageAsync: post, + channelId: messageScope.id, serverId: messageScope.serverId, viewer, + })) + const accept = vi.fn((...[markdown, attachments, mentionType]: Parameters>) => acceptChannelMessage({ + runtime, markdown, attachments, mentionType, messageScope, viewer, replyTo, + runAcceptedIntent: runner, channelId: messageScope.id, clearReply, + })) + function ConnectedComposer() { + const value = useCommunityRuntime() + useLayoutEffect(() => { runtime = value }, [value]) + return React.createElement(Composer, { + channel: "general", context: "channel", members: [], sendContract: "accepted", + hideAttach: true, hideEmoji: true, replyingTo: replyTo, onAcceptSend: accept, + } satisfies ComposerProps) + } + const createPreview = vi.fn(() => "blob:new-rejected") + const revokePreview = vi.fn() + vi.stubGlobal("URL", Object.assign(URL, { createObjectURL: createPreview, revokeObjectURL: revokePreview })) + const renderer = render(React.createElement(ConnectedComposer)) + try { + const original = new File(["original"], "original.txt", { type: "text/plain" }) + act(() => expect(runtime.messageStream.actions.accept(messageScope, { + nonce: "nonce_collision", tempId: "temp_original", message: { type: "chat", content: "original body" }, + localUploads: [{ file: original, previewObjectUrl: "blob:original" }], + })).toBe(true)) + const originalPayload = runtime.messageStream.actions.getRetryPayload(messageScope, "nonce_collision") + const file = new File(["keep me"], "rejected.txt", { type: "text/plain" }) + const supplied = new File(["keep supplied"], "supplied.txt", { type: "text/plain" }) + pendingFiles = [ + { file, thumbnailUrl: null, thumbnailBlob: null }, + { file: supplied, thumbnailUrl: "blob:supplied", thumbnailBlob: null }, + ] + renderer.rerender(React.createElement(ConnectedComposer)) + await act(async () => { + firstEditorOptions!.editorProps.handleKeyDown({} as never, { + key: "Enter", shiftKey: false, isComposing: false, preventDefault: vi.fn(), + } as unknown as KeyboardEvent) + }) + expect(accept).toHaveReturnedWith(false) + expect(runtime.messageStream.actions.getRetryPayload(messageScope, "nonce_collision")).toEqual(originalPayload) + expect(runner).not.toHaveBeenCalled() + expect(upload).not.toHaveBeenCalled() + expect(post).not.toHaveBeenCalled() + expect(clearReply).not.toHaveBeenCalled() + expect(clearContent).not.toHaveBeenCalled() + expect(transferPendingFiles).not.toHaveBeenCalled() + expect(renderer.container.textContent).toContain("rejected.txt") + expect(renderer.container.textContent).toContain("supplied.txt") + expect(renderer.container.textContent).toContain("Latest reply") + expect(mocks.serializeDocument).toHaveReturnedWith("9. latest\n10. draft") + expect(createPreview).toHaveBeenCalledOnce() + expect(revokePreview.mock.calls).toEqual([["blob:new-rejected"]]) + } finally { renderer.unmount(); vi.unstubAllGlobals() } + }) + it("waits for same-tick file preparation and sends once with the exact thumbnail Blob", async () => { let releasePreparation!: () => void const preparation = new Promise((resolve) => { releasePreparation = resolve }) diff --git a/src/web/src/components/community/messages/conversation-footer-shell.tsx b/src/web/src/components/community/messages/conversation-footer-shell.tsx index 9434e283f..ca14bf6bc 100644 --- a/src/web/src/components/community/messages/conversation-footer-shell.tsx +++ b/src/web/src/components/community/messages/conversation-footer-shell.tsx @@ -1,35 +1,26 @@ "use client" -import { - createContext, - useCallback, - useContext, - useMemo, - useState, - type Dispatch, - type HTMLAttributes, - type ReactNode, - type SetStateAction, -} from "react" +import { useAtom, useCreateAtom, type Atom } from "@tanstack/react-store"; +import { createContext, useCallback, useContext, useMemo, type HTMLAttributes, type ReactNode } from "react" type ConversationFooterSlot = { target: HTMLDivElement | null - setTarget: Dispatch> + setTarget: Atom["set"] selectionActive: boolean - setSelectionActive: Dispatch> + setSelectionActive: Atom["set"] } const ConversationFooterSlotContext = createContext(null) export function ConversationFooterSlotProvider({ children }: { children: ReactNode }) { - const [target, setTarget] = useState(null) - const [selectionActive, setSelectionActive] = useState(false) +const [target, setTarget] = useAtom(useCreateAtom(null)) + const [selectionActive, setSelectionActive] = useAtom(useCreateAtom(false)) const value = useMemo(() => ({ target, setTarget, selectionActive, setSelectionActive, - }), [selectionActive, target]) + }), [selectionActive, setSelectionActive, setTarget, target]) return ( diff --git a/src/web/src/components/community/messages/create-forum-thread.dom.test.ts b/src/web/src/components/community/messages/create-forum-thread.dom.test.ts index 0c7634999..999d75148 100644 --- a/src/web/src/components/community/messages/create-forum-thread.dom.test.ts +++ b/src/web/src/components/community/messages/create-forum-thread.dom.test.ts @@ -6,7 +6,9 @@ import { beforeEach, describe, it, expect, vi } from "vitest" import { createElement } from "react" import { renderToStaticMarkup } from "react-dom/server" -import { act, fireEvent, render as renderDom } from "@/test/react-dom-harness" +import { act, fireEvent, render as renderDom, waitFor } from "@/test/react-dom-harness" +import { CommunityTestProvider } from "@/test/community-owner-fixture" +import { createCommunityQueryOwner } from "@/test/community-query-owner" const uploadMock = vi.hoisted(() => vi.fn()) const composerCapture = vi.hoisted(() => ({ current: null as Record | null })) @@ -22,32 +24,29 @@ vi.mock("./composer", () => ({ "data-hide-emoji": String(!!props.hideEmoji), "data-placeholder": (props.placeholder as string) ?? "", onSubmit: props.onDeferredSubmit, - onDirty: props.onDirty, }) }, })) -vi.mock("@/hooks/community/mutations/uploads", () => ({ - useUploadFile: () => ({ mutateAsync: uploadMock }), - zipUploadResultsWithDimensions: (results: unknown[]) => results, -})) +vi.mock("@/lib/api/client", () => ({ apiFetch: (...args: unknown[]) => uploadMock(...args), toastApiError: vi.fn() })) import { CreateForumThread } from "./create-forum-thread" +let owner: Awaited> +beforeEach(async () => { uploadMock.mockReset(); owner = await createCommunityQueryOwner() }) function render(over: Partial[0]> = {}) { return renderToStaticMarkup( - createElement(CreateForumThread, { + createElement(CommunityTestProvider, { client: owner.client, registry: owner.registry, retainOwner: true }, createElement(CreateForumThread, { forumChannelId: "cha_forum", members: [], onCancel: () => {}, onCreatePost: async () => {}, ...over, - }), + })), ) } describe("CreateForumThread — copy + structure", () => { - beforeEach(() => uploadMock.mockReset()) it("renders the region role + label so keyboard/SR users land in a named region", () => { const html = render() expect(html).toContain('role="region"') @@ -121,12 +120,12 @@ describe("CreateForumThread — copy + structure", () => { }) const file = new File(["original"], "photo.png", { type: "image/png" }) const thumbnailBlob = new Blob(["thumbnail"], { type: "image/jpeg" }) - const renderer = renderDom(createElement(CreateForumThread, { + const renderer = renderDom(createElement(CommunityTestProvider, { client: owner.client, registry: owner.registry, retainOwner: true }, createElement(CreateForumThread, { forumChannelId: "cha_forum", members: [], onCancel: () => {}, onCreatePost, - })) + }))) fireEvent.change(renderer.getByPlaceholderText("New post"), { target: { value: "Post" } }) act(() => (composerCapture.current?.onDirty as (dirty: boolean) => void)(true)) const attachments = [{ file, thumbnailBlob, width: 640, height: 480 }] @@ -139,12 +138,18 @@ describe("CreateForumThread — copy + structure", () => { await act(async () => { await submit("photo", attachments, undefined) }) + await waitFor(() => { + expect(onCreatePost).toHaveBeenCalledOnce() + expect(owner.client.getMutationCache().findAll({ mutationKey: ["community", "forum-compose"], status: "pending" })).toHaveLength(0) + }) await act(async () => { await submit("photo", attachments, undefined) }) + await waitFor(() => expect(onCreatePost).toHaveBeenCalledTimes(2)) expect(uploadMock).toHaveBeenCalledTimes(1) - expect(uploadMock).toHaveBeenCalledWith(expect.objectContaining({ thumbnailBlob })) + expect(uploadMock).toHaveBeenCalledWith("/api/community/channels/cha_forum/attachments", expect.objectContaining({ body: expect.any(FormData), authenticationAccount: "viewer", signal: expect.any(AbortSignal) })) + expect(uploadMock.mock.calls[0][1].body.get("thumbnail")).toMatchObject({ size: thumbnailBlob.size, type: "image/jpeg" }) expect(onCreatePost).toHaveBeenCalledTimes(2) for (const [post] of onCreatePost.mock.calls) { expect(post.attachments).toEqual([expect.objectContaining({ hasThumbnail: true })]) diff --git a/src/web/src/components/community/messages/create-forum-thread.tsx b/src/web/src/components/community/messages/create-forum-thread.tsx index 83b6a6b0e..62a328e24 100644 --- a/src/web/src/components/community/messages/create-forum-thread.tsx +++ b/src/web/src/components/community/messages/create-forum-thread.tsx @@ -1,6 +1,10 @@ "use client" -import { useCallback, useRef, useState } from "react" +import { useAtom, useCreateAtom, useCreateStore } from "@tanstack/react-store"; +import { useCallback, useRef, useMemo, useEffect } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useCommunityViewSource } from "@/hooks/community/use-community-view-source" +import type { UploadFileArgs, UploadFileResult } from "@/hooks/community/mutations/uploads" import { PlusCircle, Upload, X } from "lucide-react" import { Button } from "@/components/ui/button" import { Kbd } from "@/components/ui/kbd" @@ -30,6 +34,7 @@ import { // attachments and the audience-broadcast `mentionType` extracted from the body // text. Tags are added AFTER creation from the post card's tag dialog, not here. export type NewForumThread = { + assertActive?: (() => void) & { signal: AbortSignal } nonce: string name: string content: string @@ -55,83 +60,57 @@ export function CreateForumThread({ // (failure — child toasts and preserves state for retry). onCreatePost: (post: NewForumThread) => Promise }) { - const [title, setTitle] = useState("") - const [bodyHasContent, setBodyHasContent] = useState(false) - const [isSubmitting, setIsSubmitting] = useState(false) - // Ref-mirrored `isSubmitting` — the state-based flag is set asynchronously, - // so a rapid second Shift+Enter (which fires the editor's keydown BEFORE - // React commits `setIsSubmitting(true)`) sees a stale `false` and double- - // submits. The button's own `disabled` attribute blocks click-based - // double-submits, but keyboard submits go straight through the composer. - const submittingRef = useRef(false) + const [title, setTitle] = useAtom(useCreateAtom("")) + const [bodyHasContent, setBodyHasContent] = useAtom(useCreateAtom(false)) + const client = useQueryClient() + const scopeId = useMemo(() => crypto.randomUUID(), []) + const source = useCommunityViewSource(`forum-compose:${scopeId}`) + const nonce = useCreateStore({ value: crypto.randomUUID() }) const bodyComposerRef = useRef(null) - const uploadFile = useUploadFile() - - // Retry cache: files already uploaded to R2 skip re-upload on retry after a - // create failure. Mutation-only (ref, not state) — read inside the async - // submit path. Cleared after a successful create. - const uploadedCacheRef = useRef>(new Map()) - const commandNonceRef = useRef(crypto.randomUUID()) - + const uploadFile = useUploadFile({ gcTime: Infinity }) + const key = ["community", "forum-compose", scopeId] const canSubmit = title.trim().length > 0 && bodyHasContent - - const onCancelGuarded = useCallback(() => { - // Belt-and-suspenders: Escape mid-upload from ANY source (title, root, - // composer body, footer button) is a no-op. Prevents orphaned R2 objects - // and half-submitted posts. - if (isSubmitting) return + const clearUploadReceipts = useCallback(() => { + for (const mutation of client.getMutationCache().findAll({ mutationKey: ["community", "file-upload"], predicate: (mutation) => (mutation.state.variables as UploadFileArgs | undefined)?.receiptScope === scopeId })) client.getMutationCache().remove(mutation) + }, [client, scopeId]) + useEffect(() => () => clearUploadReceipts(), [clearUploadReceipts]) + const command = useMutation({ mutationKey: key, gcTime: 0, + mutationFn: async ({ markdown, title, attachments, mentionType, nonce, original }: { markdown: string; title: string; attachments: SendAttachment[]; mentionType?: MentionType; nonce: string; original: ReturnType }) => { + original() + const results = await Promise.all(attachments.map(async (attachment) => { + const cached = client.getMutationCache().findAll({ mutationKey: ["community", "file-upload"], status: "success", predicate: (mutation) => { + const input = mutation.state.variables as UploadFileArgs | undefined + return input?.receiptScope === scopeId && input.file === attachment.file && input.width === attachment.width && input.height === attachment.height && input.assertActive?.signal === original.signal + } }).at(-1)?.state.data as UploadFileResult | undefined + if (cached) return cached + return uploadFile.mutateAsync({ target: { channelId: forumChannelId }, receiptScope: scopeId, assertActive: original, ...attachment }) + })) + original() + const uploaded = zipUploadResultsWithDimensions(results, attachments) + await onCreatePost({ nonce, name: title, content: markdown, attachments: uploaded.length ? uploaded : undefined, mentionType, assertActive: original }) + return null + }, + onSuccess: (_result, input) => { + try { input.original() } catch { return } + clearUploadReceipts() + nonce.setState(() => ({ value: crypto.randomUUID() })) + bodyComposerRef.current?.resetAfterSubmit() + setTitle("") + }, + onError: (error, input) => toastApiError(error, "Failed to create post", input.original), + }) + const isSubmitting = command.isPending + const onCancelGuarded = () => { + if (client.getMutationCache().findAll({ mutationKey: key, status: "pending" }).length) return + source.retire() onCancel() - }, [isSubmitting, onCancel]) - + } const focusBody = () => bodyComposerRef.current?.focusEditor() - const doSubmit = async (markdown: string, attachments: SendAttachment[] | undefined, mentionType: MentionType | undefined) => { - if (!canSubmit || submittingRef.current) return - submittingRef.current = true - setIsSubmitting(true) - try { - const pending = attachments ?? [] - let uploaded: UploadedAttachment[] = [] - if (pending.length > 0) { - const results = await Promise.all( - pending.map(async (att) => { - const cached = uploadedCacheRef.current.get(att.file) - if (cached) return cached - const res = await uploadFile.mutateAsync({ - target: { channelId: forumChannelId }, - file: att.file, - thumbnailBlob: att.thumbnailBlob, - width: att.width, - height: att.height, - }) - uploadedCacheRef.current.set(att.file, res) - return res - }), - ) - uploaded = zipUploadResultsWithDimensions(results, pending) - } - try { - await onCreatePost({ - nonce: commandNonceRef.current, - name: title.trim(), - content: markdown, - attachments: uploaded.length > 0 ? uploaded : undefined, - mentionType, - }) - } catch (e) { - toastApiError(e, "Failed to create post") - return - } - uploadedCacheRef.current.clear() - commandNonceRef.current = crypto.randomUUID() - bodyComposerRef.current?.resetAfterSubmit() - setTitle("") - } catch (e) { - toastApiError(e, "Failed to upload attachment") - } finally { - submittingRef.current = false - setIsSubmitting(false) - } + if (!canSubmit || client.getMutationCache().findAll({ mutationKey: key, status: "pending" }).length) return + const original = source.capture() + original() + try { await command.mutateAsync({ markdown, title: title.trim(), attachments: attachments ?? [], mentionType, nonce: nonce.get().value, original }) } catch {} } const handleBodySubmit = (markdown: string, attachments: SendAttachment[] | undefined, mentionType: MentionType | undefined) => { diff --git a/src/web/src/components/community/messages/emoji-picker.tsx b/src/web/src/components/community/messages/emoji-picker.tsx index 3955f36ae..02fb92771 100644 --- a/src/web/src/components/community/messages/emoji-picker.tsx +++ b/src/web/src/components/community/messages/emoji-picker.tsx @@ -1,6 +1,7 @@ "use client" -import { useState } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; + import type React from "react" import { useTheme } from "next-themes" import EmojiMartPicker from "@emoji-mart/react" @@ -34,7 +35,7 @@ export function EmojiPickerPopover({ align?: "start" | "center" | "end" onOpenChange?: (open: boolean) => void }) { - const [open, setOpen] = useState(false) + const [open, setOpen] = useAtom(useCreateAtom(false)) const setBoth = (o: boolean) => { setOpen(o); onOpenChange?.(o) } return ( diff --git a/src/web/src/components/community/messages/image-lightbox.tsx b/src/web/src/components/community/messages/image-lightbox.tsx index 985226cf5..f427a1ace 100644 --- a/src/web/src/components/community/messages/image-lightbox.tsx +++ b/src/web/src/components/community/messages/image-lightbox.tsx @@ -1,6 +1,8 @@ "use client" -import { useEffect, useMemo, useState } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; + +import { useEffect, useMemo } from "react" import { Dialog, DialogContent } from "@/components/ui/dialog" import type { ImagePreview } from "@/lib/community/models/message" import { tid } from "@/lib/community/testids" @@ -16,8 +18,8 @@ function PreviewFrame({ image }: { image: ImagePreview }) { () => validImageDimensions(image.width, image.height), [image.height, image.width], ) - const [dimensions, setDimensions] = useState(knownDimensions) - const [revealedAttempt, setRevealedAttempt] = useState(null) + const [dimensions, setDimensions] = useAtom(useCreateAtom(knownDimensions)) + const [revealedAttempt, setRevealedAttempt] = useAtom(useCreateAtom(null)) const [ thumbnailStatus, thumbnailAttempt, @@ -55,7 +57,7 @@ function PreviewFrame({ image }: { image: ImagePreview }) { } const frameId = requestAnimationFrame(reveal) return () => cancelAnimationFrame(frameId) - }, [knownDimensions, originalAttempt, originalImage, originalStatus, thumbnailSettled]) + }, [knownDimensions, originalAttempt, originalImage, originalStatus, setDimensions, setRevealedAttempt, thumbnailSettled]) return (
diff --git a/src/web/src/components/community/messages/initial-position-transition.ts b/src/web/src/components/community/messages/initial-position-transition.ts index c155dd290..cfa7aee79 100644 --- a/src/web/src/components/community/messages/initial-position-transition.ts +++ b/src/web/src/components/community/messages/initial-position-transition.ts @@ -1,6 +1,8 @@ "use client" -import { useEffect, useLayoutEffect, useRef, useState } from "react" +import { useMemo } from "react"; +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useEffect, useLayoutEffect, useRef } from "react" export const INITIAL_POSITION_CROSSFADE_MS = 300 export const INITIAL_POSITION_TIMEOUT_MS = 2_000 @@ -21,9 +23,9 @@ export function useInitialPositionTransition({ positionSettled: boolean }) { const initiallyRevealed = firstWindowReady && (authoritativeEmpty || positionSettled) - const [phase, setPhase] = useState(() => ( + const [phase, setPhase] = useAtom(useCreateAtom(useMemo(() => ( !firstWindowReady ? "skeleton" : initiallyRevealed ? "revealed" : "positioning" - )) + ), [firstWindowReady, initiallyRevealed]))) const revealedRef = useRef(initiallyRevealed) const startedAtRef = useRef(null) @@ -46,7 +48,7 @@ export function useInitialPositionTransition({ } if (startedAtRef.current === null) startedAtRef.current = Date.now() if (phase === "skeleton") setPhase("positioning") - }, [authoritativeEmpty, firstWindowReady, phase, positionSettled]) + }, [authoritativeEmpty, firstWindowReady, phase, positionSettled, setPhase]) useEffect(() => { if (phase === "revealing") { @@ -71,7 +73,7 @@ export function useInitialPositionTransition({ }, timeoutRemaining) return () => window.clearTimeout(timeoutTimer) - }, [authoritativeEmpty, firstWindowReady, phase, positionSettled]) + }, [authoritativeEmpty, firstWindowReady, phase, positionSettled, setPhase]) // The first renderable window must mount its real DOM in the same commit // that clears loading. Waiting for the layout effect above to persist the diff --git a/src/web/src/components/community/messages/inline-marks.tsx b/src/web/src/components/community/messages/inline-marks.tsx index fdc540c7d..978dc359f 100644 --- a/src/web/src/components/community/messages/inline-marks.tsx +++ b/src/web/src/components/community/messages/inline-marks.tsx @@ -1,6 +1,7 @@ "use client" -import { useState } from "react" +import { useAtom, useCreateAtom } from "@tanstack/react-store"; + import type React from "react" import { ChannelIcon } from "../channels/channel-icon" @@ -8,7 +9,7 @@ import { ChannelIcon } from "../channels/channel-icon" // Spoiler — hidden until clicked. export function Spoiler({ children }: { children?: React.ReactNode }) { - const [shown, setShown] = useState(false) + const [shown, setShown] = useAtom(useCreateAtom(false)) return ( +
: !isCreateMode && loading ? : !isCreateMode && existing ? (
diff --git a/src/web/src/components/daemon-update-notice.dom.test.ts b/src/web/src/components/daemon-update-notice.dom.test.ts index f7dc1937d..e3c9df314 100644 --- a/src/web/src/components/daemon-update-notice.dom.test.ts +++ b/src/web/src/components/daemon-update-notice.dom.test.ts @@ -4,6 +4,13 @@ import { resolve } from "node:path" import { afterAll, beforeEach, describe, expect, it, vi } from "vitest" import { act, render } from "@/test/react-dom-harness" +let application: ReturnType +vi.mock("@/lib/application-owner", async (importOriginal) => ({ + ...await importOriginal(), + useApplicationOwner: () => application, +})) +import { createApplicationOwner } from "@/lib/application-owner" + const mocks = vi.hoisted(() => ({ machinesQueryFn: vi.fn(), notificationAdd: vi.fn(), @@ -49,6 +56,7 @@ function machine( async function renderNotice() { const rendered = render(createElement(DaemonUpdateNotice, { userId: "user-1", + loadMachines: mocks.machinesQueryFn, webVersion: "0.1.27", latestDaemonVersion: "0.1.27", requestUpdate: mocks.requestUpdate, @@ -61,6 +69,7 @@ async function renderNotice() { describe("DaemonUpdateNotice", () => { beforeEach(() => { + application = createApplicationOwner("user-1") window.localStorage.clear() storageGetItem.mockClear() storageSetItem.mockClear() @@ -167,6 +176,7 @@ describe("DaemonUpdateNotice", () => { null, createElement(DaemonUpdateNotice, { userId: "user-1", + loadMachines: mocks.machinesQueryFn, webVersion: "0.1.27", latestDaemonVersion: "0.1.27", requestUpdate: mocks.requestUpdate, diff --git a/src/web/src/components/daemon-update-notice.tsx b/src/web/src/components/daemon-update-notice.tsx index 34f4dc5e0..b47521fff 100644 --- a/src/web/src/components/daemon-update-notice.tsx +++ b/src/web/src/components/daemon-update-notice.tsx @@ -8,14 +8,14 @@ import { parseReleaseVersion, releaseVersionGte, } from "@alook/shared" -import { machinesQueryFn, type MachineSummary } from "@/hooks/community/use-machines" +import type { MachinesResponse, MachineSummary } from "@/hooks/community/use-machines" import { messageNotification } from "@/components/ui/toast" -import { apiFetch } from "@/lib/api/client" +import { applicationKey, assertApplicationOwner, captureApplicationOwner, runApplicationRequest, useApplicationOwner } from "@/lib/application-owner" +import { apiFetch, type ApiRequestOptions } from "@/lib/api/client" import { tid } from "@/lib/community/testids" import { log } from "@/lib/logger" const STORAGE_KEY_PREFIX = "alook:daemon-update-check" -const pendingMachineChecks = new Map>() export function daemonUpdateStorageKey(userId: string): string { return `${STORAGE_KEY_PREFIX}:${userId}` @@ -38,9 +38,9 @@ export function eligibleDaemonUpdateMachines Promise -export async function requestMachineUpdate(machineId: string): Promise { +export async function requestMachineUpdate(machineId: string, options?: ApiRequestOptions): Promise { await apiFetch<{ dispatched: true }>(`/api/community/machines/${machineId}/update`, { - method: "POST", + ...options, method: "POST", }) } @@ -74,28 +74,14 @@ function writeCheckedWebVersion(userId: string, webVersion: string): void { } catch {} } -type MachinesLoader = typeof machinesQueryFn - -function fetchMachinesOnce( - checkKey: string, - loadMachines: MachinesLoader, -): ReturnType { - const pending = pendingMachineChecks.get(checkKey) - if (pending) return pending - - const request = loadMachines().finally(() => { - if (pendingMachineChecks.get(checkKey) === request) pendingMachineChecks.delete(checkKey) - }) - pendingMachineChecks.set(checkKey, request) - return request -} +type MachinesLoader = () => Promise export function DaemonUpdateNotice({ userId, webVersion = process.env.NEXT_PUBLIC_APP_VERSION, latestDaemonVersion = process.env.NEXT_PUBLIC_LATEST_DAEMON_VERSION, - loadMachines = machinesQueryFn, - requestUpdate = requestMachineUpdate, + loadMachines, + requestUpdate, }: { userId: string webVersion?: string @@ -103,6 +89,7 @@ export function DaemonUpdateNotice({ loadMachines?: MachinesLoader requestUpdate?: MachineUpdateRequester }) { + const owner = useApplicationOwner() const startedCheck = useRef(null) useEffect(() => { @@ -113,10 +100,17 @@ export function DaemonUpdateNotice({ startedCheck.current = checkKey if (readCheckedWebVersion(userId) === webVersion) return + const token = captureApplicationOwner(owner) + const assertActive = () => assertApplicationOwner(token) let active = true - void fetchMachinesOnce(checkKey, loadMachines) + void owner.queryClient.fetchQuery({ + queryKey: applicationKey(owner, "daemon-update-check", webVersion, latestDaemonVersion), + staleTime: Infinity, + queryFn: ({ signal }) => runApplicationRequest(owner, (options) => loadMachines ? loadMachines() : apiFetch("/api/community/machines", options), signal), + }) .then(({ machines }) => { if (!active) return + assertActive() const eligible = eligibleDaemonUpdateMachines(machines, latestDaemonVersion) if (eligible.length === 0) { writeCheckedWebVersion(userId, webVersion) @@ -141,14 +135,18 @@ export function DaemonUpdateNotice({ children: "Update", "data-testid": tid.daemonUpdateAction, onClick: () => { + try { assertActive() } catch { return } if (dispatched) return dispatched = true writeCheckedWebVersion(userId, webVersion) messageNotification.close(notificationId) - void dispatchDaemonUpdates(eligible, requestUpdate) + void dispatchDaemonUpdates(eligible, (machineId) => { + assertActive() + return runApplicationRequest(owner, (options) => requestUpdate ? requestUpdate(machineId) : requestMachineUpdate(machineId, options)) + }) }, } as ComponentPropsWithoutRef<"button">, - onClose: () => writeCheckedWebVersion(userId, webVersion), + onClose: () => { try { assertActive(); writeCheckedWebVersion(userId, webVersion) } catch {} }, }) }) .catch(() => {}) @@ -157,7 +155,7 @@ export function DaemonUpdateNotice({ active = false if (startedCheck.current === checkKey) startedCheck.current = null } - }, [latestDaemonVersion, loadMachines, requestUpdate, userId, webVersion]) + }, [latestDaemonVersion, loadMachines, owner, requestUpdate, userId, webVersion]) return null } diff --git a/src/web/src/components/email-body-frame.tsx b/src/web/src/components/email-body-frame.tsx index 91fdc0535..739a2a5f6 100644 --- a/src/web/src/components/email-body-frame.tsx +++ b/src/web/src/components/email-body-frame.tsx @@ -1,6 +1,7 @@ "use client"; -import { useRef, useEffect, useState } from "react"; +import { useAtom, useCreateAtom } from "@tanstack/react-store"; +import { useRef, useEffect } from "react"; import { useTheme } from "next-themes"; interface EmailBodyFrameProps { @@ -84,7 +85,7 @@ export function buildSrcDoc(html: string, isDark: boolean): string { export function EmailBodyFrame({ html, className }: EmailBodyFrameProps) { const iframeRef = useRef(null); const { resolvedTheme } = useTheme(); - const [height, setHeight] = useState(200); + const [height, setHeight] = useAtom(useCreateAtom(200)); const isDark = resolvedTheme === "dark"; useEffect(() => { @@ -115,7 +116,7 @@ export function EmailBodyFrame({ html, className }: EmailBodyFrameProps) { iframe.removeEventListener("load", onLoad); ro?.disconnect(); }; - }, [html, isDark]); + }, [html, isDark, setHeight]); return (