Files
goodbuddy/src/renderer/src/RightAssistantSidebar.tsx
T

2610 lines
95 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowLeft,
ArrowRight,
ChevronLeft,
ChevronRight,
CircleAlert,
Copy,
ExternalLink,
Maximize2,
Minimize2,
Pin,
Plus,
RefreshCw,
ShieldAlert,
Square
} from 'lucide-react'
import {
lazy,
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
memo
} from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantSchedule,
AssistantTask,
AssistantProject,
WorkspaceChanges,
WorkspaceDirectoryListing,
WorkspaceFilePreview
} from '../../shared/assistant-contracts'
import type { KnowledgeLibrary } from '../../shared/contracts'
import type { SupervisionResultView, SupervisionTarget } from '../../shared/supervision-contracts'
import { MarkdownRenderer } from './MarkdownRenderer'
import type {
BrowserLiveState,
BrowserTabId
} from '../../shared/contracts'
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
import { SegmentedControl } from './WorkspacePrimitives'
import {
browserOverlaySelector,
isBrowserViewportOccluded
} from './browser-viewport-occlusion'
import {
TaskScheduleActions
} from './TaskScheduleActions'
import type {
TerminalSnapshot
} from '../../shared/terminal-contracts'
import {
WORKBAR_APP_DEFINITIONS,
WORKBAR_LIMITS,
normalizeWorkbarLayoutPreferences,
workbarLayoutPreferencesSchema,
type WorkbarAppDefinition,
type WorkbarTabInstance,
type WorkbarTargetRef
} from '../../shared/workbar-contracts'
import {
DEFAULT_WORKBAR_INSTANCES,
WorkbarShell,
type WorkbarInstanceCreateRequest
} from './WorkbarShell'
import type { TerminalAdapter } from './TerminalPanel'
import './terminal-panel.css'
import './task-center.css'
const TerminalPanel = lazy(async () => {
const module = await import('./TerminalPanel')
return { default: module.TerminalPanel }
})
export type AssistantSidebarTab =
| 'tasks'
| 'workspace'
| 'browser'
| 'notes'
export type SidebarArtifact = {
id: string
title: string
content: string
createdAt: number
mimeType: string
}
export type PendingSidebarApproval = {
conversationId: string
taskId?: string
projectId?: string
messageId: string
approvalId: string
title: string
description: string
toolName?: string
}
export type SidebarConversationStats = {
// Must match activeConversationId; late results for other conversations are ignored.
conversationId: string
title?: string
messageCount: number
// Authoritative cumulative reply time, excluding idle gaps. Never a lifecycle delta.
replyDurationMs: number
incomplete?: boolean
}
export type SidebarTaskDuration = {
durationMs: number
incomplete?: boolean
}
export type RightAssistantSidebarProps = {
notify?: (input: import('./notifications').AppNotificationInput) => void
notesEnabled?: boolean
notesSettingsReady?: boolean
notesOpenRequest?: number
notesPanel?: React.ReactNode
onBeforeCloseNotes?: () => Promise<boolean>
nativeTerminals?: readonly { terminal: TerminalSnapshot; focus: boolean }[]
open: boolean
tab: AssistantSidebarTab
approvals: PendingSidebarApproval[]
schedules: AssistantSchedule[]
tasks: AssistantTask[]
conversationTitles: ReadonlyMap<string, string>
projectNames: ReadonlyMap<string, string>
selectedTaskId?: string
workspaceChanges?: WorkspaceChanges
workspaceProjectId?: string
activeConversationId?: string
conversationStats?: SidebarConversationStats
taskDurations?: ReadonlyMap<string, SidebarTaskDuration>
browserStates?: Readonly<Record<string, Readonly<Record<string, BrowserLiveState>>>>
currentProject?: AssistantProject
supervisionEnabled?: boolean
supervisionLibraries?: KnowledgeLibrary[]
onOpenSupervisionGraph?: (resultId: string) => void
onContinueSupervision?: (prompt: string, conversationId: string) => Promise<void>
onOpenSupervisionConversation?: (conversationId: string) => void
restoreFocusRef?: { current: HTMLElement | null }
onBackBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onNavigateBrowser?: (conversationId: string, tabId: BrowserTabId, url: string) => Promise<void>
onReloadBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onStopLoadingBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onCreateCustomTask: () => void
onRefreshChanges: () => Promise<void>
onLoadWorkspaceDiff: (path: string) => Promise<WorkspaceChanges>
onListWorkspaceDirectory: (
path: string
) => Promise<WorkspaceDirectoryListing>
onLoadWorkspaceFile: (
path: string,
offsetBytes?: number
) => Promise<WorkspaceFilePreview>
onOpenWorkspaceEntry: (
path: string,
type: 'file' | 'directory'
) => Promise<void>
onRemoveSchedule: (scheduleId: string) => Promise<void>
onRunSchedule: (scheduleId: string) => Promise<void>
onSetScheduleEnabled: (
scheduleId: string,
enabled: boolean
) => Promise<void>
onOpenTask: (task: AssistantTask) => void
onTabChange: (tab: AssistantSidebarTab) => void
}
const tabIds: AssistantSidebarTab[] = [
'tasks',
'workspace',
'browser',
'notes'
]
const emptyChangedFiles: WorkspaceChanges['files'] = []
const defaultSidebarRatio = 0.3
const minimumPaneWidth = 160
const keyboardResizeStep = 16
const workbarStorageKey = 'goodbuddy.workbar-layout.v1'
function SidebarApproval({ approval }: {
approval: PendingSidebarApproval
}): React.JSX.Element {
const { t } = useTranslation('workspace')
return (
<article
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${approval.toolName ?? approval.title}`}
className="assistant-sidebar__approval"
>
<strong>{approval.title}</strong>
<p>{approval.description}</p>
{approval.toolName && <code>{approval.toolName}</code>}
</article>
)
}
type SupervisionCardProps = {
onOpenSupervisionGraph?: (resultId: string) => void
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
}
export function SupervisionCard(props: SupervisionCardProps): React.JSX.Element {
return <SupervisionCardContent key={`${JSON.stringify(props.target)}:${props.activeConversationId ?? ''}`} {...props} />
}
function SupervisionCardContent({
target,
activeConversationId,
conversationTitle,
taskTitle,
pinned = false,
onTogglePinned,
onContinueSupervision: onContinue,
onOpenSupervisionGraph,
onOpenSupervisionConversation: onOpenConversation
}: SupervisionCardProps): React.JSX.Element {
const { t } = useTranslation('heartbeat')
const [result, setResult] = useState<SupervisionResultView>()
const mounted = useRef(false)
const [refresh, setRefresh] = useState(0)
const [source, setSource] = useState<Record<string, unknown>>()
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [draft, setDraft] = useState<string>()
const busy = useRef(false)
const targetKey = target ? JSON.stringify(target) : undefined
useEffect(() => {
mounted.current = true
let current = true
if (!window.goodbuddy.supervision || !targetKey) return () => { mounted.current = false }
void window.goodbuddy.supervision.overview({ target: JSON.parse(targetKey) as SupervisionTarget }).then((items) => {
if (current) setResult(items[0])
}).catch((error: unknown) => {
if (current) setMessage(error instanceof Error ? error.message : '监督反馈读取失败')
})
return () => { current = false; mounted.current = false }
}, [targetKey, refresh])
const openSource = async (sourceId: string): Promise<void> => {
if (busy.current) return
busy.current = true
setLoading(true); setMessage('')
try {
if (!sourceId) throw new Error('监督结果没有可定位来源')
const context = await window.goodbuddy.supervision.sourceContext(sourceId)
if (!mounted.current) return
setSource(context)
} catch (error) {
if (mounted.current) setMessage(error instanceof Error ? error.message : '来源不可用')
} finally { busy.current = false; if (mounted.current) setLoading(false) }
}
const continueDiscussion = async (): Promise<void> => {
if (!result || !activeConversationId || !onContinue || busy.current) return
busy.current = true
setLoading(true); setMessage('')
try {
const sourceId = String(result.sourceId ?? '')
if (!sourceId) throw new Error('监督结果没有可定位来源')
const preview = await window.goodbuddy.supervision.continueContext({ sourceId, resultId: result.id })
if (!mounted.current) return
if (typeof preview.prompt !== 'string' || !preview.prompt.trim()) throw new Error(t('supervisor.discussion.empty'))
setDraft(preview.prompt)
} catch (error) { if (mounted.current) setMessage(error instanceof Error ? error.message : '继续讨论失败') }
finally { busy.current = false; if (mounted.current) setLoading(false) }
}
const sendDiscussion = async (): Promise<void> => {
if (!draft?.trim() || !activeConversationId || !onContinue || busy.current) return
busy.current = true
setLoading(true); setMessage('')
try {
await onContinue(draft, activeConversationId)
if (!mounted.current) return
setDraft(undefined)
} catch (error) { if (mounted.current) setMessage(error instanceof Error ? error.message : '继续讨论失败') }
finally { busy.current = false; if (mounted.current) setLoading(false) }
}
return <section className="assistant-sidebar__section supervision-card" aria-label="监督反馈">
<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); setDraft(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>
{result.createdAt && <small>{new Date(result.createdAt).toLocaleString()}</small>}
<div className="supervision-card__actions">
{onOpenSupervisionGraph && <button type="button" className="secondary-button" onClick={() => onOpenSupervisionGraph(result.id)}>{t('supervisor.viewInGraph')}</button>}
<button type="button" className="secondary-button" disabled={loading} onClick={() => void openSource(String((result as Record<string, unknown>).sourceId ?? ''))}>查看来源</button>
<button type="button" className="primary-button" disabled={loading || !activeConversationId || !onContinue || draft !== undefined} title={!activeConversationId ? '没有当前会话,无法继续讨论' : undefined} onClick={() => void continueDiscussion()}>{t('supervisor.discussion.start')}</button>
</div>
{source && <div className="supervision-card__source"><strong>{String(source.title ?? '来源')}</strong><p>{String(source.content ?? source.error ?? '')}</p>{source.error !== undefined && <small>{String(source.error)}</small>}
{source.contextType === 'conversation' && Boolean(source.conversationId) && onOpenConversation && <button type="button" className="secondary-button" disabled={loading} onClick={() => onOpenConversation(String(source.conversationId))}>{t('supervisor.openConversation')}</button>}
</div>}
{draft !== undefined && <div className="supervision-discussion-editor">
<p className="supervision-discussion-editor__target" title={activeConversationId}>{t('supervisor.discussion.target', { title: conversationTitle?.trim() || '未命名会话' })}</p>
<label className="field"><span>{t('supervisor.discussion.message')}</span><textarea rows={10} value={draft} disabled={loading} onChange={(event) => setDraft(event.target.value)} /></label>
<footer className="custom-task-dialog__actions">
<button type="button" className="secondary-button" disabled={loading} onClick={() => { setDraft(undefined); setMessage('') }}>{t('supervisor.cancel')}</button>
<button type="button" className="primary-button" disabled={loading || !draft.trim() || !activeConversationId || !onContinue} onClick={() => void sendDiscussion()}>{t(loading ? 'supervisor.discussion.sending' : 'supervisor.discussion.send')}</button>
</footer>
</div>}
{loading && <p role="status">{t('supervisor.discussion.loading')}</p>}
{message && <p role="status">{message}</p>}
</>}
</section>
}
function loadPersistedWorkbarLayout(): ReturnType<
typeof workbarLayoutPreferencesSchema.parse
> | undefined {
try {
const value = localStorage.getItem(workbarStorageKey)
if (!value || value.length > 100_000) {
return undefined
}
return normalizeWorkbarLayoutPreferences(
JSON.parse(value),
DEFAULT_WORKBAR_INSTANCES
)
} catch {
return undefined
}
}
function persistWorkbarLayout(
instances: readonly WorkbarTabInstance[],
activeInstanceId: string | null,
expanded: boolean,
widthRatio: number
): void {
try {
localStorage.setItem(
workbarStorageKey,
JSON.stringify(
workbarLayoutPreferencesSchema.parse({
instances,
activeInstanceId,
expanded,
dock: 'right',
widthRatio,
taskScope: 'current-project'
})
)
)
} catch {
// The in-memory workbar remains usable when browser storage is unavailable.
}
}
function getSidebarWidthLimits(layoutWidth: number): {
minimum: number
maximum: number
} {
const boundedLayoutWidth = Math.max(0, Math.floor(layoutWidth))
const minimum = Math.min(
minimumPaneWidth,
Math.floor(boundedLayoutWidth / 2)
)
return {
minimum,
maximum: Math.max(minimum, boundedLayoutWidth - minimum)
}
}
function clampSidebarWidth(width: number, layoutWidth: number): number {
const limits = getSidebarWidthLimits(layoutWidth)
return Math.min(limits.maximum, Math.max(limits.minimum, width))
}
function findWorkspaceSibling(
sidebar: HTMLElement | null
): HTMLElement | undefined {
const parent = sidebar?.parentElement
if (!parent) {
return undefined
}
return Array.from(parent.children).find(
(element): element is HTMLElement =>
element instanceof HTMLElement &&
element.classList.contains('workspace')
)
}
function measureSplitLayoutWidth(
sidebar: HTMLElement | null,
fallbackWidth: number
): number {
const workspace = findWorkspaceSibling(sidebar)
if (!sidebar || !workspace) {
return fallbackWidth
}
const layoutWidth = sidebar.parentElement?.getBoundingClientRect().width ?? 0
if (layoutWidth > 0) {
return layoutWidth
}
const measuredWidth =
workspace.getBoundingClientRect().width +
sidebar.getBoundingClientRect().width
return measuredWidth > 0 ? measuredWidth : fallbackWidth
}
function addDefaultBrowserProtocol(address: string): string {
const protocol = /^[a-z][a-z\d+.-]*:/iu.exec(address)?.[0]
if (
protocol &&
(/^https?:$/iu.test(protocol) ||
!/^\d+(?:[/?#]|$)/u.test(address.slice(protocol.length)))
) {
return address
}
return `http://${address}`
}
function BrowserToolbar({
activeConversationId,
browserState,
fullscreen,
onBack,
onToggleFullscreen,
onNavigate,
onReload,
onStopLoading
}: {
activeConversationId?: string
browserState?: BrowserLiveState
fullscreen: boolean
onBack: () => Promise<boolean>
onToggleFullscreen: () => void
onNavigate: (url: string) => Promise<boolean>
onReload: () => Promise<boolean>
onStopLoading: () => Promise<boolean>
}): React.JSX.Element {
const { t } = useTranslation('workspace')
const inputRef = useRef<HTMLInputElement>(null)
const actionPendingRef = useRef(false)
const [addressDraft, setAddressDraft] = useState(
browserState?.url ?? ''
)
const [editing, setEditing] = useState(false)
const [dirty, setDirty] = useState(false)
const [actionPending, setActionPending] = useState(false)
const authoritativeUrl = browserState?.url ?? ''
const hasBrowserState = browserState !== undefined
const displayedAddress =
hasBrowserState && !editing && !dirty
? authoritativeUrl
: addressDraft
const sessionActive = browserState?.sessionActive === true
const isLoading = browserState?.isLoading === true
const status = browserState?.status
const isCreating = status === 'creating'
const isActing = status === 'acting'
const isInteractive = status === 'interactive'
const operationBlocked =
actionPending ||
isCreating ||
status === 'loading' ||
isActing ||
isInteractive
const addressDisabled = operationBlocked || isLoading
const backDisabled =
operationBlocked ||
!sessionActive ||
browserState?.canGoBack !== true ||
addressDisabled
const refreshDisabled =
operationBlocked ||
!sessionActive ||
!authoritativeUrl ||
isLoading
const goDisabled =
actionPending ||
!activeConversationId ||
addressDisabled ||
displayedAddress.trim().length === 0
const runToolbarAction = async (
action: () => Promise<boolean>
): Promise<boolean> => {
if (actionPendingRef.current) {
return false
}
actionPendingRef.current = true
setActionPending(true)
try {
return await action()
} finally {
actionPendingRef.current = false
setActionPending(false)
}
}
const submitAddress = async (): Promise<void> => {
const address = displayedAddress.trim()
if (goDisabled || !address) {
return
}
const url = addDefaultBrowserProtocol(address)
setAddressDraft(url)
if (await runToolbarAction(() => onNavigate(url))) {
setDirty(false)
setEditing(false)
inputRef.current?.blur()
}
}
const refreshLabel = isLoading
? t('sidebar.browser.toolbar.stopLoading')
: t('sidebar.browser.toolbar.refresh')
return (
<form
aria-label={t('sidebar.browser.toolbar.ariaLabel')}
className="assistant-sidebar__browser-toolbar"
onSubmit={(event) => {
event.preventDefault()
void submitAddress()
}}
>
<button
aria-label={t('sidebar.browser.toolbar.back')}
className="secondary-button assistant-sidebar__browser-tool-button"
disabled={backDisabled}
onClick={() => void runToolbarAction(onBack)}
title={t('sidebar.browser.toolbar.back')}
type="button"
>
<ArrowLeft aria-hidden="true" size={14} />
</button>
<button
aria-label={refreshLabel}
className="secondary-button assistant-sidebar__browser-tool-button"
disabled={
isLoading
? !sessionActive || isInteractive
: refreshDisabled
}
onClick={() =>
void (isLoading
? onStopLoading()
: runToolbarAction(onReload))
}
title={refreshLabel}
type="button"
>
{isLoading ? (
<Square aria-hidden="true" size={12} />
) : (
<RefreshCw aria-hidden="true" size={14} />
)}
</button>
<label className="assistant-sidebar__browser-address">
<span className="sr-only">
{t('sidebar.browser.toolbar.address')}
</span>
<input
aria-label={t('sidebar.browser.toolbar.address')}
autoComplete="off"
disabled={addressDisabled}
onChange={(event) => {
setAddressDraft(event.target.value)
setDirty(true)
}}
onBlur={() => setEditing(false)}
onFocus={() => {
setAddressDraft(displayedAddress)
setEditing(true)
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void submitAddress()
return
}
if (event.key !== 'Escape') {
return
}
event.preventDefault()
setAddressDraft(authoritativeUrl)
setDirty(false)
setEditing(false)
event.currentTarget.blur()
}}
placeholder={t('sidebar.browser.toolbar.addressPlaceholder')}
ref={inputRef}
spellCheck={false}
title={t('sidebar.browser.toolbar.address')}
type="text"
value={displayedAddress}
/>
</label>
<button
aria-label={t('sidebar.browser.toolbar.go')}
className="secondary-button assistant-sidebar__browser-go"
disabled={goDisabled}
title={t('sidebar.browser.toolbar.go')}
type="submit"
>
<ArrowRight aria-hidden="true" size={13} />
</button>
<button
aria-label={
fullscreen
? t('sidebar.browser.toolbar.exitFullscreen')
: t('sidebar.browser.toolbar.fullscreen')
}
aria-pressed={fullscreen}
className="secondary-button assistant-sidebar__browser-tool-button assistant-sidebar__browser-fullscreen-button"
onClick={onToggleFullscreen}
title={
fullscreen
? t('sidebar.browser.toolbar.exitFullscreen')
: t('sidebar.browser.toolbar.fullscreen')
}
type="button"
>
{fullscreen ? (
<Minimize2 aria-hidden="true" size={14} />
) : (
<Maximize2 aria-hidden="true" size={14} />
)}
</button>
</form>
)
}
function BrowserViewport({
browserState,
conversationId,
tabId,
visible
}: {
browserState?: BrowserLiveState
conversationId?: string
tabId?: BrowserTabId
visible: boolean
}): React.JSX.Element {
const { t } = useTranslation('workspace')
const hostRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const host = hostRef.current
const browserApi = window.goodbuddy?.browser
if (!host || !conversationId || !tabId || !visible || !browserApi?.setViewport) {
return
}
const leaseToken = crypto.randomUUID()
let animationFrame: number | undefined
let lastRequest = ''
let resizeObserver: ResizeObserver | undefined
const observedOverlays = new Set<Element>()
const panel = host.closest<HTMLElement>('[role="tabpanel"]')
const syncViewport = (): void => {
if (animationFrame !== undefined) {
return
}
animationFrame = requestAnimationFrame(() => {
animationFrame = undefined
const rect = host.getBoundingClientRect()
const isVisible =
visible &&
Boolean(conversationId) &&
panel?.hidden !== true &&
rect.width >= 1 &&
rect.height >= 1 &&
!isBrowserViewportOccluded(host, rect)
const bounds = isVisible
? {
x: Math.max(0, Math.round(rect.left)),
y: Math.max(0, Math.round(rect.top)),
width: Math.round(rect.width),
height: Math.round(rect.height)
}
: undefined
const request = JSON.stringify({ conversationId, tabId, bounds })
if (request === lastRequest) {
return
}
lastRequest = request
void browserApi
.setViewport(
isVisible
? { conversationId, tabId, leaseToken, bounds: bounds! }
: { leaseToken }
)
.catch(() => undefined)
})
}
const updateObservation = (): void => {
if (panel?.hidden !== false) {
resizeObserver?.disconnect()
resizeObserver = undefined
observedOverlays.clear()
syncViewport()
return
}
if (!resizeObserver && typeof ResizeObserver === 'function') {
resizeObserver = new ResizeObserver(syncViewport)
resizeObserver.observe(host)
}
if (resizeObserver) {
const overlays = new Set(document.querySelectorAll(browserOverlaySelector))
for (const overlay of observedOverlays) {
if (!overlays.has(overlay)) {
resizeObserver.unobserve(overlay)
observedOverlays.delete(overlay)
}
}
for (const overlay of overlays) {
if (!observedOverlays.has(overlay)) {
resizeObserver.observe(overlay)
observedOverlays.add(overlay)
}
}
}
syncViewport()
}
const mutationObserver = new MutationObserver(updateObservation)
mutationObserver.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['hidden', 'aria-modal', 'role', 'class', 'style', 'open']
})
window.addEventListener('resize', syncViewport)
window.addEventListener('scroll', syncViewport, true)
document.addEventListener('transitionend', syncViewport, true)
document.addEventListener('animationend', syncViewport, true)
updateObservation()
return () => {
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame)
}
resizeObserver?.disconnect()
mutationObserver.disconnect()
window.removeEventListener('resize', syncViewport)
window.removeEventListener('scroll', syncViewport, true)
document.removeEventListener('transitionend', syncViewport, true)
document.removeEventListener('animationend', syncViewport, true)
void browserApi.setViewport({ leaseToken }).catch(() => undefined)
}
}, [conversationId, tabId, visible])
return (
<div
aria-label={t('sidebar.browser.viewport')}
aria-busy={
browserState?.status === 'creating' ||
browserState?.status === 'loading'
}
className="assistant-sidebar__browser-viewport"
ref={hostRef}
>
{browserState?.sessionActive !== true ? (
<div className="assistant-sidebar__browser-placeholder">
{browserState?.status === 'creating'
? t('sidebar.browser.statuses.creating')
: browserState?.status === 'failed'
? browserState.error ?? t('sidebar.browser.statuses.failed')
: t('sidebar.browser.empty')}
</div>
) : null}
</div>
)
}
function RightAssistantSidebarView({
notesEnabled = false,
notesSettingsReady = true,
notesOpenRequest = 0,
notesPanel,
onBeforeCloseNotes,
nativeTerminals,
open,
tab,
approvals,
schedules,
tasks,
conversationTitles,
projectNames,
selectedTaskId,
workspaceChanges,
workspaceProjectId,
activeConversationId,
conversationStats,
taskDurations,
browserStates = {},
currentProject,
supervisionEnabled = false,
supervisionLibraries,
onOpenSupervisionGraph,
onContinueSupervision,
onOpenSupervisionConversation,
restoreFocusRef,
onBackBrowser = async () => {},
onNavigateBrowser = async () => {},
onReloadBrowser = async () => {},
onStopLoadingBrowser = async () => {},
onCreateCustomTask,
onRefreshChanges,
notify,
onLoadWorkspaceDiff,
onListWorkspaceDirectory,
onLoadWorkspaceFile,
onOpenWorkspaceEntry,
onRemoveSchedule,
onRunSchedule,
onSetScheduleEnabled,
onOpenTask,
onTabChange
}: RightAssistantSidebarProps): React.JSX.Element {
const { i18n, t } = useTranslation('workspace')
const locale = i18n.resolvedLanguage || 'zh-CN'
const currentStats = activeConversationId && conversationStats?.conversationId === activeConversationId
? conversationStats
: undefined
const formatDuration = (durationMs: number, incomplete = false): string => {
if (incomplete && durationMs === 0) return t('sidebar.tasks.stats.unavailable')
const seconds = Math.floor(Math.max(0, durationMs) / 1000)
return `${Math.floor(seconds / 3600).toString().padStart(2, '0')}:${Math.floor(seconds / 60 % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`
}
const tabs = useMemo(
() =>
tabIds.map((id) => ({
id,
label: t(`sidebar.tabs.${id}.label`),
description: t(`sidebar.tabs.${id}.description`)
})),
[t]
)
const initialLayout = useMemo(
() => loadPersistedWorkbarLayout(),
[]
)
const localizedAppDefinitions = useMemo(
() =>
WORKBAR_APP_DEFINITIONS.filter(definition => definition.id !== 'notes' || notesEnabled).map((definition) => {
const tabDefinition = tabs.find(
(item) => item.id === definition.id
)
return {
...definition,
label:
tabDefinition?.label ??
t('sidebar.tabs.terminal.label'),
description:
tabDefinition?.description ??
t('sidebar.tabs.terminal.description')
} satisfies WorkbarAppDefinition
}),
[t, tabs, notesEnabled]
)
const [workbarInstances, setWorkbarInstances] = useState<
WorkbarTabInstance[]
>(() => {
const instances = (initialLayout?.instances ?? DEFAULT_WORKBAR_INSTANCES).map(
(instance) => {
if (instance.appId !== 'browser') {
return instance
}
const label =
localizedAppDefinitions.find(
(definition) => definition.id === instance.appId
)?.label ?? instance.title
const conversationId =
instance.targetRef?.type === 'conversation'
? instance.targetRef.conversationId
: !initialLayout
? activeConversationId
: undefined
return {
...instance,
...(conversationId
? {
title: initialLayout
? instance.title
: `${label} · ${conversationTitles.get(conversationId) ?? conversationId}`,
targetRef: {
type: 'conversation' as const,
conversationId
}
}
: { title: initialLayout ? instance.title : `${label} · ${t('sidebar.browser.unbound')}` })
}
}
)
return instances
})
const localizedWorkbarInstances = useMemo(
() => workbarInstances.map((instance) => {
if (
instance.appId === 'browser' ||
(instance.appId === 'terminal' && instance.defaultTitleNumber === undefined)
) {
return instance
}
const label = localizedAppDefinitions.find(
(definition) => definition.id === instance.appId
)?.label ?? instance.title
return {
...instance,
title: instance.appId === 'terminal'
? `${label} ${instance.defaultTitleNumber}`
: label
}
}),
[localizedAppDefinitions, workbarInstances]
)
const [activeWorkbarInstanceId, setActiveWorkbarInstanceId] =
useState<string | null>(
() =>
initialLayout?.activeInstanceId ??
workbarInstances.find((instance) => instance.appId === tab)?.id ??
workbarInstances[0]?.id ??
null
)
const activeWorkbarInstance = workbarInstances.find(
(instance) => instance.id === activeWorkbarInstanceId
)
const activeWorkbarApp = activeWorkbarInstance?.appId
const [splitLayoutWidth, setSplitLayoutWidth] = useState(
window.innerWidth
)
const [sidebarRatio, setSidebarRatio] = useState(
initialLayout?.widthRatio ?? defaultSidebarRatio
)
const [isResizing, setIsResizing] = useState(false)
const [browserFullscreenInstanceId, setBrowserFullscreenInstanceId] =
useState<string>()
const [browserTabIds, setBrowserTabIds] = useState<
Record<string, BrowserTabId>
>({})
const browserTabIdsRef = useRef<Record<string, BrowserTabId>>({})
const pendingBrowserTabsRef = useRef<
Map<string, Promise<{ conversationId: string; tabId: BrowserTabId } | undefined>>
>(new Map())
const browserMruRef = useRef<Record<string, number>>({})
const nextBrowserMruRef = useRef(0)
const sidebarRef = useRef<HTMLElement>(null)
const wasOpen = useRef(false)
const sidebarWidth = clampSidebarWidth(
splitLayoutWidth * sidebarRatio,
splitLayoutWidth
)
const liveSidebarWidth = useRef(sidebarWidth)
const resizePointerId = useRef<number | undefined>(undefined)
const [workspacePreview, setWorkspacePreview] = useState<
| {
projectId?: string
path: string
state: 'loading'
error?: undefined
file?: undefined
}
| {
projectId?: string
path: string
state: 'error'
error: string
file?: undefined
}
| {
projectId?: string
path: string
state: 'ready'
error?: undefined
file: WorkspaceFilePreview
}
>()
const workspacePreviewRequest = useRef(0)
const workspaceListRef = useRef<HTMLElement>(null)
const workspaceBackRef = useRef<HTMLButtonElement>(null)
const workspaceReturnPosition = useRef<{ scrollTop: number; trigger?: HTMLElement }>({ scrollTop: 0 })
const workspaceTrigger = useRef<HTMLElement | undefined>(undefined)
const [workspacePreviewRefreshing, setWorkspacePreviewRefreshing] = useState(false)
const [workspaceSource, setWorkspaceSource] = useState(false)
const [workspacePreviewLoadingMore, setWorkspacePreviewLoadingMore] =
useState(false)
const [workspacePreviewLoadMoreError, setWorkspacePreviewLoadMoreError] =
useState('')
const [taskFilter, setTaskFilter] = useState<
'all' | 'active' | 'paused' | 'finished'
>('active')
const [taskListExpanded, setTaskListExpanded] = useState(false)
const [actionErrorState, setActionErrorState] = useState({
scope: { instanceId: activeWorkbarInstanceId, open },
message: ''
})
const actionErrorScope = actionErrorState.scope
const actionError = actionErrorState.message
if (actionErrorScope.instanceId !== activeWorkbarInstanceId || actionErrorScope.open !== open) {
setActionErrorState({ scope: { instanceId: activeWorkbarInstanceId, open }, message: '' })
}
const setActionError = useCallback((message: string): void => {
// Old callbacks cannot restore an error after leaving and returning to a panel.
setActionErrorState((current) =>
current.scope === actionErrorScope && actionErrorScope.open
? { ...current, message }
: current
)
}, [actionErrorScope])
const [terminalSessionIds, setTerminalSessionIds] = useState<
Record<string, string>
>({})
const [terminalSnapshots, setTerminalSnapshots] = useState<
Record<string, TerminalSnapshot>
>({})
const [attachedNativeTerminalCount, setAttachedNativeTerminalCount] = useState(0)
if (nativeTerminals && nativeTerminals.length > attachedNativeTerminalCount) {
const additions = nativeTerminals.slice(attachedNativeTerminalCount)
setAttachedNativeTerminalCount(nativeTerminals.length)
setWorkbarInstances(current => [...current, ...additions.map(({ terminal }) => ({
id: terminal.sessionId, appId: 'terminal' as const, title: terminal.title, targetRef: terminal.target
}))])
setTerminalSessionIds(current => ({ ...current, ...Object.fromEntries(additions.map(({ terminal }) => [terminal.sessionId, terminal.sessionId])) }))
setTerminalSnapshots(current => ({ ...current, ...Object.fromEntries(additions.map(({ terminal }) => [terminal.sessionId, terminal])) }))
const focused = additions.findLast(entry => entry.focus)
if (focused) setActiveWorkbarInstanceId(focused.terminal.sessionId)
}
const [terminalCloseConfirmation, setTerminalCloseConfirmation] =
useState<{
instance: WorkbarTabInstance
resolve: (accepted: boolean) => void
closing: boolean
error?: string
}>()
const terminalCloseCancelRef = useRef<HTMLButtonElement>(null)
const lastExternalTabRef = useRef(tab)
const currentWorkspacePreview =
workspacePreview?.projectId === workspaceProjectId
? workspacePreview
: undefined
if (workspacePreview && workspacePreview.projectId !== workspaceProjectId) {
setWorkspacePreview(undefined)
setWorkspacePreviewRefreshing(false)
setWorkspacePreviewLoadingMore(false)
setWorkspacePreviewLoadMoreError('')
}
const workspacePreviewVisible = Boolean(currentWorkspacePreview)
useLayoutEffect(() => {
const body = workspaceListRef.current?.closest('.assistant-sidebar__body')
if (!body) return
if (workspacePreviewVisible) {
body.scrollTop = 0
workspaceBackRef.current?.focus({ preventScroll: true })
} else {
body.scrollTop = workspaceReturnPosition.current.scrollTop
workspaceReturnPosition.current.trigger?.focus({ preventScroll: true })
}
}, [workspacePreviewVisible])
useEffect(() => () => {
workspacePreviewRequest.current += 1
workspaceReturnPosition.current = { scrollTop: 0 }
workspaceTrigger.current = undefined
}, [workspaceProjectId])
const terminalAdapter = useMemo<TerminalAdapter>(
() => ({
create: (request) => window.goodbuddy.terminal.create(request),
write: (request) => window.goodbuddy.terminal.write(request),
resize: (request) => window.goodbuddy.terminal.resize(request),
close: (request) => window.goodbuddy.terminal.close(request),
getSnapshot: (request) =>
window.goodbuddy.terminal.getSnapshot(request),
ack: (request) => window.goodbuddy.terminal.ack(request),
subscribe: (listener) =>
window.goodbuddy.terminal.onEvent(listener)
}),
[]
)
const browserFullscreenActive =
open &&
activeWorkbarApp === 'browser' &&
browserFullscreenInstanceId === activeWorkbarInstanceId
const sidebarWidthLimits = getSidebarWidthLimits(splitLayoutWidth)
const canResize =
open &&
!browserFullscreenActive &&
sidebarWidthLimits.maximum > sidebarWidthLimits.minimum
const taskMatchesScope = useCallback(
(projectId?: string): boolean => {
return Boolean(currentProject && projectId === currentProject.id)
},
[currentProject]
)
const scopedApprovals = useMemo(
() => approvals.filter((approval) => taskMatchesScope(approval.projectId)),
[approvals, taskMatchesScope]
)
const topLevelTasks = useMemo(
() =>
tasks.filter(
(task) => !task.parentTaskId && taskMatchesScope(task.projectId)
),
[taskMatchesScope, tasks]
)
const runningTaskCount = topLevelTasks.filter((task) => task.status === 'running').length
const { approvalsByTask, unassociatedApprovals } = useMemo(() => {
const tasksById = new Map(topLevelTasks.map((task) => [task.id, task]))
const approvalsByTask = new Map<string, PendingSidebarApproval[]>()
const unassociatedApprovals: PendingSidebarApproval[] = []
for (const approval of scopedApprovals) {
const task = approval.taskId ? tasksById.get(approval.taskId) : undefined
// A shared conversation alone cannot identify a task or a scheduled run.
if (task && task.conversationId === approval.conversationId && task.projectId === approval.projectId) {
const pending = approvalsByTask.get(task.id) ?? []
pending.push(approval)
approvalsByTask.set(task.id, pending)
} else {
unassociatedApprovals.push(approval)
}
}
return { approvalsByTask, unassociatedApprovals }
}, [scopedApprovals, topLevelTasks])
const filteredTasks = useMemo(
() =>
topLevelTasks.filter((task) => {
if (taskFilter === 'all') return true
if (approvalsByTask.has(task.id)) return taskFilter === 'active'
if (taskFilter === 'active') {
return (
task.status === 'idle' ||
task.status === 'queued' ||
task.status === 'running' ||
task.status === 'waiting_approval' ||
task.status === 'failed' ||
task.status === 'interrupted'
)
}
if (taskFilter === 'paused') {
return task.status === 'paused'
}
return task.status === 'completed' || task.status === 'cancelled'
}),
[approvalsByTask, taskFilter, topLevelTasks]
)
const schedulesByTaskKey = useMemo(() => {
const byKey = new Map<string, AssistantSchedule>()
for (const schedule of schedules) {
byKey.set(schedule.id, schedule)
byKey.set(schedule.taskId, schedule)
}
return byKey
}, [schedules])
useEffect(() => {
// Context switches preserve the selected instance and its pinned binding.
// Only an explicit external tab change requests a different selection.
if (lastExternalTabRef.current === tab) {
return
}
lastExternalTabRef.current = tab
const defaultBrowserId = DEFAULT_WORKBAR_INSTANCES.find(
(instance) => instance.appId === 'browser'
)?.id
const requested =
tab === 'browser'
? workbarInstances
.filter(
(instance) =>
instance.appId === 'browser' &&
(!activeConversationId ||
(instance.targetRef?.type === 'conversation' &&
instance.targetRef.conversationId === activeConversationId) ||
(instance.id === defaultBrowserId &&
instance.targetRef?.type !== 'conversation')
)
)
.sort(
(left, right) =>
(browserMruRef.current[right.id] ?? 0) -
(browserMruRef.current[left.id] ?? 0)
)[0]
: workbarInstances.find((instance) => instance.appId === tab)
if (requested) {
setActiveWorkbarInstanceId(requested.id)
}
}, [activeConversationId, tab, workbarInstances])
const toggleBrowserFullscreen = useCallback((): void => {
setBrowserFullscreenInstanceId((current) =>
current === activeWorkbarInstanceId
? undefined
: activeWorkbarInstanceId ?? undefined
)
}, [activeWorkbarInstanceId])
useEffect(() => {
if (!browserFullscreenActive) {
return
}
const exitFullscreen = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
toggleBrowserFullscreen()
}
}
window.addEventListener('keydown', exitFullscreen)
return () => window.removeEventListener('keydown', exitFullscreen)
}, [browserFullscreenActive, toggleBrowserFullscreen])
useEffect(() => {
persistWorkbarLayout(
workbarInstances,
activeWorkbarInstanceId,
open,
sidebarRatio
)
}, [
activeWorkbarInstanceId,
open,
sidebarRatio,
workbarInstances
])
useEffect(() => {
if (terminalCloseConfirmation && !terminalCloseConfirmation.closing) {
terminalCloseCancelRef.current?.focus()
}
}, [terminalCloseConfirmation])
const resolveTerminalTarget = useCallback(
(): WorkbarTargetRef =>
currentProject
? { type: 'project', projectId: currentProject.id }
: { type: 'local' },
[currentProject]
)
const updateActiveWorkbarInstance = useCallback(
(instanceId: string | null): void => {
setActiveWorkbarInstanceId(instanceId)
setBrowserFullscreenInstanceId(undefined)
if (!instanceId) {
return
}
const instance = workbarInstances.find(
(candidate) => candidate.id === instanceId
)
if (
instance &&
instance.appId !== 'terminal'
) {
if (instance.appId === 'browser') {
browserMruRef.current[instance.id] = ++nextBrowserMruRef.current
}
lastExternalTabRef.current = instance.appId
onTabChange(instance.appId)
}
},
[onTabChange, workbarInstances]
)
const createWorkbarInstance = useCallback(
(request: WorkbarInstanceCreateRequest): void => {
const definition = localizedAppDefinitions.find(
(candidate) => candidate.id === request.appId
)
if (!definition) {
return
}
if (
workbarInstances.length >= WORKBAR_LIMITS.maximumOpenInstances ||
(definition.instancePolicy === 'single' &&
workbarInstances.some(
(instance) => instance.appId === request.appId
))
) {
return
}
const sameTargetTerminals = workbarInstances.filter(
(instance) =>
instance.appId === 'terminal' &&
JSON.stringify(instance.targetRef) ===
JSON.stringify(request.targetRef)
).length
const conversationTarget =
request.appId === 'browser' && activeConversationId
? {
type: 'conversation' as const,
conversationId: activeConversationId
}
: undefined
const conversationTitle = activeConversationId
? conversationTitles.get(activeConversationId) ?? activeConversationId
: undefined
let browserNumber = 1
const browserTitle = (): string =>
`${definition.label} ${browserNumber}${conversationTitle ? ` · ${conversationTitle}` : ''}`
while (workbarInstances.some((candidate) => candidate.title === browserTitle())) {
browserNumber += 1
}
const instance: WorkbarTabInstance = {
id: crypto.randomUUID(),
appId: request.appId,
title:
request.appId === 'terminal'
? `${definition.label} ${sameTargetTerminals + 1}`
: request.appId === 'browser'
? browserTitle()
: definition.label,
...(request.appId === 'terminal'
? { defaultTitleNumber: sameTargetTerminals + 1 }
: {}),
...(request.targetRef || conversationTarget
? { targetRef: request.targetRef ?? conversationTarget }
: {})
}
setWorkbarInstances((current) => {
const afterIndex = request.insertAfterInstanceId
? current.findIndex(
(candidate) =>
candidate.id === request.insertAfterInstanceId
)
: -1
const next = [...current]
next.splice(afterIndex >= 0 ? afterIndex + 1 : next.length, 0, instance)
return next
})
setActiveWorkbarInstanceId(instance.id)
},
[activeConversationId, conversationTitles, localizedAppDefinitions, workbarInstances]
)
const [handledNotesRequest, setHandledNotesRequest] = useState(0)
if (handledNotesRequest !== notesOpenRequest) {
setHandledNotesRequest(notesOpenRequest)
if (notesEnabled && notesOpenRequest) {
const existing = workbarInstances.find(instance => instance.appId === 'notes')
if (existing) setActiveWorkbarInstanceId(existing.id)
else if (workbarInstances.length < WORKBAR_LIMITS.maximumOpenInstances) {
const instance: WorkbarTabInstance = { id: crypto.randomUUID(), appId: 'notes', title: t('sidebar.tabs.notes.label') }
setWorkbarInstances([...workbarInstances, instance])
setActiveWorkbarInstanceId(instance.id)
} else {
setActionErrorState({ scope: { instanceId: activeWorkbarInstanceId, open }, message: t('sidebar.workbar.limit', { count: WORKBAR_LIMITS.maximumOpenInstances }) })
}
setBrowserFullscreenInstanceId(undefined)
}
}
if (notesSettingsReady && !notesEnabled && workbarInstances.some(instance => instance.appId === 'notes')) {
const next = workbarInstances.filter(instance => instance.appId !== 'notes')
setWorkbarInstances(next)
if (activeWorkbarApp === 'notes') setActiveWorkbarInstanceId(next[0]?.id ?? null)
}
const ensureBrowserTab = useCallback(
(
instance: WorkbarTabInstance
): Promise<
{ conversationId: string; tabId: BrowserTabId } | undefined
> => {
const existingTabId = browserTabIdsRef.current[instance.id]
const conversationId =
instance.targetRef?.type === 'conversation'
? instance.targetRef.conversationId
: activeConversationId
if (existingTabId && conversationId) {
return Promise.resolve({ conversationId, tabId: existingTabId })
}
const pending = pendingBrowserTabsRef.current.get(instance.id)
if (pending) {
return pending
}
if (!conversationId) {
return Promise.resolve(undefined)
}
const request = (async () => {
const browserApi = window.goodbuddy.browser
if (!browserApi.createTab) {
throw new Error(t('sidebar.errors.browserControlUnavailable'))
}
if (instance.targetRef?.type !== 'conversation') {
setWorkbarInstances((current) =>
current.map((candidate) =>
candidate.id === instance.id
? {
...candidate,
...(candidate.id === DEFAULT_WORKBAR_INSTANCES.find((item) => item.appId === 'browser')?.id
? { title: `${t('sidebar.tabs.browser.label')} · ${conversationTitles.get(conversationId) ?? conversationId}` }
: {}),
targetRef: { type: 'conversation', conversationId }
}
: candidate
)
)
}
const tab = await browserApi.createTab({
conversationId,
workbarInstanceId: instance.id
})
const next = {
...browserTabIdsRef.current,
[instance.id]: tab.tabId
}
browserTabIdsRef.current = next
setBrowserTabIds(next)
return { conversationId, tabId: tab.tabId }
})()
pendingBrowserTabsRef.current.set(instance.id, request)
void request.then(
() => pendingBrowserTabsRef.current.delete(instance.id),
() => pendingBrowserTabsRef.current.delete(instance.id)
)
return request
},
[activeConversationId, conversationTitles, t]
)
useEffect(() => window.goodbuddy.browser?.onState?.((state) => {
if (state.status !== 'stopped') return
if (state.openerTabId && state.workbarInstanceId) {
setWorkbarInstances((current) => current.filter((instance) => instance.id !== state.workbarInstanceId))
const openerInstanceId = Object.entries(browserTabIdsRef.current)
.find(([, tabId]) => tabId === state.openerTabId)?.[0]
setActiveWorkbarInstanceId((current) => current === state.workbarInstanceId ? openerInstanceId ?? null : current)
}
const next = { ...browserTabIdsRef.current }
let changed = false
for (const [instanceId, tabId] of Object.entries(next)) {
if (tabId === state.tabId) {
delete next[instanceId]
changed = true
}
}
if (changed) {
browserTabIdsRef.current = next
setBrowserTabIds(next)
}
}), [])
useEffect(() => {
const liveTabs = Object.values(browserStates ?? {}).flatMap((states) =>
Object.values(states).filter((state) =>
state.workbarInstanceId &&
(state.sessionActive || state.status === 'creating')
)
)
const nextBindings = { ...browserTabIdsRef.current }
let changed = false
const newInstances: WorkbarTabInstance[] = []
for (const state of liveTabs) {
const instanceId = state.workbarInstanceId!
if (nextBindings[instanceId] !== state.tabId) {
nextBindings[instanceId] = state.tabId
changed = true
}
if (!workbarInstances.some((instance) => instance.id === instanceId)) {
newInstances.push({
id: instanceId,
appId: 'browser',
title: `${t('sidebar.tabs.browser.label')} · ${conversationTitles.get(state.conversationId) ?? state.conversationId}`,
targetRef: { type: 'conversation', conversationId: state.conversationId }
})
}
}
if (changed) {
browserTabIdsRef.current = nextBindings
setBrowserTabIds(nextBindings)
}
const additions = newInstances.slice(
0, Math.max(0, WORKBAR_LIMITS.maximumOpenInstances - workbarInstances.length)
)
if (additions.length > 0) {
setWorkbarInstances((current) => [
...current,
...additions
.filter((candidate) => !current.some((instance) => instance.id === candidate.id))
.slice(0, Math.max(0, WORKBAR_LIMITS.maximumOpenInstances - current.length))
])
const popup = additions.findLast((instance) => liveTabs.some((state) =>
state.workbarInstanceId === instance.id &&
state.openerTabId &&
activeWorkbarInstanceId &&
state.openerTabId === nextBindings[activeWorkbarInstanceId]
))
const requested = popup ?? additions.find((instance) =>
instance.targetRef?.type === 'conversation' &&
instance.targetRef.conversationId === activeConversationId
)
if (requested && tab === 'browser') {
setActiveWorkbarInstanceId(requested.id)
}
}
}, [activeConversationId, activeWorkbarInstanceId, browserStates, conversationTitles, tab, t, workbarInstances])
const removeWorkbarInstance = useCallback(
(instanceId: string): void => {
setWorkbarInstances((current) =>
current.filter((instance) => instance.id !== instanceId)
)
setTerminalSessionIds((current) => {
const next = { ...current }
delete next[instanceId]
return next
})
setTerminalSnapshots((current) => {
const next = { ...current }
delete next[instanceId]
return next
})
const nextBrowserTabIds = { ...browserTabIdsRef.current }
delete nextBrowserTabIds[instanceId]
browserTabIdsRef.current = nextBrowserTabIds
setBrowserTabIds(nextBrowserTabIds)
pendingBrowserTabsRef.current.delete(instanceId)
},
[]
)
const requestCloseWorkbarInstance = useCallback(
async (instance: WorkbarTabInstance): Promise<boolean> => {
const definition = localizedAppDefinitions.find(
(candidate) => candidate.id === instance.appId
)
if (definition?.closable !== true) {
return false
}
if (instance.appId === 'notes' && onBeforeCloseNotes && !await onBeforeCloseNotes()) return false
if (instance.appId === 'browser') {
let tabId = browserTabIdsRef.current[instance.id]
let conversationId =
instance.targetRef?.type === 'conversation'
? instance.targetRef.conversationId
: undefined
const pending = pendingBrowserTabsRef.current.get(instance.id)
if (!tabId && pending) {
try {
const binding = await pending
tabId = binding?.tabId
conversationId = binding?.conversationId
} catch (reason) {
if (instance.id === activeWorkbarInstanceId) setActionError(
reason instanceof Error
? reason.message
: t('sidebar.errors.closeBrowser')
)
return false
}
}
if (tabId && conversationId) {
try {
await window.goodbuddy.browser.closeTab?.({ conversationId, tabId })
} catch (reason) {
if (instance.id === activeWorkbarInstanceId) setActionError(
reason instanceof Error
? reason.message
: t('sidebar.errors.closeBrowser')
)
return false
}
}
if (instance.id === activeWorkbarInstanceId) setActionError('')
removeWorkbarInstance(instance.id)
return true
}
if (instance.appId !== 'terminal') {
removeWorkbarInstance(instance.id)
return true
}
const sessionId = terminalSessionIds[instance.id]
const snapshot = terminalSnapshots[instance.id]
if (
sessionId &&
(!snapshot ||
snapshot.state === 'starting' ||
snapshot.state === 'running' ||
snapshot.state === 'closing')
) {
return new Promise<boolean>((resolve) => {
setTerminalCloseConfirmation({
instance,
resolve,
closing: false
})
})
}
if (sessionId) {
try {
await terminalAdapter.close({ sessionId })
} catch {
// An already-ended session can still be removed from the workbar.
}
}
removeWorkbarInstance(instance.id)
return true
},
[
onBeforeCloseNotes,
activeWorkbarInstanceId,
setActionError,
removeWorkbarInstance,
localizedAppDefinitions,
terminalAdapter,
terminalSessionIds,
terminalSnapshots,
t
]
)
const confirmTerminalClose = useCallback(async (): Promise<void> => {
const confirmation = terminalCloseConfirmation
if (!confirmation || confirmation.closing) {
return
}
const sessionId = terminalSessionIds[confirmation.instance.id]
setTerminalCloseConfirmation({
...confirmation,
closing: true,
error: undefined
})
try {
if (sessionId) {
await terminalAdapter.close({ sessionId })
}
removeWorkbarInstance(confirmation.instance.id)
setTerminalCloseConfirmation(undefined)
confirmation.resolve(true)
} catch (reason) {
setTerminalCloseConfirmation({
...confirmation,
closing: false,
error:
reason instanceof Error
? reason.message
: t('sidebar.terminal.closeDialog.error')
})
}
}, [
removeWorkbarInstance,
terminalAdapter,
terminalCloseConfirmation,
terminalSessionIds,
t
])
const cancelTerminalClose = useCallback((): void => {
const confirmation = terminalCloseConfirmation
setTerminalCloseConfirmation(undefined)
confirmation?.resolve(false)
}, [terminalCloseConfirmation])
useEffect(() => {
const sidebar = sidebarRef.current
const workspace = findWorkspaceSibling(sidebar)
const handleLayoutResize = (): void => {
const width = measureSplitLayoutWidth(
sidebar,
window.innerWidth
)
setSplitLayoutWidth((currentWidth) =>
Math.abs(currentWidth - width) >= 0.5
? width
: currentWidth
)
}
handleLayoutResize()
window.addEventListener('resize', handleLayoutResize)
const resizeObserver =
typeof ResizeObserver === 'undefined'
? undefined
: new ResizeObserver(handleLayoutResize)
if (resizeObserver && workspace && sidebar) {
resizeObserver.observe(workspace)
resizeObserver.observe(sidebar)
}
return () => {
resizeObserver?.disconnect()
window.removeEventListener('resize', handleLayoutResize)
}
}, [])
useEffect(() => {
if (!isResizing) {
liveSidebarWidth.current = sidebarWidth
}
}, [isResizing, sidebarWidth])
useEffect(() => {
if (!open && wasOpen.current) {
requestAnimationFrame(() => restoreFocusRef?.current?.focus())
}
wasOpen.current = open
}, [open, restoreFocusRef])
const resizeFromClientX = (
clientX: number,
commit: boolean
): void => {
const layoutWidth = measureSplitLayoutWidth(
sidebarRef.current,
splitLayoutWidth
)
if (Math.abs(splitLayoutWidth - layoutWidth) >= 0.5) {
setSplitLayoutWidth(layoutWidth)
}
const sidebarBounds = sidebarRef.current?.getBoundingClientRect()
const layoutRight =
sidebarBounds && sidebarBounds.width > 0
? sidebarBounds.right
: window.innerWidth
const width = clampSidebarWidth(
layoutRight - clientX,
layoutWidth
)
liveSidebarWidth.current = width
if (commit) {
setSidebarRatio(
layoutWidth > 0 ? width / layoutWidth : defaultSidebarRatio
)
return
}
sidebarRef.current?.style.setProperty(
'--assistant-sidebar-width',
`${width}px`
)
}
const finishResize = (
event: React.PointerEvent<HTMLDivElement>
): void => {
if (resizePointerId.current !== event.pointerId) {
return
}
resizePointerId.current = undefined
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
const layoutWidth = measureSplitLayoutWidth(
sidebarRef.current,
splitLayoutWidth
)
setSidebarRatio(
layoutWidth > 0
? liveSidebarWidth.current / layoutWidth
: defaultSidebarRatio
)
setIsResizing(false)
}
const resizeWithKeyboard = (
event: React.KeyboardEvent<HTMLDivElement>
): void => {
if (!canResize) {
return
}
const layoutWidth = measureSplitLayoutWidth(
sidebarRef.current,
splitLayoutWidth
)
if (Math.abs(splitLayoutWidth - layoutWidth) >= 0.5) {
setSplitLayoutWidth(layoutWidth)
}
const limits = getSidebarWidthLimits(layoutWidth)
const nextWidth =
event.key === 'Home'
? limits.minimum
: event.key === 'End'
? limits.maximum
: event.key === 'ArrowLeft'
? sidebarWidth + keyboardResizeStep
: event.key === 'ArrowRight'
? sidebarWidth - keyboardResizeStep
: undefined
if (nextWidth === undefined) {
return
}
event.preventDefault()
setBrowserFullscreenInstanceId(undefined)
const width = clampSidebarWidth(nextWidth, layoutWidth)
setSidebarRatio(
layoutWidth > 0 ? width / layoutWidth : defaultSidebarRatio
)
}
const openWorkspaceFile = (path: string, refresh = false): void => {
if (!currentWorkspacePreview) {
workspaceReturnPosition.current = {
scrollTop: workspaceListRef.current?.closest('.assistant-sidebar__body')?.scrollTop ?? 0,
trigger: workspaceTrigger.current
}
setWorkspaceSource(false)
}
const requestId = workspacePreviewRequest.current + 1
workspacePreviewRequest.current = requestId
const projectId = workspaceProjectId
if (!refresh) setWorkspacePreview({ projectId, path, state: 'loading' })
setWorkspacePreviewRefreshing(refresh)
setWorkspacePreviewLoadingMore(false)
setWorkspacePreviewLoadMoreError('')
setActionError('')
void onLoadWorkspaceFile(path, 0)
.then((file) => {
if (workspacePreviewRequest.current === requestId) {
setWorkspacePreview({
projectId,
path,
state: 'ready',
file
})
}
})
.finally(() => {
if (workspacePreviewRequest.current === requestId) setWorkspacePreviewRefreshing(false)
})
.catch((reason: unknown) => {
if (workspacePreviewRequest.current === requestId) {
if (refresh) {
setWorkspacePreviewLoadMoreError(reason instanceof Error ? reason.message : t('sidebar.errors.workspacePreview'))
return
}
setWorkspacePreview({
path,
projectId,
state: 'error',
error:
reason instanceof Error
? reason.message
: t('sidebar.errors.workspacePreview')
})
}
})
}
const loadMoreWorkspaceFile = (): void => {
const preview = currentWorkspacePreview
if (
preview?.state !== 'ready' ||
!preview.file.truncated ||
workspacePreviewLoadingMore || workspacePreviewRefreshing
) {
return
}
const requestId = workspacePreviewRequest.current
const expectedOffset = preview.file.nextOffsetBytes
setWorkspacePreviewLoadingMore(true)
setWorkspacePreviewLoadMoreError('')
void onLoadWorkspaceFile(preview.path, expectedOffset)
.then((nextPage) => {
if (workspacePreviewRequest.current !== requestId) {
return
}
if (
nextPage.path !== preview.file.path ||
nextPage.offsetBytes !== expectedOffset ||
nextPage.size !== preview.file.size ||
nextPage.mimeType !== preview.file.mimeType
) {
throw new Error(t('sidebar.errors.workspacePreview'))
}
setWorkspacePreview({
projectId: preview.projectId,
path: preview.path,
state: 'ready',
file: {
...nextPage,
content: `${preview.file.content}${nextPage.content}`,
offsetBytes: preview.file.offsetBytes
}
})
})
.catch((reason: unknown) => {
if (workspacePreviewRequest.current === requestId) {
setWorkspacePreviewLoadMoreError(
reason instanceof Error
? reason.message
: t('sidebar.errors.workspacePreview')
)
}
})
.finally(() => {
if (workspacePreviewRequest.current === requestId) {
setWorkspacePreviewLoadingMore(false)
}
})
}
const runAction = async (
action: () => Promise<void>,
fallback: string,
onSuccess?: () => void
): Promise<boolean> => {
setActionError('')
try {
await action()
onSuccess?.()
return true
} catch (reason) {
setActionError(
reason instanceof Error ? reason.message : fallback
)
return false
}
}
const hasUnboundRequestBrowser = Object.values(
browserStates?.[activeConversationId ?? ''] ?? {}
).some((state) =>
state.workbarInstanceId &&
(state.sessionActive || state.status === 'creating') &&
!workbarInstances.some((instance) => instance.id === state.workbarInstanceId)
)
useEffect(() => {
if (!open || activeWorkbarInstance?.appId !== 'browser') {
return
}
// Bind a newly materialized request tab before opening a fallback instance.
if (hasUnboundRequestBrowser) return
void ensureBrowserTab(activeWorkbarInstance).catch((reason: unknown) => {
setActionError(
reason instanceof Error
? reason.message
: t('sidebar.errors.browserControlUnavailable')
)
})
}, [activeWorkbarInstance, ensureBrowserTab, hasUnboundRequestBrowser, open, setActionError, t])
return (
<aside
ref={sidebarRef}
aria-label={t('sidebar.ariaLabel')}
aria-hidden={!open}
className={
open
? `assistant-sidebar assistant-sidebar--open${isResizing && canResize ? ' assistant-sidebar--resizing' : ''}${browserFullscreenActive ? ' assistant-sidebar--browser-fullscreen' : ''}`
: 'assistant-sidebar'
}
id="assistant-sidebar"
inert={!open}
role="complementary"
style={
{
'--assistant-sidebar-width': `${Math.round(sidebarWidth)}px`
} as React.CSSProperties
}
>
<div
aria-controls="assistant-sidebar-panel"
aria-label={t('sidebar.resizeAriaLabel')}
aria-orientation="vertical"
aria-valuemax={Math.round(sidebarWidthLimits.maximum)}
aria-valuemin={Math.round(sidebarWidthLimits.minimum)}
aria-valuenow={Math.round(sidebarWidth)}
aria-valuetext={t('sidebar.resizeValue', {
width: Math.round(sidebarWidth)
})}
aria-disabled={!canResize}
className="assistant-sidebar__resize-handle"
onKeyDown={resizeWithKeyboard}
onLostPointerCapture={(event) => {
if (resizePointerId.current === event.pointerId) {
resizePointerId.current = undefined
const layoutWidth = measureSplitLayoutWidth(
sidebarRef.current,
splitLayoutWidth
)
setSidebarRatio(
layoutWidth > 0
? liveSidebarWidth.current / layoutWidth
: defaultSidebarRatio
)
setIsResizing(false)
}
}}
onPointerCancel={finishResize}
onPointerDown={(event) => {
if (event.button !== 0 || !canResize) {
return
}
event.preventDefault()
setBrowserFullscreenInstanceId(undefined)
resizePointerId.current = event.pointerId
event.currentTarget.setPointerCapture(event.pointerId)
resizeFromClientX(event.clientX, true)
setIsResizing(true)
}}
onPointerMove={(event) => {
if (resizePointerId.current !== event.pointerId) {
return
}
if (!canResize) {
finishResize(event)
return
}
event.preventDefault()
resizeFromClientX(event.clientX, false)
}}
onPointerUp={finishResize}
role="separator"
tabIndex={canResize ? 0 : -1}
/>
<WorkbarShell
activeInstanceId={activeWorkbarInstanceId}
appDefinitions={localizedAppDefinitions}
instances={localizedWorkbarInstances}
onActiveInstanceChange={updateActiveWorkbarInstance}
onCloseInstance={requestCloseWorkbarInstance}
onCreateInstance={createWorkbarInstance}
onResolveTerminalTarget={resolveTerminalTarget}
renderTabAdornment={(instance) =>
instance.appId === 'tasks' && scopedApprovals.length > 0 ? (
<span
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${scopedApprovals.length}`}
className="assistant-sidebar__badge"
>
{scopedApprovals.length}
</span>
) : null
}
renderPanel={(instance) => (
<div
className={`assistant-sidebar__body${instance.appId === 'browser' ? ' assistant-sidebar__body--browser' : instance.appId === 'notes' ? ' assistant-sidebar__body--notes' : ''}`}
>
{hasUnboundRequestBrowser &&
workbarInstances.length >= WORKBAR_LIMITS.maximumOpenInstances ? (
<p className="settings-error" role="alert">
{t('sidebar.browser.workbarFull', { count: WORKBAR_LIMITS.maximumOpenInstances })}
</p>
) : null}
{actionError ? (
<p className="settings-error" role="alert">
{actionError}
</p>
) : null}
{instance.appId === 'notes' && notesPanel}
{instance.appId === 'terminal' &&
instance.targetRef &&
(instance.targetRef.type === 'local' || instance.targetRef.type === 'project') ? (
<Suspense
fallback={
<section
aria-busy="true"
aria-label={t('sidebar.terminal.loading')}
className="terminal-panel terminal-panel--loading"
>
<span>{t('sidebar.terminal.loading')}</span>
</section>
}
>
<TerminalPanel
adapter={terminalAdapter}
onRename={(title) =>
setWorkbarInstances((current) =>
current.map((candidate) =>
candidate.id === instance.id
? { ...candidate, title, defaultTitleNumber: undefined }
: candidate
)
)
}
onSessionChange={(snapshot) => {
setTerminalSessionIds((current) =>
current[instance.id] === snapshot.sessionId
? current
: {
...current,
[instance.id]: snapshot.sessionId
}
)
setTerminalSnapshots((current) => ({
...current,
[instance.id]: snapshot
}))
}}
sessionId={terminalSessionIds[instance.id]}
target={instance.targetRef}
title={instance.title}
/>
</Suspense>
) : null}
{instance.appId === 'tasks' && (
<section className="assistant-sidebar__section task-center">
<section className="task-center__stats" aria-label={t('sidebar.tasks.stats.label')}>
<p className="task-center__scope">{currentProject
? t('sidebar.tasks.projectScope', { project: currentProject.name })
: t('sidebar.tasks.scope.currentProject')}</p>
<span className="task-center__eyebrow">{t('sidebar.tasks.stats.currentConversation')}</span>
<h3>{activeConversationId
? currentStats?.title ?? conversationTitles.get(activeConversationId) ?? t('sidebar.tasks.stats.currentConversation')
: t('sidebar.tasks.stats.noConversation')}</h3>
<dl className="task-center__stat-grid">
<div><dt>{t('sidebar.tasks.stats.replyDuration')}</dt><dd>{currentStats ? formatDuration(currentStats.replyDurationMs, currentStats.incomplete) : t('sidebar.tasks.stats.unavailable')}</dd></div>
<div><dt>{t('sidebar.tasks.stats.messages')}</dt><dd>{currentStats ? currentStats.messageCount.toLocaleString(locale) : t('sidebar.tasks.stats.unavailable')}</dd></div>
</dl>
{currentStats?.incomplete && <p className="task-center__incomplete">{t('sidebar.tasks.stats.incomplete')}</p>}
<details><summary>{t('sidebar.tasks.stats.timeHelp')}</summary><p>{t('sidebar.tasks.stats.timeDescription')}</p></details>
</section>
{supervisionEnabled && <>
<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={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}
onOpenSupervisionConversation={onOpenSupervisionConversation}
/>
</>}
{!currentProject ? (
<p className="assistant-sidebar__empty">
{t('sidebar.tasks.scope.noCurrentProject')}
</p>
) : (
<>
<div className="task-center__index-heading">
<h3>
<button
className="task-center__list-toggle"
type="button"
aria-expanded={taskListExpanded}
aria-controls={`project-tasks-${instance.id}`}
onClick={() => setTaskListExpanded((expanded) => !expanded)}
>
<ChevronRight aria-hidden="true" size={15} />
{t('sidebar.tasks.taskIndexTitle')}{' '}
<span className="task-center__count">{topLevelTasks.length}</span>
</button>
{runningTaskCount > 0 && <span className="task-center__running-count">
{t('sidebar.tasks.runningCount', { count: runningTaskCount })}
</span>}
</h3>
<button
className="secondary-button task-center__create"
aria-label={t('taskStrip.create')}
title={t('taskStrip.create')}
onClick={onCreateCustomTask}
type="button"
>
<Plus aria-hidden="true" size={13} />
</button>
</div>
<div className="task-center__filters" id={`task-filters-${instance.id}`} hidden={!taskListExpanded}
onFocusCapture={(event) => event.target.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })}>
<SegmentedControl
ariaLabel={t('sidebar.tasks.filters.ariaLabel')}
onChange={setTaskFilter}
options={[
{ value: 'all', label: t('sidebar.tasks.filters.all') },
{
value: 'active',
label: t('sidebar.tasks.filters.active')
},
{
value: 'paused',
label: t('sidebar.tasks.filters.paused')
},
{
value: 'finished',
label: t('sidebar.tasks.filters.finished')
}
]}
value={taskFilter}
/>
</div>
{unassociatedApprovals.length > 0 && <>
<h3><ShieldAlert size={15} />{t('sidebar.tasks.approvalsTitle')}</h3>
{unassociatedApprovals.map((approval) => (
<SidebarApproval key={approval.approvalId} approval={approval} />
))}
</>}
<div className="task-center__list" id={`project-tasks-${instance.id}`} hidden={!taskListExpanded}>
{filteredTasks.length === 0 ? (
<p className="assistant-sidebar__empty">
{topLevelTasks.length === 0
? t('sidebar.tasks.empty')
: t('sidebar.tasks.noFilterResults')}
</p>
) : (
filteredTasks.map((task) => {
const schedule =
schedulesByTaskKey.get(task.scheduleId ?? '') ??
schedulesByTaskKey.get(task.id)
const conversationTitle = task.conversationId
? conversationTitles.get(task.conversationId)
: undefined
const projectName = task.projectId
? projectNames.get(task.projectId)
: undefined
const duration = taskDurations?.get(task.id)
return (
<article
className={
selectedTaskId === task.id
? 'task-center__item task-center__item--selected'
: 'task-center__item'
}
key={task.id}
>
<button
className="task-center__item-main"
onClick={() => onOpenTask(task)}
aria-current={selectedTaskId === task.id ? 'true' : undefined}
type="button"
>
<span
aria-hidden="true"
className={`task-status-dot task-status-dot--${task.status}`}
/>
<span>
<strong>{task.title}</strong>
<small>
{conversationTitle ??
t('sidebar.tasks.conversationUnavailable')}
</small>
</span>
{task.status === 'failed' ? (
<CircleAlert aria-hidden="true" size={14} />
) : (
<ChevronRight aria-hidden="true" size={14} />
)}
</button>
<div className="task-center__metadata">
<span>
{task.projectId
? t('sidebar.tasks.projectScope', {
project: projectName ?? task.projectId
})
: t('sidebar.tasks.unboundProject')}
</span>
<span>{t(`task.status.${task.status}`)}</span>
{duration && <span title={t('sidebar.tasks.stats.taskDuration')}>
{t('sidebar.tasks.stats.taskDuration')}: {formatDuration(duration.durationMs, duration.incomplete)}
{duration.incomplete && ` (${t('sidebar.tasks.stats.partial')})`}
</span>}
</div>
<p>
{task.error ??
(task.completedAt
? t('task.completedAt', {
time: new Date(
task.completedAt
).toLocaleString(locale)
})
: task.startedAt
? t('sidebar.tasks.startedAt', {
time: new Date(
task.startedAt
).toLocaleString(locale)
})
: schedule
? t('sidebar.tasks.nextRunAt', {
time: new Date(
schedule.nextRunAt
).toLocaleString(locale)
})
: t('sidebar.tasks.notStarted'))}
</p>
{approvalsByTask.get(task.id)?.map((approval) => (
<SidebarApproval key={approval.approvalId} approval={approval} />
))}
{schedule && (
<div className="task-center__actions">
<TaskScheduleActions
onError={setActionError}
onRemoveSchedule={onRemoveSchedule}
onRunSchedule={onRunSchedule}
onSetScheduleEnabled={onSetScheduleEnabled}
schedule={schedule}
taskStatus={task.status}
taskTitle={task.title}
/>
</div>
)}
</article>
)
})
)}
</div>
</>
)}
</section>
)}
{instance.appId === 'workspace' && (
<>
{currentWorkspacePreview && (
<section
aria-busy={currentWorkspacePreview.state === 'loading' || workspacePreviewRefreshing}
className="assistant-sidebar__preview"
>
<header>
<button
ref={workspaceBackRef}
aria-label={t('sidebar.workspace.back')}
className="assistant-sidebar__back"
onClick={() => {
workspacePreviewRequest.current += 1
setWorkspacePreview(undefined)
setWorkspacePreviewLoadingMore(false)
setWorkspacePreviewRefreshing(false)
setWorkspacePreviewLoadMoreError('')
setActionError('')
}}
type="button"
>
<ChevronLeft size={14} />
{t('sidebar.workspace.title')}
</button>
<span>
<strong title={currentWorkspacePreview.path}>{currentWorkspacePreview.path}</strong>
<small>
{currentWorkspacePreview.state === 'ready'
? t('sidebar.workspace.fileSize', {
formattedSize:
currentWorkspacePreview.file.size.toLocaleString(
locale
)
})
: t('sidebar.workspace.fileFallback')}
</small>
</span>
</header>
<div className="workspace-files__toolbar workspace-files__preview-actions">
{currentProject?.executionSpace?.kind !== 'ssh' && (
<button className="secondary-button" type="button" onClick={() => {
void runAction(() => onOpenWorkspaceEntry(currentWorkspacePreview.path, 'file'), t('files.errors.openFile'))
}}><ExternalLink size={14} />{t('sidebar.workspace.openFile')}</button>
)}
<button className="secondary-button" type="button" onClick={() => {
void runAction(() => window.goodbuddy.clipboard.writeText(currentWorkspacePreview.path), t('sidebar.workspace.copyError'))
}}><Copy size={14} />{t('sidebar.workspace.copyPath')}</button>
{currentWorkspacePreview.state === 'ready' && (
<button className="secondary-button" disabled={workspacePreviewRefreshing || workspacePreviewLoadingMore} type="button" onClick={() => openWorkspaceFile(currentWorkspacePreview.path, true)}>
<RefreshCw size={14} />{t(workspacePreviewRefreshing ? 'sidebar.workspace.refreshing' : 'sidebar.workspace.refresh')}
</button>
)}
{currentWorkspacePreview.file?.mimeType === 'text/markdown' && (
<SegmentedControl ariaLabel={t('sidebar.workspace.title')} value={workspaceSource ? 'source' : 'preview'} onChange={(value) => setWorkspaceSource(value === 'source')} options={[
{ value: 'preview', label: t('sidebar.workspace.preview') },
{ value: 'source', label: t('sidebar.workspace.source') }
]} />
)}
</div>
{currentWorkspacePreview.state === 'loading' ? (
<p
aria-label={t('sidebar.workspace.reading')}
aria-live="polite"
className="assistant-sidebar__empty"
role="status"
>
{t('sidebar.workspace.reading')}
</p>
) : currentWorkspacePreview.state === 'error' ? (
<div className="assistant-sidebar__empty" role="alert">
<p>{currentWorkspacePreview.error}</p>
<button
className="secondary-button"
onClick={() =>
openWorkspaceFile(currentWorkspacePreview.path)
}
type="button"
>
<RefreshCw aria-hidden="true" size={13} />
{t('sidebar.workspace.refresh')}
</button>
</div>
) : (
<>
<div className="markdown-body markdown-content">
{currentWorkspacePreview.file.mimeType ===
'text/markdown' && !workspaceSource ? (
<MarkdownRenderer>
{currentWorkspacePreview.file.content}
</MarkdownRenderer>
) : (
<pre>{currentWorkspacePreview.file.content}</pre>
)}
</div>
{currentWorkspacePreview.file.truncated && (
<div className="assistant-sidebar__preview-more">
<p>
{t('sidebar.workspace.partialPreview', {
loaded: currentWorkspacePreview.file
.nextOffsetBytes.toLocaleString(locale),
total:
currentWorkspacePreview.file.size.toLocaleString(
locale
)
})}
</p>
<button
className="secondary-button"
disabled={workspacePreviewLoadingMore || workspacePreviewRefreshing}
onClick={loadMoreWorkspaceFile}
type="button"
>
{workspacePreviewLoadingMore
? t('sidebar.workspace.loadingMore')
: t('sidebar.workspace.loadMore')}
</button>
</div>
)}
{workspacePreviewLoadMoreError && (
<p
className="assistant-sidebar__preview-more-error"
role="alert"
>
{workspacePreviewLoadMoreError}
</p>
)}
</>
)}
</section>
)}
<section ref={workspaceListRef} hidden={workspacePreviewVisible} className="assistant-sidebar__section workspace-files__navigation" onClickCapture={(event) => {
if (event.target instanceof Element) workspaceTrigger.current = event.target.closest('button') ?? undefined
}}>
<WorkspaceFilesPanel
notify={notify}
rootPath={currentProject?.rootPath}
onRefresh={onRefreshChanges}
changedFiles={workspaceChanges?.files ?? emptyChangedFiles}
gitError={workspaceChanges?.error}
isRepository={workspaceChanges?.available}
key={workspaceProjectId ?? 'none'}
refreshToken={workspaceChanges}
onLoadDiff={onLoadWorkspaceDiff}
onListDirectory={onListWorkspaceDirectory}
onOpenEntry={currentProject?.executionSpace?.kind === 'ssh' ? undefined : onOpenWorkspaceEntry}
onOpenFile={openWorkspaceFile}
projectId={workspaceProjectId}
/>
</section>
</>
)}
{instance.appId === 'browser' && (
<section
className="assistant-sidebar__browser"
key={instance.id}
>
<BrowserToolbar
activeConversationId={
instance.targetRef?.type === 'conversation'
? instance.targetRef.conversationId
: activeConversationId
}
browserState={
instance.targetRef?.type === 'conversation' &&
browserTabIds[instance.id]
? browserStates[instance.targetRef.conversationId]?.[
browserTabIds[instance.id]!
]
: undefined
}
fullscreen={browserFullscreenActive}
onBack={() =>
runAction(
async () => {
const binding = await ensureBrowserTab(instance)
if (binding) {
await onBackBrowser(binding.conversationId, binding.tabId)
}
},
t('sidebar.errors.backBrowser')
)
}
onNavigate={(url) =>
runAction(
async () => {
const binding = await ensureBrowserTab(instance)
if (binding) {
await onNavigateBrowser(
binding.conversationId,
binding.tabId,
url
)
}
},
t('sidebar.errors.navigateBrowser')
)
}
onReload={() =>
runAction(
async () => {
const binding = await ensureBrowserTab(instance)
if (binding) {
await onReloadBrowser(binding.conversationId, binding.tabId)
}
},
t('sidebar.errors.reloadBrowser')
)
}
onStopLoading={() =>
runAction(
async () => {
const binding = await ensureBrowserTab(instance)
if (binding) {
await onStopLoadingBrowser(
binding.conversationId,
binding.tabId
)
}
},
t('sidebar.errors.stopLoadingBrowser')
)
}
onToggleFullscreen={() =>
toggleBrowserFullscreen()
}
/>
<BrowserViewport
browserState={
instance.targetRef?.type === 'conversation' &&
browserTabIds[instance.id]
? browserStates[instance.targetRef.conversationId]?.[
browserTabIds[instance.id]!
]
: undefined
}
conversationId={
instance.targetRef?.type === 'conversation'
? instance.targetRef.conversationId
: undefined
}
tabId={browserTabIds[instance.id]}
visible={open && instance.id === activeWorkbarInstanceId}
/>
</section>
)}
</div>
)}
/>
{terminalCloseConfirmation ? (
<div className="assistant-sidebar__overlay">
<section
aria-label={t('sidebar.terminal.closeDialog.ariaLabel')}
className="assistant-sidebar__terminal-close"
onKeyDown={(event) => {
if (
event.key === 'Escape' &&
!terminalCloseConfirmation.closing
) {
event.preventDefault()
cancelTerminalClose()
}
}}
role="alertdialog"
>
<h2>{t('sidebar.terminal.closeDialog.title')}</h2>
<p>{t('sidebar.terminal.closeDialog.description')}</p>
{terminalCloseConfirmation.error ? (
<p role="alert">{terminalCloseConfirmation.error}</p>
) : null}
<div>
<button
className="secondary-button"
disabled={terminalCloseConfirmation.closing}
onClick={cancelTerminalClose}
ref={terminalCloseCancelRef}
type="button"
>
{t('sidebar.terminal.closeDialog.cancel')}
</button>
<button
className="danger-button"
disabled={terminalCloseConfirmation.closing}
onClick={() => void confirmTerminalClose()}
type="button"
>
{terminalCloseConfirmation.closing
? t('sidebar.terminal.closeDialog.closing')
: t('sidebar.terminal.closeDialog.confirm')}
</button>
</div>
</section>
</div>
) : null}
</aside>
)
}
/** Memoized: App re-renders on chat updates; this view re-renders only when its props change. */
export const RightAssistantSidebar = memo(RightAssistantSidebarView)