@nextclaw/ui 0.20.0 → 0.20.2

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 (108) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/assets/{api-C2iYhdTe.js → api-DvpQWjTS.js} +3 -3
  3. package/dist/assets/{appearance-settings-page-BwQDQr0n.js → appearance-settings-page-U0m0ktu3.js} +1 -1
  4. package/dist/assets/{book-open-C0IlT9zs.js → book-open-DRofSTWr.js} +1 -1
  5. package/dist/assets/channels-list-page-BURQarD0.js +8 -0
  6. package/dist/assets/{config-split-page-VDTf3zZb.js → config-split-page-BrAQYTIl.js} +1 -1
  7. package/dist/assets/{confirm-dialog-BI_Vt2s_.js → confirm-dialog-DDJomLnO.js} +1 -1
  8. package/dist/assets/{createLucideIcon-Bydi9Gc5.js → createLucideIcon-DzkNHr5Z.js} +1 -1
  9. package/dist/assets/{desktop-update-config-DVoD6NxG.js → desktop-update-config-BzZFhMv9.js} +1 -1
  10. package/dist/assets/{dist-CmHtkFbs.js → dist-DXbLo2b1.js} +1 -1
  11. package/dist/assets/{dist-A5btQciA.js → dist-NskPB_yW.js} +1 -1
  12. package/dist/assets/doc-browser-DB22tQKU.js +1 -0
  13. package/dist/assets/{doc-browser-context-Bxw0ora-.js → doc-browser-context-CnKV4gJy.js} +1 -1
  14. package/dist/assets/doc-browser-sJjw9fl5.js +1 -0
  15. package/dist/assets/{extensions-CG4voDCh.js → extensions-DvlFMpAs.js} +1 -1
  16. package/dist/assets/{form-actions-lv47uGIB.js → form-actions-UnPEZBkx.js} +1 -1
  17. package/dist/assets/index-Dc_ZMkmo.css +1 -0
  18. package/dist/assets/index-N1HnesBp.js +109 -0
  19. package/dist/assets/loader-circle-BlPXxgDB.js +1 -0
  20. package/dist/assets/mcp-marketplace-page-BIgFTbMq.js +9 -0
  21. package/dist/assets/mcp-marketplace-page-CZN4ovdK.js +1 -0
  22. package/dist/assets/model-config-page-CnSm7kfa.js +1 -0
  23. package/dist/assets/{navigation-link-D0agTj5t.js → navigation-link-NjUdG6Vp.js} +1 -1
  24. package/dist/assets/plus-DVXol9Q5.js +1 -0
  25. package/dist/assets/privacy-settings-page-DVn2HU7W.js +1 -0
  26. package/dist/assets/providers-config-page-CzHJS3eg.js +1 -0
  27. package/dist/assets/react-DCVbGroo.js +8 -0
  28. package/dist/assets/{refresh-cw-DjgY2Eju.js → refresh-cw-CkeninE0.js} +1 -1
  29. package/dist/assets/remote-B2lsJF1c.js +1 -0
  30. package/dist/assets/{rotate-cw-Dot4PVh0.js → rotate-cw-DP33PuXU.js} +1 -1
  31. package/dist/assets/runtime-config-page-D69buyGp.js +1 -0
  32. package/dist/assets/{save-B-jdTe_c.js → save-Cw2Xz-pB.js} +1 -1
  33. package/dist/assets/{search-CEp2AIiV.js → search-C2CFAVvd.js} +1 -1
  34. package/dist/assets/{search-config-page-Dp5guS5O.js → search-config-page-dqNT0Swf.js} +1 -1
  35. package/dist/assets/{secrets-config-page-C7nRMjiv.js → secrets-config-page-De6J3wVx.js} +2 -2
  36. package/dist/assets/security-config-Dcu9C4OL.js +1 -0
  37. package/dist/assets/{select-BNA3cma8.js → select-DSuMhwVP.js} +1 -1
  38. package/dist/assets/skeleton-QoWydNLp.js +1 -0
  39. package/dist/assets/{tag-chip-DmCNi4-9.js → tag-chip-DFBLgsGf.js} +1 -1
  40. package/dist/assets/x-BXvAzpjb.js +1 -0
  41. package/dist/index.html +21 -21
  42. package/package.json +8 -8
  43. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +4 -3
  44. package/src/features/chat/components/layout/__tests__/chat-sidebar-session-area.test.tsx +11 -4
  45. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +17 -17
  46. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +17 -5
  47. package/src/features/chat/components/layout/chat-sidebar.tsx +28 -21
  48. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +41 -1
  49. package/src/features/chat/features/conversation/hooks/use-session-pending-input-actions.ts +22 -4
  50. package/src/features/chat/features/message/components/chat-message-list.container.tsx +33 -4
  51. package/src/features/chat/features/message/utils/__tests__/chat-message-file-operation.utils.test.ts +186 -0
  52. package/src/features/chat/features/message/utils/__tests__/chat-message-texts.utils.test.ts +9 -0
  53. package/src/features/chat/features/message/utils/__tests__/chat-message-timeline.utils.test.ts +71 -0
  54. package/src/features/chat/features/message/utils/__tests__/chat-message-trigger-details.utils.test.ts +147 -0
  55. package/src/features/chat/features/message/utils/chat-message-part.utils.ts +1 -1
  56. package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +36 -0
  57. package/src/features/chat/features/message/utils/chat-message-trigger-details.utils.ts +118 -0
  58. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +50 -0
  59. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +90 -1
  60. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +24 -3
  61. package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +35 -19
  62. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +26 -2
  63. package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx +55 -27
  64. package/src/features/chat/features/session/components/chat-sidebar-context-card.tsx +60 -0
  65. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +95 -54
  66. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +9 -8
  67. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +141 -95
  68. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +11 -2
  69. package/src/features/chat/features/session/hooks/use-chat-sidebar-context-counts.ts +40 -0
  70. package/src/features/chat/features/session/utils/ncp-session-context-metadata.utils.ts +5 -1
  71. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +16 -9
  72. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +5 -5
  73. package/src/features/chat/features/workspace/components/__tests__/workspace-markdown-outline.test.tsx +128 -0
  74. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +3 -0
  75. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +20 -2
  76. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +3 -40
  77. package/src/features/chat/features/workspace/components/workspace-markdown-outline.tsx +156 -0
  78. package/src/features/chat/managers/__tests__/chat-completion-notification.manager.test.ts +70 -0
  79. package/src/features/chat/managers/chat-completion-notification.manager.ts +23 -6
  80. package/src/features/settings/pages/__tests__/privacy-settings-page.test.tsx +14 -5
  81. package/src/features/settings/pages/privacy-settings-page.tsx +38 -2
  82. package/src/shared/components/ui/actions/icon-action-button.tsx +1 -8
  83. package/src/shared/components/ui/tooltip.tsx +16 -1
  84. package/src/shared/hooks/use-config.ts +11 -0
  85. package/src/shared/lib/api/types.ts +8 -0
  86. package/src/shared/lib/api/utils/config.utils.ts +5 -0
  87. package/src/shared/lib/i18n/locales/en-US/chat.json +28 -0
  88. package/src/shared/lib/i18n/locales/en-US/core.json +17 -9
  89. package/src/shared/lib/i18n/locales/zh-CN/chat.json +28 -0
  90. package/src/shared/lib/i18n/locales/zh-CN/core.json +17 -9
  91. package/dist/assets/channels-list-page-B0z4bh8e.js +0 -8
  92. package/dist/assets/doc-browser-Cm_tMZ_C.js +0 -1
  93. package/dist/assets/doc-browser-Dto54tCf.js +0 -1
  94. package/dist/assets/index-BTSwNDVi.js +0 -109
  95. package/dist/assets/index-QrdUWHfm.css +0 -1
  96. package/dist/assets/loader-circle-CkaqeEne.js +0 -1
  97. package/dist/assets/mcp-marketplace-page-BSk0qUOX.js +0 -9
  98. package/dist/assets/mcp-marketplace-page-BgZGc-S_.js +0 -1
  99. package/dist/assets/model-config-page-c8RnUGoH.js +0 -1
  100. package/dist/assets/plus-BiMjkv3R.js +0 -1
  101. package/dist/assets/privacy-settings-page-CPkyTvEE.js +0 -1
  102. package/dist/assets/providers-config-page-CGUEb5Dx.js +0 -1
  103. package/dist/assets/react-BMYFkaGi.js +0 -8
  104. package/dist/assets/remote-1LxkpRED.js +0 -1
  105. package/dist/assets/runtime-config-page-C6nzqTHg.js +0 -1
  106. package/dist/assets/security-config-DvHUDxSg.js +0 -1
  107. package/dist/assets/skeleton-CbroEO8Q.js +0 -1
  108. package/dist/assets/x-BTo5-A8h.js +0 -1
