mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
refactor(renderer): split oversized UI surfaces
(cherry picked from commit da89be4345)
This commit is contained in:
@@ -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
Reference in New Issue
Block a user