@inploi/plugin-chatbot 1.0.7 → 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.
Files changed (41) hide show
  1. package/.env +0 -1
  2. package/.env.example +0 -1
  3. package/.env.test +2 -0
  4. package/CHANGELOG.md +17 -0
  5. package/index.html +2 -1
  6. package/package.json +21 -8
  7. package/playwright.config.ts +82 -0
  8. package/public/mockServiceWorker.js +4 -9
  9. package/src/chatbot.api.ts +14 -14
  10. package/src/chatbot.constants.ts +3 -0
  11. package/src/chatbot.css +8 -15
  12. package/src/chatbot.dom.ts +10 -5
  13. package/src/chatbot.idb.ts +17 -0
  14. package/src/chatbot.state.ts +78 -144
  15. package/src/chatbot.ts +25 -35
  16. package/src/chatbot.utils.ts +27 -9
  17. package/src/index.dev.ts +7 -6
  18. package/src/interpreter/interpreter.ts +28 -20
  19. package/src/mocks/browser.ts +2 -2
  20. package/src/mocks/example.flows.ts +56 -18
  21. package/src/mocks/handlers.ts +37 -8
  22. package/src/style/palette.test.ts +20 -0
  23. package/src/style/palette.ts +69 -0
  24. package/src/ui/chat-bubble.tsx +20 -5
  25. package/src/ui/chat-input/chat-input.boolean.tsx +10 -5
  26. package/src/ui/chat-input/chat-input.file.tsx +8 -6
  27. package/src/ui/chat-input/chat-input.multiple-choice.tsx +52 -27
  28. package/src/ui/chat-input/chat-input.text.tsx +23 -17
  29. package/src/ui/chat-input/chat-input.tsx +47 -23
  30. package/src/ui/chatbot-header.tsx +34 -28
  31. package/src/ui/chatbot.tsx +83 -42
  32. package/src/ui/input-error.tsx +25 -31
  33. package/src/ui/job-application-content.tsx +68 -46
  34. package/src/ui/job-application-messages.tsx +42 -34
  35. package/src/ui/send-button.tsx +1 -1
  36. package/src/ui/typing-indicator.tsx +1 -1
  37. package/src/ui/useChatService.ts +18 -33
  38. package/src/ui/useFocus.ts +10 -0
  39. package/tests/integration.spec.ts +19 -0
  40. package/tests/test.ts +22 -0
  41. package/tsconfig.json +1 -1
package/src/chatbot.ts CHANGED
@@ -1,26 +1,39 @@
1
1
  import { createPlugin } from '@inploi/sdk';
2
2
  import { h, render } from 'preact';
3
+ import { Chatbot } from '~/ui/chatbot';
3
4
 
4
5
  import { getApplicationData } from './chatbot.api';
5
- import './chatbot.css';
6
+ import tailwind from './chatbot.css?inline';
6
7
  import { ChatbotDomManager, createChatbotDomManager } from './chatbot.dom';
7
- import { useChatbotStore, useLocalState } from './chatbot.state';
8
+ import { application } from './chatbot.state';
9
+ import { formatCssVariables, generatePalette } from './style/palette';
8
10
 
9
11
  export const chatbotPlugin = ({
10
12
  _internal_domManager: dom = createChatbotDomManager(),
13
+ hue,
11
14
  }: {
12
15
  geolocationApiKey?: string;
16
+ hue: number;
13
17
  _internal_domManager?: ChatbotDomManager;
14
18
  }) =>
