diff --git a/.changeset/app-1004-assistant-chat-design-iteration.md b/.changeset/app-1004-assistant-chat-design-iteration.md new file mode 100644 index 0000000000..1bec47d525 --- /dev/null +++ b/.changeset/app-1004-assistant-chat-design-iteration.md @@ -0,0 +1,6 @@ +--- +"@aragon/assistant-chat": minor +"@aragon/app": patch +--- + +Iterate the support assistant on design feedback: the panel header carries the Aragon mark and names the request being drafted or created, the ticket card leads with the ticket title instead of a "Review your request" label, spent drafts collapse into a quiet line, past requests move from the greeting into their own view reached from under the composer, and the mail escape hatch becomes a footnote there. The transcript opens with a time divider, and the navigation trigger withdraws while the panel is open — the panel collapses through its own chevron. diff --git a/.changeset/app-1004-assistant-chat-polish.md b/.changeset/app-1004-assistant-chat-polish.md new file mode 100644 index 0000000000..af9f76102e --- /dev/null +++ b/.changeset/app-1004-assistant-chat-polish.md @@ -0,0 +1,5 @@ +--- +"@aragon/assistant-chat": patch +--- + +Send the name and type of an attachment with the conversation (never its bytes), so the assistant can say where a file arrived. The panel header now carries the exact height of the app's navigation bar, so the two bottom borders meet in a line rather than a step, and every control in the widget points at the cursor. diff --git a/.changeset/app-1004-assistant-resume-and-attachments.md b/.changeset/app-1004-assistant-resume-and-attachments.md new file mode 100644 index 0000000000..e8f5d387a0 --- /dev/null +++ b/.changeset/app-1004-assistant-resume-and-attachments.md @@ -0,0 +1,6 @@ +--- +"@aragon/assistant": patch +"@aragon/assistant-contracts": patch +--- + +Stream every reply against the incoming history, so an approval resume continues the assistant message the draft lives in instead of opening a second one — the sentence written before the tool call was shown again under the created ticket. Attachments now reach the model as a line inside the message that carried them (a `data-attachment` part on the wire, named in the contracts), replacing the positionless "N files are attached" note the model could not place in time: it kept asking for a screenshot the user had just sent. diff --git a/apps/app/e2e/tests/smoke/supportChat/supportChat.spec.ts b/apps/app/e2e/tests/smoke/supportChat/supportChat.spec.ts index ae5306d953..2cc4299606 100644 --- a/apps/app/e2e/tests/smoke/supportChat/supportChat.spec.ts +++ b/apps/app/e2e/tests/smoke/supportChat/supportChat.spec.ts @@ -102,18 +102,16 @@ test.describe('Support chat', () => { await page.goto('/'); await getChatTrigger(page).click(); - // Panel open: accessible title + greeting message + the persistent email escape hatch. + // Panel open: accessible title, the subline naming a fresh conversation, and the greeting. const panel = getChatPanel(page); await expect(panel).toBeVisible(); await expect( - panel.getByRole('heading', { name: 'Aragon Support Assistant' }), + panel.getByRole('heading', { name: 'Aragon Assistant' }), ).toBeVisible(); + await expect(panel.getByText('New conversation')).toBeVisible(); await expect( - page.getByText("Hi! Tell us what's going on"), + page.getByText('What do you need help with?'), ).toBeVisible(); - await expect( - panel.getByRole('link', { name: 'support@aragon.org' }), - ).toHaveAttribute('href', supportEmailHref); // Send a message; the agent streams back the ticket draft for review. const composer = page.getByRole('textbox', { name: 'Message' }); @@ -124,20 +122,32 @@ test.describe('Support chat', () => { page.getByText('The proposal page crashes on load.').first(), ).toBeVisible(); await expect( - page.getByText('Proposal page crashes', { exact: true }), + panel.getByRole('heading', { name: 'Proposal page crashes' }), ).toBeVisible(); await expect( panel.getByRole('button', { name: 'Dismiss' }), ).toBeVisible(); + // The header names the draft, and the escape hatch to a human now sits under the composer. + await expect( + panel.getByText('Draft: Proposal page crashes'), + ).toBeVisible(); + await expect( + panel.getByRole('link', { name: 'Email support' }), + ).toHaveAttribute('href', supportEmailHref); + // Approving resumes the stream: the tool executes and the card links to the ticket. - // `exact` keeps the match away from other "Create …" buttons of the page. - await panel - .getByRole('button', { name: 'Create', exact: true }) - .click(); + await panel.getByRole('button', { name: 'Create ticket' }).click(); await expect(page.getByText('Request created')).toBeVisible(); - await expect(page.getByText('SUP-123')).toBeVisible(); + await expect( + panel.getByRole('link', { name: /SUP-123/ }), + ).toHaveAttribute('href', 'https://linear.app/aragon/issue/SUP-123'); + + // The header follows the ticket from draft to created. + await expect( + panel.getByText('SUP-123: Proposal page crashes'), + ).toBeVisible(); }); test('hides the chat entry points and keeps the external support link when the flag is disabled', async ({ diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index f42d84af5a..e00cbc109a 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -424,7 +424,6 @@ "label": "Support chat" }, "trigger": { - "close": "Close support chat", "open": "Open support chat" } }, diff --git a/apps/app/src/modules/application/components/supportChat/supportChat.tsx b/apps/app/src/modules/application/components/supportChat/supportChat.tsx index 38b5f4f84e..4381951cec 100644 --- a/apps/app/src/modules/application/components/supportChat/supportChat.tsx +++ b/apps/app/src/modules/application/components/supportChat/supportChat.tsx @@ -10,7 +10,7 @@ const assistantUrl = process.env.NEXT_PUBLIC_ASSISTANT_URL ?? ''; // The trigger opens the chat whenever the feature flag is on — no availability gate in front of // the panel. Service failures surface inside the widget, where mailing the support team stays -// one click away (header link + error escape hatches). +// one click away (the line under the composer + error escape hatches). export const SupportChat: React.FC = () => { const { isOpen, close } = useSupportChatContext(); diff --git a/apps/app/src/modules/application/components/supportChat/supportChatTrigger.test.tsx b/apps/app/src/modules/application/components/supportChat/supportChatTrigger.test.tsx index 08167c29dd..fad1741b86 100644 --- a/apps/app/src/modules/application/components/supportChat/supportChatTrigger.test.tsx +++ b/apps/app/src/modules/application/components/supportChat/supportChatTrigger.test.tsx @@ -21,14 +21,14 @@ describe(' component', () => { } as ReturnType); }; - const setContext = (isOpen: boolean, toggle = jest.fn()) => { + const setContext = (isOpen: boolean, open = jest.fn()) => { useSupportChatContextSpy.mockReturnValue({ isOpen, - open: jest.fn(), + open, close: jest.fn(), - toggle, + toggle: jest.fn(), }); - return toggle; + return open; }; beforeEach(() => { @@ -47,22 +47,32 @@ describe(' component', () => { expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); - it('renders an open button and toggles the chat on click', async () => { - const toggle = setContext(false); + it('renders an open button and opens the chat on click', async () => { + const open = setContext(false); render(); const button = screen.getByRole('button', { name: /supportChat.trigger.open/, }); await userEvent.click(button); - expect(toggle).toHaveBeenCalled(); + expect(open).toHaveBeenCalled(); }); - it('renders a close button while the chat is open', () => { + it('withdraws while the chat is open, which closes through the panel itself', () => { setContext(true); render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('takes focus back when the chat is collapsed from the panel', () => { + setContext(true); + const { rerender } = render(); + + setContext(false); + rerender(); + expect( - screen.getByRole('button', { name: /supportChat.trigger.close/ }), - ).toBeInTheDocument(); + screen.getByRole('button', { name: /supportChat.trigger.open/ }), + ).toHaveFocus(); }); }); diff --git a/apps/app/src/modules/application/components/supportChat/supportChatTrigger.tsx b/apps/app/src/modules/application/components/supportChat/supportChatTrigger.tsx index 10358733f5..88771110a9 100644 --- a/apps/app/src/modules/application/components/supportChat/supportChatTrigger.tsx +++ b/apps/app/src/modules/application/components/supportChat/supportChatTrigger.tsx @@ -1,34 +1,43 @@ 'use client'; import { Button, IconType } from '@aragon/gov-ui-kit'; +import { useEffect, useRef } from 'react'; import { useFeatureFlags } from '@/shared/components/featureFlagsProvider'; import { useTranslations } from '@/shared/components/translationsProvider'; import { useSupportChatContext } from './supportChatContext'; -// Header entry point of the support chat: the feedback icon opens the side panel, the chevron -// (pointing at the panel edge) tucks it away again. Rendered at the trailing edge of the -// navigation bar (outside the centered container) so it always sits right next to the panel. +// Header entry point of the support chat. Rendered at the trailing edge of the navigation bar +// (outside the centered container) so it always sits right next to the panel, and withdrawn while +// the panel is open — the panel then owns its own collapse control. export const SupportChatTrigger: React.FC = () => { const { t } = useTranslations(); const { isEnabled } = useFeatureFlags(); - const { isOpen, toggle } = useSupportChatContext(); + const { isOpen, open } = useSupportChatContext(); - if (!isEnabled('supportChat')) { + const buttonRef = useRef(null); + const wasOpen = useRef(false); + + // The trigger withdraws while the panel is open, so collapsing the chat would leave the + // keyboard on the document body: focus returns to the trigger that comes back in its place. + useEffect(() => { + if (!isOpen && wasOpen.current) { + buttonRef.current?.focus(); + } + + wasOpen.current = isOpen; + }, [isOpen]); + + if (!isEnabled('supportChat') || isOpen) { return null; } - const label = t( - isOpen - ? 'app.application.supportChat.trigger.close' - : 'app.application.supportChat.trigger.open', - ); - return (
); }; diff --git a/packages/assistant-chat/src/components/chatRequestHistory/chatRequestHistory.tsx b/packages/assistant-chat/src/components/chatRequestHistory/chatRequestHistory.tsx index fbf1fbfa1f..08e38fd5fe 100644 --- a/packages/assistant-chat/src/components/chatRequestHistory/chatRequestHistory.tsx +++ b/packages/assistant-chat/src/components/chatRequestHistory/chatRequestHistory.tsx @@ -1,9 +1,6 @@ import { Tag } from '@aragon/gov-ui-kit'; -import { useState } from 'react'; import { chatCopy } from '../../copy'; -import { getRequestHistory } from '../../requests'; - -const maxVisibleEntries = 5; +import { useRequestHistory } from '../../requests'; const dateFormatter = new Intl.DateTimeFormat('en-US', { month: 'short', @@ -16,35 +13,35 @@ const formatCreatedAt = (createdAt: string): string => { return Number.isNaN(date.getTime()) ? '' : dateFormatter.format(date); }; -// Past requests of this device, shown on the greeting screen so users can get back to a created -// ticket without digging through their email. The component only exists on the greeting screen, -// so reading the stored history once per mount is always fresh: any newly created ticket -// unmounts the greeting, and the next fresh chat remounts it. +// The requests filed from this device, as a view of their own reached from the link under the +// composer. The entries deep-link to the created ticket — the conversations themselves are not +// restorable, they live for the length of a session. export const ChatRequestHistory: React.FC = () => { - const [requestHistory] = useState(() => getRequestHistory()); + const requestHistory = useRequestHistory(); if (requestHistory.length === 0) { - return null; + return ( +

+ {chatCopy.requestHistory.empty} +

+ ); } return ( -
-

- {chatCopy.requestHistory.heading} -

- {requestHistory.slice(0, maxVisibleEntries).map((entry) => ( +
+ {requestHistory.map((entry) => ( - + {entry.summary} - + {formatCreatedAt(entry.createdAt)} diff --git a/packages/assistant-chat/src/components/createTicketCard/createTicketCard.tsx b/packages/assistant-chat/src/components/createTicketCard/createTicketCard.tsx index 12c20479bd..ce10d24fa3 100644 --- a/packages/assistant-chat/src/components/createTicketCard/createTicketCard.tsx +++ b/packages/assistant-chat/src/components/createTicketCard/createTicketCard.tsx @@ -21,7 +21,12 @@ import { appendRequestToHistory } from '../../requests'; // The top margin separates the card from the assistant text preceding it in the same message. const cardClassName = - 'mt-3 flex w-full flex-col gap-3 rounded-xl border border-neutral-100 bg-neutral-0 p-4 first:mt-0'; + 'mt-3 flex w-full flex-col gap-3.5 rounded-xl border border-neutral-100 bg-neutral-0 p-5 shadow-neutral-md first:mt-0'; + +// A draft nobody acted on is not an event worth a card: it collapses into a quiet line so the +// transcript stays readable while still reading honestly. +const spentNoteClassName = + 'mt-3 text-center text-neutral-400 text-xs leading-normal first:mt-0'; // The ticket draft the model assembled, rendered as an approval card in the transcript. It walks // four states: the draft streaming in, the draft awaiting the user's Create/Dismiss decision, the @@ -87,15 +92,12 @@ export const CreateTicketCard: ToolCallMessagePartComponent< } // A draft that broke before the user ever approved it (clipped stream, invalid tool input) - // is not a failed creation — nothing was attempted. Render it as a quiet spent card so the - // conversation just moves on. + // is not a failed creation — nothing was attempted, so the conversation just moves on. if (isError && approval?.approved !== true) { return ( -
-

- {chatCopy.ticketCard.draftInterrupted} -

-
+

+ {chatCopy.ticketCard.draftInterrupted} +

); } @@ -131,26 +133,21 @@ export const CreateTicketCard: ToolCallMessagePartComponent< } // Superseded draft: the user kept typing past an undecided card, so the approval was never - // answered — the server resolves it as superseded and a fresh draft follows. A quiet spent - // card, kept in place so the transcript reads honestly. + // answered — the server resolves it as superseded and a fresh draft follows. if (approval?.approved == null && !isLastMessage) { return ( -
-

- {chatCopy.ticketCard.superseded} -

-
+

+ {chatCopy.ticketCard.superseded} +

); } // Dismissed or cancelled draft: the user explicitly declined it. if (approval?.approved === false || approval?.resolution != null) { return ( -
-

- {chatCopy.ticketCard.dismissed} -

-
+

+ {chatCopy.ticketCard.dismissed} +

); } @@ -180,38 +177,23 @@ export const CreateTicketCard: ToolCallMessagePartComponent< return (
-
- - {chatCopy.ticketCard.draftHeading} - - {args.intent != null && ( - - )} -
+ {/* The ticket leads with itself: its title is the card's heading. */} {args.title != null && ( -

+ {args.title} -

+ )} {args.description != null && ( -
-

- {chatCopy.ticketCard.descriptionLabel} -

-

- {args.description} -

-
+

+ {args.description} +

)} {steps.length > 0 && ( -
-

+

+

{chatCopy.ticketCard.stepsLabel}

-
    +
      {steps.map((step, index) => ( // Steps are positional and may repeat verbatim, so the index is the // only stable identity available. @@ -220,16 +202,6 @@ export const CreateTicketCard: ToolCallMessagePartComponent<
)} - {args.contact != null && args.contact.length > 0 && ( -
-

- {chatCopy.ticketCard.contactLabel} -

-

- {args.contact} -

-
- )} {awaitingDecision ? ( <> {/* Deterministic invite to enrich the request: the model does not reliably @@ -237,12 +209,17 @@ export const CreateTicketCard: ToolCallMessagePartComponent<

{chatCopy.ticketCard.addMore}

-
+ {args.contact != null && args.contact.length > 0 && ( +

+ {`${chatCopy.ticketCard.contactPrefix} ${args.contact}`} +

+ )} +
- + isNewChatView(state) && state.composer.isEmpty @@ -90,6 +93,14 @@ export const Thread: React.FC = (props) => { + {/* One quiet line under the composer: the way back to a filed request on a + fresh chat, the way to a human once the conversation is under way. */} + + + + !isNewChatView(state)}> + +
@@ -97,23 +108,120 @@ export const Thread: React.FC = (props) => { ); }; +// Messages sent within the same sitting need no divider; a longer pause means the user comes back +// to the conversation, and then the transcript says when it was left. +const conversationGapMs = 30 * 60 * 1000; + +const timeFormatter = new Intl.DateTimeFormat('en-US', { + hour: '2-digit', + hour12: false, + minute: '2-digit', +}); + +const weekdayFormatter = new Intl.DateTimeFormat('en-US', { weekday: 'long' }); + +const formatDividerLabel = (date: Date): string => { + const day = + date.toDateString() === new Date().toDateString() + ? chatCopy.thread.today + : weekdayFormatter.format(date); + + return `${day} ${timeFormatter.format(date)}`; +}; + +const selectDividerLabel = (state: AssistantState): string | undefined => { + const { createdAt, index } = state.message; + const previous = state.thread.messages[index - 1]; + + if ( + previous != null && + createdAt.getTime() - previous.createdAt.getTime() < conversationGapMs + ) { + return undefined; + } + + return formatDividerLabel(createdAt); +}; + +interface IThreadTimeDividerProps { + /** + * Time the messages below the divider start at. + */ + label: string; +} + +const ThreadTimeDivider: React.FC = (props) => { + const { label } = props; + + return ( +
+ +

{label}

+ +
+ ); +}; + const ThreadMessage: React.FC = () => { const role = useAuiState((state) => state.message.role); + const dividerLabel = useAuiState(selectDividerLabel); - return role === 'user' ? : ; + return ( + <> + {dividerLabel != null && } + {role === 'user' ? : } + + ); }; -// Fills the space between header and composer on a fresh chat: the greeting sits centered in it, -// the request history (when any) right below. +// Fills the space between header and composer on a fresh chat. const ThreadWelcome: React.FC = () => ( -
-

+
+ {chatCopy.welcome.greeting} -

- +
); +interface IPastRequestsLinkProps { + /** + * Opens the requests filed from this device. + */ + onViewRequests: () => void; +} + +const PastRequestsLink: React.FC = (props) => { + const { onViewRequests } = props; + + const requestHistory = useRequestHistory(); + + if (requestHistory.length === 0) { + return null; + } + + return ( + + ); +}; + +const EmailEscalation: React.FC = () => ( +

+ {`${chatCopy.composer.escalationPrompt} `} + + {chatCopy.composer.escalationLink} + +

+); + const ThreadSuggestions: React.FC = () => (
{chatCopy.welcome.suggestions.map((suggestion) => ( @@ -124,7 +232,7 @@ const ThreadSuggestions: React.FC = () => ( send={true} >