@nextclaw/ui 0.27.2 → 0.27.4
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/CHANGELOG.md +23 -0
- package/dist/.vite/manifest.json +424 -424
- package/dist/assets/{account-panel-svU9I6E6.js → account-panel-Cfx3ocPY.js} +1 -1
- package/dist/assets/{agents-CtI89gpb.js → agents-BdeFtIOV.js} +1 -1
- package/dist/assets/agents-p_wcGhC3.js +1 -0
- package/dist/assets/{api-CI6_8Qt1.js → api-CXWxExzS.js} +2 -2
- package/dist/assets/app-presenter-provider-BB3Xl8b0.js +28 -0
- package/dist/assets/{appearance-settings-page-BhedtMx9.js → appearance-settings-page-DiSRSDN3.js} +1 -1
- package/dist/assets/apps-CKFrWb00.js +1 -0
- package/dist/assets/{book-open-i_BjUBqL.js → book-open-B3Ac0voD.js} +1 -1
- package/dist/assets/{card-DokH7OBp.js → card-eFBmxYiI.js} +1 -1
- package/dist/assets/{channels-list-page-B7G9lHUm.js → channels-list-page-lQOINLW8.js} +1 -1
- package/dist/assets/{circle-dashed-Cx8CIEbg.js → circle-dashed-KGwElDWV.js} +1 -1
- package/dist/assets/{client-CUJUci3r.js → client-BDS360Kz.js} +1 -1
- package/dist/assets/{config-split-page-hY1xug28.js → config-split-page-BaHtm9OW.js} +1 -1
- package/dist/assets/{confirm-dialog-B-ejV666.js → confirm-dialog-jz2cQKId.js} +1 -1
- package/dist/assets/{context-menu-dGVv8klA.js → context-menu-C0qywo73.js} +71 -72
- package/dist/assets/{createLucideIcon-reFMl-W-.js → createLucideIcon-C3LSyx-e.js} +1 -1
- package/dist/assets/cron-UqsFT41c.js +1 -0
- package/dist/assets/{desktop-capabilities-page-CYJTQ3nC.js → desktop-capabilities-page-CKq2Miye.js} +1 -1
- package/dist/assets/{desktop-update-config-guKAozXO.js → desktop-update-config-C_SBtjA1.js} +1 -1
- package/dist/assets/{dialog-DLOwdjT1.js → dialog-DMKYZDFu.js} +1 -1
- package/dist/assets/dist-D9h2cWk3.js +41 -0
- package/dist/assets/doc-browser-2VjSkax1.js +1 -0
- package/dist/assets/{doc-browser-context-CTmOmuqa.js → doc-browser-context-CoVMIiU8.js} +1 -1
- package/dist/assets/{download-DovI3Yvf.js → download-C2gYe8n1.js} +1 -1
- package/dist/assets/{extensions-BZ3qrM8M.js → extensions-DXDFcRVC.js} +1 -1
- package/dist/assets/{external-link-DUD3AbsF.js → external-link-DZzwHRHW.js} +1 -1
- package/dist/assets/{form-actions-B8VniHts.js → form-actions-CZxcWXSm.js} +1 -1
- package/dist/assets/{i18n-IGQbjBqS.js → i18n-ByVUrt9w.js} +2 -2
- package/dist/assets/{inbox-ClAG7SI_.js → inbox-CI8ATevA.js} +1 -1
- package/dist/assets/inbox-D62apR2j.js +1 -0
- package/dist/assets/{keyboard-shortcuts-settings-page-B5oxyPS2.js → keyboard-shortcuts-settings-page-BFBXVf20.js} +1 -1
- package/dist/assets/{login-page-DYaozpzz.js → login-page--Njgbmpm.js} +1 -1
- package/dist/assets/login-page-CzvxaAU8.js +1 -0
- package/dist/assets/{main-gFUvLF57.js → main-Bp1iozNB.js} +2 -2
- package/dist/assets/main-i18n-catalog.config-_4gMZaNz.js +1 -0
- package/dist/assets/main-kp_0b1bg.css +1 -0
- package/dist/assets/marketplace-BTDWnHP7.js +1 -0
- package/dist/assets/mcp-marketplace-page-oHCbBkS-.js +1 -0
- package/dist/assets/{model-config-page-Cduyz2Hy.js → model-config-page-CeVvZ29o.js} +1 -1
- package/dist/assets/{navigation-link-D-JSWFxj.js → navigation-link-O9Wm0epj.js} +1 -1
- package/dist/assets/panel-app-i18n-catalog.config-B9c87s2X.js +1 -0
- package/dist/assets/panel-apps-CniMi7QW.js +1 -0
- package/dist/assets/{panelStandalone-oHxbXvYt.js → panelStandalone-DomappXm.js} +2 -2
- package/dist/assets/plus-C42o6xrD.js +1 -0
- package/dist/assets/{privacy-settings-page-DG9E8N-F.js → privacy-settings-page-CHDv9ppI.js} +1 -1
- package/dist/assets/{projects-DvHyoKIF.js → projects-fPTOn2Wz.js} +1 -1
- package/dist/assets/{provider-models-COzjYl29.js → provider-models-D8WOdsdu.js} +1 -1
- package/dist/assets/{provider-scoped-model-input-Cvy74uj8.js → provider-scoped-model-input-BOPkUOtD.js} +1 -1
- package/dist/assets/{providers-config-page-7rCwVnhh.js → providers-config-page-BilPL0kF.js} +1 -1
- package/dist/assets/{puzzle-C5Wfpjk_.js → puzzle-DmKVREoh.js} +1 -1
- package/dist/assets/{pwa-CAg_9zFs.js → pwa-Cp7pPvsZ.js} +1 -1
- package/dist/assets/{refresh-cw-OhwaPDn1.js → refresh-cw-DOg2pWGu.js} +1 -1
- package/dist/assets/remote-CsJMF8OR.js +1 -0
- package/dist/assets/{rotate-cw-aBponvp7.js → rotate-cw-CWFIat29.js} +1 -1
- package/dist/assets/{runtime-config-page-FI2pk3HH.js → runtime-config-page-P5RUq3YK.js} +1 -1
- package/dist/assets/{save-394QCMXn.js → save-C1G0ZU-G.js} +1 -1
- package/dist/assets/{search-BG2qAlNZ.js → search-Dpgv--de.js} +1 -1
- package/dist/assets/{search-config-page-D4LeiB9y.js → search-config-page-DevSv1ZK.js} +1 -1
- package/dist/assets/{secrets-config-page-BzGcr5Vw.js → secrets-config-page-B4jWTV6V.js} +1 -1
- package/dist/assets/security-config-BGEs0SHi.js +1 -0
- package/dist/assets/{select-xUXYV0Gr.js → select-BJ-EjmCz.js} +1 -1
- package/dist/assets/{service-apps-panel-BTqXn0pg.js → service-apps-panel-BReYr_ni.js} +1 -1
- package/dist/assets/{settings-2-CWOGRRaA.js → settings-2-D3-m8a3v.js} +1 -1
- package/dist/assets/{status-dot-CYifnSnt.js → status-dot-DlELD18e.js} +1 -1
- package/dist/assets/{tag-chip-CjM6lyeh.js → tag-chip-DFzDSvcF.js} +1 -1
- package/dist/assets/{theme-provider-BToFZGZG.js → theme-provider-BsJ6HxeX.js} +1 -1
- package/dist/assets/{trash-2-DvJ6duOW.js → trash-2-BVQs-0ji.js} +1 -1
- package/dist/assets/{use-config-COusaY5p.js → use-config-M1L4xn8c.js} +1 -1
- package/dist/assets/{use-desktop-capabilities-B-u6ojjW.js → use-desktop-capabilities-BO_2CXgu.js} +1 -1
- package/dist/assets/{use-viewport-layout-IKKGpjzF.js → use-viewport-layout-Bnm5W5Qz.js} +1 -1
- package/dist/assets/{x-E9VB4HQx.js → x-waP-WLuT.js} +1 -1
- package/dist/index.html +37 -37
- package/dist/panel-standalone.html +14 -14
- package/package.json +8 -8
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-input-top-slot.test.tsx +71 -0
- package/src/features/chat/features/conversation/components/__tests__/session-user-questions.test.tsx +98 -0
- package/src/features/chat/features/conversation/components/session-conversation-area.tsx +14 -3
- package/src/features/chat/features/conversation/components/session-conversation-input-top-slot.tsx +6 -7
- package/src/features/chat/features/conversation/components/session-user-questions.tsx +177 -0
- package/src/features/chat/features/conversation/hooks/use-session-user-questions.ts +100 -0
- package/src/features/chat/features/message/components/chat-message-list.container.tsx +7 -0
- package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +45 -0
- package/src/features/chat/features/message/utils/chat-message-part.utils.ts +12 -0
- package/src/features/chat/features/message/utils/chat-message.utils.ts +55 -9
- package/src/shared/lib/i18n/locales/en-US/chat.json +16 -1
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +16 -1
- package/dist/assets/agents-DWygCmAK.js +0 -1
- package/dist/assets/app-presenter-provider-_UK6sArj.js +0 -28
- package/dist/assets/apps-C4c4NXqB.js +0 -1
- package/dist/assets/cron-DkYSmE-f.js +0 -1
- package/dist/assets/dist-ZaFFmAGl.js +0 -41
- package/dist/assets/doc-browser-B0zg0EXA.js +0 -1
- package/dist/assets/inbox-CJBCLB1X.js +0 -1
- package/dist/assets/login-page-BxSMwUsc.js +0 -1
- package/dist/assets/main-D_zmSVS1.css +0 -1
- package/dist/assets/main-i18n-catalog.config-itg3Gvzp.js +0 -1
- package/dist/assets/marketplace-DsAf_rnt.js +0 -1
- package/dist/assets/mcp-marketplace-page-BqUz09cj.js +0 -1
- package/dist/assets/panel-app-i18n-catalog.config-BfLHr1tc.js +0 -1
- package/dist/assets/panel-apps-Bg5Fzm3T.js +0 -1
- package/dist/assets/plus-DUdl68Eo.js +0 -1
- package/dist/assets/remote-Dhr4K1oK.js +0 -1
- package/dist/assets/security-config-vD_V0BQF.js +0 -1
package/src/features/chat/features/conversation/components/__tests__/session-user-questions.test.tsx
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
2
|
+
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { UiNcpSessionUserQuestionsView } from "@nextclaw/client-sdk";
|
|
5
|
+
import { nextclawClient } from "@/shared/lib/api";
|
|
6
|
+
import { SessionUserQuestionProvider, SessionUserQuestions, UserQuestionInlineQuestions, UserQuestionReplyReference } from "@/features/chat/features/conversation/components/session-user-questions";
|
|
7
|
+
|
|
8
|
+
const questions: UiNcpSessionUserQuestionsView = {
|
|
9
|
+
sessionId: "session-1",
|
|
10
|
+
questions: [
|
|
11
|
+
{ id: "q-1", messageId: "m-1", askedAt: "2026-09-28T00:00:00.000Z", title: "Which format?", options: ["PDF", "DOCX"], recommendedOption: "PDF", optionDescriptions: { PDF: "Keeps the layout fixed." }, status: "pending" },
|
|
12
|
+
{ id: "q-2", messageId: "m-1", askedAt: "2026-09-28T00:00:00.000Z", title: "Which language?", status: "pending" },
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function renderQuestions() {
|
|
17
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
18
|
+
const onResolved = vi.fn(async () => undefined);
|
|
19
|
+
const view = render(
|
|
20
|
+
<QueryClientProvider client={queryClient}>
|
|
21
|
+
<SessionUserQuestionProvider sessionId="session-1" disabled={false} onResolved={onResolved}>
|
|
22
|
+
<UserQuestionInlineQuestions data={{ questions: questions.questions.map(({ id, title }) => ({ id, title })) }} />
|
|
23
|
+
<textarea aria-label="ordinary draft" defaultValue="keep this draft" />
|
|
24
|
+
<SessionUserQuestions />
|
|
25
|
+
</SessionUserQuestionProvider>
|
|
26
|
+
</QueryClientProvider>,
|
|
27
|
+
);
|
|
28
|
+
return { ...view, onResolved };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("SessionUserQuestions", () => {
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
sessionStorage.clear();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("opens a new question, closes without losing it, reopens from its timeline label, and removes the settled composer entry", async () => {
|
|
38
|
+
let current = questions;
|
|
39
|
+
vi.spyOn(nextclawClient.sessions, "listUserQuestions").mockImplementation(async () => current);
|
|
40
|
+
const resolve = vi.spyOn(nextclawClient.sessions, "resolveUserQuestion").mockImplementation(async () => {
|
|
41
|
+
current = { ...questions, questions: questions.questions.map((question) => question.id === "q-1" ? { ...question, status: "answered" as const, answer: "PDF" } : question) };
|
|
42
|
+
return {
|
|
43
|
+
question: current.questions[0]!,
|
|
44
|
+
handle: { sessionId: "session-1", userMessageId: "answer-1", assistantMessageId: null, runId: "run-1", delivery: "steered" },
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
const { onResolved } = renderQuestions();
|
|
48
|
+
const autoPanel = await screen.findByRole("dialog", { name: "Which language?" });
|
|
49
|
+
fireEvent.click(within(autoPanel).getByRole("button", { name: "Which format?" }));
|
|
50
|
+
expect(screen.getByRole("dialog", { name: "Which format?" })).toBeTruthy();
|
|
51
|
+
fireEvent.click(screen.getByRole("button", { name: /关闭问题面板|Close question panel/ }));
|
|
52
|
+
expect(screen.queryByRole("dialog")).toBeNull();
|
|
53
|
+
const formatTag = screen.getByRole("button", { name: /Which format\?.*(待回答|Waiting)/ });
|
|
54
|
+
fireEvent.click(formatTag);
|
|
55
|
+
const panel = screen.getByRole("dialog", { name: "Which format?" });
|
|
56
|
+
expect(screen.queryByRole("button", { name: /待回答 2|2 to answer/ })).toBeNull();
|
|
57
|
+
expect(within(panel).getByRole("button", { name: /发送|Send/ }).hasAttribute("disabled")).toBe(true);
|
|
58
|
+
expect(within(panel).getByText(/推荐|Recommended/)).toBeTruthy();
|
|
59
|
+
fireEvent.click(within(panel).getByRole("button", { name: /PDF:(?:选项说明|Option details)/ }));
|
|
60
|
+
expect(await screen.findByRole("tooltip")).toHaveProperty("textContent", "Keeps the layout fixed.");
|
|
61
|
+
expect(resolve).not.toHaveBeenCalled();
|
|
62
|
+
fireEvent.click(within(panel).getByRole("button", { name: /^PDF\s+(推荐|Recommended)$/ }));
|
|
63
|
+
await waitFor(() => expect(resolve).toHaveBeenCalledWith("session-1", "q-1", { action: "answer", answer: "PDF" }));
|
|
64
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
|
65
|
+
expect(onResolved).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(screen.getByTitle("PDF").textContent).toMatch(/Which format\?.*(已回答|Answered)/);
|
|
67
|
+
expect(screen.getByRole("button", { name: /待回答 1|1 to answer/ })).toBeTruthy();
|
|
68
|
+
expect((screen.getByRole("textbox", { name: "ordinary draft" }) as HTMLTextAreaElement).value).toBe("keep this draft");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("shows the custom answer as the last lettered option and submits it on Enter", async () => {
|
|
72
|
+
vi.spyOn(nextclawClient.sessions, "listUserQuestions").mockResolvedValue(questions);
|
|
73
|
+
const resolve = vi.spyOn(nextclawClient.sessions, "resolveUserQuestion").mockResolvedValue({
|
|
74
|
+
question: { ...questions.questions[0]!, status: "answered", answer: "ODT" },
|
|
75
|
+
handle: null,
|
|
76
|
+
});
|
|
77
|
+
renderQuestions();
|
|
78
|
+
const panel = await screen.findByRole("dialog", { name: "Which language?" });
|
|
79
|
+
fireEvent.click(within(panel).getByRole("button", { name: "Which format?" }));
|
|
80
|
+
const formatPanel = screen.getByRole("dialog", { name: "Which format?" });
|
|
81
|
+
const input = within(formatPanel).getByRole("textbox", { name: /你的回答|Your answer/ });
|
|
82
|
+
expect(input.getAttribute("placeholder")).toMatch(/自行|own/);
|
|
83
|
+
expect(input.parentElement?.textContent).toContain("C");
|
|
84
|
+
expect(resolve).not.toHaveBeenCalled();
|
|
85
|
+
fireEvent.change(input, { target: { value: "ODT" } });
|
|
86
|
+
fireEvent.keyDown(input, { key: "Enter" });
|
|
87
|
+
await waitFor(() => expect(resolve).toHaveBeenCalledWith("session-1", "q-1", { action: "answer", answer: "ODT" }));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("renders the resolved message as a reference to the original question", () => {
|
|
91
|
+
render(<UserQuestionReplyReference data={{ questionId: "q-1", questionMessageId: "m-1", title: "Which format?", action: "answered", answer: "PDF" }} />);
|
|
92
|
+
const quote = screen.getByText("Which format?").closest("blockquote");
|
|
93
|
+
expect(quote?.getAttribute("data-chat-question-reference-id")).toBe("q-1");
|
|
94
|
+
expect(quote?.getAttribute("data-chat-question-message-id")).toBe("m-1");
|
|
95
|
+
expect(quote?.getAttribute("title")).toMatch(/Which format\?/);
|
|
96
|
+
expect(quote?.textContent).toBe("Which format?");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { useLocation } from "react-router-dom";
|
|
3
|
+
import type { NcpRunHandle } from "@nextclaw/ncp";
|
|
4
|
+
import { SessionUserQuestionProvider, SessionUserQuestions } from "./session-user-questions";
|
|
3
5
|
|
|
4
6
|
import { useAppPresenter } from "@/app/components/app-presenter-provider";
|
|
5
7
|
import { ChatConversationContent } from "@/features/chat/components/conversation/chat-conversation-content";
|
|
@@ -419,7 +421,11 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
|
|
|
419
421
|
!controller.isSending;
|
|
420
422
|
|
|
421
423
|
return (
|
|
422
|
-
|
|
424
|
+
<SessionUserQuestionProvider
|
|
425
|
+
sessionId={sessionKey}
|
|
426
|
+
disabled={isRuntimeBlocked}
|
|
427
|
+
onResolved={async (handle: NcpRunHandle) => { await agent.acceptRun(handle); }}
|
|
428
|
+
>
|
|
423
429
|
<SessionConversationAlerts inputQuery={inputQuery} />
|
|
424
430
|
<ChatConversationContent
|
|
425
431
|
resourceWorkingDir={selectedSession?.workingDir ?? selectedSession?.projectRoot ?? null}
|
|
@@ -460,7 +466,12 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
|
|
|
460
466
|
/>
|
|
461
467
|
}
|
|
462
468
|
/>
|
|
463
|
-
{showWelcome ? null :
|
|
464
|
-
|
|
469
|
+
{showWelcome ? null : (
|
|
470
|
+
<>
|
|
471
|
+
<SessionUserQuestions />
|
|
472
|
+
{renderInput("default")}
|
|
473
|
+
</>
|
|
474
|
+
)}
|
|
475
|
+
</SessionUserQuestionProvider>
|
|
465
476
|
);
|
|
466
477
|
}
|
package/src/features/chat/features/conversation/components/session-conversation-input-top-slot.tsx
CHANGED
|
@@ -9,10 +9,11 @@ export function SessionConversationInputTopSlot({ inputSnapshot, controller }: {
|
|
|
9
9
|
readonly controller: SessionConversationInputController;
|
|
10
10
|
}) {
|
|
11
11
|
const submission = inputSnapshot.pendingSubmission;
|
|
12
|
-
|
|
12
|
+
const needsRecovery = submission && submission.status !== 'sending';
|
|
13
|
+
if (!needsRecovery && controller.queuedInputs.length === 0) return null;
|
|
13
14
|
return <>
|
|
14
15
|
{controller.queuedInputs.length > 0 && <SessionQueuedInputRows controller={controller} />}
|
|
15
|
-
{
|
|
16
|
+
{needsRecovery && <SubmissionStatus submission={submission} controller={controller}
|
|
16
17
|
hasComposerContent={Boolean(inputSnapshot.text.trim() || inputSnapshot.selectedSkills.length || inputSnapshot.attachments.length)} />}
|
|
17
18
|
</>;
|
|
18
19
|
}
|
|
@@ -22,11 +23,9 @@ function SubmissionStatus({ submission, controller, hasComposerContent }: {
|
|
|
22
23
|
readonly controller: SessionConversationInputController;
|
|
23
24
|
readonly hasComposerContent: boolean;
|
|
24
25
|
}) {
|
|
25
|
-
const statusText = submission.status === '
|
|
26
|
-
? t('
|
|
27
|
-
: submission.
|
|
28
|
-
? t('chatSubmissionRejected')
|
|
29
|
-
: t(submission.envelope.sessionId ? 'chatSendResultUnknown' : 'chatSendResultUnknownChild');
|
|
26
|
+
const statusText = submission.status === 'rejected'
|
|
27
|
+
? t('chatSubmissionRejected')
|
|
28
|
+
: t(submission.envelope.sessionId ? 'chatSendResultUnknown' : 'chatSendResultUnknownChild');
|
|
30
29
|
return <div className="rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm" role="status">
|
|
31
30
|
<p>{statusText}</p>
|
|
32
31
|
<p className="mt-1 max-h-16 overflow-auto whitespace-pre-wrap text-muted-foreground">{submission.composer.text}</p>
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { ArrowRight, CircleHelp, CircleSlash, Loader2, MessageCircleQuestion, MessageSquareQuote, X } from "lucide-react";
|
|
3
|
+
import type { NcpRunHandle } from "@nextclaw/client-sdk";
|
|
4
|
+
import { nextclawClient } from "@/shared/lib/api";
|
|
5
|
+
import { t } from "@/shared/lib/i18n";
|
|
6
|
+
import { cn } from "@/shared/lib/utils";
|
|
7
|
+
import type { ChatUserQuestionReply } from "@/features/chat/features/message/utils/chat-message.utils";
|
|
8
|
+
import { ChatConversationTrack } from "@/features/chat/components/conversation/chat-conversation-track";
|
|
9
|
+
import { useChatMessageLayoutStore } from "@/features/chat/stores/chat-message-layout.store";
|
|
10
|
+
import { useSessionUserQuestions, type Question, type QuestionContext } from "@/features/chat/features/conversation/hooks/use-session-user-questions";
|
|
11
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip";
|
|
12
|
+
|
|
13
|
+
const context = createContext<QuestionContext | null>(null);
|
|
14
|
+
|
|
15
|
+
export function SessionUserQuestionProvider({ children, sessionId, disabled, onResolved }: {
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
sessionId: string | null;
|
|
18
|
+
disabled: boolean;
|
|
19
|
+
onResolved: (handle: NcpRunHandle) => Promise<void>;
|
|
20
|
+
}) {
|
|
21
|
+
const value = useSessionUserQuestions({ sessionId, disabled, onResolved });
|
|
22
|
+
return <context.Provider value={value}>{children}</context.Provider>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function UserQuestionInlineQuestions({ data }: { data: unknown }) {
|
|
26
|
+
const state = useContext(context);
|
|
27
|
+
const entries = data && typeof data === "object" && !Array.isArray(data) ? (data as { questions?: unknown }).questions : null;
|
|
28
|
+
if (!Array.isArray(entries)) return null;
|
|
29
|
+
return (
|
|
30
|
+
<span className="inline-flex flex-wrap items-center gap-1.5 align-middle">
|
|
31
|
+
{entries.map((entry) => {
|
|
32
|
+
if (!entry || typeof entry !== "object" || typeof entry.id !== "string" || typeof entry.title !== "string") return null;
|
|
33
|
+
const question = state?.questions.find((item) => item.id === entry.id);
|
|
34
|
+
const status = question?.status ?? "pending";
|
|
35
|
+
const label = status === "answered" ? t("chatUserQuestionAnswered") : status === "dismissed" ? t("chatUserQuestionDismissed") : t("chatUserQuestionPending");
|
|
36
|
+
return status === "pending" ? (
|
|
37
|
+
<button key={entry.id} type="button" aria-expanded={state?.openId === entry.id} onClick={(event) => state?.openQuestion(entry.id, event.currentTarget)}
|
|
38
|
+
className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-border bg-muted/60 px-2 py-1 text-xs leading-4 text-foreground transition-colors hover:border-[var(--interaction-selection-border)] hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
39
|
+
<MessageCircleQuestion aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
|
|
40
|
+
<span className="truncate">{entry.title}</span><span className="shrink-0 text-muted-foreground">{label}</span>
|
|
41
|
+
</button>
|
|
42
|
+
) : (
|
|
43
|
+
<span key={entry.id} className="inline-flex max-w-full items-center gap-1.5 rounded-md bg-muted/40 px-2 py-1 text-xs leading-4 text-muted-foreground" title={question?.answer}>
|
|
44
|
+
<span className="truncate">{entry.title}</span><span>· {label}</span>
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
})}
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function UserQuestionReplyReference({ data }: { data: unknown }) {
|
|
53
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return null;
|
|
54
|
+
const reply = data as ChatUserQuestionReply;
|
|
55
|
+
if (typeof reply.questionId !== "string" || typeof reply.title !== "string") return null;
|
|
56
|
+
const dismissed = reply.action === "dismissed";
|
|
57
|
+
const label = dismissed ? t("chatUserQuestionDismissed") : t("chatUserQuestionReplyReference");
|
|
58
|
+
const Icon = dismissed ? CircleSlash : MessageSquareQuote;
|
|
59
|
+
return (
|
|
60
|
+
<blockquote data-chat-question-reference-id={reply.questionId}
|
|
61
|
+
data-chat-question-message-id={reply.questionMessageId ?? undefined}
|
|
62
|
+
aria-label={`${label}:${reply.title}`}
|
|
63
|
+
title={`${label}:${reply.title}`}
|
|
64
|
+
className="mb-2 flex max-w-full items-center gap-1.5 border-l-2 border-current/35 py-0.5 pl-2 text-xs leading-4 opacity-70">
|
|
65
|
+
<Icon aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
|
|
66
|
+
<span className="min-w-0 truncate">{reply.title}</span>
|
|
67
|
+
</blockquote>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function UserQuestionPanel({ question, pending, state }: { question: Question; pending: readonly Question[]; state: QuestionContext }) {
|
|
72
|
+
const [answer, setAnswer] = useState("");
|
|
73
|
+
const [selectedOption, setSelectedOption] = useState<string | null>(null);
|
|
74
|
+
const [activeHelp, setActiveHelp] = useState<string | null>(null);
|
|
75
|
+
const [submitting, setSubmitting] = useState(false);
|
|
76
|
+
const [error, setError] = useState<string | null>(null);
|
|
77
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
78
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
79
|
+
const submittingRef = useRef(false);
|
|
80
|
+
useEffect(() => { panelRef.current?.focus(); }, []);
|
|
81
|
+
useEffect(() => { if (!question.options?.length) inputRef.current?.focus(); }, [question.options]);
|
|
82
|
+
const resolve = async (action: "answer" | "dismiss", answerValue?: string) => {
|
|
83
|
+
if (!state.sessionId || submittingRef.current || (action === "answer" && !answerValue?.trim())) return;
|
|
84
|
+
submittingRef.current = true;
|
|
85
|
+
setSubmitting(true);
|
|
86
|
+
setError(null);
|
|
87
|
+
setSelectedOption(action === "answer" && question.options?.includes(answerValue ?? "") ? answerValue ?? null : null);
|
|
88
|
+
try {
|
|
89
|
+
const result = await nextclawClient.sessions.resolveUserQuestion(state.sessionId, question.id, {
|
|
90
|
+
action,
|
|
91
|
+
...(action === "answer" ? { answer: answerValue?.trim() } : {}),
|
|
92
|
+
});
|
|
93
|
+
state.closeQuestion();
|
|
94
|
+
await state.refresh();
|
|
95
|
+
if (result.handle) await state.onResolved(result.handle);
|
|
96
|
+
} catch (cause) {
|
|
97
|
+
setSelectedOption(null);
|
|
98
|
+
setError(cause instanceof Error ? cause.message : t("chatUserQuestionFailed"));
|
|
99
|
+
} finally {
|
|
100
|
+
submittingRef.current = false;
|
|
101
|
+
setSubmitting(false);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
return (
|
|
105
|
+
<div ref={panelRef} role="dialog" aria-label={question.title} tabIndex={-1}
|
|
106
|
+
onKeyDown={(event) => { if (event.key === "Escape") { event.stopPropagation(); state.closeQuestion(); } }}
|
|
107
|
+
className="max-h-[min(70vh,30rem)] overflow-y-auto rounded-2xl border border-border bg-card p-3 text-card-foreground shadow-card outline-none sm:p-4">
|
|
108
|
+
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
|
109
|
+
<div className="flex items-center gap-2"><CircleHelp aria-hidden="true" className="h-4 w-4" />{t("chatUserQuestionPanelHeading")}</div>
|
|
110
|
+
<button type="button" aria-label={t("chatUserQuestionClose")} onClick={state.closeQuestion} className="-mr-1 rounded-md p-1 hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><X className="h-4 w-4" /></button>
|
|
111
|
+
</div>
|
|
112
|
+
{pending.length > 1 ? <nav aria-label={t("chatUserQuestionsLabel")} className="mt-3 flex gap-1.5 overflow-x-auto pb-1">
|
|
113
|
+
{pending.map((item) => <button key={item.id} type="button" aria-current={item.id === question.id ? "true" : undefined}
|
|
114
|
+
onClick={() => state.openQuestion(item.id)}
|
|
115
|
+
className="shrink-0 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-[current=true]:bg-[var(--interaction-selection)] aria-[current=true]:text-foreground">{item.title}</button>)}
|
|
116
|
+
</nav> : null}
|
|
117
|
+
<h2 className="mt-3 text-base font-semibold leading-6">{question.title}</h2>
|
|
118
|
+
<TooltipProvider delayDuration={180}>
|
|
119
|
+
<div className="mt-2 space-y-0.5">
|
|
120
|
+
{question.options?.map((option, index) => {
|
|
121
|
+
const description = question.optionDescriptions?.[option];
|
|
122
|
+
return <div key={option} className={cn("group flex min-h-10 w-full items-center rounded-xl transition-colors hover:bg-[var(--interaction-hover)]", question.recommendedOption === option && "bg-[var(--interaction-selection)]")}>
|
|
123
|
+
<button type="button" aria-pressed={selectedOption === option} disabled={state.disabled || submitting} onClick={() => void resolve("answer", option)}
|
|
124
|
+
className="flex min-h-10 min-w-0 flex-1 items-center gap-2.5 rounded-xl px-2 py-1.5 text-left text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
|
|
125
|
+
<span aria-hidden="true" className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border bg-muted/50 text-xs text-muted-foreground">{index < 26 ? String.fromCharCode(65 + index) : index + 1}</span>
|
|
126
|
+
<span className="min-w-0 flex-1">{option}</span>
|
|
127
|
+
{question.recommendedOption === option ? <span className="shrink-0 text-xs text-muted-foreground">{t("chatUserQuestionRecommended")}</span> : null}
|
|
128
|
+
{submitting && selectedOption === option ? <Loader2 aria-label={t("chatUserQuestionSending")} className="h-4 w-4 animate-spin" /> : <ArrowRight aria-hidden="true" className={cn("h-4 w-4 shrink-0 text-muted-foreground transition-opacity", question.recommendedOption === option ? "opacity-70" : "opacity-0 group-hover:opacity-70 group-focus-within:opacity-70")} />}
|
|
129
|
+
</button>
|
|
130
|
+
{description ? <Tooltip open={activeHelp === option} onOpenChange={(open) => setActiveHelp(open ? option : null)}>
|
|
131
|
+
<TooltipTrigger asChild>
|
|
132
|
+
<button type="button" aria-label={`${option}:${t("chatUserQuestionOptionHelp")}`} onClick={() => setActiveHelp(activeHelp === option ? null : option)}
|
|
133
|
+
className="mr-2 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
134
|
+
<CircleHelp aria-hidden="true" className="h-4 w-4" />
|
|
135
|
+
</button>
|
|
136
|
+
</TooltipTrigger>
|
|
137
|
+
<TooltipContent side="top" align="end" className="max-w-64 whitespace-normal break-words text-xs">{description}</TooltipContent>
|
|
138
|
+
</Tooltip> : null}
|
|
139
|
+
</div>;
|
|
140
|
+
})}
|
|
141
|
+
</div>
|
|
142
|
+
</TooltipProvider>
|
|
143
|
+
<div className="mt-2 flex h-10 items-center gap-2.5 px-2">
|
|
144
|
+
<span aria-hidden="true" className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border bg-muted/50 text-xs text-muted-foreground">{(question.options?.length ?? 0) < 26 ? String.fromCharCode(65 + (question.options?.length ?? 0)) : (question.options?.length ?? 0) + 1}</span>
|
|
145
|
+
<input ref={inputRef} aria-label={t("chatUserQuestionAnswerLabel")} value={answer} disabled={state.disabled || submitting} onChange={(event) => setAnswer(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void resolve("answer", answer); } }} placeholder={t("chatUserQuestionAnswerPlaceholder")}
|
|
146
|
+
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-50" />
|
|
147
|
+
<button type="button" disabled={state.disabled || submitting} onClick={() => void resolve("dismiss")} className="h-8 shrink-0 rounded-full border border-border px-3 text-xs hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">{t("chatUserQuestionDismiss")}</button>
|
|
148
|
+
<button type="button" disabled={state.disabled || submitting || !answer.trim()} onClick={() => void resolve("answer", answer)} className="h-8 shrink-0 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:bg-muted disabled:text-muted-foreground">
|
|
149
|
+
{submitting && !selectedOption ? t("chatUserQuestionSending") : t("chatUserQuestionSend")}
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
{error ? <p role="alert" className="mt-2 text-xs text-destructive">{error}</p> : null}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function SessionUserQuestions() {
|
|
158
|
+
const state = useContext(context);
|
|
159
|
+
const layout = useChatMessageLayoutStore((snapshot) => snapshot.layout);
|
|
160
|
+
if (!state?.sessionId) return null;
|
|
161
|
+
const pending = state.questions.filter((question) => question.status === "pending");
|
|
162
|
+
const openQuestion = pending.find((question) => question.id === state.openId);
|
|
163
|
+
if (!pending.length && !state.error) return null;
|
|
164
|
+
const content = (
|
|
165
|
+
<div className={cn("relative mx-auto w-full", layout !== "flat" && "max-w-[min(1120px,100%)] px-3 sm:px-4")} aria-label={t("chatUserQuestionsLabel")}>
|
|
166
|
+
{openQuestion ? <UserQuestionPanel key={openQuestion.id} question={openQuestion} pending={pending} state={state} /> : null}
|
|
167
|
+
{pending.length && !openQuestion ? <button type="button" aria-expanded={false} onClick={(event) => state.openQuestion(pending[pending.length - 1]!.id, event.currentTarget)}
|
|
168
|
+
className="inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs text-muted-foreground hover:bg-[var(--interaction-hover)] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
169
|
+
<MessageCircleQuestion aria-hidden="true" className="h-3.5 w-3.5" />{t("chatUserQuestionsPendingCount").replace("{{count}}", String(pending.length))}
|
|
170
|
+
</button> : null}
|
|
171
|
+
{state.error ? <button type="button" onClick={() => void state.refresh()} className="ml-2 text-xs text-destructive underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">{t("chatUserQuestionFailed")} · {t("chatHistoryRetry")}</button> : null}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
return layout === "flat"
|
|
175
|
+
? <ChatConversationTrack width="composer">{content}</ChatConversationTrack>
|
|
176
|
+
: <div className="bg-background">{content}</div>;
|
|
177
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { eventKeys, type NcpRunHandle, type UiNcpSessionUserQuestionsView } from "@nextclaw/client-sdk";
|
|
4
|
+
import { NcpEventType } from "@nextclaw/ncp";
|
|
5
|
+
import { nextclawClient } from "@/shared/lib/api";
|
|
6
|
+
|
|
7
|
+
const QUESTION_QUERY_KEY = "session-user-questions";
|
|
8
|
+
export type Question = UiNcpSessionUserQuestionsView["questions"][number];
|
|
9
|
+
export type QuestionContext = {
|
|
10
|
+
questions: readonly Question[];
|
|
11
|
+
openId: string | null;
|
|
12
|
+
openQuestion: (id: string, trigger?: HTMLElement) => void;
|
|
13
|
+
closeQuestion: () => void;
|
|
14
|
+
sessionId: string | null;
|
|
15
|
+
disabled: boolean;
|
|
16
|
+
onResolved: (handle: NcpRunHandle) => Promise<void>;
|
|
17
|
+
refresh: () => Promise<void>;
|
|
18
|
+
error: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function readSeenIds(sessionId: string): Set<string> {
|
|
22
|
+
try {
|
|
23
|
+
return new Set(JSON.parse(sessionStorage.getItem(`nextclaw-question-seen:${sessionId}`) ?? "[]") as string[]);
|
|
24
|
+
} catch {
|
|
25
|
+
return new Set();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function markSeen(sessionId: string, ids: Set<string>) {
|
|
30
|
+
try {
|
|
31
|
+
sessionStorage.setItem(`nextclaw-question-seen:${sessionId}`, JSON.stringify([...ids]));
|
|
32
|
+
} catch { /* Storage may be disabled. The current mount still tracks seen IDs. */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useSessionUserQuestions({ sessionId, disabled, onResolved }: {
|
|
36
|
+
sessionId: string | null;
|
|
37
|
+
disabled: boolean;
|
|
38
|
+
onResolved: (handle: NcpRunHandle) => Promise<void>;
|
|
39
|
+
}): QuestionContext {
|
|
40
|
+
const queryClient = useQueryClient();
|
|
41
|
+
const query = useQuery({
|
|
42
|
+
queryKey: [QUESTION_QUERY_KEY, sessionId],
|
|
43
|
+
queryFn: () => nextclawClient.sessions.listUserQuestions(sessionId as string),
|
|
44
|
+
enabled: Boolean(sessionId),
|
|
45
|
+
retry: false,
|
|
46
|
+
staleTime: 0,
|
|
47
|
+
});
|
|
48
|
+
const [manualOpen, setManualOpen] = useState<{ sessionId: string; id: string } | null>(null);
|
|
49
|
+
const [suppressed, setSuppressed] = useState<{ sessionId: string; ids: Set<string> } | null>(null);
|
|
50
|
+
const triggerRef = useRef<HTMLElement | null>(null);
|
|
51
|
+
const questions = useMemo(() => query.data?.questions ?? [], [query.data]);
|
|
52
|
+
const pending = questions.filter((question) => question.status === "pending");
|
|
53
|
+
const seen = sessionId ? readSeenIds(sessionId) : new Set<string>();
|
|
54
|
+
if (suppressed?.sessionId === sessionId) for (const id of suppressed.ids) seen.add(id);
|
|
55
|
+
const autoOpenId = pending.findLast((question) => !seen.has(question.id))?.id ?? null;
|
|
56
|
+
const openId = manualOpen?.sessionId === sessionId && pending.some((question) => question.id === manualOpen.id)
|
|
57
|
+
? manualOpen.id
|
|
58
|
+
: autoOpenId;
|
|
59
|
+
const refresh = async () => {
|
|
60
|
+
if (sessionId) await queryClient.invalidateQueries({ queryKey: [QUESTION_QUERY_KEY, sessionId], exact: true });
|
|
61
|
+
};
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!sessionId) return undefined;
|
|
64
|
+
const invalidate = () => void queryClient.invalidateQueries({ queryKey: [QUESTION_QUERY_KEY, sessionId], exact: true });
|
|
65
|
+
const unsubscribeEvent = nextclawClient.eventBus.on(eventKeys.ncpEvent, (event) => {
|
|
66
|
+
if (event.type !== NcpEventType.MessageSent || event.payload.sessionId !== sessionId) return;
|
|
67
|
+
const { message } = event.payload;
|
|
68
|
+
if (typeof message.metadata?.nextclaw_user_question_id === "string" ||
|
|
69
|
+
message.parts.some((part) => part.type === "extension" && part.extensionType === "nextclaw.user-question")) invalidate();
|
|
70
|
+
});
|
|
71
|
+
const unsubscribeQueue = nextclawClient.eventBus.on(eventKeys.sessionRunQueueUpdated, (event) => {
|
|
72
|
+
if (event.sessionKey === sessionId) invalidate();
|
|
73
|
+
});
|
|
74
|
+
return () => { unsubscribeEvent(); unsubscribeQueue(); };
|
|
75
|
+
}, [queryClient, sessionId]);
|
|
76
|
+
return {
|
|
77
|
+
questions,
|
|
78
|
+
openId,
|
|
79
|
+
openQuestion: (id, trigger) => {
|
|
80
|
+
if (!sessionId) return;
|
|
81
|
+
triggerRef.current = trigger ?? null;
|
|
82
|
+
setManualOpen({ sessionId, id });
|
|
83
|
+
},
|
|
84
|
+
closeQuestion: () => {
|
|
85
|
+
if (sessionId) {
|
|
86
|
+
const ids = new Set([...readSeenIds(sessionId), ...pending.map((question) => question.id)]);
|
|
87
|
+
markSeen(sessionId, ids);
|
|
88
|
+
setSuppressed({ sessionId, ids });
|
|
89
|
+
}
|
|
90
|
+
setManualOpen(null);
|
|
91
|
+
triggerRef.current?.focus();
|
|
92
|
+
triggerRef.current = null;
|
|
93
|
+
},
|
|
94
|
+
sessionId,
|
|
95
|
+
disabled,
|
|
96
|
+
onResolved,
|
|
97
|
+
refresh,
|
|
98
|
+
error: Boolean(query.error),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { usePresenter } from "@/features/chat/components/providers/chat-presenter.provider";
|
|
17
17
|
import {
|
|
18
18
|
adaptChatMessage,
|
|
19
|
+
readChatUserQuestionReply,
|
|
19
20
|
type ChatMessageSource,
|
|
20
21
|
} from "@/features/chat/features/message/utils/chat-message.utils";
|
|
21
22
|
import { buildChatMessageProcessSummary } from "@/features/chat/features/message/utils/chat-message-process-summary.utils";
|
|
@@ -70,6 +71,7 @@ import { formatDateTime, t, type I18nLanguage } from "@/shared/lib/i18n";
|
|
|
70
71
|
import { cn } from "@/shared/lib/utils";
|
|
71
72
|
import type { SessionMessageToolPayloadState } from "@/features/chat/features/ncp/hooks/use-ncp-session-message-history";
|
|
72
73
|
import { ChatMessageObservationEvent } from "@/features/chat/features/message/components/chat-message-observation-event";
|
|
74
|
+
import { UserQuestionInlineQuestions, UserQuestionReplyReference } from "@/features/chat/features/conversation/components/session-user-questions";
|
|
73
75
|
import {
|
|
74
76
|
isObservationEventPartExtensionType,
|
|
75
77
|
readObservationEventPartData,
|
|
@@ -166,6 +168,7 @@ class ChatMessageViewModelAdapter {
|
|
|
166
168
|
processSummary,
|
|
167
169
|
executionSummaryLabel: executionPresentation?.summaryLabel,
|
|
168
170
|
moreActions,
|
|
171
|
+
userQuestionReply: readChatUserQuestionReply(message) ?? undefined,
|
|
169
172
|
},
|
|
170
173
|
parts: adaptNcpMessagePartsForChat(message.parts) as ChatMessageSource["parts"],
|
|
171
174
|
};
|
|
@@ -407,6 +410,10 @@ export function ChatMessageListContainer({
|
|
|
407
410
|
}, []);
|
|
408
411
|
const renderCustomPart = useCallback(
|
|
409
412
|
(part: Extract<ChatMessageViewModel["parts"][number], { type: "custom" }>) => {
|
|
413
|
+
if (part.customType === "nextclaw.user-question") {
|
|
414
|
+
return <UserQuestionInlineQuestions data={part.data} />;
|
|
415
|
+
}
|
|
416
|
+
if (part.customType === "nextclaw.user-question-reply") return <UserQuestionReplyReference data={part.data} />;
|
|
410
417
|
if (isObservationEventPartExtensionType(part.customType)) {
|
|
411
418
|
const event = readObservationEventPartData(part.data);
|
|
412
419
|
return event ? <ChatMessageObservationEvent event={event} /> : undefined;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { ToolInvocationStatus, type UiMessage } from "@nextclaw/agent-chat";
|
|
2
|
+
import type { NcpMessage } from "@nextclaw/ncp";
|
|
3
|
+
import { readChatUserQuestionReply } from "@/features/chat/features/message/utils/chat-message.utils";
|
|
2
4
|
import { adapt, toSource, type ChatMessageSource } from "./chat-message-test.utils";
|
|
3
5
|
|
|
4
6
|
it("maps markdown, reasoning, and tool parts into UI view models", () => {
|
|
@@ -116,6 +118,49 @@ it("maps context compaction extensions into stable process parts", () => {
|
|
|
116
118
|
}]);
|
|
117
119
|
});
|
|
118
120
|
|
|
121
|
+
it("renders a structured user question at its message position without repeating fallback text", () => {
|
|
122
|
+
const data = { questions: [{ id: "question-1", title: "Which format?", options: ["PDF", "DOCX"] }] };
|
|
123
|
+
const adapted = adapt([{
|
|
124
|
+
id: "assistant-question",
|
|
125
|
+
role: "assistant",
|
|
126
|
+
parts: [
|
|
127
|
+
{ type: "text", text: "Which format? (PDF / DOCX)" },
|
|
128
|
+
{ type: "extension", extensionType: "nextclaw.user-question", data },
|
|
129
|
+
],
|
|
130
|
+
}]);
|
|
131
|
+
expect(adapted[0]?.parts).toEqual([{
|
|
132
|
+
type: "custom",
|
|
133
|
+
id: "question-1",
|
|
134
|
+
customType: "nextclaw.user-question",
|
|
135
|
+
data,
|
|
136
|
+
}]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("projects a delayed answer as a quoted question plus the actual answer", () => {
|
|
140
|
+
const message: NcpMessage = {
|
|
141
|
+
id: "answer-1",
|
|
142
|
+
sessionId: "session-1",
|
|
143
|
+
role: "user",
|
|
144
|
+
status: "final",
|
|
145
|
+
timestamp: "2026-09-28T00:00:00.000Z",
|
|
146
|
+
parts: [{ type: "text", text: "[Reply to an earlier question]\nQuestion: Which format?\nAnswer: PDF" }],
|
|
147
|
+
metadata: {
|
|
148
|
+
nextclaw_user_question_id: "q-1",
|
|
149
|
+
nextclaw_user_question_action: "answered",
|
|
150
|
+
nextclaw_user_question_title: "Which format?",
|
|
151
|
+
nextclaw_user_question_message_id: "asked-1",
|
|
152
|
+
nextclaw_user_question_answer: "PDF",
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
const reply = readChatUserQuestionReply(message);
|
|
156
|
+
expect(reply).toMatchObject({ questionId: "q-1", questionMessageId: "asked-1", title: "Which format?", answer: "PDF" });
|
|
157
|
+
const adapted = adapt([{ id: message.id, role: "user", meta: { userQuestionReply: reply! }, parts: [{ type: "text", text: message.parts[0]!.type === "text" ? message.parts[0]!.text : "" }] }]);
|
|
158
|
+
expect(adapted[0]?.parts).toEqual([
|
|
159
|
+
{ type: "custom", id: "q-1", customType: "nextclaw.user-question-reply", data: reply },
|
|
160
|
+
{ type: "markdown", text: "PDF" },
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
119
164
|
it("maps observation event extensions into visible custom parts", () => {
|
|
120
165
|
const adapted = adapt([{
|
|
121
166
|
id: "observation-event-message-1",
|
|
@@ -250,6 +250,18 @@ function isToolInvocationPart(
|
|
|
250
250
|
function buildExtensionPart(
|
|
251
251
|
part: Extract<ChatMessagePartSource, { type: "extension" }>,
|
|
252
252
|
): Extract<ChatMessagePartViewModel, { type: "custom" }> | null {
|
|
253
|
+
if (part.extensionType === "nextclaw.user-question") {
|
|
254
|
+
const questions = typeof part.data === "object" && part.data !== null
|
|
255
|
+
? (part.data as { questions?: unknown }).questions
|
|
256
|
+
: null;
|
|
257
|
+
if (!Array.isArray(questions) || !questions.length || typeof questions[0]?.id !== "string") return null;
|
|
258
|
+
return {
|
|
259
|
+
type: "custom",
|
|
260
|
+
id: questions[0].id,
|
|
261
|
+
customType: part.extensionType,
|
|
262
|
+
data: part.data,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
253
265
|
if (isObservationEventPartExtensionType(part.extensionType)) {
|
|
254
266
|
const data = readObservationEventPartData(part.data);
|
|
255
267
|
if (!data) return null;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { adaptChatMessagePart } from "./chat-message-part.utils";
|
|
2
|
+
import type { NcpMessage } from "@nextclaw/ncp";
|
|
2
3
|
import type { ChatInlineTokenSource } from "@/features/chat/features/input/utils/chat-inline-token.utils";
|
|
3
4
|
import type {
|
|
4
5
|
ChatMessageMoreActionsViewModel,
|
|
@@ -26,10 +27,46 @@ export type ChatMessageSource = {
|
|
|
26
27
|
processSummary?: ChatMessageProcessSummarySource;
|
|
27
28
|
executionSummaryLabel?: string;
|
|
28
29
|
moreActions?: ChatMessageMoreActionsViewModel;
|
|
30
|
+
userQuestionReply?: ChatUserQuestionReply;
|
|
29
31
|
};
|
|
30
32
|
parts: ChatMessagePartSource[];
|
|
31
33
|
};
|
|
32
34
|
|
|
35
|
+
export type ChatUserQuestionReply = {
|
|
36
|
+
questionId: string;
|
|
37
|
+
questionMessageId: string | null;
|
|
38
|
+
title: string;
|
|
39
|
+
action: "answered" | "dismissed";
|
|
40
|
+
answer: string | null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function readChatUserQuestionReply(message: NcpMessage): ChatUserQuestionReply | null {
|
|
44
|
+
if (message.role !== "user") return null;
|
|
45
|
+
const { metadata } = message;
|
|
46
|
+
const questionId = metadata?.nextclaw_user_question_id;
|
|
47
|
+
const action = metadata?.nextclaw_user_question_action;
|
|
48
|
+
if (typeof questionId !== "string" || (action !== "answered" && action !== "dismissed")) return null;
|
|
49
|
+
const text = message.parts.find((part) => part.type === "text");
|
|
50
|
+
const legacy = text?.type === "text" ? /^↳ ([^\n]+)\n([\s\S]*)$/.exec(text.text) : null;
|
|
51
|
+
const title = typeof metadata?.nextclaw_user_question_title === "string"
|
|
52
|
+
? metadata.nextclaw_user_question_title
|
|
53
|
+
: legacy?.[1];
|
|
54
|
+
if (!title?.trim()) return null;
|
|
55
|
+
const answer = typeof metadata?.nextclaw_user_question_answer === "string"
|
|
56
|
+
? metadata.nextclaw_user_question_answer
|
|
57
|
+
: legacy?.[2] ?? null;
|
|
58
|
+
if (action === "answered" && !answer?.trim()) return null;
|
|
59
|
+
return {
|
|
60
|
+
questionId,
|
|
61
|
+
questionMessageId: typeof metadata?.nextclaw_user_question_message_id === "string"
|
|
62
|
+
? metadata.nextclaw_user_question_message_id
|
|
63
|
+
: null,
|
|
64
|
+
title,
|
|
65
|
+
action,
|
|
66
|
+
answer: action === "answered" ? answer : null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
33
70
|
function resolveMessageTimestamp(message: ChatMessageSource): string {
|
|
34
71
|
const candidate = message.meta?.timestamp;
|
|
35
72
|
if (candidate && Number.isFinite(Date.parse(candidate))) {
|
|
@@ -78,6 +115,23 @@ export function adaptChatMessage(
|
|
|
78
115
|
message: ChatMessageSource,
|
|
79
116
|
params: ChatMessageAdapterParams,
|
|
80
117
|
): ChatMessageViewModel {
|
|
118
|
+
const parts = message.parts
|
|
119
|
+
.map((part) => adaptChatMessagePart({
|
|
120
|
+
part,
|
|
121
|
+
inlineTokens: message.meta?.inlineTokens ?? [],
|
|
122
|
+
texts: params.texts,
|
|
123
|
+
}))
|
|
124
|
+
.filter((part) => part !== null);
|
|
125
|
+
const hasQuestionPart = parts.some(
|
|
126
|
+
(part) => part.type === "custom" && part.customType === "nextclaw.user-question",
|
|
127
|
+
);
|
|
128
|
+
const reply = message.meta?.userQuestionReply;
|
|
129
|
+
const displayParts: ChatMessageViewModel["parts"] = reply
|
|
130
|
+
? [
|
|
131
|
+
{ type: "custom", id: reply.questionId, customType: "nextclaw.user-question-reply", data: reply },
|
|
132
|
+
...(reply.answer ? [{ type: "markdown" as const, text: reply.answer }] : []),
|
|
133
|
+
]
|
|
134
|
+
: hasQuestionPart ? parts.filter((part) => part.type !== "markdown") : parts;
|
|
81
135
|
return {
|
|
82
136
|
id: message.id,
|
|
83
137
|
role: resolveUiRole(message.role),
|
|
@@ -87,15 +141,7 @@ export function adaptChatMessage(
|
|
|
87
141
|
processSummary: message.meta?.processSummary,
|
|
88
142
|
executionSummaryLabel: message.meta?.executionSummaryLabel,
|
|
89
143
|
moreActions: message.meta?.moreActions,
|
|
90
|
-
parts:
|
|
91
|
-
.map((part) =>
|
|
92
|
-
adaptChatMessagePart({
|
|
93
|
-
part,
|
|
94
|
-
inlineTokens: message.meta?.inlineTokens ?? [],
|
|
95
|
-
texts: params.texts,
|
|
96
|
-
}),
|
|
97
|
-
)
|
|
98
|
-
.filter((part) => part !== null),
|
|
144
|
+
parts: displayParts,
|
|
99
145
|
};
|
|
100
146
|
}
|
|
101
147
|
|