codev / desktop /src /components /workspace /WorkspacePanel.tsx
chenbhao's picture
feat: desktop
1f21206
Raw
History Blame
58 kB
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'
import { MessageCircle } from 'lucide-react'
import { Highlight } from 'prism-react-renderer'
import type {
WorkspaceChangedFile,
WorkspaceFileStatus,
WorkspaceTreeEntry,
WorkspaceTreeResult,
} from '../../api/sessions'
import { useTranslation } from '../../i18n'
import { useShallow } from 'zustand/react/shallow'
import {
useWorkspacePanelStore,
type WorkspacePreviewCloseScope,
type WorkspacePreviewKind,
type WorkspacePreviewTab,
} from '../../stores/workspacePanelStore'
import { useChatStore } from '../../stores/chatStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { useUIStore } from '../../stores/uiStore'
import { copyTextToClipboard } from '../chat/clipboard'
import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import {
getFileExtension,
normalizePrismLanguage,
WORKSPACE_PREVIEW_LINE_LIMIT,
WorkspaceDiffSurface,
workspacePrismTheme,
} from './WorkspaceCodeSurface'
type WorkspacePanelProps = {
sessionId: string
}
type TreeNodeProps = {
sessionId: string
entry: WorkspaceTreeEntry
depth: number
expandedPaths: Set<string>
treeByPath: Record<string, WorkspaceTreeResult | undefined>
treeLoadingByPath: Record<string, boolean | undefined>
treeErrorsByPath: Record<string, string | null | undefined>
filterQuery: string
onToggle: (path: string) => void
onOpenFile: (path: string) => void
onFileContextMenu: (event: MouseEvent, path: string) => void
activePath: string | null
}
const FILE_STATUS_META: Record<WorkspaceFileStatus, { label: string; className: string }> = {
modified: {
label: 'M',
className: 'border-[var(--color-warning)]/35 bg-[var(--color-warning)]/12 text-[var(--color-warning)]',
},
added: {
label: 'A',
className: 'border-[var(--color-success)]/35 bg-[var(--color-success)]/12 text-[var(--color-success)]',
},
deleted: {
label: 'D',
className: 'border-[var(--color-error)]/35 bg-[var(--color-error)]/12 text-[var(--color-error)]',
},
renamed: {
label: 'R',
className: 'border-[var(--color-brand)]/35 bg-[var(--color-brand)]/12 text-[var(--color-brand)]',
},
untracked: {
label: 'U',
className: 'border-[var(--color-tertiary)]/35 bg-[var(--color-tertiary)]/12 text-[var(--color-tertiary)]',
},
copied: {
label: 'C',
className: 'border-[var(--color-secondary)]/35 bg-[var(--color-secondary)]/12 text-[var(--color-secondary)]',
},
type_changed: {
label: 'T',
className: 'border-[var(--color-outline)]/45 bg-[var(--color-outline)]/10 text-[var(--color-text-secondary)]',
},
unknown: {
label: '?',
className: 'border-[var(--color-outline)]/45 bg-[var(--color-outline)]/10 text-[var(--color-text-secondary)]',
},
}
const EMPTY_TREE_BY_PATH: Record<string, WorkspaceTreeResult | undefined> = {}
const EMPTY_PREVIEW_TABS: WorkspacePreviewTab[] = []
const EMPTY_EXPANDED_PATHS: string[] = []
const SELECTION_MENU_OFFSET = 10
const SELECTION_MENU_WIDTH = 158
const SELECTION_MENU_HEIGHT = 44
const FILE_BADGE_META: Record<string, { label: string; className: string }> = {
ts: { label: 'TS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' },
tsx: { label: 'TSX', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' },
js: { label: 'JS', className: 'bg-[var(--color-warning)]/16 text-[var(--color-warning)]' },
jsx: { label: 'JSX', className: 'bg-[var(--color-warning)]/16 text-[var(--color-warning)]' },
json: { label: '{}', className: 'bg-[var(--color-tertiary)]/14 text-[var(--color-tertiary)]' },
md: { label: 'MD', className: 'bg-[var(--color-text-tertiary)]/14 text-[var(--color-text-secondary)]' },
css: { label: 'CSS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' },
html: { label: 'H', className: 'bg-[var(--color-brand)]/14 text-[var(--color-brand)]' },
png: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' },
jpg: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' },
jpeg: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' },
gif: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' },
svg: { label: 'SVG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' },
}
function makeTreeStateKey(sessionId: string, path: string) {
return `${sessionId}::${path}`
}
function makePreviewStateKey(sessionId: string, tabId: string) {
return `${sessionId}::${tabId}`
}
function getSessionScopedRecord<T>(
record: Record<string, T>,
sessionId: string,
) {
const prefix = `${sessionId}::`
return Object.fromEntries(
Object.entries(record).filter(([key]) => key.startsWith(prefix)),
) as Record<string, T>
}
function getPreviewKindLabel(
t: ReturnType<typeof useTranslation>,
kind: WorkspacePreviewKind,
) {
return kind === 'diff' ? t('workspace.previewKind.diff') : t('workspace.previewKind.file')
}
function getFileBadgeMeta(name: string) {
const extension = getFileExtension(name)
return FILE_BADGE_META[extension] ?? {
label: extension ? extension.slice(0, 3).toUpperCase() : 'TXT',
className: 'bg-[var(--color-text-tertiary)]/12 text-[var(--color-text-secondary)]',
}
}
function resolveWorkspaceAttachmentPath(workDir: string | undefined, filePath: string) {
if (!workDir || filePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(filePath)) return filePath
return `${workDir.replace(/[\\/]+$/, '')}/${filePath.replace(/^[/\\]+/, '')}`
}
function isMarkdownPreview(tab: WorkspacePreviewTab) {
if (tab.kind !== 'file') return false
const language = (tab.language ?? '').toLowerCase()
const extension = getFileExtension(tab.path)
return language === 'markdown' || language === 'md' || extension === 'md' || extension === 'markdown'
}
function FileTypeBadge({ name, subtle = false }: { name: string; subtle?: boolean }) {
const meta = getFileBadgeMeta(name)
return (
<span
className={`inline-flex h-[18px] min-w-[18px] shrink-0 items-center justify-center rounded-[5px] px-1 font-[var(--font-label)] text-[9px] font-semibold leading-none ${meta.className} ${subtle ? 'opacity-55 grayscale' : ''}`}
aria-hidden="true"
>
{meta.label}
</span>
)
}
function getInlineStateMessage(
t: ReturnType<typeof useTranslation>,
state: WorkspacePreviewTab['state'] | WorkspaceTreeResult['state'] | 'not_git_repo' | undefined,
fallbackError?: string | null,
) {
switch (state) {
case 'loading':
return t('workspace.previewState.loading')
case 'binary':
return t('workspace.previewState.binary')
case 'too_large':
return t('workspace.previewState.tooLarge')
case 'missing':
return t('workspace.previewState.missing')
case 'not_git_repo':
return t('workspace.notGitRepo')
case 'error':
return fallbackError || t('workspace.loadError')
default:
return fallbackError || t('workspace.loadError')
}
}
function normalizeFilterQuery(query: string) {
return query.trim().toLowerCase()
}
function changedFileMatchesFilter(file: WorkspaceChangedFile, query: string) {
if (!query) return true
return (
file.path.toLowerCase().includes(query)
|| file.oldPath?.toLowerCase().includes(query)
|| file.status.toLowerCase().includes(query)
)
}
function treeEntryMatchesFilter(
entry: WorkspaceTreeEntry,
query: string,
treeByPath: Record<string, WorkspaceTreeResult | undefined>,
): boolean {
if (!query) return true
if (entry.name.toLowerCase().includes(query) || entry.path.toLowerCase().includes(query)) {
return true
}
if (!entry.isDirectory) return false
const childTree = treeByPath[entry.path]
if (childTree?.state !== 'ok') return false
return childTree.entries.some((child) => treeEntryMatchesFilter(child, query, treeByPath))
}
type WorkspaceTextSelection = {
text: string
startLine?: number
endLine?: number
}
type FloatingSelectionMenuState = WorkspaceTextSelection & {
x: number
y: number
}
type SelectionPointer = {
clientX: number
clientY: number
}
function getElementForNode(node: Node | null): Element | null {
if (!node) return null
return node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement
}
function getLineNumberFromNode(node: Node | null, root: HTMLElement) {
const element = getElementForNode(node)
const row = element?.closest('[data-workspace-line-number]')
if (!row || !root.contains(row)) return undefined
const line = Number(row.getAttribute('data-workspace-line-number'))
return Number.isFinite(line) ? line : undefined
}
function getSelectionPosition(range: Range, root: HTMLElement, pointer?: SelectionPointer) {
return getSelectionPopoverPosition(range, root, {
menuWidth: SELECTION_MENU_WIDTH,
menuHeight: SELECTION_MENU_HEIGHT,
offset: SELECTION_MENU_OFFSET,
fallbackPointer: pointer,
})
}
function getTextSelectionFromContainer(
root: HTMLElement | null,
resolveLines?: (text: string, range: Range) => { startLine?: number; endLine?: number },
pointer?: SelectionPointer,
): FloatingSelectionMenuState | null {
if (!root) return null
const selection = window.getSelection()
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null
const range = selection.getRangeAt(0)
const startElement = getElementForNode(range.startContainer)
const endElement = getElementForNode(range.endContainer)
if (!startElement || !endElement || !root.contains(startElement) || !root.contains(endElement)) {
return null
}
const text = selection.toString().trim()
if (!text) return null
const nodeLines = {
startLine: getLineNumberFromNode(range.startContainer, root),
endLine: getLineNumberFromNode(range.endContainer, root),
}
const resolvedLines = resolveLines?.(text, range) ?? nodeLines
const startLine = resolvedLines.startLine ?? nodeLines.startLine
const endLine = resolvedLines.endLine ?? nodeLines.endLine ?? startLine
const orderedStart = startLine && endLine ? Math.min(startLine, endLine) : startLine
const orderedEnd = startLine && endLine ? Math.max(startLine, endLine) : endLine
return {
...getSelectionPosition(range, root, pointer),
text,
...(orderedStart ? { startLine: orderedStart } : {}),
...(orderedEnd ? { endLine: orderedEnd } : {}),
}
}
function getLineRangeForText(value: string, text: string) {
const index = value.indexOf(text)
if (index < 0) return {}
const startLine = value.slice(0, index).split('\n').length
const endLine = startLine + text.split('\n').length - 1
return { startLine, endLine }
}
function FloatingSelectionMenu({
selection,
onAdd,
popoverRef,
}: {
selection: FloatingSelectionMenuState | null
onAdd: () => void
popoverRef: { current: HTMLButtonElement | null }
}) {
const t = useTranslation()
if (!selection) return null
return (
<button
ref={popoverRef}
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={onAdd}
className="fixed z-50 inline-flex h-11 items-center gap-2 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-lowest)] px-5 text-[15px] font-semibold text-[var(--color-text-primary)] shadow-[0_10px_28px_rgba(15,23,42,0.14),0_2px_8px_rgba(15,23,42,0.08)] transition-colors hover:bg-[var(--color-surface)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
style={{ left: selection.x, top: selection.y }}
>
<MessageCircle size={21} strokeWidth={2.15} className="shrink-0 text-[var(--color-text-primary)]" aria-hidden="true" />
<span>{t('workspace.addSelectionToChat')}</span>
</button>
)
}
function PanelMessage({
icon,
message,
tone = 'muted',
compact = false,
}: {
icon: string
message: string
tone?: 'muted' | 'error'
compact?: boolean
}) {
const toneClass =
tone === 'error'
? 'text-[var(--color-error)]'
: 'text-[var(--color-text-tertiary)]'
return (
<div
className={`flex items-center gap-2 px-4 ${compact ? 'py-2 text-[11px]' : 'py-8 text-xs'} ${toneClass}`}
role={tone === 'error' ? 'alert' : 'status'}
>
<span className={`material-symbols-outlined shrink-0 text-[16px] ${icon === 'progress_activity' ? 'animate-spin' : ''}`}>
{icon}
</span>
<span className="min-w-0 leading-relaxed">{message}</span>
</div>
)
}
function ToolbarIconButton({
icon,
label,
onClick,
}: {
icon: string
label: string
onClick: () => void
}) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className="inline-flex h-7 w-7 items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
>
<span className="material-symbols-outlined text-[16px]">{icon}</span>
</button>
)
}
function WorkspaceFilterInput({
value,
onChange,
}: {
value: string
onChange: (value: string) => void
}) {
const t = useTranslation()
return (
<div className="shrink-0 border-b border-[var(--color-border)] px-3 py-2">
<label className="flex h-8 items-center gap-2 rounded-[9px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 text-[var(--color-text-tertiary)] transition-colors focus-within:border-[var(--color-border-focus)] focus-within:ring-2 focus-within:ring-[var(--color-brand)]/10">
<span className="material-symbols-outlined shrink-0 text-[17px]">search</span>
<input
value={value}
onChange={(event) => onChange(event.target.value)}
aria-label={t('workspace.filterPlaceholder')}
placeholder={t('workspace.filterPlaceholder')}
className="min-w-0 flex-1 bg-transparent text-[13px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
{value.length > 0 && (
<button
type="button"
aria-label={t('workspace.clearFilter')}
onClick={() => onChange('')}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<span className="material-symbols-outlined text-[13px]">close</span>
</button>
)}
</label>
</div>
)
}
function FileStatusBadge({ status }: { status: WorkspaceFileStatus }) {
const meta = FILE_STATUS_META[status]
return (
<span
className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded border text-[10px] font-semibold ${meta.className}`}
aria-label={status}
>
{meta.label}
</span>
)
}
function CodeSurface({
value,
language,
onAddLineComment,
onAddSelection,
}: {
value: string
language: string
onAddLineComment: (line: number, note: string, quote: string) => void
onAddSelection: (selection: WorkspaceTextSelection) => void
}) {
const t = useTranslation()
const surfaceRef = useRef<HTMLDivElement>(null)
const selectionMenuRef = useRef<HTMLButtonElement>(null)
const [commentLine, setCommentLine] = useState<number | null>(null)
const [commentDraft, setCommentDraft] = useState('')
const [showAllLines, setShowAllLines] = useState(false)
const [selectionMenu, setSelectionMenu] = useState<FloatingSelectionMenuState | null>(null)
const lines = value.split('\n')
const visibleLines = showAllLines ? lines : lines.slice(0, WORKSPACE_PREVIEW_LINE_LIMIT)
const activeQuote = commentLine ? visibleLines[commentLine - 1] ?? '' : ''
const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PREVIEW_LINE_LIMIT
const visibleCode = usePlainLargePreview ? '' : visibleLines.join('\n')
useEffect(() => {
setShowAllLines(false)
setCommentLine(null)
setCommentDraft('')
setSelectionMenu(null)
}, [language, value])
const dismissSelectionMenu = useCallback(() => {
setSelectionMenu(null)
}, [])
useSelectionPopoverDismiss({
active: Boolean(selectionMenu),
popoverRef: selectionMenuRef,
onDismiss: dismissSelectionMenu,
})
const submitLineComment = () => {
if (!commentLine || !commentDraft.trim()) return
onAddLineComment(commentLine, commentDraft.trim(), activeQuote)
setCommentLine(null)
setCommentDraft('')
}
const handleSelectionMouseUp = (event: MouseEvent<HTMLDivElement>) => {
const selection = getTextSelectionFromContainer(surfaceRef.current, undefined, event)
if (!selection?.startLine || !selection.endLine || selection.startLine === selection.endLine) {
setSelectionMenu(selection)
return
}
setSelectionMenu({
...selection,
text: visibleLines.slice(selection.startLine - 1, selection.endLine).join('\n').trim(),
})
}
const addCurrentSelectionToChat = () => {
if (!selectionMenu) return
onAddSelection({
text: selectionMenu.text,
startLine: selectionMenu.startLine,
endLine: selectionMenu.endLine,
})
setSelectionMenu(null)
clearWindowSelection()
}
const renderLineCommentEditor = (lineNumber: number) => {
if (commentLine !== lineNumber) return null
return (
<div className="grid grid-cols-[48px_minmax(0,720px)] gap-3 bg-[var(--color-brand)]/10 px-3 py-2">
<span aria-hidden="true" />
<div className="rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-sm">
<div className="flex items-center gap-2 border-b border-[var(--color-border)] px-3 py-2">
<span className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">chat_bubble</span>
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">{t('workspace.localComment')}</span>
<span className="ml-auto text-[11px] text-[var(--color-text-tertiary)]">
{t('workspace.commentLineTarget', { line: lineNumber })}
</span>
</div>
<textarea
value={commentDraft}
onChange={(event) => setCommentDraft(event.target.value)}
autoFocus
rows={3}
placeholder={t('workspace.commentPlaceholder')}
className="block w-full resize-none bg-transparent px-3 py-3 text-[13px] leading-6 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
<div className="flex justify-end gap-2 px-3 pb-3">
<button
type="button"
onClick={() => {
setCommentLine(null)
setCommentDraft('')
}}
className="rounded-[7px] px-2.5 py-1 text-[12px] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={submitLineComment}
disabled={!commentDraft.trim()}
className="rounded-[7px] bg-[var(--color-text-primary)] px-2.5 py-1 text-[12px] font-medium text-[var(--color-surface)] disabled:cursor-not-allowed disabled:opacity-45"
>
{t('workspace.addCommentToChat')}
</button>
</div>
</div>
</div>
)
}
const renderLineNumberButton = (lineNumber: number) => (
<button
type="button"
aria-label={t('workspace.commentLine', { line: lineNumber })}
onClick={() => {
setCommentLine(lineNumber)
setCommentDraft('')
}}
className="select-none text-right text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-brand)] focus-visible:outline-none focus-visible:text-[var(--color-brand)]"
>
{lineNumber}
</button>
)
return (
<div
ref={surfaceRef}
className="min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]"
onMouseUp={handleSelectionMouseUp}
onKeyDown={(event) => {
if (event.key === 'Escape') setSelectionMenu(null)
}}
>
<div className="relative min-w-max py-2">
{usePlainLargePreview ? (
<pre
data-workspace-code=""
data-testid="workspace-code"
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55]"
style={{ color: 'var(--color-code-fg)', background: 'transparent' }}
>
{visibleLines.map((line, index) => {
const lineNumber = index + 1
return (
<div key={lineNumber}>
<div
className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]"
data-workspace-line-number={lineNumber}
>
{renderLineNumberButton(lineNumber)}
<span className="whitespace-pre pr-6">{line || ' '}</span>
</div>
{renderLineCommentEditor(lineNumber)}
</div>
)
})}
</pre>
) : (
<Highlight
theme={workspacePrismTheme}
code={visibleCode}
language={normalizePrismLanguage(language)}
>
{({ tokens, getLineProps, getTokenProps }) => (
<pre
data-workspace-code=""
data-testid="workspace-code"
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55]"
style={{ color: 'var(--color-code-fg)', background: 'transparent' }}
>
{tokens.map((line, index) => {
const { key: lineKey, ...lineProps } = getLineProps({ line, key: index })
const lineNumber = index + 1
return (
<div key={String(lineKey)}>
<div
{...lineProps}
data-workspace-line-number={lineNumber}
className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]"
>
{renderLineNumberButton(lineNumber)}
<span className="whitespace-pre pr-6">
{line.length === 1 && line[0]?.empty ? ' ' : line.map((token, tokenIndex) => {
const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
return <span key={String(tokenKey)} {...tokenProps} />
})}
</span>
</div>
{renderLineCommentEditor(lineNumber)}
</div>
)
})}
</pre>
)}
</Highlight>
)}
{lines.length > WORKSPACE_PREVIEW_LINE_LIMIT && (
<div className="sticky bottom-0 flex items-center gap-3 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
<span>
{showAllLines
? t('workspace.previewAllLines', { total: lines.length })
: t('workspace.previewLineLimit', { count: visibleLines.length, total: lines.length })}
</span>
<button
type="button"
onClick={() => setShowAllLines((current) => !current)}
className="ml-auto rounded-[6px] px-2 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
{showAllLines ? t('workspace.collapsePreview') : t('workspace.showAllLoadedLines')}
</button>
</div>
)}
</div>
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} />
</div>
)
}
function MarkdownSurface({
value,
onAddSelection,
}: {
value: string
onAddSelection: (selection: WorkspaceTextSelection) => void
}) {
const surfaceRef = useRef<HTMLDivElement>(null)
const selectionMenuRef = useRef<HTMLButtonElement>(null)
const [selectionMenu, setSelectionMenu] = useState<FloatingSelectionMenuState | null>(null)
useEffect(() => {
setSelectionMenu(null)
}, [value])
const dismissSelectionMenu = useCallback(() => {
setSelectionMenu(null)
}, [])
useSelectionPopoverDismiss({
active: Boolean(selectionMenu),
popoverRef: selectionMenuRef,
onDismiss: dismissSelectionMenu,
})
const handleSelectionMouseUp = (event: MouseEvent<HTMLDivElement>) => {
setSelectionMenu(getTextSelectionFromContainer(
surfaceRef.current,
(text) => getLineRangeForText(value, text),
event,
))
}
const addCurrentSelectionToChat = () => {
if (!selectionMenu) return
onAddSelection({
text: selectionMenu.text,
startLine: selectionMenu.startLine,
endLine: selectionMenu.endLine,
})
setSelectionMenu(null)
clearWindowSelection()
}
return (
<div
ref={surfaceRef}
className="min-h-0 flex-1 overflow-auto bg-[var(--color-surface)]"
onMouseUp={handleSelectionMouseUp}
onKeyDown={(event) => {
if (event.key === 'Escape') setSelectionMenu(null)
}}
>
<div className="mx-auto w-full max-w-[860px] px-6 py-5">
<MarkdownRenderer
content={value}
variant="document"
className="workspace-markdown-preview prose-p:text-[14px] prose-p:leading-7 prose-h1:text-[24px] prose-h2:text-[18px] prose-h3:text-[15px] prose-code:text-[12px] prose-pre:my-4"
/>
</div>
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} />
</div>
)
}
function ImagePreview({ tab }: { tab: WorkspacePreviewTab }) {
const t = useTranslation()
if (!tab.dataUrl) {
return (
<PanelMessage
icon="image_not_supported"
message={tab.error || t('workspace.imagePreviewUnavailable')}
/>
)
}
return (
<div className="min-h-0 flex-1 overflow-auto bg-[var(--color-surface)] p-4">
<div className="flex min-h-full items-center justify-center">
<img
src={tab.dataUrl}
alt={tab.path}
className="max-h-full max-w-full rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] object-contain shadow-sm"
/>
</div>
</div>
)
}
function ChangedFileRow({
file,
onClick,
onContextMenu,
}: {
file: WorkspaceChangedFile
onClick: () => void
onContextMenu: (event: MouseEvent, path: string) => void
}) {
return (
<button
type="button"
onClick={onClick}
onContextMenu={(event) => onContextMenu(event, file.path)}
className="mx-2 flex w-[calc(100%-16px)] items-center gap-3 rounded-[7px] px-2 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<FileStatusBadge status={file.status} />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-[var(--color-text-primary)]">{file.path}</div>
{file.oldPath && (
<div className="truncate text-[11px] text-[var(--color-text-tertiary)]">
{file.oldPath}
</div>
)}
</div>
<div className="shrink-0 text-right font-[var(--font-mono)] text-[11px] leading-4">
<div className="text-[var(--color-success)]">+{file.additions}</div>
<div className="text-[var(--color-error)]">-{file.deletions}</div>
</div>
</button>
)
}
function TreeNode({
sessionId,
entry,
depth,
expandedPaths,
treeByPath,
treeLoadingByPath,
treeErrorsByPath,
filterQuery,
onToggle,
onOpenFile,
onFileContextMenu,
activePath,
}: TreeNodeProps) {
const t = useTranslation()
const childTree = treeByPath[entry.path]
const childLoading = treeLoadingByPath[makeTreeStateKey(sessionId, entry.path)] ?? false
const childError = treeErrorsByPath[makeTreeStateKey(sessionId, entry.path)] ?? null
const isExpanded = expandedPaths.has(entry.path)
const isVisuallyExpanded = isExpanded || filterQuery.length > 0
const indent = 14 + depth * 20
if (!entry.isDirectory) {
const isActive = entry.path === activePath
return (
<button
type="button"
onClick={() => onOpenFile(entry.path)}
onContextMenu={(event) => onFileContextMenu(event, entry.path)}
className={`group mx-2 flex h-8 w-[calc(100%-16px)] items-center gap-2 rounded-[7px] pr-2 text-left transition-colors ${
isActive
? 'bg-[var(--color-surface-selected)] shadow-[inset_0_0_0_1.5px_var(--color-border-focus)]'
: 'hover:bg-[var(--color-surface-hover)]'
}`}
style={{ paddingLeft: indent }}
>
<FileTypeBadge name={entry.name} subtle={!isActive} />
<span className="min-w-0 truncate text-[14px] font-medium text-[var(--color-text-primary)]">{entry.name}</span>
</button>
)
}
return (
<div>
<button
type="button"
onClick={() => onToggle(entry.path)}
aria-expanded={isVisuallyExpanded}
className="group mx-2 flex h-8 w-[calc(100%-16px)] items-center gap-2 rounded-[7px] pr-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
style={{ paddingLeft: indent }}
>
<span className="material-symbols-outlined shrink-0 text-[18px] text-[var(--color-text-tertiary)] transition-colors group-hover:text-[var(--color-text-primary)]">
{isVisuallyExpanded ? 'expand_more' : 'chevron_right'}
</span>
<span className="min-w-0 truncate text-[15px] font-medium text-[var(--color-text-primary)]">{entry.name}</span>
</button>
{isVisuallyExpanded && (
<div className="relative">
{depth < 4 && (
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-1 top-1 w-px bg-[var(--color-border)]"
style={{ left: 28 + depth * 20 }}
/>
)}
{childLoading && !childTree && (
<PanelMessage
compact
icon="progress_activity"
message={t('common.loading')}
/>
)}
{!childLoading && childError && (
<PanelMessage compact icon="error" tone="error" message={childError} />
)}
{!childLoading && !childError && childTree?.state === 'missing' && (
<PanelMessage compact icon="folder_off" message={t('workspace.previewState.missing')} />
)}
{!childLoading && !childError && childTree?.state === 'error' && (
<PanelMessage
compact
icon="error"
tone="error"
message={childTree.error || t('workspace.loadError')}
/>
)}
{!childLoading && !childError && childTree?.state === 'ok' && childTree.entries.length === 0 && (
<PanelMessage compact icon="folder_open" message={t('workspace.noFiles')} />
)}
{!childLoading && !childError && childTree?.state === 'ok' && childTree.entries
.filter((childEntry) => treeEntryMatchesFilter(childEntry, filterQuery, treeByPath))
.map((childEntry) => (
<TreeNode
key={childEntry.path}
sessionId={sessionId}
entry={childEntry}
depth={depth + 1}
expandedPaths={expandedPaths}
treeByPath={treeByPath}
treeLoadingByPath={treeLoadingByPath}
treeErrorsByPath={treeErrorsByPath}
filterQuery={filterQuery}
onToggle={onToggle}
onOpenFile={onOpenFile}
onFileContextMenu={onFileContextMenu}
activePath={activePath}
/>
))}
</div>
)}
</div>
)
}
export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
const t = useTranslation()
const addToast = useUIStore((state) => state.addToast)
const [filterQuery, setFilterQuery] = useState('')
const [isViewMenuOpen, setIsViewMenuOpen] = useState(false)
const [previewTabContextMenu, setPreviewTabContextMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
const [fileContextMenu, setFileContextMenu] = useState<{ path: string; x: number; y: number } | null>(null)
const width = useWorkspacePanelStore((state) => state.width)
const isOpen = useWorkspacePanelStore((state) => state.isPanelOpen(sessionId))
const activeView = useWorkspacePanelStore((state) => state.getActiveView(sessionId))
const status = useWorkspacePanelStore((state) => state.statusBySession[sessionId])
const treeByPath = useWorkspacePanelStore((state) => state.treeBySessionPath[sessionId] ?? EMPTY_TREE_BY_PATH)
const previewTabs = useWorkspacePanelStore((state) => state.previewTabsBySession[sessionId] ?? EMPTY_PREVIEW_TABS)
const activePreviewTabId = useWorkspacePanelStore((state) => state.activePreviewTabIdBySession[sessionId] ?? null)
const expandedPaths = useWorkspacePanelStore((state) => state.expandedPathsBySession[sessionId] ?? EMPTY_EXPANDED_PATHS)
const statusLoading = useWorkspacePanelStore((state) => state.loading.statusBySession[sessionId] ?? false)
const treeLoadingByPath = useWorkspacePanelStore(
useShallow((state) => getSessionScopedRecord(state.loading.treeBySessionPath, sessionId)),
)
const statusError = useWorkspacePanelStore((state) => state.errors.statusBySession[sessionId] ?? null)
const treeErrorsByPath = useWorkspacePanelStore(
useShallow((state) => getSessionScopedRecord(state.errors.treeBySessionPath, sessionId)),
)
const setActiveView = useWorkspacePanelStore((state) => state.setActiveView)
const loadStatus = useWorkspacePanelStore((state) => state.loadStatus)
const loadTree = useWorkspacePanelStore((state) => state.loadTree)
const toggleTreeNode = useWorkspacePanelStore((state) => state.toggleTreeNode)
const openPreview = useWorkspacePanelStore((state) => state.openPreview)
const closePreview = useWorkspacePanelStore((state) => state.closePreview)
const closePreviewTabs = useWorkspacePanelStore((state) => state.closePreviewTabs)
const closePanel = useWorkspacePanelStore((state) => state.closePanel)
const addWorkspaceReference = useWorkspaceChatContextStore((state) => state.addReference)
const chatState = useChatStore((state) => state.sessions[sessionId]?.chatState ?? 'idle')
const refreshLifecycleRef = useRef({
sessionId,
isOpen: false,
chatState: 'idle',
})
const rootTree = treeByPath['']
const rootTreeKey = makeTreeStateKey(sessionId, '')
const rootTreeLoading = treeLoadingByPath[rootTreeKey] ?? false
const rootTreeError = treeErrorsByPath[rootTreeKey] ?? null
const normalizedFilterQuery = normalizeFilterQuery(filterQuery)
const expandedPathSet = new Set(expandedPaths)
const activePreviewTab =
previewTabs.find((tab) => tab.id === activePreviewTabId) ?? previewTabs[previewTabs.length - 1] ?? null
const activeTreePath = activePreviewTab?.kind === 'file' ? activePreviewTab.path : null
const filteredChangedFiles = useMemo(
() => (status?.changedFiles ?? []).filter((file) => changedFileMatchesFilter(file, normalizedFilterQuery)),
[normalizedFilterQuery, status?.changedFiles],
)
const filteredRootEntries = useMemo(
() => rootTree?.state === 'ok'
? rootTree.entries.filter((entry) => treeEntryMatchesFilter(entry, normalizedFilterQuery, treeByPath))
: [],
[normalizedFilterQuery, rootTree, treeByPath],
)
const activePreviewRequestKey = activePreviewTab
? makePreviewStateKey(sessionId, activePreviewTab.id)
: null
const activePreviewLoading = useWorkspacePanelStore((state) =>
activePreviewRequestKey ? state.loading.previewByTabId[activePreviewRequestKey] ?? false : false,
)
const activePreviewError = useWorkspacePanelStore((state) =>
activePreviewRequestKey ? state.errors.previewByTabId[activePreviewRequestKey] ?? null : null,
)
useEffect(() => {
const previous = refreshLifecycleRef.current
const sessionChanged = previous.sessionId !== sessionId
const opened = isOpen && (sessionChanged || !previous.isOpen)
const completedTurn =
isOpen &&
!sessionChanged &&
previous.chatState !== 'idle' &&
chatState === 'idle'
refreshLifecycleRef.current = { sessionId, isOpen, chatState }
const shouldRefreshOnOpen = opened
const shouldRefreshAfterCompletedTurn = completedTurn && chatState === 'idle'
if ((!shouldRefreshOnOpen && !shouldRefreshAfterCompletedTurn) || statusLoading) return
void loadStatus(sessionId)
}, [chatState, isOpen, loadStatus, sessionId, statusLoading])
useEffect(() => {
if (!isOpen || activeView !== 'all' || rootTree || rootTreeLoading || rootTreeError) return
void loadTree(sessionId, '')
}, [activeView, isOpen, loadTree, rootTree, rootTreeError, rootTreeLoading, sessionId])
useEffect(() => {
if (!previewTabContextMenu && !fileContextMenu) return
const close = () => {
setPreviewTabContextMenu(null)
setFileContextMenu(null)
}
document.addEventListener('click', close)
return () => document.removeEventListener('click', close)
}, [fileContextMenu, previewTabContextMenu])
if (!isOpen) return null
const hasPreviewTabs = previewTabs.length > 0
const panelWidth = hasPreviewTabs ? width : Math.min(width, 520)
const panelMaxWidth = hasPreviewTabs ? 'min(62%, calc(100% - 328px))' : '36%'
const panelMinWidth = hasPreviewTabs ? 'min(420px, 54%)' : 'min(340px, 40%)'
const handleRefresh = () => {
void loadStatus(sessionId)
if (activeView === 'all') {
void loadTree(sessionId, '')
}
}
const handleOpenDiff = (path: string) => {
void openPreview(sessionId, path, 'diff')
}
const handleOpenFile = (path: string) => {
void openPreview(sessionId, path, 'file')
}
const addFileToChat = (path: string) => {
addWorkspaceReference(sessionId, {
kind: 'file',
path,
absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path),
name: path.split('/').pop() || path,
})
}
const addLineCommentToChat = (path: string, line: number, note: string, quote: string) => {
addWorkspaceReference(sessionId, {
kind: 'code-comment',
path,
absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path),
name: path.split('/').pop() || path,
lineStart: line,
lineEnd: line,
note,
quote,
})
}
const addSelectionToChat = (path: string, selection: WorkspaceTextSelection) => {
addWorkspaceReference(sessionId, {
kind: 'code-selection',
path,
absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path),
name: path.split('/').pop() || path,
lineStart: selection.startLine,
lineEnd: selection.endLine,
quote: selection.text,
})
}
const handleSetActiveView = (view: 'changed' | 'all') => {
setActiveView(sessionId, view)
setIsViewMenuOpen(false)
}
const handlePreviewTabContextMenu = (event: MouseEvent, tabId: string) => {
event.preventDefault()
event.stopPropagation()
setFileContextMenu(null)
setPreviewTabContextMenu({ tabId, x: event.clientX, y: event.clientY })
}
const handleFileContextMenu = (event: MouseEvent, path: string) => {
event.preventDefault()
event.stopPropagation()
setPreviewTabContextMenu(null)
setFileContextMenu({ path, x: event.clientX, y: event.clientY })
}
const handleClosePreviewTabs = (scope: WorkspacePreviewCloseScope) => {
if (!previewTabContextMenu) return
closePreviewTabs(sessionId, previewTabContextMenu.tabId, scope)
setPreviewTabContextMenu(null)
}
const copyWorkspacePath = async (path: string) => {
const resolvedPath = resolveWorkspaceAttachmentPath(status?.workDir, path)
const copied = await copyTextToClipboard(resolvedPath)
setFileContextMenu(null)
addToast({
type: copied ? 'success' : 'error',
message: copied ? t('workspace.pathCopied') : t('common.copyFailed'),
})
}
const renderChangedView = () => {
if (statusLoading && !status) {
return <PanelMessage icon="progress_activity" message={t('common.loading')} />
}
if (status?.state === 'missing_workdir') {
return <PanelMessage icon="folder_off" message={t('workspace.missingWorkdir')} />
}
if (status?.state === 'not_git_repo') {
return <PanelMessage icon="account_tree" message={t('workspace.notGitRepo')} />
}
if (statusError || status?.state === 'error') {
return (
<PanelMessage
icon="error"
tone="error"
message={statusError || status?.error || t('workspace.loadError')}
/>
)
}
if (!status) {
return <PanelMessage icon="progress_activity" message={t('common.loading')} />
}
if (status.changedFiles.length === 0) {
return <PanelMessage icon="check_circle" message={t('workspace.noChanges')} />
}
if (filteredChangedFiles.length === 0) {
return <PanelMessage icon="search_off" message={t('workspace.noMatchingFiles')} />
}
return (
<div>
{filteredChangedFiles.map((file) => (
<ChangedFileRow
key={`${file.path}:${file.status}:${file.oldPath ?? ''}`}
file={file}
onClick={() => handleOpenDiff(file.path)}
onContextMenu={handleFileContextMenu}
/>
))}
</div>
)
}
const renderAllFilesView = () => {
if (rootTreeLoading && !rootTree) {
return <PanelMessage icon="progress_activity" message={t('common.loading')} />
}
if (rootTreeError) {
return <PanelMessage icon="error" tone="error" message={rootTreeError} />
}
if (rootTree?.state === 'missing') {
return <PanelMessage icon="folder_off" message={t('workspace.missingWorkdir')} />
}
if (rootTree?.state === 'error') {
return <PanelMessage icon="error" tone="error" message={rootTree.error || t('workspace.loadError')} />
}
if (!rootTree) {
return <PanelMessage icon="progress_activity" message={t('common.loading')} />
}
if (rootTree.entries.length === 0) {
return <PanelMessage icon="folder_open" message={t('workspace.noFiles')} />
}
if (filteredRootEntries.length === 0) {
return <PanelMessage icon="search_off" message={t('workspace.noMatchingFiles')} />
}
return (
<div className="py-1">
{filteredRootEntries.map((entry) => (
<TreeNode
key={entry.path}
sessionId={sessionId}
entry={entry}
depth={0}
expandedPaths={expandedPathSet}
treeByPath={treeByPath}
treeLoadingByPath={treeLoadingByPath}
treeErrorsByPath={treeErrorsByPath}
filterQuery={normalizedFilterQuery}
onToggle={(path) => {
void toggleTreeNode(sessionId, path)
}}
onOpenFile={handleOpenFile}
onFileContextMenu={handleFileContextMenu}
activePath={activeTreePath}
/>
))}
</div>
)
}
const renderPreviewContent = () => {
if (!activePreviewTab) {
return (
<div className="flex min-h-0 flex-1 items-center justify-center px-4 text-xs text-[var(--color-text-tertiary)]">
{t('workspace.previewEmpty')}
</div>
)
}
const kindLabel = getPreviewKindLabel(t, activePreviewTab.kind)
const state = activePreviewTab.state ?? 'loading'
return (
<>
<div className="flex h-10 shrink-0 items-center gap-1.5 border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 text-[12px]">
<span className="truncate text-[var(--color-text-tertiary)]">{status?.repoName || 'workspace'}</span>
{activePreviewTab.path.split('/').map((segment, index, segments) => (
<span key={`${segment}:${index}`} className="flex min-w-0 items-center gap-1.5">
<span className="text-[var(--color-text-tertiary)]"></span>
<span className={`truncate ${index === segments.length - 1 ? 'font-semibold text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'}`}>
{segment}
</span>
</span>
))}
<button
type="button"
onClick={() => addFileToChat(activePreviewTab.path)}
className="ml-auto inline-flex h-6 shrink-0 items-center gap-1 rounded-[6px] px-1.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<span aria-hidden="true" className="material-symbols-outlined text-[14px]">person_add</span>
<span>{t('workspace.addToChat')}</span>
</button>
<span className="shrink-0 rounded-[5px] border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]">
{kindLabel}
</span>
</div>
{activePreviewLoading || state === 'loading' ? (
<PanelMessage icon="progress_activity" message={t('workspace.previewState.loading')} />
) : state === 'ok' && activePreviewTab.previewType === 'image' ? (
<ImagePreview tab={activePreviewTab} />
) : state === 'ok' && activePreviewTab.kind === 'diff' ? (
<WorkspaceDiffSurface
value={activePreviewTab.diff ?? ''}
path={activePreviewTab.path}
/>
) : state === 'ok' && isMarkdownPreview(activePreviewTab) ? (
<MarkdownSurface
value={activePreviewTab.content ?? ''}
onAddSelection={(selection) => addSelectionToChat(activePreviewTab.path, selection)}
/>
) : state === 'ok' ? (
<CodeSurface
value={activePreviewTab.content ?? ''}
language={activePreviewTab.language ?? 'text'}
onAddLineComment={(line, note, quote) => addLineCommentToChat(activePreviewTab.path, line, note, quote)}
onAddSelection={(selection) => addSelectionToChat(activePreviewTab.path, selection)}
/>
) : (
<PanelMessage
icon="error"
tone={state === 'error' ? 'error' : 'muted'}
message={getInlineStateMessage(t, state, activePreviewError || activePreviewTab.error || null)}
/>
)}
</>
)
}
const renderPreviewTabs = () => (
<>
<div
role="tablist"
aria-label={t('workspace.previewTabs')}
className="flex h-11 shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3"
>
{previewTabs.length === 0 ? (
<div className="flex items-center gap-2 px-1.5 text-[12px] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[15px]">docs</span>
<span>{t('workspace.preview')}</span>
</div>
) : (
previewTabs.map((tab) => {
const kindLabel = getPreviewKindLabel(t, tab.kind)
const isActive = tab.id === activePreviewTab?.id
return (
<div
key={tab.id}
onContextMenu={(event) => handlePreviewTabContextMenu(event, tab.id)}
className={`group flex h-8 min-w-[118px] max-w-[210px] shrink-0 items-center gap-2 rounded-[8px] px-2 text-left text-[13px] transition-colors ${
isActive
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
<button
type="button"
role="tab"
aria-selected={isActive}
onClick={() => {
void openPreview(sessionId, tab.path, tab.kind)
}}
className="min-w-0 flex flex-1 items-center gap-2 text-left"
>
{tab.kind === 'diff' ? (
<span className="material-symbols-outlined shrink-0 text-[15px] text-[var(--color-text-tertiary)]">difference</span>
) : (
<FileTypeBadge name={tab.title} subtle={!isActive} />
)}
<span className="min-w-0 flex-1 truncate">{tab.title}</span>
</button>
<button
type="button"
aria-label={`${t('workspace.closeTab')} ${tab.title} ${kindLabel}`}
onClick={() => {
closePreview(sessionId, tab.id)
}}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[var(--color-text-tertiary)] opacity-0 transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] group-hover:opacity-100 focus-visible:opacity-100"
>
<span className="material-symbols-outlined text-[13px] leading-none">close</span>
</button>
</div>
)
})
)}
</div>
{previewTabContextMenu && (
<div
role="menu"
className="fixed z-50 min-w-[156px] rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 text-[12px] shadow-[var(--shadow-dropdown)]"
style={{ left: previewTabContextMenu.x, top: previewTabContextMenu.y }}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
role="menuitem"
onClick={() => handleClosePreviewTabs('current')}
className="block w-full px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
>
{t('tabs.close')}
</button>
<button
type="button"
role="menuitem"
onClick={() => handleClosePreviewTabs('others')}
className="block w-full px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
>
{t('tabs.closeOthers')}
</button>
<button
type="button"
role="menuitem"
onClick={() => handleClosePreviewTabs('left')}
className="block w-full px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
>
{t('tabs.closeLeft')}
</button>
<button
type="button"
role="menuitem"
onClick={() => handleClosePreviewTabs('right')}
className="block w-full px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
>
{t('tabs.closeRight')}
</button>
<div className="my-1 border-t border-[var(--color-border)]" />
<button
type="button"
role="menuitem"
onClick={() => handleClosePreviewTabs('all')}
className="block w-full px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
>
{t('tabs.closeAll')}
</button>
</div>
)}
</>
)
return (
<aside
data-testid="workspace-panel"
className="flex h-full shrink-0 border-l border-[var(--color-border)] bg-[var(--color-surface)]"
style={{ width: panelWidth, maxWidth: panelMaxWidth, minWidth: panelMinWidth }}
>
{hasPreviewTabs && (
<div className="flex min-w-0 flex-1 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)]">
{renderPreviewTabs()}
{renderPreviewContent()}
</div>
)}
<div
className={`${hasPreviewTabs ? 'basis-[32%] min-w-[220px] max-w-[320px]' : 'w-full'} flex h-full shrink-0 flex-col bg-[var(--color-surface)]`}
>
<div className="flex h-10 shrink-0 items-center gap-1.5 border-b border-[var(--color-border)] px-2.5">
<div className="relative min-w-0">
<button
type="button"
aria-label={activeView === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')}
aria-haspopup="menu"
aria-expanded={isViewMenuOpen}
onClick={() => setIsViewMenuOpen((open) => !open)}
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-[7px] px-2 py-1 text-[14px] font-semibold leading-5 text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
>
<span className="truncate">
{activeView === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')}
</span>
<span className="material-symbols-outlined shrink-0 text-[15px] font-normal text-[var(--color-text-tertiary)]">expand_more</span>
</button>
{isViewMenuOpen && (
<div
role="menu"
className="absolute left-0 top-[calc(100%+4px)] z-30 min-w-[124px] overflow-hidden rounded-[9px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]"
>
{(['changed', 'all'] as const).map((view) => {
const selected = activeView === view
return (
<button
key={view}
type="button"
role="menuitem"
onClick={() => handleSetActiveView(view)}
className={`flex h-7 w-full items-center gap-2 px-2.5 text-left text-[12px] transition-colors ${
selected ? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="min-w-0 flex-1 truncate">
{view === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')}
</span>
{selected && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">check</span>
)}
</button>
)
})}
</div>
)}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
<ToolbarIconButton
icon="refresh"
label={t('workspace.refresh')}
onClick={handleRefresh}
/>
<ToolbarIconButton
icon="close"
label={t('workspace.closePanel')}
onClick={() => closePanel(sessionId)}
/>
</div>
</div>
<WorkspaceFilterInput value={filterQuery} onChange={setFilterQuery} />
<div className="min-h-0 flex-1 overflow-auto py-2">
{activeView === 'changed' ? renderChangedView() : renderAllFilesView()}
</div>
</div>
{fileContextMenu && (
<div
role="menu"
className="fixed z-50 min-w-[156px] rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 text-[12px] shadow-[var(--shadow-dropdown)]"
style={{ left: fileContextMenu.x, top: fileContextMenu.y }}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
role="menuitem"
onClick={() => {
addFileToChat(fileContextMenu.path)
setFileContextMenu(null)
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
>
<span aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">person_add</span>
<span>{t('workspace.addToChat')}</span>
</button>
<button
type="button"
role="menuitem"
onClick={() => void copyWorkspacePath(fileContextMenu.path)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
>
<span aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">content_copy</span>
<span>{t('workspace.copyPath')}</span>
</button>
</div>
)}
</aside>
)
}