Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/app-1004-assistant-chat-design-iteration.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 22 additions & 12 deletions apps/app/e2e/tests/smoke/supportChat/supportChat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand All @@ -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 ({
Expand Down
1 change: 0 additions & 1 deletion apps/app/src/assets/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,6 @@
"label": "Support chat"
},
"trigger": {
"close": "Close support chat",
"open": "Open support chat"
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ describe('<SupportChatTrigger /> component', () => {
} as ReturnType<typeof featureFlagsProvider.useFeatureFlags>);
};

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(() => {
Expand All @@ -47,22 +47,32 @@ describe('<SupportChatTrigger /> 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(<SupportChatTrigger />);

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(<SupportChatTrigger />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

it('takes focus back when the chat is collapsed from the panel', () => {
setContext(true);
const { rerender } = render(<SupportChatTrigger />);

setContext(false);
rerender(<SupportChatTrigger />);

expect(
screen.getByRole('button', { name: /supportChat.trigger.close/ }),
).toBeInTheDocument();
screen.getByRole('button', { name: /supportChat.trigger.open/ }),
).toHaveFocus();
});
});
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(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;
}
Comment on lines +30 to 32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the trigger hidden while the panel is open, collapsing from the chevron seems to drop keyboard focus to the body. Worth moving focus back to the trigger on close?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The trigger keeps a ref to its button and focuses it when isOpen goes back to false — the component itself stays mounted while the panel is open (it only renders null), so the ref is there by the time the effect runs. Test added in supportChatTrigger.test.tsx.


const label = t(
isOpen
? 'app.application.supportChat.trigger.close'
: 'app.application.supportChat.trigger.open',
);

return (
<div className="flex items-center pr-3 pl-2 lg:pr-4">
<Button
aria-label={label}
iconLeft={isOpen ? IconType.CHEVRON_RIGHT : IconType.FEEDBACK}
onClick={toggle}
aria-label={t('app.application.supportChat.trigger.open')}
iconLeft={IconType.FEEDBACK}
onClick={open}
ref={buttonRef}
size="md"
variant="tertiary"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,16 +168,18 @@ describe('<AssistantChat /> integration', () => {
global.fetch = originalFetch;
});

const widget = (isOpen = true, monitoring?: IChatMonitoring) => (
<AssistantChat
appContext={{ route: '/dashboard', appVersion: '1.0.0' }}
assistantUrl={assistantUrl}
isOpen={isOpen}
monitoring={monitoring}
onClose={jest.fn()}
/>
);

const renderWidget = (monitoring?: IChatMonitoring) =>
render(
<AssistantChat
appContext={{ route: '/dashboard', appVersion: '1.0.0' }}
assistantUrl={assistantUrl}
isOpen={true}
monitoring={monitoring}
onClose={jest.fn()}
/>,
);
render(widget(true, monitoring));

const sendMessageAndReviewDraft = async () => {
const composer = await screen.findByRole('textbox', {
Expand All @@ -193,7 +195,7 @@ describe('<AssistantChat /> integration', () => {
await screen.findByText('Proposal page crashes on load'),
).toBeInTheDocument();

return screen.getByRole('button', { name: 'Create' });
return screen.getByRole('button', { name: 'Create ticket' });
};

it('drafts the ticket, creates it on approval and links to it', async () => {
Expand All @@ -208,13 +210,24 @@ describe('<AssistantChat /> integration', () => {
screen.getByRole('button', { name: 'Dismiss' }),
).toBeInTheDocument();

// The transcript opens with the time it started, the header names the draft in progress.
expect(screen.getByText(/^Today \d{2}:\d{2}$/)).toBeInTheDocument();
expect(
screen.getByText('Draft: Proposal page crashes on load'),
).toBeInTheDocument();

await userEvent.click(createButton);

// Approval resumes the stream (a second /chat call) and the tool output renders as success.
expect(await screen.findByText('Request created')).toBeInTheDocument();
expect(screen.getByText('SUP-123')).toBeInTheDocument();
expect(chatCalls()).toHaveLength(2);

// The header follows the ticket from draft to created.
expect(
screen.getByText('SUP-123: Proposal page crashes on load'),
).toBeInTheDocument();

// The created ticket is remembered in the device-local history.
expect(
JSON.parse(
Expand Down Expand Up @@ -249,7 +262,7 @@ describe('<AssistantChat /> integration', () => {
// Retry re-drafts; approving the fresh draft creates the ticket.
await userEvent.click(screen.getByRole('button', { name: 'Retry' }));
await userEvent.click(
await screen.findByRole('button', { name: 'Create' }),
await screen.findByRole('button', { name: 'Create ticket' }),
);

expect(await screen.findByText('Request created')).toBeInTheDocument();
Expand Down Expand Up @@ -317,6 +330,96 @@ describe('<AssistantChat /> integration', () => {
expect(requestParts.every((part) => part.type !== 'file')).toBe(true);
});

it('opens the requests filed from this device from the link under the composer', async () => {
localStorage.setItem(
'aragon-assistant:requests',
JSON.stringify([
{
identifier: 'SUP-1',
url: 'https://linear.app/aragon/issue/SUP-1',
summary: 'Treasury shows a stale balance',
createdAt: '2026-07-24T10:00:00.000Z',
},
]),
);
renderWidget();

await userEvent.click(
await screen.findByRole('button', { name: 'Past requests (1)' }),
);

// The requests take over the panel: the entry deep-links to the ticket, the composer is
// gone until the user comes back to the conversation.
expect(
screen.getByRole('link', {
name: /Treasury shows a stale balance/,
}),
).toHaveAttribute('href', 'https://linear.app/aragon/issue/SUP-1');
expect(
screen.queryByRole('textbox', { name: 'Message' }),
).not.toBeInTheDocument();

await userEvent.click(
screen.getByRole('button', { name: 'Back to chat' }),
);
expect(
await screen.findByRole('textbox', { name: 'Message' }),
).toBeInTheDocument();
});

it('leaves the requests behind when the panel is closed while showing them', async () => {
localStorage.setItem(
'aragon-assistant:requests',
JSON.stringify([
{
identifier: 'SUP-1',
url: 'https://linear.app/aragon/issue/SUP-1',
summary: 'Treasury shows a stale balance',
createdAt: '2026-07-24T10:00:00.000Z',
},
]),
);
const { rerender } = renderWidget();

await userEvent.click(
await screen.findByRole('button', { name: 'Past requests (1)' }),
);
expect(
screen.queryByRole('textbox', { name: 'Message' }),
).not.toBeInTheDocument();

// Reopening lands on the conversation, not on the detour the panel was closed from.
rerender(widget(false));
rerender(widget(true));

expect(
await screen.findByRole('textbox', { name: 'Message' }),
).toBeInTheDocument();
});

it('stops naming a draft in the header once the user dismisses it', async () => {
chatResponses = [
createChatResponse(draftChunks('tc-1')),
createChatResponse(textChunks('No problem, the draft is gone.')),
];
renderWidget();

await sendMessageAndReviewDraft();
await userEvent.click(screen.getByRole('button', { name: 'Dismiss' }));

// The card says the draft was dismissed, so nothing is being worked on: the header drops
// back to naming a fresh conversation.
expect(
await screen.findByText(
'Draft dismissed. Keep chatting to prepare a new one.',
),
).toBeInTheDocument();
expect(
screen.queryByText('Draft: Proposal page crashes on load'),
).not.toBeInTheDocument();
expect(screen.getByText('New conversation')).toBeInTheDocument();
});

it('marks an unapproved draft as superseded when the user keeps typing', async () => {
chatResponses = [
createChatResponse(draftChunks('tc-1')),
Expand All @@ -335,7 +438,7 @@ describe('<AssistantChat /> integration', () => {
// The undecided draft is spent quietly — no error wording, no lingering Create button.
expect(
screen.getByText(
'This draft was set aside after your newer messages.',
'Earlier draft set aside your newer messages replaced it.',
),
).toBeInTheDocument();
expect(
Expand All @@ -344,8 +447,9 @@ describe('<AssistantChat /> integration', () => {
),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Create' }),
screen.queryByRole('button', { name: 'Create ticket' }),
).not.toBeInTheDocument();
expect(screen.getByText('New conversation')).toBeInTheDocument();

// The pending approval travels to the server untouched, where it resolves as superseded —
// the client must not rewrite it into a tool error.
Expand Down
Loading
Loading