cc-haha/desktop/src/components/workspace/WorkspacePanel.tsx
程序员阿江(Relakkes) 52b11697e1 feat: make selected context reusable in desktop chat
Workspace and transcript selections now flow into the composer as lightweight references so users can carry exact snippets into the next prompt without file-only workarounds. The prompt formatter keeps workspace and chat excerpts separate, and the composer avoids sending chat snippets as fake file attachments.

Constraint: Selection references must prepare model context without polluting backend file attachment payloads.
Rejected: Reuse file attachments for chat selections | would leak chat:// pseudo paths into file-oriented payloads.
Confidence: high
Scope-risk: moderate
Directive: Keep chat-selection references prompt-only unless the backend gains a first-class chat context attachment type.
Tested: bun run check:desktop
Tested: bun run verify
Tested: agent-browser UI smoke for workspace, user-message, and assistant-message selections
Not-tested: Strict Chrome extension channel; connection timed out, local browser automation covered the flow.
2026-05-13 14:08:12 +08:00

1474 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState, type MouseEvent } from '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 { 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 = 8
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 clampValue(value: number, min: number, max: number) {
return Math.max(min, Math.min(value, max))
}
function getSelectionPosition(range: Range, root: HTMLElement, pointer?: SelectionPointer) {
const rect = typeof range.getBoundingClientRect === 'function'
? range.getBoundingClientRect()
: null
const rootRect = root.getBoundingClientRect()
const pointerInsideRoot = pointer
&& pointer.clientX >= rootRect.left
&& pointer.clientX <= rootRect.right
&& pointer.clientY >= rootRect.top
&& pointer.clientY <= rootRect.bottom
const fallbackX = rect && rect.width > 0
? rect.left + rect.width / 2
: rect?.left ?? rootRect.left + 24
const fallbackY = rect
? rect.bottom + SELECTION_MENU_OFFSET
: rootRect.top + 24
const unclampedX = pointerInsideRoot ? pointer.clientX : fallbackX
const unclampedY = pointerInsideRoot ? pointer.clientY + SELECTION_MENU_OFFSET : fallbackY
const minX = Math.max(12, rootRect.left + 8)
const maxX = Math.max(minX, Math.min(window.innerWidth - 160, rootRect.right - 136))
const minY = Math.max(12, rootRect.top + 8)
const maxY = Math.max(minY, Math.min(window.innerHeight - 48, rootRect.bottom - 40))
return {
x: clampValue(unclampedX, minX, maxX),
y: clampValue(unclampedY, minY, maxY),
}
}
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,
}: {
selection: FloatingSelectionMenuState | null
onAdd: () => void
}) {
const t = useTranslation()
if (!selection) return null
return (
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={onAdd}
className="fixed z-50 inline-flex h-8 items-center gap-1.5 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 text-[12px] font-medium text-[var(--color-text-primary)] shadow-[var(--shadow-dropdown)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
style={{ left: selection.x, top: selection.y }}
>
<span aria-hidden="true" className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">person_add</span>
<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 [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 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)
window.getSelection()?.removeAllRanges()
}
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} />
</div>
)
}
function MarkdownSurface({
value,
onAddSelection,
}: {
value: string
onAddSelection: (selection: WorkspaceTextSelection) => void
}) {
const surfaceRef = useRef<HTMLDivElement>(null)
const [selectionMenu, setSelectionMenu] = useState<FloatingSelectionMenuState | null>(null)
useEffect(() => {
setSelectionMenu(null)
}, [value])
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)
window.getSelection()?.removeAllRanges()
}
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} />
</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 [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 = (path: string) => {
const resolvedPath = resolveWorkspaceAttachmentPath(status?.workDir, path)
void navigator.clipboard?.writeText(resolvedPath)
setFileContextMenu(null)
}
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={() => 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>
)
}