@aleph-alpha/chat-kit 1.18.0 → 2.1.0

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.
@@ -665,14 +665,14 @@ const _sfc_main$d = /* @__PURE__ */ defineComponent({
665
665
  const submitLabel = computed(
666
666
  () => __props.submitLabel ?? labels.value.feedbackDialogSubmitLabel ?? defaultChatKitLabels.feedbackDialogSubmitLabel
667
667
  );
668
- const selectedReason = ref("");
668
+ const selectedReasons = ref([]);
669
669
  const freeText = ref("");
670
670
  const freeTextId = useId();
671
671
  watch(
672
672
  () => __props.open,
673
673
  (isOpen, wasOpen) => {
674
674
  if (isOpen && !wasOpen) {
675
- selectedReason.value = "";
675
+ selectedReasons.value = [];
676
676
  freeText.value = "";
677
677
  }
678
678
  }
@@ -682,7 +682,7 @@ const _sfc_main$d = /* @__PURE__ */ defineComponent({
682
682
  );
683
683
  const characterCount = computed(() => freeText.value.length);
684
684
  const canSubmit = computed(
685
- () => !__props.disabled && !__props.submitting && (selectedReason.value !== "" || freeText.value.trim() !== "")
685
+ () => !__props.disabled && !__props.submitting && (selectedReasons.value.length > 0 || freeText.value.trim() !== "")
686
686
  );
687
687
  function handleOpenChange(next) {
688
688
  if (!next && (__props.submitting || __props.disabled)) {
@@ -706,7 +706,9 @@ const _sfc_main$d = /* @__PURE__ */ defineComponent({
706
706
  }
707
707
  emit("submit", {
708
708
  sentiment: __props.sentiment,
709
- reason: selectedReason.value === "" ? null : selectedReason.value,
709
+ // Copy so the host can't mutate our internal reactive array, and so the
710
+ // payload is a plain snapshot at submit time.
711
+ reasons: [...selectedReasons.value],
710
712
  reasonText: freeText.value.trim()
711
713
  });
712
714
  }
@@ -775,9 +777,9 @@ const _sfc_main$d = /* @__PURE__ */ defineComponent({
775
777
  ])) : createCommentVNode("", true),
776
778
  createElementVNode("section", _hoisted_3$4, [
777
779
  createVNode(unref(UiToggleGroup), {
778
- modelValue: selectedReason.value,
779
- "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => selectedReason.value = $event),
780
- type: "single",
780
+ modelValue: selectedReasons.value,
781
+ "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => selectedReasons.value = $event),
782
+ type: "multiple",
781
783
  variant: "outline",
782
784
  disabled: __props.disabled || __props.submitting,
783
785
  class: "grid grid-cols-2 gap-4",
@@ -835,7 +837,7 @@ const _sfc_main$d = /* @__PURE__ */ defineComponent({
835
837
  };
836
838
  }
837
839
  });
838
- const CkFeedbackDialog = /* @__PURE__ */ _export_sfc(_sfc_main$d, [["__scopeId", "data-v-181a7aa9"]]);
840
+ const CkFeedbackDialog = /* @__PURE__ */ _export_sfc(_sfc_main$d, [["__scopeId", "data-v-4d1d9c59"]]);
839
841
  const _sfc_main$c = /* @__PURE__ */ defineComponent({
840
842
  ...{
841
843
  name: "CkFileItemRow",
package/dist/chat-kit.css CHANGED
@@ -23,7 +23,7 @@
23
23
  }
24
24
  }
25
25
 
26
- .ck-feedback-dialog__chip[data-v-181a7aa9] {
26
+ .ck-feedback-dialog__chip[data-v-4d1d9c59] {
27
27
  width: 100%;
28
28
  min-height: 36px;
29
29
  height: auto;
@@ -31,14 +31,14 @@
31
31
  text-align: center;
32
32
  line-height: 1rem;
33
33
  }
34
- .ck-feedback-dialog__response[data-v-181a7aa9] p,
35
- .ck-feedback-dialog__response[data-v-181a7aa9] li {
34
+ .ck-feedback-dialog__response[data-v-4d1d9c59] p,
35
+ .ck-feedback-dialog__response[data-v-4d1d9c59] li {
36
36
  line-height: 1.5rem;
37
37
  }
38
- .ck-feedback-dialog__response[data-v-181a7aa9] p {
38
+ .ck-feedback-dialog__response[data-v-4d1d9c59] p {
39
39
  margin: 0 0 0.5rem 0;
40
40
  }
41
- .ck-feedback-dialog__response[data-v-181a7aa9] p:last-child {
41
+ .ck-feedback-dialog__response[data-v-4d1d9c59] p:last-child {
42
42
  margin-bottom: 0;
43
43
  }
44
44
 
@@ -4,7 +4,7 @@ declare const meta: Meta<typeof CkFeedbackDialog>;
4
4
  export default meta;
5
5
  type Story = StoryObj<typeof meta>;
6
6
  /**
7
- * Positive variant. Single-select reason chips on the right, the response on
7
+ * Positive variant. Multi-select reason chips on the right, the response on
8
8
  * the left, optional free text underneath.
9
9
  */
10
10
  export declare const Positive: Story;
@@ -1 +1 @@
1
- {"version":3,"file":"CkFeedbackDialog.vue.d.ts","sourceRoot":"","sources":["../../../../src/components/base/CkFeedbackDialog/CkFeedbackDialog.vue"],"names":[],"mappings":"AAwSA,OAAO,KAAK,EACV,qBAAqB,EACrB,6BAA6B,EAC9B,MAAM,SAAS,CAAC;AAygBjB,QAAA,MAAM,YAAY;;;;;;;;kFAGhB,CAAC;wBACkB,OAAO,YAAY;AAAxC,wBAAyC"}
1
+ {"version":3,"file":"CkFeedbackDialog.vue.d.ts","sourceRoot":"","sources":["../../../../src/components/base/CkFeedbackDialog/CkFeedbackDialog.vue"],"names":[],"mappings":"AA4SA,OAAO,KAAK,EACV,qBAAqB,EACrB,6BAA6B,EAC9B,MAAM,SAAS,CAAC;AA6gBjB,QAAA,MAAM,YAAY;;;;;;;;kFAGhB,CAAC;wBACkB,OAAO,YAAY;AAAxC,wBAAyC"}
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * Modal dialog for collecting qualitative feedback on an assistant response.
3
3
  * Opened after a binary thumbs-up / thumbs-down click (see `CkFeedbackButton`),
4
- * it lets the user pick a single reason code and add an optional free-text
5
- * comment, then submit both alongside the original sentiment.
4
+ * it lets the user pick one or more reason codes and add an optional free-text
5
+ * comment, then submit them alongside the original sentiment.
6
6
  *
7
- * One reason per submission: the backend (feedback-kit) stores a single
8
- * `reason` enum per response, so the dialog is single-select by design.
7
+ * Multiple reasons per submission: the backend (feedback-kit) stores a list of
8
+ * reason codes per response (each becomes its own categorical score), so the
9
+ * chips are multi-select.
9
10
  *
10
11
  * Fully prop-driven: the host application owns `open`, the list of reasons to
11
12
  * show (fetched from `GET /feedback/config`), the response text to display, and
12
13
  * the submission handler. The component only manages transient form state (the
13
- * selected reason and free-text value) until the user submits or cancels.
14
+ * selected reasons and free-text value) until the user submits or cancels.
14
15
  *
15
16
  * @category Feedback
16
17
  * @useCases collect qualitative feedback, gather a reason code for a response,
@@ -34,8 +35,12 @@ export interface CkFeedbackReason {
34
35
  export interface CkFeedbackDialogSubmitPayload {
35
36
  /** The sentiment the dialog was opened for. */
36
37
  sentiment: CkFeedbackSentiment;
37
- /** The selected reason `code`, or `null` if the user only left free text. */
38
- reason: string | null;
38
+ /**
39
+ * The selected reason `code`s. Empty array when the user submitted with
40
+ * only free text and no reason chips selected. Order follows the order in
41
+ * which the user toggled the chips on.
42
+ */
43
+ reasons: string[];
39
44
  /** Free-text comment, trimmed. Empty string when the user typed nothing. */
40
45
  reasonText: string;
41
46
  }
@@ -60,9 +65,9 @@ export interface CkFeedbackDialogProps {
60
65
  */
61
66
  responseText?: string;
62
67
  /**
63
- * Available reasons to render as single-select chips. The host fetches the
68
+ * Available reasons to render as multi-select chips. The host fetches the
64
69
  * appropriate per-sentiment list from `GET /feedback/config` and passes it
65
- * here. The chip shows `label`; the selected `code` is emitted on submit.
70
+ * here. The chip shows `label`; the selected `code`s are emitted on submit.
66
71
  */
67
72
  reasons: CkFeedbackReason[];
68
73
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/components/base/CkFeedbackDialog/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,MAAM,mBAAmB,GAAG,UAAU,GAAG,UAAU,CAAC;AAE1D;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,6BAA6B;IAC5C,+CAA+C;IAC/C,SAAS,EAAE,mBAAmB,CAAC;IAC/B,6EAA6E;IAC7E,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,4EAA4E;IAC5E,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;OAIG;IACH,SAAS,EAAE,mBAAmB,CAAC;IAC/B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,OAAO,EAAE,gBAAgB,EAAE,CAAC;IAC5B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,CAAC,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,OAAO,GAAG,IAAI,CAAC;IACxC;;;;OAIG;IACH,CAAC,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,6BAA6B,GAAG,IAAI,CAAC;IAC5D;;;OAGG;IACH,CAAC,CAAC,EAAE,QAAQ,GAAG,IAAI,CAAC;CACrB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/components/base/CkFeedbackDialog/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,MAAM,mBAAmB,GAAG,UAAU,GAAG,UAAU,CAAC;AAE1D;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,6BAA6B;IAC5C,+CAA+C;IAC/C,SAAS,EAAE,mBAAmB,CAAC;IAC/B;;;;OAIG;IACH,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,4EAA4E;IAC5E,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;OAIG;IACH,SAAS,EAAE,mBAAmB,CAAC;IAC/B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,OAAO,EAAE,gBAAgB,EAAE,CAAC;IAC5B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,CAAC,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,OAAO,GAAG,IAAI,CAAC;IACxC;;;;OAIG;IACH,CAAC,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,6BAA6B,GAAG,IAAI,CAAC;IAC5D;;;OAGG;IACH,CAAC,CAAC,EAAE,QAAQ,GAAG,IAAI,CAAC;CACrB"}
@@ -1,4 +1,4 @@
1
- import { _, a, b, C, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s } from "../CkInput.vue_vue_type_script_setup_true_lang-BGlztr2p.js";
1
+ import { _, a, b, C, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s } from "../CkInput.vue_vue_type_script_setup_true_lang-DPZedAQS.js";
2
2
  export {
3
3
  _ as CkAgentMessage,
4
4
  a as CkConversation,
@@ -1 +1 @@
1
- {"version":3,"file":"useFileUpload.d.ts","sourceRoot":"","sources":["../../../src/composables/fileUpload/useFileUpload.ts"],"names":[],"mappings":"AACA,OAAO,EAkBL,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,EAKzB,MAAM,SAAS,CAAC;AAEjB,cAAc,SAAS,CAAC;AAybxB,wBAAgB,aAAa,CAC3B,OAAO,EAAE,oBAAoB,GAC5B,mBAAmB,CAmwBrB"}
1
+ {"version":3,"file":"useFileUpload.d.ts","sourceRoot":"","sources":["../../../src/composables/fileUpload/useFileUpload.ts"],"names":[],"mappings":"AACA,OAAO,EAkBL,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,EAKzB,MAAM,SAAS,CAAC;AAEjB,cAAc,SAAS,CAAC;AAybxB,wBAAgB,aAAa,CAC3B,OAAO,EAAE,oBAAoB,GAC5B,mBAAmB,CA6xBrB"}
@@ -1,4 +1,4 @@
1
- import { c, d, u, b, e, f } from "../index-Ct5hD214.js";
1
+ import { c, d, u, b, e, f } from "../index-DrtHeZpl.js";
2
2
  export {
3
3
  c as createDocumentUploadProvider,
4
4
  d as defaultDocumentUploadConfig,
@@ -1033,6 +1033,19 @@ function useFileUpload(options) {
1033
1033
  for (const key of involvedStateKeys) {
1034
1034
  updateEntry(key, { status: "processing", progress: 0 });
1035
1035
  }
1036
+ const completeInvolvedFiles = (documents = []) => {
1037
+ const documentIdByFileId = new Map(
1038
+ documents.filter((doc) => doc.file_id && doc.document_id).map((doc) => [doc.file_id, doc.document_id])
1039
+ );
1040
+ for (const key of involvedStateKeys) {
1041
+ updateEntry(key, { status: "success", progress: 100 });
1042
+ const remoteFileId = files.value.get(key)?.remoteFileId;
1043
+ const documentId = remoteFileId ? documentIdByFileId.get(remoteFileId) : void 0;
1044
+ if (documentId) {
1045
+ updateFileMetadata(key, { documentId });
1046
+ }
1047
+ }
1048
+ };
1036
1049
  const runResponse = await client.post(
1037
1050
  endpoints.workflowRun(storeId, workflowId),
1038
1051
  { input },
@@ -1075,9 +1088,7 @@ function useFileUpload(options) {
1075
1088
  if (data.status === "completed") {
1076
1089
  workflowRun.value.status = "completed";
1077
1090
  workflowRun.value.completedAt = now();
1078
- for (const key of involvedStateKeys) {
1079
- updateEntry(key, { status: "success", progress: 100 });
1080
- }
1091
+ completeInvolvedFiles(Array.isArray(data.documents) ? data.documents : []);
1081
1092
  emit({ type: "workflow:completed", runId });
1082
1093
  } else if (data.status === "failed" || data.status === "error") {
1083
1094
  workflowRun.value.status = "failed";
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { u } from "./useMessageHandler-CMdy-lm0.js";
2
2
  import { C, d, p, u as u2, a } from "./useChatKitLabels-ZutacPad.js";
3
- import { S, c, d as d2, a as a2, u as u3, b, e, f } from "./index-Ct5hD214.js";
4
- import { _, a as a3, b as b2, C as C2, c as c2, d as d3, e as e2, f as f2, g, h, i, j, k, l, m, n, o, p as p2, q, r, s, t } from "./CkInput.vue_vue_type_script_setup_true_lang-BGlztr2p.js";
3
+ import { S, c, d as d2, a as a2, u as u3, b, e, f } from "./index-DrtHeZpl.js";
4
+ import { _, a as a3, b as b2, C as C2, c as c2, d as d3, e as e2, f as f2, g, h, i, j, k, l, m, n, o, p as p2, q, r, s, t } from "./CkInput.vue_vue_type_script_setup_true_lang-DPZedAQS.js";
5
5
  export {
6
6
  C as CHAT_KIT_LABELS,
7
7
  _ as CkAgentMessage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aleph-alpha/chat-kit",
3
- "version": "1.18.0",
3
+ "version": "2.1.0",
4
4
  "description": "Chat kit package for building chat interfaces with Vue",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@ describe('CkFeedbackDialog', () => {
60
60
  expect(await screen.findByText(TITLE_NEGATIVE)).toBeInTheDocument();
61
61
  });
62
62
 
63
- // AC: "Submitting sends sentiment + reason + reason_text to the backend."
64
- // The dialog emits the selected reason CODE (not the label) and the sentiment.
63
+ // AC: "Submitting sends sentiment + reasons + reason_text to the backend."
64
+ // The dialog emits the selected reason CODE(s) (not the label) and sentiment.
65
65
  it('emits submit with the selected reason code and sentiment', async () => {
66
66
  const user = userEvent.setup();
67
67
  const { emitted } = renderDialog({ sentiment: 'negative' });
@@ -75,25 +75,48 @@ describe('CkFeedbackDialog', () => {
75
75
  expect(submitEvents).toHaveLength(1);
76
76
  expect(submitEvents[0][0]).toEqual({
77
77
  sentiment: 'negative',
78
- reason: REASONS[0].code,
78
+ reasons: [REASONS[0].code],
79
79
  reasonText: '',
80
80
  });
81
81
  });
82
82
 
83
- // Single-select: choosing a second reason replaces the first (only one code
84
- // is ever submitted).
85
- it('keeps only one reason selected (single-select)', async () => {
83
+ // Multi-select: choosing several reasons emits all selected codes, in the
84
+ // order the user toggled them on.
85
+ it('emits all selected reason codes (multi-select)', async () => {
86
86
  const user = userEvent.setup();
87
87
  const { emitted } = renderDialog();
88
88
 
89
89
  await user.click(
90
90
  await screen.findByRole('button', { name: REASONS[0].label }),
91
91
  );
92
- await user.click(screen.getByRole('button', { name: REASONS[1].label }));
92
+ await user.click(screen.getByRole('button', { name: REASONS[2].label }));
93
93
  await user.click(screen.getByRole('button', { name: SUBMIT }));
94
94
 
95
95
  const submitEvents = emitted('submit') as [CkFeedbackDialogSubmitPayload][];
96
- expect(submitEvents[0][0].reason).toBe(REASONS[1].code);
96
+ expect(submitEvents).toHaveLength(1);
97
+ expect(submitEvents[0][0].reasons).toEqual([
98
+ REASONS[0].code,
99
+ REASONS[2].code,
100
+ ]);
101
+ });
102
+
103
+ // Edge case: toggling a selected reason off again removes it from the
104
+ // payload. If it was the only signal, submit becomes disabled again.
105
+ it('deselects a reason when toggled twice and re-gates submit', async () => {
106
+ const user = userEvent.setup();
107
+ const { emitted } = renderDialog();
108
+
109
+ const chip = await screen.findByRole('button', { name: REASONS[0].label });
110
+ const submit = screen.getByRole('button', { name: SUBMIT });
111
+
112
+ await user.click(chip); // select
113
+ await waitFor(() => expect(submit).not.toBeDisabled());
114
+
115
+ await user.click(chip); // deselect
116
+ await waitFor(() => expect(submit).toBeDisabled());
117
+
118
+ // Never submitted, so no payload was emitted.
119
+ expect(emitted('submit')).toBeUndefined();
97
120
  });
98
121
 
99
122
  // Submit is gated until the user provides at least one signal (a reason or
@@ -109,7 +132,7 @@ describe('CkFeedbackDialog', () => {
109
132
  await waitFor(() => expect(submit).not.toBeDisabled());
110
133
  });
111
134
 
112
- // Free text alone (no reason) is a valid submission; reason is null.
135
+ // Free text alone (no reason) is a valid submission; reasons is empty.
113
136
  it('allows submitting with only free text', async () => {
114
137
  const user = userEvent.setup();
115
138
  const { emitted } = renderDialog();
@@ -120,11 +143,31 @@ describe('CkFeedbackDialog', () => {
120
143
  const submitEvents = emitted('submit') as [CkFeedbackDialogSubmitPayload][];
121
144
  expect(submitEvents).toHaveLength(1);
122
145
  expect(submitEvents[0][0]).toMatchObject({
123
- reason: null,
146
+ reasons: [],
124
147
  reasonText: 'Needs more detail',
125
148
  });
126
149
  });
127
150
 
151
+ // Edge case: reasons + free text combine into one payload, free text trimmed.
152
+ it('emits both reasons and free text when given', async () => {
153
+ const user = userEvent.setup();
154
+ const { emitted } = renderDialog({ sentiment: 'negative' });
155
+
156
+ await user.click(
157
+ await screen.findByRole('button', { name: REASONS[0].label }),
158
+ );
159
+ await user.click(screen.getByRole('button', { name: REASONS[1].label }));
160
+ await user.type(screen.getByRole('textbox'), ' extra context ');
161
+ await user.click(screen.getByRole('button', { name: SUBMIT }));
162
+
163
+ const submitEvents = emitted('submit') as [CkFeedbackDialogSubmitPayload][];
164
+ expect(submitEvents[0][0]).toEqual({
165
+ sentiment: 'negative',
166
+ reasons: [REASONS[0].code, REASONS[1].code],
167
+ reasonText: 'extra context',
168
+ });
169
+ });
170
+
128
171
  // AC: "After clicking Cancel ... the user can click again" — cancel emits
129
172
  // both `cancel` and `update:open(false)` without a `submit`.
130
173
  it('emits cancel and closes without submitting', async () => {
@@ -152,7 +152,7 @@ function onCancel() {
152
152
  </template>`;
153
153
 
154
154
  /**
155
- * Positive variant. Single-select reason chips on the right, the response on
155
+ * Positive variant. Multi-select reason chips on the right, the response on
156
156
  * the left, optional free text underneath.
157
157
  */
158
158
  export const Positive: Story = {
@@ -109,7 +109,9 @@ const submitLabel = computed(
109
109
  defaultChatKitLabels.feedbackDialogSubmitLabel!,
110
110
  );
111
111
 
112
- const selectedReason = ref('');
112
+ // Multi-select: the user may pick zero or more reason codes. Backed by an
113
+ // array so `UiToggleGroup type="multiple"` can two-way bind directly.
114
+ const selectedReasons = ref<string[]>([]);
113
115
  const freeText = ref('');
114
116
 
115
117
  // Per-instance id so the label/textarea association stays correct even when
@@ -120,7 +122,7 @@ watch(
120
122
  () => open,
121
123
  (isOpen, wasOpen) => {
122
124
  if (isOpen && !wasOpen) {
123
- selectedReason.value = '';
125
+ selectedReasons.value = [];
124
126
  freeText.value = '';
125
127
  }
126
128
  },
@@ -134,7 +136,7 @@ const canSubmit = computed(
134
136
  () =>
135
137
  !disabled &&
136
138
  !submitting &&
137
- (selectedReason.value !== '' || freeText.value.trim() !== ''),
139
+ (selectedReasons.value.length > 0 || freeText.value.trim() !== ''),
138
140
  );
139
141
 
140
142
  function handleOpenChange(next: boolean): void {
@@ -166,7 +168,9 @@ function handleSubmit(): void {
166
168
  }
167
169
  emit('submit', {
168
170
  sentiment,
169
- reason: selectedReason.value === '' ? null : selectedReason.value,
171
+ // Copy so the host can't mutate our internal reactive array, and so the
172
+ // payload is a plain snapshot at submit time.
173
+ reasons: [...selectedReasons.value],
170
174
  reasonText: freeText.value.trim(),
171
175
  });
172
176
  }
@@ -200,8 +204,8 @@ function handleSubmit(): void {
200
204
 
201
205
  <section class="flex min-h-0 flex-col gap-4">
202
206
  <UiToggleGroup
203
- v-model="selectedReason"
204
- type="single"
207
+ v-model="selectedReasons"
208
+ type="multiple"
205
209
  variant="outline"
206
210
  :disabled="disabled || submitting"
207
211
  class="grid grid-cols-2 gap-4"
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * Modal dialog for collecting qualitative feedback on an assistant response.
3
3
  * Opened after a binary thumbs-up / thumbs-down click (see `CkFeedbackButton`),
4
- * it lets the user pick a single reason code and add an optional free-text
5
- * comment, then submit both alongside the original sentiment.
4
+ * it lets the user pick one or more reason codes and add an optional free-text
5
+ * comment, then submit them alongside the original sentiment.
6
6
  *
7
- * One reason per submission: the backend (feedback-kit) stores a single
8
- * `reason` enum per response, so the dialog is single-select by design.
7
+ * Multiple reasons per submission: the backend (feedback-kit) stores a list of
8
+ * reason codes per response (each becomes its own categorical score), so the
9
+ * chips are multi-select.
9
10
  *
10
11
  * Fully prop-driven: the host application owns `open`, the list of reasons to
11
12
  * show (fetched from `GET /feedback/config`), the response text to display, and
12
13
  * the submission handler. The component only manages transient form state (the
13
- * selected reason and free-text value) until the user submits or cancels.
14
+ * selected reasons and free-text value) until the user submits or cancels.
14
15
  *
15
16
  * @category Feedback
16
17
  * @useCases collect qualitative feedback, gather a reason code for a response,
@@ -36,8 +37,12 @@ export interface CkFeedbackReason {
36
37
  export interface CkFeedbackDialogSubmitPayload {
37
38
  /** The sentiment the dialog was opened for. */
38
39
  sentiment: CkFeedbackSentiment;
39
- /** The selected reason `code`, or `null` if the user only left free text. */
40
- reason: string | null;
40
+ /**
41
+ * The selected reason `code`s. Empty array when the user submitted with
42
+ * only free text and no reason chips selected. Order follows the order in
43
+ * which the user toggled the chips on.
44
+ */
45
+ reasons: string[];
41
46
  /** Free-text comment, trimmed. Empty string when the user typed nothing. */
42
47
  reasonText: string;
43
48
  }
@@ -63,9 +68,9 @@ export interface CkFeedbackDialogProps {
63
68
  */
64
69
  responseText?: string;
65
70
  /**
66
- * Available reasons to render as single-select chips. The host fetches the
71
+ * Available reasons to render as multi-select chips. The host fetches the
67
72
  * appropriate per-sentiment list from `GET /feedback/config` and passes it
68
- * here. The chip shows `label`; the selected `code` is emitted on submit.
73
+ * here. The chip shows `label`; the selected `code`s are emitted on submit.
69
74
  */
70
75
  reasons: CkFeedbackReason[];
71
76
  /**
@@ -0,0 +1,111 @@
1
+ import { useFileUpload } from './useFileUpload';
2
+ import type { FileEntry, HttpClient, SseEvent } from './types';
3
+
4
+ /**
5
+ * Build a minimal `FileEntry` in the post-upload `processing` state so
6
+ * `runWorkflow` treats it as an involved file (it filters on `remoteFileId`).
7
+ */
8
+ function makeEntry(id: string, remoteFileId: string): FileEntry {
9
+ return {
10
+ id,
11
+ groupId: 'group-1',
12
+ fileName: `${id}.pdf`,
13
+ fileSize: 1,
14
+ fileType: 'application/pdf',
15
+ status: 'processing',
16
+ progress: 0,
17
+ queuedAt: 'now',
18
+ uploadStartedAt: null,
19
+ uploadCompletedAt: null,
20
+ cancelledAt: null,
21
+ removedAt: null,
22
+ uploadDurationMs: null,
23
+ remoteFileId,
24
+ error: null,
25
+ attemptCount: 0,
26
+ lastAttemptAt: null,
27
+ thumbnail: null,
28
+ metadata: {},
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Mock `HttpClient` whose `stream` captures the subscriber so the test can
34
+ * drive SSE events synchronously. `post` resolves the workflow run id.
35
+ */
36
+ function createMockClient(captured: { cb?: (event: SseEvent) => void }) {
37
+ return {
38
+ get: vi.fn(),
39
+ delete: vi.fn(),
40
+ post: vi.fn().mockResolvedValue({ id: 'run-1' }),
41
+ stream: vi.fn(() => ({
42
+ subscribe: (cb: (event: SseEvent) => void) => {
43
+ captured.cb = cb;
44
+ },
45
+ close: vi.fn(),
46
+ })),
47
+ } as unknown as HttpClient;
48
+ }
49
+
50
+ function completeEvent(
51
+ documents: Array<{ document_id?: string; file_id?: string }>,
52
+ ): SseEvent {
53
+ return {
54
+ event: 'complete',
55
+ data: JSON.stringify({
56
+ document_ids: documents.map((d) => d.document_id),
57
+ documents,
58
+ status: 'completed',
59
+ }),
60
+ };
61
+ }
62
+
63
+ describe('useFileUpload runWorkflow', () => {
64
+ it('stamps documentId onto each file from the completed event, matched by file_id', async () => {
65
+ const captured: { cb?: (event: SseEvent) => void } = {};
66
+ const upload = useFileUpload({
67
+ client: createMockClient(captured),
68
+ workflowId: 'wf',
69
+ storeId: 'ss-1',
70
+ });
71
+
72
+ upload.files.value.set('key-1', makeEntry('key-1', 'file-1'));
73
+ upload.files.value.set('key-2', makeEntry('key-2', 'file-2'));
74
+
75
+ await upload.runWorkflow(['file-1', 'file-2']);
76
+ expect(captured.cb).toBeDefined();
77
+
78
+ // Reverse order on purpose: mapping must be keyed by file_id, not position.
79
+ captured.cb!(
80
+ completeEvent([
81
+ { document_id: 'doc-2', file_id: 'file-2' },
82
+ { document_id: 'doc-1', file_id: 'file-1' },
83
+ ]),
84
+ );
85
+
86
+ expect(upload.getFileState('key-1')?.metadata.documentId).toBe('doc-1');
87
+ expect(upload.getFileState('key-2')?.metadata.documentId).toBe('doc-2');
88
+ expect(upload.getFileState('key-1')?.status).toBe('success');
89
+ expect(upload.getFileState('key-2')?.status).toBe('success');
90
+ });
91
+
92
+ it('marks files successful without a documentId when the event has no documents', async () => {
93
+ const captured: { cb?: (event: SseEvent) => void } = {};
94
+ const upload = useFileUpload({
95
+ client: createMockClient(captured),
96
+ workflowId: 'wf',
97
+ storeId: 'ss-1',
98
+ });
99
+
100
+ upload.files.value.set('key-1', makeEntry('key-1', 'file-1'));
101
+
102
+ await upload.runWorkflow(['file-1']);
103
+ captured.cb!({
104
+ event: 'complete',
105
+ data: JSON.stringify({ status: 'completed' }),
106
+ });
107
+
108
+ expect(upload.getFileState('key-1')?.status).toBe('success');
109
+ expect(upload.getFileState('key-1')?.metadata.documentId).toBeUndefined();
110
+ });
111
+ });
@@ -1054,6 +1054,30 @@ export function useFileUpload(
1054
1054
  updateEntry(key, { status: 'processing', progress: 0 });
1055
1055
  }
1056
1056
 
1057
+ // Mark the involved files successful and stamp the `documentId` resolved
1058
+ // from the terminal event's `documents` array (mapping `file_id` ->
1059
+ // `document_id`) onto each matching entry, so consumers can reference the
1060
+ // produced document directly without a separate lookup.
1061
+ const completeInvolvedFiles = (
1062
+ documents: Array<{ document_id?: string; file_id?: string }> = [],
1063
+ ) => {
1064
+ const documentIdByFileId = new Map(
1065
+ documents
1066
+ .filter((doc) => doc.file_id && doc.document_id)
1067
+ .map((doc) => [doc.file_id, doc.document_id] as const),
1068
+ );
1069
+ for (const key of involvedStateKeys) {
1070
+ updateEntry(key, { status: 'success', progress: 100 });
1071
+ const remoteFileId = files.value.get(key)?.remoteFileId;
1072
+ const documentId = remoteFileId
1073
+ ? documentIdByFileId.get(remoteFileId)
1074
+ : undefined;
1075
+ if (documentId) {
1076
+ updateFileMetadata(key, { documentId });
1077
+ }
1078
+ }
1079
+ };
1080
+
1057
1081
  const runResponse = await client.post<{ id: string }>(
1058
1082
  endpoints.workflowRun(storeId, workflowId),
1059
1083
  { input },
@@ -1087,7 +1111,11 @@ export function useFileUpload(
1087
1111
  sseStream.subscribe((event: SseEvent) => {
1088
1112
  if (!workflowRun.value) return;
1089
1113
 
1090
- let data: { id?: string; status?: string };
1114
+ let data: {
1115
+ id?: string;
1116
+ status?: string;
1117
+ documents?: Array<{ document_id?: string; file_id?: string }>;
1118
+ };
1091
1119
  try {
1092
1120
  data = JSON.parse(event.data);
1093
1121
  } catch {
@@ -1105,9 +1133,7 @@ export function useFileUpload(
1105
1133
  if (data.status === 'completed') {
1106
1134
  workflowRun.value.status = 'completed';
1107
1135
  workflowRun.value.completedAt = now();
1108
- for (const key of involvedStateKeys) {
1109
- updateEntry(key, { status: 'success', progress: 100 });
1110
- }
1136
+ completeInvolvedFiles(Array.isArray(data.documents) ? data.documents : []);
1111
1137
  emit({ type: 'workflow:completed', runId });
1112
1138
  } else if (data.status === 'failed' || data.status === 'error') {
1113
1139
  workflowRun.value.status = 'failed';