-
diff --git a/packages/assistant-chat/src/components/tooltipIconButton/tooltipIconButton.tsx b/packages/assistant-chat/src/components/tooltipIconButton/tooltipIconButton.tsx
index 264c5f0011..da5bcc5a71 100644
--- a/packages/assistant-chat/src/components/tooltipIconButton/tooltipIconButton.tsx
+++ b/packages/assistant-chat/src/components/tooltipIconButton/tooltipIconButton.tsx
@@ -72,8 +72,9 @@ export const TooltipIconButton = forwardRef<
...rest
} = props;
+ // Tailwind's reset gives buttons the default arrow cursor; every control in the app points.
const classes = classNames(
- 'focus-ring-primary inline-flex shrink-0 items-center justify-center rounded-full outline-none transition-colors active:scale-90 disabled:pointer-events-none disabled:opacity-50',
+ 'focus-ring-primary inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full outline-none transition-colors active:scale-90 disabled:pointer-events-none disabled:opacity-50',
variantClasses[variant],
sizeClasses[size],
className,
diff --git a/packages/assistant-chat/src/copy.ts b/packages/assistant-chat/src/copy.ts
index 3486fd5dfd..04fde1508e 100644
--- a/packages/assistant-chat/src/copy.ts
+++ b/packages/assistant-chat/src/copy.ts
@@ -1,7 +1,4 @@
-import {
- assistantLimits,
- type ITicketIntent,
-} from '@aragon/assistant-contracts';
+import { assistantLimits } from '@aragon/assistant-contracts';
import type { FileRejectReason } from './files/fileValidation';
// Single source of every user-facing string of the widget, grouped by component. Change wording
@@ -19,13 +16,16 @@ const maxFileSizeMb = Math.round(
export const chatCopy = {
header: {
- title: 'Aragon Support Assistant',
- close: 'Close',
+ title: 'Aragon Assistant',
+ collapse: 'Collapse chat',
+ back: 'Back to chat',
startNewChat: 'Start new chat',
+ contextNew: 'New conversation',
+ // Rendered ahead of the drafted ticket title, e.g. "Draft: Voting page crashes".
+ contextDraftPrefix: 'Draft:',
},
welcome: {
- greeting:
- "Hi! Tell us what's going on and we'll get it to the right team.",
+ greeting: 'What do you need help with?',
suggestions: [
{ label: 'Report a bug', message: "I'd like to report a bug." },
{
@@ -38,21 +38,27 @@ export const chatCopy = {
thread: {
typing: 'Assistant is typing',
copyMessage: 'Copy',
+ // Prefix of the time divider of messages sent today, e.g. "Today 14:26".
+ today: 'Today',
chatErrorFallback:
'Something went wrong. Please try sending your message again.',
emailEscapeHatch: `…or email your request to ${supportEmail} →`,
},
composer: {
- placeholder: 'Message…',
+ placeholder: 'Describe the issue…',
+ placeholderReply: 'Reply…',
inputLabel: 'Message',
send: 'Send message',
stop: 'Stop generating',
addAttachment: 'Add attachment',
attachmentsShared: 'Attachments are shared with the support team.',
+ escalationPrompt: 'Prefer a human?',
+ escalationLink: 'Email support',
},
attachments: {
remove: 'Remove file',
previewTitle: 'Image attachment preview',
+ closePreview: 'Close preview',
typeLabel: {
image: 'Image',
document: 'Document',
@@ -72,22 +78,17 @@ export const chatCopy = {
string
>,
ticketCard: {
- draftHeading: 'Review your request',
- intentLabel: {
- feedback: 'Feedback',
- bug: 'Bug report',
- support: 'Support request',
- } satisfies Record
,
- descriptionLabel: 'Details',
stepsLabel: 'Steps to reproduce',
- contactLabel: 'Contact',
+ // Rendered ahead of the contact the user left, e.g. "We'll reply to evan@aragon.org".
+ contactPrefix: "We'll reply to",
preparing: 'Putting your request together…',
- addMore: 'Anything to add? Any detail helps — just keep typing.',
- create: 'Create',
+ addMore: 'Anything to add? Keep typing — this draft updates.',
+ create: 'Create ticket',
dismiss: 'Dismiss',
creating: 'Creating your request…',
dismissed: 'Draft dismissed. Keep chatting to prepare a new one.',
- superseded: 'This draft was set aside after your newer messages.',
+ superseded:
+ 'Earlier draft set aside — your newer messages replaced it.',
draftInterrupted:
'That draft did not come through. Keep chatting to prepare a new one.',
successTitle: 'Request created',
@@ -103,6 +104,7 @@ export const chatCopy = {
},
requestHistory: {
heading: 'Past requests',
+ empty: 'No requests from this device yet.',
},
serviceErrors: {
rateLimited:
diff --git a/packages/assistant-chat/src/requests/index.ts b/packages/assistant-chat/src/requests/index.ts
index 5dfd1d8fc4..e02b15a4fc 100644
--- a/packages/assistant-chat/src/requests/index.ts
+++ b/packages/assistant-chat/src/requests/index.ts
@@ -3,3 +3,4 @@ export {
getRequestHistory,
type IRequestHistoryEntry,
} from './requestHistory';
+export { useRequestHistory } from './useRequestHistory';
diff --git a/packages/assistant-chat/src/requests/useRequestHistory.ts b/packages/assistant-chat/src/requests/useRequestHistory.ts
new file mode 100644
index 0000000000..ed7b9856c6
--- /dev/null
+++ b/packages/assistant-chat/src/requests/useRequestHistory.ts
@@ -0,0 +1,13 @@
+import { useState } from 'react';
+import { getRequestHistory, type IRequestHistoryEntry } from './requestHistory';
+
+/**
+ * Reads the device-local request history. A read per mount is enough: both places that show it
+ * (the link under the composer and the requests view) mount when the user enters them, which is
+ * always after a ticket has been filed.
+ */
+export const useRequestHistory = (): IRequestHistoryEntry[] => {
+ const [requestHistory] = useState(getRequestHistory);
+
+ return requestHistory;
+};
diff --git a/packages/assistant-chat/src/transport/createChatTransport.ts b/packages/assistant-chat/src/transport/createChatTransport.ts
index 0bb3c8597d..0028d74c43 100644
--- a/packages/assistant-chat/src/transport/createChatTransport.ts
+++ b/packages/assistant-chat/src/transport/createChatTransport.ts
@@ -1,4 +1,8 @@
-import type { IAppContext, IChatRequest } from '@aragon/assistant-contracts';
+import {
+ attachmentPartType,
+ type IAppContext,
+ type IChatRequest,
+} from '@aragon/assistant-contracts';
import { DefaultChatTransport } from 'ai';
import type { AssistantUIMessage } from './chatTransport.api';
@@ -17,10 +21,17 @@ export interface ICreateChatTransportParams {
getAppContext: () => IAppContext;
}
+// A file part carries the whole file inline as a data URL (the local transcript renders its
+// preview from it) and the service already holds the bytes for the ticket, so only the name
+// travels with the conversation — enough for the service to tell the model where an attachment
+// arrived, without pushing megabytes through every turn.
+const toRequestPart = (part: AssistantUIMessage['parts'][number]) =>
+ part.type === 'file'
+ ? { type: attachmentPartType, data: { filename: part.filename } }
+ : part;
+
// The server re-validates against chatRequestSchema; only the fields of the contract are sent
-// (the default AI SDK body would leak chat id / trigger metadata). File parts exist only for the
-// local transcript preview — the server reads queued files from its own session store, so they
-// never travel with the conversation.
+// (the default AI SDK body would leak chat id / trigger metadata).
const buildRequestBody = (
params: ICreateChatTransportParams,
messages: AssistantUIMessage[],
@@ -29,7 +40,7 @@ const buildRequestBody = (
messages: messages.map((message) => ({
id: message.id,
role: message.role === 'user' ? 'user' : 'assistant',
- parts: message.parts.filter((part) => part.type !== 'file'),
+ parts: message.parts.map(toRequestPart),
})),
appContext: params.getAppContext(),
});
diff --git a/packages/assistant-contracts/src/file.ts b/packages/assistant-contracts/src/file.ts
index ef1bc1cbf0..6bcea2e420 100644
--- a/packages/assistant-contracts/src/file.ts
+++ b/packages/assistant-contracts/src/file.ts
@@ -5,6 +5,13 @@ import { z } from 'zod';
// bodies — Vercel functions cap request bodies at 4.5 MB), then confirms the upload so the
// service can validate the content and queue the file for the ticket.
+// The bytes of a queued file never travel with the conversation, but the model must see WHERE an
+// attachment arrived, so the message that carried it keeps a part naming the file. It rides as a
+// data part on purpose: an AI SDK service that does not know this part drops it, so the widget can
+// ship ahead of the service without breaking a turn that carries an attachment.
+// Shape: `{ type: 'data-attachment', data: { filename } }`.
+export const attachmentPartType = 'data-attachment';
+
export const confirmFileRequestSchema = z.object({
sessionId: z.uuid(),
// URL returned by the blob client upload; the server only accepts URLs of its own store
diff --git a/packages/assistant-contracts/src/index.ts b/packages/assistant-contracts/src/index.ts
index 5f84b207c0..be08e15093 100644
--- a/packages/assistant-contracts/src/index.ts
+++ b/packages/assistant-contracts/src/index.ts
@@ -19,6 +19,7 @@ export {
type IAssistantErrorCode,
} from './error';
export {
+ attachmentPartType,
confirmFileRequestSchema,
deleteFileRequestSchema,
type IConfirmFileRequest,