fix: restore supervision feedback sidebar layout
Replace unstyled full-width pin and refresh controls with shared icon buttons in the feedback header. Show conversation and task titles instead of raw IDs, with responsive spacing and keyboard focus coverage. Release note: 修复任务中心监督反馈按钮样式和布局,显示会话或任务名称,并改善窄侧栏下的阅读与操作体验。
This commit is contained in:
@@ -151,6 +151,7 @@ function sidebarElement({
|
||||
workspaceProjectId,
|
||||
restoreFocusRef,
|
||||
activeConversationId,
|
||||
conversationTitles = new Map(),
|
||||
supervisionEnabled,
|
||||
supervisionLibraries,
|
||||
conversationStats,
|
||||
@@ -191,6 +192,7 @@ function sidebarElement({
|
||||
workspaceProjectId?: string
|
||||
restoreFocusRef?: { current: HTMLElement | null }
|
||||
activeConversationId?: string
|
||||
conversationTitles?: ReadonlyMap<string, string>
|
||||
supervisionEnabled?: boolean
|
||||
supervisionLibraries?: React.ComponentProps<typeof RightAssistantSidebar>['supervisionLibraries']
|
||||
conversationStats?: React.ComponentProps<typeof RightAssistantSidebar>['conversationStats']
|
||||
@@ -221,7 +223,7 @@ function sidebarElement({
|
||||
browserStates={browserStates}
|
||||
schedules={[]}
|
||||
tasks={tasks}
|
||||
conversationTitles={new Map()}
|
||||
conversationTitles={conversationTitles}
|
||||
currentProject={activeProject ?? undefined}
|
||||
projectNames={new Map()}
|
||||
onCreateCustomTask={onCreateCustomTask}
|
||||
@@ -286,15 +288,37 @@ it.each([false, undefined])('does not mount or query supervision when enabled is
|
||||
it('pins the supervision target across conversation switches and restores following when unpinned', async () => {
|
||||
const overview = vi.fn(async () => [])
|
||||
Object.assign(window.goodbuddy, { supervision: { overview } })
|
||||
const view = render(sidebarElement({ activeConversationId: 'A', supervisionEnabled: true }))
|
||||
const conversationTitles = new Map([['A', '会话甲'], ['B', '会话乙']])
|
||||
const view = render(sidebarElement({ activeConversationId: 'A', conversationTitles, supervisionEnabled: true }))
|
||||
expect(screen.getByText('会话:会话甲')).toBeVisible()
|
||||
const card = within(screen.getByRole('region', { name: '监督反馈' }))
|
||||
expect(card.getByRole('button', { name: '固定监督目标' })).toHaveAttribute('aria-pressed', 'false')
|
||||
await waitFor(() => expect(overview).toHaveBeenLastCalledWith({ target: { type: 'conversation', conversationId: 'A' } }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '固定监督目标' }))
|
||||
view.rerender(sidebarElement({ activeConversationId: 'B', supervisionEnabled: true }))
|
||||
view.rerender(sidebarElement({ activeConversationId: 'B', conversationTitles, supervisionEnabled: true }))
|
||||
expect(screen.getByText('已固定 · 会话:会话甲')).toBeVisible()
|
||||
expect(card.getByRole('button', { name: '取消固定监督目标' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: '取消固定监督目标' })).toBeInTheDocument()
|
||||
expect(overview).toHaveBeenLastCalledWith({ target: { type: 'conversation', conversationId: 'A' } })
|
||||
expect(localStorage.getItem('goodbuddy.workbar-layout.v1')).toContain('"conversationId":"A"')
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消固定监督目标' }))
|
||||
await waitFor(() => expect(overview).toHaveBeenLastCalledWith({ target: { type: 'conversation', conversationId: 'B' } }))
|
||||
expect(screen.getByText('会话:会话乙')).toBeVisible()
|
||||
})
|
||||
|
||||
it('shows the task name while following and pinned, without displaying its raw ID', async () => {
|
||||
const overview = vi.fn(async () => [])
|
||||
Object.assign(window.goodbuddy, { supervision: { overview } })
|
||||
const tasks = [{ id: 'task-uuid', title: '核对交付清单', conversationId: 'A' }] as AssistantTask[]
|
||||
const view = render(sidebarElement({ tasks, selectedTaskId: 'task-uuid', supervisionEnabled: true }))
|
||||
expect(screen.getByText('任务:核对交付清单')).toHaveAttribute('title', 'task-uuid')
|
||||
fireEvent.click(screen.getByRole('button', { name: '固定监督目标' }))
|
||||
view.rerender(sidebarElement({ tasks, activeConversationId: 'B', supervisionEnabled: true }))
|
||||
expect(screen.getByText('已固定 · 任务:核对交付清单')).toBeVisible()
|
||||
const callsBeforeRefresh = overview.mock.calls.length
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新监督回顾' }))
|
||||
await waitFor(() => expect(overview).toHaveBeenCalledTimes(callsBeforeRefresh + 1))
|
||||
await waitFor(() => expect(overview).toHaveBeenLastCalledWith({ target: { type: 'task', taskId: 'task-uuid' } }))
|
||||
})
|
||||
|
||||
it.each(['cancel', 'failure'] as const)('retries supervision knowledge preview after %s and shows returned content', async (outcome) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FileText,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Pin,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldAlert,
|
||||
@@ -216,6 +217,9 @@ type SupervisionCardProps = {
|
||||
target?: SupervisionTarget
|
||||
activeConversationId?: string
|
||||
conversationTitle?: string
|
||||
taskTitle?: string
|
||||
pinned?: boolean
|
||||
onTogglePinned?: () => void
|
||||
supervisionLibraries?: KnowledgeLibrary[]
|
||||
onContinueSupervision?: (prompt: string, conversationId: string) => Promise<void>
|
||||
onOpenSupervisionConversation?: (conversationId: string) => void
|
||||
@@ -229,6 +233,9 @@ function SupervisionCardContent({
|
||||
target,
|
||||
activeConversationId,
|
||||
conversationTitle,
|
||||
taskTitle,
|
||||
pinned = false,
|
||||
onTogglePinned,
|
||||
supervisionLibraries: libraries,
|
||||
onContinueSupervision: onContinue,
|
||||
onOpenSupervisionGraph,
|
||||
@@ -310,9 +317,14 @@ function SupervisionCardContent({
|
||||
}
|
||||
|
||||
return <section className="assistant-sidebar__section supervision-card" aria-label="监督反馈">
|
||||
<h3>监督反馈</h3>
|
||||
{target && <p>{target.type === 'conversation' ? `会话:${conversationTitle ?? target.conversationId}` : `任务:${target.taskId}`}</p>}
|
||||
<button type="button" className="link-button" disabled={!target || loading} onClick={() => { setResult(undefined); setSource(undefined); setMessage(''); setRefresh((value) => value + 1) }}>刷新监督回顾</button>
|
||||
<div className="supervision-card__header">
|
||||
<h3>监督反馈</h3>
|
||||
<div className="supervision-card__actions">
|
||||
{onTogglePinned && <button type="button" className={`icon-button${pinned ? ' icon-button--active' : ''}`} aria-label={pinned ? '取消固定监督目标' : '固定监督目标'} title={pinned ? '取消固定监督目标' : '固定监督目标'} aria-pressed={pinned} disabled={!target} onClick={onTogglePinned}><Pin aria-hidden="true" size={14} /></button>}
|
||||
<button type="button" className="icon-button" aria-label="刷新监督回顾" title="刷新监督回顾" disabled={!target || loading} onClick={() => { setResult(undefined); setSource(undefined); setMessage(''); setRefresh((value) => value + 1) }}><RefreshCw aria-hidden="true" size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{target && <p className="supervision-card__target" title={target.type === 'conversation' ? target.conversationId : target.taskId}>{pinned ? '已固定 · ' : ''}{target.type === 'conversation' ? `会话:${conversationTitle?.trim() || '未命名会话'}` : `任务:${taskTitle?.trim() || '未命名任务'}`}</p>}
|
||||
{!result ? <p className="assistant-sidebar__empty">{message || '暂无监督回顾'}</p> : <>
|
||||
<p>{result.summary || '监督回顾没有摘要'}</p>
|
||||
<p>{result.scope.kind === 'global' ? '全局回顾' : `项目范围:${result.scope.projectIds.join('、')}`} · {new Date(result.timeRange.from).toLocaleString()} – {new Date(result.timeRange.to).toLocaleString()}</p>
|
||||
@@ -2071,19 +2083,18 @@ export function RightAssistantSidebar({
|
||||
{instance.appId === 'tasks' && (
|
||||
<section className="assistant-sidebar__section task-center">
|
||||
{supervisionEnabled && <>
|
||||
<button type="button" className="link-button" disabled={!instance.targetRef && !selectedTaskId && !activeConversationId}
|
||||
onClick={() => setWorkbarInstances((current) => current.map((item) => item.id === instance.id
|
||||
? { ...item, targetRef: item.targetRef ? undefined : selectedTaskId ? { type: 'task', taskId: selectedTaskId } : activeConversationId ? { type: 'conversation', conversationId: activeConversationId } : undefined }
|
||||
: item))}>
|
||||
{instance.targetRef ? '取消固定监督目标' : '固定监督目标'}
|
||||
</button>
|
||||
<SupervisionCard
|
||||
pinned={Boolean(instance.targetRef)}
|
||||
onTogglePinned={() => setWorkbarInstances((current) => current.map((item) => item.id === instance.id
|
||||
? { ...item, targetRef: item.targetRef ? undefined : selectedTaskId ? { type: 'task', taskId: selectedTaskId } : activeConversationId ? { type: 'conversation', conversationId: activeConversationId } : undefined }
|
||||
: item))}
|
||||
target={instance.targetRef?.type === 'conversation' || instance.targetRef?.type === 'task'
|
||||
? instance.targetRef
|
||||
: selectedTaskId ? { type: 'task', taskId: selectedTaskId }
|
||||
: activeConversationId ? { type: 'conversation', conversationId: activeConversationId } : undefined}
|
||||
activeConversationId={instance.targetRef?.type === 'conversation' ? instance.targetRef.conversationId : instance.targetRef?.type === 'task' ? tasks.find((task) => instance.targetRef?.type === 'task' && task.id === instance.targetRef.taskId)?.conversationId : activeConversationId}
|
||||
conversationTitle={instance.targetRef?.type === 'conversation' ? conversationTitles.get(instance.targetRef.conversationId) : undefined}
|
||||
conversationTitle={conversationTitles.get(instance.targetRef?.type === 'conversation' ? instance.targetRef.conversationId : instance.targetRef?.type === 'task' ? tasks.find((task) => instance.targetRef?.type === 'task' && task.id === instance.targetRef.taskId)?.conversationId ?? '' : activeConversationId ?? '')}
|
||||
taskTitle={tasks.find((task) => task.id === (instance.targetRef?.type === 'task' ? instance.targetRef.taskId : selectedTaskId))?.title}
|
||||
supervisionLibraries={supervisionLibraries}
|
||||
onOpenSupervisionGraph={onOpenSupervisionGraph}
|
||||
onContinueSupervision={onContinueSupervision}
|
||||
|
||||
@@ -6,6 +6,17 @@ import i18n from './i18n'
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); window.goodbuddy = {} as never })
|
||||
const result = (id: string) => ({ id, storyLineId: id, sourceId: `source-${id}`, summary: `${id} recap`, scope: { kind: 'global' }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-22T00:00:00Z' } })
|
||||
|
||||
it('keeps unknown target IDs out of visible copy and disables actions without a target', async () => {
|
||||
window.goodbuddy = { supervision: { overview: async () => [] } } as never
|
||||
const view = render(<SupervisionCard target={{ type: 'conversation', conversationId: 'raw-uuid' }} onTogglePinned={vi.fn()} />)
|
||||
expect(screen.getByText('会话:未命名会话')).toHaveAttribute('title', 'raw-uuid')
|
||||
expect(screen.queryByText(/raw-uuid/)).not.toBeInTheDocument()
|
||||
await act(async () => {})
|
||||
view.rerender(<SupervisionCard onTogglePinned={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: '固定监督目标' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '刷新监督回顾' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('graph navigation exposes only the matched result through a native bilingual button', async () => {
|
||||
const open = vi.fn()
|
||||
const overview = vi.fn().mockResolvedValueOnce([result('pinned')]).mockResolvedValueOnce([])
|
||||
|
||||
@@ -1,3 +1,37 @@
|
||||
.assistant-sidebar__section.supervision-card {
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-body);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.supervision-card__header,
|
||||
.supervision-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.supervision-card__header { justify-content: space-between; }
|
||||
.supervision-card__actions { flex-wrap: wrap; }
|
||||
.supervision-card__header > .supervision-card__actions { flex: 0 0 auto; }
|
||||
.supervision-card .supervision-card__header h3,
|
||||
.supervision-card p { margin: 0; }
|
||||
.supervision-card__target { color: var(--text-secondary); }
|
||||
.supervision-card__source {
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
.supervision-card__actions select { min-width: 0; max-width: 100%; }
|
||||
.supervision-card .icon-button:disabled {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.task-center .task-center__scope {
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -8,7 +8,9 @@ import { createServer } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it('renders the production supervisor at desktop, narrow, and mobile widths', async () => {
|
||||
it(process.env.GOODBUDDY_SUPERVISOR_SIDEBAR
|
||||
? 'renders the production supervision card at narrow widths in both themes'
|
||||
: 'renders the production supervisor at desktop, narrow, and mobile widths', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-supervisor-'))
|
||||
const css = await readFile(
|
||||
'src/renderer/src/supervisor-workspace.css',
|
||||
@@ -45,7 +47,7 @@ it('renders the production supervisor at desktop, narrow, and mobile widths', as
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
GOODBUDDY_SUPERVISOR_URL:
|
||||
server.resolvedUrls!.local[0] + 'supervisor.html',
|
||||
server.resolvedUrls!.local[0] + 'supervisor.html' + (process.env.GOODBUDDY_SUPERVISOR_SIDEBAR ? '?sidebar=1' : ''),
|
||||
GOODBUDDY_SUPERVISOR_DIRECTORY: directory,
|
||||
GOODBUDDY_SUPERVISOR_TOKENS: JSON.stringify([
|
||||
...new Set(
|
||||
|
||||
@@ -61,6 +61,51 @@ app
|
||||
await settle()
|
||||
}
|
||||
const reports = []
|
||||
if (process.env.GOODBUDDY_SUPERVISOR_SIDEBAR) {
|
||||
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL)
|
||||
await wait('!!document.querySelector(".supervision-card")')
|
||||
await js('document.fonts.ready')
|
||||
win.show()
|
||||
win.focus()
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await js(`document.documentElement.dataset.theme = '${theme}'`)
|
||||
for (const width of [480, 300, 200]) {
|
||||
win.setContentSize(width, 640)
|
||||
await js('document.querySelector(".supervision-card__header button").focus()')
|
||||
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Tab' })
|
||||
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Tab' })
|
||||
await settle()
|
||||
const report = await js(`(() => {
|
||||
const card = document.querySelector('.supervision-card');
|
||||
const header = card.querySelector('.supervision-card__header');
|
||||
const buttons = [...header.querySelectorAll('button')];
|
||||
return {
|
||||
width: innerWidth, scrollWidth: document.documentElement.scrollWidth,
|
||||
cardWidth: card.clientWidth, cardScrollWidth: card.scrollWidth,
|
||||
headerHeight: header.getBoundingClientRect().height,
|
||||
buttons: buttons.map(b => ({ width: b.getBoundingClientRect().width,
|
||||
height: b.getBoundingClientRect().height, background: getComputedStyle(b).backgroundColor })),
|
||||
focus: getComputedStyle(document.activeElement).outlineStyle,
|
||||
text: card.textContent
|
||||
};
|
||||
})()`)
|
||||
assert(report.scrollWidth <= report.width, `Page overflow: ${JSON.stringify(report)}`)
|
||||
assert(report.cardScrollWidth <= report.cardWidth, 'Card overflow')
|
||||
assert.equal(report.headerHeight, 34)
|
||||
assert(report.buttons.every(b => b.width === 34 && b.height === 34), 'Compact shared buttons')
|
||||
assert.equal(report.focus, 'solid')
|
||||
assert(!report.text.includes('simulated-conversation-uuid'), 'Raw UUID visible')
|
||||
reports.push({ theme, ...report })
|
||||
await writeFile(join(artifacts, `sidebar-${theme}-${width}.png`), (await win.webContents.capturePage()).toPNG())
|
||||
}
|
||||
}
|
||||
assert.deepEqual(errors, [])
|
||||
await writeFile(join(artifacts, 'sidebar-measurements.json'), JSON.stringify(reports, null, 2))
|
||||
console.log(JSON.stringify({ artifacts, cases: reports.length, errors }))
|
||||
win.destroy()
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
await open(false)
|
||||
await js(
|
||||
'document.querySelector(".supervisor-workspace__legend .link-button").click()'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Simulated visual fixture only. No preload, user database, or model is used.
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { HeartbeatCenter } from '../../src/renderer/src/HeartbeatCenter'
|
||||
import { SupervisionCard } from '../../src/renderer/src/RightAssistantSidebar'
|
||||
import { PageShell } from '../../src/renderer/src/WorkspacePrimitives'
|
||||
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
|
||||
import type { SupervisionGraphView } from '../../src/shared/supervision-contracts'
|
||||
@@ -202,6 +203,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
color: 'var(--text-secondary)',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
overflowWrap: 'anywhere',
|
||||
gap: 'var(--space-3)'
|
||||
}}
|
||||
>
|
||||
@@ -212,7 +214,14 @@ createRoot(document.getElementById('root')!).render(
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<PageShell variant="supervisor">
|
||||
{params.has('sidebar') ? <div className="assistant-sidebar__section task-center">
|
||||
<SupervisionCard
|
||||
target={{ type: 'conversation', conversationId: 'simulated-conversation-uuid' }}
|
||||
conversationTitle={'模拟会话:核对项目交付清单与阶段回顾'.repeat(3)}
|
||||
pinned
|
||||
onTogglePinned={() => {}}
|
||||
/>
|
||||
</div> : <PageShell variant="supervisor">
|
||||
<HeartbeatCenter
|
||||
configs={[]}
|
||||
runs={[]}
|
||||
@@ -231,7 +240,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
onUseFollowUpTask={() => {}}
|
||||
onRetryLoad={noop}
|
||||
/>
|
||||
</PageShell>
|
||||
</PageShell>}
|
||||
</div>
|
||||
</UiLocaleProvider>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user