feat(tabs): add useResolvedTabs join hook resolving titles from zero

This commit is contained in:
Anish Sarkar 2026-07-16 18:10:53 +05:30
parent a8fb161bef
commit 873ec0bcd7
2 changed files with 182 additions and 0 deletions

View file

@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
getMissingCompleteChatIds,
resolveTabPointers,
type ResolvedTab,
} from "./use-resolved-tabs";
// Run with: pnpm exec tsx --test hooks/use-resolved-tabs.test.ts
test("does not prune unresolved chat tabs before Zero completes", () => {
const missing = getMissingCompleteChatIds({
tabs: [{ id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 }],
threadRows: [],
resultType: "unknown",
});
assert.equal(missing.size, 0);
});
test("prunes unresolved chat tabs only after Zero completes", () => {
const missing = getMissingCompleteChatIds({
tabs: [
{ id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 },
{ id: "chat-43", type: "chat", entityId: 43, workspaceId: 7 },
],
threadRows: [{ id: 43, title: "Present", visibility: "PRIVATE" }],
resultType: "complete",
});
assert.deepEqual([...missing], [42]);
});
test("merges pointer tabs with synced row titles", () => {
const resolved = resolveTabPointers({
tabs: [
{ id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 },
{ id: "doc-9", type: "document", entityId: 9, workspaceId: 7 },
],
threadRows: [{ id: 42, title: "Live chat title", visibility: "SEARCH_SPACE" }],
documentRows: [{ id: 9, title: "Live document title" }],
});
assert.deepEqual(
resolved.map((tab): Pick<ResolvedTab, "id" | "title"> => ({
id: tab.id,
title: tab.title,
})),
[
{ id: "chat-42", title: "Live chat title" },
{ id: "doc-9", title: "Live document title" },
]
);
});

View file

@ -0,0 +1,129 @@
"use client";
import { useQuery } from "@rocicorp/zero/react";
import { useAtomValue, useSetAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { pruneMissingChatTabsAtom, type Tab, tabsAtom } from "@/atoms/tabs/tabs.atom";
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
import { queries } from "@/zero/queries";
interface ThreadRow {
id: number;
title: string;
visibility: string;
}
interface DocumentRow {
id: number;
title: string;
}
export interface ResolvedTab extends Tab {
title: string;
chatUrl?: string;
visibility?: ChatVisibility;
}
function uniqueEntityIds(tabs: Tab[], type: Tab["type"]): number[] {
const ids = new Set<number>();
for (const tab of tabs) {
if (tab.type === type && tab.entityId !== null) {
ids.add(tab.entityId);
}
}
return [...ids];
}
function rowById<T extends { id: number }>(rows: readonly T[] | undefined): Map<number, T> {
return new Map((rows ?? []).map((row) => [row.id, row]));
}
export function getChatUrl(workspaceId: number, threadId: number | null): string {
return threadId
? `/dashboard/${workspaceId}/new-chat/${threadId}`
: `/dashboard/${workspaceId}/new-chat`;
}
export function resolveTabPointers({
tabs,
threadRows,
documentRows,
}: {
tabs: Tab[];
threadRows?: readonly ThreadRow[];
documentRows?: readonly DocumentRow[];
}): ResolvedTab[] {
const threads = rowById(threadRows);
const documents = rowById(documentRows);
return tabs.map((tab) => {
if (tab.type === "document") {
const title =
tab.entityId === null ? "Document" : (documents.get(tab.entityId)?.title ?? `Document ${tab.entityId}`);
return { ...tab, title };
}
const row = tab.entityId === null ? undefined : threads.get(tab.entityId);
return {
...tab,
title: row?.title || (tab.entityId === null ? "New Chat" : `Chat ${tab.entityId}`),
chatUrl: getChatUrl(tab.workspaceId, tab.entityId),
...(row?.visibility !== undefined ? { visibility: row.visibility as ChatVisibility } : {}),
};
});
}
export function getMissingCompleteChatIds({
tabs,
threadRows,
resultType,
}: {
tabs: Tab[];
threadRows?: readonly ThreadRow[];
resultType: string;
}): Set<number> {
if (resultType !== "complete") return new Set();
const threadIds = new Set((threadRows ?? []).map((row) => row.id));
return new Set(
tabs
.filter(
(tab): tab is Tab & { type: "chat"; entityId: number } =>
tab.type === "chat" && tab.entityId !== null && !threadIds.has(tab.entityId)
)
.map((tab) => tab.entityId)
);
}
export function useResolvedTabs(): ResolvedTab[] {
const tabs = useAtomValue(tabsAtom);
const pruneMissingChatTabs = useSetAtom(pruneMissingChatTabsAtom);
const chatIds = useMemo(() => uniqueEntityIds(tabs, "chat"), [tabs]);
const documentIds = useMemo(() => uniqueEntityIds(tabs, "document"), [tabs]);
const [threadRows, threadResult] = useQuery(
queries.threads.byIds({ ids: chatIds.length > 0 ? chatIds : [-1] })
);
const [documentRows] = useQuery(
queries.documents.byIds({ ids: documentIds.length > 0 ? documentIds : [-1] })
);
const missingChatIds = useMemo(
() =>
getMissingCompleteChatIds({
tabs,
threadRows,
resultType: threadResult.type,
}),
[tabs, threadRows, threadResult.type]
);
useEffect(() => {
pruneMissingChatTabs(missingChatIds);
}, [missingChatIds, pruneMissingChatTabs]);
return useMemo(
() => resolveTabPointers({ tabs, threadRows, documentRows }),
[tabs, threadRows, documentRows]
);
}