fix(merge): remove unrelated stale snapshot changes

This commit is contained in:
Brennan Benson
2026-08-27 17:07:24 -07:00
parent 637301af4a
commit 49cb76dd9d
22 changed files with 669 additions and 1545 deletions
+2 -3
View File
@@ -40,10 +40,9 @@ jobs:
with:
ref: ${{ inputs.ref || github.ref }}
# Why: the build's plain-Node daemon smoke load resolves node-pty.
# Why no native-runtime: this job only produces JS bundles. Native modules
# are prepared in the consumer shards that actually launch Electron.
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
# Why: building here avoids parallel builds inside Playwright globalSetup;
# paired-browser specs also need the standalone web bundle.
+1
View File
@@ -42,6 +42,7 @@ inline src/relay/git-handler.ts
inline src/relay/pty-handler.ts
inline src/relay/workspace-space-scan.ts
inline src/renderer/src/components/GitLabItemDialog.tsx
inline src/renderer/src/components/JiraIssueWorkspace.tsx
inline src/renderer/src/components/LinearItemDrawer.tsx
inline src/renderer/src/components/TaskPage.tsx
inline src/renderer/src/components/Terminal.tsx
+1 -1
View File
@@ -165,7 +165,7 @@ describe('PR E2E gate contract', () => {
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(installFor('build').with['native-runtime']).toBe('node')
expect(installFor('build').with).toBeUndefined()
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
expect(installFor(jobName).with['native-runtime'], jobName).toBe('electron')
}
@@ -1,13 +1,33 @@
/* eslint-disable max-lines -- Why: the Jira drawer co-locates preview,
metadata edits, and comments so the task page has one full issue surface. */
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: Jira issue hydration, comments, transitions, priorities, and user options are loaded from provider IPC for the selected issue. */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ArrowRight,
Clipboard,
ExternalLink,
GitBranch,
LoaderCircle,
RefreshCw,
Save,
Send,
X
} from 'lucide-react'
import { toast } from 'sonner'
import { VisuallyHidden } from 'radix-ui'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { JiraIcon } from '@/components/icons/JiraIcon'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import {
getCommentBodySubmitState,
hasBoundedCommentBodyText
} from '@/lib/comment-body-submit-state'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { useAppStore } from '@/store'
import {
@@ -28,9 +48,7 @@ import type {
} from '../../../shared/jira-types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { translate } from '@/i18n/i18n'
import { JiraIssueMetadataBar, JiraIssueWorkspaceHeader } from './jira-issue-workspace-chrome'
import { JiraIssueCommentComposer, JiraIssueWorkspaceContent } from './jira-issue-workspace-content'
import { getJiraIssueWorkspaceActions } from './jira-issue-workspace-actions'
import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format'
type JiraIssueWorkspaceProps = {
issue: JiraIssue | null
@@ -39,6 +57,50 @@ type JiraIssueWorkspaceProps = {
sourceContext?: TaskSourceContext | null
}
function formatRelativeTime(input: string): string {
return formatUiRelativeTimeFromDate(input)
}
function buildJiraBranchName(issue: JiraIssue): string {
const slug = issue.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 52)
return `${issue.key.toLowerCase()}${slug ? `-${slug}` : ''}`
}
function buildJiraPrompt(issue: JiraIssue): string {
return `Complete Jira issue ${issue.key}: ${issue.title}\n\n${issue.url}`
}
function jiraStatusClass(categoryKey: string): string {
if (categoryKey === 'done') {
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
}
if (categoryKey === 'indeterminate') {
return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-200'
}
return 'border-border/50 bg-muted/40 text-muted-foreground'
}
async function copyTextToClipboard(text: string, label: string): Promise<void> {
try {
await window.api.ui.writeClipboardText(text)
toast.success(
translate('auto.components.JiraIssueWorkspace.2ff69a3545', '{{value0}} copied', {
value0: label
})
)
} catch {
toast.error(
translate('auto.components.JiraIssueWorkspace.6c41a9bcea', 'Failed to copy {{value0}}', {
value0: label.toLowerCase()
})
)
}
}
export default function JiraIssueWorkspace({
issue,
onUse,
@@ -281,10 +343,41 @@ export default function JiraIssueWorkspace({
}, [commentDraft, commentSubmitting, displayed, providerSettings])
const canSubmitComment = hasBoundedCommentBodyText(commentDraft)
const actionItems = useMemo(
() => (displayed ? getJiraIssueWorkspaceActions(displayed) : []),
[displayed]
)
const actionItems = useMemo(() => {
if (!displayed) {
return []
}
return [
{
label: translate('auto.components.JiraIssueWorkspace.69da9a208c', 'Open in Jira'),
icon: ExternalLink,
action: () => window.api.shell.openUrl(displayed.url)
},
{
label: translate('auto.components.JiraIssueWorkspace.779bb91ee0', 'Copy URL'),
icon: Clipboard,
action: () => void copyTextToClipboard(displayed.url, 'URL')
},
{
label: translate('auto.components.JiraIssueWorkspace.38839801e8', 'Copy key'),
icon: Clipboard,
action: () => void copyTextToClipboard(displayed.key, 'Key')
},
{
label: translate(
'auto.components.JiraIssueWorkspace.80efa101c5',
'Copy suggested branch name'
),
icon: GitBranch,
action: () => void copyTextToClipboard(buildJiraBranchName(displayed), 'Branch name')
},
{
label: translate('auto.components.JiraIssueWorkspace.0cc62bd690', 'Copy prompt'),
icon: Clipboard,
action: () => void copyTextToClipboard(buildJiraPrompt(displayed), 'Prompt')
}
]
}, [displayed])
return (
<Sheet open={issue !== null} onOpenChange={(open) => !open && onClose()}>
@@ -311,46 +404,419 @@ export default function JiraIssueWorkspace({
{displayed ? (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<JiraIssueWorkspaceHeader
displayed={displayed}
issueLoading={issueLoading}
onUse={onUse}
onClose={onClose}
/>
<div className="flex-none border-b border-border/50 bg-muted/30 px-4 py-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
<span className="font-mono">{displayed.key}</span>
{displayed.siteName ? <span>{displayed.siteName}</span> : null}
<span>{displayed.project.key}</span>
<span>{formatRelativeTime(displayed.updatedAt)}</span>
{issueLoading ? <LoaderCircle className="size-3 animate-spin" /> : null}
</div>
<h2 className="mt-1 text-[20px] font-semibold leading-tight text-foreground">
{displayed.title}
</h2>
</div>
<Button
onClick={() => onUse(displayed)}
className="hidden shrink-0 gap-2 sm:inline-flex"
size="sm"
>
{translate('auto.components.JiraIssueWorkspace.2441be6f9f', 'Start workspace')}
<ArrowRight className="size-4" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="shrink-0"
onClick={onClose}
aria-label={translate(
'auto.components.JiraIssueWorkspace.76513c7898',
'Close Jira issue preview'
)}
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.JiraIssueWorkspace.7a96985ca0', 'Close')}
</TooltipContent>
</Tooltip>
</div>
</div>
<JiraIssueMetadataBar
displayed={displayed}
pendingField={pendingField}
transitions={transitions}
priorities={priorities}
users={users}
mutateIssue={mutateIssue}
/>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2.5">
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={pendingField === 'transition' || transitions.length === 0}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium transition hover:opacity-80 disabled:opacity-50',
jiraStatusClass(displayed.status.categoryKey)
)}
>
{displayed.status.name}
{pendingField === 'transition' ? (
<LoaderCircle className="size-3 animate-spin" />
) : null}
</button>
</PopoverTrigger>
<PopoverContent
className="popover-scroll-content scrollbar-sleek w-52 p-1"
align="start"
>
{transitions.map((transition) => (
<button
key={transition.id}
type="button"
onClick={() =>
void mutateIssue(
'transition',
{ transitionId: transition.id },
{ status: transition.to }
)
}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
{transition.name}
</button>
))}
</PopoverContent>
</Popover>
<JiraIssueWorkspaceContent
displayed={displayed}
titleDraft={titleDraft}
setTitleDraft={setTitleDraft}
labelsDraft={labelsDraft}
setLabelsDraft={setLabelsDraft}
handleSaveTitle={handleSaveTitle}
handleSaveLabels={handleSaveLabels}
pendingField={pendingField}
comments={comments}
commentsError={commentsError}
commentsLoading={commentsLoading}
retryComments={() => void loadComments(displayed, requestIdRef.current)}
onUse={onUse}
actionItems={actionItems}
/>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={pendingField === 'priority'}
className="rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground transition hover:bg-muted/40 disabled:opacity-50"
>
{displayed.priority?.name ??
translate('auto.components.JiraIssueWorkspace.51bed73f88', 'No priority')}
{pendingField === 'priority' ? (
<LoaderCircle className="ml-1 inline size-3 animate-spin" />
) : null}
</button>
</PopoverTrigger>
<PopoverContent
className="popover-scroll-content scrollbar-sleek w-48 p-1"
align="start"
>
<button
type="button"
onClick={() =>
void mutateIssue('priority', { priorityId: null }, { priority: undefined })
}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
{translate('auto.components.JiraIssueWorkspace.51bed73f88', 'No priority')}
</button>
{priorities.map((priority) => (
<button
key={priority.id}
type="button"
onClick={() =>
void mutateIssue('priority', { priorityId: priority.id }, { priority })
}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
{priority.name}
</button>
))}
</PopoverContent>
</Popover>
<JiraIssueCommentComposer
commentDraft={commentDraft}
setCommentDraft={setCommentDraft}
commentSubmitting={commentSubmitting}
canSubmitComment={canSubmitComment}
handleSubmitComment={() => void handleSubmitComment()}
/>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={pendingField === 'assignee'}
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground transition hover:bg-muted/40 disabled:opacity-50"
>
{displayed.assignee?.displayName ??
translate('auto.components.JiraIssueWorkspace.54649eaeab', '+ Assignee')}
{pendingField === 'assignee' ? (
<LoaderCircle className="size-3 animate-spin" />
) : null}
</button>
</PopoverTrigger>
<PopoverContent
className="popover-scroll-content scrollbar-sleek w-56 p-1"
align="start"
>
<button
type="button"
onClick={() =>
void mutateIssue(
'assignee',
{ assigneeAccountId: null },
{ assignee: undefined }
)
}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
{translate('auto.components.JiraIssueWorkspace.0b6b5646ed', 'Unassigned')}
</button>
{users.map((user) => (
<button
key={user.accountId}
type="button"
onClick={() =>
void mutateIssue(
'assignee',
{ assigneeAccountId: user.accountId },
{ assignee: user }
)
}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="size-5 rounded-full" />
) : null}
<span className="truncate">{user.displayName}</span>
</button>
))}
</PopoverContent>
</Popover>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_228px]">
<div className="min-h-0 overflow-y-auto scrollbar-sleek">
<section className="border-b border-border/40 px-4 py-4">
<div className="grid gap-2">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.JiraIssueWorkspace.444865b4a8', 'Title')}
</label>
<div className="flex gap-2">
<Input
value={titleDraft}
onChange={(event) => setTitleDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
handleSaveTitle()
}
}}
className="h-8 text-xs"
/>
<Button
size="sm"
variant="outline"
onClick={handleSaveTitle}
disabled={pendingField === 'title'}
>
{pendingField === 'title' ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
</Button>
</div>
<label className="mt-2 text-[11px] font-medium text-muted-foreground">
{translate('auto.components.JiraIssueWorkspace.aee97b6913', 'Labels')}
</label>
<div className="flex gap-2">
<Input
value={labelsDraft}
onChange={(event) => setLabelsDraft(event.target.value)}
placeholder={translate(
'auto.components.JiraIssueWorkspace.0f3c07a901',
'backend, bug'
)}
className="h-8 text-xs"
/>
<Button
size="sm"
variant="outline"
onClick={handleSaveLabels}
disabled={pendingField === 'labels'}
>
{pendingField === 'labels' ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
</Button>
</div>
</div>
</section>
<section className="border-b border-border/40 px-4 py-4">
<div className="mb-2 flex items-center gap-2">
<JiraIcon className="size-3 text-muted-foreground" />
<span className="text-xs font-medium text-foreground">
{displayed.issueType.name}
</span>
<span className="text-xs text-muted-foreground">
{displayed.project.key} ·{' '}
{displayed.assignee?.displayName ??
translate('auto.components.JiraIssueWorkspace.0b6b5646ed', 'Unassigned')}
</span>
</div>
{displayed.description?.trim() ? (
<CommentMarkdown
content={displayed.description}
variant="document"
className="text-[14px] leading-relaxed"
/>
) : (
<p className="text-sm italic text-muted-foreground">
{translate(
'auto.components.JiraIssueWorkspace.c4889a47e4',
'No description provided.'
)}
</p>
)}
</section>
<section className="px-4 py-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-foreground">
{translate('auto.components.JiraIssueWorkspace.9a980b06b9', 'Comments')}
</span>
{comments.length > 0 ? (
<span className="text-[12px] text-muted-foreground">{comments.length}</span>
) : null}
</div>
{commentsError ? (
<Button
variant="outline"
size="xs"
onClick={() => void loadComments(displayed, requestIdRef.current)}
disabled={commentsLoading}
className="gap-1"
>
{commentsLoading ? (
<LoaderCircle className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
{translate('auto.components.JiraIssueWorkspace.5cd09beaf9', 'Retry')}
</Button>
) : null}
</div>
{commentsError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{commentsError}
</div>
) : commentsLoading && comments.length === 0 ? (
<div className="flex items-center justify-center py-8">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : comments.length === 0 ? (
<p className="text-sm text-muted-foreground">
{translate(
'auto.components.JiraIssueWorkspace.9178090e26',
'No comments yet.'
)}
</p>
) : (
<div className="flex flex-col gap-3">
{comments.map((comment) => (
<div
key={comment.id}
className="rounded-md border border-border/50 bg-muted/20"
>
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-2">
{comment.user?.avatarUrl ? (
<img
src={comment.user.avatarUrl}
alt=""
className="size-5 shrink-0 rounded-full"
/>
) : null}
<span className="truncate text-[13px] font-semibold text-foreground">
{comment.user?.displayName ??
translate(
'auto.components.JiraIssueWorkspace.666cfdd835',
'Unknown'
)}
</span>
<span className="shrink-0 text-[12px] text-muted-foreground">
{formatRelativeTime(comment.createdAt)}
</span>
</div>
<div className="px-3 py-2">
{/* Why: Jira comment screenshots need the same preview
affordance without changing compact comment typography. */}
<CommentMarkdown
content={comment.body}
expandImages
className="text-[13px] leading-relaxed"
/>
</div>
</div>
))}
</div>
)}
</section>
</div>
<aside className="border-t border-border/50 bg-muted/20 px-3 py-3 xl:border-l xl:border-t-0">
<Button
onClick={() => onUse(displayed)}
className="mb-3 w-full justify-center gap-2 sm:hidden"
>
{translate('auto.components.JiraIssueWorkspace.2441be6f9f', 'Start workspace')}
<ArrowRight className="size-4" />
</Button>
<div className="grid gap-1">
{actionItems.map((item) => {
const Icon = item.icon
return (
<Tooltip key={item.label}>
<TooltipTrigger asChild>
<button
type="button"
onClick={item.action}
className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground transition hover:bg-accent hover:text-accent-foreground"
>
<Icon className="size-3.5 shrink-0" />
<span className="truncate">{item.label}</span>
</button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{item.label}
</TooltipContent>
</Tooltip>
)
})}
</div>
</aside>
</div>
<div className="flex-none border-t border-border/50 bg-background px-3 py-3">
<div className="flex gap-2">
<textarea
value={commentDraft}
onChange={(event) => setCommentDraft(event.target.value)}
placeholder={translate(
'auto.components.JiraIssueWorkspace.a585fd204e',
'Add a Jira comment...'
)}
rows={2}
disabled={commentSubmitting}
className="min-h-10 flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
<Button
onClick={() => void handleSubmitComment()}
disabled={!canSubmitComment || commentSubmitting}
className="self-end gap-2"
>
{commentSubmitting ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
{translate('auto.components.JiraIssueWorkspace.b0b92666c9', 'Comment')}
</Button>
</div>
</div>
</div>
) : null}
</SheetContent>
@@ -7,8 +7,6 @@ import {
getOpenableAnnotationLine,
openAnnotationLocation
} from './check-annotation-open'
import { formatAnnotationsForClipboard } from './check-run-clipboard-text'
import { CheckRunCopyButton } from './CheckRunCopyButton'
export function CheckRunAnnotations({
annotations,
@@ -36,29 +34,18 @@ export function CheckRunAnnotations({
[worktreeId]
)
const annotationFallback = translate(
'auto.components.editor.CheckRunDetailsPanel.cdbfda4dec',
'Annotation'
)
const clipboardText = formatAnnotationsForClipboard(annotations, annotationFallback)
return (
<section className="rounded-md border border-border bg-background">
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<div className="text-sm font-medium">
{translate('auto.components.editor.CheckRunDetailsPanel.f2fe8a4e8f', 'Annotations')}
</div>
<CheckRunCopyButton
text={clipboardText}
label={translate('auto.components.editor.CheckRunAnnotations.copy', 'Copy annotations')}
/>
<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">
{annotations.map((annotation, index) => {
const openable = worktreeId ? getOpenableAnnotationLine(annotation) : null
const locationLabel = `${annotation.path ?? annotationFallback}${
annotation.startLine ? `:${annotation.startLine}` : ''
}`
const locationLabel = `${
annotation.path ??
translate('auto.components.editor.CheckRunDetailsPanel.cdbfda4dec', 'Annotation')
}${annotation.startLine ? `:${annotation.startLine}` : ''}`
return (
<div key={`${annotation.path ?? 'annotation'}-${index}`} className="px-3 py-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -1,50 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { CheckRunCopyButton } from './CheckRunCopyButton'
const writeClipboardText = vi.fn<(text: string) => Promise<void>>()
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
afterEach(cleanup)
beforeEach(() => {
writeClipboardText.mockReset().mockResolvedValue(undefined)
Object.assign(window, { api: { ui: { writeClipboardText } } })
})
function renderCopyButton(text: string): void {
render(
<TooltipProvider>
<CheckRunCopyButton text={text} label="Copy annotations" />
</TooltipProvider>
)
}
describe('CheckRunCopyButton', () => {
it('copies the card text and shows copied feedback', async () => {
renderCopyButton('src/main.ts:10\nBuild failed')
const copyButton = screen.getByRole('button', { name: 'Copy annotations' })
fireEvent.click(copyButton)
await waitFor(() =>
expect(writeClipboardText).toHaveBeenCalledWith('src/main.ts:10\nBuild failed')
)
expect(await screen.findByRole('button', { name: 'Copied' })).toBeDefined()
})
it('disables the action when there is no card text', () => {
renderCopyButton('')
const copyButton = screen.getByRole('button', { name: 'Nothing to copy' })
expect((copyButton as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(copyButton)
expect(writeClipboardText).not.toHaveBeenCalled()
})
})
@@ -1,50 +0,0 @@
import React from 'react'
import { Check, Copy } from 'lucide-react'
import { useClipboardTextCopyFeedback } from '@/hooks/use-clipboard-text-copy-feedback'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
export function CheckRunCopyButton({
text,
label
}: {
text: string
label: string
}): React.JSX.Element {
const { canCopy, copyText, status } = useClipboardTextCopyFeedback(text)
const accessibleLabel =
status === 'copied'
? translate('auto.components.editor.CheckRunCopyButton.copied', 'Copied')
: status === 'failed'
? translate('auto.components.editor.CheckRunCopyButton.failed', "Couldn't copy")
: canCopy
? label
: translate('auto.components.editor.CheckRunCopyButton.empty', 'Nothing to copy')
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={accessibleLabel}
disabled={!canCopy}
onClick={() => void copyText()}
className={cn(
'text-muted-foreground',
status === 'copied' && 'text-status-success',
status === 'failed' && 'text-destructive'
)}
>
{status === 'copied' ? <Check /> : <Copy />}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{accessibleLabel}
</TooltipContent>
</Tooltip>
)
}
@@ -7,10 +7,8 @@ import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github
import { SourceControlFixSplitButton } from '@/components/right-sidebar/source-control-fix-split-button'
import { translate } from '@/i18n/i18n'
import { useCheckRunDetailsFixWithAI } from './check-run-details-fix-with-ai'
import { formatCheckRunOutputForClipboard } from './check-run-clipboard-text'
import { CheckRunAnnotations } from './CheckRunAnnotations'
import { CheckRunJobs } from './CheckRunJobs'
import { CheckRunCopyButton } from './CheckRunCopyButton'
function formatCheckTimestamp(value: string | null | undefined): string | null {
if (!value) {
@@ -126,7 +124,6 @@ export function CheckRunDetailsPanel({
const hasOutput = Boolean(details?.title || details?.summary || details?.text)
const hasAnnotations = (details?.annotations.length ?? 0) > 0
const hasJobs = jobs.length > 0
const outputClipboardText = details ? formatCheckRunOutputForClipboard(details) : ''
return (
<div className="flex h-full min-h-0 flex-col bg-editor-surface">
@@ -271,17 +268,8 @@ export function CheckRunDetailsPanel({
{hasOutput && (
<section className="rounded-md border border-border bg-background">
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<div className="text-sm font-medium">
{translate('auto.components.editor.CheckRunDetailsPanel.d098e5529a', 'Output')}
</div>
<CheckRunCopyButton
text={outputClipboardText}
label={translate(
'auto.components.editor.CheckRunDetailsPanel.copyOutput',
'Copy output'
)}
/>
<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 && (
@@ -5,8 +5,6 @@ import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type { PRCheckJob, PRCheckStep } from '../../../../shared/github/check-types'
import { resolveStepOutcome, summarizeJobSteps, type StepOutcome } from './check-job-step-status'
import { formatJobsForClipboard } from './check-run-clipboard-text'
import { CheckRunCopyButton } from './CheckRunCopyButton'
function StepOutcomeIcon({ outcome }: { outcome: StepOutcome }): React.JSX.Element {
switch (outcome) {
@@ -151,23 +149,12 @@ export function CheckRunJobs({
jobs: PRCheckJob[]
hasFailedJobs: boolean
}): React.JSX.Element {
const clipboardText = formatJobsForClipboard(
jobs,
translate('auto.components.editor.CheckRunDetailsPanel.ee07b33924', 'unknown')
)
return (
<section className="rounded-md border border-border bg-background">
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<div className="text-sm font-medium">
{hasFailedJobs
? translate('auto.components.editor.CheckRunDetailsPanel.066fedd446', 'Failed jobs')
: translate('auto.components.editor.CheckRunDetailsPanel.49731703ea', 'Jobs')}
</div>
<CheckRunCopyButton
text={clipboardText}
label={translate('auto.components.editor.CheckRunJobs.copy', 'Copy jobs')}
/>
<div className="border-b border-border px-3 py-2 text-sm font-medium">
{hasFailedJobs
? 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) => (
@@ -1,89 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { PRCheckAnnotation, PRCheckJob } from '../../../../shared/github/check-types'
import {
formatAnnotationsForClipboard,
formatCheckRunOutputForClipboard,
formatJobsForClipboard
} from './check-run-clipboard-text'
describe('check run clipboard text', () => {
it('formats all output sections in display order', () => {
expect(
formatCheckRunOutputForClipboard({
title: 'Build failed',
summary: 'One test failed.',
text: 'Run the suite locally.'
})
).toBe('Build failed\n\nOne test failed.\n\nRun the suite locally.')
expect(
formatCheckRunOutputForClipboard({ title: null, summary: 'Summary only', text: null })
).toBe('Summary only')
})
it('formats annotations with localized missing-path labels and separators', () => {
const annotations: PRCheckAnnotation[] = [
{
path: 'src/main.ts',
startLine: 10,
endLine: 10,
annotationLevel: 'failure',
title: 'Type error',
message: 'Expected string',
rawDetails: 'Actual: number'
},
{
path: null,
startLine: null,
endLine: null,
annotationLevel: null,
title: null,
message: 'Workflow failed',
rawDetails: null
}
]
expect(formatAnnotationsForClipboard(annotations, 'Anotacion')).toBe(
'src/main.ts:10\nfailure\nType error\nExpected string\nActual: number\n\nAnotacion\nWorkflow failed'
)
})
it('formats jobs with localized unknown states and log excerpts', () => {
const jobs: PRCheckJob[] = [
{
id: 1,
name: 'unit',
status: null,
conclusion: 'failure',
startedAt: null,
completedAt: null,
url: null,
logTail: 'AssertionError: expected true',
steps: [
{
name: 'test',
status: null,
conclusion: null,
startedAt: null,
completedAt: null
}
]
},
{
id: 2,
name: 'deploy',
status: null,
conclusion: null,
startedAt: null,
completedAt: null,
url: null,
logTail: null,
steps: []
}
]
expect(formatJobsForClipboard(jobs, 'desconocido')).toBe(
'unit: failure\ntest: desconocido\nAssertionError: expected true\n\ndeploy: desconocido'
)
})
})
@@ -1,48 +0,0 @@
import type {
PRCheckAnnotation,
PRCheckJob,
PRCheckRunDetails
} from '../../../../shared/github/check-types'
export function formatCheckRunOutputForClipboard(
details: Pick<PRCheckRunDetails, 'title' | 'summary' | 'text'>
): string {
return [details.title, details.summary, details.text]
.filter((value): value is string => Boolean(value))
.join('\n\n')
}
export function formatAnnotationsForClipboard(
annotations: PRCheckAnnotation[],
annotationFallback: string
): string {
return annotations
.map((annotation) => {
const location = `${annotation.path ?? annotationFallback}${
annotation.startLine ? `:${annotation.startLine}` : ''
}`
return [
location,
annotation.annotationLevel,
annotation.title,
annotation.message,
annotation.rawDetails
]
.filter((value): value is string => Boolean(value))
.join('\n')
})
.join('\n\n')
}
export function formatJobsForClipboard(jobs: PRCheckJob[], unknownLabel: string): string {
return jobs
.map((job) => {
const steps = job.steps.map(
(step) => `${step.name}: ${step.conclusion ?? step.status ?? unknownLabel}`
)
return [`${job.name}: ${job.conclusion ?? job.status ?? unknownLabel}`, ...steps, job.logTail]
.filter((value): value is string => Boolean(value))
.join('\n')
})
.join('\n\n')
}
@@ -2,25 +2,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
extractDetailsSummaryHtml,
isEditableDetailsHtmlBlock,
matchDetailsHtmlBlock,
parseDetailsAttributes,
parseToggleHeadingVariant,
type DetailsHtmlBlock
} from './details-markdown-html'
function nestedToggles(depth: number): string {
let html = '<details class="orca-details" open>\n<summary>leaf</summary>\n\nBody\n\n</details>'
for (let level = depth - 1; level > 0; level -= 1) {
html = `<details class="orca-details" open>\n<summary>level ${level}</summary>\n\n${html}\n\n</details>`
}
return html
}
function isEditableHtml(html: string): boolean {
const block = matchDetailsHtmlBlock(html, 0)
return block !== null && isEditableDetailsHtmlBlock(block)
}
afterEach(() => {
vi.restoreAllMocks()
})
@@ -48,7 +34,8 @@ describe('details markdown html', () => {
const block: DetailsHtmlBlock = {
raw: '',
openingAttributes: '',
inner: `<summary>${'Heading line\n'.repeat(1_000)}</summary><p>Body</p>`
inner: `<summary>${'Heading line\n'.repeat(1_000)}</summary><p>Body</p>`,
hasNestedDetails: false
}
expect(isEditableDetailsHtmlBlock(block)).toBe(true)
@@ -74,67 +61,17 @@ describe('details markdown html', () => {
const editableHeading5: DetailsHtmlBlock = {
raw: '',
openingAttributes: ' data-orca-toggle="heading-5"',
inner: '<summary>Toggle</summary><p>Body</p>'
inner: '<summary>Toggle</summary><p>Body</p>',
hasNestedDetails: false
}
const unsupportedHeading6: DetailsHtmlBlock = {
raw: '',
openingAttributes: ' data-orca-toggle="heading-6"',
inner: '<summary>Toggle</summary><p>Body</p>'
inner: '<summary>Toggle</summary><p>Body</p>',
hasNestedDetails: false
}
expect(isEditableDetailsHtmlBlock(editableHeading5)).toBe(true)
expect(isEditableDetailsHtmlBlock(unsupportedHeading6)).toBe(false)
})
it('accepts nested toggles whose descendants are all editable', () => {
expect(isEditableHtml(nestedToggles(2))).toBe(true)
expect(isEditableHtml(nestedToggles(16))).toBe(true)
})
it('bounds each of many sibling nested toggles independently', () => {
const siblings = (count: number, extra = ''): string =>
Array.from(
{ length: count },
(_, index) =>
`<details class="orca-details"${extra}>\n<summary>sibling ${index}</summary>\n\nBody\n\n</details>`
).join('\n\n')
const wrap = (body: string): string =>
`<details class="orca-details">\n<summary>Outer</summary>\n\n${body}\n\n</details>`
expect(isEditableHtml(wrap(siblings(40)))).toBe(true)
// A single non-editable sibling must still reject, so sharing fence ranges
// cannot make later siblings inherit an earlier sibling's boundaries.
expect(isEditableHtml(wrap(`${siblings(20)}\n\n${siblings(1, ' id="x"')}`))).toBe(false)
})
it('rejects a toggle whose nested toggle is not itself editable', () => {
const block: DetailsHtmlBlock = {
raw: '',
openingAttributes: ' class="orca-details"',
inner: '<summary>Outer</summary><details id="x"><summary>Inner</summary><p>Body</p></details>'
}
expect(isEditableDetailsHtmlBlock(block)).toBe(false)
})
it('rejects nesting past the recursion guard instead of recursing without bound', () => {
expect(isEditableHtml(nestedToggles(17))).toBe(false)
expect(isEditableHtml(nestedToggles(400))).toBe(false)
})
it('rejects unbalanced and non-toggle tags that start with details', () => {
const unbalanced: DetailsHtmlBlock = {
raw: '',
openingAttributes: '',
inner: '<summary>Outer</summary><details><summary>Inner</summary>'
}
const lookalike: DetailsHtmlBlock = {
raw: '',
openingAttributes: '',
inner: '<summary>Outer</summary><detailsish>Body</detailsish>'
}
expect(isEditableDetailsHtmlBlock(unbalanced)).toBe(false)
expect(isEditableDetailsHtmlBlock(lookalike)).toBe(false)
})
})
@@ -34,12 +34,9 @@ export type DetailsHtmlBlock = {
raw: string
openingAttributes: string
inner: string
hasNestedDetails: boolean
}
// Fence ranges depend only on the scanned string, so callers scanning one body
// repeatedly compute them once and share them across sibling matches.
export type MarkdownFenceRanges = readonly (readonly [number, number])[]
export type DetailsSummaryHtml = {
attributes: string
content: string
@@ -91,7 +88,7 @@ export function renderDetailsAttributes(attrs: Record<string, unknown> | undefin
return attributes.join(' ')
}
function markdownFenceRanges(content: string): MarkdownFenceRanges {
function markdownFenceRanges(content: string): [number, number][] {
const ranges: [number, number][] = []
let offset = 0
let openFence: { marker: '`' | '~'; length: number; start: number } | null = null
@@ -133,15 +130,11 @@ function markdownFenceRanges(content: string): MarkdownFenceRanges {
return ranges
}
function isInsideRange(index: number, ranges: MarkdownFenceRanges): boolean {
function isInsideRange(index: number, ranges: [number, number][]): boolean {
return ranges.some(([start, end]) => index >= start && index < end)
}
export function matchDetailsHtmlBlock(
content: string,
start: number,
precomputedFenceRanges?: MarkdownFenceRanges
): DetailsHtmlBlock | null {
export function matchDetailsHtmlBlock(content: string, start: number): DetailsHtmlBlock | null {
const openingMatch = content.slice(start).match(/^<details\b[^>]*>/i)
if (!openingMatch) {
return null
@@ -149,9 +142,10 @@ export function matchDetailsHtmlBlock(
const detailsTagPattern = /<\/?details\b[^>]*>/gi
detailsTagPattern.lastIndex = start
const fenceRanges = precomputedFenceRanges ?? markdownFenceRanges(content)
const fenceRanges = markdownFenceRanges(content)
let depth = 0
let hasNestedDetails = false
for (;;) {
const tagMatch = detailsTagPattern.exec(content)
@@ -173,10 +167,14 @@ export function matchDetailsHtmlBlock(
return {
raw: content.slice(start, closingEnd),
openingAttributes: openingMatch[0].replace(/^<details\b/i, '').replace(/>$/u, ''),
inner: content.slice(start + openingMatch[0].length, tagMatch.index)
inner: content.slice(start + openingMatch[0].length, tagMatch.index),
hasNestedDetails
}
}
} else {
if (depth > 0) {
hasNestedDetails = true
}
depth += 1
}
}
@@ -269,40 +267,11 @@ function isHtmlTagNamePart(code: number): boolean {
)
}
// Why: nested toggles validate recursively and each level rescans its own body,
// so a pathological file would otherwise blow the stack or go quadratic.
const MAX_DETAILS_NESTING_LEVELS = 16
// Removes nested toggles that are themselves editable, so the caller's raw-tag
// scan sees only the body's own markup. Null when a nested toggle can't be one.
function stripEditableNestedDetails(bodyHtml: string, nestingLevel: number): string | null {
let result = ''
let index = 0
// Why: without sharing this, N sibling toggles rescan the whole body N times.
let fenceRanges: MarkdownFenceRanges | null = null
for (;;) {
const nestedStart = indexOfAsciiIgnoreCase(bodyHtml, '<details', index)
if (nestedStart === -1) {
return result + bodyHtml.slice(index)
}
if (nestingLevel >= MAX_DETAILS_NESTING_LEVELS) {
return null
}
fenceRanges ??= markdownFenceRanges(bodyHtml)
const nested = matchDetailsHtmlBlock(bodyHtml, nestedStart, fenceRanges)
if (!nested || !isEditableDetailsHtmlBlock(nested, nestingLevel + 1)) {
return null
}
result += bodyHtml.slice(index, nestedStart)
index = nestedStart + nested.raw.length
export function isEditableDetailsHtmlBlock(block: DetailsHtmlBlock): boolean {
if (block.hasNestedDetails) {
return false
}
}
export function isEditableDetailsHtmlBlock(block: DetailsHtmlBlock, nestingLevel = 1): boolean {
if (!hasOnlySupportedDetailsAttributes(block.openingAttributes)) {
return false
}
@@ -320,11 +289,7 @@ export function isEditableDetailsHtmlBlock(block: DetailsHtmlBlock, nestingLevel
return false
}
const bodyHtml = stripEditableNestedDetails(block.inner.slice(summary.rawLength), nestingLevel)
if (bodyHtml === null) {
return false
}
const bodyHtml = block.inner.slice(summary.rawLength)
if (!hasOnlyPlainParagraphAndBreakTags(bodyHtml)) {
return false
}
@@ -186,74 +186,9 @@ describe('rich markdown round trip', () => {
expect(roundTripMarkdown(input)).toBe(input.trimEnd())
})
it('reopens nested toggles as editable details blocks', () => {
expect(
roundTripMarkdown(
'<details><summary>Outer</summary><details><summary>Inner</summary><p>Body</p></details></details>\n'
)
).toBe(
[
'<details class="orca-details">',
'<summary>Outer</summary>',
'',
'<details class="orca-details">',
'<summary>Inner</summary>',
'',
'Body',
'',
'</details>',
'',
'</details>'
].join('\n')
)
})
it('round-trips an orca-authored nested toggle unchanged', () => {
const input = [
'<details class="orca-details" data-orca-toggle="heading-3" open>',
'<summary>08/26/2026</summary>',
'',
'<details class="orca-details" open>',
'<summary>goals</summary>',
'',
'- Read X post',
' - Collab',
'',
'</details>',
'',
'- after inner',
'',
'</details>',
''
].join('\n')
expect(roundTripMarkdown(input)).toBe(input.trimEnd())
})
it('keeps nested toggle bodies editable rather than inert raw html', () => {
const markdown = markdownAfterTextReplace(
[
'<details class="orca-details" open>',
'<summary>Outer</summary>',
'',
'<details class="orca-details" open>',
'<summary>Inner</summary>',
'',
'Body',
'',
'</details>',
'',
'</details>',
''
].join('\n'),
'Body',
'Edited body'
)
expect(markdown).toContain('Edited body')
})
it('preserves a nested toggle that is not itself editable as passthrough html', () => {
it('preserves nested details blocks as passthrough html', () => {
const input =
'<details><summary>Outer</summary><details id="x"><summary>Inner</summary><p>Body</p></details></details>\n'
'<details><summary>Outer</summary><details><summary>Inner</summary><p>Body</p></details></details>\n'
expect(roundTripMarkdown(input)).toBe(input.trimEnd())
})
@@ -1,68 +0,0 @@
import { Clipboard, ExternalLink, GitBranch } from 'lucide-react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import type { JiraIssue } from '../../../shared/jira-types'
import type { JiraIssueWorkspaceAction } from './jira-issue-workspace-content'
function buildJiraBranchName(issue: JiraIssue): string {
const slug = issue.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 52)
return `${issue.key.toLowerCase()}${slug ? `-${slug}` : ''}`
}
async function copyTextToClipboard(text: string, label: string): Promise<void> {
try {
await window.api.ui.writeClipboardText(text)
toast.success(
translate('auto.components.JiraIssueWorkspace.2ff69a3545', '{{value0}} copied', {
value0: label
})
)
} catch {
toast.error(
translate('auto.components.JiraIssueWorkspace.6c41a9bcea', 'Failed to copy {{value0}}', {
value0: label.toLowerCase()
})
)
}
}
export function getJiraIssueWorkspaceActions(issue: JiraIssue): JiraIssueWorkspaceAction[] {
return [
{
label: translate('auto.components.JiraIssueWorkspace.69da9a208c', 'Open in Jira'),
icon: ExternalLink,
action: () => window.api.shell.openUrl(issue.url)
},
{
label: translate('auto.components.JiraIssueWorkspace.779bb91ee0', 'Copy URL'),
icon: Clipboard,
action: () => void copyTextToClipboard(issue.url, 'URL')
},
{
label: translate('auto.components.JiraIssueWorkspace.38839801e8', 'Copy key'),
icon: Clipboard,
action: () => void copyTextToClipboard(issue.key, 'Key')
},
{
label: translate(
'auto.components.JiraIssueWorkspace.80efa101c5',
'Copy suggested branch name'
),
icon: GitBranch,
action: () => void copyTextToClipboard(buildJiraBranchName(issue), 'Branch name')
},
{
label: translate('auto.components.JiraIssueWorkspace.0cc62bd690', 'Copy prompt'),
icon: Clipboard,
action: () =>
void copyTextToClipboard(
`Complete Jira issue ${issue.key}: ${issue.title}\n\n${issue.url}`,
'Prompt'
)
}
]
}
@@ -1,225 +0,0 @@
import { ArrowRight, LoaderCircle, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format'
import type {
JiraIssue,
JiraIssueUpdate,
JiraPriority,
JiraTransition,
JiraUser
} from '../../../shared/jira-types'
function jiraStatusClass(categoryKey: string): string {
if (categoryKey === 'done') {
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
}
if (categoryKey === 'indeterminate') {
return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-200'
}
return 'border-border/50 bg-muted/40 text-muted-foreground'
}
export function JiraIssueWorkspaceHeader({
displayed,
issueLoading,
onUse,
onClose
}: {
displayed: JiraIssue
issueLoading: boolean
onUse: (issue: JiraIssue) => void
onClose: () => void
}): React.JSX.Element {
return (
<div className="flex-none border-b border-border/50 bg-muted/30 px-4 py-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
<span className="font-mono">{displayed.key}</span>
{displayed.siteName ? <span>{displayed.siteName}</span> : null}
<span>{displayed.project.key}</span>
<span>{formatUiRelativeTimeFromDate(displayed.updatedAt)}</span>
{issueLoading ? <LoaderCircle className="size-3 animate-spin" /> : null}
</div>
<h2 className="mt-1 text-[20px] font-semibold leading-tight text-foreground">
{displayed.title}
</h2>
</div>
<Button
onClick={() => onUse(displayed)}
className="hidden shrink-0 gap-2 sm:inline-flex"
size="sm"
>
{translate('auto.components.JiraIssueWorkspace.2441be6f9f', 'Start workspace')}
<ArrowRight className="size-4" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="shrink-0"
onClick={onClose}
aria-label={translate(
'auto.components.JiraIssueWorkspace.76513c7898',
'Close Jira issue preview'
)}
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.JiraIssueWorkspace.7a96985ca0', 'Close')}
</TooltipContent>
</Tooltip>
</div>
</div>
)
}
export function JiraIssueMetadataBar({
displayed,
pendingField,
transitions,
priorities,
users,
mutateIssue
}: {
displayed: JiraIssue
pendingField: string | null
transitions: JiraTransition[]
priorities: JiraPriority[]
users: JiraUser[]
mutateIssue: (
field: string,
updates: JiraIssueUpdate,
optimistic?: Partial<JiraIssue>
) => Promise<void>
}): React.JSX.Element {
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2.5">
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={pendingField === 'transition' || transitions.length === 0}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium transition hover:opacity-80 disabled:opacity-50',
jiraStatusClass(displayed.status.categoryKey)
)}
>
{displayed.status.name}
{pendingField === 'transition' ? (
<LoaderCircle className="size-3 animate-spin" />
) : null}
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
{transitions.map((transition) => (
<button
key={transition.id}
type="button"
onClick={() =>
void mutateIssue(
'transition',
{ transitionId: transition.id },
{ status: transition.to }
)
}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
{transition.name}
</button>
))}
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={pendingField === 'priority'}
className="rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground transition hover:bg-muted/40 disabled:opacity-50"
>
{displayed.priority?.name ??
translate('auto.components.JiraIssueWorkspace.51bed73f88', 'No priority')}
{pendingField === 'priority' ? (
<LoaderCircle className="ml-1 inline size-3 animate-spin" />
) : null}
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-48 p-1" align="start">
<button
type="button"
onClick={() =>
void mutateIssue('priority', { priorityId: null }, { priority: undefined })
}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
{translate('auto.components.JiraIssueWorkspace.51bed73f88', 'No priority')}
</button>
{priorities.map((priority) => (
<button
key={priority.id}
type="button"
onClick={() =>
void mutateIssue('priority', { priorityId: priority.id }, { priority })
}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
{priority.name}
</button>
))}
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={pendingField === 'assignee'}
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground transition hover:bg-muted/40 disabled:opacity-50"
>
{displayed.assignee?.displayName ??
translate('auto.components.JiraIssueWorkspace.54649eaeab', '+ Assignee')}
{pendingField === 'assignee' ? <LoaderCircle className="size-3 animate-spin" /> : null}
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-56 p-1" align="start">
<button
type="button"
onClick={() =>
void mutateIssue('assignee', { assigneeAccountId: null }, { assignee: undefined })
}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
{translate('auto.components.JiraIssueWorkspace.0b6b5646ed', 'Unassigned')}
</button>
{users.map((user) => (
<button
key={user.accountId}
type="button"
onClick={() =>
void mutateIssue(
'assignee',
{ assigneeAccountId: user.accountId },
{ assignee: user }
)
}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="size-5 rounded-full" />
) : null}
<span className="truncate">{user.displayName}</span>
</button>
))}
</PopoverContent>
</Popover>
</div>
)
}
@@ -1,289 +0,0 @@
import type { LucideIcon } from 'lucide-react'
import { ArrowRight, LoaderCircle, RefreshCw, Save, Send } from 'lucide-react'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { JiraIcon } from '@/components/icons/JiraIcon'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format'
import type { JiraComment, JiraIssue } from '../../../shared/jira-types'
export type JiraIssueWorkspaceAction = {
label: string
icon: LucideIcon
action: () => void
}
export function JiraIssueWorkspaceContent({
displayed,
titleDraft,
setTitleDraft,
labelsDraft,
setLabelsDraft,
handleSaveTitle,
handleSaveLabels,
pendingField,
comments,
commentsError,
commentsLoading,
retryComments,
onUse,
actionItems
}: {
displayed: JiraIssue
titleDraft: string
setTitleDraft: (value: string) => void
labelsDraft: string
setLabelsDraft: (value: string) => void
handleSaveTitle: () => void
handleSaveLabels: () => void
pendingField: string | null
comments: JiraComment[]
commentsError: string | null
commentsLoading: boolean
retryComments: () => void
onUse: (issue: JiraIssue) => void
actionItems: JiraIssueWorkspaceAction[]
}): React.JSX.Element {
return (
<div className="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_228px]">
<div className="min-h-0 overflow-y-auto scrollbar-sleek">
<section className="border-b border-border/40 px-4 py-4">
<div className="grid gap-2">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.JiraIssueWorkspace.444865b4a8', 'Title')}
</label>
<div className="flex gap-2">
<Input
value={titleDraft}
onChange={(event) => setTitleDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
handleSaveTitle()
}
}}
className="h-8 text-xs"
/>
<Button
size="sm"
variant="outline"
onClick={handleSaveTitle}
disabled={pendingField === 'title'}
>
{pendingField === 'title' ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
</Button>
</div>
<label className="mt-2 text-[11px] font-medium text-muted-foreground">
{translate('auto.components.JiraIssueWorkspace.aee97b6913', 'Labels')}
</label>
<div className="flex gap-2">
<Input
value={labelsDraft}
onChange={(event) => setLabelsDraft(event.target.value)}
placeholder={translate(
'auto.components.JiraIssueWorkspace.0f3c07a901',
'backend, bug'
)}
className="h-8 text-xs"
/>
<Button
size="sm"
variant="outline"
onClick={handleSaveLabels}
disabled={pendingField === 'labels'}
>
{pendingField === 'labels' ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
</Button>
</div>
</div>
</section>
<section className="border-b border-border/40 px-4 py-4">
<div className="mb-2 flex items-center gap-2">
<JiraIcon className="size-3 text-muted-foreground" />
<span className="text-xs font-medium text-foreground">{displayed.issueType.name}</span>
<span className="text-xs text-muted-foreground">
{displayed.project.key} ·{' '}
{displayed.assignee?.displayName ??
translate('auto.components.JiraIssueWorkspace.0b6b5646ed', 'Unassigned')}
</span>
</div>
{displayed.description?.trim() ? (
<CommentMarkdown
content={displayed.description}
variant="document"
className="text-[14px] leading-relaxed"
/>
) : (
<p className="text-sm italic text-muted-foreground">
{translate(
'auto.components.JiraIssueWorkspace.c4889a47e4',
'No description provided.'
)}
</p>
)}
</section>
<section className="px-4 py-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-foreground">
{translate('auto.components.JiraIssueWorkspace.9a980b06b9', 'Comments')}
</span>
{comments.length > 0 ? (
<span className="text-[12px] text-muted-foreground">{comments.length}</span>
) : null}
</div>
{commentsError ? (
<Button
variant="outline"
size="xs"
onClick={retryComments}
disabled={commentsLoading}
className="gap-1"
>
{commentsLoading ? (
<LoaderCircle className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
{translate('auto.components.JiraIssueWorkspace.5cd09beaf9', 'Retry')}
</Button>
) : null}
</div>
{commentsError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{commentsError}
</div>
) : commentsLoading && comments.length === 0 ? (
<div className="flex items-center justify-center py-8">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : comments.length === 0 ? (
<p className="text-sm text-muted-foreground">
{translate('auto.components.JiraIssueWorkspace.9178090e26', 'No comments yet.')}
</p>
) : (
<div className="flex flex-col gap-3">
{comments.map((comment) => (
<div key={comment.id} className="rounded-md border border-border/50 bg-muted/20">
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-2">
{comment.user?.avatarUrl ? (
<img
src={comment.user.avatarUrl}
alt=""
className="size-5 shrink-0 rounded-full"
/>
) : null}
<span className="truncate text-[13px] font-semibold text-foreground">
{comment.user?.displayName ??
translate('auto.components.JiraIssueWorkspace.666cfdd835', 'Unknown')}
</span>
<span className="shrink-0 text-[12px] text-muted-foreground">
{formatUiRelativeTimeFromDate(comment.createdAt)}
</span>
</div>
<div className="px-3 py-2">
{/* Why: Jira comment screenshots need the same preview
affordance without changing compact comment typography. */}
<CommentMarkdown
content={comment.body}
expandImages
className="text-[13px] leading-relaxed"
/>
</div>
</div>
))}
</div>
)}
</section>
</div>
<aside className="border-t border-border/50 bg-muted/20 px-3 py-3 xl:border-l xl:border-t-0">
<Button
onClick={() => onUse(displayed)}
className="mb-3 w-full justify-center gap-2 sm:hidden"
>
{translate('auto.components.JiraIssueWorkspace.2441be6f9f', 'Start workspace')}
<ArrowRight className="size-4" />
</Button>
<div className="grid gap-1">
{actionItems.map((item) => {
const Icon = item.icon
return (
<Tooltip key={item.label}>
<TooltipTrigger asChild>
<button
type="button"
onClick={item.action}
className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground transition hover:bg-accent hover:text-accent-foreground"
>
<Icon className="size-3.5 shrink-0" />
<span className="truncate">{item.label}</span>
</button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{item.label}
</TooltipContent>
</Tooltip>
)
})}
</div>
</aside>
</div>
)
}
export function JiraIssueCommentComposer({
commentDraft,
setCommentDraft,
commentSubmitting,
canSubmitComment,
handleSubmitComment
}: {
commentDraft: string
setCommentDraft: (value: string) => void
commentSubmitting: boolean
canSubmitComment: boolean
handleSubmitComment: () => void
}): React.JSX.Element {
return (
<div className="flex-none border-t border-border/50 bg-background px-3 py-3">
<div className="flex gap-2">
<textarea
value={commentDraft}
onChange={(event) => setCommentDraft(event.target.value)}
placeholder={translate(
'auto.components.JiraIssueWorkspace.a585fd204e',
'Add a Jira comment...'
)}
rows={2}
disabled={commentSubmitting}
className="min-h-10 flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
<Button
onClick={handleSubmitComment}
disabled={!canSubmitComment || commentSubmitting}
className="self-end gap-2"
>
{commentSubmitting ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
{translate('auto.components.JiraIssueWorkspace.b0b92666c9', 'Comment')}
</Button>
</div>
</div>
)
}
+2 -12
View File
@@ -14678,16 +14678,14 @@
"d5a8c2f1b9": "Start the default AI agent to fix this check",
"e2b4d7c8a1": "Choose an agent for this check",
"f1c9e3a6d4": "Choose agent to fix check",
"actionRequired": "Action required",
"copyOutput": "Copy output"
"actionRequired": "Action required"
},
"CheckRunJobs": {
"1c0a4d7e02": "succeeded",
"2d3b8f1a55": "skipped",
"3e6c9a2b71": "pending",
"4f7d0c3e88": "steps failed",
"5a8e1d4f23": " · ",
"copy": "Copy jobs"
"5a8e1d4f23": " · "
},
"check": {
"run": {
@@ -14767,14 +14765,6 @@
"insertColumnRight": "Insert column right",
"deleteRow": "Delete row",
"deleteColumn": "Delete column"
},
"CheckRunAnnotations": {
"copy": "Copy annotations"
},
"CheckRunCopyButton": {
"copied": "Copied",
"failed": "Couldn't copy",
"empty": "Nothing to copy"
}
},
"diff": {
@@ -1,139 +0,0 @@
import { randomUUID } from 'node:crypto'
import { mkdir, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import type { Locator, Page } from '@stablyai/playwright-test'
import { expect } from '@stablyai/playwright-test'
const MARKDOWN_HYDRATION_TIMEOUT_MS = 25_000
export type ActiveWorktreeContext = {
worktreeId: string
rootPath: string
}
export type ActiveEditorFile = {
filePath: string
}
export async function getActiveWorktreeContext(page: Page): Promise<ActiveWorktreeContext> {
return page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
throw new Error('No active worktree is selected')
}
const worktree = Object.values(state.worktreesByRepo)
.flat()
.find((entry) => entry.id === worktreeId)
if (!worktree) {
throw new Error(`Active worktree was not found in store: ${worktreeId}`)
}
return { worktreeId, rootPath: worktree.path }
})
}
export async function createMarkdownFixture(
context: ActiveWorktreeContext,
directoryName: string,
slug: string,
workerIndex: number,
initialMarkdown: string
): Promise<string> {
const directory = path.join(context.rootPath, directoryName)
await mkdir(directory, { recursive: true })
const filePath = path.join(directory, `${slug}-${workerIndex}-${Date.now()}-${randomUUID()}.md`)
await writeFile(filePath, initialMarkdown, 'utf8')
return filePath
}
export async function cleanupMarkdownFixture(filePath: string | null): Promise<void> {
if (!filePath) {
return
}
try {
await rm(filePath, { force: true })
} catch {
// Best-effort cleanup must not hide the editor regression assertion.
}
}
export async function openMarkdownFixture(
page: Page,
context: ActiveWorktreeContext,
filePath: string
): Promise<ActiveEditorFile> {
const relativePath = path.relative(context.rootPath, filePath)
await page.evaluate(
({ filePath, relativePath, worktreeId }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
store.getState().openFile({
filePath,
relativePath,
worktreeId,
language: 'markdown',
mode: 'edit'
})
},
{ filePath, relativePath, worktreeId: context.worktreeId }
)
let activeFile: ActiveEditorFile | null = null
await expect
.poll(
async () => {
activeFile = await page.evaluate(() => {
const store = window.__store
if (!store) {
return null
}
const state = store.getState()
const file = state.openFiles.find((entry) => entry.id === state.activeFileId)
return file ? { filePath: file.filePath } : null
})
return activeFile?.filePath ?? null
},
{
timeout: 5_000,
message: `Active editor file did not become ${filePath}`
}
)
.toBe(filePath)
if (!activeFile) {
throw new Error(`Active editor file was not available after opening ${filePath}`)
}
return activeFile
}
export async function waitForRichMarkdownEditor(page: Page): Promise<Locator> {
const editor = page.locator('.rich-markdown-editor')
await expect(editor).toBeVisible({ timeout: MARKDOWN_HYDRATION_TIMEOUT_MS })
return editor
}
export async function closeActiveEditorTab(page: Page, filePath: string): Promise<void> {
const fileName = path.basename(filePath)
const tab = page.locator('[data-tab-id]').filter({ hasText: fileName }).last()
await tab.getByRole('button', { name: 'Close tab' }).click()
await expect(page.locator('.editor-header-path').filter({ hasText: fileName })).toHaveCount(0, {
timeout: 10_000
})
}
-163
View File
@@ -1,163 +0,0 @@
import type { Page } from '@stablyai/playwright-test'
import { expect } from '@stablyai/playwright-test'
const TOGGLE_RENDER_TIMEOUT_MS = 10_000
export const NESTED_TOGGLE_FIXTURE_DIRECTORY = '.orca-e2e-markdown-nested-toggle'
export const NESTED_TOGGLE_BODY_TEXT = 'Get back to ppl'
export const NESTED_TOGGLE_MARKDOWN = [
'# 08/27/2026',
'',
'<details class="orca-details" data-orca-toggle="heading-3" open>',
'<summary>08/26/2026</summary>',
'',
'<details class="orca-details" open>',
'<summary>goals</summary>',
'',
'- Get back to ppl',
'',
'</details>',
'',
'- after inner toggle',
'',
'</details>',
''
].join('\n')
// An inner toggle carrying unsupported attributes cannot become an editable
// node, so the whole block must still fall back to byte-preserving passthrough.
export const UNSUPPORTED_NESTED_TOGGLE_MARKDOWN = [
'<details class="orca-details" open>',
'<summary>outer</summary>',
'',
'<details id="not-a-toggle">',
'<summary>inner</summary>',
'',
'Body',
'',
'</details>',
'',
'</details>',
''
].join('\n')
export type RenderedToggles = {
toggleCount: number
nestedToggleCount: number
summaries: string[]
variants: (string | null)[]
passthroughBlockCount: number
}
export async function readRenderedToggles(page: Page): Promise<RenderedToggles> {
return page.evaluate(() => {
const editor = document.querySelector('.rich-markdown-editor')
const toggles = Array.from(editor?.querySelectorAll('[data-type="details"]') ?? [])
return {
toggleCount: toggles.length,
nestedToggleCount:
editor?.querySelectorAll('[data-type="details"] [data-type="details"]').length ?? 0,
summaries: toggles.map(
(toggle) => toggle.querySelector('summary')?.textContent?.trim() ?? ''
),
variants: toggles.map((toggle) => toggle.getAttribute('data-orca-toggle')),
passthroughBlockCount: editor?.querySelectorAll('[data-raw-markdown-html-block]').length ?? 0
}
})
}
export async function expectEditableNestedToggles(page: Page): Promise<void> {
await expect
.poll(async () => readRenderedToggles(page), {
timeout: TOGGLE_RENDER_TIMEOUT_MS,
message: 'Nested toggles did not render as editable toggle nodes'
})
.toEqual({
toggleCount: 2,
nestedToggleCount: 1,
summaries: ['08/26/2026', 'goals'],
variants: ['heading-3', null],
passthroughBlockCount: 0
})
}
export async function expectPassthroughFallback(page: Page): Promise<void> {
const passthrough = page.locator('.rich-markdown-editor [data-raw-markdown-html-block]')
await expect(passthrough).toHaveCount(1, { timeout: TOGGLE_RENDER_TIMEOUT_MS })
await expect(passthrough).toContainText('<details id="not-a-toggle">')
}
async function selectionIsInsideNestedToggleBody(page: Page): Promise<boolean> {
return page.evaluate(() => {
const editor = document.querySelector('.rich-markdown-editor') as
| (Element & { editor?: { state?: { selection?: { from?: number; empty?: boolean } } } })
| null
const paragraph = editor?.querySelector(
'[data-type="details"] [data-type="details"] [data-type="detailsContent"] p'
) as (Element & { pmViewDesc?: { posAtStart?: number; posAtEnd?: number } }) | null
const selection = editor?.editor?.state?.selection
const from = selection?.from
const start = paragraph?.pmViewDesc?.posAtStart
const end = paragraph?.pmViewDesc?.posAtEnd
if (
!selection?.empty ||
typeof from !== 'number' ||
typeof start !== 'number' ||
typeof end !== 'number'
) {
return false
}
return from >= start && from <= end
})
}
export async function placeCaretInNestedToggleBody(page: Page): Promise<void> {
// Real input only: click the body text itself, then extend to end of line.
await page
.locator('.rich-markdown-editor')
.getByText(NESTED_TOGGLE_BODY_TEXT, { exact: true })
.click()
await page.keyboard.press('End')
await expect
.poll(async () => selectionIsInsideNestedToggleBody(page), {
timeout: 3_000,
message: 'Clicking the nested toggle body text did not place the caret inside it'
})
.toBe(true)
}
export async function expectSentinelInsideNestedToggle(
page: Page,
sentinel: string
): Promise<void> {
const nestedBody = page.locator(
'.rich-markdown-editor [data-type="details"] [data-type="details"] [data-type="detailsContent"]'
)
await expect(nestedBody).toContainText(sentinel, { timeout: TOGGLE_RENDER_TIMEOUT_MS })
}
export function expectFileKeepsNesting(fileContents: string, sentinel: string): void {
const outerOpen = fileContents.indexOf('<details class="orca-details" data-orca-toggle=')
const innerOpen = fileContents.indexOf('<details class="orca-details" open>')
const firstClose = fileContents.indexOf('</details>')
const sentinelAt = fileContents.indexOf(sentinel)
expect({
openTagCount: fileContents.split('<details').length - 1,
closeTagCount: fileContents.split('</details>').length - 1,
innerIsNestedInOuter: outerOpen !== -1 && innerOpen > outerOpen,
// The edited line has to stay inside the inner toggle, before its close.
sentinelInsideInner: sentinelAt !== -1 && sentinelAt > innerOpen && sentinelAt < firstClose
}).toEqual({
openTagCount: 2,
closeTagCount: 2,
innerIsNestedInOuter: true,
sentinelInsideInner: true
})
}
+117 -14
View File
@@ -1,20 +1,16 @@
import type { Page } from '@stablyai/playwright-test'
import { randomUUID } from 'node:crypto'
import { mkdir, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import type { Locator, Page } from '@stablyai/playwright-test'
import { expect } from '@stablyai/playwright-test'
import {
createMarkdownFixture as createMarkdownFixtureIn,
type ActiveWorktreeContext
} from './markdown-editor-fixture'
const MARKDOWN_HYDRATION_TIMEOUT_MS = 25_000
const DRAFT_SERIALIZATION_TIMEOUT_MS = 10_000
const FIXTURE_DIRECTORY = '.orca-e2e-markdown-ordered-list'
export {
cleanupMarkdownFixture,
getActiveWorktreeContext,
openMarkdownFixture,
waitForRichMarkdownEditor,
type ActiveWorktreeContext
} from './markdown-editor-fixture'
export type ActiveWorktreeContext = {
worktreeId: string
rootPath: string
}
export type MatrixRow = {
name: string
@@ -24,13 +20,120 @@ export type MatrixRow = {
run: (page: Page, sentinel: string) => Promise<void>
}
type ActiveEditorFile = {
filePath: string
}
export async function getActiveWorktreeContext(page: Page): Promise<ActiveWorktreeContext> {
return page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
throw new Error('No active worktree is selected')
}
const worktree = Object.values(state.worktreesByRepo)
.flat()
.find((entry) => entry.id === worktreeId)
if (!worktree) {
throw new Error(`Active worktree was not found in store: ${worktreeId}`)
}
return { worktreeId, rootPath: worktree.path }
})
}
export async function createMarkdownFixture(
context: ActiveWorktreeContext,
slug: string,
workerIndex: number,
initialMarkdown: string
): Promise<string> {
return createMarkdownFixtureIn(context, FIXTURE_DIRECTORY, slug, workerIndex, initialMarkdown)
const directory = path.join(context.rootPath, '.orca-e2e-markdown-ordered-list')
await mkdir(directory, { recursive: true })
const filePath = path.join(directory, `${slug}-${workerIndex}-${Date.now()}-${randomUUID()}.md`)
await writeFile(filePath, initialMarkdown, 'utf8')
return filePath
}
export async function cleanupMarkdownFixture(filePath: string | null): Promise<void> {
if (!filePath) {
return
}
try {
await rm(filePath, { force: true })
} catch {
// Best-effort cleanup must not hide the editor regression assertion.
}
}
export async function openMarkdownFixture(
page: Page,
context: ActiveWorktreeContext,
filePath: string
): Promise<ActiveEditorFile> {
const relativePath = path.relative(context.rootPath, filePath)
await page.evaluate(
({ filePath, relativePath, worktreeId }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
store.getState().openFile({
filePath,
relativePath,
worktreeId,
language: 'markdown',
mode: 'edit'
})
},
{ filePath, relativePath, worktreeId: context.worktreeId }
)
let activeFile: ActiveEditorFile | null = null
await expect
.poll(
async () => {
activeFile = await page.evaluate(() => {
const store = window.__store
if (!store) {
return null
}
const state = store.getState()
const file = state.openFiles.find((entry) => entry.id === state.activeFileId)
return file ? { filePath: file.filePath } : null
})
return activeFile?.filePath ?? null
},
{
timeout: 5_000,
message: `Active editor file did not become ${filePath}`
}
)
.toBe(filePath)
if (!activeFile) {
throw new Error(`Active editor file was not available after opening ${filePath}`)
}
return activeFile
}
export async function waitForRichMarkdownEditor(page: Page): Promise<Locator> {
const editor = page.locator('.rich-markdown-editor')
await expect(editor).toBeVisible({ timeout: MARKDOWN_HYDRATION_TIMEOUT_MS })
return editor
}
export async function expectSentinelParagraphOutsideOrderedList(
-113
View File
@@ -1,113 +0,0 @@
import { readFileSync } from 'node:fs'
import { test, expect } from './helpers/orca-app'
import {
cleanupMarkdownFixture,
closeActiveEditorTab,
createMarkdownFixture,
getActiveWorktreeContext,
openMarkdownFixture,
waitForRichMarkdownEditor
} from './helpers/markdown-editor-fixture'
import {
expectEditableNestedToggles,
expectFileKeepsNesting,
expectPassthroughFallback,
expectSentinelInsideNestedToggle,
NESTED_TOGGLE_FIXTURE_DIRECTORY,
NESTED_TOGGLE_MARKDOWN,
placeCaretInNestedToggleBody,
UNSUPPORTED_NESTED_TOGGLE_MARKDOWN
} from './helpers/markdown-nested-toggle'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
test.describe('Markdown nested toggle regression', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
})
test('a nested toggle on disk reopens as editable toggles', async ({ orcaPage }, testInfo) => {
const context = await getActiveWorktreeContext(orcaPage)
let filePath: string | null = null
try {
filePath = await createMarkdownFixture(
context,
NESTED_TOGGLE_FIXTURE_DIRECTORY,
'nested-toggle-reopen',
testInfo.workerIndex,
NESTED_TOGGLE_MARKDOWN
)
await openMarkdownFixture(orcaPage, context, filePath)
await waitForRichMarkdownEditor(orcaPage)
await expectEditableNestedToggles(orcaPage)
} finally {
await cleanupMarkdownFixture(filePath)
}
})
test('editing a nested toggle survives save and reopen', async ({ orcaPage }, testInfo) => {
const context = await getActiveWorktreeContext(orcaPage)
const sentinel = `editedInsideNestedToggle${Date.now()}`
let filePath: string | null = null
try {
filePath = await createMarkdownFixture(
context,
NESTED_TOGGLE_FIXTURE_DIRECTORY,
'nested-toggle-edit',
testInfo.workerIndex,
NESTED_TOGGLE_MARKDOWN
)
await openMarkdownFixture(orcaPage, context, filePath)
await waitForRichMarkdownEditor(orcaPage)
await expectEditableNestedToggles(orcaPage)
await placeCaretInNestedToggleBody(orcaPage)
await orcaPage.keyboard.type(` ${sentinel}`)
await expectSentinelInsideNestedToggle(orcaPage, sentinel)
// Save through the real shortcut and assert the bytes that landed on disk.
await orcaPage.keyboard.press('ControlOrMeta+S')
const savedPath = filePath
await expect
.poll(() => readFileSync(savedPath, 'utf8'), { timeout: 10_000 })
.toContain(sentinel)
expectFileKeepsNesting(readFileSync(savedPath, 'utf8'), sentinel)
// The reported bug only appeared on reopen, so close the tab and parse
// the saved file again from scratch.
await closeActiveEditorTab(orcaPage, savedPath)
await openMarkdownFixture(orcaPage, context, savedPath)
await waitForRichMarkdownEditor(orcaPage)
await expectEditableNestedToggles(orcaPage)
await expectSentinelInsideNestedToggle(orcaPage, sentinel)
} finally {
await cleanupMarkdownFixture(filePath)
}
})
test('a nested toggle that cannot be represented stays raw passthrough', async ({
orcaPage
}, testInfo) => {
const context = await getActiveWorktreeContext(orcaPage)
let filePath: string | null = null
try {
filePath = await createMarkdownFixture(
context,
NESTED_TOGGLE_FIXTURE_DIRECTORY,
'nested-toggle-unsupported',
testInfo.workerIndex,
UNSUPPORTED_NESTED_TOGGLE_MARKDOWN
)
await openMarkdownFixture(orcaPage, context, filePath)
await waitForRichMarkdownEditor(orcaPage)
await expectPassthroughFallback(orcaPage)
} finally {
await cleanupMarkdownFixture(filePath)
}
})
})