@@ -0,0 +1,147 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildChatMessageTriggerDetails,
4
+ mergeChatMessageMoreActions,
5
+ } from "@/features/chat/features/message/utils/chat-message-trigger-details.utils";
6
+
7
+ const labels = {
8
+ moreActions: "More actions",
9
+ viewTrigger: "View trigger details",
10
+ title: "Run trigger",
11
+ description: "Why this run started.",
12
+ close: "Close",
13
+ notAvailable: "Not available",
14
+ fields: {
15
+ actor: "Actor",
16
+ source: "Source",
17
+ triggeredAt: "Triggered at",
18
+ targetRunId: "Target run",
19
+ sourceSessionId: "Source session",
20
+ sourceMessageId: "Source message",
21
+ sourceRunId: "Source run",
22
+ sourceToolCallId: "Source tool call",
23
+ sourceRequestId: "Source request",
24
+ sourceModel: "Source model",
25
+ targetModel: "Run model",
26
+ sourceContext: "Source context",
27
+ raw: "Raw metadata",
28
+ },
29
+ actors: {
30
+ human: "Human",
31
+ agent: "Agent",
32
+ automation: "Automation",
33
+ system: "System",
34
+ },
35
+ };
36
+
37
+ describe("buildChatMessageTriggerDetails", () => {
38
+ it("projects structured and raw provenance rows", () => {
39
+ const details = buildChatMessageTriggerDetails({
40
+ labels,
41
+ message: {
42
+ metadata: {
43
+ run_trigger: {
44
+ version: 1,
45
+ actor: "agent",
46
+ source: "sessions_spawn",
47
+ triggeredAt: "2026-08-25T00:00:00.000Z",
48
+ targetRunId: "target-run",
49
+ sourceSessionId: "source-session",
50
+ sourceMessageId: "source-message",
51
+ sourceRunId: "source-run",
52
+ sourceToolCallId: "source-tool-call",
53
+ sourceRequestId: "source-request",
54
+ sourceModel: "openai/gpt-5.6",
55
+ sourceContext: { channel: "telegram", chatId: "chat-1" },
56
+ },
57
+ run_spec: {
58
+ runId: "target-run",
59
+ model: "minimax/MiniMax-M3",
60
+ },
61
+ },
62
+ },
63
+ });
64
+
65
+ expect(details?.triggerLabel).toBe("More actions");
66
+ expect(details?.items[0]?.dialog.rows).toEqual(expect.arrayContaining([
67
+ { label: "Actor", value: "Agent" },
68
+ { label: "Source model", value: "openai/gpt-5.6" },
69
+ { label: "Run model", value: "minimax/MiniMax-M3" },
70
+ { label: "Source context", value: expect.stringContaining("telegram") },
71
+ expect.objectContaining({ label: "Raw metadata" }),
72
+ ]));
73
+ expect(details?.items[0]?.dialog.rows.find(
74
+ (row) => row.label === "Raw metadata",
75
+ )?.value).toContain("run_spec");
76
+ });
77
+
78
+ it("does not create an empty action for legacy messages", () => {
79
+ expect(buildChatMessageTriggerDetails({ labels, message: {} })).toBeNull();
80
+ });
81
+
82
+ it("keeps trigger-only messages actionable and merges assistant details in order", () => {
83
+ const triggerDetails = buildChatMessageTriggerDetails({
84
+ labels,
85
+ message: {
86
+ metadata: {
87
+ run_trigger: {
88
+ version: 1,
89
+ actor: "human",
90
+ source: "ui-http",
91
+ triggeredAt: "2026-08-25T00:00:00.000Z",
92
+ targetRunId: "run-1",
93
+ },
94
+ },
95
+ },
96
+ });
97
+ expect(mergeChatMessageMoreActions(null, triggerDetails)?.items.map(
98
+ (item) => item.key,
99
+ )).toEqual(["run-trigger-metadata"]);
100
+ expect(mergeChatMessageMoreActions({
101
+ triggerLabel: "More actions",
102
+ items: [{
103
+ key: "ai-execution-metadata",
104
+ label: "View run metadata",
105
+ dialog: { title: "Run", closeLabel: "Close", rows: [] },
106
+ }],
107
+ }, triggerDetails)?.items.map((item) => item.key)).toEqual([
108
+ "ai-execution-metadata",
109
+ "run-trigger-metadata",
110
+ ]);
111
+ });
112
+
113
+ it("keeps a completed steering user message actionable from persisted run metadata", () => {
114
+ const details = buildChatMessageTriggerDetails({
115
+ labels,
116
+ message: {
117
+ metadata: {
118
+ run_spec: {
119
+ runId: "run-1",
120
+ model: "openai/gpt-5.6",
121
+ },
122
+ run_trigger: {
123
+ version: 1,
124
+ actor: "human",
125
+ source: "internal",
126
+ sourceMessageId: "user-steering",
127
+ targetRunId: "run-1",
128
+ triggeredAt: "2026-08-25T13:39:04.000Z",
129
+ },
130
+ },
131
+ },
132
+ });
133
+
134
+ expect(details).toMatchObject({
135
+ triggerLabel: "More actions",
136
+ items: [{
137
+ key: "run-trigger-metadata",
138
+ dialog: {
139
+ rows: expect.arrayContaining([
140
+ { label: "Target run", value: "run-1" },
141
+ { label: "Run model", value: "openai/gpt-5.6" },
142
+ ]),
143
+ },
144
+ }],
145
+ });
146
+ });
147
+ });
@@ -174,7 +174,7 @@ function buildToolInvocationPart(
174
174
  detail,
175
175
  inputData: fileOperationCardData ? undefined : invocation.parsedArgs ?? invocation.args,
176
176
  text: shouldShowRawResult && rawResult ? rawResult : undefined,
177
- outputData: invocation.result,
177
+ outputData: shouldShowRawResult ? invocation.result : undefined,
178
178
  callId: invocation.toolCallId || undefined,
179
179
  toolCallId: invocation.toolCallId || undefined,
180
180
  ...(invocation.execution ? { execution: invocation.execution } : {}),
@@ -1,5 +1,6 @@
1
1
  import type { ChatMessageTexts } from "@nextclaw/agent-chat-ui";
2
2
  import type { ChatMessageExecutionPresentationLabels } from "./chat-message-execution-summary.utils";
3
+ import type { ChatMessageTriggerDetailsLabels } from "./chat-message-trigger-details.utils";
3
4
  import type { ChatMessageAdapterTexts } from "@/features/chat/features/message/utils/chat-message.utils";
4
5
  import { t } from "@/shared/lib/i18n";
5
6
 
@@ -140,6 +141,41 @@ export function buildChatMessageExecutionLabels(
140
141
  };
141
142
  }
