From f18a93ac34949500c3f2b97b199a2105abc5c8a6 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 5 Aug 2026 20:03:45 +0200 Subject: [PATCH] feat(APP-1004): iterate the support assistant on design feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel header carries the Aragon mark and a live subline naming 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 out of the greeting into a view of their own, reached from a link under the composer, where the mail escape hatch now lives as a footnote. The transcript opens with a time divider, and the navigation trigger withdraws while the panel is open — the panel collapses through its own chevron, which hands focus back to the trigger taking its place. The header carries the exact height of the navigation bar so their bottom borders meet in a line rather than a step, and every control in the widget points at the cursor again. Two conversations were also broken. Every stream is now opened against the incoming history, so an approval resume continues the assistant message the draft lives in instead of opening a second one — the widget seeds its response from the last message, so a fresh id made it append a copy, and the sentence written before the tool call appeared again under the created ticket. And an attachment reaches the model as a line inside the message that carried it, 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. The name travels as a data part, which a service that does not know it drops, so the widget can ship ahead of the assistant. --- ...pp-1004-assistant-chat-design-iteration.md | 6 + .changeset/app-1004-assistant-chat-polish.md | 5 + ...p-1004-assistant-resume-and-attachments.md | 6 + .../smoke/supportChat/supportChat.spec.ts | 34 ++-- apps/app/src/assets/locales/en.json | 1 - .../components/supportChat/supportChat.tsx | 2 +- .../supportChat/supportChatTrigger.test.tsx | 30 ++-- .../supportChat/supportChatTrigger.tsx | 37 +++-- .../assistant/src/chat/prompts/agentPrompt.ts | 21 ++- apps/assistant/src/routes/chat.test.ts | 61 +++++++ apps/assistant/src/routes/chat.ts | 68 ++++++-- .../assistantChat/assistantChat.test.tsx | 142 ++++++++++++++-- .../assistantChat/assistantChat.tsx | 32 +++- .../src/components/attachment/attachment.tsx | 2 +- .../src/components/chatHeader/aragonMark.tsx | 22 +++ .../src/components/chatHeader/chatHeader.tsx | 137 ++++++++++++---- .../chatRequestHistory/chatRequestHistory.tsx | 33 ++-- .../createTicketCard/createTicketCard.tsx | 91 ++++------- .../src/components/thread/thread.tsx | 151 +++++++++++++++--- .../tooltipIconButton/tooltipIconButton.tsx | 3 +- packages/assistant-chat/src/copy.ts | 42 ++--- packages/assistant-chat/src/requests/index.ts | 1 + .../src/requests/useRequestHistory.ts | 13 ++ .../src/transport/createChatTransport.ts | 21 ++- packages/assistant-contracts/src/file.ts | 7 + packages/assistant-contracts/src/index.ts | 1 + 26 files changed, 741 insertions(+), 228 deletions(-) create mode 100644 .changeset/app-1004-assistant-chat-design-iteration.md create mode 100644 .changeset/app-1004-assistant-chat-polish.md create mode 100644 .changeset/app-1004-assistant-resume-and-attachments.md create mode 100644 packages/assistant-chat/src/components/chatHeader/aragonMark.tsx create mode 100644 packages/assistant-chat/src/requests/useRequestHistory.ts 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} >