Compare commits

...
5 Commits
Author SHA1 Message Date
james.maesandCodex f1dbb4b148 test: make standalone fixtures portable on Windows
Use native path separators and retain POSIX permission assertions on platforms implementing those semantics. Preserve staged-content and rewrite-denial assertions.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 08:38:49 -05:00
james.maesandCodex cab6f79255 merge: integrate assistant conversations and attachments
Preserve chrome accessibility and current conversation controls. Validation: 3658 unit tests and coverage, 27 production browser regressions, typecheck, lint, production build, Storybook and static export passed. Existing report-only size warnings remain.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 08:38:48 -05:00
james.maesandCodex a7cca3162d fix(assistant): keep header tooltips above the mobile dialog
Co-Authored-By: Codex <noreply@openai.com>
2026-10-19 07:18:03 -05:00
james.maes 955f310539 Merge branch 'main' into feature/assistant-panel-chrome 2026-10-19 07:06:27 -05:00
james.maesandClaude Opus 5.5 6e1142553b feat(assistant): clearer panel header buttons and wrapping example prompts
- History icon and "Conversation history" label for the rail toggle
- Hover and keyboard-focus tooltips plus aria-labels on history, new and close
- Example prompts wrap inside full-width chips (no clipping), stack when narrow
- Unit tests with axe, and a width e2e spec

Part of KofTwentyTwo/Website-Backend#303

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:35:06 -05:00
7 changed files with 235 additions and 18 deletions

No files matched your search