142
143
 
144
+ export function buildChatMessageTriggerLabels(
145
+ language: string,
146
+ ): ChatMessageTriggerDetailsLabels {
147
+ void language;
148
+ return {
149
+ moreActions: t("chatMessageMoreActions"),
150
+ viewTrigger: t("chatRunTriggerView"),
151
+ title: t("chatRunTriggerTitle"),
152
+ description: t("chatRunTriggerDescription"),
153
+ close: t("chatRunTriggerClose"),
154
+ notAvailable: t("chatAiExecutionNotAvailable"),
155
+ fields: {
156
+ actor: t("chatRunTriggerActor"),
157
+ source: t("chatRunTriggerSource"),
158
+ triggeredAt: t("chatRunTriggerTime"),
159
+ targetRunId: t("chatRunTriggerTargetRun"),
160
+ sourceSessionId: t("chatRunTriggerSourceSession"),
161
+ sourceMessageId: t("chatRunTriggerSourceMessage"),
162
+ sourceRunId: t("chatRunTriggerSourceRun"),
163
+ sourceToolCallId: t("chatRunTriggerSourceToolCall"),
164
+ sourceRequestId: t("chatRunTriggerSourceRequest"),
165
+ sourceModel: t("chatRunTriggerSourceModel"),
166
+ targetModel: t("chatRunTriggerTargetModel"),
167
+ sourceContext: t("chatRunTriggerSourceContext"),
168
+ raw: t("chatRunTriggerRaw"),
169
+ },
170
+ actors: {
171
+ human: t("chatRunTriggerActorHuman"),
172
+ agent: t("chatRunTriggerActorAgent"),
173
+ automation: t("chatRunTriggerActorAutomation"),
174
+ system: t("chatRunTriggerActorSystem"),
175
+ },
176
+ };
177
+ }
178
+
143
179
  export function buildChatMessageTexts(
144
180
  language: string,
145
181
  ): ChatMessageTexts {
@@ -0,0 +1,118 @@
1
+ import type { ChatMessageMoreActionsViewModel } from "@nextclaw/agent-chat-ui";
2
+ import {
3
+ readNcpAiExecutionMetadata,
4
+ readNcpRunTriggerMetadata,
5
+ type NcpMessage,
6
+ type NcpRunTriggerActor,
7
+ } from "@nextclaw/ncp";
8
+
9
+ export type ChatMessageTriggerDetailsLabels = {
10
+ moreActions: string;
11
+ viewTrigger: string;
12
+ title: string;
13
+ description: string;
14
+ close: string;
15
+ notAvailable: string;
16
+ fields: {
17
+ actor: string;
18
+ source: string;
19
+ triggeredAt: string;
20
+ targetRunId: string;
21
+ sourceSessionId: string;
22
+ sourceMessageId: string;
23
+ sourceRunId: string;
24
+ sourceToolCallId: string;
25
+ sourceRequestId: string;
26
+ sourceModel: string;
27
+ targetModel: string;
28
+ sourceContext: string;
29
+ raw: string;
30
+ };
31
+ actors: Record<NcpRunTriggerActor, string>;
32
+ };
33
+
34
+ export function mergeChatMessageMoreActions(
35
+ ...candidates: Array<ChatMessageMoreActionsViewModel | null | undefined>
36
+ ): ChatMessageMoreActionsViewModel | undefined {
37
+ const available = candidates.filter(
38
+ (candidate): candidate is ChatMessageMoreActionsViewModel =>
39
+ Boolean(candidate?.items.length),
40
+ );
41
+ if (available.length === 0) return undefined;
42
+ return {
43
+ triggerLabel: available[0]!.triggerLabel,
44
+ items: available.flatMap((candidate) => candidate.items),
45
+ };
46
+ }
47
+
48
+ function readRunSpec(
49
+ metadata: Record<string, unknown> | null | undefined,
50
+ ): Record<string, unknown> | null {
51
+ const value = metadata?.run_spec;
52
+ return value && typeof value === "object" && !Array.isArray(value)
53
+ ? value as Record<string, unknown>
54
+ : null;
55
+ }
56
+
57
+ function readTargetModel(
58
+ metadata: Record<string, unknown> | null | undefined,
59
+ ): string | undefined {
60
+ const runSpecModel = readRunSpec(metadata)?.model;
61
+ if (typeof runSpecModel === "string" && runSpecModel.trim()) {
62
+ return runSpecModel.trim();
63
+ }
64
+ return readNcpAiExecutionMetadata(metadata)?.model;
65
+ }
66
+
67
+ export function buildChatMessageTriggerDetails(params: {
68
+ labels: ChatMessageTriggerDetailsLabels;
69
+ message: Pick<NcpMessage, "metadata">;
70
+ }): ChatMessageMoreActionsViewModel | null {
71
+ const { labels, message } = params;
72
+ const trigger = readNcpRunTriggerMetadata(message.metadata);
73
+ if (!trigger) return null;
74
+ const { fields } = labels;
75
+ const runSpec = readRunSpec(message.metadata);
76
+ const rows = [
77
+ { label: fields.actor, value: labels.actors[trigger.actor] },
78
+ { label: fields.source, value: trigger.source },
79
+ { label: fields.triggeredAt, value: trigger.triggeredAt },
80
+ { label: fields.targetRunId, value: trigger.targetRunId },
81
+ { label: fields.sourceSessionId, value: trigger.sourceSessionId },
82
+ { label: fields.sourceMessageId, value: trigger.sourceMessageId },
83
+ { label: fields.sourceRunId, value: trigger.sourceRunId },
84
+ { label: fields.sourceToolCallId, value: trigger.sourceToolCallId },
85
+ { label: fields.sourceRequestId, value: trigger.sourceRequestId },
86
+ { label: fields.sourceModel, value: trigger.sourceModel },
87
+ { label: fields.targetModel, value: readTargetModel(message.metadata) },
88
+ {
89
+ label: fields.sourceContext,
90
+ value: trigger.sourceContext
91
+ ? JSON.stringify(trigger.sourceContext, null, 2)
92
+ : undefined,
93
+ },
94
+ ].map((row) => ({
95
+ label: row.label,
96
+ value: row.value ?? labels.notAvailable,
97
+ }));
98
+ rows.push({
99
+ label: fields.raw,
100
+ value: JSON.stringify({
101
+ run_trigger: trigger,
102
+ ...(runSpec ? { run_spec: runSpec } : {}),
103
+ }, null, 2),
104
+ });
105
+ return {
106
+ triggerLabel: labels.moreActions,
107
+ items: [{
108
+ key: "run-trigger-metadata",
109
+ label: labels.viewTrigger,
110
+ dialog: {
111
+ title: labels.title,
112
+ description: labels.description,
113
+ closeLabel: labels.close,
114
+ rows,
115
+ },
116
+ }],
117
+ };
118
+ }
@@ -73,4 +73,54 @@ describe('useNcpAgentRuntime backend queue submission', () => {
73
73
  expect(result.current.visibleMessages).toEqual([queuedMessage]);
74
74
  });
75
75
  });
