refactor(renderer): split oversized UI surfaces

(cherry picked from commit da89be4345)
This commit is contained in:
Neil
2026-09-01 00:06:23 -07:00
parent 0b2912b507
commit 286c21005b
370 changed files with 53838 additions and 30703 deletions
+6 -4
View File
@@ -21,7 +21,7 @@ const PERSISTED_UI_WRITER_PATH = 'src/renderer/src/app-shell/use-persisted-ui-wr
describe('renderer startup runtime routing', () => {
it('routes packaged terminal restore through the daemon adoption gate', () => {
const source = readFileSync(
join(process.cwd(), 'src/renderer/src/components/Terminal.tsx'),
join(process.cwd(), 'src/renderer/src/components/use-terminal-watcher-effects.ts'),
'utf8'
)
const gateStart = source.indexOf('const startupActivationGateWorktreeIdsRef')
@@ -226,7 +226,9 @@ describe('renderer startup runtime routing', () => {
})
it('keeps the persisted Automations view from starting its own bootstrap worktree scan', () => {
const source = readSource('src/renderer/src/components/automations/AutomationsPage.tsx')
const source = readSource(
'src/renderer/src/components/automations/use-automations-page-refresh.ts'
)
const fullRefreshStart = source.indexOf('const mountedBeforeStartupWorktreeRefreshRef')
const fullRefreshEffect = source.slice(
fullRefreshStart,
@@ -348,7 +350,7 @@ describe('renderer startup runtime routing', () => {
'utf8'
)
const terminalSource = readFileSync(
join(process.cwd(), 'src/renderer/src/components/Terminal.tsx'),
join(process.cwd(), 'src/renderer/src/components/use-terminal-watcher-effects.ts'),
'utf8'
)
const hydrateIndex = appSource.indexOf("timeRendererStartupSyncStep('hydrate-session-stores'")
@@ -468,7 +470,7 @@ describe('renderer startup runtime routing', () => {
})
it('does not eagerly import optional status-bar segments on startup', () => {
const source = readSource('src/renderer/src/components/status-bar/StatusBar.tsx')
const source = readSource('src/renderer/src/components/status-bar/StatusBarSurface.tsx')
expect(source).toContain("import('./ResourceUsageStatusSegment').then")
expect(source).toContain("import('./PortsStatusSegment').then")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,75 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import PullRequestPage from '@/components/PullRequestPage'
import GitHubItemDialog from '@/components/GitHubItemDialog'
import ProjectViewWrapper from '@/components/github-project/ProjectViewWrapper'
import { TaskPageGitHubList } from './TaskPageGitHubList'
import { TaskPageGitLabTodoList } from './TaskPageGitLabTodoList'
import { TaskPageGitLabItemList } from './TaskPageGitLabItemList'
import { TaskPageJiraContent } from './TaskPageJiraContent'
export function TaskPageContent({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
repoSelection,
taskSource,
githubMode,
gitlabView,
dialogInitialTab,
dialogWorkItem,
dialogRepoPath,
dialogSourceContext,
setDialogWorkItem,
handleDialogReviewRequestsChange,
closeTaskDetailPage,
handleUseWorkItem
} = model
return taskSource === 'github' && dialogWorkItem ? (
dialogWorkItem.type === 'pr' ? (
<PullRequestPage
workItem={dialogWorkItem}
initialTab={dialogInitialTab}
repoPath={dialogRepoPath}
repoId={dialogWorkItem.repoId}
sourceContext={dialogSourceContext}
backLabel="Pull requests"
onUse={(item) => {
setDialogWorkItem(null)
handleUseWorkItem(item)
}}
onReviewRequestsChange={handleDialogReviewRequestsChange}
onClose={closeTaskDetailPage}
/>
) : (
<GitHubItemDialog
workItem={dialogWorkItem}
initialTab={dialogInitialTab}
repoPath={dialogRepoPath}
repoId={dialogWorkItem.repoId}
sourceContext={dialogSourceContext}
backLabel="GitHub list"
onUse={(item) => {
setDialogWorkItem(null)
handleUseWorkItem(item)
}}
onReviewRequestsChange={handleDialogReviewRequestsChange}
onClose={closeTaskDetailPage}
/>
)
) : taskSource === 'github' && githubMode === 'project' ? (
<div className="mt-3 flex min-h-0 min-w-0 max-h-full flex-col overflow-hidden rounded-md border border-border/50 bg-muted/50 shadow-sm">
<ProjectViewWrapper selectedRepoIds={repoSelection} />
</div>
) : taskSource === 'github' ? (
// Why: bottom of the joined GitHub list card — flush under the filter
// chrome (no gap, no top border/radius) so toolbar + table read as one.
<TaskPageGitHubList model={model} />
) : taskSource === 'gitlab' && gitlabView === 'todos' ? (
<TaskPageGitLabTodoList model={model} />
) : taskSource === 'gitlab' ? (
<TaskPageGitLabItemList model={model} />
) : (
<TaskPageJiraContent model={model} />
)
}
@@ -0,0 +1,19 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { TaskPageListChrome } from './TaskPageListChrome'
import { TaskPageContent } from './TaskPageContent'
export function TaskPageFrame({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
return (
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
{/* Why: pt-1.5 (6px) aligns this 32px icon cluster's center with the sidebar Tasks row, 22px below the titlebar. */}
<div className="mx-auto flex min-h-0 min-w-0 w-full flex-1 flex-col px-5 pt-1.5 pb-4 md:px-8 md:pt-1.5 md:pb-5">
<TaskPageListChrome model={model} />
<TaskPageContent model={model} />
</div>
</div>
)
}
@@ -0,0 +1,295 @@
import type { GitHubWorkItem } from '../../../shared/github/work-item-types'
import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types'
import type { Repo } from '../../../shared/repo-types'
import {
type TaskSourceContext,
getTaskSourceRuntimeSettings
} from '../../../shared/task-source-context'
import React, { useMemo, useState, useCallback } from 'react'
import { useAppStore } from '@/store'
import { useShallow } from 'zustand/react/shallow'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
import { parseGitHubIssueOrPRLink } from '@/lib/github-links'
import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
import { translate } from '@/i18n/i18n'
import { getActiveRuntimeTarget, callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { githubProjectHost } from '../../../shared/github/project-identity'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { LoaderCircle, ChevronDown, Check } from 'lucide-react'
import type { TaskPageGitHubWorkItemMutationRunner } from './task-page-linear-jira-list-model'
import { GitHubAssigneeAvatar } from './TaskPageGitHubAvatars'
export function GHAssigneesCell({
item,
repo,
sourceContext,
workItemMutation
}: {
item: GitHubWorkItem
repo: Repo | null
sourceContext?: TaskSourceContext | null
workItemMutation: TaskPageGitHubWorkItemMutationRunner
}): React.JSX.Element {
const repoOwnerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null))
)
const sourceSettings = useMemo(
() =>
sourceContext?.provider === 'github'
? ({
...repoOwnerSettings,
...getTaskSourceRuntimeSettings(sourceContext)
} as typeof repoOwnerSettings)
: repoOwnerSettings,
[repoOwnerSettings, sourceContext]
)
const [open, setOpen] = useState(false)
const [pendingLogin, setPendingLogin] = useState<string | null>(null)
const assignees = useMemo(() => item.assignees ?? [], [item.assignees])
const parsed = useMemo(() => parseGitHubIssueOrPRLink(item.url), [item.url])
const owner = parsed?.slug.owner ?? null
const repoName = parsed?.slug.repo ?? null
const seedLogins = useMemo(
() =>
assignees
.map((a) => a.login)
.sort()
.filter(Boolean),
[assignees]
)
const metadata = useRepoAssigneesBySlug(
open ? owner : null,
open ? repoName : null,
seedLogins,
sourceSettings,
parsed?.slug.host
)
const toggleAssignee = useCallback(
async (user: GitHubAssignableUser): Promise<void> => {
if (item.type !== 'issue') {
return
}
const userLoginKey = user.login.toLowerCase()
const isOn = assignees.some((a) => a.login.toLowerCase() === userLoginKey)
if (
workItemMutation.isIntentPending({
item,
intent: {
type: 'toggleAssignee',
user
},
sourceContext
})
) {
return
}
setPendingLogin(user.login)
try {
await workItemMutation.run({
item,
intent: {
type: 'toggleAssignee',
user
},
sourceContext,
errorToast: translate(
'auto.components.TaskPage.ca63694b4c',
'Failed to update assignees.'
),
mutate: async () => {
const updates = isOn
? {
removeAssignees: [user.login]
}
: {
addAssignees: [user.login]
}
const target = getActiveRuntimeTarget(sourceSettings)
if (owner && repoName) {
const args = {
owner,
repo: repoName,
host: githubProjectHost(parsed?.slug.host),
number: item.number,
updates
}
const res =
target.kind === 'environment'
? await callRuntimeRpc<
Awaited<ReturnType<typeof window.api.gh.updateIssueBySlug>>
>(target, 'github.project.updateIssueBySlug', args, {
timeoutMs: 30_000
})
: await window.api.gh.updateIssueBySlug(args)
if (!res.ok) {
throw new Error(res.error.message)
}
return res
}
if (repo) {
const runtimeRepoId =
sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id
const res =
target.kind === 'environment'
? await callRuntimeRpc<{
ok?: boolean
error?: string
}>(
target,
'github.updateIssue',
{
repo: runtimeRepoId,
number: item.number,
updates
},
{
timeoutMs: 30_000
}
)
: await window.api.gh.updateIssue({
repoPath: repo.path,
repoId: repo.id,
sourceContext,
number: item.number,
updates
})
if (res && res.ok === false) {
throw new Error(res.error)
}
return res
}
throw new Error('No GitHub repository context available for this issue.')
}
})
} finally {
setPendingLogin(null)
}
},
[
assignees,
item,
owner,
parsed?.slug.host,
repo,
repoName,
sourceContext,
sourceSettings,
workItemMutation
]
)
const triggerContent =
assignees.length > 0 ? (
<>
<div className="flex min-w-0 -space-x-1 overflow-hidden">
{assignees.slice(0, 3).map((assignee) => (
<GitHubAssigneeAvatar key={assignee.login} assignee={assignee} />
))}
</div>
{assignees.length > 3 ? (
<span className="ml-1 shrink-0 text-[10px] font-medium text-muted-foreground">
+{assignees.length - 3}
</span>
) : null}
</>
) : (
<span className="text-xs text-muted-foreground/60">-</span>
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={
assignees.length
? translate('auto.components.TaskPage.bb63046423', 'Assigned to {{value0}}', {
value0: assignees.map((a) => a.login).join(', ')
})
: translate('auto.components.TaskPage.7f94eb6395', 'Assign issue')
}
aria-busy={pendingLogin !== null}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
className={cn(
'inline-flex h-6 max-w-full items-center gap-1 text-left transition disabled:opacity-60',
assignees.length > 0
? 'rounded-full border border-border/40 bg-background/70 px-1.5 hover:bg-muted/60'
: 'w-full rounded-sm border border-transparent bg-transparent px-1 hover:bg-muted/40'
)}
>
{triggerContent}
{pendingLogin ? (
<LoaderCircle className="size-3 shrink-0 animate-spin text-muted-foreground" />
) : assignees.length > 0 ? (
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
) : null}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="popover-scroll-content scrollbar-sleek w-64 p-1"
onClick={(event) => event.stopPropagation()}
>
{!owner || !repoName ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
{translate('auto.components.TaskPage.53e002d895', 'Issue has no repo slug.')}
</div>
) : metadata.loading ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
{translate('auto.components.TaskPage.0eacf48491', 'Loading…')}
</div>
) : metadata.error ? (
<div className="px-2 py-2 text-xs text-destructive">{metadata.error}</div>
) : metadata.data.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
{translate('auto.components.TaskPage.edf4bc4135', 'No assignable users.')}
</div>
) : (
metadata.data.map((user) => {
const isOn = assignees.some((a) => a.login.toLowerCase() === user.login.toLowerCase())
const pending = pendingLogin === user.login
return (
<button
key={user.login}
type="button"
disabled={pendingLogin !== null}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted/50 disabled:opacity-60"
onClick={(event) => {
event.stopPropagation()
void toggleAssignee(user)
}}
>
<span
className={cn(
'flex size-3.5 shrink-0 items-center justify-center rounded-sm border',
isOn ? 'border-primary bg-primary text-primary-foreground' : 'border-input'
)}
>
{pending ? (
<LoaderCircle className="size-3 animate-spin" />
) : isOn ? (
<Check className="size-3" />
) : null}
</span>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="size-5 shrink-0 rounded-full" />
) : (
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
{user.login.slice(0, 1).toUpperCase()}
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate">{user.login}</span>
{user.name ? (
<span className="block truncate text-[11px] text-muted-foreground">
{user.name}
</span>
) : null}
</span>
</button>
)
})
)}
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,54 @@
import type { GitHubPRPrimaryReviewer } from '@/components/github-pr-reviewer-display'
import React from 'react'
import { GitHubUserAvatar } from '@/components/github/github-user-avatar'
import { Users } from 'lucide-react'
import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types'
export function ReviewChipAvatar({
reviewer,
avatarHost
}: {
reviewer: GitHubPRPrimaryReviewer | null
avatarHost?: string
}): React.JSX.Element {
if (reviewer?.login) {
// Why: review requests may contain only logins; use the PR host before falling back to initials.
const avatarUrl =
reviewer.avatarUrl || `https://${avatarHost ?? 'github.com'}/${reviewer.login}.png?size=40`
return (
<GitHubUserAvatar
login={reviewer.login}
name={reviewer.name}
avatarUrl={avatarUrl}
title={reviewer.name ? `${reviewer.name} (${reviewer.login})` : reviewer.login}
className="size-5"
/>
)
}
return <Users className="size-5 shrink-0" />
}
export function GitHubAssigneeAvatar({
assignee
}: {
assignee: GitHubAssignableUser
}): React.JSX.Element {
if (assignee.avatarUrl) {
return (
<img
src={assignee.avatarUrl}
alt={assignee.login}
loading="lazy"
decoding="async"
title={assignee.name ? `${assignee.name} (${assignee.login})` : assignee.login}
className="size-5 rounded-full border border-border/40 bg-muted object-cover"
/>
)
}
return (
<span
title={assignee.login}
className="inline-flex size-5 items-center justify-center rounded-full border border-border/40 bg-muted text-[10px] font-medium text-muted-foreground"
>
{assignee.login.slice(0, 1).toUpperCase()}
</span>
)
}
@@ -0,0 +1,86 @@
import type { GitHubWorkItem } from '../../../shared/github/work-item-types'
import React, { useRef, useEffect } from 'react'
import { translate } from '@/i18n/i18n'
import { CheckCircle2, AlertCircle, Clock3, Minus } from 'lucide-react'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { getChecksPillTone, getChecksLabel } from '@/components/task-page-checks-pill'
export function PRChecksCell({
item,
onOpen,
onLoadChecks
}: {
item: GitHubWorkItem
onOpen: () => void
onLoadChecks: () => void
}): React.JSX.Element {
const triggerRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
if (item.type !== 'pr' || item.checksSummary) {
return
}
const node = triggerRef.current
if (!node || typeof IntersectionObserver === 'undefined') {
return
}
let requested = false
const observer = new IntersectionObserver(
(entries) => {
if (requested || !entries.some((entry) => entry.isIntersecting)) {
return
}
requested = true
onLoadChecks()
observer.disconnect()
},
{
rootMargin: '160px 0px'
}
)
observer.observe(node)
return () => observer.disconnect()
}, [item.checksSummary, item.type, onLoadChecks])
if (item.type !== 'pr') {
return (
<span className="text-[11px] text-muted-foreground">
{translate('auto.components.TaskPage.b1eaa18ace', 'Issue')}
</span>
)
}
const summary = item.checksSummary
const Icon =
summary?.state === 'success'
? CheckCircle2
: summary?.state === 'failure'
? AlertCircle
: summary?.state === 'pending'
? Clock3
: Minus
return (
<Tooltip>
<TooltipTrigger asChild>
<button
ref={triggerRef}
type="button"
onFocus={onLoadChecks}
onMouseEnter={onLoadChecks}
onClick={(event) => {
event.stopPropagation()
onLoadChecks()
onOpen()
}}
className={cn(
'inline-flex max-w-full items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium transition hover:brightness-110',
getChecksPillTone(item)
)}
>
<Icon className="size-3" />
<span className="truncate">{getChecksLabel(item)}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.TaskPage.995dd6af9b', 'Open PR checks')}
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,118 @@
import type React from 'react'
import { CheckCircle2, ChevronLeft, CircleDot, Copy, Search } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import type { GitHubWorkItem } from '../../../shared/github/work-item-types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
export function TaskPageGitHubDuplicatePicker({
closeAsDuplicate,
directDuplicateTarget,
duplicateError,
duplicatePickerTitle,
duplicateSearch,
filteredDuplicateCandidates,
handleDuplicateSearchSubmit,
setDuplicateError,
setDuplicatePickerOpen,
setDuplicateSearch
}: {
closeAsDuplicate: (targetIssueNumber: number | string) => void
directDuplicateTarget: number | null
duplicateError: string | null
duplicatePickerTitle: string
duplicateSearch: string
filteredDuplicateCandidates: GitHubWorkItem[]
handleDuplicateSearchSubmit: () => void
setDuplicateError: React.Dispatch<React.SetStateAction<string | null>>
setDuplicatePickerOpen: React.Dispatch<React.SetStateAction<boolean>>
setDuplicateSearch: React.Dispatch<React.SetStateAction<string>>
}): React.JSX.Element {
return (
<div>
<div className="flex items-center gap-2 px-1 py-1.5">
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-7"
onClick={() => {
setDuplicatePickerOpen(false)
setDuplicateSearch('')
setDuplicateError(null)
}}
aria-label={translate('auto.components.TaskPage.backToCloseReasons', 'Back')}
>
<ChevronLeft className="size-4" />
</Button>
<span className="min-w-0 truncate text-[12px] font-semibold">{duplicatePickerTitle}</span>
</div>
<div className="relative px-1 pb-2">
<Search className="pointer-events-none absolute left-3 top-2.5 size-4 text-muted-foreground" />
<Input
autoFocus
value={duplicateSearch}
onChange={(event) => {
setDuplicateSearch(event.target.value)
setDuplicateError(null)
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
handleDuplicateSearchSubmit()
}
}}
placeholder={translate('auto.components.TaskPage.searchIssues', 'Search issues')}
className="h-9 pl-8 text-[12px]"
aria-invalid={duplicateError ? true : undefined}
/>
</div>
{duplicateError ? (
<p className="px-2 pb-2 text-[11px] text-destructive">{duplicateError}</p>
) : null}
<div className="scrollbar-sleek max-h-72 overflow-y-auto pr-1">
{directDuplicateTarget ? (
<button
type="button"
onClick={() => closeAsDuplicate(directDuplicateTarget)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-2 text-left hover:bg-accent"
>
<Copy className="size-4 text-primary" />
<span className="min-w-0 flex-1 text-[12px] font-medium">
{translate('auto.components.TaskPage.useIssueNumber', 'Use issue #{{value0}}', {
value0: directDuplicateTarget
})}
</span>
</button>
) : null}
{filteredDuplicateCandidates.map((candidate) => (
<button
key={`${candidate.repoId}:${candidate.number}`}
type="button"
onClick={() => closeAsDuplicate(candidate.number)}
className="flex w-full items-start gap-2 rounded-sm px-2 py-2 text-left hover:bg-accent"
>
{candidate.state === 'closed' ? (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-primary" />
) : (
<CircleDot className="mt-0.5 size-4 shrink-0 text-emerald-500" />
)}
<span className="min-w-0 flex-1">
<span className="block text-[12px] font-medium leading-snug">{candidate.title}</span>
</span>
<span className="shrink-0 text-[12px] text-muted-foreground">#{candidate.number}</span>
</button>
))}
{!directDuplicateTarget && filteredDuplicateCandidates.length === 0 ? (
<p className="px-2 py-3 text-[12px] text-muted-foreground">
{translate(
'auto.components.TaskPage.noMatchingIssuesLoaded',
'No matching issues loaded.'
)}
</p>
) : null}
</div>
</div>
)
}
@@ -0,0 +1,256 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { getGitHubTaskKindPresets } from '@/components/task-page-localized-options'
import { cn } from '@/lib/utils'
import PRFilterDropdowns from '@/components/github/PRFilterDropdowns'
import { Search, X, Plus, LoaderCircle, RefreshCw } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { translate } from '@/i18n/i18n'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
import { resolveNewIssueOpenSeed } from '@/components/task-page-new-issue-draft'
import { useAppStore } from '@/store'
import { hasUpstreamCandidateDivergence, hasDivergentSources } from './task-page-draft-storage'
import IssueSourceIndicator from '@/components/github/IssueSourceIndicator'
import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
export function TaskPageGitHubFilters({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
settings,
setTaskResumeState,
setIssueSourcePreference,
selectedRepos,
taskSearchInput,
setTaskSearchInput,
appliedTaskSearch,
setAppliedTaskSearch,
taskSearchInputRef,
activeTaskPreset,
setActiveTaskPreset,
setTaskRefreshNonce,
perRepoSourceState,
handleRefreshGithubTasks,
setNewIssueOpen,
setNewIssueTitle,
setNewIssueBody,
setNewIssueLabels,
setNewIssueAssignees,
setNewIssueRepoId,
newIssueTargetRepo,
activeGithubTaskKind,
appliedTaskQuery,
loadedGitHubAuthorLogins,
primaryGithubFilterSlug,
applyPRFilterChange,
handleTaskSearchChange,
handleSetDefaultTaskPreset,
handleResetGithubTaskSearch,
handleTaskSearchKeyDown,
githubTasksBusy
} = model
return (
// Why: top of the joined GitHub list card — pairs with the
// table shell below (rounded-t-none border-t-0) as one surface.
<div
className="flex min-w-0 flex-col gap-2.5 rounded-md rounded-b-none border border-border/50 bg-muted/35 px-3 py-2.5"
data-contextual-tour-target="tasks-search-presets"
>
<div className="flex flex-wrap gap-1.5">
{getGitHubTaskKindPresets(activeGithubTaskKind).map((option) => {
const active = activeTaskPreset === option.id
return (
<button
key={option.id}
type="button"
onClick={() => {
const query = option.query
setTaskSearchInput(query)
setAppliedTaskSearch(query)
setActiveTaskPreset(option.id)
setTaskResumeState({
githubItemsPreset: option.id,
githubItemsQuery: query
})
setTaskRefreshNonce((current) => current + 1)
}}
onContextMenu={(event) => {
event.preventDefault()
handleSetDefaultTaskPreset(option.id)
}}
className={cn(
'rounded-md border px-2.5 py-1 text-xs font-medium transition',
active
? 'border-border/50 bg-foreground/90 text-background shadow-xs'
: 'border-border/60 bg-background text-foreground shadow-xs hover:bg-muted/60'
)}
>
{option.label}
</button>
)
})}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<PRFilterDropdowns
parsed={appliedTaskQuery}
kind={activeGithubTaskKind}
authorLogins={loadedGitHubAuthorLogins}
primarySlug={primaryGithubFilterSlug}
settings={settings}
onChange={(change) => applyPRFilterChange(change)}
/>
<div className="relative min-w-0 flex-1 basis-64">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
ref={taskSearchInputRef}
data-github-items-search-input
value={taskSearchInput}
onChange={handleTaskSearchChange}
onKeyDown={handleTaskSearchKeyDown}
placeholder={
activeGithubTaskKind === 'prs'
? translate('auto.components.TaskPage.eee4df4c66', 'Search GitHub PRs...')
: translate('auto.components.TaskPage.b15ceb409d', 'Search GitHub issues...')
}
className="h-8 rounded-md border-border/60 bg-background pl-8 pr-8 text-xs text-foreground shadow-xs"
/>
{taskSearchInput || appliedTaskSearch ? (
<button
type="button"
aria-label={translate('auto.components.TaskPage.b797bdd7c3', 'Clear search')}
onClick={handleResetGithubTaskSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
>
<X className="size-4" />
</button>
) : null}
</div>
<div
className="flex shrink-0 items-center gap-2"
data-contextual-tour-target="tasks-actions"
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => {
// Why: restore a non-empty draft (accidental dismissal recoverable); empty default guards a stale draft after a repo change.
const seed = resolveNewIssueOpenSeed({
draft: useAppStore.getState().newIssueDraft,
selectedRepoIds: selectedRepos.map((r) => r.id)
})
setNewIssueTitle(seed.title)
setNewIssueBody(seed.body)
setNewIssueLabels(seed.labels)
setNewIssueAssignees(seed.assignees)
setNewIssueRepoId(seed.repoId)
setNewIssueOpen(true)
}}
disabled={!newIssueTargetRepo}
aria-label={translate('auto.components.TaskPage.d3d0998b7d', 'New GitHub issue')}
className="size-8 border-border/60 bg-background text-foreground shadow-xs hover:bg-muted/60"
>
<Plus className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.TaskPage.d3d0998b7d', 'New GitHub issue')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={handleRefreshGithubTasks}
disabled={githubTasksBusy}
aria-busy={githubTasksBusy}
aria-label={
githubTasksBusy
? translate('auto.components.TaskPage.6ffa6be99f', 'Refreshing GitHub work')
: translate('auto.components.TaskPage.ff53631e6f', 'Refresh GitHub work')
}
className="size-8 cursor-pointer border-border/60 bg-background text-foreground shadow-xs hover:bg-muted/60 disabled:pointer-events-auto disabled:cursor-wait"
>
{githubTasksBusy ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{githubTasksBusy
? translate('auto.components.TaskPage.31f81cc334', 'Refreshing GitHub work…')
: translate('auto.components.TaskPage.ff53631e6f', 'Refresh GitHub work')}
</TooltipContent>
</Tooltip>
</div>
</div>
{(() => {
// Why: show the source-slug chip only when the selector can't render (no upstream to toggle); otherwise it duplicates the selector.
const rows = perRepoSourceState.filter(
(s) => hasUpstreamCandidateDivergence(s) || hasDivergentSources(s)
)
if (rows.length === 0) {
return null
}
return (
<div className="flex flex-wrap items-center gap-2">
{rows.map((s) => {
const repo = selectedRepos.find((r) => r.id === s.repoId)
const showRepoBadgeLabel = selectedRepos.length > 1 && repo
const selectorRenderable = hasUpstreamCandidateDivergence(s)
// Why: render the indicator standalone — it has its own chip styles, so nesting it in our chip would double-border it.
if (!selectorRenderable && hasDivergentSources(s)) {
return (
<IssueSourceIndicator
key={s.repoId}
issues={s.sources.issues}
prs={s.sources.prs}
localRepo={
showRepoBadgeLabel && repo
? {
displayName: repo.displayName,
color: repo.badgeColor
}
: undefined
}
/>
)
}
if (!selectorRenderable || !repo) {
return null
}
// Why: <div> not <span> — the child selector renders a block <div> (div-in-span is invalid HTML); inline-flex class looks identical.
return (
<div key={s.repoId} className={issueSourceChipClass}>
{showRepoBadgeLabel ? (
<RepoBadgeLabel
name={repo.displayName}
color={repo.badgeColor}
badgeClassName="size-1.5"
className="text-[10px] text-muted-foreground"
/>
) : null}
<IssueSourceSelector
preference={repo.issueSourcePreference}
origin={s.sources.originCandidate}
upstream={s.sources.upstreamCandidate}
onChange={(next) => {
void setIssueSourcePreference(repo.id, repo.path, next)
}}
/>
</div>
)
})}
</div>
)
})()}
</div>
)
}
@@ -0,0 +1,241 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter
} from '@/components/ui/dialog'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { translate } from '@/i18n/i18n'
import { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator'
import IssueSourceSelector from '@/components/github/IssueSourceSelector'
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem
} from '@/components/ui/select'
import { resolveUserRepoSwitchReset } from '@/components/task-page-new-issue-draft'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
import { Input } from '@/components/ui/input'
import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer'
import {
GitHubIssueLabelSelector,
GitHubIssueAssigneeSelector
} from './TaskPageGitHubIssueSelectors'
import { Button } from '@/components/ui/button'
import { LoaderCircle } from 'lucide-react'
export function TaskPageGitHubIssueDialog({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
setIssueSourcePreference,
submitShortcutLabel,
selectedRepos,
perRepoSourceState,
newIssueOpen,
setNewIssueOpen,
newIssueTitle,
setNewIssueTitle,
newIssueBody,
setNewIssueBody,
newIssueLabels,
setNewIssueLabels,
newIssueAssignees,
setNewIssueAssignees,
newIssueSubmitting,
newIssueRepoId,
setNewIssueRepoId,
newIssueTargetRepo,
newIssueRepoLabels,
newIssueRepoAssignees,
handleCreateNewIssue
} = model
return (
<Dialog
open={newIssueOpen}
onOpenChange={(open) => {
if (!newIssueSubmitting) {
setNewIssueOpen(open)
}
}}
>
<DialogContent
className="sm:max-w-2xl"
onKeyDown={(event) => {
if (isScreenSubmitShortcut(event)) {
event.preventDefault()
void handleCreateNewIssue()
}
}}
>
<DialogHeader>
<DialogTitle>
{translate('auto.components.TaskPage.d3d0998b7d', 'New GitHub issue')}
</DialogTitle>
{(() => {
// Why: inline the resolved {owner}/{repo} slug as the source indicator; fall back to displayName when unresolved.
const entry = newIssueTargetRepo
? perRepoSourceState.find((s) => s.repoId === newIssueTargetRepo.id)
: undefined
const issuesSlug = entry?.sources?.issues
? `${entry.sources.issues.owner}/${entry.sources.issues.repo}`
: null
const fallback = newIssueTargetRepo?.displayName ?? 'this repository'
return (
<DialogDescription>
{translate('auto.components.TaskPage.9f2b4c03a6', 'Filing in')}
{issuesSlug ?? fallback}
</DialogDescription>
)
})()}
{(() => {
// Why: mirror the Tasks-view target selector so a fork contributor can flip target at filing time (fork-routing regression #1076).
// Sibling (not nested) because DialogDescription renders a <p> and the selector a <div> — nesting is invalid HTML.
if (!newIssueTargetRepo) {
return null
}
const entry = perRepoSourceState.find((s) => s.repoId === newIssueTargetRepo.id)
if (!entry || !entry.sources?.upstreamCandidate || !entry.sources?.originCandidate) {
return null
}
if (
sameGitHubOwnerRepo(entry.sources.originCandidate, entry.sources.upstreamCandidate)
) {
return null
}
return (
<div className="mt-1">
<IssueSourceSelector
preference={newIssueTargetRepo.issueSourcePreference}
origin={entry.sources.originCandidate}
upstream={entry.sources.upstreamCandidate}
disabled={newIssueSubmitting}
// Why: composer only files issues, so the source tooltip is redundant here (kept on the Tasks header, which also lists PRs).
suppressTooltip
onChange={(next) => {
void setIssueSourcePreference(
newIssueTargetRepo.id,
newIssueTargetRepo.path,
next
)
}}
/>
</div>
)
})()}
</DialogHeader>
<div className="flex flex-col gap-3">
{selectedRepos.length > 1 ? (
<div className="flex flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.TaskPage.00022ec0ba', 'Project')}
</label>
<Select
value={newIssueRepoId ?? undefined}
onValueChange={(v) => {
// Why: repo-scoped labels/assignees can't survive a real repo switch, so clear them here (restore never routes through this handler).
setNewIssueRepoId(v)
const reset = resolveUserRepoSwitchReset()
setNewIssueLabels(reset.labels)
setNewIssueAssignees(reset.assignees)
}}
disabled={newIssueSubmitting}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{selectedRepos.map((r) => (
<SelectItem key={r.id} value={r.id}>
<RepoBadgeLabel name={r.displayName} color={r.badgeColor} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="flex flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.TaskPage.16cba35bee', 'Title')}
</label>
<Input
autoFocus
value={newIssueTitle}
onChange={(e) => setNewIssueTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault()
void handleCreateNewIssue()
}
}}
placeholder={translate('auto.components.TaskPage.578f730c16', 'Short summary')}
disabled={newIssueSubmitting}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.TaskPage.7f3f7b4c18', 'Description (optional, markdown)')}
</label>
<GitHubMarkdownComposer
value={newIssueBody}
onChange={setNewIssueBody}
placeholder={translate('auto.components.TaskPage.34d97ca682', "What's going on?")}
disabled={newIssueSubmitting}
minHeightClassName="min-h-40"
onSubmitShortcut={() => void handleCreateNewIssue()}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<GitHubIssueLabelSelector
labels={newIssueRepoLabels.data}
selectedLabels={newIssueLabels}
loading={newIssueRepoLabels.loading}
error={newIssueRepoLabels.error}
disabled={newIssueSubmitting || !newIssueTargetRepo}
onChange={setNewIssueLabels}
/>
<GitHubIssueAssigneeSelector
assignees={newIssueRepoAssignees.data}
selectedAssignees={newIssueAssignees}
loading={newIssueRepoAssignees.loading}
error={newIssueRepoAssignees.error}
disabled={newIssueSubmitting || !newIssueTargetRepo}
onChange={setNewIssueAssignees}
/>
</div>
<p className="text-[10px] text-muted-foreground">
{submitShortcutLabel} {translate('auto.components.TaskPage.fc0d8a1fa4', 'to submit.')}
</p>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setNewIssueOpen(false)}
disabled={newIssueSubmitting}
>
{translate('auto.components.TaskPage.ff69a30681', 'Cancel')}
</Button>
<Button
onClick={() => void handleCreateNewIssue()}
disabled={!newIssueTargetRepo || !newIssueTitle.trim() || newIssueSubmitting}
>
{newIssueSubmitting ? (
<>
<LoaderCircle className="size-4 animate-spin" />
{translate('auto.components.TaskPage.8ff6fdc368', 'Creating…')}
</>
) : (
translate('auto.components.TaskPage.e15ba2d2eb', 'Create issue')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,207 @@
import React, { useMemo, useCallback } from 'react'
import { translate } from '@/i18n/i18n'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { LoaderCircle, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types'
import { GitHubAssigneeAvatar } from './TaskPageGitHubAvatars'
export function GitHubIssueLabelSelector({
labels,
selectedLabels,
loading,
error,
disabled,
onChange
}: {
labels: string[]
selectedLabels: string[]
loading: boolean
error: string | null
disabled: boolean
onChange: (labels: string[]) => void
}): React.JSX.Element {
const selectedSet = useMemo(() => new Set(selectedLabels), [selectedLabels])
const toggleLabel = useCallback(
(label: string) => {
onChange(
selectedSet.has(label)
? selectedLabels.filter((name) => name !== label)
: [...selectedLabels, label]
)
},
[onChange, selectedLabels, selectedSet]
)
return (
<div className="flex min-w-0 flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.TaskPage.d0ca4aa1d0', 'Labels')}
</label>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled}
className="h-auto min-h-9 justify-start gap-2 px-3 py-2 text-left"
>
{selectedLabels.length === 0 ? (
<span className="text-muted-foreground">
{translate('auto.components.TaskPage.5ebff3a0aa', 'None')}
</span>
) : (
<span className="flex min-w-0 flex-wrap gap-1.5">
{selectedLabels.map((label) => (
<span
key={label}
className="rounded-full border border-border/50 bg-muted/40 px-2 py-0.5 text-[11px] font-medium"
>
{label}
</span>
))}
</span>
)}
{loading ? <LoaderCircle className="ml-auto size-3.5 animate-spin" /> : null}
</Button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-64 p-1" align="start">
{error ? (
<div className="px-2 py-2 text-xs text-destructive">{error}</div>
) : labels.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
{translate('auto.components.TaskPage.b36f4bf9de', 'No labels.')}
</div>
) : (
labels.map((label) => (
<button
key={label}
type="button"
onClick={() => toggleLabel(label)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 shrink-0 items-center justify-center rounded-sm border',
selectedSet.has(label)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{selectedSet.has(label) ? <Check className="size-2.5" /> : null}
</span>
<span className="min-w-0 truncate">{label}</span>
</button>
))
)}
</PopoverContent>
</Popover>
</div>
)
}
export function GitHubIssueAssigneeSelector({
assignees,
selectedAssignees,
loading,
error,
disabled,
onChange
}: {
assignees: GitHubAssignableUser[]
selectedAssignees: GitHubAssignableUser[]
loading: boolean
error: string | null
disabled: boolean
onChange: (assignees: GitHubAssignableUser[]) => void
}): React.JSX.Element {
const selectedLogins = useMemo(
() => new Set(selectedAssignees.map((assignee) => assignee.login.toLowerCase())),
[selectedAssignees]
)
const toggleAssignee = useCallback(
(assignee: GitHubAssignableUser) => {
const key = assignee.login.toLowerCase()
onChange(
selectedLogins.has(key)
? selectedAssignees.filter((current) => current.login.toLowerCase() !== key)
: [...selectedAssignees, assignee]
)
},
[onChange, selectedAssignees, selectedLogins]
)
return (
<div className="flex min-w-0 flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.TaskPage.8aba10579d', 'Assignees')}
</label>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled}
className="h-auto min-h-9 justify-start gap-2 px-3 py-2 text-left"
>
{selectedAssignees.length === 0 ? (
<span className="text-muted-foreground">
{translate('auto.components.TaskPage.42a9160321', 'Unassigned')}
</span>
) : (
<span className="flex min-w-0 items-center gap-1.5">
<span className="flex -space-x-1">
{selectedAssignees.slice(0, 3).map((assignee) => (
<GitHubAssigneeAvatar key={assignee.login} assignee={assignee} />
))}
</span>
<span className="min-w-0 truncate text-xs">
{selectedAssignees.map((assignee) => assignee.login).join(', ')}
</span>
</span>
)}
{loading ? <LoaderCircle className="ml-auto size-3.5 animate-spin" /> : null}
</Button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-72 p-1" align="start">
{error ? (
<div className="px-2 py-2 text-xs text-destructive">{error}</div>
) : assignees.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
{translate('auto.components.TaskPage.edf4bc4135', 'No assignable users.')}
</div>
) : (
assignees.map((assignee) => {
const selected = selectedLogins.has(assignee.login.toLowerCase())
return (
<button
key={assignee.login}
type="button"
onClick={() => toggleAssignee(assignee)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 shrink-0 items-center justify-center rounded-sm border',
selected
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{selected ? <Check className="size-2.5" /> : null}
</span>
<GitHubAssigneeAvatar assignee={assignee} />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{assignee.login}</span>
{assignee.name ? (
<span className="block truncate text-[11px] text-muted-foreground">
{assignee.name}
</span>
) : null}
</span>
</button>
)
})
)}
</PopoverContent>
</Popover>
</div>
)
}
@@ -0,0 +1,320 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import {
GITHUB_TASK_HEADER_SURFACE_CLASS,
GITHUB_TASK_STICKY_ID_HEADER_CLASS,
GITHUB_TASK_STICKY_TITLE_HEADER_CLASS,
GITHUB_TASK_STICKY_ID_CELL_CLASS,
GITHUB_TASK_STICKY_TITLE_CELL_CLASS
} from './task-page-source-context'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { LoaderCircle } from 'lucide-react'
import { TaskPageGitHubRows } from './TaskPageGitHubRows'
import { PaginationBar } from './TaskPagePaginationBar'
export function TaskPageGitHubList({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
selectedRepos,
githubEmptyState,
tasksLoading,
tasksError,
failedCount,
githubUnavailable,
githubResumeContextKey,
pages,
currentPage,
setCurrentPage,
currentPageRef,
githubListScrollRef,
githubListScrollTopRef,
pendingGithubScrollRestoreRef,
loadingTargetPage,
taskListPositionRef,
perRepoSourceState,
unresolvedSourceRepos,
retryingSourceKeys,
handleRetryIssuesFetch,
activeGithubTaskKind,
filteredWorkItems,
softHiddenVisibleCount,
showGitHubTaskSkeletons,
showPRManagementColumns,
githubTaskGridClass,
totalPages,
handleLoadNextPage
} = model
return (
// Why: bottom of the joined GitHub list card — flush under the filter
// chrome (no gap, no top border/radius) so toolbar + table read as one.
<div className="flex min-h-0 min-w-0 max-h-full flex-col overflow-hidden rounded-md rounded-t-none border border-t-0 border-border/50 bg-background shadow-sm">
<div
ref={githubListScrollRef}
data-task-list-scroll="github"
className="min-h-0 flex-initial overflow-auto scrollbar-sleek scrollbar-sleek-lg"
style={{
scrollbarGutter: 'stable'
}}
onScroll={(event) => {
const state = useAppStore.getState()
if (
state.activeView !== 'tasks' ||
state.taskPageData.openGitHubWorkItem ||
pendingGithubScrollRestoreRef.current !== null
) {
return
}
const scrollTop = event.currentTarget.scrollTop
githubListScrollTopRef.current = scrollTop
taskListPositionRef.current = {
contextKey: githubResumeContextKey,
page: currentPageRef.current,
scrollTop
}
}}
>
<div
// Why: z-40 must beat the rows' sticky left cells (z-20); this stacking context's z sets the whole header's level.
className={cn(
'sticky top-0 z-40 grid h-8 gap-3 border-b border-border/50 px-3 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground [&>span]:flex [&>span]:items-center',
GITHUB_TASK_HEADER_SURFACE_CLASS,
githubTaskGridClass
)}
>
<span className={GITHUB_TASK_STICKY_ID_HEADER_CLASS}>
{translate('auto.components.TaskPage.eb10c32872', 'ID')}
</span>
<span className={GITHUB_TASK_STICKY_TITLE_HEADER_CLASS}>
{translate('auto.components.TaskPage.5eccb3c841', 'Title / Context')}
</span>
{activeGithubTaskKind === 'issues' ? (
<span>{translate('auto.components.TaskPage.8aba10579d', 'Assignees')}</span>
) : null}
{showPRManagementColumns ? (
<>
<span>{translate('auto.components.TaskPage.f6fa3c97d0', 'Reviewers')}</span>
<span>{translate('auto.components.TaskPage.a7396b05c6', 'Checks')}</span>
<span>{translate('auto.components.TaskPage.443f7dd928', 'Merge')}</span>
</>
) : (
<span>{translate('auto.components.TaskPage.154b0fa623', 'Status')}</span>
)}
<span>{translate('auto.components.TaskPage.f362667d55', 'Updated')}</span>
<span />
</div>
{tasksError ? (
<div className="border-b border-border px-4 py-4 text-sm text-destructive">
{tasksError}
</div>
) : null}
{!tasksError && githubUnavailable ? (
// Why: name the GitHub outage explicitly so an empty list isn't misread as an Orca bug; takes priority over the count banner.
<div
role="alert"
className="border-b border-border/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
{translate(
'auto.components.TaskPage.75a38d7df8',
'GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly.'
)}
</div>
) : null}
{!tasksError && !githubUnavailable && failedCount > 0 ? (
// Why: per-repo partial-failure signal, distinct from a hard IPC reject (tasksError); the two are mutually exclusive.
<div className="border-b border-border/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
{failedCount} {translate('auto.components.TaskPage.7762f4b03a', 'of')}{' '}
{selectedRepos.length}{' '}
{translate('auto.components.TaskPage.d1766fd62d', 'projects failed to load')}
</div>
) : null}
{perRepoSourceState
.filter((s) => s.error)
.map((s) => {
const err = s.error!
// Why: Retry re-fetches force=true via the shared refresh nonce, invalidating any still-failing in-flight request first.
return (
<div
key={`source-err-${s.repoId}`}
role="alert"
// Why: aria-atomic re-announces the whole banner on a new same-repo error SRs would otherwise miss (stable key → text-only diff).
aria-atomic="true"
className="flex items-center justify-between gap-3 border-b border-border/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
<span>
{translate('auto.components.TaskPage.0c0de0fc0e', "Couldn't load issues from")}{' '}
<span className="font-mono">
{err.source.owner}/{err.source.repo}
</span>{' '}
— {err.message}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handleRetryIssuesFetch(s.sourceKey)}
disabled={tasksLoading || retryingSourceKeys.has(s.sourceKey)}
>
{retryingSourceKeys.has(s.sourceKey) ? (
<span className="flex items-center gap-1">
<LoaderCircle className="h-3 w-3 animate-spin" />
{translate('auto.components.TaskPage.5b6b2af943', 'Retrying…')}
</span>
) : (
translate('auto.components.TaskPage.0bfbf62f75', 'Retry')
)}
</Button>
</div>
)
})}
{unresolvedSourceRepos.map((r) => (
// Why: null-source repos (#9660) render empty like genuine zero — name the repo and offer Retry so a transient resolve blip is recoverable.
<div
key={`source-unresolved-${r.repoId}`}
role="status"
aria-atomic="true"
className="flex items-center justify-between gap-3 border-b border-border/50 bg-muted/40 px-4 py-3 text-sm text-muted-foreground"
>
<span>
{translate(
'auto.components.TaskPage.noGithubSourceDetected',
'No GitHub source detected for'
)}{' '}
<span className="font-mono">{r.label}</span> —{' '}
{translate(
'auto.components.TaskPage.noGithubSourceDetectedHint',
'it may have no GitHub remote, or the source could not be resolved.'
)}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handleRetryIssuesFetch(r.sourceKey)}
disabled={tasksLoading || retryingSourceKeys.has(r.sourceKey)}
>
{retryingSourceKeys.has(r.sourceKey) ? (
<span className="flex items-center gap-1">
<LoaderCircle className="h-3 w-3 animate-spin" />
{translate('auto.components.TaskPage.5b6b2af943', 'Retrying…')}
</span>
) : (
translate('auto.components.TaskPage.0bfbf62f75', 'Retry')
)}
</Button>
</div>
))}
{showGitHubTaskSkeletons ? (
// Why: render enough shimmer rows to fill a typical viewport
// so the table doesn't visibly grow when results land. A
// 3-row stub jumps to ~30 real rows; matching the steady-
// state height keeps layout stable across the load.
<div className="divide-y divide-border/40">
{Array.from({
length: 12
}).map((_, i) => (
<div key={i} className={cn('grid min-h-12 gap-3 px-3 py-2.5', githubTaskGridClass)}>
<div className={GITHUB_TASK_STICKY_ID_CELL_CLASS}>
<div className="h-6 w-16 animate-pulse rounded-md bg-muted/70" />
</div>
<div className={GITHUB_TASK_STICKY_TITLE_CELL_CLASS}>
<div className="h-3.5 w-3/5 animate-pulse rounded bg-muted/70" />
<div className="mt-1.5 h-3 w-2/5 animate-pulse rounded bg-muted/60" />
</div>
{!showPRManagementColumns ? (
<div className="flex items-center">
<div className="h-3 w-24 animate-pulse rounded bg-muted/60" />
</div>
) : null}
{showPRManagementColumns ? (
<>
<div className="flex items-center">
<div className="h-5 w-20 animate-pulse rounded-full bg-muted/70" />
</div>
<div className="flex items-center">
<div className="h-5 w-20 animate-pulse rounded-full bg-muted/70" />
</div>
<div className="flex items-center">
<div className="h-5 w-20 animate-pulse rounded-full bg-muted/70" />
</div>
</>
) : (
<div className="flex items-center">
<div className="h-5 w-14 animate-pulse rounded-full bg-muted/70" />
</div>
)}
<div className="flex items-center">
<div className="h-3 w-20 animate-pulse rounded bg-muted/60" />
</div>
<div className="flex items-center justify-start lg:justify-end">
<div className="h-7 w-16 animate-pulse rounded-md bg-muted/70" />
</div>
</div>
))}
</div>
) : null}
{/* Why: suppress the generic empty state when any error banner is
visible (IPC reject via tasksError, cross-repo partial failure
via failedCount, or per-repo issue-side error). Showing
"No matching GitHub work" next to "Couldn't load issues from X/Y"
is contradictory and misleads the user into thinking they
typed the wrong query. */}
{/* Why: soft-hidden membership exits must not look like an empty
query when pager/count still implies items remain — but on a
single page (totalPages <= 1) there's nothing else to show, so
surface the empty state instead of a blank panel. */}
{!showGitHubTaskSkeletons &&
filteredWorkItems.length === 0 &&
(softHiddenVisibleCount === 0 || totalPages <= 1) &&
!tasksError &&
!githubUnavailable &&
failedCount === 0 &&
unresolvedSourceRepos.length === 0 &&
perRepoSourceState.every((s) => !s.error) ? (
<div className="px-4 py-10 text-center">
<p className="text-base font-medium text-foreground">{githubEmptyState.title}</p>
<p className="mt-2 text-sm text-muted-foreground">{githubEmptyState.description}</p>
</div>
) : null}
<TaskPageGitHubRows model={model} />
</div>
{/* Why: pagination sits outside the scroll container so it
remains pinned at the bottom of the panel rather than
hiding below the last row inside the scrolling region. */}
{(filteredWorkItems.length > 0 || softHiddenVisibleCount > 0) &&
!showGitHubTaskSkeletons &&
totalPages > 1 ? (
<div className="flex-none border-t border-border/50 bg-background">
<PaginationBar
currentPage={currentPage}
totalPages={totalPages}
loadingTarget={loadingTargetPage}
onPageChange={(page) => {
pendingGithubScrollRestoreRef.current = null
githubListScrollTopRef.current = 0
if (githubListScrollRef.current) {
githubListScrollRef.current.scrollTop = 0
}
if (pages[page] !== null && pages[page] !== undefined) {
currentPageRef.current = page
setCurrentPage(page)
} else {
void handleLoadNextPage(page)
}
}}
/>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,266 @@
import type { GitHubWorkItem } from '../../../shared/github/work-item-types'
import type { GitHubPRMergeMethod } from '../../../shared/github/pull-request-types'
import type { Repo } from '../../../shared/repo-types'
import {
type TaskSourceContext,
getTaskSourceRuntimeSettings
} from '../../../shared/task-source-context'
import React, { useState, useMemo } from 'react'
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
import { useAppStore } from '@/store'
import { useShallow } from 'zustand/react/shallow'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
import { translate } from '@/i18n/i18n'
import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state'
import {
resolveGitHubPRMergeMethods,
GITHUB_PR_MERGE_METHOD_LABELS
} from '../../../shared/github/pull-request-merge-methods'
import { getActiveRuntimeTarget, callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { LoaderCircle, GitMerge, ChevronDown, ExternalLink } from 'lucide-react'
import type { TaskPageGitHubWorkItemMutationRunner } from './task-page-linear-jira-list-model'
import { resolveTaskPullRequestRepo } from './task-page-github-review-model'
export function PRMergeCell({
item,
repo,
sourceContext,
workItemMutation
}: {
item: GitHubWorkItem
repo: Repo | null
sourceContext?: TaskSourceContext | null
workItemMutation: TaskPageGitHubWorkItemMutationRunner
}): React.JSX.Element {
const [merging, setMerging] = useState(false)
const confirm = useConfirmationDialog()
const repoOwnerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null))
)
const sourceSettings = useMemo(
() =>
sourceContext?.provider === 'github'
? ({
...repoOwnerSettings,
...getTaskSourceRuntimeSettings(sourceContext)
} as typeof repoOwnerSettings)
: repoOwnerSettings,
[repoOwnerSettings, sourceContext]
)
if (item.type !== 'pr') {
return (
<span className="text-[11px] text-muted-foreground">
{translate('auto.components.TaskPage.b1eaa18ace', 'Issue')}
</span>
)
}
const mergePresentation = presentGitHubPRMergeState(item)
const mergeMethods = resolveGitHubPRMergeMethods(item.mergeMethodSettings)
const prRepo = resolveTaskPullRequestRepo(item)
const mergeMutationPending = workItemMutation.isIntentPending({
item,
intent: {
type: 'merge'
},
sourceContext
})
const autoMergeMutationPending = mergePresentation.autoMergeAction
? workItemMutation.isIntentPending({
item,
intent: {
type: 'setAutoMerge',
enabled: mergePresentation.autoMergeAction.kind === 'enable'
},
sourceContext
})
: false
const mergeDisabled =
!repo || merging || mergeMutationPending || !mergePresentation.directMergeAvailable
const handleMerge = async (method: GitHubPRMergeMethod): Promise<void> => {
if (!repo || mergeDisabled) {
return
}
const label = GITHUB_PR_MERGE_METHOD_LABELS[method]
const confirmed = await confirm({
title: translate('auto.components.TaskPage.844dc193c7', '{{value0}} PR #{{value1}}?', {
value0: label,
value1: item.number
}),
description: translate(
'auto.components.TaskPage.0506a78337',
'This will update the pull request on GitHub.'
),
confirmLabel: label
})
if (!confirmed) {
return
}
setMerging(true)
try {
await workItemMutation.run({
item,
intent: {
type: 'merge'
},
sourceContext,
successToast: translate('auto.components.TaskPage.a161925adc', 'Pull request merged'),
errorToast: translate(
'auto.components.TaskPage.88f478cdef',
'Failed to merge pull request'
),
mutate: async () => {
const target = getActiveRuntimeTarget(sourceSettings)
const runtimeRepoId =
sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id
return target.kind === 'environment'
? callRuntimeRpc<{
ok: boolean
error?: string
}>(
target,
'github.mergePR',
{
repo: runtimeRepoId,
prNumber: item.number,
method,
prRepo
},
{
timeoutMs: 30_000
}
)
: window.api.gh.mergePR({
repoPath: repo.path,
repoId: repo.id,
sourceContext,
prNumber: item.number,
method,
prRepo
})
}
})
} finally {
setMerging(false)
}
}
const handleAutoMerge = async (): Promise<void> => {
if (!repo || autoMergeMutationPending || !mergePresentation.autoMergeAction) {
return
}
const enabled = mergePresentation.autoMergeAction.kind === 'enable'
setMerging(true)
try {
await workItemMutation.run({
item,
intent: {
type: 'setAutoMerge',
enabled
},
sourceContext,
successToast: enabled
? translate('auto.components.TaskPage.fed317634c', 'Auto-merge enabled')
: translate('auto.components.TaskPage.a5bf86defe', 'Auto-merge disabled'),
errorToast: enabled
? translate('auto.components.TaskPage.a3318684bc', 'Failed to enable auto-merge')
: translate('auto.components.TaskPage.1a9ea003dc', 'Failed to disable auto-merge'),
mutate: async () => {
const target = getActiveRuntimeTarget(sourceSettings)
const runtimeRepoId =
sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id
return target.kind === 'environment'
? callRuntimeRpc<{
ok: boolean
error?: string
}>(
target,
'github.setPRAutoMerge',
{
repo: runtimeRepoId,
prNumber: item.number,
enabled,
method: enabled ? mergeMethods.defaultMethod : undefined,
prRepo
},
{
timeoutMs: 30_000
}
)
: window.api.gh.setPRAutoMerge({
repoPath: repo.path,
repoId: repo.id,
sourceContext,
prNumber: item.number,
enabled,
method: enabled ? mergeMethods.defaultMethod : undefined,
prRepo
})
}
})
} finally {
setMerging(false)
}
}
return (
<DropdownMenu modal={false}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(event) => event.stopPropagation()}
className={cn(
'inline-flex max-w-full items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium transition hover:brightness-110',
mergePresentation.tone
)}
>
{merging ? (
<LoaderCircle className="size-3 animate-spin text-muted-foreground" />
) : (
<GitMerge className="size-3" />
)}
<span className="truncate">{mergePresentation.label}</span>
<ChevronDown className="size-2.5 opacity-60" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{mergePresentation.tooltip}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" onClick={(event) => event.stopPropagation()}>
{mergePresentation.autoMergeAction && (
<DropdownMenuItem
disabled={!repo || merging || autoMergeMutationPending}
onSelect={() => void handleAutoMerge()}
>
<GitMerge className="size-4" />
{mergePresentation.autoMergeAction.label}
</DropdownMenuItem>
)}
{mergePresentation.autoMergeAction && <DropdownMenuSeparator />}
{mergeMethods.methods.map(({ method, label }) => (
<DropdownMenuItem
key={method}
disabled={mergeDisabled}
onSelect={() => void handleMerge(method)}
>
<GitMerge className="size-4" />
{label}
</DropdownMenuItem>
))}
<DropdownMenuItem onSelect={() => window.api.shell.openUrl(item.url)}>
<ExternalLink className="size-4" />
{translate('auto.components.TaskPage.37d60046e3', 'Open GitHub merge box')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,150 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { cn } from '@/lib/utils'
import TaskProjectSourceCombobox from '@/components/task-project-source-combobox'
import { normalizeTaskRepoSelection } from '@/components/task-page-default-repo-selection'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
import { ExternalLink } from 'lucide-react'
export function TaskPageGitHubModeControls({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
setTaskResumeState,
updateSettings,
eligibleRepos,
repoSelection,
setRepoSelection,
taskPickerGroups,
taskPickerRepos,
githubModeButtons,
taskSource,
getTaskPickerRepoHostLabel,
projectModeVisible,
githubMode,
setGithubMode,
activeGithubTaskKind,
selectedGitHubRepoExternalLink,
handleSelectGithubTaskKind
} = model
return taskSource === 'github' ? (
<div className="flex min-w-0 flex-wrap items-center gap-2">
{projectModeVisible ? (
<div className="flex items-center gap-1 text-xs">
{githubModeButtons.map((mode) => {
const active =
mode.id === 'project'
? githubMode === 'project'
: githubMode === 'items' && activeGithubTaskKind === mode.id
return (
<button
key={mode.id}
type="button"
onClick={() => {
if (mode.id === 'project') {
setGithubMode('project')
setTaskResumeState({
githubMode: 'project'
})
return
}
setGithubMode('items')
setTaskResumeState({
githubMode: 'items'
})
handleSelectGithubTaskKind(mode.id)
}}
className={cn(
'rounded-md border px-2.5 py-1 text-xs font-medium transition',
active
? 'border-border/50 bg-foreground/90 text-background shadow-xs'
: 'border-border/60 bg-muted/50 text-foreground shadow-xs hover:bg-muted/70'
)}
>
{mode.label}
</button>
)
})}
</div>
) : null}
{/* Why: Project rows are repo-scoped, so the selection must stay visible in both GitHub modes. */}
<div className="min-w-0 max-w-[220px] shrink-0">
<TaskProjectSourceCombobox
groups={taskPickerGroups}
selected={repoSelection}
getRepoHostLabel={getTaskPickerRepoHostLabel}
onChange={(next) => {
const normalized = normalizeTaskRepoSelection(eligibleRepos, next)
setRepoSelection(normalized)
void updateSettings({
defaultRepoSelection: [...normalized]
}).catch(() => {
toast.error(
translate(
'auto.components.TaskPage.dfd72673e7',
'Failed to save project selection.'
)
)
})
}}
onSelectAll={() => {
const allIds = new Set(taskPickerRepos.map((r) => r.id))
setRepoSelection(allIds)
void updateSettings({
defaultRepoSelection: null
}).catch(() => {
toast.error(
translate(
'auto.components.TaskPage.dfd72673e7',
'Failed to save project selection.'
)
)
})
}}
triggerClassName="h-8 w-auto max-w-[220px] rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none"
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={() => {
if (!selectedGitHubRepoExternalLink?.url) {
return
}
void window.api.shell.openUrl(selectedGitHubRepoExternalLink.url)
}}
aria-label={
selectedGitHubRepoExternalLink
? translate('auto.components.TaskPage.8d1e17a3ef', 'Open {{value0}} in GitHub', {
value0: selectedGitHubRepoExternalLink.label
})
: translate(
'auto.components.TaskPage.d1132848f8',
'Select one GitHub project to open in GitHub'
)
}
className="h-8 w-8 rounded-md border-border/50 bg-muted/50 text-foreground shadow-sm transition hover:bg-muted/50"
>
<ExternalLink className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{selectedGitHubRepoExternalLink
? translate('auto.components.TaskPage.8d1e17a3ef', 'Open {{value0}} in GitHub', {
value0: selectedGitHubRepoExternalLink.label
})
: translate(
'auto.components.TaskPage.bc46d8204e',
'Select one project to open in GitHub'
)}
</TooltipContent>
</Tooltip>
</div>
) : null
}
@@ -0,0 +1,345 @@
import type { GitHubWorkItem } from '../../../shared/github/work-item-types'
import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types'
import type { Repo } from '../../../shared/repo-types'
import {
type TaskSourceContext,
getTaskSourceRuntimeSettings
} from '../../../shared/task-source-context'
import React, { useState, useMemo, useRef, useCallback } from 'react'
import { useAppStore } from '@/store'
import { useShallow } from 'zustand/react/shallow'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
import {
getGitHubPRReviewerQueryState,
filterGitHubPRReviewerCandidates
} from '@/components/github/github-pr-reviewer-candidate-filter'
import { translate } from '@/i18n/i18n'
import {
getGitHubPRPrimaryReviewer,
getGitHubPRReviewerRows,
getGitHubPRReviewLabel
} from '@/components/github-pr-reviewer-display'
import { cn } from '@/lib/utils'
import { ChevronDown } from 'lucide-react'
import { Popover, PopoverTrigger } from '@/components/ui/popover'
import type { TaskPageGitHubWorkItemMutationRunner } from './task-page-linear-jira-list-model'
import {
mergeReviewerSuggestions,
resolveTaskPullRequestRepo
} from './task-page-github-review-model'
import { ReviewChipAvatar } from './TaskPageGitHubAvatars'
import { createTaskPageGitHubReviewerActions } from './task-page-github-reviewer-actions'
import { TaskPageGitHubReviewerPicker } from './TaskPageGitHubReviewerPicker'
export function PRReviewCell({
item,
repo,
sourceContext,
workItemMutation
}: {
item: GitHubWorkItem
repo: Repo | null
sourceContext?: TaskSourceContext | null
workItemMutation: TaskPageGitHubWorkItemMutationRunner
}): React.JSX.Element {
const [open, setOpen] = useState(false)
const [reviewerInput, setReviewerInput] = useState('')
const [localReviewRequests, setLocalReviewRequests] = useState<GitHubAssignableUser[]>(
() => item.reviewRequests ?? []
)
const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom')
const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState<number | null>(null)
const [reviewRequestsSource, setReviewRequestsSource] = useState(() => ({
itemId: item.id,
repoId: item.repoId,
reviewRequests: item.reviewRequests
}))
const [activeReviewerCursor, setActiveReviewerCursor] = useState({
resetKey: '',
index: 0
})
const [submitting, setSubmitting] = useState(false)
const repoOwnerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null))
)
const sourceSettings = useMemo(
() =>
sourceContext?.provider === 'github'
? ({
...repoOwnerSettings,
...getTaskSourceRuntimeSettings(sourceContext)
} as typeof repoOwnerSettings)
: repoOwnerSettings,
[repoOwnerSettings, sourceContext]
)
const reviewerInputRef = useRef<HTMLInputElement | null>(null)
const reviewerTriggerRef = useRef<HTMLButtonElement | null>(null)
const reviewerInputFocusFrameRef = useRef<number | null>(null)
const cancelReviewerInputFocusFrame = useCallback((): void => {
if (reviewerInputFocusFrameRef.current === null) {
return
}
cancelAnimationFrame(reviewerInputFocusFrameRef.current)
reviewerInputFocusFrameRef.current = null
}, [])
const setReviewerInputNode = useCallback(
(node: HTMLInputElement | null): void => {
// Why: the queued picker focus is only valid while this input is mounted.
if (!node) {
cancelReviewerInputFocusFrame()
}
reviewerInputRef.current = node
},
[cancelReviewerInputFocusFrame]
)
// Why: reviewer edits are optimistic, but item switches/refetches must clear stale local requests before paint (a passive Effect leaves one stale frame).
if (
reviewRequestsSource.itemId !== item.id ||
reviewRequestsSource.repoId !== item.repoId ||
reviewRequestsSource.reviewRequests !== item.reviewRequests
) {
setReviewRequestsSource({
itemId: item.id,
repoId: item.repoId,
reviewRequests: item.reviewRequests
})
setLocalReviewRequests(item.reviewRequests ?? [])
}
const reviewerSeedUsers = useMemo<GitHubAssignableUser[]>(() => {
const byLogin = new Map<string, GitHubAssignableUser>()
const add = (user: GitHubAssignableUser): void => {
if (!user.login) {
return
}
byLogin.set(user.login.toLowerCase(), user)
}
for (const user of localReviewRequests) {
add(user)
}
for (const review of item.latestReviews ?? []) {
add({
login: review.login,
name: null,
avatarUrl: review.avatarUrl ?? ''
})
}
if (item.author) {
add({
login: item.author,
name: null,
avatarUrl: ''
})
}
return Array.from(byLogin.values())
}, [item.author, item.latestReviews, localReviewRequests])
const reviewRepo = useMemo(() => resolveTaskPullRequestRepo(item), [item])
const reviewerMetadata = useRepoAssigneesBySlug(
open && reviewRepo ? reviewRepo.owner : null,
open && reviewRepo ? reviewRepo.repo : null,
reviewerSeedUsers.map((user) => user.login),
sourceSettings,
reviewRepo?.host
)
const authorLogin = item.author?.toLowerCase() ?? null
const reviewerCandidates = useMemo(
() =>
mergeReviewerSuggestions(reviewerMetadata.data, reviewerSeedUsers).filter(
(user) => user.login.toLowerCase() !== authorLogin
),
[authorLogin, reviewerMetadata.data, reviewerSeedUsers]
)
const reviewerCandidatesByLogin = useMemo(
() => new Map(reviewerCandidates.map((user) => [user.login.toLowerCase(), user])),
[reviewerCandidates]
)
const selectedReviewerLogins = useMemo(
() =>
new Set(
localReviewRequests.map((reviewer) => reviewer.login.trim().toLowerCase()).filter(Boolean)
),
[localReviewRequests]
)
const reviewerQueryState = useMemo(
() => getGitHubPRReviewerQueryState(reviewerInput),
[reviewerInput]
)
const reviewerQuery = reviewerQueryState.query
const filteredReviewerCandidates = useMemo(
() =>
filterGitHubPRReviewerCandidates({
candidates: reviewerCandidates,
queryState: reviewerQueryState
}),
[reviewerCandidates, reviewerQueryState]
)
const suggestedReviewerRows = useMemo(
() =>
reviewerQuery.length === 0 && !reviewerQueryState.isTooLarge
? reviewerSeedUsers
.filter((user) => !selectedReviewerLogins.has(user.login.toLowerCase()))
.filter((user) => user.login.toLowerCase() !== authorLogin)
.map((user) => reviewerCandidatesByLogin.get(user.login.toLowerCase()) ?? user)
.slice(0, 1)
: [],
[
authorLogin,
reviewerCandidatesByLogin,
reviewerQuery.length,
reviewerQueryState.isTooLarge,
reviewerSeedUsers,
selectedReviewerLogins
]
)
const everyoneElseReviewerRows = useMemo(() => {
const suggestedLogins = new Set(suggestedReviewerRows.map((user) => user.login.toLowerCase()))
return filteredReviewerCandidates.filter(
(user) => !suggestedLogins.has(user.login.toLowerCase())
)
}, [filteredReviewerCandidates, suggestedReviewerRows])
const actionableReviewerRows = useMemo(
() => [...suggestedReviewerRows, ...everyoneElseReviewerRows],
[everyoneElseReviewerRows, suggestedReviewerRows]
)
const reviewerCursorResetKey = `${reviewerQuery}\u0000${actionableReviewerRows.length}`
if (activeReviewerCursor.resetKey !== reviewerCursorResetKey) {
setActiveReviewerCursor({
resetKey: reviewerCursorResetKey,
index: 0
})
}
const activeReviewerIndex =
activeReviewerCursor.resetKey === reviewerCursorResetKey ? activeReviewerCursor.index : 0
const setActiveReviewerIndex = useCallback(
(nextIndex: number | ((current: number) => number)): void => {
setActiveReviewerCursor((current) => {
const currentIndex = current.resetKey === reviewerCursorResetKey ? current.index : 0
return {
resetKey: reviewerCursorResetKey,
index: typeof nextIndex === 'function' ? nextIndex(currentIndex) : nextIndex
}
})
},
[reviewerCursorResetKey]
)
if (item.type !== 'pr') {
return (
<span className="text-[11px] text-muted-foreground">
{translate('auto.components.TaskPage.b1eaa18ace', 'Issue')}
</span>
)
}
const itemWithLocalReviewRequests = {
...item,
reviewRequests: localReviewRequests
}
const primaryReviewer = getGitHubPRPrimaryReviewer(itemWithLocalReviewRequests)
const reviewerRows = getGitHubPRReviewerRows(itemWithLocalReviewRequests)
const extraReviewerCount = Math.max(0, reviewerRows.length - 1)
const hasReviewerMetadata =
item.reviewDecision !== undefined ||
localReviewRequests.length > 0 ||
item.reviewRequests !== undefined ||
item.latestReviews !== undefined
const { handleRequestReview, requestReviewer } = createTaskPageGitHubReviewerActions({
item,
localReviewRequests,
repo,
reviewRepo,
reviewerCandidates,
reviewerInput,
selectedReviewerLogins,
setLocalReviewRequests,
setOpen,
setReviewerInput,
setSubmitting,
sourceContext,
sourceSettings,
submitting,
workItemMutation
})
const handleReviewerPickerOpenChange = (nextOpen: boolean): void => {
if (nextOpen) {
const rect = reviewerTriggerRef.current?.getBoundingClientRect()
const gap = 8
const availableBelow = rect ? window.innerHeight - rect.bottom - gap : 0
const availableAbove = rect ? rect.top - gap : 0
const nextSide = availableBelow < 240 && availableAbove > availableBelow ? 'top' : 'bottom'
const available = nextSide === 'top' ? availableAbove : availableBelow
setReviewerPickerSide(nextSide)
setReviewerPickerMaxHeight(Math.max(180, Math.min(360, available || 360)))
}
setOpen(nextOpen)
if (nextOpen) {
cancelReviewerInputFocusFrame()
reviewerInputFocusFrameRef.current = requestAnimationFrame(() => {
reviewerInputFocusFrameRef.current = null
reviewerInputRef.current?.focus()
})
return
}
cancelReviewerInputFocusFrame()
setReviewerInput('')
}
return (
<Popover open={open} onOpenChange={handleReviewerPickerOpenChange}>
<PopoverTrigger asChild>
<button
ref={reviewerTriggerRef}
type="button"
onClick={(event) => event.stopPropagation()}
className={cn(
'inline-flex h-7 max-w-full items-center justify-center text-[12px] font-medium transition hover:brightness-110',
primaryReviewer
? 'gap-1 rounded-full border border-border/40 bg-background/70 px-1.5 text-muted-foreground hover:text-foreground'
: 'min-w-7 text-muted-foreground hover:text-foreground'
)}
aria-label={translate(
'auto.components.TaskPage.editReviewersWithCurrent',
'Edit reviewers: {{value0}}',
{
value0: getGitHubPRReviewLabel(itemWithLocalReviewRequests)
}
)}
title={getGitHubPRReviewLabel(itemWithLocalReviewRequests)}
>
{primaryReviewer ? (
<>
<ReviewChipAvatar reviewer={primaryReviewer} avatarHost={reviewRepo?.host} />
{extraReviewerCount > 0 ? (
<span className="text-[10px] tabular-nums text-muted-foreground">
+{extraReviewerCount}
</span>
) : null}
<ChevronDown className="size-3 text-muted-foreground" />
</>
) : (
<span aria-hidden="true">-</span>
)}
</button>
</PopoverTrigger>
<TaskPageGitHubReviewerPicker
actionableReviewerRows={actionableReviewerRows}
activeReviewerIndex={activeReviewerIndex}
everyoneElseReviewerRows={everyoneElseReviewerRows}
filteredReviewerCandidates={filteredReviewerCandidates}
handleRequestReview={handleRequestReview}
handleReviewerPickerOpenChange={handleReviewerPickerOpenChange}
hasReviewerMetadata={hasReviewerMetadata}
repo={repo}
requestReviewer={requestReviewer}
reviewerInput={reviewerInput}
reviewerMetadataError={reviewerMetadata.error}
reviewerMetadataLoading={reviewerMetadata.loading}
reviewerPickerMaxHeight={reviewerPickerMaxHeight}
reviewerPickerSide={reviewerPickerSide}
selectedReviewerLogins={selectedReviewerLogins}
setActiveReviewerIndex={setActiveReviewerIndex}
setReviewerInput={setReviewerInput}
setReviewerInputNode={setReviewerInputNode}
submitting={submitting}
suggestedReviewerRows={suggestedReviewerRows}
/>
</Popover>
)
}
@@ -0,0 +1,222 @@
import type { Dispatch, RefCallback, SetStateAction } from 'react'
import { Check } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/input'
import { PopoverContent } from '@/components/ui/popover'
import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types'
import type { Repo } from '../../../shared/repo-types'
type ReviewerPickerProps = {
actionableReviewerRows: GitHubAssignableUser[]
activeReviewerIndex: number
everyoneElseReviewerRows: GitHubAssignableUser[]
filteredReviewerCandidates: GitHubAssignableUser[]
handleRequestReview: (requestedLogins?: string[]) => Promise<void>
handleReviewerPickerOpenChange: (nextOpen: boolean) => void
hasReviewerMetadata: boolean
repo: Repo | null
requestReviewer: (reviewer: GitHubAssignableUser) => Promise<void>
reviewerInput: string
reviewerMetadataError: string | null
reviewerMetadataLoading: boolean
reviewerPickerMaxHeight: number | null
reviewerPickerSide: 'top' | 'bottom'
selectedReviewerLogins: ReadonlySet<string>
setActiveReviewerIndex: (nextIndex: number | ((current: number) => number)) => void
setReviewerInput: Dispatch<SetStateAction<string>>
setReviewerInputNode: RefCallback<HTMLInputElement>
submitting: boolean
suggestedReviewerRows: GitHubAssignableUser[]
}
export function TaskPageGitHubReviewerPicker({
actionableReviewerRows,
activeReviewerIndex,
everyoneElseReviewerRows,
filteredReviewerCandidates,
handleRequestReview,
handleReviewerPickerOpenChange,
hasReviewerMetadata,
repo,
requestReviewer,
reviewerInput,
reviewerMetadataError,
reviewerMetadataLoading,
reviewerPickerMaxHeight,
reviewerPickerSide,
selectedReviewerLogins,
setActiveReviewerIndex,
setReviewerInput,
setReviewerInputNode,
submitting,
suggestedReviewerRows
}: ReviewerPickerProps): React.JSX.Element {
const renderReviewerPickerRow = (
reviewer: GitHubAssignableUser,
options: {
suggested: boolean
activeIndex: number
}
): React.JSX.Element => {
const selected = selectedReviewerLogins.has(reviewer.login.toLowerCase())
const active = actionableReviewerRows[activeReviewerIndex]?.login === reviewer.login
return (
<button
key={`${options.suggested ? 'suggested' : 'reviewer'}:${reviewer.login}`}
type="button"
className={cn(
'flex min-h-10 w-full items-center gap-2 border-b border-border/50 px-3 py-2 text-left text-[13px] outline-none last:border-b-0 hover:bg-accent/70',
active && 'bg-accent text-accent-foreground',
selected && 'font-medium'
)}
onMouseEnter={() => setActiveReviewerIndex(options.activeIndex)}
onMouseDown={(event) => {
event.preventDefault()
void requestReviewer(reviewer)
}}
>
<span className="flex size-4 shrink-0 items-center justify-center text-foreground">
{selected ? <Check className="size-3.5" /> : null}
</span>
{reviewer.avatarUrl ? (
<img src={reviewer.avatarUrl} alt="" className="size-5 shrink-0 rounded-full" />
) : (
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
{reviewer.login.slice(0, 1).toUpperCase()}
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate">
<span className="font-semibold text-foreground">{reviewer.login}</span>
{reviewer.name ? (
<span className="ml-1 font-normal text-muted-foreground">{reviewer.name}</span>
) : null}
</span>
{options.suggested ? (
<span className="block truncate text-[12px] leading-4 text-muted-foreground">
{translate(
'auto.components.TaskPage.5d4fd69a6a',
'Recently active in this pull request'
)}
</span>
) : null}
</span>
</button>
)
}
return (
<PopoverContent
className="flex w-[330px] flex-col overflow-hidden rounded-md border-border/70 p-0"
align="start"
side={reviewerPickerSide}
sideOffset={6}
avoidCollisions={false}
style={{
maxHeight: reviewerPickerMaxHeight ? `${reviewerPickerMaxHeight}px` : undefined
}}
onClick={(event) => event.stopPropagation()}
onOpenAutoFocus={(event) => {
event.preventDefault()
}}
>
<div className="border-b border-border/70 px-3 py-2">
<div className="text-[13px] font-semibold text-foreground">
{translate('auto.components.TaskPage.62c7bd789f', 'Request up to 15 reviewers')}
</div>
</div>
<div className="border-b border-border/70 p-3">
<Input
ref={setReviewerInputNode}
value={reviewerInput}
onChange={(event) => setReviewerInput(event.target.value)}
placeholder={translate('auto.components.TaskPage.0b9b04f4b5', 'Type or choose a user')}
disabled={!repo || submitting}
className="h-8 rounded-md bg-background px-2 text-[13px]"
aria-label={translate('auto.components.TaskPage.0b9b04f4b5', 'Type or choose a user')}
aria-autocomplete="list"
onKeyDown={(event) => {
if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) {
event.preventDefault()
setActiveReviewerIndex((current) => (current + 1) % actionableReviewerRows.length)
return
}
if (event.key === 'ArrowUp' && actionableReviewerRows.length > 0) {
event.preventDefault()
setActiveReviewerIndex(
(current) =>
(current - 1 + actionableReviewerRows.length) % actionableReviewerRows.length
)
return
}
if (event.key === 'Enter') {
event.preventDefault()
const activeReviewer = actionableReviewerRows[activeReviewerIndex]
if (activeReviewer) {
void requestReviewer(activeReviewer)
return
}
void handleRequestReview()
return
}
if (event.key === 'Escape') {
event.preventDefault()
handleReviewerPickerOpenChange(false)
}
}}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
{reviewerMetadataLoading ? (
<div className="px-3 py-2 text-[13px] text-muted-foreground">
{translate('auto.components.TaskPage.0eacf48491', 'Loading…')}
</div>
) : filteredReviewerCandidates.length > 0 ? (
<>
{suggestedReviewerRows.length > 0 ? (
<>
<div className="border-b border-border/70 bg-muted/50 px-3 py-1.5 text-[12px] font-semibold text-foreground">
{translate('auto.components.TaskPage.3ace2e6bcf', 'Suggestions')}
</div>
{suggestedReviewerRows.map((reviewer, index) =>
renderReviewerPickerRow(reviewer, {
suggested: true,
activeIndex: index
})
)}
</>
) : null}
<div className="border-b border-border/70 bg-muted/50 px-3 py-1.5 text-[12px] font-semibold text-foreground">
{translate('auto.components.TaskPage.67755a83a1', 'Everyone else')}
</div>
{everyoneElseReviewerRows.length > 0 ? (
everyoneElseReviewerRows.map((reviewer, index) =>
renderReviewerPickerRow(reviewer, {
suggested: false,
activeIndex: suggestedReviewerRows.length + index
})
)
) : (
<div className="px-3 py-2 text-[13px] text-muted-foreground">
{translate('auto.components.TaskPage.8a22eb3f7b', 'No matching reviewers.')}
</div>
)}
</>
) : (
<div className="px-3 py-2 text-[13px] text-muted-foreground">
{reviewerMetadataError ??
(hasReviewerMetadata
? translate('auto.components.TaskPage.8a22eb3f7b', 'No matching reviewers.')
: translate(
'auto.components.TaskPage.9e03c17847',
'Open the PR details to view current reviewers.'
))}
</div>
)}
</div>
</PopoverContent>
)
}
@@ -0,0 +1,388 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { findGithubWorkItemWorkspaceAttachment } from '@/lib/github-work-item-workspace-attachment'
import { getWorktreeAttachmentLabel } from '@/lib/worktree-attachment-label'
import { formatPRDelta } from '@/components/task-page-pr-delta-summary'
import {
isTaskPageGitHubDraftPR,
getTaskPageGitHubPRIconTone
} from '@/components/task-page-github-work-item-status'
import {
GitPullRequestDraft,
GitPullRequest,
CircleDot,
Files,
FolderKanban,
ArrowRight,
ChevronDown,
Plus,
ExternalLink,
EllipsisVertical
} from 'lucide-react'
import { cn } from '@/lib/utils'
import {
GITHUB_TASK_STICKY_ID_CELL_CLASS,
GITHUB_TASK_STICKY_TITLE_CELL_CLASS,
getTaskPageRepoSourceContext,
formatRelativeTime
} from './task-page-source-context'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { TaskPageGitHubWorkItemStateBadge } from '@/components/task-page-github-work-item-status-badge'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
import { GHAssigneesCell } from './TaskPageGitHubAssigneesCell'
import { PRReviewCell } from './TaskPageGitHubReviewCell'
import { PRChecksCell } from './TaskPageGitHubChecksCell'
import { PRMergeCell } from './TaskPageGitHubMergeCell'
import { GHStatusCell } from './TaskPageGitHubStatusCell'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem
} from '@/components/ui/dropdown-menu'
import { ButtonGroup } from '@/components/ui/button-group'
import { Button } from '@/components/ui/button'
export function TaskPageGitHubRows({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
repoMap,
allWorktrees,
selectedRepos,
openGitHubDetailPage,
githubWorkItemMutation,
filteredWorkItems,
showGitHubTaskSkeletons,
showPRManagementColumns,
githubTaskGridClass,
ensurePRChecksLoaded,
handleUseWorkItem,
handleOpenOrUseGitHubWorkItem
} = model
return (
<div className="divide-y divide-border/40">
{!showGitHubTaskSkeletons &&
filteredWorkItems.map((item) => {
const itemRepo = repoMap.get(item.repoId) ?? null
const attachedWorkspace = findGithubWorkItemWorkspaceAttachment(
allWorktrees,
item.repoId,
item.type,
item.number
)
const attachedWorkspaceLabel = attachedWorkspace
? getWorktreeAttachmentLabel(attachedWorkspace)
: null
const prDelta = item.type === 'pr' ? formatPRDelta(item) : null
const githubTaskIdPill = (
<span
// Why: no fill — a muted wash on the pill stacks on the
// row's hover:bg-accent and reads as a second hover tint.
className="inline-flex items-center gap-1 rounded-md border border-border/40 px-1.5 py-0.5 text-muted-foreground"
aria-label={`${item.type === 'pr' ? (isTaskPageGitHubDraftPR(item) ? 'Draft pull request' : 'Pull request') : 'Issue'} #${item.number}`}
>
{item.type === 'pr' ? (
isTaskPageGitHubDraftPR(item) ? (
<GitPullRequestDraft
className={cn('size-3', getTaskPageGitHubPRIconTone(item))}
aria-hidden="true"
/>
) : (
<GitPullRequest
className={cn('size-3', getTaskPageGitHubPRIconTone(item))}
aria-hidden="true"
/>
)
) : (
<CircleDot className="size-3" aria-hidden="true" />
)}
<span className="font-mono text-[11px] font-normal">#{item.number}</span>
</span>
)
return (
// Why: clickable div not a <button> — it nests buttons, and button-in-button is invalid HTML that breaks hydration.
<div
// Why: key on repoId+item.id — repos sharing an upstream reuse item.id, so a bare key collides and React silently drops rows.
key={`${item.repoId}:${item.id}`}
role="button"
tabIndex={0}
onClick={() => openGitHubDetailPage(item)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openGitHubDetailPage(item)
}
}}
className={cn(
// Why: sticky ID/Title paint the same bg-background /
// hover:bg-accent pair (with transition-colors) so the
// left columns don't flash a separate hover wash.
// Grid stretch (default) keeps sticky fills full-height.
'group/github-task-row grid min-h-12 cursor-pointer gap-3 px-3 py-2.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
githubTaskGridClass
)}
>
<div className={GITHUB_TASK_STICKY_ID_CELL_CLASS}>
{isTaskPageGitHubDraftPR(item) ? (
<Tooltip>
<TooltipTrigger asChild>{githubTaskIdPill}</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.TaskPage.054bf695cc', 'Draft')}
</TooltipContent>
</Tooltip>
) : (
githubTaskIdPill
)}
</div>
<div className={GITHUB_TASK_STICKY_TITLE_CELL_CLASS}>
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-[13px] font-medium text-foreground">{item.title}</h3>
{item.type === 'pr' && item.state !== 'open' && item.state !== 'draft' ? (
<TaskPageGitHubWorkItemStateBadge
item={item}
className="shrink-0 px-1.5 py-0"
/>
) : null}
{selectedRepos.length > 1 && itemRepo ? (
// Why: disambiguate rows in the merged multi-repo list; a single-repo view doesn't need it.
<RepoBadgeLabel
name={itemRepo.displayName}
color={itemRepo.badgeColor}
badgeClassName="size-1.5"
className="shrink-0 text-[11px] text-muted-foreground"
/>
) : null}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[12px] text-muted-foreground">
<span>
{item.author ??
translate('auto.components.TaskPage.6430594b18', 'unknown author')}
</span>
{selectedRepos.length === 1 && itemRepo ? (
<span>{itemRepo.displayName}</span>
) : null}
{item.type === 'pr' && item.state === 'draft' ? (
<>
<span aria-hidden="true">·</span>
<span>{translate('auto.components.TaskPage.054bf695cc', 'Draft')}</span>
</>
) : null}
{prDelta ? (
<span className="inline-flex items-center gap-1">
<Files className="size-3" />
{prDelta}
</span>
) : null}
{attachedWorkspaceLabel ? (
<span className="inline-flex min-w-0 items-center gap-1">
<FolderKanban className="size-3 shrink-0" />
<span className="truncate">{attachedWorkspaceLabel}</span>
</span>
) : null}
{item.labels.slice(0, 3).map((label) => (
<span
key={label}
className="rounded-full border border-border/40 bg-muted/30 px-1.5 py-0 text-[10px] text-muted-foreground"
>
{label}
</span>
))}
</div>
</div>
{!showPRManagementColumns ? (
<div className="min-w-0 flex items-center text-xs text-muted-foreground">
<GHAssigneesCell
item={item}
repo={itemRepo ?? null}
sourceContext={getTaskPageRepoSourceContext(itemRepo, 'github')}
workItemMutation={githubWorkItemMutation}
/>
</div>
) : null}
{showPRManagementColumns ? (
<>
<div className="flex min-w-0 items-center">
<PRReviewCell
item={item}
repo={itemRepo ?? null}
sourceContext={getTaskPageRepoSourceContext(itemRepo, 'github')}
workItemMutation={githubWorkItemMutation}
/>
</div>
<div className="flex min-w-0 items-center">
<PRChecksCell
item={item}
onOpen={() => openGitHubDetailPage(item, 'checks')}
onLoadChecks={() => ensurePRChecksLoaded(item)}
/>
</div>
<div className="flex min-w-0 items-center">
<PRMergeCell
item={item}
repo={itemRepo ?? null}
sourceContext={getTaskPageRepoSourceContext(itemRepo, 'github')}
workItemMutation={githubWorkItemMutation}
/>
</div>
</>
) : (
<div className="flex items-center">
<GHStatusCell
item={item}
repo={itemRepo ?? null}
sourceContext={getTaskPageRepoSourceContext(itemRepo, 'github')}
workItemMutation={githubWorkItemMutation}
/>
</div>
)}
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center text-[11px] text-muted-foreground">
{formatRelativeTime(item.updatedAt)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{new Date(item.updatedAt).toLocaleString()}
</TooltipContent>
</Tooltip>
<div className="flex items-center justify-start gap-1 lg:justify-end">
{item.type === 'pr' ? (
<DropdownMenu modal={false}>
<ButtonGroup>
<Button
type="button"
variant={attachedWorkspace ? 'default' : 'outline'}
size="xs"
data-contextual-tour-target="tasks-start-workspace"
onClick={(event) => {
event.stopPropagation()
handleOpenOrUseGitHubWorkItem(item)
}}
className={cn(
'min-w-[72px] gap-1 font-semibold',
attachedWorkspace ? 'shadow-xs' : 'bg-background/80'
)}
aria-label={
attachedWorkspace
? translate(
'auto.components.TaskPage.67d881244c',
'Resume workspace attached to PR'
)
: translate(
'auto.components.TaskPage.e4b29c5bcf',
'Start workspace from PR'
)
}
>
{attachedWorkspace
? translate('auto.components.TaskPage.7753652524', 'Resume')
: translate('auto.components.TaskPage.7d08e8be0f', 'Start')}
<ArrowRight className="size-3" />
</Button>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant={attachedWorkspace ? 'default' : 'outline'}
size="icon-xs"
onClick={(event) => event.stopPropagation()}
className={cn(attachedWorkspace ? 'shadow-xs' : 'bg-background/80')}
aria-label={translate(
'auto.components.TaskPage.7deb9e59a5',
'More PR actions'
)}
>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
</ButtonGroup>
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
{attachedWorkspace ? (
<DropdownMenuItem onSelect={() => handleUseWorkItem(item)}>
<Plus className="size-4" />
{translate('auto.components.TaskPage.b6329379ca', 'Start new workspace')}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onSelect={() => window.api.shell.openUrl(item.url)}>
<ExternalLink className="size-4" />
{translate('auto.components.TaskPage.c1d1600362', 'Open in browser')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
type="button"
// Why: Open resumes an existing workspace — solid primary reads stronger than outline Start (new workspace).
variant={attachedWorkspace ? 'default' : 'outline'}
size="xs"
data-contextual-tour-target="tasks-start-workspace"
onClick={(event) => {
event.stopPropagation()
handleOpenOrUseGitHubWorkItem(item)
}}
className={cn(
'min-w-[72px] gap-1 font-semibold',
attachedWorkspace ? 'shadow-xs' : 'bg-background/80'
)}
aria-label={
attachedWorkspace
? translate(
'auto.components.TaskPage.2193a99ec1',
'Open workspace attached to issue'
)
: translate(
'auto.components.TaskPage.e104fa3d3d',
'Start workspace from issue'
)
}
>
{attachedWorkspace
? translate('auto.components.TaskPage.606a85c774', 'Open')
: translate('auto.components.TaskPage.7d08e8be0f', 'Start')}
<ArrowRight className="size-3" />
</Button>
)}
{item.type !== 'pr' ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="rounded-lg p-1.5 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
aria-label={translate(
'auto.components.TaskPage.66ae7330f6',
'More actions'
)}
>
<EllipsisVertical className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
{attachedWorkspace ? (
<DropdownMenuItem onSelect={() => handleUseWorkItem(item)}>
<Plus className="size-4" />
{translate('auto.components.TaskPage.b6329379ca', 'Start new workspace')}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onSelect={() => window.api.shell.openUrl(item.url)}>
<ExternalLink className="size-4" />
{translate('auto.components.TaskPage.c1d1600362', 'Open in browser')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,396 @@
import type { GitHubWorkItem } from '../../../shared/github/work-item-types'
import type { GitHubIssueUpdate } from '../../../shared/issue-mutation-types'
import type { Repo } from '../../../shared/repo-types'
import {
type TaskSourceContext,
getTaskSourceRuntimeSettings
} from '../../../shared/task-source-context'
import React, { useState, useMemo, useCallback } from 'react'
import {
createTaskPageGitHubStatusStateDraft,
resolveTaskPageGitHubStatusStateDraft,
updateTaskPageGitHubStatusLocalState
} from '@/components/task-page-github-status-state'
import { useAppStore } from '@/store'
import { useShallow } from 'zustand/react/shallow'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
import { parseGitHubIssueOrPRLink } from '@/lib/github-links'
import {
getTaskPageGitHubDuplicateCandidates,
validateTaskPageGitHubDuplicateTarget,
type TaskPageGitHubCloseAction,
buildTaskPageGitHubCloseUpdate,
getTaskPageGitHubDuplicateTargetErrorMessage
} from '@/components/task-page-github-status-actions'
import { translate } from '@/i18n/i18n'
import { getActiveRuntimeTarget, callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { githubProjectHost } from '../../../shared/github/project-identity'
import { TaskPageGitHubWorkItemStateBadge } from '@/components/task-page-github-work-item-status-badge'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { CircleDot, ChevronDown, Copy, CheckCircle2, Ban, ChevronRight } from 'lucide-react'
import type { TaskPageGitHubWorkItemMutationRunner } from './task-page-linear-jira-list-model'
import { TaskPageGitHubDuplicatePicker } from './TaskPageGitHubDuplicatePicker'
export function GHStatusCell({
item,
repo,
sourceContext,
workItemMutation
}: {
item: GitHubWorkItem
repo: Repo | null
sourceContext?: TaskSourceContext | null
workItemMutation: TaskPageGitHubWorkItemMutationRunner
}): React.JSX.Element {
const [statusStateDraft, setStatusStateDraft] = useState(() =>
createTaskPageGitHubStatusStateDraft(item)
)
const [open, setOpen] = useState(false)
const [statusUpdating, setStatusUpdating] = useState(false)
const [duplicatePickerOpen, setDuplicatePickerOpen] = useState(false)
const [duplicateSearch, setDuplicateSearch] = useState('')
const [duplicateError, setDuplicateError] = useState<string | null>(null)
const duplicateIssueCandidates = useAppStore(
useShallow((s) => {
if (!duplicatePickerOpen) {
return []
}
const deduped = new Map<number, GitHubWorkItem>()
for (const entry of Object.values(s.workItemsCache)) {
for (const candidate of entry.data ?? []) {
if (
candidate.type === 'issue' &&
candidate.repoId === item.repoId &&
candidate.number !== item.number &&
!deduped.has(candidate.number)
) {
deduped.set(candidate.number, candidate)
}
}
}
return Array.from(deduped.values()).sort((a, b) => b.number - a.number)
})
)
const repoOwnerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null))
)
const sourceSettings = useMemo(
() =>
sourceContext?.provider === 'github'
? ({
...repoOwnerSettings,
...getTaskSourceRuntimeSettings(sourceContext)
} as typeof repoOwnerSettings)
: repoOwnerSettings,
[repoOwnerSettings, sourceContext]
)
const parsedIssueLink = useMemo(() => parseGitHubIssueOrPRLink(item.url), [item.url])
const filteredDuplicateCandidates = useMemo(
() =>
getTaskPageGitHubDuplicateCandidates(duplicateIssueCandidates, item.number, duplicateSearch),
[duplicateIssueCandidates, duplicateSearch, item.number]
)
const directDuplicateTarget = useMemo(() => {
const trimmed = duplicateSearch.trim()
const validation = validateTaskPageGitHubDuplicateTarget(trimmed, item.number)
if (!trimmed || !validation.ok) {
return null
}
if (
filteredDuplicateCandidates.some((candidate) => candidate.number === validation.duplicateOf)
) {
return null
}
return validation.duplicateOf
}, [duplicateSearch, filteredDuplicateCandidates, item.number])
const duplicatePickerTitle = parsedIssueLink?.slug
? `${parsedIssueLink.slug.owner}/${parsedIssueLink.slug.repo}`
: (repo?.displayName ?? translate('auto.components.TaskPage.repository', 'Repository'))
const resolvedStatusStateDraft = resolveTaskPageGitHubStatusStateDraft(statusStateDraft, item)
if (resolvedStatusStateDraft !== statusStateDraft) {
// Why: item rows can refresh from the cache while this cell is mounted; reconcile before paint to avoid one stale status frame.
setStatusStateDraft(resolvedStatusStateDraft)
}
const localState = resolvedStatusStateDraft.localState
const stateMutationPending = workItemMutation.isIntentPending({
item,
intent: {
type: 'setState',
state: localState === 'open' ? 'closed' : 'open'
},
sourceContext
})
const updateLocalState = useCallback(
(nextState: GitHubWorkItem['state']) => {
setStatusStateDraft((current) =>
updateTaskPageGitHubStatusLocalState(current, item, nextState)
)
},
[item]
)
const handleStateChange = useCallback(
async (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => {
if (
statusUpdating ||
stateMutationPending ||
newState === localState ||
item.type !== 'issue'
) {
return
}
const parsedOwnerRepo = parsedIssueLink?.slug
if (!repo && !parsedOwnerRepo) {
return
}
const updates: GitHubIssueUpdate =
newState === 'closed' && closeAction
? buildTaskPageGitHubCloseUpdate(closeAction)
: {
state: newState
}
updateLocalState(newState)
// Why: coordinator owns durable patch + soft-hide + quiet revalidate; keep
// the status draft so one-frame flash is still covered until proven safe.
setStatusUpdating(true)
try {
await workItemMutation.run({
item,
intent: {
type: 'setState',
state: newState,
closeAction
},
sourceContext,
errorToast: translate('auto.components.TaskPage.1c893195ac', 'Failed to update state'),
mutate: async () => {
const target = getActiveRuntimeTarget(sourceSettings)
// Why: issue rows can be sourced by owner/repo URL instead of the local
// repo context; slug-aware writes preserve close reasons and duplicates.
if (parsedOwnerRepo) {
return target.kind === 'environment'
? callRuntimeRpc<{
ok?: boolean
error?:
| {
message?: string
}
| string
}>(
target,
'github.project.updateIssueBySlug',
{
owner: parsedOwnerRepo.owner,
repo: parsedOwnerRepo.repo,
host: githubProjectHost(parsedOwnerRepo.host),
number: item.number,
updates
},
{
timeoutMs: 30_000
}
)
: window.api.gh.updateIssueBySlug({
owner: parsedOwnerRepo.owner,
repo: parsedOwnerRepo.repo,
host: githubProjectHost(parsedOwnerRepo.host),
number: item.number,
updates
})
}
if (!repo) {
throw new Error('No GitHub repository context available for this issue.')
}
const runtimeRepoId =
sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id
return target.kind === 'environment'
? callRuntimeRpc<{
ok?: boolean
error?: string
}>(
target,
'github.updateIssue',
{
repo: runtimeRepoId,
number: item.number,
updates
},
{
timeoutMs: 30_000
}
)
: window.api.gh.updateIssue({
repoPath: repo.path,
repoId: repo.id,
sourceContext,
number: item.number,
updates
})
}
})
} finally {
setStatusUpdating(false)
}
// Why: draft realigns from item.state via resolveTaskPageGitHubStatusStateDraft
// when patchWorkItem (begin/rollback) updates the cache-backed row.
},
[
item,
localState,
parsedIssueLink,
repo,
sourceContext,
sourceSettings,
stateMutationPending,
statusUpdating,
updateLocalState,
workItemMutation
]
)
const closeAsDuplicate = useCallback(
(targetIssueNumber: number | string) => {
const validation = validateTaskPageGitHubDuplicateTarget(
String(targetIssueNumber),
item.number
)
if (!validation.ok) {
setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate))
return
}
setDuplicateError(null)
handleStateChange('closed', {
stateReason: 'duplicate',
duplicateOf: validation.duplicateOf
})
setOpen(false)
setDuplicatePickerOpen(false)
},
[handleStateChange, item.number]
)
const handleDuplicateSearchSubmit = useCallback(() => {
const validation = validateTaskPageGitHubDuplicateTarget(duplicateSearch, item.number)
if (!validation.ok) {
setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate))
return
}
closeAsDuplicate(validation.duplicateOf)
}, [closeAsDuplicate, duplicateSearch, item.number])
const handlePopoverOpenChange = useCallback((nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
setDuplicatePickerOpen(false)
setDuplicateSearch('')
setDuplicateError(null)
}
}, [])
if (item.type !== 'issue' || (!repo && !parsedIssueLink?.slug)) {
return <TaskPageGitHubWorkItemStateBadge item={item} />
}
return (
<Popover open={open} onOpenChange={handlePopoverOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
disabled={statusUpdating || stateMutationPending}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
className={cn(
'group/status inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium transition hover:brightness-125 hover:ring-1 hover:ring-white/10',
localState === 'closed'
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
)}
>
{localState === 'open' ? <CircleDot className="size-2.5" /> : null}
<span>
{localState === 'closed'
? translate('auto.components.TaskPage.d09bf34db7', 'Closed')
: translate('auto.components.TaskPage.606a85c774', 'Open')}
</span>
<ChevronDown className="size-2.5 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent
className={cn(duplicatePickerOpen ? 'w-[360px]' : 'w-56', 'p-1')}
align="start"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{duplicatePickerOpen ? (
<TaskPageGitHubDuplicatePicker
closeAsDuplicate={closeAsDuplicate}
directDuplicateTarget={directDuplicateTarget}
duplicateError={duplicateError}
duplicatePickerTitle={duplicatePickerTitle}
duplicateSearch={duplicateSearch}
filteredDuplicateCandidates={filteredDuplicateCandidates}
handleDuplicateSearchSubmit={handleDuplicateSearchSubmit}
setDuplicateError={setDuplicateError}
setDuplicatePickerOpen={setDuplicatePickerOpen}
setDuplicateSearch={setDuplicateSearch}
/>
) : (
<>
<button
type="button"
onClick={() => {
handleStateChange('open')
setOpen(false)
}}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
localState === 'open' && 'bg-accent/50'
)}
>
<CircleDot className="size-4 text-muted-foreground" />
{translate('auto.components.TaskPage.606a85c774', 'Open')}
</button>
<button
type="button"
onClick={() => {
handleStateChange('closed', {
stateReason: 'completed'
})
setOpen(false)
}}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent',
localState === 'closed' && 'bg-accent/50'
)}
>
<CheckCircle2 className="size-4 text-muted-foreground" />
{translate('auto.components.TaskPage.closeAsCompleted', 'Close as completed')}
</button>
<button
type="button"
onClick={() => {
handleStateChange('closed', {
stateReason: 'not_planned'
})
setOpen(false)
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
<Ban className="size-4 text-muted-foreground" />
{translate('auto.components.TaskPage.closeAsNotPlanned', 'Close as not planned')}
</button>
<button
type="button"
onClick={() => {
setDuplicatePickerOpen(true)
setDuplicateSearch('')
setDuplicateError(null)
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
<Copy className="size-4 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">
{translate('auto.components.TaskPage.closeAsDuplicate', 'Close as duplicate')}
</span>
<ChevronRight className="size-3.5 text-muted-foreground" />
</button>
</>
)}
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,29 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import GitLabItemDialog from '@/components/GitLabItemDialog'
export function TaskPageGitLabDialog({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
gitlabDialogItem,
setGitlabDialogItem,
gitlabDialogRepo,
gitlabDialogSourceContext,
handleUseGitLabItem
} = model
return (
<GitLabItemDialog
item={gitlabDialogItem}
// Why: repoPath comes from the clicked item's own repo, not primaryRepo — the GitLab fetch is now multi-repo.
repoPath={gitlabDialogRepo?.path ?? null}
repoId={gitlabDialogItem?.repoId ?? null}
sourceContext={gitlabDialogSourceContext}
onCreateWorkspace={(item) => {
setGitlabDialogItem(null)
handleUseGitLabItem(item)
}}
onClose={() => setGitlabDialogItem(null)}
/>
)
}
@@ -0,0 +1,167 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { cn } from '@/lib/utils'
import TaskProjectSourceCombobox from '@/components/task-project-source-combobox'
import { normalizeTaskRepoSelection } from '@/components/task-page-default-repo-selection'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
import { LoaderCircle, RefreshCw } from 'lucide-react'
export function TaskPageGitLabFilters({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
updateSettings,
eligibleRepos,
repoSelection,
setRepoSelection,
taskPickerGroups,
taskPickerRepos,
gitLabIssueFilters,
gitLabMRFilters,
getTaskPickerRepoHostLabel,
setGitlabFilter,
gitlabLoading,
setGitlabRefreshNonce,
gitlabView,
setGitlabView,
gitlabTodosLoading,
activeGitlabFilter
} = model
return (
<>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div className="flex items-center gap-1 text-xs">
{(['issues', 'mrs', 'todos'] as const).map((view) => {
const active = gitlabView === view
const label = view === 'issues' ? 'Issues' : view === 'mrs' ? 'MRs' : 'My Todos'
return (
<button
key={view}
type="button"
onClick={() => setGitlabView(view)}
className={cn(
'rounded-md border px-2.5 py-1 text-xs transition',
active
? 'border-foreground/40 bg-foreground/90 text-background'
: 'border-border/50 bg-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground'
)}
>
{label}
</button>
)
})}
</div>
<div className="min-w-0 w-full sm:w-[200px]">
<TaskProjectSourceCombobox
groups={taskPickerGroups}
selected={repoSelection}
getRepoHostLabel={getTaskPickerRepoHostLabel}
onChange={(next) => {
const normalized = normalizeTaskRepoSelection(eligibleRepos, next)
setRepoSelection(normalized)
void updateSettings({
defaultRepoSelection: [...normalized]
}).catch(() => {
toast.error(
translate(
'auto.components.TaskPage.dfd72673e7',
'Failed to save project selection.'
)
)
})
}}
onSelectAll={() => {
const allIds = new Set(taskPickerRepos.map((r) => r.id))
setRepoSelection(allIds)
void updateSettings({
defaultRepoSelection: null
}).catch(() => {
toast.error(
translate(
'auto.components.TaskPage.dfd72673e7',
'Failed to save project selection.'
)
)
})
}}
triggerClassName="h-8 w-full rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none"
/>
</div>
</div>
<div
className="min-w-0 rounded-md rounded-b-none border border-border/50 bg-muted/50 px-3 pt-2 pb-0 shadow-sm"
data-contextual-tour-target="tasks-search-presets"
>
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div className="flex flex-wrap gap-2">
{gitlabView === 'issues' || gitlabView === 'mrs'
? (gitlabView === 'issues' ? gitLabIssueFilters : gitLabMRFilters).map(
({ id, label }) => {
const active = activeGitlabFilter === id
return (
<button
key={id}
type="button"
onClick={() => {
setGitlabFilter(id)
setGitlabRefreshNonce((n) => n + 1)
}}
className={cn(
'rounded-md border px-2 py-1 text-xs transition',
active
? 'border-border/50 bg-foreground/90 text-background backdrop-blur-md'
: 'border-border/50 bg-transparent text-foreground hover:bg-muted/50'
)}
>
{label}
</button>
)
}
)
: null}
</div>
</div>
<div
className="flex shrink-0 items-center gap-2"
data-contextual-tour-target="tasks-actions"
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setGitlabRefreshNonce((n) => n + 1)}
disabled={gitlabLoading || gitlabTodosLoading}
aria-label={
gitlabView === 'todos'
? translate('auto.components.TaskPage.c679af7ad9', 'Refresh My Todos')
: translate(
'auto.components.TaskPage.d4c2830063',
'Refresh GitLab work items'
)
}
className="border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent"
>
{gitlabLoading || gitlabTodosLoading ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{gitlabView === 'todos'
? translate('auto.components.TaskPage.c679af7ad9', 'Refresh My Todos')
: translate('auto.components.TaskPage.d4c2830063', 'Refresh GitLab work items')}
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
</>
)
}
@@ -0,0 +1,148 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
import { ArrowRight, ExternalLink } from 'lucide-react'
export function TaskPageGitLabItemList({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
gitlabItems,
gitlabLoading,
gitlabError,
gitlabEmptyState,
displayedGitLabItems,
openGitLabDetailPage,
handleUseGitLabItem
} = model
return (
<div className="flex min-h-0 max-h-full flex-col rounded-md border border-t-0 border-border/50 bg-muted/50 overflow-hidden rounded-t-none shadow-sm">
<div className="flex-none grid grid-cols-[80px_minmax(0,3fr)_120px_110px_50px] gap-3 border-b border-border/50 px-3 py-2 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
<span>{translate('auto.components.TaskPage.eb10c32872', 'ID')}</span>
<span>{translate('auto.components.TaskPage.16cba35bee', 'Title')}</span>
<span>{translate('auto.components.TaskPage.00b7ffb952', 'Type / State')}</span>
<span>{translate('auto.components.TaskPage.f362667d55', 'Updated')}</span>
<span />
</div>
<div
className="min-h-0 flex-initial overflow-y-auto scrollbar-sleek"
style={{
scrollbarGutter: 'stable'
}}
>
{gitlabError ? (
<div className="border-b border-border px-4 py-4 text-sm text-destructive">
{gitlabError}
</div>
) : null}
{gitlabLoading && gitlabItems.length === 0 ? (
// Why: shimmer rows fill the viewport so the card never flashes empty and the table doesn't jump when real rows land.
<div className="divide-y divide-border/50">
{Array.from({
length: 12
}).map((_, i) => (
<div
key={i}
className="grid w-full gap-3 px-3 py-2 grid-cols-[80px_minmax(0,3fr)_120px_110px_50px]"
>
<div className="h-4 w-16 animate-pulse rounded bg-muted/70" />
<div>
<div className="h-4 w-3/5 animate-pulse rounded bg-muted/70" />
</div>
<div className="h-3 w-20 animate-pulse rounded bg-muted/60" />
<div className="h-3 w-20 animate-pulse rounded bg-muted/60" />
<div />
</div>
))}
</div>
) : null}
{!gitlabLoading && displayedGitLabItems.length === 0 && !gitlabError ? (
<div className="px-4 py-12 text-center">
<p className="text-base font-medium text-foreground">{gitlabEmptyState.title}</p>
<p className="mt-2 text-sm text-muted-foreground">{gitlabEmptyState.description}</p>
</div>
) : null}
<div className="divide-y divide-border/50">
{displayedGitLabItems.map((item) => (
// Why: div role="button" not a <button> — it nests an open-in-browser button, and button-in-button is invalid HTML.
<div
role="button"
tabIndex={0}
key={item.id}
onClick={() => {
useAppStore.getState().recordFeatureInteraction('gitlab-tasks')
openGitLabDetailPage(item)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
useAppStore.getState().recordFeatureInteraction('gitlab-tasks')
openGitLabDetailPage(item)
}
}}
className="grid w-full cursor-pointer gap-3 px-3 py-2 text-left grid-cols-[80px_minmax(0,3fr)_120px_110px_50px] hover:bg-muted/50"
>
<span className="font-mono text-xs text-muted-foreground">
{/* Why: GitLab uses !N for MRs and #N for issues — match gitlab.com so rows map to web links. */}
{item.type === 'mr' ? '!' : '#'}
{item.number}
</span>
<span className="min-w-0 truncate text-sm">{item.title}</span>
<span className="text-xs text-muted-foreground">
{item.type === 'mr'
? translate('auto.components.TaskPage.e224d76876', 'MR')
: translate('auto.components.TaskPage.b1eaa18ace', 'Issue')}{' '}
· {item.state}
</span>
<span className="text-xs text-muted-foreground">
{item.updatedAt ? new Date(item.updatedAt).toLocaleDateString() : ''}
</span>
<div className="flex items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
data-contextual-tour-target="tasks-start-workspace"
onClick={(event) => {
event.stopPropagation()
handleUseGitLabItem(item)
}}
aria-label={translate(
'auto.components.TaskPage.5e8061b088',
'Start workspace from {{value0}} {{value1}}',
{
value0: item.type === 'mr' ? 'MR' : 'issue',
value1: item.number
}
)}
>
<ArrowRight className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.TaskPage.9497f2787c', 'Start workspace')}
</TooltipContent>
</Tooltip>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void window.api.shell.openUrl(item.url)
}}
aria-label={translate('auto.components.TaskPage.bcdc1330b2', 'Open in GitLab')}
className="text-muted-foreground hover:text-foreground"
>
<ExternalLink className="size-3.5" />
</button>
</div>
</div>
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1,104 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { translate } from '@/i18n/i18n'
import { ExternalLink } from 'lucide-react'
export function TaskPageGitLabTodoList({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const { primaryRepo, gitlabTodos, gitlabTodosLoading } = model
return (
<div className="flex min-h-0 max-h-full flex-col rounded-md border border-t-0 border-border/50 bg-muted/50 overflow-hidden rounded-t-none shadow-sm">
<div className="flex-none grid grid-cols-[110px_minmax(0,3fr)_minmax(120px,1.2fr)_110px_50px] gap-3 border-b border-border/50 px-3 py-2 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
<span>{translate('auto.components.TaskPage.8396825a14', 'Action')}</span>
<span>{translate('auto.components.TaskPage.16cba35bee', 'Title')}</span>
<span>{translate('auto.components.TaskPage.00022ec0ba', 'Project')}</span>
<span>{translate('auto.components.TaskPage.f362667d55', 'Updated')}</span>
<span />
</div>
<div
className="min-h-0 flex-initial overflow-y-auto scrollbar-sleek"
style={{
scrollbarGutter: 'stable'
}}
>
{gitlabTodosLoading && gitlabTodos.length === 0 ? (
<div className="divide-y divide-border/50">
{Array.from({
length: 12
}).map((_, i) => (
<div
key={i}
className="grid w-full gap-3 px-3 py-2 grid-cols-[110px_minmax(0,3fr)_minmax(120px,1.2fr)_110px_50px]"
>
<div className="h-4 w-20 animate-pulse rounded bg-muted/70" />
<div>
<div className="h-4 w-3/5 animate-pulse rounded bg-muted/70" />
</div>
<div className="h-3 w-24 animate-pulse rounded bg-muted/60" />
<div className="h-3 w-20 animate-pulse rounded bg-muted/60" />
<div />
</div>
))}
</div>
) : null}
{!gitlabTodosLoading && gitlabTodos.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
{primaryRepo
? translate(
'auto.components.TaskPage.d591aac6ae',
'No pending todos. You’re all caught up!'
)
: translate(
'auto.components.TaskPage.03da966159',
'Select a project so we can authenticate to GitLab.'
)}
</div>
) : null}
<div className="divide-y divide-border/50">
{gitlabTodos.map((todo) => (
<div
role="button"
tabIndex={0}
key={todo.id}
onClick={() => void window.api.shell.openUrl(todo.targetUrl)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
void window.api.shell.openUrl(todo.targetUrl)
}
}}
className="grid w-full cursor-pointer gap-3 px-3 py-2 text-left grid-cols-[110px_minmax(0,3fr)_minmax(120px,1.2fr)_110px_50px] hover:bg-muted/50"
title={
todo.targetType === 'MergeRequest'
? translate('auto.components.TaskPage.a0544fb653', 'MR !{{value0}}', {
value0: todo.targetIid ?? ''
})
: todo.targetType === 'Issue'
? translate('auto.components.TaskPage.e9b6955dcd', 'Issue #{{value0}}', {
value0: todo.targetIid ?? ''
})
: todo.targetType
}
>
<span className="text-xs text-muted-foreground">
{/* Why: GitLab action_name is snake_case (review_requested); swap _ for space so the row reads like a sentence. */}
{todo.actionName.replace(/_/g, ' ')}
</span>
<span className="min-w-0 truncate text-sm">{todo.targetTitle}</span>
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground">
{todo.projectPath}
</span>
<span className="text-xs text-muted-foreground">
{todo.updatedAt ? new Date(todo.updatedAt).toLocaleDateString() : ''}
</span>
<span className="flex justify-end">
<ExternalLink className="size-3.5 text-muted-foreground" />
</span>
</div>
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1,10 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { JiraConnectDialog } from '@/components/jira-connect-dialog'
export function TaskPageJiraConnectDialog({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const { jiraConnectOpen, setJiraConnectOpen } = model
return <JiraConnectDialog open={jiraConnectOpen} onOpenChange={setJiraConnectOpen} />
}
@@ -0,0 +1,158 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { LoaderCircle } from 'lucide-react'
import { JiraIcon } from '@/components/icons/JiraIcon'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { TaskPageJiraSortControls } from './task-page-jira-sort-controls'
import { TaskPageJiraErrorBanner } from './task-page-linear-jira-list-model'
import { TaskPageJiraIssueList } from '@/components/task-page-jira-issue-list'
import { formatRelativeTime } from './task-page-source-context'
import { getJiraStatusTone } from '@/components/task-page-jira-status-tone'
import JiraIssueWorkspace from '@/components/JiraIssueWorkspace'
import { TaskPageLinearContent } from './TaskPageLinearContent'
export function TaskPageJiraContent({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
jiraStatus,
jiraStatusReady,
jiraConnected,
selectedJiraSiteId,
hideTaskSource,
taskSource,
closeTaskDetailPage,
selectedJiraIssue,
jiraDetailSourceContext,
openJiraDetailPage,
jiraIssues,
jiraLoading,
jiraError,
jiraErrorDetailsOpen,
setJiraErrorDetailsOpen,
jiraSearchInput,
jiraOrderBy,
jiraOrderDirection,
handleJiraSort,
displayedJiraIssues,
displayedJiraStatusOrder,
sortedJiraIssues,
setJiraConnectOpen,
handleUseJiraItem
} = model
return taskSource === 'jira' ? (
!jiraStatusReady ? (
<div className="mt-4 flex items-center justify-center py-14">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
</div>
) : !jiraConnected ? (
<div className="mt-4 flex flex-col items-center justify-center rounded-md border border-border/50 bg-muted/50 px-6 py-14 text-center shadow-sm">
<JiraIcon className="mb-4 size-8 text-muted-foreground/60" />
<p className="text-base font-medium text-foreground">
{translate('auto.components.TaskPage.a150c59da7', 'Connect your Jira site')}
</p>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
{translate(
'auto.components.TaskPage.b518ae6307',
'Browse, edit, create, and start work from Jira issues directly from here.'
)}
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button onClick={() => setJiraConnectOpen(true)}>
{translate('auto.components.TaskPage.83bce6be5c', 'Connect Jira')}
</Button>
<Button variant="outline" onClick={() => hideTaskSource('jira', 'Jira')}>
{translate('auto.components.TaskPage.e7115334aa', 'Hide Jira')}
</Button>
</div>
</div>
) : (
<div className="flex min-h-0 max-h-full flex-col overflow-hidden rounded-md rounded-t-none border border-t-0 border-border/50 bg-background shadow-sm">
<div className="flex h-10 flex-none items-center justify-between gap-3 border-b border-border/50 bg-muted/35 px-3">
<div className="min-w-0 text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
{translate('auto.components.TaskPage.63b2abd3aa', 'Jira issues')}
</div>
<div className="shrink-0 text-[11px] text-muted-foreground">
{displayedJiraIssues.length} {translate('auto.components.TaskPage.b7bae28b6a', 'shown')}
</div>
</div>
<TaskPageJiraSortControls
direction={jiraOrderDirection}
onSort={handleJiraSort}
orderBy={jiraOrderBy}
/>
<div
className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek"
style={{
scrollbarGutter: 'stable'
}}
>
{jiraStatus.credentialError ? (
<div className="border-b border-border px-4 py-4 text-sm text-destructive">
{jiraStatus.credentialError}
</div>
) : null}
{!jiraStatus.credentialError && jiraError ? (
<TaskPageJiraErrorBanner
error={jiraError}
open={jiraErrorDetailsOpen}
onOpenChange={setJiraErrorDetailsOpen}
/>
) : null}
{jiraLoading && jiraIssues.length === 0 ? (
<div className="divide-y divide-border/50">
{Array.from({
length: 6
}).map((_, i) => (
<div key={i} className="px-3 py-3">
<div className="h-4 w-4/5 animate-pulse rounded bg-muted/70" />
<div className="mt-2 h-3 w-3/5 animate-pulse rounded bg-muted/60" />
</div>
))}
</div>
) : null}
{!jiraLoading && jiraIssues.length === 0 && !jiraError && !jiraStatus.credentialError ? (
<div className="px-4 py-10 text-center">
<p className="text-sm font-medium text-foreground">
{translate('auto.components.TaskPage.eba87f2edb', 'No Jira issues found')}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{jiraSearchInput
? translate('auto.components.TaskPage.f51e254d35', 'Try a different JQL query.')
: translate(
'auto.components.TaskPage.94d900518d',
'No issues match the selected preset.'
)}
</p>
</div>
) : null}
<TaskPageJiraIssueList
formatUpdatedAt={formatRelativeTime}
getStatusTone={getJiraStatusTone}
issues={sortedJiraIssues}
onOpenIssue={openJiraDetailPage}
onStartWorkspace={handleUseJiraItem}
selectedIssue={selectedJiraIssue}
showSiteContext={selectedJiraSiteId === 'all'}
statusDirection={jiraOrderBy === 'status' ? jiraOrderDirection : 'asc'}
statusOrder={displayedJiraStatusOrder}
/>
</div>
<JiraIssueWorkspace
issue={selectedJiraIssue}
onUse={handleUseJiraItem}
onClose={closeTaskDetailPage}
sourceContext={jiraDetailSourceContext}
/>
</div>
)
) : (
<TaskPageLinearContent model={model} />
)
}
@@ -0,0 +1,183 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
import { useAppStore } from '@/store'
import { getJiraProjectSelectionKey } from '@/components/task-page-jira-project-selection'
import { translate } from '@/i18n/i18n'
import { LoaderCircle, Plus, RefreshCw, Search, X } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
export function TaskPageJiraFilters({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
setTaskResumeState,
jiraPresets,
jiraLoading,
jiraSearchInput,
setJiraSearchInput,
setAppliedJiraSearch,
activeJiraPreset,
setActiveJiraPreset,
setJiraRefreshNonce,
jiraProjectsLoading,
setNewJiraIssueOpen,
setNewJiraIssueTitle,
setNewJiraIssueBody,
setNewJiraIssueProjectId,
setNewJiraIssueProjectQuery,
setNewJiraIssueProjectCommandValue,
setNewJiraIssueTypeId,
sortedAvailableJiraProjects
} = model
return (
<div className="rounded-md rounded-b-none border border-border/50 bg-muted/50 px-3 pt-2 pb-0 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{jiraPresets.map((preset) => {
const active = !jiraSearchInput && activeJiraPreset === preset.id
return (
<button
key={preset.id}
type="button"
onClick={() => {
setJiraSearchInput('')
setAppliedJiraSearch('')
setActiveJiraPreset(preset.id)
setTaskResumeState({
jiraPreset: preset.id,
jiraQuery: ''
})
setJiraRefreshNonce((n) => n + 1)
}}
className={cn(
'rounded-md border px-2 py-1 text-xs transition',
active
? 'border-border/50 bg-foreground/90 text-background backdrop-blur-md'
: 'border-border/50 bg-transparent text-foreground hover:bg-muted/50'
)}
>
{preset.label}
</button>
)
})}
</div>
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => {
// Why: restore dismissed typed text (accidental dismissal recoverable); pickers keep their fresh open-time defaults.
const draft = useAppStore.getState().newJiraIssueDraft
setNewJiraIssueTitle(draft?.title ?? '')
setNewJiraIssueBody(draft?.body ?? '')
setNewJiraIssueProjectId(
sortedAvailableJiraProjects[0]
? getJiraProjectSelectionKey(sortedAvailableJiraProjects[0])
: null
)
setNewJiraIssueProjectQuery('')
setNewJiraIssueProjectCommandValue('')
setNewJiraIssueTypeId(null)
setNewJiraIssueOpen(true)
}}
disabled={sortedAvailableJiraProjects.length === 0 || jiraProjectsLoading}
aria-label={translate('auto.components.TaskPage.0c11ca0b6d', 'New Jira issue')}
className="border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent"
>
{jiraProjectsLoading ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Plus className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.TaskPage.0c11ca0b6d', 'New Jira issue')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setJiraRefreshNonce((n) => n + 1)}
disabled={jiraLoading}
aria-label={translate('auto.components.TaskPage.2ff9fd71fd', 'Refresh Jira issues')}
className="border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent"
>
{jiraLoading ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.TaskPage.2ff9fd71fd', 'Refresh Jira issues')}
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="mt-3 flex items-center gap-3">
<div className="relative min-w-[320px] flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={jiraSearchInput}
onChange={(e) => setJiraSearchInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (
shouldSuppressEnterSubmit(
{
isComposing: e.nativeEvent.isComposing,
shiftKey: e.shiftKey
},
false
)
) {
return
}
e.preventDefault()
const trimmed = jiraSearchInput.trim()
setJiraSearchInput(trimmed)
setAppliedJiraSearch(trimmed)
setTaskResumeState({
jiraQuery: trimmed
})
setJiraRefreshNonce((n) => n + 1)
}
}}
placeholder={translate(
'auto.components.TaskPage.99c2755218',
'Jira JQL, e.g. project = ABC AND statusCategory != Done'
)}
className="h-8 rounded-md border-border/50 bg-background pl-8 pr-8 text-xs"
/>
{jiraSearchInput ? (
<button
type="button"
aria-label={translate('auto.components.TaskPage.b797bdd7c3', 'Clear search')}
onClick={() => {
setJiraSearchInput('')
setAppliedJiraSearch('')
setTaskResumeState({
jiraQuery: ''
})
setJiraRefreshNonce((n) => n + 1)
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
>
<X className="size-4" />
</button>
) : null}
</div>
</div>
</div>
)
}
@@ -0,0 +1,380 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter
} from '@/components/ui/dialog'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { translate } from '@/i18n/i18n'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { getJiraProjectPickerDisplayLabel as getJiraProjectDisplayLabel } from '@/components/jira-project-picker-filter'
import { ChevronDown, Check, LoaderCircle } from 'lucide-react'
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandItem
} from '@/components/ui/command'
import { getJiraProjectSelectionKey } from '@/components/task-page-jira-project-selection'
import { cn } from '@/lib/utils'
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { getJiraCreateAllowedValueLabel } from '@/components/task-page-jira-create-fields'
export function TaskPageJiraIssueDialog({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
submitShortcutLabel,
newJiraIssueOpen,
setNewJiraIssueOpen,
newJiraIssueTitle,
setNewJiraIssueTitle,
newJiraIssueBody,
setNewJiraIssueBody,
newJiraIssueProjectComboboxOpen,
newJiraIssueProjectQuery,
setNewJiraIssueProjectQuery,
newJiraIssueProjectCommandValue,
setNewJiraIssueProjectCommandValue,
newJiraIssueTypeId,
setNewJiraIssueTypeId,
newJiraIssueSubmitting,
newJiraIssueProjectSearchInputRef,
availableJiraIssueTypes,
jiraIssueTypesLoading,
jiraCreateFieldsLoading,
jiraCreateFieldsError,
newJiraIssueCustomFieldValues,
setNewJiraIssueCustomFieldValues,
includeJiraSiteNameInProjectLabel,
sortedAvailableJiraProjects,
filteredNewJiraIssueProjects,
newJiraIssueTargetProject,
newJiraIssueTargetProjectSelectionKey,
newJiraIssueTargetType,
visibleJiraCreateFields,
hasMissingJiraCreateField,
handleNewJiraIssueProjectComboboxOpenChange,
handleNewJiraIssueProjectSelect,
handleNewJiraIssueProjectTriggerKeyDown,
handleCreateNewJiraIssue
} = model
return (
<Dialog
open={newJiraIssueOpen}
onOpenChange={(open) => {
if (!newJiraIssueSubmitting) {
setNewJiraIssueOpen(open)
}
}}
>
<DialogContent
className="sm:max-w-lg"
onKeyDown={(event) => {
if (isScreenSubmitShortcut(event)) {
event.preventDefault()
void handleCreateNewJiraIssue()
}
}}
>
<DialogHeader>
<DialogTitle>
{translate('auto.components.TaskPage.0c11ca0b6d', 'New Jira issue')}
</DialogTitle>
<DialogDescription>
{newJiraIssueTargetProject
? translate(
'auto.components.TaskPage.0f7b0d964a',
'Creates a new issue in {{value0}}.',
{
value0: newJiraIssueTargetProject.key
}
)
: translate(
'auto.components.TaskPage.e178c0a953',
'Choose a Jira project before creating the issue.'
)}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.TaskPage.00022ec0ba', 'Project')}
</label>
<Popover
open={newJiraIssueProjectComboboxOpen}
onOpenChange={handleNewJiraIssueProjectComboboxOpenChange}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={newJiraIssueProjectComboboxOpen}
onKeyDown={handleNewJiraIssueProjectTriggerKeyDown}
disabled={newJiraIssueSubmitting || sortedAvailableJiraProjects.length === 0}
className="h-9 w-full justify-between px-3 text-left text-xs font-normal"
>
{newJiraIssueTargetProject ? (
<span className="min-w-0 truncate">
{getJiraProjectDisplayLabel(
newJiraIssueTargetProject,
includeJiraSiteNameInProjectLabel
)}
</span>
) : (
<span className="min-w-0 truncate text-muted-foreground">
{translate('auto.components.TaskPage.00022ec0ba', 'Project')}
</span>
)}
<ChevronDown className="size-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-[18rem] p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<Command
shouldFilter={false}
value={newJiraIssueProjectCommandValue}
onValueChange={setNewJiraIssueProjectCommandValue}
>
<CommandInput
ref={newJiraIssueProjectSearchInputRef}
placeholder={translate(
'auto.components.TaskPage.cfb56a7868',
'Search projects...'
)}
value={newJiraIssueProjectQuery}
onValueChange={setNewJiraIssueProjectQuery}
/>
<CommandList className="max-h-56">
<CommandEmpty>
{translate('auto.components.TaskPage.93c57f15e5', 'No projects found.')}
</CommandEmpty>
{filteredNewJiraIssueProjects.map((project) => {
const selectionKey = getJiraProjectSelectionKey(project)
const selected = selectionKey === newJiraIssueTargetProjectSelectionKey
return (
<CommandItem
key={selectionKey}
value={selectionKey}
onSelect={() => handleNewJiraIssueProjectSelect(selectionKey)}
className="items-center gap-2 px-3 py-2 text-xs"
>
<Check
className={cn(
'size-3.5 text-foreground',
selected ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="min-w-0 flex-1 truncate">
{getJiraProjectDisplayLabel(
project,
includeJiraSiteNameInProjectLabel
)}
</span>
</CommandItem>
)
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.TaskPage.ae592fee62', 'Issue type')}
</label>
<Select
value={newJiraIssueTypeId ?? newJiraIssueTargetType?.id ?? undefined}
onValueChange={(v) => setNewJiraIssueTypeId(v)}
disabled={
newJiraIssueSubmitting ||
jiraIssueTypesLoading ||
availableJiraIssueTypes.length === 0
}
>
<SelectTrigger>
<SelectValue
placeholder={
jiraIssueTypesLoading
? translate('auto.components.TaskPage.7d63e2626e', 'Loading...')
: translate('auto.components.TaskPage.ae592fee62', 'Issue type')
}
/>
</SelectTrigger>
<SelectContent>
{availableJiraIssueTypes.map((issueType) => (
<SelectItem key={issueType.id} value={issueType.id}>
{issueType.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.TaskPage.16cba35bee', 'Title')}
</label>
<Input
autoFocus
value={newJiraIssueTitle}
onChange={(e) => setNewJiraIssueTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault()
void handleCreateNewJiraIssue()
}
}}
placeholder={translate('auto.components.TaskPage.578f730c16', 'Short summary')}
disabled={newJiraIssueSubmitting}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.TaskPage.f161bf9ede', 'Description (optional)')}
</label>
<textarea
value={newJiraIssueBody}
onChange={(e) => setNewJiraIssueBody(e.target.value)}
placeholder={translate('auto.components.TaskPage.34d97ca682', "What's going on?")}
rows={6}
disabled={newJiraIssueSubmitting}
className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 resize-none max-h-60 overflow-y-auto scrollbar-sleek"
/>
</div>
{jiraCreateFieldsLoading ? (
<div className="flex items-center gap-2 rounded-md border border-border/50 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
{translate('auto.components.TaskPage.cbcdcbe244', 'Loading required Jira fields…')}
</div>
) : null}
{jiraCreateFieldsError ? (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{jiraCreateFieldsError}
</p>
) : null}
{visibleJiraCreateFields.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{visibleJiraCreateFields.map((field) => {
const fieldValue = newJiraIssueCustomFieldValues[field.key] ?? ''
return (
<div key={field.key} className="flex min-w-0 flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">
{field.name}
</label>
{field.allowedValues?.length && field.schema?.type !== 'array' ? (
<Select
value={fieldValue}
onValueChange={(value) =>
setNewJiraIssueCustomFieldValues((prev) => ({
...prev,
[field.key]: value
}))
}
disabled={newJiraIssueSubmitting}
>
<SelectTrigger>
<SelectValue
placeholder={translate(
'auto.components.TaskPage.1f0fce91e3',
'Select {{value0}}',
{
value0: field.name
}
)}
/>
</SelectTrigger>
<SelectContent>
{field.allowedValues.map((value) => {
const optionValue = value.id ?? value.value ?? value.name ?? ''
return optionValue ? (
<SelectItem key={optionValue} value={optionValue}>
{getJiraCreateAllowedValueLabel(value)}
</SelectItem>
) : null
})}
</SelectContent>
</Select>
) : (
<Input
value={fieldValue}
onChange={(event) =>
setNewJiraIssueCustomFieldValues((prev) => ({
...prev,
[field.key]: event.target.value
}))
}
type={field.schema?.type === 'number' ? 'number' : 'text'}
placeholder={
field.schema?.type === 'array'
? translate(
'auto.components.TaskPage.56cdb413a2',
'Comma-separated values'
)
: translate('auto.components.TaskPage.919a20dd5b', 'Enter {{value0}}', {
value0: field.name
})
}
disabled={newJiraIssueSubmitting}
/>
)}
</div>
)
})}
</div>
) : null}
<p className="text-[10px] text-muted-foreground">
{submitShortcutLabel} {translate('auto.components.TaskPage.fc0d8a1fa4', 'to submit.')}
</p>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setNewJiraIssueOpen(false)}
disabled={newJiraIssueSubmitting}
>
{translate('auto.components.TaskPage.ff69a30681', 'Cancel')}
</Button>
<Button
onClick={() => void handleCreateNewJiraIssue()}
disabled={
!newJiraIssueTargetProject ||
!newJiraIssueTargetType ||
!newJiraIssueTitle.trim() ||
hasMissingJiraCreateField ||
jiraCreateFieldsLoading ||
newJiraIssueSubmitting
}
>
{newJiraIssueSubmitting ? (
<>
<LoaderCircle className="size-4 animate-spin" />
{translate('auto.components.TaskPage.8ff6fdc368', 'Creating…')}
</>
) : (
translate('auto.components.TaskPage.e15ba2d2eb', 'Create issue')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,23 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog'
export function TaskPageLinearConnectDialog({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
selectedLinearWorkspace,
linearConnectOpen,
setLinearConnectOpen,
handleLinearAccessConnected
} = model
return (
<LinearApiKeyDialog
open={linearConnectOpen}
onOpenChange={setLinearConnectOpen}
workspace={selectedLinearWorkspace}
connectLabel={selectedLinearWorkspace ? 'Update access' : 'Add Linear access'}
onConnected={handleLinearAccessConnected}
/>
)
}
@@ -0,0 +1,79 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import LinearIssueWorkspace from '@/components/LinearIssueWorkspace'
import { LoaderCircle } from 'lucide-react'
import { LinearIcon } from '@/components/task-page-localized-options'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { TaskPageLinearProjectOverview } from './TaskPageLinearProjectOverview'
import { TaskPageLinearProjectList } from './TaskPageLinearProjectList'
import { TaskPageLinearCustomViewList } from './TaskPageLinearCustomViewList'
import { TaskPageLinearCustomViewProjects } from './TaskPageLinearCustomViewProjects'
import { TaskPageLinearIssueList } from './TaskPageLinearIssueList'
export function TaskPageLinearContent({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
linearStatusReady,
linearConnected,
taskSource,
selectedLinearIssue,
linearDetailSourceContext,
openRelatedLinearIssue,
closeTaskDetailPage,
linearMode,
selectedLinearProject,
linearProjectTab,
selectedLinearCustomView,
activeLinearIssueContextLabel,
setLinearConnectOpen,
handleUseLinearItem
} = model
return taskSource === 'linear' && selectedLinearIssue ? (
<LinearIssueWorkspace
issue={selectedLinearIssue}
variant="page"
backLabel={activeLinearIssueContextLabel ?? 'Linear list'}
onUse={handleUseLinearItem}
onOpenIssue={openRelatedLinearIssue}
onClose={closeTaskDetailPage}
sourceContext={linearDetailSourceContext}
/>
) : !linearStatusReady ? (
<div className="mt-4 flex items-center justify-center py-14">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
</div>
) : !linearConnected ? (
<div className="mt-4 flex flex-col items-center justify-center rounded-md border border-border/50 bg-muted/50 px-6 py-14 text-center shadow-sm">
<LinearIcon className="mb-4 size-8 text-muted-foreground/60" />
<p className="text-base font-medium text-foreground">
{translate('auto.components.TaskPage.6d56559467', 'Connect your Linear account')}
</p>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
{translate(
'auto.components.TaskPage.228b25028f',
'Browse and start work on your assigned Linear issues directly from here.'
)}
</p>
<Button
className="mt-5"
onClick={() => {
setLinearConnectOpen(true)
}}
>
{translate('auto.components.TaskPage.851017590d', 'Add Linear access')}
</Button>
</div>
) : selectedLinearProject && linearProjectTab === 'overview' ? (
<TaskPageLinearProjectOverview model={model} />
) : linearMode === 'projects' && !selectedLinearProject ? (
<TaskPageLinearProjectList model={model} />
) : linearMode === 'views' && !selectedLinearCustomView ? (
<TaskPageLinearCustomViewList model={model} />
) : selectedLinearCustomView?.model === 'project' && !selectedLinearProject ? (
<TaskPageLinearCustomViewProjects model={model} />
) : (
<TaskPageLinearIssueList model={model} />
)
}
@@ -0,0 +1,56 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { translate } from '@/i18n/i18n'
import {
LinearCustomViewTable,
LinearCollectionNotice
} from '@/components/linear-project-view-surfaces'
export function TaskPageLinearCustomViewList({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
selectedLinearWorkspaceId,
linearCustomViewsResult,
linearCustomViewsLoading,
linearCustomViewsError,
openLinearCustomViewContext
} = model
return (
<div className="flex min-h-0 max-h-full flex-col overflow-hidden rounded-md rounded-t-none border border-t-0 border-border/50 bg-background shadow-sm">
<div className="grid h-8 flex-none items-center gap-3 border-b border-border/50 bg-muted/25 px-3 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground grid-cols-[minmax(220px,1.5fr)_120px_120px_120px_130px_60px]">
<span>{translate('auto.components.TaskPage.9c57663908', 'View')}</span>
<span>{translate('auto.components.TaskPage.0aa8525950', 'Model')}</span>
<span>{translate('auto.components.TaskPage.a04fe7ba73', 'Visibility')}</span>
<span>{translate('auto.components.TaskPage.b4e10f096e', 'Owner')}</span>
<span>{translate('auto.components.TaskPage.f362667d55', 'Updated')}</span>
<span />
</div>
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-auto scrollbar-sleek">
{linearCustomViewsError ? (
<div className="border-b border-border px-4 py-4 text-sm text-destructive">
{linearCustomViewsError}
</div>
) : null}
<LinearCustomViewTable
views={linearCustomViewsResult.items}
loading={linearCustomViewsLoading}
hasError={!!linearCustomViewsResult.errors?.length}
workspaceSelection={selectedLinearWorkspaceId}
onSelectView={openLinearCustomViewContext}
onOpenView={(view) => {
if (view.url) {
void window.api.shell.openUrl(view.url)
}
}}
/>
</div>
<LinearCollectionNotice
errors={linearCustomViewsResult.errors}
hasMore={linearCustomViewsResult.hasMore}
count={linearCustomViewsResult.items.length}
label={translate('auto.components.TaskPage.3cb855080f', 'views')}
/>
</div>
)
}
@@ -0,0 +1,102 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { ChevronLeft, ExternalLink } from 'lucide-react'
import {
LinearProjectTable,
LinearCollectionNotice
} from '@/components/linear-project-view-surfaces'
export function TaskPageLinearCustomViewProjects({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
setTaskResumeState,
selectedLinearWorkspaceId,
setLinearProjectTab,
selectedLinearCustomView,
setSelectedLinearCustomView,
setLinearProjectParentView,
linearCustomViewProjectsResult,
linearCustomViewContentsLoading,
linearCustomViewContentsError,
openLinearProjectContext
} = model
return (
<div className="flex min-h-0 max-h-full flex-col overflow-hidden rounded-md rounded-t-none border border-t-0 border-border/50 bg-background shadow-sm">
<div className="flex h-10 flex-none items-center justify-between gap-3 border-b border-border/50 bg-muted/35 px-3">
<div className="flex min-w-0 items-center gap-2">
<Button
variant="ghost"
size="icon-xs"
onClick={() => {
setSelectedLinearCustomView(null)
setLinearProjectParentView(null)
setTaskResumeState({
linearContext: undefined
})
}}
aria-label={translate('auto.components.TaskPage.bc06ed0fb0', 'Back to views')}
>
<ChevronLeft className="size-3.5" />
</Button>
<div className="min-w-0">
<div className="truncate text-[13px] font-medium text-foreground">
{selectedLinearCustomView!.name}
</div>
<div className="truncate text-[11px] text-muted-foreground">
{translate('auto.components.TaskPage.733b8f2421', 'Linear / Views')}
</div>
</div>
</div>
{selectedLinearCustomView!.url ? (
<Button
variant="outline"
size="xs"
onClick={() => void window.api.shell.openUrl(selectedLinearCustomView!.url!)}
className="gap-1 border-border/50 bg-background/70"
>
<ExternalLink className="size-3.5" />
{translate('auto.components.TaskPage.8675cd6188', 'Linear')}
</Button>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-auto scrollbar-sleek">
{linearCustomViewContentsError ? (
<div className="border-b border-border px-4 py-4 text-sm text-destructive">
{linearCustomViewContentsError}
</div>
) : null}
<LinearProjectTable
projects={linearCustomViewProjectsResult.items}
loading={linearCustomViewContentsLoading}
hasError={!!linearCustomViewProjectsResult.errors?.length}
workspaceSelection={selectedLinearWorkspaceId}
onSelectProject={(project) =>
openLinearProjectContext(project, {
parentView: selectedLinearCustomView
})
}
onOpenProject={(project) => {
if (project.url) {
void window.api.shell.openUrl(project.url)
}
}}
onUseProjectIssues={(project) => {
openLinearProjectContext(project, {
parentView: selectedLinearCustomView
})
setLinearProjectTab('issues')
}}
/>
</div>
<LinearCollectionNotice
errors={linearCustomViewProjectsResult.errors}
hasMore={linearCustomViewProjectsResult.hasMore}
count={linearCustomViewProjectsResult.items.length}
label={translate('auto.components.TaskPage.b39fe6511d', 'projects')}
/>
</div>
)
}
@@ -0,0 +1,316 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
import { useAppStore } from '@/store'
import { Plus, LoaderCircle, RefreshCw, Search, X } from 'lucide-react'
import LinearIssueAttributeFilterDropdowns from '@/components/linear-issue-attribute-filter-dropdowns'
import { Input } from '@/components/ui/input'
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
export function TaskPageLinearFilters({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
settings,
setTaskResumeState,
linearModeOptions,
linearTaskSourceContext,
linearMode,
linearLoading,
linearSearchInput,
setLinearSearchInput,
setAppliedLinearSearch,
linearAttributeFilterWorkspaceId,
linearAttributeFilter,
setLinearRefreshNonce,
linearProjectSearchInput,
setLinearProjectSearchInput,
setAppliedLinearProjectSearch,
linearProjectsLoading,
selectedLinearProject,
linearProjectDetailLoading,
linearCustomViewsLoading,
linearCustomViewContentsLoading,
selectLinearMode,
availableTeams,
linearTeamSelection,
linearTeamOptions,
linearAttributePrimaryTeam,
applyLinearAttributeFilter,
showLinearAttributeFilters,
setNewLinearProjectOpen,
setNewLinearProjectName,
setNewLinearProjectDescription,
setNewLinearProjectContent,
setNewLinearProjectTeamId,
setNewLinearProjectLeadId,
setNewLinearProjectMemberIds,
setNewLinearProjectLabelIds,
setNewLinearProjectPriority,
setNewLinearProjectStartDate,
setNewLinearProjectTargetDate,
setNewLinearIssueOpen,
setNewLinearIssueTitle,
setNewLinearIssueBody,
setNewLinearIssueTeamId,
setNewLinearIssueProjectId
} = model
return (
<div
className="min-w-0 rounded-md rounded-b-none border border-border/50 bg-muted/50 px-3 pt-2 pb-0 shadow-sm"
data-contextual-tour-target="tasks-search-presets"
>
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<div
className="flex items-center gap-1 text-xs"
role="group"
aria-label={translate('auto.components.TaskPage.0cbf7e5cf3', 'Linear task mode')}
>
{linearModeOptions.map((mode) => {
const active = linearMode === mode.id
const buttonClassName = cn(
'rounded-md border px-2 py-1 text-xs transition',
active
? 'border-border/50 bg-foreground/90 text-background'
: 'border-border/50 bg-transparent text-foreground hover:bg-muted/50'
)
if (mode.id === 'in-orca') {
return (
<Tooltip key={mode.id}>
<TooltipTrigger asChild>
<button
type="button"
aria-pressed={active}
onClick={() => selectLinearMode(mode.id)}
className={buttonClassName}
>
{mode.label}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.TaskPage.linearModeHasWorktreeTooltip',
'Linear tickets linked to an Orca workspace'
)}
</TooltipContent>
</Tooltip>
)
}
return (
<button
key={mode.id}
type="button"
aria-pressed={active}
onClick={() => selectLinearMode(mode.id)}
className={buttonClassName}
>
{mode.label}
</button>
)
})}
</div>
<div
className="flex shrink-0 items-center gap-2"
data-contextual-tour-target="tasks-actions"
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => {
if (linearMode === 'projects' && !selectedLinearProject) {
// Why: restore dismissed typed text (accidental dismissal recoverable); pickers keep their fresh open-time defaults.
const draft = useAppStore.getState().newLinearProjectDraft
setNewLinearProjectName(draft?.name ?? '')
setNewLinearProjectDescription(draft?.description ?? '')
setNewLinearProjectContent(draft?.content ?? '')
setNewLinearProjectTeamId(availableTeams[0]?.id ?? null)
setNewLinearProjectLeadId(null)
setNewLinearProjectMemberIds([])
setNewLinearProjectLabelIds([])
setNewLinearProjectPriority(0)
setNewLinearProjectStartDate('')
setNewLinearProjectTargetDate('')
setNewLinearProjectOpen(true)
return
}
// Why: restore dismissed typed text (accidental dismissal recoverable); pickers keep their fresh open-time defaults.
const issueDraft = useAppStore.getState().newLinearIssueDraft
setNewLinearIssueTitle(issueDraft?.title ?? '')
setNewLinearIssueBody(issueDraft?.body ?? '')
const projectTeamId =
selectedLinearProject?.teams?.[0]?.id ??
availableTeams.find(
(team) => team.workspaceId === selectedLinearProject?.workspaceId
)?.id
setNewLinearIssueTeamId(projectTeamId ?? availableTeams[0]?.id ?? null)
setNewLinearIssueProjectId(selectedLinearProject?.id ?? null)
setNewLinearIssueOpen(true)
}}
disabled={availableTeams.length === 0}
aria-label={
linearMode === 'projects' && !selectedLinearProject
? translate('auto.components.TaskPage.1361275ec3', 'New Linear project')
: translate('auto.components.TaskPage.3feb524d42', 'New Linear issue')
}
className="size-8 border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent"
>
<Plus className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{linearMode === 'projects' && !selectedLinearProject
? translate('auto.components.TaskPage.1361275ec3', 'New Linear project')
: translate('auto.components.TaskPage.3feb524d42', 'New Linear issue')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setLinearRefreshNonce((n) => n + 1)}
disabled={
linearMode === 'issues' || linearMode === 'in-orca'
? linearLoading
: linearMode === 'projects'
? linearProjectsLoading || linearProjectDetailLoading
: linearCustomViewsLoading || linearCustomViewContentsLoading
}
aria-label={translate('auto.components.TaskPage.8964184a8b', 'Refresh Linear')}
className="size-8 border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent"
>
{(linearMode === 'issues' || linearMode === 'in-orca') && linearLoading ? (
<LoaderCircle className="size-4 animate-spin" />
) : linearMode === 'projects' &&
(linearProjectsLoading || linearProjectDetailLoading) ? (
<LoaderCircle className="size-4 animate-spin" />
) : linearMode === 'views' &&
(linearCustomViewsLoading || linearCustomViewContentsLoading) ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.TaskPage.8964184a8b', 'Refresh Linear')}
</TooltipContent>
</Tooltip>
</div>
</div>
{linearMode === 'issues' || linearMode === 'in-orca' ? (
<div className="mt-3 flex min-w-0 items-center gap-2">
{showLinearAttributeFilters ? (
<LinearIssueAttributeFilterDropdowns
value={linearAttributeFilter}
onChange={applyLinearAttributeFilter}
workspaceId={linearAttributeFilterWorkspaceId}
primaryTeam={linearAttributePrimaryTeam}
selectedTeamIds={[...linearTeamSelection]}
availableTeams={linearTeamOptions}
teamsSettled={availableTeams.length > 0}
settings={linearTaskSourceContext ?? settings}
/>
) : null}
<div className="relative min-w-0 flex-1 basis-64">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={linearSearchInput}
onChange={(e) => setLinearSearchInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (
shouldSuppressEnterSubmit(
{
isComposing: e.nativeEvent.isComposing,
shiftKey: e.shiftKey
},
false
)
) {
return
}
e.preventDefault()
const trimmed = linearSearchInput.trim()
setLinearSearchInput(trimmed)
setAppliedLinearSearch(trimmed)
setTaskResumeState({
linearQuery: trimmed,
linearMode: linearMode === 'in-orca' ? 'in-orca' : 'issues'
})
if (linearMode !== 'in-orca') {
setLinearRefreshNonce((n) => n + 1)
}
}
}}
placeholder={
linearMode === 'in-orca'
? translate(
'auto.components.TaskPage.linearHasWorktreeSearchPlaceholder',
'Filter issues linked to an Orca workspace...'
)
: translate('auto.components.TaskPage.eec0c5c079', 'Search Linear issues...')
}
className="h-8 rounded-md border-border/50 bg-background pl-8 pr-8 text-xs"
/>
{linearSearchInput ? (
<button
type="button"
aria-label={translate('auto.components.TaskPage.b797bdd7c3', 'Clear search')}
onClick={() => {
setLinearSearchInput('')
setAppliedLinearSearch('')
setTaskResumeState({
linearQuery: '',
linearMode: linearMode === 'in-orca' ? 'in-orca' : 'issues'
})
if (linearMode !== 'in-orca') {
setLinearRefreshNonce((n) => n + 1)
}
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
>
<X className="size-4" />
</button>
) : null}
</div>
</div>
) : linearMode === 'projects' && !selectedLinearProject ? (
<div className="mt-3 flex min-w-0 items-center gap-3">
<div className="relative min-w-0 flex-1 basis-64">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={linearProjectSearchInput}
onChange={(e) => setLinearProjectSearchInput(e.target.value)}
placeholder={translate(
'auto.components.TaskPage.0b65d3fb2c',
'Search Linear projects...'
)}
className="h-8 rounded-md border-border/50 bg-background pl-8 pr-8 text-xs"
/>
{linearProjectSearchInput ? (
<button
type="button"
aria-label={translate('auto.components.TaskPage.b797bdd7c3', 'Clear search')}
onClick={() => {
setLinearProjectSearchInput('')
setAppliedLinearProjectSearch('')
setLinearRefreshNonce((n) => n + 1)
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
>
<X className="size-4" />
</button>
) : null}
</div>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,372 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { translate } from '@/i18n/i18n'
import { ChevronDown, LoaderCircle, Check, UserRound, FolderKanban, Tag } from 'lucide-react'
import { LinearPriorityIcon } from '@/components/linear-priority-icon'
export function TaskPageLinearIssueAttributes({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
newLinearIssueSubmitting,
newLinearIssueStateId,
setNewLinearIssueStateId,
newLinearIssueAssigneeId,
setNewLinearIssueAssigneeId,
newLinearIssuePriority,
setNewLinearIssuePriority,
newLinearIssueProjectId,
setNewLinearIssueProjectId,
newLinearIssueLabelIds,
setNewLinearIssueLabelIds,
newLinearIssueProjects,
newLinearIssueProjectsLoading,
newLinearStates,
newLinearMembers,
newLinearLabels
} = model
return (
<div className="flex flex-wrap items-center gap-2 border-t border-border/40 pt-4 mt-2">
{/* Status Selector */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearIssueSubmitting}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs border border-border/80 bg-muted/15 hover:bg-muted/50 active:bg-muted transition-colors text-foreground/80 cursor-pointer disabled:opacity-50"
>
{(() => {
const selectedState = newLinearStates.data.find((s) => s.id === newLinearIssueStateId)
return (
<>
<span
className="size-2 rounded-full flex-shrink-0"
style={{
backgroundColor: selectedState?.color || '#a3a3a3'
}}
/>
<span>
{selectedState?.name ||
translate('auto.components.TaskPage.154b0fa623', 'Status')}
</span>
</>
)
})()}
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-1">
<div className="text-[10px] font-semibold text-muted-foreground px-2 py-1 uppercase tracking-wider">
{translate('auto.components.TaskPage.154b0fa623', 'Status')}
</div>
{newLinearStates.loading ? (
<div className="flex items-center justify-center p-4">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="max-h-60 overflow-y-auto scrollbar-sleek">
{newLinearStates.data.map((s) => (
<button
key={s.id}
type="button"
onClick={() => setNewLinearIssueStateId(s.id)}
className={`w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-sm hover:bg-muted transition-colors ${newLinearIssueStateId === s.id ? 'bg-muted font-medium text-foreground' : 'text-foreground/80'}`}
>
<div className="flex items-center gap-2">
<span
className="size-2 rounded-full flex-shrink-0"
style={{
backgroundColor: s.color || '#a3a3a3'
}}
/>
<span>{s.name}</span>
</div>
{newLinearIssueStateId === s.id && <Check className="size-3 text-foreground" />}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
{/* Assignee Selector */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearIssueSubmitting}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs border border-border/80 bg-muted/15 hover:bg-muted/50 active:bg-muted transition-colors text-foreground/80 cursor-pointer disabled:opacity-50"
>
{(() => {
const selectedAssignee = newLinearMembers.data.find(
(m) => m.id === newLinearIssueAssigneeId
)
if (selectedAssignee) {
return (
<>
{selectedAssignee.avatarUrl ? (
<img
src={selectedAssignee.avatarUrl}
alt={selectedAssignee.displayName}
className="size-3.5 rounded-full flex-shrink-0"
/>
) : (
<UserRound className="size-3.5 text-muted-foreground/70" />
)}
<span className="truncate max-w-[100px]">{selectedAssignee.displayName}</span>
</>
)
}
return (
<>
<UserRound className="size-3.5 text-muted-foreground/70" />
<span>{translate('auto.components.TaskPage.d2a876ca53', 'Assignee')}</span>
</>
)
})()}
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<div className="text-[10px] font-semibold text-muted-foreground px-2 py-1 uppercase tracking-wider">
{translate('auto.components.TaskPage.d2a876ca53', 'Assignee')}
</div>
{newLinearMembers.loading ? (
<div className="flex items-center justify-center p-4">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="max-h-60 overflow-y-auto scrollbar-sleek">
<button
type="button"
onClick={() => setNewLinearIssueAssigneeId(null)}
className={`w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-sm hover:bg-muted transition-colors ${newLinearIssueAssigneeId === null ? 'bg-muted font-medium text-foreground' : 'text-foreground/80'}`}
>
<div className="flex items-center gap-2">
<UserRound className="size-3.5 text-muted-foreground/50" />
<span>{translate('auto.components.TaskPage.42a9160321', 'Unassigned')}</span>
</div>
{newLinearIssueAssigneeId === null && <Check className="size-3 text-foreground" />}
</button>
{newLinearMembers.data.map((m) => (
<button
key={m.id}
type="button"
onClick={() => setNewLinearIssueAssigneeId(m.id)}
className={`w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-sm hover:bg-muted transition-colors ${newLinearIssueAssigneeId === m.id ? 'bg-muted font-medium text-foreground' : 'text-foreground/80'}`}
>
<div className="flex items-center gap-2 truncate">
{m.avatarUrl ? (
<img
src={m.avatarUrl}
alt={m.displayName}
className="size-3.5 rounded-full flex-shrink-0"
/>
) : (
<UserRound className="size-3.5 text-muted-foreground/70" />
)}
<span className="truncate">{m.displayName}</span>
</div>
{newLinearIssueAssigneeId === m.id && (
<Check className="size-3 text-foreground" />
)}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
{/* Priority Selector */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearIssueSubmitting}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs border border-border/80 bg-muted/15 hover:bg-muted/50 active:bg-muted transition-colors text-foreground/80 cursor-pointer disabled:opacity-50"
>
<LinearPriorityIcon priority={newLinearIssuePriority} className="size-3.5" />
<span>
{newLinearIssuePriority === 1
? translate('auto.components.TaskPage.f373ab1a4f', 'Urgent')
: newLinearIssuePriority === 2
? translate('auto.components.TaskPage.345b169f1f', 'High')
: newLinearIssuePriority === 3
? translate('auto.components.TaskPage.7fd59c18d8', 'Medium')
: newLinearIssuePriority === 4
? translate('auto.components.TaskPage.69591944e7', 'Low')
: translate('auto.components.TaskPage.c8d5bec5f7', 'Priority')}
</span>
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-48 p-1">
<div className="text-[10px] font-semibold text-muted-foreground px-2 py-1 uppercase tracking-wider">
{translate('auto.components.TaskPage.c8d5bec5f7', 'Priority')}
</div>
{[
{
val: 0,
label: translate('auto.components.TaskPage.713179dfdc', 'No priority')
},
{
val: 1,
label: translate('auto.components.TaskPage.f373ab1a4f', 'Urgent')
},
{
val: 2,
label: translate('auto.components.TaskPage.345b169f1f', 'High')
},
{
val: 3,
label: translate('auto.components.TaskPage.7fd59c18d8', 'Medium')
},
{
val: 4,
label: translate('auto.components.TaskPage.69591944e7', 'Low')
}
].map((p) => (
<button
key={p.val}
type="button"
onClick={() => setNewLinearIssuePriority(p.val)}
className={`w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-sm hover:bg-muted transition-colors ${newLinearIssuePriority === p.val ? 'bg-muted font-medium text-foreground' : 'text-foreground/80'}`}
>
<div className="flex items-center gap-2">
<LinearPriorityIcon priority={p.val} className="size-3.5" />
<span>{p.label}</span>
</div>
{newLinearIssuePriority === p.val && <Check className="size-3 text-foreground" />}
</button>
))}
</PopoverContent>
</Popover>
{/* Project Selector */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearIssueSubmitting}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs border border-border/80 bg-muted/15 hover:bg-muted/50 active:bg-muted transition-colors text-foreground/80 cursor-pointer disabled:opacity-50"
>
<FolderKanban className="size-3.5 text-muted-foreground/70" />
<span className="truncate max-w-[120px]">
{(() => {
const selectedProj = newLinearIssueProjects.find(
(p) => p.id === newLinearIssueProjectId
)
return selectedProj?.name || 'Project'
})()}
</span>
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<div className="text-[10px] font-semibold text-muted-foreground px-2 py-1 uppercase tracking-wider">
{translate('auto.components.TaskPage.00022ec0ba', 'Project')}
</div>
{newLinearIssueProjectsLoading ? (
<div className="flex items-center justify-center p-4">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="max-h-60 overflow-y-auto scrollbar-sleek">
<button
type="button"
onClick={() => setNewLinearIssueProjectId(null)}
className={`w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-sm hover:bg-muted transition-colors ${newLinearIssueProjectId === null ? 'bg-muted font-medium text-foreground' : 'text-foreground/80'}`}
>
<div className="flex items-center gap-2">
<FolderKanban className="size-3.5 text-muted-foreground/50" />
<span>{translate('auto.components.TaskPage.1742eafc14', 'No Project')}</span>
</div>
{newLinearIssueProjectId === null && <Check className="size-3 text-foreground" />}
</button>
{newLinearIssueProjects.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setNewLinearIssueProjectId(p.id)}
className={`w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-sm hover:bg-muted transition-colors ${newLinearIssueProjectId === p.id ? 'bg-muted font-medium text-foreground' : 'text-foreground/80'}`}
>
<div className="flex items-center gap-2 truncate">
<FolderKanban className="size-3.5 text-muted-foreground/70 flex-shrink-0" />
<span className="truncate">{p.name}</span>
</div>
{newLinearIssueProjectId === p.id && <Check className="size-3 text-foreground" />}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
{/* Labels Selector */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearIssueSubmitting}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs border border-border/80 bg-muted/15 hover:bg-muted/50 active:bg-muted transition-colors text-foreground/80 cursor-pointer disabled:opacity-50"
>
<Tag className="size-3.5 text-muted-foreground/70" />
<span>
{newLinearIssueLabelIds.length === 0
? translate('auto.components.TaskPage.d0ca4aa1d0', 'Labels')
: translate('auto.components.TaskPage.eff9800d4b', '{{value0}} label{{value1}}', {
value0: newLinearIssueLabelIds.length,
value1: newLinearIssueLabelIds.length > 1 ? 's' : ''
})}
</span>
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<div className="text-[10px] font-semibold text-muted-foreground px-2 py-1 uppercase tracking-wider">
{translate('auto.components.TaskPage.d0ca4aa1d0', 'Labels')}
</div>
{newLinearLabels.loading ? (
<div className="flex items-center justify-center p-4">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="max-h-60 overflow-y-auto scrollbar-sleek">
{newLinearLabels.data.map((l) => {
const isSelected = newLinearIssueLabelIds.includes(l.id)
return (
<button
key={l.id}
type="button"
onClick={() => {
if (isSelected) {
setNewLinearIssueLabelIds(
newLinearIssueLabelIds.filter((id) => id !== l.id)
)
} else {
setNewLinearIssueLabelIds([...newLinearIssueLabelIds, l.id])
}
}}
className={`w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-sm hover:bg-muted transition-colors ${isSelected ? 'bg-muted font-medium text-foreground' : 'text-foreground/80'}`}
>
<div className="flex items-center gap-2">
<span
className="size-2 rounded-full flex-shrink-0"
style={{
backgroundColor: l.color || '#a3a3a3'
}}
/>
<span>{l.name}</span>
</div>
{isSelected && <Check className="size-3 text-foreground" />}
</button>
)
})}
</div>
)}
</PopoverContent>
</Popover>
</div>
)
}
@@ -0,0 +1,185 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { translate } from '@/i18n/i18n'
import {
resolveLinearIssueEmptyKind,
shouldOfferLinearIssueFetchMore
} from '@/components/task-page-linear-issue-empty-state'
import { Button } from '@/components/ui/button'
import {
clampLinearIssueListLimit,
LINEAR_ISSUE_LIST_MAX
} from '../../../shared/linear/issue-read-limits'
import { LINEAR_ITEM_LIMIT } from './task-page-source-context'
import { TaskPageLinearIssueBoardColumns } from './TaskPageLinearIssueBoardColumns'
import { TaskPageLinearIssueTable } from './TaskPageLinearIssueTable'
export function TaskPageLinearIssueBoard({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
linearMode,
setLinearIssueLimit,
linearIssuesHasMore,
linearAttributeFilter,
linearViewMode,
activeLinearIssues,
activeLinearIssueLoading,
activeLinearIssueError,
activeLinearIssueHasCollectionError,
activeLinearIssueContextLabel,
linearSearchActive,
filteredLinearIssues
} = model
return (
<div
className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek"
style={{
scrollbarGutter: 'stable'
}}
>
{activeLinearIssueError ? (
<div className="border-b border-border px-4 py-4 text-sm text-destructive">
{activeLinearIssueError}
</div>
) : null}
{activeLinearIssueLoading && activeLinearIssues.length === 0 ? (
<div className="divide-y divide-border/50">
{Array.from({
length: 12
}).map((_, i) => (
<div key={i} className="px-3 py-3">
<div className="h-4 w-4/5 animate-pulse rounded bg-muted/70" />
<div className="mt-2 h-3 w-3/5 animate-pulse rounded bg-muted/60" />
</div>
))}
</div>
) : null}
{!activeLinearIssueLoading &&
activeLinearIssues.length === 0 &&
!activeLinearIssueError &&
activeLinearIssueHasCollectionError ? (
<div className="px-4 py-10 text-center">
<p className="text-sm font-medium text-foreground">
{translate('auto.components.TaskPage.cc8795e07c', 'Unable to load Linear issues')}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{translate(
'auto.components.TaskPage.5ed38a49e5',
'Review the workspace error below, then refresh.'
)}
</p>
</div>
) : null}
{!activeLinearIssueLoading &&
activeLinearIssues.length === 0 &&
!activeLinearIssueError &&
!activeLinearIssueHasCollectionError ? (
<div className="px-4 py-10 text-center">
<p className="text-sm font-medium text-foreground">
{translate('auto.components.TaskPage.903c7af49f', 'No Linear issues found')}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{(() => {
if (linearMode === 'in-orca') {
if (linearSearchActive) {
return translate(
'auto.components.TaskPage.2bdefbcac3',
'Try a different search query.'
)
}
return translate(
'auto.components.TaskPage.linearEmptyHasWorktree',
'No Linear tickets are linked to an Orca workspace yet. Start work from a Linear issue to see it here.'
)
}
const emptyKind = resolveLinearIssueEmptyKind({
hasContextLabel: Boolean(activeLinearIssueContextLabel),
searchActive: linearSearchActive,
attributeFilter: linearAttributeFilter,
serverIssueCount: activeLinearIssues.length,
filteredIssueCount: filteredLinearIssues.length
})
if (emptyKind === 'context') {
return translate(
'auto.components.TaskPage.25ff84769a',
'No issues match this Linear context.'
)
}
if (emptyKind === 'search') {
return translate(
'auto.components.TaskPage.2bdefbcac3',
'Try a different search query.'
)
}
if (emptyKind === 'server-attribute-filter') {
return translate(
'auto.components.TaskPage.linearEmptyAttributeFilter',
'No issues match the selected filters. Clear a filter or try different criteria.'
)
}
return translate(
'auto.components.TaskPage.linearEmptyUnfilteredScope',
'No issues in this workspace scope. Try searching or adjusting teams.'
)
})()}
</p>
</div>
) : null}
{!activeLinearIssueLoading &&
activeLinearIssues.length > 0 &&
filteredLinearIssues.length === 0 ? (
<div className="px-4 py-10 text-center">
<p className="text-sm font-medium text-foreground">
{linearMode === 'in-orca' && linearSearchActive
? translate('auto.components.TaskPage.903c7af49f', 'No Linear issues found')
: translate(
'auto.components.TaskPage.618107fab3',
'No fetched issues match the selected teams'
)}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{linearMode === 'in-orca' && linearSearchActive
? translate('auto.components.TaskPage.2bdefbcac3', 'Try a different search query.')
: translate(
'auto.components.TaskPage.592a55611b',
'Try selecting more teams or refreshing; team filters apply to the current fetched issue set.'
)}
</p>
{linearMode !== 'in-orca' &&
shouldOfferLinearIssueFetchMore({
emptyKind: 'client-team',
serverHasMore: linearIssuesHasMore
}) ? (
<Button
type="button"
variant="outline"
size="sm"
className="mt-3 h-7 text-xs"
onClick={() => {
setLinearIssueLimit((limit) =>
Math.min(
clampLinearIssueListLimit(limit + LINEAR_ITEM_LIMIT),
LINEAR_ISSUE_LIST_MAX
)
)
}}
>
{translate('auto.components.TaskPage.linearFetchMore', 'Fetch more')}
</Button>
) : null}
</div>
) : null}
{linearViewMode === 'board' ? (
<TaskPageLinearIssueBoardColumns model={model} />
) : (
<TaskPageLinearIssueTable model={model} />
)}
</div>
)
}
@@ -0,0 +1,227 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { cn } from '@/lib/utils'
import { findLinearIssueWorkspaceAttachmentInIndex } from '@/lib/linear-issue-workspace-attachment'
import { getWorktreeAttachmentLabel } from '@/lib/worktree-attachment-label'
import { LinearPriorityIcon } from '@/components/linear-priority-icon'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { FolderOpen, ArrowRight, ExternalLink } from 'lucide-react'
import { LinearStateCell } from './task-page-linear-issue-model'
import { formatRelativeTime } from './task-page-source-context'
export function TaskPageLinearIssueBoardColumns({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
selectedLinearWorkspaceId,
linearTaskSourceContext,
selectedLinearIssueId,
openLinearDetailPage,
linearBoardDraggingIssueId,
setLinearBoardDraggingIssueId,
linearBoardDragOverKey,
setLinearBoardDragOverKey,
linearBoardUpdatingIssueIds,
linearIssueAttachmentIndex,
effectiveLinearDisplayProperties,
linearBoardSections,
linearStatusBoardEnabled,
handleLinearBoardCardDragStart,
handleLinearBoardDragOver,
handleLinearBoardDrop,
handleOpenOrUseLinearItem
} = model
return (
<div className="grid min-w-0 gap-3 p-3 md:grid-cols-2 xl:grid-cols-3">
{linearBoardSections.map((section) => (
<section
key={section.key}
onDragOver={(event) => handleLinearBoardDragOver(section, event)}
onDrop={(event) => void handleLinearBoardDrop(section, event)}
className={cn(
'min-h-0 rounded-md border border-border/50 bg-muted/20 transition-[border-color,box-shadow]',
linearBoardDragOverKey === section.key && 'border-ring/70 ring-1 ring-ring/70'
)}
>
<div className="flex h-9 items-center justify-between border-b border-border/50 px-3">
<span className="truncate text-xs font-medium text-foreground">{section.label}</span>
<span className="text-[11px] text-muted-foreground">{section.issues.length}</span>
</div>
<div className="space-y-2 p-2">
{section.issues.map((issue) => {
const selected = issue.id === selectedLinearIssueId
const labels = issue.labels.slice(0, 2)
const dragging = linearBoardDraggingIssueId === issue.id
const updating = linearBoardUpdatingIssueIds.has(issue.id)
const teamLabel =
selectedLinearWorkspaceId === 'all' && issue.workspaceName
? `${issue.workspaceName} / ${issue.team.name}`
: issue.team.name
const attachedWorkspace = findLinearIssueWorkspaceAttachmentInIndex(
linearIssueAttachmentIndex,
issue
)
const attachedWorkspaceLabel = attachedWorkspace
? getWorktreeAttachmentLabel(attachedWorkspace)
: null
return (
<div
key={issue.id}
role="button"
tabIndex={0}
draggable={linearStatusBoardEnabled && !updating}
aria-current={selected ? 'true' : undefined}
data-current={selected ? 'true' : undefined}
aria-disabled={updating ? 'true' : undefined}
onDragStart={(event) => handleLinearBoardCardDragStart(issue, event)}
onDragEnd={() => {
setLinearBoardDraggingIssueId(null)
setLinearBoardDragOverKey(null)
}}
onClick={() => openLinearDetailPage(issue)}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) {
return
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
openLinearDetailPage(issue)
}
}}
className={cn(
'group/row cursor-pointer rounded-md border border-border/50 bg-background px-3 py-2 text-left transition hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
linearStatusBoardEnabled && !updating && 'cursor-grab active:cursor-grabbing',
selected && 'bg-accent',
dragging && 'opacity-50',
updating && 'cursor-wait opacity-70'
)}
>
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5 font-mono text-[11px] text-muted-foreground">
{effectiveLinearDisplayProperties.has('priority') ? (
<LinearPriorityIcon priority={issue.priority} className="size-3.5" />
) : null}
<span className="truncate">{issue.identifier}</span>
</div>
<h3 className="mt-1 line-clamp-2 text-[13px] font-medium leading-snug text-foreground">
{issue.title}
</h3>
</div>
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
// Why: solid primary when a workspace is already linked so Open reads stronger than Start.
variant={attachedWorkspace ? 'default' : 'ghost'}
size="icon-xs"
data-contextual-tour-target="tasks-start-workspace"
onClick={(event) => {
event.stopPropagation()
handleOpenOrUseLinearItem(issue)
}}
aria-label={
attachedWorkspace
? translate(
'auto.components.TaskPage.linearOpenAttachedWorkspace',
'Open workspace attached to {{value0}}',
{
value0: issue.identifier
}
)
: translate(
'auto.components.TaskPage.ff90d0abc7',
'Start workspace from {{value0}}',
{
value0: issue.identifier
}
)
}
>
{attachedWorkspace ? (
<FolderOpen className="size-3.5" />
) : (
<ArrowRight className="size-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{attachedWorkspace
? translate('auto.components.TaskPage.606a85c774', 'Open')
: translate('auto.components.TaskPage.7d08e8be0f', 'Start')}
</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon-xs"
onClick={(event) => {
event.stopPropagation()
window.api.shell.openUrl(issue.url)
}}
aria-label={translate(
'auto.components.TaskPage.246bd64aed',
'Open {{value0}} in Linear',
{
value0: issue.identifier
}
)}
>
<ExternalLink className="size-3.5" />
</Button>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
{effectiveLinearDisplayProperties.has('state') ? (
<LinearStateCell
issue={issue}
className="px-1.5 py-0.5"
sourceContext={linearTaskSourceContext}
/>
) : null}
{effectiveLinearDisplayProperties.has('assignee') ? (
<span>
{issue.assignee?.displayName ??
translate('auto.components.TaskPage.42a9160321', 'Unassigned')}
</span>
) : null}
{effectiveLinearDisplayProperties.has('team') ? (
<span className="truncate">{teamLabel}</span>
) : null}
{effectiveLinearDisplayProperties.has('updated') ? (
<span>{formatRelativeTime(issue.updatedAt)}</span>
) : null}
{attachedWorkspaceLabel ? (
<span className="inline-flex min-w-0 items-center gap-1">
<FolderOpen className="size-3 shrink-0" />
<span className="truncate">{attachedWorkspaceLabel}</span>
</span>
) : null}
</div>
{effectiveLinearDisplayProperties.has('labels') && issue.labels.length > 0 ? (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1">
{labels.map((label) => (
<span
key={label}
className="max-w-[140px] truncate rounded-full border border-border/50 bg-muted/35 px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{label}
</span>
))}
{issue.labels.length > labels.length ? (
<span className="text-[10px] text-muted-foreground">
+{issue.labels.length - labels.length}
</span>
) : null}
</div>
) : null}
</div>
)
})}
</div>
</section>
))}
</div>
)
}
@@ -0,0 +1,171 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { translate } from '@/i18n/i18n'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { ChevronDown, Check, X, LoaderCircle } from 'lucide-react'
import { TaskPageLinearIssueAttributes } from './TaskPageLinearIssueAttributes'
export function TaskPageLinearIssueDialog({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
submitShortcutLabel,
availableTeams,
newLinearIssueOpen,
setNewLinearIssueOpen,
newLinearIssueTitle,
setNewLinearIssueTitle,
newLinearIssueBody,
setNewLinearIssueBody,
newLinearIssueTeamId,
setNewLinearIssueTeamId,
newLinearIssueSubmitting,
newLinearIssueTargetTeam,
handleCreateNewLinearIssue
} = model
return (
<Dialog
open={newLinearIssueOpen}
onOpenChange={(open) => {
if (!newLinearIssueSubmitting) {
setNewLinearIssueOpen(open)
}
}}
>
<DialogContent
showCloseButton={false}
className="sm:max-w-2xl bg-background border-border shadow-2xl p-0 overflow-hidden flex flex-col gap-0 rounded-xl"
onKeyDown={(event) => {
if (isScreenSubmitShortcut(event)) {
event.preventDefault()
void handleCreateNewLinearIssue()
}
}}
>
{/* Header/Team section */}
<div className="flex items-center justify-between border-b border-border/60 px-5 py-3 bg-muted/10">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{translate('auto.components.TaskPage.c11105dac5', 'New Issue')}
</span>
<span className="text-muted-foreground/40 text-xs">/</span>
{availableTeams.length > 1 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="xs"
className="h-7 gap-1 px-2 font-medium text-xs text-foreground hover:bg-muted"
>
{newLinearIssueTargetTeam?.key ??
translate('auto.components.TaskPage.d7f16d0e32', 'Select Team')}
<ChevronDown className="size-3 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<div className="text-[10px] font-semibold text-muted-foreground px-2 py-1.5 uppercase tracking-wider">
{translate('auto.components.TaskPage.4f3cb99f41', 'Switch Team')}
</div>
{availableTeams.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setNewLinearIssueTeamId(t.id)}
className={`w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-sm hover:bg-muted transition-colors ${newLinearIssueTeamId === t.id ? 'bg-muted font-medium' : ''}`}
>
<span>
{t.key} — {t.name}
</span>
{newLinearIssueTeamId === t.id && <Check className="size-3" />}
</button>
))}
</PopoverContent>
</Popover>
) : (
<span className="text-xs font-medium text-foreground">
{newLinearIssueTargetTeam?.key ?? ''} — {newLinearIssueTargetTeam?.name ?? ''}
</span>
)}
</div>
<button
onClick={() => setNewLinearIssueOpen(false)}
className="text-muted-foreground hover:text-foreground p-1 rounded-md transition-colors"
disabled={newLinearIssueSubmitting}
>
<X className="size-4" />
</button>
</div>
{/* Form Content */}
<div className="flex flex-col px-6 py-4 gap-3">
{/* Title */}
<input
autoFocus
value={newLinearIssueTitle}
onChange={(e) => setNewLinearIssueTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault()
void handleCreateNewLinearIssue()
}
}}
placeholder={translate('auto.components.TaskPage.d9151fd4e9', 'Issue title')}
disabled={newLinearIssueSubmitting}
className="text-lg font-semibold bg-transparent border-none outline-none focus:outline-none focus:ring-0 focus-visible:ring-0 p-0 placeholder:text-muted-foreground/40 text-foreground w-full"
/>
{/* Description */}
<textarea
value={newLinearIssueBody}
onChange={(e) => setNewLinearIssueBody(e.target.value)}
placeholder={translate('auto.components.TaskPage.9bc8aea407', 'Add description...')}
rows={5}
disabled={newLinearIssueSubmitting}
className="w-full min-w-0 text-sm bg-transparent border-none outline-none focus:outline-none focus:ring-0 focus-visible:ring-0 p-0 placeholder:text-muted-foreground/45 text-foreground resize-none max-h-60 overflow-y-auto scrollbar-sleek py-1"
/>
{/* Attribute Badges Row */}
<TaskPageLinearIssueAttributes model={model} />
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border/60 px-6 py-4 bg-muted/5">
<span className="text-[10px] text-muted-foreground/60 font-medium">
{submitShortcutLabel} {translate('auto.components.TaskPage.fc0d8a1fa4', 'to submit.')}
</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setNewLinearIssueOpen(false)}
disabled={newLinearIssueSubmitting}
className="text-xs h-8 text-muted-foreground hover:text-foreground"
>
{translate('auto.components.TaskPage.ff69a30681', 'Cancel')}
</Button>
<Button
size="sm"
onClick={() => void handleCreateNewLinearIssue()}
disabled={
!newLinearIssueTargetTeam || !newLinearIssueTitle.trim() || newLinearIssueSubmitting
}
className="text-xs h-8 bg-foreground text-background hover:bg-foreground/90 disabled:opacity-50"
>
{newLinearIssueSubmitting ? (
<>
<LoaderCircle className="size-3.5 animate-spin mr-1" />
{translate('auto.components.TaskPage.8ff6fdc368', 'Creating…')}
</>
) : (
translate('auto.components.TaskPage.e15ba2d2eb', 'Create issue')
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,133 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { TaskPageLinearIssueToolbar } from './TaskPageLinearIssueToolbar'
import { translate } from '@/i18n/i18n'
import { TaskPageLinearIssueRows } from './TaskPageLinearIssueRows'
import { LinearCollectionNotice } from '@/components/linear-project-view-surfaces'
import { PaginationBar } from './TaskPagePaginationBar'
export function TaskPageLinearIssueList({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
linearIssues,
linearViewMode,
linearGroupBy,
selectedLinearProject,
linearProjectTab,
linearProjectIssuesResult,
selectedLinearCustomView,
linearCustomViewIssuesResult,
activeLinearIssueLoading,
activeLinearIssueLoadingTargetPage,
linearIssueTotalPages,
visibleLinearIssuePage,
showLinearIssuePagination,
handleLinearIssuePageChange,
showLinearEmptyFilteredLoadMore,
handleLinearEmptyFilteredLoadMore,
effectiveLinearDisplayProperties,
linearIssueGridStyle
} = model
return (
<div className="flex min-h-0 max-h-full flex-col overflow-hidden rounded-md rounded-t-none border border-t-0 border-border/50 bg-background shadow-sm">
<TaskPageLinearIssueToolbar model={model} />
{linearViewMode === 'list' && linearGroupBy === 'none' ? (
<div
className="grid h-8 flex-none items-center gap-3 border-b border-border/50 bg-muted/25 px-3 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground max-lg:!hidden lg:grid-cols-[var(--linear-grid-template)] [&>span]:min-w-0 [&>span]:truncate"
style={linearIssueGridStyle}
>
<span>{translate('auto.components.TaskPage.37e7ee311e', 'Key')}</span>
<span>{translate('auto.components.TaskPage.b1eaa18ace', 'Issue')}</span>
{effectiveLinearDisplayProperties.has('labels') ? (
<span>{translate('auto.components.TaskPage.d0ca4aa1d0', 'Labels')}</span>
) : null}
{effectiveLinearDisplayProperties.has('team') ? (
<span>{translate('auto.components.TaskPage.a98cbe7664', 'Team')}</span>
) : null}
{effectiveLinearDisplayProperties.has('state') ? (
<span>{translate('auto.components.TaskPage.154b0fa623', 'Status')}</span>
) : null}
{effectiveLinearDisplayProperties.has('assignee') ? (
<span className="text-center">
{translate('auto.components.TaskPage.d2a876ca53', 'Assignee')}
</span>
) : null}
{effectiveLinearDisplayProperties.has('updated') ? (
<span>{translate('auto.components.TaskPage.f362667d55', 'Updated')}</span>
) : null}
<span>{translate('auto.components.TaskPage.linearWorktreesColumn', 'Workspaces')}</span>
</div>
) : null}
<TaskPageLinearIssueRows model={model} />
{selectedLinearProject && linearProjectTab === 'issues' ? (
<>
<LinearCollectionNotice
errors={linearProjectIssuesResult.errors}
hasMore={showLinearEmptyFilteredLoadMore}
count={linearProjectIssuesResult.items.length}
label={translate('auto.components.TaskPage.67662ade50', 'project issues')}
onLoadMore={handleLinearEmptyFilteredLoadMore}
loading={activeLinearIssueLoading}
loadMoreLabel="Fetch more"
/>
{showLinearIssuePagination ? (
<div className="flex-none border-t border-border/50 bg-muted/50">
<PaginationBar
currentPage={visibleLinearIssuePage}
totalPages={linearIssueTotalPages}
loadingTarget={activeLinearIssueLoadingTargetPage}
onPageChange={handleLinearIssuePageChange}
/>
</div>
) : null}
</>
) : selectedLinearCustomView?.model === 'issue' ? (
<>
<LinearCollectionNotice
errors={linearCustomViewIssuesResult.errors}
hasMore={showLinearEmptyFilteredLoadMore}
count={linearCustomViewIssuesResult.items.length}
label={translate('auto.components.TaskPage.be8cf68d9f', 'view issues')}
onLoadMore={handleLinearEmptyFilteredLoadMore}
loading={activeLinearIssueLoading}
loadMoreLabel="Fetch more"
/>
{showLinearIssuePagination ? (
<div className="flex-none border-t border-border/50 bg-muted/50">
<PaginationBar
currentPage={visibleLinearIssuePage}
totalPages={linearIssueTotalPages}
loadingTarget={activeLinearIssueLoadingTargetPage}
onPageChange={handleLinearIssuePageChange}
/>
</div>
) : null}
</>
) : (
<>
<LinearCollectionNotice
hasMore={showLinearEmptyFilteredLoadMore}
count={linearIssues.length}
label={translate('auto.components.TaskPage.d1e243795c', 'issues')}
onLoadMore={handleLinearEmptyFilteredLoadMore}
loading={activeLinearIssueLoading}
loadMoreLabel="Fetch more"
/>
{showLinearIssuePagination ? (
<div className="flex-none border-t border-border/50 bg-muted/50">
<PaginationBar
currentPage={visibleLinearIssuePage}
totalPages={linearIssueTotalPages}
loadingTarget={activeLinearIssueLoadingTargetPage}
onPageChange={handleLinearIssuePageChange}
/>
</div>
) : null}
</>
)}
</div>
)
}
@@ -0,0 +1,9 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { TaskPageLinearIssueBoard } from './TaskPageLinearIssueBoard'
export function TaskPageLinearIssueRows({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
return <TaskPageLinearIssueBoard model={model} />
}
@@ -0,0 +1,280 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { ChevronDown, FolderOpen, ArrowRight, ExternalLink } from 'lucide-react'
import { findLinearIssueWorkspaceAttachmentInIndex } from '@/lib/linear-issue-workspace-attachment'
import { getWorktreeAttachmentLabel } from '@/lib/worktree-attachment-label'
import { cn } from '@/lib/utils'
import { LinearPriorityIcon } from '@/components/linear-priority-icon'
import { LinearStateCell } from './task-page-linear-issue-model'
import { translate } from '@/i18n/i18n'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { formatRelativeTime } from './task-page-source-context'
import { Button } from '@/components/ui/button'
export function TaskPageLinearIssueTable({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
selectedLinearWorkspaceId,
linearTaskSourceContext,
selectedLinearIssueId,
openLinearDetailPage,
linearIssueAttachmentIndex,
effectiveLinearDisplayProperties,
linearIssueGridStyle,
linearIssueListRows,
handleOpenOrUseLinearItem
} = model
return (
<div className="divide-y divide-border/50">
{linearIssueListRows.map((row) => {
if (row.type === 'section') {
return (
<div key={row.key} className="flex h-9 items-center gap-2 bg-muted/35 px-3">
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-[13px] font-medium text-foreground">
{row.label}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{row.count}</span>
</div>
)
}
const issue = row.issue
const selected = issue.id === selectedLinearIssueId
const labels = issue.labels.slice(0, 3)
const teamLabel =
selectedLinearWorkspaceId === 'all' && issue.workspaceName
? `${issue.workspaceName} / ${issue.team.name}`
: issue.team.name
const attachedWorkspace = findLinearIssueWorkspaceAttachmentInIndex(
linearIssueAttachmentIndex,
issue
)
const attachedWorkspaceLabel = attachedWorkspace
? getWorktreeAttachmentLabel(attachedWorkspace)
: null
return (
<div
key={issue.id}
role="button"
tabIndex={0}
aria-current={selected ? 'true' : undefined}
data-current={selected ? 'true' : undefined}
onClick={() => {
openLinearDetailPage(issue)
}}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) {
return
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
openLinearDetailPage(issue)
}
}}
className={cn(
'group/row grid min-h-12 cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-3 py-2 text-left transition hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring lg:grid-cols-[var(--linear-grid-template)]',
selected && 'bg-accent'
)}
style={linearIssueGridStyle}
>
<div className="flex min-w-0 items-center gap-2 max-lg:!hidden">
<span className="min-w-0 truncate font-mono text-[12px] text-muted-foreground">
{issue.identifier}
</span>
</div>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
{effectiveLinearDisplayProperties.has('priority') ? (
<LinearPriorityIcon priority={issue.priority} />
) : null}
<span className="shrink-0 font-mono text-[11px] text-muted-foreground lg:hidden">
{issue.identifier}
</span>
<h3 className="min-w-0 truncate text-[13px] font-medium text-foreground">
{issue.title}
</h3>
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 lg:!hidden">
{effectiveLinearDisplayProperties.has('state') ? (
<LinearStateCell
issue={issue}
className="px-1.5 py-0.5"
sourceContext={linearTaskSourceContext}
/>
) : null}
{effectiveLinearDisplayProperties.has('assignee') ? (
<span className="min-w-0 truncate text-[11px] text-muted-foreground">
{issue.assignee?.displayName ??
translate('auto.components.TaskPage.42a9160321', 'Unassigned')}
</span>
) : null}
{effectiveLinearDisplayProperties.has('team') ? (
<span className="min-w-0 truncate text-[11px] text-muted-foreground">
{teamLabel}
</span>
) : null}
{attachedWorkspaceLabel ? (
<span className="inline-flex min-w-0 items-center gap-1 text-[11px] text-muted-foreground">
<FolderOpen className="size-3 shrink-0" />
<span className="truncate">{attachedWorkspaceLabel}</span>
</span>
) : null}
</div>
</div>
{effectiveLinearDisplayProperties.has('labels') ? (
<div className="flex min-w-0 items-center gap-1 max-lg:!hidden">
{labels.map((label) => (
<span
key={label}
className="max-w-[150px] truncate rounded-full border border-border/50 bg-muted/35 px-1.5 py-0.5 text-[11px] text-muted-foreground"
>
{label}
</span>
))}
{issue.labels.length > labels.length ? (
<span className="text-[11px] text-muted-foreground">
+{issue.labels.length - labels.length}
</span>
) : null}
</div>
) : null}
{effectiveLinearDisplayProperties.has('team') ? (
<div className="block min-w-0 text-[12px] text-muted-foreground max-lg:!hidden">
<div className="truncate">{teamLabel}</div>
</div>
) : null}
{effectiveLinearDisplayProperties.has('state') ? (
<div className="flex min-w-0 max-lg:!hidden">
<LinearStateCell
issue={issue}
className="max-w-full px-2 py-0.5"
sourceContext={linearTaskSourceContext}
/>
</div>
) : null}
{effectiveLinearDisplayProperties.has('assignee') ? (
<div className="flex min-w-0 justify-center max-lg:!hidden">
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex size-5 shrink-0 items-center justify-center rounded-full border border-border/50 bg-muted/40 text-[10px] text-muted-foreground"
aria-label={
issue.assignee?.displayName ??
translate('auto.components.TaskPage.42a9160321', 'Unassigned')
}
>
{issue.assignee?.avatarUrl ? (
<img
src={issue.assignee.avatarUrl}
alt={issue.assignee.displayName}
className="size-5 rounded-full"
/>
) : (
(issue.assignee?.displayName?.slice(0, 1) ?? '-')
)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{issue.assignee?.displayName ??
translate('auto.components.TaskPage.42a9160321', 'Unassigned')}
</TooltipContent>
</Tooltip>
</div>
) : null}
{effectiveLinearDisplayProperties.has('updated') ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="block min-w-0 truncate text-[12px] text-muted-foreground max-lg:!hidden">
{formatRelativeTime(issue.updatedAt)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{new Date(issue.updatedAt).toLocaleString()}
</TooltipContent>
</Tooltip>
) : null}
<div className="flex shrink-0 items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
// Why: solid primary when a workspace is already linked so Open reads stronger than Start.
variant={attachedWorkspace ? 'default' : 'ghost'}
size="icon-xs"
data-contextual-tour-target="tasks-start-workspace"
onClick={(event) => {
event.stopPropagation()
handleOpenOrUseLinearItem(issue)
}}
className={attachedWorkspace ? 'shadow-xs' : undefined}
aria-label={
attachedWorkspace
? translate(
'auto.components.TaskPage.linearOpenAttachedWorkspace',
'Open workspace attached to {{value0}}',
{
value0: issue.identifier
}
)
: translate(
'auto.components.TaskPage.ff90d0abc7',
'Start workspace from {{value0}}',
{
value0: issue.identifier
}
)
}
>
{attachedWorkspace ? (
<FolderOpen className="size-3.5" />
) : (
<ArrowRight className="size-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{attachedWorkspace
? (attachedWorkspaceLabel ??
translate('auto.components.TaskPage.606a85c774', 'Open'))
: translate('auto.components.TaskPage.7d08e8be0f', 'Start')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={(event) => {
event.stopPropagation()
window.api.shell.openUrl(issue.url)
}}
aria-label={translate(
'auto.components.TaskPage.246bd64aed',
'Open {{value0}} in Linear',
{
value0: issue.identifier
}
)}
>
<ExternalLink className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.TaskPage.6244a02f46', 'Open in Linear')}
</TooltipContent>
</Tooltip>
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,197 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { ChevronLeft, SlidersHorizontal, List, ArrowDownUp, Eye } from 'lucide-react'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuCheckboxItem
} from '@/components/ui/dropdown-menu'
import type {
LinearViewMode,
LinearGroupBy,
LinearOrderBy
} from '@/components/task-page-localized-options'
export function TaskPageLinearIssueToolbar({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
setTaskResumeState,
linearViewOptions,
linearGroupOptions,
linearOrderOptions,
linearDisplayPropertyOptions,
linearMode,
linearViewMode,
setLinearViewMode,
linearGroupBy,
setLinearGroupBy,
linearOrderBy,
setLinearOrderBy,
selectedLinearProject,
setLinearProjectTab,
setSelectedLinearCustomView,
setLinearProjectParentView,
activeLinearIssueContextLabel,
pagedLinearIssues,
effectiveLinearDisplayProperties,
toggleLinearDisplayProperty
} = model
return (
<div className="flex h-10 flex-none items-center justify-between gap-3 border-b border-border/50 bg-muted/35 px-3">
<div className="flex min-w-0 items-center gap-2">
{activeLinearIssueContextLabel ? (
<Button
variant="ghost"
size="icon-xs"
onClick={() => {
if (selectedLinearProject) {
setLinearProjectTab('overview')
return
}
setSelectedLinearCustomView(null)
setLinearProjectParentView(null)
setTaskResumeState({
linearContext: undefined
})
}}
aria-label={translate('auto.components.TaskPage.f397d513e3', 'Back')}
>
<ChevronLeft className="size-3.5" />
</Button>
) : null}
<div className="min-w-0 text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
{activeLinearIssueContextLabel ??
(linearMode === 'in-orca'
? translate('auto.components.TaskPage.linearModeHasWorktree', 'Has Workspace')
: translate('auto.components.TaskPage.60f68a2ef4', 'Linear issues'))}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<div
className="hidden items-center rounded-md border border-border/50 bg-background/70 p-0.5 md:flex"
aria-label={translate('auto.components.TaskPage.d47248df4d', 'Linear view mode')}
>
{linearViewOptions.map(({ id, label, Icon }) => {
const active = linearViewMode === id
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setLinearViewMode(id)}
aria-label={translate(
'auto.components.TaskPage.af377b13b1',
'{{value0}} view',
{
value0: label
}
)}
aria-pressed={active}
className={cn(
'inline-flex size-6 items-center justify-center rounded text-muted-foreground transition hover:text-foreground',
active && 'bg-accent text-accent-foreground shadow-xs'
)}
>
<Icon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.TaskPage.af377b13b1', '{{value0}} view', {
value0: label
})}
</TooltipContent>
</Tooltip>
)
})}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="xs"
className="gap-1 border-border/50 bg-background/70 text-[11px]"
>
<SlidersHorizontal className="size-3.5" />
{translate('auto.components.TaskPage.9c57663908', 'View')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="flex items-center gap-2">
<List className="size-3.5" />
{translate('auto.components.TaskPage.9c57663908', 'View')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={linearViewMode}
onValueChange={(value) => setLinearViewMode(value as LinearViewMode)}
>
{linearViewOptions.map(({ id, label, Icon }) => (
<DropdownMenuRadioItem key={id} value={id}>
<Icon className="size-3.5" />
{label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-center gap-2">
<SlidersHorizontal className="size-3.5" />
{translate('auto.components.TaskPage.5659da12fc', 'Grouping')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={linearGroupBy}
onValueChange={(value) => setLinearGroupBy(value as LinearGroupBy)}
>
{linearGroupOptions.map((option) => (
<DropdownMenuRadioItem key={option.id} value={option.id}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-center gap-2">
<ArrowDownUp className="size-3.5" />
{translate('auto.components.TaskPage.5d2d835467', 'Ordering')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={linearOrderBy}
onValueChange={(value) => setLinearOrderBy(value as LinearOrderBy)}
>
{linearOrderOptions.map((option) => (
<DropdownMenuRadioItem key={option.id} value={option.id}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-center gap-2">
<Eye className="size-3.5" />
{translate('auto.components.TaskPage.a26a48252e', 'Display properties')}
</DropdownMenuLabel>
{linearDisplayPropertyOptions.map((property) => (
<DropdownMenuCheckboxItem
key={property.id}
checked={effectiveLinearDisplayProperties.has(property.id)}
onSelect={(event) => event.preventDefault()}
onCheckedChange={() => toggleLinearDisplayProperty(property.id)}
>
{property.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="text-[11px] text-muted-foreground">
{pagedLinearIssues.length} {translate('auto.components.TaskPage.b7bae28b6a', 'shown')}
</div>
</div>
</div>
)
}
@@ -0,0 +1,159 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
DialogFooter
} from '@/components/ui/dialog'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { translate } from '@/i18n/i18n'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { ChevronDown, Check, X, LoaderCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { TaskPageLinearProjectFields } from './TaskPageLinearProjectFields'
export function TaskPageLinearProjectDialog({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
availableTeams,
newLinearProjectOpen,
setNewLinearProjectOpen,
newLinearProjectName,
setNewLinearProjectTeamId,
newLinearProjectSubmitting,
newLinearProjectTargetTeam,
handleCreateNewLinearProject
} = model
return (
<Dialog
open={newLinearProjectOpen}
onOpenChange={(open) => {
if (!newLinearProjectSubmitting) {
setNewLinearProjectOpen(open)
}
}}
>
<DialogContent
showCloseButton={false}
className="flex max-h-[88vh] flex-col gap-0 overflow-hidden rounded-xl border-border bg-background p-0 shadow-2xl sm:max-w-3xl"
onKeyDown={(event) => {
if (isScreenSubmitShortcut(event)) {
event.preventDefault()
void handleCreateNewLinearProject()
}
}}
>
<DialogTitle className="sr-only">
{translate('auto.components.TaskPage.1361275ec3', 'New Linear project')}
</DialogTitle>
<DialogDescription className="sr-only">
{translate(
'auto.components.TaskPage.bdebffcbfe',
'Create a Linear project for the selected team.'
)}
</DialogDescription>
<div className="flex items-center justify-between border-b border-border/60 bg-muted/10 px-5 py-3">
<div className="flex min-w-0 items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{translate('auto.components.TaskPage.02f67c0d09', 'New Project')}
</span>
<span className="text-xs text-muted-foreground/40">/</span>
{availableTeams.length > 1 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="xs"
className="h-7 max-w-56 gap-1 px-2 text-xs font-medium text-foreground hover:bg-muted"
>
<span className="truncate">
{newLinearProjectTargetTeam
? `${newLinearProjectTargetTeam.key} - ${newLinearProjectTargetTeam.name}`
: translate('auto.components.TaskPage.5af6f0ae5b', 'Select team')}
</span>
<ChevronDown className="size-3 flex-none text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-1">
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{translate('auto.components.TaskPage.a98cbe7664', 'Team')}
</div>
<div className="max-h-64 overflow-y-auto scrollbar-sleek">
{availableTeams.map((team) => (
<button
key={team.id}
type="button"
onClick={() => setNewLinearProjectTeamId(team.id)}
className={cn(
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
newLinearProjectTargetTeam?.id === team.id
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
)}
>
<span className="truncate">
{team.key} - {team.name}
</span>
{newLinearProjectTargetTeam?.id === team.id ? (
<Check className="size-3 flex-none" />
) : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
) : (
<span className="truncate text-xs font-medium text-foreground">
{newLinearProjectTargetTeam
? `${newLinearProjectTargetTeam.key} - ${newLinearProjectTargetTeam.name}`
: ''}
</span>
)}
</div>
<button
type="button"
onClick={() => setNewLinearProjectOpen(false)}
className="rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground"
disabled={newLinearProjectSubmitting}
aria-label={translate('auto.components.TaskPage.b6795e65fd', 'Close')}
>
<X className="size-4" />
</button>
</div>
<TaskPageLinearProjectFields model={model} />
<DialogFooter className="border-t border-border/60 bg-muted/10 px-5 py-3">
<Button
variant="outline"
onClick={() => setNewLinearProjectOpen(false)}
disabled={newLinearProjectSubmitting}
>
{translate('auto.components.TaskPage.ff69a30681', 'Cancel')}
</Button>
<Button
onClick={() => void handleCreateNewLinearProject()}
disabled={
!newLinearProjectTargetTeam ||
!newLinearProjectName.trim() ||
newLinearProjectSubmitting
}
>
{newLinearProjectSubmitting ? (
<>
<LoaderCircle className="size-4 animate-spin" />
{translate('auto.components.TaskPage.1b59a07674', 'Creating...')}
</>
) : (
translate('auto.components.TaskPage.5301ca0f20', 'Create project')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,375 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { translate } from '@/i18n/i18n'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { LinearPriorityIcon } from '@/components/linear-priority-icon'
import { getLinearPriorityLabel } from '@/components/task-page-localized-options'
import { ChevronDown, Check, UserRound, LoaderCircle, Users, Tag, Clock3 } from 'lucide-react'
import { cn } from '@/lib/utils'
export function TaskPageLinearProjectFields({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
submitShortcutLabel,
newLinearProjectName,
setNewLinearProjectName,
newLinearProjectDescription,
setNewLinearProjectDescription,
newLinearProjectContent,
setNewLinearProjectContent,
newLinearProjectLeadId,
setNewLinearProjectLeadId,
newLinearProjectMemberIds,
setNewLinearProjectMemberIds,
newLinearProjectLabelIds,
setNewLinearProjectLabelIds,
newLinearProjectPriority,
setNewLinearProjectPriority,
newLinearProjectStartDate,
setNewLinearProjectStartDate,
newLinearProjectTargetDate,
setNewLinearProjectTargetDate,
newLinearProjectSubmitting,
newLinearProjectMembers,
newLinearProjectLabels,
handleCreateNewLinearProject
} = model
return (
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 py-5 scrollbar-sleek">
<input
autoFocus
value={newLinearProjectName}
onChange={(event) => setNewLinearProjectName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
void handleCreateNewLinearProject()
}
}}
placeholder={translate('auto.components.TaskPage.ecbcc83140', 'Project name')}
disabled={newLinearProjectSubmitting}
className="w-full border-none bg-transparent p-0 text-xl font-semibold text-foreground outline-none placeholder:text-muted-foreground/45 focus:outline-none focus:ring-0 focus-visible:ring-0"
/>
<input
value={newLinearProjectDescription}
onChange={(event) => setNewLinearProjectDescription(event.target.value)}
placeholder={translate('auto.components.TaskPage.579f98afcd', 'Add a short summary...')}
disabled={newLinearProjectSubmitting}
className="w-full border-none bg-transparent p-0 text-sm text-foreground outline-none placeholder:text-muted-foreground/45 focus:outline-none focus:ring-0 focus-visible:ring-0"
/>
<div className="flex flex-wrap items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearProjectSubmitting}
className="flex items-center gap-1.5 rounded-md border border-border/80 bg-muted/15 px-2 py-1 text-xs text-foreground/80 transition-colors hover:bg-muted/50 active:bg-muted disabled:opacity-50"
>
<LinearPriorityIcon priority={newLinearProjectPriority} className="size-3.5" />
<span>{getLinearPriorityLabel(newLinearProjectPriority)}</span>
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-48 p-1">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{translate('auto.components.TaskPage.c8d5bec5f7', 'Priority')}
</div>
{[0, 1, 2, 3, 4].map((priority) => (
<button
key={priority}
type="button"
onClick={() => setNewLinearProjectPriority(priority)}
className={cn(
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
newLinearProjectPriority === priority
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
)}
>
<span className="flex items-center gap-2">
<LinearPriorityIcon priority={priority} className="size-3.5" />
{getLinearPriorityLabel(priority)}
</span>
{newLinearProjectPriority === priority ? (
<Check className="size-3 text-foreground" />
) : null}
</button>
))}
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearProjectSubmitting}
className="flex items-center gap-1.5 rounded-md border border-border/80 bg-muted/15 px-2 py-1 text-xs text-foreground/80 transition-colors hover:bg-muted/50 active:bg-muted disabled:opacity-50"
>
<UserRound className="size-3.5 text-muted-foreground/70" />
<span className="max-w-[120px] truncate">
{newLinearProjectMembers.data.find((member) => member.id === newLinearProjectLeadId)
?.displayName ?? translate('auto.components.TaskPage.34da8ac06c', 'Lead')}
</span>
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{translate('auto.components.TaskPage.34da8ac06c', 'Lead')}
</div>
{newLinearProjectMembers.loading ? (
<div className="flex items-center justify-center p-4">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="max-h-64 overflow-y-auto scrollbar-sleek">
<button
type="button"
onClick={() => setNewLinearProjectLeadId(null)}
className={cn(
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
newLinearProjectLeadId === null
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
)}
>
<span className="flex items-center gap-2">
<UserRound className="size-3.5 text-muted-foreground/50" />
{translate('auto.components.TaskPage.cfaadb6b22', 'No lead')}
</span>
{newLinearProjectLeadId === null ? <Check className="size-3" /> : null}
</button>
{newLinearProjectMembers.data.map((member) => (
<button
key={member.id}
type="button"
onClick={() => setNewLinearProjectLeadId(member.id)}
className={cn(
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
newLinearProjectLeadId === member.id
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
)}
>
<span className="flex min-w-0 items-center gap-2">
{member.avatarUrl ? (
<img
src={member.avatarUrl}
alt={member.displayName}
className="size-3.5 flex-none rounded-full"
/>
) : (
<UserRound className="size-3.5 flex-none text-muted-foreground/70" />
)}
<span className="truncate">{member.displayName}</span>
</span>
{newLinearProjectLeadId === member.id ? (
<Check className="size-3 flex-none" />
) : null}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearProjectSubmitting}
className="flex items-center gap-1.5 rounded-md border border-border/80 bg-muted/15 px-2 py-1 text-xs text-foreground/80 transition-colors hover:bg-muted/50 active:bg-muted disabled:opacity-50"
>
<Users className="size-3.5 text-muted-foreground/70" />
<span>
{newLinearProjectMemberIds.length === 0
? translate('auto.components.TaskPage.d6cda23ef1', 'Members')
: translate(
'auto.components.TaskPage.7719d8daa9',
'{{value0}} member{{value1}}',
{
value0: newLinearProjectMemberIds.length,
value1: newLinearProjectMemberIds.length > 1 ? 's' : ''
}
)}
</span>
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{translate('auto.components.TaskPage.d6cda23ef1', 'Members')}
</div>
{newLinearProjectMembers.loading ? (
<div className="flex items-center justify-center p-4">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="max-h-64 overflow-y-auto scrollbar-sleek">
{newLinearProjectMembers.data.map((member) => {
const selected = newLinearProjectMemberIds.includes(member.id)
return (
<button
key={member.id}
type="button"
onClick={() =>
setNewLinearProjectMemberIds((current) =>
selected
? current.filter((id) => id !== member.id)
: [...current, member.id]
)
}
className={cn(
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
selected ? 'bg-muted font-medium text-foreground' : 'text-foreground/80'
)}
>
<span className="flex min-w-0 items-center gap-2">
{member.avatarUrl ? (
<img
src={member.avatarUrl}
alt={member.displayName}
className="size-3.5 flex-none rounded-full"
/>
) : (
<UserRound className="size-3.5 flex-none text-muted-foreground/70" />
)}
<span className="truncate">{member.displayName}</span>
</span>
{selected ? <Check className="size-3 flex-none" /> : null}
</button>
)
})}
</div>
)}
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearProjectSubmitting}
className="flex items-center gap-1.5 rounded-md border border-border/80 bg-muted/15 px-2 py-1 text-xs text-foreground/80 transition-colors hover:bg-muted/50 active:bg-muted disabled:opacity-50"
>
<Tag className="size-3.5 text-muted-foreground/70" />
<span>
{newLinearProjectLabelIds.length === 0
? translate('auto.components.TaskPage.d0ca4aa1d0', 'Labels')
: translate('auto.components.TaskPage.eff9800d4b', '{{value0}} label{{value1}}', {
value0: newLinearProjectLabelIds.length,
value1: newLinearProjectLabelIds.length > 1 ? 's' : ''
})}
</span>
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{translate('auto.components.TaskPage.d0ca4aa1d0', 'Labels')}
</div>
{newLinearProjectLabels.loading ? (
<div className="flex items-center justify-center p-4">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="max-h-64 overflow-y-auto scrollbar-sleek">
{newLinearProjectLabels.data.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
{translate('auto.components.TaskPage.af9e877f30', 'No labels')}
</div>
) : (
newLinearProjectLabels.data.map((label) => {
const selected = newLinearProjectLabelIds.includes(label.id)
return (
<button
key={label.id}
type="button"
onClick={() =>
setNewLinearProjectLabelIds((current) =>
selected
? current.filter((id) => id !== label.id)
: [...current, label.id]
)
}
className={cn(
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
selected ? 'bg-muted font-medium text-foreground' : 'text-foreground/80'
)}
>
<span className="flex min-w-0 items-center gap-2">
<span
className="size-2 flex-none rounded-full bg-muted-foreground/40"
style={
label.color
? {
backgroundColor: label.color
}
: undefined
}
/>
<span className="truncate">{label.name}</span>
</span>
{selected ? <Check className="size-3 flex-none" /> : null}
</button>
)
})
)}
</div>
)}
</PopoverContent>
</Popover>
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted/50 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50">
<Clock3 className="size-3.5 shrink-0 text-muted-foreground" />
<span className="shrink-0 text-muted-foreground">
{translate('auto.components.TaskPage.7d08e8be0f', 'Start')}
</span>
<input
type="date"
value={newLinearProjectStartDate}
onChange={(event) => setNewLinearProjectStartDate(event.target.value)}
disabled={newLinearProjectSubmitting}
className="h-5 min-w-[6.75rem] cursor-pointer border-none bg-transparent p-0 text-xs text-foreground outline-none disabled:cursor-not-allowed"
aria-label={translate('auto.components.TaskPage.09623359b9', 'Start date')}
/>
</label>
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted/50 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50">
<Clock3 className="size-3.5 shrink-0 text-muted-foreground" />
<span className="shrink-0 text-muted-foreground">
{translate('auto.components.TaskPage.7da41c9225', 'Target')}
</span>
<input
type="date"
value={newLinearProjectTargetDate}
onChange={(event) => setNewLinearProjectTargetDate(event.target.value)}
disabled={newLinearProjectSubmitting}
className="h-5 min-w-[6.75rem] cursor-pointer border-none bg-transparent p-0 text-xs text-foreground outline-none disabled:cursor-not-allowed"
aria-label={translate('auto.components.TaskPage.2ea1c701b6', 'Target date')}
/>
</label>
</div>
<div className="border-t border-border/40 pt-4">
<textarea
value={newLinearProjectContent}
onChange={(event) => setNewLinearProjectContent(event.target.value)}
placeholder={translate(
'auto.components.TaskPage.cf72580c04',
'Write a description, project brief, or collect ideas...'
)}
rows={8}
disabled={newLinearProjectSubmitting}
className="max-h-72 min-h-40 w-full min-w-0 resize-none overflow-y-auto border-none bg-transparent p-0 text-sm text-foreground outline-none placeholder:text-muted-foreground/45 scrollbar-sleek focus:outline-none focus:ring-0 focus-visible:ring-0"
/>
</div>
<p className="text-[10px] text-muted-foreground">
{submitShortcutLabel} {translate('auto.components.TaskPage.fc0d8a1fa4', 'to submit.')}
</p>
</div>
)
}
@@ -0,0 +1,63 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { translate } from '@/i18n/i18n'
import {
LinearProjectTable,
LinearCollectionNotice
} from '@/components/linear-project-view-surfaces'
export function TaskPageLinearProjectList({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
selectedLinearWorkspaceId,
linearProjectsResult,
linearProjectsLoading,
linearProjectsError,
setLinearProjectTab,
openLinearProjectContext
} = model
return (
<div className="flex min-h-0 max-h-full flex-col overflow-hidden rounded-md rounded-t-none border border-t-0 border-border/50 bg-background shadow-sm">
<div className="grid h-8 flex-none items-center gap-3 border-b border-border/50 bg-muted/25 px-3 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground grid-cols-[minmax(180px,1.5fr)_110px_100px_90px_120px_110px_80px_70px]">
<span>{translate('auto.components.TaskPage.00022ec0ba', 'Project')}</span>
<span>{translate('auto.components.TaskPage.154b0fa623', 'Status')}</span>
<span>{translate('auto.components.TaskPage.8a07f21e76', 'Health')}</span>
<span>{translate('auto.components.TaskPage.c8d5bec5f7', 'Priority')}</span>
<span>{translate('auto.components.TaskPage.34da8ac06c', 'Lead')}</span>
<span>{translate('auto.components.TaskPage.7da41c9225', 'Target')}</span>
<span>{translate('auto.components.TaskPage.dfc0c79bd8', 'Issues')}</span>
<span />
</div>
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-auto scrollbar-sleek">
{linearProjectsError ? (
<div className="border-b border-border px-4 py-4 text-sm text-destructive">
{linearProjectsError}
</div>
) : null}
<LinearProjectTable
projects={linearProjectsResult.items}
loading={linearProjectsLoading}
hasError={!!linearProjectsResult.errors?.length}
workspaceSelection={selectedLinearWorkspaceId}
onSelectProject={openLinearProjectContext}
onOpenProject={(project) => {
if (project.url) {
void window.api.shell.openUrl(project.url)
}
}}
onUseProjectIssues={(project) => {
openLinearProjectContext(project)
setLinearProjectTab('issues')
}}
/>
</div>
<LinearCollectionNotice
errors={linearProjectsResult.errors}
hasMore={linearProjectsResult.hasMore}
count={linearProjectsResult.items.length}
label={translate('auto.components.TaskPage.b39fe6511d', 'projects')}
/>
</div>
)
}
@@ -0,0 +1,73 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { LinearProjectOverview } from '@/components/linear-project-view-surfaces'
export function TaskPageLinearProjectOverview({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
setTaskResumeState,
setLinearMode,
setLinearRefreshNonce,
selectedLinearProject,
setSelectedLinearProject,
selectedLinearProjectDetail,
setSelectedLinearProjectDetail,
linearProjectDetailLoading,
linearProjectDetailError,
setLinearProjectTab,
setSelectedLinearCustomView,
linearProjectParentView,
setLinearProjectParentView
} = model
return (
<div className="flex min-h-0 max-h-full flex-col overflow-hidden rounded-md rounded-t-none border border-t-0 border-border/50 bg-background shadow-sm">
<LinearProjectOverview
project={selectedLinearProjectDetail ?? selectedLinearProject}
loading={linearProjectDetailLoading}
error={linearProjectDetailError}
onBack={() => {
if (linearProjectParentView) {
setSelectedLinearProject(null)
setSelectedLinearProjectDetail(null)
setLinearProjectTab('overview')
setLinearMode('views')
setSelectedLinearCustomView(linearProjectParentView)
setTaskResumeState(
linearProjectParentView.workspaceId
? {
linearMode: 'views',
linearContext: {
kind: 'view',
id: linearProjectParentView.id,
workspaceId: linearProjectParentView.workspaceId,
model: linearProjectParentView.model
}
}
: {
linearMode: 'views',
linearContext: undefined
}
)
setLinearProjectParentView(null)
return
}
setSelectedLinearProject(null)
setSelectedLinearProjectDetail(null)
setLinearProjectParentView(null)
setLinearProjectTab('overview')
setTaskResumeState({
linearContext: undefined
})
}}
onOpenProject={(project) => {
if (project.url) {
void window.api.shell.openUrl(project.url)
}
}}
onRefresh={() => setLinearRefreshNonce((n) => n + 1)}
onOpenIssues={() => setLinearProjectTab('issues')}
/>
</div>
)
}
@@ -0,0 +1,37 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { cn } from '@/lib/utils'
import { TaskPageSourceBar } from './TaskPageSourceBar'
import { AlertCircle } from 'lucide-react'
import { TaskPageGitHubModeControls } from './TaskPageGitHubModeControls'
import { TaskPageProviderFilters } from './TaskPageProviderFilters'
export function TaskPageListChrome({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const { taskSourceAvailabilityNotice, taskPageListChromeHidden } = model
return (
<div className={cn('flex-none flex flex-col gap-2', taskPageListChromeHidden && 'hidden')}>
<section className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<TaskPageSourceBar model={model} />
{taskSourceAvailabilityNotice ? (
<div
role="status"
className="flex max-w-3xl items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-xs text-muted-foreground"
title={taskSourceAvailabilityNotice.title}
>
<AlertCircle className="size-3.5 flex-none" />
<span className="min-w-0 truncate">{taskSourceAvailabilityNotice.label}</span>
</div>
) : null}
<TaskPageGitHubModeControls model={model} />
<TaskPageProviderFilters model={model} />
</div>
</section>
</div>
)
}
@@ -0,0 +1,87 @@
import React from 'react'
import { getPageNumbers } from '@/components/task-page-pagination-page-numbers'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { ChevronLeft, LoaderCircle, ChevronRight } from 'lucide-react'
export function PaginationBar({
currentPage,
totalPages,
loadingTarget,
onPageChange
}: {
currentPage: number
totalPages: number
loadingTarget: number | null
onPageChange: (page: number) => void
}): React.JSX.Element {
const pageNumbers = getPageNumbers(currentPage, totalPages)
const btnClass =
'inline-flex w-24 items-center justify-center gap-0.5 rounded-md px-2 py-1 text-sm text-muted-foreground transition hover:bg-muted/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-40'
const numClass = (page: number): string =>
cn(
'inline-flex size-8 items-center justify-center rounded-md text-sm transition',
page === currentPage
? 'bg-primary text-primary-foreground font-medium'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
)
return (
<nav
aria-label={translate('auto.components.TaskPage.e65757a338', 'Pagination')}
className="flex items-center justify-center gap-1 border-t border-border/50 px-4 py-3"
>
<button
type="button"
disabled={currentPage === 0 || loadingTarget !== null}
onClick={() => onPageChange(currentPage - 1)}
aria-label={translate('auto.components.TaskPage.6cd6b3ae6a', 'Previous page')}
className={btnClass}
>
<ChevronLeft className="size-4" />
{translate('auto.components.TaskPage.297a805b64', 'Previous')}
</button>
{pageNumbers.map((entry, idx) =>
entry === 'ellipsis' ? (
<span
key={`ellipsis-${idx}`}
aria-hidden
className="inline-flex size-8 items-center justify-center text-sm text-muted-foreground"
>
{translate('auto.components.TaskPage.cd171f3391', '...')}
</span>
) : (
<button
key={entry}
type="button"
disabled={loadingTarget !== null && loadingTarget !== entry}
onClick={() => onPageChange(entry)}
aria-label={translate('auto.components.TaskPage.ae859c816b', 'Page {{value0}}', {
value0: entry + 1
})}
aria-current={entry === currentPage ? 'page' : undefined}
className={numClass(entry)}
>
{loadingTarget === entry ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
entry + 1
)}
</button>
)
)}
<button
type="button"
disabled={currentPage >= totalPages - 1 || loadingTarget !== null}
onClick={() => onPageChange(currentPage + 1)}
aria-label={translate('auto.components.TaskPage.0c8df28045', 'Next page')}
className={btnClass}
>
{translate('auto.components.TaskPage.b73717af92', 'Next')}
<ChevronRight className="size-4" />
</button>
</nav>
)
}
// Why: hoisted to module scope so the type-guard predicate isn't re-allocated on every TaskPage render.
@@ -0,0 +1,23 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { TaskPageGitHubFilters } from './TaskPageGitHubFilters'
import { TaskPageLinearFilters } from './TaskPageLinearFilters'
import { TaskPageJiraFilters } from './TaskPageJiraFilters'
import { TaskPageGitLabFilters } from './TaskPageGitLabFilters'
export function TaskPageProviderFilters({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const { linearConnected, jiraConnected, taskSource, githubMode } = model
return taskSource === 'github' && githubMode === 'items' ? (
// Why: top of the joined GitHub list card — pairs with the
// table shell below (rounded-t-none border-t-0) as one surface.
<TaskPageGitHubFilters model={model} />
) : taskSource === 'linear' && linearConnected ? (
<TaskPageLinearFilters model={model} />
) : taskSource === 'jira' && jiraConnected ? (
<TaskPageJiraFilters model={model} />
) : taskSource === 'gitlab' ? (
<TaskPageGitLabFilters model={model} />
) : null
}
@@ -0,0 +1,231 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { X, ExternalLink } from 'lucide-react'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { LinearScopeSelector } from '@/components/linear-scope-selector'
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem
} from '@/components/ui/select'
export function TaskPageSourceBar({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
openTaskPage,
closeTaskPage,
updateSettings,
selectJiraSite,
linearConnected,
jiraConnected,
linearWorkspaces,
selectedLinearWorkspaceId,
jiraSites,
selectedJiraSiteId,
visibleSourceOptions,
taskSource,
taskSourceAvailabilityNoticeByProvider,
taskSourceContextSummary,
taskSourceManuallyChangedRef,
setSelectedJiraIssueKey,
setSelectedJiraIssueFallback,
setJiraIssues,
setJiraLoading,
setJiraError,
defaultLinearTeamSelection,
linearTeamSelection,
linearTeamOptions,
selectedLinearTeamForExternalLink,
setLinearConnectOpen,
handleLinearWorkspaceChange,
handleLinearTeamSelectionChange,
handleLinearScopeOpen
} = model
return (
<div className="flex items-center justify-between gap-2">
<div
className="flex min-w-0 flex-wrap items-center gap-2"
data-contextual-tour-target="tasks-source-filters"
>
{/* Why: Close is anchored left with the source icons for one compact band, clear of the app sidebar on the right. */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 rounded-full"
onClick={closeTaskPage}
aria-label={translate('auto.components.TaskPage.1a06219d5c', 'Close tasks')}
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.TaskPage.4826fd1ad8', 'Close · Esc')}
</TooltipContent>
</Tooltip>
<div className="mx-1 h-5 w-px bg-border/50" aria-hidden />
{visibleSourceOptions.map((source) => {
const active = taskSource === source.id
const sourceAvailabilityNotice = taskSourceAvailabilityNoticeByProvider[source.id] ?? null
const sourceDisabled = source.disabled || sourceAvailabilityNotice?.blocking
return (
<Tooltip key={source.id}>
<TooltipTrigger asChild>
<button
type="button"
disabled={sourceDisabled}
onClick={() => {
if (sourceAvailabilityNotice?.blocking) {
return
}
taskSourceManuallyChangedRef.current = true
openTaskPage(
{
taskSource: source.id
},
{
recordTasksInteraction: false
}
)
void updateSettings({
defaultTaskSource: source.id
}).catch(() => {
toast.error(
translate(
'auto.components.TaskPage.609532fae7',
'Failed to save default task source.'
)
)
})
}}
data-task-source={source.id}
aria-label={sourceAvailabilityNotice?.label ?? source.label}
aria-pressed={active}
className={cn(
'group flex h-8 w-8 items-center justify-center rounded-md border transition',
active
? 'border-foreground/40 bg-muted/70 text-foreground shadow-sm'
: 'border-border/40 bg-transparent text-muted-foreground hover:bg-muted/40 hover:text-foreground',
sourceDisabled && 'cursor-not-allowed opacity-55'
)}
>
<source.Icon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{sourceAvailabilityNotice?.label ?? source.label}
</TooltipContent>
</Tooltip>
)
})}
<div
className="hidden min-w-0 max-w-[min(420px,40vw)] items-center rounded-md border border-border/50 bg-muted/35 px-2 py-1 text-xs text-muted-foreground sm:flex"
title={taskSourceContextSummary.title}
>
<span className="truncate">{taskSourceContextSummary.label}</span>
</div>
</div>
{taskSource === 'linear' && linearConnected ? (
<div className="flex items-center gap-2">
<LinearScopeSelector
workspaces={linearWorkspaces}
selectedWorkspaceId={selectedLinearWorkspaceId}
teams={linearTeamOptions}
selectedTeamIds={linearTeamSelection}
teamSelectionIsStickyAll={defaultLinearTeamSelection == null}
onWorkspaceChange={handleLinearWorkspaceChange}
onTeamSelectionChange={handleLinearTeamSelectionChange}
onAddTeamAccess={() => setLinearConnectOpen(true)}
onOpen={handleLinearScopeOpen}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={() => {
if (!selectedLinearTeamForExternalLink?.url) {
return
}
void window.api.shell.openUrl(selectedLinearTeamForExternalLink.url)
}}
disabled={!selectedLinearTeamForExternalLink}
aria-label={
selectedLinearTeamForExternalLink
? translate(
'auto.components.TaskPage.246bd64aed',
'Open {{value0}} in Linear',
{
value0: selectedLinearTeamForExternalLink.name
}
)
: translate(
'auto.components.TaskPage.8029e2bd4d',
'Select one Linear team to open in Linear'
)
}
className="h-8 w-8 rounded-md border-border/50 bg-muted/50 text-foreground shadow-sm transition hover:bg-muted/50"
>
<ExternalLink className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{selectedLinearTeamForExternalLink
? translate('auto.components.TaskPage.246bd64aed', 'Open {{value0}} in Linear', {
value0: selectedLinearTeamForExternalLink.name
})
: translate(
'auto.components.TaskPage.2af3ab5c58',
'Select one team to open in Linear'
)}
</TooltipContent>
</Tooltip>
</div>
) : null}
{taskSource === 'jira' && jiraConnected ? (
<div className="flex items-center gap-2">
{jiraSites.length > 1 ? (
<Select
value={selectedJiraSiteId ?? undefined}
onValueChange={(value) => {
setSelectedJiraIssueKey(null)
setSelectedJiraIssueFallback(null)
setJiraIssues([])
setJiraError(null)
setJiraLoading(true)
void selectJiraSite(value).catch(() => {
toast.error(
translate('auto.components.TaskPage.d09b7631b7', 'Failed to switch Jira site.')
)
})
}}
>
<SelectTrigger className="h-8 w-[220px] rounded-md border-border/50 bg-muted/50 text-xs font-medium shadow-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{translate('auto.components.TaskPage.e592d99051', 'All Jira sites')}
</SelectItem>
{jiraSites.map((site) => (
<SelectItem key={site.id} value={site.id}>
{site.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,34 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { TaskPageFrame } from './TaskPageFrame'
import { TaskPageGitHubIssueDialog } from './TaskPageGitHubIssueDialog'
import { TaskPageLinearProjectDialog } from './TaskPageLinearProjectDialog'
import { TaskPageLinearIssueDialog } from './TaskPageLinearIssueDialog'
import { TaskPageJiraIssueDialog } from './TaskPageJiraIssueDialog'
import { TaskPageGitLabDialog } from './TaskPageGitLabDialog'
import { TaskPageLinearConnectDialog } from './TaskPageLinearConnectDialog'
import { TaskPageJiraConnectDialog } from './TaskPageJiraConnectDialog'
export function TaskPageSurface({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element {
return (
<div className="relative flex h-full min-h-0 flex-1 overflow-hidden bg-background text-foreground">
<TaskPageFrame model={model} />
<TaskPageGitHubIssueDialog model={model} />
<TaskPageLinearProjectDialog model={model} />
<TaskPageLinearIssueDialog model={model} />
<TaskPageJiraIssueDialog model={model} />
<TaskPageGitLabDialog model={model} />
<TaskPageLinearConnectDialog model={model} />
<TaskPageJiraConnectDialog model={model} />
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
import BrowserPane from './browser-pane/BrowserPane'
import type { TerminalController } from './use-terminal-controller'
export function TerminalLegacyBrowserPanes({
controller
}: {
controller: TerminalController
}): React.JSX.Element {
const {
activeBrowserTabId,
activeTabType,
activeView,
browserTabsByWorktree,
renderedActiveWorktreeId,
workspaceSurfaces
} = controller
return (
<div
className={`relative flex-1 min-h-0 overflow-hidden ${
activeTabType !== 'browser' ? 'hidden' : ''
}`}
>
{workspaceSurfaces.map((workspace) => {
const browserTabs = browserTabsByWorktree[workspace.id] ?? []
const isVisibleWorktree =
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
if (browserTabs.length === 0) {
return null
}
return (
<div
key={`browser-${workspace.id}`}
className={isVisibleWorktree ? 'absolute inset-0' : 'absolute inset-0 hidden'}
aria-hidden={!isVisibleWorktree}
>
{browserTabs.map((browserTab) => {
const isBrowserActive =
isVisibleWorktree &&
activeTabType === 'browser' &&
browserTab.id === activeBrowserTabId
return (
<div
key={browserTab.id}
className={`absolute inset-0${
isBrowserActive ? '' : ' pointer-events-none hidden'
}`}
>
{isBrowserActive ? (
<BrowserPane browserTab={browserTab} isActive={isBrowserActive} />
) : null}
</div>
)
})}
</div>
)
})}
</div>
)
}
@@ -0,0 +1,28 @@
import { Suspense } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { translate } from '@/i18n/i18n'
import type { TerminalController } from './use-terminal-controller'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
export function TerminalLegacyEditorSurface({
controller
}: {
controller: TerminalController
}): React.JSX.Element | null {
const { activeTabType, renderedActiveWorktreeId, worktreeFiles } = controller
if (!renderedActiveWorktreeId || activeTabType !== 'editor' || worktreeFiles.length === 0) {
return null
}
return (
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
{translate('auto.components.Terminal.5c1d2a32bb', 'Loading editor...')}
</div>
}
>
<EditorPanel />
</Suspense>
)
}
@@ -0,0 +1,112 @@
import { createPortal } from 'react-dom'
import TerminalPane from './terminal-pane/TerminalPane'
import { findActivityTerminalPortal } from './activity/activity-terminal-portal'
import { shouldMountBackgroundWorktreeTab } from './terminal/background-terminal-worktree-mount'
import type { TerminalController } from './use-terminal-controller'
export function TerminalLegacyTerminalPanes({
controller
}: {
controller: TerminalController
}): React.JSX.Element {
const {
activeTabId,
activeTabType,
activeView,
activityTerminalPortals,
backgroundMountTabIdsByWorktreeRef,
effectiveParkedTerminalWorktreeIds,
evictionExemptTerminalTabIds,
handleCloseTab,
handlePtyExit,
measurableBackgroundWorktreeIdsRef,
mountedWorktreeIdsRef,
renderedActiveWorktreeId,
tabsByWorktree,
worktreeBrowserTabs,
worktreeFiles,
workspaceSurfaces
} = controller
return (
<div
className={`relative flex-1 min-h-0 overflow-hidden ${
(activeTabType === 'editor' && worktreeFiles.length > 0) ||
(activeTabType === 'browser' && worktreeBrowserTabs.length > 0) ||
activeTabType === 'simulator'
? 'hidden'
: ''
}`}
>
{workspaceSurfaces
.filter((workspace) => mountedWorktreeIdsRef.current.has(workspace.id))
.map((workspace) => {
const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
const shouldColdParkTerminalPanes =
!isVisible &&
!shouldMeasureHiddenWorktree &&
effectiveParkedTerminalWorktreeIds.has(workspace.id)
return (
<div
key={workspace.id}
className={
isVisible
? 'absolute inset-0'
: shouldMeasureHiddenWorktree
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'absolute inset-0 hidden'
}
aria-hidden={!isVisible}
>
{(tabsByWorktree[workspace.id] ?? [])
.filter((tab) =>
shouldMountBackgroundWorktreeTab(
backgroundMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null,
tab.id
)
)
.map((tab) => {
const activityTerminalPortal = findActivityTerminalPortal(
activityTerminalPortals,
{ worktreeId: workspace.id, tabId: tab.id }
)
const isActivityPortalTab = activityTerminalPortal !== null
const isActiveTerminalTab =
isVisible && tab.id === activeTabId && activeTabType === 'terminal'
if (
shouldColdParkTerminalPanes &&
!isActivityPortalTab &&
!evictionExemptTerminalTabIds.has(tab.id)
) {
return null
}
const terminalPane = (
<TerminalPane
key={`${tab.id}-${tab.generation ?? 0}`}
tabId={tab.id}
worktreeId={workspace.id}
cwd={tab.startupCwd ?? workspace.path}
isActive={isActiveTerminalTab || activityTerminalPortal?.active === true}
isVisible={isActiveTerminalTab || isActivityPortalTab}
isWorktreeActive={isVisible || isActivityPortalTab}
isolatedPaneKey={activityTerminalPortal?.paneKey ?? null}
onPtyExit={(ptyId, exitCode) => handlePtyExit(tab.id, ptyId, exitCode)}
onCloseTab={() => handleCloseTab(tab.id)}
/>
)
if (activityTerminalPortal) {
return createPortal(
terminalPane,
activityTerminalPortal.target,
`activity-terminal-${tab.id}`
)
}
return terminalPane
})}
</div>
)
})}
</div>
)
}
@@ -0,0 +1,21 @@
import { TerminalLegacyTerminalPanes } from './TerminalLegacyTerminalPanes'
import { TerminalLegacyBrowserPanes } from './TerminalLegacyBrowserPanes'
import { TerminalLegacyEditorSurface } from './TerminalLegacyEditorSurface'
import type { TerminalController } from './use-terminal-controller'
export function TerminalLegacyWorkspaceSurface({
controller
}: {
controller: TerminalController
}): React.JSX.Element | null {
if (controller.effectiveActiveLayout || controller.anyMountedWorktreeHasLayout) {
return null
}
return (
<>
<TerminalLegacyTerminalPanes controller={controller} />
<TerminalLegacyBrowserPanes controller={controller} />
<TerminalLegacyEditorSurface controller={controller} />
</>
)
}
@@ -0,0 +1,69 @@
import { WorktreeSplitSurface } from './TerminalWorktreeSplitSurface'
import type { TerminalController } from './use-terminal-controller'
export function TerminalSplitWorkspaceSurfaces({
controller
}: {
controller: TerminalController
}): React.JSX.Element | null {
const {
activationDeferredMountTabIdsByWorktreeRef,
activeGroupIdByWorktree,
activeView,
activityTerminalPortals,
anyMountedWorktreeHasLayout,
backgroundMountTabIdsByWorktreeRef,
effectiveActiveLayout,
effectiveParkedTerminalWorktreeIds,
forceParkedTerminalWorktreeIds,
getEffectiveLayoutForWorktree,
measurableBackgroundWorktreeIdsRef,
mountedWorktreeIdsRef,
renderedActiveWorktreeId,
workspaceSurfaces
} = controller
if (!anyMountedWorktreeHasLayout) {
return null
}
return (
<div
className={`relative flex flex-1 min-w-0 min-h-0 overflow-hidden${effectiveActiveLayout ? '' : ' hidden'}`}
>
{workspaceSurfaces
.filter((workspace) => mountedWorktreeIdsRef.current.has(workspace.id))
.map((workspace) => {
const layout = getEffectiveLayoutForWorktree(workspace.id)
if (!layout) {
return null
}
const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
const shouldColdParkTerminalPanes =
!isVisible &&
!shouldMeasureHiddenWorktree &&
effectiveParkedTerminalWorktreeIds.has(workspace.id)
return (
<WorktreeSplitSurface
key={`tab-groups-${workspace.id}`}
worktreeId={workspace.id}
worktreePath={workspace.path}
layout={layout}
focusedGroupId={activeGroupIdByWorktree[workspace.id]}
isVisible={isVisible}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
shouldColdParkTerminalPanes={shouldColdParkTerminalPanes}
isForceParked={forceParkedTerminalWorktreeIds.has(workspace.id)}
activityTerminalPortals={activityTerminalPortals}
backgroundMountTabIds={
backgroundMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null
}
activationDeferredMountTabIds={
activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null
}
/>
)
})}
</div>
)
}
@@ -0,0 +1,28 @@
import EditorAutosaveController from './editor/EditorAutosaveController'
import { TerminalTitlebarTabs } from './TerminalTitlebarTabs'
import { TerminalSplitWorkspaceSurfaces } from './TerminalSplitWorkspaceSurfaces'
import { TerminalLegacyWorkspaceSurface } from './TerminalLegacyWorkspaceSurface'
import { TerminalWorkspaceDialogs } from './TerminalWorkspaceDialogs'
import type { TerminalController } from './use-terminal-controller'
export function TerminalSurface({
controller
}: {
controller: TerminalController
}): React.JSX.Element {
const { renderedActiveWorktreeId } = controller
return (
<div
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
renderedActiveWorktreeId ? '' : ' hidden'
}`}
data-rendered-active-worktree-id={renderedActiveWorktreeId ?? undefined}
>
<EditorAutosaveController />
<TerminalTitlebarTabs controller={controller} />
<TerminalSplitWorkspaceSurfaces controller={controller} />
<TerminalLegacyWorkspaceSurface controller={controller} />
<TerminalWorkspaceDialogs controller={controller} />
</div>
)
}
@@ -0,0 +1,105 @@
import { createPortal } from 'react-dom'
import { useAppStore } from '../store'
import TabBar from './tab-bar/TabBar'
import type { TerminalController } from './use-terminal-controller'
export function TerminalTitlebarTabs({
controller
}: {
controller: TerminalController
}): React.JSX.Element | null {
const {
activeBrowserTabId,
activeFileId,
activeTabId,
activeTabType,
effectiveActiveLayout,
expandedPaneByTabId,
handleActivateBrowserTab,
handleActivateTab,
handleCloseAllFiles,
handleCloseBrowserTab,
handleCloseFile,
handleCloseOthers,
handleCloseTab,
handleCloseTabsToLeft,
handleCloseTabsToRight,
handleDuplicateBrowserTab,
handleNewBrowserTab,
handleNewFile,
handleNewSimulatorTab,
handleNewTab,
handleOpenEntry,
handleTogglePaneExpand,
makePreviewFilePermanent,
mobileEmulatorEnabled,
pinFile,
renderedActiveWorktreeId,
setActiveFile,
setActiveTab,
setActiveTabType,
setTabColor,
setTabCustomTitle,
tabBarOrder,
tabs,
titlebarTabsTarget,
worktreeBrowserTabs,
worktreeFiles
} = controller
if (!renderedActiveWorktreeId || effectiveActiveLayout || !titlebarTabsTarget) {
return null
}
return createPortal(
<TabBar
tabs={tabs}
activeTabId={activeTabId}
worktreeId={renderedActiveWorktreeId}
onActivate={handleActivateTab}
onClose={handleCloseTab}
onCloseOthers={handleCloseOthers}
onCloseToRight={handleCloseTabsToRight}
onCloseToLeft={handleCloseTabsToLeft}
onNewTerminalTab={() => handleNewTab()}
onNewTerminalWithShell={handleNewTab}
onNewBrowserTab={handleNewBrowserTab}
onNewSimulatorTab={mobileEmulatorEnabled ? handleNewSimulatorTab : undefined}
onOpenEntry={handleOpenEntry}
onNewFileTab={handleNewFile}
onSetCustomTitle={setTabCustomTitle}
onSetTabColor={setTabColor}
expandedPaneByTabId={expandedPaneByTabId}
onTogglePaneExpand={handleTogglePaneExpand}
editorFiles={worktreeFiles}
browserTabs={worktreeBrowserTabs}
activeFileId={activeFileId}
activeBrowserTabId={activeBrowserTabId}
activeSimulatorTabId={
activeTabType === 'simulator' && renderedActiveWorktreeId
? (useAppStore.getState().getActiveTab(renderedActiveWorktreeId)?.id ?? null)
: null
}
activeTabType={activeTabType}
onActivateFile={(fileId) => {
const unifiedTabs =
useAppStore.getState().unifiedTabsByWorktree[renderedActiveWorktreeId ?? ''] ?? []
const unifiedTab = unifiedTabs.find((tab) => tab.id === fileId)
if (unifiedTab?.contentType === 'simulator') {
setActiveTab(fileId)
setActiveTabType('simulator')
return
}
setActiveFile(fileId)
setActiveTabType('editor')
}}
onCloseFile={handleCloseFile}
onActivateBrowserTab={handleActivateBrowserTab}
onCloseBrowserTab={handleCloseBrowserTab}
onDuplicateBrowserTab={handleDuplicateBrowserTab}
onCloseAllFiles={handleCloseAllFiles}
onMakePreviewFilePermanent={makePreviewFilePermanent}
onPinFile={pinFile}
tabBarOrder={tabBarOrder}
/>,
titlebarTabsTarget
)
}
@@ -0,0 +1,117 @@
import { basename } from '../lib/path'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import type { TerminalController } from './use-terminal-controller'
export function TerminalWorkspaceDialogs({
controller
}: {
controller: TerminalController
}): React.JSX.Element {
const {
confirmNativeWindowClose,
handleSaveDialogCancel,
handleSaveDialogDiscard,
handleSaveDialogSave,
saveDialogFile,
saveDialogFileId,
setWindowCloseDialogOpen,
windowCloseDialogOpen
} = controller
return (
<>
<Dialog
open={saveDialogFileId !== null}
onOpenChange={(open) => {
if (!open) {
handleSaveDialogCancel()
}
}}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-sm">
{translate('auto.components.Terminal.21295c6b8c', 'Unsaved Changes')}
</DialogTitle>
<DialogDescription className="text-xs">
{saveDialogFile
? translate(
'auto.components.Terminal.61ed600d29',
'"{{value0}}" has unsaved changes. Do you want to save before closing?',
{ value0: basename(saveDialogFile.relativePath) }
)
: translate(
'auto.components.Terminal.46e08bc5c8',
'This file has unsaved changes.'
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" size="sm" onClick={handleSaveDialogCancel}>
{translate('auto.components.Terminal.f82e9f02df', 'Cancel')}
</Button>
<Button type="button" variant="outline" size="sm" onClick={handleSaveDialogDiscard}>
{translate('auto.components.Terminal.0037b21794', "Don't Save")}
</Button>
<Button type="button" size="sm" onClick={handleSaveDialogSave}>
{translate('auto.components.Terminal.cd51e28d8b', 'Save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={windowCloseDialogOpen}
onOpenChange={(open) => {
if (!open) {
setWindowCloseDialogOpen(false)
}
}}
>
<DialogContent className="max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">
{translate('auto.components.Terminal.2fa9c69ff3', 'Close Window?')}
</DialogTitle>
<DialogDescription className="text-xs">
{translate(
'auto.components.Terminal.7958465754',
'There are local terminals with running processes. Close the window anyway?'
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setWindowCloseDialogOpen(false)}
>
{translate('auto.components.Terminal.f82e9f02df', 'Cancel')}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
autoFocus
onClick={() => {
setWindowCloseDialogOpen(false)
confirmNativeWindowClose()
}}
>
{translate('auto.components.Terminal.73768427cf', 'Close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,96 @@
import React from 'react'
import { useShallow } from 'zustand/react/shallow'
import type { TabGroupLayoutNode } from '../../../shared/tab-types'
import { useAppStore } from '../store'
import type { ActivityTerminalPortalTarget } from './activity/activity-terminal-portal'
import { useBrowserAutomationVisibilityForAny } from './browser-pane/browser-automation-visibility'
import { useBrowserMobileDriverForAny } from '@/lib/pane-manager/browser-mobile-driver-state'
import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout'
import TerminalPaneOverlayLayer from './terminal-pane/TerminalPaneOverlayLayer'
import { RetainedBrowserPaneOverlayLayer } from './browser-pane/BrowserPaneOverlayLayer'
import EmulatorPaneOverlayLayer from './emulator-pane/EmulatorPaneOverlayLayer'
import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer'
export const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
worktreeId,
worktreePath,
layout,
focusedGroupId,
isVisible,
shouldMeasureHiddenWorktree,
shouldColdParkTerminalPanes,
isForceParked,
activityTerminalPortals,
backgroundMountTabIds,
activationDeferredMountTabIds
}: {
worktreeId: string
worktreePath: string
layout: TabGroupLayoutNode
focusedGroupId?: string
isVisible: boolean
shouldMeasureHiddenWorktree: boolean
shouldColdParkTerminalPanes: boolean
isForceParked: boolean
activityTerminalPortals: ActivityTerminalPortalTarget[]
backgroundMountTabIds: ReadonlySet<string> | null
activationDeferredMountTabIds: ReadonlySet<string> | null
}): React.JSX.Element {
const browserPageIds = useAppStore(
useShallow((state) =>
(state.browserTabsByWorktree[worktreeId] ?? []).flatMap((tab) =>
tab.pageIds && tab.pageIds.length > 0 ? tab.pageIds : [tab.activePageId ?? tab.id]
)
)
)
const hasAutomationVisibleBrowser = useBrowserAutomationVisibilityForAny(browserPageIds)
const hasMobileDrivenBrowser = useBrowserMobileDriverForAny(browserPageIds)
const shouldKeepPaintable =
shouldMeasureHiddenWorktree || hasAutomationVisibleBrowser || hasMobileDrivenBrowser
return (
<div
className={
isVisible
? 'absolute inset-0 flex'
: shouldKeepPaintable
? 'absolute inset-0 flex opacity-0 pointer-events-none'
: 'absolute inset-0 hidden'
}
inert={!isVisible}
aria-hidden={!isVisible}
>
<TabGroupSplitLayout
layout={layout}
worktreeId={worktreeId}
focusedGroupId={focusedGroupId}
isWorktreeActive={isVisible}
/>
<TerminalPaneOverlayLayer
worktreeId={worktreeId}
worktreePath={worktreePath}
isWorktreeActive={isVisible}
coldParkTerminalPanes={shouldColdParkTerminalPanes}
isForceParked={isForceParked}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
activityTerminalPortals={activityTerminalPortals}
backgroundMountTabIds={backgroundMountTabIds}
activationDeferredMountTabIds={activationDeferredMountTabIds}
/>
<RetainedBrowserPaneOverlayLayer
worktreeId={worktreeId}
isWorktreeActive={isVisible}
mountEligible={
isVisible ||
backgroundMountTabIds === null ||
hasAutomationVisibleBrowser ||
hasMobileDrivenBrowser
}
/>
{isVisible || backgroundMountTabIds === null ? (
<EmulatorPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
) : null}
<AiVaultSessionDropLayer worktreeId={worktreeId} enabled={isVisible} />
</div>
)
})
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type {
Automation,
AutomationRun,
ExternalAutomationAction,
ExternalAutomationJob,
@@ -54,6 +55,9 @@ const EMPTY_AUTOMATION_RUNS: ReadonlyMap<string, AutomationRun> = new Map()
type AutomationsListPanelProps = {
hasListItems: boolean
hasFilteredListItems: boolean
/** Compatibility projections for consumers that still inspect bare rows. */
isListSearchActive?: boolean
filteredAutomations?: readonly Automation[]
listSearchQuery: string
isListSearchQueryTooLarge: boolean
onListSearchQueryChange: (query: string) => void
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,381 @@
import React, { useMemo } from 'react'
import { translate } from '@/i18n/i18n'
import { AutomationDeleteDialog, ExternalAutomationDeleteDialog } from './AutomationDeleteDialogs'
import { AutomationEditorDialog } from './AutomationEditorDialog'
import { AutomationOwnerConflictNotice } from './AutomationOwnerConflictNotice'
import { AutomationsDetailPane } from './AutomationsDetailPane'
import { AutomationsListPanel } from './AutomationsListPanel'
import { AutomationsPageSkeleton } from './AutomationsPageSkeleton'
import { getAutomationAuthorityTarget } from './automation-host-client'
import type { AutomationListRow } from './automation-list-row-identity'
import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors'
import type { AutomationsPageController } from './use-automations-page-controller'
/** Renders the page from controller state; host/query side effects stay in hooks. */
export function AutomationsPageSurface({
controller
}: {
controller: AutomationsPageController
}): React.JSX.Element {
const {
store,
local,
list,
destination,
destinationForm,
setup,
runPage,
sourceAvailability,
presentation,
pageRefresh,
draftEffects,
editorActions,
saveAutomation,
managementActions,
runActions,
externalActions,
openRunWorkspace
} = controller
const {
projectHostSetups,
repoMap,
worktreeMap,
settings,
sshConnectionStates,
runtimeStatusByEnvironmentId,
repoForRow,
worktreeForRow
} = store
const {
createOpen,
editingAutomationId,
isSaving,
editingExternalTarget,
createTarget,
automationYamlHooksByRepoKey,
draft,
setCreateOpen,
deleteTarget,
dontAskDeleteAgain,
deleteConfirmButtonRef,
setDeleteTarget,
setDontAskDeleteAgain,
externalDeleteTarget,
externalDeleteConfirmButtonRef,
setExternalDeleteTarget,
listSearchQuery,
setListSearchQuery,
listFilter,
setListFilter,
relativeNow,
externalActionKey,
activePaneTab,
setActivePaneTab,
selectedExternalRunPage,
setSelectedExternalRunPage,
setSelectedAutomationRunPageId,
setSelectedAutomationRuns,
setRunHistoryReloadToken,
isDetailOpen,
setIsDetailOpen,
ownerAction,
setOwnerAction,
editorNotice,
setEditorNotice,
editorNoticeHost,
setEditorNoticeHost,
isLoading
} = local
const {
hostCatalog,
hasListItems,
hasFilteredListItems,
isListSearchActive,
isListSearchQueryTooLarge,
filteredRows,
filteredAutomations,
filteredExternalAutomationEntries,
selected,
selectedRow,
selectedExternal,
searchCounts
} = list
const selectedRunWorktreeMap = useMemo(() => {
if (!selectedRow) {
return worktreeMap
}
const repo = repoForRow(selectedRow)
return new Map(
setup.selectedRuns.flatMap((run) => {
const worktree = worktreeForRow(selectedRow, repo, run.workspaceId)
return worktree ? [[worktree.id, worktree] as const] : []
})
)
}, [repoForRow, selectedRow, setup.selectedRuns, worktreeForRow, worktreeMap])
const runSelectedRowAction = (action: (row: AutomationListRow) => void): void => {
if (selectedRow) {
action(selectedRow)
}
}
const recoverOwnerAction = (
action: AutomationHostRecoveryAction,
host = ownerAction?.host ?? null
): void => {
setOwnerAction(null)
hostCatalog.recover(action, host)
if (action === 'retry') {
void pageRefresh.refresh()
}
}
const onListFilterChange = (next: typeof listFilter): void => {
setListFilter(next)
if ((next.hostStableKeys?.length ?? 0) > 0 && hostCatalog.resolution.effective.kind !== 'all') {
// Host narrowing now lives in the Filters menu; clear the old single-host scope.
hostCatalog.selectHost({ kind: 'all' })
}
}
return (
<main className="relative flex h-full min-h-0 flex-col bg-background pt-5 text-foreground md:pt-6">
<header
className="flex shrink-0 items-center px-3 pb-3 md:px-5"
style={{ paddingRight: 'max(0.75rem, var(--window-controls-width, 0px))' }}
>
<h1 className="truncate text-base font-semibold leading-8">
{translate('auto.components.automations.AutomationsPage.77c2778945', 'Automations')}
</h1>
</header>
<AutomationOwnerConflictNotice
notice={ownerAction?.notice ?? null}
className="mx-4 mb-2"
onRecover={(action) => recoverOwnerAction(action)}
onDismiss={() => setOwnerAction(null)}
/>
<AutomationEditorDialog
open={createOpen}
isEditing={editingAutomationId !== null}
isSaving={isSaving}
canSave={presentation.canSaveDraft}
isEditingExternal={editingExternalTarget !== null}
createTarget={createTarget}
createDestination={destination.createDestination.control}
editDestination={
destinationForm.isOrcaForm ? destinationForm.editDestinationControl : undefined
}
notice={editorNotice}
onNoticeRecover={(action) => {
const host = editorNoticeHost ?? destination.editorRecoveryHost
setEditorNotice(null)
setEditorNoticeHost(null)
hostCatalog.recover(action, host)
if (action === 'retry') {
void pageRefresh.refresh()
}
}}
onNoticeDismiss={() => {
setEditorNotice(null)
setEditorNoticeHost(null)
}}
repos={destinationForm.dialogRepos}
projectHostSetups={projectHostSetups}
automationYamlHooksByRepoKey={automationYamlHooksByRepoKey}
getAutomationHooksCacheKey={setup.getAutomationHooksCacheKey}
repoMap={repoMap}
worktrees={destinationForm.dialogWorktrees}
settings={settings}
draft={draft}
onProjectChange={editorActions.handleProjectChange}
getRepoHostLabel={presentation.getAutomationRepoHostLabel}
allowAddProject={
!destinationForm.isOrcaForm ||
(editingAutomationId !== null
? destinationForm.editHostResolution.status === 'ready'
? getAutomationAuthorityTarget(destinationForm.editHostResolution.authority).kind ===
'local'
: destinationForm.automationDialogTarget.kind === 'local'
: destinationForm.automationDialogTarget.kind === 'local')
}
onCreateTargetChange={draftEffects.handleCreateTargetChange}
onOpenChange={(open) => {
setCreateOpen(open)
if (!open) {
setEditorNotice(null)
setEditorNoticeHost(null)
}
}}
onDraftChange={editorActions.handleDraftChange}
onSetupDecisionTouched={setup.markSetupDecisionTouched}
onApplyTemplate={draftEffects.applyTemplateToDraft}
onSave={() => void saveAutomation()}
/>
<AutomationDeleteDialog
deleteTarget={deleteTarget?.automation ?? null}
dontAskDeleteAgain={dontAskDeleteAgain}
confirmButtonRef={deleteConfirmButtonRef}
onOpenChange={(open) => {
if (!open) {
setDeleteTarget(null)
setDontAskDeleteAgain(false)
}
}}
onDontAskAgainToggle={() => setDontAskDeleteAgain((previous) => !previous)}
onCancel={() => {
setDeleteTarget(null)
setDontAskDeleteAgain(false)
}}
onConfirm={() => void managementActions.confirmDeleteAutomation()}
/>
<ExternalAutomationDeleteDialog
externalDeleteTarget={externalDeleteTarget}
confirmButtonRef={externalDeleteConfirmButtonRef}
onOpenChange={(open) => {
if (!open) {
setExternalDeleteTarget(null)
}
}}
onCancel={() => setExternalDeleteTarget(null)}
onConfirm={() => void externalActions.confirmDeleteExternalAutomation()}
/>
{isLoading && !hasListItems ? (
<AutomationsPageSkeleton />
) : isDetailOpen && (selected || selectedExternal) ? (
<AutomationsDetailPane
selected={selected}
selectedExternal={selectedExternal}
selectedExternalRunPage={selectedExternalRunPage}
selectedAutomationRunPage={setup.selectedAutomationRunPage}
selectedRuns={setup.selectedRuns}
selectedRunsNotice={setup.selectedRunsNotice}
selectedHostEntry={destination.rowRecoveryHost(selectedRow?.key ?? null)}
recoverSelectedRuns={(action) => {
hostCatalog.recover(action, destination.rowRecoveryHost(selectedRow?.key ?? null))
setSelectedAutomationRuns((current) => ({ ...current, notice: null }))
setRunHistoryReloadToken((token) => token + 1)
}}
activePaneTab={activePaneTab}
relativeNow={relativeNow}
externalActionKey={externalActionKey}
selectedRepoDisplayName={
presentation.selectedRepo?.displayName ??
translate('auto.components.automations.AutomationsPage.13118faadf', 'Unknown project')
}
selectedRepoDefaultBaseRef={presentation.selectedRepo?.worktreeBaseRef ?? null}
selectedWorkspaceName={
selected?.workspaceMode === 'new_per_run'
? translate(
'auto.components.automations.AutomationsPage.cd8397cc32',
'New workspace each run'
)
: (presentation.selectedWorktree?.displayName ??
translate(
'auto.components.automations.AutomationsPage.missingWorkspace',
'Missing workspace'
))
}
hostLabelById={presentation.hostLabelById}
selectedRunNowAvailability={presentation.selectedRunNowAvailability}
selectedAutomationRunPageWorkspaceDisplay={
runPage.selectedAutomationRunPageWorkspaceDisplay
}
selectedAutomationRunPageViewState={runPage.selectedAutomationRunPageViewState}
canRerunSelectedAutomationRunPage={runPage.canRerunSelectedAutomationRunPage}
isSelectedAutomationRunPageRerunPending={runPage.isSelectedAutomationRunPageRerunPending}
worktreeMap={selectedRunWorktreeMap}
fetchExternalAutomationRuns={externalActions.fetchExternalAutomationRuns}
onActivePaneTabChange={setActivePaneTab}
onClearExternalRunPage={() => setSelectedExternalRunPage(null)}
onClearAutomationRunPage={() => setSelectedAutomationRunPageId(null)}
requestExternalAction={externalActions.requestExternalAction}
openExternalRunPage={externalActions.openExternalRunPage}
openEditExternalDialog={editorActions.openEditExternalDialog}
runNow={() => runSelectedRowAction(runActions.runNow)}
openEditDialog={() => runSelectedRowAction(editorActions.openEditDialog)}
toggleAutomation={() => runSelectedRowAction(managementActions.toggleAutomation)}
requestDeleteAutomation={() =>
runSelectedRowAction(managementActions.requestDeleteAutomation)
}
rerunAutomationRun={(_automation, run) =>
runSelectedRowAction((row) => runActions.rerunAutomationRun(row, run))
}
openRunWorkspace={openRunWorkspace}
openAutomationRunPage={externalActions.openAutomationRunPage}
onBackToList={() => {
setIsDetailOpen(false)
setSelectedAutomationRunPageId(null)
setSelectedExternalRunPage(null)
setActivePaneTab('overview')
}}
/>
) : (
<AutomationsListPanel
hasListItems={hasListItems}
hasFilteredListItems={hasFilteredListItems}
listSearchQuery={listSearchQuery}
isListSearchQueryTooLarge={isListSearchQueryTooLarge}
isListSearchActive={isListSearchActive}
onListSearchQueryChange={setListSearchQuery}
listFilter={listFilter}
onListFilterChange={onListFilterChange}
searchCounts={{
...searchCounts,
hostRowCount: list.visibleRows.length + list.externalAutomationEntries.length
}}
hostCatalog={hostCatalog}
externalManagersUncheckedNotice={list.externalManagersUncheckedNotice}
onSelectHost={hostCatalog.selectHost}
onRecoverHost={(action, entry) => {
hostCatalog.recover(action, entry)
if (action === 'retry') {
void pageRefresh.refresh()
}
}}
filteredRows={filteredRows}
filteredAutomations={filteredAutomations}
filteredExternalAutomationEntries={filteredExternalAutomationEntries}
selectedRowKey={selectedRow?.key ?? null}
selectedExternalKey={local.selectedExternalKey}
selectedExternal={selectedExternal}
relativeNow={relativeNow}
repoMap={repoMap}
worktreeMap={worktreeMap}
repoForRow={repoForRow}
worktreeForRow={worktreeForRow}
projectHostSetups={projectHostSetups}
sshConnectionStates={sshConnectionStates}
runtimeStatusByEnvironmentId={runtimeStatusByEnvironmentId}
hostTargetFor={destination.automationHostTargetFor}
automationSourceHostAvailabilityByRowKey={
sourceAvailability.automationSourceHostAvailabilityByRowKey
}
hostLabelById={presentation.hostLabelById}
isActionEnabled={destination.isAutomationRowActionEnabled}
externalActionKey={externalActionKey}
selectAutomationRow={list.selectAutomationRow}
selectExternalKey={local.selectExternalKey}
setActivePaneTab={setActivePaneTab}
runNow={(row) => void runActions.runNow(row)}
openEditDialog={(row) => void editorActions.openEditDialog(row)}
toggleAutomation={(row) => void managementActions.toggleAutomation(row)}
requestDeleteAutomation={managementActions.requestDeleteAutomation}
requestExternalAction={externalActions.requestExternalAction}
openEditExternalDialog={editorActions.openEditExternalDialog}
openCreateDialog={editorActions.openCreateDialog}
canCreateAutomation={destination.canCreateAutomation}
onOpenDetail={() => setIsDetailOpen(true)}
onRefresh={() => {
hostCatalog.refreshHosts()
void pageRefresh.refresh()
}}
isRefreshing={isLoading}
/>
)}
</main>
)
}
@@ -0,0 +1,89 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { buildHermesCronSchedule } from './automation-draft-model'
import { externalAutomationJobKey } from './external-automation-scope-keys'
import type { AutomationSaveContext } from './automation-save-context'
export async function saveHermesAutomation({
store,
local,
list,
pageRefresh
}: AutomationSaveContext): Promise<void> {
const { repoMap, worktreeMap } = store
const {
draft,
editingExternalTarget,
setCreateOpen,
setEditingExternalTarget,
selectExternalKey
} = local
const repo = repoMap.get(draft.projectId)
const selectedWorktree = worktreeMap.get(draft.workspaceId) ?? null
if (!repo || !selectedWorktree) {
toast.error(
translate(
'auto.components.automations.AutomationsPage.32534e7c9c',
'Choose an available workspace before saving.'
)
)
return
}
const scopedExternal = list.scopedExternal
const scope =
editingExternalTarget?.scope ?? scopedExternal.createScope(repo.connectionId ?? null)
const repoTargetMatches = scope
? scope.owner.selector.kind === 'ssh'
? repo.connectionId === scope.owner.selector.targetId
: !repo.connectionId
: false
if (!scope || !repoTargetMatches) {
toast.error(
translate(
'auto.components.automations.AutomationsPage.e431bb85d4',
'Choose a workspace on the same host as this Hermes automation.'
)
)
return
}
const schedule = buildHermesCronSchedule(draft)
const fields = {
name: draft.name,
prompt: draft.prompt,
schedule,
workdir: selectedWorktree.path
}
await scopedExternal.saveExternalAutomation(
scope,
{
name: fields.name,
prompt: fields.prompt,
schedule: fields.schedule,
workdir: fields.workdir
},
editingExternalTarget?.job.id ?? null
)
if (!editingExternalTarget) {
useAppStore.getState().recordFeatureInteraction('automation-created')
}
await pageRefresh.refresh()
setCreateOpen(false)
setEditingExternalTarget(null)
selectExternalKey(
editingExternalTarget
? externalAutomationJobKey(editingExternalTarget.scope, editingExternalTarget.job.id)
: null
)
toast.success(
editingExternalTarget
? translate(
'auto.components.automations.AutomationsPage.08efc3ae12',
'Hermes automation updated.'
)
: translate(
'auto.components.automations.AutomationsPage.77b81bc4ac',
'Hermes automation created.'
)
)
}
@@ -0,0 +1,102 @@
import { deleteAutomationForTarget, updateAutomationForTarget } from './automation-host-client'
import {
dispatchAutomationDelete,
dispatchAutomationUpdate
} from './automation-row-action-dispatch'
import { persistSkipDeleteAutomationConfirm } from './automation-delete-confirm-preference'
import type { AutomationListRow } from './automation-list-row-identity'
import type { AutomationsPageActionContext } from './automations-page-action-context'
/** Fenced toggle/delete handlers and the confirmation preference flow. */
export function createAutomationManagementActions({
store,
local,
destination,
pageRefresh
}: AutomationsPageActionContext) {
const { updateSettings, openSettingsPage, openSettingsTarget, settings } = store
const {
selectAutomationId,
selectedRowKey,
setSelectedAutomationRunPageId,
setActivePaneTab,
setIsDetailOpen,
setDontAskDeleteAgain,
setDeleteTarget,
deleteTarget,
dontAskDeleteAgain
} = local
const {
automationHostTargetFor,
automationDispatchContext,
reportOwnerAction,
invalidateRowHost
} = destination
const toggleAutomation = async (row: AutomationListRow): Promise<void> => {
const result = await dispatchAutomationUpdate(
automationDispatchContext,
{ rowKey: row.key, automationId: row.automation.id },
{ enabled: !row.automation.enabled },
() =>
updateAutomationForTarget(
row.automation,
{ enabled: !row.automation.enabled },
automationHostTargetFor(row)
)
)
reportOwnerAction(row.key, result.ok ? null : result.notice)
if (result.ok) {
invalidateRowHost(row.key, 'definition')
}
await pageRefresh.refresh()
}
const deleteAutomation = async (row: AutomationListRow): Promise<void> => {
const result = await dispatchAutomationDelete(
automationDispatchContext,
{ rowKey: row.key, automationId: row.automation.id },
() => deleteAutomationForTarget(row.automation, automationHostTargetFor(row))
)
reportOwnerAction(row.key, result.ok ? null : result.notice)
if (result.ok) {
if (selectedRowKey === row.key) {
selectAutomationId(null)
setIsDetailOpen(false)
setSelectedAutomationRunPageId(null)
setActivePaneTab('overview')
}
invalidateRowHost(row.key, 'definition')
}
await pageRefresh.refresh()
}
const persistDeleteAutomationPreference = (): void => {
persistSkipDeleteAutomationConfirm({ updateSettings, openSettingsPage, openSettingsTarget })
}
const requestDeleteAutomation = (row: AutomationListRow): void => {
if (settings?.skipDeleteAutomationConfirm) {
void deleteAutomation(row)
return
}
setDontAskDeleteAgain(false)
setDeleteTarget(row)
}
const confirmDeleteAutomation = async (): Promise<void> => {
if (!deleteTarget) {
return
}
if (dontAskDeleteAgain) {
persistDeleteAutomationPreference()
}
const target = deleteTarget
setDeleteTarget(null)
setDontAskDeleteAgain(false)
await deleteAutomation(target)
}
return { toggleAutomation, deleteAutomation, requestDeleteAutomation, confirmDeleteAutomation }
}
export type AutomationManagementActions = ReturnType<typeof createAutomationManagementActions>
@@ -0,0 +1,264 @@
import { toast } from 'sonner'
import type {
Automation,
AutomationCreateInput,
AutomationUpdateInput
} from '../../../../shared/automations-types'
import type {
AutomationAuthorityRef,
StableAutomationCatalogRef
} from '../../../../shared/automation-owner-ref'
import type { AutomationDestination } from '../../../../shared/automation-owner-precondition'
import { translate } from '@/i18n/i18n'
import {
deleteAutomationForTarget,
listAutomationsForTarget,
updateAutomationForTarget
} from './automation-host-client'
import { createAutomationAtDestination } from './automation-owner-action-runner'
import {
dispatchAutomationDelete,
dispatchAutomationReread,
dispatchAutomationUpdate,
toDispatchResult,
type AutomationDispatchContext,
type AutomationDispatchResult
} from './automation-row-action-dispatch'
import type { AutomationCreateDestination } from './automation-create-destination'
import type { AutomationHostTarget } from './automation-host-client'
import type { AutomationAuthorityChangeReason } from './automation-host-invalidation'
import type { AutomationActionNotice } from './automation-row-action-dispatch'
import type { AutomationSaveContext } from './automation-save-context'
import { revalidateAutomationCreateDestination } from './automation-create-destination'
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
export type AutomationMoveOperationContext = {
automationDispatchContext: AutomationDispatchContext
editingRowKey: string | null
automationDialogTarget: AutomationHostTarget
moveCreationKeysRef: { current: Map<string, string> }
invalidateWrittenHost: (
ref: StableAutomationCatalogRef,
reason: AutomationAuthorityChangeReason
) => void
}
export type AutomationEditDestinationInput = {
currentAutomation: Automation | null
editingAutomationId: string | null
editingDestination: {
projectId: string
destination: AutomationCreateDestination
} | null
draft: Pick<Automation, 'projectId' | 'workspaceId' | 'workspaceMode'> & {
projectId: string
workspaceId: string
}
rowKey: string | null
automationDialogTarget: AutomationHostTarget
editHostEntries: Parameters<typeof revalidateAutomationCreateDestination>[1]
rowRecoveryHost: (rowKey: string | null) => { stableKey: string } | null
}
export type AutomationEditDestinationResult =
| {
ok: true
editDestination: AutomationDestination | undefined
moveTarget: AutomationCreateDestination | null
}
| { ok: false; notice: AutomationActionNotice }
/** Validates an edited row's destination and identifies a cross-authority move. */
export function resolveAutomationEditDestination(
input: AutomationEditDestinationInput
): AutomationEditDestinationResult {
const destinationHostChanged =
input.editingDestination !== null &&
input.editingDestination.destination.entry.stableKey !==
input.rowRecoveryHost(input.rowKey)?.stableKey
const destinationChanged =
input.currentAutomation !== null &&
(destinationHostChanged ||
input.currentAutomation.projectId !== input.draft.projectId ||
input.currentAutomation.workspaceId !== (input.draft.workspaceId || null) ||
input.currentAutomation.workspaceMode !== input.draft.workspaceMode)
if (!input.editingAutomationId || !input.currentAutomation || !destinationChanged) {
return { ok: true, editDestination: undefined, moveTarget: null }
}
if (!input.editingDestination || input.editingDestination.projectId !== input.draft.projectId) {
return { ok: false, notice: unavailableDestinationNotice() }
}
const revalidated = revalidateAutomationCreateDestination(
input.editingDestination.destination,
input.editHostEntries
)
if (revalidated.status === 'stale') {
return {
ok: false,
notice: {
message: translate(
'auto.components.automations.createDestination.stale',
'{host} changed while this form was open. Choose the project again before saving.'
).replace('{host}', revalidated.entry.label),
recovery: 'retry',
severity: 'owner'
}
}
}
if (revalidated.status !== 'ready') {
return { ok: false, notice: unavailableDestinationNotice() }
}
const sourceAuthorityKey =
input.automationDialogTarget.kind === 'environment'
? automationAuthorityCatalogKey({
kind: 'runtime',
environmentId: input.automationDialogTarget.environmentId
})
: automationAuthorityCatalogKey({ kind: 'desktop' })
const targetAuthorityKey = automationAuthorityCatalogKey(
revalidated.authority.kind === 'runtime'
? { kind: 'runtime', environmentId: revalidated.authority.environmentId }
: { kind: 'desktop' }
)
return {
ok: true,
editDestination: revalidated.destination,
moveTarget: sourceAuthorityKey === targetAuthorityKey ? null : revalidated
}
}
function unavailableDestinationNotice(): AutomationActionNotice {
return {
message: translate(
'auto.components.automations.createDestination.unavailable',
'Choose an available project on this host before saving.'
),
recovery: 'retry',
severity: 'owner'
}
}
/** Creates a new copy first, then removes the source copy when moving hosts. */
export async function moveAutomationToDestination(
context: AutomationMoveOperationContext,
source: Automation | null,
target: AutomationCreateDestination,
input: AutomationCreateInput
): Promise<{ saved: AutomationDispatchResult<Automation>; originalRemoved: boolean }> {
if (!source) {
return {
saved: {
ok: false,
notice: {
message: translate(
'auto.components.automations.AutomationsPage.ownerChanged',
'This automation changed hosts. Refresh and try again.'
),
recovery: 'retry',
severity: 'owner'
}
},
originalRemoved: false
}
}
const operationKey = `${source.id}:${target.entry.stableKey}`
const creationKey = context.moveCreationKeysRef.current.get(operationKey) ?? crypto.randomUUID()
context.moveCreationKeysRef.current.set(operationKey, creationKey)
const created = toDispatchResult(
await createAutomationAtDestination(
target.authority,
{ ...input, creationKey },
target.destination
)
)
if (!created.ok) {
return { saved: created, originalRemoved: false }
}
context.invalidateWrittenHost(target.entry.stableRef, 'definition')
const removed = await dispatchAutomationDelete(
context.automationDispatchContext,
{ rowKey: context.editingRowKey ?? '', automationId: source.id },
() => deleteAutomationForTarget(source, context.automationDialogTarget)
)
if (removed.ok) {
context.moveCreationKeysRef.current.delete(operationKey)
return { saved: created, originalRemoved: true }
}
// A transport error is not proof that deletion failed; verify the source copy.
const reread = await dispatchAutomationReread(
context.automationDispatchContext,
{ rowKey: context.editingRowKey ?? '', automationId: source.id },
async () =>
(await listAutomationsForTarget(context.automationDialogTarget)).find(
(automation) => automation.id === source.id
) ?? null
)
if (reread.ok && reread.value === null) {
context.moveCreationKeysRef.current.delete(operationKey)
return { saved: created, originalRemoved: true }
}
const message =
reread.ok && reread.value
? 'Created on {host}, but the original could not be deleted. Remove it on the old host.'
: 'Created on {host}, but the original deletion could not be verified. Check the old host before retrying.'
toast.error(
translate(
reread.ok && reread.value
? 'auto.components.automations.AutomationsPage.moveOriginalKept'
: 'auto.components.automations.AutomationsPage.moveOriginalUnverified',
message
).replace('{host}', () => target.entry.authorityLabel)
)
return { saved: created, originalRemoved: false }
}
/** Writes a new automation through the destination's owner-qualified RPC. */
export async function createAutomationOnDestination(
authority: AutomationAuthorityRef,
input: AutomationCreateInput,
target: AutomationCreateDestination,
invalidateWrittenHost: AutomationMoveOperationContext['invalidateWrittenHost']
): Promise<AutomationDispatchResult<Automation>> {
const result = toDispatchResult(
await createAutomationAtDestination(authority, input, target.destination)
)
if (result.ok) {
invalidateWrittenHost(target.entry.stableRef, 'definition')
}
return result
}
export async function saveExistingAutomation(
context: AutomationSaveContext,
currentAutomation: Automation | null,
updates: AutomationUpdateInput,
destination: AutomationDestination | undefined,
fallbackTarget: AutomationHostTarget | null,
rowKey: string | null
): Promise<AutomationDispatchResult<Automation>> {
if (!currentAutomation) {
return {
ok: false,
notice: {
message: translate(
'auto.components.automations.AutomationsPage.ownerChanged',
'This automation changed hosts. Refresh and try again.'
),
recovery: 'retry',
severity: 'owner'
}
}
}
return await dispatchAutomationUpdate(
context.destination.automationDispatchContext,
{ rowKey: rowKey ?? '', automationId: currentAutomation.id },
updates,
() => updateAutomationForTarget(currentAutomation, updates, fallbackTarget),
'save',
destination
)
}
@@ -0,0 +1,288 @@
import { toast } from 'sonner'
import type {
Automation,
AutomationCreateInput,
AutomationUpdateInput
} from '../../../../shared/automations-types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { buildAutomationRrule } from '../../../../shared/automation-schedule-occurrences'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { listAutomationsForTarget } from './automation-host-client'
import {
dispatchAutomationReread,
type AutomationDispatchResult
} from './automation-row-action-dispatch'
import { buildDraftPrecheck } from './automation-draft-model'
import { buildAutomationRunContextForRepo } from './automation-run-context'
import { resolveAutomationSetupDecisionForSave } from './automation-setup-decision'
import {
createAutomationOnDestination,
moveAutomationToDestination,
resolveAutomationEditDestination,
saveExistingAutomation
} from './automation-orca-save-operations'
import type { AutomationSaveContext } from './automation-save-context'
/** Saves an Orca automation, including destination validation and host moves. */
export async function saveOrcaAutomation(
context: AutomationSaveContext,
time: { hour: number; minute: number; now: number }
): Promise<void> {
const { store, local, setup, destination, destinationForm, pageRefresh } = context
const { repos, projectHostSetups } = store
const {
draft,
createTarget,
editingAutomationId,
editingRowKey,
editingDestination,
automations,
setAutomations,
setDraft,
selectAutomationId,
setSelectedRowKey,
setSelectedAutomationRunPageId,
setCreateOpen,
setEditorNotice,
setEditorNoticeHost,
moveCreationKeysRef
} = local
const {
automationHostTarget,
automationDispatchContext,
invalidateRowHost,
invalidateWrittenHost,
rowRecoveryHost,
createDestination
} = destination
const { dialogRepos, editHostResolution, automationDialogTarget } = destinationForm
// Re-check the destination at the start of the transaction. This keeps all
// side effects (hook probes and trust prompts) behind the destination fence.
const createCheck = editingAutomationId === null ? createDestination.check(draft.projectId) : null
if (createCheck && !createCheck.ok) {
setEditorNotice(createCheck.notice)
return
}
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const rrule =
draft.preset === 'custom'
? draft.customSchedule.trim()
: buildAutomationRrule({
preset: draft.preset,
hour: time.hour,
minute: time.minute,
dayOfWeek: Number(draft.dayOfWeek)
})
const rawGrace = Number(draft.missedRunGraceMinutes)
const missedRunGraceMinutes = Number.isFinite(rawGrace) ? Math.max(0, rawGrace) : 720
const precheck = buildDraftPrecheck(draft)
const reposForDraft = editingAutomationId !== null ? dialogRepos : repos
const setupResolution =
editingAutomationId !== null
? editHostResolution
: createCheck
? { status: 'ready' as const, ...createCheck.destination }
: null
const setupHostId: ExecutionHostId | undefined =
setupResolution?.status === 'ready' && setupResolution.authority.kind === 'runtime'
? (`runtime:${setupResolution.authority.environmentId}` as ExecutionHostId)
: undefined
const setupProjectHostSetups = setupHostId
? projectHostSetups.filter(
(candidate) => candidate.repoId !== draft.projectId || candidate.hostId === setupHostId
)
: projectHostSetups
let setupDecision = resolveAutomationSetupDecisionForSave({
createTarget,
workspaceMode: draft.workspaceMode,
repoId: draft.projectId,
repos: reposForDraft,
projectHostSetups: setupProjectHostSetups,
yamlHooks:
draft.workspaceMode === 'new_per_run'
? await setup.loadAutomationYamlHooksForRepo(draft.projectId, setupHostId)
: null,
draftSetupDecision: draft.setupDecision
})
if (setupDecision === 'run') {
const trustDecision = await ensureHooksConfirmed(
useAppStore.getState(),
draft.projectId,
'setup',
setupHostId
)
if (trustDecision === 'skip') {
setupDecision = 'skip'
}
}
const runContext = buildAutomationRunContextForRepo({
repoId: draft.projectId,
repos: reposForDraft,
projectHostSetups: setupProjectHostSetups
})
if (!runContext) {
toast.error(
translate(
'auto.components.automations.AutomationsPage.32534e7c9c',
'Choose an available workspace before saving.'
)
)
return
}
let currentAutomation = editingAutomationId
? (automations.find((automation) => automation.id === editingAutomationId) ?? null)
: null
if (editingAutomationId !== null) {
const reread = await dispatchAutomationReread(
automationDispatchContext,
{ rowKey: editingRowKey ?? '', automationId: editingAutomationId },
async () =>
(await listAutomationsForTarget(automationHostTarget ?? { kind: 'local' })).find(
(automation) => automation.id === editingAutomationId
) ?? null
)
if (!reread.ok && reread.notice.severity === 'owner') {
setEditorNotice(reread.notice)
return
}
currentAutomation = (reread.ok ? reread.value : null) ?? currentAutomation
}
const updates: AutomationUpdateInput = {
name: draft.name,
prompt: draft.prompt,
precheck,
agentId: draft.agentId,
runContext,
projectId: draft.projectId,
workspaceMode: draft.workspaceMode,
workspaceId: draft.workspaceId,
baseBranch: draft.baseBranch.trim() || null,
setupDecision,
reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession,
timezone,
missedRunGraceMinutes
}
if (!currentAutomation || currentAutomation.rrule !== rrule) {
updates.rrule = rrule
updates.dtstart = time.now
}
const createInput: AutomationCreateInput = {
name: draft.name,
prompt: draft.prompt,
precheck,
agentId: draft.agentId,
runContext,
projectId: draft.projectId,
workspaceMode: draft.workspaceMode,
workspaceId: draft.workspaceId,
baseBranch: draft.baseBranch.trim() || null,
setupDecision,
reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession,
timezone,
rrule,
dtstart: updates.dtstart ?? time.now,
missedRunGraceMinutes
}
const destinationResult = resolveAutomationEditDestination({
currentAutomation,
editingAutomationId,
editingDestination,
draft,
rowKey: editingRowKey,
automationDialogTarget,
editHostEntries: destinationForm.editHostEntries,
rowRecoveryHost
})
if (!destinationResult.ok) {
setEditorNotice(destinationResult.notice)
return
}
const { editDestination, moveTarget } = destinationResult
let saved: AutomationDispatchResult<Automation>
let originalRemoved = true
if (moveTarget) {
const moved = await moveAutomationToDestination(
{
automationDispatchContext,
editingRowKey,
automationDialogTarget,
moveCreationKeysRef,
invalidateWrittenHost
},
currentAutomation,
moveTarget,
{
...createInput,
dtstart: updates.dtstart ?? currentAutomation?.dtstart ?? createInput.dtstart,
enabled: currentAutomation?.enabled ?? true,
sourceContext: currentAutomation?.sourceContext ?? null
}
)
saved = moved.saved
originalRemoved = moved.originalRemoved
} else if (editingAutomationId !== null) {
saved = await saveExistingAutomation(
context,
currentAutomation,
updates,
editDestination,
automationHostTarget,
editingRowKey
)
} else {
saved = await createAutomationOnDestination(
createCheck!.destination.authority,
createInput,
createCheck!.destination,
invalidateWrittenHost
)
}
if (!saved.ok) {
setEditorNotice(saved.notice)
setEditorNoticeHost(moveTarget?.entry ?? null)
return
}
const automation = saved.value
if (editingAutomationId !== null) {
invalidateRowHost(editingRowKey, 'definition')
} else {
await pageRefresh.hydratePersistedUIState()
}
setAutomations((current) => {
const next = current.filter((entry) => entry.id !== automation.id)
return [...next, automation].sort((left, right) => left.name.localeCompare(right.name))
})
setDraft((current) => ({ ...current, name: '', prompt: '' }))
await pageRefresh.refresh()
if (editingAutomationId !== null && editingRowKey && !moveTarget) {
setSelectedAutomationRunPageId(null)
setSelectedRowKey(editingRowKey)
}
selectAutomationId(automation.id)
setCreateOpen(false)
if (editingAutomationId === null) {
useAppStore.getState().recordFeatureInteraction('automation-created')
}
if (moveTarget && !originalRemoved) {
return
}
toast.success(
moveTarget
? translate(
'auto.components.automations.AutomationsPage.moved',
'Automation moved to {host}.'
).replace('{host}', () => moveTarget.entry.authorityLabel)
: editingAutomationId !== null
? translate('auto.components.automations.AutomationsPage.244727e655', 'Automation updated.')
: translate('auto.components.automations.AutomationsPage.2a20596d6b', 'Automation saved.')
)
}
@@ -0,0 +1,122 @@
import { toast } from 'sonner'
import type { AutomationRun } from '../../../../shared/automations-types'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { getAutomationTargetAvailability } from './automation-target-availability'
import { runAutomationNowForTarget } from './automation-host-client'
import { dispatchAutomationRunNow } from './automation-row-action-dispatch'
import { waitForAutomationRerunPendingVisibility } from './automation-run-view-state'
import type { AutomationListRow } from './automation-list-row-identity'
import type { AutomationsPageActionContext } from './automations-page-action-context'
/** Run-now/rerun handlers keyed by the selected row's captured host. */
export function createAutomationRunActions({
store,
local,
destination,
sourceAvailability,
pageRefresh
}: AutomationsPageActionContext) {
const {
projectHostSetups,
sshConnectionStates,
runtimeStatusByEnvironmentId,
repoForRow,
worktreeForRow
} = store
const { rerunRunIdsInFlightRef, setRerunRunIdsInFlight } = local
const {
automationHostTargetFor,
automationDispatchContext,
reportOwnerAction,
invalidateRowHost
} = destination
const { automationSourceHostAvailabilityByRowKey } = sourceAvailability
const runNow = async (row: AutomationListRow): Promise<void> => {
const repo = repoForRow(row) ?? null
const workspace = row.automation.workspaceId
? (worktreeForRow(row, repo ?? undefined) ?? null)
: null
const rowHostTarget = automationHostTargetFor(row)
const availability = getAutomationTargetAvailability({
automation: row.automation,
repo,
workspace,
projectHostSetups,
sshConnectionStates,
runtimeStatusByEnvironmentId,
automationHostTarget: rowHostTarget,
sourceHostAvailability: automationSourceHostAvailabilityByRowKey.get(row.key)
})
if (!availability.canRunNow) {
toast.error(availability.message)
return
}
const result = await dispatchAutomationRunNow(
automationDispatchContext,
{ rowKey: row.key, automationId: row.automation.id },
() => runAutomationNowForTarget(row.automation, rowHostTarget)
)
reportOwnerAction(row.key, result.ok ? null : result.notice)
if (!result.ok) {
return
}
useAppStore.getState().recordFeatureInteraction('automation-run')
invalidateRowHost(row.key, 'run')
await pageRefresh.hydratePersistedUIState()
await pageRefresh.refresh()
toast.message(
translate('auto.components.automations.AutomationsPage.a1bdb57008', 'Automation run queued.')
)
}
const rerunAutomationRun = async (row: AutomationListRow, run: AutomationRun): Promise<void> => {
const runId = run.id
if (rerunRunIdsInFlightRef.current.has(runId)) {
return
}
const pendingStartedAt = Date.now()
rerunRunIdsInFlightRef.current.add(runId)
setRerunRunIdsInFlight(new Set(rerunRunIdsInFlightRef.current))
try {
const result = await dispatchAutomationRunNow(
automationDispatchContext,
{ rowKey: row.key, automationId: row.automation.id },
() => runAutomationNowForTarget(row.automation, automationHostTargetFor(row))
)
reportOwnerAction(row.key, result.ok ? null : result.notice)
if (!result.ok) {
await pageRefresh.refresh()
return
}
invalidateRowHost(row.key, 'run')
await pageRefresh.hydratePersistedUIState()
await pageRefresh.refresh()
toast.message(
translate(
'auto.components.automations.AutomationsPage.a1bdb57008',
'Automation run queued.'
)
)
} catch (error) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.automations.AutomationsPage.3a4c476aa0',
'Failed to rerun automation.'
)
)
await pageRefresh.refresh()
} finally {
await waitForAutomationRerunPendingVisibility(pendingStartedAt)
rerunRunIdsInFlightRef.current.delete(runId)
setRerunRunIdsInFlight(new Set(rerunRunIdsInFlightRef.current))
}
}
return { runNow, rerunAutomationRun }
}
export type AutomationRunActions = ReturnType<typeof createAutomationRunActions>
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import type { AutomationRun } from '../../../../shared/automations-types'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import { hasAutomationRunCompletionEvidence } from './automation-run-completion-evidence'
const PANE = 'tab-1:11111111-1111-4111-8111-111111111111'
function run(): AutomationRun {
return {
id: 'run-1',
automationId: 'automation-1',
title: 'Run 1',
scheduledFor: 1,
status: 'dispatched',
trigger: 'manual',
workspaceId: 'wt-1',
sessionKind: 'terminal',
chatSessionId: null,
terminalSessionId: 'tab-1',
terminalPaneKey: PANE,
terminalPtyId: 'pty-1',
outputSnapshot: null,
precheckResult: null,
usage: null,
error: null,
startedAt: 1_000,
dispatchedAt: 1_000,
createdAt: 1_000
}
}
function entry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
return {
paneKey: PANE,
state: 'done',
prompt: '',
updatedAt: 2_000,
stateStartedAt: 2_000,
stateHistory: [],
agentType: 'claude',
...overrides
}
}
function retained(status: AgentStatusEntry): RetainedAgentEntry {
return {
entry: status,
worktreeId: 'wt-1',
tab: {
id: 'tab-1',
ptyId: 'pty-1',
worktreeId: 'wt-1',
title: 'Agent',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1_000
},
agentType: 'claude',
startedAt: status.stateStartedAt
}
}
describe('automation run completion evidence (STA-3386)', () => {
it('ignores live and retained session-boundary done rows', () => {
const boundary = entry({ sessionBoundary: true })
expect(
hasAutomationRunCompletionEvidence({
run: run(),
dispatchedAt: 1_000,
agentStatusByPaneKey: { [PANE]: boundary },
retainedAgentsByPaneKey: { [PANE]: retained(boundary) }
})
).toBe(false)
})
it('accepts a real done after dispatch', () => {
expect(
hasAutomationRunCompletionEvidence({
run: run(),
dispatchedAt: 1_000,
agentStatusByPaneKey: { [PANE]: entry() },
retainedAgentsByPaneKey: {}
})
).toBe(true)
})
})
@@ -0,0 +1,38 @@
import type { AutomationRun } from '../../../../shared/automations-types'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import { automationRunMatchesPaneKey } from './automation-run-open-target'
function isCompletionAfterDispatch(entry: AgentStatusEntry, dispatchedAt: number): boolean {
return entry.state === 'done' && entry.sessionBoundary !== true && entry.updatedAt >= dispatchedAt
}
export function hasAutomationRunCompletionEvidence({
run,
dispatchedAt,
agentStatusByPaneKey,
retainedAgentsByPaneKey
}: {
run: AutomationRun
dispatchedAt: number
agentStatusByPaneKey: Record<string, AgentStatusEntry>
retainedAgentsByPaneKey: Record<string, RetainedAgentEntry>
}): boolean {
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
if (
automationRunMatchesPaneKey(run, paneKey) &&
isCompletionAfterDispatch(entry, dispatchedAt)
) {
return true
}
}
for (const [paneKey, retained] of Object.entries(retainedAgentsByPaneKey)) {
if (
automationRunMatchesPaneKey(run, paneKey) &&
isCompletionAfterDispatch(retained.entry, dispatchedAt)
) {
return true
}
}
return false
}
@@ -0,0 +1,71 @@
import type { AutomationRun } from '../../../../shared/automations-types'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
import {
buildAutomationRunOpenLayout,
getAutomationRunOpenTabId,
resolveAutomationRunOpenTarget
} from './automation-run-open-target'
import { getAutomationRunViewState } from './automation-run-view-state'
import type { AutomationsPageActionContext } from './automations-page-action-context'
/** Opens the original run terminal when its host-qualified workspace is alive. */
export function createAutomationRunWorkspaceAction({ store, list }: AutomationsPageActionContext) {
const { repoForRow, worktreeForRow } = store
const { selectedRow } = list
return function openRunWorkspace(run: AutomationRun): void {
const runWorktree =
run.workspaceId && selectedRow
? (worktreeForRow(selectedRow, repoForRow(selectedRow), run.workspaceId) ?? null)
: null
const appStore = useAppStore.getState()
const openTabId = getAutomationRunOpenTabId(run)
const terminalTabExists = openTabId ? Boolean(appStore.getTab(openTabId)) : false
const currentLayout = openTabId ? appStore.terminalLayoutsByTabId[openTabId] : null
const livePtyIds = openTabId ? (appStore.ptyIdsByTabId[openTabId] ?? []) : []
const terminalTarget = resolveAutomationRunOpenTarget({
run,
terminalTabExists,
currentLayout,
livePtyIds
})
const runViewState = getAutomationRunViewState({
run,
workspaceExists: Boolean(runWorktree),
terminalTargetExists: terminalTarget !== null
})
if (!run.workspaceId || !runWorktree || !runViewState.canOpen) {
toast.error(runViewState.statusLabel)
return
}
if (runViewState.availability === 'terminal' && !terminalTarget) {
toast.error(runViewState.statusLabel)
return
}
if (terminalTarget && currentLayout) {
appStore.setTabLayout(
terminalTarget.tabId,
buildAutomationRunOpenLayout({ target: terminalTarget, currentLayout })
)
if (activateAndRevealWorktree(run.workspaceId)) {
appStore.setActiveTab(terminalTarget.tabId)
appStore.setActiveTabType('terminal')
return
}
}
if (!activateAndRevealWorktree(run.workspaceId)) {
toast.error(
translate(
'auto.components.automations.AutomationsPage.e1bf9b1512',
'Workspace is not available.'
)
)
return
}
toast.message(runViewState.statusLabel)
}
}
export type AutomationRunWorkspaceAction = ReturnType<typeof createAutomationRunWorkspaceAction>
@@ -0,0 +1,118 @@
import { toast } from 'sonner'
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
import {
isValidAutomationCronSchedule,
isValidAutomationSchedule
} from '../../../../shared/automation-schedule-parsing'
import { translate } from '@/i18n/i18n'
import { parseDraftTime } from './automation-draft-model'
import { saveHermesAutomation } from './automation-hermes-save'
import { saveOrcaAutomation } from './automation-orca-save'
import type { AutomationSaveContext } from './automation-save-context'
/** Validates editor input then delegates the provider-specific save transaction. */
export function createAutomationSaveAction(context: AutomationSaveContext) {
return async function saveAutomation(now = Date.now()): Promise<void> {
const { store, local, destinationForm, destination } = context
const { settings } = store
const {
draft,
editingAutomationId,
createTarget,
editingExternalTarget,
setIsSaving,
setEditorNotice
} = local
const isHermesSave =
editingAutomationId === null && (createTarget === 'hermes' || editingExternalTarget !== null)
const { hour, minute } = parseDraftTime(draft.time)
if (
!draft.projectId ||
((draft.workspaceMode === 'existing' || isHermesSave) && !draft.workspaceId) ||
!draft.prompt.trim()
) {
toast.error(
translate(
'auto.components.automations.AutomationsPage.2430fecf53',
'Choose a run location and enter a prompt before saving.'
)
)
return
}
if (draft.scheduleWarning) {
toast.error(
translate(
'auto.components.automations.AutomationsPage.64bdb2304f',
'Pick a supported schedule before saving.'
)
)
return
}
const validateAdvancedSchedule = isHermesSave
? isValidAutomationCronSchedule
: isValidAutomationSchedule
if (draft.preset === 'custom' && !validateAdvancedSchedule(draft.customSchedule)) {
toast.error(
translate(
'auto.components.automations.AutomationsPage.6e91dab317',
'Enter a valid advanced schedule before saving.'
)
)
return
}
if (
editingAutomationId === null &&
!isHermesSave &&
!isTuiAgentEnabled(draft.agentId, settings?.disabledTuiAgents)
) {
toast.error(
translate(
'auto.components.automations.AutomationsPage.2360ffc956',
'Choose an enabled agent before saving.'
)
)
return
}
setIsSaving(true)
try {
const selectedWorkspaceExists =
draft.workspaceMode !== 'existing' ||
destinationForm.dialogWorktrees.some((worktree) => worktree.id === draft.workspaceId)
if (!selectedWorkspaceExists) {
toast.error(
translate(
'auto.components.automations.AutomationsPage.32534e7c9c',
'Choose an available workspace before saving.'
)
)
return
}
if (!isHermesSave && editingAutomationId === null) {
const checked = destination.createDestination.check(draft.projectId)
if (!checked.ok) {
setEditorNotice(checked.notice)
return
}
}
await (isHermesSave
? saveHermesAutomation(context)
: saveOrcaAutomation(context, { hour, minute, now }))
} catch (error) {
if (isHermesSave) {
await context.pageRefresh.refresh().catch(() => undefined)
}
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.automations.AutomationsPage.b11170a008',
'Failed to save automation.'
)
)
} finally {
setIsSaving(false)
}
}
}
export type AutomationSaveAction = ReturnType<typeof createAutomationSaveAction>
@@ -0,0 +1,17 @@
import type { AutomationsPageListState } from './use-automations-page-list-state'
import type { AutomationsPageLocalState } from './use-automations-page-local-state'
import type { AutomationsPageRefresh } from './use-automations-page-refresh'
import type { AutomationsPageSetupState } from './use-automations-page-setup-state'
import type { AutomationsPageStoreState } from './use-automations-page-store-state'
import type { AutomationsPageDestinationState } from './use-automations-page-destination-state'
import type { AutomationsPageDestinationFormState } from './use-automations-page-destination-form'
export type AutomationSaveContext = {
store: AutomationsPageStoreState
local: AutomationsPageLocalState
list: AutomationsPageListState
setup: AutomationsPageSetupState
destination: AutomationsPageDestinationState
destinationForm: AutomationsPageDestinationFormState
pageRefresh: AutomationsPageRefresh
}
@@ -0,0 +1,21 @@
import type { AutomationSourceAvailability } from './use-automation-source-availability'
import type { AutomationsPageListState } from './use-automations-page-list-state'
import type { AutomationsPageLocalState } from './use-automations-page-local-state'
import type { AutomationsPagePresentationState } from './use-automations-page-presentation-state'
import type { AutomationsPageRefresh } from './use-automations-page-refresh'
import type { AutomationsPageSetupState } from './use-automations-page-setup-state'
import type { AutomationsPageStoreState } from './use-automations-page-store-state'
import type { AutomationsPageDestinationState } from './use-automations-page-destination-state'
import type { AutomationsPageDestinationFormState } from './use-automations-page-destination-form'
export type AutomationsPageActionContext = {
store: AutomationsPageStoreState
local: AutomationsPageLocalState
list: AutomationsPageListState
setup: AutomationsPageSetupState
destination: AutomationsPageDestinationState
destinationForm: AutomationsPageDestinationFormState
sourceAvailability: AutomationSourceAvailability
presentation: AutomationsPagePresentationState
pageRefresh: AutomationsPageRefresh
}
@@ -0,0 +1,179 @@
import { useCallback, useEffect } from 'react'
import type { AutomationTemplate } from './automation-templates'
import type { AutomationCreateTarget } from './AutomationEditorDialog'
import { getAutomationCreateRepos } from './automation-create-projects'
import { getDefaultWorktree } from './automation-draft-model'
import type { AutomationsPageDestinationState } from './use-automations-page-destination-state'
import type { AutomationsPageDestinationFormState } from './use-automations-page-destination-form'
import type { AutomationsPageLocalState } from './use-automations-page-local-state'
import type { AutomationsPageRefresh } from './use-automations-page-refresh'
import type { AutomationsPageSetupState } from './use-automations-page-setup-state'
import type { AutomationsPageStoreState } from './use-automations-page-store-state'
/** Synchronizes draft defaults, setup policy, and template/target controls. */
export function useAutomationDraftEffects({
store,
local,
setup,
destination,
destinationForm,
pageRefresh
}: {
store: AutomationsPageStoreState
local: AutomationsPageLocalState
setup: AutomationsPageSetupState
destination: AutomationsPageDestinationState
destinationForm: AutomationsPageDestinationFormState
pageRefresh: AutomationsPageRefresh
}) {
const { repos, worktreesByRepo } = store
const {
draft,
setDraft,
createOpen,
createTarget,
editingAutomationId,
setupDecisionPolicyDefaultRef,
setupDecisionDefaultSignatureRef,
setupDecisionTouchedRef,
setCreateTarget
} = local
const {
loadAutomationYamlHooksForRepo,
getDraftSetupDecisionDefault,
getDraftSetupDecisionDefaultSignature
} = setup
const { createDestinationHostId } = destination
const { dialogWorktrees } = destinationForm
const { getDefaultTarget } = pageRefresh
useEffect(() => {
if (!draft.projectId && editingAutomationId === null) {
const target = getDefaultTarget()
if (!target.projectId) {
return
}
setDraft((current) => ({
...current,
projectId: target.projectId,
workspaceId: target.workspaceId
}))
}
}, [draft.projectId, editingAutomationId, getDefaultTarget, setDraft])
useEffect(() => {
if (!draft.projectId) {
return
}
const defaultWorktree = getDefaultWorktree(dialogWorktrees)
if (!draft.workspaceId && defaultWorktree) {
setDraft((current) => ({ ...current, workspaceId: defaultWorktree.id }))
}
}, [dialogWorktrees, draft.projectId, draft.workspaceId, setDraft])
useEffect(() => {
if (
!createOpen ||
createTarget !== 'orca' ||
draft.workspaceMode !== 'new_per_run' ||
!draft.projectId
) {
return
}
void loadAutomationYamlHooksForRepo(draft.projectId, createDestinationHostId)
}, [
createOpen,
createDestinationHostId,
createTarget,
draft.projectId,
draft.workspaceMode,
loadAutomationYamlHooksForRepo
])
useEffect(() => {
if (!createOpen) {
setupDecisionPolicyDefaultRef.current = undefined
setupDecisionDefaultSignatureRef.current = null
setupDecisionTouchedRef.current = false
return
}
const nextDefault = getDraftSetupDecisionDefault(draft)
const nextSignature = getDraftSetupDecisionDefaultSignature(draft)
if (setupDecisionDefaultSignatureRef.current !== nextSignature) {
setupDecisionDefaultSignatureRef.current = nextSignature
setupDecisionTouchedRef.current = false
}
const previousDefault = setupDecisionPolicyDefaultRef.current
setupDecisionPolicyDefaultRef.current = nextDefault
const shouldApplyPolicyDefault =
!setupDecisionTouchedRef.current &&
(nextDefault === undefined ||
draft.setupDecision === undefined ||
draft.setupDecision === previousDefault)
if (!shouldApplyPolicyDefault || draft.setupDecision === nextDefault) {
return
}
setDraft((current) => ({ ...current, setupDecision: nextDefault }))
}, [
createOpen,
draft,
getDraftSetupDecisionDefault,
getDraftSetupDecisionDefaultSignature,
setDraft,
setupDecisionDefaultSignatureRef,
setupDecisionPolicyDefaultRef,
setupDecisionTouchedRef
])
const applyTemplateToDraft = useCallback(
(template: AutomationTemplate): void => {
setDraft((current) => ({
...current,
name: template.name,
prompt: template.prompt,
preset: template.preset,
time: template.time ?? current.time,
dayOfWeek: template.dayOfWeek ?? current.dayOfWeek,
customSchedule: '',
agentId: template.agentId ?? current.agentId,
missedRunGraceMinutes: template.missedRunGraceMinutes ?? current.missedRunGraceMinutes,
scheduleWarning: null
}))
},
[setDraft]
)
const handleCreateTargetChange = useCallback(
(target: AutomationCreateTarget): void => {
setCreateTarget(target)
if (target !== 'hermes') {
return
}
const localRepos = getAutomationCreateRepos(repos, { kind: 'local' })
setDraft((current) => {
const currentRepo = repos.find((repo) => repo.id === current.projectId)
const currentRepoIsLocal =
currentRepo !== undefined &&
localRepos.some(
(repo) =>
repo.id === currentRepo.id &&
(repo.connectionId ?? null) === (currentRepo.connectionId ?? null)
)
const nextRepo = currentRepoIsLocal ? currentRepo : localRepos[0]
const nextWorkspace = nextRepo
? getDefaultWorktree(worktreesByRepo[nextRepo.id] ?? [])
: null
return {
...current,
agentId: 'hermes',
projectId: nextRepo?.id ?? '',
workspaceId: nextWorkspace?.id ?? '',
workspaceMode: 'existing',
setupDecision: undefined,
reuseSession: false
}
})
},
[repos, setCreateTarget, setDraft, worktreesByRepo]
)
return { applyTemplateToDraft, handleCreateTargetChange }
}
export type AutomationDraftEffects = ReturnType<typeof useAutomationDraftEffects>
@@ -0,0 +1,263 @@
import { useCallback } from 'react'
import type {
ExternalAutomationJob,
ExternalAutomationManager
} from '../../../../shared/automations-types'
import { toRuntimeExecutionHostId } from '../../../../shared/execution-host'
import { useAppStore } from '@/store'
import { getAutomationCreateRepos } from './automation-create-projects'
import { buildAutomationEditDraft, buildExternalAutomationEditDraft } from './automation-edit-draft'
import { AUTOMATION_DEFAULT_TIME, getDefaultWorktree } from './automation-draft-model'
import type { AutomationTemplate } from './automation-templates'
import { dispatchAutomationReread } from './automation-row-action-dispatch'
import { listAutomationsForTarget } from './automation-host-client'
import { repoMatchesExternalAutomationTarget } from './automation-external-target-match'
import type { ExternalAutomationScope } from './external-automation-scope-client'
import type { AutomationDraft } from './AutomationEditorDialog'
import type { AutomationListRow } from './automation-list-row-identity'
import type { AutomationsPageDestinationState } from './use-automations-page-destination-state'
import type { AutomationsPageDestinationFormState } from './use-automations-page-destination-form'
import type { AutomationsPageLocalState } from './use-automations-page-local-state'
import type { AutomationsPageStoreState } from './use-automations-page-store-state'
/** Dialog open/edit actions, including host-captured re-reads and project changes. */
export function useAutomationEditorActions({
store,
local,
destination,
destinationForm
}: {
store: AutomationsPageStoreState
local: AutomationsPageLocalState
destination: AutomationsPageDestinationState
destinationForm: AutomationsPageDestinationFormState
}) {
const { defaultAgent, worktreesByRepo, repoMap, fetchWorktrees, repos } = store
const {
editRequestRef,
setEditingAutomationId,
setEditingRowKey,
setEditingDestination,
setEditingHostStableKey,
setEditingExternalTarget,
setCreateTarget,
setDraft,
setDraftAtOpen,
setCreateOpen,
draftRef,
editingAutomationId,
editingHostStableKey
} = local
const { getDefaultTarget, automationDispatchContext, rowRecoveryHost } = destination
const { destinationForProject, editHostResolution } = destinationForm
const openCreateDialog = (template?: AutomationTemplate): void => {
editRequestRef.current += 1
const target = getDefaultTarget()
setEditingAutomationId(null)
setEditingExternalTarget(null)
setEditingDestination(null)
setEditingHostStableKey(null)
setCreateTarget('orca')
const baseDraft: AutomationDraft = {
name: '',
prompt: '',
agentId: defaultAgent,
projectId: target.projectId,
workspaceMode: 'existing',
workspaceId: target.workspaceId,
baseBranch: '',
setupDecision: undefined,
reuseSession: false,
precheckCommand: '',
precheckTimeoutSeconds: '60',
preset: 'weekdays',
time: AUTOMATION_DEFAULT_TIME,
dayOfWeek: '1',
customSchedule: '',
missedRunGraceMinutes: '720',
scheduleWarning: null
}
const nextDraft = template
? {
...baseDraft,
name: template.name,
prompt: template.prompt,
preset: template.preset,
time: template.time ?? baseDraft.time,
dayOfWeek: template.dayOfWeek ?? baseDraft.dayOfWeek,
agentId: template.agentId ?? baseDraft.agentId,
missedRunGraceMinutes: template.missedRunGraceMinutes ?? baseDraft.missedRunGraceMinutes
}
: baseDraft
setDraft(nextDraft)
setDraftAtOpen(nextDraft)
setCreateOpen(true)
}
const openEditDialog = async (row: AutomationListRow): Promise<void> => {
const requestId = (editRequestRef.current += 1)
setEditingExternalTarget(null)
setCreateTarget('orca')
const automationId = row.automation.id
const reread = await dispatchAutomationReread(
automationDispatchContext,
{ rowKey: row.key, automationId },
async () =>
(await listAutomationsForTarget({ kind: 'local' })).find(
(entry) => entry.id === automationId
) ?? null
)
if (!reread.ok && reread.notice.severity === 'owner') {
destination.reportOwnerAction(row.key, reread.notice)
return
}
const latest = (reread.ok ? reread.value : null) ?? row.automation
if (requestId !== editRequestRef.current) {
return
}
setEditingAutomationId(latest.id)
setEditingRowKey(row.key)
const initialHostStableKey = rowRecoveryHost(row.key)?.stableKey ?? null
const initialDestination = destinationForProject(latest.projectId, initialHostStableKey)
setEditingDestination(
initialDestination ? { projectId: latest.projectId, destination: initialDestination } : null
)
setEditingHostStableKey(initialDestination?.entry.stableKey ?? null)
const nextDraft = buildAutomationEditDraft(latest)
setDraft(nextDraft)
setDraftAtOpen(nextDraft)
setCreateOpen(true)
}
const openEditExternalDialog = (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
scope: ExternalAutomationScope
): void => {
editRequestRef.current += 1
const targetWorktree = Object.values(worktreesByRepo)
.flat()
.find((worktree) => {
const repo = repoMap.get(worktree.repoId)
return (
repo !== undefined &&
repoMatchesExternalAutomationTarget(repo, manager.target) &&
job.workdir !== null &&
worktree.path === job.workdir
)
})
const localRepos = getAutomationCreateRepos(repos, { kind: 'local' })
const fallbackRepo = localRepos[0] ?? null
const fallbackWorktree = fallbackRepo
? getDefaultWorktree(worktreesByRepo[fallbackRepo.id] ?? [])
: null
const projectId = targetWorktree?.repoId ?? fallbackRepo?.id ?? ''
const workspaceId = targetWorktree?.id ?? fallbackWorktree?.id ?? ''
const nextDraft = buildExternalAutomationEditDraft(job, { projectId, workspaceId })
setEditingAutomationId(null)
setEditingRowKey(null)
setEditingDestination(null)
setEditingHostStableKey(null)
setEditingExternalTarget({ manager, job, scope })
setCreateTarget('hermes')
setDraft(nextDraft)
setDraftAtOpen(nextDraft)
setCreateOpen(true)
}
const handleProjectChange = useCallback(
(projectId: string): void => {
const currentWorktrees = worktreesByRepo[projectId] ?? []
const currentDefaultWorktree = getDefaultWorktree(currentWorktrees)
const selectedEditDestination =
editingAutomationId !== null && editingHostStableKey
? editHostResolution.status === 'ready'
? editHostResolution
: null
: null
const worktreeFetchOptions =
selectedEditDestination?.status === 'ready' &&
selectedEditDestination.authority.kind === 'runtime'
? {
executionHostId: toRuntimeExecutionHostId(
selectedEditDestination.authority.environmentId
)
}
: undefined
if (editingAutomationId !== null) {
const target = destinationForProject(projectId, editingHostStableKey)
setEditingDestination(target ? { projectId, destination: target } : null)
if (target) {
setEditingHostStableKey(target.entry.stableKey)
}
}
setDraft((current) => ({
...current,
projectId,
workspaceId: currentDefaultWorktree?.id ?? '',
baseBranch: ''
}))
void fetchWorktrees(projectId, worktreeFetchOptions).then(() => {
const latestWorktrees = useAppStore.getState().worktreesByRepo[projectId] ?? []
const latestWorktree = getDefaultWorktree(latestWorktrees)
if (!latestWorktree) {
return
}
setDraft((current) =>
current.projectId === projectId && !current.workspaceId
? { ...current, workspaceId: latestWorktree.id }
: current
)
})
},
[
destinationForProject,
editingAutomationId,
editingHostStableKey,
editHostResolution,
fetchWorktrees,
setEditingHostStableKey,
setEditingDestination,
setDraft,
worktreesByRepo
]
)
const handleDraftChange = useCallback(
(updater: (current: AutomationDraft) => AutomationDraft): void => {
const current = draftRef.current
const next = updater(current)
draftRef.current = next
setDraft(next)
if (
editingAutomationId !== null &&
(next.projectId !== current.projectId || next.workspaceId !== current.workspaceId)
) {
const target = destinationForProject(next.projectId, editingHostStableKey)
setEditingDestination(target ? { projectId: next.projectId, destination: target } : null)
if (target) {
setEditingHostStableKey(target.entry.stableKey)
}
}
},
[
destinationForProject,
draftRef,
editingAutomationId,
editingHostStableKey,
setDraft,
setEditingDestination,
setEditingHostStableKey
]
)
return {
openCreateDialog,
openEditDialog,
openEditExternalDialog,
handleProjectChange,
handleDraftChange
}
}
export type AutomationEditorActions = ReturnType<typeof useAutomationEditorActions>
@@ -0,0 +1,236 @@
import { useEffect, useMemo } from 'react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { getAutomationHostTargetKey, getAutomationTargetFromHostId } from './automation-host-client'
import {
canOpenAutomationRunOpenTarget,
getAutomationRunOpenTabId
} from './automation-run-open-target'
import { canRerunAutomationRun, getAutomationRunViewState } from './automation-run-view-state'
import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display'
import type { AutomationsPageListState } from './use-automations-page-list-state'
import type { AutomationsPageLocalState } from './use-automations-page-local-state'
import type { AutomationsPageSetupState } from './use-automations-page-setup-state'
import type { AutomationsPageStoreState } from './use-automations-page-store-state'
/** Navigation and terminal affordances for the selected automation run. */
export function useAutomationRunPageState({
store,
local,
list,
setup
}: {
store: AutomationsPageStoreState
local: AutomationsPageLocalState
list: AutomationsPageListState
setup: AutomationsPageSetupState
}) {
const {
worktreeMap,
repoForRow,
worktreeForRow,
pendingAutomationRunNavigation,
setPendingAutomationRunNavigation,
selectedId,
setSelectedId,
unifiedTabsByWorktree,
terminalLayoutsByTabId,
ptyIdsByTabId
} = store
const {
isLoading,
automationHostTargetKey,
automations,
selectedAutomationRuns,
setSelectedAutomationRunPageId,
setActivePaneTab,
rerunRunIdsInFlight,
selectedExternalKey,
selectExternalKey,
setSelectedExternalRunPage,
isDetailOpen,
setIsDetailOpen
} = local
const { selected, selectedRow } = list
const { selectedRuns, selectedAutomationRunPage } = setup
useEffect(() => {
if (!isDetailOpen || pendingAutomationRunNavigation) {
return
}
const hasSelectedLocal = selectedRow?.key
? list.visibleRows.some((row) => row.key === selectedRow.key)
: selectedId !== null && selectedRow !== null
const hasSelectedExternal =
selectedExternalKey !== null &&
list.externalAutomationEntries.some((entry) => entry.key === selectedExternalKey)
if (hasSelectedLocal || hasSelectedExternal) {
return
}
setIsDetailOpen(false)
setSelectedAutomationRunPageId(null)
setSelectedExternalRunPage(null)
setActivePaneTab('overview')
}, [
isDetailOpen,
list.externalAutomationEntries,
list.visibleRows,
pendingAutomationRunNavigation,
selectedExternalKey,
selectedId,
selectedRow,
setActivePaneTab,
setIsDetailOpen,
setSelectedAutomationRunPageId,
setSelectedExternalRunPage
])
useEffect(() => {
if (!pendingAutomationRunNavigation || isLoading) {
return
}
const pending = pendingAutomationRunNavigation
const pendingTargetKey = getAutomationHostTargetKey(
getAutomationTargetFromHostId(pending.hostId)
)
if (automationHostTargetKey !== pendingTargetKey) {
return
}
const pendingAutomation = automations.find(
(automation) => automation.id === pending.automationId
)
if (selectedExternalKey !== null) {
selectExternalKey(null)
}
if (!pendingAutomation) {
setSelectedId(pending.automationId)
setSelectedAutomationRunPageId(null)
setPendingAutomationRunNavigation(null)
toast.message(
translate(
'auto.components.automations.AutomationsPage.pendingAutomationMissing',
'Automation no longer available.'
)
)
return
}
if (selectedId !== pending.automationId) {
setSelectedId(pending.automationId)
setIsDetailOpen(true)
return
}
if (!pending.runId) {
setIsDetailOpen(true)
setActivePaneTab('overview')
setSelectedAutomationRunPageId(null)
setPendingAutomationRunNavigation(null)
return
}
if (
selectedAutomationRuns.notice &&
selectedAutomationRuns.automationId === pending.automationId
) {
setIsDetailOpen(true)
setActivePaneTab('runs')
setSelectedAutomationRunPageId(null)
setPendingAutomationRunNavigation(null)
return
}
if (selectedAutomationRuns.automationId !== pending.automationId) {
return
}
setIsDetailOpen(true)
setActivePaneTab('runs')
const pendingRun = selectedRuns.find((run) => run.id === pending.runId)
if (pendingRun) {
setSelectedAutomationRunPageId(pending.runId)
setPendingAutomationRunNavigation(null)
return
}
setSelectedAutomationRunPageId(null)
setPendingAutomationRunNavigation(null)
toast.message(
translate(
'auto.components.automations.AutomationsPage.pendingAutomationRunMissing',
'Run history no longer available.'
)
)
}, [
automations,
automationHostTargetKey,
isLoading,
pendingAutomationRunNavigation,
selectExternalKey,
selectedAutomationRuns.automationId,
selectedAutomationRuns.notice,
selectedExternalKey,
selectedId,
selectedRuns,
setActivePaneTab,
setIsDetailOpen,
setPendingAutomationRunNavigation,
setSelectedAutomationRunPageId,
setSelectedId
])
const activeTerminalTabIds = useMemo(() => {
const ids = new Set<string>()
for (const tabs of Object.values(unifiedTabsByWorktree)) {
for (const tab of tabs) {
if (tab.contentType === 'terminal') {
ids.add(tab.entityId)
}
}
}
return ids
}, [unifiedTabsByWorktree])
const selectedAutomationRunPageWorktree = selectedAutomationRunPage?.workspaceId
? selectedRow
? (worktreeForRow(
selectedRow,
repoForRow(selectedRow),
selectedAutomationRunPage.workspaceId
) ?? null)
: (worktreeMap.get(selectedAutomationRunPage.workspaceId) ?? null)
: null
const selectedAutomationRunPageWorkspaceDisplay = selectedAutomationRunPage
? getAutomationRunWorkspaceDisplay({
run: selectedAutomationRunPage,
worktree: selectedAutomationRunPageWorktree
})
: null
const selectedAutomationRunPageOpenTabId = selectedAutomationRunPage
? getAutomationRunOpenTabId(selectedAutomationRunPage)
: null
const selectedAutomationRunPageViewState = selectedAutomationRunPage
? getAutomationRunViewState({
run: selectedAutomationRunPage,
workspaceExists: Boolean(selectedAutomationRunPageWorktree),
terminalTargetExists: canOpenAutomationRunOpenTarget({
run: selectedAutomationRunPage,
terminalTabExists: selectedAutomationRunPageOpenTabId
? activeTerminalTabIds.has(selectedAutomationRunPageOpenTabId)
: false,
currentLayout: selectedAutomationRunPageOpenTabId
? terminalLayoutsByTabId[selectedAutomationRunPageOpenTabId]
: null,
livePtyIds: selectedAutomationRunPageOpenTabId
? (ptyIdsByTabId[selectedAutomationRunPageOpenTabId] ?? [])
: []
})
})
: null
const canRerunSelectedAutomationRunPage =
selectedAutomationRunPage !== null &&
canRerunAutomationRun({ automation: selected, run: selectedAutomationRunPage })
const isSelectedAutomationRunPageRerunPending =
selectedAutomationRunPage !== null && rerunRunIdsInFlight.has(selectedAutomationRunPage.id)
return {
selectedAutomationRunPageWorkspaceDisplay,
selectedAutomationRunPageViewState,
canRerunSelectedAutomationRunPage,
isSelectedAutomationRunPageRerunPending
}
}
export type AutomationRunPageState = ReturnType<typeof useAutomationRunPageState>
@@ -0,0 +1,13 @@
import type { TaskSourceHostAvailability } from '../task-source-context-summary'
import type { AutomationListRow } from './automation-list-row-identity'
import { useAutomationSourceHostAvailability } from './use-automation-source-host-availability'
/** Keeps source-host probing behind the page controller's small state contract. */
export function useAutomationSourceAvailability(rows: readonly AutomationListRow[]) {
const automationSourceHostAvailabilityByRowKey = useAutomationSourceHostAvailability(rows)
return { automationSourceHostAvailabilityByRowKey }
}
export type AutomationSourceAvailability = {
automationSourceHostAvailabilityByRowKey: ReadonlyMap<string, TaskSourceHostAvailability[]>
}
@@ -0,0 +1,109 @@
import { createAutomationManagementActions } from './automation-management-actions'
import { createAutomationRunActions } from './automation-run-actions'
import { createAutomationRunWorkspaceAction } from './automation-run-workspace-action'
import { createAutomationSaveAction } from './automation-save-action'
import { useAutomationDraftEffects } from './use-automation-draft-effects'
import { useAutomationEditorActions } from './use-automation-editor-actions'
import { useAutomationRunPageState } from './use-automation-run-page-state'
import { useAutomationSourceAvailability } from './use-automation-source-availability'
import { useAutomationsPageEscape } from './use-automations-page-escape'
import { useAutomationsPageListState } from './use-automations-page-list-state'
import { useAutomationsPageLocalState } from './use-automations-page-local-state'
import { useAutomationsPageDestinationState } from './use-automations-page-destination-state'
import { useAutomationsPageDestinationForm } from './use-automations-page-destination-form'
import { useAutomationsPagePresentationState } from './use-automations-page-presentation-state'
import { useAutomationsPageRefresh } from './use-automations-page-refresh'
import { useAutomationsPageSetupState } from './use-automations-page-setup-state'
import { useAutomationsPageStoreState } from './use-automations-page-store-state'
import { useExternalAutomationActions } from './use-external-automation-actions'
export function useAutomationsPageController() {
const store = useAutomationsPageStoreState()
const local = useAutomationsPageLocalState(store)
const list = useAutomationsPageListState({ store, local })
const destination = useAutomationsPageDestinationState({ store, local, list })
const destinationForm = useAutomationsPageDestinationForm({
store,
local,
list,
base: destination
})
const setup = useAutomationsPageSetupState({ store, local, list })
const runPage = useAutomationRunPageState({ store, local, list, setup })
const sourceAvailability = useAutomationSourceAvailability(list.visibleRows)
const presentation = useAutomationsPagePresentationState({
store,
local,
list,
destination,
destinationForm,
sourceAvailability
})
const pageRefresh = useAutomationsPageRefresh({
store,
local,
list,
destination
})
const draftEffects = useAutomationDraftEffects({
store,
local,
setup,
destination,
destinationForm,
pageRefresh
})
const editorActions = useAutomationEditorActions({
store,
local,
destination,
destinationForm
})
const saveAutomation = createAutomationSaveAction({
store,
local,
list,
setup,
destination,
destinationForm,
pageRefresh
})
const actionContext = {
store,
local,
list,
setup,
destination,
destinationForm,
sourceAvailability,
presentation,
pageRefresh
}
const managementActions = createAutomationManagementActions(actionContext)
const runActions = createAutomationRunActions(actionContext)
const externalActions = useExternalAutomationActions(actionContext)
const openRunWorkspace = createAutomationRunWorkspaceAction(actionContext)
useAutomationsPageEscape({ store, local })
return {
store,
local,
list,
destination,
destinationForm,
setup,
runPage,
sourceAvailability,
presentation,
pageRefresh,
draftEffects,
editorActions,
saveAutomation,
managementActions,
runActions,
externalActions,
openRunWorkspace
}
}
export type AutomationsPageController = ReturnType<typeof useAutomationsPageController>
@@ -0,0 +1,237 @@
import { useCallback, useEffect, useMemo } from 'react'
import {
getRepoExecutionHostId,
getWorktreeExecutionHostId,
parseExecutionHostId,
toRuntimeExecutionHostId
} from '../../../../shared/execution-host'
import { translate } from '@/i18n/i18n'
import { getAutomationCreateRepos } from './automation-create-projects'
import {
automationCreateEligibleProjects,
automationCreateHostStableKey,
automationCreateProjectMismatch,
resolveAutomationCreateDestination,
type AutomationCreateDestination
} from './automation-create-destination'
import {
getAutomationAuthorityTarget,
getAutomationListTarget,
getAutomationOwnerTarget,
getAutomationTargetFromHostId,
type AutomationHostTarget
} from './automation-host-client'
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
import { capturedAutomationOwner, type AutomationCapturedOwner } from './automation-captured-owner'
import { buildAutomationRunContextForRepo } from './automation-run-context'
import type { AutomationCreateDestinationControl } from './use-automation-create-destination'
import type { AutomationsPageDestinationState } from './use-automations-page-destination-state'
import type { AutomationsPageListState } from './use-automations-page-list-state'
import type { AutomationsPageLocalState } from './use-automations-page-local-state'
import type { AutomationsPageStoreState } from './use-automations-page-store-state'
/** Editor project/workspace projections for the captured create or edit host. */
export function useAutomationsPageDestinationForm({
store,
local,
list,
base
}: {
store: AutomationsPageStoreState
local: AutomationsPageLocalState
list: AutomationsPageListState
base: AutomationsPageDestinationState
}) {
const {
repos,
projectHostSetups,
worktreesByRepo,
fetchRuntimeEnvironmentRepos,
fetchWorktrees,
settings
} = store
const {
createTarget,
editingAutomationId,
editingRowKey,
editingHostStableKey,
setEditingHostStableKey,
setEditingDestination,
setDraft,
draftRef
} = local
const { visibleRows, hostCatalog, capturedAutomationOwners } = list
const { automationHostTarget, automationHostTargetForRowKey, repoTables, editorProjects } = base
const editingRow = editingRowKey
? (visibleRows.find((row) => row.key === editingRowKey) ?? null)
: null
const editingRowCapturedOwner: AutomationCapturedOwner['owner'] = capturedAutomationOwner(
capturedAutomationOwners,
editingRowKey
).owner
const automationDialogTarget: AutomationHostTarget = (() => {
if (editingAutomationId === null) {
return getAutomationListTarget(settings)
}
if (editingRow && !editingRowCapturedOwner) {
return getAutomationOwnerTarget(editingRow.automation, automationHostTarget)
}
return (
automationHostTargetForRowKey(editingRowKey) ??
getAutomationTargetFromHostId(editingRow?.automation.runContext?.hostId)
)
})()
const isOrcaForm = createTarget === 'orca' && local.editingExternalTarget === null
const dialogAuthorityRepos = getAutomationCreateRepos(repos, automationDialogTarget)
const dialogAuthorityKey = automationAuthorityCatalogKey(
automationDialogTarget.kind === 'environment'
? { kind: 'runtime', environmentId: automationDialogTarget.environmentId }
: { kind: 'desktop' }
)
const editHostEntries = hostCatalog.entries
const editHostResolution = resolveAutomationCreateDestination(
editingHostStableKey
? editHostEntries.find((entry) => entry.stableKey === editingHostStableKey)
: null
)
const editHostProjects =
editHostResolution.status === 'ready'
? automationCreateEligibleProjects(
repoTables,
editHostResolution,
getAutomationCreateRepos(
repos,
getAutomationAuthorityTarget(editHostResolution.authority)
)
)
: dialogAuthorityRepos
const editMoveTargetEntry =
editHostResolution.status === 'ready' &&
automationAuthorityCatalogKey(editHostResolution.authority) !== dialogAuthorityKey
? editHostResolution.entry
: null
const editDestinationRuntimeEnvironmentId =
editHostResolution.status === 'ready' && editHostResolution.authority.kind === 'runtime'
? editHostResolution.authority.environmentId
: null
useEffect(() => {
if (editDestinationRuntimeEnvironmentId) {
void fetchRuntimeEnvironmentRepos(editDestinationRuntimeEnvironmentId)
}
}, [editDestinationRuntimeEnvironmentId, fetchRuntimeEnvironmentRepos])
const handleEditHostChange = (stableKey: string): void => {
setEditingHostStableKey(stableKey)
const resolved = resolveAutomationCreateDestination(
editHostEntries.find((entry) => entry.stableKey === stableKey)
)
const projectId = draftRef.current.projectId
if (
projectId &&
resolved.status === 'ready' &&
!automationCreateProjectMismatch(repoTables, resolved, projectId)
) {
setEditingDestination({ projectId, destination: resolved })
if (editingHostStableKey !== stableKey) {
setDraft((current) => ({ ...current, workspaceId: '', baseBranch: '' }))
if (resolved.authority.kind === 'runtime') {
void fetchWorktrees(projectId, {
executionHostId: toRuntimeExecutionHostId(resolved.authority.environmentId)
})
}
}
return
}
setEditingDestination(null)
setDraft((current) => ({ ...current, projectId: '', workspaceId: '', baseBranch: '' }))
}
const editDestinationControl: AutomationCreateDestinationControl = {
entries: editHostEntries,
resolution: editHostResolution,
onSelect: handleEditHostChange,
projects: editHostProjects,
moveWarning: editMoveTargetEntry
? translate(
'auto.components.automations.createDestination.move',
'Saving creates this automation on {host} and deletes the original and its run history.'
).replace('{host}', () => editMoveTargetEntry.authorityLabel)
: null
}
const dialogRepos = isOrcaForm
? editingAutomationId !== null
? editHostProjects
: editorProjects
: getAutomationCreateRepos(repos, { kind: 'local' })
const dialogWorktrees = useMemo(() => {
const candidates = worktreesByRepo[draftRef.current.projectId] ?? []
const project = dialogRepos.find((repo) => repo.id === draftRef.current.projectId)
if (!project) {
return candidates
}
const hostId = getRepoExecutionHostId(project)
const parsedHost = parseExecutionHostId(hostId)
return candidates.filter((worktree) => {
if (getWorktreeExecutionHostId(worktree, project) === hostId) {
return true
}
return (
parsedHost?.kind === 'runtime' &&
worktree.runtimeOwnerEnvironmentId === parsedHost.environmentId
)
})
}, [dialogRepos, draftRef, worktreesByRepo])
const destinationForProject = useCallback(
(projectId: string, hostStableKey?: string | null): AutomationCreateDestination | null => {
const selectedEntry = hostStableKey
? hostCatalog.entries.find((candidate) => candidate.stableKey === hostStableKey)
: null
if (hostStableKey) {
const selected = resolveAutomationCreateDestination(selectedEntry)
return selected.status === 'ready' &&
!automationCreateProjectMismatch(repoTables, selected, projectId)
? selected
: null
}
const runContext = buildAutomationRunContextForRepo({
repoId: projectId,
repos,
projectHostSetups
})
if (!runContext) {
return null
}
const stableKey = automationCreateHostStableKey(runContext.hostId)
const entry = stableKey
? hostCatalog.entries.find((candidate) => candidate.stableKey === stableKey)
: undefined
const resolved = resolveAutomationCreateDestination(entry)
return resolved.status === 'ready' ? resolved : null
},
[hostCatalog.entries, projectHostSetups, repoTables, repos]
)
return {
editingRow,
editingRowCapturedOwner,
automationDialogTarget,
isOrcaForm,
dialogAuthorityRepos,
dialogAuthorityKey,
editHostEntries,
editHostResolution,
editHostProjects,
editMoveTargetEntry,
editDestinationRuntimeEnvironmentId,
handleEditHostChange,
editDestinationControl,
dialogRepos,
dialogWorktrees,
destinationForProject,
projectHostSetups,
settings
}
}
export type AutomationsPageDestinationFormState = ReturnType<
typeof useAutomationsPageDestinationForm
>
@@ -0,0 +1,214 @@
import { useCallback, useEffect, useMemo } from 'react'
import type {
AutomationAuthorityRef,
StableAutomationCatalogRef
} from '../../../../shared/automation-owner-ref'
import {
getWorktreeExecutionHostId,
toRuntimeExecutionHostId
} from '../../../../shared/execution-host'
import {
automationCreateHostOffered,
automationCreateHostStableKey
} from './automation-create-destination'
import { getAutomationHostTargetFromKey, type AutomationHostTarget } from './automation-host-client'
import {
capturedAutomationOwner,
isAutomationActionEnabled,
type AutomationRowAction
} from './automation-captured-owner'
import {
automationRowCatalogRef,
automationWriteChangeEvent
} from './automation-write-invalidation'
import { automationRowRecoveryHost } from './automation-notice-recovery-host'
import type {
AutomationActionNotice,
AutomationDispatchContext
} from './automation-row-action-dispatch'
import {
automationRuntimePairingRevision,
groupReposByAutomationAuthority
} from './automation-authority-identity'
import { getDefaultWorktree } from './automation-draft-model'
import type { AutomationsPageListState } from './use-automations-page-list-state'
import type { AutomationsPageLocalState } from './use-automations-page-local-state'
import type { AutomationsPageStoreState } from './use-automations-page-store-state'
import { useAutomationCreateDestination } from './use-automation-create-destination'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import type { AutomationAuthorityChangeReason } from './automation-host-invalidation'
/** Host destination, owner fencing, and authority-scoped project/workspace choices. */
export function useAutomationsPageDestinationState({
store,
local,
list
}: {
store: AutomationsPageStoreState
local: AutomationsPageLocalState
list: AutomationsPageListState
}) {
const {
repos,
worktreesByRepo,
activeWorktreeId,
repoMap,
worktreeMap,
runtimeEnvironments,
fetchRuntimeEnvironmentRepos
} = store
const { createOpen, createTarget, editingAutomationId, editingRowKey, setOwnerAction } = local
const { hostCatalog, capturedAutomationOwners } = list
const automationHostTarget = useMemo(
() => getAutomationHostTargetFromKey(local.automationHostTargetKey),
[local.automationHostTargetKey]
)
const automationAuthority = useMemo((): AutomationAuthorityRef => {
const selectedAuthority = hostCatalog.resolution.entry?.stableRef.authority
if (selectedAuthority?.kind !== 'runtime') {
return { kind: 'desktop' }
}
const environmentId = selectedAuthority.environmentId
return {
kind: 'runtime',
environmentId,
pairingRevision: automationRuntimePairingRevision(runtimeEnvironments, environmentId)
}
}, [hostCatalog.resolution.entry, runtimeEnvironments])
const repoTables = useMemo(() => groupReposByAutomationAuthority(repos), [repos])
const activeWorkspaceHostStableKey = useMemo(() => {
const worktree = activeWorktreeId ? worktreeMap.get(activeWorktreeId) : null
return worktree
? automationCreateHostStableKey(
getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId))
)
: null
}, [activeWorktreeId, repoMap, worktreeMap])
const createDestination = useAutomationCreateDestination({
open: createOpen && editingAutomationId === null && createTarget === 'orca',
catalog: hostCatalog.catalog,
entries: hostCatalog.entries,
filterStableKey: hostCatalog.resolution.entry?.stableKey ?? null,
activeWorkspaceStableKey: activeWorkspaceHostStableKey,
repoTables,
projects: repos
})
const editorProjects = createDestination.control.projects
const createDestinationRuntimeEnvironmentId =
createDestination.control.resolution.status === 'ready' &&
createDestination.control.resolution.authority.kind === 'runtime'
? createDestination.control.resolution.authority.environmentId
: null
const createDestinationHostId = createDestinationRuntimeEnvironmentId
? toRuntimeExecutionHostId(createDestinationRuntimeEnvironmentId)
: undefined
useEffect(() => {
if (createDestinationRuntimeEnvironmentId) {
void fetchRuntimeEnvironmentRepos(createDestinationRuntimeEnvironmentId)
}
}, [createDestinationRuntimeEnvironmentId, fetchRuntimeEnvironmentRepos])
const automationHostTargetForRowKey = useCallback(
(rowKey: string | null): AutomationHostTarget | null => {
const owner = capturedAutomationOwner(capturedAutomationOwners, rowKey).owner
if (owner?.authority.kind === 'runtime') {
return { kind: 'environment', environmentId: owner.authority.environmentId }
}
return owner ? { kind: 'local' } : automationHostTarget
},
[automationHostTarget, capturedAutomationOwners]
)
const automationHostTargetFor = useCallback(
(row: { key: string }): AutomationHostTarget | null => automationHostTargetForRowKey(row.key),
[automationHostTargetForRowKey]
)
const automationDispatchContext = useMemo<AutomationDispatchContext>(
() => ({ capturedOwners: capturedAutomationOwners, authority: automationAuthority }),
[automationAuthority, capturedAutomationOwners]
)
const rowRecoveryHost = useCallback(
(rowKey: string | null): AutomationHostCatalogEntry | null =>
automationRowRecoveryHost(
hostCatalog.catalog,
capturedAutomationOwner(capturedAutomationOwners, rowKey),
automationAuthority
),
[automationAuthority, capturedAutomationOwners, hostCatalog.catalog]
)
const reportOwnerAction = useCallback(
(rowKey: string | null, notice: AutomationActionNotice | null): void => {
setOwnerAction(notice ? { notice, host: rowRecoveryHost(rowKey) } : null)
},
[rowRecoveryHost, setOwnerAction]
)
const editorRecoveryHost = useMemo((): AutomationHostCatalogEntry | null => {
if (editingAutomationId !== null) {
return rowRecoveryHost(editingRowKey)
}
const resolution = createDestination.control.resolution
return resolution.status === 'ready' ? resolution.entry : null
}, [createDestination.control.resolution, editingAutomationId, editingRowKey, rowRecoveryHost])
const notifyAuthorityChange = hostCatalog.notifyAuthorityChange
const invalidateWrittenHost = useCallback(
(ref: StableAutomationCatalogRef | null, reason: AutomationAuthorityChangeReason): void => {
notifyAuthorityChange(automationWriteChangeEvent(ref, automationAuthority, reason))
},
[automationAuthority, notifyAuthorityChange]
)
const invalidateRowHost = useCallback(
(rowKey: string | null, reason: AutomationAuthorityChangeReason): void => {
const captured = capturedAutomationOwner(capturedAutomationOwners, rowKey)
invalidateWrittenHost(automationRowCatalogRef(captured, automationAuthority), reason)
},
[automationAuthority, capturedAutomationOwners, invalidateWrittenHost]
)
const isAutomationRowActionEnabled = useCallback(
(row: { key: string }, action: AutomationRowAction): boolean =>
isAutomationActionEnabled(capturedAutomationOwner(capturedAutomationOwners, row.key), action),
[capturedAutomationOwners]
)
const getDefaultTarget = useCallback(() => {
const activeWorktree = activeWorktreeId ? worktreeMap.get(activeWorktreeId) : null
const activeRepo = activeWorktree ? (repoMap.get(activeWorktree.repoId) ?? null) : null
const eligibleActiveRepo =
activeRepo && editorProjects.some((project) => project.id === activeRepo.id)
? activeRepo
: null
const fallbackRepo = eligibleActiveRepo ?? editorProjects[0] ?? null
const fallbackWorktrees = fallbackRepo ? (worktreesByRepo[fallbackRepo.id] ?? []) : []
const targetWorktree =
getDefaultWorktree(fallbackWorktrees) ??
(activeWorktree && activeWorktree.repoId === fallbackRepo?.id ? activeWorktree : null)
return {
projectId: fallbackRepo?.id ?? targetWorktree?.repoId ?? '',
workspaceId: targetWorktree?.id ?? ''
}
}, [activeWorktreeId, editorProjects, repoMap, worktreeMap, worktreesByRepo])
return {
automationHostTarget,
automationAuthority,
repoTables,
activeWorkspaceHostStableKey,
createDestination,
editorProjects,
createDestinationRuntimeEnvironmentId,
createDestinationHostId,
automationHostTargetForRowKey,
automationHostTargetFor,
automationDispatchContext,
rowRecoveryHost,
reportOwnerAction,
editorRecoveryHost,
invalidateWrittenHost,
invalidateRowHost,
isAutomationRowActionEnabled,
getDefaultTarget,
canCreateAutomation: hostCatalog.entries.some(automationCreateHostOffered),
setOwnerAction
}
}
export type AutomationsPageDestinationState = ReturnType<typeof useAutomationsPageDestinationState>
@@ -0,0 +1,54 @@
import { useEffect } from 'react'
import type { AutomationsPageLocalState } from './use-automations-page-local-state'
import type { AutomationsPageStoreState } from './use-automations-page-store-state'
export function useAutomationsPageEscape({
store,
local
}: {
store: AutomationsPageStoreState
local: AutomationsPageLocalState
}): void {
const { closeAutomationsPage } = store
const { createOpen, deleteTarget, externalDeleteTarget } = local
useEffect(() => {
if (createOpen || deleteTarget || externalDeleteTarget) {
return
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key !== 'Escape' || event.defaultPrevented) {
return
}
const target = event.target
if (target instanceof Element) {
// Fields that clear their own value on Escape consume this press.
if (target.getAttribute('data-escape-clears-value') === 'true') {
return
}
// Esc first exits field focus, then exits the page, matching the Tasks page.
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable) ||
target.matches('[contenteditable="true"], [contenteditable=""]')
) {
event.preventDefault()
if (target instanceof HTMLElement) {
target.blur()
}
return
}
}
event.preventDefault()
closeAutomationsPage()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [closeAutomationsPage, createOpen, deleteTarget, externalDeleteTarget])
}
@@ -0,0 +1,161 @@
import { useCallback, useMemo } from 'react'
import { getWorktreePathBasenameFromId } from '../../../../shared/worktree/id'
import { buildExternalAutomationListEntries } from './external-automation-list-entries'
import { externalAutomationScopeEntries } from './external-automation-scope-gating'
import { externalAutomationUncheckedNotice } from './external-automation-unchecked-hosts'
import {
filterAutomationListRows,
filterExternalAutomationListEntries
} from './automation-list-view'
import { unscopedAutomationListRows } from './automation-list-row-identity'
import { useAutomationHostCatalog } from './use-automation-host-catalog'
import { useAutomationListSearch } from './use-automation-list-search'
import { useScopedExternalAutomations } from './use-scoped-external-automations'
import type { AutomationsPageLocalState } from './use-automations-page-local-state'
import type { AutomationsPageStoreState } from './use-automations-page-store-state'
/** Host-qualified rows, external scopes, selection, and list-search projection. */
export function useAutomationsPageListState({
store,
local
}: {
store: AutomationsPageStoreState
local: AutomationsPageLocalState
}) {
const { repoMap, worktreeMap, repoForRow, worktreeForRow, selectedId, setSelectedId } = store
const {
automations,
failedAuthorityKeys,
listSearchQuery,
listFilter,
selectedRowKey,
selectedExternalKey,
selectedAutomationRuns,
workspaceNameCacheRef,
selectExternalKey,
setSelectedAutomationRunPageId,
setSelectedRowKey
} = local
const hostCatalog = useAutomationHostCatalog({ failedAuthorityKeys })
const externalScopeEntries = useMemo(
() => externalAutomationScopeEntries(hostCatalog.entries, hostCatalog.resolution),
[hostCatalog.entries, hostCatalog.resolution]
)
const scopedExternal = useScopedExternalAutomations({
catalogEntries: hostCatalog.entries,
scopeEntries: externalScopeEntries
})
const unscopedRows = useMemo(() => unscopedAutomationListRows(automations), [automations])
const visibleRows = hostCatalog.rows.answered ? hostCatalog.rows.rows : unscopedRows
const capturedAutomationOwners = hostCatalog.rows.capturedOwners
const externalAutomationEntries = useMemo(
() => buildExternalAutomationListEntries(scopedExternal.managers),
[scopedExternal.managers]
)
const attributeFilteredRows = useMemo(
() => filterAutomationListRows(visibleRows, listFilter),
[listFilter, visibleRows]
)
const attributeFilteredExternalEntries = useMemo(
() => filterExternalAutomationListEntries(externalAutomationEntries, listFilter),
[externalAutomationEntries, listFilter]
)
const selectAutomationRow = useCallback(
(rowKey: string | null): void => {
const row = rowKey === null ? null : visibleRows.find((candidate) => candidate.key === rowKey)
setSelectedAutomationRunPageId(null)
setSelectedRowKey(row?.key ?? null)
setSelectedId(row?.automation.id ?? null)
},
[setSelectedAutomationRunPageId, setSelectedId, setSelectedRowKey, visibleRows]
)
const selectedExternal =
externalAutomationEntries.find((entry) => entry.key === selectedExternalKey) ??
(visibleRows.length === 0 ? (externalAutomationEntries[0] ?? null) : null)
const selectedRow =
selectedExternal === null
? selectedId
? (visibleRows.find(
(row) => row.key === selectedRowKey && row.automation.id === selectedId
) ??
visibleRows.find((row) => row.automation.id === selectedId) ??
null)
: (visibleRows[0] ?? null)
: null
const selected = selectedRow?.automation ?? null
const {
isListSearchQueryTooLarge,
isListSearchActive,
filteredRows,
filteredExternalAutomationEntries,
hasListItems,
hasFilteredListItems,
searchCounts
} = useAutomationListSearch({
listSearchQuery,
rows: attributeFilteredRows,
externalAutomationEntries: attributeFilteredExternalEntries,
repoMap,
worktreeMap,
selectedRowKey: selectedRow?.key ?? null,
selectedExternalKey,
selectAutomationRow,
selectExternalKey
})
const selectedAutomationRunsWithWorkspaceNames = useMemo(
() =>
selectedAutomationRuns.runs.map((run) => {
if (!run.workspaceId || run.workspaceDisplayName?.trim()) {
return run
}
const displayName =
(selectedRow
? worktreeForRow(selectedRow, repoForRow(selectedRow), run.workspaceId)?.displayName
: worktreeMap.get(run.workspaceId)?.displayName) ??
workspaceNameCacheRef.current.get(run.workspaceId) ??
getWorktreePathBasenameFromId(run.workspaceId)
const trimmedDisplayName = displayName?.trim()
return trimmedDisplayName ? { ...run, workspaceDisplayName: trimmedDisplayName } : run
}),
[
repoForRow,
selectedAutomationRuns.runs,
selectedRow,
worktreeForRow,
worktreeMap,
workspaceNameCacheRef
]
)
const externalManagersUncheckedNotice = useMemo(
() => externalAutomationUncheckedNotice(scopedExternal.failures, hostCatalog.entries),
[hostCatalog.entries, scopedExternal.failures]
)
return {
hostCatalog,
externalScopeEntries,
scopedExternal,
unscopedRows,
visibleRows,
capturedAutomationOwners,
externalAutomationEntries,
externalManagersUncheckedNotice,
selectedExternal,
selectedRow,
selected,
selectedAutomationRunsWithWorkspaceNames,
isListSearchQueryTooLarge,
isListSearchActive,
filteredRows,
// Keep the legacy projection available to integrations that still consume
// bare automations; the table itself uses host-qualified rows.
filteredAutomations: filteredRows.map((row) => row.automation),
filteredExternalAutomationEntries,
hasListItems,
hasFilteredListItems,
searchCounts,
selectAutomationRow
}
}
export type AutomationsPageListState = ReturnType<typeof useAutomationsPageListState>
@@ -0,0 +1,229 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type {
Automation,
ExternalAutomationJob,
ExternalAutomationManager
} from '../../../../shared/automations-types'
import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
import type { AutomationCreateTarget, AutomationDraft } from './AutomationEditorDialog'
import { AUTOMATION_DEFAULT_TIME } from './automation-draft-model'
import type { AutomationActionNotice } from './automation-row-action-dispatch'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import type { AutomationCreateDestination } from './automation-create-destination'
import type { AutomationListRow } from './automation-list-row-identity'
import { EMPTY_AUTOMATION_LIST_FILTER, type AutomationListFilter } from './automation-list-view'
import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state'
import type { ExternalAutomationScope } from './external-automation-scope-client'
import type { SelectedAutomationRunHistoryOutcome } from './use-selected-automation-run-history'
import type { AutomationsPageStoreState } from './use-automations-page-store-state'
/** React-owned values for the page; host/catalog state remains in dedicated hooks. */
export function useAutomationsPageLocalState(store: AutomationsPageStoreState) {
const { defaultAgent, setSelectedId } = store
const [automations, setAutomations] = useState<Automation[]>([])
const [automationHostTargetKey, setAutomationHostTargetKey] = useState<string | null>(null)
const [selectedAutomationRuns, setSelectedAutomationRuns] =
useState<SelectedAutomationRunHistoryOutcome>({
automationId: null,
rowKey: null,
ownerKey: null,
runs: [],
notice: null
})
const [runHistoryReloadToken, setRunHistoryReloadToken] = useState(0)
const [failedAuthorityKeys, setFailedAuthorityKeys] = useState<ReadonlySet<string>>(
() => new Set()
)
const [ownerAction, setOwnerAction] = useState<{
notice: AutomationActionNotice
host: AutomationHostCatalogEntry | null
} | null>(null)
const [editorNotice, setEditorNotice] = useState<AutomationActionNotice | null>(null)
const [editorNoticeHost, setEditorNoticeHost] = useState<AutomationHostCatalogEntry | null>(null)
const [externalActionKey, setExternalActionKey] = useState<string | null>(null)
const [rerunRunIdsInFlight, setRerunRunIdsInFlight] = useState<ReadonlySet<string>>(
() => new Set()
)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [listSearchQuery, setListSearchQuery] = useState('')
const [listFilter, setListFilter] = useState<AutomationListFilter>(EMPTY_AUTOMATION_LIST_FILTER)
const [createOpen, setCreateOpen] = useState(false)
const [createTarget, setCreateTarget] = useState<AutomationCreateTarget>('orca')
const [editingAutomationId, setEditingAutomationId] = useState<string | null>(null)
const [editingRowKey, setEditingRowKey] = useState<string | null>(null)
const [editingDestination, setEditingDestination] = useState<{
projectId: string
destination: AutomationCreateDestination
} | null>(null)
const [editingHostStableKey, setEditingHostStableKey] = useState<string | null>(null)
const moveCreationKeysRef = useRef(new Map<string, string>())
const [relativeNow, setRelativeNow] = useState(() => Date.now())
const [activePaneTab, setActivePaneTab] = useState<AutomationPaneTab>('overview')
const [selectedAutomationRunPageId, setSelectedAutomationRunPageId] = useState<string | null>(
null
)
const [selectedRowKey, setSelectedRowKey] = useState<string | null>(null)
const [selectedExternalKey, setSelectedExternalKey] = useState<string | null>(null)
const [selectedExternalRunPage, setSelectedExternalRunPage] =
useState<SelectedExternalRunPage | null>(null)
const [isDetailOpen, setIsDetailOpen] = useState(false)
const selectedExternalKeyRef = useRef<string | null>(null)
useEffect(() => {
selectedExternalKeyRef.current = selectedExternalKey
}, [selectedExternalKey])
const selectAutomationId = useCallback(
(automationId: string | null): void => {
setSelectedAutomationRunPageId(null)
setSelectedRowKey(null)
setSelectedId(automationId)
},
[setSelectedId]
)
const selectExternalKey = useCallback((externalKey: string | null): void => {
setSelectedExternalRunPage(null)
setSelectedExternalKey(externalKey)
}, [])
const [draftAtOpen, setDraftAtOpen] = useState<AutomationDraft | null>(null)
const [deleteTarget, setDeleteTarget] = useState<AutomationListRow | null>(null)
const [externalDeleteTarget, setExternalDeleteTarget] = useState<{
manager: ExternalAutomationManager
job: ExternalAutomationJob
scope: ExternalAutomationScope
} | null>(null)
useContextualTour(
'automations',
!isLoading && !createOpen && !deleteTarget && !externalDeleteTarget,
'automations_open'
)
const [editingExternalTarget, setEditingExternalTarget] = useState<{
manager: ExternalAutomationManager
job: ExternalAutomationJob
scope: ExternalAutomationScope
} | null>(null)
const [dontAskDeleteAgain, setDontAskDeleteAgain] = useState(false)
const editRequestRef = useRef(0)
const deleteConfirmButtonRef = useRef<HTMLButtonElement>(null)
const externalDeleteConfirmButtonRef = useRef<HTMLButtonElement>(null)
const rerunRunIdsInFlightRef = useRef<Set<string>>(new Set())
const workspaceNameCacheRef = useRef<Map<string, string>>(new Map())
const setupDecisionPolicyDefaultRef = useRef<AutomationDraft['setupDecision']>(undefined)
const setupDecisionDefaultSignatureRef = useRef<string | null>(null)
const setupDecisionTouchedRef = useRef(false)
const automationHookCheckPromisesRef = useRef<
Map<string, Promise<{ hooks: OrcaHooks | null; ok: boolean }>>
>(new Map())
const [automationYamlHooksByRepoKey, setAutomationYamlHooksByRepoKey] = useState<
Record<string, OrcaHooks | null>
>({})
const [draft, setDraft] = useState<AutomationDraft>({
name: '',
prompt: '',
agentId: defaultAgent,
projectId: '',
workspaceMode: 'existing',
workspaceId: '',
baseBranch: '',
setupDecision: undefined,
reuseSession: false,
precheckCommand: '',
precheckTimeoutSeconds: '60',
preset: 'weekdays',
time: AUTOMATION_DEFAULT_TIME,
dayOfWeek: '1',
customSchedule: '',
missedRunGraceMinutes: '720',
scheduleWarning: null
})
const draftRef = useRef(draft)
draftRef.current = draft
return {
automations,
setAutomations,
automationHostTargetKey,
setAutomationHostTargetKey,
selectedAutomationRuns,
setSelectedAutomationRuns,
runHistoryReloadToken,
setRunHistoryReloadToken,
failedAuthorityKeys,
setFailedAuthorityKeys,
ownerAction,
setOwnerAction,
editorNotice,
setEditorNotice,
editorNoticeHost,
setEditorNoticeHost,
externalActionKey,
setExternalActionKey,
rerunRunIdsInFlight,
setRerunRunIdsInFlight,
isLoading,
setIsLoading,
isSaving,
setIsSaving,
listSearchQuery,
setListSearchQuery,
listFilter,
setListFilter,
createOpen,
setCreateOpen,
createTarget,
setCreateTarget,
editingAutomationId,
setEditingAutomationId,
editingRowKey,
setEditingRowKey,
editingDestination,
setEditingDestination,
editingHostStableKey,
setEditingHostStableKey,
moveCreationKeysRef,
relativeNow,
setRelativeNow,
activePaneTab,
setActivePaneTab,
selectedAutomationRunPageId,
setSelectedAutomationRunPageId,
selectedRowKey,
setSelectedRowKey,
selectedExternalKey,
setSelectedExternalKey,
selectedExternalRunPage,
setSelectedExternalRunPage,
isDetailOpen,
setIsDetailOpen,
selectedExternalKeyRef,
selectAutomationId,
selectExternalKey,
draftAtOpen,
setDraftAtOpen,
deleteTarget,
setDeleteTarget,
externalDeleteTarget,
setExternalDeleteTarget,
editingExternalTarget,
setEditingExternalTarget,
dontAskDeleteAgain,
setDontAskDeleteAgain,
editRequestRef,
deleteConfirmButtonRef,
externalDeleteConfirmButtonRef,
rerunRunIdsInFlightRef,
workspaceNameCacheRef,
setupDecisionPolicyDefaultRef,
setupDecisionDefaultSignatureRef,
setupDecisionTouchedRef,
automationHookCheckPromisesRef,
automationYamlHooksByRepoKey,
setAutomationYamlHooksByRepoKey,
draft,
setDraft,
draftRef
}
}
export type AutomationsPageLocalState = ReturnType<typeof useAutomationsPageLocalState>
@@ -0,0 +1,121 @@
import { useCallback, useEffect, useMemo } from 'react'
import {
getLocalExecutionHostLabel,
getRepoExecutionHostId,
parseExecutionHostId
} from '../../../../shared/execution-host'
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
import type { Repo } from '../../../../shared/repo-types'
import { getAutomationTargetAvailability } from './automation-target-availability'
import type { AutomationSourceAvailability } from './use-automation-source-availability'
import type { AutomationsPageDestinationState } from './use-automations-page-destination-state'
import type { AutomationsPageDestinationFormState } from './use-automations-page-destination-form'
import type { AutomationsPageListState } from './use-automations-page-list-state'
import type { AutomationsPageLocalState } from './use-automations-page-local-state'
import type { AutomationsPageStoreState } from './use-automations-page-store-state'
/** Display labels and action availability derived from the selected host row. */
export function useAutomationsPagePresentationState({
store,
local,
list,
destination,
destinationForm,
sourceAvailability
}: {
store: AutomationsPageStoreState
local: AutomationsPageLocalState
list: AutomationsPageListState
destination: AutomationsPageDestinationState
destinationForm: AutomationsPageDestinationFormState
sourceAvailability: AutomationSourceAvailability
}) {
const {
projectHostSetups,
sshConnectionStates,
runtimeStatusByEnvironmentId,
sshTargetLabels,
runtimeEnvironments,
settings,
repoForRow,
worktreeForRow
} = store
const { editingAutomationId, draftAtOpen, draft, activePaneTab, setActivePaneTab } = local
const { selected, selectedRow } = list
const { automationHostTargetFor } = destination
const { automationSourceHostAvailabilityByRowKey } = sourceAvailability
const selectedRepo = selectedRow ? (repoForRow(selectedRow) ?? null) : null
const selectedWorktree =
selectedRow && selected?.workspaceId
? (worktreeForRow(selectedRow, selectedRepo ?? undefined) ?? null)
: null
const selectedRunNowAvailability = selectedRow
? getAutomationTargetAvailability({
automation: selectedRow.automation,
repo: selectedRepo,
workspace: selectedWorktree,
projectHostSetups,
sshConnectionStates,
runtimeStatusByEnvironmentId,
automationHostTarget: automationHostTargetFor(selectedRow),
sourceHostAvailability: automationSourceHostAvailabilityByRowKey.get(selectedRow.key)
})
: null
const canSaveDraft =
editingAutomationId === null ||
!draftAtOpen ||
JSON.stringify(draft) !== JSON.stringify(draftAtOpen) ||
(editingAutomationId !== null &&
local.editingHostStableKey !== destination.rowRecoveryHost(local.editingRowKey)?.stableKey)
const getAutomationRepoHostLabel = useCallback(
(repo: Repo): string => {
const hostId = getRepoExecutionHostId(repo)
const parsed = parseExecutionHostId(hostId)
if (parsed?.kind === 'ssh') {
return sshTargetLabels.get(parsed.targetId) ?? parsed.targetId
}
if (parsed?.kind === 'runtime') {
return (
runtimeEnvironments.find((environment) => environment.id === parsed.environmentId)
?.name ?? parsed.environmentId
)
}
return getLocalExecutionHostLabel()
},
[runtimeEnvironments, sshTargetLabels]
)
const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings])
const hostLabelById = useMemo(() => {
const labels = new Map<string, string>([['local', getLocalExecutionHostLabel()]])
for (const [targetId, label] of sshTargetLabels) {
labels.set(`ssh:${encodeURIComponent(targetId)}`, label)
}
for (const environment of runtimeEnvironments) {
labels.set(`runtime:${encodeURIComponent(environment.id)}`, environment.name)
}
for (const [hostId, label] of hostLabelOverrides) {
labels.set(hostId, label)
}
return labels
}, [hostLabelOverrides, runtimeEnvironments, sshTargetLabels])
useEffect(() => {
if ((!selected || list.selectedExternal) && activePaneTab === 'runs') {
setActivePaneTab('overview')
}
}, [activePaneTab, list.selectedExternal, selected, setActivePaneTab])
return {
selectedRepo,
selectedWorktree,
selectedRunNowAvailability,
canSaveDraft,
getAutomationRepoHostLabel,
hostLabelById,
dialogRepos: destinationForm.dialogRepos,
dialogWorktrees: destinationForm.dialogWorktrees
}
}
export type AutomationsPagePresentationState = ReturnType<
typeof useAutomationsPagePresentationState
>
@@ -0,0 +1,166 @@
import { useCallback, useEffect, useRef } from 'react'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
import { useAppStore } from '@/store'
import { AUTOMATIONS_CHANGED_EVENT } from '@/lib/automations-changed-window-event'
import {
getAutomationHostTargetKey,
getAutomationTargetFromHostId,
listAutomationsForTarget
} from './automation-host-client'
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
import { useSelectedAutomationRunHistory } from './use-selected-automation-run-history'
import type { AutomationsPageDestinationState } from './use-automations-page-destination-state'
import type { AutomationsPageListState } from './use-automations-page-list-state'
import type { AutomationsPageLocalState } from './use-automations-page-local-state'
import type { AutomationsPageStoreState } from './use-automations-page-store-state'
/** Refreshes host-scoped rows and wires lifecycle/selection history effects. */
export function useAutomationsPageRefresh({
store,
local,
list,
destination
}: {
store: AutomationsPageStoreState
local: AutomationsPageLocalState
list: AutomationsPageListState
destination: AutomationsPageDestinationState
}) {
const { fetchAllWorktrees, startupWorktreeRefreshCompleted, pendingAutomationRunNavigation } =
store
const {
setIsLoading,
setAutomations,
setAutomationHostTargetKey,
setFailedAuthorityKeys,
isLoading,
automationHostTargetKey,
setRelativeNow,
runHistoryReloadToken,
workspaceNameCacheRef
} = local
const { scopedExternal, selectedRow } = list
const { automationHostTargetFor } = destination
const reloadExternalManagers = scopedExternal.reload
const refresh = useCallback(
async (options?: { awaitExternalManagers?: boolean }): Promise<void> => {
setIsLoading(true)
const pendingNavigation = useAppStore.getState().pendingAutomationRunNavigation
// Until a navigation names a host, the desktop is the only authority the
// legacy unscoped arm can address without guessing.
const target = pendingNavigation
? getAutomationTargetFromHostId(pendingNavigation.hostId)
: { kind: 'local' as const }
const authorityKey = automationAuthorityCatalogKey(
target.kind === 'environment'
? { kind: 'runtime', environmentId: target.environmentId }
: { kind: 'desktop' }
)
const managersSettled = reloadExternalManagers().catch(() => undefined)
try {
const nextAutomations = await listAutomationsForTarget(target)
setAutomations(nextAutomations)
setAutomationHostTargetKey(getAutomationHostTargetKey(target))
setFailedAuthorityKeys((current) => {
if (!current.has(authorityKey)) {
return current
}
const next = new Set(current)
next.delete(authorityKey)
return next
})
} catch {
setFailedAuthorityKeys((current) => new Set(current).add(authorityKey))
} finally {
setIsLoading(false)
}
if (options?.awaitExternalManagers) {
await managersSettled
}
},
[
reloadExternalManagers,
setAutomations,
setAutomationHostTargetKey,
setFailedAuthorityKeys,
setIsLoading
]
)
const hydratePersistedUIState = useCallback(async (): Promise<void> => {
useAppStore.getState().hydratePersistedUI(await window.api.ui.get(), 'sync')
}, [])
const mountedBeforeStartupWorktreeRefreshRef = useRef(!startupWorktreeRefreshCompleted)
useEffect(() => {
if (!startupWorktreeRefreshCompleted) {
return
}
if (mountedBeforeStartupWorktreeRefreshRef.current) {
mountedBeforeStartupWorktreeRefreshRef.current = false
return
}
void fetchAllWorktrees()
}, [fetchAllWorktrees, startupWorktreeRefreshCompleted])
useEffect(() => {
void refresh()
}, [refresh])
useEffect(() => {
return installWindowVisibilityInterval({
run: () => setRelativeNow(Date.now()),
intervalMs: 60 * 1000
})
}, [setRelativeNow])
useEffect(() => {
if (!pendingAutomationRunNavigation || isLoading) {
return
}
const pendingTargetKey = getAutomationHostTargetKey(
getAutomationTargetFromHostId(pendingAutomationRunNavigation.hostId)
)
if (automationHostTargetKey !== pendingTargetKey) {
void refresh()
}
}, [automationHostTargetKey, isLoading, pendingAutomationRunNavigation, refresh])
useEffect(() => {
for (const [workspaceId, worktree] of store.worktreeMap) {
const displayName = worktree.displayName.trim()
if (displayName) {
workspaceNameCacheRef.current.set(workspaceId, displayName)
}
}
}, [store.worktreeMap, workspaceNameCacheRef])
useSelectedAutomationRunHistory({
selected: selectedRow,
context: destination.automationDispatchContext,
legacyTarget: automationHostTargetFor,
navigation: pendingAutomationRunNavigation,
reloadToken: runHistoryReloadToken,
onSettled: local.setSelectedAutomationRuns
})
useEffect(() => {
const onAutomationsChanged = (): void => {
void refresh()
}
window.addEventListener(AUTOMATIONS_CHANGED_EVENT, onAutomationsChanged)
return () => window.removeEventListener(AUTOMATIONS_CHANGED_EVENT, onAutomationsChanged)
}, [refresh])
useEffect(() => {
const onVisibilityOrFocus = (): void => {
if (document.visibilityState === 'visible') {
void refresh()
}
}
window.addEventListener('focus', onVisibilityOrFocus)
document.addEventListener('visibilitychange', onVisibilityOrFocus)
return () => {
window.removeEventListener('focus', onVisibilityOrFocus)
document.removeEventListener('visibilitychange', onVisibilityOrFocus)
}
}, [refresh])
return { getDefaultTarget: destination.getDefaultTarget, refresh, hydratePersistedUIState }
}
export type AutomationsPageRefresh = ReturnType<typeof useAutomationsPageRefresh>
@@ -0,0 +1,199 @@
import { useCallback, useMemo } from 'react'
import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
import type { AutomationDraft } from './AutomationEditorDialog'
import { getAutomationHostTargetFromKey, type AutomationHostTarget } from './automation-host-client'
import { getAutomationCreateRepos } from './automation-create-projects'
import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import { getVisibleAutomationSetupDecision } from './automation-setup-decision'
import { capturedAutomationOwner, capturedAutomationOwnerKey } from './automation-captured-owner'
import type { AutomationsPageListState } from './use-automations-page-list-state'
import type { AutomationsPageLocalState } from './use-automations-page-local-state'
import type { AutomationsPageStoreState } from './use-automations-page-store-state'
/** Draft setup policy, selected history, and the page's legacy host target. */
export function useAutomationsPageSetupState({
store,
local,
list
}: {
store: AutomationsPageStoreState
local: AutomationsPageLocalState
list: AutomationsPageListState
}) {
const { repos, settings, projectHostSetups, worktreesByRepo } = store
const {
createTarget,
automationYamlHooksByRepoKey,
automationHookCheckPromisesRef,
setAutomationYamlHooksByRepoKey,
setupDecisionTouchedRef,
selectedAutomationRuns,
draft,
automationHostTargetKey,
selectedAutomationRunPageId
} = local
const {
selected,
selectedRow,
selectedAutomationRunsWithWorkspaceNames,
capturedAutomationOwners
} = list
const getDraftSetupDecisionDefault = useCallback(
(
candidate: Pick<AutomationDraft, 'projectId' | 'workspaceMode'>
): AutomationDraft['setupDecision'] => {
// During an edit the selected destination, not the ambient repo-id lookup,
// owns setup policy. Repo ids can collide across runtime authorities.
const selectedEditEntry =
local.editingAutomationId !== null && local.editingHostStableKey
? list.hostCatalog.entries.find((entry) => entry.stableKey === local.editingHostStableKey)
: null
const selectedEditAuthority = selectedEditEntry?.stableRef.authority
const setupHostId =
selectedEditAuthority?.kind === 'runtime'
? toRuntimeExecutionHostId(selectedEditAuthority.environmentId)
: undefined
const setupRepos =
selectedEditAuthority?.kind === 'runtime'
? getAutomationCreateRepos(repos, {
kind: 'environment',
environmentId: selectedEditAuthority.environmentId
})
: repos
const setupProjectHostSetups = setupHostId
? projectHostSetups.filter(
(setup) => setup.repoId !== candidate.projectId || setup.hostId === setupHostId
)
: projectHostSetups
const settingsForRepo = setupHostId
? {
...settings,
activeRuntimeEnvironmentId:
selectedEditAuthority?.kind === 'runtime' ? selectedEditAuthority.environmentId : null
}
: getSettingsForRepoRuntimeOwner({ repos, settings }, candidate.projectId)
const hookKey = `${setupHostId ?? settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${candidate.projectId}`
return getVisibleAutomationSetupDecision({
createTarget,
workspaceMode: candidate.workspaceMode,
repoId: candidate.projectId,
repos: setupRepos,
projectHostSetups: setupProjectHostSetups,
yamlHooks: automationYamlHooksByRepoKey[hookKey]
})
},
[
automationYamlHooksByRepoKey,
createTarget,
list.hostCatalog.entries,
local.editingAutomationId,
local.editingHostStableKey,
projectHostSetups,
repos,
settings
]
)
const getAutomationHooksCacheKey = useCallback(
(repoId: string, hostId?: string): string => {
if (hostId) {
return `${hostId}:${repoId}`
}
const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId)
return `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${repoId}`
},
[repos, settings]
)
const loadAutomationYamlHooksForRepo = useCallback(
async (repoId: string, hostId?: ExecutionHostId): Promise<OrcaHooks | null> => {
const key = getAutomationHooksCacheKey(repoId, hostId)
if (Object.hasOwn(automationYamlHooksByRepoKey, key)) {
return automationYamlHooksByRepoKey[key] ?? null
}
const existingPromise = automationHookCheckPromisesRef.current.get(key)
if (existingPromise) {
return (await existingPromise).hooks
}
const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId)
const promise = checkRuntimeHooks(settingsForRepo, repoId, hostId)
.then((result) => ({
hooks: result.status === 'error' ? null : ((result.hooks as OrcaHooks | null) ?? null),
ok: result.status !== 'error'
}))
.catch(() => ({ hooks: null, ok: false }))
automationHookCheckPromisesRef.current.set(key, promise)
const { hooks, ok } = await promise
automationHookCheckPromisesRef.current.delete(key)
if (!ok) {
return hooks
}
setAutomationYamlHooksByRepoKey((current) =>
Object.hasOwn(current, key) ? current : { ...current, [key]: hooks }
)
return hooks
},
[
automationHookCheckPromisesRef,
automationYamlHooksByRepoKey,
getAutomationHooksCacheKey,
repos,
setAutomationYamlHooksByRepoKey,
settings
]
)
const getDraftSetupDecisionDefaultSignature = useCallback(
(candidate: Pick<AutomationDraft, 'projectId' | 'workspaceMode'>): string =>
[
createTarget,
candidate.workspaceMode,
candidate.projectId,
getDraftSetupDecisionDefault(candidate) ?? 'none'
].join(':'),
[createTarget, getDraftSetupDecisionDefault]
)
const markSetupDecisionTouched = useCallback((): void => {
setupDecisionTouchedRef.current = true
}, [setupDecisionTouchedRef])
const selectedRunsMatchSelection =
selectedRow !== null &&
selectedAutomationRuns.rowKey === selectedRow.key &&
selectedAutomationRuns.ownerKey ===
capturedAutomationOwnerKey(capturedAutomationOwner(capturedAutomationOwners, selectedRow.key))
const selectedRunsNotice = selectedRunsMatchSelection ? selectedAutomationRuns.notice : null
const selectedRuns = useMemo(
() =>
selected && selectedRunsMatchSelection
? selectedAutomationRunsWithWorkspaceNames.filter((run) => run.automationId === selected.id)
: [],
[selected, selectedRunsMatchSelection, selectedAutomationRunsWithWorkspaceNames]
)
const selectedAutomationRunPage = selectedAutomationRunPageId
? (selectedRuns.find((run) => run.id === selectedAutomationRunPageId) ?? null)
: null
const worktrees = useMemo(
() => worktreesByRepo[draft.projectId] ?? [],
[draft.projectId, worktreesByRepo]
)
const automationHostTarget: AutomationHostTarget | null = useMemo(
() => getAutomationHostTargetFromKey(automationHostTargetKey),
[automationHostTargetKey]
)
return {
getDraftSetupDecisionDefault,
getAutomationHooksCacheKey,
loadAutomationYamlHooksForRepo,
getDraftSetupDecisionDefaultSignature,
markSetupDecisionTouched,
selectedRuns,
selectedRunsNotice,
selectedAutomationRunPage,
worktrees,
automationHostTarget
}
}
export type AutomationsPageSetupState = ReturnType<typeof useAutomationsPageSetupState>
@@ -0,0 +1,98 @@
import { useCallback } from 'react'
import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { getAgentCatalog } from '@/lib/agent-catalog'
import { useAppStore } from '@/store'
import { useRepoMap, useWorktreeMap } from '@/store/selectors'
import {
automationRepoForRow,
automationWorktreeForRow,
type AutomationListRow
} from './automation-list-row-identity'
const AGENTS = getAgentCatalog().map((agent) => agent.id)
/** Store-backed values shared by the page's state and action hooks. */
export function useAutomationsPageStoreState() {
const repos = useAppStore((s) => s.repos)
const projectHostSetups = useAppStore((s) => s.projectHostSetups)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree)
const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId)
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const fetchRuntimeEnvironmentRepos = useAppStore((s) => s.fetchRuntimeEnvironmentRepos)
const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees)
const startupWorktreeRefreshCompleted = useAppStore((s) => s.startupWorktreeRefreshCompleted)
const updateSettings = useAppStore((s) => s.updateSettings)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const closeAutomationsPage = useAppStore((s) => s.closeAutomationsPage)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
const settings = useAppStore((s) => s.settings)
const selectedId = useAppStore((s) => s.selectedAutomationId)
const setSelectedId = useAppStore((s) => s.setSelectedAutomationId)
const pendingAutomationRunNavigation = useAppStore((s) => s.pendingAutomationRunNavigation)
const setPendingAutomationRunNavigation = useAppStore((s) => s.setPendingAutomationRunNavigation)
const repoMap = useRepoMap()
const worktreeMap = useWorktreeMap()
const repoForRow = useCallback(
(row: AutomationListRow): Repo | undefined => automationRepoForRow(row, repos, repoMap),
[repoMap, repos]
)
const worktreeForRow = useCallback(
(
row: AutomationListRow,
repo: Repo | undefined,
workspaceId: string | null | undefined = row.automation.workspaceId
): Worktree | undefined =>
automationWorktreeForRow(row, worktreesByRepo, repo, worktreeMap, workspaceId),
[worktreeMap, worktreesByRepo]
)
const enabledAgents = filterEnabledTuiAgents(AGENTS, settings?.disabledTuiAgents)
const defaultAgent =
settings?.defaultTuiAgent &&
settings.defaultTuiAgent !== 'blank' &&
isTuiAgentEnabled(settings.defaultTuiAgent, settings.disabledTuiAgents)
? settings.defaultTuiAgent
: (enabledAgents[0] ?? AGENTS[0])
return {
repos,
projectHostSetups,
worktreesByRepo,
unifiedTabsByWorktree,
terminalLayoutsByTabId,
ptyIdsByTabId,
activeWorktreeId,
fetchWorktrees,
fetchRuntimeEnvironmentRepos,
fetchAllWorktrees,
startupWorktreeRefreshCompleted,
updateSettings,
openSettingsPage,
openSettingsTarget,
closeAutomationsPage,
sshConnectionStates,
sshTargetLabels,
runtimeEnvironments,
runtimeStatusByEnvironmentId,
settings,
selectedId,
setSelectedId,
pendingAutomationRunNavigation,
setPendingAutomationRunNavigation,
repoMap,
worktreeMap,
repoForRow,
worktreeForRow,
defaultAgent
}
}
export type AutomationsPageStoreState = ReturnType<typeof useAutomationsPageStoreState>
@@ -0,0 +1,155 @@
import { useCallback } from 'react'
import { toast } from 'sonner'
import type {
AutomationRun,
ExternalAutomationAction,
ExternalAutomationJob,
ExternalAutomationManager,
ExternalAutomationRun
} from '../../../../shared/automations-types'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable'
import { isMissingExternalRunsApiError } from './external-automation-display'
import {
externalAutomationActionKey,
externalAutomationJobKey
} from './external-automation-scope-keys'
import type { ExternalAutomationScope } from './external-automation-scope-client'
import type { AutomationsPageActionContext } from './automations-page-action-context'
/** External manager actions always carry the scope that produced their row. */
export function useExternalAutomationActions({
local,
list,
pageRefresh
}: AutomationsPageActionContext) {
const {
setExternalActionKey,
setSelectedExternalRunPage,
setSelectedAutomationRunPageId,
setExternalDeleteTarget,
externalDeleteTarget,
selectedExternalKeyRef,
selectExternalKey,
setIsDetailOpen,
setActivePaneTab
} = local
const { scopedExternal } = list
const fetchScopedExternalRuns = scopedExternal.fetchRuns
const runExternalAction = async (
scope: ExternalAutomationScope,
job: ExternalAutomationJob,
action: ExternalAutomationAction
): Promise<void> => {
setExternalActionKey(externalAutomationActionKey(scope, job.id, action))
try {
await scopedExternal.runExternalAction(scope, job.id, action)
if (action === 'run') {
useAppStore.getState().recordFeatureInteraction('automation-run')
}
await pageRefresh.refresh({ awaitExternalManagers: true })
if (action === 'delete') {
const deletedKey = externalAutomationJobKey(scope, job.id)
if (selectedExternalKeyRef.current === deletedKey) {
selectExternalKey(null)
setIsDetailOpen(false)
setActivePaneTab('overview')
}
}
toast.success(
action === 'delete'
? translate(
'auto.components.automations.AutomationsPage.4c22bc9913',
'External automation deleted.'
)
: action === 'run'
? translate(
'auto.components.automations.AutomationsPage.4d7878402c',
'External automation queued.'
)
: action === 'pause'
? translate(
'auto.components.automations.AutomationsPage.77c518a34b',
'External automation paused.'
)
: translate(
'auto.components.automations.AutomationsPage.37288942f0',
'External automation resumed.'
)
)
} catch (error) {
await pageRefresh.refresh().catch(() => undefined)
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.automations.AutomationsPage.126d726546',
'External automation action failed.'
)
)
} finally {
setExternalActionKey(null)
}
}
const fetchExternalAutomationRuns = useCallback<FetchExternalAutomationRuns>(
async ({ scope, job, page, pageSize }) => {
try {
const result = await fetchScopedExternalRuns(scope, job, page, pageSize)
return { runs: [...result.runs], totalCount: result.totalCount }
} catch (error) {
if (isMissingExternalRunsApiError(error)) {
return {
runs: job.runs.slice(page * pageSize, page * pageSize + pageSize),
totalCount: job.runCount
}
}
throw error
}
},
[fetchScopedExternalRuns]
)
const openExternalRunPage = (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
run: ExternalAutomationRun
): void => {
setSelectedExternalRunPage({ manager, job, run })
}
const openAutomationRunPage = (run: AutomationRun): void => {
setSelectedAutomationRunPageId(run.id)
}
const requestExternalAction = (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
action: ExternalAutomationAction,
scope: ExternalAutomationScope
): void => {
if (action === 'delete') {
setExternalDeleteTarget({ manager, job, scope })
return
}
void runExternalAction(scope, job, action)
}
const confirmDeleteExternalAutomation = async (): Promise<void> => {
if (!externalDeleteTarget) {
return
}
const target = externalDeleteTarget
setExternalDeleteTarget(null)
await runExternalAction(target.scope, target.job, 'delete')
}
return {
runExternalAction,
fetchExternalAutomationRuns,
openExternalRunPage,
openAutomationRunPage,
requestExternalAction,
confirmDeleteExternalAutomation
}
}
export type ExternalAutomationActions = ReturnType<typeof useExternalAutomationActions>
@@ -4,8 +4,8 @@ import { describe, expect, it } from 'vitest'
// Why: happy-dom does not reproduce Chromium's display:none focus no-op, so the #9939 regression
// is invisible to behavioral tests. Pin the routing decision at the source level instead.
function paletteSource(): string {
return readFileSync(join(__dirname, '..', 'WorktreeJumpPalette.tsx'), 'utf8')
function paletteSource(fileName: string): string {
return readFileSync(join(__dirname, '..', fileName), 'utf8')
}
function sourceBetween(source: string, startPattern: string, endPattern: string): string {
@@ -19,7 +19,7 @@ function sourceBetween(source: string, startPattern: string, endPattern: string)
describe('Cmd+J activation focus routing (#9939)', () => {
it('routes worktree selection through the scoped helper before any unscoped fallback', () => {
const handler = sourceBetween(
paletteSource(),
paletteSource('use-worktree-jump-palette-selection-actions.ts'),
'const handleSelectWorktree = useCallback',
'const handleSelectBrowserPage'
)
@@ -36,7 +36,7 @@ describe('Cmd+J activation focus routing (#9939)', () => {
it('restores the pre-palette element for project targets instead of the first terminal found', () => {
const handler = sourceBetween(
paletteSource(),
paletteSource('use-worktree-jump-palette-selection-actions.ts'),
'const handleSelectProjectTarget = useCallback',
'const handleSelectItem = useCallback'
)
@@ -47,15 +47,15 @@ describe('Cmd+J activation focus routing (#9939)', () => {
it('falls back when scoped focus declines for an already-open issue match', () => {
const handler = sourceBetween(
paletteSource(),
paletteSource('use-worktree-jump-palette-create-action.ts'),
'const handleCreateWorktree = useCallback',
'const handleCloseAutoFocus'
)
const activationCalls =
handler.match(/const activation = activateAndRevealWorktree\(activeMatch\.id\)/g)?.length ?? 0
// Typed #N still jumps from create. Pasted issue/PR URLs jump via the
// worktree row (handleSelectWorktree), which has its own focus routing.
expect(activationCalls).toBe(1)
// Both a pasted issue/PR URL and a typed #N can jump to an existing match
// from the create handler; each path must keep the scoped-focus fallback.
expect(activationCalls).toBe(2)
expect(
handler.match(
/if \(!queueWorkspaceActivationTerminalFocus\(activeMatch\.id, activation\)\) \{\s*focusFallbackSurface\(\)\s*\}/g
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,144 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { CornerDownLeft } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { formatMarkdownReviewNotes, type MarkdownReviewNote } from '@/lib/markdown-review-notes'
import { installOpenDraftAddReviewNoteGuard } from './editor-shortcuts'
import { NotesSendMenu } from './NotesSendMenu'
export function MarkdownPreviewSingleNoteSendMenu({
worktreeId,
filePath,
content,
note,
modeSlot,
onDelivered
}: {
worktreeId: string
filePath: string
content: string
note: MarkdownReviewNote
modeSlot: string
onDelivered: (notes: readonly MarkdownReviewNote[]) => void
}): React.JSX.Element {
return (
<NotesSendMenu
worktreeId={worktreeId}
groupId={worktreeId}
modeIdParts={['markdown-notes', worktreeId, filePath, modeSlot, note.id]}
scopes={[
{
id: 'note',
label: translate('auto.components.editor.MarkdownPreview.f37b98999e', 'This note'),
notes: note.sentAt ? [] : [note],
prompt: formatMarkdownReviewNotes([note], content)
}
]}
targetModeLabel="This note"
triggerClassName="orca-diff-comment-pill-btn"
disabledTooltip="Note already sent"
onDelivered={onDelivered}
/>
)
}
export function MarkdownPreviewAnnotationComposer({
onCancel,
onSubmit
}: {
lineNumber: number
startLine?: number
onCancel: () => void
onSubmit: (body: string) => Promise<boolean>
}): React.JSX.Element {
const [body, setBody] = useState('')
const [submitting, setSubmitting] = useState(false)
const mountedRef = useMountedRef()
const composerRef = useRef<HTMLDivElement | null>(null)
// Why: scope the add-review-note chord to this composer subtree.
useEffect(() => {
const composer = composerRef.current
if (!composer) {
return
}
return installOpenDraftAddReviewNoteGuard(composer)
}, [])
const focusTextareaRef = useCallback((textarea: HTMLTextAreaElement | null): void => {
textarea?.focus()
}, [])
const trimmed = body.trim()
const submit = async (): Promise<void> => {
if (submitting || !trimmed) {
return
}
setSubmitting(true)
try {
const ok = await onSubmit(trimmed)
if (!mountedRef.current) {
return
}
if (ok) {
setBody('')
}
} finally {
if (mountedRef.current) {
setSubmitting(false)
}
}
}
return (
<div
ref={composerRef}
className="markdown-annotation-composer"
onClick={(event) => event.stopPropagation()}
>
<div className="orca-diff-comment-popover-label">
{translate('auto.components.editor.MarkdownPreview.b1bfc04034', 'Selected text')}
</div>
<textarea
ref={focusTextareaRef}
className="orca-diff-comment-popover-textarea"
placeholder={translate(
'auto.components.editor.MarkdownPreview.d737791433',
'Add note for the AI'
)}
value={body}
onChange={(event) => {
setBody(event.target.value)
const el = event.currentTarget
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 240)}px`
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
onCancel()
return
}
if (event.key === 'Enter' && !event.nativeEvent.isComposing && !event.shiftKey) {
event.preventDefault()
void submit()
}
}}
rows={3}
/>
<div className="orca-diff-comment-popover-footer">
<Button variant="ghost" size="sm" onClick={onCancel} disabled={submitting}>
{translate('auto.components.editor.MarkdownPreview.e4683f70c4', 'Cancel')}
</Button>
<Button size="sm" onClick={() => void submit()} disabled={submitting || !trimmed}>
{submitting
? translate('auto.components.editor.MarkdownPreview.d652c87c91', 'Saving…')
: translate('auto.components.editor.MarkdownPreview.13f94d760c', 'Add note')}
{!submitting && <CornerDownLeft className="ml-1 size-3 opacity-70" />}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,90 @@
import { memo } from 'react'
import Markdown from 'react-markdown'
import type { Components, Options as ReactMarkdownOptions } from 'react-markdown'
import rehypeHighlight from 'rehype-highlight'
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import rehypeSlug from 'rehype-slug'
import remarkBreaks from 'remark-breaks'
import remarkFrontmatter from 'remark-frontmatter'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { remarkMarkdownDocLinks } from './markdown-doc-links'
import { markdownPreviewUrlTransform } from './markdown-preview-url-transform'
const markdownPreviewSanitizeSchema = {
...defaultSchema,
tagNames: [...(defaultSchema.tagNames ?? []), 'details', 'summary', 'kbd', 'sub', 'sup', 'ins'],
protocols: {
...defaultSchema.protocols,
// Why: keep file:// through sanitize so the click handler can authorize and open the target.
href: [...(defaultSchema.protocols?.href ?? []), 'file'],
src: [...(defaultSchema.protocols?.src ?? []), 'file']
},
attributes: {
...defaultSchema.attributes,
'*': [...(defaultSchema.attributes?.['*'] ?? []), 'id'],
a: [...(defaultSchema.attributes?.a ?? []), 'href', 'title'],
code: [
...(defaultSchema.attributes?.code ?? []),
['className', /^language-[\w-]+$/, 'math-inline', 'math-display']
],
div: [...(defaultSchema.attributes?.div ?? []), ['className', /^language-[\w-]+$/], 'align'],
details: [
...(defaultSchema.attributes?.details ?? []),
'open',
['className', 'orca-details'],
['dataOrcaToggle', 'heading-1', 'heading-2', 'heading-3', 'heading-4', 'heading-5']
],
h1: [...(defaultSchema.attributes?.h1 ?? []), 'id'],
h2: [...(defaultSchema.attributes?.h2 ?? []), 'id'],
h3: [...(defaultSchema.attributes?.h3 ?? []), 'id'],
h4: [...(defaultSchema.attributes?.h4 ?? []), 'id'],
h5: [...(defaultSchema.attributes?.h5 ?? []), 'id'],
h6: [...(defaultSchema.attributes?.h6 ?? []), 'id'],
img: [...(defaultSchema.attributes?.img ?? []), 'src', 'alt', 'title', 'width', 'height'],
input: [...(defaultSchema.attributes?.input ?? []), 'type', 'checked', 'disabled'],
pre: [...(defaultSchema.attributes?.pre ?? []), ['className', /^language-[\w-]+$/]],
span: [...(defaultSchema.attributes?.span ?? []), ['className', /^hljs(?:-[\w-]+)?$/]],
td: [...(defaultSchema.attributes?.td ?? []), 'align'],
th: [...(defaultSchema.attributes?.th ?? []), 'align']
}
}
type MarkdownPluginList = NonNullable<ReactMarkdownOptions['remarkPlugins']>
const MARKDOWN_REMARK_PLUGINS: MarkdownPluginList = [
remarkGfm,
remarkBreaks,
remarkFrontmatter,
remarkMath,
remarkMarkdownDocLinks
]
// Why: sanitize raw HTML before KaTeX/highlight expand it.
const MARKDOWN_REHYPE_PLUGINS: MarkdownPluginList = [
rehypeRaw,
[rehypeSanitize, markdownPreviewSanitizeSchema],
rehypeSlug,
rehypeHighlight,
rehypeKatex
]
// Why: find-state renders must not rebuild the full remark/rehype pipeline.
export const MarkdownPreviewBody = memo(function MarkdownPreviewBody({
content,
components
}: {
content: string
components: Components
}) {
return (
<Markdown
components={components}
urlTransform={markdownPreviewUrlTransform}
remarkPlugins={MARKDOWN_REMARK_PLUGINS}
rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
>
{content}
</Markdown>
)
})
@@ -0,0 +1,80 @@
import { Check, Copy, MessageSquare } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { NotesSendMenu } from './NotesSendMenu'
import type { MarkdownPreviewFoundation } from './use-markdown-preview-foundation'
import type { MarkdownPreviewReviewActions } from './use-markdown-preview-review-actions'
export function MarkdownPreviewReviewToolbar({
foundation,
reviewActions,
filePath
}: {
foundation: MarkdownPreviewFoundation
reviewActions: MarkdownPreviewReviewActions
filePath: string
}): React.JSX.Element {
const {
markdownReviewNotes,
reviewNotesCopied,
sourceWorktree,
unsentMarkdownReviewScope,
clearDeliveredDiffComments
} = foundation
const { scrollToReviewNote, handleCopyMarkdownReviewNotes } = reviewActions
return (
<div className="markdown-review-toolbar">
<button
type="button"
className="markdown-review-toolbar-button"
onClick={() => {
const firstNote = markdownReviewNotes[0]
if (firstNote) {
scrollToReviewNote(firstNote)
}
}}
disabled={markdownReviewNotes.length === 0}
title={translate(
'auto.components.editor.MarkdownPreview.0f9969a159',
'Jump to first review note'
)}
aria-label={translate(
'auto.components.editor.MarkdownPreview.0f9969a159',
'Jump to first review note'
)}
>
<MessageSquare className="size-3.5" />
<span>
{translate('auto.components.editor.MarkdownPreview.322afab6ff', 'Review notes')}
</span>
<span className="markdown-review-count">{markdownReviewNotes.length}</span>
</button>
<button
type="button"
className="markdown-review-icon-button"
onClick={() => void handleCopyMarkdownReviewNotes()}
disabled={markdownReviewNotes.length === 0}
title={translate(
'auto.components.editor.MarkdownPreview.bb629de58a',
'Copy notes for agent'
)}
aria-label={translate(
'auto.components.editor.MarkdownPreview.bb629de58a',
'Copy notes for agent'
)}
>
{reviewNotesCopied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</button>
{sourceWorktree ? (
<NotesSendMenu
worktreeId={sourceWorktree.id}
groupId={sourceWorktree.id}
modeIdParts={['markdown-notes', sourceWorktree.id, filePath, 'preview-toolbar']}
scopes={unsentMarkdownReviewScope}
triggerClassName="markdown-review-icon-button"
onDelivered={(notes) => void clearDeliveredDiffComments(sourceWorktree.id, notes)}
/>
) : null}
</div>
)
}
@@ -0,0 +1,100 @@
import { ChevronDown, ChevronUp, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { translate } from '@/i18n/i18n'
import type { MarkdownPreviewFoundation } from './use-markdown-preview-foundation'
import type { MarkdownPreviewViewport } from './use-markdown-preview-viewport'
export function MarkdownPreviewSearchBar({
foundation,
viewport
}: {
foundation: MarkdownPreviewFoundation
viewport: MarkdownPreviewViewport
}): React.JSX.Element {
const { rootRef, setSearchInputElement, query, setQuery, matchCount, activeMatchIndex } =
foundation
const { moveToMatch, closeSearch } = viewport
return (
<div className="markdown-preview-search" onKeyDown={(event) => event.stopPropagation()}>
<div className="markdown-preview-search-field">
<Input
ref={setSearchInputElement}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && event.shiftKey) {
event.preventDefault()
moveToMatch(-1)
return
}
if (event.key === 'Enter') {
event.preventDefault()
moveToMatch(1)
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeSearch()
rootRef.current?.focus()
}
}}
placeholder={translate(
'auto.components.editor.MarkdownPreview.517aea303b',
'Find in preview'
)}
className="markdown-preview-search-input h-7 !border-0 bg-transparent px-2 shadow-none focus-visible:!border-0 focus-visible:ring-0"
aria-label={translate(
'auto.components.editor.MarkdownPreview.ec77985138',
'Find in markdown preview'
)}
/>
</div>
<div className="markdown-preview-search-status">
{query && matchCount === 0
? translate('auto.components.editor.MarkdownPreview.c5dc92cfe3', 'No results')
: `${matchCount === 0 ? 0 : activeMatchIndex + 1}/${matchCount}`}
</div>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() => moveToMatch(-1)}
disabled={matchCount === 0}
title={translate('auto.components.editor.MarkdownPreview.1febd97f5c', 'Previous match')}
aria-label={translate(
'auto.components.editor.MarkdownPreview.1febd97f5c',
'Previous match'
)}
className="markdown-preview-search-button"
>
<ChevronUp size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() => moveToMatch(1)}
disabled={matchCount === 0}
title={translate('auto.components.editor.MarkdownPreview.b42c41bd0d', 'Next match')}
aria-label={translate('auto.components.editor.MarkdownPreview.b42c41bd0d', 'Next match')}
className="markdown-preview-search-button"
>
<ChevronDown size={14} />
</Button>
<div className="markdown-preview-search-divider" />
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={closeSearch}
title={translate('auto.components.editor.MarkdownPreview.12052c639c', 'Close search')}
aria-label={translate('auto.components.editor.MarkdownPreview.12052c639c', 'Close search')}
className="markdown-preview-search-button"
>
<X size={14} />
</Button>
</div>
)
}
@@ -0,0 +1,83 @@
import type { Components } from 'react-markdown'
import { translate } from '@/i18n/i18n'
import { MarkdownTableOfContentsPanel } from './MarkdownTableOfContentsPanel'
import { MarkdownPreviewBody } from './MarkdownPreviewBody'
import { MarkdownPreviewReviewToolbar } from './MarkdownPreviewReviewToolbar'
import { MarkdownPreviewSearchBar } from './MarkdownPreviewSearchBar'
import type { MarkdownPreviewFoundation } from './use-markdown-preview-foundation'
import type { MarkdownPreviewReviewActions } from './use-markdown-preview-review-actions'
import type { MarkdownPreviewViewport } from './use-markdown-preview-viewport'
export function MarkdownPreviewSurface({
foundation,
viewport,
reviewActions,
components,
filePath,
showTableOfContents,
onCloseTableOfContents
}: {
foundation: MarkdownPreviewFoundation
viewport: MarkdownPreviewViewport
reviewActions: MarkdownPreviewReviewActions
components: Components
filePath: string
showTableOfContents: boolean
onCloseTableOfContents?: () => void
}): React.JSX.Element {
const {
isSearchOpen,
canShowReviewTools,
tableOfContentsItems,
editorFontSize,
isDark,
bodyRef,
frontMatter,
frontmatterVisible,
frontMatterInner,
renderedContent
} = foundation
return (
<div className="markdown-preview-shell">
{showTableOfContents ? (
<MarkdownTableOfContentsPanel
items={tableOfContentsItems}
onClose={onCloseTableOfContents ?? (() => {})}
onNavigate={viewport.navigateToTableOfContentsItem}
/>
) : null}
<div
ref={viewport.setRootRef}
tabIndex={0}
style={{ fontSize: `${editorFontSize}px` }}
className={`markdown-preview h-full min-h-0 overflow-auto scrollbar-editor ${isDark ? 'markdown-dark' : 'markdown-light'}`}
>
{isSearchOpen ? (
<MarkdownPreviewSearchBar foundation={foundation} viewport={viewport} />
) : null}
{canShowReviewTools ? (
<MarkdownPreviewReviewToolbar
foundation={foundation}
reviewActions={reviewActions}
filePath={filePath}
/>
) : null}
{/* Why: OS page translation can replace react-owned text nodes and crash reconciliation. */}
<div ref={bodyRef} className="markdown-body" translate="no">
{frontMatter && frontmatterVisible ? (
<div className="mb-4 rounded border border-border/60 bg-muted/40 px-3 py-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{translate('auto.components.editor.MarkdownPreview.2b2b31382c', 'Front Matter')}
</div>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground font-mono scrollbar-editor">
{frontMatterInner}
</pre>
</div>
) : null}
<MarkdownPreviewBody content={renderedContent} components={components} />
</div>
</div>
</div>
)
}
@@ -0,0 +1,16 @@
export function getMarkdownPreviewAnchorScrollTop(
container: Pick<HTMLElement, 'getBoundingClientRect' | 'scrollTop'>,
target: Pick<HTMLElement, 'getBoundingClientRect'>
): number {
const containerTop = container.getBoundingClientRect().top
const targetTop = target.getBoundingClientRect().top
return Math.max(0, targetTop - containerTop + container.scrollTop - 12)
}
export function decodeMarkdownPreviewAnchor(rawAnchor: string): string {
try {
return decodeURIComponent(rawAnchor)
} catch {
return rawAnchor
}
}
@@ -0,0 +1,51 @@
import React from 'react'
import { formatMarkdownReviewCardQuote } from '@/lib/markdown-review-notes'
import type {
MarkdownPreviewBlockRange,
MarkdownPreviewPositionNode
} from './markdown-preview-types'
export function getMarkdownPreviewBlockRange(
node: MarkdownPreviewPositionNode | undefined
): MarkdownPreviewBlockRange | null {
const startLine = node?.position?.start?.line
const endLine = node?.position?.end?.line
if (!Number.isInteger(startLine) || !Number.isInteger(endLine)) {
return null
}
if (typeof startLine !== 'number' || typeof endLine !== 'number' || startLine < 1) {
return null
}
return { startLine, endLine: Math.max(startLine, endLine) }
}
function getMarkdownPreviewReactText(node: React.ReactNode): string {
if (typeof node === 'string' || typeof node === 'number') {
return String(node)
}
if (!node || typeof node === 'boolean') {
return ''
}
if (Array.isArray(node)) {
return node.map(getMarkdownPreviewReactText).join(' ')
}
if (!React.isValidElement(node)) {
return ''
}
const props = node.props as { alt?: unknown; children?: React.ReactNode }
if (typeof props.alt === 'string' && props.alt.trim()) {
return props.alt
}
return getMarkdownPreviewReactText(props.children)
}
export function getMarkdownPreviewAnnotationQuote(node: React.ReactNode): string | undefined {
return formatMarkdownReviewCardQuote(getMarkdownPreviewReactText(node))
}
export function hasMarkdownPreviewNestedBlock(
node: MarkdownPreviewPositionNode | undefined
): boolean {
const blockTags = new Set(['p', 'pre', 'table', 'blockquote', 'ul', 'ol'])
return Boolean(node?.children?.some((child) => child.tagName && blockTags.has(child.tagName)))
}
@@ -0,0 +1,42 @@
import type { MutableRefObject } from 'react'
export function cancelMarkdownPreviewEditorRevealFrames(
frameIds: MutableRefObject<number[]>
): void {
for (const frameId of frameIds.current) {
cancelAnimationFrame(frameId)
}
frameIds.current = []
}
export function requestMarkdownPreviewEditorRevealFrame(
frameIds: MutableRefObject<number[]>,
callback: FrameRequestCallback
): void {
let completed = false
let frameId: number | undefined
frameId = requestAnimationFrame((timestamp) => {
completed = true
if (frameId !== undefined) {
frameIds.current = frameIds.current.filter((pendingFrameId) => pendingFrameId !== frameId)
}
callback(timestamp)
})
if (!completed) {
frameIds.current.push(frameId)
}
}
export function parseMarkdownPreviewLineTarget(
hash: string
): { line: number; column?: number } | null {
if (!hash) {
return null
}
const trimmed = hash.startsWith('#') ? hash.slice(1) : hash
const match = /^L(\d+)(?:C(\d+))?$/i.exec(trimmed)
if (!match) {
return null
}
return { line: Number(match[1]), column: match[2] ? Number(match[2]) : undefined }
}
@@ -0,0 +1,263 @@
import { toast } from 'sonner'
import { getConnectionIdForFile } from '@/lib/connection-context'
import { detectLanguage } from '@/lib/language-detect'
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
import { openHttpLink } from '@/lib/http-link-routing'
import { translate } from '@/i18n/i18n'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import { statRuntimePath } from '@/runtime/runtime-file-client'
import { useAppStore } from '@/store'
import { relativePathInsideRoot } from '../../../../shared/cross-platform-path'
import { resolveMarkdownLinkTarget } from './markdown-internal-links'
import {
fileUrlToAbsolutePath,
resolveMarkdownPreviewHref,
resolveMarkdownPreviewHttpOpenOptions
} from './markdown-preview-links'
import {
cancelMarkdownPreviewEditorRevealFrames,
parseMarkdownPreviewLineTarget,
requestMarkdownPreviewEditorRevealFrame
} from './markdown-preview-editor-reveal'
import {
findMarkdownPreviewOpenedEditFileId,
findMarkdownPreviewTargetWorktree
} from './markdown-preview-source-routing'
import { handleMarkdownPreviewSystemLinkClick } from './markdown-preview-system-link-action'
import type { MarkdownPreviewFoundation } from './use-markdown-preview-foundation'
import type { MarkdownPreviewViewport } from './use-markdown-preview-viewport'
export type MarkdownPreviewLinkContext = Pick<
MarkdownPreviewFoundation,
| 'isMac'
| 'sourceOwner'
| 'sourceRoutingWorktreeId'
| 'sourceConnectionId'
| 'resolvedSourceRuntimeEnvironmentId'
| 'worktreeRoot'
| 'worktreesByRepo'
| 'sourceWorktree'
| 'activateMarkdownLink'
| 'openFile'
| 'openMarkdownPreview'
| 'setMarkdownViewMode'
| 'pendingEditorRevealFrameIdsRef'
| 'setPendingEditorReveal'
> &
Pick<MarkdownPreviewViewport, 'scrollToAnchor'>
export async function handleMarkdownPreviewLinkClick({
event,
href,
filePath,
context
}: {
event: React.MouseEvent<HTMLAnchorElement>
href: string | undefined
filePath: string
context: MarkdownPreviewLinkContext
}): Promise<void> {
if (!href) {
return
}
event.preventDefault()
const {
isMac,
sourceOwner,
sourceRoutingWorktreeId,
sourceConnectionId,
resolvedSourceRuntimeEnvironmentId,
worktreeRoot,
worktreesByRepo,
sourceWorktree,
activateMarkdownLink,
openFile,
openMarkdownPreview,
setMarkdownViewMode,
pendingEditorRevealFrameIdsRef,
setPendingEditorReveal,
scrollToAnchor
} = context
if (href.startsWith('#')) {
void scrollToAnchor(href.slice(1))
return
}
if (handleMarkdownPreviewSystemLinkClick({ event, href, filePath, context })) {
return
}
const target = resolveMarkdownPreviewHref(href, filePath)
if (!target) {
return
}
if (target.protocol === 'http:' || target.protocol === 'https:') {
openHttpLink(
target.toString(),
resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId, sourceOwner)
)
return
}
if (target.protocol !== 'file:') {
return
}
const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot)
const classifiedFileTarget =
classified?.kind === 'markdown' || classified?.kind === 'file' ? classified : null
const absolutePath = classifiedFileTarget?.absolutePath ?? fileUrlToAbsolutePath(target)
if (!absolutePath) {
return
}
const lineTarget =
classifiedFileTarget?.line !== undefined
? { line: classifiedFileTarget.line, column: classifiedFileTarget.column }
: parseMarkdownPreviewLineTarget(target.hash)
// Why: same-file anchors need no ownership resolution.
if (absolutePath === filePath && target.hash && !lineTarget) {
void scrollToAnchor(target.hash.slice(1))
return
}
if (sourceOwner.kind === 'unknown') {
return
}
const targetWorktree = findMarkdownPreviewTargetWorktree(
worktreesByRepo,
absolutePath,
sourceWorktree,
sourceOwner
)
if (!targetWorktree) {
if (sourceRoutingWorktreeId && worktreeRoot) {
void activateMarkdownLink(href, {
sourceFilePath: filePath,
worktreeId: sourceRoutingWorktreeId,
worktreeRoot,
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
sourceOwner
})
return
}
if (
isLocalPathOpenBlocked(
settingsForRuntimeOwner(
useAppStore.getState().settings,
resolvedSourceRuntimeEnvironmentId
),
{ connectionId: sourceConnectionId }
)
) {
// Why: an unmatched remote path cannot fall back to the client OS.
showLocalPathOpenBlockedToast()
return
}
void window.api.shell.openFileUri(target.toString())
return
}
const relativePath = relativePathInsideRoot(targetWorktree.path, absolutePath)
if (relativePath === null) {
return
}
const language = detectLanguage(absolutePath)
const targetConnectionId = getConnectionIdForFile(targetWorktree.id, absolutePath)
if (targetConnectionId === undefined) {
return
}
try {
const stats = await statRuntimePath(
{
settings: settingsForRuntimeOwner(
useAppStore.getState().settings,
resolvedSourceRuntimeEnvironmentId
),
worktreeId: targetWorktree.id,
worktreePath: targetWorktree.path,
connectionId: targetConnectionId ?? undefined
},
absolutePath
)
if (stats.isDirectory) {
toast.error(
translate(
'auto.components.editor.MarkdownPreview.759463a221',
'Cannot open directory: {{value0}}',
{ value0: relativePath }
)
)
return
}
} catch {
toast.error(
translate('auto.components.editor.MarkdownPreview.6c043947ae', 'File not found: {{value0}}', {
value0: relativePath
})
)
return
}
if (lineTarget) {
openFile({
filePath: absolutePath,
relativePath,
worktreeId: targetWorktree.id,
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
language,
mode: 'edit'
})
const openedState = useAppStore.getState()
const targetFileId = findMarkdownPreviewOpenedEditFileId(
openedState.openFiles,
openedState.activeFileIdByWorktree,
{ filePath: absolutePath, worktreeId: targetWorktree.id }
)
if (language === 'markdown') {
setMarkdownViewMode(targetFileId, 'source')
}
cancelMarkdownPreviewEditorRevealFrames(pendingEditorRevealFrameIdsRef)
setPendingEditorReveal(null)
requestMarkdownPreviewEditorRevealFrame(pendingEditorRevealFrameIdsRef, () => {
requestMarkdownPreviewEditorRevealFrame(pendingEditorRevealFrameIdsRef, () => {
setPendingEditorReveal({
filePath: absolutePath,
fileId: targetFileId,
line: lineTarget.line,
column: lineTarget.column ?? 1,
matchLength: 0
})
})
})
return
}
if (language === 'markdown') {
openMarkdownPreview(
{
filePath: absolutePath,
relativePath,
worktreeId: targetWorktree.id,
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
language
},
{ anchor: target.hash ? target.hash.slice(1) : null }
)
return
}
openFile({
filePath: absolutePath,
relativePath,
worktreeId: targetWorktree.id,
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
language,
mode: 'edit'
})
}
@@ -0,0 +1,175 @@
import { getConnectionIdForFile } from '@/lib/connection-context'
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
import { dirname } from '@/lib/path'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { relativePathInsideRoot } from '../../../../shared/cross-platform-path'
import type { Worktree } from '../../../../shared/worktree/types'
import type { MarkdownPreviewSourceOpenFile } from './markdown-preview-types'
export function findMarkdownPreviewSourceOpenFile(
openFiles: MarkdownPreviewSourceOpenFile[],
params: {
sourceFileId: string | null
filePath: string
sourceWorktreeId: string | null
sourceRuntimeEnvironmentId: string | null | undefined
}
): MarkdownPreviewSourceOpenFile | undefined {
const ownerMatches = (file: MarkdownPreviewSourceOpenFile): boolean =>
(!params.sourceWorktreeId || file.worktreeId === params.sourceWorktreeId) &&
(params.sourceRuntimeEnvironmentId === undefined ||
(file.runtimeEnvironmentId ?? null) === (params.sourceRuntimeEnvironmentId ?? null))
if (params.sourceFileId) {
const idMatch = openFiles.find((file) => file.id === params.sourceFileId && ownerMatches(file))
return (
idMatch ??
openFiles.find(
(file) =>
file.mode === 'markdown-preview' &&
file.filePath === params.filePath &&
file.markdownPreviewSourceFileId === params.sourceFileId &&
ownerMatches(file)
) ??
openFiles.find((file) => file.id === params.sourceFileId)
)
}
return openFiles.find((file) => file.filePath === params.filePath && ownerMatches(file))
}
export function findMarkdownPreviewOpenedEditFileId(
openFiles: MarkdownPreviewSourceOpenFile[],
activeFileIdByWorktree: Record<string, string | null>,
params: { filePath: string; worktreeId: string }
): string {
const activeFileId = activeFileIdByWorktree[params.worktreeId]
const activeFile = openFiles.find(
(file) =>
file.id === activeFileId &&
file.filePath === params.filePath &&
file.worktreeId === params.worktreeId &&
file.mode === 'edit'
)
if (activeFile) {
return activeFile.id
}
return (
openFiles.find(
(file) =>
file.filePath === params.filePath &&
file.worktreeId === params.worktreeId &&
file.mode === 'edit'
)?.id ?? params.filePath
)
}
function normalizeMarkdownPreviewAbsolutePath(absolutePath: string): string {
return absolutePath.replaceAll('\\', '/')
}
function normalizeMarkdownPreviewRelativePath(relativePath: string): string {
return relativePath.replaceAll('\\', '/').replace(/^\/+/, '')
}
function isMarkdownPreviewAbsolutePathLike(path: string): boolean {
return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\')
}
function formatMarkdownPreviewRootPath(rootPath: string): string {
if (rootPath === '') {
return '/'
}
if (/^[A-Za-z]:$/.test(rootPath)) {
return `${rootPath}/`
}
return rootPath
}
export function deriveMarkdownPreviewSourceRoot(
filePath: string,
relativePath: string | null | undefined
): string {
const normalizedFilePath = normalizeMarkdownPreviewAbsolutePath(filePath)
const normalizedRelativePath =
relativePath && !isMarkdownPreviewAbsolutePathLike(relativePath)
? normalizeMarkdownPreviewRelativePath(relativePath)
: ''
if (normalizedRelativePath) {
const suffix = `/${normalizedRelativePath}`
if (normalizedFilePath.endsWith(suffix)) {
return formatMarkdownPreviewRootPath(normalizedFilePath.slice(0, -suffix.length))
}
}
return formatMarkdownPreviewRootPath(normalizeMarkdownPreviewAbsolutePath(dirname(filePath)))
}
function findWorktreeForMarkdownPreviewPath(
worktreesByRepo: Record<string, Worktree[]>,
absolutePath: string,
acceptsWorktree: (worktree: Worktree) => boolean = () => true
): Worktree | null {
let bestMatch: Worktree | null = null
let bestMatchLength = -1
for (const worktrees of Object.values(worktreesByRepo)) {
for (const worktree of worktrees) {
if (
acceptsWorktree(worktree) &&
relativePathInsideRoot(worktree.path, absolutePath) !== null
) {
const normalizedWorktreePathLength = normalizeMarkdownPreviewAbsolutePath(
worktree.path
).length
if (normalizedWorktreePathLength > bestMatchLength) {
bestMatch = worktree
bestMatchLength = normalizedWorktreePathLength
}
}
}
}
return bestMatch
}
export function findMarkdownPreviewTargetWorktree(
worktreesByRepo: Record<string, Worktree[]>,
absolutePath: string,
sourceWorktree: Worktree | null,
sourceOwner: HttpLinkSourceOwner
): Worktree | null {
if (sourceWorktree && relativePathInsideRoot(sourceWorktree.path, absolutePath) !== null) {
return sourceWorktree
}
return findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath, (worktree) => {
const connectionId = getConnectionIdForFile(worktree.id, absolutePath)
if (sourceOwner.kind === 'local') {
return connectionId === null
}
if (sourceOwner.kind === 'ssh') {
return connectionId === sourceOwner.connectionId
}
return false
})
}
export function resolveMarkdownPreviewSourceWorktree(
worktreesByRepo: Record<string, Worktree[]>,
sourceWorktreeId: string | null | undefined,
filePath: string
): Worktree | null {
const sourceWorktree = sourceWorktreeId
? (findWorktreeById(worktreesByRepo, sourceWorktreeId) ?? null)
: null
return sourceWorktree ?? findWorktreeForMarkdownPreviewPath(worktreesByRepo, filePath)
}
export function getMarkdownPreviewSourceRelativePath(
filePath: string,
sourceWorktreePath: string
): string | null {
return relativePathInsideRoot(sourceWorktreePath, filePath)
}

Some files were not shown because too many files have changed in this diff Show More