@inploi/plugin-chatbot 1.0.6 → 2.0.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @inploi/plugin-chatbot
2
2
 
3
+ ## 2.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 2975c09: Replace `zustand` with `idb-keyval` and `@preact/signals` to better utilise indexeddb and reduce bundle size
8
+ - cc46781: Track started applications via the new `analytics` service exposed by sdk
9
+ - Updated dependencies [d8dc36f]
10
+ - Updated dependencies [d8dc36f]
11
+ - @inploi/sdk@1.5.0
12
+ - @inploi/core@1.5.6
13
+
14
+ ## 1.0.7
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies
19
+ - @inploi/core@1.5.5
20
+ - @inploi/sdk@1.4.6
21
+
3
22
  ## 1.0.6
4
23
 
5
24
  ### Patch Changes
package/package.json CHANGED
@@ -1,25 +1,29 @@
1
1
  {
2
2
  "name": "@inploi/plugin-chatbot",
3
- "version": "1.0.6",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@hookform/resolvers": "^3.3.2",
7
+ "@preact/signals": "^1.2.2",
8
+ "@radix-ui/react-dialog": "^1.0.5",
9
+ "@radix-ui/react-focus-guards": "^1.0.1",
10
+ "@radix-ui/react-focus-scope": "^1.0.4",
11
+ "@radix-ui/react-slot": "^1.0.2",
7
12
  "class-variance-authority": "^0.7.0",
8
13
  "clsx": "^2.0.0",
14
+ "framer-motion": "^10.16.5",
9
15
  "idb-keyval": "^6.2.1",
10
- "immer": "^10.0.3",
11
16
  "preact": "^10.16.0",
12
17
  "react": "npm:@preact/compat",
13
18
  "react-dom": "npm:@preact/compat",
14
19
  "react-hook-form": "^7.48.2",
20
+ "react-remove-scroll": "^2.5.7",
15
21
  "react-transition-group": "^4.4.5",
16
22
  "swr": "^2.2.4",
17
23
  "tailwindcss-touch": "^1.0.1",
18
24
  "ts-pattern": "^5.0.5",
19
- "vaul": "^0.7.8",
20
- "zod": "^3.22.0",
21
- "zustand": "^4.4.4",
22
- "@inploi/core": "1.5.4"
25
+ "valibot": "^0.21.0",
26
+ "@inploi/core": "1.5.6"
23
27
  },
24
28
  "peerDependencies": {
25
29
  "@inploi/sdk": "*"
@@ -42,9 +46,9 @@
42
46
  "typescript": "^5.3.2",
43
47
  "vite": "^4.4.5",
44
48
  "vite-tsconfig-paths": "^4.2.1",
45
- "@inploi/sdk": "1.4.5",
46
- "tsconfig": "0.1.0",
47
- "eslint-config-custom": "0.1.0"
49
+ "@inploi/sdk": "1.5.0",
50
+ "eslint-config-custom": "0.1.0",
51
+ "tsconfig": "0.1.0"
48
52
  },