76
+
77
+ it('keeps a steered message in the pending projection instead of accepting it as a new run', async () => {
78
+ const manager = new DefaultNcpAgentConversationStateManager();
79
+ manager.hydrate({ sessionId: 'session-1', messages: [] });
80
+ await manager.dispatch({
81
+ type: NcpEventType.RunStarted,
82
+ payload: {
83
+ sessionId: 'session-1',
84
+ runId: 'run-1',
85
+ messageId: 'assistant-1',
86
+ },
87
+ });
88
+ const send = vi.fn(async (envelope: NcpAgentSendEnvelope) => ({
89
+ assistantMessageId: null,
90
+ delivery: 'steered' as const,
91
+ runId: 'run-1',
92
+ sessionId: 'session-1',
93
+ userMessageId: envelope.message.id,
94
+ }));
95
+ const client = {
96
+ abort: vi.fn(async () => undefined),
97
+ send,
98
+ stop: vi.fn(async () => undefined),
99
+ stream: vi.fn(async () => undefined),
100
+ subscribe: vi.fn(() => () => undefined),
101
+ } as unknown as NcpAgentClientEndpoint;
102
+ const { result } = renderHook(() => useNcpAgentRuntime({
103
+ client,
104
+ manager,
105
+ sessionId: 'session-1',
106
+ }));
107
+
108
+ await act(async () => {
109
+ await result.current.send({
110
+ delivery: 'prefer-steer',
111
+ message: {
112
+ id: 'user-steering',
113
+ sessionId: 'session-1',
114
+ role: 'user',
115
+ status: 'final',
116
+ parts: [{ type: 'text', text: 'change direction' }],
117
+ timestamp: '2026-08-25T00:00:00.000Z',
118
+ },
119
+ sessionId: 'session-1',
120
+ });
121
+ });
122
+
123
+ expect(result.current.visibleMessages).toEqual([]);
124
+ expect(result.current.snapshot.activeRun?.runId).toBe('run-1');
125
+ });
76
126
  });
