Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae6ed76d9a | |||
| 406921ac6a |
@@ -97,13 +97,6 @@ per-workspace rolling-day token budget.
|
|||||||
applies it through the existing `/pages/update` route — reflecting it in the
|
applies it through the existing `/pages/update` route — reflecting it in the
|
||||||
title field and broadcasting to other clients. Gated by the `settings.ai.generative`
|
title field and broadcasting to other clients. Gated by the `settings.ai.generative`
|
||||||
flag and throttled per user. (#199)
|
flag and throttled per user. (#199)
|
||||||
- **AI chat: header button auto-opens the chat bound to the current document.**
|
|
||||||
Clicking the AI-chat button in the header while viewing a page now reopens the
|
|
||||||
latest chat tied to that document instead of whatever chat was last active,
|
|
||||||
reusing the existing `ai_chats.page_id` provenance (no migration). The newest
|
|
||||||
chat you created on the page wins; with no bound chat — or off a page, or if
|
|
||||||
the lookup fails — it falls soft to a fresh chat and keeps the current
|
|
||||||
selection otherwise. (#191)
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import classes from "./app-header.module.css";
|
|||||||
import { BrandLogo } from "@/components/ui/brand-logo";
|
import { BrandLogo } from "@/components/ui/brand-logo";
|
||||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
desktopSidebarAtom,
|
desktopSidebarAtom,
|
||||||
mobileSidebarAtom,
|
mobileSidebarAtom,
|
||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { useOpenAiChatForCurrentPage } from "@/features/ai-chat/hooks/use-open-ai-chat.ts";
|
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||||
@@ -38,9 +38,7 @@ export function AppHeader() {
|
|||||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||||
|
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
// Opening from the header auto-opens the document's bound chat (last chat
|
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
||||||
// created on the current page); off a page it keeps the current selection.
|
|
||||||
const openAiChat = useOpenAiChatForCurrentPage();
|
|
||||||
// AI chat entry point: only shown when the workspace enables it (A7 gate).
|
// AI chat entry point: only shown when the workspace enables it (A7 gate).
|
||||||
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
||||||
|
|
||||||
@@ -107,7 +105,7 @@ export function AppHeader() {
|
|||||||
color="dark"
|
color="dark"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label={t("AI chat")}
|
aria-label={t("AI chat")}
|
||||||
onClick={openAiChat}
|
onClick={() => setAiChatWindowOpen((v) => !v)}
|
||||||
>
|
>
|
||||||
<IconMessage size={20} />
|
<IconMessage size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -26,16 +26,20 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import MessageItem from "./message-item";
|
import MessageItem from "./message-item";
|
||||||
|
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||||
|
|
||||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||||
|
|
||||||
|
// Mirror MessageList: snapshot the signature at (parent) render time and pass it
|
||||||
|
// as the memo key. The signature must NOT be recomputed inside the memo from the
|
||||||
|
// live (mutable) message — see message-item.tsx.
|
||||||
const renderRow = (message: UIMessage) =>
|
const renderRow = (message: UIMessage) =>
|
||||||
render(
|
render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MessageItem message={message} />
|
<MessageItem message={message} signature={messageSignature(message)} />
|
||||||
</MantineProvider>,
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -67,7 +71,7 @@ describe("MessageItem markdown memoization", () => {
|
|||||||
]);
|
]);
|
||||||
rerender(
|
rerender(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MessageItem message={next} />
|
<MessageItem message={next} signature={messageSignature(next)} />
|
||||||
</MantineProvider>,
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -78,4 +82,35 @@ describe("MessageItem markdown memoization", () => {
|
|||||||
expect(callsFor("beta")).toBe(1);
|
expect(callsFor("beta")).toBe(1);
|
||||||
expect(callsFor("gamm")).toBe(1);
|
expect(callsFor("gamm")).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// REGRESSION (empty-render bug): the AI SDK streams a turn by MUTATING the same
|
||||||
|
// `parts` IN PLACE and reusing the message object. A row that mounted empty
|
||||||
|
// (reasoning-first providers render nothing at first) must still stream its text
|
||||||
|
// in once the parent hands down a fresh signature snapshot. Before the fix the
|
||||||
|
// memo recomputed the signature from the (mutated) message — identical on both
|
||||||
|
// sides — and froze the row at its empty render, so the answer never appeared.
|
||||||
|
it("streams text in after the row mounted empty and parts mutated in place", () => {
|
||||||
|
renderChatMarkdownSpy.mockClear();
|
||||||
|
// Reuse ONE message object across renders (as the SDK does).
|
||||||
|
const message = msg([{ type: "text", text: "" }]);
|
||||||
|
const { rerender, queryByText } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem message={message} signature={messageSignature(message)} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
// Empty text part: nothing visible rendered yet.
|
||||||
|
expect(queryByText("streamed answer")).toBeNull();
|
||||||
|
|
||||||
|
// SDK delta: mutate the SAME part in place, then re-render with a NEW snapshot.
|
||||||
|
(message.parts[0] as { text: string }).text = "streamed answer";
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem message={message} signature={messageSignature(message)} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The grown text now renders (the memo did NOT freeze the empty mount).
|
||||||
|
expect(callsFor("streamed answer")).toBe(1);
|
||||||
|
expect(queryByText("streamed answer")).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,21 +10,28 @@ vi.mock("react-i18next", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { arePropsEqual } from "./message-item";
|
import { arePropsEqual } from "./message-item";
|
||||||
|
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must
|
* Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must
|
||||||
* return false on any visible prop/content change (so the row re-renders) and
|
* return false on any visible prop/content change (so the row re-renders) and
|
||||||
* true when nothing visible changed (so a finalized row is skipped). A FIXED
|
* true when nothing visible changed (so a finalized row is skipped). The memo key
|
||||||
* message id is used so a content-identical clone yields an equal signature.
|
* is the `signature` PROP — an immutable snapshot the PARENT (MessageList) takes
|
||||||
|
* per render via `messageSignature(message)`. A FIXED message id is used so a
|
||||||
|
* content-identical clone yields an equal signature.
|
||||||
*/
|
*/
|
||||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||||
|
|
||||||
|
// Build the props the parent would pass, INCLUDING the snapshot signature it
|
||||||
|
// computes during its own render (the load-bearing part — see message-item.tsx:
|
||||||
|
// the signature must never be recomputed inside arePropsEqual).
|
||||||
const props = (
|
const props = (
|
||||||
message: UIMessage,
|
message: UIMessage,
|
||||||
over: Record<string, unknown> = {},
|
over: Record<string, unknown> = {},
|
||||||
) => ({
|
) => ({
|
||||||
message,
|
message,
|
||||||
|
signature: messageSignature(message),
|
||||||
showCitations: true,
|
showCitations: true,
|
||||||
neutralizeInternalLinks: false,
|
neutralizeInternalLinks: false,
|
||||||
assistantName: "AI",
|
assistantName: "AI",
|
||||||
@@ -53,7 +60,7 @@ describe("arePropsEqual", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns true on the identity fast path (same message object, equal props)", () => {
|
it("returns true for equal snapshot + equal props (finalized row skipped)", () => {
|
||||||
const m = msg([{ type: "text", text: "answer" }]);
|
const m = msg([{ type: "text", text: "answer" }]);
|
||||||
expect(arePropsEqual(props(m), props(m))).toBe(true);
|
expect(arePropsEqual(props(m), props(m))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -70,4 +77,36 @@ describe("arePropsEqual", () => {
|
|||||||
const b = msg([{ type: "text", text: "answer grown" }]);
|
const b = msg([{ type: "text", text: "answer grown" }]);
|
||||||
expect(arePropsEqual(props(a), props(b))).toBe(false);
|
expect(arePropsEqual(props(a), props(b))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// REGRESSION (empty-render bug): the AI SDK streams deltas by mutating the SAME
|
||||||
|
// `parts` in place and handing back a message wrapper that SHARES them. So the
|
||||||
|
// PREVIOUS and NEXT props can carry the SAME (mutated) message object, and
|
||||||
|
// recomputing `messageSignature(message)` inside the comparator would read
|
||||||
|
// identical (latest) content on BOTH sides → always "equal" → the memo skips
|
||||||
|
// every streamed update and the assistant row freezes at its initial empty
|
||||||
|
// render. The comparator MUST instead trust the immutable `signature` SNAPSHOT
|
||||||
|
// the parent captured at each render. This fails against the old implementation
|
||||||
|
// (a `prev.message === next.message` fast path + a signature recomputed from the
|
||||||
|
// live objects).
|
||||||
|
it("re-renders when parts were mutated in place but the snapshot changed", () => {
|
||||||
|
const message = msg([{ type: "text", text: "" }]); // empty (renders null)
|
||||||
|
const prevSig = messageSignature(message); // snapshot BEFORE the delta
|
||||||
|
// SDK streams a delta by mutating the shared part IN PLACE:
|
||||||
|
(message.parts[0] as { text: string }).text = "hello world";
|
||||||
|
const nextSig = messageSignature(message); // snapshot AFTER the delta
|
||||||
|
expect(prevSig).not.toBe(nextSig);
|
||||||
|
// Same object reference on both sides (the SDK reuses it), differing snapshots.
|
||||||
|
const base = {
|
||||||
|
message,
|
||||||
|
showCitations: true,
|
||||||
|
neutralizeInternalLinks: false,
|
||||||
|
assistantName: "AI",
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
arePropsEqual(
|
||||||
|
{ ...base, signature: prevSig },
|
||||||
|
{ ...base, signature: nextSig },
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,12 +11,30 @@ import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/mess
|
|||||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
|
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
|
||||||
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
|
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
|
||||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
|
||||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||||
|
|
||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
message: UIMessage;
|
message: UIMessage;
|
||||||
|
/**
|
||||||
|
* Immutable content signature for `message`, computed by the PARENT
|
||||||
|
* (MessageList) during its render via `messageSignature(message)`. This is the
|
||||||
|
* memo key (see `arePropsEqual`): it MUST be a snapshot captured at render time,
|
||||||
|
* NOT recomputed from `message` inside `arePropsEqual`.
|
||||||
|
*
|
||||||
|
* WHY (load-bearing): the AI SDK streams deltas by mutating the SAME `parts`
|
||||||
|
* array/objects in place and handing back a message wrapper that SHARES those
|
||||||
|
* mutated parts. So inside `arePropsEqual`, `prev.message` and `next.message`
|
||||||
|
* both reflect the CURRENT (latest) parts — `messageSignature(prev.message) ===
|
||||||
|
* messageSignature(next.message)` is therefore ALWAYS true, the memo skips every
|
||||||
|
* post-mount render, and the assistant row freezes at its initial empty (null)
|
||||||
|
* render — i.e. the streamed answer + tool cards never appear (reasoning-first
|
||||||
|
* providers start empty, so NOTHING shows). Snapshotting the signature into this
|
||||||
|
* immutable string prop in the parent fixes that: `prev.signature` holds the
|
||||||
|
* value from the previous render (old content) and `next.signature` the new
|
||||||
|
* content, so they differ as the turn streams in and the row re-renders.
|
||||||
|
*/
|
||||||
|
signature: string;
|
||||||
/**
|
/**
|
||||||
* Forwarded to ToolCallCard: whether tool cards render page citation links.
|
* Forwarded to ToolCallCard: whether tool cards render page citation links.
|
||||||
* Defaults to true (internal chat). The public share passes false.
|
* Defaults to true (internal chat). The public share passes false.
|
||||||
@@ -88,6 +106,8 @@ function MessageItem({
|
|||||||
neutralizeInternalLinks = false,
|
neutralizeInternalLinks = false,
|
||||||
assistantName,
|
assistantName,
|
||||||
}: MessageItemProps) {
|
}: MessageItemProps) {
|
||||||
|
// `signature` is intentionally not read in the body — it exists solely as the
|
||||||
|
// memo key (see arePropsEqual). The render reads `message` directly.
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
|
|
||||||
@@ -203,24 +223,30 @@ function MessageItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Skip re-rendering a message whose visible content is unchanged. The streaming
|
/** Skip re-rendering a message whose visible content is unchanged. The streaming
|
||||||
* TAIL message gets a fresh object whose signature changes each delta, so it
|
* TAIL message gets a fresh `signature` snapshot each delta (computed by the
|
||||||
* still re-renders and streams in; every FINALIZED message is skipped, turning a
|
* parent), so it still re-renders and streams in; every FINALIZED message keeps
|
||||||
* per-token whole-transcript re-render into a tail-only one. */
|
* the same signature and is skipped, turning a per-token whole-transcript
|
||||||
|
* re-render into a tail-only one.
|
||||||
|
*
|
||||||
|
* CRITICAL: compare the `signature` PROP (an immutable snapshot the parent took
|
||||||
|
* at its own render), NEVER `messageSignature(prev.message)` vs
|
||||||
|
* `messageSignature(next.message)`. The AI SDK mutates the shared `parts` in
|
||||||
|
* place, so both `prev.message` and `next.message` reflect the latest content
|
||||||
|
* here — recomputing the signature from them yields equal strings every time and
|
||||||
|
* freezes the row at its initial empty render (the bug this guards against). See
|
||||||
|
* the `signature` prop doc. Likewise there is NO `prev.message === next.message`
|
||||||
|
* fast path: same-reference-but-mutated must still re-render when the snapshot
|
||||||
|
* signature changed. */
|
||||||
export function arePropsEqual(
|
export function arePropsEqual(
|
||||||
prev: MessageItemProps,
|
prev: MessageItemProps,
|
||||||
next: MessageItemProps,
|
next: MessageItemProps,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (
|
return (
|
||||||
prev.showCitations !== next.showCitations ||
|
prev.signature === next.signature &&
|
||||||
prev.neutralizeInternalLinks !== next.neutralizeInternalLinks ||
|
prev.showCitations === next.showCitations &&
|
||||||
prev.assistantName !== next.assistantName
|
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||||
) {
|
prev.assistantName === next.assistantName
|
||||||
return false;
|
);
|
||||||
}
|
|
||||||
// Fast path: identical message object (finalized rows keep their identity
|
|
||||||
// across deltas) — skip without building signatures.
|
|
||||||
if (prev.message === next.message) return true;
|
|
||||||
return messageSignature(prev.message) === messageSignature(next.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(MessageItem, arePropsEqual);
|
export default memo(MessageItem, arePropsEqual);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import MessageItem from "@/features/ai-chat/components/message-item.tsx";
|
|||||||
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
|
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
|
||||||
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||||
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
||||||
|
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
@@ -196,9 +197,16 @@ export default function MessageList({
|
|||||||
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
||||||
<Stack gap={0} pr="xs">
|
<Stack gap={0} pr="xs">
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
|
// `signature` is snapshotted HERE (parent render) into an immutable
|
||||||
|
// string and handed to MessageItem as its memo key. It must NOT be
|
||||||
|
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
|
||||||
|
// shared `parts` in place, so prev/next message objects both read the
|
||||||
|
// latest content there and the memo would skip every streamed update
|
||||||
|
// (freezing the row at its empty render). See message-item.tsx.
|
||||||
<MessageItem
|
<MessageItem
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
|
signature={messageSignature(message)}
|
||||||
showCitations={showCitations}
|
showCitations={showCitations}
|
||||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
assistantName={assistantName}
|
assistantName={assistantName}
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { renderHook, act } from "@testing-library/react";
|
|
||||||
import { Provider, createStore } from "jotai";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useOpenAiChatForCurrentPage } from "./use-open-ai-chat";
|
|
||||||
import {
|
|
||||||
activeAiChatIdAtom,
|
|
||||||
aiChatWindowOpenAtom,
|
|
||||||
aiChatDraftAtom,
|
|
||||||
selectedAiRoleIdAtom,
|
|
||||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
|
||||||
|
|
||||||
// useMatch is the only react-router-dom export the hook uses; drive its return
|
|
||||||
// per test to simulate "on a page" vs "off a page".
|
|
||||||
const useMatchMock = vi.fn();
|
|
||||||
vi.mock("react-router-dom", () => ({
|
|
||||||
useMatch: () => useMatchMock(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// The bound-chat resolver is the network boundary; stub it per test.
|
|
||||||
const getBoundChatMock = vi.fn();
|
|
||||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
|
||||||
getBoundChat: (pageId: string) => getBoundChatMock(pageId),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Put the hook on a page route by default ("doc-p1" -> page id "p1"); individual
|
|
||||||
// tests override useMatch to go off-page.
|
|
||||||
function onPage(pageSlug = "doc-p1") {
|
|
||||||
useMatchMock.mockReturnValue({ params: { pageSlug } });
|
|
||||||
}
|
|
||||||
function offPage() {
|
|
||||||
useMatchMock.mockReturnValue(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the hook inside an explicit jotai store so atom side effects are
|
|
||||||
// assertable; the store is returned for setup + assertions.
|
|
||||||
function setup(seed?: (store: ReturnType<typeof createStore>) => void) {
|
|
||||||
const store = createStore();
|
|
||||||
seed?.(store);
|
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
||||||
<Provider store={store}>{children}</Provider>
|
|
||||||
);
|
|
||||||
const { result } = renderHook(() => useOpenAiChatForCurrentPage(), { wrapper });
|
|
||||||
return { store, open: () => act(() => result.current()) };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("useOpenAiChatForCurrentPage", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
onPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("on a page: resolves the bound chat, selects it, and opens the window", async () => {
|
|
||||||
getBoundChatMock.mockResolvedValue("bound-chat-1");
|
|
||||||
const { store, open } = setup((s) => s.set(aiChatDraftAtom, "stale draft"));
|
|
||||||
|
|
||||||
await open();
|
|
||||||
|
|
||||||
expect(getBoundChatMock).toHaveBeenCalledWith("p1");
|
|
||||||
expect(store.get(activeAiChatIdAtom)).toBe("bound-chat-1");
|
|
||||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
|
||||||
expect(store.get(aiChatDraftAtom)).toBe(""); // cleared on a real switch
|
|
||||||
});
|
|
||||||
|
|
||||||
it("on a page with no bound chat: opens a fresh chat (null)", async () => {
|
|
||||||
getBoundChatMock.mockResolvedValue(null);
|
|
||||||
const { store, open } = setup((s) => s.set(activeAiChatIdAtom, "previous"));
|
|
||||||
|
|
||||||
await open();
|
|
||||||
|
|
||||||
expect(store.get(activeAiChatIdAtom)).toBeNull();
|
|
||||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("off a page: keeps the current selection and does NOT resolve", async () => {
|
|
||||||
offPage();
|
|
||||||
const { store, open } = setup((s) => {
|
|
||||||
s.set(activeAiChatIdAtom, "keep-me");
|
|
||||||
s.set(aiChatDraftAtom, "untouched");
|
|
||||||
});
|
|
||||||
|
|
||||||
await open();
|
|
||||||
|
|
||||||
expect(getBoundChatMock).not.toHaveBeenCalled();
|
|
||||||
expect(store.get(activeAiChatIdAtom)).toBe("keep-me");
|
|
||||||
expect(store.get(aiChatDraftAtom)).toBe("untouched"); // no switch -> kept
|
|
||||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("window already open: re-click does NOT re-resolve or switch chats", async () => {
|
|
||||||
getBoundChatMock.mockResolvedValue("would-switch");
|
|
||||||
const { store, open } = setup((s) => {
|
|
||||||
s.set(aiChatWindowOpenAtom, true);
|
|
||||||
s.set(activeAiChatIdAtom, "current");
|
|
||||||
});
|
|
||||||
|
|
||||||
await open();
|
|
||||||
|
|
||||||
expect(getBoundChatMock).not.toHaveBeenCalled();
|
|
||||||
expect(store.get(activeAiChatIdAtom)).toBe("current");
|
|
||||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT clear the draft when the resolved chat equals the current one", async () => {
|
|
||||||
getBoundChatMock.mockResolvedValue("same");
|
|
||||||
const { store, open } = setup((s) => {
|
|
||||||
s.set(activeAiChatIdAtom, "same");
|
|
||||||
s.set(aiChatDraftAtom, "in-progress");
|
|
||||||
});
|
|
||||||
|
|
||||||
await open();
|
|
||||||
|
|
||||||
expect(store.get(aiChatDraftAtom)).toBe("in-progress"); // no switch
|
|
||||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fail-soft: a resolve error opens a fresh chat (null)", async () => {
|
|
||||||
getBoundChatMock.mockRejectedValue(new Error("network"));
|
|
||||||
const { store, open } = setup((s) => s.set(activeAiChatIdAtom, "previous"));
|
|
||||||
|
|
||||||
await open();
|
|
||||||
|
|
||||||
expect(store.get(activeAiChatIdAtom)).toBeNull();
|
|
||||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears the picked role on a real switch", async () => {
|
|
||||||
getBoundChatMock.mockResolvedValue("bound");
|
|
||||||
const { store, open } = setup((s) => s.set(selectedAiRoleIdAtom, "role-1"));
|
|
||||||
|
|
||||||
await open();
|
|
||||||
|
|
||||||
expect(store.get(selectedAiRoleIdAtom)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
|
||||||
import { useMatch } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
aiChatWindowOpenAtom,
|
|
||||||
activeAiChatIdAtom,
|
|
||||||
aiChatDraftAtom,
|
|
||||||
selectedAiRoleIdAtom,
|
|
||||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
|
||||||
import { getBoundChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
|
||||||
import { extractPageSlugId } from "@/lib";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The generic "open the AI chat" action, WITH document binding: when invoked
|
|
||||||
* while viewing a page, it resolves that page's bound chat and selects it before
|
|
||||||
* opening — so the last chat for this document re-opens by itself. With no bound
|
|
||||||
* chat (or off a page) it keeps the current selection / opens a fresh chat. Used
|
|
||||||
* by the app-header entry point; NOT by the provenance badge (which deep-links).
|
|
||||||
*/
|
|
||||||
export function useOpenAiChatForCurrentPage() {
|
|
||||||
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
|
|
||||||
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
|
|
||||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
|
||||||
const setSelectedRoleId = useSetAtom(selectedAiRoleIdAtom);
|
|
||||||
|
|
||||||
// Same route-match trick the window uses: read :pageSlug from the pathname.
|
|
||||||
// AiChatWindow lives in a pathless parent layout route, so useParams() can't
|
|
||||||
// see :pageSlug — match the full path against the authenticated page route.
|
|
||||||
const match = useMatch("/s/:spaceSlug/p/:pageSlug");
|
|
||||||
const pageId = extractPageSlugId(match?.params?.pageSlug);
|
|
||||||
|
|
||||||
return useCallback(async () => {
|
|
||||||
// Re-clicks while the window is already open (incl. minimized) must NOT
|
|
||||||
// re-resolve and yank the user to another chat: resolve only on a genuine
|
|
||||||
// closed -> open transition. (`windowOpen` is already true here, so there
|
|
||||||
// is nothing to set — just bail.)
|
|
||||||
if (windowOpen) return;
|
|
||||||
// Open the window FIRST so the control feels instant: the bound-chat
|
|
||||||
// round-trip below must never gate the window appearing, or on a slow
|
|
||||||
// connection the first click reads as a hung control until the POST returns.
|
|
||||||
setWindowOpen(true);
|
|
||||||
let resolved: string | null = activeChatId; // off-a-page: keep current
|
|
||||||
if (pageId) {
|
|
||||||
try {
|
|
||||||
resolved = await getBoundChat(pageId); // null => fresh chat
|
|
||||||
} catch {
|
|
||||||
resolved = null; // fail-soft: a fresh chat is always a safe fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Clear the composer draft / picked role ONLY on an actual switch, so
|
|
||||||
// reopening the same chat does not wipe an in-progress draft. Applied after
|
|
||||||
// the resolve so the window is already visible while the switch settles.
|
|
||||||
if (resolved !== activeChatId) {
|
|
||||||
setActiveChatId(resolved);
|
|
||||||
setDraft("");
|
|
||||||
setSelectedRoleId(null);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
windowOpen,
|
|
||||||
activeChatId,
|
|
||||||
pageId,
|
|
||||||
setWindowOpen,
|
|
||||||
setActiveChatId,
|
|
||||||
setDraft,
|
|
||||||
setSelectedRoleId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@@ -37,17 +37,6 @@ export async function getAiChatMessages(
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the chat bound to a document (the current user's most-recent chat
|
|
||||||
* created on that page), or null when there is none. Drives auto-open-on-page.
|
|
||||||
*/
|
|
||||||
export async function getBoundChat(pageId: string): Promise<string | null> {
|
|
||||||
const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
|
|
||||||
pageId,
|
|
||||||
});
|
|
||||||
return req.data.chatId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rename a chat. */
|
/** Rename a chat. */
|
||||||
export async function renameAiChat(data: {
|
export async function renameAiChat(data: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -7,7 +8,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconExternalLink } from "@tabler/icons-react";
|
import { IconExternalLink } from "@tabler/icons-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import CopyTextButton from "@/components/common/copy.tsx";
|
import CopyTextButton from "@/components/common/copy.tsx";
|
||||||
import { getAppUrl } from "@/lib/config.ts";
|
import { getAppUrl } from "@/lib/config.ts";
|
||||||
@@ -122,12 +123,25 @@ export default function ShareAliasSection({
|
|||||||
const showTaken =
|
const showTaken =
|
||||||
isValid && !unchanged && availability && !availability.available;
|
isValid && !unchanged && availability && !availability.available;
|
||||||
|
|
||||||
|
// The slug prefix (e.g. "docs.example.com/l/") is static for the session.
|
||||||
|
const prefixLabel = aliasPrefixLabel();
|
||||||
|
const prefixRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [prefixWidth, setPrefixWidth] = useState(0);
|
||||||
|
|
||||||
|
// Measure the real rendered width of the prefix so the slug input sits flush
|
||||||
|
// next to it, instead of after an over-estimated character-counted gap.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (prefixRef.current) {
|
||||||
|
setPrefixWidth(Math.ceil(prefixRef.current.scrollWidth) + 1);
|
||||||
|
}
|
||||||
|
}, [prefixLabel]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text size="sm" fw={500} mt="md">
|
<Text size="sm" fw={500} mt="md">
|
||||||
{t("Custom address")}
|
{t("Custom address")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed" mb={4}>
|
<Text size="xs" c="dimmed" mb={6}>
|
||||||
{t("A short, memorable link you can point at any shared page.")}
|
{t("A short, memorable link you can point at any shared page.")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@@ -159,11 +173,27 @@ export default function ShareAliasSection({
|
|||||||
// visibly to what gets stored.
|
// visibly to what gets stored.
|
||||||
onBlur={() => setValue(normalized)}
|
onBlur={() => setValue(normalized)}
|
||||||
leftSection={
|
leftSection={
|
||||||
<Text size="xs" c="dimmed" pl={4} style={{ whiteSpace: "nowrap" }}>
|
<Box
|
||||||
{aliasPrefixLabel()}
|
ref={prefixRef}
|
||||||
</Text>
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
paddingInline: "var(--mantine-spacing-xs)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontSize: "var(--mantine-font-size-xs)",
|
||||||
|
color: "var(--mantine-color-dimmed)",
|
||||||
|
backgroundColor: "var(--mantine-color-default-hover)",
|
||||||
|
borderRight: "1px solid var(--mantine-color-default-border)",
|
||||||
|
borderTopLeftRadius: "var(--input-radius)",
|
||||||
|
borderBottomLeftRadius: "var(--input-radius)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{prefixLabel}
|
||||||
|
</Box>
|
||||||
}
|
}
|
||||||
leftSectionWidth={Math.min(aliasPrefixLabel().length * 7 + 12, 180)}
|
leftSectionWidth={prefixWidth || undefined}
|
||||||
placeholder={t("my-page")}
|
placeholder={t("my-page")}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
error={
|
error={
|
||||||
@@ -175,7 +205,7 @@ export default function ShareAliasSection({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group mt="xs" gap="xs">
|
<Group mt="sm" gap="xs">
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
onClick={() => handleSave(false)}
|
onClick={() => handleSave(false)}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { AiChatController } from './ai-chat.controller';
|
|
||||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wiring spec for the #191 `POST /ai-chat/bound-chat` endpoint. It must forward
|
|
||||||
* the requesting user + workspace + pageId to findLatestByPage and return the
|
|
||||||
* matched chat's id, or `{ chatId: null }` when there is none. The repo already
|
|
||||||
* scopes to the caller's OWN chats, so a foreign pageId simply yields no match
|
|
||||||
* (null) — no extra page-access check is needed. Exercised with hand-rolled
|
|
||||||
* mocks, no Nest graph and no DB.
|
|
||||||
*/
|
|
||||||
describe('AiChatController.boundChat', () => {
|
|
||||||
const user = { id: 'u1' } as User;
|
|
||||||
const workspace = { id: 'ws1' } as Workspace;
|
|
||||||
|
|
||||||
function makeController(chat: unknown) {
|
|
||||||
const aiChatRepo = {
|
|
||||||
findLatestByPage: jest.fn().mockResolvedValue(chat),
|
|
||||||
};
|
|
||||||
const controller = new AiChatController(
|
|
||||||
{} as never,
|
|
||||||
aiChatRepo as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
return { controller, aiChatRepo };
|
|
||||||
}
|
|
||||||
|
|
||||||
it('returns the owned chat id and scopes the lookup to user + workspace + page', async () => {
|
|
||||||
const { controller, aiChatRepo } = makeController({
|
|
||||||
id: 'c1',
|
|
||||||
creatorId: 'u1',
|
|
||||||
});
|
|
||||||
const res = await controller.boundChat({ pageId: 'p1' }, user, workspace);
|
|
||||||
expect(aiChatRepo.findLatestByPage).toHaveBeenCalledWith('u1', 'ws1', 'p1');
|
|
||||||
expect(res).toEqual({ chatId: 'c1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns { chatId: null } for a page with no owned chat (incl. foreign pageId)', async () => {
|
|
||||||
const { controller } = makeController(undefined);
|
|
||||||
const res = await controller.boundChat({ pageId: 'foreign' }, user, workspace);
|
|
||||||
expect(res).toEqual({ chatId: null });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -30,7 +30,6 @@ import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
|||||||
import { AiChatService, AiChatStreamBody } from './ai-chat.service';
|
import { AiChatService, AiChatStreamBody } from './ai-chat.service';
|
||||||
import { AiTranscriptionService } from './ai-transcription.service';
|
import { AiTranscriptionService } from './ai-transcription.service';
|
||||||
import {
|
import {
|
||||||
BoundChatDto,
|
|
||||||
ChatIdDto,
|
ChatIdDto,
|
||||||
ExportChatDto,
|
ExportChatDto,
|
||||||
GeneratePageTitleDto,
|
GeneratePageTitleDto,
|
||||||
@@ -68,28 +67,6 @@ export class AiChatController {
|
|||||||
return this.aiChatRepo.findByCreator(user.id, workspace.id, pagination);
|
return this.aiChatRepo.findByCreator(user.id, workspace.id, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the chat bound to a document for the requesting user: the most-recent
|
|
||||||
* non-deleted chat created on that page (ai_chats.page_id). Returns
|
|
||||||
* { chatId: null } when the page has no owned chat (-> a fresh chat). No page
|
|
||||||
* access check needed: only the caller's OWN chats are matched, so a foreign
|
|
||||||
* pageId reveals nothing.
|
|
||||||
*/
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('bound-chat')
|
|
||||||
async boundChat(
|
|
||||||
@Body() dto: BoundChatDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
): Promise<{ chatId: string | null }> {
|
|
||||||
const chat = await this.aiChatRepo.findLatestByPage(
|
|
||||||
user.id,
|
|
||||||
workspace.id,
|
|
||||||
dto.pageId,
|
|
||||||
);
|
|
||||||
return { chatId: chat?.id ?? null };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch the messages of a chat (oldest first, paginated). */
|
/** Fetch the messages of a chat (oldest first, paginated). */
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('messages')
|
@Post('messages')
|
||||||
|
|||||||
@@ -37,12 +37,6 @@ export class GetChatMessagesDto {
|
|||||||
cursor?: string;
|
cursor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the chat bound to a document (the page's most-recent owned chat). */
|
|
||||||
export class BoundChatDto {
|
|
||||||
@IsString()
|
|
||||||
pageId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Export a chat to Markdown (#183). `lang` localizes the few fixed
|
/** Export a chat to Markdown (#183). `lang` localizes the few fixed
|
||||||
* role/tool-action labels; defaults to English server-side. */
|
* role/tool-action labels; defaults to English server-side. */
|
||||||
export class ExportChatDto {
|
export class ExportChatDto {
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { AiChatRepo } from './ai-chat.repo';
|
|
||||||
import type { KyselyDB } from '../../types/kysely.types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit test for AiChatRepo.findLatestByPage — the "bound chat" resolver behind
|
|
||||||
* #191 (auto-open the last chat created on a document). It builds the scoping
|
|
||||||
* query, so we assert the EXACT predicates/ordering the spec mandates over a
|
|
||||||
* chainable builder mock (no live DB): user + workspace + page scope, the
|
|
||||||
* deletedAt filter, newest-by-createdAt with an id tiebreaker, limit 1. A
|
|
||||||
* live-Postgres ordering test is out of scope for this pure unit test.
|
|
||||||
*/
|
|
||||||
describe('AiChatRepo.findLatestByPage', () => {
|
|
||||||
type Recorded = {
|
|
||||||
table?: string;
|
|
||||||
wheres: Array<[string, string, unknown]>;
|
|
||||||
orderBys: Array<[string, string]>;
|
|
||||||
limit?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeDb(result: unknown): { db: KyselyDB; rec: Recorded } {
|
|
||||||
const rec: Recorded = { wheres: [], orderBys: [] };
|
|
||||||
const builder: Record<string, unknown> = {};
|
|
||||||
const chain = () => builder;
|
|
||||||
builder.selectAll = chain;
|
|
||||||
builder.where = (col: string, op: string, val: unknown) => {
|
|
||||||
rec.wheres.push([col, op, val]);
|
|
||||||
return builder;
|
|
||||||
};
|
|
||||||
builder.orderBy = (col: string, dir: string) => {
|
|
||||||
rec.orderBys.push([col, dir]);
|
|
||||||
return builder;
|
|
||||||
};
|
|
||||||
builder.limit = (n: number) => {
|
|
||||||
rec.limit = n;
|
|
||||||
return builder;
|
|
||||||
};
|
|
||||||
builder.executeTakeFirst = () => Promise.resolve(result);
|
|
||||||
const db = {
|
|
||||||
selectFrom: (table: string) => {
|
|
||||||
rec.table = table;
|
|
||||||
return builder;
|
|
||||||
},
|
|
||||||
} as unknown as KyselyDB;
|
|
||||||
return { db, rec };
|
|
||||||
}
|
|
||||||
|
|
||||||
it('returns the matched chat and scopes by user + workspace + page (deletedAt null)', async () => {
|
|
||||||
const chat = { id: 'c1', creatorId: 'u1', workspaceId: 'ws1', pageId: 'p1' };
|
|
||||||
const { db, rec } = makeDb(chat);
|
|
||||||
const repo = new AiChatRepo(db);
|
|
||||||
|
|
||||||
const res = await repo.findLatestByPage('u1', 'ws1', 'p1');
|
|
||||||
|
|
||||||
expect(res).toBe(chat);
|
|
||||||
expect(rec.table).toBe('aiChats');
|
|
||||||
expect(rec.wheres).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
['creatorId', '=', 'u1'],
|
|
||||||
['workspaceId', '=', 'ws1'],
|
|
||||||
['pageId', '=', 'p1'],
|
|
||||||
['deletedAt', 'is', null],
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('orders newest-first by createdAt then id, limit 1', async () => {
|
|
||||||
const { db, rec } = makeDb(undefined);
|
|
||||||
const repo = new AiChatRepo(db);
|
|
||||||
|
|
||||||
await repo.findLatestByPage('u1', 'ws1', 'p1');
|
|
||||||
|
|
||||||
expect(rec.orderBys).toEqual([
|
|
||||||
['createdAt', 'desc'],
|
|
||||||
['id', 'desc'],
|
|
||||||
]);
|
|
||||||
expect(rec.limit).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined when the page has no owned chat', async () => {
|
|
||||||
const { db } = makeDb(undefined);
|
|
||||||
const repo = new AiChatRepo(db);
|
|
||||||
|
|
||||||
await expect(repo.findLatestByPage('u1', 'ws1', 'p1')).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -80,32 +80,6 @@ export class AiChatRepo {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The "bound chat" for a document: the requesting user's most recently
|
|
||||||
* created, non-deleted chat whose origin page is `pageId`. Auto-opened when
|
|
||||||
* the AI chat window is opened on that page. Newest-by-createdAt wins, so a
|
|
||||||
* chat created later on the same page supersedes earlier ones — exactly how
|
|
||||||
* "new chat -> becomes the bound one" falls out for free. Scoped to the user +
|
|
||||||
* workspace, so a foreign pageId can only ever match the caller's own chats.
|
|
||||||
*/
|
|
||||||
async findLatestByPage(
|
|
||||||
creatorId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
pageId: string,
|
|
||||||
): Promise<AiChat | undefined> {
|
|
||||||
return this.db
|
|
||||||
.selectFrom('aiChats')
|
|
||||||
.selectAll('aiChats')
|
|
||||||
.where('creatorId', '=', creatorId)
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
|
||||||
.where('pageId', '=', pageId)
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.orderBy('createdAt', 'desc')
|
|
||||||
.orderBy('id', 'desc') // stable tiebreaker, mirrors findByCreator's cursor
|
|
||||||
.limit(1)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async insert(
|
async insert(
|
||||||
insertable: InsertableAiChat,
|
insertable: InsertableAiChat,
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
|
|||||||
Reference in New Issue
Block a user