Clean up long renderer JSX files

This commit is contained in:
Neil
2026-06-02 20:31:40 -07:00
committed by GitHub
parent c0b573abda
commit 256ae1ef0b
85 changed files with 7968 additions and 5636 deletions
@@ -1,11 +1,7 @@
/* eslint-disable max-lines -- Why: the automation editor keeps its form fields
colocated so create/edit draft preservation rules stay reviewable. */
import React from 'react'
import { Info, Plus, Sparkles } from 'lucide-react'
import { Info, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import {
Select,
SelectContent,
@@ -29,6 +25,7 @@ import {
isValidAutomationSchedule
} from '../../../../shared/automation-schedules'
import { Field } from './automation-page-parts'
import { AutomationEditorDialogHeader } from './AutomationEditorDialogHeader'
import { AutomationSchedulePicker } from './AutomationSchedulePicker'
import { AutomationSessionField } from './AutomationSessionField'
import { AUTOMATION_TEMPLATES, type AutomationTemplate } from './automation-templates'
@@ -81,28 +78,6 @@ type AutomationEditorDialogProps = {
onSave: () => void
}
function AutomationTemplateCard({
template,
onSelect
}: {
template: AutomationTemplate
onSelect: () => void
}): React.JSX.Element {
return (
<button
type="button"
onClick={onSelect}
className="rounded-md border border-border/70 bg-background px-3 py-2 text-left shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<div className="text-[11px] font-medium uppercase text-muted-foreground">
{template.category}
</div>
<div className="mt-1 text-sm font-medium">{template.label}</div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{template.description}</div>
</button>
)
}
export function AutomationEditorDialog({
open,
isEditing,
@@ -144,78 +119,25 @@ export function AutomationEditorDialog({
event.preventDefault()
}}
>
<DialogHeader className="border-b border-border/50 px-5 py-4 pr-12">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-2">
<DialogTitle className="text-sm font-medium">
{isEditing
? 'Edit automation'
: isEditingExternal
? 'Edit Hermes automation'
: isHermesCreate
? 'Create Hermes automation'
: 'Create automation'}
</DialogTitle>
<Input
value={draft.name}
placeholder="Weekday repo audit"
aria-label="Automation name"
className="h-10 max-w-md border-input bg-input/30 px-3 text-lg font-semibold text-foreground shadow-xs placeholder:text-muted-foreground dark:bg-input/30"
onChange={(event) =>
onDraftChange((current) => ({ ...current, name: event.target.value }))
}
/>
</div>
{isCreateMode ? (
<div className="flex shrink-0 items-center gap-2">
<ToggleGroup
type="single"
value={createTarget}
onValueChange={(value) =>
value && onCreateTargetChange(value as AutomationCreateTarget)
}
variant="outline"
size="sm"
className="grid grid-cols-2"
>
<ToggleGroupItem value="orca" className={MODE_TOGGLE_ITEM_CLASS}>
Orca
</ToggleGroupItem>
<ToggleGroupItem value="hermes" className={MODE_TOGGLE_ITEM_CLASS}>
Hermes
</ToggleGroupItem>
</ToggleGroup>
<Popover open={templateOpen} onOpenChange={setTemplateOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={PICKER_TRIGGER_CLASS}
>
<Sparkles className="size-4" />
Use template
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-96 p-3">
<div className="grid gap-2">
{AUTOMATION_TEMPLATES.map((template) => (
<AutomationTemplateCard
key={template.id}
template={template}
onSelect={() => {
onApplyTemplate(template)
setTemplateOpen(false)
}}
/>
))}
</div>
</PopoverContent>
</Popover>
</div>
) : null}
</div>
</DialogHeader>
<AutomationEditorDialogHeader
isEditing={isEditing}
isEditingExternal={isEditingExternal}
isHermesCreate={isHermesCreate}
isCreateMode={isCreateMode}
createTarget={createTarget}
draftName={draft.name}
templateOpen={templateOpen}
templates={AUTOMATION_TEMPLATES}
modeToggleItemClassName={MODE_TOGGLE_ITEM_CLASS}
pickerTriggerClassName={PICKER_TRIGGER_CLASS}
onCreateTargetChange={onCreateTargetChange}
onDraftNameChange={(name) => onDraftChange((current) => ({ ...current, name }))}
onTemplateOpenChange={setTemplateOpen}
onApplyTemplate={(template) => {
onApplyTemplate(template)
setTemplateOpen(false)
}}
/>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4 scrollbar-sleek">
{draft.scheduleWarning ? (
@@ -0,0 +1,134 @@
import { Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { AutomationCreateTarget } from './AutomationEditorDialog'
import type { AutomationTemplate } from './automation-templates'
type AutomationEditorDialogHeaderProps = {
isEditing: boolean
isEditingExternal: boolean
isHermesCreate: boolean
isCreateMode: boolean
createTarget: AutomationCreateTarget
draftName: string
templateOpen: boolean
templates: AutomationTemplate[]
modeToggleItemClassName: string
pickerTriggerClassName: string
onCreateTargetChange: (target: AutomationCreateTarget) => void
onDraftNameChange: (name: string) => void
onTemplateOpenChange: (open: boolean) => void
onApplyTemplate: (template: AutomationTemplate) => void
}
function AutomationTemplateCard({
template,
onSelect
}: {
template: AutomationTemplate
onSelect: () => void
}): React.JSX.Element {
return (
<button
type="button"
onClick={onSelect}
className="rounded-md border border-border/70 bg-background px-3 py-2 text-left shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<div className="text-[11px] font-medium uppercase text-muted-foreground">
{template.category}
</div>
<div className="mt-1 text-sm font-medium">{template.label}</div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{template.description}</div>
</button>
)
}
export function AutomationEditorDialogHeader({
isEditing,
isEditingExternal,
isHermesCreate,
isCreateMode,
createTarget,
draftName,
templateOpen,
templates,
modeToggleItemClassName,
pickerTriggerClassName,
onCreateTargetChange,
onDraftNameChange,
onTemplateOpenChange,
onApplyTemplate
}: AutomationEditorDialogHeaderProps): React.JSX.Element {
return (
<DialogHeader className="border-b border-border/50 px-5 py-4 pr-12">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-2">
<DialogTitle className="text-sm font-medium">
{isEditing
? 'Edit automation'
: isEditingExternal
? 'Edit Hermes automation'
: isHermesCreate
? 'Create Hermes automation'
: 'Create automation'}
</DialogTitle>
<Input
value={draftName}
placeholder="Weekday repo audit"
aria-label="Automation name"
className="h-10 max-w-md border-input bg-input/30 px-3 text-lg font-semibold text-foreground shadow-xs placeholder:text-muted-foreground dark:bg-input/30"
onChange={(event) => onDraftNameChange(event.target.value)}
/>
</div>
{isCreateMode ? (
<div className="flex shrink-0 items-center gap-2">
<ToggleGroup
type="single"
value={createTarget}
onValueChange={(value) =>
value && onCreateTargetChange(value as AutomationCreateTarget)
}
variant="outline"
size="sm"
className="grid grid-cols-2"
>
<ToggleGroupItem value="orca" className={modeToggleItemClassName}>
Orca
</ToggleGroupItem>
<ToggleGroupItem value="hermes" className={modeToggleItemClassName}>
Hermes
</ToggleGroupItem>
</ToggleGroup>
<Popover open={templateOpen} onOpenChange={onTemplateOpenChange}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={pickerTriggerClassName}
>
<Sparkles className="size-4" />
Use template
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-96 p-3">
<div className="grid gap-2">
{templates.map((template) => (
<AutomationTemplateCard
key={template.id}
template={template}
onSelect={() => onApplyTemplate(template)}
/>
))}
</div>
</PopoverContent>
</Popover>
</div>
) : null}
</div>
</DialogHeader>
)
}
@@ -1,13 +1,13 @@
/* eslint-disable max-lines */
import React, { useState, useCallback } from 'react'
import { X, Wrench, ChevronDown, Send } from 'lucide-react'
import { X, ChevronDown, Send } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { DashboardAgentChildDisclosure } from './DashboardAgentChildDisclosure'
import { DashboardAgentRowMessage } from './DashboardAgentRowMessage'
import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
@@ -529,121 +529,17 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
)}
</span>
</div>
{/* Why: tool row and message row both carry different info — tool shows
the mechanical step (Bash: ...), message shows the agent's narration
("let me verify the test ordering"). Antigravity can emit working
hooks without tool metadata between tool events, so the empty tool
slot must be a real line box instead of whitespace that can collapse.
Tool slot only reserves height while working, since done/blocked rows
shouldn't show a dangling wrench. */}
{isWorking && (
<div
data-agent-row-tool-slot=""
className="mt-0.5 min-w-0 pl-5 text-[10px] leading-snug text-muted-foreground/70"
>
{toolName ? (
<>
{/* Why: header (wrench + tool name) stays on one line. When
collapsed, the input truncates inline next to the name. When
expanded, the input moves to its own block below so long
commands wrap to a consistent left margin instead of the
jagged shape that flex-wrapping produces. */}
<div
data-agent-row-tool-header="true"
className={cn(
'flex h-[1lh] min-w-0 items-center gap-1',
!expanded && 'overflow-hidden'
)}
>
<Wrench className="size-2.5 shrink-0" />
<code className="shrink-0 font-mono text-[10px]">{toolName}</code>
{!expanded && toolInput && (
<span className="min-w-0 truncate text-muted-foreground/60" title={toolInput}>
{toolInput}
</span>
)}
</div>
{/* Why: grid-rows [0fr]→[1fr] is the CSS-only height animation
pattern — outer grid track interpolates smoothly while the
inner min-h-0 + overflow-hidden clips content during the
transition. This avoids measuring heights in JS and still
animates unknown content sizes. */}
{toolInput && (
<div
className={cn(
'grid transition-[grid-template-rows,margin-top] duration-200 ease-out',
expanded ? 'mt-0.5 grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
>
<pre className="min-h-0 overflow-hidden whitespace-pre-wrap break-words font-mono text-[10px] text-muted-foreground/60">
{toolInput}
</pre>
</div>
)}
</>
) : (
<span data-agent-row-tool-placeholder="true" aria-hidden className="block h-[1lh]" />
)}
</div>
)}
{/* Why: message slot is always reserved in collapsed view so the row
height stays fixed as lastAssistantMessage arrives/clears. The
expand animation lives on the CommentMarkdown itself (height +
interpolate-size) so the body reveals smoothly instead of snapping
open. When the message is empty we still render a placeholder in
the collapsed view to preserve the reserved line height.
Interrupted gets its visible text on this secondary line, where the
agent response normally appears. That keeps the prompt line clean
while making the red status dot's meaning visible without hover. */}
{isInterrupted || lastAssistantMessage ? (
<div className="mt-0.5 flex min-w-0 items-start gap-1.5 pl-5">
{isInterrupted && (
<span
className="shrink-0 text-[10px] leading-snug text-muted-foreground/80"
aria-label="Interrupted by user"
>
interrupted
</span>
)}
{lastAssistantMessage && (
<CommentMarkdown
content={lastAssistantMessage}
// Why: animate between a 1-line clipped height and the content's
// natural height using Chromium's `interpolate-size: allow-keywords`
// so the message body expands/collapses smoothly instead of
// snapping. Height transition + overflow-hidden keeps the inline-
// flattened preview clipped during the interpolation. Render the
// markdown in both states; in the collapsed view we force every
// nested element inline so `truncate` can ellipsize the whole
// thing on one line. The [&_*]:inline descendant selector flattens
// the markdown tree (lists, pre, headings, blockquotes) into inline
// flow; block margins and list markers are suppressed by
// [&_*]:!m-0 / [&_ul]:list-none so the preview reads as a single
// clean line.
className={cn(
'min-w-0 flex-1 overflow-hidden text-[10px] leading-snug text-muted-foreground/80',
'transition-[height] duration-200 ease-out [interpolate-size:allow-keywords]',
expanded ? 'h-auto' : 'h-[1lh]',
// Why: in collapsed mode we need a single truncated line. Markdown
// blocks (pre, lists, headings) are flattened inline and forced
// to inherit `white-space: nowrap` so <pre>/<code>'s preserved
// newlines don't break out of the truncation container. The
// `!` prefixes override CommentMarkdown's own layout styles so
// nothing (margins, list markers, block line-breaks) can push
// the preview onto a second line.
!expanded &&
'truncate whitespace-nowrap [&_*]:inline [&_*]:!whitespace-nowrap [&_*]:!m-0 [&_*]:!p-0 [&_ul]:list-none [&_ol]:list-none [&_br]:hidden'
)}
title={!expanded ? lastAssistantMessage : undefined}
/>
)}
</div>
) : (
!expanded && (
<div className="mt-0.5 pl-5 text-[10px] leading-snug text-muted-foreground/70"> </div>
)
)}
<DashboardAgentRowToolStep
expanded={expanded}
isWorking={isWorking}
toolName={toolName}
toolInput={toolInput}
/>
<DashboardAgentRowMessage
expanded={expanded}
isInterrupted={isInterrupted}
lastAssistantMessage={lastAssistantMessage}
/>
</div>
)
})
@@ -0,0 +1,50 @@
import { cn } from '@/lib/utils'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
type DashboardAgentRowMessageProps = {
expanded: boolean
isInterrupted: boolean
lastAssistantMessage: string
}
export function DashboardAgentRowMessage({
expanded,
isInterrupted,
lastAssistantMessage
}: DashboardAgentRowMessageProps): React.JSX.Element | null {
// Why: message slot is always reserved in collapsed view so the row height
// stays fixed as assistant text arrives or clears.
if (!isInterrupted && !lastAssistantMessage) {
return expanded ? null : (
<div className="mt-0.5 pl-5 text-[10px] leading-snug text-muted-foreground/70"> </div>
)
}
return (
<div className="mt-0.5 flex min-w-0 items-start gap-1.5 pl-5">
{isInterrupted ? (
<span
className="shrink-0 text-[10px] leading-snug text-muted-foreground/80"
aria-label="Interrupted by user"
>
interrupted
</span>
) : null}
{lastAssistantMessage ? (
<CommentMarkdown
content={lastAssistantMessage}
// Why: animate between a clipped preview and natural height without
// measuring markdown content in JS.
className={cn(
'min-w-0 flex-1 overflow-hidden text-[10px] leading-snug text-muted-foreground/80',
'transition-[height] duration-200 ease-out [interpolate-size:allow-keywords]',
expanded ? 'h-auto' : 'h-[1lh]',
!expanded &&
'truncate whitespace-nowrap [&_*]:inline [&_*]:!whitespace-nowrap [&_*]:!m-0 [&_*]:!p-0 [&_ul]:list-none [&_ol]:list-none [&_br]:hidden'
)}
title={!expanded ? lastAssistantMessage : undefined}
/>
) : null}
</div>
)
}
@@ -0,0 +1,61 @@
import { Wrench } from 'lucide-react'
import { cn } from '@/lib/utils'
type DashboardAgentRowToolStepProps = {
expanded: boolean
isWorking: boolean
toolName: string
toolInput: string
}
export function DashboardAgentRowToolStep({
expanded,
isWorking,
toolName,
toolInput
}: DashboardAgentRowToolStepProps): React.JSX.Element | null {
if (!isWorking) {
return null
}
return (
<div
data-agent-row-tool-slot=""
className="mt-0.5 min-w-0 pl-5 text-[10px] leading-snug text-muted-foreground/70"
>
{toolName ? (
<>
<div
data-agent-row-tool-header="true"
className={cn(
'flex h-[1lh] min-w-0 items-center gap-1',
!expanded && 'overflow-hidden'
)}
>
<Wrench className="size-2.5 shrink-0" />
<code className="shrink-0 font-mono text-[10px]">{toolName}</code>
{!expanded && toolInput ? (
<span className="min-w-0 truncate text-muted-foreground/60" title={toolInput}>
{toolInput}
</span>
) : null}
</div>
{toolInput ? (
<div
className={cn(
'grid transition-[grid-template-rows,margin-top] duration-200 ease-out',
expanded ? 'mt-0.5 grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
>
<pre className="min-h-0 overflow-hidden whitespace-pre-wrap break-words font-mono text-[10px] text-muted-foreground/60">
{toolInput}
</pre>
</div>
) : null}
</>
) : (
<span data-agent-row-tool-placeholder="true" aria-hidden className="block h-[1lh]" />
)}
</div>
)
}
@@ -1,20 +1,6 @@
/* eslint-disable max-lines -- Why: the combined diff tree keeps filtering,
directory rows, file rows, drag metadata, and navigation wiring together so
the row contracts stay local to the surface. */
import React from 'react'
import {
Check,
ChevronDown,
Filter,
Folder,
FolderOpen,
PanelLeftClose,
Search
} from 'lucide-react'
import { basename, dirname, joinPath } from '@/lib/path'
import { Check, Filter, PanelLeftClose, Search } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@@ -22,18 +8,10 @@ import {
buildGitStatusSourceControlTree,
buildSourceControlTree,
compactSourceControlTree,
flattenSourceControlTree,
type SourceControlTreeNode
flattenSourceControlTree
} from '@/components/right-sidebar/source-control-tree'
import { STATUS_COLORS, STATUS_LABELS } from '@/components/right-sidebar/status-display'
import type {
GitBranchChangeEntry,
GitFileStatus,
GitStagingArea,
GitStatusEntry
} from '../../../../shared/types'
import type { GitBranchChangeEntry, GitStagingArea, GitStatusEntry } from '../../../../shared/types'
import {
getCombinedDiffFileTreeSectionKey,
getEntryExtension,
getFilteredCombinedDiffFileTreeEntries,
isGitStatusEntry,
@@ -41,6 +19,7 @@ import {
type CombinedDiffFileTreeEntry,
type CombinedDiffFileTreeMode
} from './combined-diff-file-tree-model'
import { CombinedDiffFileTreeRow, type CombinedDiffTreeNode } from './combined-diff-file-tree-row'
export {
createCombinedDiffSectionIndexMap,
@@ -49,14 +28,6 @@ export {
handleCombinedDiffFileTreeNavigation
} from './combined-diff-file-tree-model'
type CombinedDiffTreeNode = SourceControlTreeNode<
GitStatusEntry | GitBranchChangeEntry,
GitStagingArea | CombinedDiffBranchTreeArea
>
const COMBINED_DIFF_TREE_INDENT_PX = 12
const COMBINED_DIFF_TREE_DIRECTORY_PADDING_PX = 8
const COMBINED_DIFF_TREE_FILE_PADDING_PX = 20
const UNCOMMITTED_AREA_ORDER: readonly GitStagingArea[] = ['unstaged', 'staged', 'untracked']
const UNCOMMITTED_AREA_LABELS: Record<GitStagingArea, string> = {
unstaged: 'Changes',
@@ -325,106 +296,3 @@ export function CombinedDiffFileTree({
</aside>
)
}
function CombinedDiffFileTreeRow({
node,
mode,
worktreePath,
activeSectionKey,
sectionIndexByKey,
isCollapsed,
onToggleDirectory,
onNavigate
}: {
node: CombinedDiffTreeNode
mode: CombinedDiffFileTreeMode
worktreePath: string
activeSectionKey: string | null
sectionIndexByKey: ReadonlyMap<string, number>
isCollapsed: boolean
onToggleDirectory: (key: string) => void
onNavigate: (entry: CombinedDiffFileTreeEntry) => void
}): React.JSX.Element {
if (node.type === 'directory') {
return (
<div
className="group relative flex w-full items-center gap-1 py-1 pr-3 text-xs text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
style={{
paddingLeft: `${node.depth * COMBINED_DIFF_TREE_INDENT_PX + COMBINED_DIFF_TREE_DIRECTORY_PADDING_PX}px`
}}
draggable
onDragStart={(event) => {
event.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, joinPath(worktreePath, node.path))
event.dataTransfer.effectAllowed = 'copy'
}}
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-1 text-left"
onClick={() => onToggleDirectory(node.key)}
aria-expanded={!isCollapsed}
>
<ChevronDown
className={cn('size-3 shrink-0 transition-transform', isCollapsed && '-rotate-90')}
/>
{isCollapsed ? (
<Folder className="size-3 shrink-0" />
) : (
<FolderOpen className="size-3 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate">{node.name}</span>
</button>
<span className="w-4 shrink-0 text-center text-[10px] font-bold tabular-nums text-muted-foreground/80">
{node.fileCount}
</span>
</div>
)
}
const sectionKey = getCombinedDiffFileTreeSectionKey(mode, node.entry)
const FileIcon = getFileTypeIcon(node.entry.path)
const fileName = basename(node.entry.path)
const parentDir = dirname(node.entry.path)
const dirPath = parentDir === '.' ? '' : parentDir
const status = node.entry.status as GitFileStatus
const disabled = !sectionIndexByKey.has(sectionKey)
return (
<button
type="button"
className={cn(
'group flex w-full min-w-0 cursor-pointer items-center gap-1 py-1 pr-3 text-left text-xs transition-colors hover:bg-accent/40 disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent',
activeSectionKey === sectionKey && 'bg-accent/60'
)}
style={{
paddingLeft: `${node.depth * COMBINED_DIFF_TREE_INDENT_PX + COMBINED_DIFF_TREE_FILE_PADDING_PX}px`
}}
disabled={disabled}
draggable={!disabled}
onDragStart={(event) => {
if (disabled) {
event.preventDefault()
return
}
event.dataTransfer.setData(
WORKSPACE_FILE_PATH_MIME,
joinPath(worktreePath, node.entry.path)
)
event.dataTransfer.effectAllowed = 'copy'
}}
onClick={() => onNavigate(node.entry)}
>
<FileIcon className="size-3.5 shrink-0" style={{ color: STATUS_COLORS[status] }} />
<span className="min-w-0 flex-1 truncate">
<span className="text-foreground">{fileName}</span>
{dirPath && <span className="ml-1.5 text-[11px] text-muted-foreground">{dirPath}</span>}
</span>
<span
className="w-4 shrink-0 text-center text-[10px] font-bold"
style={{ color: STATUS_COLORS[status] }}
>
{STATUS_LABELS[status]}
</span>
</button>
)
}
@@ -0,0 +1,168 @@
import { lazy, type RefObject } from 'react'
import { AlertCircle, RefreshCw } from 'lucide-react'
import { DiffEditor, type DiffOnMount } from '@monaco-editor/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover'
import { combinedDiffSectionScrollbarOptions } from './diff-editor-scrollbar-options'
import type { DiffSection } from './diff-section-types'
const ImageDiffViewer = lazy(() => import('./ImageDiffViewer'))
type DiffSectionBodyProps = {
section: DiffSection
index: number
sectionBodyRef: RefObject<HTMLDivElement | null>
sectionBodyHeight: number | undefined
useIntrinsicImageHeight: boolean
popover: {
lineNumber: number
startLine?: number
top: number
left?: number
} | null
addLineCommentPlaceholder?: string
addLineCommentLabel?: string
isBranchMode: boolean
sideBySide: boolean
isDark: boolean
language: string
modelPathBase: string
isEditable: boolean
diffEditorFontSize: number
terminalFontFamily?: string
onCancelComment: () => void
onSubmitComment: (body: string) => Promise<void>
onRetrySection: (index: number) => void
onMount: DiffOnMount
}
export function DiffSectionBody({
section,
index,
sectionBodyRef,
sectionBodyHeight,
useIntrinsicImageHeight,
popover,
addLineCommentPlaceholder,
addLineCommentLabel,
isBranchMode,
sideBySide,
isDark,
language,
modelPathBase,
isEditable,
diffEditorFontSize,
terminalFontFamily,
onCancelComment,
onSubmitComment,
onRetrySection,
onMount
}: DiffSectionBodyProps): React.JSX.Element {
return (
<div
ref={sectionBodyRef}
className={cn('relative', useIntrinsicImageHeight && 'overflow-visible')}
style={sectionBodyHeight === undefined ? undefined : { height: sectionBodyHeight }}
>
{popover ? (
// Why: key by lineNumber so the popover remounts when the anchor
// line changes instead of leaking draft state across lines.
<DiffCommentPopover
key={popover.lineNumber}
lineNumber={popover.lineNumber}
startLine={popover.startLine}
top={popover.top}
left={popover.left}
placeholder={addLineCommentPlaceholder}
submitLabel={addLineCommentLabel}
submittingLabel="Posting…"
onCancel={onCancelComment}
onSubmit={onSubmitComment}
/>
) : null}
{section.loading ? (
<div className="flex h-full items-center gap-2 bg-muted/10 px-3 text-[11px] text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" />
<span>Loading diff...</span>
</div>
) : section.error ? (
<div className="flex h-full items-center justify-between gap-3 bg-muted/10 px-3 text-[11px] text-muted-foreground">
<div className="flex min-w-0 items-center gap-2">
<AlertCircle className="size-3.5 shrink-0 text-destructive" />
<span className="truncate">{section.error}</span>
</div>
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 shrink-0 px-2 text-[11px]"
onClick={(event) => {
event.stopPropagation()
onRetrySection(index)
}}
>
<RefreshCw className="size-3" />
Retry
</Button>
</div>
) : section.diffResult?.kind === 'binary' ? (
section.diffResult.isImage ? (
<ImageDiffViewer
originalContent={section.diffResult.originalContent}
modifiedContent={section.diffResult.modifiedContent}
filePath={section.path}
mimeType={section.diffResult.mimeType}
sideBySide={sideBySide}
layout={useIntrinsicImageHeight ? 'intrinsic' : 'fill'}
/>
) : (
<div className="flex h-full items-center justify-center px-6 text-center">
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">Binary file changed</div>
<div className="text-xs text-muted-foreground">
{isBranchMode
? 'Text diff is unavailable for this file in branch compare.'
: 'Text diff is unavailable for this file.'}
</div>
</div>
</div>
)
) : (
<DiffEditor
height="100%"
language={language}
original={section.originalContent}
modified={section.modifiedContent}
theme={isDark ? 'vs-dark' : 'vs'}
onMount={onMount}
// Why: @monaco-editor/react can dispose models before widget teardown.
// Keep them through unmount and dispose unattached models next tick.
originalModelPath={`${modelPathBase}:original`}
modifiedModelPath={`${modelPathBase}:modified`}
keepCurrentOriginalModel
keepCurrentModifiedModel
options={{
readOnly: !isEditable,
originalEditable: false,
renderSideBySide: sideBySide,
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: diffEditorFontSize,
fontFamily: terminalFontFamily || 'monospace',
lineNumbers: 'on',
automaticLayout: true,
renderOverviewRuler: false,
scrollbar: combinedDiffSectionScrollbarOptions,
hideUnchangedRegions: { enabled: true },
find: {
addExtraSpaceOnTop: false,
autoFindInSelection: 'never',
seedSearchStringFromSelection: 'never'
}
}}
/>
)}
</div>
)
}
@@ -1,6 +1,4 @@
/* eslint-disable max-lines -- Why: this component owns diff rendering, image previews, comment popovers, and expansion state as one synchronized editor row. */
import {
lazy,
useCallback,
useEffect,
useMemo,
@@ -9,8 +7,7 @@ import {
type MutableRefObject,
type ReactNode
} from 'react'
import { AlertCircle, RefreshCw } from 'lucide-react'
import { DiffEditor, type DiffOnMount } from '@monaco-editor/react'
import type { DiffOnMount } from '@monaco-editor/react'
import type { editor as monacoEditor } from 'monaco-editor'
import { monaco } from '@/lib/monaco-setup'
import { detectLanguage } from '@/lib/language-detect'
@@ -21,24 +18,18 @@ import {
useDiffCommentDecorator,
type DecoratedDiffComment
} from '../diff-comments/useDiffCommentDecorator'
import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover'
import {
getDiffCommentPopoverLeft,
getDiffCommentPopoverTop
} from '../diff-comments/diff-comment-popover-position'
import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options'
import { computeLineStats } from './diff-line-stats'
import { DiffSectionHeader } from './DiffSectionHeader'
import { getDiffSectionBodyHeight, isIntrinsicHeightImageDiff } from './diff-section-layout'
import type { DiffSection } from './diff-section-types'
import type { DiffComment } from '../../../../shared/types'
import { cn } from '@/lib/utils'
import { isDiffComment } from '@/lib/diff-comment-compat'
import { Button } from '@/components/ui/button'
import { installEditorSaveShortcut } from './editor-shortcuts'
import { combinedDiffSectionScrollbarOptions } from './diff-editor-scrollbar-options'
const ImageDiffViewer = lazy(() => import('./ImageDiffViewer'))
import { DiffSectionBody } from './DiffSectionBody'
import { useDiffSectionLayoutMetrics } from './useDiffSectionLayoutMetrics'
export function DiffSectionItem({
section,
@@ -285,37 +276,9 @@ export function DiffSectionItem({
}
}
const lineStats = useMemo(
() =>
section.loading || section.error
? null
: computeLineStats(section.originalContent, section.modifiedContent, section.status),
[
section.error,
section.loading,
section.originalContent,
section.modifiedContent,
section.status
]
)
const changedLineCount = useMemo(() => {
if (lineStats) {
return lineStats.added + lineStats.removed
}
if (section.added === undefined && section.removed === undefined) {
return undefined
}
return (section.added ?? 0) + (section.removed ?? 0)
}, [lineStats, section.added, section.removed])
// Why: image diffs need document-flow height in the combined view; the text
// fallback only knows line counts and would squash screenshots into one row.
const useIntrinsicImageHeight = isIntrinsicHeightImageDiff(section.diffResult)
const sectionBodyHeight = getDiffSectionBodyHeight({
measuredContentHeight: sectionHeight,
originalContent: section.originalContent,
modifiedContent: section.modifiedContent,
changedLineCount,
useIntrinsicImageHeight
const { lineStats, sectionBodyHeight, useIntrinsicImageHeight } = useDiffSectionLayoutMetrics({
section,
sectionHeight
})
const handleMount: DiffOnMount = (editor, _monaco) => {
@@ -445,111 +408,28 @@ export function DiffSectionItem({
/>
{!section.collapsed && (
<div
ref={sectionBodyRef}
className={cn('relative', useIntrinsicImageHeight && 'overflow-visible')}
style={sectionBodyHeight === undefined ? undefined : { height: sectionBodyHeight }}
>
{popover && (
// Why: key by lineNumber so the popover remounts when the anchor
// line changes, resetting the internal draft body and textarea
// focus per anchor line instead of leaking state across lines.
<DiffCommentPopover
key={popover.lineNumber}
lineNumber={popover.lineNumber}
startLine={popover.startLine}
top={popover.top}
left={popover.left}
placeholder={addLineCommentPlaceholder}
submitLabel={addLineCommentLabel}
submittingLabel="Posting…"
onCancel={() => setPopover(null)}
onSubmit={handleSubmitComment}
/>
)}
{section.loading ? (
<div className="flex h-full items-center gap-2 bg-muted/10 px-3 text-[11px] text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" />
<span>Loading diff...</span>
</div>
) : section.error ? (
<div className="flex h-full items-center justify-between gap-3 bg-muted/10 px-3 text-[11px] text-muted-foreground">
<div className="flex min-w-0 items-center gap-2">
<AlertCircle className="size-3.5 shrink-0 text-destructive" />
<span className="truncate">{section.error}</span>
</div>
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 shrink-0 px-2 text-[11px]"
onClick={(event) => {
event.stopPropagation()
retrySection(index)
}}
>
<RefreshCw className="size-3" />
Retry
</Button>
</div>
) : section.diffResult?.kind === 'binary' ? (
section.diffResult.isImage ? (
<ImageDiffViewer
originalContent={section.diffResult.originalContent}
modifiedContent={section.diffResult.modifiedContent}
filePath={section.path}
mimeType={section.diffResult.mimeType}
sideBySide={sideBySide}
layout={useIntrinsicImageHeight ? 'intrinsic' : 'fill'}
/>
) : (
<div className="flex h-full items-center justify-center px-6 text-center">
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">Binary file changed</div>
<div className="text-xs text-muted-foreground">
{isBranchMode
? 'Text diff is unavailable for this file in branch compare.'
: 'Text diff is unavailable for this file.'}
</div>
</div>
</div>
)
) : (
<DiffEditor
height="100%"
language={language}
original={section.originalContent}
modified={section.modifiedContent}
theme={isDark ? 'vs-dark' : 'vs'}
onMount={handleMount}
// Why: @monaco-editor/react can dispose models before widget teardown.
// Keep them through unmount and dispose unattached models next tick.
originalModelPath={`${modelPathBase}:original`}
modifiedModelPath={`${modelPathBase}:modified`}
keepCurrentOriginalModel
keepCurrentModifiedModel
options={{
readOnly: !isEditable,
originalEditable: false,
renderSideBySide: sideBySide,
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: diffEditorFontSize,
fontFamily: settings?.terminalFontFamily || 'monospace',
lineNumbers: 'on',
automaticLayout: true,
renderOverviewRuler: false,
scrollbar: combinedDiffSectionScrollbarOptions,
hideUnchangedRegions: { enabled: true },
find: {
addExtraSpaceOnTop: false,
autoFindInSelection: 'never',
seedSearchStringFromSelection: 'never'
}
}}
/>
)}
</div>
<DiffSectionBody
section={section}
index={index}
sectionBodyRef={sectionBodyRef}
sectionBodyHeight={sectionBodyHeight}
useIntrinsicImageHeight={useIntrinsicImageHeight}
popover={popover}
addLineCommentPlaceholder={addLineCommentPlaceholder}
addLineCommentLabel={addLineCommentLabel}
isBranchMode={isBranchMode}
sideBySide={sideBySide}
isDark={isDark}
language={language}
modelPathBase={modelPathBase}
isEditable={isEditable}
diffEditorFontSize={diffEditorFontSize}
terminalFontFamily={settings?.terminalFontFamily}
onCancelComment={() => setPopover(null)}
onSubmitComment={handleSubmitComment}
onRetrySection={retrySection}
onMount={handleMount}
/>
)}
</div>
)
@@ -0,0 +1,65 @@
import { Plus } from 'lucide-react'
import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover'
import type { RichMarkdownAnnotationTarget } from './rich-markdown-review-annotations'
type RichMarkdownAnnotationOverlayProps = {
target: RichMarkdownAnnotationTarget | null
popover: RichMarkdownAnnotationTarget | null
markdownSourceLineOffset: number
onOpenPopover: () => void
onCancelPopover: () => void
onSubmit: (body: string) => Promise<void>
}
export function RichMarkdownAnnotationOverlay({
target,
popover,
markdownSourceLineOffset,
onOpenPopover,
onCancelPopover,
onSubmit
}: RichMarkdownAnnotationOverlayProps): React.JSX.Element {
return (
<>
{target ? (
<button
type="button"
className="orca-diff-comment-add-btn rich-markdown-comment-add-btn"
style={{
top: target.buttonTop ?? 56,
left: target.buttonLeft ?? 16
}}
title="Add review note"
aria-label="Add review note"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onOpenPopover()
}}
>
<Plus className="size-3" />
</button>
) : null}
{popover ? (
<DiffCommentPopover
key={`${popover.startLine ?? popover.lineNumber}:${popover.lineNumber}`}
lineNumber={popover.lineNumber + markdownSourceLineOffset}
startLine={
popover.startLine === undefined
? undefined
: popover.startLine + markdownSourceLineOffset
}
top={popover.top}
left={popover.left}
title="Selected text"
onCancel={onCancelPopover}
onSubmit={onSubmit}
/>
) : null}
</>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,292 @@
import type React from 'react'
import { EditorContent } from '@tiptap/react'
import type { Editor } from '@tiptap/react'
import { RichMarkdownToolbar } from './RichMarkdownToolbar'
import { RichMarkdownSearchBar } from './RichMarkdownSearchBar'
import { RichMarkdownSlashMenu } from './RichMarkdownSlashMenu'
import { RichMarkdownDocLinkMenu } from './RichMarkdownDocLinkMenu'
import { RichMarkdownEmojiMenu } from './RichMarkdownEmojiMenu'
import { RichMarkdownLinkBubble, type LinkBubbleState } from './RichMarkdownLinkBubble'
import { MarkdownTableOfContentsPanel } from './MarkdownTableOfContentsPanel'
import { RichMarkdownAnnotationOverlay } from './RichMarkdownAnnotationOverlay'
import { RichMarkdownReviewNoteLayer } from './RichMarkdownReviewNoteLayer'
import { RichMarkdownReviewRailActions } from './RichMarkdownReviewRailActions'
import type { DocLinkMenuRow, DocLinkMenuState } from './rich-markdown-commands'
import type { SlashCommand, SlashMenuState } from './rich-markdown-slash-commands'
import type { MarkdownTocItem } from './markdown-table-of-contents'
import type { NotesSendMenuScope } from './NotesSendMenu'
import type { MarkdownReviewNote } from '@/lib/markdown-review-notes'
import type { RichMarkdownAnnotationTarget } from './rich-markdown-review-annotations'
import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout'
import type { DiffComment } from '../../../../shared/types'
function shouldFocusEmptyEditorFromSurfaceClick(
event: React.MouseEvent<HTMLDivElement>,
editor: Editor | null
): boolean {
if (!editor?.isEmpty || event.button !== 0) {
return false
}
const target = event.target
if (!(target instanceof Element)) {
return false
}
return !target.closest('.rich-markdown-editor-shell button, .rich-markdown-editor-shell input')
}
type RichMarkdownEditorSurfaceProps = {
editor: Editor | null
editorFontZoomLevel: number
rootRef: (node: HTMLDivElement | null) => void
scrollContainerRef: React.RefObject<HTMLDivElement | null>
headerSlot?: React.ReactNode
reviewRailExpanded: boolean
reviewRailVisible: boolean
notePositions: RichMarkdownReviewNotePosition[]
activeReviewCommentId: string | null
attentionReviewCommentId: string | null
copiedReviewNoteId: string | null
markdownReviewContent: string
worktreeId: string
filePath: string
markdownCommentsCount: number
reviewRailOpen: boolean
reviewNotesCopied: boolean
unsentMarkdownReviewScope: NotesSendMenuScope<MarkdownReviewNote>[]
linkBubble: LinkBubbleState | null
isEditingLink: boolean
slashMenu: SlashMenuState | null
filteredSlashCommands: SlashCommand[]
selectedCommandIndex: number
emojiMenu: { left: number; top: number } | null
docLinkMenu: DocLinkMenuState | null
docLinkRows: DocLinkMenuRow[]
docLinkTotalMatches: number
selectedDocLinkIndex: number
annotationTarget: RichMarkdownAnnotationTarget | null
annotationPopover: RichMarkdownAnnotationTarget | null
markdownSourceLineOffset: number
tableOfContentsItems: MarkdownTocItem[]
showTableOfContents: boolean
searchState: {
activeMatchIndex: number
isSearchOpen: boolean
matchCount: number
searchQuery: string
searchInputRef: React.RefObject<HTMLInputElement | null>
}
searchActions: {
closeSearch: () => void
moveToMatch: (direction: 1 | -1) => void
setSearchQuery: (query: string) => void
}
linkBubbleActions: {
handleLinkSave: (href: string) => void
handleLinkRemove: () => void
handleLinkEditCancel: () => void
handleLinkOpen: () => void
setIsEditingLink: (editing: boolean) => void
}
onToggleLink: () => void
onImagePick: () => void
onEmojiPick: (menu: SlashMenuState) => void
onCloseEmojiMenu: () => void
onOpenAnnotationPopover: () => void
onCancelAnnotationPopover: () => void
onSubmitAnnotation: (body: string) => Promise<void>
onCopyReviewNotes: () => void
onCopyReviewNote: (note: MarkdownReviewNote) => void
onToggleReviewRail: () => void
onReviewNotesDelivered: (notes: readonly MarkdownReviewNote[]) => void
onReviewNoteSourceClick: (comment: DiffComment) => void
onDeleteReviewComment: (commentId: string) => void
onSubmitReviewCommentEdit: (commentId: string, body: string) => Promise<boolean>
onReviewNoteContentResize: () => void
onNavigateTableOfContentsItem: (id: string) => void
onCloseTableOfContents?: () => void
}
export function RichMarkdownEditorSurface({
editor,
editorFontZoomLevel,
rootRef,
scrollContainerRef,
headerSlot,
reviewRailExpanded,
reviewRailVisible,
notePositions,
activeReviewCommentId,
attentionReviewCommentId,
copiedReviewNoteId,
markdownReviewContent,
worktreeId,
filePath,
markdownCommentsCount,
reviewRailOpen,
reviewNotesCopied,
unsentMarkdownReviewScope,
linkBubble,
isEditingLink,
slashMenu,
filteredSlashCommands,
selectedCommandIndex,
emojiMenu,
docLinkMenu,
docLinkRows,
docLinkTotalMatches,
selectedDocLinkIndex,
annotationTarget,
annotationPopover,
markdownSourceLineOffset,
tableOfContentsItems,
showTableOfContents,
searchState,
searchActions,
linkBubbleActions,
onToggleLink,
onImagePick,
onEmojiPick,
onCloseEmojiMenu,
onOpenAnnotationPopover,
onCancelAnnotationPopover,
onSubmitAnnotation,
onCopyReviewNotes,
onCopyReviewNote,
onToggleReviewRail,
onReviewNotesDelivered,
onReviewNoteSourceClick,
onDeleteReviewComment,
onSubmitReviewCommentEdit,
onReviewNoteContentResize,
onNavigateTableOfContentsItem,
onCloseTableOfContents
}: RichMarkdownEditorSurfaceProps): React.JSX.Element {
return (
<div className="rich-markdown-editor-layout">
<div
ref={rootRef}
className={`rich-markdown-editor-shell ${
reviewRailExpanded ? 'has-rich-markdown-review-notes' : ''
}`.trim()}
style={{ '--editor-font-zoom-level': editorFontZoomLevel } as React.CSSProperties}
>
<RichMarkdownToolbar
editor={editor}
onToggleLink={onToggleLink}
onImagePick={onImagePick}
/>
{headerSlot}
<div className="relative min-h-0 flex-1">
<div
ref={scrollContainerRef}
className="relative h-full overflow-auto scrollbar-editor"
onMouseDown={(event) => {
if (!shouldFocusEmptyEditorFromSurfaceClick(event, editor)) {
return
}
// Why: native contenteditable only places the caret on actual line
// boxes; an empty note should still focus from blank document space.
event.preventDefault()
editor?.commands.focus('start')
}}
>
<EditorContent editor={editor} />
{reviewRailVisible && notePositions.length > 0 ? (
<RichMarkdownReviewNoteLayer
positions={notePositions}
activeCommentId={activeReviewCommentId}
attentionCommentId={attentionReviewCommentId}
copiedCommentId={copiedReviewNoteId}
markdownReviewContent={markdownReviewContent}
worktreeId={worktreeId}
filePath={filePath}
onCopyNote={onCopyReviewNote}
onScrollSourceIntoView={onReviewNoteSourceClick}
onDeleteComment={onDeleteReviewComment}
onSubmitEdit={onSubmitReviewCommentEdit}
onContentResize={onReviewNoteContentResize}
onDelivered={onReviewNotesDelivered}
/>
) : null}
</div>
<RichMarkdownSearchBar
activeMatchIndex={searchState.activeMatchIndex}
isOpen={searchState.isSearchOpen}
matchCount={searchState.matchCount}
onClose={searchActions.closeSearch}
onMoveToMatch={searchActions.moveToMatch}
onQueryChange={searchActions.setSearchQuery}
query={searchState.searchQuery}
searchInputRef={searchState.searchInputRef}
/>
</div>
{linkBubble ? (
<RichMarkdownLinkBubble
linkBubble={linkBubble}
isEditing={isEditingLink}
onSave={linkBubbleActions.handleLinkSave}
onRemove={linkBubbleActions.handleLinkRemove}
onEditStart={() => linkBubbleActions.setIsEditingLink(true)}
onEditCancel={linkBubbleActions.handleLinkEditCancel}
onOpen={linkBubbleActions.handleLinkOpen}
/>
) : null}
{slashMenu ? (
<RichMarkdownSlashMenu
editor={editor}
slashMenu={slashMenu}
filteredCommands={filteredSlashCommands}
selectedIndex={selectedCommandIndex}
onImagePick={onImagePick}
onEmojiPick={() => onEmojiPick(slashMenu)}
/>
) : null}
{emojiMenu ? (
<RichMarkdownEmojiMenu
editor={editor}
left={emojiMenu.left}
top={emojiMenu.top}
onClose={onCloseEmojiMenu}
/>
) : null}
{docLinkMenu ? (
<RichMarkdownDocLinkMenu
editor={editor}
menu={docLinkMenu}
rows={docLinkRows}
totalMatches={docLinkTotalMatches}
selectedIndex={selectedDocLinkIndex}
/>
) : null}
<RichMarkdownAnnotationOverlay
target={annotationTarget}
popover={annotationPopover}
markdownSourceLineOffset={markdownSourceLineOffset}
onOpenPopover={onOpenAnnotationPopover}
onCancelPopover={onCancelAnnotationPopover}
onSubmit={onSubmitAnnotation}
/>
{markdownCommentsCount > 0 ? (
<RichMarkdownReviewRailActions
worktreeId={worktreeId}
filePath={filePath}
noteCount={markdownCommentsCount}
railOpen={reviewRailOpen}
notesCopied={reviewNotesCopied}
unsentScope={unsentMarkdownReviewScope}
onToggleRail={onToggleReviewRail}
onCopyNotes={onCopyReviewNotes}
onDelivered={onReviewNotesDelivered}
/>
) : null}
</div>
{showTableOfContents ? (
<MarkdownTableOfContentsPanel
items={tableOfContentsItems}
onClose={onCloseTableOfContents ?? (() => {})}
onNavigate={onNavigateTableOfContentsItem}
/>
) : null}
</div>
)
}
@@ -0,0 +1,127 @@
import { Check, Copy } from 'lucide-react'
import type { DiffComment } from '../../../../shared/types'
import { DiffCommentCard } from '../diff-comments/DiffCommentCard'
import { NotesSendMenu } from './NotesSendMenu'
import {
formatMarkdownReviewNotes,
getMarkdownReviewCardQuote,
type MarkdownReviewNote
} from '@/lib/markdown-review-notes'
import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout'
function isRichMarkdownReviewNoteNavigationClick(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false
}
return !target.closest('button,input,textarea,select,a,[contenteditable="true"]')
}
type RichMarkdownReviewNoteLayerProps = {
positions: RichMarkdownReviewNotePosition[]
activeCommentId: string | null
attentionCommentId: string | null
copiedCommentId: string | null
markdownReviewContent: string
worktreeId: string
filePath: string
onCopyNote: (note: MarkdownReviewNote) => void
onScrollSourceIntoView: (comment: DiffComment) => void
onDeleteComment: (commentId: string) => void
onSubmitEdit: (commentId: string, body: string) => Promise<boolean>
onContentResize: () => void
onDelivered: (notes: readonly MarkdownReviewNote[]) => void
}
export function RichMarkdownReviewNoteLayer({
positions,
activeCommentId,
attentionCommentId,
copiedCommentId,
markdownReviewContent,
worktreeId,
filePath,
onCopyNote,
onScrollSourceIntoView,
onDeleteComment,
onSubmitEdit,
onContentResize,
onDelivered
}: RichMarkdownReviewNoteLayerProps): React.JSX.Element {
return (
<div className="rich-markdown-review-note-layer" aria-label="Review notes">
{positions.map(({ comment, top }) => (
<div
key={comment.id}
data-rich-markdown-review-note-id={comment.id}
className={`rich-markdown-review-note-card ${
activeCommentId === comment.id ? 'is-active' : ''
} ${attentionCommentId === comment.id ? 'is-attention' : ''}`.trim()}
style={{ top }}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
if (!isRichMarkdownReviewNoteNavigationClick(event.target)) {
return
}
onScrollSourceIntoView(comment)
}}
>
<DiffCommentCard
lineNumber={comment.lineNumber}
startLine={comment.startLine}
label={null}
quote={getMarkdownReviewCardQuote(markdownReviewContent, comment)}
body={comment.body}
sentAt={comment.sentAt}
onDelete={() => onDeleteComment(comment.id)}
onSubmitEdit={(body) => onSubmitEdit(comment.id, body)}
onContentResize={onContentResize}
headerActions={
<>
<button
type="button"
className="rich-markdown-review-note-action"
title={copiedCommentId === comment.id ? 'Copied note' : 'Copy note for agent'}
aria-label={
copiedCommentId === comment.id ? 'Copied note' : 'Copy note for agent'
}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onCopyNote(comment as MarkdownReviewNote)
}}
>
{copiedCommentId === comment.id ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</button>
<NotesSendMenu
worktreeId={worktreeId}
groupId={worktreeId}
modeIdParts={['markdown-notes', worktreeId, filePath, 'note', comment.id]}
scopes={[
{
id: 'note',
label: 'This note',
notes: comment.sentAt ? [] : [comment as MarkdownReviewNote],
prompt: formatMarkdownReviewNotes(
[comment as MarkdownReviewNote],
markdownReviewContent
)
}
]}
targetModeLabel="This note"
triggerClassName="rich-markdown-review-note-action"
disabledTooltip="Note already sent"
onDelivered={onDelivered}
/>
</>
}
/>
</div>
))}
</div>
)
}
@@ -0,0 +1,60 @@
import { Check, Copy, MessageSquare } from 'lucide-react'
import { NotesSendMenu, type NotesSendMenuScope } from './NotesSendMenu'
import type { MarkdownReviewNote } from '@/lib/markdown-review-notes'
type RichMarkdownReviewRailActionsProps = {
worktreeId: string
filePath: string
noteCount: number
railOpen: boolean
notesCopied: boolean
unsentScope: NotesSendMenuScope<MarkdownReviewNote>[]
onToggleRail: () => void
onCopyNotes: () => void
onDelivered: (notes: readonly MarkdownReviewNote[]) => void
}
export function RichMarkdownReviewRailActions({
worktreeId,
filePath,
noteCount,
railOpen,
notesCopied,
unsentScope,
onToggleRail,
onCopyNotes,
onDelivered
}: RichMarkdownReviewRailActionsProps): React.JSX.Element {
return (
<div className="rich-markdown-review-rail-actions">
<button
type="button"
className="rich-markdown-review-rail-toggle"
aria-label={railOpen ? 'Hide review notes' : 'Show review notes'}
aria-expanded={railOpen}
title={railOpen ? 'Hide review notes' : 'Show review notes'}
onClick={onToggleRail}
>
<MessageSquare className="size-3.5" />
<span>{noteCount}</span>
</button>
<button
type="button"
className="rich-markdown-review-rail-action"
title={notesCopied ? 'Copied notes' : 'Copy notes for agent'}
aria-label={notesCopied ? 'Copied notes' : 'Copy notes for agent'}
onClick={onCopyNotes}
>
{notesCopied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</button>
<NotesSendMenu
worktreeId={worktreeId}
groupId={worktreeId}
modeIdParts={['markdown-notes', worktreeId, filePath, 'rail']}
scopes={unsentScope}
triggerClassName="rich-markdown-review-rail-action"
onDelivered={onDelivered}
/>
</div>
)
}
@@ -2,8 +2,8 @@ import React from 'react'
import type { Editor } from '@tiptap/react'
import { Search } from 'lucide-react'
import { cn } from '@/lib/utils'
import { runSlashCommand } from './rich-markdown-commands'
import type { SlashCommand, SlashMenuState } from './rich-markdown-commands'
import { runSlashCommand } from './rich-markdown-slash-commands'
import type { SlashCommand, SlashMenuState } from './rich-markdown-slash-commands'
type RichMarkdownSlashMenuProps = {
editor: Editor | null
@@ -0,0 +1,132 @@
import type React from 'react'
import { ChevronDown, Folder, FolderOpen } from 'lucide-react'
import { STATUS_COLORS, STATUS_LABELS } from '@/components/right-sidebar/status-display'
import type { SourceControlTreeNode } from '@/components/right-sidebar/source-control-tree'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { basename, dirname, joinPath } from '@/lib/path'
import { cn } from '@/lib/utils'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import type {
GitBranchChangeEntry,
GitFileStatus,
GitStagingArea,
GitStatusEntry
} from '../../../../shared/types'
import {
getCombinedDiffFileTreeSectionKey,
type CombinedDiffBranchTreeArea,
type CombinedDiffFileTreeEntry,
type CombinedDiffFileTreeMode
} from './combined-diff-file-tree-model'
export type CombinedDiffTreeNode = SourceControlTreeNode<
GitStatusEntry | GitBranchChangeEntry,
GitStagingArea | CombinedDiffBranchTreeArea
>
const COMBINED_DIFF_TREE_INDENT_PX = 12
const COMBINED_DIFF_TREE_DIRECTORY_PADDING_PX = 8
const COMBINED_DIFF_TREE_FILE_PADDING_PX = 20
export function CombinedDiffFileTreeRow({
node,
mode,
worktreePath,
activeSectionKey,
sectionIndexByKey,
isCollapsed,
onToggleDirectory,
onNavigate
}: {
node: CombinedDiffTreeNode
mode: CombinedDiffFileTreeMode
worktreePath: string
activeSectionKey: string | null
sectionIndexByKey: ReadonlyMap<string, number>
isCollapsed: boolean
onToggleDirectory: (key: string) => void
onNavigate: (entry: CombinedDiffFileTreeEntry) => void
}): React.JSX.Element {
if (node.type === 'directory') {
return (
<div
className="group relative flex w-full items-center gap-1 py-1 pr-3 text-xs text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
style={{
paddingLeft: `${node.depth * COMBINED_DIFF_TREE_INDENT_PX + COMBINED_DIFF_TREE_DIRECTORY_PADDING_PX}px`
}}
draggable
onDragStart={(event) => {
event.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, joinPath(worktreePath, node.path))
event.dataTransfer.effectAllowed = 'copy'
}}
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-1 text-left"
onClick={() => onToggleDirectory(node.key)}
aria-expanded={!isCollapsed}
>
<ChevronDown
className={cn('size-3 shrink-0 transition-transform', isCollapsed && '-rotate-90')}
/>
{isCollapsed ? (
<Folder className="size-3 shrink-0" />
) : (
<FolderOpen className="size-3 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate">{node.name}</span>
</button>
<span className="w-4 shrink-0 text-center text-[10px] font-bold tabular-nums text-muted-foreground/80">
{node.fileCount}
</span>
</div>
)
}
const sectionKey = getCombinedDiffFileTreeSectionKey(mode, node.entry)
const FileIcon = getFileTypeIcon(node.entry.path)
const fileName = basename(node.entry.path)
const parentDir = dirname(node.entry.path)
const dirPath = parentDir === '.' ? '' : parentDir
const status = node.entry.status as GitFileStatus
const disabled = !sectionIndexByKey.has(sectionKey)
return (
<button
type="button"
className={cn(
'group flex w-full min-w-0 cursor-pointer items-center gap-1 py-1 pr-3 text-left text-xs transition-colors hover:bg-accent/40 disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent',
activeSectionKey === sectionKey && 'bg-accent/60'
)}
style={{
paddingLeft: `${node.depth * COMBINED_DIFF_TREE_INDENT_PX + COMBINED_DIFF_TREE_FILE_PADDING_PX}px`
}}
disabled={disabled}
draggable={!disabled}
onDragStart={(event) => {
if (disabled) {
event.preventDefault()
return
}
event.dataTransfer.setData(
WORKSPACE_FILE_PATH_MIME,
joinPath(worktreePath, node.entry.path)
)
event.dataTransfer.effectAllowed = 'copy'
}}
onClick={() => onNavigate(node.entry)}
>
<FileIcon className="size-3.5 shrink-0" style={{ color: STATUS_COLORS[status] }} />
<span className="min-w-0 flex-1 truncate">
<span className="text-foreground">{fileName}</span>
{dirPath && <span className="ml-1.5 text-[11px] text-muted-foreground">{dirPath}</span>}
</span>
<span
className="w-4 shrink-0 text-center text-[10px] font-bold"
style={{ color: STATUS_COLORS[status] }}
>
{STATUS_LABELS[status]}
</span>
</button>
)
}
@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import type { SlashCommandId } from './rich-markdown-commands'
import { slashCommands } from './rich-markdown-commands'
import type { SlashCommandId } from './rich-markdown-slash-commands'
import { slashCommands } from './rich-markdown-slash-commands'
function roundTripMarkdown(content: string): string {
const editor = new Editor({
@@ -0,0 +1,39 @@
import type { Editor } from '@tiptap/react'
import type { DiffComment } from '../../../../shared/types'
import { richMarkdownAnnotationHighlightPluginKey } from './rich-markdown-annotation-highlight'
import {
getRichMarkdownAnnotationHighlightRanges,
type RichMarkdownAnnotationTarget
} from './rich-markdown-review-annotations'
export function updateRichMarkdownAnnotationHighlightsAfterSubmit({
annotationPopover,
comments,
editor,
markdownSourceLineOffset
}: {
annotationPopover: RichMarkdownAnnotationTarget
comments: DiffComment[]
editor: Editor | null
markdownSourceLineOffset: number
}): void {
if (!editor) {
return
}
const noteRanges = getRichMarkdownAnnotationHighlightRanges(
editor,
comments,
markdownSourceLineOffset
)
const hasSubmittedRange = noteRanges.some(
(range) => range.from <= annotationPopover.from && annotationPopover.to <= range.to
)
editor.view.dispatch(
editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, {
activeRange: null,
noteRanges: hasSubmittedRange
? noteRanges
: [...noteRanges, { from: annotationPopover.from, to: annotationPopover.to }]
})
)
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { runSlashCommand, slashCommands, type SlashCommandId } from './rich-markdown-commands'
import { runSlashCommand, slashCommands, type SlashCommandId } from './rich-markdown-slash-commands'
function createEditor(content = '/'): Editor {
return new Editor({
@@ -1,32 +1,8 @@
/* eslint-disable max-lines -- Why: slash commands and doc-link command helpers share trigger-range contracts used by the editor keyboard path. */
import React from 'react'
import type React from 'react'
import type { Editor } from '@tiptap/react'
import { TextSelection } from '@tiptap/pm/state'
import type {} from '@tiptap/extension-mathematics'
import {
ChevronRight,
Heading1,
Heading2,
Heading3,
ImageIcon,
List,
ListOrdered,
Quote,
Sigma,
Table2,
Workflow
} from 'lucide-react'
import type { MarkdownDocument } from '../../../../shared/types'
import { stripMarkdownExtension } from './markdown-doc-links'
export type SlashMenuState = {
query: string
from: number
to: number
left: number
top: number
}
export type DocLinkMenuState = {
query: string
// Why: `from` points at the first `[` of the `[[` trigger (not after the brackets)
@@ -44,395 +20,6 @@ export type DocLinkMenuRow =
| { kind: 'document'; document: MarkdownDocument }
| { kind: 'action'; id: string; label: string; run: (editor: Editor) => void }
export type SlashCommandId =
| 'text'
| 'toggle-text'
| 'heading-1'
| 'toggle-h1'
| 'heading-2'
| 'heading-3'
| 'task-list'
| 'bullet-list'
| 'ordered-list'
| 'blockquote'
| 'code-block'
| 'divider'
| 'image'
| 'table'
| 'mermaid'
| 'inline-math'
| 'math-block'
| 'emoji'
export type SlashCommandIcon =
| { kind: 'component'; component: React.ComponentType<{ className?: string }> }
| { kind: 'text'; value: string }
export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others'
export type SlashCommand = {
id: SlashCommandId
label: string
aliases: string[]
icon: SlashCommandIcon
group: SlashCommandGroup
description: string
run: (editor: Editor) => void
}
function icon(component: React.ComponentType<{ className?: string }>): SlashCommandIcon {
return { kind: 'component', component }
}
function textIcon(value: string): SlashCommandIcon {
return { kind: 'text', value }
}
function insertTextWithSelection(
editor: Editor,
text: string,
selectionStartOffset?: number,
selectionEndOffset = selectionStartOffset
): void {
editor.commands.command(({ state, dispatch }) => {
const from = state.selection.from
const tr = state.tr.insertText(text, from, state.selection.to)
if (selectionStartOffset !== undefined) {
const selectionFrom = from + selectionStartOffset
const selectionTo = from + (selectionEndOffset ?? selectionStartOffset)
tr.setSelection(TextSelection.create(tr.doc, selectionFrom, selectionTo))
}
dispatch?.(tr.scrollIntoView())
return true
})
}
function insertCodeBlock(editor: Editor, language: string, text: string): void {
editor.commands.command(({ state, dispatch }) => {
const codeBlockType = state.schema.nodes.codeBlock
if (!codeBlockType) {
return false
}
const node = codeBlockType.create({ language }, text ? state.schema.text(text) : undefined)
const tr = state.tr.replaceSelectionWith(node).scrollIntoView()
const cursor = tr.selection.from + 1
tr.setSelection(TextSelection.create(tr.doc, cursor, cursor))
dispatch?.(tr)
return true
})
}
function insertToggle(editor: Editor, variant?: 'heading-1'): void {
const insertAt = editor.state.selection.from
editor
.chain()
.focus()
.insertContentAt(insertAt, {
type: 'details',
attrs: {
open: true,
...(variant ? { variant } : {})
},
content: [
{
type: 'detailsSummary'
},
{
type: 'detailsContent',
content: [{ type: 'paragraph' }]
}
]
})
.setTextSelection(insertAt + 1)
.run()
}
/**
* Executes a slash command by first deleting the typed slash text, then
* delegating to the command's run method. Image is special-cased because
* window.prompt() is not supported in Electron's renderer process.
*/
export function runSlashCommand(
editor: Editor,
slashMenu: { from: number; to: number },
command: SlashCommand,
onImageCommand?: () => void,
onEmojiCommand?: () => void
): void {
editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run()
// Why: image insertion cannot rely on window.prompt() in Electron, so this
// command is rerouted into the editor's local image picker flow.
if (command.id === 'image' && onImageCommand) {
onImageCommand()
return
}
if (command.id === 'emoji' && onEmojiCommand) {
onEmojiCommand()
return
}
command.run(editor)
}
export const slashCommands: SlashCommand[] = [
{
id: 'heading-1',
label: 'Heading 1',
aliases: ['h1', 'title'],
icon: icon(Heading1),
group: 'Headings',
description: 'Large section heading.',
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
// invoking "/h1" on an existing H1 should keep it as H1, not revert to paragraph.
editor.chain().focus().setHeading({ level: 1 }).run()
}
},
{
id: 'toggle-h1',
label: 'Toggle Heading 1',
aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'],
icon: icon(ChevronRight),
group: 'Headings',
description: 'Create a collapsible section with a large heading summary.',
run: (editor) => {
insertToggle(editor, 'heading-1')
}
},
{
id: 'heading-2',
label: 'Heading 2',
aliases: ['h2'],
icon: icon(Heading2),
group: 'Headings',
description: 'Medium section heading.',
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
// invoking "/h2" on an existing H2 should keep it as H2, not revert to paragraph.
editor.chain().focus().setHeading({ level: 2 }).run()
}
},
{
id: 'heading-3',
label: 'Heading 3',
aliases: ['h3'],
icon: icon(Heading3),
group: 'Headings',
description: 'Small section heading.',
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
// invoking "/h3" on an existing H3 should keep it as H3, not revert to paragraph.
editor.chain().focus().setHeading({ level: 3 }).run()
}
},
{
id: 'blockquote',
label: 'Quote',
aliases: ['quote', 'blockquote'],
icon: icon(Quote),
group: 'Basic blocks',
description: 'Insert a blockquote.',
run: (editor) => {
editor.chain().focus().toggleBlockquote().run()
}
},
{
id: 'ordered-list',
label: 'Numbered List',
aliases: ['ordered', 'ol', 'numbered'],
icon: icon(ListOrdered),
group: 'Basic blocks',
description: 'Create an ordered list.',
run: (editor) => {
editor.chain().focus().toggleOrderedList().run()
}
},
{
id: 'bullet-list',
label: 'Bullet List',
aliases: ['bullet', 'ul', 'list'],
icon: icon(List),
group: 'Basic blocks',
description: 'Create an unordered list.',
run: (editor) => {
editor.chain().focus().toggleBulletList().run()
}
},
{
id: 'task-list',
label: 'Check List',
aliases: ['todo', 'task', 'checkbox'],
icon: icon(List),
group: 'Basic blocks',
description: 'Create a checklist.',
run: (editor) => {
editor.chain().focus().toggleTaskList().run()
}
},
{
id: 'text',
label: 'Paragraph',
aliases: ['paragraph', 'plain'],
icon: icon(List),
group: 'Basic blocks',
description: 'Start a normal paragraph.',
run: (editor) => {
editor.chain().focus().setParagraph().run()
}
},
{
id: 'toggle-text',
label: 'Toggle Text',
aliases: ['toggle', 'details', 'collapse', 'toggle-text'],
icon: icon(ChevronRight),
group: 'Basic blocks',
description: 'Create a collapsible text section.',
run: (editor) => {
insertToggle(editor)
}
},
{
id: 'code-block',
label: 'Code Block',
aliases: ['code', 'snippet'],
icon: icon(List),
group: 'Basic blocks',
description: 'Insert a fenced code block.',
run: (editor) => {
editor.chain().focus().toggleCodeBlock().run()
}
},
{
id: 'divider',
label: 'Divider',
aliases: ['divider', 'rule', 'hr'],
icon: icon(List),
group: 'Basic blocks',
description: 'Insert a horizontal rule.',
run: (editor) => {
editor.chain().focus().setHorizontalRule().run()
}
},
{
id: 'table',
label: 'Table',
aliases: ['grid', 'columns', 'rows'],
icon: icon(Table2),
group: 'Advanced',
description: 'Insert a 3x3 markdown table.',
run: (editor) => {
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
}
},
{
id: 'mermaid',
label: 'Mermaid Diagram',
aliases: ['diagram', 'flowchart', 'chart', 'graph'],
icon: icon(Workflow),
group: 'Advanced',
description: 'Insert a Mermaid fenced block.',
run: (editor) => {
insertCodeBlock(editor, 'mermaid', 'graph TD\n A[Start] --> B[End]')
}
},
{
id: 'inline-math',
label: 'Inline Math',
aliases: ['math', 'latex', 'equation', 'formula'],
icon: icon(Sigma),
group: 'Advanced',
description: 'Insert inline LaTeX math.',
run: (editor) => {
editor.commands.insertInlineMath({ latex: 'x' })
}
},
{
id: 'math-block',
label: 'Math Block',
aliases: ['display math', 'latex block', 'equation block'],
icon: icon(Sigma),
group: 'Advanced',
description: 'Insert display LaTeX math.',
run: (editor) => {
editor.commands.insertBlockMath({ latex: 'x' })
}
},
{
id: 'image',
label: 'Image',
aliases: ['image', 'img'],
icon: icon(ImageIcon),
group: 'Media',
description: 'Insert an image from your computer.',
// Why: window.prompt() is not supported in Electron's renderer process,
// so image URL input is handled by an inline input bar in RichMarkdownEditor.
run: (editor) => {
editor.chain().focus().run()
}
},
{
id: 'emoji',
label: 'Emoji',
aliases: ['smile', 'reaction', 'icon'],
icon: textIcon('🙂'),
group: 'Others',
description: 'Insert a plain Unicode emoji.',
run: (editor) => {
insertTextWithSelection(editor, '🙂')
}
}
]
/**
* Inspects the editor selection to decide whether the slash-command menu
* should be open (and where to position it), or dismissed.
*/
export function syncSlashMenu(
editor: Editor,
root: HTMLDivElement | null,
setSlashMenu: React.Dispatch<React.SetStateAction<SlashMenuState | null>>
): void {
if (!root || editor.view.composing || !editor.isEditable) {
setSlashMenu(null)
return
}
const { state, view } = editor
const { selection } = state
if (!selection.empty) {
setSlashMenu(null)
return
}
const { $from } = selection
if (!$from.parent.isTextblock) {
setSlashMenu(null)
return
}
const blockTextBeforeCursor = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0')
const slashMatch = blockTextBeforeCursor.match(/^\s*\/([a-z0-9-]*)$/i)
if (!slashMatch) {
setSlashMenu(null)
return
}
const slashOffset = blockTextBeforeCursor.lastIndexOf('/')
const start = selection.from - ($from.parentOffset - slashOffset)
const coords = view.coordsAtPos(selection.from)
const rect = root.getBoundingClientRect()
setSlashMenu({
query: slashMatch[1] ?? '',
from: start,
to: selection.from,
left: coords.left - rect.left,
top: coords.bottom - rect.top + 8
})
}
// Why: the leading alternation `(^|[\s(])` is a mid-word guard so typing `foo[[`
// inside a word does not fire the popover — `[[` only triggers at start-of-block,
// after whitespace, or after `(`. `[^[\]|\r\n]*` bans characters that would break
@@ -0,0 +1,83 @@
import type { Editor } from '@tiptap/react'
import type {
RichMarkdownContextMenuCommand,
RichMarkdownContextMenuCommandPayload
} from '../../../../shared/rich-markdown-context-menu'
export function runRichMarkdownContextCommand({
command,
editor,
toggleLink,
pickImage
}: {
command: RichMarkdownContextMenuCommand
editor: Editor
toggleLink: () => void
pickImage: () => void
}): void {
switch (command) {
case 'add-link':
toggleLink()
return
case 'bold':
editor.chain().focus().toggleBold().run()
return
case 'italic':
editor.chain().focus().toggleItalic().run()
return
case 'strike':
editor.chain().focus().toggleStrike().run()
return
case 'inline-code':
editor.chain().focus().toggleCode().run()
return
case 'code-block':
editor.chain().focus().toggleCodeBlock().run()
return
case 'blockquote':
editor.chain().focus().toggleBlockquote().run()
return
case 'paragraph':
editor.chain().focus().setParagraph().run()
return
case 'heading-1':
editor.chain().focus().setHeading({ level: 1 }).run()
return
case 'heading-2':
editor.chain().focus().setHeading({ level: 2 }).run()
return
case 'heading-3':
editor.chain().focus().setHeading({ level: 3 }).run()
return
case 'bullet-list':
editor.chain().focus().toggleBulletList().run()
return
case 'ordered-list':
editor.chain().focus().toggleOrderedList().run()
return
case 'task-list':
editor.chain().focus().toggleTaskList().run()
return
case 'image':
pickImage()
return
case 'divider':
editor.chain().focus().setHorizontalRule().run()
}
}
export function isRichMarkdownContextCommandTarget(
payload: RichMarkdownContextMenuCommandPayload,
root: HTMLElement | null
): boolean {
if (!root) {
return false
}
const rect = root.getBoundingClientRect()
return (
payload.x >= rect.left &&
payload.x <= rect.right &&
payload.y >= rect.top &&
payload.y <= rect.bottom
)
}
@@ -0,0 +1,208 @@
import type { MutableRefObject } from 'react'
import type { Editor } from '@tiptap/react'
import type { EditorView } from '@tiptap/pm/view'
import { toast } from 'sonner'
import { getConnectionId } from '@/lib/connection-context'
import { openHttpLink } from '@/lib/http-link-routing'
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import {
absolutePathToFileUri as toFileUrlForOsEscape,
resolveMarkdownLinkTarget
} from './markdown-internal-links'
import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
import { getRichMarkdownCommentAtPos } from './rich-markdown-review-annotations'
import type { DiffComment } from '../../../../shared/types'
export type ActivateMarkdownLink = (
href: string,
context: {
sourceFilePath: string
worktreeId: string
worktreeRoot: string | null
runtimeEnvironmentId?: string | null
}
) => void | Promise<unknown>
export type RichMarkdownRuntimeSettings = Parameters<typeof settingsForRuntimeOwner>[0]
type RichMarkdownEditorClickRoutingOptions = {
activateMarkdownLink: ActivateMarkdownLink
editorRef: MutableRefObject<Editor | null>
event: MouseEvent
filePath: string
isMac: boolean
markdownCommentsRef: MutableRefObject<DiffComment[]>
markdownSourceLineOffsetRef: MutableRefObject<number>
onOpenDocLinkRef: MutableRefObject<((target: string) => void) | undefined>
pos: number
rootRef: MutableRefObject<HTMLDivElement | null>
runtimeEnvironmentId?: string | null
scrollRichMarkdownReviewNoteCardIntoView: (commentId: string) => void
settings: RichMarkdownRuntimeSettings
view: EditorView
worktreeId: string
worktreeRoot: string | null
}
export function handleRichMarkdownEditorClick({
activateMarkdownLink,
editorRef,
event,
filePath,
isMac,
markdownCommentsRef,
markdownSourceLineOffsetRef,
onOpenDocLinkRef,
pos,
rootRef,
runtimeEnvironmentId,
scrollRichMarkdownReviewNoteCardIntoView,
settings,
view,
worktreeId,
worktreeRoot
}: RichMarkdownEditorClickRoutingOptions): boolean {
const editor = editorRef.current
const modKey = isMac ? event.metaKey : event.ctrlKey
if (!editor) {
return false
}
if (!modKey) {
const selectedComment = getRichMarkdownCommentAtPos(
editor,
markdownCommentsRef.current,
markdownSourceLineOffsetRef.current,
pos
)
if (selectedComment) {
scrollRichMarkdownReviewNoteCardIntoView(selectedComment.id)
}
return false
}
const clickedNode = view.state.doc.nodeAt(pos)
if (clickedNode?.type.name === 'image') {
return activateMarkdownImageClick({
activateMarkdownLink,
filePath,
runtimeEnvironmentId,
src: (clickedNode.attrs.src as string | undefined) ?? '',
worktreeId,
worktreeRoot
})
}
if (clickedNode?.type.name === 'markdownDocLink') {
onOpenDocLinkRef.current?.(clickedNode.attrs.target as string)
return true
}
const href = getClickedLinkHref(view, pos)
if (!href) {
return false
}
if (href.startsWith('#')) {
scrollToAnchorInEditor(rootRef.current, href.slice(1))
return true
}
if (event.shiftKey) {
openMarkdownLinkInClientOs({
href,
filePath,
runtimeEnvironmentId,
settings,
worktreeId,
worktreeRoot
})
return true
}
void activateMarkdownLink(href, {
sourceFilePath: filePath,
worktreeId,
worktreeRoot,
runtimeEnvironmentId
})
return true
}
function activateMarkdownImageClick({
activateMarkdownLink,
filePath,
runtimeEnvironmentId,
src,
worktreeId,
worktreeRoot
}: {
activateMarkdownLink: ActivateMarkdownLink
filePath: string
runtimeEnvironmentId?: string | null
src: string
worktreeId: string
worktreeRoot: string | null
}): boolean {
if (!src) {
return false
}
void activateMarkdownLink(src, {
sourceFilePath: filePath,
worktreeId,
worktreeRoot,
runtimeEnvironmentId
})
return true
}
function getClickedLinkHref(view: EditorView, pos: number): string {
const linkMark = view.state.doc
.resolve(pos)
.marks()
.find((mark) => mark.type.name === 'link')
return linkMark ? (linkMark.attrs.href as string) || '' : ''
}
function openMarkdownLinkInClientOs({
href,
filePath,
worktreeId,
worktreeRoot,
runtimeEnvironmentId,
settings
}: {
href: string
filePath: string
worktreeId: string
worktreeRoot: string | null
runtimeEnvironmentId?: string | null
settings: RichMarkdownRuntimeSettings
}): void {
const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot)
if (!classified) {
return
}
if (classified.kind === 'external') {
openHttpLink(classified.url, { forceSystemBrowser: true })
return
}
if (classified.kind === 'anchor') {
return
}
if (
isLocalPathOpenBlocked(settingsForRuntimeOwner(settings, runtimeEnvironmentId), {
connectionId: getConnectionId(worktreeId)
})
) {
// Why: Shift-click opens through the client OS, which cannot safely resolve
// server-local paths from SSH or remote runtime worktrees.
showLocalPathOpenBlockedToast()
return
}
if (classified.kind === 'markdown') {
void window.api.shell.pathExists(classified.absolutePath).then((exists) => {
if (!exists) {
toast.error(`File not found: ${classified.relativePath}`)
return
}
void window.api.shell.openFileUri(toFileUrlForOsEscape(classified.absolutePath))
})
return
}
void window.api.shell.openFileUri(classified.uri)
}
@@ -5,14 +5,12 @@ import { useAppStore } from '@/store'
import { isMarkdownPreviewFindShortcut } from './markdown-preview-search'
import { editorShortcutMatches } from './editor-shortcuts'
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
import { commitRow, type DocLinkMenuRow, type DocLinkMenuState } from './rich-markdown-commands'
import {
commitRow,
runSlashCommand,
type DocLinkMenuRow,
type DocLinkMenuState,
type SlashCommand,
type SlashMenuState
} from './rich-markdown-commands'
} from './rich-markdown-slash-commands'
import {
collapseEmptyListContinuationParagraph,
commitEmptyOrderedListMarkerAsText,
@@ -0,0 +1,268 @@
import type { Dispatch, SetStateAction } from 'react'
import type { Editor } from '@tiptap/react'
import type { JSONContent } from '@tiptap/core'
import type { DiffComment } from '../../../../shared/types'
import type { RichMarkdownAnnotationHighlightRange } from './rich-markdown-annotation-highlight'
import {
getRichMarkdownLineRangeFromBlocks,
getRichMarkdownRangeStart
} from './rich-markdown-range-bounds'
import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout'
import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges'
export type RichMarkdownCommentBlock = {
key: string
startLine: number
endLine: number
from: number
to: number
}
export type RichMarkdownComposerState = {
lineNumber: number
startLine?: number
}
export type RichMarkdownAnnotationTarget = RichMarkdownComposerState & {
from: number
to: number
selectedText: string
top: number
left?: number
buttonTop: number
buttonLeft: number
}
function countMarkdownLines(value: string): number {
if (value.length === 0) {
return 1
}
return value.split(/\r\n|\r|\n/).length
}
function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string {
return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd()
}
export function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownCommentBlock[] {
const jsonContent = editor.getJSON().content ?? []
const blocks: RichMarkdownCommentBlock[] = []
let nextLine = 1
let previousNodeJson: JSONContent | null = null
let previousNodeLineCount = 0
editor.state.doc.forEach((node, nodeOffset, index) => {
const nodeJson = jsonContent[index]
if (!nodeJson) {
return
}
const nodeMarkdown = serializeRichMarkdownJson(editor, [nodeJson])
const nodeLineCount = countMarkdownLines(nodeMarkdown)
if (previousNodeJson) {
const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson])
const separatorLineCount = Math.max(
0,
countMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount
)
nextLine += separatorLineCount
}
const startLine = nextLine
const endLine = Math.max(startLine, startLine + nodeLineCount - 1)
const from = nodeOffset + 1
blocks.push({
key: `${index}:${startLine}-${endLine}`,
startLine,
endLine,
from,
to: from + Math.max(0, node.nodeSize - 1)
})
nextLine = endLine + 1
previousNodeJson = nodeJson
previousNodeLineCount = nodeLineCount
})
if (blocks.length === 0) {
blocks.push({ key: 'empty:1-1', startLine: 1, endLine: 1, from: 1, to: 1 })
}
return blocks
}
export function clampRichMarkdownAnnotationTarget(
editor: Editor,
target: RichMarkdownAnnotationTarget
): RichMarkdownAnnotationTarget | null {
const maxPos = Math.max(1, editor.state.doc.content.size)
const from = Math.max(1, Math.min(target.from, maxPos))
const to = Math.max(1, Math.min(target.to, maxPos))
const clampedFrom = Math.min(from, to)
const clampedTo = Math.max(from, to)
if (clampedFrom === clampedTo) {
return null
}
return { ...target, from: clampedFrom, to: clampedTo }
}
export function clearRichMarkdownNotePositions(
setNotePositions: Dispatch<SetStateAction<RichMarkdownReviewNotePosition[]>>
): void {
setNotePositions((current) => (current.length === 0 ? current : []))
}
export function getRichMarkdownAnnotationHighlightRanges(
editor: Editor,
comments: readonly DiffComment[],
markdownSourceLineOffset: number
): RichMarkdownAnnotationHighlightRange[] {
return comments.flatMap((comment) =>
getRichMarkdownAnnotationHighlightRangesForComment(editor, comment, markdownSourceLineOffset)
)
}
export function getRichMarkdownAnnotationHighlightRangesForComment(
editor: Editor,
comment: DiffComment,
markdownSourceLineOffset: number
): RichMarkdownAnnotationHighlightRange[] {
const blocks = buildRichMarkdownCommentBlocks(editor)
const selectedText = comment.selectedText?.trim()
if (!selectedText) {
return []
}
const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset)
const block = blocks.find(
(candidate) => candidate.startLine <= bodyLineNumber && bodyLineNumber <= candidate.endLine
)
if (block) {
const blockRanges = findRichMarkdownSelectedTextRanges({
editor,
selectedText,
from: block.from,
to: block.to
})
if (blockRanges.length > 0) {
return blockRanges
}
}
return findRichMarkdownSelectedTextRanges({ editor, selectedText })
}
export function getRichMarkdownCommentAtPos(
editor: Editor,
comments: readonly DiffComment[],
markdownSourceLineOffset: number,
pos: number
): DiffComment | null {
return (
comments.find((comment) =>
getRichMarkdownAnnotationHighlightRangesForComment(
editor,
comment,
markdownSourceLineOffset
).some((range) => range.from <= pos && pos <= range.to)
) ?? null
)
}
export function getRichMarkdownCommentAnchorTop(
editor: Editor,
comment: DiffComment,
block: RichMarkdownCommentBlock,
containerRect: DOMRect,
containerScrollTop: number,
markdownSourceLineOffset: number
): number | null {
try {
const ranges = getRichMarkdownAnnotationHighlightRangesForComment(
editor,
comment,
markdownSourceLineOffset
)
// Why: range notes should sort by the start of the selected text. Anchoring
// to the end puts overlapping ranges with the same final line in creation
// order, so a 43-45 card can render above a 41-45 card.
const anchorPos = getRichMarkdownRangeStart(ranges) ?? block.from
const coords = editor.view.coordsAtPos(
Math.max(1, Math.min(anchorPos, editor.state.doc.content.size))
)
return coords.top - containerRect.top + containerScrollTop
} catch {
return null
}
}
function getRichMarkdownSelectionRange(editor: Editor): RichMarkdownComposerState {
const blocks = buildRichMarkdownCommentBlocks(editor)
const { from, to, empty } = editor.state.selection
const selectedBlocks = empty
? blocks.filter((block) => block.from <= from && from <= block.to)
: blocks.filter((block) => from <= block.to && to >= block.from)
const targetBlocks = selectedBlocks.length > 0 ? selectedBlocks : [blocks[0]!]
return getRichMarkdownLineRangeFromBlocks(targetBlocks) ?? { lineNumber: 1 }
}
export function hasRichMarkdownCommentForRange(
comments: readonly DiffComment[],
target: Pick<RichMarkdownAnnotationTarget, 'lineNumber' | 'selectedText' | 'startLine'>,
markdownSourceLineOffset: number
): boolean {
const startLine = (target.startLine ?? target.lineNumber) + markdownSourceLineOffset
const endLine = target.lineNumber + markdownSourceLineOffset
const selectedText = target.selectedText.trim()
return comments.some((comment) => {
const commentStartLine = comment.startLine ?? comment.lineNumber
return (
commentStartLine === startLine &&
comment.lineNumber === endLine &&
(comment.selectedText?.trim() ?? '') === selectedText
)
})
}
function getCurrentRichMarkdownSelectionRect(root: HTMLElement): DOMRect | null {
const selection = window.getSelection()
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
return null
}
const range = selection.getRangeAt(0)
if (!root.contains(range.commonAncestorContainer)) {
return null
}
const rect = range.getBoundingClientRect()
if (rect.width > 0 || rect.height > 0) {
return rect
}
return Array.from(range.getClientRects()).find((candidate) => candidate.width > 0) ?? null
}
export function getRichMarkdownAnnotationTarget(
editor: Editor,
root: HTMLElement
): RichMarkdownAnnotationTarget | null {
if (editor.state.selection.empty) {
return null
}
const rect = getCurrentRichMarkdownSelectionRect(root)
if (!rect) {
return null
}
const selectedText = window.getSelection()?.toString().trim() ?? ''
if (!selectedText) {
return null
}
const rootRect = root.getBoundingClientRect()
const popoverWidth = 420
const left = Math.max(56, rootRect.width - popoverWidth - 24)
const buttonTop = Math.max(8, rect.bottom - rootRect.top + 6)
const popoverTop = Math.max(8, Math.min(buttonTop + 28, rootRect.height - 220))
return {
...getRichMarkdownSelectionRange(editor),
from: editor.state.selection.from,
to: editor.state.selection.to,
selectedText,
top: popoverTop,
left,
buttonTop,
buttonLeft: Math.max(56, rootRect.width - 42)
}
}
@@ -0,0 +1,65 @@
import type { Editor } from '@tiptap/react'
import type { DiffComment } from '../../../../shared/types'
import {
buildRichMarkdownCommentBlocks,
getRichMarkdownCommentAnchorTop
} from './rich-markdown-review-annotations'
import {
stackRichMarkdownReviewNotePositions,
type RichMarkdownReviewNotePosition
} from './rich-markdown-review-note-layout'
type MeasureRichMarkdownReviewNotePositionsOptions = {
container: HTMLDivElement
editor: Editor
markdownComments: DiffComment[]
markdownSourceLineOffset: number
}
export function measureRichMarkdownReviewNotePositions({
container,
editor,
markdownComments,
markdownSourceLineOffset
}: MeasureRichMarkdownReviewNotePositionsOptions): RichMarkdownReviewNotePosition[] {
const containerRect = container.getBoundingClientRect()
const blocks = buildRichMarkdownCommentBlocks(editor)
const nextPositions = markdownComments
.map((comment): RichMarkdownReviewNotePosition | null => {
const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset)
const block = blocks.find(
(candidate) => candidate.startLine <= bodyLineNumber && bodyLineNumber <= candidate.endLine
)
if (!block) {
return null
}
const top = getRichMarkdownCommentAnchorTop(
editor,
comment,
block,
containerRect,
container.scrollTop,
markdownSourceLineOffset
)
return top === null ? null : { comment, top }
})
.filter((position): position is RichMarkdownReviewNotePosition => position !== null)
return stackRichMarkdownReviewNotePositions(
nextPositions,
measureReviewNoteHeights(container, nextPositions)
)
}
function measureReviewNoteHeights(
container: HTMLDivElement,
positions: RichMarkdownReviewNotePosition[]
): Map<string, number> {
const measuredHeights = new Map<string, number>()
for (const pos of positions) {
const el = container.querySelector(`[data-rich-markdown-review-note-id="${pos.comment.id}"]`)
if (el) {
measuredHeights.set(pos.comment.id, el.getBoundingClientRect().height)
}
}
return measuredHeights
}
@@ -0,0 +1,96 @@
import type { Editor } from '@tiptap/react'
import type { RichMarkdownAnnotationHighlightRange } from './rich-markdown-annotation-highlight'
type RichMarkdownTextChar = {
value: string
pos: number | null
}
function normalizeRichMarkdownTextWithPositions(
chars: RichMarkdownTextChar[]
): RichMarkdownTextChar[] {
const normalized: RichMarkdownTextChar[] = []
let previousWasWhitespace = false
for (const char of chars) {
if (/\s/.test(char.value)) {
if (!previousWasWhitespace) {
normalized.push({ value: ' ', pos: char.pos })
}
previousWasWhitespace = true
continue
}
normalized.push(char)
previousWasWhitespace = false
}
return normalized
}
function collectRichMarkdownTextChars(
editor: Editor,
from = 0,
to = editor.state.doc.content.size
): RichMarkdownTextChar[] {
const chars: RichMarkdownTextChar[] = []
editor.state.doc.nodesBetween(from, to, (node, pos) => {
if (!node.isText || !node.text) {
return
}
if (chars.length > 0) {
chars.push({ value: ' ', pos: null })
}
for (let index = 0; index < node.text.length; index += 1) {
chars.push({ value: node.text[index], pos: pos + index })
}
})
return chars
}
export function findRichMarkdownSelectedTextRanges({
editor,
selectedText,
from,
to
}: {
editor: Editor
selectedText: string
from?: number
to?: number
}): RichMarkdownAnnotationHighlightRange[] {
const normalizedChars = normalizeRichMarkdownTextWithPositions(
collectRichMarkdownTextChars(editor, from, to)
)
const haystack = normalizedChars.map((char) => char.value).join('')
const needle = normalizeRichMarkdownTextWithPositions(
Array.from(selectedText).map((value) => ({ value, pos: null }))
)
.map((char) => char.value)
.join('')
const start = haystack.indexOf(needle)
if (start === -1) {
return []
}
const positions = normalizedChars
.slice(start, start + needle.length)
.map((char) => char.pos)
.filter((pos): pos is number => pos !== null)
.sort((left, right) => left - right)
if (positions.length === 0) {
return []
}
const ranges: RichMarkdownAnnotationHighlightRange[] = []
let rangeFrom = positions[0]
let rangeTo = positions[0] + 1
for (const pos of positions.slice(1)) {
if (pos === rangeTo) {
rangeTo += 1
continue
}
ranges.push({ from: rangeFrom, to: rangeTo })
rangeFrom = pos
rangeTo = pos + 1
}
ranges.push({ from: rangeFrom, to: rangeTo })
return ranges
}
@@ -0,0 +1,414 @@
import type React from 'react'
import type { Editor } from '@tiptap/react'
import { TextSelection } from '@tiptap/pm/state'
import type {} from '@tiptap/extension-mathematics'
import {
ChevronRight,
Heading1,
Heading2,
Heading3,
ImageIcon,
List,
ListOrdered,
Quote,
Sigma,
Table2,
Workflow
} from 'lucide-react'
export type SlashMenuState = {
query: string
from: number
to: number
left: number
top: number
}
export type SlashCommandId =
| 'text'
| 'toggle-text'
| 'heading-1'
| 'toggle-h1'
| 'heading-2'
| 'heading-3'
| 'task-list'
| 'bullet-list'
| 'ordered-list'
| 'blockquote'
| 'code-block'
| 'divider'
| 'image'
| 'table'
| 'mermaid'
| 'inline-math'
| 'math-block'
| 'emoji'
export type SlashCommandIcon =
| { kind: 'component'; component: React.ComponentType<{ className?: string }> }
| { kind: 'text'; value: string }
export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others'
export type SlashCommand = {
id: SlashCommandId
label: string
aliases: string[]
icon: SlashCommandIcon
group: SlashCommandGroup
description: string
run: (editor: Editor) => void
}
function icon(component: React.ComponentType<{ className?: string }>): SlashCommandIcon {
return { kind: 'component', component }
}
function textIcon(value: string): SlashCommandIcon {
return { kind: 'text', value }
}
function insertTextWithSelection(
editor: Editor,
text: string,
selectionStartOffset?: number,
selectionEndOffset = selectionStartOffset
): void {
editor.commands.command(({ state, dispatch }) => {
const from = state.selection.from
const tr = state.tr.insertText(text, from, state.selection.to)
if (selectionStartOffset !== undefined) {
const selectionFrom = from + selectionStartOffset
const selectionTo = from + (selectionEndOffset ?? selectionStartOffset)
tr.setSelection(TextSelection.create(tr.doc, selectionFrom, selectionTo))
}
dispatch?.(tr.scrollIntoView())
return true
})
}
function insertCodeBlock(editor: Editor, language: string, text: string): void {
editor.commands.command(({ state, dispatch }) => {
const codeBlockType = state.schema.nodes.codeBlock
if (!codeBlockType) {
return false
}
const node = codeBlockType.create({ language }, text ? state.schema.text(text) : undefined)
const tr = state.tr.replaceSelectionWith(node).scrollIntoView()
const cursor = tr.selection.from + 1
tr.setSelection(TextSelection.create(tr.doc, cursor, cursor))
dispatch?.(tr)
return true
})
}
function insertToggle(editor: Editor, variant?: 'heading-1'): void {
const insertAt = editor.state.selection.from
editor
.chain()
.focus()
.insertContentAt(insertAt, {
type: 'details',
attrs: {
open: true,
...(variant ? { variant } : {})
},
content: [
{
type: 'detailsSummary'
},
{
type: 'detailsContent',
content: [{ type: 'paragraph' }]
}
]
})
.setTextSelection(insertAt + 1)
.run()
}
/**
* Executes a slash command by first deleting the typed slash text, then
* delegating to the command's run method. Image is special-cased because
* window.prompt() is not supported in Electron's renderer process.
*/
export function runSlashCommand(
editor: Editor,
slashMenu: { from: number; to: number },
command: SlashCommand,
onImageCommand?: () => void,
onEmojiCommand?: () => void
): void {
editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run()
// Why: image insertion cannot rely on window.prompt() in Electron, so this
// command is rerouted into the editor's local image picker flow.
if (command.id === 'image' && onImageCommand) {
onImageCommand()
return
}
if (command.id === 'emoji' && onEmojiCommand) {
onEmojiCommand()
return
}
command.run(editor)
}
export const slashCommands: SlashCommand[] = [
{
id: 'heading-1',
label: 'Heading 1',
aliases: ['h1', 'title'],
icon: icon(Heading1),
group: 'Headings',
description: 'Large section heading.',
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
// invoking "/h1" on an existing H1 should keep it as H1, not revert to paragraph.
editor.chain().focus().setHeading({ level: 1 }).run()
}
},
{
id: 'toggle-h1',
label: 'Toggle Heading 1',
aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'],
icon: icon(ChevronRight),
group: 'Headings',
description: 'Create a collapsible section with a large heading summary.',
run: (editor) => {
insertToggle(editor, 'heading-1')
}
},
{
id: 'heading-2',
label: 'Heading 2',
aliases: ['h2'],
icon: icon(Heading2),
group: 'Headings',
description: 'Medium section heading.',
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
// invoking "/h2" on an existing H2 should keep it as H2, not revert to paragraph.
editor.chain().focus().setHeading({ level: 2 }).run()
}
},
{
id: 'heading-3',
label: 'Heading 3',
aliases: ['h3'],
icon: icon(Heading3),
group: 'Headings',
description: 'Small section heading.',
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
// invoking "/h3" on an existing H3 should keep it as H3, not revert to paragraph.
editor.chain().focus().setHeading({ level: 3 }).run()
}
},
{
id: 'blockquote',
label: 'Quote',
aliases: ['quote', 'blockquote'],
icon: icon(Quote),
group: 'Basic blocks',
description: 'Insert a blockquote.',
run: (editor) => {
editor.chain().focus().toggleBlockquote().run()
}
},
{
id: 'ordered-list',
label: 'Numbered List',
aliases: ['ordered', 'ol', 'numbered'],
icon: icon(ListOrdered),
group: 'Basic blocks',
description: 'Create an ordered list.',
run: (editor) => {
editor.chain().focus().toggleOrderedList().run()
}
},
{
id: 'bullet-list',
label: 'Bullet List',
aliases: ['bullet', 'ul', 'list'],
icon: icon(List),
group: 'Basic blocks',
description: 'Create an unordered list.',
run: (editor) => {
editor.chain().focus().toggleBulletList().run()
}
},
{
id: 'task-list',
label: 'Check List',
aliases: ['todo', 'task', 'checkbox'],
icon: icon(List),
group: 'Basic blocks',
description: 'Create a checklist.',
run: (editor) => {
editor.chain().focus().toggleTaskList().run()
}
},
{
id: 'text',
label: 'Paragraph',
aliases: ['paragraph', 'plain'],
icon: icon(List),
group: 'Basic blocks',
description: 'Start a normal paragraph.',
run: (editor) => {
editor.chain().focus().setParagraph().run()
}
},
{
id: 'toggle-text',
label: 'Toggle Text',
aliases: ['toggle', 'details', 'collapse', 'toggle-text'],
icon: icon(ChevronRight),
group: 'Basic blocks',
description: 'Create a collapsible text section.',
run: (editor) => {
insertToggle(editor)
}
},
{
id: 'code-block',
label: 'Code Block',
aliases: ['code', 'snippet'],
icon: icon(List),
group: 'Basic blocks',
description: 'Insert a fenced code block.',
run: (editor) => {
editor.chain().focus().toggleCodeBlock().run()
}
},
{
id: 'divider',
label: 'Divider',
aliases: ['divider', 'rule', 'hr'],
icon: icon(List),
group: 'Basic blocks',
description: 'Insert a horizontal rule.',
run: (editor) => {
editor.chain().focus().setHorizontalRule().run()
}
},
{
id: 'table',
label: 'Table',
aliases: ['grid', 'columns', 'rows'],
icon: icon(Table2),
group: 'Advanced',
description: 'Insert a 3x3 markdown table.',
run: (editor) => {
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
}
},
{
id: 'mermaid',
label: 'Mermaid Diagram',
aliases: ['diagram', 'flowchart', 'chart', 'graph'],
icon: icon(Workflow),
group: 'Advanced',
description: 'Insert a Mermaid fenced block.',
run: (editor) => {
insertCodeBlock(editor, 'mermaid', 'graph TD\n A[Start] --> B[End]')
}
},
{
id: 'inline-math',
label: 'Inline Math',
aliases: ['math', 'latex', 'equation', 'formula'],
icon: icon(Sigma),
group: 'Advanced',
description: 'Insert inline LaTeX math.',
run: (editor) => {
editor.commands.insertInlineMath({ latex: 'x' })
}
},
{
id: 'math-block',
label: 'Math Block',
aliases: ['display math', 'latex block', 'equation block'],
icon: icon(Sigma),
group: 'Advanced',
description: 'Insert display LaTeX math.',
run: (editor) => {
editor.commands.insertBlockMath({ latex: 'x' })
}
},
{
id: 'image',
label: 'Image',
aliases: ['image', 'img'],
icon: icon(ImageIcon),
group: 'Media',
description: 'Insert an image from your computer.',
// Why: window.prompt() is not supported in Electron's renderer process,
// so image URL input is handled by an inline input bar in RichMarkdownEditor.
run: (editor) => {
editor.chain().focus().run()
}
},
{
id: 'emoji',
label: 'Emoji',
aliases: ['smile', 'reaction', 'icon'],
icon: textIcon('🙂'),
group: 'Others',
description: 'Insert a plain Unicode emoji.',
run: (editor) => {
insertTextWithSelection(editor, '🙂')
}
}
]
/**
* Inspects the editor selection to decide whether the slash-command menu
* should be open (and where to position it), or dismissed.
*/
export function syncSlashMenu(
editor: Editor,
root: HTMLDivElement | null,
setSlashMenu: React.Dispatch<React.SetStateAction<SlashMenuState | null>>
): void {
if (!root || editor.view.composing || !editor.isEditable) {
setSlashMenu(null)
return
}
const { state, view } = editor
const { selection } = state
if (!selection.empty) {
setSlashMenu(null)
return
}
const { $from } = selection
if (!$from.parent.isTextblock) {
setSlashMenu(null)
return
}
const blockTextBeforeCursor = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0')
const slashMatch = blockTextBeforeCursor.match(/^\s*\/([a-z0-9-]*)$/i)
if (!slashMatch) {
setSlashMenu(null)
return
}
const slashOffset = blockTextBeforeCursor.lastIndexOf('/')
const start = selection.from - ($from.parentOffset - slashOffset)
const coords = view.coordsAtPos(selection.from)
const rect = root.getBoundingClientRect()
setSlashMenu({
query: slashMatch[1] ?? '',
from: start,
to: selection.from,
left: coords.left - rect.left,
top: coords.bottom - rect.top + 8
})
}
@@ -0,0 +1,51 @@
import { useMemo } from 'react'
import { computeLineStats } from './diff-line-stats'
import { getDiffSectionBodyHeight, isIntrinsicHeightImageDiff } from './diff-section-layout'
import type { DiffSection } from './diff-section-types'
export function useDiffSectionLayoutMetrics({
section,
sectionHeight
}: {
section: DiffSection
sectionHeight: number | undefined
}): {
lineStats: ReturnType<typeof computeLineStats> | null
sectionBodyHeight: number | undefined
useIntrinsicImageHeight: boolean
} {
const lineStats = useMemo(
() =>
section.loading || section.error
? null
: computeLineStats(section.originalContent, section.modifiedContent, section.status),
[
section.error,
section.loading,
section.originalContent,
section.modifiedContent,
section.status
]
)
const changedLineCount = useMemo(() => {
if (lineStats) {
return lineStats.added + lineStats.removed
}
if (section.added === undefined && section.removed === undefined) {
return undefined
}
return (section.added ?? 0) + (section.removed ?? 0)
}, [lineStats, section.added, section.removed])
// Why: image diffs need document-flow height in the combined view; the text
// fallback only knows line counts and would squash screenshots into one row.
const useIntrinsicImageHeight = isIntrinsicHeightImageDiff(section.diffResult)
const sectionBodyHeight = getDiffSectionBodyHeight({
measuredContentHeight: sectionHeight,
originalContent: section.originalContent,
modifiedContent: section.modifiedContent,
changedLineCount,
useIntrinsicImageHeight
})
return { lineStats, sectionBodyHeight, useIntrinsicImageHeight }
}
@@ -0,0 +1,290 @@
import { type Dispatch, type MutableRefObject, type SetStateAction, useMemo } from 'react'
import { useEditor, type Editor } from '@tiptap/react'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownKeyHandler } from './rich-markdown-key-handler'
import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { normalizeSoftBreaks } from './rich-markdown-normalize'
import { autoFocusRichEditor } from './rich-markdown-auto-focus'
import {
syncDocLinkMenu,
type DocLinkMenuRow,
type DocLinkMenuState
} from './rich-markdown-commands'
import {
syncSlashMenu,
type SlashCommand,
type SlashMenuState
} from './rich-markdown-slash-commands'
import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuation'
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
import {
handleRichMarkdownEditorClick,
type ActivateMarkdownLink,
type RichMarkdownRuntimeSettings
} from './rich-markdown-editor-click-routing'
import type { DiffComment } from '../../../../shared/types'
const richMarkdownExtensions = createRichMarkdownExtensions({ includePlaceholder: true })
export function useRichMarkdownEditorInstance({
content,
filePath,
worktreeId,
worktreeRoot,
runtimeEnvironmentId,
isMac,
settings,
activateMarkdownLink,
rootRef,
editorRef,
lastCommittedMarkdownRef,
onContentChangeRef,
onDirtyStateHintRef,
onSaveRef,
onOpenDocLinkRef,
isEditingLinkRef,
slashMenuRef,
filteredSlashCommandsRef,
selectedCommandIndexRef,
docLinkMenuRef,
filteredDocLinkRowsRef,
selectedDocLinkIndexRef,
handleLocalImagePickRef,
handleEmojiPickRef,
typedEmptyOrderedListMarkerRef,
cancelAutoFocusRef,
serializeTimerRef,
isInitializingRef,
isApplyingProgrammaticUpdateRef,
markdownCommentsRef,
markdownSourceLineOffsetRef,
flushPendingSerialization,
openSearchRef,
syncAnnotationTarget,
clearAnnotationTarget,
scrollRichMarkdownReviewNoteCardIntoView,
setIsEditingLink,
setLinkBubble,
setSelectedCommandIndex,
setSelectedDocLinkIndex,
setSlashMenu,
setDocLinkMenu
}: {
content: string
filePath: string
worktreeId: string
worktreeRoot: string | null
runtimeEnvironmentId?: string | null
isMac: boolean
settings: RichMarkdownRuntimeSettings
activateMarkdownLink: ActivateMarkdownLink
rootRef: MutableRefObject<HTMLDivElement | null>
editorRef: MutableRefObject<Editor | null>
lastCommittedMarkdownRef: MutableRefObject<string>
onContentChangeRef: MutableRefObject<(content: string) => void>
onDirtyStateHintRef: MutableRefObject<(dirty: boolean) => void>
onSaveRef: MutableRefObject<(content: string) => void>
onOpenDocLinkRef: MutableRefObject<((target: string) => void) | undefined>
isEditingLinkRef: MutableRefObject<boolean>
slashMenuRef: MutableRefObject<SlashMenuState | null>
filteredSlashCommandsRef: MutableRefObject<SlashCommand[]>
selectedCommandIndexRef: MutableRefObject<number>
docLinkMenuRef: MutableRefObject<DocLinkMenuState | null>
filteredDocLinkRowsRef: MutableRefObject<DocLinkMenuRow[]>
selectedDocLinkIndexRef: MutableRefObject<number>
handleLocalImagePickRef: MutableRefObject<() => void>
handleEmojiPickRef: MutableRefObject<(menu: SlashMenuState) => void>
typedEmptyOrderedListMarkerRef: MutableRefObject<boolean>
cancelAutoFocusRef: MutableRefObject<(() => void) | null>
serializeTimerRef: MutableRefObject<number | null>
isInitializingRef: MutableRefObject<boolean>
isApplyingProgrammaticUpdateRef: MutableRefObject<boolean>
markdownCommentsRef: MutableRefObject<DiffComment[]>
markdownSourceLineOffsetRef: MutableRefObject<number>
flushPendingSerialization: () => void
openSearchRef: MutableRefObject<() => void>
syncAnnotationTarget: (editor: Editor) => void
clearAnnotationTarget: () => void
scrollRichMarkdownReviewNoteCardIntoView: (commentId: string) => void
setIsEditingLink: Dispatch<SetStateAction<boolean>>
setLinkBubble: Dispatch<SetStateAction<LinkBubbleState | null>>
setSelectedCommandIndex: Dispatch<SetStateAction<number>>
setSelectedDocLinkIndex: Dispatch<SetStateAction<number>>
setSlashMenu: Dispatch<SetStateAction<SlashMenuState | null>>
setDocLinkMenu: Dispatch<SetStateAction<DocLinkMenuState | null>>
}): Editor | null {
const editor = useEditor(
useMemo(
() => ({
immediatelyRender: false,
extensions: richMarkdownExtensions,
content: encodeRawMarkdownHtmlForRichEditor(content),
contentType: 'markdown' as const,
editorProps: {
attributes: {
class: 'rich-markdown-editor',
spellcheck: 'true'
},
handleDOMEvents: {
cut: handleRichMarkdownCut
},
handleTextInput: (view, from, to, text) => {
typedEmptyOrderedListMarkerRef.current = false
if (text !== ' ' || from !== to || !view.state.selection.empty) {
return false
}
const { $from } = view.state.selection
const beforeCursor = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0')
typedEmptyOrderedListMarkerRef.current = /^\d+\.$/.test(beforeCursor)
return false
},
handleKeyDown: createRichMarkdownKeyHandler({
isMac,
editorRef,
rootRef,
lastCommittedMarkdownRef,
onContentChangeRef,
onSaveRef,
isEditingLinkRef,
slashMenuRef,
filteredSlashCommandsRef,
selectedCommandIndexRef,
docLinkMenuRef,
filteredDocLinkRowsRef,
selectedDocLinkIndexRef,
handleLocalImagePickRef,
handleEmojiPickRef,
typedEmptyOrderedListMarkerRef,
flushPendingSerialization,
openSearchRef,
setIsEditingLink,
setLinkBubble,
setSelectedCommandIndex,
setSelectedDocLinkIndex,
setSlashMenu,
setDocLinkMenu
}),
handleClick: (view, pos, event) => {
return handleRichMarkdownEditorClick({
activateMarkdownLink,
editorRef,
event,
filePath,
isMac,
markdownCommentsRef,
markdownSourceLineOffsetRef,
onOpenDocLinkRef,
pos,
rootRef,
runtimeEnvironmentId,
scrollRichMarkdownReviewNoteCardIntoView,
settings,
view,
worktreeId,
worktreeRoot
})
}
},
onFocus: () => {
window.api.ui.setMarkdownEditorFocused(true)
},
onBlur: () => {
window.api.ui.setMarkdownEditorFocused(false)
clearAnnotationTarget()
},
onCreate: ({ editor: nextEditor }) => {
normalizeSoftBreaks(nextEditor)
lastCommittedMarkdownRef.current = content
isInitializingRef.current = false
cancelAutoFocusRef.current?.()
cancelAutoFocusRef.current = autoFocusRichEditor(nextEditor, rootRef.current)
},
onUpdate: ({ editor: nextEditor }) => {
syncSlashMenu(nextEditor, rootRef.current, setSlashMenu)
syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu)
if (!isSingleEmptyTopLevelOrderedList(nextEditor)) {
typedEmptyOrderedListMarkerRef.current = false
}
if (isInitializingRef.current || isApplyingProgrammaticUpdateRef.current) {
return
}
onDirtyStateHintRef.current(true)
if (serializeTimerRef.current !== null) {
window.clearTimeout(serializeTimerRef.current)
}
serializeTimerRef.current = window.setTimeout(() => {
serializeTimerRef.current = null
try {
const markdown = nextEditor.getMarkdown()
lastCommittedMarkdownRef.current = markdown
onContentChangeRef.current(markdown)
} catch {
// Why: save/restart flows should never crash the UI just because
// the editor was torn down between scheduling and serializing.
}
}, 300)
},
onSelectionUpdate: ({ editor: nextEditor }) => {
syncSlashMenu(nextEditor, rootRef.current, setSlashMenu)
syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu)
syncAnnotationTarget(nextEditor)
setIsEditingLink(false)
if (nextEditor.isActive('link')) {
const attrs = nextEditor.getAttributes('link')
const pos = getLinkBubblePosition(nextEditor, rootRef.current)
setLinkBubble(pos ? { href: (attrs.href as string) || '', ...pos } : null)
} else {
setLinkBubble(null)
}
}
}),
[
activateMarkdownLink,
cancelAutoFocusRef,
clearAnnotationTarget,
content,
docLinkMenuRef,
editorRef,
filePath,
filteredDocLinkRowsRef,
filteredSlashCommandsRef,
flushPendingSerialization,
handleEmojiPickRef,
handleLocalImagePickRef,
isApplyingProgrammaticUpdateRef,
isEditingLinkRef,
isInitializingRef,
isMac,
lastCommittedMarkdownRef,
markdownCommentsRef,
markdownSourceLineOffsetRef,
onContentChangeRef,
onDirtyStateHintRef,
onOpenDocLinkRef,
onSaveRef,
openSearchRef,
rootRef,
runtimeEnvironmentId,
scrollRichMarkdownReviewNoteCardIntoView,
selectedCommandIndexRef,
selectedDocLinkIndexRef,
serializeTimerRef,
setDocLinkMenu,
setIsEditingLink,
setLinkBubble,
setSelectedCommandIndex,
setSelectedDocLinkIndex,
setSlashMenu,
settings,
slashMenuRef,
syncAnnotationTarget,
typedEmptyOrderedListMarkerRef,
worktreeId,
worktreeRoot
]
)
)
editorRef.current = editor ?? null
return editor
}
@@ -0,0 +1,154 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import type React from 'react'
import type { MarkdownDocument } from '../../../../shared/types'
import { getMarkdownDocCompletionDocuments } from './markdown-doc-completions'
import type { DocLinkMenuRow, DocLinkMenuState } from './rich-markdown-commands'
import {
slashCommands,
type SlashCommand,
type SlashMenuState
} from './rich-markdown-slash-commands'
const DOC_LINK_MENU_MAX_ROWS = 20
type MenuSelection = {
index: number
query: string | null
}
export function useRichMarkdownMenuController({
markdownDocuments
}: {
markdownDocuments?: MarkdownDocument[]
}) {
const [slashMenu, setSlashMenu] = useState<SlashMenuState | null>(null)
const [slashSelection, setSlashSelection] = useState<MenuSelection>({ query: null, index: 0 })
const [docLinkMenu, setDocLinkMenu] = useState<DocLinkMenuState | null>(null)
const [docLinkSelection, setDocLinkSelection] = useState<MenuSelection>({ query: null, index: 0 })
const [emojiMenu, setEmojiMenu] = useState<{ left: number; top: number } | null>(null)
const slashMenuRef = useRef<SlashMenuState | null>(null)
const filteredSlashCommandsRef = useRef<SlashCommand[]>(slashCommands)
const selectedCommandIndexRef = useRef(0)
const docLinkMenuRef = useRef<DocLinkMenuState | null>(null)
const filteredDocLinkRowsRef = useRef<DocLinkMenuRow[]>([])
const selectedDocLinkIndexRef = useRef(0)
const handleEmojiPickRef = useRef<(menu: SlashMenuState) => void>(() => {})
slashMenuRef.current = slashMenu
docLinkMenuRef.current = docLinkMenu
const setSelectedCommandIndex = useCallback<React.Dispatch<React.SetStateAction<number>>>(
(nextIndex) => {
setSlashSelection((current) => {
const query = slashMenuRef.current?.query ?? null
const optionCount = filteredSlashCommandsRef.current.length
const currentIndex =
current.query === query ? clampMenuSelectionIndex(current.index, optionCount) : 0
const resolvedIndex = typeof nextIndex === 'function' ? nextIndex(currentIndex) : nextIndex
return {
query,
index: clampMenuSelectionIndex(resolvedIndex, optionCount)
}
})
},
[]
)
const setSelectedDocLinkIndex = useCallback<React.Dispatch<React.SetStateAction<number>>>(
(nextIndex) => {
setDocLinkSelection((current) => {
const query = docLinkMenuRef.current?.query ?? null
const rowCount = filteredDocLinkRowsRef.current.length
const currentIndex =
current.query === query ? clampMenuSelectionIndex(current.index, rowCount) : 0
const resolvedIndex = typeof nextIndex === 'function' ? nextIndex(currentIndex) : nextIndex
return {
query,
index: clampMenuSelectionIndex(resolvedIndex, rowCount)
}
})
},
[]
)
const filteredSlashCommands = useMemo(() => {
const query = slashMenu?.query.trim().toLowerCase() ?? ''
if (!query) {
return slashCommands
}
return slashCommands.filter((command) => {
const haystack = [command.label, ...command.aliases].join(' ').toLowerCase()
return haystack.includes(query)
})
}, [slashMenu?.query])
const selectedCommandIndex = resolveSelectedMenuIndex(
slashSelection,
slashMenu?.query ?? null,
filteredSlashCommands.length
)
filteredSlashCommandsRef.current = filteredSlashCommands
selectedCommandIndexRef.current = selectedCommandIndex
const { docLinkRows, docLinkTotalMatches } = useMemo(() => {
if (!docLinkMenu || !markdownDocuments) {
return { docLinkRows: [] as DocLinkMenuRow[], docLinkTotalMatches: 0 }
}
const matches = getMarkdownDocCompletionDocuments(markdownDocuments, docLinkMenu.query)
const rows: DocLinkMenuRow[] = matches
.slice(0, DOC_LINK_MENU_MAX_ROWS)
.map((document) => ({ kind: 'document', document }))
return { docLinkRows: rows, docLinkTotalMatches: matches.length }
}, [docLinkMenu, markdownDocuments])
const selectedDocLinkIndex = resolveSelectedMenuIndex(
docLinkSelection,
docLinkMenu?.query ?? null,
docLinkRows.length
)
filteredDocLinkRowsRef.current = docLinkRows
selectedDocLinkIndexRef.current = selectedDocLinkIndex
const openEmojiMenu = useCallback((menu: SlashMenuState): void => {
setSlashMenu(null)
setEmojiMenu({ left: menu.left, top: menu.top })
}, [])
handleEmojiPickRef.current = openEmojiMenu
return {
docLinkMenu,
docLinkRows,
docLinkTotalMatches,
docLinkMenuRef,
emojiMenu,
filteredDocLinkRowsRef,
filteredSlashCommands,
filteredSlashCommandsRef,
handleEmojiPickRef,
openEmojiMenu,
selectedCommandIndex,
selectedCommandIndexRef,
selectedDocLinkIndex,
selectedDocLinkIndexRef,
setDocLinkMenu,
setEmojiMenu,
setSelectedCommandIndex,
setSelectedDocLinkIndex,
setSlashMenu,
slashMenu,
slashMenuRef
}
}
function resolveSelectedMenuIndex(
selection: MenuSelection,
query: string | null,
itemCount: number
): number {
return selection.query === query ? clampMenuSelectionIndex(selection.index, itemCount) : 0
}
function clampMenuSelectionIndex(index: number, itemCount: number): number {
if (itemCount <= 0) {
return 0
}
return Math.min(Math.max(index, 0), itemCount - 1)
}
@@ -0,0 +1,156 @@
import { useEffect } from 'react'
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type { Editor } from '@tiptap/react'
import { getConnectionId } from '@/lib/connection-context'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import type { MarkdownDocument } from '../../../../shared/types'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { syncDocLinkMenu, type DocLinkMenuState } from './rich-markdown-commands'
import { normalizeSoftBreaks } from './rich-markdown-normalize'
import { syncSlashMenu, type SlashMenuState } from './rich-markdown-slash-commands'
type RichMarkdownProgrammaticSyncOptions = {
content: string
docLinkMenuSetter: Dispatch<SetStateAction<DocLinkMenuState | null>>
editor: Editor | null
fileId: string
filePath: string
isApplyingProgrammaticUpdateRef: MutableRefObject<boolean>
lastCommittedMarkdownRef: MutableRefObject<string>
markdownDocuments?: MarkdownDocument[]
rootRef: MutableRefObject<HTMLDivElement | null>
runtimeEnvironmentId?: string | null
settings: Parameters<typeof settingsForRuntimeOwner>[0]
slashMenuSetter: Dispatch<SetStateAction<SlashMenuState | null>>
worktreeId: string
worktreeRoot: string | null
}
type RichMarkdownEditorStorage = {
image: {
filePath: string
runtimeContext?: {
connectionId: string | null | undefined
settings: ReturnType<typeof settingsForRuntimeOwner>
worktreeId: string
worktreePath: string
}
}
markdownDocLink: {
documents: MarkdownDocument[]
}
}
export function useRichMarkdownProgrammaticSync({
content,
docLinkMenuSetter,
editor,
fileId,
filePath,
isApplyingProgrammaticUpdateRef,
lastCommittedMarkdownRef,
markdownDocuments,
rootRef,
runtimeEnvironmentId,
settings,
slashMenuSetter,
worktreeId,
worktreeRoot
}: RichMarkdownProgrammaticSyncOptions): void {
useEffect(() => {
if (!editor) {
return
}
isApplyingProgrammaticUpdateRef.current = true
try {
const storage = editor.storage as unknown as RichMarkdownEditorStorage
storage.image.filePath = filePath
storage.image.runtimeContext = worktreeRoot
? {
settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId),
worktreeId,
worktreePath: worktreeRoot,
connectionId: getConnectionId(worktreeId)
}
: undefined
editor.view.dispatch(editor.state.tr)
} finally {
isApplyingProgrammaticUpdateRef.current = false
}
}, [
editor,
filePath,
isApplyingProgrammaticUpdateRef,
runtimeEnvironmentId,
settings,
worktreeId,
worktreeRoot
])
useEffect(() => {
if (!editor || !markdownDocuments) {
return
}
isApplyingProgrammaticUpdateRef.current = true
try {
const storage = editor.storage as unknown as RichMarkdownEditorStorage
storage.markdownDocLink.documents = markdownDocuments
editor.view.dispatch(editor.state.tr.setMeta('docLinksUpdated', true))
} finally {
isApplyingProgrammaticUpdateRef.current = false
}
}, [editor, isApplyingProgrammaticUpdateRef, markdownDocuments])
useEffect(() => {
if (!editor) {
return
}
if (content === lastCommittedMarkdownRef.current || editor.getMarkdown() === content) {
return
}
isApplyingProgrammaticUpdateRef.current = true
try {
applyExternalRichMarkdownContent(editor, content, lastCommittedMarkdownRef)
} finally {
isApplyingProgrammaticUpdateRef.current = false
}
syncSlashMenu(editor, rootRef.current, slashMenuSetter)
syncDocLinkMenu(editor, rootRef.current, docLinkMenuSetter)
}, [
content,
docLinkMenuSetter,
editor,
fileId,
isApplyingProgrammaticUpdateRef,
lastCommittedMarkdownRef,
rootRef,
slashMenuSetter
])
}
function applyExternalRichMarkdownContent(
editor: Editor,
content: string,
lastCommittedMarkdownRef: MutableRefObject<string>
): void {
try {
const hadFocus = editor.isFocused
const { from: prevFrom, to: prevTo } = editor.state.selection
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(content), {
contentType: 'markdown',
emitUpdate: false
})
normalizeSoftBreaks(editor)
lastCommittedMarkdownRef.current = content
if (hadFocus) {
const docSize = editor.state.doc.content.size
editor
.chain()
.setTextSelection({ from: Math.min(prevFrom, docSize), to: Math.min(prevTo, docSize) })
.focus()
.run()
}
} catch (err) {
console.error('[RichMarkdownEditor] failed to apply external content update', err)
}
}
@@ -0,0 +1,280 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { MutableRefObject } from 'react'
import type { Editor } from '@tiptap/react'
import type { AppState } from '@/store'
import { richMarkdownAnnotationHighlightPluginKey } from './rich-markdown-annotation-highlight'
import { updateRichMarkdownAnnotationHighlightsAfterSubmit } from './rich-markdown-annotation-submit-highlights'
import {
clampRichMarkdownAnnotationTarget,
getRichMarkdownAnnotationTarget,
hasRichMarkdownCommentForRange,
type RichMarkdownAnnotationTarget
} from './rich-markdown-review-annotations'
import { shouldExpandRichMarkdownReviewRail } from './rich-markdown-review-note-layout'
import { useRichMarkdownReviewData } from './useRichMarkdownReviewData'
import { useRichMarkdownReviewCopyFeedback } from './useRichMarkdownReviewCopyFeedback'
import { useRichMarkdownReviewRailController } from './useRichMarkdownReviewRailController'
import type { DiffComment } from '../../../../shared/types'
type UseRichMarkdownReviewControllerOptions = {
addDiffComment: AppState['addDiffComment']
allDiffComments: DiffComment[] | undefined
content: string
editorRef: MutableRefObject<Editor | null>
filePath: string
markdownAnnotationFilePath?: string
markdownAnnotationsEnabled: boolean
markdownReviewContent: string
markdownSourceLineOffset: number
rootRef: MutableRefObject<HTMLDivElement | null>
scrollContainerRef: MutableRefObject<HTMLDivElement | null>
worktreeId: string
worktreeRoot: string | null
}
export function useRichMarkdownReviewController({
addDiffComment,
allDiffComments,
content,
editorRef,
filePath,
markdownAnnotationFilePath,
markdownAnnotationsEnabled,
markdownReviewContent,
markdownSourceLineOffset,
rootRef,
scrollContainerRef,
worktreeId,
worktreeRoot
}: UseRichMarkdownReviewControllerOptions) {
const [annotationTarget, setAnnotationTarget] = useState<RichMarkdownAnnotationTarget | null>(
null
)
const [annotationPopover, setAnnotationPopover] = useState<RichMarkdownAnnotationTarget | null>(
null
)
const annotationPopoverRef = useRef<RichMarkdownAnnotationTarget | null>(null)
const canAnnotateRichMarkdownRef = useRef(false)
const markdownCommentsRef = useRef<DiffComment[]>([])
const markdownSourceLineOffsetRef = useRef(markdownSourceLineOffset)
const annotationTargetFrameRef = useRef<number | null>(null)
const {
canAnnotateRichMarkdown,
markdownComments,
markdownReviewNotes,
sourceRelativePath,
unsentMarkdownReviewScope
} = useRichMarkdownReviewData({
allDiffComments,
filePath,
markdownAnnotationFilePath,
markdownAnnotationsEnabled,
markdownReviewContent,
worktreeRoot
})
annotationPopoverRef.current = annotationPopover
canAnnotateRichMarkdownRef.current = canAnnotateRichMarkdown
markdownCommentsRef.current = markdownComments
markdownSourceLineOffsetRef.current = markdownSourceLineOffset
const copyFeedback = useRichMarkdownReviewCopyFeedback({
markdownReviewContent,
markdownReviewNotes,
rootRef
})
const { clearReviewCopyTimers } = copyFeedback
const rail = useRichMarkdownReviewRailController({
canAnnotateRichMarkdown,
content,
editorRef,
markdownComments,
markdownSourceLineOffset,
markdownSourceLineOffsetRef,
scrollContainerRef
})
const { cancelNotePositionFrame, clearAttentionTimers, setReviewRailOpen } = rail
const reviewRailExpanded = shouldExpandRichMarkdownReviewRail({
hasReviewNotes: markdownComments.length > 0,
reviewRailOpen: rail.reviewRailOpen,
hasDraftNote: annotationPopover !== null
})
const clearAllAnnotationHighlights = useCallback((): void => {
const editor = editorRef.current
if (!editor) {
return
}
editor.view.dispatch(
editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, {
activeRange: null,
noteRanges: []
})
)
}, [editorRef])
const clearAnnotationHighlight = useCallback((): void => {
const editor = editorRef.current
editor?.view.dispatch(editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, null))
}, [editorRef])
const clearAnnotationTarget = useCallback((): void => setAnnotationTarget(null), [])
const clearTransientReviewState = useCallback((): void => {
clearAttentionTimers()
clearReviewCopyTimers()
clearAllAnnotationHighlights()
cancelFrame(annotationTargetFrameRef)
cancelNotePositionFrame()
}, [
cancelNotePositionFrame,
clearAllAnnotationHighlights,
clearAttentionTimers,
clearReviewCopyTimers
])
const syncAnnotationTarget = useCallback(
(editor: Editor): void => {
cancelFrame(annotationTargetFrameRef)
annotationTargetFrameRef.current = window.requestAnimationFrame(() => {
annotationTargetFrameRef.current = null
const root = rootRef.current
if (!root || annotationPopoverRef.current || !canAnnotateRichMarkdownRef.current) {
setAnnotationTarget(null)
return
}
const target = getRichMarkdownAnnotationTarget(editor, root)
const hasExistingComment =
target &&
hasRichMarkdownCommentForRange(
markdownCommentsRef.current,
target,
markdownSourceLineOffsetRef.current
)
setAnnotationTarget(hasExistingComment ? null : target)
})
},
[rootRef]
)
const submitAnnotation = useCallback(
async (body: string): Promise<void> => {
if (!annotationPopover || sourceRelativePath === null) {
return
}
const result = await addDiffComment({
worktreeId,
filePath: sourceRelativePath,
source: 'markdown',
startLine:
annotationPopover.startLine === undefined
? undefined
: annotationPopover.startLine + markdownSourceLineOffset,
lineNumber: annotationPopover.lineNumber + markdownSourceLineOffset,
selectedText: annotationPopover.selectedText,
body,
side: 'modified'
})
if (!result) {
console.error('Failed to add markdown comment — draft preserved')
return
}
updateRichMarkdownAnnotationHighlightsAfterSubmit({
annotationPopover,
comments: [...markdownComments, result],
editor: editorRef.current,
markdownSourceLineOffset
})
setAnnotationPopover(null)
clearAnnotationHighlight()
window.getSelection()?.removeAllRanges()
},
[
addDiffComment,
annotationPopover,
clearAnnotationHighlight,
editorRef,
markdownComments,
markdownSourceLineOffset,
sourceRelativePath,
worktreeId
]
)
const openAnnotationPopover = useCallback((): void => {
if (!annotationTarget || !canAnnotateRichMarkdown) {
return
}
const editor = editorRef.current
const root = rootRef.current
const liveTarget = editor && root ? getRichMarkdownAnnotationTarget(editor, root) : null
const target = editor
? clampRichMarkdownAnnotationTarget(editor, liveTarget ?? annotationTarget)
: annotationTarget
if (
!target ||
hasRichMarkdownCommentForRange(markdownComments, target, markdownSourceLineOffset)
) {
setAnnotationTarget(null)
return
}
editor?.view.dispatch(
editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, {
activeRange: { from: target.from, to: target.to }
})
)
// Why: opening a draft should reserve the notes rail immediately; saved notes stay visible.
setReviewRailOpen(true)
setAnnotationPopover(target)
setAnnotationTarget(null)
}, [
annotationTarget,
canAnnotateRichMarkdown,
editorRef,
markdownComments,
markdownSourceLineOffset,
rootRef,
setReviewRailOpen
])
useEffect(() => {
if (canAnnotateRichMarkdown) {
return
}
// Why: disabling annotations must immediately remove stale popovers and
// highlights that cannot be derived from the next non-annotatable render.
// oxlint-disable-next-line react-doctor/no-adjust-state-on-prop-change
setAnnotationTarget(null)
// oxlint-disable-next-line react-doctor/no-adjust-state-on-prop-change
setAnnotationPopover(null)
clearAllAnnotationHighlights()
}, [canAnnotateRichMarkdown, clearAllAnnotationHighlights])
return {
...copyFeedback,
...rail,
annotationPopover,
annotationTarget,
canAnnotateRichMarkdown,
clearAnnotationHighlight,
clearAnnotationTarget,
clearAllAnnotationHighlights,
clearTransientReviewState,
markdownComments,
markdownCommentsRef,
markdownSourceLineOffsetRef,
openAnnotationPopover,
reviewRailExpanded,
setAnnotationPopover,
submitAnnotation,
syncAnnotationTarget,
unsentMarkdownReviewScope
}
}
function cancelFrame(ref: MutableRefObject<number | null>): void {
if (ref.current !== null) {
window.cancelAnimationFrame(ref.current)
ref.current = null
}
}
@@ -0,0 +1,81 @@
import { useCallback, useRef, useState } from 'react'
import type { MutableRefObject } from 'react'
import { copyMarkdownReviewNotesForAgent } from '@/lib/markdown-review-note-copy'
import type { MarkdownReviewNote } from '@/lib/markdown-review-notes'
type UseRichMarkdownReviewCopyFeedbackOptions = {
markdownReviewContent: string
markdownReviewNotes: MarkdownReviewNote[]
rootRef: MutableRefObject<HTMLDivElement | null>
}
export function useRichMarkdownReviewCopyFeedback({
markdownReviewContent,
markdownReviewNotes,
rootRef
}: UseRichMarkdownReviewCopyFeedbackOptions) {
const [reviewNotesCopied, setReviewNotesCopied] = useState(false)
const [copiedReviewNoteId, setCopiedReviewNoteId] = useState<string | null>(null)
const reviewNotesCopiedResetTimerRef = useRef<number | null>(null)
const copiedReviewNoteResetTimerRef = useRef<number | null>(null)
const clearReviewCopyTimers = useCallback((): void => {
clearWindowTimer(reviewNotesCopiedResetTimerRef)
clearWindowTimer(copiedReviewNoteResetTimerRef)
}, [])
const handleCopyMarkdownReviewNotes = useCallback(async (): Promise<void> => {
const copied = await copyReviewNotes(markdownReviewNotes, markdownReviewContent)
if (copied && rootRef.current) {
clearReviewCopyTimers()
setCopiedReviewNoteId(null)
setReviewNotesCopied(true)
reviewNotesCopiedResetTimerRef.current = window.setTimeout(() => {
reviewNotesCopiedResetTimerRef.current = null
setReviewNotesCopied(false)
}, 1600)
}
}, [clearReviewCopyTimers, markdownReviewContent, markdownReviewNotes, rootRef])
const handleCopyMarkdownReviewNote = useCallback(
async (note: MarkdownReviewNote): Promise<void> => {
const copied = await copyReviewNotes([note], markdownReviewContent)
if (copied && rootRef.current) {
clearWindowTimer(copiedReviewNoteResetTimerRef)
setCopiedReviewNoteId(note.id)
copiedReviewNoteResetTimerRef.current = window.setTimeout(() => {
copiedReviewNoteResetTimerRef.current = null
setCopiedReviewNoteId(null)
}, 1600)
}
},
[markdownReviewContent, rootRef]
)
return {
clearReviewCopyTimers,
copiedReviewNoteId,
handleCopyMarkdownReviewNote,
handleCopyMarkdownReviewNotes,
reviewNotesCopied
}
}
function clearWindowTimer(ref: MutableRefObject<number | null>): void {
if (ref.current !== null) {
window.clearTimeout(ref.current)
ref.current = null
}
}
async function copyReviewNotes(notes: MarkdownReviewNote[], content: string): Promise<boolean> {
try {
return await copyMarkdownReviewNotesForAgent({
notes,
content,
writeClipboardText: window.api.ui.writeClipboardText
})
} catch {
return false
}
}
@@ -0,0 +1,73 @@
import { useMemo } from 'react'
import { getRelativePathInsideRoot, normalizeRelativePath } from '@/lib/path'
import { isMarkdownComment } from '@/lib/diff-comment-compat'
import {
formatMarkdownReviewNotes,
sortMarkdownReviewNotes,
type MarkdownReviewNote
} from '@/lib/markdown-review-notes'
import type { NotesSendMenuScope } from './NotesSendMenu'
import type { DiffComment } from '../../../../shared/types'
type UseRichMarkdownReviewDataOptions = {
allDiffComments: DiffComment[] | undefined
filePath: string
markdownAnnotationFilePath?: string
markdownAnnotationsEnabled: boolean
markdownReviewContent: string
worktreeRoot: string | null
}
export function useRichMarkdownReviewData({
allDiffComments,
filePath,
markdownAnnotationFilePath,
markdownAnnotationsEnabled,
markdownReviewContent,
worktreeRoot
}: UseRichMarkdownReviewDataOptions): {
canAnnotateRichMarkdown: boolean
markdownComments: DiffComment[]
markdownReviewNotes: MarkdownReviewNote[]
sourceRelativePath: string | null
unsentMarkdownReviewScope: NotesSendMenuScope<MarkdownReviewNote>[]
} {
const sourceRelativePath = useMemo(
() =>
markdownAnnotationFilePath
? normalizeRelativePath(markdownAnnotationFilePath)
: getRelativePathInsideRoot(filePath, worktreeRoot),
[filePath, markdownAnnotationFilePath, worktreeRoot]
)
const canAnnotateRichMarkdown = Boolean(markdownAnnotationsEnabled && sourceRelativePath !== null)
const markdownComments = useMemo(
() =>
(allDiffComments ?? []).filter(
(comment) => comment.filePath === sourceRelativePath && isMarkdownComment(comment)
),
[allDiffComments, sourceRelativePath]
)
const markdownReviewNotes = useMemo(
() => sortMarkdownReviewNotes(markdownComments as MarkdownReviewNote[]),
[markdownComments]
)
const unsentMarkdownReviewScope = useMemo<NotesSendMenuScope<MarkdownReviewNote>[]>(() => {
const unsentNotes = markdownReviewNotes.filter((note) => !note.sentAt)
return [
{
id: 'all',
label: 'All unsent notes',
notes: unsentNotes,
prompt: formatMarkdownReviewNotes(unsentNotes, markdownReviewContent)
}
]
}, [markdownReviewContent, markdownReviewNotes])
return {
canAnnotateRichMarkdown,
markdownComments,
markdownReviewNotes,
sourceRelativePath,
unsentMarkdownReviewScope
}
}
@@ -0,0 +1,56 @@
import { useEffect, type RefObject } from 'react'
import type { Editor } from '@tiptap/react'
import { richMarkdownAnnotationHighlightPluginKey } from './rich-markdown-annotation-highlight'
import { getRichMarkdownAnnotationHighlightRanges } from './rich-markdown-review-annotations'
import type { DiffComment } from '../../../../shared/types'
type UseRichMarkdownReviewEditorEffectsOptions = {
canAnnotateRichMarkdown: boolean
content: string
editor: Editor | null
markdownComments: DiffComment[]
markdownSourceLineOffset: number
scrollContainerRef: RefObject<HTMLDivElement | null>
syncAnnotationTarget: (editor: Editor) => void
}
export function useRichMarkdownReviewEditorEffects({
canAnnotateRichMarkdown,
content,
editor,
markdownComments,
markdownSourceLineOffset,
scrollContainerRef,
syncAnnotationTarget
}: UseRichMarkdownReviewEditorEffectsOptions): void {
useEffect(() => {
if (!editor || !canAnnotateRichMarkdown) {
return
}
const noteRanges = getRichMarkdownAnnotationHighlightRanges(
editor,
markdownComments,
markdownSourceLineOffset
)
editor.view.dispatch(
editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { noteRanges })
)
}, [canAnnotateRichMarkdown, content, editor, markdownComments, markdownSourceLineOffset])
useEffect(() => {
if (!editor) {
return
}
const container = scrollContainerRef.current
if (!container) {
return
}
const update = (): void => syncAnnotationTarget(editor)
container.addEventListener('scroll', update)
window.addEventListener('resize', update)
return () => {
container.removeEventListener('scroll', update)
window.removeEventListener('resize', update)
}
}, [editor, scrollContainerRef, syncAnnotationTarget])
}
@@ -0,0 +1,261 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { MutableRefObject } from 'react'
import type { Editor } from '@tiptap/react'
import {
richMarkdownAnnotationHighlightPluginKey,
type RichMarkdownAnnotationHighlightRange
} from './rich-markdown-annotation-highlight'
import { getRichMarkdownRangeBounds } from './rich-markdown-range-bounds'
import {
clearRichMarkdownNotePositions,
getRichMarkdownAnnotationHighlightRangesForComment
} from './rich-markdown-review-annotations'
import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout'
import { measureRichMarkdownReviewNotePositions } from './rich-markdown-review-note-positioning'
import type { DiffComment } from '../../../../shared/types'
type UseRichMarkdownReviewRailControllerOptions = {
canAnnotateRichMarkdown: boolean
content: string
editorRef: MutableRefObject<Editor | null>
markdownComments: DiffComment[]
markdownSourceLineOffset: number
markdownSourceLineOffsetRef: MutableRefObject<number>
scrollContainerRef: MutableRefObject<HTMLDivElement | null>
}
export function useRichMarkdownReviewRailController({
canAnnotateRichMarkdown,
content,
editorRef,
markdownComments,
markdownSourceLineOffset,
markdownSourceLineOffsetRef,
scrollContainerRef
}: UseRichMarkdownReviewRailControllerOptions) {
const [reviewRailOpen, setReviewRailOpen] = useState(false)
const [activeReviewCommentId, setActiveReviewCommentId] = useState<string | null>(null)
const [attentionReviewCommentId, setAttentionReviewCommentId] = useState<string | null>(null)
const [notePositions, setNotePositions] = useState<RichMarkdownReviewNotePosition[]>([])
const notePositionsRef = useRef<RichMarkdownReviewNotePosition[]>([])
const attentionReviewCommentTimeoutRef = useRef<number | null>(null)
const sourceAttentionTimeoutRef = useRef<number | null>(null)
const notePositionsFrameRef = useRef<number | null>(null)
const reviewRailVisible = markdownComments.length > 0 && reviewRailOpen
notePositionsRef.current = notePositions
const clearAttentionTimers = useCallback((): void => {
clearWindowTimer(attentionReviewCommentTimeoutRef)
clearWindowTimer(sourceAttentionTimeoutRef)
}, [])
const cancelNotePositionFrame = useCallback((): void => {
cancelFrame(notePositionsFrameRef)
}, [])
const syncNotePositions = useCallback((): void => {
const editor = editorRef.current
const container = scrollContainerRef.current
if (
!reviewRailVisible ||
!canAnnotateRichMarkdown ||
!editor ||
!container ||
markdownComments.length === 0
) {
clearRichMarkdownNotePositions(setNotePositions)
return
}
setNotePositions(
measureRichMarkdownReviewNotePositions({
editor,
container,
markdownComments,
markdownSourceLineOffset
})
)
}, [
canAnnotateRichMarkdown,
editorRef,
markdownComments,
markdownSourceLineOffset,
reviewRailVisible,
scrollContainerRef
])
const requestSyncNotePositions = useCallback((): void => {
if (!reviewRailVisible) {
clearRichMarkdownNotePositions(setNotePositions)
return
}
if (notePositionsFrameRef.current !== null) {
return
}
notePositionsFrameRef.current = window.requestAnimationFrame(() => {
notePositionsFrameRef.current = null
syncNotePositions()
})
}, [reviewRailVisible, syncNotePositions])
const pulseRichMarkdownReviewNote = useCallback((commentId: string): void => {
clearWindowTimer(attentionReviewCommentTimeoutRef)
setAttentionReviewCommentId(null)
window.requestAnimationFrame(() => {
setAttentionReviewCommentId(commentId)
attentionReviewCommentTimeoutRef.current = window.setTimeout(() => {
attentionReviewCommentTimeoutRef.current = null
setAttentionReviewCommentId(null)
}, 900)
})
}, [])
const scrollRichMarkdownReviewNoteCardIntoView = useCallback(
(commentId: string): void => {
setReviewRailOpen(true)
setActiveReviewCommentId(commentId)
pulseRichMarkdownReviewNote(commentId)
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() =>
centerReviewNoteCard(scrollContainerRef.current, notePositionsRef.current, commentId)
)
})
},
[pulseRichMarkdownReviewNote, scrollContainerRef]
)
const pulseRichMarkdownSourceRange = useCallback(
(range: RichMarkdownAnnotationHighlightRange): void => {
const editor = editorRef.current
if (!editor) {
return
}
clearWindowTimer(sourceAttentionTimeoutRef)
editor.view.dispatch(
editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { activeRange: null })
)
window.requestAnimationFrame(() => {
const latestEditor = editorRef.current
if (!latestEditor) {
return
}
latestEditor.view.dispatch(
latestEditor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, {
activeRange: range
})
)
sourceAttentionTimeoutRef.current = window.setTimeout(() => {
sourceAttentionTimeoutRef.current = null
editorRef.current?.view.dispatch(
editorRef.current.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, {
activeRange: null
})
)
}, 900)
})
},
[editorRef]
)
const scrollRichMarkdownReviewNoteSourceIntoView = useCallback(
(comment: DiffComment): void => {
const editor = editorRef.current
const container = scrollContainerRef.current
if (!editor || !container) {
return
}
const ranges = getRichMarkdownAnnotationHighlightRangesForComment(
editor,
comment,
markdownSourceLineOffsetRef.current
)
const bounds = getRichMarkdownRangeBounds(ranges)
if (!bounds) {
return
}
const maxPos = editor.state.doc.content.size
const startCoords = editor.view.coordsAtPos(Math.max(1, Math.min(bounds.from, maxPos)))
const endCoords = editor.view.coordsAtPos(Math.max(1, Math.min(bounds.to, maxPos)))
const containerRect = container.getBoundingClientRect()
const sourceTop = startCoords.top - containerRect.top + container.scrollTop
const sourceBottom = endCoords.bottom - containerRect.top + container.scrollTop
setActiveReviewCommentId(comment.id)
container.scrollTo({
top: Math.max(0, (sourceTop + sourceBottom) / 2 - container.clientHeight / 2),
behavior: 'smooth'
})
pulseRichMarkdownSourceRange({ from: bounds.from, to: bounds.to })
},
[editorRef, markdownSourceLineOffsetRef, pulseRichMarkdownSourceRange, scrollContainerRef]
)
useEffect(() => requestSyncNotePositions(), [content, markdownComments, requestSyncNotePositions])
useEffect(() => {
if (!reviewRailVisible) {
clearRichMarkdownNotePositions(setNotePositions)
return
}
const container = scrollContainerRef.current
if (!container) {
return
}
const update = (): void => requestSyncNotePositions()
container.addEventListener('scroll', update, { passive: true })
window.addEventListener('resize', update)
requestSyncNotePositions()
return () => {
container.removeEventListener('scroll', update)
window.removeEventListener('resize', update)
}
}, [requestSyncNotePositions, reviewRailVisible, scrollContainerRef])
return {
activeReviewCommentId,
attentionReviewCommentId,
cancelNotePositionFrame,
clearAttentionTimers,
notePositions,
reviewRailOpen,
reviewRailVisible,
scrollRichMarkdownReviewNoteCardIntoView,
scrollRichMarkdownReviewNoteSourceIntoView,
setReviewRailOpen,
syncNotePositions
}
}
function clearWindowTimer(ref: MutableRefObject<number | null>): void {
if (ref.current !== null) {
window.clearTimeout(ref.current)
ref.current = null
}
}
function cancelFrame(ref: MutableRefObject<number | null>): void {
if (ref.current !== null) {
window.cancelAnimationFrame(ref.current)
ref.current = null
}
}
function centerReviewNoteCard(
container: HTMLDivElement | null,
positions: RichMarkdownReviewNotePosition[],
commentId: string
): void {
const card = container?.querySelector<HTMLElement>(
`[data-rich-markdown-review-note-id="${CSS.escape(commentId)}"]`
)
if (!container) {
return
}
const position = positions.find((item) => item.comment.id === commentId)
const cardHeight = card?.offsetHeight ?? 72
const cardTop = position?.top ?? card?.offsetTop
if (cardTop === undefined) {
return
}
const targetTop = cardTop - Math.max(0, (container.clientHeight - cardHeight) / 2)
container.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' })
}
@@ -1,6 +1,5 @@
/* eslint-disable max-lines -- Why: orchestrator for the inline tour surface; splitting it here would scatter related state across helpers without making the file easier to read. */
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import type { JSX, KeyboardEvent, ReactNode } from 'react'
import type { JSX, ReactNode } from 'react'
import {
DEFAULT_FEATURE_WALL_WORKFLOW_ID,
FEATURE_WALL_WORKFLOWS,
@@ -21,10 +20,6 @@ import {
useInstalledAgentSkill
} from '@/hooks/useInstalledAgentSkills'
import { usePrefersReducedMotion } from './feature-wall-modal-helpers'
import {
getFeatureWallRailNavigationTarget,
type FeatureWallRailNavigationKey
} from './feature-wall-rail-navigation'
import { toFeatureWallAssetUrl, useFeatureWallAssetBaseUrl } from './feature-wall-assets'
import { useFeatureWallTaskSourcePresentation } from './use-feature-wall-task-source-presentation'
import { useFeatureWallCompletion } from './use-feature-wall-completion'
@@ -32,9 +27,9 @@ import { useFeatureWallTourTelemetry } from './use-feature-wall-tour-telemetry'
import { FeatureWallContinueButton } from './FeatureWallContinueButton'
import { FeatureWallTourPanel } from './FeatureWallTourPanel'
import { getFeatureWallActiveStepCopy } from './feature-wall-active-step-copy'
import { getScreenSubmitModifierLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
const NAVIGATION_KEYS = new Set<string>(['ArrowUp', 'ArrowDown', 'Home', 'End'])
import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut'
import { useFeatureWallTourKeyboardShortcut } from './use-feature-wall-tour-keyboard-shortcut'
import { useFeatureWallTourRailKeydown } from './use-feature-wall-tour-rail-keydown'
type FeatureWallTourSurfaceProps = {
isOpen: boolean
@@ -252,23 +247,10 @@ export function FeatureWallTourSurface({
[markReviewStepVisited]
)
const handleRailKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number): void => {
if (!NAVIGATION_KEYS.has(event.key)) {
return
}
event.preventDefault()
const nextIndex = getFeatureWallRailNavigationTarget({
currentIndex: index,
key: event.key as FeatureWallRailNavigationKey,
itemCount: FEATURE_WALL_WORKFLOWS.length
})
const nextWorkflow = FEATURE_WALL_WORKFLOWS[nextIndex]
if (!nextWorkflow) {
return
}
handleSelect(nextWorkflow)
railRefs.current[nextIndex]?.focus()
}
const handleRailKeyDown = useFeatureWallTourRailKeydown({
railRefs,
onSelectWorkflow: handleSelect
})
const isLastWorkflow = selectedIndex >= FEATURE_WALL_WORKFLOWS.length - 1
const agentsStepIndex =
@@ -366,20 +348,11 @@ export function FeatureWallTourSurface({
workbenchSteps
])
useEffect(() => {
if (!isOpen || !enableKeyboardShortcut) {
return
}
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
if (!isScreenSubmitShortcut(event)) {
return
}
event.preventDefault()
handleContinue()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [enableKeyboardShortcut, handleContinue, isOpen])
useFeatureWallTourKeyboardShortcut({
isOpen,
enabled: enableKeyboardShortcut,
onContinue: handleContinue
})
if (!isOpen) {
return null
@@ -1,4 +1,4 @@
/* eslint-disable max-lines -- Why: this is a single storyboarded setup-script animation; splitting the phase markup from target measurement makes the visual harder to verify. */
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: this visual is a timed storyboard; phase and typed-name state intentionally advance from animation effects and the reduced-motion gate. */
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { JSX, ReactNode, RefObject } from 'react'
import { FolderGit2, Plus, TerminalSquare } from 'lucide-react'
@@ -6,6 +6,8 @@ import { cn } from '@/lib/utils'
import { ClaudeIcon } from '../status-bar/icons'
import { CodexInlineIcon, CursorIcon, WorkingSpinner } from './feature-tour-preview-glyphs'
import { FeatureWallClickRing } from './FeatureWallClickRing'
import { SetupScriptNewWorkspaceModal } from './SetupScriptNewWorkspaceModal'
import { SetupScriptWorkspaceListCard } from './SetupScriptWorkspaceListCard'
const PHASES = [
{ name: 'create-init', duration: 1200 },
@@ -157,7 +159,7 @@ export function SetupScriptAnimatedVisual(props: { reducedMotion: boolean }): JS
</div>
<div className="mt-1 flex flex-col gap-1.5 min-w-0 flex-1">
<WorkspaceListCard
<SetupScriptWorkspaceListCard
title="release notes"
active={!isWorkspaceActive}
prompt="draft release notes"
@@ -166,7 +168,7 @@ export function SetupScriptAnimatedVisual(props: { reducedMotion: boolean }): JS
reducedMotion={reducedMotion}
/>
<WorkspaceListCard
<SetupScriptWorkspaceListCard
title="checkout fix"
active={isWorkspaceActive}
prompt="fix checkout timeout"
@@ -319,7 +321,7 @@ export function SetupScriptAnimatedVisual(props: { reducedMotion: boolean }): JS
</div>
{/* Create Worktree Modal Overlay */}
<NewWorkspaceModal
<SetupScriptNewWorkspaceModal
visible={modalVisible}
nameValue={modalNameValue}
nameTyping={phase === 'modal-typing'}
@@ -383,112 +385,6 @@ function useMeasuredCursor(
return pos
}
function WorkspaceListCard(props: {
title: string
active: boolean
prompt?: string
icon?: ReactNode
state: 'idle' | 'starting' | 'setup' | 'working'
reducedMotion: boolean
className?: string
}): JSX.Element {
return (
<div
className={cn(
'rounded-md border border-sidebar-border px-2 py-1.5 transition-colors duration-300',
props.active ? 'bg-sidebar-accent' : 'bg-sidebar',
props.className
)}
>
<div className="flex items-center gap-1.5">
<span
className={cn(
'size-1.5 shrink-0 rounded-full transition-colors duration-300',
props.active ? 'bg-emerald-500' : 'bg-muted-foreground/35'
)}
/>
<span className="truncate text-xs font-semibold text-sidebar-foreground">
{props.title}
</span>
</div>
{props.prompt && (
<div className="mt-1.5 grid grid-cols-[8px_14px_minmax(0,1fr)] items-center gap-1.5">
{props.state === 'working' || props.state === 'starting' || props.state === 'setup' ? (
<WorkingSpinner size="xs" reducedMotion={props.reducedMotion} />
) : (
<span className="size-1.5 rounded-full bg-muted-foreground/35" />
)}
<span className="flex size-3.5 items-center justify-center text-sidebar-foreground/65">
{props.icon}
</span>
<span className="truncate font-mono text-[10px] text-sidebar-foreground/65">
{props.state === 'setup' ? 'running setup' : props.prompt}
</span>
</div>
)}
</div>
)
}
function NewWorkspaceModal(props: {
visible: boolean
nameValue: string
nameTyping: boolean
createHovered: boolean
createClicked: boolean
createButtonRef: RefObject<HTMLDivElement | null>
}): JSX.Element {
return (
<div
className={cn(
'pointer-events-none absolute inset-0 flex items-center justify-center rounded-lg bg-background/55 backdrop-blur-[1px] transition-opacity duration-300 z-40',
props.visible ? 'opacity-100' : 'opacity-0'
)}
aria-hidden
>
<div
className={cn(
'relative w-[min(240px,92%)] rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-lg transition-[opacity,transform] duration-300',
props.visible
? 'translate-y-0 scale-100 opacity-100'
: 'translate-y-2 scale-[0.98] opacity-0'
)}
>
<div className="text-xs font-semibold leading-none text-foreground">Create Worktree</div>
<div className="mt-2.5 space-y-1.5">
<ModalField label="Project" value="orca" />
<ModalField label="Name" value={props.nameValue} typing={props.nameTyping} />
<ModalField label="Agent" value="Codex" />
</div>
<div
ref={props.createButtonRef}
className={cn(
'mt-3 flex h-7 w-full items-center justify-center rounded-md bg-primary px-3 text-[11px] font-medium text-primary-foreground transition-all duration-200',
props.createHovered ? 'opacity-90' : null,
props.createClicked ? 'scale-[0.98]' : null
)}
>
Create worktree
</div>
</div>
</div>
)
}
function ModalField(props: { label: string; value: string; typing?: boolean }): JSX.Element {
return (
<div className="flex items-center justify-between gap-2 rounded-md border border-border bg-muted/30 px-2 py-1 text-[11px]">
<span className="text-muted-foreground">{props.label}</span>
<div className="flex items-center font-mono font-medium text-foreground min-w-0">
<span className="truncate">{props.value}</span>
{props.typing ? (
<span className="ml-px inline-block h-2.5 w-[5px] animate-pulse bg-foreground" />
) : null}
</div>
</div>
)
}
function TerminalLine(props: {
children: ReactNode
muted?: boolean
@@ -0,0 +1,72 @@
import type { JSX, RefObject } from 'react'
import { cn } from '@/lib/utils'
type SetupScriptNewWorkspaceModalProps = {
visible: boolean
nameValue: string
nameTyping: boolean
createHovered: boolean
createClicked: boolean
createButtonRef: RefObject<HTMLDivElement | null>
}
export function SetupScriptNewWorkspaceModal({
visible,
nameValue,
nameTyping,
createHovered,
createClicked,
createButtonRef
}: SetupScriptNewWorkspaceModalProps): JSX.Element {
return (
<div
className={cn(
'pointer-events-none absolute inset-0 flex items-center justify-center rounded-lg bg-background/55 backdrop-blur-[1px] transition-opacity duration-300 z-40',
visible ? 'opacity-100' : 'opacity-0'
)}
aria-hidden
>
<div
className={cn(
'relative w-[min(240px,92%)] rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-lg transition-[opacity,transform] duration-300',
visible ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-2 scale-[0.98] opacity-0'
)}
>
<div className="text-xs font-semibold leading-none text-foreground">Create Worktree</div>
<div className="mt-2.5 space-y-1.5">
<SetupScriptModalField label="Project" value="orca" />
<SetupScriptModalField label="Name" value={nameValue} typing={nameTyping} />
<SetupScriptModalField label="Agent" value="Codex" />
</div>
<div
ref={createButtonRef}
className={cn(
'mt-3 flex h-7 w-full items-center justify-center rounded-md bg-primary px-3 text-[11px] font-medium text-primary-foreground transition-all duration-200',
createHovered ? 'opacity-90' : null,
createClicked ? 'scale-[0.98]' : null
)}
>
Create worktree
</div>
</div>
</div>
)
}
function SetupScriptModalField(props: {
label: string
value: string
typing?: boolean
}): JSX.Element {
return (
<div className="flex items-center justify-between gap-2 rounded-md border border-border bg-muted/30 px-2 py-1 text-[11px]">
<span className="text-muted-foreground">{props.label}</span>
<div className="flex items-center font-mono font-medium text-foreground min-w-0">
<span className="truncate">{props.value}</span>
{props.typing ? (
<span className="ml-px inline-block h-2.5 w-[5px] animate-pulse bg-foreground" />
) : null}
</div>
</div>
)
}
@@ -0,0 +1,54 @@
import type { JSX, ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { WorkingSpinner } from './feature-tour-preview-glyphs'
type SetupScriptWorkspaceListCardProps = {
title: string
active: boolean
prompt?: string
icon?: ReactNode
state: 'idle' | 'starting' | 'setup' | 'working'
reducedMotion: boolean
className?: string
}
export function SetupScriptWorkspaceListCard(
props: SetupScriptWorkspaceListCardProps
): JSX.Element {
return (
<div
className={cn(
'rounded-md border border-sidebar-border px-2 py-1.5 transition-colors duration-300',
props.active ? 'bg-sidebar-accent' : 'bg-sidebar',
props.className
)}
>
<div className="flex items-center gap-1.5">
<span
className={cn(
'size-1.5 shrink-0 rounded-full transition-colors duration-300',
props.active ? 'bg-emerald-500' : 'bg-muted-foreground/35'
)}
/>
<span className="truncate text-xs font-semibold text-sidebar-foreground">
{props.title}
</span>
</div>
{props.prompt ? (
<div className="mt-1.5 grid grid-cols-[8px_14px_minmax(0,1fr)] items-center gap-1.5">
{props.state === 'working' || props.state === 'starting' || props.state === 'setup' ? (
<WorkingSpinner size="xs" reducedMotion={props.reducedMotion} />
) : (
<span className="size-1.5 rounded-full bg-muted-foreground/35" />
)}
<span className="flex size-3.5 items-center justify-center text-sidebar-foreground/65">
{props.icon}
</span>
<span className="truncate font-mono text-[10px] text-sidebar-foreground/65">
{props.state === 'setup' ? 'running setup' : props.prompt}
</span>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,27 @@
import { useEffect } from 'react'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
export function useFeatureWallTourKeyboardShortcut({
isOpen,
enabled,
onContinue
}: {
isOpen: boolean
enabled: boolean
onContinue: () => void
}): void {
useEffect(() => {
if (!isOpen || !enabled) {
return
}
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
if (!isScreenSubmitShortcut(event)) {
return
}
event.preventDefault()
onContinue()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [enabled, isOpen, onContinue])
}
@@ -0,0 +1,41 @@
import { useCallback } from 'react'
import type { KeyboardEvent, RefObject } from 'react'
import {
FEATURE_WALL_WORKFLOWS,
type FeatureWallWorkflow
} from '../../../../shared/feature-wall-workflows'
import {
getFeatureWallRailNavigationTarget,
type FeatureWallRailNavigationKey
} from './feature-wall-rail-navigation'
const FEATURE_WALL_TOUR_NAVIGATION_KEYS = new Set<string>(['ArrowUp', 'ArrowDown', 'Home', 'End'])
export function useFeatureWallTourRailKeydown({
railRefs,
onSelectWorkflow
}: {
railRefs: RefObject<(HTMLButtonElement | null)[]>
onSelectWorkflow: (workflow: FeatureWallWorkflow) => void
}): (event: KeyboardEvent<HTMLButtonElement>, index: number) => void {
return useCallback(
(event: KeyboardEvent<HTMLButtonElement>, index: number): void => {
if (!FEATURE_WALL_TOUR_NAVIGATION_KEYS.has(event.key)) {
return
}
event.preventDefault()
const nextIndex = getFeatureWallRailNavigationTarget({
currentIndex: index,
key: event.key as FeatureWallRailNavigationKey,
itemCount: FEATURE_WALL_WORKFLOWS.length
})
const nextWorkflow = FEATURE_WALL_WORKFLOWS[nextIndex]
if (!nextWorkflow) {
return
}
onSelectWorkflow(nextWorkflow)
railRefs.current[nextIndex]?.focus()
},
[onSelectWorkflow, railRefs]
)
}
@@ -1,11 +1,6 @@
/* eslint-disable max-lines -- Why: the mobile page keeps pairing, device
revoke, QR, and stage transitions together so the flow remains auditable. */
import { useCallback, useEffect, useRef, useState } from 'react'
import QRCodeBrowser from 'qrcode/lib/browser'
import { toast } from 'sonner'
import { Eye, EyeOff, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '@/store'
import { PhoneCarousel } from './PhoneCarousel'
@@ -17,6 +12,7 @@ import {
type Platform,
type StepIndex
} from './MobileHero'
import { PLATFORM_COPY } from './mobile-platform-copy'
import {
selectRefreshedNetworkAddress,
type MobileNetworkInterface
@@ -26,6 +22,7 @@ import {
shouldShowPairedAfterDeviceRefresh,
type MobilePageStage as FlowStage
} from './mobile-page-stage'
import { MobilePageToolbar } from './MobilePageToolbar'
async function renderQrDataUrl(text: string): Promise<string> {
return QRCodeBrowser.toDataURL(text, {
@@ -35,22 +32,6 @@ async function renderQrDataUrl(text: string): Promise<string> {
})
}
export const PLATFORM_COPY: Record<
Platform,
{ description: string; ctaLabel: string; url: string }
> = {
ios: {
description: 'Scan with your iPhone camera to open the App Store.',
ctaLabel: 'Open App Store',
url: 'https://apps.apple.com/app/orca-ide/id6766130217'
},
android: {
description: 'Scan with your Android camera to download the latest APK from GitHub Releases.',
ctaLabel: 'Download APK',
url: 'https://github.com/stablyai/orca/releases/download/mobile-v0.0.11/app-release.apk'
}
}
export default function MobilePage(): React.JSX.Element {
// Why: stage starts unresolved so we don't flash the intro before we know
// whether any devices are already paired.
@@ -428,33 +409,11 @@ export default function MobilePage(): React.JSX.Element {
return (
<div className="mobile-page-root">
<div className="mp-page-toolbar">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 rounded-full"
onClick={closeMobilePage}
aria-label="Close Orca Mobile"
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Close · Esc
</TooltipContent>
</Tooltip>
<Button
variant="outline"
size="sm"
className="h-8 gap-2 rounded-md bg-card px-3 text-xs font-medium shadow-xs"
onClick={toggleMobileSidebarButton}
>
{showMobileButton ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
{showMobileButton ? 'Hide from sidebar' : 'Show in sidebar'}
</Button>
</div>
<MobilePageToolbar
showMobileButton={showMobileButton}
onClose={closeMobilePage}
onToggleMobileSidebarButton={toggleMobileSidebarButton}
/>
<section className="mp-hero">
<div className="mp-hero-copy">
{stage === null ? null : stage === 'intro' ? (
@@ -0,0 +1,45 @@
import { Eye, EyeOff, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
type MobilePageToolbarProps = {
showMobileButton: boolean
onClose: () => void
onToggleMobileSidebarButton: () => void
}
export function MobilePageToolbar({
showMobileButton,
onClose,
onToggleMobileSidebarButton
}: MobilePageToolbarProps): React.JSX.Element {
return (
<div className="mp-page-toolbar">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 rounded-full"
onClick={onClose}
aria-label="Close Orca Mobile"
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Close · Esc
</TooltipContent>
</Tooltip>
<Button
variant="outline"
size="sm"
className="h-8 gap-2 rounded-md bg-card px-3 text-xs font-medium shadow-xs"
onClick={onToggleMobileSidebarButton}
>
{showMobileButton ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
{showMobileButton ? 'Hide from sidebar' : 'Show in sidebar'}
</Button>
</div>
)
}
@@ -0,0 +1,17 @@
import type { Platform } from './MobileHero'
export const PLATFORM_COPY: Record<
Platform,
{ description: string; ctaLabel: string; url: string }
> = {
ios: {
description: 'Scan with your iPhone camera to open the App Store.',
ctaLabel: 'Open App Store',
url: 'https://apps.apple.com/app/orca-ide/id6766130217'
},
android: {
description: 'Scan with your Android camera to download the latest APK from GitHub Releases.',
ctaLabel: 'Download APK',
url: 'https://github.com/stablyai/orca/releases/download/mobile-v0.0.11/app-release.apk'
}
}
@@ -1,6 +1,5 @@
/* eslint-disable max-lines -- Why: create-review dialog keeps provider copy, base/head selection, template controls, and submit state together so GitHub PR and GitLab MR creation stay symmetric. */
import React, { useCallback, useRef, useState } from 'react'
import { Check, ChevronsUpDown, Loader2, Sparkles, Square, RefreshCw } from 'lucide-react'
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
@@ -13,7 +12,6 @@ import {
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import type {
@@ -30,6 +28,7 @@ import {
} from '../../../../shared/source-control-ai'
import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key'
import { getRuntimeGitScope } from '@/runtime/runtime-git-client'
import { CreatePullRequestGenerateButton } from './CreatePullRequestGenerateButton'
type CreatePullRequestDialogProps = {
open: boolean
@@ -283,42 +282,15 @@ export function CreatePullRequestDialog({
<div className="flex min-w-0 items-center justify-between gap-2 pr-8">
<DialogTitle className="min-w-0 truncate">Create {copy.titleLabel}</DialogTitle>
{aiGenerationEnabled ? (
<div className="shrink-0">
{generating ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCancelGenerate}
title="Stop generating"
aria-label={`Stop generating ${copy.reviewLabel} details`}
>
<RefreshCw className="size-4 animate-spin" />
Generating…
<Square className="size-3 fill-current" />
</Button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
Generating {copy.shortLabel} details. Click to stop.
</TooltipContent>
</Tooltip>
) : (
<Button
type="button"
variant="outline"
size="sm"
disabled={generateDisabled}
onClick={() => void handleGenerate()}
title={generateDisabledReason ?? `Generate ${copy.reviewLabel} details with AI`}
aria-label={`Generate ${copy.reviewLabel} details with AI`}
>
<Sparkles className="size-4" />
Generate with AI
</Button>
)}
</div>
<CreatePullRequestGenerateButton
generating={generating}
generateDisabled={generateDisabled}
generateDisabledReason={generateDisabledReason}
shortLabel={copy.shortLabel}
reviewLabel={copy.reviewLabel}
onGenerate={() => void handleGenerate()}
onCancelGenerate={handleCancelGenerate}
/>
) : null}
</div>
<DialogDescription>
@@ -0,0 +1,64 @@
import { RefreshCw, Sparkles, Square } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
export function CreatePullRequestGenerateButton({
generating,
generateDisabled,
generateDisabledReason,
shortLabel,
reviewLabel,
onGenerate,
onCancelGenerate
}: {
generating: boolean
generateDisabled: boolean
generateDisabledReason: string | null | undefined
shortLabel: 'PR' | 'MR'
reviewLabel: 'pull request' | 'merge request'
onGenerate: () => void
onCancelGenerate: () => void
}): React.JSX.Element {
if (generating) {
return (
<div className="shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
onClick={onCancelGenerate}
title="Stop generating"
aria-label={`Stop generating ${reviewLabel} details`}
>
<RefreshCw className="size-4 animate-spin" />
Generating…
<Square className="size-3 fill-current" />
</Button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
Generating {shortLabel} details. Click to stop.
</TooltipContent>
</Tooltip>
</div>
)
}
return (
<div className="shrink-0">
<Button
type="button"
variant="outline"
size="sm"
disabled={generateDisabled}
onClick={onGenerate}
title={generateDisabledReason ?? `Generate ${reviewLabel} details with AI`}
aria-label={`Generate ${reviewLabel} details with AI`}
>
<Sparkles className="size-4" />
Generate with AI
</Button>
</div>
)
}
@@ -1,4 +1,3 @@
/* eslint-disable max-lines -- Why: Checks owns one compact hosted-review action bar; keeping GitHub PR and GitLab MR action branching together keeps provider parity visible. */
import React, { useCallback, useMemo, useState } from 'react'
import {
LoaderCircle,
@@ -27,6 +26,7 @@ import type { PRInfo, Repo, Worktree } from '../../../../shared/types'
import type { GitHubPRMergeMethod } from '../../../../shared/types'
import { resolveGitHubPRMergeMethods } from '../../../../shared/github-pr-merge-methods'
import { runWorktreeDelete } from '../sidebar/delete-worktree-flow'
import { presentGitLabMRMergeState } from './gitlab-mr-merge-state'
type HostedReviewActionInfo = Pick<
HostedReviewInfo,
@@ -39,63 +39,6 @@ type HostedReviewActionInfo = Pick<
>
>
function presentGitLabMRMergeState(review: HostedReviewActionInfo): {
label: string
tooltip: string
directMergeAvailable: boolean
} {
if (review.state === 'merged') {
return {
label: 'Merged',
tooltip: 'This merge request is already merged',
directMergeAvailable: false
}
}
if (review.state === 'closed') {
return {
label: 'Closed',
tooltip: 'This merge request is closed',
directMergeAvailable: false
}
}
if (review.state === 'draft') {
return {
label: 'Draft',
tooltip: 'This merge request is still a draft',
directMergeAvailable: false
}
}
if (review.mergeable === 'CONFLICTING') {
return {
label: 'Conflicts',
tooltip: 'GitLab reports merge conflicts',
directMergeAvailable: false
}
}
if (review.status === 'failure') {
return {
label: 'Checks failed',
tooltip: 'GitLab says this MR can merge, but some pipeline jobs failed',
directMergeAvailable: true
}
}
if (review.status === 'pending') {
return {
label: 'Checks pending',
tooltip: 'GitLab says this MR can merge, but the pipeline is still running',
directMergeAvailable: true
}
}
return {
label: 'Able to merge',
tooltip:
review.mergeable === 'UNKNOWN'
? 'GitLab has not reported a final merge status'
: 'GitLab says this MR can merge',
directMergeAvailable: true
}
}
export default function HostedReviewActions({
review,
githubPR,
@@ -0,0 +1,60 @@
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
type GitLabMRMergeStateReview = Pick<HostedReviewInfo, 'state' | 'status' | 'mergeable'>
export function presentGitLabMRMergeState(review: GitLabMRMergeStateReview): {
label: string
tooltip: string
directMergeAvailable: boolean
} {
if (review.state === 'merged') {
return {
label: 'Merged',
tooltip: 'This merge request is already merged',
directMergeAvailable: false
}
}
if (review.state === 'closed') {
return {
label: 'Closed',
tooltip: 'This merge request is closed',
directMergeAvailable: false
}
}
if (review.state === 'draft') {
return {
label: 'Draft',
tooltip: 'This merge request is still a draft',
directMergeAvailable: false
}
}
if (review.mergeable === 'CONFLICTING') {
return {
label: 'Conflicts',
tooltip: 'GitLab reports merge conflicts',
directMergeAvailable: false
}
}
if (review.status === 'failure') {
return {
label: 'Checks failed',
tooltip: 'GitLab says this MR can merge, but some pipeline jobs failed',
directMergeAvailable: true
}
}
if (review.status === 'pending') {
return {
label: 'Checks pending',
tooltip: 'GitLab says this MR can merge, but the pipeline is still running',
directMergeAvailable: true
}
}
return {
label: 'Able to merge',
tooltip:
review.mergeable === 'UNKNOWN'
? 'GitLab has not reported a final merge status'
: 'GitLab says this MR can merge',
directMergeAvailable: true
}
}
@@ -1,6 +1,3 @@
/* eslint-disable max-lines -- Why: the setting owns one collapsed form with
queued writes, model selection, and prompt draft state. Splitting the
tiny subcontrols would make the settings write flow harder to audit. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/types'
@@ -9,7 +6,6 @@ import type {
SourceControlAiSettingsPatch,
SourceControlAiSettings
} from '../../../../shared/source-control-ai-types'
import { buildBranchNamePrompt } from '../../../../shared/branch-name-from-work'
import {
clearSourceControlAiModelChoiceForHost,
normalizeSourceControlAiSettings,
@@ -35,9 +31,9 @@ import { useActiveWorktree } from '../../store/selectors'
import { Button } from '../ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible'
import { Label } from '../ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { AUTO_RENAME_BRANCH_ADVANCED_SEARCH_ENTRIES } from './auto-rename-branch-search'
import { AutoRenameBranchPromptEditor } from './AutoRenameBranchPromptEditor'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
@@ -52,10 +48,6 @@ type AutoRenameBranchFromWorkSettingProps = {
}
const INHERIT_BRANCH_MODEL_VALUE = '__inherit_branch_model__'
const BUILT_IN_BRANCH_NAME_PROMPT = buildBranchNamePrompt({
firstPrompt: '{first agent prompt}',
assistantMessage: '{agent initial response, when available}'
})
export function shouldOpenAutoRenameBranchAdvanced(searchQuery: string): boolean {
return (
normalizeSettingsSearchQuery(searchQuery) !== '' &&
@@ -351,73 +343,14 @@ export function AutoRenameBranchFromWorkSetting({
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 space-y-3 rounded-md border border-border/60 bg-muted/20 px-3 py-3">
<div className="space-y-2">
<div className="space-y-0.5">
<Label htmlFor="git-auto-rename-branch-name-prompt">Branch name prompt</Label>
<p className="text-xs text-muted-foreground">
Appended to Orca&apos;s{' '}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="inline rounded-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
built-in branch-name prompt
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
className="w-[520px] max-w-[calc(100vw-2rem)] p-3"
>
<div>
<pre className="scrollbar-sleek max-h-72 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-background px-3 py-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
{BUILT_IN_BRANCH_NAME_PROMPT}
</pre>
</div>
</PopoverContent>
</Popover>
. Orca generates only the final segment, like{' '}
<code className="font-mono">fix-login-flow</code>; your branch prefix setting
still applies.
</p>
</div>
<textarea
id="git-auto-rename-branch-name-prompt"
rows={4}
value={branchNamePromptDraft}
onChange={(event) => setBranchNamePromptDraft(event.target.value)}
placeholder="Prefer domain nouns from the task, avoid ticket IDs, and keep names reviewer-friendly."
className="w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
/>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
{branchNamePromptDirty ? 'Unsaved changes' : 'Saved'}
</p>
<div className="flex items-center gap-2">
{branchNamePromptDirty ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={onDiscardPrompt}
disabled={isSavingPrompt}
>
Discard
</Button>
) : null}
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => void onSavePrompt()}
disabled={!branchNamePromptDirty || isSavingPrompt}
>
{isSavingPrompt ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
</div>
<AutoRenameBranchPromptEditor
draft={branchNamePromptDraft}
dirty={branchNamePromptDirty}
saving={isSavingPrompt}
onDraftChange={setBranchNamePromptDraft}
onDiscard={onDiscardPrompt}
onSave={onSavePrompt}
/>
<div className="flex flex-col gap-3 border-t border-border/50 pt-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-0.5">
@@ -0,0 +1,89 @@
import { buildBranchNamePrompt } from '../../../../shared/branch-name-from-work'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
const BUILT_IN_BRANCH_NAME_PROMPT = buildBranchNamePrompt({
firstPrompt: '{first agent prompt}',
assistantMessage: '{agent initial response, when available}'
})
type AutoRenameBranchPromptEditorProps = {
draft: string
dirty: boolean
saving: boolean
onDraftChange: (value: string) => void
onDiscard: () => void
onSave: () => void | Promise<void>
}
export function AutoRenameBranchPromptEditor({
draft,
dirty,
saving,
onDraftChange,
onDiscard,
onSave
}: AutoRenameBranchPromptEditorProps): React.JSX.Element {
return (
<div className="space-y-2">
<div className="space-y-0.5">
<Label htmlFor="git-auto-rename-branch-name-prompt">Branch name prompt</Label>
<p className="text-xs text-muted-foreground">
Appended to Orca&apos;s{' '}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="inline rounded-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
built-in branch-name prompt
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
className="w-[520px] max-w-[calc(100vw-2rem)] p-3"
>
<div>
<pre className="scrollbar-sleek max-h-72 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-background px-3 py-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
{BUILT_IN_BRANCH_NAME_PROMPT}
</pre>
</div>
</PopoverContent>
</Popover>
. Orca generates only the final segment, like{' '}
<code className="font-mono">fix-login-flow</code>; your branch prefix setting still
applies.
</p>
</div>
<textarea
id="git-auto-rename-branch-name-prompt"
rows={4}
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
placeholder="Prefer domain nouns from the task, avoid ticket IDs, and keep names reviewer-friendly."
className="w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
/>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">{dirty ? 'Unsaved changes' : 'Saved'}</p>
<div className="flex items-center gap-2">
{dirty ? (
<Button type="button" variant="ghost" size="xs" onClick={onDiscard} disabled={saving}>
Discard
</Button>
) : null}
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => void onSave()}
disabled={!dirty || saving}
>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,33 @@
import { MousePointerClick } from 'lucide-react'
import { Button } from '../ui/button'
export function BrowserUseComputerUseNotice({
onOpenComputerUse
}: {
onOpenComputerUse: () => void
}): React.JSX.Element {
return (
<div className="rounded-xl border border-border/60 bg-card/50 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium">Use an existing browser session</p>
<p className="text-xs text-muted-foreground">
If cookie import is not the right fit, Computer Use can control local apps and may use
existing logged-in browser sessions where applicable. Install the Computer Use skill;
macOS also requires privacy permissions.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={onOpenComputerUse}
className="shrink-0 gap-1.5 self-start"
>
<MousePointerClick className="size-3.5" />
Open Computer Use
</Button>
</div>
</div>
)
}
@@ -0,0 +1,25 @@
export function BrowserUseEnableSwitch({
enabled,
onToggle
}: {
enabled: boolean
onToggle: () => void
}): React.JSX.Element {
return (
<button
role="switch"
aria-checked={enabled}
aria-label="Enable Agent Browser Use"
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
enabled ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
enabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
)
}
@@ -1,6 +1,5 @@
/* eslint-disable max-lines -- Why: Browser Use setup keeps enablement, CLI registration, skill install, cookie import, examples, and interaction tracking in one pane so the three-step setup state stays coherent. */
import { useCallback, useEffect, useState } from 'react'
import { Import, Loader2, MousePointerClick } from 'lucide-react'
import { Import, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
import {
@@ -37,6 +36,8 @@ import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
import { BROWSER_USE_PANE_SEARCH_ENTRIES } from './browser-use-search'
import { BrowserUseExamples } from './BrowserUseExamples'
import { BrowserUseComputerUseNotice } from './BrowserUseComputerUseNotice'
import { BrowserUseEnableSwitch } from './BrowserUseEnableSwitch'
import { StepBadge } from './BrowserUseStepBadge'
import { BrowserUseSkillStep } from './BrowserUseSkillStep'
@@ -188,24 +189,6 @@ export function BrowserUseSetup({
? `${BROWSER_FAMILY_LABELS[defaultProfile.source.browserFamily] ?? defaultProfile.source.browserFamily}${defaultProfile.source.profileName ? ` (${defaultProfile.source.profileName})` : ''}`
: null
const toggleSwitch = (
<button
role="switch"
aria-checked={browserUseEnabled}
aria-label="Enable Agent Browser Use"
onClick={() => toggleBrowserUse(!browserUseEnabled)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
browserUseEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
browserUseEnabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
)
if (!browserUseEnabled) {
return (
<div className="flex items-center justify-between gap-4 py-2">
@@ -215,7 +198,10 @@ export function BrowserUseSetup({
Let coding agents drive this browser with your logins.
</p>
</div>
{toggleSwitch}
<BrowserUseEnableSwitch
enabled={browserUseEnabled}
onToggle={() => toggleBrowserUse(!browserUseEnabled)}
/>
</div>
)
}
@@ -239,33 +225,15 @@ export function BrowserUseSetup({
>
{completedCount}/3
</span>
{toggleSwitch}
<BrowserUseEnableSwitch
enabled={browserUseEnabled}
onToggle={() => toggleBrowserUse(!browserUseEnabled)}
/>
</div>
</div>
{onOpenComputerUse ? (
<div className="rounded-xl border border-border/60 bg-card/50 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium">Use an existing browser session</p>
<p className="text-xs text-muted-foreground">
If cookie import is not the right fit, Computer Use can control local apps and may
use existing logged-in browser sessions where applicable. Install the Computer Use
skill; macOS also requires privacy permissions.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={onOpenComputerUse}
className="shrink-0 gap-1.5 self-start"
>
<MousePointerClick className="size-3.5" />
Open Computer Use
</Button>
</div>
</div>
<BrowserUseComputerUseNotice onOpenComputerUse={onOpenComputerUse} />
) : null}
{showStep1 ? (
@@ -0,0 +1,78 @@
import { LoaderCircle } from 'lucide-react'
import type { PtyManagementSession } from '../../../../preload/api-types'
import { Button } from '../ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
type ManageSessionKillDialogProps = {
session: PtyManagementSession | null
isBusy: boolean
onCancel: () => void
onConfirm: () => void
}
export function ManageSessionKillDialog({
session,
isBusy,
onCancel,
onConfirm
}: ManageSessionKillDialogProps): React.JSX.Element {
return (
<Dialog
open={session !== null}
onOpenChange={(open) => {
if (open) {
return
}
// Why: destructive terminal mutations should keep their progress
// dialog open until the daemon responds, matching other confirm flows.
if (isBusy) {
return
}
onCancel()
}}
>
<DialogContent
className="max-w-md"
showCloseButton={!isBusy}
onPointerDownOutside={(event) => {
if (isBusy) {
event.preventDefault()
}
}}
onEscapeKeyDown={(event) => {
if (isBusy) {
event.preventDefault()
}
}}
>
{session ? (
<>
<DialogHeader>
<DialogTitle className="text-sm">Kill this session?</DialogTitle>
<DialogDescription className="text-xs">
Force-quits <span className="font-medium text-foreground">{session.sessionId}</span>
. Any unsaved work in that pane is lost. This can&apos;t be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isBusy}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm} disabled={isBusy}>
{isBusy ? <LoaderCircle className="size-4 animate-spin" /> : null}
{isBusy ? 'Killing…' : 'Kill session'}
</Button>
</DialogFooter>
</>
) : null}
</DialogContent>
</Dialog>
)
}
@@ -1,20 +1,8 @@
/* eslint-disable max-lines -- Why: this pane owns all admin controls for the
pty daemon (list, kill-all, kill-one, restart) plus the confirmation
dialog and table. Splitting would scatter the shared action state and
toast copy across files without a cleaner ownership seam. */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { LoaderCircle, RefreshCw, RotateCw, Trash2, X } from 'lucide-react'
import { toast } from 'sonner'
import type { PtyManagementSession } from '../../../../preload/api-types'
import { Button } from '../ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { SearchableSetting } from './SearchableSetting'
import { MANAGE_SESSIONS_SEARCH_ENTRIES } from './terminal-search'
@@ -23,11 +11,10 @@ import { useAppStore } from '../../store'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions'
import { ManageSessionKillDialog } from './ManageSessionKillDialog'
type ConfirmKind = 'killOne'
type PendingConfirm = { kind: 'killOne'; session: PtyManagementSession } | null
// Why: mirror the status-bar SessionsStatusSegment label style — last two
// path segments joined by the platform separator, no ellipsis prefix. This
// keeps the Manage Sessions table visually consistent with how Orca labels
@@ -73,28 +60,6 @@ function formatState(session: PtyManagementSession): string {
return session.state
}
function getConfirmCopy(confirm: PendingConfirm): {
title: string
description: React.ReactNode
confirmLabel: string
busyLabel: string
} | null {
if (!confirm) {
return null
}
return {
title: 'Kill this session?',
description: (
<>
Force-quits <span className="font-medium text-foreground">{confirm.session.sessionId}</span>
. Any unsaved work in that pane is lost. This can&apos;t be undone.
</>
),
confirmLabel: 'Kill session',
busyLabel: 'Killing…'
}
}
export function ManageSessionsSection(): React.JSX.Element {
const activeRuntimeEnvironmentId = useAppStore(
(s) => s.settings?.activeRuntimeEnvironmentId ?? null
@@ -102,7 +67,7 @@ export function ManageSessionsSection(): React.JSX.Element {
const [sessions, setSessions] = useState<PtyManagementSession[]>([])
const [isRefreshing, setIsRefreshing] = useState(true)
const [hasLoadedOnce, setHasLoadedOnce] = useState(false)
const [confirm, setConfirm] = useState<PendingConfirm>(null)
const [pendingKillSession, setPendingKillSession] = useState<PtyManagementSession | null>(null)
const [busyKind, setBusyKind] = useState<ConfirmKind | null>(null)
// Why: optimistic UI snapshot for Kill-all rollback. The design doc calls
// for emptying the list immediately and restoring it on error so users see
@@ -265,25 +230,24 @@ export function ManageSessionsSection(): React.JSX.Element {
mutationInFlight.current = false
if (isMounted.current) {
setBusyKind(null)
setConfirm(null)
setPendingKillSession(null)
}
}
},
[refresh]
)
// Why: do NOT clear `confirm` here — the dialog must stay open for the
// Why: do NOT clear `pendingKillSession` here — the dialog must stay open for the
// duration of the mutation so the spinner + busyLabel render and the
// anti-dismiss guard can actually hold. handleKillOne's `finally` clears
// `confirm` when the op resolves.
// `pendingKillSession` when the op resolves.
const runConfirmed = useCallback(() => {
if (!confirm) {
if (!pendingKillSession) {
return
}
void handleKillOne(confirm.session)
}, [confirm, handleKillOne])
void handleKillOne(pendingKillSession)
}, [pendingKillSession, handleKillOne])
const copy = useMemo(() => getConfirmCopy(confirm), [confirm])
const isBusy = busyKind !== null || daemonActions.isBusy
if (activeRuntimeEnvironmentId?.trim()) {
@@ -465,7 +429,7 @@ export function ManageSessionsSection(): React.JSX.Element {
// without stopPropagation the kill click would
// also fire handleNavigate and dismiss Settings.
e.stopPropagation()
setConfirm({ kind: 'killOne', session })
setPendingKillSession(session)
}}
disabled={isBusy}
aria-label={`Kill session ${session.sessionId}`}
@@ -484,55 +448,12 @@ export function ManageSessionsSection(): React.JSX.Element {
</div>
</SearchableSetting>
<Dialog
open={confirm !== null}
onOpenChange={(open) => {
if (open) {
return
}
// Why: don't let overlay click / Escape / close-button close the
// dialog mid-mutation — the confirm button is the canonical exit.
// Matches the "can't cancel a destructive op in flight" convention
// in other confirm dialogs across the app.
if (isBusy) {
return
}
setConfirm(null)
}}
>
<DialogContent
className="max-w-md"
showCloseButton={!isBusy}
onPointerDownOutside={(e) => {
if (isBusy) {
e.preventDefault()
}
}}
onEscapeKeyDown={(e) => {
if (isBusy) {
e.preventDefault()
}
}}
>
{copy ? (
<>
<DialogHeader>
<DialogTitle className="text-sm">{copy.title}</DialogTitle>
<DialogDescription className="text-xs">{copy.description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirm(null)} disabled={isBusy}>
Cancel
</Button>
<Button variant="destructive" onClick={runConfirmed} disabled={isBusy}>
{isBusy ? <LoaderCircle className="size-4 animate-spin" /> : null}
{isBusy ? copy.busyLabel : copy.confirmLabel}
</Button>
</DialogFooter>
</>
) : null}
</DialogContent>
</Dialog>
<ManageSessionKillDialog
session={pendingKillSession}
isBusy={isBusy}
onCancel={() => setPendingKillSession(null)}
onConfirm={runConfirmed}
/>
<DaemonActionDialog api={daemonActions} />
</section>
)
@@ -0,0 +1,48 @@
import type { ReactNode } from 'react'
import { Label } from '../ui/label'
export type NotificationSettingToggleProps = {
label: string
description: string
checked: boolean
onToggle: () => void
disabled?: boolean
icon?: ReactNode
}
export function NotificationSettingToggle({
label,
description,
checked,
onToggle,
disabled = false,
icon
}: NotificationSettingToggleProps): React.JSX.Element {
return (
<div className="flex items-center justify-between gap-4 py-2">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
{icon}
<Label>{label}</Label>
</div>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<button
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors ${
checked ? 'bg-foreground' : 'bg-muted-foreground/30'
} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
checked ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
)
}
@@ -1,5 +1,4 @@
/* eslint-disable max-lines -- Why: notification settings keeps delivery toggles, system test feedback, and sound selection on one settings merge path. */
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import type { GlobalSettings } from '../../../../shared/types'
import { Button } from '../ui/button'
@@ -18,6 +17,7 @@ import { BellRing, Bot, FileAudio, Siren, Upload, Volume2 } from 'lucide-react'
import { getNotificationSoundOptions } from '@/components/notification-sound-options'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '@/store'
import { NotificationSettingToggle } from './NotificationSettingToggle'
export { NOTIFICATIONS_PANE_SEARCH_ENTRIES } from './notifications-search'
type NotificationsPaneProps = {
@@ -265,7 +265,7 @@ export function NotificationsPane({
return (
<div className="space-y-1">
<SettingToggle
<NotificationSettingToggle
label="Enable Notifications"
description="Native system notifications for background events."
checked={notificationSettings.enabled}
@@ -279,7 +279,7 @@ export function NotificationsPane({
<Separator />
<SettingToggle
<NotificationSettingToggle
icon={<Bot className="size-4" />}
label="Agent Task Complete"
description="A coding agent finishes and becomes idle."
@@ -292,7 +292,7 @@ export function NotificationsPane({
}
/>
<SettingToggle
<NotificationSettingToggle
icon={<Siren className="size-4" />}
label="Terminal Bell"
description="A background terminal emits a bell character."
@@ -375,7 +375,7 @@ export function NotificationsPane({
<Separator />
<SettingToggle
<NotificationSettingToggle
label="Suppress While Focused"
description="Skip notifications when the triggering worktree is already visible."
checked={notificationSettings.suppressWhenFocused}
@@ -402,49 +402,3 @@ export function NotificationsPane({
</div>
)
}
export type SettingToggleProps = {
label: string
description: string
checked: boolean
onToggle: () => void
disabled?: boolean
icon?: ReactNode
}
export function SettingToggle({
label,
description,
checked,
onToggle,
disabled = false,
icon
}: SettingToggleProps): React.JSX.Element {
return (
<div className="flex items-center justify-between gap-4 py-2">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
{icon}
<Label>{label}</Label>
</div>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<button
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors ${
checked ? 'bg-foreground' : 'bg-muted-foreground/30'
} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
checked ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
)
}
@@ -0,0 +1,55 @@
import { Check, Copy } from 'lucide-react'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
export function GeneratedUrlRow({
label,
description,
value,
copied,
onCopy
}: {
label: string
description?: string
value: string
copied: boolean
onCopy: () => void
}): React.JSX.Element {
return (
<div className="space-y-1">
<Label>{label}</Label>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border/60 bg-background/70 px-2 py-1.5">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
{value}
</code>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onCopy}
aria-label={`Copy ${label}`}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</Button>
</div>
</div>
)
}
export function UnavailableUrlRow({
label,
description
}: {
label: string
description: string
}): React.JSX.Element {
return (
<div className="space-y-1">
<Label>{label}</Label>
<div className="rounded-md border border-border/60 px-2 py-1.5">
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
)
}
@@ -1,7 +1,4 @@
/* eslint-disable max-lines -- Why: the generated URL cache, grant list, and
settings form stay together so revocation and cache invalidation remain
auditable. */
import { Check, Copy, Loader2, RefreshCw } from 'lucide-react'
import { Loader2, RefreshCw } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useMountedRef } from '@/hooks/useMountedRef'
@@ -11,6 +8,7 @@ import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { GeneratedUrlRow, UnavailableUrlRow } from './RuntimePairingGeneratedUrlRows'
import { RuntimeAccessGrantList } from './RuntimeAccessGrantList'
const LOOPBACK_ADDRESS = '127.0.0.1'
@@ -31,58 +29,6 @@ const runtimePairingUrlCache: {
runtimePairingDeviceId: null
}
function GeneratedUrlRow({
label,
description,
value,
copied,
onCopy
}: {
label: string
description?: string
value: string
copied: boolean
onCopy: () => void
}): React.JSX.Element {
return (
<div className="space-y-1">
<Label>{label}</Label>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border/60 bg-background/70 px-2 py-1.5">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
{value}
</code>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onCopy}
aria-label={`Copy ${label}`}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</Button>
</div>
</div>
)
}
function UnavailableUrlRow({
label,
description
}: {
label: string
description: string
}): React.JSX.Element {
return (
<div className="space-y-1">
<Label>{label}</Label>
<div className="rounded-md border border-border/60 px-2 py-1.5">
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
)
}
type RuntimePairingUrlGeneratorProps = {
framed?: boolean
showHeader?: boolean
@@ -1,5 +1,3 @@
/* eslint-disable max-lines -- Why: terminal visual controls stay together under Appearance so
search, previews, and the Ghostty import flow move as one user-facing surface. */
import { useState } from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import {
@@ -13,9 +11,7 @@ import {
fontFamilyHasKnownLigatures,
resolveTerminalLigaturesEnabled
} from '../../../../shared/terminal-ligatures'
import { Minus, Plus } from 'lucide-react'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import {
FontAutocomplete,
NumberField,
@@ -40,6 +36,7 @@ import {
import { DarkTerminalThemeSection, LightTerminalThemeSection } from './TerminalThemeSections'
import { TerminalWindowSection } from './TerminalWindowSection'
import { TerminalSettingsPreview } from './TerminalSettingsPreview'
import { TerminalFontSizeSetting } from './TerminalFontSizeSetting'
import { GhosttyImportModal } from './GhosttyImportModal'
import type { UseGhosttyImportReturn } from './useGhosttyImport'
import ghosttyIcon from '../../../../../resources/ghostty.svg'
@@ -88,56 +85,7 @@ export function TerminalAppearanceSection({
</div>
<div className="divide-y divide-border/40">
<SearchableSetting
title="Font Size"
description="Default terminal font size for new panes and live updates."
keywords={['terminal', 'typography', 'text size']}
>
<SettingsRow
label="Font Size"
description="Default terminal font size for new panes and live updates."
control={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon-sm"
onClick={() => {
const next = Math.max(10, settings.terminalFontSize - 1)
updateSettings({ terminalFontSize: next })
}}
disabled={settings.terminalFontSize <= 10}
>
<Minus className="size-3" />
</Button>
<Input
type="number"
min={10}
max={24}
value={settings.terminalFontSize}
onChange={(e) => {
const value = parseInt(e.target.value, 10)
if (!Number.isNaN(value) && value >= 10 && value <= 24) {
updateSettings({ terminalFontSize: value })
}
}}
className="w-14 text-center tabular-nums"
/>
<Button
variant="outline"
size="icon-sm"
onClick={() => {
const next = Math.min(24, settings.terminalFontSize + 1)
updateSettings({ terminalFontSize: next })
}}
disabled={settings.terminalFontSize >= 24}
>
<Plus className="size-3" />
</Button>
<span className="text-xs text-muted-foreground">px</span>
</div>
}
/>
</SearchableSetting>
<TerminalFontSizeSetting settings={settings} updateSettings={updateSettings} />
<SearchableSetting
title="Font Family"
@@ -0,0 +1,67 @@
import { Minus, Plus } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/types'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { SettingsRow } from './SettingsFormControls'
import { SearchableSetting } from './SearchableSetting'
export function TerminalFontSizeSetting({
settings,
updateSettings
}: {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}): React.JSX.Element {
return (
<SearchableSetting
title="Font Size"
description="Default terminal font size for new panes and live updates."
keywords={['terminal', 'typography', 'text size']}
>
<SettingsRow
label="Font Size"
description="Default terminal font size for new panes and live updates."
control={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon-sm"
onClick={() => {
const next = Math.max(10, settings.terminalFontSize - 1)
updateSettings({ terminalFontSize: next })
}}
disabled={settings.terminalFontSize <= 10}
>
<Minus className="size-3" />
</Button>
<Input
type="number"
min={10}
max={24}
value={settings.terminalFontSize}
onChange={(e) => {
const value = parseInt(e.target.value, 10)
if (!Number.isNaN(value) && value >= 10 && value <= 24) {
updateSettings({ terminalFontSize: value })
}
}}
className="w-14 text-center tabular-nums"
/>
<Button
variant="outline"
size="icon-sm"
onClick={() => {
const next = Math.min(24, settings.terminalFontSize + 1)
updateSettings({ terminalFontSize: next })
}}
disabled={settings.terminalFontSize >= 24}
>
<Plus className="size-3" />
</Button>
<span className="text-xs text-muted-foreground">px</span>
</div>
}
/>
</SearchableSetting>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,252 @@
import type { Dispatch, SetStateAction } from 'react'
import { CloneStep } from './AddRepoSteps'
import { RemoteStep } from './AddRepoRemoteStep'
import { CreateStep } from './AddRepoCreateStep'
import { SetupStep } from './AddRepoSetupStep'
import { AddRepoLocalStartStep, AddRepoServerPathStartStep } from './AddRepoStartSteps'
import { AddRepoNestedImportStep } from './AddRepoNestedImportStep'
import type { AddRepoDialogStep } from './add-repo-dialog-types'
import type { NestedRepoScanResult } from '../../../../shared/types'
import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types'
type AddRepoDialogStepContentProps = {
step: AddRepoDialogStep
isRuntimeEnvironmentActive: boolean
repoCount: number
isAdding: boolean
addProjectBusyLabel: string | null
nestedScanInProgress: boolean
nestedScanId: string | null
serverPath: string
isAddingServerPath: boolean
cloneUrl: string
cloneDestination: string
cloneError: string | null
cloneProgress: { phase: string; percent: number } | null
isCloning: boolean
sshTargets: (SshTarget & { state?: SshConnectionState })[]
selectedTargetId: string | null
remotePath: string
remoteError: string | null
isAddingRemote: boolean
isScanningRemoteNested: boolean
nestedScan: NestedRepoScanResult | null
nestedSelectedPaths: Set<string>
nestedGroupName: string
createName: string
createParent: string
createKind: 'git' | 'folder'
createError: string | null
isCreating: boolean
addedRepoName: string
hiddenWorktreeCount: number
primaryBranchName: string | null
onBrowse: () => void
onOpenCloneStep: () => void
onOpenCreateStep: () => void
onOpenRemoteStep: () => void
onStopNestedScan: () => void
onServerPathChange: (path: string) => void
onAddServerPath: (kind: 'git' | 'folder') => void
onSelectTarget: (id: string) => void
onRemotePathChange: (path: string) => void
onAddRemoteRepo: () => void
onOpenSshSettings: () => void
onConnectTarget: (id: string) => Promise<void>
onStopRemoteNestedScan: () => void
onCloneUrlChange: (url: string) => void
onCloneDestinationChange: (destination: string) => void
onPickCloneDestination: () => void
onClone: () => void
onNestedGroupNameChange: (name: string) => void
onNestedSelectedPathsChange: Dispatch<SetStateAction<Set<string>>>
onNestedBack: () => void
onImportNestedRepos: (mode: 'group' | 'separate') => void
onCreateNameChange: (name: string) => void
onCreateParentChange: (parent: string) => void
onCreateKindChange: (kind: 'git' | 'folder') => void
onPickCreateParent: () => void
onCreate: () => void
onStartPrimaryWorktree: () => void
onUseExistingWorktrees: () => void
onCreateWorktree: (name?: string) => void
onConfigureRepo: () => void
}
export function AddRepoDialogStepContent({
step,
isRuntimeEnvironmentActive,
repoCount,
isAdding,
addProjectBusyLabel,
nestedScanInProgress,
nestedScanId,
serverPath,
isAddingServerPath,
cloneUrl,
cloneDestination,
cloneError,
cloneProgress,
isCloning,
sshTargets,
selectedTargetId,
remotePath,
remoteError,
isAddingRemote,
isScanningRemoteNested,
nestedScan,
nestedSelectedPaths,
nestedGroupName,
createName,
createParent,
createKind,
createError,
isCreating,
addedRepoName,
hiddenWorktreeCount,
primaryBranchName,
onBrowse,
onOpenCloneStep,
onOpenCreateStep,
onOpenRemoteStep,
onStopNestedScan,
onServerPathChange,
onAddServerPath,
onSelectTarget,
onRemotePathChange,
onAddRemoteRepo,
onOpenSshSettings,
onConnectTarget,
onStopRemoteNestedScan,
onCloneUrlChange,
onCloneDestinationChange,
onPickCloneDestination,
onClone,
onNestedGroupNameChange,
onNestedSelectedPathsChange,
onNestedBack,
onImportNestedRepos,
onCreateNameChange,
onCreateParentChange,
onCreateKindChange,
onPickCreateParent,
onCreate,
onStartPrimaryWorktree,
onUseExistingWorktrees,
onCreateWorktree,
onConfigureRepo
}: AddRepoDialogStepContentProps): React.JSX.Element {
if (step === 'add' && isRuntimeEnvironmentActive) {
return (
<AddRepoServerPathStartStep
serverPath={serverPath}
isAddingServerPath={isAddingServerPath}
addProjectBusyLabel={addProjectBusyLabel}
onServerPathChange={onServerPathChange}
onAddServerPath={onAddServerPath}
onOpenCloneStep={onOpenCloneStep}
onOpenCreateStep={onOpenCreateStep}
/>
)
}
if (step === 'add') {
return (
<AddRepoLocalStartStep
repoCount={repoCount}
isAdding={isAdding}
addProjectBusyLabel={addProjectBusyLabel}
nestedScanInProgress={nestedScanInProgress}
nestedScanId={nestedScanId}
onBrowse={onBrowse}
onOpenCloneStep={onOpenCloneStep}
onOpenRemoteStep={onOpenRemoteStep}
onOpenCreateStep={onOpenCreateStep}
onStopNestedScan={onStopNestedScan}
/>
)
}
if (step === 'remote') {
return (
<RemoteStep
sshTargets={sshTargets}
selectedTargetId={selectedTargetId}
remotePath={remotePath}
remoteError={remoteError}
isAddingRemote={isAddingRemote}
isScanningNested={isScanningRemoteNested}
onSelectTarget={onSelectTarget}
onRemotePathChange={onRemotePathChange}
onAdd={onAddRemoteRepo}
onOpenSshSettings={onOpenSshSettings}
onConnectTarget={onConnectTarget}
onStopNestedScan={onStopRemoteNestedScan}
/>
)
}
if (step === 'clone') {
return (
<CloneStep
cloneUrl={cloneUrl}
cloneDestination={cloneDestination}
cloneError={cloneError}
cloneProgress={cloneProgress}
isCloning={isCloning}
disableDestinationPicker={isRuntimeEnvironmentActive}
onUrlChange={onCloneUrlChange}
onDestChange={onCloneDestinationChange}
onPickDestination={onPickCloneDestination}
onClone={onClone}
/>
)
}
if (step === 'nested' && nestedScan) {
return (
<AddRepoNestedImportStep
scan={nestedScan}
groupName={nestedGroupName}
selectedPaths={nestedSelectedPaths}
isAdding={isAdding}
scanInProgress={nestedScanInProgress}
onGroupNameChange={onNestedGroupNameChange}
onSelectedPathsChange={onNestedSelectedPathsChange}
onBack={onNestedBack}
onImport={onImportNestedRepos}
onStopScan={onStopNestedScan}
/>
)
}
if (step === 'create') {
return (
<CreateStep
createName={createName}
createParent={createParent}
createKind={createKind}
createError={createError}
isCreating={isCreating}
manualParentEntry={isRuntimeEnvironmentActive}
onNameChange={onCreateNameChange}
onParentChange={onCreateParentChange}
onKindChange={onCreateKindChange}
onPickParent={onPickCreateParent}
onCreate={onCreate}
/>
)
}
return (
<SetupStep
repoName={addedRepoName}
hiddenWorktreeCount={hiddenWorktreeCount}
primaryBranchName={primaryBranchName ?? undefined}
onStartPrimaryWorktree={onStartPrimaryWorktree}
onUseExistingWorktrees={onUseExistingWorktrees}
onCreateWorktree={onCreateWorktree}
onConfigureRepo={onConfigureRepo}
/>
)
}
@@ -0,0 +1,135 @@
import { type Dispatch, type SetStateAction } from 'react'
import { ArrowLeft, CircleStop, FolderTree, Loader2 } from 'lucide-react'
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { NestedRepoTreePreview } from '@/components/repo/NestedRepoTreePreview'
import type { NestedRepoScanResult } from '../../../../shared/types'
import { NestedRepoScanLimitNotice } from '../repo/NestedRepoScanLimitNotice'
type AddRepoNestedImportStepProps = {
scan: NestedRepoScanResult
groupName: string
selectedPaths: Set<string>
isAdding: boolean
scanInProgress: boolean
onGroupNameChange: (value: string) => void
onSelectedPathsChange: Dispatch<SetStateAction<Set<string>>>
onBack: () => void
onImport: (mode: 'group' | 'separate') => void
onStopScan: () => void
}
export function AddRepoNestedImportStep({
scan,
groupName,
selectedPaths,
isAdding,
scanInProgress,
onGroupNameChange,
onSelectedPathsChange,
onBack,
onImport,
onStopScan
}: AddRepoNestedImportStepProps): React.JSX.Element {
return (
<>
<DialogHeader>
<DialogTitle>Import as project group</DialogTitle>
<div className="flex min-w-0 items-center gap-1.5">
{scanInProgress ? <AddRepoNestedImportStopButton onStopScan={onStopScan} /> : null}
<DialogDescription className="min-w-0 truncate">
{`${scanInProgress ? 'Scanning... ' : ''}Found ${scan.repos.length} git ${
scan.repos.length === 1 ? 'repository' : 'repositories'
} in this folder.`}
</DialogDescription>
</div>
</DialogHeader>
<div className="flex min-h-0 min-w-0 max-w-full flex-col gap-3 overflow-hidden pt-1">
<div className="flex min-w-0 max-w-full items-center gap-3 overflow-hidden rounded-md border border-border bg-muted/30 p-3">
<div className="grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
<FolderTree className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
Group under {groupName}
</div>
<div className="truncate text-[11px] text-muted-foreground">{scan.selectedPath}</div>
</div>
</div>
<div className="min-w-0 space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">Group name</label>
<Input
value={groupName}
onChange={(event) => onGroupNameChange(event.target.value)}
disabled={scanInProgress}
className="h-9"
/>
</div>
<NestedRepoTreePreview
scan={scan}
selectedPaths={selectedPaths}
onSelectedPathsChange={onSelectedPathsChange}
disabled={isAdding || scanInProgress}
className="flex-1"
/>
{scanInProgress || scan.truncated || scan.timedOut || scan.stopped ? (
<NestedRepoScanLimitNotice scan={scan} />
) : null}
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button onClick={onBack} disabled={isAdding && !scanInProgress} variant="ghost">
<ArrowLeft className="size-3.5" />
Back
</Button>
<div className="ml-auto flex min-w-0 flex-wrap justify-end gap-2">
<Button
onClick={() => onImport('separate')}
disabled={isAdding || scanInProgress || selectedPaths.size === 0}
variant="outline"
>
Import separately
</Button>
<Button
onClick={() => onImport('group')}
disabled={isAdding || scanInProgress || selectedPaths.size === 0 || !groupName.trim()}
>
Import as project group
</Button>
</div>
</div>
</div>
</>
)
}
function AddRepoNestedImportStopButton({
onStopScan
}: {
onStopScan: () => void
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="group text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:ring-destructive/40"
aria-label="Stop scan"
title="Stop scanning"
onClick={onStopScan}
>
<Loader2 className="size-3.5 animate-spin text-annotation-highlight group-hover:hidden group-focus-visible:hidden" />
<CircleStop className="hidden size-3.5 group-hover:block group-focus-visible:block" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Scanning repositories. Click to stop.
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,151 @@
import { useState } from 'react'
import { CircleStop, FolderOpen, Settings } from 'lucide-react'
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types'
import { RemoteFileBrowser } from './RemoteFileBrowser'
import { SshTargetRow } from './SshTargetRow'
type RemoteStepProps = {
sshTargets: (SshTarget & { state?: SshConnectionState })[]
selectedTargetId: string | null
remotePath: string
remoteError: string | null
isAddingRemote: boolean
isScanningNested?: boolean
onSelectTarget: (id: string) => void
onRemotePathChange: (value: string) => void
onAdd: () => void
onOpenSshSettings: () => void
onConnectTarget: (id: string) => Promise<void>
onStopNestedScan?: () => void
}
export function RemoteStep({
sshTargets,
selectedTargetId,
remotePath,
remoteError,
isAddingRemote,
isScanningNested,
onSelectTarget,
onRemotePathChange,
onAdd,
onOpenSshSettings,
onConnectTarget,
onStopNestedScan
}: RemoteStepProps): React.JSX.Element {
const [browsing, setBrowsing] = useState(false)
if (browsing && selectedTargetId) {
return (
<>
<DialogHeader>
<DialogTitle>Browse remote filesystem</DialogTitle>
<DialogDescription>
Navigate to a directory and click Select to choose it.
</DialogDescription>
</DialogHeader>
<RemoteFileBrowser
targetId={selectedTargetId}
initialPath={remotePath || '~'}
onSelect={(path) => {
onRemotePathChange(path)
setBrowsing(false)
}}
onCancel={() => setBrowsing(false)}
/>
</>
)
}
return (
<>
<DialogHeader>
<DialogTitle>Open remote project</DialogTitle>
<DialogDescription>
Choose a connected SSH target and enter the path to a Git repository.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-1">
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">SSH target</label>
{sshTargets.length === 0 ? (
<div className="space-y-1.5 py-1">
<p className="text-xs text-muted-foreground">No SSH targets configured.</p>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={onOpenSshSettings}
>
<Settings className="size-3.5" />
Add in Settings
</Button>
</div>
) : (
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1 scrollbar-sleek">
{sshTargets.map((target) => (
<SshTargetRow
key={target.id}
target={target}
isSelected={selectedTargetId === target.id}
onSelect={onSelectTarget}
onConnect={onConnectTarget}
/>
))}
</div>
)}
</div>
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">Remote path</label>
<div className="flex gap-2">
<Input
value={remotePath}
onChange={(event) => onRemotePathChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
if (selectedTargetId && remotePath.trim() && !isAddingRemote) {
onAdd()
}
}
}}
placeholder="/home/user/project"
className="h-8 text-xs flex-1"
disabled={isAddingRemote || !selectedTargetId}
/>
<Button
variant="outline"
size="sm"
className="h-8 px-2 shrink-0"
onClick={() => setBrowsing(true)}
disabled={!selectedTargetId || isAddingRemote}
>
<FolderOpen className="size-3.5" />
</Button>
</div>
</div>
{remoteError ? <p className="text-[11px] text-destructive">{remoteError}</p> : null}
<Button
onClick={onAdd}
disabled={!selectedTargetId || !remotePath.trim() || isAddingRemote}
className="w-full"
>
{isAddingRemote ? 'Adding...' : 'Add remote project'}
</Button>
{isScanningNested ? (
<Button variant="outline" className="w-full" onClick={onStopNestedScan}>
<CircleStop className="size-3.5" />
Stop scan
</Button>
) : null}
</div>
</>
)
}
@@ -0,0 +1,253 @@
import { CircleStop, FolderOpen, Globe, Lightbulb, Loader2, Monitor } from 'lucide-react'
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
type AddRepoNestedScanProgressNoticeProps = {
busyLabel: string
nestedScanInProgress: boolean
nestedScanId: string | null
onStopNestedScan: () => void
}
function AddRepoNestedScanProgressNotice({
busyLabel,
nestedScanInProgress,
nestedScanId,
onStopNestedScan
}: AddRepoNestedScanProgressNoticeProps): React.JSX.Element {
return (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 shrink-0 animate-spin" />
<span className="min-w-0 flex-1">{busyLabel}</span>
{nestedScanInProgress && nestedScanId ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="group text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:ring-destructive/40"
aria-label="Stop scan"
title="Stop scanning"
onClick={onStopNestedScan}
>
<Loader2 className="size-3.5 animate-spin text-annotation-highlight group-hover:hidden group-focus-visible:hidden" />
<CircleStop className="hidden size-3.5 group-hover:block group-focus-visible:block" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Scanning repositories. Click to stop.
</TooltipContent>
</Tooltip>
) : null}
</div>
)
}
type AddRepoServerPathStartStepProps = {
serverPath: string
isAddingServerPath: boolean
addProjectBusyLabel: string | null
onServerPathChange: (path: string) => void
onAddServerPath: (kind: 'git' | 'folder') => void
onOpenCloneStep: () => void
onOpenCreateStep: () => void
}
export function AddRepoServerPathStartStep({
serverPath,
isAddingServerPath,
addProjectBusyLabel,
onServerPathChange,
onAddServerPath,
onOpenCloneStep,
onOpenCreateStep
}: AddRepoServerPathStartStepProps): React.JSX.Element {
return (
<>
<DialogHeader>
<DialogTitle>Add a server project</DialogTitle>
<DialogDescription>
Add a Git repository or folder that already exists on the selected runtime server.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-2">
<div className="space-y-1">
<label
htmlFor="server-project-path"
className="text-[11px] font-medium text-muted-foreground block"
>
Server path
</label>
<Input
id="server-project-path"
value={serverPath}
onChange={(event) => onServerPathChange(event.target.value)}
placeholder="/home/user/project"
className="h-11 text-sm font-mono"
disabled={isAddingServerPath}
autoFocus
spellCheck={false}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
onClick={() => onAddServerPath('git')}
disabled={!serverPath.trim() || isAddingServerPath}
className="h-10"
>
Add Git Project
</Button>
<Button
onClick={() => onAddServerPath('folder')}
disabled={!serverPath.trim() || isAddingServerPath}
variant="outline"
className="h-10"
>
Open as Folder
</Button>
</div>
{isAddingServerPath && addProjectBusyLabel ? (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 shrink-0 animate-spin" />
<span>{addProjectBusyLabel}</span>
</div>
) : null}
<div className="flex items-center justify-center gap-4 pt-1">
<button
type="button"
onClick={onOpenCloneStep}
disabled={isAddingServerPath}
className="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-default disabled:opacity-40"
>
Clone into server path
</button>
<button
type="button"
onClick={onOpenCreateStep}
disabled={isAddingServerPath}
className="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-default disabled:opacity-40"
>
Create on server
</button>
</div>
</div>
</>
)
}
type AddRepoLocalStartStepProps = {
repoCount: number
isAdding: boolean
addProjectBusyLabel: string | null
nestedScanInProgress: boolean
nestedScanId: string | null
onBrowse: () => void
onOpenCloneStep: () => void
onOpenRemoteStep: () => void
onOpenCreateStep: () => void
onStopNestedScan: () => void
}
export function AddRepoLocalStartStep({
repoCount,
isAdding,
addProjectBusyLabel,
nestedScanInProgress,
nestedScanId,
onBrowse,
onOpenCloneStep,
onOpenRemoteStep,
onOpenCreateStep,
onStopNestedScan
}: AddRepoLocalStartStepProps): React.JSX.Element {
return (
<>
<DialogHeader>
<DialogTitle>Add a project</DialogTitle>
<DialogDescription>
{repoCount === 0
? 'Add a project to get started with Orca.'
: 'Add another project to manage with Orca.'}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-3 gap-3 pt-2">
<Button
onClick={onBrowse}
disabled={isAdding}
variant="outline"
className="h-auto py-5 px-2 flex flex-col items-center gap-2 text-center border-border/80"
>
<FolderOpen className="size-6 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Browse folder</p>
<p className="text-[11px] text-muted-foreground font-normal mt-0.5">
Local Git project or folder
</p>
</div>
</Button>
<Button
onClick={onOpenCloneStep}
disabled={isAdding}
variant="outline"
className="h-auto py-5 px-2 flex flex-col items-center gap-2 text-center border-border/80"
>
<Globe className="size-6 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Clone from URL</p>
<p className="text-[11px] text-muted-foreground font-normal mt-0.5">
Remote Git repository
</p>
</div>
</Button>
<Button
onClick={onOpenRemoteStep}
disabled={isAdding}
variant="outline"
className="h-auto py-5 px-2 flex flex-col items-center gap-2 text-center border-border/80"
>
<Monitor className="size-6 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Remote project</p>
<p className="text-[11px] text-muted-foreground font-normal mt-0.5">
SSH connected target
</p>
</div>
</Button>
</div>
{isAdding && addProjectBusyLabel ? (
<AddRepoNestedScanProgressNotice
busyLabel={addProjectBusyLabel}
nestedScanInProgress={nestedScanInProgress}
nestedScanId={nestedScanId}
onStopNestedScan={onStopNestedScan}
/>
) : null}
<div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs text-muted-foreground">
<span className="grid size-6 shrink-0 place-items-center rounded-md border border-border bg-background text-foreground">
<Lightbulb className="size-3.5" />
</span>
<span>Want to import many repos at once? Select the parent folder.</span>
</div>
<div className="flex items-center justify-center pt-1">
<button
type="button"
onClick={onOpenCreateStep}
disabled={isAdding}
className="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-default disabled:opacity-40"
>
Or start a new project from scratch
</button>
</div>
</>
)
}
@@ -0,0 +1,59 @@
import { ArrowLeft } from 'lucide-react'
import type { AddRepoDialogStep } from './add-repo-dialog-types'
type AddRepoStepIndicatorProps = {
step: AddRepoDialogStep
isInputStep: boolean
isAdding: boolean
onBack: () => void
onSetupBack: () => void
}
export function AddRepoStepIndicator({
step,
isInputStep,
isAdding,
onBack,
onSetupBack
}: AddRepoStepIndicatorProps): React.JSX.Element {
return (
<div className="flex items-center justify-center -mt-1">
{(step === 'clone' || step === 'remote' || step === 'create') && (
<button
className="absolute left-6 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={onBack}
>
<ArrowLeft className="size-3" />
Back
</button>
)}
{step === 'nested' && (
<button
className="absolute left-6 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer disabled:cursor-default disabled:opacity-40"
disabled={isAdding}
onClick={onBack}
>
<ArrowLeft className="size-3" />
Back
</button>
)}
{step === 'setup' && (
<button
className="absolute left-6 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={onSetupBack}
>
<ArrowLeft className="size-3" />
Add another project
</button>
)}
<div className="flex items-center gap-1.5">
<div
className={`size-1.5 rounded-full transition-colors ${isInputStep ? 'bg-foreground' : 'bg-muted-foreground/30'}`}
/>
<div
className={`size-1.5 rounded-full transition-colors ${step === 'setup' ? 'bg-foreground' : 'bg-muted-foreground/30'}`}
/>
</div>
</div>
)
}
@@ -1,15 +1,10 @@
/* eslint-disable max-lines -- Why: AddRepoDialog step views are already split from the parent,
and keeping clone/remote/setup step props together avoids a larger wizard refactor in this
leak fix. */
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { CircleStop, Folder, FolderOpen, Settings } from 'lucide-react'
import { Folder } from 'lucide-react'
import { useAppStore } from '@/store'
import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { RemoteFileBrowser } from './RemoteFileBrowser'
import { SshTargetRow } from './SshTargetRow'
import { useMountedRef } from '@/hooks/useMountedRef'
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
import type { NestedRepoScanResult, Repo } from '../../../../shared/types'
@@ -253,151 +248,6 @@ export function useRemoteRepo(
}
}
// ── Remote step ──────────────────────────────────────────────────────
type RemoteStepProps = {
sshTargets: (SshTarget & { state?: SshConnectionState })[]
selectedTargetId: string | null
remotePath: string
remoteError: string | null
isAddingRemote: boolean
isScanningNested?: boolean
onSelectTarget: (id: string) => void
onRemotePathChange: (value: string) => void
onAdd: () => void
onOpenSshSettings: () => void
onConnectTarget: (id: string) => Promise<void>
onStopNestedScan?: () => void
}
export function RemoteStep({
sshTargets,
selectedTargetId,
remotePath,
remoteError,
isAddingRemote,
isScanningNested,
onSelectTarget,
onRemotePathChange,
onAdd,
onOpenSshSettings,
onConnectTarget,
onStopNestedScan
}: RemoteStepProps): React.JSX.Element {
const [browsing, setBrowsing] = useState(false)
if (browsing && selectedTargetId) {
return (
<>
<DialogHeader>
<DialogTitle>Browse remote filesystem</DialogTitle>
<DialogDescription>
Navigate to a directory and click Select to choose it.
</DialogDescription>
</DialogHeader>
<RemoteFileBrowser
targetId={selectedTargetId}
initialPath={remotePath || '~'}
onSelect={(path) => {
onRemotePathChange(path)
setBrowsing(false)
}}
onCancel={() => setBrowsing(false)}
/>
</>
)
}
return (
<>
<DialogHeader>
<DialogTitle>Open remote project</DialogTitle>
<DialogDescription>
Choose a connected SSH target and enter the path to a Git repository.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-1">
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">SSH target</label>
{sshTargets.length === 0 ? (
<div className="space-y-1.5 py-1">
<p className="text-xs text-muted-foreground">No SSH targets configured.</p>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={onOpenSshSettings}
>
<Settings className="size-3.5" />
Add in Settings
</Button>
</div>
) : (
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1 scrollbar-sleek">
{sshTargets.map((target) => (
<SshTargetRow
key={target.id}
target={target}
isSelected={selectedTargetId === target.id}
onSelect={onSelectTarget}
onConnect={onConnectTarget}
/>
))}
</div>
)}
</div>
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">Remote path</label>
<div className="flex gap-2">
<Input
value={remotePath}
onChange={(e) => onRemotePathChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault()
if (selectedTargetId && remotePath.trim() && !isAddingRemote) {
onAdd()
}
}
}}
placeholder="/home/user/project"
className="h-8 text-xs flex-1"
disabled={isAddingRemote || !selectedTargetId}
/>
<Button
variant="outline"
size="sm"
className="h-8 px-2 shrink-0"
onClick={() => setBrowsing(true)}
disabled={!selectedTargetId || isAddingRemote}
>
<FolderOpen className="size-3.5" />
</Button>
</div>
</div>
{remoteError && <p className="text-[11px] text-destructive">{remoteError}</p>}
<Button
onClick={onAdd}
disabled={!selectedTargetId || !remotePath.trim() || isAddingRemote}
className="w-full"
>
{isAddingRemote ? 'Adding...' : 'Add remote project'}
</Button>
{isScanningNested ? (
<Button variant="outline" className="w-full" onClick={onStopNestedScan}>
<CircleStop className="size-3.5" />
Stop scan
</Button>
) : null}
</div>
</>
)
}
// ── Clone step ───────────────────────────────────────────────────────
type CloneStepProps = {
@@ -1,11 +1,6 @@
/* eslint-disable max-lines -- Why: the card metadata hover keeps compact badge rendering,
provider-specific action rows, and markdown note preview together so the sidebar
card has one metadata contract. */
import React from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import {
CircleDot,
ExternalLink,
@@ -25,6 +20,7 @@ import {
WorktreeCardDetailSection,
WorktreeCardDetailSectionContent
} from './WorktreeCardDetailSection'
import { DetailHeader, MetaIconBadge, MetadataActionIcon } from './WorktreeCardMetadataControls'
import {
IssueStateBadge,
LinearStateBadge,
@@ -145,90 +141,6 @@ function ReviewIcon({
)
}
function MetaIconBadge({
label,
children
}: {
label: string
children: React.ReactNode
}): React.JSX.Element {
return (
<span className="inline-flex size-3.5 shrink-0 items-center justify-center text-muted-foreground/70 hover:text-foreground [&>svg]:size-3.5">
{children}
<span className="sr-only">{label}</span>
</span>
)
}
function DetailHeader({
icon,
label,
actions
}: {
icon: React.ReactNode
label: string
actions?: React.ReactNode
}): React.JSX.Element {
return (
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{icon}
<span className="truncate">{label}</span>
</div>
{actions && <div className="flex shrink-0 items-center gap-0.5">{actions}</div>}
</div>
)
}
function MetadataActionIcon({
label,
href,
onClick,
children
}: {
label: string
href?: string
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
children: React.ReactNode
}): React.JSX.Element {
const trigger = href ? (
<Button asChild variant="ghost" size="icon-xs" className="size-6">
<a
href={href}
target="_blank"
rel="noreferrer"
aria-label={label}
onClick={(event) => event.stopPropagation()}
>
{children}
</a>
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-6"
aria-label={label}
onClick={(event) => {
event.stopPropagation()
onClick?.(event)
}}
>
{children}
</Button>
)
return (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
export const WorktreeCardMetaBadges = React.forwardRef<
HTMLDivElement,
WorktreeCardMetaBadgesRootProps
@@ -0,0 +1,86 @@
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
export function MetaIconBadge({
label,
children
}: {
label: string
children: React.ReactNode
}): React.JSX.Element {
return (
<span className="inline-flex size-3.5 shrink-0 items-center justify-center text-muted-foreground/70 hover:text-foreground [&>svg]:size-3.5">
{children}
<span className="sr-only">{label}</span>
</span>
)
}
export function DetailHeader({
icon,
label,
actions
}: {
icon: React.ReactNode
label: string
actions?: React.ReactNode
}): React.JSX.Element {
return (
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{icon}
<span className="truncate">{label}</span>
</div>
{actions ? <div className="flex shrink-0 items-center gap-0.5">{actions}</div> : null}
</div>
)
}
export function MetadataActionIcon({
label,
href,
onClick,
children
}: {
label: string
href?: string
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
children: React.ReactNode
}): React.JSX.Element {
const trigger = href ? (
<Button asChild variant="ghost" size="icon-xs" className="size-6">
<a
href={href}
target="_blank"
rel="noreferrer"
aria-label={label}
onClick={(event) => event.stopPropagation()}
>
{children}
</a>
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-6"
aria-label={label}
onClick={(event) => {
event.stopPropagation()
onClick?.(event)
}}
>
{children}
</Button>
)
return (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,15 @@
export type AddRepoDialogStep = 'add' | 'clone' | 'remote' | 'create' | 'nested' | 'setup'
export function defaultProjectGroupNameForPath(path: string): string {
return (
path
.replace(/[\\/]+$/g, '')
.split(/[\\/]/)
.filter(Boolean)
.at(-1) ?? path
)
}
export function createNestedRepoScanId(): string {
return `nested-repo-scan-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
@@ -0,0 +1,181 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
import type { Repo } from '../../../../shared/types'
import { getCloneDestinationAutoFill } from './clone-defaults'
import type { AddRepoDialogStep } from './add-repo-dialog-types'
export function useAddRepoCloneFlow({
step,
activeRuntimeEnvironmentId,
workspaceDir,
fetchWorktrees,
setStep,
setAddedRepo,
setExistingWorkspaceSource
}: {
step: AddRepoDialogStep
activeRuntimeEnvironmentId: string | null | undefined
workspaceDir: string | null | undefined
fetchWorktrees: (repoId: string) => Promise<unknown>
setStep: (step: AddRepoDialogStep) => void
setAddedRepo: (repo: Repo | null) => void
setExistingWorkspaceSource: (source: AddRepoExistingWorkspaceSource | null) => void
}): {
cloneUrl: string
cloneDestination: string
cloneError: string | null
cloneProgress: { phase: string; percent: number } | null
isCloning: boolean
setCloneUrl: Dispatch<SetStateAction<string>>
setCloneDestination: Dispatch<SetStateAction<string>>
setCloneError: Dispatch<SetStateAction<string | null>>
resetCloneFlow: () => void
handlePickDestination: () => Promise<void>
handleClone: () => Promise<void>
} {
const [cloneUrl, setCloneUrl] = useState('')
const [cloneDestination, setCloneDestination] = useState('')
const [isCloning, setIsCloning] = useState(false)
const [cloneError, setCloneError] = useState<string | null>(null)
const [cloneProgress, setCloneProgress] = useState<{ phase: string; percent: number } | null>(
null
)
// Why: monotonic ID so stale clone callbacks can detect they were superseded.
const cloneGenRef = useRef(0)
// Why: track whether we've already auto-filled for this entry into the clone step,
// so a late settings hydration still gets a chance to set the default.
const cloneStepAutoFilledRef = useRef(false)
useEffect(() => {
if (!isCloning) {
return
}
return window.api.repos.onCloneProgress(setCloneProgress)
}, [isCloning])
const cloneDestinationAutoFill = getCloneDestinationAutoFill({
step,
cloneDestination,
activeRuntimeEnvironmentId,
workspaceDir,
cloneStepAutoFilled: cloneStepAutoFilledRef.current
})
if (step !== 'clone') {
cloneStepAutoFilledRef.current = false
} else if (cloneDestinationAutoFill) {
// Why: late settings hydration should still seed the local clone path,
// but runtime/server clone flows must keep their destination user-entered.
cloneStepAutoFilledRef.current = true
setCloneDestination(cloneDestinationAutoFill.destination)
}
const resetCloneFlow = useCallback((): void => {
cloneGenRef.current++
setCloneUrl('')
setCloneDestination('')
setIsCloning(false)
setCloneError(null)
setCloneProgress(null)
}, [])
const handlePickDestination = useCallback(async (): Promise<void> => {
if (activeRuntimeEnvironmentId?.trim()) {
// Why: the native folder picker returns a client-local path. Runtime
// clone destinations must be typed as server paths.
toast.error('Enter a server path for the clone destination.')
return
}
const gen = cloneGenRef.current
const dir = await window.api.repos.pickDirectory()
if (dir && gen === cloneGenRef.current) {
setCloneDestination(dir)
setCloneError(null)
}
}, [activeRuntimeEnvironmentId])
const handleClone = useCallback(async (): Promise<void> => {
const trimmedUrl = cloneUrl.trim()
if (!trimmedUrl || !cloneDestination.trim()) {
return
}
const gen = ++cloneGenRef.current
setIsCloning(true)
setCloneError(null)
setCloneProgress(null)
try {
const target = getActiveRuntimeTarget(useAppStore.getState().settings)
const repo =
target.kind === 'environment'
? (
await callRuntimeRpc<{ repo: Repo }>(
target,
'repo.clone',
{
url: trimmedUrl,
destination: cloneDestination.trim()
},
{ timeoutMs: 10 * 60_000 }
)
).repo
: ((await window.api.repos.clone({
url: trimmedUrl,
destination: cloneDestination.trim()
})) as Repo)
if (gen !== cloneGenRef.current) {
return
}
toast.success('Repository cloned', { description: repo.displayName })
// Why: eagerly upsert so step 2 finds the repo before the IPC event.
const state = useAppStore.getState()
const existingIdx = state.repos.findIndex((r) => r.id === repo.id)
if (existingIdx === -1) {
useAppStore.setState({ repos: [...state.repos, repo] })
} else {
const updated = [...state.repos]
updated[existingIdx] = repo
useAppStore.setState({ repos: updated })
}
setAddedRepo(repo)
setExistingWorkspaceSource('clone_url')
await fetchWorktrees(repo.id)
if (gen !== cloneGenRef.current) {
return
}
setStep('setup')
} catch (err) {
if (gen !== cloneGenRef.current) {
return
}
const message = err instanceof Error ? err.message : String(err)
setCloneError(message)
} finally {
if (gen === cloneGenRef.current) {
setIsCloning(false)
}
}
}, [
cloneUrl,
cloneDestination,
fetchWorktrees,
setAddedRepo,
setExistingWorkspaceSource,
setStep
])
return {
cloneUrl,
cloneDestination,
cloneError,
cloneProgress,
isCloning,
setCloneUrl,
setCloneDestination,
setCloneError,
resetCloneFlow,
handlePickDestination,
handleClone
}
}
@@ -0,0 +1,207 @@
import { useCallback, useEffect, useRef } from 'react'
import { toast } from 'sonner'
import { track } from '@/lib/telemetry'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import {
buildNestedRepoScanTelemetry,
createNestedRepoTelemetryAttemptId,
type NestedRepoTelemetryRuntimeKind
} from '../../../../shared/nested-repo-telemetry'
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
import type { NestedRepoScanResult, Repo } from '../../../../shared/types'
import { createNestedRepoScanId, type AddRepoDialogStep } from './add-repo-dialog-types'
type ShowNestedRepoReview = (args: {
scan: NestedRepoScanResult
selectedPath: string
connectionId: string | null
attemptId: string
runtimeKind: NestedRepoTelemetryRuntimeKind
inProgress: boolean
scanId: string | null
}) => void
export function useAddRepoLocalFolderFlow({
isOpen,
droppedLocalPath,
activeRuntimeEnvironmentId,
addRepoPath,
closeModal,
fetchWorktrees,
scanNestedRepos,
setActiveNestedScanId,
setNestedScanInProgress,
showNestedRepoReview,
setStep,
setAddedRepo,
setExistingWorkspaceSource,
setIsAdding,
setAddProjectBusyLabel
}: {
isOpen: boolean
droppedLocalPath: string
activeRuntimeEnvironmentId: string | null | undefined
addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise<Repo | null>
closeModal: () => void
fetchWorktrees: (repoId: string) => Promise<unknown>
scanNestedRepos: (
path: string,
connectionId?: string,
controls?: { scanId?: string; onProgress?: (scan: NestedRepoScanResult) => void }
) => Promise<NestedRepoScanResult | null>
setActiveNestedScanId: (scanId: string | null) => void
setNestedScanInProgress: (inProgress: boolean) => void
showNestedRepoReview: ShowNestedRepoReview
setStep: (step: AddRepoDialogStep) => void
setAddedRepo: (repo: Repo | null) => void
setExistingWorkspaceSource: (source: AddRepoExistingWorkspaceSource | null) => void
setIsAdding: (isAdding: boolean) => void
setAddProjectBusyLabel: (label: string | null) => void
}): {
handleBrowse: () => Promise<void>
resetLocalFolderFlow: () => void
} {
const localAddGenRef = useRef(0)
const droppedLocalPathHandledRef = useRef<string | null>(null)
const resetLocalFolderFlow = useCallback((): void => {
localAddGenRef.current++
droppedLocalPathHandledRef.current = null
}, [])
const handleAddLocalPath = useCallback(
async (path: string, source: AddRepoExistingWorkspaceSource): Promise<void> => {
if (activeRuntimeEnvironmentId?.trim()) {
toast.error('Use a server path to add projects from a remote runtime.')
closeModal()
return
}
const gen = ++localAddGenRef.current
setIsAdding(true)
setAddProjectBusyLabel('Scanning for repositories...')
try {
const attemptId = createNestedRepoTelemetryAttemptId()
const scanId = createNestedRepoScanId()
setActiveNestedScanId(scanId)
setNestedScanInProgress(true)
const scan = await scanNestedRepos(path, undefined, {
scanId,
onProgress: (progressScan) => {
if (
gen !== localAddGenRef.current ||
progressScan.selectedPathKind !== 'non_git_folder' ||
progressScan.repos.length === 0
) {
return
}
showNestedRepoReview({
scan: progressScan,
selectedPath: path,
connectionId: null,
attemptId,
runtimeKind: 'local',
inProgress: true,
scanId
})
}
})
if (gen !== localAddGenRef.current) {
return
}
setNestedScanInProgress(false)
setActiveNestedScanId(null)
track(
'add_repo_nested_scan_result',
buildNestedRepoScanTelemetry({
attemptId,
surface: 'sidebar',
runtimeKind: 'local',
scan
})
)
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
showNestedRepoReview({
scan,
selectedPath: path,
connectionId: null,
attemptId,
runtimeKind: 'local',
inProgress: false,
scanId
})
return
}
setAddProjectBusyLabel('Opening project...')
const repo = await addRepoPath(path)
if (gen !== localAddGenRef.current) {
return
}
if (repo && isGitRepoKind(repo)) {
setAddedRepo(repo)
setExistingWorkspaceSource(source)
await fetchWorktrees(repo.id)
if (gen !== localAddGenRef.current) {
return
}
setStep('setup')
} else if (repo) {
// Why: folder repos skip the Git worktree setup step and activate
// their synthetic root workspace in the folder add flow.
closeModal()
}
} finally {
if (gen === localAddGenRef.current) {
setNestedScanInProgress(false)
setActiveNestedScanId(null)
setIsAdding(false)
setAddProjectBusyLabel(null)
}
}
},
[
activeRuntimeEnvironmentId,
addRepoPath,
closeModal,
fetchWorktrees,
scanNestedRepos,
setActiveNestedScanId,
setAddProjectBusyLabel,
setAddedRepo,
setExistingWorkspaceSource,
setIsAdding,
setNestedScanInProgress,
setStep,
showNestedRepoReview
]
)
useEffect(() => {
if (!isOpen || !droppedLocalPath) {
return
}
if (droppedLocalPathHandledRef.current === droppedLocalPath) {
return
}
droppedLocalPathHandledRef.current = droppedLocalPath
void handleAddLocalPath(droppedLocalPath, 'local_folder_picker')
}, [droppedLocalPath, handleAddLocalPath, isOpen])
const handleBrowse = useCallback(async (): Promise<void> => {
const gen = ++localAddGenRef.current
setIsAdding(true)
setAddProjectBusyLabel('Choose a folder...')
try {
const path = await window.api.repos.pickFolder()
if (!path || gen !== localAddGenRef.current) {
return
}
await handleAddLocalPath(path, 'local_folder_picker')
} finally {
if (gen === localAddGenRef.current) {
setIsAdding(false)
}
}
}, [handleAddLocalPath, setAddProjectBusyLabel, setIsAdding])
return { handleBrowse, resetLocalFolderFlow }
}
@@ -0,0 +1,220 @@
import { useCallback, useRef } from 'react'
import { toast } from 'sonner'
import { track } from '@/lib/telemetry'
import { useAppStore } from '@/store'
import {
buildNestedRepoImportActionTelemetry,
buildNestedRepoImportResultTelemetry,
shouldEmitNestedRepoImportSubmitTelemetry,
type NestedRepoTelemetryRuntimeKind
} from '../../../../shared/nested-repo-telemetry'
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
import type { NestedRepoScanResult, ProjectGroupImportResult, Repo } from '../../../../shared/types'
import type { AddRepoDialogStep } from './add-repo-dialog-types'
export function useAddRepoNestedImportFlow({
nestedAttemptId,
nestedScan,
nestedSelectedPaths,
nestedRuntimeKind,
nestedConnectionId,
nestedGroupName,
nestedImportScanId,
activeRuntimeEnvironmentId,
fetchWorktrees,
importNestedRepos,
getNestedRepoRuntimeKind,
setAddedRepo,
setExistingWorkspaceSource,
setStep,
setIsAdding
}: {
nestedAttemptId: string | null
nestedScan: NestedRepoScanResult | null
nestedSelectedPaths: Set<string>
nestedRuntimeKind: NestedRepoTelemetryRuntimeKind | null
nestedConnectionId: string | null
nestedGroupName: string
nestedImportScanId: string | null
activeRuntimeEnvironmentId: string | null | undefined
fetchWorktrees: (repoId: string) => Promise<unknown>
importNestedRepos: (args: {
parentPath: string
groupName: string
projectPaths: string[]
connectionId?: string
scanId?: string
mode: 'group' | 'separate'
}) => Promise<ProjectGroupImportResult | null>
getNestedRepoRuntimeKind: (connectionId: string | null) => NestedRepoTelemetryRuntimeKind
setAddedRepo: (repo: Repo | null) => void
setExistingWorkspaceSource: (source: AddRepoExistingWorkspaceSource | null) => void
setStep: (step: AddRepoDialogStep) => void
setIsAdding: (isAdding: boolean) => void
}): {
handleImportNestedRepos: (mode: 'group' | 'separate') => Promise<void>
resetNestedImportFlow: () => void
trackNestedBackAction: () => void
} {
const nestedImportGenRef = useRef(0)
const resetNestedImportFlow = useCallback((): void => {
nestedImportGenRef.current++
}, [])
const trackNestedBackAction = useCallback((): void => {
if (!nestedScan || !nestedAttemptId) {
return
}
track(
'add_repo_nested_import_action',
buildNestedRepoImportActionTelemetry({
attemptId: nestedAttemptId,
surface: 'sidebar',
runtimeKind: nestedRuntimeKind ?? getNestedRepoRuntimeKind(nestedConnectionId),
action: 'back',
foundCount: nestedScan.repos.length,
selectedCount: nestedSelectedPaths.size
})
)
}, [
getNestedRepoRuntimeKind,
nestedAttemptId,
nestedConnectionId,
nestedRuntimeKind,
nestedScan,
nestedSelectedPaths.size
])
const handleImportNestedRepos = useCallback(
async (mode: 'group' | 'separate'): Promise<void> => {
const attemptId = nestedAttemptId
if (
!nestedScan ||
!attemptId ||
!shouldEmitNestedRepoImportSubmitTelemetry({
attemptId,
selectedCount: nestedSelectedPaths.size
})
) {
return
}
const foundCount = nestedScan.repos.length
const selectedCount = nestedSelectedPaths.size
const runtimeKind = nestedRuntimeKind ?? getNestedRepoRuntimeKind(nestedConnectionId)
const gen = ++nestedImportGenRef.current
setIsAdding(true)
track(
'add_repo_nested_import_action',
buildNestedRepoImportActionTelemetry({
attemptId,
surface: 'sidebar',
runtimeKind,
action: mode === 'group' ? 'import_group' : 'import_separate',
foundCount,
selectedCount
})
)
let resultTracked = false
try {
const result = await importNestedRepos({
parentPath: nestedScan.selectedPath,
groupName: nestedGroupName,
projectPaths: [...nestedSelectedPaths],
...(nestedConnectionId ? { connectionId: nestedConnectionId } : {}),
...(nestedImportScanId ? { scanId: nestedImportScanId } : {}),
mode
})
track(
'add_repo_nested_import_result',
buildNestedRepoImportResultTelemetry({
attemptId,
surface: 'sidebar',
runtimeKind,
mode,
foundCount,
selectedCount,
result
})
)
resultTracked = true
if (!result) {
return
}
const importedRepoIds = result.projects
.map((entry) => entry.projectId)
.filter((projectId): projectId is string => typeof projectId === 'string')
const firstRepoId = importedRepoIds[0]
if (!firstRepoId) {
const firstFailure = result.projects.find((entry) => entry.status === 'failed')?.error
if (gen === nestedImportGenRef.current) {
toast.error('No repositories imported', {
description: firstFailure ?? undefined
})
}
return
}
for (const projectId of importedRepoIds) {
await fetchWorktrees(projectId)
}
if (gen !== nestedImportGenRef.current) {
return
}
const repo = useAppStore.getState().repos.find((entry) => entry.id === firstRepoId)
if (repo) {
setAddedRepo(repo)
setExistingWorkspaceSource(
nestedConnectionId
? 'ssh_remote_path'
: activeRuntimeEnvironmentId?.trim()
? 'runtime_server_path'
: 'local_folder_picker'
)
setStep('setup')
}
if (result.failedCount > 0 && gen === nestedImportGenRef.current) {
toast.warning('Some repositories could not be imported', {
description: `${result.failedCount} failed`
})
}
} finally {
if (!resultTracked) {
track(
'add_repo_nested_import_result',
buildNestedRepoImportResultTelemetry({
attemptId,
surface: 'sidebar',
runtimeKind,
mode,
foundCount,
selectedCount,
result: null
})
)
}
if (gen === nestedImportGenRef.current) {
setIsAdding(false)
}
}
},
[
activeRuntimeEnvironmentId,
fetchWorktrees,
importNestedRepos,
nestedAttemptId,
nestedConnectionId,
nestedGroupName,
nestedImportScanId,
nestedRuntimeKind,
nestedScan,
nestedSelectedPaths,
getNestedRepoRuntimeKind,
setAddedRepo,
setExistingWorkspaceSource,
setIsAdding,
setStep
]
)
return { handleImportNestedRepos, resetNestedImportFlow, trackNestedBackAction }
}
@@ -0,0 +1,131 @@
import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import type { NestedRepoTelemetryRuntimeKind } from '../../../../shared/nested-repo-telemetry'
import type { NestedRepoScanResult } from '../../../../shared/types'
import { defaultProjectGroupNameForPath, type AddRepoDialogStep } from './add-repo-dialog-types'
type ShowNestedRepoReviewArgs = {
scan: NestedRepoScanResult
selectedPath: string
connectionId: string | null
attemptId: string
runtimeKind: NestedRepoTelemetryRuntimeKind
inProgress: boolean
scanId: string | null
}
export function useAddRepoNestedReviewState({
activeRuntimeEnvironmentId,
cancelNestedRepoScan,
setStep
}: {
activeRuntimeEnvironmentId: string | null | undefined
cancelNestedRepoScan: (scanId: string) => Promise<unknown>
setStep: (step: AddRepoDialogStep) => void
}): {
nestedScan: NestedRepoScanResult | null
nestedSelectedPaths: Set<string>
nestedGroupName: string
nestedConnectionId: string | null
nestedAttemptId: string | null
nestedRuntimeKind: NestedRepoTelemetryRuntimeKind | null
nestedScanInProgress: boolean
nestedScanId: string | null
nestedImportScanId: string | null
setNestedSelectedPaths: Dispatch<SetStateAction<Set<string>>>
setNestedGroupName: Dispatch<SetStateAction<string>>
setNestedScanInProgress: Dispatch<SetStateAction<boolean>>
getNestedRepoRuntimeKind: (connectionId: string | null) => NestedRepoTelemetryRuntimeKind
showNestedRepoReview: (args: ShowNestedRepoReviewArgs) => void
setActiveNestedScanId: (scanId: string | null) => void
handleStopNestedScan: () => void
resetNestedRepoReviewState: () => void
} {
const [nestedScan, setNestedScan] = useState<NestedRepoScanResult | null>(null)
const [nestedSelectedPaths, setNestedSelectedPaths] = useState<Set<string>>(new Set())
const [nestedGroupName, setNestedGroupName] = useState('')
const [nestedConnectionId, setNestedConnectionId] = useState<string | null>(null)
const [nestedAttemptId, setNestedAttemptId] = useState<string | null>(null)
const [nestedRuntimeKind, setNestedRuntimeKind] = useState<NestedRepoTelemetryRuntimeKind | null>(
null
)
const [nestedScanInProgress, setNestedScanInProgress] = useState(false)
const [nestedScanId, setNestedScanId] = useState<string | null>(null)
const [nestedImportScanId, setNestedImportScanId] = useState<string | null>(null)
const nestedScanIdRef = useRef<string | null>(null)
const getNestedRepoRuntimeKind = useCallback(
(connectionId: string | null): NestedRepoTelemetryRuntimeKind => {
if (connectionId) {
return 'ssh'
}
return activeRuntimeEnvironmentId?.trim() ? 'runtime' : 'local'
},
[activeRuntimeEnvironmentId]
)
const showNestedRepoReview = useCallback(
(args: ShowNestedRepoReviewArgs): void => {
setNestedScan(args.scan)
setNestedSelectedPaths(new Set(args.scan.repos.map((repo) => repo.path)))
setNestedGroupName(
defaultProjectGroupNameForPath(args.scan.selectedPath || args.selectedPath)
)
setNestedConnectionId(args.connectionId)
setNestedAttemptId(args.attemptId)
setNestedRuntimeKind(args.runtimeKind)
setNestedScanInProgress(args.inProgress)
setNestedImportScanId(args.scanId)
setStep('nested')
},
[setStep]
)
const setActiveNestedScanId = useCallback((scanId: string | null): void => {
nestedScanIdRef.current = scanId
setNestedScanId(scanId)
}, [])
const handleStopNestedScan = useCallback(() => {
const scanId = nestedScanIdRef.current
if (!scanId) {
return
}
void cancelNestedRepoScan(scanId)
}, [cancelNestedRepoScan])
const resetNestedRepoReviewState = useCallback((): void => {
const activeNestedScanId = nestedScanIdRef.current
if (activeNestedScanId) {
void cancelNestedRepoScan(activeNestedScanId)
}
setNestedScan(null)
setNestedSelectedPaths(new Set())
setNestedGroupName('')
setNestedConnectionId(null)
setNestedAttemptId(null)
setNestedRuntimeKind(null)
setNestedScanInProgress(false)
setNestedImportScanId(null)
setActiveNestedScanId(null)
}, [cancelNestedRepoScan, setActiveNestedScanId])
return {
nestedScan,
nestedSelectedPaths,
nestedGroupName,
nestedConnectionId,
nestedAttemptId,
nestedRuntimeKind,
nestedScanInProgress,
nestedScanId,
nestedImportScanId,
setNestedSelectedPaths,
setNestedGroupName,
setNestedScanInProgress,
getNestedRepoRuntimeKind,
showNestedRepoReview,
setActiveNestedScanId,
handleStopNestedScan,
resetNestedRepoReviewState
}
}
@@ -0,0 +1,188 @@
import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import { track } from '@/lib/telemetry'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import {
buildNestedRepoScanTelemetry,
createNestedRepoTelemetryAttemptId,
type NestedRepoTelemetryRuntimeKind
} from '../../../../shared/nested-repo-telemetry'
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
import type { NestedRepoScanResult, Repo } from '../../../../shared/types'
import { createNestedRepoScanId, type AddRepoDialogStep } from './add-repo-dialog-types'
type ShowNestedRepoReview = (args: {
scan: NestedRepoScanResult
selectedPath: string
connectionId: string | null
attemptId: string
runtimeKind: NestedRepoTelemetryRuntimeKind
inProgress: boolean
scanId: string | null
}) => void
export function useAddRepoServerPathFlow({
addRepoPath,
closeModal,
fetchWorktrees,
getNestedRepoRuntimeKind,
scanNestedRepos,
setActiveNestedScanId,
setNestedScanInProgress,
showNestedRepoReview,
setStep,
setAddedRepo,
setExistingWorkspaceSource,
setAddProjectBusyLabel
}: {
addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise<Repo | null>
closeModal: () => void
fetchWorktrees: (repoId: string) => Promise<unknown>
getNestedRepoRuntimeKind: (connectionId: string | null) => NestedRepoTelemetryRuntimeKind
scanNestedRepos: (
path: string,
connectionId?: string,
controls?: { scanId?: string; onProgress?: (scan: NestedRepoScanResult) => void }
) => Promise<NestedRepoScanResult | null>
setActiveNestedScanId: (scanId: string | null) => void
setNestedScanInProgress: (inProgress: boolean) => void
showNestedRepoReview: ShowNestedRepoReview
setStep: (step: AddRepoDialogStep) => void
setAddedRepo: (repo: Repo | null) => void
setExistingWorkspaceSource: (source: AddRepoExistingWorkspaceSource | null) => void
setAddProjectBusyLabel: (label: string | null) => void
}): {
serverPath: string
isAddingServerPath: boolean
setServerPath: Dispatch<SetStateAction<string>>
resetServerPathFlow: () => void
handleAddServerPath: (kind: 'git' | 'folder') => Promise<void>
} {
const [serverPath, setServerPath] = useState('')
const [isAddingServerPath, setIsAddingServerPath] = useState(false)
const serverAddGenRef = useRef(0)
const resetServerPathFlow = useCallback((): void => {
serverAddGenRef.current++
setServerPath('')
setIsAddingServerPath(false)
}, [])
const handleAddServerPath = useCallback(
async (kind: 'git' | 'folder'): Promise<void> => {
const path = serverPath.trim()
if (!path) {
return
}
const gen = ++serverAddGenRef.current
setIsAddingServerPath(true)
setAddProjectBusyLabel(kind === 'git' ? 'Scanning for repositories...' : 'Opening folder...')
try {
if (kind === 'git') {
const attemptId = createNestedRepoTelemetryAttemptId()
const runtimeKind = getNestedRepoRuntimeKind(null)
const supportsStreamingScan = runtimeKind !== 'runtime'
const scanId = supportsStreamingScan ? createNestedRepoScanId() : null
if (scanId) {
setActiveNestedScanId(scanId)
setNestedScanInProgress(true)
}
const scan = await scanNestedRepos(
path,
undefined,
scanId
? {
scanId,
onProgress: (progressScan) => {
if (
gen !== serverAddGenRef.current ||
progressScan.selectedPathKind !== 'non_git_folder' ||
progressScan.repos.length === 0
) {
return
}
showNestedRepoReview({
scan: progressScan,
selectedPath: path,
connectionId: null,
attemptId,
runtimeKind,
inProgress: true,
scanId
})
}
}
: undefined
)
if (gen !== serverAddGenRef.current) {
return
}
setNestedScanInProgress(false)
setActiveNestedScanId(null)
track(
'add_repo_nested_scan_result',
buildNestedRepoScanTelemetry({
attemptId,
surface: 'sidebar',
runtimeKind,
scan
})
)
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
showNestedRepoReview({
scan,
selectedPath: path,
connectionId: null,
attemptId,
runtimeKind,
inProgress: false,
scanId
})
return
}
}
setAddProjectBusyLabel(kind === 'git' ? 'Opening project...' : 'Opening folder...')
const repo = await addRepoPath(path, kind)
if (gen !== serverAddGenRef.current) {
return
}
if (repo && isGitRepoKind(repo)) {
setAddedRepo(repo)
setExistingWorkspaceSource('runtime_server_path')
await fetchWorktrees(repo.id)
if (gen !== serverAddGenRef.current) {
return
}
setStep('setup')
} else if (repo) {
// Why: folder repos skip the Git worktree setup step; their synthetic
// root workspace is opened by the folder add flow.
closeModal()
}
} finally {
if (gen === serverAddGenRef.current) {
setNestedScanInProgress(false)
setActiveNestedScanId(null)
setIsAddingServerPath(false)
setAddProjectBusyLabel(null)
}
}
},
[
addRepoPath,
closeModal,
fetchWorktrees,
getNestedRepoRuntimeKind,
scanNestedRepos,
serverPath,
setActiveNestedScanId,
setAddProjectBusyLabel,
setAddedRepo,
setExistingWorkspaceSource,
setNestedScanInProgress,
setStep,
showNestedRepoReview
]
)
return { serverPath, isAddingServerPath, setServerPath, resetServerPathFlow, handleAddServerPath }
}
@@ -0,0 +1,217 @@
import { useCallback, useEffect, useMemo, useRef, type MutableRefObject } from 'react'
import { track } from '@/lib/telemetry'
import { useAppStore } from '@/store'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import {
effectiveExternalWorktreeVisibility,
isLegacyRepoForExternalWorktreeVisibility
} from '../../../../shared/worktree-ownership'
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
import type { AddRepoSetupStepAction } from '../../../../shared/telemetry-events'
import type { Repo } from '../../../../shared/types'
import { finalizeImportedRepoAfterSkip } from './add-repo-skip-finalization'
import {
buildAddRepoExistingWorkspacesTelemetry,
shouldTrackAddRepoExistingWorkspacesDetected
} from './add-repo-existing-workspaces-telemetry'
import { getProjectAddedPrimaryBranchName } from './AddRepoSetupStep'
export function useAddRepoSetupActions({
addedRepo,
existingWorkspaceSource,
isSetupStep,
projectId,
closeModal,
openModal,
openSettingsPage,
openSettingsTarget,
fetchWorktrees,
resetState,
setupActionGenRef,
setAddedRepo
}: {
addedRepo: Repo | null
existingWorkspaceSource: AddRepoExistingWorkspaceSource | null
isSetupStep: boolean
projectId: string
closeModal: () => void
openModal: (
modal: 'new-workspace-composer',
data: { initialRepoId: string; prefilledName?: string; telemetrySource: 'sidebar' }
) => void
openSettingsPage: () => void
openSettingsTarget: (target: { pane: 'repo'; repoId: string }) => void
fetchWorktrees: (repoId: string) => Promise<unknown>
resetState: () => void
setupActionGenRef: MutableRefObject<number>
setAddedRepo: (repo: Repo | null) => void
}): {
hiddenWorktreeCount: number
primaryBranchName: string | null
trackSetupAction: (action: AddRepoSetupStepAction) => void
finishImportedRepoWithoutOpening: () => Promise<void>
handleCreateWorktree: (name?: string) => void
handleStartPrimaryWorktree: () => void
handleConfigureRepo: () => void
handleUseExistingWorktrees: () => Promise<void>
} {
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo)
const updateRepo = useAppStore((s) => s.updateRepo)
const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace)
const worktrees = useMemo(() => worktreesByRepo[projectId] ?? [], [worktreesByRepo, projectId])
const detectedResult = projectId ? detectedWorktreesByRepo[projectId] : undefined
const hiddenWorktreeCount =
detectedResult?.authoritative === true
? detectedResult.worktrees.filter(
(worktree) => !worktree.selectedCheckout && worktree.ownership !== 'orca-managed'
).length
: 0
const otherWorktreesVisible = addedRepo
? effectiveExternalWorktreeVisibility(
addedRepo,
isLegacyRepoForExternalWorktreeVisibility(addedRepo)
) === 'show'
: false
// Why: sort by recent activity with alphabetical fallback.
const sortedWorktrees = useMemo(() => {
return [...worktrees].sort((a, b) => {
if (a.lastActivityAt !== b.lastActivityAt) {
return b.lastActivityAt - a.lastActivityAt
}
return a.displayName.localeCompare(b.displayName)
})
}, [worktrees])
const primaryWorktree = useMemo(
() => sortedWorktrees.find((worktree) => worktree.isMainWorktree) ?? null,
[sortedWorktrees]
)
const primaryBranchName = getProjectAddedPrimaryBranchName(primaryWorktree)
const existingWorkspaceTelemetry = useMemo(
() => buildAddRepoExistingWorkspacesTelemetry(existingWorkspaceSource, sortedWorktrees),
[existingWorkspaceSource, sortedWorktrees]
)
const detectedTelemetryTrackedRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (
!isSetupStep ||
!projectId ||
!existingWorkspaceTelemetry ||
!shouldTrackAddRepoExistingWorkspacesDetected(existingWorkspaceTelemetry) ||
detectedTelemetryTrackedRef.current.has(projectId)
) {
return
}
detectedTelemetryTrackedRef.current.add(projectId)
track('add_repo_existing_workspaces_detected', existingWorkspaceTelemetry)
}, [existingWorkspaceTelemetry, isSetupStep, projectId])
const trackSetupAction = useCallback(
(action: AddRepoSetupStepAction): void => {
track('add_repo_setup_step_action', {
action,
...(existingWorkspaceTelemetry
? {
source: existingWorkspaceTelemetry.source,
existing_workspace_count: existingWorkspaceTelemetry.existing_workspace_count,
existing_linked_workspace_count:
existingWorkspaceTelemetry.existing_linked_workspace_count
}
: {})
})
},
[existingWorkspaceTelemetry]
)
const handleCreateWorktree = useCallback(
(name?: string): void => {
trackSetupAction('create_worktree')
// Why: let the dialog close animation finish before the composer takes focus.
closeModal()
setTimeout(() => {
openModal('new-workspace-composer', {
initialRepoId: projectId,
...(name ? { prefilledName: name } : {}),
telemetrySource: 'sidebar'
})
}, 150)
},
[closeModal, openModal, projectId, trackSetupAction]
)
const handleStartPrimaryWorktree = useCallback((): void => {
if (!primaryWorktree) {
return
}
trackSetupAction('open_primary')
closeModal()
if (useAppStore.getState().hideDefaultBranchWorkspace) {
setHideDefaultBranchWorkspace(false)
}
activateAndRevealWorktree(primaryWorktree.id)
}, [closeModal, primaryWorktree, setHideDefaultBranchWorkspace, trackSetupAction])
const handleConfigureRepo = useCallback((): void => {
trackSetupAction('configure')
closeModal()
openSettingsTarget({ pane: 'repo', repoId: projectId })
openSettingsPage()
}, [closeModal, openSettingsTarget, openSettingsPage, projectId, trackSetupAction])
const finishImportedRepoWithoutOpening = useCallback(async (): Promise<void> => {
const importedRepoId = projectId
closeModal()
resetState()
if (!importedRepoId) {
return
}
await fetchWorktrees(importedRepoId)
const state = useAppStore.getState()
finalizeImportedRepoAfterSkip(state, importedRepoId)
}, [closeModal, fetchWorktrees, projectId, resetState])
const handleUseExistingWorktrees = useCallback(async (): Promise<void> => {
if (!projectId) {
return
}
const gen = ++setupActionGenRef.current
trackSetupAction('open_existing')
if (!otherWorktreesVisible) {
const updated = await updateRepo(projectId, { externalWorktreeVisibility: 'show' })
if (gen !== setupActionGenRef.current) {
return
}
if (updated && addedRepo) {
setAddedRepo({ ...addedRepo, externalWorktreeVisibility: 'show' })
}
await fetchWorktrees(projectId)
if (gen !== setupActionGenRef.current) {
return
}
}
await finishImportedRepoWithoutOpening()
}, [
addedRepo,
fetchWorktrees,
finishImportedRepoWithoutOpening,
otherWorktreesVisible,
projectId,
setupActionGenRef,
setAddedRepo,
trackSetupAction,
updateRepo
])
return {
hiddenWorktreeCount,
primaryBranchName,
trackSetupAction,
finishImportedRepoWithoutOpening,
handleCreateWorktree,
handleStartPrimaryWorktree,
handleConfigureRepo,
handleUseExistingWorktrees
}
}
@@ -1,15 +1,5 @@
/* eslint-disable max-lines -- Why: the overview keeps its small display components beside the
provider fetch wiring so the combined usage surface stays easy to audit. */
import { useEffect, useMemo } from 'react'
import {
Activity,
AlertCircle,
CalendarDays,
Coins,
DatabaseZap,
RefreshCw,
Sparkles
} from 'lucide-react'
import { Activity, CalendarDays, Coins, DatabaseZap, RefreshCw, Sparkles } from 'lucide-react'
import { useAppStore } from '../../store'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
@@ -19,22 +9,12 @@ import {
buildUsageOverview,
formatUsageCost,
formatUsageTokens,
getRecentUsageDays,
type UsageOverviewDailyPoint,
type UsageOverviewModel,
type UsageProviderOverview
getRecentUsageDays
} from './usage-overview-model'
import { DailyIntensityGrid, ProviderUsageRow, TokenMixBar } from './usage-overview-sections'
const RECENT_DAY_COUNT = 42
const INTENSITY_CLASS: Record<UsageOverviewDailyPoint['intensity'], string> = {
0: 'border-border/60 bg-muted/40',
1: 'border-border/60 bg-muted-foreground/20',
2: 'border-border/60 bg-muted-foreground/35',
3: 'border-border/60 bg-muted-foreground/55',
4: 'border-border/60 bg-foreground/75'
}
function formatPercent(value: number | null): string {
if (value === null) {
return 'n/a'
@@ -49,200 +29,6 @@ function formatUpdatedAt(timestamp: number | null): string {
return `Updated ${new Date(timestamp).toLocaleString()}`
}
function formatDayLabel(day: string): string {
const parsed = new Date(`${day}T12:00:00`)
if (Number.isNaN(parsed.getTime())) {
return day
}
return parsed.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
function TokenMixBar({ overview }: { overview: UsageOverviewModel }): React.JSX.Element {
const segments = [
{
key: 'new-input',
label: 'New input',
value: overview.newInputTokens,
className: 'bg-foreground'
},
{
key: 'output',
label: 'Output',
value: overview.outputTokens,
className: 'bg-muted-foreground'
},
{
key: 'cache',
label: 'Cache',
value: overview.cacheTokens,
className: 'bg-border'
}
]
// Why: Codex cached input is a subset of input. The overview model normalizes
// that into new/cache buckets so the visual mix does not double-count it.
const mixTotal = segments.reduce((sum, segment) => sum + segment.value, 0)
return (
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-foreground">Token mix</h4>
<p className="text-xs text-muted-foreground">
Combined input, output, and cache tokens across enabled providers.
</p>
</div>
{overview.reasoningTokens > 0 ? (
<Badge variant="outline" className="shrink-0">
{formatUsageTokens(overview.reasoningTokens)} reasoning
</Badge>
) : null}
</div>
{mixTotal > 0 ? (
<div
className="flex h-3 overflow-hidden rounded-full border border-border/60 bg-muted"
aria-label="Combined token mix"
>
{segments.map((segment) =>
segment.value > 0 ? (
<div
key={segment.key}
className={segment.className}
style={{ width: `${(segment.value / mixTotal) * 100}%` }}
aria-label={`${segment.label}: ${segment.value.toLocaleString()} tokens`}
/>
) : null
)}
</div>
) : (
<div className="h-3 rounded-full border border-dashed border-border/60 bg-muted/40" />
)}
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
{segments.map((segment) => (
<div key={segment.key} className="flex min-w-0 items-center gap-2">
<span className={`size-2 shrink-0 rounded-full ${segment.className}`} />
<span className="min-w-0 truncate">
{segment.label}: {formatUsageTokens(segment.value)}
</span>
</div>
))}
</div>
</section>
)
}
function DailyIntensityGrid({
days,
bestDay
}: {
days: UsageOverviewDailyPoint[]
bestDay: UsageOverviewDailyPoint | null
}): React.JSX.Element {
return (
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-foreground">Daily intensity</h4>
<p className="text-xs text-muted-foreground">
Recent combined Claude, Codex, and OpenCode token activity.
</p>
</div>
{bestDay && bestDay.totalTokens > 0 ? (
<Badge variant="outline" className="shrink-0">
Best: {formatDayLabel(bestDay.day)}
</Badge>
) : null}
</div>
<div
className="grid grid-cols-[repeat(14,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(21,minmax(0,1fr))]"
aria-label="Recent token activity heatmap"
>
{days.map((day) => (
<div
key={day.day}
className={`aspect-square min-h-3 rounded-[2px] border ${INTENSITY_CLASS[day.intensity]}`}
aria-label={`${day.day}: ${day.totalTokens.toLocaleString()} tokens`}
/>
))}
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{formatDayLabel(days[0]?.day ?? '')}</span>
<span>Less</span>
<div className="flex items-center gap-1" aria-hidden>
{[0, 1, 2, 3, 4].map((intensity) => (
<span
key={intensity}
className={`size-2 rounded-[2px] border ${INTENSITY_CLASS[intensity as UsageOverviewDailyPoint['intensity']]}`}
/>
))}
</div>
<span>More</span>
<span>{formatDayLabel(days.at(-1)?.day ?? '')}</span>
</div>
</section>
)
}
function ProviderRow({
provider,
totalTokens,
onEnable
}: {
provider: UsageProviderOverview
totalTokens: number
onEnable: () => void
}): React.JSX.Element {
const share = totalTokens > 0 ? provider.totalTokens / totalTokens : 0
const status = provider.enabled ? (provider.isScanning ? 'Scanning' : 'Enabled') : 'Off'
const statusVariant = provider.enabled ? 'secondary' : 'outline'
return (
<div className="rounded-lg border border-border/60 bg-card/40 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<h5 className="truncate text-sm font-semibold text-foreground">{provider.label}</h5>
<Badge variant={statusVariant}>{status}</Badge>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{provider.topModel ?? 'No model yet'}
{provider.topProject ? ` - ${provider.topProject}` : ''}
</p>
</div>
{!provider.enabled ? (
<Button variant="outline" size="xs" onClick={onEnable}>
Enable
</Button>
) : null}
</div>
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
<span>{formatUsageTokens(provider.totalTokens)} tokens</span>
<span>
{provider.sessions.toLocaleString()} sessions - {provider.activityCount.toLocaleString()}{' '}
{provider.activityLabel}
</span>
<span>{formatUsageCost(provider.estimatedCostUsd)}</span>
</div>
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-foreground/75"
style={{ width: `${Math.max(share * 100, provider.totalTokens > 0 ? 2 : 0)}%` }}
/>
</div>
{provider.lastScanError ? (
<p className="mt-2 flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="size-3" />
{provider.lastScanError}
</p>
) : null}
</div>
)
}
export function UsageOverviewPane(): React.JSX.Element {
const claudeScanState = useAppStore((state) => state.claudeUsageScanState)
const claudeSummary = useAppStore((state) => state.claudeUsageSummary)
@@ -441,7 +227,7 @@ export function UsageOverviewPane(): React.JSX.Element {
</div>
<div className="grid gap-3 xl:grid-cols-2">
{overview.providers.map((provider) => (
<ProviderRow
<ProviderUsageRow
key={provider.id}
provider={provider}
totalTokens={overview.totalTokens}
@@ -0,0 +1,212 @@
import { AlertCircle } from 'lucide-react'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import {
formatUsageCost,
formatUsageTokens,
type UsageOverviewDailyPoint,
type UsageOverviewModel,
type UsageProviderOverview
} from './usage-overview-model'
const INTENSITY_CLASS: Record<UsageOverviewDailyPoint['intensity'], string> = {
0: 'border-border/60 bg-muted/40',
1: 'border-border/60 bg-muted-foreground/20',
2: 'border-border/60 bg-muted-foreground/35',
3: 'border-border/60 bg-muted-foreground/55',
4: 'border-border/60 bg-foreground/75'
}
function formatDayLabel(day: string): string {
const parsed = new Date(`${day}T12:00:00`)
if (Number.isNaN(parsed.getTime())) {
return day
}
return parsed.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
export function TokenMixBar({ overview }: { overview: UsageOverviewModel }): React.JSX.Element {
const segments = [
{
key: 'new-input',
label: 'New input',
value: overview.newInputTokens,
className: 'bg-foreground'
},
{
key: 'output',
label: 'Output',
value: overview.outputTokens,
className: 'bg-muted-foreground'
},
{
key: 'cache',
label: 'Cache',
value: overview.cacheTokens,
className: 'bg-border'
}
]
// Why: Codex cached input is a subset of input. The overview model normalizes
// that into new/cache buckets so the visual mix does not double-count it.
const mixTotal = segments.reduce((sum, segment) => sum + segment.value, 0)
return (
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-foreground">Token mix</h4>
<p className="text-xs text-muted-foreground">
Combined input, output, and cache tokens across enabled providers.
</p>
</div>
{overview.reasoningTokens > 0 ? (
<Badge variant="outline" className="shrink-0">
{formatUsageTokens(overview.reasoningTokens)} reasoning
</Badge>
) : null}
</div>
{mixTotal > 0 ? (
<div
className="flex h-3 overflow-hidden rounded-full border border-border/60 bg-muted"
aria-label="Combined token mix"
>
{segments.map((segment) =>
segment.value > 0 ? (
<div
key={segment.key}
className={segment.className}
style={{ width: `${(segment.value / mixTotal) * 100}%` }}
aria-label={`${segment.label}: ${segment.value.toLocaleString()} tokens`}
/>
) : null
)}
</div>
) : (
<div className="h-3 rounded-full border border-dashed border-border/60 bg-muted/40" />
)}
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
{segments.map((segment) => (
<div key={segment.key} className="flex min-w-0 items-center gap-2">
<span className={`size-2 shrink-0 rounded-full ${segment.className}`} />
<span className="min-w-0 truncate">
{segment.label}: {formatUsageTokens(segment.value)}
</span>
</div>
))}
</div>
</section>
)
}
export function DailyIntensityGrid({
days,
bestDay
}: {
days: UsageOverviewDailyPoint[]
bestDay: UsageOverviewDailyPoint | null
}): React.JSX.Element {
return (
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-foreground">Daily intensity</h4>
<p className="text-xs text-muted-foreground">
Recent combined Claude, Codex, and OpenCode token activity.
</p>
</div>
{bestDay && bestDay.totalTokens > 0 ? (
<Badge variant="outline" className="shrink-0">
Best: {formatDayLabel(bestDay.day)}
</Badge>
) : null}
</div>
<div
className="grid grid-cols-[repeat(14,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(21,minmax(0,1fr))]"
aria-label="Recent token activity heatmap"
>
{days.map((day) => (
<div
key={day.day}
className={`aspect-square min-h-3 rounded-[2px] border ${INTENSITY_CLASS[day.intensity]}`}
aria-label={`${day.day}: ${day.totalTokens.toLocaleString()} tokens`}
/>
))}
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{formatDayLabel(days[0]?.day ?? '')}</span>
<span>Less</span>
<div className="flex items-center gap-1" aria-hidden>
{[0, 1, 2, 3, 4].map((intensity) => (
<span
key={intensity}
className={`size-2 rounded-[2px] border ${INTENSITY_CLASS[intensity as UsageOverviewDailyPoint['intensity']]}`}
/>
))}
</div>
<span>More</span>
<span>{formatDayLabel(days.at(-1)?.day ?? '')}</span>
</div>
</section>
)
}
export function ProviderUsageRow({
provider,
totalTokens,
onEnable
}: {
provider: UsageProviderOverview
totalTokens: number
onEnable: () => void
}): React.JSX.Element {
const share = totalTokens > 0 ? provider.totalTokens / totalTokens : 0
const status = provider.enabled ? (provider.isScanning ? 'Scanning' : 'Enabled') : 'Off'
const statusVariant = provider.enabled ? 'secondary' : 'outline'
return (
<div className="rounded-lg border border-border/60 bg-card/40 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<h5 className="truncate text-sm font-semibold text-foreground">{provider.label}</h5>
<Badge variant={statusVariant}>{status}</Badge>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{provider.topModel ?? 'No model yet'}
{provider.topProject ? ` - ${provider.topProject}` : ''}
</p>
</div>
{!provider.enabled ? (
<Button variant="outline" size="xs" onClick={onEnable}>
Enable
</Button>
) : null}
</div>
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
<span>{formatUsageTokens(provider.totalTokens)} tokens</span>
<span>
{provider.sessions.toLocaleString()} sessions - {provider.activityCount.toLocaleString()}{' '}
{provider.activityLabel}
</span>
<span>{formatUsageCost(provider.estimatedCostUsd)}</span>
</div>
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-foreground/75"
style={{ width: `${Math.max(share * 100, provider.totalTokens > 0 ? 2 : 0)}%` }}
/>
</div>
{provider.lastScanError ? (
<p className="mt-2 flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="size-3" />
{provider.lastScanError}
</p>
) : null}
</div>
)
}
@@ -1,274 +1,23 @@
/* eslint-disable max-lines -- Why: PortsStatusSegment keeps port scanning, grouping, row actions,
and status-bar interaction tracking together so the popover state stays coherent. */
import React, { useCallback, useMemo, useState } from 'react'
import {
Plug,
ChevronDown,
ChevronRight,
Copy,
ExternalLink,
FolderOpen,
LoaderCircle,
Trash2
} from 'lucide-react'
import { toast } from 'sonner'
import { Plug, ChevronDown, ChevronRight, LoaderCircle } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
import { useAppStore } from '@/store'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import {
addressForPort,
canStopWorkspacePort,
goToWorkspacePortOwner,
killWorkspacePortForTarget,
openWorkspacePortInBrowser,
refreshWorkspacePortScanAfterStop,
scanWorkspacePortsForTarget,
shouldOpenWorkspacePortInOrcaBrowser,
workspacePortRuntimeTargetKey
} from '@/lib/workspace-port-actions'
import {
getExternalWorkspacePorts,
getWorkspacePortGroups,
type WorkspacePortGroup
} from '@/lib/workspace-port-groups'
import { getExternalWorkspacePorts, getWorkspacePortGroups } from '@/lib/workspace-port-groups'
import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu'
import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy'
import type { WorkspacePort } from '../../../../shared/workspace-ports'
import { PortRow, WorkspaceGroupRows } from './ports-status-popover-rows'
type PortsStatusSegmentProps = {
compact?: boolean
iconOnly: boolean
}
function PortAction({
label,
onClick,
disabled,
children
}: {
label: string
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
disabled?: boolean
children: React.ReactNode
}): React.JSX.Element {
const handleClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
onClick(event)
if (event.detail > 0) {
event.currentTarget.blur()
}
}
const button = (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-5 text-muted-foreground hover:text-foreground disabled:pointer-events-none disabled:text-muted-foreground/35"
aria-label={label}
onClick={handleClick}
disabled={disabled}
>
{children}
</Button>
)
return (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
{disabled ? <span className="inline-flex">{button}</span> : button}
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4} className="z-[70]">
{label}
</TooltipContent>
</Tooltip>
)
}
function PortRow({
port,
activeWorktreeId,
external
}: {
port: WorkspacePort
activeWorktreeId: string | null
external?: boolean
}): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle)
const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan)
const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing)
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings])
const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process')
const openInOrcaBrowser = shouldOpenWorkspacePortInOrcaBrowser(settings)
const canOpen = !openInOrcaBrowser || port.kind === 'workspace' || Boolean(activeWorktreeId)
const canStop = canStopWorkspacePort(port)
const handleOpen = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
recordFeatureInteraction('ports')
void openWorkspacePortInBrowser({
port,
activeWorktreeId,
runtimeTarget,
createBrowserTab,
setRemoteBrowserPageHandle,
openInOrcaBrowser
}).then((result) => {
if (!result.ok) {
toast.error('Failed to open browser', { description: result.reason })
}
})
},
[
activeWorktreeId,
createBrowserTab,
openInOrcaBrowser,
port,
recordFeatureInteraction,
runtimeTarget,
setRemoteBrowserPageHandle
]
)
const handleCopy = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
recordFeatureInteraction('ports')
const address = addressForPort(port)
void window.api.ui.writeClipboardText(address)
toast.success(`Copied ${address}`)
},
[port, recordFeatureInteraction]
)
const handleStop = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
if (!canStopWorkspacePort(port)) {
return
}
recordFeatureInteraction('ports')
const run = async (): Promise<void> => {
const result = await killWorkspacePortForTarget(runtimeTarget, {
repoId: port.owner.repoId,
pid: port.pid,
port: port.port
})
if (!result.ok) {
toast.error(result.reason)
return
}
toast.success(`Stopped process on ${port.port}`)
const refreshResult = await refreshWorkspacePortScanAfterStop({
runtimeTarget,
setWorkspacePortScan,
setWorkspacePortScanRefreshing
})
if (!refreshResult.ok) {
toast.error('Failed to refresh ports', {
description: refreshResult.reason
})
}
}
void run()
},
[
port,
recordFeatureInteraction,
runtimeTarget,
setWorkspacePortScan,
setWorkspacePortScanRefreshing
]
)
return (
<div className="group/port grid min-w-0 grid-cols-[4.5rem_minmax(0,1fr)] items-start gap-2 rounded-md px-2 py-1.5 hover:bg-accent/50">
<span className="select-text font-mono text-[12px] font-semibold tabular-nums text-foreground">
{port.port}
</span>
<div className="min-w-0 space-y-0.5">
<div className="relative flex h-5 min-w-0 items-center">
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span className="block min-w-0 select-text truncate text-[11px] text-muted-foreground">
{processLabel}
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{processLabel}
</TooltipContent>
</Tooltip>
<div className="absolute inset-y-0 right-0 flex items-center gap-0.5 rounded-md border border-border/40 bg-popover/95 px-0.5 opacity-0 shadow-xs transition-opacity group-hover/port:opacity-100 group-focus-within/port:opacity-100">
<PortAction label="Open in Browser" onClick={handleOpen} disabled={!canOpen}>
<ExternalLink className="size-3" />
</PortAction>
<PortAction label={`Copy ${addressForPort(port)}`} onClick={handleCopy}>
<Copy className="size-3" />
</PortAction>
<PortAction label="Stop Process" disabled={!canStop} onClick={handleStop}>
<Trash2 className="size-3" />
</PortAction>
</div>
</div>
<div className="select-text truncate text-[10px] text-muted-foreground/70">
{external ? port.kind : addressForPort(port)}
</div>
</div>
</div>
)
}
function WorkspaceGroupRows({
group,
activeWorktreeId
}: {
group: WorkspacePortGroup
activeWorktreeId: string | null
}): React.JSX.Element {
const handleGoToWorkspace = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
const ownerPort = group.ports[0]
if (!ownerPort || !goToWorkspacePortOwner(ownerPort)) {
toast.error('Workspace unavailable')
}
},
[group.ports]
)
return (
<section className="border-t border-border/40 first:border-t-0">
<div className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-border/40 bg-popover px-3 py-2">
<span className="min-w-0 truncate text-[12px] font-medium text-foreground">
{group.displayName}
</span>
<div className="flex shrink-0 items-center gap-1">
<PortAction
label="Go to Worktree"
onClick={handleGoToWorkspace}
disabled={group.ports.length === 0}
>
<FolderOpen className="size-3" />
</PortAction>
<span className="font-mono text-[10px] text-muted-foreground/70">
{group.ports.length}
</span>
</div>
</div>
<div className="px-1 pb-1">
{group.ports.map((port) => (
<PortRow key={port.id} port={port} activeWorktreeId={activeWorktreeId} />
))}
</div>
</section>
)
}
export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const scan = useAppStore((s) => s.workspacePortScan?.result ?? null)
@@ -0,0 +1,245 @@
import React, { useCallback, useMemo } from 'react'
import { Copy, ExternalLink, FolderOpen, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
addressForPort,
canStopWorkspacePort,
goToWorkspacePortOwner,
killWorkspacePortForTarget,
openWorkspacePortInBrowser,
refreshWorkspacePortScanAfterStop,
shouldOpenWorkspacePortInOrcaBrowser
} from '@/lib/workspace-port-actions'
import type { WorkspacePortGroup } from '@/lib/workspace-port-groups'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { useAppStore } from '@/store'
import type { WorkspacePort } from '../../../../shared/workspace-ports'
function PortAction({
label,
onClick,
disabled,
children
}: {
label: string
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
disabled?: boolean
children: React.ReactNode
}): React.JSX.Element {
const handleClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
onClick(event)
if (event.detail > 0) {
event.currentTarget.blur()
}
}
const button = (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-5 text-muted-foreground hover:text-foreground disabled:pointer-events-none disabled:text-muted-foreground/35"
aria-label={label}
onClick={handleClick}
disabled={disabled}
>
{children}
</Button>
)
return (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
{disabled ? <span className="inline-flex">{button}</span> : button}
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4} className="z-[70]">
{label}
</TooltipContent>
</Tooltip>
)
}
export function PortRow({
port,
activeWorktreeId,
external
}: {
port: WorkspacePort
activeWorktreeId: string | null
external?: boolean
}): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle)
const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan)
const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing)
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings])
const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process')
const openInOrcaBrowser = shouldOpenWorkspacePortInOrcaBrowser(settings)
const canOpen = !openInOrcaBrowser || port.kind === 'workspace' || Boolean(activeWorktreeId)
const canStop = canStopWorkspacePort(port)
const handleOpen = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
recordFeatureInteraction('ports')
void openWorkspacePortInBrowser({
port,
activeWorktreeId,
runtimeTarget,
createBrowserTab,
setRemoteBrowserPageHandle,
openInOrcaBrowser
}).then((result) => {
if (!result.ok) {
toast.error('Failed to open browser', { description: result.reason })
}
})
},
[
activeWorktreeId,
createBrowserTab,
openInOrcaBrowser,
port,
recordFeatureInteraction,
runtimeTarget,
setRemoteBrowserPageHandle
]
)
const handleCopy = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
recordFeatureInteraction('ports')
const address = addressForPort(port)
void window.api.ui.writeClipboardText(address)
toast.success(`Copied ${address}`)
},
[port, recordFeatureInteraction]
)
const handleStop = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
if (!canStopWorkspacePort(port)) {
return
}
recordFeatureInteraction('ports')
const run = async (): Promise<void> => {
const result = await killWorkspacePortForTarget(runtimeTarget, {
repoId: port.owner.repoId,
pid: port.pid,
port: port.port
})
if (!result.ok) {
toast.error(result.reason)
return
}
toast.success(`Stopped process on ${port.port}`)
const refreshResult = await refreshWorkspacePortScanAfterStop({
runtimeTarget,
setWorkspacePortScan,
setWorkspacePortScanRefreshing
})
if (!refreshResult.ok) {
toast.error('Failed to refresh ports', {
description: refreshResult.reason
})
}
}
void run()
},
[
port,
recordFeatureInteraction,
runtimeTarget,
setWorkspacePortScan,
setWorkspacePortScanRefreshing
]
)
return (
<div className="group/port grid min-w-0 grid-cols-[4.5rem_minmax(0,1fr)] items-start gap-2 rounded-md px-2 py-1.5 hover:bg-accent/50">
<span className="select-text font-mono text-[12px] font-semibold tabular-nums text-foreground">
{port.port}
</span>
<div className="min-w-0 space-y-0.5">
<div className="relative flex h-5 min-w-0 items-center">
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span className="block min-w-0 select-text truncate text-[11px] text-muted-foreground">
{processLabel}
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{processLabel}
</TooltipContent>
</Tooltip>
<div className="absolute inset-y-0 right-0 flex items-center gap-0.5 rounded-md border border-border/40 bg-popover/95 px-0.5 opacity-0 shadow-xs transition-opacity group-hover/port:opacity-100 group-focus-within/port:opacity-100">
<PortAction label="Open in Browser" onClick={handleOpen} disabled={!canOpen}>
<ExternalLink className="size-3" />
</PortAction>
<PortAction label={`Copy ${addressForPort(port)}`} onClick={handleCopy}>
<Copy className="size-3" />
</PortAction>
<PortAction label="Stop Process" disabled={!canStop} onClick={handleStop}>
<Trash2 className="size-3" />
</PortAction>
</div>
</div>
<div className="select-text truncate text-[10px] text-muted-foreground/70">
{external ? port.kind : addressForPort(port)}
</div>
</div>
</div>
)
}
export function WorkspaceGroupRows({
group,
activeWorktreeId
}: {
group: WorkspacePortGroup
activeWorktreeId: string | null
}): React.JSX.Element {
const handleGoToWorkspace = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
const ownerPort = group.ports[0]
if (!ownerPort || !goToWorkspacePortOwner(ownerPort)) {
toast.error('Workspace unavailable')
}
},
[group.ports]
)
return (
<section className="border-t border-border/40 first:border-t-0">
<div className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-border/40 bg-popover px-3 py-2">
<span className="min-w-0 truncate text-[12px] font-medium text-foreground">
{group.displayName}
</span>
<div className="flex shrink-0 items-center gap-1">
<PortAction
label="Go to Worktree"
onClick={handleGoToWorkspace}
disabled={group.ports.length === 0}
>
<FolderOpen className="size-3" />
</PortAction>
<span className="font-mono text-[10px] text-muted-foreground/70">
{group.ports.length}
</span>
</div>
</div>
<div className="px-1 pb-1">
{group.ports.map((port) => (
<PortRow key={port.id} port={port} activeWorktreeId={activeWorktreeId} />
))}
</div>
</section>
)
}
@@ -1,26 +1,6 @@
/* eslint-disable max-lines -- Why: editor tab rendering, drag behavior, rename handling, and its context menu share one tightly-coupled tab surface. */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import {
X,
GitCompareArrows,
Copy,
Eye,
ShieldAlert,
ExternalLink,
Columns2,
Rows2,
Pencil,
Pin,
PinOff
} from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { X, GitCompareArrows, Eye, ShieldAlert, Pin } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { basename, normalizeRelativePath } from '@/lib/path'
@@ -43,18 +23,7 @@ import {
type DropIndicator
} from './drop-indicator'
import { canOpenMarkdownPreview } from '@/components/editor/markdown-preview-controls'
import { showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard'
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 { EditorFileTabContextMenu } from './EditorFileTabContextMenu'
export default function EditorFileTab({
file,
@@ -397,132 +366,28 @@ export default function EditorFileTab({
)}
</div>
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none fixed size-px opacity-0"
style={{ left: menuPoint.x, top: menuPoint.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48"
sideOffset={0}
align="start"
onCloseAutoFocus={(event) => {
if (!skipMenuFocusRestoreRef.current) {
return
}
skipMenuFocusRestoreRef.current = false
event.preventDefault()
}}
>
<DropdownMenuItem onSelect={() => onSplitGroup('up', file.tabId ?? file.id)}>
<Rows2 className="mr-1.5 size-3.5" />
Split Up
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('down', file.tabId ?? file.id)}>
<Rows2 className="mr-1.5 size-3.5" />
Split Down
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('left', file.tabId ?? file.id)}>
<Columns2 className="mr-1.5 size-3.5" />
Split Left
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('right', file.tabId ?? file.id)}>
<Columns2 className="mr-1.5 size-3.5" />
Split Right
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={!canRename || isRenaming}
onSelect={() => {
skipMenuFocusRestoreRef.current = true
onActivate()
openRenameInput()
}}
>
<Pencil className="mr-1.5 size-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onTogglePin}>
{isPinned ? (
<PinOff className="mr-1.5 size-3.5" />
) : (
<Pin className="mr-1.5 size-3.5" />
)}
{isPinned ? 'Unpin Tab' : 'Pin Tab'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => !isPinned && onClose()} disabled={isPinned}>
Close
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseAll}>Close All Editor Tabs</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseToRight} disabled={!hasTabsToRight}>
Close Tabs To The Right
</DropdownMenuItem>
<DropdownMenuSeparator />
{canShowMarkdownPreview && (
<>
<DropdownMenuItem
onSelect={() => {
onActivate()
openMarkdownPreview(
{
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
runtimeEnvironmentId: file.runtimeEnvironmentId,
language: resolvedLanguage
},
{ sourceFileId: file.id }
)
}}
>
Open Markdown Preview
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onSelect={() => {
void window.api.ui.writeClipboardText(file.filePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Path
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void window.api.ui.writeClipboardText(file.relativePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Relative Path
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
if (
shouldBlockEditorTabLocalOpen(
useAppStore.getState().settings,
file.runtimeEnvironmentId,
repo?.connectionId ?? null
)
) {
showLocalPathOpenBlockedToast()
return
}
window.api.shell.openPath(file.filePath)
}}
>
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
{revealLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<EditorFileTabContextMenu
open={menuOpen}
menuPoint={menuPoint}
file={file}
isPinned={isPinned}
isRenaming={isRenaming}
hasTabsToRight={hasTabsToRight}
canRename={canRename}
canShowMarkdownPreview={canShowMarkdownPreview}
resolvedLanguage={resolvedLanguage}
repoConnectionId={repo?.connectionId ?? null}
skipMenuFocusRestoreRef={skipMenuFocusRestoreRef}
onOpenChange={setMenuOpen}
onActivate={onActivate}
onOpenRenameInput={openRenameInput}
onTogglePin={onTogglePin}
onClose={onClose}
onCloseAll={onCloseAll}
onCloseToRight={onCloseToRight}
onSplitGroup={onSplitGroup}
onOpenMarkdownPreview={openMarkdownPreview}
/>
</>
)
}
@@ -0,0 +1,204 @@
import { Copy, ExternalLink, Columns2, Rows2, Pencil, Pin, PinOff } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useAppStore } from '@/store'
import { showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
import type { OpenFile } from '../../store/slices/editor'
import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard'
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 EditorFileTabContextMenuProps = {
open: boolean
menuPoint: { x: number; y: number }
file: OpenFile & { tabId?: string }
isPinned: boolean
isRenaming: boolean
hasTabsToRight: boolean
canRename: boolean
canShowMarkdownPreview: boolean
resolvedLanguage: string
repoConnectionId: string | null
skipMenuFocusRestoreRef: React.MutableRefObject<boolean>
onOpenChange: (open: boolean) => void
onActivate: () => void
onOpenRenameInput: () => void
onTogglePin: () => void
onClose: () => void
onCloseAll: () => void
onCloseToRight: () => void
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
onOpenMarkdownPreview: (
file: {
filePath: string
relativePath: string
worktreeId: string
runtimeEnvironmentId?: string | null
language: string
},
options: { sourceFileId: string }
) => void
}
export function EditorFileTabContextMenu({
open,
menuPoint,
file,
isPinned,
isRenaming,
hasTabsToRight,
canRename,
canShowMarkdownPreview,
resolvedLanguage,
repoConnectionId,
skipMenuFocusRestoreRef,
onOpenChange,
onActivate,
onOpenRenameInput,
onTogglePin,
onClose,
onCloseAll,
onCloseToRight,
onSplitGroup,
onOpenMarkdownPreview
}: EditorFileTabContextMenuProps): React.JSX.Element {
const sourceVisibleTabId = file.tabId ?? file.id
return (
<DropdownMenu open={open} onOpenChange={onOpenChange} modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none fixed size-px opacity-0"
style={{ left: menuPoint.x, top: menuPoint.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48"
sideOffset={0}
align="start"
onCloseAutoFocus={(event) => {
if (!skipMenuFocusRestoreRef.current) {
return
}
skipMenuFocusRestoreRef.current = false
event.preventDefault()
}}
>
<DropdownMenuItem onSelect={() => onSplitGroup('up', sourceVisibleTabId)}>
<Rows2 className="mr-1.5 size-3.5" />
Split Up
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('down', sourceVisibleTabId)}>
<Rows2 className="mr-1.5 size-3.5" />
Split Down
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('left', sourceVisibleTabId)}>
<Columns2 className="mr-1.5 size-3.5" />
Split Left
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('right', sourceVisibleTabId)}>
<Columns2 className="mr-1.5 size-3.5" />
Split Right
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={!canRename || isRenaming}
onSelect={() => {
skipMenuFocusRestoreRef.current = true
onActivate()
onOpenRenameInput()
}}
>
<Pencil className="mr-1.5 size-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onTogglePin}>
{isPinned ? <PinOff className="mr-1.5 size-3.5" /> : <Pin className="mr-1.5 size-3.5" />}
{isPinned ? 'Unpin Tab' : 'Pin Tab'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => !isPinned && onClose()} disabled={isPinned}>
Close
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseAll}>Close All Editor Tabs</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseToRight} disabled={!hasTabsToRight}>
Close Tabs To The Right
</DropdownMenuItem>
<DropdownMenuSeparator />
{canShowMarkdownPreview ? (
<>
<DropdownMenuItem
onSelect={() => {
onActivate()
onOpenMarkdownPreview(
{
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
runtimeEnvironmentId: file.runtimeEnvironmentId,
language: resolvedLanguage
},
{ sourceFileId: file.id }
)
}}
>
Open Markdown Preview
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuItem
onSelect={() => {
void window.api.ui.writeClipboardText(file.filePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Path
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void window.api.ui.writeClipboardText(file.relativePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Relative Path
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
if (
shouldBlockEditorTabLocalOpen(
useAppStore.getState().settings,
file.runtimeEnvironmentId,
repoConnectionId
)
) {
showLocalPathOpenBlockedToast()
return
}
window.api.shell.openPath(file.filePath)
}}
>
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
{revealLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
+9 -148
View File
@@ -1,10 +1,15 @@
/* eslint-disable max-lines -- Why: the supported-agent catalog, icon glyphs,
and fallback icon renderer must stay in one place so agent option labels and
status-surface icons do not drift. */
import React from 'react'
import type React from 'react'
import { ClaudeIcon, DroidIcon, OpenAIIcon } from '@/components/status-bar/icons'
import openClaudeLogoUrl from '../../../../resources/openclaude-logo.png?url'
import type { TuiAgent } from '../../../shared/types'
import {
AgentLetterIcon,
AiderIcon,
CopilotIcon,
KiloIcon,
OmpIcon,
PiIcon
} from './agent-icon-glyphs'
export type AgentCatalogEntry = {
id: TuiAgent
@@ -242,150 +247,6 @@ export function getAgentLabel(agent: TuiAgent): string {
return AGENT_CATALOG.find((entry) => entry.id === agent)?.label ?? agent
}
function PiIcon({ size = 14 }: { size?: number }): React.JSX.Element {
// SVG sourced from pi.dev/favicon.svg — the π shape rendered in currentColor.
// Why: className="text-current" opts out of shadcn's Select rule that forces
// text-muted-foreground on any <svg> that lacks a text-* class.
return (
<svg
height={size}
width={size}
viewBox="0 0 800 800"
xmlns="http://www.w3.org/2000/svg"
className="text-current"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
/>
<path fill="currentColor" d="M517.36 400 H634.72 V634.72 H517.36 Z" />
</svg>
)
}
function OmpIcon({ size = 14 }: { size?: number }): React.JSX.Element {
const gradientId = `${React.useId().replace(/:/g, '')}-omp-gradient`
// SVG sourced from omp.sh's homepage mark. Why: omp.sh/favicon.svg includes
// a dark square background, while the homepage mark is transparent.
return (
<svg
width={size}
height={size}
viewBox="0 0 64 64"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="oklch(0.7 0.24 340)" />
<stop offset=".5" stopColor="oklch(0.62 0.21 295)" />
<stop offset="1" stopColor="oklch(0.81 0.14 200)" />
</linearGradient>
</defs>
<path fill={`url(#${gradientId})`} d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
</svg>
)
}
function KiloIcon({ size = 14 }: { size?: number }): React.JSX.Element {
// SVG sourced from Kilo-Org/kilocode:packages/kilo-vscode/assets/icons/kilo-light.svg.
// Why: the Google favicon for kilo.ai is black-on-black at small sizes and
// is illegible. Inlining the brand mark (yellow on black) keeps it readable
// on both light and dark app themes without using currentColor — this logo
// is intentionally brand-colored, not theme-colored.
return (
<svg
width={size}
height={size}
viewBox="0 0 512 512"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
style={{ borderRadius: 2 }}
>
<path d="M512 0H0V512H512V0Z" fill="black" />
<path
d="M322 377H377V421H307.857L278 391.143V322H322V377ZM421 307.857L391.143 278H322V322L377 322V377H421V307.857ZM234 278H190V322H234V278ZM91 391.143L120.857 421H234V377H135V278H91V391.143ZM371.172 189.999V120.856L341.315 90.9995H278V135H327.172V189.999H278V233.999H421V189.999H371.172ZM135 91H91V233.999H135V184.5H190V233.999H234V184.5L190 140.5H135V91ZM234 91H190V140.5H234V91Z"
fill="#FAF74F"
/>
</svg>
)
}
function AiderIcon({ size = 14 }: { size?: number }): React.JSX.Element {
// SVG sourced from aider.chat/assets/icons/safari-pinned-tab.svg.
// Why: className="text-current" opts out of shadcn's Select rule that forces
// text-muted-foreground on any <svg> that lacks a text-* class.
return (
<svg
width={size}
height={size}
viewBox="0 0 436 436"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
className="text-current"
>
<g transform="translate(0,436) scale(0.1,-0.1)" fill="currentColor" stroke="none">
<path d="M0 2180 l0 -2180 2180 0 2180 0 0 2180 0 2180 -2180 0 -2180 0 0 -2180z m2705 1818 c20 -20 28 -121 30 -398 l2 -305 216 -5 c118 -3 218 -8 222 -12 3 -3 10 -46 15 -95 5 -48 16 -126 25 -172 17 -86 17 -81 -17 -233 -14 -67 -13 -365 2 -438 21 -100 22 -159 5 -247 -24 -122 -24 -363 1 -458 23 -88 23 -213 1 -330 -9 -49 -17 -109 -17 -132 l0 -43 203 0 c111 0 208 -4 216 -9 10 -6 18 -51 27 -148 8 -76 16 -152 20 -168 7 -39 -23 -361 -37 -387 -10 -18 -21 -19 -214 -16 -135 2 -208 7 -215 14 -22 22 -33 301 -21 501 6 102 8 189 5 194 -8 13 -417 12 -431 -2 -12 -12 -8 -146 8 -261 8 -55 8 -95 1 -140 -6 -35 -14 -99 -17 -143 -9 -123 -14 -141 -41 -154 -18 -8 -217 -11 -679 -11 l-653 0 -11 33 c-31 97 -43 336 -27 533 5 56 6 113 2 128 l-6 26 -194 0 c-211 0 -252 4 -261 28 -12 33 -17 392 -6 522 15 186 -2 174 260 180 115 3 213 8 217 12 4 4 1 52 -5 105 -7 54 -17 130 -22 168 -7 56 -5 91 11 171 10 55 22 130 26 166 4 36 10 72 15 79 7 12 128 15 665 19 l658 5 8 30 c5 18 4 72 -3 130 -12 115 -7 346 11 454 10 61 10 75 -1 82 -8 5 -300 9 -650 9 l-636 0 -27 25 c-18 16 -26 34 -26 57 0 18 -5 87 -10 153 -10 128 5 449 22 472 5 7 26 13 46 15 78 6 1281 3 1287 -4z" />
<path d="M1360 1833 c0 -5 -1 -164 -3 -356 l-2 -347 625 -1 c704 -1 708 -1 722 7 5 4 7 20 4 38 -29 141 -32 491 -6 595 9 38 8 45 -7 57 -15 11 -139 13 -675 14 -362 0 -658 -3 -658 -7z" />
</g>
</svg>
)
}
function CopilotIcon({ size = 14 }: { size?: number }): React.JSX.Element {
// SVG sourced from Primer Octicons' copilot-16 icon. GitHub's 2025 brand
// guidance deprecated the old standalone Copilot mascot logo.
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
className="text-current"
fill="currentColor"
>
<path d="M7.998 15.035c-4.562 0-7.873-2.914-7.998-3.749V9.338c.085-.628.677-1.686 1.588-2.065.013-.07.024-.143.036-.218.029-.183.06-.384.126-.612-.201-.508-.254-1.084-.254-1.656 0-.87.128-1.769.693-2.484.579-.733 1.494-1.124 2.724-1.261 1.206-.134 2.262.034 2.944.765.05.053.096.108.139.165.044-.057.094-.112.143-.165.682-.731 1.738-.899 2.944-.765 1.23.137 2.145.528 2.724 1.261.566.715.693 1.614.693 2.484 0 .572-.053 1.148-.254 1.656.066.228.098.429.126.612.012.076.024.148.037.218.924.385 1.522 1.471 1.591 2.095v1.872c0 .766-3.351 3.795-8.002 3.795Zm0-1.485c2.28 0 4.584-1.11 5.002-1.433V7.862l-.023-.116c-.49.21-1.075.291-1.727.291-1.146 0-2.059-.327-2.71-.991A3.222 3.222 0 0 1 8 6.303a3.24 3.24 0 0 1-.544.743c-.65.664-1.563.991-2.71.991-.652 0-1.236-.081-1.727-.291l-.023.116v4.255c.419.323 2.722 1.433 5.002 1.433ZM6.762 2.83c-.193-.206-.637-.413-1.682-.297-1.019.113-1.479.404-1.713.7-.247.312-.369.789-.369 1.554 0 .793.129 1.171.308 1.371.162.181.519.379 1.442.379.853 0 1.339-.235 1.638-.54.315-.322.527-.827.617-1.553.117-.935-.037-1.395-.241-1.614Zm4.155-.297c-1.044-.116-1.488.091-1.681.297-.204.219-.359.679-.242 1.614.091.726.303 1.231.618 1.553.299.305.784.54 1.638.54.922 0 1.28-.198 1.442-.379.179-.2.308-.578.308-1.371 0-.765-.123-1.242-.37-1.554-.233-.296-.693-.587-1.713-.7Z" />
<path d="M6.25 9.037a.75.75 0 0 1 .75.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 .75-.75Zm4.25.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 1.5 0Z" />
</svg>
)
}
function AgentLetterIcon({
letter,
size = 14
}: {
letter: string
size?: number
}): React.JSX.Element {
return (
<svg
width={size}
height={size}
viewBox="0 0 14 14"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
className="text-current"
>
<rect width="14" height="14" rx="3" fill="currentColor" fillOpacity="0.2" />
<text
x="7"
y="10.5"
textAnchor="middle"
fontSize="8.5"
fill="currentColor"
fontWeight="700"
fontFamily="system-ui, -apple-system, sans-serif"
>
{letter}
</text>
</svg>
)
}
export function AgentIcon({
agent,
size = 14
+145
View File
@@ -0,0 +1,145 @@
import React from 'react'
export function PiIcon({ size = 14 }: { size?: number }): React.JSX.Element {
// SVG sourced from pi.dev/favicon.svg — the π shape rendered in currentColor.
// Why: className="text-current" opts out of shadcn's Select rule that forces
// text-muted-foreground on any <svg> that lacks a text-* class.
return (
<svg
height={size}
width={size}
viewBox="0 0 800 800"
xmlns="http://www.w3.org/2000/svg"
className="text-current"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
/>
<path fill="currentColor" d="M517.36 400 H634.72 V634.72 H517.36 Z" />
</svg>
)
}
export function OmpIcon({ size = 14 }: { size?: number }): React.JSX.Element {
const gradientId = `${React.useId().replace(/:/g, '')}-omp-gradient`
// SVG sourced from omp.sh's homepage mark. Why: omp.sh/favicon.svg includes
// a dark square background, while the homepage mark is transparent.
return (
<svg
width={size}
height={size}
viewBox="0 0 64 64"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="oklch(0.7 0.24 340)" />
<stop offset=".5" stopColor="oklch(0.62 0.21 295)" />
<stop offset="1" stopColor="oklch(0.81 0.14 200)" />
</linearGradient>
</defs>
<path fill={`url(#${gradientId})`} d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
</svg>
)
}
export function KiloIcon({ size = 14 }: { size?: number }): React.JSX.Element {
// SVG sourced from Kilo-Org/kilocode:packages/kilo-vscode/assets/icons/kilo-light.svg.
// Why: the Google favicon for kilo.ai is black-on-black at small sizes and
// is illegible. Inlining the brand mark (yellow on black) keeps it readable
// on both light and dark app themes without using currentColor — this logo
// is intentionally brand-colored, not theme-colored.
return (
<svg
width={size}
height={size}
viewBox="0 0 512 512"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
style={{ borderRadius: 2 }}
>
<path d="M512 0H0V512H512V0Z" fill="black" />
<path
d="M322 377H377V421H307.857L278 391.143V322H322V377ZM421 307.857L391.143 278H322V322L377 322V377H421V307.857ZM234 278H190V322H234V278ZM91 391.143L120.857 421H234V377H135V278H91V391.143ZM371.172 189.999V120.856L341.315 90.9995H278V135H327.172V189.999H278V233.999H421V189.999H371.172ZM135 91H91V233.999H135V184.5H190V233.999H234V184.5L190 140.5H135V91ZM234 91H190V140.5H234V91Z"
fill="#FAF74F"
/>
</svg>
)
}
export function AiderIcon({ size = 14 }: { size?: number }): React.JSX.Element {
// SVG sourced from aider.chat/assets/icons/safari-pinned-tab.svg.
// Why: className="text-current" opts out of shadcn's Select rule that forces
// text-muted-foreground on any <svg> that lacks a text-* class.
return (
<svg
width={size}
height={size}
viewBox="0 0 436 436"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
className="text-current"
>
<g transform="translate(0,436) scale(0.1,-0.1)" fill="currentColor" stroke="none">
<path d="M0 2180 l0 -2180 2180 0 2180 0 0 2180 0 2180 -2180 0 -2180 0 0 -2180z m2705 1818 c20 -20 28 -121 30 -398 l2 -305 216 -5 c118 -3 218 -8 222 -12 3 -3 10 -46 15 -95 5 -48 16 -126 25 -172 17 -86 17 -81 -17 -233 -14 -67 -13 -365 2 -438 21 -100 22 -159 5 -247 -24 -122 -24 -363 1 -458 23 -88 23 -213 1 -330 -9 -49 -17 -109 -17 -132 l0 -43 203 0 c111 0 208 -4 216 -9 10 -6 18 -51 27 -148 8 -76 16 -152 20 -168 7 -39 -23 -361 -37 -387 -10 -18 -21 -19 -214 -16 -135 2 -208 7 -215 14 -22 22 -33 301 -21 501 6 102 8 189 5 194 -8 13 -417 12 -431 -2 -12 -12 -8 -146 8 -261 8 -55 8 -95 1 -140 -6 -35 -14 -99 -17 -143 -9 -123 -14 -141 -41 -154 -18 -8 -217 -11 -679 -11 l-653 0 -11 33 c-31 97 -43 336 -27 533 5 56 6 113 2 128 l-6 26 -194 0 c-211 0 -252 4 -261 28 -12 33 -17 392 -6 522 15 186 -2 174 260 180 115 3 213 8 217 12 4 4 1 52 -5 105 -7 54 -17 130 -22 168 -7 56 -5 91 11 171 10 55 22 130 26 166 4 36 10 72 15 79 7 12 128 15 665 19 l658 5 8 30 c5 18 4 72 -3 130 -12 115 -7 346 11 454 10 61 10 75 -1 82 -8 5 -300 9 -650 9 l-636 0 -27 25 c-18 16 -26 34 -26 57 0 18 -5 87 -10 153 -10 128 5 449 22 472 5 7 26 13 46 15 78 6 1281 3 1287 -4z" />
<path d="M1360 1833 c0 -5 -1 -164 -3 -356 l-2 -347 625 -1 c704 -1 708 -1 722 7 5 4 7 20 4 38 -29 141 -32 491 -6 595 9 38 8 45 -7 57 -15 11 -139 13 -675 14 -362 0 -658 -3 -658 -7z" />
</g>
</svg>
)
}
export function CopilotIcon({ size = 14 }: { size?: number }): React.JSX.Element {
// SVG sourced from Primer Octicons' copilot-16 icon. GitHub's 2025 brand
// guidance deprecated the old standalone Copilot mascot logo.
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
className="text-current"
fill="currentColor"
>
<path d="M7.998 15.035c-4.562 0-7.873-2.914-7.998-3.749V9.338c.085-.628.677-1.686 1.588-2.065.013-.07.024-.143.036-.218.029-.183.06-.384.126-.612-.201-.508-.254-1.084-.254-1.656 0-.87.128-1.769.693-2.484.579-.733 1.494-1.124 2.724-1.261 1.206-.134 2.262.034 2.944.765.05.053.096.108.139.165.044-.057.094-.112.143-.165.682-.731 1.738-.899 2.944-.765 1.23.137 2.145.528 2.724 1.261.566.715.693 1.614.693 2.484 0 .572-.053 1.148-.254 1.656.066.228.098.429.126.612.012.076.024.148.037.218.924.385 1.522 1.471 1.591 2.095v1.872c0 .766-3.351 3.795-8.002 3.795Zm0-1.485c2.28 0 4.584-1.11 5.002-1.433V7.862l-.023-.116c-.49.21-1.075.291-1.727.291-1.146 0-2.059-.327-2.71-.991A3.222 3.222 0 0 1 8 6.303a3.24 3.24 0 0 1-.544.743c-.65.664-1.563.991-2.71.991-.652 0-1.236-.081-1.727-.291l-.023.116v4.255c.419.323 2.722 1.433 5.002 1.433ZM6.762 2.83c-.193-.206-.637-.413-1.682-.297-1.019.113-1.479.404-1.713.7-.247.312-.369.789-.369 1.554 0 .793.129 1.171.308 1.371.162.181.519.379 1.442.379.853 0 1.339-.235 1.638-.54.315-.322.527-.827.617-1.553.117-.935-.037-1.395-.241-1.614Zm4.155-.297c-1.044-.116-1.488.091-1.681.297-.204.219-.359.679-.242 1.614.091.726.303 1.231.618 1.553.299.305.784.54 1.638.54.922 0 1.28-.198 1.442-.379.179-.2.308-.578.308-1.371 0-.765-.123-1.242-.37-1.554-.233-.296-.693-.587-1.713-.7Z" />
<path d="M6.25 9.037a.75.75 0 0 1 .75.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 .75-.75Zm4.25.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 1.5 0Z" />
</svg>
)
}
export function AgentLetterIcon({
letter,
size = 14
}: {
letter: string
size?: number
}): React.JSX.Element {
return (
<svg
width={size}
height={size}
viewBox="0 0 14 14"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
className="text-current"
>
<rect width="14" height="14" rx="3" fill="currentColor" fillOpacity="0.2" />
<text
x="7"
y="10.5"
textAnchor="middle"
fontSize="8.5"
fill="currentColor"
fontWeight="700"
fontFamily="system-ui, -apple-system, sans-serif"
>
{letter}
</text>
</svg>
)
}