@@ -1,4 +1,4 @@
1
- import { act, renderHook, waitFor } from "@testing-library/react";
1
+ import { act, render, renderHook, waitFor } from "@testing-library/react";
2
2
  import {
3
3
  type NcpAgentClientEndpoint,
4
4
  type NcpAgentSendEnvelope,
@@ -158,6 +158,29 @@ class ExistingSessionLiveClient implements NcpAgentClientEndpoint {
158
158
  };
159
159
  }
160
160
 
161
+ function RuntimeMessagesProbe({
162
+ client,
163
+ manager,
164
+ }: {
165
+ client: NcpAgentClientEndpoint;
166
+ manager: DefaultNcpAgentConversationStateManager;
167
+ }) {
168
+ const { visibleMessages } = useNcpAgentRuntime({
169
+ sessionId: "ncp-mt7hs0u9-15yce6jv",
170
+ client,
171
+ manager,
172
+ });
173
+ return (
174
+ <div data-testid="runtime-messages">
175
+ {visibleMessages.map((message) => (
176
+ <div key={message.id} data-message-id={message.id}>
177
+ {message.id}
178
+ </div>
179
+ ))}
180
+ </div>
181
+ );
182
+ }
183
+
161
184
  function readAssistantText(messages: readonly NcpMessage[]): string {
162
185
  const assistant = messages.find((message) => message.role === "assistant");
163
186
  return assistant?.parts
@@ -421,6 +444,72 @@ describe("useNcpAgentRuntime", () => {
421
444
  ]);
422
445
  });
