@carlonicora/nextjs-jsonapi 3.22.0 → 3.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{AssistantMessageInterface-CuaMDs2i.d.ts → AssistantMessageInterface-1Kbz8RVs.d.ts} +34 -1
- package/dist/{AssistantMessageInterface-Du3ShThp.d.mts → AssistantMessageInterface-CoxJyuFM.d.mts} +34 -1
- package/dist/{BlockNoteEditor-VEEMWB2J.js → BlockNoteEditor-PTD2V4CH.js} +19 -19
- package/dist/{BlockNoteEditor-VEEMWB2J.js.map → BlockNoteEditor-PTD2V4CH.js.map} +1 -1
- package/dist/{BlockNoteEditor-5QBGVWE7.mjs → BlockNoteEditor-Q2WEDMKJ.mjs} +4 -4
- package/dist/{BlockNoteViewer-ZY4ZF3HO.mjs → BlockNoteViewer-WO6GTDSI.mjs} +2 -2
- package/dist/{BlockNoteViewer-XCGBX4ST.js → BlockNoteViewer-WOXEJ6RM.js} +3 -3
- package/dist/{BlockNoteViewer-XCGBX4ST.js.map → BlockNoteViewer-WOXEJ6RM.js.map} +1 -1
- package/dist/{HandbookSectionInterface-DqU_d0sy.d.mts → HandbookSectionInterface-BYsmnPEz.d.mts} +36 -0
- package/dist/{HandbookSectionInterface-DFj2zErb.d.ts → HandbookSectionInterface-OePKPbqN.d.ts} +36 -0
- package/dist/billing/index.js +401 -401
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-CK47KSFV.mjs → chunk-2O3SFHQ7.mjs} +91 -39
- package/dist/chunk-2O3SFHQ7.mjs.map +1 -0
- package/dist/{chunk-SYQMVTIC.js → chunk-3D2MCVRZ.js} +7 -7
- package/dist/{chunk-SYQMVTIC.js.map → chunk-3D2MCVRZ.js.map} +1 -1
- package/dist/{chunk-XYSWOW5G.js → chunk-3YJF7QY4.js} +1358 -1116
- package/dist/chunk-3YJF7QY4.js.map +1 -0
- package/dist/{chunk-5G7I3OUB.mjs → chunk-GJHKOG5L.mjs} +1419 -1177
- package/dist/chunk-GJHKOG5L.mjs.map +1 -0
- package/dist/{chunk-U55JQ4NY.mjs → chunk-HAI7G6MD.mjs} +2 -2
- package/dist/{chunk-MWZCCOTS.js → chunk-IV4DTYXN.js} +91 -39
- package/dist/chunk-IV4DTYXN.js.map +1 -0
- package/dist/client/index.d.mts +13 -2
- package/dist/client/index.d.ts +13 -2
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +90 -31
- package/dist/components/index.d.ts +90 -31
- package/dist/components/index.js +8 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +7 -3
- package/dist/contexts/index.d.mts +11 -2
- package/dist/contexts/index.d.ts +11 -2
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +29 -7
- package/dist/core/index.d.ts +29 -7
- package/dist/core/index.js +2 -2
- package/dist/core/index.mjs +1 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +101 -101
- package/dist/features/tokenusage/index.mjs +3 -3
- package/dist/index.d.mts +3 -4
- package/dist/index.d.ts +3 -4
- package/dist/index.js +3 -3
- package/dist/index.mjs +2 -2
- package/dist/{oauth.interface-CD90ycYz.d.ts → oauth.interface-BZKZAPRw.d.ts} +8 -1
- package/dist/{oauth.interface-DRE3NAKc.d.mts → oauth.interface-nVwnzxJw.d.mts} +8 -1
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/package.json +1 -1
- package/src/components/forms/__tests__/EditorSheet.test.tsx +5 -3
- package/src/core/utils/__tests__/translateResponse.test.ts +3 -1
- package/src/features/ai-connection/components/forms/AiConnectionEditor.tsx +1 -0
- package/src/features/ai-connection/components/lists/AiConnectionTypeCard.tsx +9 -0
- package/src/features/ai-connection/data/__tests__/ai-connection.test.ts +51 -0
- package/src/features/ai-connection/data/ai-connection.interface.ts +18 -0
- package/src/features/ai-connection/data/ai-connection.ts +63 -0
- package/src/features/ai-connection/i18n-keys.ts +9 -0
- package/src/features/assistant/components/containers/AssistantContainer.tsx +105 -36
- package/src/features/assistant/components/containers/__tests__/AssistantContainer.spec.tsx +163 -1
- package/src/features/assistant/components/parts/AssistantComposer.tsx +38 -15
- package/src/features/assistant/components/parts/AssistantEmptyState.tsx +5 -3
- package/src/features/assistant/components/parts/AssistantSidebar.tsx +26 -7
- package/src/features/assistant/components/parts/__tests__/AssistantComposer.spec.tsx +8 -0
- package/src/features/assistant/components/parts/__tests__/AssistantSidebar.spec.tsx +7 -0
- package/src/features/assistant/contexts/AssistantContext.tsx +91 -7
- package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +196 -0
- package/src/features/assistant-action/components/AssistantContainerWithApprovals.tsx +23 -1
- package/src/features/assistant-message/components/EntityMentionLink.tsx +28 -0
- package/src/features/assistant-message/components/__tests__/EntityMentionLink.spec.tsx +58 -0
- package/src/features/assistant-message/components/index.ts +1 -0
- package/src/features/assistant-message/components/parts/tabs/ContentsTab.tsx +1 -1
- package/src/features/assistant-message/components/parts/tabs/ReferencesTab.tsx +2 -2
- package/src/features/auth/components/forms/__tests__/Logout.spec.tsx +3 -2
- package/src/features/oauth/components/OAuthClientForm.tsx +21 -4
- package/src/features/oauth/components/__tests__/OAuthClientForm.spec.tsx +63 -0
- package/src/features/oauth/components/consent/OAuthCompanySelector.tsx +54 -0
- package/src/features/oauth/components/consent/OAuthConsentActions.tsx +5 -2
- package/src/features/oauth/components/consent/OAuthConsentHeader.tsx +10 -6
- package/src/features/oauth/components/consent/OAuthConsentScreen.tsx +33 -13
- package/src/features/oauth/components/consent/OAuthScopeList.tsx +4 -1
- package/src/features/oauth/components/consent/__tests__/OAuthConsentHeader.spec.tsx +25 -0
- package/src/features/oauth/components/consent/__tests__/OAuthConsentScreen.spec.tsx +126 -0
- package/src/features/oauth/components/consent/index.ts +1 -0
- package/src/features/oauth/data/__tests__/oauth.service.spec.ts +103 -0
- package/src/features/oauth/data/oauth.service.ts +3 -13
- package/src/features/oauth/data/oauth.ts +30 -1
- package/src/features/oauth/hooks/useOAuthConsent.ts +34 -3
- package/src/features/oauth/interfaces/oauth.interface.ts +11 -25
- package/src/hooks/__tests__/useDataListRetriever.test.ts +1 -1
- package/dist/AssistantInterface-RvZzlcA_.d.ts +0 -36
- package/dist/AssistantInterface-jL56dyNc.d.mts +0 -36
- package/dist/chunk-5G7I3OUB.mjs.map +0 -1
- package/dist/chunk-CK47KSFV.mjs.map +0 -1
- package/dist/chunk-MWZCCOTS.js.map +0 -1
- package/dist/chunk-XYSWOW5G.js.map +0 -1
- /package/dist/{BlockNoteEditor-5QBGVWE7.mjs.map → BlockNoteEditor-Q2WEDMKJ.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-ZY4ZF3HO.mjs.map → BlockNoteViewer-WO6GTDSI.mjs.map} +0 -0
- /package/dist/{chunk-U55JQ4NY.mjs.map → chunk-HAI7G6MD.mjs.map} +0 -0
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { MessagesSquareIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useMemo, type ReactNode } from "react";
|
|
3
6
|
import { RoundPageContainer } from "../../../../components/containers/RoundPageContainer";
|
|
4
7
|
import { Modules } from "../../../../core";
|
|
5
|
-
import
|
|
8
|
+
import { EntityMentionLink } from "../../../assistant-message/components/EntityMentionLink";
|
|
9
|
+
import type { ApprovalActionRenderer, MentionRenderer } from "../../../assistant-message/components/MessageItem";
|
|
10
|
+
import type { AssistantInterface } from "../../data/AssistantInterface";
|
|
6
11
|
import { useAssistantContext } from "../../contexts/AssistantContext";
|
|
7
12
|
import { AssistantSidebar } from "../parts/AssistantSidebar";
|
|
8
13
|
import { AssistantEmptyState } from "../parts/AssistantEmptyState";
|
|
@@ -18,55 +23,119 @@ interface Props {
|
|
|
18
23
|
* plain markdown bubble.
|
|
19
24
|
*/
|
|
20
25
|
renderApprovalAction?: ApprovalActionRenderer;
|
|
26
|
+
/**
|
|
27
|
+
* Renderer for `mention://<type>/<id>` links in assistant answers. Defaults
|
|
28
|
+
* to `EntityMentionLink`, which links registered modules to their page.
|
|
29
|
+
*/
|
|
30
|
+
renderMention?: MentionRenderer;
|
|
31
|
+
/** When true the container renders without the `RoundPageContainer` page chrome (e.g. inside a sheet). */
|
|
32
|
+
embedded?: boolean;
|
|
33
|
+
/** Rendered inside the composer (bottom-left), before the first message of a new thread. */
|
|
34
|
+
renderScopePicker?: () => ReactNode;
|
|
35
|
+
/** Rendered after each thread title in the sidebar. */
|
|
36
|
+
renderThreadBadge?: (thread: AssistantInterface) => ReactNode;
|
|
37
|
+
/** When false, the empty state hides the operator-mode toggle and threads use the default engine. Defaults to true. */
|
|
38
|
+
showOperatorToggle?: boolean;
|
|
21
39
|
}
|
|
22
40
|
|
|
23
|
-
export function AssistantContainer({
|
|
41
|
+
export function AssistantContainer({
|
|
42
|
+
renderApprovalAction,
|
|
43
|
+
renderMention,
|
|
44
|
+
embedded,
|
|
45
|
+
renderScopePicker,
|
|
46
|
+
renderThreadBadge,
|
|
47
|
+
showOperatorToggle = true,
|
|
48
|
+
}: Props = {}) {
|
|
49
|
+
const t = useTranslations();
|
|
24
50
|
const ctx = useAssistantContext();
|
|
25
51
|
const showThread = !!ctx.assistant || ctx.sending || ctx.messages.length > 0;
|
|
26
52
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
53
|
+
// Memoised: a fresh element on every render fed into RoundPageContainer is
|
|
54
|
+
// the shape that produces React's "Maximum update depth exceeded".
|
|
55
|
+
const details = useMemo(
|
|
56
|
+
() => (
|
|
57
|
+
// The panel supplies its own padding and border; cancel both so the list
|
|
58
|
+
// reads as the panel body rather than a card inside it.
|
|
59
|
+
<div className="-m-4 flex h-full min-h-0 [&>aside]:w-full [&>aside]:border-e-0 [&>aside]:bg-transparent">
|
|
30
60
|
<AssistantSidebar
|
|
31
61
|
threads={ctx.threads}
|
|
32
62
|
activeId={ctx.assistant?.id}
|
|
33
63
|
onSelect={ctx.selectThread}
|
|
34
|
-
|
|
64
|
+
renderThreadBadge={renderThreadBadge}
|
|
35
65
|
/>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
66
|
+
</div>
|
|
67
|
+
),
|
|
68
|
+
[ctx.threads, ctx.assistant?.id, ctx.selectThread, renderThreadBadge],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const thread = (
|
|
72
|
+
<>
|
|
73
|
+
{!showThread ? (
|
|
74
|
+
<AssistantEmptyState
|
|
75
|
+
onSend={ctx.sendMessage}
|
|
76
|
+
operatorMode={ctx.operatorMode}
|
|
77
|
+
onOperatorModeChange={showOperatorToggle ? ctx.setOperatorMode : undefined}
|
|
78
|
+
composerLeading={renderScopePicker?.()}
|
|
79
|
+
/>
|
|
80
|
+
) : (
|
|
81
|
+
<>
|
|
82
|
+
{ctx.assistant ? (
|
|
83
|
+
<AssistantThreadHeader
|
|
84
|
+
assistant={ctx.assistant}
|
|
85
|
+
onRename={(title) => ctx.renameThread(ctx.assistant!.id, title)}
|
|
86
|
+
onDelete={() => ctx.deleteThread(ctx.assistant!.id)}
|
|
42
87
|
/>
|
|
43
88
|
) : (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
assistant={ctx.assistant}
|
|
48
|
-
onRename={(title) => ctx.renameThread(ctx.assistant!.id, title)}
|
|
49
|
-
onDelete={() => ctx.deleteThread(ctx.assistant!.id)}
|
|
50
|
-
/>
|
|
51
|
-
) : (
|
|
52
|
-
<div className="flex items-center justify-between border-b px-5 py-3" aria-hidden>
|
|
53
|
-
<div className="h-5" />
|
|
54
|
-
</div>
|
|
55
|
-
)}
|
|
56
|
-
<AssistantThread
|
|
57
|
-
messages={ctx.messages}
|
|
58
|
-
sending={ctx.sending}
|
|
59
|
-
status={ctx.status}
|
|
60
|
-
onSelectFollowUp={ctx.sendMessage}
|
|
61
|
-
failedMessageIds={ctx.failedMessageIds}
|
|
62
|
-
onRetry={ctx.retrySend}
|
|
63
|
-
renderApprovalAction={renderApprovalAction}
|
|
64
|
-
/>
|
|
65
|
-
<AssistantComposer onSend={ctx.sendMessage} disabled={ctx.sending} />
|
|
66
|
-
</>
|
|
89
|
+
<div className="flex items-center justify-between border-b px-5 py-3" aria-hidden>
|
|
90
|
+
<div className="h-5" />
|
|
91
|
+
</div>
|
|
67
92
|
)}
|
|
68
|
-
|
|
93
|
+
<AssistantThread
|
|
94
|
+
messages={ctx.messages}
|
|
95
|
+
sending={ctx.sending}
|
|
96
|
+
status={ctx.status}
|
|
97
|
+
onSelectFollowUp={ctx.sendMessage}
|
|
98
|
+
failedMessageIds={ctx.failedMessageIds}
|
|
99
|
+
onRetry={ctx.retrySend}
|
|
100
|
+
renderApprovalAction={renderApprovalAction}
|
|
101
|
+
renderMention={renderMention ?? EntityMentionLink}
|
|
102
|
+
/>
|
|
103
|
+
<AssistantComposer onSend={ctx.sendMessage} disabled={ctx.sending} />
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
</>
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if (embedded) {
|
|
110
|
+
return (
|
|
111
|
+
<div className="bg-background flex h-full w-full overflow-hidden rounded-lg border">
|
|
112
|
+
<AssistantSidebar
|
|
113
|
+
threads={ctx.threads}
|
|
114
|
+
activeId={ctx.assistant?.id}
|
|
115
|
+
onSelect={ctx.selectThread}
|
|
116
|
+
onNew={ctx.startNew}
|
|
117
|
+
renderThreadBadge={renderThreadBadge}
|
|
118
|
+
/>
|
|
119
|
+
<main className="flex flex-1 flex-col">{thread}</main>
|
|
69
120
|
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The page mirrors the conversations page: the thread list lives in the
|
|
125
|
+
// page's details panel, the new-thread action in the page header (set by
|
|
126
|
+
// AssistantProvider), and the conversation fills the card.
|
|
127
|
+
return (
|
|
128
|
+
<RoundPageContainer
|
|
129
|
+
module={Modules.Assistant}
|
|
130
|
+
id={ctx.assistant?.id}
|
|
131
|
+
fullWidth
|
|
132
|
+
forceHeader
|
|
133
|
+
defaultDetailsOpen
|
|
134
|
+
detailsTitle={t("features.assistant.list_title")}
|
|
135
|
+
detailsIcon={<MessagesSquareIcon />}
|
|
136
|
+
details={details}
|
|
137
|
+
>
|
|
138
|
+
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden">{thread}</div>
|
|
70
139
|
</RoundPageContainer>
|
|
71
140
|
);
|
|
72
141
|
}
|
|
@@ -3,7 +3,23 @@ vi.mock("../../../../../contexts/SocketContext", () => ({
|
|
|
3
3
|
}));
|
|
4
4
|
|
|
5
5
|
vi.mock("../../../../../components/containers/RoundPageContainer", () => ({
|
|
6
|
-
RoundPageContainer: ({
|
|
6
|
+
RoundPageContainer: ({
|
|
7
|
+
children,
|
|
8
|
+
details,
|
|
9
|
+
detailsTitle,
|
|
10
|
+
}: {
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
details?: React.ReactNode;
|
|
13
|
+
detailsTitle?: React.ReactNode;
|
|
14
|
+
}) => (
|
|
15
|
+
<div data-testid="round-page-container">
|
|
16
|
+
{children}
|
|
17
|
+
<div data-testid="round-page-details">
|
|
18
|
+
{detailsTitle}
|
|
19
|
+
{details}
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
),
|
|
7
23
|
}));
|
|
8
24
|
|
|
9
25
|
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
|
|
@@ -13,11 +29,40 @@ import { AssistantService } from "../../../data/AssistantService";
|
|
|
13
29
|
import type { JsonApiHydratedDataInterface } from "../../../../../core";
|
|
14
30
|
import { AssistantContainer } from "../AssistantContainer";
|
|
15
31
|
import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
|
|
32
|
+
import { configureI18n } from "../../../../../i18n";
|
|
16
33
|
import { DataClassRegistry } from "../../../../../core/registry/DataClassRegistry";
|
|
17
34
|
import { Assistant } from "../../../data/Assistant";
|
|
18
35
|
import { AssistantMessage } from "../../../../assistant-message/data/AssistantMessage";
|
|
19
36
|
|
|
20
37
|
beforeAll(() => {
|
|
38
|
+
// The package Link resolves its inner component at runtime and throws if i18n
|
|
39
|
+
// was never configured (src/i18n/config.ts).
|
|
40
|
+
configureI18n({
|
|
41
|
+
useRouter: () => ({
|
|
42
|
+
push: vi.fn(),
|
|
43
|
+
replace: vi.fn(),
|
|
44
|
+
back: vi.fn(),
|
|
45
|
+
forward: vi.fn(),
|
|
46
|
+
refresh: vi.fn(),
|
|
47
|
+
prefetch: vi.fn(),
|
|
48
|
+
}),
|
|
49
|
+
useTranslations: () => (key: string) => key,
|
|
50
|
+
usePathname: () => "/",
|
|
51
|
+
Link: ({
|
|
52
|
+
href,
|
|
53
|
+
children,
|
|
54
|
+
prefetch: _prefetch,
|
|
55
|
+
...rest
|
|
56
|
+
}: {
|
|
57
|
+
href: string;
|
|
58
|
+
children: React.ReactNode;
|
|
59
|
+
[key: string]: any;
|
|
60
|
+
}) => (
|
|
61
|
+
<a href={href} {...rest}>
|
|
62
|
+
{children}
|
|
63
|
+
</a>
|
|
64
|
+
),
|
|
65
|
+
});
|
|
21
66
|
Element.prototype.scrollIntoView = vi.fn();
|
|
22
67
|
const assistantModule = { name: "assistants", model: Assistant } as any;
|
|
23
68
|
const assistantMessageModule = { name: "assistant-messages", model: AssistantMessage } as any;
|
|
@@ -25,6 +70,7 @@ beforeAll(() => {
|
|
|
25
70
|
DataClassRegistry.registerObjectClass(assistantMessageModule, AssistantMessage);
|
|
26
71
|
ModuleRegistry.register("Assistant" as any, assistantModule as any);
|
|
27
72
|
ModuleRegistry.register("AssistantMessage" as any, assistantMessageModule as any);
|
|
73
|
+
ModuleRegistry.register("Npc" as any, { name: "npcs", pageUrl: "/npcs" } as any);
|
|
28
74
|
});
|
|
29
75
|
|
|
30
76
|
beforeEach(() => {
|
|
@@ -48,6 +94,17 @@ function buildAssistantDehydrated({
|
|
|
48
94
|
};
|
|
49
95
|
}
|
|
50
96
|
|
|
97
|
+
function buildMessageDehydrated({ id, content }: { id: string; content: string }): JsonApiHydratedDataInterface {
|
|
98
|
+
return {
|
|
99
|
+
jsonApi: {
|
|
100
|
+
type: "assistant-messages",
|
|
101
|
+
id,
|
|
102
|
+
attributes: { role: "user", content, position: 1 },
|
|
103
|
+
},
|
|
104
|
+
included: [],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
51
108
|
describe("AssistantContainer", () => {
|
|
52
109
|
it("shows empty state when no assistant is active", async () => {
|
|
53
110
|
render(
|
|
@@ -98,4 +155,109 @@ describe("AssistantContainer", () => {
|
|
|
98
155
|
// Status line is showing ("thinking…" translation key renders literally in tests).
|
|
99
156
|
expect(screen.getByText("features.assistant.thinking")).toBeInTheDocument();
|
|
100
157
|
});
|
|
158
|
+
|
|
159
|
+
it("passes renderMention through to messages", () => {
|
|
160
|
+
const active = buildAssistantDehydrated({ id: "a1", title: "T" });
|
|
161
|
+
const messages = [buildMessageDehydrated({ id: "m1", content: "Ask [X](mention://npcs/n1) about it." })];
|
|
162
|
+
render(
|
|
163
|
+
<AssistantProvider dehydratedAssistant={active} dehydratedMessages={messages}>
|
|
164
|
+
<AssistantContainer
|
|
165
|
+
renderMention={({ type, id, alias }) => <span data-testid="custom-mention">{`${type}:${id}:${alias}`}</span>}
|
|
166
|
+
/>
|
|
167
|
+
</AssistantProvider>,
|
|
168
|
+
);
|
|
169
|
+
expect(screen.getByTestId("custom-mention")).toHaveTextContent("npcs:n1:X");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("uses EntityMentionLink by default", () => {
|
|
173
|
+
const active = buildAssistantDehydrated({ id: "a1", title: "T" });
|
|
174
|
+
const messages = [buildMessageDehydrated({ id: "m1", content: "Ask [X](mention://npcs/n1) about it." })];
|
|
175
|
+
render(
|
|
176
|
+
<AssistantProvider dehydratedAssistant={active} dehydratedMessages={messages}>
|
|
177
|
+
<AssistantContainer />
|
|
178
|
+
</AssistantProvider>,
|
|
179
|
+
);
|
|
180
|
+
expect(screen.getByRole("link", { name: "X" })).toHaveAttribute("href", "/npcs/n1");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("embedded renders without RoundPageContainer", async () => {
|
|
184
|
+
const { unmount } = render(
|
|
185
|
+
<AssistantProvider>
|
|
186
|
+
<AssistantContainer />
|
|
187
|
+
</AssistantProvider>,
|
|
188
|
+
);
|
|
189
|
+
expect(screen.getByTestId("round-page-container")).toBeInTheDocument();
|
|
190
|
+
unmount();
|
|
191
|
+
|
|
192
|
+
render(
|
|
193
|
+
<AssistantProvider>
|
|
194
|
+
<AssistantContainer embedded />
|
|
195
|
+
</AssistantProvider>,
|
|
196
|
+
);
|
|
197
|
+
await waitFor(() =>
|
|
198
|
+
expect(screen.getByRole("heading", { name: "features.assistant.empty_state.title" })).toBeInTheDocument(),
|
|
199
|
+
);
|
|
200
|
+
expect(screen.queryByTestId("round-page-container")).not.toBeInTheDocument();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("renderScopePicker shows only before the first message", async () => {
|
|
204
|
+
AssistantService.create = vi.fn().mockImplementation(() => new Promise(() => {}));
|
|
205
|
+
|
|
206
|
+
render(
|
|
207
|
+
<AssistantProvider>
|
|
208
|
+
<AssistantContainer renderScopePicker={() => <div data-testid="scope-picker" />} />
|
|
209
|
+
</AssistantProvider>,
|
|
210
|
+
);
|
|
211
|
+
expect(screen.getByTestId("scope-picker")).toBeInTheDocument();
|
|
212
|
+
|
|
213
|
+
const textarea = await screen.findByRole("textbox");
|
|
214
|
+
const user = (await import("@testing-library/user-event")).default.setup();
|
|
215
|
+
await user.type(textarea, "first one");
|
|
216
|
+
await user.keyboard("{Enter}");
|
|
217
|
+
|
|
218
|
+
await waitFor(() => expect(screen.queryByTestId("scope-picker")).not.toBeInTheDocument());
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("shows the operator toggle by default", async () => {
|
|
222
|
+
render(
|
|
223
|
+
<AssistantProvider>
|
|
224
|
+
<AssistantContainer />
|
|
225
|
+
</AssistantProvider>,
|
|
226
|
+
);
|
|
227
|
+
await waitFor(() => expect(screen.getByText("features.assistant.operator_mode")).toBeInTheDocument());
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("showOperatorToggle={false} hides the operator toggle", async () => {
|
|
231
|
+
render(
|
|
232
|
+
<AssistantProvider>
|
|
233
|
+
<AssistantContainer showOperatorToggle={false} />
|
|
234
|
+
</AssistantProvider>,
|
|
235
|
+
);
|
|
236
|
+
await waitFor(() =>
|
|
237
|
+
expect(screen.getByRole("heading", { name: "features.assistant.empty_state.title" })).toBeInTheDocument(),
|
|
238
|
+
);
|
|
239
|
+
expect(screen.queryByText("features.assistant.operator_mode")).not.toBeInTheDocument();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("page: thread list sits in the details panel, without its own new button", async () => {
|
|
243
|
+
AssistantService.findMany = vi.fn().mockResolvedValue([]);
|
|
244
|
+
render(
|
|
245
|
+
<AssistantProvider>
|
|
246
|
+
<AssistantContainer />
|
|
247
|
+
</AssistantProvider>,
|
|
248
|
+
);
|
|
249
|
+
const details = screen.getByTestId("round-page-details");
|
|
250
|
+
expect(details).toHaveTextContent("features.assistant.list_title");
|
|
251
|
+
await waitFor(() => expect(details).toHaveTextContent("features.assistant.empty_sidebar"));
|
|
252
|
+
expect(screen.queryByRole("button", { name: /features\.assistant\.new/ })).not.toBeInTheDocument();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("embedded: keeps the sidebar with its new button", async () => {
|
|
256
|
+
render(
|
|
257
|
+
<AssistantProvider>
|
|
258
|
+
<AssistantContainer embedded />
|
|
259
|
+
</AssistantProvider>,
|
|
260
|
+
);
|
|
261
|
+
expect(screen.getByRole("button", { name: /features\.assistant\.new/ })).toBeInTheDocument();
|
|
262
|
+
});
|
|
101
263
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, type KeyboardEvent } from "react";
|
|
3
|
+
import { useState, type KeyboardEvent, type ReactNode } from "react";
|
|
4
4
|
import { useTranslations } from "next-intl";
|
|
5
5
|
import { ArrowUp } from "lucide-react";
|
|
6
6
|
import { Button, Textarea } from "../../../../shadcnui";
|
|
@@ -22,6 +22,11 @@ interface Props {
|
|
|
22
22
|
* move.
|
|
23
23
|
*/
|
|
24
24
|
sendLabel?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Rendered at the bottom-left of the input box, opposite the send button
|
|
27
|
+
* (e.g. a scope picker). When omitted the box keeps its single-row layout.
|
|
28
|
+
*/
|
|
29
|
+
leading?: ReactNode;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export function AssistantComposer({
|
|
@@ -31,6 +36,7 @@ export function AssistantComposer({
|
|
|
31
36
|
onValueChange,
|
|
32
37
|
placeholder,
|
|
33
38
|
sendLabel,
|
|
39
|
+
leading,
|
|
34
40
|
}: Props) {
|
|
35
41
|
const t = useTranslations();
|
|
36
42
|
const [internal, setInternal] = useState("");
|
|
@@ -53,22 +59,39 @@ export function AssistantComposer({
|
|
|
53
59
|
}
|
|
54
60
|
};
|
|
55
61
|
|
|
62
|
+
const textarea = (
|
|
63
|
+
<Textarea
|
|
64
|
+
value={value}
|
|
65
|
+
onChange={(e) => setValue(e.target.value)}
|
|
66
|
+
onKeyDown={onKeyDown}
|
|
67
|
+
placeholder={placeholder ?? t("features.assistant.composer_placeholder")}
|
|
68
|
+
disabled={disabled}
|
|
69
|
+
rows={2}
|
|
70
|
+
className="min-h-[48px] resize-none border-0 bg-transparent focus-visible:ring-0"
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
const sendButton = (
|
|
74
|
+
<Button onClick={submit} disabled={!canSend} size="sm" className="h-8">
|
|
75
|
+
<ArrowUp className="me-1 h-4 w-4" /> {sendLabel ?? t("features.assistant.send")}
|
|
76
|
+
</Button>
|
|
77
|
+
);
|
|
78
|
+
|
|
56
79
|
return (
|
|
57
80
|
<div className="flex flex-col gap-1 border-t p-4">
|
|
58
|
-
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
</
|
|
71
|
-
|
|
81
|
+
{leading ? (
|
|
82
|
+
<div className="bg-muted/30 flex flex-col gap-2 rounded-lg border p-2">
|
|
83
|
+
{textarea}
|
|
84
|
+
<div className="flex items-center justify-between gap-2">
|
|
85
|
+
<div className="min-w-0">{leading}</div>
|
|
86
|
+
{sendButton}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
) : (
|
|
90
|
+
<div className="bg-muted/30 flex items-end gap-2 rounded-lg border p-2">
|
|
91
|
+
{textarea}
|
|
92
|
+
{sendButton}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
72
95
|
<div className="text-muted-foreground text-end text-xs">{t("features.assistant.keyboard_hint")}</div>
|
|
73
96
|
</div>
|
|
74
97
|
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
4
|
import { useTranslations } from "next-intl";
|
|
5
5
|
import { Sparkles } from "lucide-react";
|
|
6
6
|
import { Label, Switch } from "../../../../shadcnui";
|
|
@@ -16,11 +16,13 @@ interface Props {
|
|
|
16
16
|
*/
|
|
17
17
|
operatorMode?: boolean;
|
|
18
18
|
onOperatorModeChange?: (value: boolean) => void;
|
|
19
|
+
/** Rendered inside the composer, bottom-left (e.g. a scope picker). */
|
|
20
|
+
composerLeading?: ReactNode;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
const STARTER_KEYS = ["a", "b", "c", "d"] as const;
|
|
22
24
|
|
|
23
|
-
export function AssistantEmptyState({ onSend, operatorMode = false, onOperatorModeChange }: Props) {
|
|
25
|
+
export function AssistantEmptyState({ onSend, operatorMode = false, onOperatorModeChange, composerLeading }: Props) {
|
|
24
26
|
const t = useTranslations();
|
|
25
27
|
const [draft, setDraft] = useState("");
|
|
26
28
|
|
|
@@ -34,7 +36,7 @@ export function AssistantEmptyState({ onSend, operatorMode = false, onOperatorMo
|
|
|
34
36
|
<SectionHeader>{t("features.assistant.empty_state.title")}</SectionHeader>
|
|
35
37
|
<p className="text-muted-foreground mt-1 text-sm">{t("features.assistant.empty_state.subtitle")}</p>
|
|
36
38
|
</div>
|
|
37
|
-
<AssistantComposer value={draft} onValueChange={setDraft} onSend={onSend} />
|
|
39
|
+
<AssistantComposer value={draft} onValueChange={setDraft} onSend={onSend} leading={composerLeading} />
|
|
38
40
|
{onOperatorModeChange && (
|
|
39
41
|
<div className="flex items-center justify-end gap-2">
|
|
40
42
|
<Switch
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useTranslations } from "next-intl";
|
|
4
4
|
import { Plus } from "lucide-react";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
5
6
|
import { Button } from "../../../../shadcnui";
|
|
6
7
|
import { MicroLabel } from "../../../../components/typography";
|
|
7
8
|
import type { AssistantInterface } from "../../data/AssistantInterface";
|
|
@@ -11,7 +12,12 @@ interface Props {
|
|
|
11
12
|
threads: AssistantInterface[];
|
|
12
13
|
activeId?: string;
|
|
13
14
|
onSelect: (id: string) => void;
|
|
14
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Starts a new thread. Omit it when the host renders the "new" action
|
|
17
|
+
* elsewhere (the `/assistants` page puts it in the page header), and the
|
|
18
|
+
* sidebar then shows only the thread list.
|
|
19
|
+
*/
|
|
20
|
+
onNew?: () => void;
|
|
15
21
|
/**
|
|
16
22
|
* Label of the "start a new thread" button. Defaults to the assistant's own
|
|
17
23
|
* copy; a surface that is not the assistant (the handbook chat) passes its
|
|
@@ -20,9 +26,19 @@ interface Props {
|
|
|
20
26
|
newLabel?: string;
|
|
21
27
|
/** Shown when there are no threads. Same rationale as `newLabel`. */
|
|
22
28
|
emptyLabel?: string;
|
|
29
|
+
/** Optional badge rendered inside each thread button, after the title (e.g. the thread's scope). */
|
|
30
|
+
renderThreadBadge?: (thread: AssistantInterface) => ReactNode;
|
|
23
31
|
}
|
|
24
32
|
|
|
25
|
-
export function AssistantSidebar({
|
|
33
|
+
export function AssistantSidebar({
|
|
34
|
+
threads,
|
|
35
|
+
activeId,
|
|
36
|
+
onSelect,
|
|
37
|
+
onNew,
|
|
38
|
+
newLabel,
|
|
39
|
+
emptyLabel,
|
|
40
|
+
renderThreadBadge,
|
|
41
|
+
}: Props) {
|
|
26
42
|
const t = useTranslations();
|
|
27
43
|
const groups = groupThreadsByBucket(threads);
|
|
28
44
|
|
|
@@ -42,6 +58,7 @@ export function AssistantSidebar({ threads, activeId, onSelect, onNew, newLabel,
|
|
|
42
58
|
}
|
|
43
59
|
>
|
|
44
60
|
{thread.title}
|
|
61
|
+
{renderThreadBadge?.(thread)}
|
|
45
62
|
</button>
|
|
46
63
|
))}
|
|
47
64
|
</div>
|
|
@@ -50,11 +67,13 @@ export function AssistantSidebar({ threads, activeId, onSelect, onNew, newLabel,
|
|
|
50
67
|
|
|
51
68
|
return (
|
|
52
69
|
<aside className="bg-muted/30 flex w-64 flex-col border-e">
|
|
53
|
-
|
|
54
|
-
<
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
70
|
+
{onNew && (
|
|
71
|
+
<div className="border-b p-3">
|
|
72
|
+
<Button onClick={onNew} className="w-full" size="sm">
|
|
73
|
+
<Plus className="me-1 h-4 w-4" /> {newLabel ?? t("features.assistant.new")}
|
|
74
|
+
</Button>
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
58
77
|
<div className="flex-1 overflow-y-auto p-2">
|
|
59
78
|
{threads.length === 0 ? (
|
|
60
79
|
<div className="text-muted-foreground mt-6 text-center text-xs">
|
|
@@ -29,4 +29,12 @@ describe("AssistantComposer", () => {
|
|
|
29
29
|
const send = screen.getByRole("button", { name: /features\.assistant\.send/i });
|
|
30
30
|
expect(send).toBeDisabled();
|
|
31
31
|
});
|
|
32
|
+
|
|
33
|
+
it("renders the leading slot inside the input box, before the send button", () => {
|
|
34
|
+
render(<AssistantComposer onSend={vi.fn()} leading={<span data-testid="leading-slot" />} />);
|
|
35
|
+
const slot = screen.getByTestId("leading-slot");
|
|
36
|
+
const send = screen.getByRole("button", { name: /features\.assistant\.send/i });
|
|
37
|
+
expect(slot.compareDocumentPosition(send) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
38
|
+
expect(slot.closest(".rounded-lg")).toBe(send.closest(".rounded-lg"));
|
|
39
|
+
});
|
|
32
40
|
});
|
|
@@ -55,4 +55,11 @@ describe("AssistantSidebar", () => {
|
|
|
55
55
|
await userEvent.click(screen.getByRole("button", { name: /^T$/ }));
|
|
56
56
|
expect(onSelect).toHaveBeenCalledWith("a1");
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
it("without onNew renders no new button", () => {
|
|
60
|
+
const threads = [buildAssistantStub({ id: "a1", title: "T", updatedAt: new Date("2026-04-22T09:00:00Z") })];
|
|
61
|
+
render(<AssistantSidebar threads={threads} onSelect={vi.fn()} />);
|
|
62
|
+
expect(screen.queryByRole("button", { name: /features\.assistant\.new/ })).not.toBeInTheDocument();
|
|
63
|
+
expect(screen.getByRole("button", { name: /^T$/ })).toBeInTheDocument();
|
|
64
|
+
});
|
|
58
65
|
});
|