@nextclaw/ui 0.12.25 → 0.12.27

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 (48) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/assets/{channels-list-page-FJDuPwU6.js → channels-list-page-DkPvpAqc.js} +1 -1
  3. package/dist/assets/chat-page-b7Zf32fF.js +1 -0
  4. package/dist/assets/{desktop-kk7qvZ-v.js → desktop-DVUbOWbR.js} +1 -1
  5. package/dist/assets/index-DmWo8dX2.css +1 -0
  6. package/dist/assets/{index-D-AAMKCt.js → index-DqJ3CYwi.js} +34 -37
  7. package/dist/assets/marketplace-page-BVqFjnEB.js +105 -0
  8. package/dist/assets/marketplace-page-DkQ2hTs1.js +1 -0
  9. package/dist/assets/{mcp-marketplace-page-DIq_SpMe.js → mcp-marketplace-page-BOYJO0kp.js} +1 -1
  10. package/dist/assets/mcp-marketplace-page-DSML7NN0.js +1 -0
  11. package/dist/assets/{model-config-Bc6VVnxy.js → model-config-Bg2yycmn.js} +1 -1
  12. package/dist/assets/{providers-list-DN0tvISH.js → providers-list-DC1q3fvC.js} +1 -1
  13. package/dist/assets/{runtime-config-page-CRWOwBbl.js → runtime-config-page-q-nC0C5i.js} +1 -1
  14. package/dist/assets/{search-config-C4c1yZSP.js → search-config-CcKHif8O.js} +1 -1
  15. package/dist/assets/{secrets-config-zAF30YfO.js → secrets-config-DSg6O92a.js} +1 -1
  16. package/dist/assets/{use-infinite-scroll-loader-Cvz8ZteY.js → use-infinite-scroll-loader-DF2e6nQ2.js} +1 -1
  17. package/dist/index.html +3 -3
  18. package/package.json +9 -9
  19. package/src/features/agents/components/agents-page.test.tsx +1 -1
  20. package/src/features/agents/components/agents-page.tsx +1 -1
  21. package/src/features/chat/components/chat-session-workspace-panel.tsx +31 -45
  22. package/src/features/chat/components/chat-sidebar-session-item.tsx +7 -9
  23. package/src/features/chat/components/conversation/chat-conversation-header.test.tsx +5 -2
  24. package/src/features/chat/components/conversation/chat-conversation-header.tsx +2 -2
  25. package/src/features/chat/components/conversation/chat-conversation-panel.test.tsx +106 -78
  26. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +172 -167
  27. package/src/features/chat/components/conversation/chat-input-bar.container.tsx +11 -1
  28. package/src/features/chat/components/conversation/session-header/chat-session-header-actions.tsx +2 -2
  29. package/src/features/chat/components/providers/chat-presenter.provider.tsx +2 -0
  30. package/src/features/chat/hooks/use-ncp-agent-runtime.test.tsx +147 -88
  31. package/src/features/chat/managers/ncp-chat-input.manager.test.ts +20 -0
  32. package/src/features/chat/managers/ncp-chat-input.manager.ts +18 -0
  33. package/src/features/chat/managers/ncp-chat-presenter.manager.ts +1 -0
  34. package/src/features/chat/pages/ncp-chat-page.tsx +4 -1
  35. package/src/features/chat/stores/chat-input.store.ts +3 -1
  36. package/src/features/chat/utils/ncp-chat-input-availability.utils.test.ts +1 -0
  37. package/src/features/marketplace/components/curated-shelves/marketplace-curated-scene-route.test.tsx +54 -16
  38. package/src/features/marketplace/components/curated-shelves/marketplace-curated-shelves.tsx +96 -24
  39. package/src/features/marketplace/components/marketplace-page.test.tsx +4 -0
  40. package/src/features/marketplace/components/marketplace-page.tsx +16 -12
  41. package/src/features/marketplace/hooks/use-marketplace-curated-scene-route.ts +14 -5
  42. package/src/platforms/desktop/components/desktop-app-shell.test.tsx +1 -0
  43. package/src/platforms/desktop/components/desktop-app-shell.tsx +1 -1
  44. package/dist/assets/chat-page-D1fMNBrT.js +0 -1
  45. package/dist/assets/index-DnBeV2Xm.css +0 -1
  46. package/dist/assets/marketplace-page-BrCLRIc4.js +0 -105
  47. package/dist/assets/marketplace-page-odDpPYEs.js +0 -1
  48. package/dist/assets/mcp-marketplace-page-CfbOBgKK.js +0 -1
