Open check run details in an editor tab instead of a modal (#5447)

* Open check run full details in an editor tab instead of a modal

- Display check run output, annotations, and job lists within the main editor workspace.
- Replace the right-sidebar checks panel detail dialog with a tab-opening action.
- Suppress local file-system actions (like path copying and revealing) on virtual check-run detail tabs.

* Extract path and rename UI to EditorPanelHeaderPath

- Offloads path rendering, renaming, and context-menu behaviors from
  EditorPanelHeader into a new dedicated component.
- Improves code maintainability and readability by decoupling distinct
  responsibilities within the editor panel headers.
This commit is contained in:
Jinjing
2026-06-15 20:24:28 -07:00
committed by GitHub
parent 2aa4ae6d21
commit 22263a23b0
27 changed files with 1321 additions and 477 deletions
+9
View File
@@ -835,6 +835,15 @@
color: var(--foreground);
}
.editor-header-path--static {
cursor: default;
}
.editor-header-path--static:hover,
.editor-header-path--static:focus-visible {
color: var(--muted-foreground);
}
.editor-header-path-row {
display: flex;
align-items: center;
+6 -1
View File
@@ -112,7 +112,12 @@ const EditorPanel = lazy(() => import('./editor/EditorPanel'))
// feel responsive on a deliberate follow-up click; long enough to absorb the
// trailing edge of a physical double-click (~150 ms on most hardware).
const CLOSE_DIALOG_DEBOUNCE_MS = 200
const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>(['editor', 'diff', 'conflict-review'])
const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>([
'editor',
'diff',
'conflict-review',
'check-details'
])
type TerminalStoreSnapshot = ReturnType<typeof useAppStore.getState>
@@ -0,0 +1,294 @@
import React from 'react'
import { ExternalLink, LoaderCircle, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types'
import { CheckJobLogTail } from '@/components/right-sidebar/check-job-log-tail'
import { translate } from '@/i18n/i18n'
function formatCheckTimestamp(value: string | null | undefined): string | null {
if (!value) {
return null
}
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) {
return value
}
return parsed.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
}
function getCheckStatusLabel(check: PRCheckDetail): string {
const conclusion = check.conclusion ?? 'pending'
switch (conclusion) {
case 'success':
return translate('auto.components.editor.CheckRunDetailsPanel.8f2d0f5a91', 'Passed')
case 'failure':
return translate('auto.components.editor.CheckRunDetailsPanel.4c8e1b2d73', 'Failed')
case 'cancelled':
return translate('auto.components.editor.CheckRunDetailsPanel.91a4c7e2b0', 'Cancelled')
case 'timed_out':
return translate('auto.components.editor.CheckRunDetailsPanel.2f6d8a1c45', 'Timed out')
case 'skipped':
return translate('auto.components.editor.CheckRunDetailsPanel.7b3e9d4f12', 'Skipped')
case 'neutral':
return translate('auto.components.editor.CheckRunDetailsPanel.5a1c8e3d67', 'Neutral')
case 'pending':
return translate('auto.components.editor.CheckRunDetailsPanel.3d9f2b8e14', 'Pending')
default:
return conclusion
}
}
function isFailureState(state: string | null | undefined): boolean {
return state === 'failure' || state === 'cancelled' || state === 'timed_out'
}
export function CheckRunDetailsPanel({
check,
details,
loading,
error,
openUrl,
onRefresh
}: {
check: PRCheckDetail
details: PRCheckRunDetails | null
loading: boolean
error: string | null
openUrl: string | null | undefined
onRefresh?: () => void
}): React.JSX.Element {
const startedAt = formatCheckTimestamp(details?.startedAt)
const completedAt = formatCheckTimestamp(details?.completedAt)
const detailsStatusCheck: PRCheckDetail = {
...check,
status: (details?.status as PRCheckDetail['status'] | undefined) ?? check.status,
conclusion: (details?.conclusion as PRCheckDetail['conclusion'] | undefined) ?? check.conclusion
}
const failedJobs =
details?.jobs.filter((job) => {
const state = job.conclusion ?? job.status
return isFailureState(state)
}) ?? []
const jobs = failedJobs.length > 0 ? failedJobs : (details?.jobs ?? [])
const hasOutput = Boolean(details?.title || details?.summary || details?.text)
const hasAnnotations = (details?.annotations.length ?? 0) > 0
const hasJobs = jobs.length > 0
return (
<div className="flex h-full min-h-0 flex-col bg-editor-surface">
<div className="border-b border-border px-5 py-4">
<div className="flex min-w-0 items-start gap-3">
<h1 className="min-w-0 flex-1 truncate text-base font-medium text-foreground">
{check.name}
</h1>
{onRefresh && (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
disabled={loading}
onClick={onRefresh}
>
<RefreshCw className={`size-3.5${loading ? ' animate-spin' : ''}`} />
{translate('auto.components.editor.CheckRunDetailsPanel.b7f5e2c91a', 'Refresh')}
</Button>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>
{translate('auto.components.editor.CheckRunDetailsPanel.a54ae21c6f', 'Status:')}{' '}
{details ? getCheckStatusLabel(detailsStatusCheck) : getCheckStatusLabel(check)}
</span>
{startedAt && (
<span>
{translate('auto.components.editor.CheckRunDetailsPanel.fd46a70f1a', 'Started')}{' '}
{startedAt}
</span>
)}
{completedAt && (
<span>
{translate('auto.components.editor.CheckRunDetailsPanel.00e1c1658a', 'Completed')}{' '}
{completedAt}
</span>
)}
{check.checkRunId && (
<span className="font-mono">
{translate('auto.components.editor.CheckRunDetailsPanel.aa8494ae3c', 'check #')}
{check.checkRunId}
</span>
)}
{check.workflowRunId && (
<span className="font-mono">
{translate('auto.components.editor.CheckRunDetailsPanel.2dd5ddabc4', 'workflow #')}
{check.workflowRunId}
</span>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 scrollbar-sleek">
{loading ? (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<LoaderCircle className="size-4 animate-spin" />
{translate(
'auto.components.editor.CheckRunDetailsPanel.1f2b980522',
'Loading check details…'
)}
</div>
) : (
<div className="grid gap-4">
{error && <div className="text-sm text-muted-foreground">{error}</div>}
{hasOutput && (
<section className="rounded-md border border-border bg-background">
<div className="border-b border-border px-3 py-2 text-sm font-medium">
{translate('auto.components.editor.CheckRunDetailsPanel.d098e5529a', 'Output')}
</div>
<div className="px-3 py-3">
{details?.title && (
<div className="mb-2 text-sm font-medium text-foreground">{details.title}</div>
)}
{details?.summary && (
<CommentMarkdown
content={details.summary}
variant="document"
className="min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
/>
)}
{details?.text && (
<CommentMarkdown
content={details.text}
variant="document"
className="mt-3 min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
/>
)}
</div>
</section>
)}
{hasAnnotations && (
<section className="rounded-md border border-border bg-background">
<div className="border-b border-border px-3 py-2 text-sm font-medium">
{translate(
'auto.components.editor.CheckRunDetailsPanel.f2fe8a4e8f',
'Annotations'
)}
</div>
<div className="divide-y divide-border/50">
{details!.annotations.map((annotation, index) => (
<div key={`${annotation.path ?? 'annotation'}-${index}`} className="px-3 py-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="min-w-0 break-all font-mono text-xs text-muted-foreground">
{annotation.path ??
translate(
'auto.components.editor.CheckRunDetailsPanel.cdbfda4dec',
'Annotation'
)}
{annotation.startLine ? `:${annotation.startLine}` : ''}
</span>
{annotation.annotationLevel && (
<span className="shrink-0 text-xs text-muted-foreground">
{annotation.annotationLevel}
</span>
)}
</div>
{annotation.title && (
<div className="mt-2 text-sm font-medium text-foreground">
{annotation.title}
</div>
)}
<div className="mt-2 break-words text-sm text-foreground">
{annotation.message}
</div>
{annotation.rawDetails && (
<pre className="mt-2 max-h-60 overflow-auto whitespace-pre-wrap rounded bg-muted/40 p-3 font-mono text-xs text-muted-foreground scrollbar-sleek">
{annotation.rawDetails}
</pre>
)}
</div>
))}
</div>
</section>
)}
{hasJobs && (
<section className="rounded-md border border-border bg-background">
<div className="border-b border-border px-3 py-2 text-sm font-medium">
{failedJobs.length > 0
? translate(
'auto.components.editor.CheckRunDetailsPanel.066fedd446',
'Failed jobs'
)
: translate('auto.components.editor.CheckRunDetailsPanel.49731703ea', 'Jobs')}
</div>
<div className="divide-y divide-border/50">
{jobs.map((job, index) => (
<div key={`${job.name}-${index}`} className="px-3 py-3">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{job.name}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{job.conclusion ??
job.status ??
translate(
'auto.components.editor.CheckRunDetailsPanel.ee07b33924',
'unknown'
)}
</span>
</div>
{job.steps.length > 0 && (
<div className="mt-2 grid gap-1">
{job.steps.map((step) => (
<div
key={step.name}
className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground"
>
<span className="min-w-0 flex-1 truncate">{step.name}</span>
<span className="shrink-0">{step.conclusion ?? step.status}</span>
</div>
))}
</div>
)}
{job.logTail && <CheckJobLogTail logTail={job.logTail} />}
</div>
))}
</div>
</section>
)}
{!error && !hasOutput && !hasAnnotations && !hasJobs && (
<div className="text-sm text-muted-foreground">
{translate(
'auto.components.editor.CheckRunDetailsPanel.07eccfa397',
'No details are available for this check.'
)}
</div>
)}
</div>
)}
</div>
{openUrl && (
<div className="flex justify-end border-t border-border px-5 py-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => window.api.shell.openUrl(openUrl)}
>
{translate('auto.components.editor.CheckRunDetailsPanel.a916648574', 'Open details')}
<ExternalLink className="size-3.5" />
</Button>
</div>
)}
</div>
)
}
@@ -28,6 +28,7 @@ import { useMarkdownDocuments } from './useMarkdownDocuments'
import { findGitConflictBlocks } from './monaco-conflict-decorations'
import { getDiffContentSignature } from './diff-content-signature'
import { translate } from '@/i18n/i18n'
import { CheckRunDetailsPanel } from './CheckRunDetailsPanel'
const MonacoEditor = lazy(() => import('./MonacoEditor'))
const DiffViewer = lazy(() => import('./DiffViewer'))
@@ -167,6 +168,7 @@ export function EditorContent({
const closeFile = useAppStore((s) => s.closeFile)
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
const setPendingEditorReveal = useAppStore((s) => s.setPendingEditorReveal)
const reloadOpenCheckRunDetailsTab = useAppStore((s) => s.reloadOpenCheckRunDetailsTab)
const [conflictNavigationIndexByFile, setConflictNavigationIndexByFile] = React.useState<
Record<string, number>
>({})
@@ -614,6 +616,34 @@ export function EditorContent({
)
}
if (activeFile.mode === 'check-details') {
const checkRunDetails = activeFile.checkRunDetails
if (!checkRunDetails) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{translate(
'auto.components.editor.EditorContent.6c4f1a8d2e',
'Check details are unavailable.'
)}
</div>
)
}
const details = checkRunDetails.details
const openUrl = details?.detailsUrl ?? details?.url ?? checkRunDetails.check.url
return (
<CheckRunDetailsPanel
check={checkRunDetails.check}
details={checkRunDetails.details}
loading={checkRunDetails.loading}
error={checkRunDetails.error}
openUrl={openUrl}
onRefresh={() => {
void reloadOpenCheckRunDetailsTab(activeFile.id)
}}
/>
)
}
if (activeFile.mode === 'conflict-review') {
return (
<ConflictReviewPanel
@@ -290,6 +290,10 @@ function EditorPanelInner({
)
}
const handleOpenContainingFolder = (): void => {
// Why: virtual editor tabs use synthetic ids instead of on-disk paths.
if (activeFile.mode === 'check-details') {
return
}
if (
isLocalPathOpenBlocked(settingsForRuntimeOwner(settings, activeFile.runtimeEnvironmentId), {
connectionId: getConnectionId(activeFile.worktreeId)
@@ -1,40 +1,18 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Columns2, Copy, Eye, ExternalLink, FileText, ListTree, Pencil, Rows2 } from 'lucide-react'
import { useMemo } from 'react'
import { Columns2, Eye, FileText, ListTree, Rows2 } from 'lucide-react'
import { useAppStore } from '@/store'
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '../tab-bar/SortableTab'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import EditorViewToggle, {
CSV_VIEW_MODE_METADATA,
NOTEBOOK_VIEW_MODE_METADATA
} from './EditorViewToggle'
import type { EditorToggleValue } from './EditorViewToggle'
import type { EditorHeaderOpenFileState } from './editor-header'
import { getEditorHeaderCopyState } from './editor-header'
import { DiffNotesSendMenu } from './DiffNotesSendMenu'
import { useEditorHeaderFileRename } from './editor-header-file-rename'
import { EditorPanelMarkdownActionsMenu } from './EditorPanelMarkdownActionsMenu'
import { translate } from '@/i18n/i18n'
const isMac = navigator.userAgent.includes('Mac')
const isLinux = navigator.userAgent.includes('Linux')
/** Platform-appropriate label: macOS -> Finder, Windows -> File Explorer, Linux -> Files */
const revealLabel = isMac
? 'Reveal in Finder'
: isLinux
? 'Open Containing Folder'
: 'Reveal in File Explorer'
import { EditorPanelHeaderPath } from './EditorPanelHeaderPath'
type EditorPanelHeaderProps = {
activeFile: OpenFile
@@ -103,149 +81,23 @@ export function EditorPanelHeader({
onToggleMarkdownFrontmatter,
onExportMarkdownToPdf
}: EditorPanelHeaderProps): React.JSX.Element {
const [pathMenuOpen, setPathMenuOpen] = useState(false)
const [pathMenuPoint, setPathMenuPoint] = useState({ x: 0, y: 0 })
const skipMenuFocusRestoreRef = useRef(false)
const headerCopyState = getEditorHeaderCopyState(activeFile)
const {
canRename,
currentFileName,
isRenaming,
renameInputRef,
openRenameInput,
commitRename,
cancelRename
} = useEditorHeaderFileRename(activeFile)
const diffComments = useAppStore((s) => s.getDiffComments(activeFile.worktreeId))
const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[activeFile.worktreeId])
const fileDiffComments = useMemo(
() => diffComments.filter((comment) => comment.filePath === activeFile.relativePath),
[activeFile.relativePath, diffComments]
)
const markdownPreviewShortcutLabel = useShortcutLabel('editor.markdownPreview')
useEffect(() => {
const closeMenu = (): void => setPathMenuOpen(false)
window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
}, [])
return (
<div className="editor-header">
<div className="editor-header-text">
<div
className="editor-header-path-row"
onContextMenuCapture={(event) => {
event.preventDefault()
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
setPathMenuPoint({ x: event.clientX, y: event.clientY })
setPathMenuOpen(true)
}}
>
{isRenaming ? (
<Input
ref={renameInputRef}
data-editor-header-rename-input="true"
aria-label={translate("auto.components.editor.EditorPanelHeader.1bb1e226ec", "Rename file {{value0}}", { value0: currentFileName })}
defaultValue={currentFileName}
// Why: the header is narrow in floating mode; this keeps the
// edit field aligned with the path label without growing chrome.
className="h-6 w-[16ch] min-w-[104px] max-w-full rounded-sm bg-input/40 px-1.5 py-0 font-mono text-xs text-foreground md:text-xs focus-visible:ring-[1px]"
spellCheck={false}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
commitRename()
} else if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
cancelRename()
}
}}
onBlur={commitRename}
/>
) : (
<button
type="button"
className="editor-header-path"
onClick={onCopyPath}
title={headerCopyState.pathTitle}
>
{headerCopyState.pathLabel}
</button>
)}
<span
className={`editor-header-copy-toast${copiedPathVisible ? ' is-visible' : ''}`}
aria-live="polite"
>
{headerCopyState.copyToastLabel}
</span>
</div>
<DropdownMenu open={pathMenuOpen} onOpenChange={setPathMenuOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none fixed size-px opacity-0"
style={{ left: pathMenuPoint.x, top: pathMenuPoint.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-56"
sideOffset={0}
align="start"
onCloseAutoFocus={(event) => {
if (!skipMenuFocusRestoreRef.current) {
return
}
skipMenuFocusRestoreRef.current = false
event.preventDefault()
}}
>
<DropdownMenuItem
disabled={!canRename}
onSelect={() => {
skipMenuFocusRestoreRef.current = true
openRenameInput()
}}
>
<Pencil className="w-3.5 h-3.5 mr-1.5" />
{translate("auto.components.editor.EditorPanelHeader.84cdc0794b", "Rename")}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
void window.api.ui.writeClipboardText(activeFile.filePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
{translate("auto.components.editor.EditorPanelHeader.7c08a1f990", "Copy Path")}</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void window.api.ui.writeClipboardText(activeFile.relativePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
{translate("auto.components.editor.EditorPanelHeader.269ce4842b", "Copy Relative Path")}</DropdownMenuItem>
<DropdownMenuSeparator />
{canShowMarkdownPreview && (
<DropdownMenuItem onSelect={onOpenMarkdownPreview}>
<Eye className="w-3.5 h-3.5 mr-1.5" />
{translate("auto.components.editor.EditorPanelHeader.4157f3cbf3", "Open Markdown Preview")}<DropdownMenuShortcut>{markdownPreviewShortcutLabel}</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{canShowMarkdownPreview && <DropdownMenuSeparator />}
<DropdownMenuItem onSelect={onOpenContainingFolder}>
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
{revealLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<EditorPanelHeaderPath
activeFile={activeFile}
copiedPathVisible={copiedPathVisible}
canShowMarkdownPreview={canShowMarkdownPreview}
onCopyPath={onCopyPath}
onOpenMarkdownPreview={onOpenMarkdownPreview}
onOpenContainingFolder={onOpenContainingFolder}
/>
{isSingleDiff && (
<TooltipProvider delayDuration={300}>
<Tooltip>
@@ -254,7 +106,10 @@ export function EditorPanelHeader({
type="button"
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0 disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
onClick={() => onOpenDiffTargetFile(isMarkdown ? 'rich' : undefined)}
aria-label={translate("auto.components.editor.EditorPanelHeader.a10d9b8337", "Open file")}
aria-label={translate(
'auto.components.editor.EditorPanelHeader.a10d9b8337',
'Open file'
)}
disabled={!openFileState.canOpen}
>
<FileText size={14} />
@@ -263,9 +118,18 @@ export function EditorPanelHeader({
<TooltipContent side="bottom" sideOffset={4}>
{openFileState.canOpen
? isMarkdown
? translate("auto.components.editor.EditorPanelHeader.f0fd4174b5", "Open file tab to use rich markdown editing")
: translate("auto.components.editor.EditorPanelHeader.9b80bbe1de", "Open file tab")
: translate("auto.components.editor.EditorPanelHeader.c98ce191da", "This diff has no modified-side file to open")}
? translate(
'auto.components.editor.EditorPanelHeader.f0fd4174b5',
'Open file tab to use rich markdown editing'
)
: translate(
'auto.components.editor.EditorPanelHeader.9b80bbe1de',
'Open file tab'
)
: translate(
'auto.components.editor.EditorPanelHeader.c98ce191da',
'This diff has no modified-side file to open'
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -291,13 +155,20 @@ export function EditorPanelHeader({
type="button"
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
onClick={onOpenPreviewToSide}
aria-label={translate("auto.components.editor.EditorPanelHeader.fb8331694e", "Open Preview to the Side")}
aria-label={translate(
'auto.components.editor.EditorPanelHeader.fb8331694e',
'Open Preview to the Side'
)}
>
<Eye size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{translate("auto.components.editor.EditorPanelHeader.fb8331694e", "Open Preview to the Side")}</TooltipContent>
{translate(
'auto.components.editor.EditorPanelHeader.fb8331694e',
'Open Preview to the Side'
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
@@ -314,7 +185,15 @@ export function EditorPanelHeader({
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{sideBySide ? translate("auto.components.editor.EditorPanelHeader.94756f08ba", "Switch to inline diff") : translate("auto.components.editor.EditorPanelHeader.e836faacfa", "Switch to side-by-side diff")}
{sideBySide
? translate(
'auto.components.editor.EditorPanelHeader.94756f08ba',
'Switch to inline diff'
)
: translate(
'auto.components.editor.EditorPanelHeader.e836faacfa',
'Switch to side-by-side diff'
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -342,7 +221,10 @@ export function EditorPanelHeader({
}`}
onClick={onToggleMarkdownTableOfContents}
disabled={isMarkdownTableOfContentsDisabled}
aria-label={translate("auto.components.editor.EditorPanelHeader.5447c4f68f", "Table of Contents")}
aria-label={translate(
'auto.components.editor.EditorPanelHeader.5447c4f68f',
'Table of Contents'
)}
aria-pressed={showMarkdownTableOfContents}
>
<ListTree size={14} />
@@ -350,8 +232,14 @@ export function EditorPanelHeader({
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{isMarkdownTableOfContentsDisabled
? translate("auto.components.editor.EditorPanelHeader.146cb5473c", "Table of Contents is available in rich or preview mode")
: translate("auto.components.editor.EditorPanelHeader.5447c4f68f", "Table of Contents")}
? translate(
'auto.components.editor.EditorPanelHeader.146cb5473c',
'Table of Contents is available in rich or preview mode'
)
: translate(
'auto.components.editor.EditorPanelHeader.5447c4f68f',
'Table of Contents'
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -0,0 +1,206 @@
import { useEffect, useRef, useState } from 'react'
import { Copy, ExternalLink, Eye, Pencil } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
import type { OpenFile } from '@/store/slices/editor'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '../tab-bar/SortableTab'
import { useEditorHeaderFileRename } from './editor-header-file-rename'
import { getEditorHeaderCopyState } from './editor-header'
const isMac = navigator.userAgent.includes('Mac')
const isLinux = navigator.userAgent.includes('Linux')
/** Platform-appropriate label: macOS -> Finder, Windows -> File Explorer, Linux -> Files */
const revealLabel = isMac
? 'Reveal in Finder'
: isLinux
? 'Open Containing Folder'
: 'Reveal in File Explorer'
type EditorPanelHeaderPathProps = {
activeFile: OpenFile
copiedPathVisible: boolean
canShowMarkdownPreview: boolean
onCopyPath: () => void
onOpenMarkdownPreview: () => void
onOpenContainingFolder: () => void
}
export function EditorPanelHeaderPath({
activeFile,
copiedPathVisible,
canShowMarkdownPreview,
onCopyPath,
onOpenMarkdownPreview,
onOpenContainingFolder
}: EditorPanelHeaderPathProps): React.JSX.Element {
const [pathMenuOpen, setPathMenuOpen] = useState(false)
const [pathMenuPoint, setPathMenuPoint] = useState({ x: 0, y: 0 })
const skipMenuFocusRestoreRef = useRef(false)
const headerCopyState = getEditorHeaderCopyState(activeFile)
const canCopyHeaderPath = headerCopyState.copyText !== null
const isVirtualEditorTab = activeFile.mode === 'check-details'
const markdownPreviewShortcutLabel = useShortcutLabel('editor.markdownPreview')
const {
canRename,
currentFileName,
isRenaming,
renameInputRef,
openRenameInput,
commitRename,
cancelRename
} = useEditorHeaderFileRename(activeFile)
useEffect(() => {
const closeMenu = (): void => setPathMenuOpen(false)
window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
}, [])
return (
<div className="editor-header-text">
<div
className="editor-header-path-row"
onContextMenuCapture={(event) => {
event.preventDefault()
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
setPathMenuPoint({ x: event.clientX, y: event.clientY })
setPathMenuOpen(true)
}}
>
{isRenaming ? (
<Input
ref={renameInputRef}
data-editor-header-rename-input="true"
aria-label={translate(
'auto.components.editor.EditorPanelHeader.1bb1e226ec',
'Rename file {{value0}}',
{ value0: currentFileName }
)}
defaultValue={currentFileName}
// Why: the header is narrow in floating mode; this keeps the
// edit field aligned with the path label without growing chrome.
className="h-6 w-[16ch] min-w-[104px] max-w-full rounded-sm bg-input/40 px-1.5 py-0 font-mono text-xs text-foreground md:text-xs focus-visible:ring-[1px]"
spellCheck={false}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
commitRename()
} else if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
cancelRename()
}
}}
onBlur={commitRename}
/>
) : (
<button
type="button"
className={`editor-header-path${canCopyHeaderPath ? '' : ' editor-header-path--static'}`}
onClick={canCopyHeaderPath ? onCopyPath : undefined}
disabled={!canCopyHeaderPath}
title={headerCopyState.pathTitle}
>
{headerCopyState.pathLabel}
</button>
)}
<span
className={`editor-header-copy-toast${copiedPathVisible ? ' is-visible' : ''}`}
aria-live="polite"
>
{headerCopyState.copyToastLabel}
</span>
</div>
<DropdownMenu open={pathMenuOpen} onOpenChange={setPathMenuOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none fixed size-px opacity-0"
style={{ left: pathMenuPoint.x, top: pathMenuPoint.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-56"
sideOffset={0}
align="start"
onCloseAutoFocus={(event) => {
if (!skipMenuFocusRestoreRef.current) {
return
}
skipMenuFocusRestoreRef.current = false
event.preventDefault()
}}
>
<DropdownMenuItem
disabled={!canRename}
onSelect={() => {
skipMenuFocusRestoreRef.current = true
openRenameInput()
}}
>
<Pencil className="w-3.5 h-3.5 mr-1.5" />
{translate('auto.components.editor.EditorPanelHeader.84cdc0794b', 'Rename')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{!isVirtualEditorTab && (
<>
<DropdownMenuItem
onSelect={() => {
void window.api.ui.writeClipboardText(activeFile.filePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
{translate('auto.components.editor.EditorPanelHeader.7c08a1f990', 'Copy Path')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void window.api.ui.writeClipboardText(activeFile.relativePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
{translate(
'auto.components.editor.EditorPanelHeader.269ce4842b',
'Copy Relative Path'
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{canShowMarkdownPreview && (
<DropdownMenuItem onSelect={onOpenMarkdownPreview}>
<Eye className="w-3.5 h-3.5 mr-1.5" />
{translate(
'auto.components.editor.EditorPanelHeader.4157f3cbf3',
'Open Markdown Preview'
)}
<DropdownMenuShortcut>{markdownPreviewShortcutLabel}</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{canShowMarkdownPreview && <DropdownMenuSeparator />}
{!isVirtualEditorTab && (
<DropdownMenuItem onSelect={onOpenContainingFolder}>
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
{revealLabel}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -94,7 +94,7 @@ export function EditorPanelShell({
}: EditorPanelShellProps): JSX.Element {
return (
<div ref={panelRef} className="flex flex-col flex-1 min-w-0 min-h-0">
{!model.isCombinedDiff && (
{!model.isCombinedDiff && activeFile.mode !== 'check-details' && (
<EditorPanelHeader
activeFile={activeFile}
copiedPathVisible={copiedPathVisible}
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import {
buildCheckRunDetailsTabId,
getCheckRunDetailsTabLabel,
getCheckRunTabIdentity
} from './check-run-details-tab'
describe('check-run-details-tab', () => {
it('builds a stable tab id from worktree and check identity', () => {
expect(
buildCheckRunDetailsTabId('wt-1', {
name: 'verify',
status: 'completed',
conclusion: 'failure',
url: null,
checkRunId: 99
})
).toBe('wt-1::check-details::check-run:99')
})
it('falls back to workflow and url identities when check run id is missing', () => {
expect(
getCheckRunTabIdentity({
name: 'verify',
status: 'completed',
conclusion: 'failure',
url: 'https://github.com/acme/widgets/actions/runs/1',
workflowRunId: 12
})
).toBe('workflow-run:12')
expect(
getCheckRunTabIdentity({
name: 'verify',
status: 'completed',
conclusion: 'failure',
url: 'https://github.com/acme/widgets/actions/runs/1'
})
).toBe('url:https://github.com/acme/widgets/actions/runs/1')
})
it('uses the check name for the tab label', () => {
expect(
getCheckRunDetailsTabLabel({
name: 'verify',
status: 'completed',
conclusion: 'failure',
url: null
})
).toBe('verify')
})
})
@@ -0,0 +1,32 @@
import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types'
export type OpenCheckRunDetailsState = {
contextKey: string
check: PRCheckDetail
details: PRCheckRunDetails | null
loading: boolean
error: string | null
}
export function getCheckRunTabIdentity(check: PRCheckDetail): string {
if (check.checkRunId) {
return `check-run:${check.checkRunId}`
}
if (check.workflowRunId) {
return `workflow-run:${check.workflowRunId}`
}
if (check.url) {
return `url:${check.url}`
}
return `name:${check.name}`
}
export function buildCheckRunDetailsTabId(worktreeId: string, check: PRCheckDetail): string {
// Why: one tab per hosted check identity keeps the center pane stable across
// PR head refreshes; contextKey lives on the tab state instead of the tab id.
return `${worktreeId}::check-details::${getCheckRunTabIdentity(check)}`
}
export function getCheckRunDetailsTabLabel(check: PRCheckDetail): string {
return check.name
}
@@ -59,6 +59,37 @@ describe('getEditorHeaderCopyState', () => {
})
})
it('shows the check name without a copyable path for check-details tabs', () => {
expect(
getEditorHeaderCopyState(
makeOpenFile({
id: 'wt-1::check-details::check-run:99',
filePath: 'wt-1::check-details::check-run:99',
relativePath: 'verify',
mode: 'check-details',
checkRunDetails: {
contextKey: 'repo:42',
check: {
name: 'verify',
status: 'completed',
conclusion: 'failure',
url: null,
checkRunId: 99
},
details: null,
loading: false,
error: null
}
})
)
).toEqual({
copyText: null,
copyToastLabel: 'Check details copied',
pathLabel: 'verify',
pathTitle: 'verify'
})
})
it('shows All Changes while still copying the worktree path', () => {
expect(
getEditorHeaderCopyState(
@@ -23,6 +23,16 @@ export function getEditorHeaderCopyState(file: OpenFile): EditorHeaderCopyState
}
}
if (file.mode === 'check-details') {
const label = file.checkRunDetails?.check.name ?? 'Check details'
return {
copyText: null,
copyToastLabel: 'Check details copied',
pathLabel: label,
pathTitle: label
}
}
const isCombinedDiff =
file.mode === 'diff' &&
(file.diffSource === 'combined-uncommitted' ||
@@ -29,6 +29,10 @@ export function getEditorDisplayLabel(
return 'Conflict Review'
}
if (file.mode === 'check-details') {
return file.checkRunDetails?.check.name ?? getBaseLabel(file, variant)
}
if (file.mode === 'markdown-preview') {
return `${getBaseLabel(file, variant)} (preview)`
}
@@ -66,5 +66,7 @@ function disposeClosedEditorTab(prevId: string, prevFile: OpenFile): void {
break
case 'conflict-review':
break
case 'check-details':
break
}
}
@@ -0,0 +1,88 @@
import React, { useCallback, useRef, useState } from 'react'
import { Check, Copy } from 'lucide-react'
import { translate } from '@/i18n/i18n'
function CopyButton({
text,
title = 'Copy comment'
}: {
text: string
title?: string
}): React.JSX.Element {
const [copied, setCopied] = useState(false)
const copiedResetTimerRef = useRef<number | null>(null)
// Why: clipboard IPC can resolve after this row action unmounts; avoid
// starting a reset timer that will outlive the component.
const isMountedRef = useRef(false)
const clearCopiedResetTimer = useCallback((): void => {
if (copiedResetTimerRef.current !== null) {
window.clearTimeout(copiedResetTimerRef.current)
copiedResetTimerRef.current = null
}
}, [])
const setCopyButtonRef = useCallback(
(node: HTMLButtonElement | null) => {
isMountedRef.current = node !== null
if (node === null) {
clearCopiedResetTimer()
}
},
[clearCopiedResetTimer]
)
const handleCopy = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
void window.api.ui.writeClipboardText(text).then(() => {
if (!isMountedRef.current) {
return
}
clearCopiedResetTimer()
setCopied(true)
copiedResetTimerRef.current = window.setTimeout(() => {
copiedResetTimerRef.current = null
setCopied(false)
}, 1500)
})
},
[clearCopiedResetTimer, text]
)
return (
<button
ref={setCopyButtonRef}
className="p-1 rounded hover:bg-accent text-muted-foreground/40 hover:text-foreground transition-colors shrink-0"
title={title}
onClick={handleCopy}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
</button>
)
}
export function CheckJobLogTail({ logTail }: { logTail: string }): React.JSX.Element {
return (
<div className="mt-3 min-w-0">
<div className="mb-1.5 flex min-w-0 items-center gap-2">
<div className="min-w-0 flex-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{translate(
'auto.components.right.sidebar.checks.panel.content.d713f500b2',
'Log tail (last 200 lines)'
)}
</div>
<CopyButton
text={logTail}
title={translate(
'auto.components.right.sidebar.checks.panel.content.679bf2093c',
'Copy log tail'
)}
/>
</div>
<pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded bg-muted/40 p-3 font-mono text-xs text-muted-foreground scrollbar-sleek">
{logTail}
</pre>
</div>
)
}
@@ -34,14 +34,6 @@ import {
AccordionItem,
AccordionTrigger
} from '@/components/ui/accordion'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
@@ -85,6 +77,8 @@ import {
} from './right-panel-comment-composer'
import { usePRCommentsListSelection } from './pr-comments-list-selection'
import { translate } from '@/i18n/i18n'
import { useActiveWorktree } from '@/store/selectors'
import { useAppStore } from '@/store'
export const PullRequestIcon = GitPullRequest
@@ -477,13 +471,16 @@ export function getFailedChecksForDetails(checks: PRCheckDetail[]): PRCheckDetai
function CheckRunDetails({
check,
state
state,
checkDetailsContextKey
}: {
check: PRCheckDetail
state: CheckDetailsLoadState | undefined
checkDetailsContextKey: string
}): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const openCheckRunDetails = useAppStore((s) => s.openCheckRunDetails)
const details = state?.details
const openUrl = details?.detailsUrl ?? details?.url ?? check.url
const startedAt = formatCheckTimestamp(details?.startedAt)
const completedAt = formatCheckTimestamp(details?.completedAt)
const detailsStatusCheck: PRCheckDetail = {
@@ -502,14 +499,46 @@ function CheckRunDetails({
const hasJobs = jobs.length > 0
const hasLogTail = jobs.some((job) => Boolean(job.logTail))
const openFullDetailsTab = (): void => {
if (!activeWorktree) {
return
}
openCheckRunDetails(activeWorktree.id, checkDetailsContextKey, check, {
details: state?.details ?? null,
loading: state?.loading ?? false,
error: state?.error ?? null
})
}
return (
<div className="mb-1 ml-[26px] mr-3 min-w-0 border-l border-border pl-3">
{state?.loading ? (
<div className="flex items-center gap-2 py-1.5 text-[12px] text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
{translate(
'auto.components.right.sidebar.checks.panel.content.1f2b980522',
'Loading check details…'
<div className="flex min-w-0 flex-col gap-2 py-1.5">
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
{translate(
'auto.components.right.sidebar.checks.panel.content.1f2b980522',
'Loading check details…'
)}
</div>
{activeWorktree && (
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="xs"
className="h-7 gap-1 px-2 text-[11px]"
onClick={(event) => {
event.stopPropagation()
openFullDetailsTab()
}}
>
{translate(
'auto.components.right.sidebar.checks.panel.content.e4e3af15ee',
'View full details'
)}
</Button>
</div>
)}
</div>
) : (
@@ -717,30 +746,22 @@ function CheckRunDetails({
)}
<div className="flex justify-end pt-1">
{!state?.loading && (
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
size="xs"
className="h-7 gap-1 px-2 text-[11px]"
onClick={(event) => event.stopPropagation()}
>
{translate(
'auto.components.right.sidebar.checks.panel.content.e4e3af15ee',
'View full details'
)}
</Button>
</DialogTrigger>
<CheckRunDetailsDialog
check={check}
state={state}
detailsStatusCheck={detailsStatusCheck}
jobs={jobs}
openUrl={openUrl}
/>
</Dialog>
{activeWorktree && (
<Button
type="button"
variant="outline"
size="xs"
className="h-7 gap-1 px-2 text-[11px]"
onClick={(event) => {
event.stopPropagation()
openFullDetailsTab()
}}
>
{translate(
'auto.components.right.sidebar.checks.panel.content.e4e3af15ee',
'View full details'
)}
</Button>
)}
</div>
</div>
@@ -749,253 +770,7 @@ function CheckRunDetails({
)
}
export function CheckRunDetailsDialog({
check,
state,
detailsStatusCheck,
jobs,
openUrl
}: {
check: PRCheckDetail
state: CheckDetailsLoadState | undefined
detailsStatusCheck: PRCheckDetail
jobs: NonNullable<PRCheckRunDetails['jobs']>
openUrl: string | null | undefined
}): React.JSX.Element {
const details = state?.details
const startedAt = formatCheckTimestamp(details?.startedAt)
const completedAt = formatCheckTimestamp(details?.completedAt)
const hasOutput = Boolean(details?.title || details?.summary || details?.text)
const hasAnnotations = (details?.annotations.length ?? 0) > 0
const hasJobs = jobs.length > 0
return (
<DialogContent
className="flex max-h-[85vh] w-[min(760px,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden p-0"
onClick={(event) => event.stopPropagation()}
>
<DialogHeader className="border-b border-border px-5 py-4 pr-12">
<DialogTitle className="truncate text-base">{check.name}</DialogTitle>
<DialogDescription className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>
{translate('auto.components.right.sidebar.checks.panel.content.a54ae21c6f', 'Status:')}
{details ? getCheckStatusLabel(detailsStatusCheck) : getCheckStatusLabel(check)}
</span>
{startedAt && (
<span>
{translate(
'auto.components.right.sidebar.checks.panel.content.fd46a70f1a',
'Started'
)}
{startedAt}
</span>
)}
{completedAt && (
<span>
{translate(
'auto.components.right.sidebar.checks.panel.content.00e1c1658a',
'Completed'
)}
{completedAt}
</span>
)}
{check.checkRunId && (
<span className="font-mono">
{translate(
'auto.components.right.sidebar.checks.panel.content.aa8494ae3c',
'check #'
)}
{check.checkRunId}
</span>
)}
{check.workflowRunId && (
<span className="font-mono">
{translate(
'auto.components.right.sidebar.checks.panel.content.2dd5ddabc4',
'workflow #'
)}
{check.workflowRunId}
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 scrollbar-sleek">
<div className="grid gap-4">
{state?.error && <div className="text-sm text-muted-foreground">{state.error}</div>}
{hasOutput && (
<section className="rounded-md border border-border bg-background">
<div className="border-b border-border px-3 py-2 text-sm font-medium">
{translate(
'auto.components.right.sidebar.checks.panel.content.d098e5529a',
'Output'
)}
</div>
<div className="px-3 py-3">
{details?.title && (
<div className="mb-2 text-sm font-medium text-foreground">{details.title}</div>
)}
{details?.summary && (
<CommentMarkdown
content={details.summary}
variant="document"
className="min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
/>
)}
{details?.text && (
<CommentMarkdown
content={details.text}
variant="document"
className="mt-3 min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
/>
)}
</div>
</section>
)}
{hasAnnotations && (
<section className="rounded-md border border-border bg-background">
<div className="border-b border-border px-3 py-2 text-sm font-medium">
{translate(
'auto.components.right.sidebar.checks.panel.content.f2fe8a4e8f',
'Annotations'
)}
</div>
<div className="divide-y divide-border/50">
{details!.annotations.map((annotation, index) => (
<div key={`${annotation.path ?? 'annotation'}-${index}`} className="px-3 py-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="min-w-0 break-all font-mono text-xs text-muted-foreground">
{annotation.path ??
translate(
'auto.components.right.sidebar.checks.panel.content.cdbfda4dec',
'Annotation'
)}
{annotation.startLine ? `:${annotation.startLine}` : ''}
</span>
{annotation.annotationLevel && (
<span className="shrink-0 text-xs text-muted-foreground">
{annotation.annotationLevel}
</span>
)}
</div>
{annotation.title && (
<div className="mt-2 text-sm font-medium text-foreground">
{annotation.title}
</div>
)}
<div className="mt-2 break-words text-sm text-foreground">
{annotation.message}
</div>
{annotation.rawDetails && (
<pre className="mt-2 max-h-60 overflow-auto whitespace-pre-wrap rounded bg-muted/40 p-3 font-mono text-xs text-muted-foreground scrollbar-sleek">
{annotation.rawDetails}
</pre>
)}
</div>
))}
</div>
</section>
)}
{hasJobs && (
<section className="rounded-md border border-border bg-background">
<div className="border-b border-border px-3 py-2 text-sm font-medium">
{translate('auto.components.right.sidebar.checks.panel.content.49731703ea', 'Jobs')}
</div>
<div className="divide-y divide-border/50">
{jobs.map((job, index) => (
<div key={`${job.name}-${index}`} className="px-3 py-3">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{job.name}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{job.conclusion ??
job.status ??
translate(
'auto.components.right.sidebar.checks.panel.content.ee07b33924',
'unknown'
)}
</span>
</div>
{job.steps.length > 0 && (
<div className="mt-2 grid gap-1">
{job.steps.map((step) => (
<div
key={step.name}
className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground"
>
<span className="min-w-0 flex-1 truncate">{step.name}</span>
<span className="shrink-0">{step.conclusion ?? step.status}</span>
</div>
))}
</div>
)}
{job.logTail && <CheckJobLogTail logTail={job.logTail} />}
</div>
))}
</div>
</section>
)}
{!state?.error && !hasOutput && !hasAnnotations && !hasJobs && (
<div className="text-sm text-muted-foreground">
{translate(
'auto.components.right.sidebar.checks.panel.content.07eccfa397',
'No details are available for this check.'
)}
</div>
)}
</div>
</div>
{openUrl && (
<div className="flex justify-end border-t border-border px-5 py-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={(event) => {
event.stopPropagation()
window.api.shell.openUrl(openUrl)
}}
>
{translate(
'auto.components.right.sidebar.checks.panel.content.a916648574',
'Open details'
)}
<ExternalLink className="size-3.5" />
</Button>
</div>
)}
</DialogContent>
)
}
export function CheckJobLogTail({ logTail }: { logTail: string }): React.JSX.Element {
return (
<div className="mt-3 min-w-0">
<div className="mb-1.5 flex min-w-0 items-center gap-2">
<div className="min-w-0 flex-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{translate(
'auto.components.right.sidebar.checks.panel.content.d713f500b2',
'Log tail (last 200 lines)'
)}
</div>
<CopyButton
text={logTail}
title={translate(
'auto.components.right.sidebar.checks.panel.content.679bf2093c',
'Copy log tail'
)}
/>
</div>
<pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded bg-muted/40 p-3 font-mono text-xs text-muted-foreground scrollbar-sleek">
{logTail}
</pre>
</div>
)
}
export { CheckJobLogTail } from './check-job-log-tail'
/** Renders the checks summary bar + scrollable check list. */
export function ChecksList({
@@ -1009,6 +784,8 @@ export function ChecksList({
checkDetailsContextKey: string
onLoadCheckDetails?: (check: PRCheckDetail) => Promise<PRCheckRunDetails | null>
}): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const patchOpenCheckRunDetails = useAppStore((s) => s.patchOpenCheckRunDetails)
const [checksExpanded, setChecksExpanded] = useState(true)
const [expandedCheckKeys, setExpandedCheckKeys] = useState<Set<string>>(new Set())
const [detailsByCheckKey, setDetailsByCheckKey] = useState<Record<string, CheckDetailsLoadState>>(
@@ -1073,6 +850,27 @@ export function ChecksList({
})
}, [checkDetailsContextKey, rows])
useEffect(() => {
setDetailsByCheckKey((current) => {
let changed = false
const next: Record<string, CheckDetailsLoadState> = { ...current }
for (const row of rows) {
const cached = next[row.key]
if (!cached?.details) {
continue
}
if (
cached.details.status !== row.check.status ||
cached.details.conclusion !== row.check.conclusion
) {
delete next[row.key]
changed = true
}
}
return changed ? next : current
})
}, [rows])
const requestCheckDetails = useCallback(
(row: { check: PRCheckDetail; key: string }) => {
if (detailsByCheckKey[row.key]?.loading || detailsByCheckKey[row.key]?.details) {
@@ -1153,6 +951,23 @@ export function ChecksList({
}
}, [checksExpanded, detailsByCheckKey, expandedCheckKeys, requestCheckDetails, rows])
useEffect(() => {
if (!activeWorktree) {
return
}
for (const row of rows) {
const detailsState = detailsByCheckKey[row.key]
if (!detailsState) {
continue
}
patchOpenCheckRunDetails(activeWorktree.id, checkDetailsContextKey, row.check, {
details: detailsState.details ?? null,
loading: detailsState.loading ?? false,
error: detailsState.error ?? null
})
}
}, [activeWorktree, checkDetailsContextKey, detailsByCheckKey, patchOpenCheckRunDetails, rows])
const toggleCheckExpanded = useCallback(
(row: { check: PRCheckDetail; key: string }) => {
const willExpand = !expandedCheckKeys.has(row.key)
@@ -1304,7 +1119,13 @@ export function ChecksList({
)}
</span>
</div>
{expanded && <CheckRunDetails check={check} state={detailsByCheckKey[row.key]} />}
{expanded && (
<CheckRunDetails
check={check}
state={detailsByCheckKey[row.key]}
checkDetailsContextKey={checkDetailsContextKey}
/>
)}
</div>
)
})}
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { X, GitCompareArrows, Eye, ShieldAlert, Pin } from 'lucide-react'
import { X, GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { basename, normalizeRelativePath } from '@/lib/path'
@@ -77,6 +77,7 @@ export default function EditorFileTab({
const isDiff = file.mode === 'diff'
const isConflictReview = file.mode === 'conflict-review'
const isCheckDetails = file.mode === 'check-details'
const isMarkdownPreviewTab = file.mode === 'markdown-preview'
const resolvedLanguage =
file.mode === 'diff'
@@ -241,6 +242,10 @@ export default function EditorFileTab({
<ShieldAlert
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-orange-400' : 'text-orange-400/70'}`}
/>
) : isCheckDetails ? (
<ListChecks
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
/>
) : isDiff ? (
<GitCompareArrows
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
@@ -34,7 +34,11 @@ function TabIcon({ item }: { item: RecentTabSwitcherItem }): React.JSX.Element {
if (item.type === 'browser') {
return <Globe2 className={className} />
}
if (item.contentType === 'diff' || item.contentType === 'conflict-review') {
if (
item.contentType === 'diff' ||
item.contentType === 'conflict-review' ||
item.contentType === 'check-details'
) {
return <GitCompare className={className} />
}
return <FileText className={className} />
@@ -166,7 +166,8 @@ export function useTabGroupWorkspaceModel({
(item) =>
item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review'
item.contentType === 'conflict-review' ||
item.contentType === 'check-details'
)
.map((item) => {
const file = worktreeState.openFiles.find((candidate) => candidate.id === item.entityId)
@@ -196,7 +197,8 @@ export function useTabGroupWorkspaceModel({
item.entityId === entityId &&
(item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review')
item.contentType === 'conflict-review' ||
item.contentType === 'check-details')
)
if (!otherReference) {
const file = useAppStore.getState().openFiles.find((candidate) => candidate.id === entityId)
@@ -509,7 +511,8 @@ export function useTabGroupWorkspaceModel({
if (
item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review'
item.contentType === 'conflict-review' ||
item.contentType === 'check-details'
) {
closeItem(item.id)
}
@@ -12,7 +12,12 @@ import {
import { resolveHostSessionTabIdForWebSessionTab } from '@/runtime/web-session-tabs-sync'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>(['editor', 'diff', 'conflict-review'])
const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>([
'editor',
'diff',
'conflict-review',
'check-details'
])
type TerminalTabActionState = ReturnType<typeof useAppStore.getState>
@@ -1839,6 +1839,166 @@ describe('createEditorSlice conflict status reconciliation', () => {
])
})
it('reloads an open check-details tab from the hosted provider', async () => {
const fetchPRCheckDetails = vi.fn().mockResolvedValue({
name: 'verify',
status: 'completed',
conclusion: 'success',
url: null,
detailsUrl: null,
startedAt: null,
completedAt: null,
title: 'Build passed',
summary: null,
text: null,
annotations: [],
jobs: []
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = createStore<any>()((...args: any[]) => ({
activeWorktreeId: 'wt-1',
repos: [{ id: 'repo-1', path: '/repo' }],
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] },
fetchPRCheckDetails,
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
})) as unknown as StoreApi<AppState>
const check = {
name: 'verify',
status: 'completed' as const,
conclusion: 'failure' as const,
url: null,
checkRunId: 42
}
store.getState().openCheckRunDetails('wt-1', 'repo:99', check, {
details: null,
loading: false,
error: null
})
await store.getState().reloadOpenCheckRunDetailsTab('wt-1::check-details::check-run:42')
expect(fetchPRCheckDetails).toHaveBeenCalledWith(
'/repo',
expect.objectContaining({ checkRunId: 42, checkName: 'verify' }),
{ repoId: 'repo-1' }
)
expect(store.getState().openFiles).toContainEqual(
expect.objectContaining({
id: 'wt-1::check-details::check-run:42',
checkRunDetails: expect.objectContaining({
loading: false,
details: expect.objectContaining({ title: 'Build passed', conclusion: 'success' })
})
})
)
})
it('patches an open check-details tab without changing the active file', () => {
const store = createEditorTabsStore()
const check = {
name: 'verify',
status: 'completed' as const,
conclusion: 'failure' as const,
url: null,
checkRunId: 42
}
store.getState().openCheckRunDetails('wt-1', 'repo:99', check, {
details: null,
loading: true,
error: null
})
store.getState().openFile({
filePath: '/repo/other.ts',
relativePath: 'other.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'edit'
})
store.getState().patchOpenCheckRunDetails('wt-1', 'repo:99', check, {
details: {
name: 'verify',
status: 'completed',
conclusion: 'failure',
url: null,
detailsUrl: null,
startedAt: null,
completedAt: null,
title: 'Build failed',
summary: null,
text: null,
annotations: [],
jobs: []
},
loading: false,
error: null
})
expect(store.getState().activeFileId).toBe('/repo/other.ts')
expect(store.getState().openFiles).toContainEqual(
expect.objectContaining({
id: 'wt-1::check-details::check-run:42',
checkRunDetails: expect.objectContaining({
loading: false,
details: expect.objectContaining({ title: 'Build failed' })
})
})
)
})
it('opens check full details as a center-pane editor tab', () => {
const store = createEditorTabsStore()
const check = {
name: 'verify',
status: 'completed' as const,
conclusion: 'failure' as const,
url: null,
checkRunId: 42
}
store.getState().openCheckRunDetails('wt-1', 'repo:99', check, {
details: {
name: 'verify',
status: 'completed',
conclusion: 'failure',
url: null,
detailsUrl: null,
startedAt: null,
completedAt: null,
title: 'Build failed',
summary: null,
text: null,
annotations: [],
jobs: []
},
loading: false,
error: null
})
expect(store.getState().activeFileId).toBe('wt-1::check-details::check-run:42')
expect(store.getState().openFiles).toContainEqual(
expect.objectContaining({
id: 'wt-1::check-details::check-run:42',
mode: 'check-details',
relativePath: 'verify',
checkRunDetails: expect.objectContaining({
contextKey: 'repo:99',
check,
details: expect.objectContaining({ title: 'Build failed' })
})
})
)
expect(store.getState().unifiedTabsByWorktree['wt-1']).toContainEqual(
expect.objectContaining({
entityId: 'wt-1::check-details::check-run:42',
contentType: 'check-details',
label: 'verify'
})
)
})
it('keeps the conflict review active when selecting a conflict from its tree', () => {
const store = createEditorStore()
+188 -8
View File
@@ -5,6 +5,11 @@ import { joinPath } from '@/lib/path'
import { toast } from 'sonner'
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
import { resolveMarkdownLinkTarget } from '@/components/editor/markdown-internal-links'
import {
buildCheckRunDetailsTabId,
getCheckRunDetailsTabLabel,
type OpenCheckRunDetailsState
} from '@/components/editor/check-run-details-tab'
import { openHttpLink } from '@/lib/http-link-routing'
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
import { detectLanguage } from '@/lib/language-detect'
@@ -217,7 +222,10 @@ export type OpenFile = {
* tab from the tree bumps this so the panel refetches instead of reusing a
* stale snapshot. */
diffContentReloadNonce?: number
mode: 'edit' | 'diff' | 'conflict-review' | 'markdown-preview'
/** Why: CI check full-details tabs are virtual editor tabs backed by fetched
* PR check-run metadata instead of a file on disk. */
checkRunDetails?: OpenCheckRunDetailsState
mode: 'edit' | 'diff' | 'conflict-review' | 'markdown-preview' | 'check-details'
}
export type ActivityBarPosition = 'top' | 'side'
@@ -471,6 +479,19 @@ export type EditorSlice = {
entries: ConflictReviewEntry[],
source: ConflictReviewState['source']
) => void
openCheckRunDetails: (
worktreeId: string,
contextKey: string,
check: OpenCheckRunDetailsState['check'],
state: Pick<OpenCheckRunDetailsState, 'details' | 'loading' | 'error'>
) => void
patchOpenCheckRunDetails: (
worktreeId: string,
contextKey: string,
check: OpenCheckRunDetailsState['check'],
state: Pick<OpenCheckRunDetailsState, 'details' | 'loading' | 'error'>
) => void
reloadOpenCheckRunDetailsTab: (fileId: string) => Promise<void>
openBranchAllDiffs: (
worktreeId: string,
worktreePath: string,
@@ -623,7 +644,7 @@ function openWorkspaceEditorItem(
fileId: string,
worktreeId: string,
label: string,
contentType: 'editor' | 'diff' | 'conflict-review',
contentType: 'editor' | 'diff' | 'conflict-review' | 'check-details',
isPreview?: boolean,
targetGroupId?: string
): string {
@@ -652,7 +673,12 @@ function openWorkspaceEditorItem(
}
function isEditorTabContentType(contentType: Tab['contentType']): boolean {
return contentType === 'editor' || contentType === 'diff' || contentType === 'conflict-review'
return (
contentType === 'editor' ||
contentType === 'diff' ||
contentType === 'conflict-review' ||
contentType === 'check-details'
)
}
function getReplaceablePreviewFileId(
@@ -1630,8 +1656,14 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
let editorItemWorktreeId = file.worktreeId
let editorItemFileId = file.filePath
let editorItemLabel = file.relativePath
let editorItemContentType: 'editor' | 'diff' | 'conflict-review' =
file.mode === 'conflict-review' ? 'conflict-review' : file.mode === 'diff' ? 'diff' : 'editor'
let editorItemContentType: 'editor' | 'diff' | 'conflict-review' | 'check-details' =
file.mode === 'conflict-review'
? 'conflict-review'
: file.mode === 'check-details'
? 'check-details'
: file.mode === 'diff'
? 'diff'
: 'editor'
let editorItemTargetGroupId = options?.targetGroupId
set((s) => {
const worktreeId = file.worktreeId
@@ -2218,7 +2250,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
entry.entityId === fileId &&
(entry.contentType === 'editor' ||
entry.contentType === 'diff' ||
entry.contentType === 'conflict-review')
entry.contentType === 'conflict-review' ||
entry.contentType === 'check-details')
)
if (unifiedTab) {
get().closeUnifiedTab(unifiedTab.id)
@@ -2261,7 +2294,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
(item) =>
(item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review') &&
item.contentType === 'conflict-review' ||
item.contentType === 'check-details') &&
(!activeWorktreeId || item.worktreeId === activeWorktreeId)
)
.map((item) => item.id)
@@ -3097,6 +3131,152 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
void openWorkspaceEditorItem(get(), id, worktreeId, 'Conflict Review', 'conflict-review')
},
// Why: the checks sidebar only has room for inline summaries; full logs and
// annotations belong in the center editor pane like diff tabs.
openCheckRunDetails: (worktreeId, contextKey, check, state) => {
const id = buildCheckRunDetailsTabId(worktreeId, check)
const label = getCheckRunDetailsTabLabel(check)
const checkRunDetails: OpenCheckRunDetailsState = {
contextKey,
check,
details: state.details,
loading: state.loading,
error: state.error
}
set((s) => {
const existing = s.openFiles.find((f) => f.id === id)
if (existing) {
return {
openFiles: s.openFiles.map((f) =>
f.id === id
? {
...f,
mode: 'check-details' as const,
relativePath: label,
language: 'plaintext',
checkRunDetails
}
: f
),
activeFileId: id,
activeTabType: 'editor',
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
}
}
const newFile: OpenFile = {
id,
filePath: id,
relativePath: label,
worktreeId,
language: 'plaintext',
isDirty: false,
mode: 'check-details',
checkRunDetails
}
return {
openFiles: [...s.openFiles, newFile],
activeFileId: id,
activeTabType: 'editor',
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
}
})
void openWorkspaceEditorItem(get(), id, worktreeId, label, 'check-details')
},
// Why: sidebar detail fetches can finish after a full-details tab is already
// open; this updates the tab snapshot without stealing focus from the user.
patchOpenCheckRunDetails: (worktreeId, contextKey, check, state) => {
const id = buildCheckRunDetailsTabId(worktreeId, check)
const nextCheckRunDetails: OpenCheckRunDetailsState = {
contextKey,
check,
details: state.details,
loading: state.loading,
error: state.error
}
set((s) => {
const existing = s.openFiles.find((f) => f.id === id)
if (!existing?.checkRunDetails) {
return s
}
const current = existing.checkRunDetails
if (
current.contextKey === nextCheckRunDetails.contextKey &&
current.check.status === nextCheckRunDetails.check.status &&
current.check.conclusion === nextCheckRunDetails.check.conclusion &&
current.loading === nextCheckRunDetails.loading &&
current.error === nextCheckRunDetails.error &&
current.details === nextCheckRunDetails.details
) {
return s
}
return {
openFiles: s.openFiles.map((f) =>
f.id === id ? { ...f, checkRunDetails: nextCheckRunDetails } : f
)
}
})
},
reloadOpenCheckRunDetailsTab: async (fileId) => {
const state = get()
const file = state.openFiles.find((candidate) => candidate.id === fileId)
const checkRunDetails = file?.checkRunDetails
if (!file || file.mode !== 'check-details' || !checkRunDetails) {
return
}
const worktree = findWorktreeById(state.worktreesByRepo, file.worktreeId)
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(file.worktreeId)
const repo = state.repos.find((candidate) => candidate.id === repoId)
if (!repo?.path) {
return
}
const { contextKey, check } = checkRunDetails
const patch = (next: Pick<OpenCheckRunDetailsState, 'details' | 'loading' | 'error'>): void => {
get().patchOpenCheckRunDetails(file.worktreeId, contextKey, check, next)
}
patch({ details: checkRunDetails.details, loading: true, error: null })
try {
const details = await get().fetchPRCheckDetails(
repo.path,
{
checkRunId: check.checkRunId,
workflowRunId: check.workflowRunId,
checkName: check.name,
url: check.url,
prRepo: null
},
{ repoId: repo.id }
)
patch({
details,
loading: false,
error: details
? null
: translate(
'auto.store.slices.editor.checkRunDetailsUnavailable',
'No details are available for this check.'
)
})
} catch (error) {
patch({
details: null,
loading: false,
error:
error instanceof Error
? error.message
: translate(
'auto.store.slices.editor.checkRunDetailsLoadFailed',
'Failed to load check details.'
)
})
}
},
openBranchAllDiffs: (worktreeId, worktreePath, compare, alternate) => {
const branchCompare = toBranchCompareSnapshot(compare)
const id = `${worktreeId}::all-diffs::branch::${compare.baseRef}::${branchCompare.compareVersion}`
@@ -4271,7 +4451,7 @@ function reconcileOpenFilesForStatus(
return [file]
}
if (file.mode === 'conflict-review') {
if (file.mode === 'conflict-review' || file.mode === 'check-details') {
return [file]
}
@@ -155,7 +155,9 @@ export function updateGroup(groups: TabGroup[], updated: TabGroup): TabGroup[] {
}
export function isTransientEditorContentType(contentType: TabContentType): boolean {
return contentType === 'diff' || contentType === 'conflict-review'
return (
contentType === 'diff' || contentType === 'conflict-review' || contentType === 'check-details'
)
}
export function getPersistedEditFileIdsByWorktree(
+8 -2
View File
@@ -239,7 +239,12 @@ function applyTabOrderSortValues(tabs: Tab[], tabOrder: string[]): Tab[] {
}
function isReplaceablePreviewContentType(contentType: Tab['contentType']): boolean {
return contentType === 'editor' || contentType === 'diff' || contentType === 'conflict-review'
return (
contentType === 'editor' ||
contentType === 'diff' ||
contentType === 'conflict-review' ||
contentType === 'check-details'
)
}
function canReplacePreviewContentType(
@@ -382,7 +387,8 @@ function deriveActiveSurfaceForWorktree(
activeFileId =
activeUnifiedTab.contentType === 'editor' ||
activeUnifiedTab.contentType === 'diff' ||
activeUnifiedTab.contentType === 'conflict-review'
activeUnifiedTab.contentType === 'conflict-review' ||
activeUnifiedTab.contentType === 'check-details'
? activeUnifiedTab.entityId
: fileStillOpen
? restoredFileId
+4 -2
View File
@@ -2798,7 +2798,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
activeFileId =
activeUnifiedTab.contentType === 'editor' ||
activeUnifiedTab.contentType === 'diff' ||
activeUnifiedTab.contentType === 'conflict-review'
activeUnifiedTab.contentType === 'conflict-review' ||
activeUnifiedTab.contentType === 'check-details'
? activeUnifiedTab.entityId
: fileStillOpen
? restoredFileId
@@ -3108,7 +3109,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const activeFileId =
activeUnifiedTab?.contentType === 'editor' ||
activeUnifiedTab?.contentType === 'diff' ||
activeUnifiedTab?.contentType === 'conflict-review'
activeUnifiedTab?.contentType === 'conflict-review' ||
activeUnifiedTab?.contentType === 'check-details'
? activeUnifiedTab.entityId
: fileStillOpen
? restoredFileId
+1
View File
@@ -710,6 +710,7 @@ export type TabContentType =
| 'editor'
| 'diff'
| 'conflict-review'
| 'check-details'
| 'browser'
| 'simulator'
+1
View File
@@ -135,6 +135,7 @@ const tabContentTypeSchema = z.enum([
'editor',
'diff',
'conflict-review',
'check-details',
'browser',
'simulator'
])