@happyvertical/smrt-chat 0.51.14 → 0.51.16

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.
@@ -2,7 +2,7 @@
2
2
  "version": "1.0.0",
3
3
  "timestamp": 0,
4
4
  "packageName": "@happyvertical/smrt-chat",
5
- "packageVersion": "0.51.14",
5
+ "packageVersion": "0.51.16",
6
6
  "objects": {
7
7
  "@happyvertical/smrt-chat:AgentSessionCollection": {
8
8
  "name": "agentsessioncollection",
@@ -3,12 +3,12 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-chat",
6
- "packageVersion": "0.51.14",
6
+ "packageVersion": "0.51.16",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "224ce698fafb422e17fde5f86cd31c3f42456dca06c545ad4b1c6c900800e214",
11
- "packageJson": "d9de1f7b46e75775af3124ff6f427cfe00b8118cc84100f4a532c370f7866536",
10
+ "manifest": "238cce53d1747fd54174b55f699556ac2cfc85cc759387dbb12dc5642e4114f5",
11
+ "packageJson": "e3feff654d5abf25692fbc53edc42108d21f4b02a297d1150576ff74cdc57487",
12
12
  "agents": "60e1f7c7079515ceee9741763b2b31d191c8234c0603245ebc6c7270213a979a",
13
13
  "moduleDoc:agents/dev-workbench.md": "69b6f827d25c8aeb0e7516c90303af23948803213b5d34bc334c1e3376c83a16",
14
14
  "agentSurface:src/routes/chat-dev.intents.ts": "ff8f7c19c7eb6e841a08f03c97773c53092ed208004666bfed372bd1ad456b35"
@@ -34,16 +34,19 @@ export interface Props {
34
34
  disabled?: boolean;
35
35
  /** Placeholder text for the empty textarea. */
36
36
  placeholder?: string;
37
+ /** The draft text (#2991). Bindable: a host can seed it with a prompt for
38
+ * the user to edit, and read back what they typed. Setting it never sends.
39
+ * Cleared after `onsend` resolves. */
40
+ value?: string;
37
41
  }
38
42
 
39
- const {
43
+ let {
40
44
  onsend,
41
45
  onupload,
42
46
  disabled = false,
43
47
  placeholder = 'Ask the assistant…',
48
+ value: content = $bindable(''),
44
49
  }: Props = $props();
45
-
46
- let content = $state('');
47
50
  let stagedAttachments = $state<AssistantAttachmentRef[]>([]);
48
51
  let uploading = $state(false);
49
52
  let sending = $state(false);
@@ -61,6 +64,23 @@ let fileInputEl: HTMLInputElement | undefined;
61
64
  // Captured from the textarea's input event so auto-resize works without
62
65
  // binding to the Textarea primitive's inner DOM node.
63
66
  let textareaEl: HTMLTextAreaElement | undefined;
67
+ // The Textarea primitive's own instance, for its public `getElement()`.
68
+ let textareaComponent:
69
+ | { getElement(): HTMLTextAreaElement | null }
70
+ | undefined = $state();
71
+
72
+ function resize(el: HTMLTextAreaElement) {
73
+ el.style.height = 'auto';
74
+ el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
75
+ }
76
+
77
+ // #2991: a seeded draft never fires the textarea's input event, so size the
78
+ // textarea to it here. Typed input still resizes through handleInput.
79
+ $effect(() => {
80
+ void content;
81
+ textareaEl ??= textareaComponent?.getElement() ?? undefined;
82
+ if (textareaEl) resize(textareaEl);
83
+ });
64
84
 
65
85
  async function handleFileChange(event: Event) {
66
86
  const input = event.currentTarget as HTMLInputElement;
@@ -114,7 +134,9 @@ async function handleSend() {
114
134
  // user's message forever on a transport failure, with no visible error.
115
135
  try {
116
136
  await onsend(trimmed, stagedAttachments);
117
- content = '';
137
+ // #2991: text typed while the send was in flight is newer than what was
138
+ // sent; keep it.
139
+ if (content.trim() === trimmed) content = '';
118
140
  stagedAttachments = [];
119
141
  if (textareaEl) {
120
142
  textareaEl.style.height = 'auto';
@@ -142,8 +164,7 @@ function handleKeydown(event: KeyboardEvent) {
142
164
  function handleInput(event: Event) {
143
165
  const el = event.currentTarget as HTMLTextAreaElement;
144
166
  textareaEl = el;
145
- el.style.height = 'auto';
146
- el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
167
+ resize(el);
147
168
  }
148
169
 
149
170
  function removeAttachment(id: string) {
@@ -219,6 +240,7 @@ function removeAttachment(id: string) {
219
240
  aria-label={t(M['chat.assistant_composer.attach_files'])}
220
241
  />
221
242
  <Textarea
243
+ bind:this={textareaComponent}
222
244
  bind:value={content}
223
245
  class="assistant-composer-textarea"
224
246
  {placeholder}
@@ -13,8 +13,12 @@ export interface Props {
13
13
  disabled?: boolean;
14
14
  /** Placeholder text for the empty textarea. */
15
15
  placeholder?: string;
16
+ /** The draft text (#2991). Bindable: a host can seed it with a prompt for
17
+ * the user to edit, and read back what they typed. Setting it never sends.
18
+ * Cleared after `onsend` resolves. */
19
+ value?: string;
16
20
  }
17
- declare const AssistantComposer: import("svelte").Component<Props, {}, "">;
21
+ declare const AssistantComposer: import("svelte").Component<Props, {}, "value">;
18
22
  type AssistantComposer = ReturnType<typeof AssistantComposer>;
19
23
  export default AssistantComposer;
20
24
  //# sourceMappingURL=AssistantComposer.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"AssistantComposer.svelte.d.ts","sourceRoot":"","sources":["../../../../src/svelte/components/assistant/AssistantComposer.svelte.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAGvE,MAAM,WAAW,KAAK;IACpB;;;;4DAIwD;IACxD,MAAM,EAAE,CACN,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,sBAAsB,EAAE,KAClC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B;sEACkE;IAClE,QAAQ,EAAE,CAAC,KAAK,EAAE,QAAQ,KAAK,OAAO,CAAC,sBAAsB,EAAE,CAAC,CAAC;IACjE,yDAAyD;IACzD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AA6KD,QAAA,MAAM,iBAAiB,2CAAwC,CAAC;AAChE,KAAK,iBAAiB,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAC9D,eAAe,iBAAiB,CAAC"}
1
+ {"version":3,"file":"AssistantComposer.svelte.d.ts","sourceRoot":"","sources":["../../../../src/svelte/components/assistant/AssistantComposer.svelte.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAGvE,MAAM,WAAW,KAAK;IACpB;;;;4DAIwD;IACxD,MAAM,EAAE,CACN,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,sBAAsB,EAAE,KAClC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B;sEACkE;IAClE,QAAQ,EAAE,CAAC,KAAK,EAAE,QAAQ,KAAK,OAAO,CAAC,sBAAsB,EAAE,CAAC,CAAC;IACjE,yDAAyD;IACzD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;0CAEsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA8LD,QAAA,MAAM,iBAAiB,gDAAwC,CAAC;AAChE,KAAK,iBAAiB,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAC9D,eAAe,iBAAiB,CAAC"}
@@ -32,6 +32,7 @@ import type {
32
32
  import { safeAttachmentHref } from './attachment-href.js';
33
33
  import {
34
34
  type AssistantActionClient,
35
+ type AssistantActionOutcome,
35
36
  type AssistantDockController,
36
37
  createAssistantDockController,
37
38
  } from './create-assistant-dock-controller.svelte.js';
@@ -101,6 +102,21 @@ export interface Props {
101
102
  request: DataSurfaceActionRequest,
102
103
  result: DataSurfaceActionResult,
103
104
  ) => void;
105
+ /** Called each time a proposed action reaches an outcome (#2991): applied,
106
+ * rejected (by the server or the user), or unknown. See
107
+ * `AssistantDockControllerOptions.onActionSettled`. */
108
+ onactionsettled?: (
109
+ request: DataSurfaceActionRequest,
110
+ outcome: AssistantActionOutcome,
111
+ ) => void;
112
+ /** Initial composer draft (#2991), for example a prompt computed for the
113
+ * item being edited. Read once, on mount; the user edits and sends it.
114
+ * It is never sent on their behalf. To replace it later, call
115
+ * `controller.setDraft(text)` from `oncontroller`. */
116
+ initialDraft?: string;
117
+ /** Placeholder for the composer's empty textarea (#2991). Defaults to the
118
+ * composer's own placeholder. */
119
+ composerPlaceholder?: string;
104
120
  }
105
121
 
106
122
  const {
@@ -112,6 +128,9 @@ const {
112
128
  toolCall,
113
129
  oncontroller,
114
130
  onactionapplied,
131
+ onactionsettled,
132
+ initialDraft,
133
+ composerPlaceholder,
115
134
  }: Props = $props();
116
135
  const { t } = useI18n();
117
136
 
@@ -135,6 +154,9 @@ const controller: AssistantDockController = createAssistantDockController({
135
154
  },
136
155
  visible: () => visible,
137
156
  onActionApplied: (request, result) => onactionapplied?.(request, result),
157
+ onActionSettled: (request, outcome) => onactionsettled?.(request, outcome),
158
+ // Read once, on mount, like the controller itself.
159
+ initialDraft: untrack(() => initialDraft),
138
160
  });
139
161
 
140
162
  // F1 (#2904 review): the whole body runs under `untrack` so the effect takes
@@ -495,9 +517,13 @@ async function handleConfirmAction(requestId: string) {
495
517
  </div>
496
518
  {/if}
497
519
  <AssistantComposer
520
+ bind:value={
521
+ () => controller.draft, (text) => controller.setDraft(text)
522
+ }
498
523
  onsend={handleSend}
499
524
  onupload={handleUpload}
500
525
  disabled={!controller.activeThreadId}
526
+ placeholder={composerPlaceholder}
501
527
  />
502
528
  </div>
503
529
  </div>
@@ -1,7 +1,7 @@
1
1
  import type { DataSurfaceActionRequest, DataSurfaceActionResult, DataSurfaceIdentity, DataSurfaceRegistry } from '@happyvertical/smrt-ui/data-surface';
2
2
  import { type Snippet } from 'svelte';
3
3
  import type { AssistantMessage, AssistantTransport } from './assistant-transport.js';
4
- import { type AssistantActionClient, type AssistantDockController } from './create-assistant-dock-controller.svelte.js';
4
+ import { type AssistantActionClient, type AssistantActionOutcome, type AssistantDockController } from './create-assistant-dock-controller.svelte.js';
5
5
  export interface Props {
6
6
  /** Thread/message I/O backend; see `./assistant-transport.js`. */
7
7
  transport: AssistantTransport;
@@ -38,6 +38,18 @@ export interface Props {
38
38
  /** Called after the server accepts an apply (#2989). See
39
39
  * `AssistantDockControllerOptions.onActionApplied`. */
40
40
  onactionapplied?: (request: DataSurfaceActionRequest, result: DataSurfaceActionResult) => void;
41
+ /** Called each time a proposed action reaches an outcome (#2991): applied,
42
+ * rejected (by the server or the user), or unknown. See
43
+ * `AssistantDockControllerOptions.onActionSettled`. */
44
+ onactionsettled?: (request: DataSurfaceActionRequest, outcome: AssistantActionOutcome) => void;
45
+ /** Initial composer draft (#2991), for example a prompt computed for the
46
+ * item being edited. Read once, on mount; the user edits and sends it.
47
+ * It is never sent on their behalf. To replace it later, call
48
+ * `controller.setDraft(text)` from `oncontroller`. */
49
+ initialDraft?: string;
50
+ /** Placeholder for the composer's empty textarea (#2991). Defaults to the
51
+ * composer's own placeholder. */
52
+ composerPlaceholder?: string;
41
53
  }
42
54
  declare const AssistantDock: import("svelte").Component<Props, {}, "">;
43
55
  type AssistantDock = ReturnType<typeof AssistantDock>;
@@ -1 +1 @@
1
- {"version":3,"file":"AssistantDock.svelte.d.ts","sourceRoot":"","sources":["../../../../src/svelte/components/assistant/AssistantDock.svelte.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EACV,wBAAwB,EACxB,uBAAuB,EACvB,mBAAmB,EACnB,mBAAmB,EACpB,MAAM,qCAAqC,CAAC;AAG7C,OAAO,EAAE,KAAK,OAAO,EAAiB,MAAM,QAAQ,CAAC;AAOrD,OAAO,KAAK,EAEV,gBAAgB,EAChB,kBAAkB,EACnB,MAAM,0BAA0B,CAAC;AAElC,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,uBAAuB,EAE7B,MAAM,8CAA8C,CAAC;AAGtD,MAAM,WAAW,KAAK;IACpB,kEAAkE;IAClE,SAAS,EAAE,kBAAkB,CAAC;IAC9B;;qBAEiB;IACjB,QAAQ,EAAE,mBAAmB,CAAC;IAC9B;qEACiE;IACjE,YAAY,CAAC,EAAE,qBAAqB,CAAC;IACrC;;;;;;;;gEAQ4D;IAC5D,QAAQ,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACjC,yEAAyE;IACzE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;0EAIsE;IACtE,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,gBAAgB,CAAC,CAAC,CAAC;IACvC;;;yEAGqE;IACrE,YAAY,CAAC,EAAE,CAAC,UAAU,EAAE,uBAAuB,KAAK,IAAI,CAAC;IAC7D;2DACuD;IACvD,eAAe,CAAC,EAAE,CAChB,OAAO,EAAE,wBAAwB,EACjC,MAAM,EAAE,uBAAuB,KAC5B,IAAI,CAAC;CACX;AAkYD,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"AssistantDock.svelte.d.ts","sourceRoot":"","sources":["../../../../src/svelte/components/assistant/AssistantDock.svelte.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EACV,wBAAwB,EACxB,uBAAuB,EACvB,mBAAmB,EACnB,mBAAmB,EACpB,MAAM,qCAAqC,CAAC;AAG7C,OAAO,EAAE,KAAK,OAAO,EAAiB,MAAM,QAAQ,CAAC;AAOrD,OAAO,KAAK,EAEV,gBAAgB,EAChB,kBAAkB,EACnB,MAAM,0BAA0B,CAAC;AAElC,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC3B,KAAK,uBAAuB,EAE7B,MAAM,8CAA8C,CAAC;AAGtD,MAAM,WAAW,KAAK;IACpB,kEAAkE;IAClE,SAAS,EAAE,kBAAkB,CAAC;IAC9B;;qBAEiB;IACjB,QAAQ,EAAE,mBAAmB,CAAC;IAC9B;qEACiE;IACjE,YAAY,CAAC,EAAE,qBAAqB,CAAC;IACrC;;;;;;;;gEAQ4D;IAC5D,QAAQ,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACjC,yEAAyE;IACzE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;0EAIsE;IACtE,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,gBAAgB,CAAC,CAAC,CAAC;IACvC;;;yEAGqE;IACrE,YAAY,CAAC,EAAE,CAAC,UAAU,EAAE,uBAAuB,KAAK,IAAI,CAAC;IAC7D;2DACuD;IACvD,eAAe,CAAC,EAAE,CAChB,OAAO,EAAE,wBAAwB,EACjC,MAAM,EAAE,uBAAuB,KAC5B,IAAI,CAAC;IACV;;2DAEuD;IACvD,eAAe,CAAC,EAAE,CAChB,OAAO,EAAE,wBAAwB,EACjC,OAAO,EAAE,sBAAsB,KAC5B,IAAI,CAAC;IACV;;;0DAGsD;IACtD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;qCACiC;IACjC,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAwYD,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -0,0 +1,448 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * #2991: a host can seed the composer draft (`initialDraft`,
4
+ * `controller.setDraft`) without sending it, and pass a composer
5
+ * `placeholder` through the dock. Also the `onActionSettled` /
6
+ * `onactionsettled` outcome callback for applied, rejected, and unknown
7
+ * action outcomes.
8
+ */
9
+ import { createDataSurfaceRegistry, } from '@happyvertical/smrt-ui/data-surface';
10
+ import { render, screen, userEvent } from '@happyvertical/smrt-vitest/svelte';
11
+ import { flushSync } from 'svelte';
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import AssistantComposer from '../AssistantComposer.svelte';
14
+ import AssistantDock from '../AssistantDock.svelte';
15
+ import { createInMemoryAssistantTransport } from '../assistant-transport.js';
16
+ import { createAssistantDockController, } from '../create-assistant-dock-controller.svelte.js';
17
+ const identity = {
18
+ surfaceId: 'assets',
19
+ kind: 'table',
20
+ subject: { type: 'tenant', id: 'tenant-a' },
21
+ };
22
+ function registryWithSurface() {
23
+ const registry = createDataSurfaceRegistry();
24
+ const descriptor = {
25
+ version: 1,
26
+ identity,
27
+ schemaVersion: 1,
28
+ label: 'Assets',
29
+ rowKey: 'id',
30
+ columns: [
31
+ { id: 'id', label: 'ID', capabilities: ['read'], role: 'row-key' },
32
+ ],
33
+ query: {
34
+ modes: ['rows'],
35
+ projectableColumnIds: ['id'],
36
+ searchableColumnIds: [],
37
+ filterableColumnIds: [],
38
+ sortableColumnIds: [],
39
+ },
40
+ actions: [],
41
+ controls: [],
42
+ limits: { maxQueryRows: 10, maxQueryBytes: 10_000, maxSelectionSize: 10 },
43
+ };
44
+ registry.register({
45
+ descriptor,
46
+ getSnapshot: () => ({ revision: 1, state: {} }),
47
+ });
48
+ return registry;
49
+ }
50
+ function proposal(requestId = 'req-1') {
51
+ return {
52
+ version: 1,
53
+ requestId,
54
+ identity,
55
+ actionId: 'approve',
56
+ phase: 'preview',
57
+ selection: { scope: 'explicit-ids', rowIds: ['row-1'] },
58
+ };
59
+ }
60
+ function result(request, phase, ok, extra = {}) {
61
+ return {
62
+ version: 1,
63
+ requestId: request.requestId,
64
+ identity: request.identity,
65
+ actionId: request.actionId,
66
+ phase,
67
+ ok,
68
+ ...extra,
69
+ };
70
+ }
71
+ function client(apply) {
72
+ return {
73
+ preview: async (request) => result(request, 'preview', true, { confirmationToken: 'tok' }),
74
+ apply,
75
+ };
76
+ }
77
+ function textarea() {
78
+ return screen.getByLabelText('Message');
79
+ }
80
+ describe('AssistantComposer value (#2991)', () => {
81
+ it('renders a seeded value without sending it', () => {
82
+ const onsend = vi.fn();
83
+ render(AssistantComposer, {
84
+ props: { onsend, onupload: vi.fn(), value: 'Remove the background' },
85
+ });
86
+ expect(textarea()).toHaveValue('Remove the background');
87
+ expect(onsend).not.toHaveBeenCalled();
88
+ });
89
+ it('sends the edited seed only when the user sends', async () => {
90
+ const onsend = vi.fn().mockResolvedValue(undefined);
91
+ render(AssistantComposer, {
92
+ props: { onsend, onupload: vi.fn(), value: 'Remove the background' },
93
+ });
94
+ await userEvent.type(textarea(), ' please');
95
+ expect(onsend).not.toHaveBeenCalled();
96
+ await userEvent.click(screen.getByRole('button', { name: 'Send' }));
97
+ expect(onsend).toHaveBeenCalledWith('Remove the background please', []);
98
+ expect(textarea()).toHaveValue('');
99
+ });
100
+ });
101
+ describe('AssistantComposer in-flight edits (#2991)', () => {
102
+ it('keeps text typed while the send was in flight', async () => {
103
+ let release;
104
+ const onsend = vi.fn(() => new Promise((resolve) => {
105
+ release = resolve;
106
+ }));
107
+ render(AssistantComposer, {
108
+ props: { onsend, onupload: vi.fn(), value: 'first' },
109
+ });
110
+ await userEvent.click(screen.getByRole('button', { name: 'Send' }));
111
+ await userEvent.type(textarea(), ' and more');
112
+ release();
113
+ await vi.waitFor(() => expect(onsend).toHaveBeenCalledTimes(1));
114
+ await new Promise((resolve) => setTimeout(resolve, 0));
115
+ expect(textarea()).toHaveValue('first and more');
116
+ });
117
+ });
118
+ describe('controller draft (#2991)', () => {
119
+ it('starts from initialDraft and setDraft replaces it without sending', async () => {
120
+ const transport = createInMemoryAssistantTransport();
121
+ const sendMessage = vi.spyOn(transport, 'sendMessage');
122
+ const controller = createAssistantDockController({
123
+ transport,
124
+ registry: registryWithSurface(),
125
+ initialDraft: 'first',
126
+ });
127
+ expect(controller.draft).toBe('first');
128
+ controller.setDraft('second');
129
+ expect(controller.draft).toBe('second');
130
+ expect(sendMessage).not.toHaveBeenCalled();
131
+ controller.dispose();
132
+ });
133
+ it('keeps the draft across a registry or transport swap', () => {
134
+ let registry = registryWithSurface();
135
+ let transport = createInMemoryAssistantTransport();
136
+ const controller = createAssistantDockController({
137
+ get transport() {
138
+ return transport;
139
+ },
140
+ get registry() {
141
+ return registry;
142
+ },
143
+ initialDraft: 'unsent text',
144
+ });
145
+ registry = registryWithSurface();
146
+ controller.syncRegistry();
147
+ expect(controller.draft).toBe('unsent text');
148
+ transport = createInMemoryAssistantTransport();
149
+ controller.syncTransport();
150
+ expect(controller.draft).toBe('unsent text');
151
+ controller.dispose();
152
+ });
153
+ it('clears the draft after a headless send of that draft', async () => {
154
+ const controller = createAssistantDockController({
155
+ transport: createInMemoryAssistantTransport(),
156
+ registry: registryWithSurface(),
157
+ });
158
+ const thread = await controller.createThread('t');
159
+ await controller.openThread(thread.id);
160
+ controller.setDraft('hello');
161
+ await controller.send('hello');
162
+ expect(controller.draft).toBe('');
163
+ controller.dispose();
164
+ });
165
+ it('keeps a draft changed while the send was in flight', async () => {
166
+ const transport = createInMemoryAssistantTransport();
167
+ const controller = createAssistantDockController({
168
+ transport,
169
+ registry: registryWithSurface(),
170
+ });
171
+ const thread = await controller.createThread('t');
172
+ await controller.openThread(thread.id);
173
+ controller.setDraft('hello');
174
+ const sending = controller.send('hello');
175
+ controller.setDraft('next message');
176
+ await sending;
177
+ expect(controller.draft).toBe('next message');
178
+ controller.dispose();
179
+ });
180
+ it('does not clear a kept draft when a send completes after a swap', async () => {
181
+ let transport = createInMemoryAssistantTransport();
182
+ const controller = createAssistantDockController({
183
+ get transport() {
184
+ return transport;
185
+ },
186
+ registry: registryWithSurface(),
187
+ });
188
+ const thread = await controller.createThread('t');
189
+ await controller.openThread(thread.id);
190
+ controller.setDraft('hello');
191
+ const sending = controller.send('hello');
192
+ transport = createInMemoryAssistantTransport();
193
+ controller.syncTransport();
194
+ await sending.catch(() => undefined);
195
+ expect(controller.draft).toBe('hello');
196
+ controller.dispose();
197
+ });
198
+ it('defaults to an empty draft', () => {
199
+ const controller = createAssistantDockController({
200
+ transport: createInMemoryAssistantTransport(),
201
+ registry: registryWithSurface(),
202
+ });
203
+ expect(controller.draft).toBe('');
204
+ controller.dispose();
205
+ });
206
+ });
207
+ describe('AssistantDock draft seeding and placeholder (#2991)', () => {
208
+ it('renders initialDraft in the composer and never sends it', async () => {
209
+ const transport = createInMemoryAssistantTransport();
210
+ const sendMessage = vi.spyOn(transport, 'sendMessage');
211
+ render(AssistantDock, {
212
+ props: {
213
+ transport,
214
+ registry: registryWithSurface(),
215
+ initialDraft: 'Remove the background from this photo',
216
+ },
217
+ });
218
+ expect(textarea()).toHaveValue('Remove the background from this photo');
219
+ await new Promise((resolve) => setTimeout(resolve, 20));
220
+ expect(sendMessage).not.toHaveBeenCalled();
221
+ });
222
+ it('lets the host seed the draft through controller.setDraft', async () => {
223
+ let hostController;
224
+ const transport = createInMemoryAssistantTransport();
225
+ const sendMessage = vi.spyOn(transport, 'sendMessage');
226
+ render(AssistantDock, {
227
+ props: {
228
+ transport,
229
+ registry: registryWithSurface(),
230
+ oncontroller: (c) => {
231
+ hostController = c;
232
+ },
233
+ },
234
+ });
235
+ expect(textarea()).toHaveValue('');
236
+ hostController?.setDraft('Describe the edit');
237
+ flushSync();
238
+ expect(textarea()).toHaveValue('Describe the edit');
239
+ expect(sendMessage).not.toHaveBeenCalled();
240
+ });
241
+ it('reflects typed text back into controller.draft', async () => {
242
+ let hostController;
243
+ render(AssistantDock, {
244
+ props: {
245
+ transport: createInMemoryAssistantTransport(),
246
+ registry: registryWithSurface(),
247
+ initialDraft: 'seed',
248
+ oncontroller: (c) => {
249
+ hostController = c;
250
+ },
251
+ },
252
+ });
253
+ // The composer is disabled until a thread is open; open one first.
254
+ const thread = await hostController?.createThread('t');
255
+ if (thread)
256
+ await hostController?.openThread(thread.id);
257
+ flushSync();
258
+ await userEvent.type(textarea(), ' more');
259
+ expect(hostController?.draft).toBe('seed more');
260
+ });
261
+ it('forwards composerPlaceholder to the composer', () => {
262
+ render(AssistantDock, {
263
+ props: {
264
+ transport: createInMemoryAssistantTransport(),
265
+ registry: registryWithSurface(),
266
+ composerPlaceholder: 'Describe the image edit to generate…',
267
+ },
268
+ });
269
+ expect(textarea()).toHaveAttribute('placeholder', 'Describe the image edit to generate…');
270
+ });
271
+ it('keeps the composer default placeholder when none is passed', () => {
272
+ render(AssistantDock, {
273
+ props: {
274
+ transport: createInMemoryAssistantTransport(),
275
+ registry: registryWithSurface(),
276
+ },
277
+ });
278
+ expect(textarea()).toHaveAttribute('placeholder', 'Ask the assistant…');
279
+ });
280
+ });
281
+ describe('controller onActionSettled', () => {
282
+ function settledController(apply, onActionSettled) {
283
+ return createAssistantDockController({
284
+ transport: createInMemoryAssistantTransport(),
285
+ registry: registryWithSurface(),
286
+ actionClient: client(apply),
287
+ onActionSettled,
288
+ });
289
+ }
290
+ it('reports an accepted apply as applied with the server result', async () => {
291
+ const onActionSettled = vi.fn();
292
+ const controller = settledController(async (request) => result(request, 'apply', true, { details: { assetId: 'srv-1' } }), onActionSettled);
293
+ await controller.previewAction(proposal());
294
+ await controller.applyAction('req-1');
295
+ expect(onActionSettled).toHaveBeenCalledTimes(1);
296
+ const [request, outcome] = onActionSettled.mock.calls[0];
297
+ expect(request.phase).toBe('apply');
298
+ expect(outcome.status).toBe('applied');
299
+ expect(outcome.result.details).toEqual({ assetId: 'srv-1' });
300
+ controller.dispose();
301
+ });
302
+ it('reports a server refusal as rejected by the server', async () => {
303
+ const onActionSettled = vi.fn();
304
+ const controller = settledController(async (request) => result(request, 'apply', false, { reason: 'stale_revision' }), onActionSettled);
305
+ await controller.previewAction(proposal());
306
+ await controller.applyAction('req-1');
307
+ expect(onActionSettled).toHaveBeenCalledTimes(1);
308
+ const outcome = onActionSettled.mock.calls[0][1];
309
+ expect(outcome).toMatchObject({ status: 'rejected', by: 'server' });
310
+ expect(outcome.result.reason).toBe('stale_revision');
311
+ controller.dispose();
312
+ });
313
+ it('reports a user reject as rejected by the user', async () => {
314
+ const onActionSettled = vi.fn();
315
+ const controller = settledController(async (request) => result(request, 'apply', true), onActionSettled);
316
+ await controller.previewAction(proposal());
317
+ controller.rejectAction('req-1');
318
+ expect(controller.actions.has('req-1')).toBe(false);
319
+ expect(onActionSettled).toHaveBeenCalledTimes(1);
320
+ const [request, outcome] = onActionSettled.mock.calls[0];
321
+ expect(request.requestId).toBe('req-1');
322
+ expect(outcome).toEqual({ status: 'rejected', by: 'user' });
323
+ controller.dispose();
324
+ });
325
+ it('reports a thrown apply as unknown, then the later decision', async () => {
326
+ const onActionSettled = vi.fn();
327
+ let mode = 'throw';
328
+ const controller = settledController(async (request) => {
329
+ if (mode === 'throw')
330
+ throw new Error('network down');
331
+ return result(request, 'apply', true);
332
+ }, onActionSettled);
333
+ await controller.previewAction(proposal());
334
+ await controller.applyAction('req-1');
335
+ expect(onActionSettled).toHaveBeenCalledTimes(1);
336
+ expect(onActionSettled.mock.calls[0][1]).toEqual({
337
+ status: 'unknown',
338
+ error: 'network down',
339
+ });
340
+ mode = 'ok';
341
+ await controller.applyAction('req-1');
342
+ expect(onActionSettled).toHaveBeenCalledTimes(2);
343
+ expect(onActionSettled.mock.calls[1][1].status).toBe('applied');
344
+ controller.dispose();
345
+ });
346
+ it('reports an unknown-outcome reason as unknown with the result', async () => {
347
+ const onActionSettled = vi.fn();
348
+ const controller = settledController(async (request) => result(request, 'apply', false, { reason: 'idempotency_in_progress' }), onActionSettled);
349
+ await controller.previewAction(proposal());
350
+ await controller.applyAction('req-1');
351
+ const outcome = onActionSettled.mock.calls[0][1];
352
+ expect(outcome.status).toBe('unknown');
353
+ expect(outcome.result.reason).toBe('idempotency_in_progress');
354
+ controller.dispose();
355
+ });
356
+ it('does not report a refused reject while the outcome is unknown', async () => {
357
+ const onActionSettled = vi.fn();
358
+ const controller = settledController(async () => {
359
+ throw new Error('timeout');
360
+ }, onActionSettled);
361
+ await controller.previewAction(proposal());
362
+ await controller.applyAction('req-1');
363
+ onActionSettled.mockClear();
364
+ controller.rejectAction('req-1');
365
+ expect(controller.actions.has('req-1')).toBe(true);
366
+ expect(onActionSettled).not.toHaveBeenCalled();
367
+ controller.dispose();
368
+ });
369
+ it('does not report an apply that lands after a context swap', async () => {
370
+ const onActionSettled = vi.fn();
371
+ let release;
372
+ let registry = registryWithSurface();
373
+ const controller = createAssistantDockController({
374
+ transport: createInMemoryAssistantTransport(),
375
+ get registry() {
376
+ return registry;
377
+ },
378
+ actionClient: client(async (request) => {
379
+ await new Promise((resolve) => {
380
+ release = resolve;
381
+ });
382
+ return result(request, 'apply', true);
383
+ }),
384
+ onActionSettled,
385
+ });
386
+ await controller.previewAction(proposal());
387
+ const applying = controller.applyAction('req-1');
388
+ registry = registryWithSurface();
389
+ controller.syncRegistry();
390
+ release();
391
+ await applying;
392
+ expect(onActionSettled).not.toHaveBeenCalled();
393
+ controller.dispose();
394
+ });
395
+ it('keeps action state when the host callback throws', async () => {
396
+ const onActionSettled = vi.fn(() => {
397
+ throw new Error('host bug');
398
+ });
399
+ const controller = settledController(async (request) => result(request, 'apply', true), onActionSettled);
400
+ await controller.previewAction(proposal());
401
+ await expect(controller.applyAction('req-1')).resolves.toBeUndefined();
402
+ expect(controller.actions.get('req-1')?.status).toBe('applied');
403
+ await controller.previewAction(proposal('req-2'));
404
+ expect(() => controller.rejectAction('req-2')).not.toThrow();
405
+ expect(controller.actions.has('req-2')).toBe(false);
406
+ controller.dispose();
407
+ });
408
+ it('still calls onActionApplied alongside onActionSettled', async () => {
409
+ const onActionApplied = vi.fn();
410
+ const onActionSettled = vi.fn();
411
+ const controller = createAssistantDockController({
412
+ transport: createInMemoryAssistantTransport(),
413
+ registry: registryWithSurface(),
414
+ actionClient: client(async (request) => result(request, 'apply', true)),
415
+ onActionApplied,
416
+ onActionSettled,
417
+ });
418
+ await controller.previewAction(proposal());
419
+ await controller.applyAction('req-1');
420
+ expect(onActionApplied).toHaveBeenCalledTimes(1);
421
+ expect(onActionSettled).toHaveBeenCalledTimes(1);
422
+ controller.dispose();
423
+ });
424
+ });
425
+ describe('AssistantDock onactionsettled', () => {
426
+ it('reports a Reject click to the host', async () => {
427
+ let hostController;
428
+ const onactionsettled = vi.fn();
429
+ render(AssistantDock, {
430
+ props: {
431
+ transport: createInMemoryAssistantTransport(),
432
+ registry: registryWithSurface(),
433
+ actionClient: client(async (request) => result(request, 'apply', true)),
434
+ oncontroller: (c) => {
435
+ hostController = c;
436
+ },
437
+ onactionsettled,
438
+ },
439
+ });
440
+ await hostController?.previewAction(proposal());
441
+ await userEvent.click(await screen.findByRole('button', { name: 'Reject' }));
442
+ await vi.waitFor(() => expect(onactionsettled).toHaveBeenCalledTimes(1));
443
+ expect(onactionsettled.mock.calls[0][1]).toEqual({
444
+ status: 'rejected',
445
+ by: 'user',
446
+ });
447
+ });
448
+ });