@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.
Files changed (105) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/.vite/manifest.json +424 -424
  3. package/dist/assets/{account-panel-svU9I6E6.js → account-panel-Cfx3ocPY.js} +1 -1
  4. package/dist/assets/{agents-CtI89gpb.js → agents-BdeFtIOV.js} +1 -1
  5. package/dist/assets/agents-p_wcGhC3.js +1 -0
  6. package/dist/assets/{api-CI6_8Qt1.js → api-CXWxExzS.js} +2 -2
  7. package/dist/assets/app-presenter-provider-BB3Xl8b0.js +28 -0
  8. package/dist/assets/{appearance-settings-page-BhedtMx9.js → appearance-settings-page-DiSRSDN3.js} +1 -1
  9. package/dist/assets/apps-CKFrWb00.js +1 -0
  10. package/dist/assets/{book-open-i_BjUBqL.js → book-open-B3Ac0voD.js} +1 -1
  11. package/dist/assets/{card-DokH7OBp.js → card-eFBmxYiI.js} +1 -1
  12. package/dist/assets/{channels-list-page-B7G9lHUm.js → channels-list-page-lQOINLW8.js} +1 -1
  13. package/dist/assets/{circle-dashed-Cx8CIEbg.js → circle-dashed-KGwElDWV.js} +1 -1
  14. package/dist/assets/{client-CUJUci3r.js → client-BDS360Kz.js} +1 -1
  15. package/dist/assets/{config-split-page-hY1xug28.js → config-split-page-BaHtm9OW.js} +1 -1
  16. package/dist/assets/{confirm-dialog-B-ejV666.js → confirm-dialog-jz2cQKId.js} +1 -1
  17. package/dist/assets/{context-menu-dGVv8klA.js → context-menu-C0qywo73.js} +71 -72
  18. package/dist/assets/{createLucideIcon-reFMl-W-.js → createLucideIcon-C3LSyx-e.js} +1 -1
  19. package/dist/assets/cron-UqsFT41c.js +1 -0
  20. package/dist/assets/{desktop-capabilities-page-CYJTQ3nC.js → desktop-capabilities-page-CKq2Miye.js} +1 -1
  21. package/dist/assets/{desktop-update-config-guKAozXO.js → desktop-update-config-C_SBtjA1.js} +1 -1
  22. package/dist/assets/{dialog-DLOwdjT1.js → dialog-DMKYZDFu.js} +1 -1
  23. package/dist/assets/dist-D9h2cWk3.js +41 -0
  24. package/dist/assets/doc-browser-2VjSkax1.js +1 -0
  25. package/dist/assets/{doc-browser-context-CTmOmuqa.js → doc-browser-context-CoVMIiU8.js} +1 -1
  26. package/dist/assets/{download-DovI3Yvf.js → download-C2gYe8n1.js} +1 -1
  27. package/dist/assets/{extensions-BZ3qrM8M.js → extensions-DXDFcRVC.js} +1 -1
  28. package/dist/assets/{external-link-DUD3AbsF.js → external-link-DZzwHRHW.js} +1 -1
  29. package/dist/assets/{form-actions-B8VniHts.js → form-actions-CZxcWXSm.js} +1 -1
  30. package/dist/assets/{i18n-IGQbjBqS.js → i18n-ByVUrt9w.js} +2 -2
  31. package/dist/assets/{inbox-ClAG7SI_.js → inbox-CI8ATevA.js} +1 -1
  32. package/dist/assets/inbox-D62apR2j.js +1 -0
  33. package/dist/assets/{keyboard-shortcuts-settings-page-B5oxyPS2.js → keyboard-shortcuts-settings-page-BFBXVf20.js} +1 -1
  34. package/dist/assets/{login-page-DYaozpzz.js → login-page--Njgbmpm.js} +1 -1
  35. package/dist/assets/login-page-CzvxaAU8.js +1 -0
  36. package/dist/assets/{main-gFUvLF57.js → main-Bp1iozNB.js} +2 -2
  37. package/dist/assets/main-i18n-catalog.config-_4gMZaNz.js +1 -0
  38. package/dist/assets/main-kp_0b1bg.css +1 -0
  39. package/dist/assets/marketplace-BTDWnHP7.js +1 -0
  40. package/dist/assets/mcp-marketplace-page-oHCbBkS-.js +1 -0
  41. package/dist/assets/{model-config-page-Cduyz2Hy.js → model-config-page-CeVvZ29o.js} +1 -1
  42. package/dist/assets/{navigation-link-D-JSWFxj.js → navigation-link-O9Wm0epj.js} +1 -1
  43. package/dist/assets/panel-app-i18n-catalog.config-B9c87s2X.js +1 -0
  44. package/dist/assets/panel-apps-CniMi7QW.js +1 -0
  45. package/dist/assets/{panelStandalone-oHxbXvYt.js → panelStandalone-DomappXm.js} +2 -2
  46. package/dist/assets/plus-C42o6xrD.js +1 -0
  47. package/dist/assets/{privacy-settings-page-DG9E8N-F.js → privacy-settings-page-CHDv9ppI.js} +1 -1
  48. package/dist/assets/{projects-DvHyoKIF.js → projects-fPTOn2Wz.js} +1 -1
  49. package/dist/assets/{provider-models-COzjYl29.js → provider-models-D8WOdsdu.js} +1 -1
  50. package/dist/assets/{provider-scoped-model-input-Cvy74uj8.js → provider-scoped-model-input-BOPkUOtD.js} +1 -1
  51. package/dist/assets/{providers-config-page-7rCwVnhh.js → providers-config-page-BilPL0kF.js} +1 -1
  52. package/dist/assets/{puzzle-C5Wfpjk_.js → puzzle-DmKVREoh.js} +1 -1
  53. package/dist/assets/{pwa-CAg_9zFs.js → pwa-Cp7pPvsZ.js} +1 -1
  54. package/dist/assets/{refresh-cw-OhwaPDn1.js → refresh-cw-DOg2pWGu.js} +1 -1
  55. package/dist/assets/remote-CsJMF8OR.js +1 -0
  56. package/dist/assets/{rotate-cw-aBponvp7.js → rotate-cw-CWFIat29.js} +1 -1
  57. package/dist/assets/{runtime-config-page-FI2pk3HH.js → runtime-config-page-P5RUq3YK.js} +1 -1
  58. package/dist/assets/{save-394QCMXn.js → save-C1G0ZU-G.js} +1 -1
  59. package/dist/assets/{search-BG2qAlNZ.js → search-Dpgv--de.js} +1 -1
  60. package/dist/assets/{search-config-page-D4LeiB9y.js → search-config-page-DevSv1ZK.js} +1 -1
  61. package/dist/assets/{secrets-config-page-BzGcr5Vw.js → secrets-config-page-B4jWTV6V.js} +1 -1
  62. package/dist/assets/security-config-BGEs0SHi.js +1 -0
  63. package/dist/assets/{select-xUXYV0Gr.js → select-BJ-EjmCz.js} +1 -1
  64. package/dist/assets/{service-apps-panel-BTqXn0pg.js → service-apps-panel-BReYr_ni.js} +1 -1
  65. package/dist/assets/{settings-2-CWOGRRaA.js → settings-2-D3-m8a3v.js} +1 -1
  66. package/dist/assets/{status-dot-CYifnSnt.js → status-dot-DlELD18e.js} +1 -1
  67. package/dist/assets/{tag-chip-CjM6lyeh.js → tag-chip-DFzDSvcF.js} +1 -1
  68. package/dist/assets/{theme-provider-BToFZGZG.js → theme-provider-BsJ6HxeX.js} +1 -1
  69. package/dist/assets/{trash-2-DvJ6duOW.js → trash-2-BVQs-0ji.js} +1 -1
  70. package/dist/assets/{use-config-COusaY5p.js → use-config-M1L4xn8c.js} +1 -1
  71. package/dist/assets/{use-desktop-capabilities-B-u6ojjW.js → use-desktop-capabilities-BO_2CXgu.js} +1 -1
  72. package/dist/assets/{use-viewport-layout-IKKGpjzF.js → use-viewport-layout-Bnm5W5Qz.js} +1 -1
  73. package/dist/assets/{x-E9VB4HQx.js → x-waP-WLuT.js} +1 -1
  74. package/dist/index.html +37 -37
  75. package/dist/panel-standalone.html +14 -14
  76. package/package.json +8 -8
  77. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input-top-slot.test.tsx +71 -0
  78. package/src/features/chat/features/conversation/components/__tests__/session-user-questions.test.tsx +98 -0
  79. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +14 -3
  80. package/src/features/chat/features/conversation/components/session-conversation-input-top-slot.tsx +6 -7
  81. package/src/features/chat/features/conversation/components/session-user-questions.tsx +177 -0
  82. package/src/features/chat/features/conversation/hooks/use-session-user-questions.ts +100 -0
  83. package/src/features/chat/features/message/components/chat-message-list.container.tsx +7 -0
  84. package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +45 -0
  85. package/src/features/chat/features/message/utils/chat-message-part.utils.ts +12 -0
  86. package/src/features/chat/features/message/utils/chat-message.utils.ts +55 -9
  87. package/src/shared/lib/i18n/locales/en-US/chat.json +16 -1
  88. package/src/shared/lib/i18n/locales/zh-CN/chat.json +16 -1
  89. package/dist/assets/agents-DWygCmAK.js +0 -1
  90. package/dist/assets/app-presenter-provider-_UK6sArj.js +0 -28
  91. package/dist/assets/apps-C4c4NXqB.js +0 -1
  92. package/dist/assets/cron-DkYSmE-f.js +0 -1
  93. package/dist/assets/dist-ZaFFmAGl.js +0 -41
  94. package/dist/assets/doc-browser-B0zg0EXA.js +0 -1
  95. package/dist/assets/inbox-CJBCLB1X.js +0 -1
  96. package/dist/assets/login-page-BxSMwUsc.js +0 -1
  97. package/dist/assets/main-D_zmSVS1.css +0 -1
  98. package/dist/assets/main-i18n-catalog.config-itg3Gvzp.js +0 -1
  99. package/dist/assets/marketplace-DsAf_rnt.js +0 -1
  100. package/dist/assets/mcp-marketplace-page-BqUz09cj.js +0 -1
  101. package/dist/assets/panel-app-i18n-catalog.config-BfLHr1tc.js +0 -1
  102. package/dist/assets/panel-apps-Bg5Fzm3T.js +0 -1
  103. package/dist/assets/plus-DUdl68Eo.js +0 -1
  104. package/dist/assets/remote-Dhr4K1oK.js +0 -1
  105. package/dist/assets/security-config-vD_V0BQF.js +0 -1
@@ -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 : renderInput("default")}
464
- </>
469
+ {showWelcome ? null : (
470
+ <>
471
+ <SessionUserQuestions />
472
+ {renderInput("default")}
473
+ </>
474
+ )}
475
+ </SessionUserQuestionProvider>
465
476
  );
466
477
  }
@@ -9,10 +9,11 @@ export function SessionConversationInputTopSlot({ inputSnapshot, controller }: {
9
9
  readonly controller: SessionConversationInputController;
10
10
  }) {
11
11
  const submission = inputSnapshot.pendingSubmission;
12
- if (!submission && controller.queuedInputs.length === 0) return null;
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
- {submission && <SubmissionStatus submission={submission} controller={controller}
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 === 'sending'
26
- ? t('chatSubmissionSending')
27
- : submission.status === 'rejected'
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: message.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