@@ -5,6 +5,8 @@ import {
5
5
  type NcpEndpointEvent,
6
6
  type NcpEndpointManifest,
7
7
  type NcpEndpointSubscriber,
8
+ type NcpMessage,
9
+ type NcpStreamRequestPayload,
8
10
  NcpEventType,
9
11
  } from "@nextclaw/ncp";
10
12
  import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -31,9 +33,57 @@ class DeferredSendClient implements NcpAgentClientEndpoint {
31
33
  readonly stream = vi.fn(async () => {});
32
34
  readonly abort = vi.fn(async () => {});
33
35
  private listeners = new Set<NcpEndpointSubscriber>();
34
- private releaseCompletion: (() => void) | null = null;
35
- private completionGate = new Promise<void>((resolve) => {
36
- this.releaseCompletion = resolve;
36
+
37
+ emit = async (event: NcpEndpointEvent): Promise<void> => {
38
+ this.publish(event);
39
+ };
40
+
41
+ subscribe = (listener: NcpEndpointSubscriber): (() => void) => {
42
+ this.listeners.add(listener);
43
+ return () => {
44
+ this.listeners.delete(listener);
45
+ };
46
+ };
47
+
48
+ send = vi.fn(async (envelope: NcpAgentSendEnvelope) => ({
49
+ sessionId: "session-created",
50
+ userMessageId: envelope.message.id,
51
+ assistantMessageId: "assistant-1",
52
+ runId: "run-1",
53
+ ...(envelope.correlationId ? { correlationId: envelope.correlationId } : {}),
54
+ }));
55
+
56
+ private publish = (event: NcpEndpointEvent): void => {
57
+ for (const listener of this.listeners) {
58
+ listener(event);
59
+ }
60
+ };
61
+ }
62
+
63
+ class ExistingSessionLiveClient implements NcpAgentClientEndpoint {
64
+ readonly manifest: NcpEndpointManifest = {
65
+ endpointKind: "agent",
66
+ endpointId: "existing-session-live-client",
67
+ version: "0.1.0",
68
+ supportsStreaming: true,
69
+ supportsAbort: true,
70
+ supportsProactiveMessages: false,
71
+ supportsLiveSessionStream: true,
72
+ supportedPartTypes: ["text"],
73
+ expectedLatency: "seconds",
74
+ };
75
+
76
+ readonly start = vi.fn(async () => {});
77
+ readonly abort = vi.fn(async () => {});
78
+ private listeners = new Set<NcpEndpointSubscriber>();
79
+ private liveStreamActive = false;
80
+
81
+ stop = vi.fn(async () => {
82
+ this.liveStreamActive = false;
83
+ });
84
+
85
+ stream = vi.fn(async (_payload: NcpStreamRequestPayload) => {
86
+ this.liveStreamActive = true;
37
87
  });
38
88
 
39
89
  emit = async (event: NcpEndpointEvent): Promise<void> => {
@@ -47,78 +97,59 @@ class DeferredSendClient implements NcpAgentClientEndpoint {
47
97
  };
48
98
  };
49
99
 
50
- send = vi.fn(async (_envelope: NcpAgentSendEnvelope): Promise<void> => {
51
- this.publish({
52
- type: NcpEventType.MessageSent,
53
- payload: {
54
- sessionId: "session-created",
55
- message: {
56
- id: "user-1",
57
- sessionId: "session-created",
58
- role: "user",
59
- status: "final",
60
- parts: [{ type: "text", text: "hello" }],
61
- timestamp: now,
100
+ send = vi.fn(async (envelope: NcpAgentSendEnvelope) => {
101
+ const events: NcpEndpointEvent[] = [
102
+ {
103
+ type: NcpEventType.RunStarted,
104
+ payload: {
105
+ sessionId: "session-existing",
106
+ messageId: "assistant-1",
107
+ runId: "run-1",
62
108
  },
63
109
  },
64
- });
65
- this.publish({
66
- type: NcpEventType.RunStarted,
67
- payload: {
68
- sessionId: "session-created",
69
- messageId: "assistant-1",
70
- runId: "run-1",
71
- },
72
- });
73
- await this.completionGate;
74
- this.publish({
75
- type: NcpEventType.MessageTextStart,
76
- payload: {
77
- sessionId: "session-created",
78
- messageId: "assistant-1",
79
- },
80
- });
81
- this.publish({
82
- type: NcpEventType.MessageTextDelta,
83
- payload: {
84
- sessionId: "session-created",
85
- messageId: "assistant-1",
86
- delta: "done",
110
+ {
111
+ type: NcpEventType.MessageTextStart,
112
+ payload: {
113
+ sessionId: "session-existing",
114
+ messageId: "assistant-1",
115
+ },
87
116
  },
88
- });
89
- this.publish({
90
- type: NcpEventType.MessageTextEnd,
91
- payload: {
92
- sessionId: "session-created",
93
- messageId: "assistant-1",
117
+ {
118
+ type: NcpEventType.MessageTextDelta,
119
+ payload: {
120
+ sessionId: "session-existing",
121
+ messageId: "assistant-1",
122
+ delta: "done",
123
+ },
94
124
  },
95
- });
96
- this.publish({
97
- type: NcpEventType.MessageCompleted,
98
- payload: {
99
- sessionId: "session-created",
100
- message: {
101
- id: "assistant-1",
102
- sessionId: "session-created",
103
- role: "assistant",
104
- status: "final",
105
- parts: [{ type: "text", text: "done" }],
106
- timestamp: now,
125
+ {
126
+ type: NcpEventType.MessageTextEnd,
127
+ payload: {
128
+ sessionId: "session-existing",
129
+ messageId: "assistant-1",
107
130
  },
108
131
  },
109
- });
110
- this.publish({
111
- type: NcpEventType.RunFinished,
112
- payload: {
113
- sessionId: "session-created",
114
- runId: "run-1",
132
+ {
133
+ type: NcpEventType.RunFinished,
134
+ payload: {
135
+ sessionId: "session-existing",
136
+ runId: "run-1",
137
+ },
115
138
  },
116
- });
117
- });
139
+ ];
118
140
 
119
- release = (): void => {
120
- this.releaseCompletion?.();
121
- };
141
+ if (this.liveStreamActive) {
142
+ for (const event of events) {
143
+ this.publish(event);
144
+ }
145
+ }
146
+ return {
147
+ sessionId: "session-existing",
148
+ userMessageId: envelope.message.id,
149
+ assistantMessageId: "assistant-1",
150
+ runId: "run-1",
151
+ };
152
+ });
122
153
 
123
154
  private publish = (event: NcpEndpointEvent): void => {
124
155
  for (const listener of this.listeners) {
@@ -127,12 +158,20 @@ class DeferredSendClient implements NcpAgentClientEndpoint {
127
158
  };
128
159
  }
129
160
 
161
+ function readAssistantText(messages: readonly NcpMessage[]): string {
162
+ const assistant = messages.find((message) => message.role === "assistant");
163
+ return assistant?.parts
164
+ .filter((part) => part.type === "text")
165
+ .map((part) => part.text ?? "")
166
+ .join("") ?? "";
167
+ }
168
+
130
169
  describe("useNcpAgentRuntime", () => {
131
170
  beforeEach(() => {
132
171
  vi.clearAllMocks();
133
172
  });
134
173
 
135
- it("keeps the active send stream alive when a new root chat materializes a session id", async () => {
174
+ it("returns a command handle when a new root chat materializes a session id", async () => {
136
175
  const client = new DeferredSendClient();
137
176
  const manager = new DefaultNcpAgentConversationStateManager();
138
177
  const envelope: NcpAgentSendEnvelope = {
@@ -150,31 +189,21 @@ describe("useNcpAgentRuntime", () => {
150
189
  { initialProps: { sessionId: undefined as string | undefined } },
151
190
  );
152
191
 
153
- let sendPromise: Promise<void>;
154
- act(() => {
155
- sendPromise = result.current.send(envelope);
192
+ let handle: Awaited<ReturnType<typeof result.current.send>> | null = null;
193
+ await act(async () => {
194
+ handle = await result.current.send(envelope);
156
195
  });
157
196
 
158
- await waitFor(() => {
159
- expect(result.current.snapshot.activeRun?.sessionId).toBe("session-created");
197
+ expect(handle).toEqual({
198
+ sessionId: "session-created",
199
+ userMessageId: "user-1",
200
+ assistantMessageId: "assistant-1",
201
+ runId: "run-1",
160
202
  });
161
-
162
- rerender({ sessionId: "session-created" });
163
-
203
+ expect(result.current.visibleMessages).toEqual([]);
164
204
  expect(client.stop).not.toHaveBeenCalled();
165
205
 
166
- await act(async () => {
167
- client.release();
168
- await sendPromise;
169
- });
170
-
171
- await waitFor(() => {
172
- expect(result.current.snapshot.activeRun).toBeNull();
173
- });
174
- expect(result.current.visibleMessages.map((message) => message.id)).toEqual([
175
- "user-1",
176
- "assistant-1",
177
- ]);
206
+ rerender({ sessionId: "session-created" });
178
207
  });
179
208
 
180
209
  it("aborts by session id even before a hydrated active run reaches local state", async () => {
@@ -190,4 +219,34 @@ describe("useNcpAgentRuntime", () => {
190
219
 
191
220
  expect(client.abort).toHaveBeenCalledWith({ sessionId: "session-running" });
192
221
  });
222
+
223
+ it("uses the hydrated live stream as the only event source while sending to an existing session", async () => {
224
+ const client = new ExistingSessionLiveClient();
225
+ const manager = new DefaultNcpAgentConversationStateManager();
226
+ await client.stream({ sessionId: "session-existing" });
227
+ const { result } = renderHook(() =>
228
+ useNcpAgentRuntime({ sessionId: "session-existing", client, manager: manager as never }),
229
+ );
230
+
231
+ await act(async () => {
232
+ await result.current.send({
233
+ sessionId: "session-existing",
234
+ message: {
235
+ id: "user-1",
236
+ sessionId: "session-existing",
237
+ role: "user",
238
+ status: "final",
239
+ parts: [{ type: "text", text: "hello" }],
240
+ timestamp: now,
241
+ },
242
+ });
243
+ });
244
+
245
+ await waitFor(() => {
246
+ expect(result.current.snapshot.activeRun).toBeNull();
247
+ expect(readAssistantText(result.current.visibleMessages)).toBe("done");
248
+ });
249
+ expect(client.stop).not.toHaveBeenCalled();
250
+ expect(client.stream).toHaveBeenCalledTimes(1);
251
+ });
193
252
  });
@@ -15,6 +15,7 @@ describe('NcpChatInputManager', () => {
15
15
  composerNodes: [createChatComposerTextNode('hello from current thread')],
16
16
  attachments: [],
17
17
  selectedSkills: [],
18
+ composerFocusRequest: null,
18
19
  selectedSessionType: 'native',
19
20
  selectedModel: 'gpt-5',
20
21
  selectedThinkingLevel: null,
@@ -209,4 +210,23 @@ describe('NcpChatInputManager', () => {
209
210
  expect(streamActionsManager.sendMessage).toHaveBeenCalledTimes(1);
210
211
  expect(sessionListManager.materializeRootSessionRoute).not.toHaveBeenCalled();
211
212
  });
213
+
214
+ it('creates and consumes one-shot composer focus requests', () => {
215
+ const manager = new NcpChatInputManager(
216
+ {} as ConstructorParameters<typeof NcpChatInputManager>[0],
217
+ {} as ConstructorParameters<typeof NcpChatInputManager>[1],
218
+ {} as ConstructorParameters<typeof NcpChatInputManager>[2],
219
+ );
220
+
221
+ manager.requestComposerFocusAtEnd();
222
+
223
+ const request = useChatInputStore.getState().snapshot.composerFocusRequest;
224
+ expect(request).toEqual({ id: 1, placement: 'end' });
225
+
226
+ manager.consumeComposerFocusRequest(999);
227
+ expect(useChatInputStore.getState().snapshot.composerFocusRequest).toEqual(request);
228
+
229
+ manager.consumeComposerFocusRequest(request!.id);
230
+ expect(useChatInputStore.getState().snapshot.composerFocusRequest).toBeNull();
231
+ });
212
232
  });
@@ -132,6 +132,24 @@ export class NcpChatInputManager {
132
132
  this.syncComposerSnapshot(createChatComposerNodesFromDraft(value));
133
133
  };
134
134
 
135
+ requestComposerFocusAtEnd = () => {
136
+ const currentRequest = useChatInputStore.getState().snapshot.composerFocusRequest;
137
+ useChatInputStore.getState().setSnapshot({
138
+ composerFocusRequest: {
139
+ id: (currentRequest?.id ?? 0) + 1,
140
+ placement: 'end',
141
+ },
142
+ });
143
+ };
144
+
145
+ consumeComposerFocusRequest = (requestId: number) => {
146
+ const currentRequest = useChatInputStore.getState().snapshot.composerFocusRequest;
147
+ if (currentRequest?.id !== requestId) {
148
+ return;
149
+ }
150
+ useChatInputStore.getState().setSnapshot({ composerFocusRequest: null });
151
+ };
152
+
135
153
  setComposerNodes = (next: SetStateAction<ChatComposerNode[]>) => {
136
154
  const prev = useChatInputStore.getState().snapshot.composerNodes;
137
155
  const value = this.resolveUpdateValue(prev, next);
@@ -23,5 +23,6 @@ export class NcpChatPresenter {
23
23
  this.chatSessionListManager.createSession();
24
24
  this.chatSessionListManager.setSelectedAgentId('main');
25
25
  this.chatInputManager.setDraft(prompt);
26
+ this.chatInputManager.requestComposerFocusAtEnd();
26
27
  };
27
28
  }
@@ -281,7 +281,10 @@ function useNcpChatStreamBindings(params: ReturnType<typeof useNcpChatPageState>
281
281
  return;
282
282
  }
283
283
  try {
284
- await agent.send(envelope);
284
+ const handle = await agent.send(envelope);
285
+ if (!payload.sessionKey && handle?.sessionId) {
286
+ presenter.chatSessionListManager.materializeRootSessionRoute(handle.sessionId);
287
+ }
285
288
  } catch (error) {
286
289
  if (payload.restoreDraftOnError) {
287
290
  if (payload.composerNodes && payload.composerNodes.length > 0) {
@@ -44,6 +44,7 @@ export type ChatInputSnapshot = {
44
44
  skillRecords: SessionSkillEntryView[];
45
45
  isSkillsLoading: boolean;
46
46
  selectedSkills: string[];
47
+ composerFocusRequest: { id: number; placement: 'end' } | null;
47
48
  };
48
49
 
49
50
  type ChatInputStore = {
@@ -75,7 +76,8 @@ const initialSnapshot: ChatInputSnapshot = {
75
76
  sessionTypeUnavailable: false,
76
77
  skillRecords: [],
77
78
  isSkillsLoading: false,
78
- selectedSkills: []
79
+ selectedSkills: [],
80
+ composerFocusRequest: null
79
81
  };
80
82
 
81
83
  export const useChatInputStore = create<ChatInputStore>((set) => ({
@@ -36,6 +36,7 @@ function createSnapshot(
36
36
  skillRecords: [],
37
37
  isSkillsLoading: false,
38
38
  selectedSkills: [],
39
+ composerFocusRequest: null,
39
40
  ...overrides,
40
41
  };
41
42
  }
@@ -18,11 +18,27 @@ type ItemsQueryState = {
18
18
  fetchNextPage?: () => Promise<unknown>;
19
19
  };
20
20
 
21
+ type ScenesQueryState = {
22
+ data?: {
23
+ scenes: Array<{
24
+ scene: string;
25
+ title: string;
26
+ description?: string;
27
+ count?: number;
28
+ }>;
29
+ };
30
+ isLoading: boolean;
31
+ isFetching: boolean;
32
+ isError: boolean;
33
+ error: Error | null;
34
+ };
35
+
21
36
  const mocks = vi.hoisted(() => ({
22
37
  navigate: vi.fn(),
23
38
  docOpen: vi.fn(),
24
39
  routeParams: {} as { scene?: string },
25
40
  itemsQuery: null as unknown as ItemsQueryState,
41
+ scenesQuery: null as unknown as ScenesQueryState,
26
42
  }));
27
43
 
28
44
  vi.mock("react-router-dom", async () => {
@@ -55,21 +71,7 @@ vi.mock("@/shared/hooks/use-confirm-dialog", () => ({
55
71
 
56
72
  vi.mock("@/features/marketplace/hooks/use-marketplace", () => ({
57
73
  useMarketplaceItems: () => mocks.itemsQuery,
58
- useMarketplaceSkillScenes: () => ({
59
- data: {
60
- scenes: [
61
- {
62
- scene: "development-debugging",
63
- title: "Development",
64
- description: "Review, debug, analyze, and verify delivery work.",
65
- },
66
- ],
67
- },
68
- isLoading: false,
69
- isFetching: false,
70
- isError: false,
71
- error: null,
72
- }),
74
+ useMarketplaceSkillScenes: () => mocks.scenesQuery,
73
75
  useMarketplaceSkillSceneCounts: () => new Map([["development-debugging", 2]]),
74
76
  useMarketplaceInstalled: () => ({
75
77
  data: {
@@ -165,12 +167,32 @@ function createItemsQuery(items: MarketplaceItemSummary[]) {
165
167
  };
166
168
  }
167
169
 
170
+ function createScenesQuery(overrides: Partial<ScenesQueryState> = {}) {
171
+ return {
172
+ data: {
173
+ scenes: [
174
+ {
175
+ scene: "development-debugging",
176
+ title: "Development",
177
+ description: "Review, debug, analyze, and verify delivery work.",
178
+ },
179
+ ],
180
+ },
181
+ isLoading: false,
182
+ isFetching: false,
183
+ isError: false,
184
+ error: null,
185
+ ...overrides,
186
+ };
187
+ }
188
+
168
189
  describe("Marketplace curated scene routes", () => {
169
190
  beforeEach(() => {
170
191
  mocks.navigate.mockReset();
171
192
  mocks.docOpen.mockReset();
172
193
  mocks.routeParams = {};
173
194
  mocks.itemsQuery = createItemsQuery(createSceneItems());
195
+ mocks.scenesQuery = createScenesQuery();
174
196
  });
175
197
 
176
198
  it("opens curated goals through a scene route", async () => {
@@ -183,6 +205,7 @@ describe("Marketplace curated scene routes", () => {
183
205
  expect(mocks.navigate).toHaveBeenCalledWith(
184
206
  "/skills/scenes/development-debugging",
185
207
  );
208
+ expect(screen.getByText("All Skills")).toBeTruthy();
186
209
  expect(container.querySelector("input")?.getAttribute("value") ?? "").toBe("");
187
210
  });
188
211
 
@@ -196,7 +219,7 @@ describe("Marketplace curated scene routes", () => {
196
219
  expect(screen.getByRole("button", { name: "Back" })).toBeTruthy();
197
220
  expect(screen.getByText("Code Review")).toBeTruthy();
198
221
  expect(screen.queryByText("Calendar Sync")).toBeNull();
199
- expect(screen.queryByText("Skill Catalog")).toBeNull();
222
+ expect(screen.queryByText("All Skills")).toBeNull();
200
223
  expect(screen.queryByPlaceholderText("Search skills...")).toBeNull();
201
224
 
202
225
  await user.click(screen.getByRole("button", { name: "Back" }));
@@ -232,4 +255,19 @@ describe("Marketplace curated scene routes", () => {
232
255
 
233
256
  expect(screen.getByTestId("marketplace-loading-more")).toBeTruthy();
234
257
  });
258
+
259
+ it("keeps the shelf layout stable while scenes are still loading", () => {
260
+ mocks.scenesQuery = createScenesQuery({
261
+ data: undefined,
262
+ isLoading: true,
263
+ isFetching: true,
264
+ });
265
+
266
+ render(<MarketplacePage forcedType="skills" />);
267
+
268
+ expect(screen.getByTestId("marketplace-scenes-skeleton")).toBeTruthy();
269
+ expect(screen.getByText("Recently updated")).toBeTruthy();
270
+ expect(screen.getByText("All Skills")).toBeTruthy();
271
+ expect(screen.queryByRole("button", { name: /Development/ })).toBeNull();
272
+ });
235
273
  });