423
446
 
447
+ it("keeps the live assistant boundary and DOM identity when a steered user has an earlier timestamp", async () => {
448
+ const client = new DeferredSendClient();
449
+ const manager = new DefaultNcpAgentConversationStateManager();
450
+ manager.hydrate({
451
+ sessionId: "ncp-mt7hs0u9-15yce6jv",
452
+ messages: [{
453
+ id: "user-mt7jp7vd",
454
+ sessionId: "ncp-mt7hs0u9-15yce6jv",
455
+ role: "user",
456
+ status: "final",
457
+ parts: [{ type: "text", text: "first" }],
458
+ timestamp: "2026-08-24T18:04:10.460Z",
459
+ }],
460
+ });
461
+ await manager.dispatchBatch([
462
+ {
463
+ type: NcpEventType.RunStarted,
464
+ payload: {
465
+ sessionId: "ncp-mt7hs0u9-15yce6jv",
466
+ runId: "agent-run-7c65d8b1-6bda-4f08-a2ad-d5f5318366fb",
467
+ },
468
+ },
469
+ {
470
+ type: NcpEventType.MessageReasoningDelta,
471
+ payload: {
472
+ sessionId: "ncp-mt7hs0u9-15yce6jv",
473
+ messageId: "assistant-message-2629eaf9-c4ce-4562-b22d-c70f1e8c85e2",
474
+ delta: "Planning",
475
+ },
476
+ },
477
+ ]);
478
+
479
+ const view = render(<RuntimeMessagesProbe client={client} manager={manager} />);
480
+ const assistantSelector = '[data-message-id="assistant-message-2629eaf9-c4ce-4562-b22d-c70f1e8c85e2"]';
481
+ const assistantNode = view.container.querySelector(assistantSelector);
482
+ expect(assistantNode).not.toBeNull();
483
+
484
+ await act(async () => {
485
+ await manager.dispatch({
486
+ type: NcpEventType.MessageSent,
487
+ payload: {
488
+ sessionId: "ncp-mt7hs0u9-15yce6jv",
489
+ message: {
490
+ id: "user-mt7jpad5",
491
+ sessionId: "ncp-mt7hs0u9-15yce6jv",
492
+ role: "user",
493
+ status: "final",
494
+ parts: [{ type: "text", text: "steer" }],
495
+ timestamp: "2026-08-24T18:04:13.625Z",
496
+ },
497
+ },
498
+ });
499
+ });
500
+
501
+ await waitFor(() => {
502
+ const ids = [...view.container.querySelectorAll("[data-message-id]")]
503
+ .map((node) => node.getAttribute("data-message-id"));
504
+ expect(ids).toEqual([
505
+ "user-mt7jp7vd",
506
+ "assistant-message-2629eaf9-c4ce-4562-b22d-c70f1e8c85e2",
507
+ "user-mt7jpad5",
508
+ ]);
509
+ });
510
+ expect(view.container.querySelector(assistantSelector)).toBe(assistantNode);
511
+ });
512
+
424
513
  });