@@ -238,7 +238,7 @@ function mockAssistant(options: { config?: Record<string, unknown>; list?: Assis
*/
async function openRail(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole('button', { name: /^Ask the assistant/ }))
await user.click(await screen.findByRole('button', { name: 'Show conversations' }))
await user.click(await screen.findByRole('button', { name: 'Conversation history' }))
await screen.findByRole('button', { name: UNREAD_ROW })
}
@@ -347,7 +347,7 @@ describe('conversation list in the panel', () => {
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Ask the assistant/ }))
const toggle = await screen.findByRole('button', { name: 'Show conversations' })
const toggle = await screen.findByRole('button', { name: 'Conversation history' })
await waitFor(() => expect(toggle.querySelector('[data-qqq-id="assistant-rail-toggle-dot"]')).not.toBeNull())
expect(toggle).toHaveAccessibleDescription('Other conversations are working or have unread replies')
})
@@ -357,7 +357,7 @@ describe('conversation list in the panel', () => {
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Ask the assistant/ }))
const toggle = await screen.findByRole('button', { name: 'Show conversations' })
const toggle = await screen.findByRole('button', { name: 'Conversation history' })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(toggle.querySelector('[data-qqq-id="assistant-rail-toggle-dot"]')).toBeNull()
})
@@ -404,7 +404,7 @@ describe('unread correctness', () => {
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Ask the assistant/ }))
await user.click(await screen.findByRole('button', { name: 'Show conversations' }))
await user.click(await screen.findByRole('button', { name: 'Conversation history' }))
await user.click(await screen.findByRole('button', { name: 'Quiet one' }))
const composer = await screen.findByRole('textbox', { name: 'Message the assistant' })
await user.type(composer, 'Hello{Enter}')
@@ -420,7 +420,7 @@ describe('unread correctness', () => {
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Ask the assistant, 2 unread/ }))
await user.click(await screen.findByRole('button', { name: 'Show conversations' }))
await user.click(await screen.findByRole('button', { name: 'Conversation history' }))
await user.click(await screen.findByRole('button', { name: UNREAD_ROW }))
await waitFor(() => expect(traffic.patches).toContainEqual({ id: '2', body: { read: true } }))
})
@@ -0,0 +1,49 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file AssistantHeaderTip — hover and keyboard-focus tooltip for the assistant panel's icon buttons.
*/
'use client'
import React from 'react'
import * as Tooltip from '@radix-ui/react-tooltip'
/**
* Wraps one icon button so its label shows as a tooltip on hover and on keyboard focus. The button
* keeps its own `aria-label`; the tooltip is a visual duplicate for sighted users.
*
* @param props - Component props.
* @param props.label - Tooltip text.
* @param props.children - The single button element.
* @returns The button with a tooltip.
*/
export function HeaderTip({ label, children }: { label: string; children: React.ReactElement }) {
return (
<Tooltip.Provider delayDuration={300}>
<Tooltip.Root>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content sideOffset={4} className="z-[var(--qqq-z-tooltip,3000)] rounded bg-popover px-2 py-1 text-xs text-popover-foreground shadow" data-qqq-id="assistant-tooltip">
{label}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
)
}
@@ -650,7 +650,7 @@ describe('answer lifecycle and recovery', () => {
const user = userEvent.setup()
renderShell()
await openComposer(user)
await user.click(screen.getByRole('button', { name: 'Show conversations' }))
await user.click(screen.getByRole('button', { name: 'Conversation history' }))
await user.click(await screen.findByRole('button', { name: 'Saved conversation' }))
await user.click(await screen.findByRole('button', { name: 'Stop the reply' }))
expect(await screen.findByText('Finished on another pod', { selector: MESSAGE_TEXT })).toBeInTheDocument()
@@ -755,3 +755,72 @@ describe('multiple run controls', () => {
await waitFor(() => expect(screen.queryByText('Writing...')).not.toBeInTheDocument())
})
})
describe('panel header chrome and starter prompts', () => {
const LONG = 'Which of my open orders have been waiting on a customer reply for more than two weeks, and what was the last thing we said to them?'
it('labels the history, new and close buttons with an icon, aria-label and keyboard-focus tooltip', async () => {
mockAssistant()
const user = userEvent.setup()
renderShell()
await openComposer(user)
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
const history = within(panel).getByRole('button', { name: 'Conversation history' })
expect(history).toHaveAttribute('aria-expanded', 'false')
expect(history.querySelector('svg')).toHaveClass('lucide-history')
expect(within(panel).getByRole('button', { name: 'New conversation' })).toBeInTheDocument()
expect(within(panel).getByRole('button', { name: 'Close assistant' })).toBeInTheDocument()
// keyboard focus alone (no hover) opens the tooltip
act(() => history.focus())
expect(await screen.findByRole('tooltip', {}, { timeout: 3000 })).toHaveTextContent('Conversation history')
await user.click(history)
expect(within(panel).getByRole('button', { name: 'Hide conversation history' })).toHaveAttribute('aria-expanded', 'true')
})
it('wraps long starter prompts inside the chip and stacks them, with no truncation', async () => {
mockAssistant({ enabled: true, profile: { starterPrompts: [LONG, 'Short one', LONG + ' Again?'] } })
const user = userEvent.setup()
renderShell()
await openComposer(user)
const list = await screen.findByRole('list', { name: 'Suggested questions' })
const chips = within(list).getAllByRole('button')
expect(chips).toHaveLength(3)
for (const chip of chips) {
expect(chip).toHaveClass('whitespace-normal', 'break-words', 'w-full')
expect(chip.className).not.toMatch(/truncate|whitespace-nowrap|overflow-hidden/)
expect(chip.parentElement).toHaveClass('w-full', 'min-w-0', '@md:w-auto')
}
expect(chips[0]).toHaveTextContent(LONG)
})
it.each([[0], [1], [2], [3]])('renders %i starter prompts without an empty list', async (count) => {
mockAssistant({ enabled: true, profile: { starterPrompts: Array.from({ length: count }, (_, index) => `Question ${index + 1}`) } })
const user = userEvent.setup()
renderShell()
await openComposer(user)
await screen.findByText('Ask about your data')
const list = screen.queryByRole('list', { name: 'Suggested questions' })
if (count === 0) expect(list).not.toBeInTheDocument()
else expect(within(list as HTMLElement).getAllByRole('button')).toHaveLength(count)
})
it('has no axe violations: empty state, rail open, and with a conversation', async () => {
mockAssistant({ enabled: true, profile: { starterPrompts: [LONG, 'Short one'] } })
server.use(http.get(`${BASE}/conversations`, () => HttpResponse.json({ conversations: [{ ...CONVERSATION, title: 'Saved conversation' }] })))
const user = userEvent.setup()
renderShell()
await openComposer(user)
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
expect(await axe(panel)).toHaveNoViolations()
await user.click(within(panel).getByRole('button', { name: 'Conversation history' }))
await screen.findByRole('button', { name: 'Saved conversation' })
expect(await axe(panel)).toHaveNoViolations()
persisted.messages = storedTurn('Find records', [{ type: 'text', text: 'Here they are.' }])
await user.click(screen.getByRole('button', { name: 'Saved conversation' }))
expect(await screen.findByText('Here they are.', { selector: MESSAGE_TEXT })).toBeInTheDocument()
expect(await axe(panel)).toHaveNoViolations()
})
})
+15 -8
View File
@@ -28,7 +28,7 @@ import * as AlertDialog from '@radix-ui/react-alert-dialog'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { usePathname } from 'next/navigation'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Archive, ArchiveRestore, Check, Eye, MoreHorizontal, PanelLeft, Pencil, PencilLine, Pin, PinOff, SquarePen, Trash2, X } from 'lucide-react'
import { Archive, ArchiveRestore, Check, Eye, History, MoreHorizontal, Pencil, PencilLine, Pin, PinOff, SquarePen, Trash2, X } from 'lucide-react'
import { toast } from 'sonner'
import type { QInstance } from '@/types'
@@ -48,6 +48,7 @@ import { cn } from '@/lib/utils/cn'
import { ASSISTANT_DOCKED_MEDIA_QUERY, assistantKeys, useAssistant, type AssistantController } from './assistant-context'
import { AssistantElapsed } from './AssistantRunStatus'
import { AssistantComposer } from './AssistantComposer'
import { HeaderTip } from './AssistantHeaderTip'
import { AssistantMessageView } from './AssistantMessageView'
import { ConversationRail, conversationTitle } from './ConversationRail'
import { markConversationRead } from './mark-read'
@@ -315,17 +316,18 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
return (
<div className="flex h-full min-h-0 min-w-0 flex-col">
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2" style={{ minHeight: 'var(--qqq-header-height)' }}>
<HeaderTip label={railOpen ? 'Hide conversation history' : 'Conversation history'}>
<button
type="button"
className={cn(iconButton, 'relative')}
onClick={() => setRailOpen((value) => !value)}
aria-expanded={railOpen}
aria-controls={listId}
aria-label={railOpen ? 'Hide conversations' : 'Show conversations'}
aria-label={railOpen ? 'Hide conversation history' : 'Conversation history'}
aria-describedby={othersBusy ? `${listId}-busy` : undefined}
data-qqq-id="assistant-rail-toggle"
>
<PanelLeft className="h-4 w-4" aria-hidden="true" />
<History className="h-4 w-4" aria-hidden="true" />
{othersBusy && (
<>
<span className="absolute right-0.5 top-0.5 h-2 w-2 rounded-full bg-primary" aria-hidden="true" data-qqq-id="assistant-rail-toggle-dot" />
@@ -333,6 +335,7 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
</>
)}
</button>
</HeaderTip>
{renaming && conversation ? (
<form
className="min-w-0 flex-1"
@@ -420,6 +423,7 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
</DropdownMenu.Portal>
</DropdownMenu.Root>
)}
<HeaderTip label="New conversation">
<button
type="button"
className={iconButton}
@@ -434,9 +438,12 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
>
<SquarePen className="h-4 w-4" aria-hidden="true" />
</button>
<button type="button" className={iconButton} onClick={() => assistant.setOpen(false)} aria-label="Close assistant" data-qqq-id="assistant-close">
<X className="h-4 w-4" aria-hidden="true" />
</button>
</HeaderTip>
<HeaderTip label="Close assistant">
<button type="button" className={iconButton} onClick={() => assistant.setOpen(false)} aria-label="Close assistant" data-qqq-id="assistant-close">
<X className="h-4 w-4" aria-hidden="true" />
</button>
</HeaderTip>
</div>
{Object.entries(engine.runs).filter(([id]) => (Number(id) !== activeConversationId || !showMessages)).map(([id, runs]) => {
@@ -495,11 +502,11 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
{(profile?.starterPrompts?.length ?? 0) > 0 && (
<ul className="mt-4 flex flex-wrap justify-center gap-2" aria-label="Suggested questions" data-qqq-id="assistant-starters">
{profile?.starterPrompts?.map((prompt) => (
<li key={prompt} className="max-w-full">
<li key={prompt} className="w-full min-w-0 @md:w-auto @md:max-w-full" data-qqq-id="assistant-starter">
<button
type="button"
onClick={() => send(prompt)}
className="max-w-full rounded-full border border-border bg-background px-3 py-1.5 text-left text-xs text-foreground hover:bg-accent pointer-coarse:min-h-11 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
className="w-full whitespace-normal break-words rounded-2xl border border-border bg-background px-3 py-1.5 text-left text-xs text-foreground hover:bg-accent pointer-coarse:min-h-11 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
>
{prompt}
</button>
@@ -7,6 +7,7 @@
/** @file Exercises the real variant lifecycle with only process and filesystem boundaries replaced. */
import { EventEmitter } from 'node:events'
import path from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
const state = vi.hoisted(() => ({
@@ -39,7 +40,7 @@ vi.mock('node:child_process', () => {
// Existing stale export and build files cannot determine the browser's selected server.
vi.mock('node:fs', async (original) => {
const fs = {
...await original<typeof import('node:fs')>(), existsSync: (file: string) => file.endsWith('next-dashboard/index.html') ? state.hasExport : true, mkdirSync: () => {}, rmSync: () => {},
...await original<typeof import('node:fs')>(), existsSync: (file: string) => file.endsWith(path.join('next-dashboard', 'index.html')) ? state.hasExport : true, mkdirSync: () => {}, rmSync: () => {},
readdirSync: () => ['Fixture.java'],
readFileSync: (file: string) => file.endsWith('routes-manifest.json')
? JSON.stringify({ rewrites: { beforeFiles: [], afterFiles: ['qqq', 'data', 'widget', 'metaData', 'download', 'processes', 'possibleValues', 'reports', 'manageSession', 'apis.json', 'api', 'qa'].map((prefix) => ({ source: `/${prefix}/:path*`, destination: `http://127.0.0.1:${state.wrongTarget ? '18765' : '18775'}/${prefix}/:path*` })), fallback: [] } })
+88
View File
@@ -0,0 +1,88 @@
// E2E: the assistant panel header (history / new / close buttons with tooltips) and the example
// prompts: long prompts wrap inside their chips at every width, nothing clips, nothing scrolls
// sideways. Set QQQ_ASSISTANT_SCREENSHOTS to a directory to keep a screenshot per viewport.
import { test, expect, type Page, type Route } from '@playwright/test'
import { METADATA, setupApiMocks } from './api-mocks'
import { checkA11y } from './a11y-helpers'
const VIEWPORTS = [
{ name: '390', width: 390, height: 844 },
{ name: '768', width: 768, height: 1024 },
{ name: '1024', width: 1024, height: 768 },
{ name: '1440', width: 1440, height: 900 },
{ name: '1920', width: 1920, height: 1080 },
{ name: '3200', width: 3200, height: 1800 },
]
const SCREENSHOT_DIR = process.env.QQQ_ASSISTANT_SCREENSHOTS
const CONVERSATION = { id: 4, title: 'Active people', status: 'ACTIVE', pinned: false, lastMessageAt: null, unreadCount: 0, createDate: '2026-10-12T00:00:00Z' }
const PROMPTS = [
'Which of my open orders have been waiting on a customer reply for more than two weeks, and what was the last thing we said to them?',
'Summarize last quarter by region and flag anything that moved more than ten percent',
'Show me every record I changed this week',
]
/**
* Mocks the assistant routes with three long example prompts and one saved conversation.
*
* @param page - The page.
*/
async function mockAssistant(page: Page) {
const json = (route: Route, body: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
await page.route('**/qqq/v1/version', (route) => json(route, { apiVersion: '1.4.0', features: [], extensions: [{ name: 'assistant', version: '1.0.0' }] }))
await page.route(/\/qqq\/v1\/metaData(\?|$)/, (route) => json(route, { ...METADATA, supplementalInstanceMetaData: { assistant: {} } }))
await page.route('**/qqq/v1/assistant/**', (route) => {
const path = new URL(route.request().url()).pathname.replace('/qqq/v1/assistant', '')
if (path === '/config') return json(route, { enabled: true, shortcut: 'mod+j', model: 'claude-sonnet-5-5', features: { writes: false }, profile: { starterPrompts: PROMPTS } })
if (path === '/conversations') return json(route, { conversations: [CONVERSATION] })
return json(route, { error: 'not mocked' }, 404)
})
}
for (const viewport of VIEWPORTS) {
test(`assistant panel chrome and example prompts at ${viewport.name}px`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height })
await setupApiMocks(page)
await mockAssistant(page)
await page.goto('/app/person')
await page.waitForSelector('[role="status"][aria-label="Loading"]', { state: 'hidden', timeout: 30000 }).catch(() => null)
await expect(page.locator('[data-qqq-id="main-content"]')).toBeVisible({ timeout: 30000 })
if (viewport.width < 640) {
// compact header: the assistant opens from the account menu
await page.locator('[data-qqq-id="button-account"]').click()
await page.getByRole('menuitem', { name: 'Ask the assistant' }).click()
} else {
await page.locator('[data-qqq-id="button-assistant"]').click()
}
const panel = page.locator('[data-qqq-id="assistant-panel"]')
await expect(panel).toBeVisible()
const starters = panel.locator('[data-qqq-id="assistant-starters"]')
await expect(starters.getByRole('button')).toHaveCount(3)
// nothing clipped: each chip's text box fits inside the chip, and the chips fit inside the list
const clipped = await starters.evaluate((list) => {
const bad: string[] = []
const listBox = list.getBoundingClientRect()
for (const chip of Array.from(list.querySelectorAll('button'))) {
const box = chip.getBoundingClientRect()
if (chip.scrollWidth > chip.clientWidth + 1 || chip.scrollHeight > chip.clientHeight + 1) bad.push('overflow')
if (box.left < listBox.left - 1 || box.right > listBox.right + 1) bad.push('outside')
}
return bad
})
expect(clipped).toEqual([])
expect(await page.evaluate(() => document.scrollingElement!.scrollWidth <= window.innerWidth + 1)).toBe(true)
expect(await panel.locator('[data-qqq-id="assistant-messages"]').evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true)
if (SCREENSHOT_DIR) await page.screenshot({ path: `${SCREENSHOT_DIR}/empty-${viewport.name}.png` })
// history rail open
await panel.locator('[data-qqq-id="assistant-rail-toggle"]').click()
await expect(panel.getByRole('button', { name: 'Active people', exact: true })).toBeVisible()
await panel.locator('[data-qqq-id="assistant-rail-toggle"]').focus()
await page.waitForTimeout(600)
if (SCREENSHOT_DIR) await page.screenshot({ path: `${SCREENSHOT_DIR}/history-${viewport.name}.png` })
await checkA11y(page)
})
}
+6 -3
View File
@@ -49,10 +49,13 @@ it('keeps the variant build, static assets and entrypoint after the main build d
expect(readFileSync(path.join(staged, 'public/icon.svg'), 'utf8')).toBe('<svg/>')
expect(readFileSync(path.join(staged, 'qqq-server.mjs'), 'utf8')).toBe('container entrypoint')
expect(readFileSync(path.join(staged, 'branding-assets.mjs'), 'utf8')).toBe('declared branding aliases')
expect(readlinkSync(path.join(staged, 'node_modules/example'))).toBe('.pnpm/example')
expect(readlinkSync(path.join(staged, 'node_modules/example'))).toBe(path.join('.pnpm', 'example'))
expect(readFileSync(path.join(staged, 'node_modules/example/index.js'), 'utf8')).toBe('independent dependency')
expect(statSync(path.join(staged, 'server.js')).mode & 0o777).toBe(0o600)
expect(statSync(path.join(staged, '.next/static')).mode & 0o777).toBe(0o700)
// Windows does not implement POSIX owner/group/other permission bits.
if (process.platform !== 'win32') {
expect(statSync(path.join(staged, 'server.js')).mode & 0o777).toBe(0o600)
expect(statSync(path.join(staged, '.next/static')).mode & 0o777).toBe(0o700)
}
})
it('rejects an old shared-backend rewrite even with a new valid variant marker', () => {