Files
goodbuddy/src/renderer/src/ProjectActivity.tsx
T
mesalogo c8f327fc4e perf: reduce streaming render cost and refresh activity page on demand
- Segment streaming Markdown so completed blocks are reused and only the growing tail is re-parsed; fall back to whole-document parsing for document-wide constructs
- Memoize conversation sidebar rows and cache per-conversation derived activity, ordering and pending approvals
- Activity page loads on entry, refreshes every 15 s while visible, and has a single header refresh button
- Include pending settings, model download source, device sharing and knowledge gateway changes in the working tree
- Add Electron streaming input benchmark and regression tests
2026-10-01 21:49:09 +08:00

298 lines
13 KiB
TypeScript

import { Check, ChevronDown, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle } from 'lucide-react'
import { memo, useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal, flushSync } from 'react-dom'
import { useTranslation } from 'react-i18next'
import type { ConversationActivity } from './conversation-activity'
import type { AssistantProject } from '../../shared/assistant-contracts'
import './project-activity.css'
export type ProjectActivityProps = {
activities: ConversationActivity[]
projects?: readonly AssistantProject[]
visible?: boolean
onOpenConversation: (id: string) => void
}
export type ProjectActivityCountsProps = { running: number; attention: number; completed?: number }
export function ProjectActivityCounts({
running,
attention,
completed = 0
}: ProjectActivityCountsProps): React.JSX.Element | null {
const { t } = useTranslation('workspace')
if (!running && !attention && !completed) return null
return (
<span className="project-activity__counts">
{attention > 0 && (
<span className="project-activity__attention">
<CircleAlert aria-hidden="true" size={13} />
{t('projectActivity.attentionCount', { count: attention })}
</span>
)}
{' '}
{running > 0 && (
<span className="project-activity__running">
<LoaderCircle aria-hidden="true" size={13} />
{t('projectActivity.runningCount', { count: running })}
</span>
)}
{completed > 0 && <>{' '}
<span className="project-activity__completed">
<Check aria-hidden="true" size={13} />
{t('projectActivity.completedCount', { count: completed })}
</span>
</>}
</span>
)
}
function ActivityMenu({
activities,
projects,
anchorRef,
id,
onOpenConversation,
onClose
}: ProjectActivityProps & { anchorRef: React.RefObject<HTMLButtonElement | null>; id: string; onClose: () => void }): React.JSX.Element {
const { t } = useTranslation('workspace')
const menuRef = useRef<HTMLDivElement>(null)
const projectRef = useRef<HTMLDivElement>(null)
const sessionsRef = useRef<HTMLDivElement>(null)
const [selected, setSelected] = useState<string | null>(null)
const groups = useMemo(() => {
const priority = { attention: 0, approval: 0, question: 0, running: 1, completed: 2 }
const rows = new Map<string, { id: string; name: string; rows: ConversationActivity[] }>()
// Keep source project order within local, SSH Host and channel groups.
const ordered = new Map<string, AssistantProject[]>()
for (const project of projects ?? []) {
const key = project.kind === 'channel' ? 'channel' : project.executionSpace.kind === 'ssh'
? `ssh:${project.executionSpace.hostId}` : 'local'
const group = ordered.get(key) ?? []
group.push(project)
ordered.set(key, group)
}
const keys = [...ordered.keys()].sort((a, b) =>
(a === 'local' ? 0 : a === 'channel' ? 2 : 1) - (b === 'local' ? 0 : b === 'channel' ? 2 : 1))
for (const key of keys) for (const project of ordered.get(key)!) {
rows.set(project.id, { id: project.id, name: project.name, rows: [] })
}
for (const activity of activities) {
const key = activity.projectId ?? ''
const group = rows.get(key) ?? { id: key, name: activity.projectName, rows: [] }
group.rows.push(activity)
rows.set(key, group)
}
return [...rows.values()].filter((group) => group.rows.length).map((group) => ({
...group,
rows: group.rows.sort((a, b) => priority[a.status] - priority[b.status]),
running: group.rows.filter((row) => row.status === 'running').length,
completed: group.rows.filter((row) => row.status === 'completed').length
}))
}, [activities, projects])
const active = groups.find((group) => group.id === selected)
if (selected !== null && !active) setSelected(null)
const position = useEffectEvent(() => {
const menu = menuRef.current
if (!menu || !anchorRef.current) return
const rect = anchorRef.current.getBoundingClientRect()
const panelWidth = Math.min(264, window.innerWidth - 32)
const baseLeft = Math.max(16, Math.min(rect.left, window.innerWidth - panelWidth - 16))
const compact = window.innerWidth < 620 || (
baseLeft + panelWidth * 2 > window.innerWidth - 16 && baseLeft < panelWidth + 16
)
const leftward = !compact && baseLeft + panelWidth * 2 > window.innerWidth - 16 && baseLeft >= panelWidth + 16
menu.dataset.compact = String(compact)
menu.dataset.left = String(leftward)
menu.style.width = `${panelWidth}px`
menu.style.left = `${baseLeft}px`
menu.style.top = `${Math.max(16, Math.min(rect.bottom + 4, window.innerHeight - menu.offsetHeight - 16))}px`
const sessions = sessionsRef.current
if (sessions) {
sessions.style.width = `${panelWidth}px`
if (compact) {
sessions.style.left = ''
sessions.style.top = ''
} else {
const row = projectRef.current?.querySelector<HTMLButtonElement>('[aria-expanded="true"]')
sessions.style.left = `${baseLeft + (leftward ? -panelWidth : panelWidth)}px`
sessions.style.top = `${Math.max(16, Math.min(row?.getBoundingClientRect().top ?? rect.bottom + 4, window.innerHeight - sessions.offsetHeight - 16))}px`
}
}
if (compact && active && projectRef.current?.contains(document.activeElement)) {
sessionsRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus()
}
})
useLayoutEffect(() => { position() })
const back = (): void => {
const index = groups.findIndex((group) => group.id === selected)
flushSync(() => setSelected(null))
projectRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')[Math.max(0, index)]?.focus()
}
const enter = (key: string): void => {
flushSync(() => setSelected(key))
sessionsRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus()
}
const handleKeyDown = useEffectEvent((event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
if (active) back()
else onClose()
return
}
if (!menuRef.current?.contains(event.target as Node)) return
if (event.key === 'Tab') { flushSync(onClose); return }
if (event.key === 'ArrowLeft' && active) {
event.preventDefault()
back()
return
}
const projectButtons = projectRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')
const projectIndex = Array.from(projectButtons ?? []).indexOf(document.activeElement as HTMLButtonElement)
if (event.key === 'ArrowRight' && projectIndex >= 0) {
event.preventDefault()
enter(groups[projectIndex]!.id)
return
}
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const buttons = Array.from((projectIndex >= 0 ? projectRef : sessionsRef).current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [])
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1
: (index + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length
buttons[next]?.focus()
})
const dismiss = useEffectEvent(onClose)
useEffect(() => {
const anchor = anchorRef.current
const menu = menuRef.current
const initialFocus = projectRef.current?.querySelector<HTMLButtonElement>('button') ?? menu
initialFocus?.focus()
const onKeyDown = (event: KeyboardEvent): void => handleKeyDown(event)
const outside = (event: Event): void => {
if (!menuRef.current?.contains(event.target as Node) && !anchor?.contains(event.target as Node)) dismiss()
}
const reposition = (): void => position()
document.addEventListener('keydown', onKeyDown, true)
document.addEventListener('pointerdown', outside)
document.addEventListener('focusin', outside)
window.addEventListener('resize', reposition)
window.addEventListener('scroll', reposition, true)
return () => {
document.removeEventListener('keydown', onKeyDown, true)
document.removeEventListener('pointerdown', outside)
document.removeEventListener('focusin', outside)
window.removeEventListener('resize', reposition)
window.removeEventListener('scroll', reposition, true)
if (document.activeElement === document.body || menu?.contains(document.activeElement)) {
anchor?.focus()
}
}
}, [anchorRef])
useLayoutEffect(() => {
if (document.activeElement === document.body) {
const fallback = (active ? sessionsRef : projectRef).current?.querySelector<HTMLButtonElement>('[role="menuitem"]') ?? menuRef.current
fallback?.focus()
}
}, [groups, active])
return createPortal(
<div className="project-activity__menu" data-submenu={Boolean(active)} ref={menuRef} id={id}
role="menu" aria-label={t('projectActivity.title')} tabIndex={-1}>
<div className="project-activity__projects" ref={projectRef} role="presentation">
<h3>{t('projectActivity.title')}</h3>
{!groups.length && <p>{t('projectActivity.empty')}</p>}
{groups.map((group) => (
<button key={group.id} type="button" role="menuitem" tabIndex={-1}
className="project-activity__row" aria-haspopup="menu" aria-expanded={selected === group.id}
aria-controls={selected === group.id ? `${id}-sessions` : undefined}
onPointerEnter={(event) => {
if (event.pointerType === 'mouse' && menuRef.current?.dataset.compact !== 'true') setSelected(group.id)
}}
onClick={() => enter(group.id)}>
<span className="project-activity__identity"><span>{group.name}</span>{' '}
<ProjectActivityCounts running={group.running} completed={group.completed}
attention={group.rows.length - group.running - group.completed} />
</span><ChevronRight aria-hidden="true" size={14} />
</button>
))}
</div>
{active && <div className="project-activity__sessions" ref={sessionsRef} id={`${id}-sessions`}
role="menu" aria-label={active.name}>
<button className="project-activity__back project-activity__row" type="button" onClick={back}>
<ChevronLeft aria-hidden="true" size={14} />{t('projectActivity.back')}
</button>
<h3>{active.name}</h3>
{active.rows.map((activity) => <button key={activity.conversationId} type="button" role="menuitem" tabIndex={-1}
className="project-activity__row" onClick={() => {
flushSync(onClose)
onOpenConversation(activity.conversationId)
}}>
<span className="project-activity__identity"><span>{activity.title}</span>{' '}
<small className={`project-activity__${activity.status === 'completed' ? 'completed' : activity.status === 'running' ? 'running' : 'attention'}`}>
{activity.status === 'completed' && <Check aria-hidden="true" size={13} />}
{t(`projectActivity.status.${activity.status}`)}
</small>
</span>
</button>)}
</div>}
</div>,
document.body
)
}
// Memoized: App re-renders on every streaming delta and keystroke, while the
// activity summary it passes keeps its identity unless an activity changes.
export const ProjectActivity = memo(function ProjectActivity({
activities,
projects,
visible = true,
onOpenConversation
}: ProjectActivityProps): React.JSX.Element | null {
const { t } = useTranslation('workspace')
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const id = useId()
if ((!visible || activities.length === 0) && open) setOpen(false)
const running = useMemo(() => activities.filter((activity) => activity.status === 'running').length, [activities])
const completed = useMemo(() => activities.filter((activity) => activity.status === 'completed').length, [activities])
const attention = activities.length - running - completed
if (activities.length === 0) return null
return (
<>
<button
aria-expanded={open}
aria-haspopup="menu"
aria-controls={open ? id : undefined}
aria-label={`${t('projectActivity.title')}: ${[t('projectActivity.attentionCount', { count: attention }), t('projectActivity.runningCount', { count: running }), ...(completed > 0 ? [t('projectActivity.completedCount', { count: completed })] : [])].join(', ')}`}
ref={triggerRef}
className="project-activity__summary"
onClick={(event) => {
event.currentTarget.focus()
setOpen(!open)
}}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
setOpen(true)
}
}}
type="button"
>
<ProjectActivityCounts attention={attention} running={running} completed={completed} />
<ChevronDown aria-hidden="true" size={14} />
</button>
{open && visible && (
<ActivityMenu
activities={activities}
projects={projects}
anchorRef={triggerRef}
id={id}
onClose={() => setOpen(false)}
onOpenConversation={onOpenConversation}
/>
)}
</>
)
})