425
514
 
426
515
  describe("useNcpAgentRuntime commands and live stream", () => {
@@ -62,13 +62,34 @@ describe("useNcpSessionListView", () => {
62
62
  it("can override the hidden sidebar query for header switching", () => {
63
63
  useChatSessionListStore.getState().setSnapshot({ query: "Alpha" });
64
64
 
65
- const { result } = renderHook(() =>
66
- useNcpSessionListView({ query: "" }),
67
- );
65
+ const { result } = renderHook(() => useNcpSessionListView({ query: "" }));
68
66
 
69
67
  expect(result.current.items.map((item) => item.session.key)).toEqual([
70
68
  "session:alpha",
71
69
  "session:beta",
72
70
  ]);
73
71
  });
72
+
73
+ it("keeps hidden child sessions available for sidebar context counts", () => {
74
+ mocks.sessions.push({
75
+ ...createSummary("session:alpha:child", "Child Task"),
76
+ metadata: {
77
+ label: "Child Task",
78
+ session_type: "native",
79
+ parent_session_id: "session:alpha",
80
+ },
81
+ });
82
+
83
+ const { result } = renderHook(() => useNcpSessionListView());
84
+
85
+ expect(result.current.items.map((item) => item.session.key)).toEqual([
86
+ "session:alpha",
87
+ "session:beta",
88
+ ]);
89
+ expect(result.current.allItems.map((item) => item.session.key)).toEqual([
90
+ "session:alpha",
91
+ "session:beta",
92
+ "session:alpha:child",
93
+ ]);
94
+ });
74
95
  });
@@ -1,17 +1,20 @@
1
- import { useMemo } from 'react';
2
- import type { SessionEntryView } from '@/shared/lib/api';
3
- import { sessionMatchesQuery } from '@/features/chat/features/session/utils/chat-session-display.utils';
4
- import { adaptNcpSessionSummaries } from '@/features/chat/features/session/utils/ncp-session-adapter.utils';
5
- import { useChatSessionListStore } from '@/features/chat/stores/chat-session-list.store';
6
- import { useNcpSessions } from '@/features/chat/features/ncp/hooks/use-ncp-session-queries';
7
- import type { SessionRunStatus } from '@/features/chat/types/session-run-status.types';
1
+ import { useMemo } from "react";
2
+ import type { SessionEntryView } from "@/shared/lib/api";
3
+ import { sessionMatchesQuery } from "@/features/chat/features/session/utils/chat-session-display.utils";
4
+ import { adaptNcpSessionSummaries } from "@/features/chat/features/session/utils/ncp-session-adapter.utils";
5
+ import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
6
+ import { useNcpSessions } from "@/features/chat/features/ncp/hooks/use-ncp-session-queries";
7
+ import type { SessionRunStatus } from "@/features/chat/types/session-run-status.types";
8
8
 
9
9
  export type NcpSessionListItemView = {
10
10
  session: SessionEntryView;
11
11
  runStatus?: SessionRunStatus;
12
12
  };
13
13
 
14
- function filterSessionsByQuery(sessions: readonly SessionEntryView[], query: string): SessionEntryView[] {
14
+ function filterSessionsByQuery(
15
+ sessions: readonly SessionEntryView[],
16
+ query: string,
17
+ ): SessionEntryView[] {
15
18
  return sessions.filter((session) => sessionMatchesQuery(session, query));
16
19
  }
17
20
 
@@ -22,26 +25,39 @@ function shouldShowSessionInSidebar(session: SessionEntryView): boolean {
22
25
  return session.isPromotedChildSession === true;
23
26
  }
24
27
 
25
- export function useNcpSessionListView(params: { limit?: number; query?: string | null } = {}) {
28
+ export function useNcpSessionListView(
29
+ params: { limit?: number; query?: string | null } = {},
30
+ ) {
26
31
  const storedQuery = useChatSessionListStore((state) => state.snapshot.query);
27
32
  const query = params.query ?? storedQuery;
28
33
  const sessionsQuery = useNcpSessions({ limit: params.limit ?? 200 });
29
34
 
30
- const items = useMemo<NcpSessionListItemView[]>(() => {
35
+ const allItems = useMemo<NcpSessionListItemView[]>(() => {
31
36
  const summaries = sessionsQuery.data?.sessions ?? [];
32
- const sessions = adaptNcpSessionSummaries(summaries).filter(
33
- shouldShowSessionInSidebar,
34
- );
35
- const filteredSessions = filterSessionsByQuery(sessions, query);
36
-
37
- return filteredSessions.map((session) => ({
37
+ return adaptNcpSessionSummaries(summaries).map((session) => ({
38
38
  session,
39
- runStatus: session.status === 'running' ? 'running' : undefined
39
+ runStatus: session.status === "running" ? "running" : undefined,
40
40
  }));
41
- }, [query, sessionsQuery.data?.sessions]);
41
+ }, [sessionsQuery.data?.sessions]);
42
+ const items = useMemo<NcpSessionListItemView[]>(() => {
43
+ const visibleItems = allItems.filter(({ session }) =>
44
+ shouldShowSessionInSidebar(session),
45
+ );
46
+ const matchingSessionKeys = new Set(
47
+ filterSessionsByQuery(
48
+ visibleItems.map(({ session }) => session),
49
+ query,
50
+ ).map((session) => session.key),
51
+ );
52
+
53
+ return visibleItems.filter(({ session }) =>
54
+ matchingSessionKeys.has(session.key),
55
+ );
56
+ }, [allItems, query]);
42
57
 
43
58
  return {
59
+ allItems,
44
60
  isLoading: sessionsQuery.isLoading,
45
- items
61
+ items,
46
62
  };
47
63
  }
@@ -1,4 +1,5 @@
1
- import { fireEvent, render, screen } from "@testing-library/react";
1
+ import { fireEvent, render, screen, within } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
2
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
4
  import { ChatSidebarProjectGroups } from "@/features/chat/features/session/components/chat-sidebar-project-groups";
4
5
  import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
@@ -38,6 +39,7 @@ function renderProjectGroups(isPinned = false) {
38
39
  return render(
39
40
  <ChatSidebarProjectGroups
40
41
  groups={[{ ...projectGroup, isPinned }]}
42
+ projectCronJobCountByRoot={new Map([[projectGroup.projectRoot, 2]])}
41
43
  defaultSessionType="native"
42
44
  sessionTypeOptions={[
43
45
  { value: "native", label: "Native", icon: null, ready: true },
@@ -74,7 +76,15 @@ describe("ChatSidebarProjectGroups", () => {
74
76
  expect(header.parentElement?.className).toContain("h-8");
75
77
  expect(header.parentElement?.className).toContain("hover:bg-gray-200/60");
76
78
  expect(header.parentElement?.className).not.toContain("focus-within:bg");
77
- expect(screen.getByText("analysis-project").nextElementSibling?.tagName).toBe("svg");
79
+ expect(
80
+ screen.getByText("analysis-project").nextElementSibling?.tagName,
81
+ ).toBe("svg");
82
+ expect(
83
+ screen
84
+ .getByText("analysis-project")
85
+ .nextElementSibling?.getAttribute("class"),
86
+ ).toContain("opacity-0");
87
+ expect(screen.queryByText("Sessions")).toBeNull();
78
88
 
79
89
  fireEvent.click(header);
80
90
 
@@ -87,6 +97,20 @@ describe("ChatSidebarProjectGroups", () => {
87
97
  expect(screen.queryByText("Project session")).toBeNull();
88
98
  });
89
99
 
100
+ it("shows project context from the row on hover", async () => {
101
+ const user = userEvent.setup();
102
+ renderProjectGroups();
103
+
104
+ await user.hover(screen.getByLabelText("Collapse project"));
105
+
106
+ const tooltip = await screen.findByRole("tooltip");
107
+ expect(within(tooltip).getByText("Path")).toBeTruthy();
108
+ expect(within(tooltip).getByText("Sessions")).toBeTruthy();
109
+ expect(within(tooltip).getByText("Scheduled tasks")).toBeTruthy();
110
+ expect(within(tooltip).getByText("/tmp/analysis-project")).toBeTruthy();
111
+ expect(within(tooltip).getByText("2")).toBeTruthy();
112
+ });
113
+
90
114
  it("keeps project creation and pinning in one trailing action cluster", () => {
91
115
  renderProjectGroups();
92
116