15
- createPlugin(({ apiClient, logger }) => {
16
- const { resetLocalState } = useLocalState.getState();
17
- const { startApplication, cancelCurrentApplication } = useChatbotStore.getState();
19
+ createPlugin(({ apiClient, logger, analytics }) => {
20
+ let prepared = false;
21
+ const renderAndPrepare = () => {
22
+ const chatbotElement = dom.getOrCreateChatbotElement();
23
+ // Add styles generated by tailwind
24
+ dom.addStyle(tailwind, 'inploi-chatbot-style');
25
+ // Add dynamic styles generated by the plugin
26
+ dom.addStyle(formatCssVariables(generatePalette(hue)), 'inploi-chatbot-theme');
27
+ render(h(Chatbot, { apiClient, logger, analytics }), chatbotElement);
28
+ prepared = true;
29
+ };
18
30
 
19
31
  return {
20
- /** Optionally eagerly loads the code to handle applications. */
32
+ /** Optionally eagerly renders the interface ahead of application requests. */
21
33
  prepare: async () => {
22
34
  try {
23
- await import('~/ui/chatbot');
35
+ if (prepared) return;
36
+ renderAndPrepare();
24
37
  logger.info('Chatbot plugin prepared');
25
38
  } catch (error) {
26
39
  console.error(error);
@@ -29,41 +42,18 @@ export const chatbotPlugin = ({
29
42
  },
30
43
  startApplication: async ({ jobId }: { jobId: string }) => {
31
44
  try {
32
- const chatbotElement = dom.getOrCreateChatbotElement();
33
- cancelCurrentApplication();
34
-
35
- dom.renderLoading(chatbotElement);
36
-
37
- /** We concurrently lazy load the module and data */
38
- const [dataPromise, chatbotPromise] = await Promise.allSettled([
39
- getApplicationData({ jobId, apiClient }),
40
- import('~/ui/chatbot').then(m => m.Chatbot),
41
- ]);
45
+ application.cancel();
46
+ getApplicationData({ jobId, apiClient }).then(application.start);
42
47
 
43
- if (dataPromise.status === 'rejected') throw dataPromise.reason;
44
- if (chatbotPromise.status === 'rejected') throw chatbotPromise.reason;
45
-
46
- const Chatbot = chatbotPromise.value;
47
- const application = dataPromise.value;
48
- startApplication(application);
49
- logger.info(`Starting application for job "${application.job.id}" using flow "${application.flow.id}"`);
50
- chatbotElement.innerHTML = '';
51
-
52
- render(h(Chatbot, { apiClient, logger }), chatbotElement);
48
+ if (!prepared) renderAndPrepare();
53
49
  } catch (error) {
54
50
  console.error(error);
55
51
  logger.error('Error starting application', error);
56
52
  }
57
53
  },
58
54
  closeApplication: async () => {
59
- const chatbotElement = dom.getOrCreateChatbotElement();
60
- chatbotElement.innerHTML = '';
61
- cancelCurrentApplication();
62
- },
63
- /** Resets the user saved data for every application. */
64
- resetLocalState: async () => {
65
- resetLocalState();
66
- cancelCurrentApplication();
55
+ logger.info('Closing application from an external source');
56
+ application.cancel();
67
57
  },
68
58
  };
69
59
  });
@@ -1,6 +1,6 @@
1
1
  import { FlowNode } from '@inploi/core/flows';
2
2
 
3
- import { ApplicationSubmission, KeyToSubmissionMap } from './chatbot.state';
3
+ import { ApplicationSubmission, KeyToSubmissionMap, StartedJobApplication } from './chatbot.state';
4
4
 
5
5
  export type DistributivePick<T, K extends keyof T> = T extends unknown ? Pick<T, K> : never;
6
6
 
@@ -10,14 +10,26 @@ export const getHeadOrThrow = (nodes: FlowNode[]) => {
10
10
  return head;
11
11
  };
12
12
 
13
- export const submissionsToPayload = (submissions: KeyToSubmissionMap) => {
14
- return Object.entries(submissions).reduce(
15
- (acc, [key, submission]) => {
16
- acc[key] = submission.value;
17
- return acc;
18
- },
19
- {} as Record<string, ApplicationSubmission['value']>,
20
- );
13
+ export const submissionsToPayload = ({
14
+ application,
15
+ submissions,
16
+ }: {
17
+ application: StartedJobApplication;
18
+ submissions: KeyToSubmissionMap;
19
+ }) => {
20
+ const payload = {
21
+ flowId: application.flow.id,
22
+ jobId: application.job.id,
23
+ submissions: Object.entries(submissions).reduce(
24
+ (acc, [key, submission]) => {
25
+ acc[key] = submission.value;
26
+ return acc;
27
+ },
28
+ {} as Record<string, ApplicationSubmission['value']>,
29
+ ),
30
+ };
31
+
32
+ return payload;
21
33
  };
22
34
 
23
35
  export const isSubmissionOfType =
@@ -36,3 +48,9 @@ export function gzip(string: string) {
36
48
  writer.close();
37
49
  return new Response(cs.readable).text();
38
50
  }
51
+
52
+ export class AbortedError extends Error {
53
+ constructor() {
54
+ super('Aborted');
55
+ }
56
+ }
package/src/index.dev.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** This file is only used for dev mode. The build output is index.ts */
2
- import { createApiClient, inploiBrandedLogger } from '@inploi/sdk';
2
+ import { initialiseSdk, inploiBrandedLogger } from '@inploi/sdk';
3
3
 
4
4
  import { chatbotPlugin } from './chatbot';
5
5
 
@@ -19,12 +19,13 @@ async function enableMocking() {
19
19
  }
20
20
 
21
21
  enableMocking().then(() => {
22
- window.chatbot = chatbotPlugin({})({
23
- apiClient: createApiClient({
24
- baseUrl: import.meta.env.VITE_BASE_URL,
25
- publishableKey: import.meta.env.VITE_PUBLISHABLE_KEY,
26
- }),
22
+ const sdk = initialiseSdk({
23
+ env: 'sandbox',
27
24
  logger: inploiBrandedLogger,
25
+ publishableKey: import.meta.env.VITE_PUBLISHABLE_KEY,
28
26
  });
27
+
28
+ // inploi’s hue: 265
29
+ window.chatbot = sdk.registerPlugin(chatbotPlugin({ hue: 372 }));
29
30
  window.chatbot.prepare();
30
31
  });
@@ -1,16 +1,17 @@
1
1
  import { FlowNode, FlowNodeType, IfBlockNode } from '@inploi/core/flows';
2
2
  import { P, match } from 'ts-pattern';
3
- import { getHeadOrThrow } from '~/chatbot.utils';
3
+ import { AbortedError, getHeadOrThrow } from '~/chatbot.utils';
4
4
  import { ChatInput } from '~/ui/chat-input/chat-input';
5
5
 
6
6
  import { ApplicationSubmission, ChatMessage, KeyToSubmissionMap } from '../chatbot.state';
7
7
 
8
- export type ChatServiceSendParams = { signal?: AbortSignal; message: ChatMessage };
8
+ export type ChatServiceSendParams = { signal?: AbortSignal; groupId?: string; message: ChatMessage };
9
9
  export type ChatService = {
10
10
  send: (params: ChatServiceSendParams) => Promise<void>;
11
- input: <TType extends ChatInput['type']>(
12
- params: Extract<ChatInput, { type: TType }>,
13
- ) => Promise<Extract<ApplicationSubmission, { type: TType }>>;
11
+ input: <TType extends ChatInput['type']>(params: {
12
+ input: Extract<ChatInput, { type: TType }>;
13
+ signal?: AbortSignal;
14
+ }) => Promise<Extract<ApplicationSubmission, { type: TType }>>;
14
15
  };
15
16
 
16
17
  type ChatbotInterpreterParams = {
@@ -33,24 +34,31 @@ export const createFlowInterpreter = ({
33
34
  onInterpret,
34
35
  }: ChatbotInterpreterParams) => {
35
36
  const controller = new AbortController();
37
+
36
38
  const interpretNode = async (node: FlowNode) => {
37
39
  const submissions = getSubmissions();
38
40
  onInterpret?.(node);
39
- await interpret({
40
- node,
41
- submissions,
42
- chat: {
43
- sendMessage: async message => chatService.send({ message, signal: controller.signal }),
44
- userInput: async input => chatService.input(input),
45
- },
46
- next: () => {
47
- const nextNodeId = getNextNodeId(node, getSubmissions());
48
- const nextNode = flow.find(node => node.id === nextNodeId);
49
- if (nextNode) {
50
- return interpretNode(nextNode);
51
- } else onFlowEnd?.(node);
52
- },
53
- });
41
+ try {
42
+ await interpret({
43
+ node,
44
+ submissions,
45
+ chat: {
46
+ sendMessage: async message => chatService.send({ groupId: node.id, message, signal: controller.signal }),
47
+ userInput: async input => chatService.input({ input, signal: controller.signal }),
48
+ },
49
+ next: () => {
50
+ const nextNodeId = getNextNodeId(node, getSubmissions());
51
+ const nextNode = flow.find(node => node.id === nextNodeId);
52
+ if (nextNode) {
53
+ return interpretNode(nextNode);
54
+ } else onFlowEnd?.(node);
55
+ },
56
+ });
57
+ } catch (e) {
58
+ // we let aborting the flow be silent
59
+ if (e instanceof AbortedError) return;
60
+ throw e;
61
+ }
54
62
  };
55
63
 
56
64
  return {
@@ -1,5 +1,5 @@
1
1
  import { setupWorker } from 'msw/browser';
2
2
 
3
- import { handlers } from './handlers';
3
+ import { browserHandlers } from './handlers';
4
4
 
5
- export const worker = setupWorker(...handlers);
5
+ export const worker = setupWorker(...browserHandlers);
@@ -1,44 +1,82 @@
1
1
  import { FlowNode } from '@inploi/core/flows';
2
2
 
3
- const textOnly: FlowNode[] = [
3
+ export const automatedTestFlow: FlowNode[] = [
4
4
  {
5
- id: 'intro',
5
+ id: '1',
6
6
  type: 'text',
7
7
  data: {
8
- text: 'Welcome to our chef application flow!',
8
+ text: 'Text node',
9
9
  },
10
10
  isHead: true,
11
- nextId: 'name',
12
11
  },
12
+ ];
13
+
14
+ const textOnly: FlowNode[] = [
13
15
  {
14
- id: 'name',
15
- type: 'text',
16
+ id: '443c2374-13d1-4b02-b2ed-17f148d2a3da',
17
+ isHead: true,
18
+ nextId: '5df04ee2-b786-4df6-874b-9d1c740363d9',
19
+ type: 'question-boolean',
16
20
  data: {
17
- text: 'What is your full name?',
21
+ key: 'like',
22
+ question: 'do u hate cats',
23
+ trueLabel: 'Yes',
24
+ falseLabel: 'No',
18
25
  },
19
- nextId: 'experience',
20
26
  },
21
27
  {
22
- id: 'experience',
23
- type: 'text',
28
+ id: '5df04ee2-b786-4df6-874b-9d1c740363d9',
29
+ nextId: 'b6060696-ebb5-4b17-9d88-470394ccd1d3',
30
+ type: 'if-block',
24
31
  data: {
25
- text: 'How many years of chef experience do you have?',
32
+ compareKey: 'like',
33
+ compareValue: 'true',
34
+ compare: 'equals',
26
35
  },
27
- nextId: 'cuisine',
36
+ branchId: 'f94662b2-5229-4602-bc4e-d84666a56a25',
28
37
  },
29
38
  {
30
- id: 'cuisine',
39
+ id: '801ccc8c-d5db-4864-9b89-1465c014e20e',
40
+ nextId: '042a8657-132d-4eb7-be4e-c1d300ecc538',
31
41
  type: 'text',
32
42
  data: {
33
- text: 'What cuisine do you specialize in?',
43
+ text: 'nice',
34
44
  },
35
- nextId: 'end',
36
45
  },
37
46
  {
38
- id: 'end',
47
+ id: 'fec23e69-ef6c-4ae5-b10a-11104d36158c',
48
+ nextId: '801ccc8c-d5db-4864-9b89-1465c014e20e',
49
+ type: 'question-boolean',
50
+ data: {
51
+ key: 'free-will',
52
+ question: 'do you have free will?',
53
+ trueLabel: 'Yes',
54
+ falseLabel: 'No',
55
+ },
56
+ },
57
+ {
58
+ id: 'b6060696-ebb5-4b17-9d88-470394ccd1d3',
59
+ nextId: 'fec23e69-ef6c-4ae5-b10a-11104d36158c',
39
60
  type: 'text',
40
61
  data: {
41
- text: 'Thanks for applying! We will review your application soon.',
62
+ text: 'nice youre a decent person',
63
+ },
64
+ },
65
+ {
66
+ id: 'f94662b2-5229-4602-bc4e-d84666a56a25',
67
+ type: 'abandon-flow',
68
+ data: {
69
+ text: 'you cant hate cats to work here',
70
+ redirectUrl: '',
71
+ cta: '',
72
+ },
73
+ },
74
+ {
75
+ id: '042a8657-132d-4eb7-be4e-c1d300ecc538',
76
+ type: 'complete-flow',
77
+ data: {
78
+ text: "we'll hire you",
79
+ submitCta: 'submit',
42
80
  },
43
81
  },
44
82
  ];
@@ -160,7 +198,7 @@ const fromAlex: FlowNode[] = [
160
198
  value: '30+ hours',
161
199
  },
162
200
  ],
163
- maxSelected: 1,
201
+ maxSelected: 3,
164
202
  minSelected: 1,
165
203
  },
166
204
  },
@@ -1,10 +1,15 @@
1
1
  import { invariant } from '@inploi/core/common';
2
- import { http } from 'msw';
2
+ import { HttpResponse, http } from 'msw';
3
+ import { match } from 'ts-pattern';
3
4
 
4
- import { exampleFlows } from './example.flows';
5
+ import { automatedTestFlow, exampleFlows } from './example.flows';
5
6
 
6
- export const handlers = [
7
- http.get(`${import.meta.env.VITE_BASE_URL}/flow/job/:jobId`, ctx => {
7
+ // sandbox URL
8
+ const BASE_URL = 'https://preview.api.inploi.com';
9
+
10
+ export const flowHandler = {
11
+ /** Returns a valid flow. */
12
+ success: http.get(`${BASE_URL}/flow/job/:jobId`, ctx => {
8
13
  invariant(typeof ctx.params.jobId === 'string', 'Missing job id');
9
14
  const mockApplication = {
10
15
  job: {
@@ -16,13 +21,37 @@ export const handlers = [
16
21
  },
17
22
  flow: {
18
23
  id: 1,
19
- nodes: ctx.params.jobId === '1' ? exampleFlows.fromAlex : exampleFlows.byClaude,
24
+ nodes: match(ctx.params.jobId)
25
+ .with('1', () => exampleFlows.fromAlex)
26
+ .with('test', () => automatedTestFlow)
27
+ .otherwise(() => exampleFlows.helloWorld),
20
28
  version: 1,
21
29
  },
22
30
  };
23
- return new Response(JSON.stringify(mockApplication));
31
+ return new HttpResponse(JSON.stringify(mockApplication));
32
+ }),
33
+
34
+ /** Returns an invalid payload with a 200 code.
35
+ * E.g.: if the backend returns something incompatible with the frontend.
36
+ */
37
+ invalid_payload: http.get(`${BASE_URL}/flow/job/:jobId`, () => {
38
+ return new HttpResponse(JSON.stringify({ foo: 'bar' }), { status: 200 });
39
+ }),
40
+
41
+ /** Returns a laravel-structured error */
42
+ exception: http.get(`${BASE_URL}/flow/job/:jobId`, () => {
43
+ return new HttpResponse(JSON.stringify({ exception: 'server_error', message: 'Something bad happened' }), {
44
+ status: 400,
45
+ });
46
+ }),
47
+ };
48
+
49
+ export const browserHandlers = [
50
+ flowHandler.success,
51
+ http.post(`${BASE_URL}/flow/job/:jobId`, () => {
52
+ return new HttpResponse(JSON.stringify({ message: 'Success' }));
24
53
  }),
25
- http.post(`${import.meta.env.VITE_BASE_URL}/flow/job/:jobId`, () => {
26
- return new Response(JSON.stringify({ message: 'Success' }));
54
+ http.post(`${BASE_URL}/analytics/log`, () => {
55
+ return new HttpResponse(JSON.stringify({ message: 'Success' }));
27
56
  }),
28
57
  ];
@@ -0,0 +1,20 @@
1
+ import { expect, test } from 'bun:test';
2
+
3
+ import { PaletteShade, generatePalette } from './palette';
4
+
5
+ test('returns palette when given valid shade and hue', () => {
6
+ const palette = generatePalette(50);
7
+
8
+ for (const shade in palette) {
9
+ expect(palette[shade as PaletteShade]).toMatch(/\d+\.*\d* \d+\.*\d*% \d+\.*\d*%/);
10
+ }
11
+ });
12
+
13
+ test('returns different palettes with different hues', () => {
14
+ const palette1 = generatePalette(25);
15
+ const palette2 = generatePalette(26);
16
+
17
+ for (const shade in palette1) {
18
+ expect(palette1[shade as PaletteShade]).not.toEqual(palette2[shade as PaletteShade]);
19
+ }
20
+ });
@@ -0,0 +1,69 @@
1
+ import { Mode, converter, differenceEuclidean, formatCss, toGamut } from 'culori';
2
+ import { DiffFn } from 'culori/src/difference';
3
+
4
+ declare module 'culori' {
5
+ export function toGamut(from: Mode, to: Mode, distance: DiffFn, tolerance: number): (color: string) => string;
6
+ }
7
+
8
+ const hsl = converter('hsl');
9
+ const oklch = converter('oklch');
10
+
11
+ export type PaletteShade = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | '11' | '12';
12
+ const colorLevels: Record<PaletteShade, true> = {
13
+ '1': true,
14
+ '2': true,
15
+ '3': true,
16
+ '4': true,
17
+ '5': true,
18
+ '6': true,
19
+ '7': true,
20
+ '8': true,
21
+ '9': true,
22
+ '10': true,
23
+ '11': true,
24
+ '12': true,
25
+ };
26
+
27
+ const COLOR_LEVELS = Object.keys(colorLevels) as PaletteShade[];
28
+ const CHROMA_MULTIPLIER = 1.5;
29
+
30
+ const colorPresets: Record<PaletteShade, { l: number; c: number }> = {
31
+ '1': { l: 97.78, c: 0.0108 },
32
+ '2': { l: 93.56, c: 0.0321 },
33
+ '3': { l: 88.11, c: 0.0609 },
34
+ '4': { l: 82.67, c: 0.0908 },
35
+ '5': { l: 74.22, c: 0.1398 },
36
+ '6': { l: 64.78, c: 0.1472 },
37
+ '7': { l: 57.33, c: 0.1299 },
38
+ '8': { l: 46.89, c: 0.1067 },
39
+ '9': { l: 39.44, c: 0.0898 },
40
+ '10': { l: 32, c: 0.0726 },
41
+ '11': { l: 23.78, c: 0.054 },
42
+ '12': { l: 15.56, c: 0.0353 },
43
+ };
44
+
45
+ const getPaletteColor = (hueAngle: number) => (i: PaletteShade) => {
46
+ const { c, l } = colorPresets[i];
47
+ const color = 'oklch(' + l + '% ' + c * CHROMA_MULTIPLIER + ' ' + hueAngle + ')';
48
+ const oklchColor = oklch(toGamut('p3', 'oklch', differenceEuclidean('oklch'), 0)(color));
49
+
50
+ return formatCss(hsl(oklchColor)) as unknown as string;
51
+ };
52
+
53
+ export const generatePalette = (hueAngle: number) =>
54
+ Object.fromEntries(COLOR_LEVELS.map(level => [level, getPaletteColor(hueAngle)(level)] as const)) as Record<
55
+ PaletteShade,
56
+ string
57
+ >;
58
+
59
+ // gets the hue saturation and lightness from a `hsl()` string. Includes % signs.
60
+ const extractHsl = (color: string) => {
61
+ return color.replace('hsl(', '').replace(')', '');
62
+ };
63
+
64
+ export const formatCssVariables = (palette: Record<PaletteShade, string>) => {
65
+ const cssVariables = Object.entries(palette)
66
+ .map(([level, color]) => `--i-a-${level}: ${extractHsl(color)};`)
67
+ .join('\n');
68
+ return `#isdk {\n${cssVariables}\n}`;
69
+ };
@@ -1,14 +1,15 @@
1
1
  import type { VariantProps } from 'class-variance-authority';
2
2
  import { cva } from 'class-variance-authority';
3
+ import { Variants, m } from 'framer-motion';
3
4
  import type { ComponentProps } from 'react';
4
5
 
5
6
  const chatBubbleVariants = cva(
6
- 'max-w-[min(100%,24rem)] flex-shrink-1 min-w-[2rem] text-sm py-2 px-3 transition-all duration-500 ease-expo-out rounded-[18px] min-h-[36px] break-words',
7
+ 'max-w-[min(100%,24rem)] flex-shrink-1 min-w-[2rem] text-md py-2 px-3 rounded-[18px] min-h-[36px] break-words',
7
8
  {
8
9
  variants: {
9
10
  side: {
10
- left: 'self-start bg-lowest text-neutral-12 shadow-surface-md outline outline-1 outline-accent-11/[.08] rounded-bl-sm',
11
- right: 'self-end bg-accent-9 text-lowest shadow-surface-md rounded-br-sm bubble-right',
11
+ left: 'bg-lowest text-neutral-12 shadow-surface-md outline outline-1 outline-accent-11/[.08] rounded-bl-md',
12
+ right: 'ml-auto bg-accent-7 text-lowest rounded-br-md bubble-right',
12
13
  },
13
14
  transitionState: {
14
15
  entering: 'opacity-0 translate-y-8',
@@ -26,11 +27,25 @@ const chatBubbleVariants = cva(
26
27
 
27
28
  type ChatBubbleVariants = VariantProps<typeof chatBubbleVariants>;
28
29
 
30
+ const motionVariants: Variants = {
31
+ hidden: { y: '100%', scale: 0.75 },
32
+ shown: { y: 0, scale: 1 },
33
+ };
34
+
29
35
  type ChatBubbleProps = ComponentProps<'p'> & ChatBubbleVariants;
30
36
  export const ChatBubble = ({ children, className, transitionState, side, ...props }: ChatBubbleProps) => {
31
37
  return (
32
- <p data-transition={transitionState} class={chatBubbleVariants({ className, side, transitionState })} {...props}>
38
+ <m.p
39
+ variants={motionVariants}
40
+ initial="hidden"
41
+ animate="shown"
42
+ transition={{ type: 'spring', damping: 25, stiffness: 500 }}
43
+ data-transition={transitionState}
44
+ style={{ transformOrigin: side === 'left' ? '0% 50%' : '100% 50%' }}
45
+ class={chatBubbleVariants({ className, side, transitionState })}
46
+ {...props}
47
+ >
33
48
  {children}
34
- </p>
49
+ </m.p>
35
50
  );
36
51
  };
@@ -1,6 +1,7 @@
1
1
  import { P, match } from 'ts-pattern';
2
- import { z } from 'zod';
2
+ import { parse, picklist } from 'valibot';
3
3
 
4
+ import { useFocusOnMount } from '../useFocus';
4
5
  import { ChatInputProps } from './chat-input';
5
6
 
6
7
  export type BooleanChoicePayload = {
@@ -11,12 +12,15 @@ export type BooleanChoicePayload = {
11
12
  };
12
13
 
13
14
  const options = ['true', 'false'] as const;
14
- const AnswerSchema = z.enum(options);
15
+ const AnswerSchema = picklist(options);
15
16
  const FIELD_NAME = 'answer';
16
17
 
17
18
  export const ChatInputBoolean = ({ input, onSubmitSuccess }: ChatInputProps<'boolean'>) => {
19
+ const focusRef = useFocusOnMount();
20
+
18
21
  return (
19
22
  <form
23
+ noValidate
20
24
  class="flex gap-2 items-center"
21
25
  onSubmit={e => {
22
26
  e.preventDefault();
@@ -36,19 +40,20 @@ export const ChatInputBoolean = ({ input, onSubmitSuccess }: ChatInputProps<'boo
36
40
  .otherwise(() => {
37
41
  throw new Error('invalid form');
38
42
  });
39
- const answer = AnswerSchema.parse(value);
43
+ const answer = parse(AnswerSchema, value);
40
44
  onSubmitSuccess(answer);
41
45
  }}
42
46
  >
43
- {options.map(value => {
47
+ {options.map((value, i) => {
44
48
  return (
45
49
  <button
50
+ ref={i === 0 ? focusRef : null}
46
51
  type="submit"
47
52
  name={FIELD_NAME}
48
53
  value={value}
49
54
  class="flex-1 overflow-hidden rounded-2xl block px-2.5 py-2.5 selection:bg-transparent transition-all bg-lowest ring-transparent ring-0 focus-visible:ring-offset-2 focus-visible:ring-4 focus-visible:ring-accent-7 ease-expo-out duration-300 outline outline-2 outline-neutral-12/5 text-neutral-12 active:outline-accent-9 active:bg-accent-4 active:text-accent-11"
50
55
  >
51
- <p class="truncate text-base">{input.config.labels[value]}</p>
56
+ <p class="truncate text-center text-base">{input.config.labels[value]}</p>
52
57
  </button>
53
58
  );
54
59
  })}
@@ -4,11 +4,12 @@ import { ComponentProps } from 'preact';
4
4
  import { useState } from 'preact/hooks';
5
5
  import { FieldError } from 'react-hook-form';
6
6
  import { P, match } from 'ts-pattern';
7
- import { useApplicationSubmission } from '~/chatbot.state';
8
- import { gzip, isSubmissionOfType } from '~/chatbot.utils';
7
+ import { application } from '~/chatbot.state';
8
+ import { isSubmissionOfType } from '~/chatbot.utils';
9
9
 
10
10
  import { InputError } from '../input-error';
11
11
  import { SendButton } from '../send-button';
12
+ import { useFocusOnMount } from '../useFocus';
12
13
  import { ChatInputProps } from './chat-input';
13
14
 
14
15
  export type FileToUpload = { name: string; data: string; sizeKb: number };
@@ -81,13 +82,13 @@ const FilenameBadge = ({ class: className, ...props }: ComponentProps<'li'>) =>
81
82
  );
82
83
  1;
83
84
 
84
- export const ChatInputFile = ({ input, onSubmitSuccess, application }: ChatInputProps<'file'>) => {
85
- const submission = useApplicationSubmission(application, input.key);
85
+ export const ChatInputFile = ({ input, onSubmitSuccess }: ChatInputProps<'file'>) => {
86
+ const submission = application.current$.value.application?.data.submissions[input.key];
86
87
  const [files, setFiles] = useState<FileToUpload[]>(isFileSubmission(submission) ? submission.value : []);
87
88
  const [error, setError] = useState<FieldError>();
88
89
  const hiddenFileCount = files.length - FILENAMES_TO_SHOW_QTY;
89
90
  const totalSize = addFileSizesKb(files);
90
-
91
+ const focusRef = useFocusOnMount();
91
92
  return (
92
93
  <form
93
94
  class="flex flex-col gap-1"
@@ -108,6 +109,7 @@ export const ChatInputFile = ({ input, onSubmitSuccess, application }: ChatInput
108
109
  >
109
110
  <div class="flex gap-2 items-center">
110
111
  <label
112
+ ref={focusRef}
111
113
  for="dropzone-file"
112
114
  class="p-4 flex flex-col overflow-hidden items-center justify-center w-full h-48 border border-neutral-8 border-dashed rounded-2xl bg-neutral-2 cursor-pointer"
113
115
  >
@@ -182,7 +184,7 @@ export const ChatInputFile = ({ input, onSubmitSuccess, application }: ChatInput
182
184
  const files = e.target.files ? Array.from(e.target.files) : [];
183
185
  const filesToUpload = await Promise.allSettled(
184
186
  files.map(async file => {
185
- const data = await toBase64(file).then(gzip);
187
+ const data = await toBase64(file);
186
188
  return {
187
189
  name: file.name,
188
190
  data: data,