2610 lines
95 KiB
TypeScript
2610 lines
95 KiB
TypeScript
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)
|