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

2011 lines
64 KiB
TypeScript

import {
ArrowLeft,
ArrowRight,
ChevronLeft,
ChevronRight,
CircleAlert,
Copy,
ExternalLink,
FileText,
ClockFading,
Maximize2,
Minimize2,
Plus,
RefreshCw,
ShieldAlert,
Square,
Upload
} from 'lucide-react'
import {
lazy,
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantSchedule,
AssistantTask,
AssistantProject,
WorkspaceChanges,
WorkspaceDirectoryListing,
WorkspaceFilePreview
} from '../../shared/assistant-contracts'
import { MarkdownRenderer } from './MarkdownRenderer'
import type {
ApprovalDecision,
BrowserLiveState
} from '../../shared/contracts'
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
import { SegmentedControl } from './WorkspacePrimitives'
import {
findTaskSchedule,
TaskScheduleActions
} from './TaskScheduleActions'
import type {
TerminalSnapshot
} from '../../shared/terminal-contracts'
import {
WORKBAR_APP_DEFINITIONS,
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'
const TerminalPanel = lazy(async () => {
const module = await import('./TerminalPanel')
return { default: module.TerminalPanel }
})
export type AssistantSidebarTab =
| 'tasks'
| 'workspace'
| 'browser'
| 'results'
export type SidebarArtifact = {
id: string
title: string
content: string
createdAt: number
mimeType: string
}
export type PendingSidebarApproval = {
conversationId: string
messageId: string
approvalId: string
title: string
description: string
toolName?: string
}
type RightAssistantSidebarProps = {
open: boolean
tab: AssistantSidebarTab
approvals: PendingSidebarApproval[]
artifacts: SidebarArtifact[]
schedules: AssistantSchedule[]
tasks: AssistantTask[]
conversationTitles: ReadonlyMap<string, string>
projectNames: ReadonlyMap<string, string>
selectedTaskId?: string
workspaceChanges?: WorkspaceChanges
workspaceProjectId?: string
activeConversationId?: string
browserState?: BrowserLiveState
currentProject?: AssistantProject
restoreFocusRef?: { current: HTMLElement | null }
onBackBrowser?: () => Promise<void>
onNavigateBrowser?: (url: string) => Promise<void>
onReloadBrowser?: () => Promise<void>
onStopLoadingBrowser?: () => Promise<void>
onCreateCustomTask: () => void
onImportArtifacts: () => Promise<void>
onLoadArtifact: (artifactId: string) => Promise<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>
onRespondApproval: (
approval: PendingSidebarApproval,
decision: ApprovalDecision
) => 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',
'results'
]
const emptyChangedFiles: WorkspaceChanges['files'] = []
const defaultSidebarRatio = 0.3
const minimumPaneWidth = 160
const keyboardResizeStep = 16
const workbarStorageKey = 'goodbuddy.workbar-layout.v1'
function loadPersistedWorkbarLayout(): ReturnType<
typeof workbarLayoutPreferencesSchema.parse
> | undefined {
try {
const value = localStorage.getItem(workbarStorageKey)
if (!value || value.length > 100_000) {
return undefined
}
return workbarLayoutPreferencesSchema.parse(JSON.parse(value))
} 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
})
)
)
} 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 ||
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} />
<span>{t('sidebar.browser.toolbar.go')}</span>
</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,
visible
}: {
browserState?: BrowserLiveState
conversationId?: string
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 || !browserApi?.setViewport) {
return
}
let animationFrame: number | undefined
let lastRequest = ''
let resizeObserver: ResizeObserver | undefined
const panel = host.closest<HTMLElement>('[role="tabpanel"]')
const syncViewport = (): void => {
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame)
}
animationFrame = requestAnimationFrame(() => {
animationFrame = undefined
const rect = host.getBoundingClientRect()
const isVisible =
visible &&
Boolean(conversationId) &&
panel?.hidden !== true &&
rect.width >= 1 &&
rect.height >= 1
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, bounds })
if (request === lastRequest) {
return
}
lastRequest = request
void browserApi
.setViewport(isVisible ? conversationId : undefined, bounds)
.catch(() => undefined)
})
}
const updateObservation = (): void => {
if (panel?.hidden !== false) {
resizeObserver?.disconnect()
resizeObserver = undefined
syncViewport()
return
}
if (!resizeObserver && typeof ResizeObserver === 'function') {
resizeObserver = new ResizeObserver(syncViewport)
resizeObserver.observe(host)
}
syncViewport()
}
const mutationObserver = new MutationObserver(updateObservation)
if (panel) {
mutationObserver.observe(panel, {
attributes: true,
attributeFilter: ['hidden']
})
}
window.addEventListener('resize', syncViewport)
window.addEventListener('scroll', syncViewport, true)
updateObservation()
return () => {
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame)
}
resizeObserver?.disconnect()
mutationObserver.disconnect()
window.removeEventListener('resize', syncViewport)
window.removeEventListener('scroll', syncViewport, true)
void browserApi.setViewport().catch(() => undefined)
}
}, [conversationId, 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>
)
}
export function RightAssistantSidebar({
open,
tab,
approvals,
artifacts,
schedules,
tasks,
conversationTitles,
projectNames,
selectedTaskId,
workspaceChanges,
workspaceProjectId,
activeConversationId,
browserState,
currentProject,
restoreFocusRef,
onBackBrowser = async () => {},
onNavigateBrowser = async () => {},
onReloadBrowser = async () => {},
onStopLoadingBrowser = async () => {},
onCreateCustomTask,
onImportArtifacts,
onLoadArtifact,
onRefreshChanges,
onLoadWorkspaceDiff,
onListWorkspaceDirectory,
onLoadWorkspaceFile,
onOpenWorkspaceEntry,
onRemoveSchedule,
onRespondApproval,
onRunSchedule,
onSetScheduleEnabled,
onOpenTask,
onTabChange
}: RightAssistantSidebarProps): React.JSX.Element {
const { i18n, t } = useTranslation('workspace')
const locale = i18n.resolvedLanguage || 'zh-CN'
const sidebarTimeFormatter = useMemo(
() =>
new Intl.DateTimeFormat(locale, {
hour: '2-digit',
minute: '2-digit'
}),
[locale]
)
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.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]
)
const defaultInstances = useMemo(
() =>
DEFAULT_WORKBAR_INSTANCES.map((instance) => ({
...instance,
title:
localizedAppDefinitions.find(
(definition) => definition.id === instance.appId
)?.label ?? instance.title
})),
[localizedAppDefinitions]
)
const [workbarInstances, setWorkbarInstances] = useState<
WorkbarTabInstance[]
>(() =>
(initialLayout?.instances ?? defaultInstances).map((instance) =>
instance.appId === 'terminal'
? instance
: {
...instance,
title:
localizedAppDefinitions.find(
(definition) => definition.id === instance.appId
)?.label ?? instance.title
}
)
)
const [activeWorkbarInstanceId, setActiveWorkbarInstanceId] =
useState<string | null>(
() =>
initialLayout?.activeInstanceId ??
defaultInstances.find((instance) => instance.appId === tab)?.id ??
defaultInstances[0]?.id ??
null
)
const activeWorkbarApp = workbarInstances.find(
(instance) => instance.id === activeWorkbarInstanceId
)?.appId
const [splitLayoutWidth, setSplitLayoutWidth] = useState(
window.innerWidth
)
const [sidebarRatio, setSidebarRatio] = useState(
initialLayout?.widthRatio ?? defaultSidebarRatio
)
const [isResizing, setIsResizing] = useState(false)
const [browserFullscreen, setBrowserFullscreen] = useState(false)
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 [selectedArtifactId, setSelectedArtifactId] = useState<string>()
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<
'attention' | 'active' | 'paused' | 'finished'
>('active')
const [actionError, setActionError] = useState('')
const [terminalSessionIds, setTerminalSessionIds] = useState<
Record<string, string>
>({})
const [terminalSnapshots, setTerminalSnapshots] = useState<
Record<string, TerminalSnapshot>
>({})
const [terminalCloseConfirmation, setTerminalCloseConfirmation] =
useState<{
instance: WorkbarTabInstance
resolve: (accepted: boolean) => void
closing: boolean
error?: string
}>()
const terminalCloseCancelRef = useRef<HTMLButtonElement>(null)
const lastExternalTabRef = useRef(tab)
const artifactPreview =
artifacts.find((artifact) => artifact.id === selectedArtifactId)
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' && browserFullscreen
const sidebarWidthLimits = getSidebarWidthLimits(splitLayoutWidth)
const canResize =
open &&
!browserFullscreenActive &&
sidebarWidthLimits.maximum > sidebarWidthLimits.minimum
const topLevelTasks = useMemo(
() => tasks.filter((task) => !task.parentTaskId),
[tasks]
)
const filteredTasks = useMemo(
() =>
topLevelTasks.filter((task) => {
if (taskFilter === 'attention') {
return (
task.status === 'waiting_approval' ||
task.status === 'failed' ||
task.status === 'interrupted'
)
}
if (taskFilter === 'active') {
return (
task.status === 'idle' ||
task.status === 'queued' ||
task.status === 'running'
)
}
if (taskFilter === 'paused') {
return task.status === 'paused'
}
return task.status === 'completed' || task.status === 'cancelled'
}),
[taskFilter, topLevelTasks]
)
useEffect(() => {
if (lastExternalTabRef.current === tab) {
return
}
lastExternalTabRef.current = tab
const requested = workbarInstances.find(
(instance) => instance.appId === tab
)
if (requested) {
// This effect intentionally mirrors the legacy external tab prop into
// the dynamic workbar only when that prop actually changes.
// eslint-disable-next-line react-hooks/set-state-in-effect
setActiveWorkbarInstanceId(requested.id)
}
}, [tab, workbarInstances])
const toggleBrowserFullscreen = useCallback((): void => {
setBrowserFullscreen((current) => !current)
}, [])
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): void => {
setActiveWorkbarInstanceId(instanceId)
const instance = workbarInstances.find(
(candidate) => candidate.id === instanceId
)
if (
instance &&
instance.appId !== 'terminal'
) {
onTabChange(instance.appId)
}
},
[onTabChange, workbarInstances]
)
const createWorkbarInstance = useCallback(
(request: WorkbarInstanceCreateRequest): void => {
const definition = localizedAppDefinitions.find(
(candidate) => candidate.id === request.appId
)
if (!definition) {
return
}
const sameTargetTerminals = workbarInstances.filter(
(instance) =>
instance.appId === 'terminal' &&
JSON.stringify(instance.targetRef) ===
JSON.stringify(request.targetRef)
).length
const instance: WorkbarTabInstance = {
id: crypto.randomUUID(),
appId: request.appId,
title:
request.appId === 'terminal'
? `${definition.label} ${sameTargetTerminals + 1}`
: definition.label,
...(request.targetRef
? { targetRef: request.targetRef }
: {})
}
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)
},
[localizedAppDefinitions, 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 requestCloseWorkbarInstance = useCallback(
async (instance: WorkbarTabInstance): Promise<boolean> => {
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
},
[
removeWorkbarInstance,
terminalAdapter,
terminalSessionIds,
terminalSnapshots
]
)
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()
setBrowserFullscreen(false)
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
})
setSelectedArtifactId(undefined)
}
})
.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
}
}
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()
setBrowserFullscreen(false)
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={workbarInstances}
onActiveInstanceChange={updateActiveWorkbarInstance}
onCloseInstance={requestCloseWorkbarInstance}
onCreateInstance={createWorkbarInstance}
onResolveTerminalTarget={resolveTerminalTarget}
renderTabAdornment={(instance) =>
instance.appId === 'tasks' && approvals.length > 0 ? (
<span
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${approvals.length}`}
className="assistant-sidebar__badge"
>
{approvals.length}
</span>
) : null
}
renderPanel={(instance) => (
<div
className={`assistant-sidebar__body${instance.appId === 'browser' ? ' assistant-sidebar__body--browser' : ''}`}
>
{actionError ? (
<p className="settings-error" role="alert">
{actionError}
</p>
) : null}
{instance.appId === 'terminal' && instance.targetRef ? (
<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 }
: 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">
<h3>
<ShieldAlert size={15} />
{t('sidebar.tasks.approvalsTitle')}
</h3>
{approvals.length === 0 ? (
<p className="assistant-sidebar__empty">
{t('sidebar.tasks.noApprovals')}
</p>
) : (
approvals.map((approval) => (
<article
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${
approval.toolName ?? approval.title
}`}
className="assistant-sidebar__approval"
key={approval.approvalId}
>
<strong>{approval.title}</strong>
<p>{approval.description}</p>
{approval.toolName && <code>{approval.toolName}</code>}
<div className="assistant-sidebar__approval-actions">
<button
className="secondary-button"
onClick={() =>
onRespondApproval(approval, 'deny')
}
type="button"
>
{t('sidebar.tasks.deny')}
</button>
<button
className="primary-button"
onClick={() =>
onRespondApproval(approval, 'once')
}
type="button"
>
{t('sidebar.tasks.allowOnce')}
</button>
</div>
</article>
))
)}
<div className="task-center__index-heading">
<h3>
<ClockFading size={15} />
{t('sidebar.tasks.taskIndexTitle')}
</h3>
<button
className="secondary-button task-center__create"
onClick={onCreateCustomTask}
type="button"
>
<Plus aria-hidden="true" size={13} />
{t('taskStrip.create')}
</button>
</div>
<div className="task-center__filters">
<SegmentedControl
ariaLabel={t('sidebar.tasks.filters.ariaLabel')}
onChange={setTaskFilter}
options={[
{
value: 'attention',
label: t('sidebar.tasks.filters.attention')
},
{
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>
{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 = findTaskSchedule(task, schedules)
const conversationTitle = task.conversationId
? conversationTitles.get(task.conversationId)
: undefined
const projectName = task.projectId
? projectNames.get(task.projectId)
: undefined
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)}
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>
{projectName
? t('sidebar.tasks.projectScope', {
project: projectName
})
: t('sidebar.tasks.globalScope')}
</span>
<span>
{schedule
? t(`task.mode.${schedule.workMode}`)
: task.workMode
? t(`task.mode.${task.workMode}`)
: t('task.mode.unavailable')}
</span>
<span>{t(`task.status.${task.status}`)}</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>
{schedule && (
<div className="task-center__actions">
<TaskScheduleActions
onError={setActionError}
onRemoveSchedule={onRemoveSchedule}
onRunSchedule={onRunSchedule}
onSetScheduleEnabled={onSetScheduleEnabled}
schedule={schedule}
taskTitle={task.title}
/>
</div>
)}
</article>
)
})
)}
</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
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 === 'results' && (
artifactPreview ? (
<section className="assistant-sidebar__preview">
<header>
<button
aria-label={t('sidebar.results.back')}
className="assistant-sidebar__back"
onClick={() => {
setSelectedArtifactId(undefined)
setActionError('')
}}
type="button"
>
<ChevronLeft size={14} />
{t('sidebar.results.title')}
</button>
<span>
<strong>{artifactPreview.title}</strong>
<small>
{sidebarTimeFormatter.format(
new Date(artifactPreview.createdAt)
)}
</small>
</span>
</header>
<div className="markdown-body markdown-content">
{artifactPreview.mimeType.startsWith('image/') ? (
artifactPreview.content ? (
<img
alt={artifactPreview.title}
className="assistant-sidebar__image-preview"
src={artifactPreview.content}
/>
) : (
<p className="assistant-sidebar__empty">
{t('sidebar.results.loadingImage')}
</p>
)
) : artifactPreview.mimeType === 'text/html' ? (
<iframe
className="assistant-sidebar__web-preview"
sandbox=""
srcDoc={artifactPreview.content}
title={artifactPreview.title}
/>
) : artifactPreview.mimeType === 'application/json' ? (
<pre>{artifactPreview.content}</pre>
) : (
<MarkdownRenderer>
{artifactPreview.content}
</MarkdownRenderer>
)}
</div>
</section>
) : (
<section className="assistant-sidebar__section">
<h3>
<FileText size={15} />
{t('sidebar.results.sectionTitle')}
</h3>
<button
className="secondary-button assistant-sidebar__import"
onClick={() =>
void runAction(
onImportArtifacts,
t('sidebar.errors.importResult')
)
}
type="button"
>
<Upload size={13} />
{t('sidebar.results.import')}
</button>
{artifacts.length === 0 ? (
<p className="assistant-sidebar__empty">
{t('sidebar.results.empty')}
</p>
) : (
artifacts.map((artifact) => (
<button
className="assistant-sidebar__row"
key={artifact.id}
onClick={() => {
setSelectedArtifactId(artifact.id)
setActionError('')
void runAction(
() => onLoadArtifact(artifact.id),
t('sidebar.errors.loadResult')
)
}}
type="button"
>
<FileText size={15} />
<span>
<strong>{artifact.title}</strong>
<small>
{sidebarTimeFormatter.format(
new Date(artifact.createdAt)
)}
</small>
</span>
<ChevronRight size={14} />
</button>
))
)}
</section>
)
)}
{instance.appId === 'browser' && (
<section
className="assistant-sidebar__browser"
key={activeConversationId}
>
<BrowserToolbar
activeConversationId={activeConversationId}
browserState={browserState}
fullscreen={browserFullscreenActive}
onBack={() =>
runAction(
onBackBrowser,
t('sidebar.errors.backBrowser')
)
}
onNavigate={(url) =>
runAction(
() => onNavigateBrowser(url),
t('sidebar.errors.navigateBrowser')
)
}
onReload={() =>
runAction(
onReloadBrowser,
t('sidebar.errors.reloadBrowser')
)
}
onStopLoading={() =>
runAction(
onStopLoadingBrowser,
t('sidebar.errors.stopLoadingBrowser')
)
}
onToggleFullscreen={() =>
toggleBrowserFullscreen()
}
/>
<BrowserViewport
browserState={browserState}
conversationId={activeConversationId}
visible={open}
/>
</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>
)
}