49
53
  "msw": {
50
54
  "workerDirectory": "public"
@@ -1,6 +1,6 @@
1
- import { FlowNode, FlowNodeSchema } from '@inploi/core/flows';
1
+ import { FlowNode } from '@inploi/core/flows';
2
2
  import { ApiClient } from '@inploi/sdk';
3
- import z from 'zod';
3
+ import { any, array, coerce, number, object, optional, parse, string } from 'valibot';
4
4
 
5
5
  export type JobApplication = {
6
6
  job: {
@@ -18,19 +18,19 @@ export type JobApplication = {
18
18
  };
19
19
  };
20
20
 
21
- const ApplicationFlowSchema = z.object({
22
- job: z.object({
23
- id: z.coerce.string(),
24
- title: z.string(),
21
+ const ApplicationSchema = object({
22
+ job: object({
23
+ id: coerce(string(), String),
24
+ title: string(),
25
25
  }),
26
- company: z.object({
27
- name: z.string(),
28
- logo: z.string().optional(),
26
+ company: object({
27
+ name: string(),
28
+ logo: optional(string()),
29
29
  }),
30
- flow: z.object({
31
- id: z.coerce.string(),
32
- nodes: z.array(FlowNodeSchema),
33
- version: z.number(),
30
+ flow: object({
31
+ id: coerce(string(), String),
32
+ nodes: array(any()),
33
+ version: number(),
34
34
  }),
35
35
  });
36
36
 
@@ -42,5 +42,5 @@ export async function getApplicationData({
42
42
  apiClient: ApiClient;
43
43
  }): Promise<JobApplication> {
44
44
  const rawData = await apiClient.fetch(`/flow/job/${jobId}`);
45
- return ApplicationFlowSchema.parse(rawData);
45
+ return parse(ApplicationSchema, rawData);
46
46
  }
@@ -1,6 +1,9 @@
1
1
  export const CHATBOT_ELEMENT_ID = 'isdk';
2
2
 
3
+ export const HEADER_HEIGHT = 44;
4
+
3
5
  export const ERROR_MESSAGES = {
6
+ not_in_local_storage: 'Application not found in local storage',
4
7
  invalid_end_node: 'Unexpected node type to finish flow',
5
8
  no_submissions: 'Application ended without any fields submitted',
6
9
  };
package/src/chatbot.css CHANGED
@@ -79,7 +79,7 @@
79
79
  box-sizing: border-box;
80
80
  }
81
81
 
82
- :is(ul) {
82
+ :is(ul, ol) {
83
83
  list-style: none;
84
84
  padding: 0;
85
85
  margin: 0;
@@ -90,11 +90,18 @@
90
90
  padding: 0;
91
91
  }
92
92
 
93
+ :is(li) {
94
+ margin: 0;
95
+ padding: 0;
96
+ list-style: none;
97
+ }
98
+
93
99
  :is(button) {
94
100
  margin: 0;
95
101
  padding: 0;
96
102
  border: unset;
97
103
  background: unset;
104
+ text-align: unset;
98
105
  }
99
106
  }
100
107
  }
@@ -1,8 +1,5 @@
1
1
  import { CHATBOT_ELEMENT_ID } from './chatbot.constants';
2
2
 
3
- export const overlayClassNames =
4
- 'data-[transition=entering]:opacity-0 data-[transition=exiting]:opacity-0 ease-expo-out duration-500 transition-opacity bg-neutral-12/60 fixed inset-0';
5
-
6
3
  export const createChatbotDomManager = () => {
7
4
  let chatbotElement: HTMLDivElement | null = null;
8
5
  return {
@@ -15,9 +12,6 @@ export const createChatbotDomManager = () => {
15
12
  chatbotElement = newElement;
16
13
  return newElement;
17
14
  },
18
- renderLoading: (element: HTMLElement) => {
19
- element.innerHTML = `<div class="${overlayClassNames}" />`;
20
- },
21
15
  };
22
16
  };
23
17
  export type ChatbotDomManager = ReturnType<typeof createChatbotDomManager>;
@@ -0,0 +1,17 @@
1
+ import { createStore, get, set } from 'idb-keyval';
2
+
3
+ import { JobApplication } from './chatbot.api';
4
+ import { ApplicationData, getCacheKey } from './chatbot.state';
5
+
6
+ const store = createStore('inploi', 'applications');
7
+
8
+ export const idb = {
9
+ getApplicationData: async (application: JobApplication) => {
10
+ const key = getCacheKey(application);
11
+ return await get<ApplicationData>(key, store);
12
+ },
13
+ setApplicationData: async (params: { application: JobApplication; data: ApplicationData }) => {
14
+ const key = getCacheKey(params.application);
15
+ return await set(key, params.data, store);
16
+ },
17
+ };
@@ -1,52 +1,59 @@
1
- import { del, get, set } from 'idb-keyval';
2
- import { produce } from 'immer';
3
- import { create } from 'zustand';
4
- import { StateStorage, createJSONStorage, persist } from 'zustand/middleware';
1
+ import { invariant } from '@inploi/core/common';
2
+ import { Signal, batch, signal } from '@preact/signals';
5
3
 
6
4
  import { JobApplication } from './chatbot.api';
5
+ import { idb } from './chatbot.idb';
7
6
  import { DistributivePick, getHeadOrThrow } from './chatbot.utils';
8
7
  import { ChatInput } from './ui/chat-input/chat-input';
9
8
  import { ChatbotInput } from './ui/chat-input/chat-input';
10
9
 
11
- const storage: StateStorage = {
12
- getItem: async (name: string): Promise<string | null> => {
13
- return (await get(name)) || null;
14
- },
15
- setItem: async (name: string, value: string): Promise<void> => {
16
- await set(name, value);
17
- },
18
- removeItem: async (name: string): Promise<void> => {
19
- await del(name);
20
- },
21
- };
10
+ export const getCacheKey = (application: JobApplication) =>
11
+ [application.job.id, application.flow.id, application.flow.version].join('/');
22
12
 
23
13
  export type ViewState = 'maximised' | 'minimised';
14
+ export const viewState = signal<ViewState>('maximised');
24
15
 
25
- type ChatbotStore = {
26
- currentApplication: (JobApplication & { startedAt: string }) | null;
27
- viewState: ViewState;
28
- startApplication: (application: JobApplication) => void;
29
- cancelCurrentApplication: () => void;
30
- setViewState: (newViewState: ViewState) => void;
16
+ export const inputHeight = signal(0);
17
+
18
+ export type StartedJobApplication = JobApplication & { data: ApplicationData };
19
+ const currentApplication: Signal<StartedJobApplication | null> = signal<StartedJobApplication | null>(null);
20
+ export const cancelCurrentApplication = () => {
21
+ currentApplication.value = null;
31
22
  };
32
23
 
33
- export const useChatbotStore = create<ChatbotStore>((set, get) => ({
34
- currentApplication: null,
35
- viewState: 'maximised',
36
- startApplication: application => {
37
- // if the application is already running, just maximise it
38
- if (get().currentApplication?.job.id === application.job.id) return set({ viewState: 'maximised' });
39
- return set({ currentApplication: { ...application, startedAt: new Date().toISOString() }, viewState: 'maximised' });
40
- },
41
- cancelCurrentApplication: () => set({ currentApplication: null }),
42
- setViewState: viewState => set({ viewState }),
43
- }));
24
+ const updateApplicationData = async (updateFn: (data: ApplicationData) => ApplicationData) => {
25
+ const application = currentApplication.value;
26
+ invariant(application, 'No application to update');
27
+ const newData = updateFn(application.data);
28
+ currentApplication.value = { ...application, data: newData };
29
+ await idb.setApplicationData({ application, data: newData });
30
+ };
44
31
 
45
- /** Returns the current application or throws if no application has been set. */
46
- export const useCurrentJobApplication = () => {
47
- const application = useChatbotStore(s => s.currentApplication);
48
- if (!application) throw new Error('No current application found');
49
- return application;
32
+ export const application = {
33
+ current$: currentApplication,
34
+ start: async (application: JobApplication) => {
35
+ const data = (await idb.getApplicationData(application)) ?? createNewApplicationData(application);
36
+ batch(() => {
37
+ viewState.value = 'maximised';
38
+ currentApplication.value = { ...application, data };
39
+ });
40
+ data.isFinished = false;
41
+ idb.setApplicationData({ application, data });
42
+ },
43
+ markAsFinished: () => updateApplicationData(data => ({ ...data, isFinished: true })),
44
+ setCurrentNodeId: (currentNodeId: string) => updateApplicationData(data => ({ ...data, currentNodeId })),
45
+ restart: () => {
46
+ const application = currentApplication.value;
47
+ invariant(application, 'No application to restart');
48
+ const data = createNewApplicationData(application);
49
+ currentApplication.value = { ...application, data };
50
+ idb.setApplicationData({ application, data });
51
+ },
52
+ addMessage: (message: ChatMessage) =>
53
+ updateApplicationData(data => ({ ...data, messages: [...data.messages, message] })),
54
+ setSubmission: (fieldKey: string, submission: ApplicationSubmission) =>
55
+ updateApplicationData(data => ({ ...data, submissions: { ...data.submissions, [fieldKey]: submission } })),
56
+ setInput: (input: ChatInput | undefined) => updateApplicationData(data => ({ ...data, currentInput: input })),
50
57
  };
51
58
 
52
59
  export type MessageAuthor = 'bot' | 'user';
@@ -63,8 +70,8 @@ export type KeyToSubmissionMap = {
63
70
  [key: string]: ApplicationSubmission;
64
71
  };
65
72
 
66
- /** Local state saved for an application */
67
- export type ApplicationLocalState = {
73
+ /** Dynamic part of an application */
74
+ export type ApplicationData = {
68
75
  /** History of messages left in the chat */
69
76
  messages: ChatMessage[];
70
77
  submissions: KeyToSubmissionMap;
@@ -74,107 +81,9 @@ export type ApplicationLocalState = {
74
81
  isFinished: boolean;
75
82
  };
76
83
 
77
- type ApplicationsStore = {
78
- applicationsLocalState: Record<string, ApplicationLocalState>;
79
- getApplicationLocalState: (application: JobApplication) => ApplicationLocalState | undefined;
80
- updateApplicationLocalState: (params: { application: JobApplication; data: Partial<ApplicationLocalState> }) => void;
81
- updateApplicationCurrentNode: (params: { application: JobApplication; currentNodeId: string }) => void;
82
- resetApplicationState: (application: JobApplication) => void;
83
- updateSubmission: (params: { application: JobApplication; fieldKey: string; data: ApplicationSubmission }) => void;
84
- addMessage: (params: { application: JobApplication; data: ChatMessage }) => void;
85
- resetLocalState: () => void;
86
- setInput: (params: { application: JobApplication; data: ChatInput | null }) => void;
87
- resetInput: (params: { application: JobApplication }) => void;
88
- };
89
-
90
- const getOrCreateApplication = (params: { application: JobApplication }, state: ApplicationsStore) => {
91
- const existingApplication = state.applicationsLocalState[getCacheKey(params.application)];
92
- if (existingApplication) return existingApplication;
93
- const newApplication: ApplicationLocalState = {
94
- messages: [],
95
- submissions: {},
96
- currentNodeId: getHeadOrThrow(params.application.flow.nodes).id,
97
- isFinished: false,
98
- };
99
- state.applicationsLocalState[getCacheKey(params.application)] = newApplication;
100
- return newApplication;
101
- };
102
-
103
- export const getCacheKey = (application: JobApplication) =>
104
- [application.job.id, application.flow.id, application.flow.version].join('/');
105
-
106
- export const useLocalState = create<ApplicationsStore>()(
107
- persist(
108
- (set, get) => ({
109
- applicationsLocalState: {},
110
- getApplicationLocalState: application => get().applicationsLocalState[getCacheKey(application)],
111
- updateApplicationLocalState: params =>
112
- set(
113
- produce((state: ApplicationsStore) => {
114
- const existingApplication = state.applicationsLocalState[getCacheKey(params.application)];
115
- if (!existingApplication) throw new Error('Application not found');
116
- Object.assign(existingApplication, params.data);
117
- }),
118
- ),
119
- updateApplicationCurrentNode: params =>
120
- set(
121
- produce((state: ApplicationsStore) => {
122
- const application = getOrCreateApplication(params, state);
123
- application.currentNodeId = params.currentNodeId;
124
- }),
125
- ),
126
- resetApplicationState: application =>
127
- set(
128
- produce((state: ApplicationsStore) => {
129
- const cacheKey = getCacheKey(application);
130
- delete state.applicationsLocalState[cacheKey];
131
- }),
132
- ),
133
- updateSubmission: params =>
134
- set(
135
- produce((state: ApplicationsStore) => {
136
- const application = getOrCreateApplication(params, state);
137
- application.submissions[params.fieldKey] = params.data;
138
- }),
139
- ),
140
- addMessage: params =>
141
- set(
142
- produce((state: ApplicationsStore) => {
143
- const application = getOrCreateApplication(params, state);
144
- application.messages.push(params.data);
145
- state.applicationsLocalState[getCacheKey(params.application)] = application;
146
- }),
147
- ),
148
- resetLocalState: () => set({ applicationsLocalState: {} }),
149
- resetInput: params =>
150
- set(
151
- produce((state: ApplicationsStore) => {
152
- const application = getOrCreateApplication(params, state);
153
- application.currentInput = undefined;
154
- }),
155
- ),
156
- setInput: params =>
157
- set(
158
- produce((state: ApplicationsStore) => {
159
- const application = getOrCreateApplication(params, state);
160
- application.currentInput = params.data ?? undefined;
161
- }),
162
- ),
163
- }),
164
- {
165
- name: 'inploi-chatbot-application',
166
- storage: createJSONStorage(() => storage),
167
- // only store the jobApplications part of the state
168
- partialize: state => ({ applicationsLocalState: state.applicationsLocalState }),
169
- },
170
- ),
171
- );
172
-
173
- export const useApplicationLocalState = (application: JobApplication) =>
174
- useLocalState(s => s.applicationsLocalState[getCacheKey(application)]);
175
-
176
- export const useApplicationInput = (application: JobApplication) =>
177
- useLocalState(s => s.applicationsLocalState[getCacheKey(application)]?.currentInput);
178
-
179
- export const useApplicationSubmission = (application: JobApplication, key: string) =>
180
- useLocalState(s => s.applicationsLocalState[getCacheKey(application)]?.submissions[key]);
84
+ const createNewApplicationData = (application: JobApplication): ApplicationData => ({
85
+ messages: [],
86
+ submissions: {},
87
+ currentNodeId: getHeadOrThrow(application.flow.nodes).id,
88
+ isFinished: false,
89
+ });
package/src/chatbot.ts CHANGED
@@ -1,10 +1,11 @@
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
6
  import './chatbot.css';
6
7
  import { ChatbotDomManager, createChatbotDomManager } from './chatbot.dom';
7
- import { useChatbotStore, useLocalState } from './chatbot.state';
8
+ import { application, cancelCurrentApplication } from './chatbot.state';
8
9
 
9
10
  export const chatbotPlugin = ({
10
11
  _internal_domManager: dom = createChatbotDomManager(),
@@ -12,15 +13,20 @@ export const chatbotPlugin = ({
12
13
  geolocationApiKey?: string;
13
14
  _internal_domManager?: ChatbotDomManager;
14
15
  }) =>
15
- createPlugin(({ apiClient, logger }) => {
16
- const { resetLocalState } = useLocalState.getState();
17
- const { startApplication, cancelCurrentApplication } = useChatbotStore.getState();
16
+ createPlugin(({ apiClient, logger, analytics }) => {
17
+ let prepared = false;
18
+ const renderAndPrepare = () => {
19
+ const chatbotElement = dom.getOrCreateChatbotElement();
20
+ render(h(Chatbot, { apiClient, logger, analytics }), chatbotElement);
21
+ prepared = true;
22
+ };
18
23
 
19
24
  return {
20
- /** Optionally eagerly loads the code to handle applications. */
25
+ /** Optionally eagerly renders the interface ahead of application requests. */
21
26
  prepare: async () => {
22
27
  try {
23
- await import('~/ui/chatbot');
28
+ if (prepared) return;
29
+ renderAndPrepare();
24
30
  logger.info('Chatbot plugin prepared');
25
31
  } catch (error) {
26
32
  console.error(error);
@@ -29,40 +35,19 @@ export const chatbotPlugin = ({
29
35
  },
30
36
  startApplication: async ({ jobId }: { jobId: string }) => {
31
37
  try {
32
- const chatbotElement = dom.getOrCreateChatbotElement();
33
38
  cancelCurrentApplication();
39
+ const applicationData = await getApplicationData({ jobId, apiClient });
34
40
 
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
- ]);
42
-
43
- if (dataPromise.status === 'rejected') throw dataPromise.reason;
44
- if (chatbotPromise.status === 'rejected') throw chatbotPromise.reason;
41
+ await application.start(applicationData);
45
42
 
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);
43
+ if (!prepared) renderAndPrepare();
53
44
  } catch (error) {
54
45
  console.error(error);
55
46
  logger.error('Error starting application', error);
56
47
  }
57
48
  },
58
49
  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();
50
+ logger.info('Closing application from an external source');
66
51
  cancelCurrentApplication();
67
52
  },
68
53
  };
@@ -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 =
package/src/index.dev.ts CHANGED
@@ -25,6 +25,12 @@ enableMocking().then(() => {
25
25
  publishableKey: import.meta.env.VITE_PUBLISHABLE_KEY,
26
26
  }),
27
27
  logger: inploiBrandedLogger,
28
+ analytics: {
29
+ log: async params => {
30
+ inploiBrandedLogger.log('stub logging', params);
31
+ return { success: true, data: {} } as any;
32
+ },
33
+ },
28
34
  });
29
35
  window.chatbot.prepare();
30
36
  });
@@ -160,7 +160,7 @@ const fromAlex: FlowNode[] = [
160
160
  value: '30+ hours',
161
161
  },
162
162
  ],
163
- maxSelected: 1,
163
+ maxSelected: 3,
164
164
  minSelected: 1,
165
165
  },
166
166
  },
@@ -1,14 +1,16 @@
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:
13
+ 'ml-auto bg-gradient-to-t from-[#2D51D2] to-[#4A7BEC] border border-[#405FCC] shadow-[inset_0_5px_3px_-3px_#6F99F1,inset_0_-5px_5px_-2px_#6F99F1DD,0_4px_6px_-1px_rgb(0_0_0_/_0.1),_0_2px_4px_-2px_rgb(0_0_0_/_0.1)] text-lowest rounded-br-md bubble-right',
12
14
  },
13
15
  transitionState: {
14
16
  entering: 'opacity-0 translate-y-8',
@@ -26,11 +28,25 @@ const chatBubbleVariants = cva(
26
28
 
27
29
  type ChatBubbleVariants = VariantProps<typeof chatBubbleVariants>;
28
30
 
31
+ const motionVariants: Variants = {
32
+ hidden: { y: '100%', scale: 0.75 },
33
+ shown: { y: 0, scale: 1 },
34
+ };
35
+
29
36
  type ChatBubbleProps = ComponentProps<'p'> & ChatBubbleVariants;
30
37
  export const ChatBubble = ({ children, className, transitionState, side, ...props }: ChatBubbleProps) => {
31
38
  return (
32
- <p data-transition={transitionState} class={chatBubbleVariants({ className, side, transitionState })} {...props}>
39
+ <m.p
40
+ variants={motionVariants}
41
+ initial="hidden"
42
+ animate="shown"
43
+ transition={{ type: 'spring', damping: 25, stiffness: 500 }}
44
+ data-transition={transitionState}
45
+ style={{ transformOrigin: side === 'left' ? '0% 50%' : '100% 50%' }}
46
+ class={chatBubbleVariants({ className, side, transitionState })}
47
+ {...props}
48
+ >
33
49
  {children}
34
- </p>
50
+ </m.p>
35
51
  );
36
52
  };
@@ -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
  })}