From babed443817f07acf0dd5ad12f4f2ff26bb1abcb Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Mon, 3 Aug 2026 18:34:25 -0700
Subject: [PATCH] feat(sidebar): link Linear issues from Edit Worktree Details
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Issue field only accepted GitHub numbers, so a workspace tracking a
Linear issue had no way to say so from the dialog — the link could only be
set at creation time or through `orca worktree set --linear-issue`.
Replaces the field with one provider-aware row: a chip suffix inside the
input selects GitHub or Linear, and pasting a URL flips the chip to match.
A bare key never steers the provider — Linear and Jira issue keys are
byte-identical in shape, so shape alone cannot decide one.
One issue per workspace. A changed field displaces the other provider's
slot and the row names what Save is about to unlink. GitLab and Jira links
are left alone: the row cannot display them, and nothing else in the UI
could restore one it dropped.
- Folder workspaces read-only (their link is creation-time only)
- Remote runtimes assert the capability before writing or clearing, since
`worktree.set` parses in strip mode and would silently drop the keys
- `updateWorktreeMeta` now reports failure so the dialog can stay open
instead of closing over a save that refetch reverted
- Parses are length-bounded — `matchGitHubItemPath` strips trailing
slashes with an unanchored regex that is quadratic on a large paste
---
.../handlers/worktree-linear-issue-link.ts | 36 +-
src/cli/index.test.ts | 3 +
.../src/components/sidebar/WorktreeCard.tsx | 6 +-
.../sidebar/WorktreeIssueLinkField.tsx | 256 +++++++++++++
.../sidebar/WorktreeMetaDialog.test.tsx | 341 ++++++++++++++++++
.../components/sidebar/WorktreeMetaDialog.tsx | 269 +++++++++-----
.../sidebar/use-worktree-issue-link.ts | 198 ++++++++--
.../sidebar/worktree-issue-displacement.ts | 37 ++
.../sidebar/worktree-meta-updates.test.ts | 253 ++++++++++---
.../sidebar/worktree-meta-updates.ts | 157 ++++++--
src/renderer/src/i18n/locales/en.json | 20 +-
.../src/store/slices/worktree-helpers.ts | 4 +-
src/renderer/src/store/slices/worktrees.ts | 43 ++-
src/shared/issue-link-input.test.ts | 112 ++++++
src/shared/issue-link-input.ts | 75 ++++
src/shared/linear-links.test.ts | 81 +++++
src/shared/linear-links.ts | 52 +++
17 files changed, 1713 insertions(+), 230 deletions(-)
create mode 100644 src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx
create mode 100644 src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx
create mode 100644 src/renderer/src/components/sidebar/worktree-issue-displacement.ts
create mode 100644 src/shared/issue-link-input.test.ts
create mode 100644 src/shared/issue-link-input.ts
diff --git a/src/cli/handlers/worktree-linear-issue-link.ts b/src/cli/handlers/worktree-linear-issue-link.ts
index 2285da4f01a..c8e1020d847 100644
--- a/src/cli/handlers/worktree-linear-issue-link.ts
+++ b/src/cli/handlers/worktree-linear-issue-link.ts
@@ -1,17 +1,15 @@
-import { parseLinearIssueInput } from '../../shared/linear-links'
+import {
+ buildLinearIssueLinkUpdates,
+ LINEAR_ISSUE_LINK_CLEARED,
+ type LinearIssueLinkUpdates
+} from '../../shared/linear-links'
import { RuntimeClientError } from '../runtime-client'
-type LinearIssueLinkParams = {
- linkedLinearIssue: string | null
- linkedLinearIssueWorkspaceId: string | null
- linkedLinearIssueOrganizationUrlKey: string | null
-}
-
export function getOptionalLinearIssueLinkFlag(
flags: Map,
name: string,
options: { allowNull?: boolean } = {}
-): LinearIssueLinkParams | undefined {
+): LinearIssueLinkUpdates | undefined {
const value = getPresentStringFlag(flags, name)
if (value === undefined) {
return undefined
@@ -24,28 +22,22 @@ export function getOptionalLinearIssueLinkFlag(
'Omit --linear-issue on create, or pass a Linear issue identifier or URL.'
)
}
- return {
- linkedLinearIssue: null,
- linkedLinearIssueWorkspaceId: null,
- linkedLinearIssueOrganizationUrlKey: null
- }
+ return { ...LINEAR_ISSUE_LINK_CLEARED }
}
- const parsed = parseLinearIssueInput(value)
- if (!parsed) {
+ // Why: the shared builder treats empty input as "clear", which is right for a
+ // text field the user can blank but wrong for a flag — `--linear-issue " "`
+ // is a mistyped argument, not a request to unlink. Only the literal `null`
+ // clears, and that is handled above.
+ const updates = value.trim() === '' ? null : buildLinearIssueLinkUpdates(value)
+ if (!updates) {
throw new RuntimeClientError(
'invalid_argument',
'Pass a Linear issue identifier like STA-335, a Linear issue URL, or null to clear.'
)
}
- return {
- linkedLinearIssue: parsed.identifier,
- // Why: changing a link must not keep a workspace id from a previous issue.
- // The org key from URLs is enough for current-resolution to safely rehydrate it.
- linkedLinearIssueWorkspaceId: null,
- linkedLinearIssueOrganizationUrlKey: parsed.organizationUrlKey ?? null
- }
+ return updates
}
function getPresentStringFlag(
diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts
index 6eb064bee28..b9429cb9d69 100644
--- a/src/cli/index.test.ts
+++ b/src/cli/index.test.ts
@@ -1236,6 +1236,9 @@ describe('orca cli worktree awareness', () => {
linkedIssue: undefined,
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
+ // Why: a bare identifier carries no org, and the two scoping fields
+ // describe the issue being replaced. Inheriting a stale org key would
+ // pin the lookup to a workspace this issue does not live in.
linkedLinearIssueOrganizationUrlKey: null,
comment: undefined,
runHooks: false,
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx
index 9cc437fc513..ac6417ef8a3 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx
@@ -901,7 +901,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
)
const handleRenameTitle = useCallback(
- (displayName: string) => updateWorktreeMeta(worktree.id, { displayName }),
+ // Inline rename has no surface for the failure; the store already logs and
+ // refetches, which reverts the optimistic title in place.
+ async (displayName: string): Promise => {
+ await updateWorktreeMeta(worktree.id, { displayName })
+ },
[updateWorktreeMeta, worktree.id]
)
diff --git a/src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx b/src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx
new file mode 100644
index 00000000000..501ef1bc607
--- /dev/null
+++ b/src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx
@@ -0,0 +1,256 @@
+import React, { useCallback, useId, useMemo } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger
+} from '@/components/ui/dropdown-menu'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { LinearIcon } from '@/components/icons/LinearIcon'
+import { ChevronDown, ExternalLink, Github, LoaderCircle } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import {
+ ISSUE_LINK_PROVIDERS,
+ isIssueLinkProvider,
+ type IssueLinkProvider
+} from '../../../../shared/issue-link-input'
+
+// Why: the value must clear the chip and the open-issue button that float over
+// the input's right edge. Provider labels are localized, so the text share is
+// measured in `ch` rather than baked into a Tailwind `pr-*` step that a longer
+// translation would overlap.
+const ADORNMENT_RESERVE_PX = 70
+const MAX_RESERVE = '55%'
+
+/** Exported for test: happy-dom drops `min()`, so this cannot be read back off a style. */
+export function issueAdornmentReserve(providerLabel: string): string {
+ return `min(calc(${ADORNMENT_RESERVE_PX}px + ${providerLabel.length}ch), ${MAX_RESERVE})`
+}
+
+function providerLabel(provider: IssueLinkProvider): string {
+ return provider === 'linear'
+ ? translate('auto.components.sidebar.WorktreeIssueLinkField.25852bfc59', 'Linear')
+ : translate('auto.components.sidebar.WorktreeIssueLinkField.5b440069e6', 'GitHub')
+}
+
+function ProviderIcon({
+ provider,
+ className
+}: {
+ provider: IssueLinkProvider
+ className?: string
+}): React.JSX.Element {
+ return provider === 'linear' ? (
+
+ ) : (
+
+ )
+}
+
+export type WorktreeIssueLinkFieldProps = {
+ inputRef: React.RefObject
+ value: string
+ provider: IssueLinkProvider
+ /** Set when the current value cannot be saved, so the row can explain why. */
+ isInvalid: boolean
+ /** Names the links this save would drop; both slots can be filled at once. */
+ displacedLinkLabels: string[] | null
+ /** Folder workspaces have no persisted link slots — see the design doc. */
+ isReadOnly: boolean
+ canOpenIssue: boolean
+ openingIssue: boolean
+ /** The last open attempt resolved to nothing — see useWorktreeIssueLink. */
+ openIssueFailed: boolean
+ onValueChange: (value: string) => void
+ onProviderChange: (provider: IssueLinkProvider) => void
+ onOpenIssue: () => void
+ onKeyDown: (event: React.KeyboardEvent) => void
+}
+
+export function WorktreeIssueLinkField(props: WorktreeIssueLinkFieldProps): React.JSX.Element {
+ const {
+ inputRef,
+ value,
+ provider,
+ isInvalid,
+ displacedLinkLabels,
+ isReadOnly,
+ canOpenIssue,
+ openingIssue,
+ openIssueFailed,
+ onValueChange,
+ onProviderChange,
+ onOpenIssue,
+ onKeyDown
+ } = props
+
+ // Why: the helper `` is the only surface for the invalid and displacement
+ // states, so it has to be announced with the field rather than just seen.
+ const helperId = useId()
+ const inputId = useId()
+ const label = providerLabel(provider)
+ const openIssueLabel = translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.161b2d053a',
+ 'Open linked issue'
+ )
+
+ const handleProviderChange = useCallback(
+ (next: string) => {
+ if (isIssueLinkProvider(next)) {
+ onProviderChange(next)
+ }
+ },
+ [onProviderChange]
+ )
+
+ const helperText = useMemo(() => {
+ if (isReadOnly) {
+ return translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.d4785f9954',
+ "Issue links are set when a folder workspace is created and can't be changed here yet."
+ )
+ }
+ if (isInvalid) {
+ return provider === 'linear'
+ ? translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.964d9bc00a',
+ 'Not a Linear issue key or linear.app issue URL.'
+ )
+ : translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.0a7a2c6efd',
+ 'Not a GitHub issue number or issue URL.'
+ )
+ }
+ // Why: ranked above displacement because it answers the click the user just
+ // made, and it clears as soon as they edit the value that caused it.
+ if (openIssueFailed) {
+ return provider === 'linear'
+ ? translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.d8c8a30d1f',
+ "Couldn't open that issue. Check the identifier and your Linear connection."
+ )
+ : translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.269198eeda',
+ "Couldn't open that issue. Check the number and your GitHub connection."
+ )
+ }
+ // Whole sentences per arity rather than a joined list: a translated " and "
+ // fragment would not survive languages that order or punctuate lists differently.
+ if (displacedLinkLabels && displacedLinkLabels.length > 1) {
+ return translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.72486800ff',
+ 'Saving unlinks {{first}} and {{second}} — a workspace tracks one issue.',
+ { first: displacedLinkLabels[0], second: displacedLinkLabels[1] }
+ )
+ }
+ if (displacedLinkLabels?.length) {
+ return translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.2c245ac134',
+ 'Saving unlinks {{link}} — a workspace tracks one issue.',
+ { link: displacedLinkLabels[0] }
+ )
+ }
+ return translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.f047887705',
+ 'Paste a GitHub or Linear URL, or enter a number. Leave blank to remove the link.'
+ )
+ }, [displacedLinkLabels, isInvalid, isReadOnly, openIssueFailed, provider])
+
+ return (
+
+
+ {translate('auto.components.sidebar.WorktreeIssueLinkField.ad78f9bee2', 'Issue')}
+
+
+
onValueChange(e.target.value)}
+ onKeyDown={onKeyDown}
+ disabled={isReadOnly}
+ aria-invalid={isInvalid || undefined}
+ placeholder={translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.662ae142f8',
+ 'Issue #, or a GitHub or Linear URL'
+ )}
+ className="h-8 text-xs"
+ style={{ paddingRight: issueAdornmentReserve(label) }}
+ />
+
+ {/* Why: `modal` would stack a second scroll-lock layer over the
+ Dialog's, whose nested teardown strands pointer-events on body. */}
+
+
+
+
+ {label}
+
+
+
+
+
+ {ISSUE_LINK_PROVIDERS.map((item) => (
+
+
+ {providerLabel(item)}
+
+ ))}
+
+
+
+
+
+
+ {openingIssue ? (
+
+ ) : (
+
+ )}
+
+
+
+ {openIssueLabel}
+
+
+
+
+ {/* Why: two lines are reserved up front. The dialog is centered by
+ transform, so a wrap-count change would shift the field being typed in. */}
+
+ {helperText}
+
+
+ )
+}
diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx
new file mode 100644
index 00000000000..7c16a086220
--- /dev/null
+++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx
@@ -0,0 +1,341 @@
+// @vitest-environment happy-dom
+
+import { act, type ReactNode } from 'react'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { useAppStore } from '@/store'
+import type { FolderWorkspace, Repo, Worktree, WorktreeMeta } from '../../../../shared/types'
+import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
+
+// Why: Radix tooltips need a provider the dialog does not own, and the menu's
+// portal needs real layout. Stand-ins keep these tests on provider selection.
+vi.mock('@/components/ui/tooltip', () => ({
+ Tooltip: ({ children }: { children?: ReactNode }) => <>{children}>,
+ TooltipContent: () => null,
+ TooltipTrigger: ({ children }: { children?: ReactNode }) => <>{children}>
+}))
+
+vi.mock('@/components/ui/dropdown-menu', async () => {
+ const React = await import('react')
+ const SelectContext = React.createContext<(value: string) => void>(() => {})
+ const Passthrough = ({ children }: { children?: ReactNode }) => <>{children}>
+ return {
+ DropdownMenu: Passthrough,
+ DropdownMenuTrigger: Passthrough,
+ DropdownMenuContent: Passthrough,
+ DropdownMenuRadioGroup: ({
+ value,
+ onValueChange,
+ children
+ }: {
+ value: string
+ onValueChange: (value: string) => void
+ children?: ReactNode
+ }) => (
+
+ {children}
+
+ ),
+ DropdownMenuRadioItem: ({ value, children }: { value: string; children?: ReactNode }) => {
+ const onSelect = React.useContext(SelectContext)
+ return (
+ onSelect(value)}>
+ {children}
+
+ )
+ }
+ }
+})
+
+import WorktreeMetaDialog from './WorktreeMetaDialog'
+
+const REPO_ID = 'repo-1'
+const WORKTREE_ID = 'repo-1::/repo/worktrees/feature'
+
+const initialState = useAppStore.getInitialState()
+const updateWorktreeMeta =
+ vi.fn<
+ (
+ id: string,
+ updates: Partial
+ ) => Promise<{ ok: true } | { ok: false; error: string }>
+ >()
+
+function makeRepo(): Repo {
+ return { id: REPO_ID, path: '/repo', displayName: 'orca', badgeColor: '#999999', addedAt: 1 }
+}
+
+function makeWorktree(overrides: Partial = {}): Worktree {
+ return {
+ id: WORKTREE_ID,
+ repoId: REPO_ID,
+ path: '/repo/worktrees/feature',
+ displayName: 'Feature work',
+ branch: 'feature',
+ head: 'abc123',
+ isBare: false,
+ isMainWorktree: false,
+ comment: 'existing note',
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: null,
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 1,
+ ...overrides
+ }
+}
+
+function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace {
+ return {
+ id: 'fw-1',
+ projectGroupId: 'pg-1',
+ name: 'Docs folder',
+ folderPath: '/repo/docs',
+ linkedTask: {
+ provider: 'linear',
+ type: 'issue',
+ number: 901,
+ title: 'Fix auth',
+ url: 'https://linear.app/acme/issue/STA-901',
+ linearIdentifier: 'STA-901'
+ },
+ comment: '',
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 1,
+ createdAt: 1,
+ updatedAt: 1,
+ ...overrides
+ }
+}
+
+function openDialog(
+ options: {
+ worktree?: Partial
+ worktreeId?: string
+ folderWorkspace?: Partial
+ } = {}
+): void {
+ const worktree = makeWorktree(options.worktree)
+ useAppStore.setState({
+ repos: [makeRepo()],
+ worktreesByRepo: { [REPO_ID]: [worktree] },
+ ...(options.folderWorkspace
+ ? { folderWorkspaces: [makeFolderWorkspace(options.folderWorkspace)] }
+ : {}),
+ activeModal: 'edit-meta',
+ modalData: {
+ worktreeId: options.worktreeId ?? worktree.id,
+ currentDisplayName: worktree.displayName,
+ currentComment: worktree.comment,
+ focus: 'comment'
+ },
+ updateWorktreeMeta
+ })
+ render( )
+}
+
+function issueInput(): HTMLInputElement {
+ return screen.getByPlaceholderText('Issue #, or a GitHub or Linear URL')
+}
+
+function providerChip(): HTMLButtonElement {
+ return screen.getByRole('button', { name: 'Issue provider' })
+}
+
+function saveButton(): HTMLButtonElement {
+ return screen.getByRole('button', { name: 'Save' })
+}
+
+describe('WorktreeMetaDialog issue link row', () => {
+ beforeEach(() => {
+ useAppStore.setState(initialState, true)
+ updateWorktreeMeta.mockReset()
+ updateWorktreeMeta.mockResolvedValue({ ok: true })
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { shell: { openUrl: vi.fn() } }
+ })
+ })
+
+ afterEach(() => {
+ cleanup()
+ useAppStore.setState(initialState, true)
+ })
+
+ it('seeds the chip and value from a GitHub link', () => {
+ openDialog({ worktree: { linkedIssue: 42 } })
+
+ expect(providerChip().textContent).toContain('GitHub')
+ expect(issueInput().value).toBe('42')
+ })
+
+ it('seeds the chip and value from a Linear link', () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+
+ expect(providerChip().textContent).toContain('Linear')
+ expect(issueInput().value).toBe('STA-335')
+ })
+
+ it('flips to Linear when a linear.app issue URL is pasted', () => {
+ openDialog({ worktree: { linkedIssue: 42 } })
+
+ fireEvent.change(issueInput(), {
+ target: { value: 'https://linear.app/acme/issue/STA-335/fix-the-thing' }
+ })
+
+ expect(providerChip().textContent).toContain('Linear')
+ })
+
+ // Why: Linear and Jira issue keys are the same shape, so only a URL may steer
+ // the provider — a bare key must never override the user's explicit choice.
+ it('keeps the chip on GitHub when a bare issue key is typed', () => {
+ openDialog({ worktree: { linkedIssue: 42 } })
+
+ fireEvent.change(issueInput(), { target: { value: 'GH-1234' } })
+
+ expect(providerChip().textContent).toContain('GitHub')
+ expect(providerChip().textContent).not.toContain('Linear')
+ })
+
+ it('flips to GitHub when a GitHub issue URL is pasted over a Linear link', () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+
+ fireEvent.change(issueInput(), {
+ target: { value: 'https://github.com/acme/orca/issues/77' }
+ })
+
+ expect(providerChip().textContent).toContain('GitHub')
+ })
+
+ it('names the Linear issue that switching to GitHub would unlink', () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+
+ fireEvent.click(screen.getByRole('menuitemradio', { name: 'GitHub' }))
+ fireEvent.change(issueInput(), { target: { value: '99' } })
+
+ expect(
+ screen.getByText('Saving unlinks Linear STA-335 — a workspace tracks one issue.')
+ ).toBeTruthy()
+ })
+
+ // Both slots can hold a link at once — naming only one understates the save.
+ it('names both links when clearing the field would drop both', () => {
+ openDialog({ worktree: { linkedIssue: 42, linkedLinearIssue: 'STA-335' } })
+
+ fireEvent.change(issueInput(), { target: { value: '' } })
+
+ expect(
+ screen.getByText(
+ 'Saving unlinks Linear STA-335 and GitHub #42 — a workspace tracks one issue.'
+ )
+ ).toBeTruthy()
+ })
+
+ // A failed save refetches and reverts the optimistic write, so closing here
+ // would report success for an edit that silently undid itself.
+ it('keeps the dialog open and reports why when the save fails', async () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+ updateWorktreeMeta.mockResolvedValue({ ok: false, error: 'Runtime is offline' })
+
+ fireEvent.change(issueInput(), { target: { value: 'STA-999' } })
+ await act(async () => {
+ fireEvent.click(saveButton())
+ })
+
+ expect(screen.getByRole('alert').textContent).toBe('Runtime is offline')
+ expect(useAppStore.getState().activeModal).toBe('edit-meta')
+ })
+
+ it('leaves the Linear link alone when only the comment is edited', async () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+
+ fireEvent.change(screen.getByPlaceholderText('Notes about this worktree...'), {
+ target: { value: 'updated note' }
+ })
+
+ expect(screen.queryByText(/Saving unlinks/)).toBeNull()
+
+ await act(async () => {
+ fireEvent.click(saveButton())
+ })
+
+ await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1))
+ const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {}
+ expect(Object.keys(updates)).not.toContain('linkedLinearIssue')
+ expect(Object.keys(updates)).not.toContain('linkedIssue')
+ expect(updates.comment).toBe('updated note')
+ })
+
+ it('blocks saving an unparseable Linear value', () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+
+ fireEvent.change(issueInput(), { target: { value: 'not an issue' } })
+
+ expect(screen.getByText('Not a Linear issue key or linear.app issue URL.')).toBeTruthy()
+ expect(saveButton().disabled).toBe(true)
+ })
+
+ it('is read-only for a folder workspace', () => {
+ openDialog({ worktreeId: folderWorkspaceKey('fw-1') })
+
+ expect(issueInput().disabled).toBe(true)
+ expect(providerChip().disabled).toBe(true)
+ expect(
+ screen.getByText(
+ "Issue links are set when a folder workspace is created and can't be changed here yet."
+ )
+ ).toBeTruthy()
+ })
+
+ // Folder workspaces live outside worktreesByRepo, so the indexed lookup alone
+ // leaves the row blank and the link it does hold looks lost.
+ it('shows a folder workspace its own linked issue', () => {
+ openDialog({ worktreeId: folderWorkspaceKey('fw-1'), folderWorkspace: {} })
+
+ expect(issueInput().value).toBe('STA-901')
+ expect(providerChip().textContent).toContain('Linear')
+ })
+
+ // A background `orca worktree set` must not move the baseline mid-edit: the
+ // field would read as dirty and a comment-only save would write the stale seed.
+ it('keeps the baseline frozen when the store changes while open', async () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+
+ act(() => {
+ useAppStore.setState({
+ worktreesByRepo: {
+ [REPO_ID]: [makeWorktree({ linkedLinearIssue: 'STA-999' })]
+ }
+ })
+ })
+ fireEvent.change(screen.getByPlaceholderText('Notes about this worktree...'), {
+ target: { value: 'still working' }
+ })
+ await act(async () => {
+ fireEvent.click(saveButton())
+ })
+
+ const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {}
+ expect(updates.comment).toBe('still working')
+ expect(updates).not.toHaveProperty('linkedLinearIssue')
+ expect(updates).not.toHaveProperty('linkedIssue')
+ })
+
+ it('dispatches nothing when the dialog is cancelled', async () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+
+ fireEvent.change(issueInput(), { target: { value: '99' } })
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ })
+
+ expect(updateWorktreeMeta).not.toHaveBeenCalled()
+ expect(useAppStore.getState().activeModal).toBe('none')
+ })
+})
diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
index 5163a839ebf..b5c1d059c57 100644
--- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
@@ -10,23 +10,41 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { getDisplacedLinkLabels } from './worktree-issue-displacement'
import {
buildWorktreeMetaUpdates,
parseGitHubWorkItemNumberForMetaField,
- type WorktreeMetaSavedPayload
+ type WorktreeMetaDraft,
+ type WorktreeMetaSavedPayload,
+ type WorktreeMetaSnapshot
} from './worktree-meta-updates'
import { useWorktreeIssueLink } from './use-worktree-issue-link'
+import { WorktreeIssueLinkField } from './WorktreeIssueLinkField'
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
-import { ExternalLink, LoaderCircle } from 'lucide-react'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
+import { findIndexedWorktreeOwner } from '@/lib/worktree-runtime-owner-index'
+import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
+import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
+import { isWorkItemLinkQueryTooLarge } from '../../../../shared/new-workspace/work-item-link-query-bounds'
+import {
+ getIssueLinkProviderFromUrl,
+ parseIssueLinkInput,
+ type IssueLinkProvider
+} from '../../../../shared/issue-link-input'
function resizeCommentTextarea(textarea: HTMLTextAreaElement): void {
textarea.style.height = 'auto'
textarea.style.height = `${textarea.scrollHeight}px`
}
+/** Only read before the first open, when nothing can be saved yet. */
+const EMPTY_SNAPSHOT: WorktreeMetaSnapshot = {
+ displayName: '',
+ issueInput: '',
+ issueProvider: 'github'
+}
+
const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
const activeModal = useAppStore((s) => s.activeModal)
const modalData = useAppStore((s) => s.modalData)
@@ -40,9 +58,6 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
const worktreeId = typeof modalData.worktreeId === 'string' ? modalData.worktreeId : ''
const currentDisplayName =
typeof modalData.currentDisplayName === 'string' ? modalData.currentDisplayName : ''
- const currentIssue =
- typeof modalData.currentIssue === 'number' ? String(modalData.currentIssue) : ''
- const currentPR = typeof modalData.currentPR === 'number' ? String(modalData.currentPR) : ''
const currentComment =
typeof modalData.currentComment === 'string' ? modalData.currentComment : ''
const focusField = typeof modalData.focus === 'string' ? modalData.focus : 'comment'
@@ -51,15 +66,73 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
? (modalData.afterSave as (payload: WorktreeMetaSavedPayload) => void | Promise)
: null
+ const workspaceScope = useMemo(() => parseWorkspaceKey(worktreeId), [worktreeId])
+ // Why: link state is read from the store rather than threaded through the
+ // untyped modalData bag — a call site that forgot a key would otherwise seed
+ // an empty field and silently destroy the link on save.
+ const indexedWorktree = useAppStore((s) => {
+ const owner = findIndexedWorktreeOwner(s.worktreesByRepo, worktreeId)
+ return owner
+ ? s.worktreesByRepo[owner.repoId]?.find((item) => item.id === worktreeId)
+ : undefined
+ })
+ // Why: folder workspaces are absent from worktreesByRepo, so the lookup above
+ // returns undefined and the row renders blank for them. The selector returns
+ // the stored record — a stable reference — and the projection happens outside
+ // it, because a selector that built the object would return a fresh identity
+ // on every store write and re-render the dialog continuously.
+ const folderWorkspace = useAppStore((s) =>
+ workspaceScope?.type === 'folder'
+ ? (s.folderWorkspaces.find((item) => item.id === workspaceScope.folderWorkspaceId) ?? null)
+ : null
+ )
+ const worktree = useMemo(
+ () => (folderWorkspace ? folderWorkspaceToWorktree(folderWorkspace) : indexedWorktree),
+ [folderWorkspace, indexedWorktree]
+ )
+ const linkedIssue = worktree?.linkedIssue ?? null
+ const linkedLinearIssue = worktree?.linkedLinearIssue ?? null
+ // Why: ChecksPanel seeds the PR it is looking at, which may not be linked yet.
+ const currentPR =
+ typeof modalData.currentPR === 'number'
+ ? String(modalData.currentPR)
+ : worktree?.linkedPR != null
+ ? String(worktree.linkedPR)
+ : ''
+ // Why: `typeof` rather than a null check — an unhydrated projection can leave
+ // linkedIssue undefined, which `!== null` would read as a GitHub link.
+ const currentProvider: IssueLinkProvider =
+ typeof linkedIssue === 'number' ? 'github' : linkedLinearIssue ? 'linear' : 'github'
+ // Why: the value and its provider must come from one source. Every call site
+ // passed `currentIssue: worktree.linkedIssue`, so the modalData seed was pure
+ // duplication that could only ever drift from the chip.
+ const currentIssue =
+ currentProvider === 'linear'
+ ? (linkedLinearIssue ?? '')
+ : typeof linkedIssue === 'number'
+ ? String(linkedIssue)
+ : ''
+ // Why: folder workspaces persist links only through their creation-time
+ // linkedTask; updateWorktreeMeta drops link keys for them entirely.
+ const isFolderWorkspace = workspaceScope?.type === 'folder'
+
const [displayNameInput, setDisplayNameInput] = useState('')
const [issueInput, setIssueInput] = useState('')
+ const [issueProvider, setIssueProvider] = useState('github')
const [prInput, setPrInput] = useState('')
const [commentInput, setCommentInput] = useState('')
const [saving, setSaving] = useState(false)
- const { canOpenIssue, openingIssue, handleOpenIssue, resetOpeningIssue } = useWorktreeIssueLink({
- worktreeId,
- issueInput
- })
+ const [saveError, setSaveError] = useState(null)
+ const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT)
+ const { canOpenIssue, openingIssue, openIssueFailed, handleOpenIssue, resetOpeningIssue } =
+ useWorktreeIssueLink({
+ worktreeId,
+ issueInput,
+ issueProvider,
+ linearOrganizationUrlKey: worktree?.linkedLinearIssueOrganizationUrlKey ?? null,
+ linkedLinearIssue: worktree?.linkedLinearIssue ?? null,
+ linearSourceContext: worktree?.linkedTaskSourceContext ?? null
+ })
const issueInputRef = useRef(null)
const prInputRef = useRef(null)
@@ -70,12 +143,42 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
if (isOpen && !prevIsOpenRef.current) {
setDisplayNameInput(currentDisplayName)
setIssueInput(currentIssue)
+ setIssueProvider(currentProvider)
setPrInput(currentPR)
setCommentInput(currentComment)
+ // Why: the baseline is frozen with the seed instead of tracking the store.
+ // A background `orca worktree set --linear-issue` while the dialog is open
+ // would otherwise move it, making the untouched field read as dirty — and
+ // the next comment-only save would write the stale seed back over the new link.
+ setSnapshot({
+ displayName: currentDisplayName,
+ issueInput: currentIssue,
+ issueProvider: currentProvider,
+ linkedWorkItemProvider: worktree?.linkedWorkItem?.provider ?? null,
+ linkedWorkItemType: worktree?.linkedWorkItem?.type ?? null
+ })
+ setSaveError(null)
resetOpeningIssue()
}
prevIsOpenRef.current = isOpen
+ const draft = useMemo(
+ () => ({ displayNameInput, issueInput, issueProvider, prInput, commentInput }),
+ [displayNameInput, issueInput, issueProvider, prInput, commentInput]
+ )
+
+ // Why: a URL names its provider unambiguously. A bare key does not — Linear
+ // and Jira identifiers are the same shape — so typing never steers the chip.
+ const handleIssueInputChange = useCallback((next: string) => {
+ setIssueInput(next)
+ // Why: the same bound the save gate applies. Detection parses on every
+ // keystroke, and the field accepts pasted URLs of any length.
+ const detected = isWorkItemLinkQueryTooLarge(next) ? null : getIssueLinkProviderFromUrl(next)
+ if (detected) {
+ setIssueProvider(detected)
+ }
+ }, [])
+
const setCommentTextareaRef = useCallback(
(textarea: HTMLTextAreaElement | null) => {
textareaRef.current = textarea
@@ -92,18 +195,42 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
resizeCommentTextarea(event.currentTarget)
}, [])
+ // Why: bound the parse before it runs on every keystroke — the field accepts
+ // pasted URLs and has no length cap of its own.
+ const issueInvalid = useMemo(() => {
+ const trimmed = issueInput.trim()
+ if (trimmed === '' || isFolderWorkspace) {
+ return false
+ }
+ return (
+ isWorkItemLinkQueryTooLarge(trimmed) || parseIssueLinkInput(trimmed, issueProvider) === null
+ )
+ }, [isFolderWorkspace, issueInput, issueProvider])
+
const canSave = useMemo(() => {
if (!worktreeId) {
return false
}
- const trimmedIssue = issueInput.trim()
const trimmedPR = prInput.trim()
- const issueValid =
- trimmedIssue === '' || parseGitHubWorkItemNumberForMetaField(trimmedIssue, 'issue') !== null
+ // Same quadratic-parse bound as the issue field — this runs on every keystroke.
const prValid =
- trimmedPR === '' || parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr') !== null
- return issueValid && prValid
- }, [worktreeId, issueInput, prInput])
+ trimmedPR === '' ||
+ (!isWorkItemLinkQueryTooLarge(trimmedPR) &&
+ parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr') !== null)
+ return !issueInvalid && prValid
+ }, [worktreeId, issueInvalid, prInput])
+
+ const displacedLinkLabels = useMemo(
+ () =>
+ getDisplacedLinkLabels({
+ draft,
+ snapshot,
+ isFolderWorkspace,
+ linkedIssue,
+ linkedLinearIssue
+ }),
+ [draft, snapshot, isFolderWorkspace, linkedIssue, linkedLinearIssue]
+ )
const handleOpenChange = useCallback(
(open: boolean) => {
@@ -120,15 +247,18 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
}
setSaving(true)
try {
- const updates = buildWorktreeMetaUpdates({
- displayNameInput,
- currentDisplayName,
- issueInput,
- prInput,
- commentInput
- })
+ const updates = buildWorktreeMetaUpdates(draft, snapshot)
- await updateWorktreeMeta(worktreeId, updates)
+ const result = await updateWorktreeMeta(worktreeId, updates)
+ // Why: a failed save refetches and reverts the optimistic write. Closing
+ // here would report success for an edit that silently undid itself, and
+ // would discard the name, comment and PR changes in the same payload.
+ if (!result.ok) {
+ if (mountedRef.current) {
+ setSaveError(result.error)
+ }
+ return
+ }
closeModal()
// Why: follow-up refreshes should not turn a successful metadata save
// into a failed dialog.
@@ -142,19 +272,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
setSaving(false)
}
}
- }, [
- worktreeId,
- canSave,
- displayNameInput,
- currentDisplayName,
- issueInput,
- prInput,
- commentInput,
- updateWorktreeMeta,
- closeModal,
- afterSave,
- mountedRef
- ])
+ }, [worktreeId, canSave, draft, snapshot, updateWorktreeMeta, closeModal, afterSave, mountedRef])
const handleCommentKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -204,8 +322,8 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
{translate(
- 'auto.components.sidebar.WorktreeMetaDialog.65770ad0f0',
- 'Edit GitHub links and notes for this workspace.'
+ 'auto.components.sidebar.WorktreeMetaDialog.a0d191b7a7',
+ 'Edit issue links, pull request links, and notes for this workspace.'
)}
@@ -234,58 +352,21 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
-
-
- {translate('auto.components.sidebar.WorktreeMetaDialog.645fa4a0fd', 'GH Issue')}
-
-
- setIssueInput(e.target.value)}
- onKeyDown={handleIssueKeyDown}
- placeholder={translate(
- 'auto.components.sidebar.WorktreeMetaDialog.741279e7b7',
- 'Issue # or GitHub URL'
- )}
- className="h-8 pr-9 text-xs"
- />
-
-
-
- {openingIssue ? (
-
- ) : (
-
- )}
-
-
-
- {translate(
- 'auto.components.sidebar.WorktreeMetaDialog.029ea5ec57',
- 'Open GitHub issue'
- )}
-
-
-
-
- {translate(
- 'auto.components.sidebar.WorktreeMetaDialog.7c454be4c5',
- 'Paste an issue URL, or enter a number. Leave blank to remove the link.'
- )}
-
-
+
@@ -340,6 +421,12 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
+ {saveError ? (
+
+ {saveError}
+
+ ) : null}
+
+): Promise {
+ let timer: ReturnType | undefined
+ try {
+ const issue = await Promise.race([
+ lookup,
+ new Promise((resolve) => {
+ timer = setTimeout(() => resolve(null), OPEN_ISSUE_TIMEOUT_MS)
+ })
+ ])
+ return issue?.url ?? null
+ } catch {
+ // Why: the store's fetchers already log and normalize failures to null; a
+ // rejection here is a transport fault and reads the same to the user.
+ return null
+ } finally {
+ clearTimeout(timer)
+ }
+}
/** Resolves the "open linked issue" affordance for the worktree meta dialog:
- * explicit URLs open directly, numbers resolve via the issue cache or an
- * owner-routed fetch. */
-export function useWorktreeIssueLink(args: { worktreeId: string; issueInput: string }): {
+ * explicit URLs open directly, GitHub numbers resolve via the issue cache or an
+ * owner-routed fetch, Linear identifiers via the stored org key or a lookup. */
+export function useWorktreeIssueLink(args: {
+ worktreeId: string
+ issueInput: string
+ issueProvider: IssueLinkProvider
+ linearOrganizationUrlKey?: string | null
+ /** The persisted identifier the stored org key belongs to. */
+ linkedLinearIssue?: string | null
+ linearSourceContext?: TaskSourceContext | null
+}): {
canOpenIssue: boolean
openingIssue: boolean
+ /** A lookup came back empty: wrong identifier, no access, or provider offline. */
+ openIssueFailed: boolean
handleOpenIssue: () => Promise
resetOpeningIssue: () => void
} {
- const { worktreeId, issueInput } = args
+ const {
+ worktreeId,
+ issueInput,
+ issueProvider,
+ linearOrganizationUrlKey,
+ linkedLinearIssue,
+ linearSourceContext
+ } = args
+ const isLinear = issueProvider === 'linear'
const fetchIssue = useAppStore((s) => s.fetchIssue)
+ const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue)
+ const viewerOrganizationUrlKey = useAppStore(
+ (s) => s.linearStatus?.viewer?.organizationUrlKey ?? null
+ )
const [openingIssue, setOpeningIssue] = useState(false)
+ const [failedIssueInput, setFailedIssueInput] = useState(null)
const mountedRef = useMountedRef()
- const issueNumber = useMemo(() => parseGitHubIssueOrPRNumber(issueInput), [issueInput])
- const issueUrlFromInput = useMemo(() => parseExplicitGitHubIssueUrl(issueInput), [issueInput])
- const issueInputLooksLikeUrl = useMemo(
- () => /^https?:\/\//i.test(issueInput.trim()),
+ // Why: `matchGitHubItemPath` strips trailing slashes with an unanchored `/\/+$/`,
+ // which is quadratic — a 160 KB paste of slashes freezes the renderer for ~19s.
+ // These parses run on every keystroke, so they get the same bound the save gate
+ // applies. The check short-circuits on length, so it stays O(1) on a huge paste.
+ const boundedInput = useMemo(
+ () => (isWorkItemLinkQueryTooLarge(issueInput) ? '' : issueInput),
[issueInput]
)
- const issueRepo = useAppStore((s) => {
- const worktree = Object.values(s.worktreesByRepo)
- .flat()
- .find((item) => item.id === worktreeId)
- if (!worktree) {
- return undefined
+
+ const issueNumber = useMemo(
+ () => (isLinear ? null : parseGitHubIssueOrPRNumber(boundedInput)),
+ [isLinear, boundedInput]
+ )
+ const issueUrlFromInput = useMemo(
+ () => (isLinear ? null : parseExplicitGitHubIssueUrl(boundedInput)),
+ [isLinear, boundedInput]
+ )
+ const issueInputLooksLikeUrl = useMemo(
+ () => /^https?:\/\//i.test(boundedInput.trim()),
+ [boundedInput]
+ )
+ const parsedLinearIssue = useMemo(
+ () => (isLinear ? parseLinearIssueInput(boundedInput) : null),
+ [isLinear, boundedInput]
+ )
+ // Why: the input's own org key wins, then the workspace's stored one, then the
+ // connected viewer's — a bare identifier is only resolvable through the latter two.
+ // The stored key describes the persisted issue, so it only applies while the typed
+ // identifier still *is* that issue; reusing it for a newly typed one builds a URL
+ // in the wrong org and short-circuits the lookup that would have reported the miss.
+ const linearIssueUrl = useMemo(() => {
+ if (!parsedLinearIssue) {
+ return null
}
- return s.repos.find((repo) => repo.id === worktree.repoId)
+ const storedKeyApplies =
+ typeof linkedLinearIssue === 'string' &&
+ linkedLinearIssue.toUpperCase() === parsedLinearIssue.identifier.toUpperCase()
+ return buildLinearIssueUrl({
+ identifier: parsedLinearIssue.identifier,
+ organizationUrlKey:
+ parsedLinearIssue.organizationUrlKey ??
+ (storedKeyApplies ? linearOrganizationUrlKey : null) ??
+ viewerOrganizationUrlKey
+ })
+ }, [parsedLinearIssue, linkedLinearIssue, linearOrganizationUrlKey, viewerOrganizationUrlKey])
+
+ const issueRepo = useAppStore((s) => {
+ const owner = findIndexedWorktreeOwner(s.worktreesByRepo, worktreeId)
+ return owner ? s.repos.find((repo) => repo.id === owner.repoId) : undefined
})
const cachedIssueUrl = useAppStore((s) => {
if (!issueRepo || issueNumber === null) {
@@ -52,14 +143,51 @@ export function useWorktreeIssueLink(args: { worktreeId: string; issueInput: str
]?.data?.url ?? null
)
})
- const canOpenIssue = issueInputLooksLikeUrl
- ? Boolean(issueUrlFromInput)
- : Boolean(cachedIssueUrl || (issueRepo && issueNumber))
+ const canOpenIssue = isLinear
+ ? Boolean(parsedLinearIssue)
+ : issueInputLooksLikeUrl
+ ? Boolean(issueUrlFromInput)
+ : Boolean(cachedIssueUrl || (issueRepo && issueNumber))
const handleOpenIssue = useCallback(async () => {
if (openingIssue) {
return
}
+ setFailedIssueInput(null)
+
+ if (isLinear) {
+ if (!parsedLinearIssue) {
+ return
+ }
+ if (linearIssueUrl) {
+ void window.api.shell.openUrl(linearIssueUrl)
+ return
+ }
+
+ setOpeningIssue(true)
+ try {
+ // Why: 'all' — the issue may belong to a different Linear workspace than
+ // the selected one, which is the usual case for a pasted identifier.
+ const url = await resolveIssueUrlWithinTimeout(
+ fetchLinearIssue(parsedLinearIssue.identifier, 'all', {
+ sourceContext: linearSourceContext ?? null
+ })
+ )
+ if (url) {
+ void window.api.shell.openUrl(url)
+ } else if (mountedRef.current) {
+ // Why: the fetchers normalize every failure — no Linear connection,
+ // unknown identifier, dead runtime — to null, so without this the
+ // button just stops spinning and the click looks ignored.
+ setFailedIssueInput(issueInput)
+ }
+ } finally {
+ if (mountedRef.current) {
+ setOpeningIssue(false)
+ }
+ }
+ return
+ }
if (issueUrlFromInput) {
void window.api.shell.openUrl(issueUrlFromInput)
@@ -81,9 +209,13 @@ export function useWorktreeIssueLink(args: { worktreeId: string; issueInput: str
setOpeningIssue(true)
try {
- const issue = await fetchIssue(issueRepo.path, issueNumber, { repoId: issueRepo.id })
- if (issue?.url) {
- void window.api.shell.openUrl(issue.url)
+ const url = await resolveIssueUrlWithinTimeout(
+ fetchIssue(issueRepo.path, issueNumber, { repoId: issueRepo.id })
+ )
+ if (url) {
+ void window.api.shell.openUrl(url)
+ } else if (mountedRef.current) {
+ setFailedIssueInput(issueInput)
}
} finally {
if (mountedRef.current) {
@@ -93,15 +225,33 @@ export function useWorktreeIssueLink(args: { worktreeId: string; issueInput: str
}, [
cachedIssueUrl,
fetchIssue,
+ fetchLinearIssue,
+ isLinear,
+ issueInput,
issueInputLooksLikeUrl,
issueNumber,
issueRepo,
issueUrlFromInput,
+ linearIssueUrl,
+ linearSourceContext,
mountedRef,
- openingIssue
+ openingIssue,
+ parsedLinearIssue
])
- const resetOpeningIssue = useCallback(() => setOpeningIssue(false), [])
+ const resetOpeningIssue = useCallback(() => {
+ setOpeningIssue(false)
+ setFailedIssueInput(null)
+ }, [])
- return { canOpenIssue, openingIssue, handleOpenIssue, resetOpeningIssue }
+ return {
+ canOpenIssue,
+ openingIssue,
+ // Why: the failure belongs to the value that produced it. Editing the field
+ // is the user's answer to it, so comparing rather than clearing on change
+ // retires the notice without an Effect that would lag a keystroke behind.
+ openIssueFailed: failedIssueInput !== null && failedIssueInput === issueInput,
+ handleOpenIssue,
+ resetOpeningIssue
+ }
}
diff --git a/src/renderer/src/components/sidebar/worktree-issue-displacement.ts b/src/renderer/src/components/sidebar/worktree-issue-displacement.ts
new file mode 100644
index 00000000000..a056ca7f45e
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-issue-displacement.ts
@@ -0,0 +1,37 @@
+import type { IssueLinkProvider } from '../../../../shared/issue-link-input'
+import {
+ isIssueFieldDirty,
+ type WorktreeMetaDraft,
+ type WorktreeMetaSnapshot
+} from './worktree-meta-updates'
+
+function formatLinkLabel(provider: IssueLinkProvider, value: string): string {
+ return provider === 'linear' ? `Linear ${value}` : `GitHub #${value}`
+}
+
+/** Names the persisted links a save would drop. A workspace tracks one issue, so
+ * a changed field displaces the other provider's slot and clearing drops both.
+ * Only a dirty field displaces anything — the dialog opens focused on Comment,
+ * and an untouched row must leave every link alone. */
+export function getDisplacedLinkLabels(args: {
+ draft: WorktreeMetaDraft
+ snapshot: WorktreeMetaSnapshot
+ isFolderWorkspace: boolean
+ linkedIssue: number | null
+ linkedLinearIssue: string | null
+}): string[] | null {
+ const { draft, snapshot, isFolderWorkspace, linkedIssue, linkedLinearIssue } = args
+ if (isFolderWorkspace || !isIssueFieldDirty(draft, snapshot)) {
+ return null
+ }
+
+ const keeping = draft.issueInput.trim() === '' ? null : draft.issueProvider
+ const displaced: string[] = []
+ if (keeping !== 'linear' && linkedLinearIssue) {
+ displaced.push(formatLinkLabel('linear', linkedLinearIssue))
+ }
+ if (keeping !== 'github' && typeof linkedIssue === 'number') {
+ displaced.push(formatLinkLabel('github', String(linkedIssue)))
+ }
+ return displaced.length > 0 ? displaced : null
+}
diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts
index e7e81727b07..d963d58eccb 100644
--- a/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts
@@ -1,77 +1,234 @@
import { describe, expect, it } from 'vitest'
-import { buildWorktreeMetaUpdates } from './worktree-meta-updates'
+import type { WorktreeMeta } from '../../../../shared/types'
+import {
+ buildWorktreeMetaUpdates,
+ type WorktreeMetaDraft,
+ type WorktreeMetaSnapshot
+} from './worktree-meta-updates'
+
+function makeDraft(overrides: Partial = {}): WorktreeMetaDraft {
+ return {
+ displayNameInput: 'Workspace',
+ issueInput: '',
+ issueProvider: 'github',
+ prInput: '',
+ commentInput: '',
+ ...overrides
+ }
+}
+
+function makeSnapshot(overrides: Partial = {}): WorktreeMetaSnapshot {
+ return {
+ displayName: 'Workspace',
+ issueInput: '',
+ issueProvider: 'github',
+ ...overrides
+ }
+}
+
+/** Persistence raw-spreads updates, so a present-but-undefined key erases the
+ * stored value — the invariant is asserted on every build in this suite. */
+function buildUpdates(
+ draft: Partial,
+ snapshot: Partial = {}
+): Partial {
+ const updates = buildWorktreeMetaUpdates(makeDraft(draft), makeSnapshot(snapshot))
+ const undefinedKeys = Object.keys(updates).filter(
+ (key) => updates[key as keyof WorktreeMeta] === undefined
+ )
+ expect(undefinedKeys).toEqual([])
+ return updates
+}
+
+const LINEAR_LINK_KEYS = [
+ 'linkedLinearIssue',
+ 'linkedLinearIssueWorkspaceId',
+ 'linkedLinearIssueOrganizationUrlKey'
+] as const
describe('buildWorktreeMetaUpdates', () => {
- it('rejects issue URLs in the PR input', () => {
- expect(
- buildWorktreeMetaUpdates({
- displayNameInput: 'Workspace',
- currentDisplayName: 'Workspace',
- issueInput: '',
- prInput: 'https://github.com/stablyai/orca/issues/6933',
- commentInput: ''
- })
- ).toEqual({
+ // The dialog opens focused on Comment, so this is the common save path; a
+ // regression here silently destroys the user's existing link.
+ it('emits no link keys when the issue field is untouched', () => {
+ const updates = buildUpdates(
+ { issueInput: 'STA-335', issueProvider: 'linear', commentInput: 'shipping today' },
+ { issueInput: 'STA-335', issueProvider: 'linear' }
+ )
+
+ expect(updates.comment).toBe('shipping today')
+ expect(updates).toHaveProperty('linkedPR', null)
+ expect(updates).not.toHaveProperty('linkedIssue')
+ for (const key of LINEAR_LINK_KEYS) {
+ expect(updates).not.toHaveProperty(key)
+ }
+ })
+
+ it('writes a GitHub issue number and clears the Linear slots', () => {
+ expect(buildUpdates({ issueInput: '12' })).toEqual({
comment: '',
- linkedIssue: null
+ linkedIssue: 12,
+ linkedLinearIssue: null,
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: null,
+ linkedPR: null
})
})
- it('accepts PR URLs in the PR input', () => {
+ it('writes a bare Linear identifier and clears the stored organization key', () => {
+ expect(buildUpdates({ issueInput: 'sta-335', issueProvider: 'linear' })).toEqual({
+ comment: '',
+ linkedIssue: null,
+ linkedLinearIssue: 'STA-335',
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: null,
+ linkedPR: null
+ })
+ })
+
+ it('takes the organization key from a Linear issue URL', () => {
expect(
- buildWorktreeMetaUpdates({
- displayNameInput: 'Workspace',
- currentDisplayName: 'Workspace',
- issueInput: '',
- prInput: 'https://github.com/stablyai/orca/pull/6934',
- commentInput: ''
+ buildUpdates({
+ issueInput: 'https://linear.app/acme/issue/STA-335/fix-auth',
+ issueProvider: 'linear'
})
).toEqual({
comment: '',
linkedIssue: null,
- linkedPR: 6934
+ linkedLinearIssue: 'STA-335',
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: 'acme',
+ linkedPR: null
})
})
- it('accepts issue URLs in the issue input', () => {
- expect(
- buildWorktreeMetaUpdates({
- displayNameInput: 'Workspace',
- currentDisplayName: 'Workspace',
- issueInput: 'https://github.com/stablyai/orca/issues/6933',
- prInput: '',
- commentInput: ''
- })
- ).toEqual({
+ it('clears every provider slot when the issue field is emptied', () => {
+ expect(buildUpdates({ issueInput: ' ' }, { issueInput: '42' })).toEqual({
comment: '',
- linkedIssue: 6933,
+ linkedIssue: null,
+ linkedLinearIssue: null,
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: null,
+ linkedPR: null
+ })
+ })
+
+ it('treats a provider switch with unchanged text as dirty', () => {
+ const updates = buildUpdates(
+ { issueInput: 'STA-335', issueProvider: 'linear' },
+ { issueInput: 'STA-335', issueProvider: 'github' }
+ )
+
+ expect(updates.linkedLinearIssue).toBe('STA-335')
+ expect(updates.linkedIssue).toBeNull()
+ })
+
+ it('displaces a Linear linked work item when the issue field changes', () => {
+ const updates = buildUpdates(
+ { issueInput: '12' },
+ { linkedWorkItemProvider: 'linear', linkedWorkItemType: 'issue' }
+ )
+
+ expect(updates).toHaveProperty('linkedWorkItem', null)
+ expect(updates).toHaveProperty('linkedTaskSourceContext', null)
+ })
+
+ // linkedWorkItem also records the PR or MR a workspace was created from, which
+ // the Issue row does not own and must not drop.
+ it('leaves a PR-typed work item alone', () => {
+ const updates = buildUpdates(
+ { issueInput: '12' },
+ { linkedWorkItemProvider: 'github', linkedWorkItemType: 'pr' }
+ )
+
+ expect(updates).not.toHaveProperty('linkedWorkItem')
+ expect(updates).not.toHaveProperty('linkedTaskSourceContext')
+ })
+
+ it('leaves work items owned by other providers alone', () => {
+ for (const provider of ['jira', 'gitlab'] as const) {
+ const updates = buildUpdates(
+ { issueInput: '12' },
+ { linkedWorkItemProvider: provider, linkedWorkItemType: 'issue' }
+ )
+
+ expect(updates).not.toHaveProperty('linkedWorkItem')
+ expect(updates).not.toHaveProperty('linkedTaskSourceContext')
+ }
+ })
+
+ // The row cannot render a GitLab or Jira issue, so clearing one would destroy a
+ // link the user was never shown — and neither has another editor to restore it.
+ it('leaves a GitLab issue attached', () => {
+ expect(buildUpdates({ issueInput: '12' })).not.toHaveProperty('linkedGitLabIssue')
+ expect(
+ buildUpdates({ issueInput: '12' }, { linkedWorkItemProvider: 'gitlab' })
+ ).not.toHaveProperty('linkedGitLabIssue')
+ })
+
+ it('ignores a provider switch on an empty field', () => {
+ const updates = buildUpdates(
+ { issueInput: '', issueProvider: 'linear' },
+ { issueInput: '', issueProvider: 'github' }
+ )
+
+ expect(updates).not.toHaveProperty('linkedIssue')
+ for (const key of LINEAR_LINK_KEYS) {
+ expect(updates).not.toHaveProperty(key)
+ }
+ })
+
+ it('does not displace a Linear work item when the issue field is clean', () => {
+ const updates = buildUpdates({ commentInput: 'note' }, { linkedWorkItemProvider: 'linear' })
+
+ expect(updates).not.toHaveProperty('linkedWorkItem')
+ expect(updates).not.toHaveProperty('linkedTaskSourceContext')
+ })
+
+ it('leaves links untouched for unparseable issue input', () => {
+ const updates = buildUpdates(
+ { issueInput: 'not an issue', displayNameInput: 'Renamed', commentInput: 'note' },
+ { displayName: 'Workspace' }
+ )
+
+ expect(updates).toEqual({
+ comment: 'note',
+ displayName: 'Renamed',
linkedPR: null
})
})
it('clears a display name with empty string, never a present-undefined key', () => {
- const updates = buildWorktreeMetaUpdates({
- displayNameInput: ' ',
- currentDisplayName: 'Custom Name',
- issueInput: '',
- prInput: '',
- commentInput: ''
- })
+ const updates = buildUpdates({ displayNameInput: ' ' }, { displayName: 'Custom Name' })
expect(updates.displayName).toBe('')
- expect(Object.values(updates).every((value) => value !== undefined)).toBe(true)
+ })
+
+ it('rejects issue URLs in the PR input', () => {
+ expect(buildUpdates({ prInput: 'https://github.com/stablyai/orca/issues/6933' })).toEqual({
+ comment: ''
+ })
+ })
+
+ it('accepts PR URLs in the PR input', () => {
+ expect(buildUpdates({ prInput: 'https://github.com/stablyai/orca/pull/6934' })).toEqual({
+ comment: '',
+ linkedPR: 6934
+ })
+ })
+
+ it('accepts issue URLs in the issue input', () => {
+ expect(buildUpdates({ issueInput: 'https://github.com/stablyai/orca/issues/6933' })).toEqual({
+ comment: '',
+ linkedIssue: 6933,
+ linkedLinearIssue: null,
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: null,
+ linkedPR: null
+ })
})
it('rejects PR URLs in the issue input', () => {
- expect(
- buildWorktreeMetaUpdates({
- displayNameInput: 'Workspace',
- currentDisplayName: 'Workspace',
- issueInput: 'https://github.com/stablyai/orca/pull/6934',
- prInput: '',
- commentInput: ''
- })
- ).toEqual({
+ expect(buildUpdates({ issueInput: 'https://github.com/stablyai/orca/pull/6934' })).toEqual({
comment: '',
linkedPR: null
})
diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.ts
index 538b8de6b3a..60d51ec8aa4 100644
--- a/src/renderer/src/components/sidebar/worktree-meta-updates.ts
+++ b/src/renderer/src/components/sidebar/worktree-meta-updates.ts
@@ -1,11 +1,38 @@
import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from '@/lib/github-links'
-import type { WorktreeMeta } from '../../../../shared/types'
+import {
+ buildLinearIssueLinkUpdates,
+ LINEAR_ISSUE_LINK_CLEARED
+} from '../../../../shared/linear-links'
+import { parseIssueLinkInput, type IssueLinkProvider } from '../../../../shared/issue-link-input'
+import type { WorkspaceSourceProvider } from '../../../../shared/new-workspace/workspace-source'
+import type { WorkspaceLinkedItem, WorktreeMeta } from '../../../../shared/types'
export type WorktreeMetaSavedPayload = {
worktreeId: string
updates: Partial
}
+/** What the user currently has typed in the dialog. */
+export type WorktreeMetaDraft = {
+ displayNameInput: string
+ issueInput: string
+ issueProvider: IssueLinkProvider
+ prInput: string
+ commentInput: string
+}
+
+/** The persisted state the dialog was seeded from. Captured once when the
+ * dialog opens: comparing a frozen draft against a live store would let a
+ * background write move the baseline and make an untouched field "dirty". */
+export type WorktreeMetaSnapshot = {
+ displayName: string
+ issueInput: string
+ issueProvider: IssueLinkProvider
+ linkedWorkItemProvider?: WorkspaceSourceProvider | null
+ /** `linkedWorkItem` also describes PRs and MRs, which this row does not own. */
+ linkedWorkItemType?: WorkspaceLinkedItem['type'] | null
+}
+
export function parseExplicitGitHubIssueUrl(input: string): string | null {
const trimmed = input.trim()
const link = parseGitHubIssueOrPRLink(trimmed)
@@ -30,38 +57,104 @@ export function parseGitHubWorkItemNumberForMetaField(
return parseGitHubIssueOrPRNumber(input)
}
-/** Pure save-payload builder for the worktree meta dialog: empty inputs clear
- * the link (null), unparseable inputs leave it untouched (omitted). */
-export function buildWorktreeMetaUpdates(args: {
- displayNameInput: string
- currentDisplayName: string
- issueInput: string
- prInput: string
- commentInput: string
-}): Partial {
- const trimmedIssue = args.issueInput.trim()
- const linkedIssueNumber = parseGitHubWorkItemNumberForMetaField(trimmedIssue, 'issue')
- const finalLinkedIssue =
- trimmedIssue === '' ? null : linkedIssueNumber !== null ? linkedIssueNumber : undefined
- const trimmedPR = args.prInput.trim()
- const linkedPRNumber = parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr')
- const finalLinkedPR =
- trimmedPR === '' ? null : linkedPRNumber !== null ? linkedPRNumber : undefined
-
- // Why: blanking the field means "fall back to the branch/folder name", and the
- // empty string is how that intent is persisted. Emitting `undefined` instead
- // put a present-but-undefined key into the store spread, wiping the live name
- // and crashing the worktree palette (crash a1f81ea1).
- const trimmedDisplayName = args.displayNameInput.trim()
- const updates: Partial = {
- comment: args.commentInput.trim(),
- ...(trimmedDisplayName !== args.currentDisplayName && { displayName: trimmedDisplayName })
+// Why: blanking the field means "fall back to the branch/folder name", and the
+// empty string is how that intent is persisted. Emitting `undefined` instead
+// put a present-but-undefined key into the store spread, wiping the live name
+// and crashing the worktree palette (crash a1f81ea1).
+function buildDisplayNameUpdate(
+ draft: WorktreeMetaDraft,
+ current: WorktreeMetaSnapshot
+): Partial {
+ const trimmed = draft.displayNameInput.trim()
+ return trimmed === current.displayName ? {} : { displayName: trimmed }
+}
+
+export function isIssueFieldDirty(
+ draft: WorktreeMetaDraft,
+ current: WorktreeMetaSnapshot
+): boolean {
+ if (draft.issueInput.trim() !== current.issueInput.trim()) {
+ return true
+ }
+ // Why: a provider switch only means something when there is a value to
+ // reinterpret. Toggling the chip on an empty field states no intent, and
+ // treating it as dirty would turn a comment-only save into a link wipe.
+ return draft.issueProvider !== current.issueProvider && draft.issueInput.trim() !== ''
+}
+
+/** Owns both provider slot families. One issue per workspace: writing one
+ * provider clears the other. Emits nothing at all unless the field changed —
+ * the dialog opens focused on Comment, so an untouched field must never
+ * destroy a link the user came here to keep. */
+function buildIssueLinkUpdates(
+ draft: WorktreeMetaDraft,
+ current: WorktreeMetaSnapshot
+): Partial {
+ if (!isIssueFieldDirty(draft, current)) {
+ return {}
+ }
+
+ const trimmed = draft.issueInput.trim()
+ // Why: the linked work item and its source context describe the issue being
+ // replaced. Leaving them would keep a stale title badge and mis-scope Linear
+ // reads. Narrow on purpose: `type` because the field also records the PR or MR
+ // a workspace was created from, and provider because GitLab and Jira issues
+ // have no slot in this row — displacing what it cannot display would destroy a
+ // link the user was never shown and has no other editor to restore it from.
+ const displacedWorkItem: Partial =
+ (current.linkedWorkItemProvider === 'github' || current.linkedWorkItemProvider === 'linear') &&
+ current.linkedWorkItemType === 'issue'
+ ? { linkedWorkItem: null, linkedTaskSourceContext: null }
+ : {}
+
+ if (trimmed === '') {
+ return {
+ linkedIssue: null,
+ ...LINEAR_ISSUE_LINK_CLEARED,
+ ...displacedWorkItem
+ }
+ }
+
+ const parsed = parseIssueLinkInput(trimmed, draft.issueProvider)
+ if (!parsed) {
+ // Why: unparseable input leaves every link untouched. `canSave` already
+ // blocks this path, but the builder stays pure rather than relying on it.
+ return {}
}
- if (finalLinkedIssue !== undefined) {
- updates.linkedIssue = finalLinkedIssue
+
+ if (parsed.provider === 'github') {
+ return {
+ linkedIssue: parsed.number,
+ ...LINEAR_ISSUE_LINK_CLEARED,
+ ...displacedWorkItem
+ }
}
- if (finalLinkedPR !== undefined) {
- updates.linkedPR = finalLinkedPR
+
+ const linearUpdates = buildLinearIssueLinkUpdates(trimmed)
+ return linearUpdates ? { linkedIssue: null, ...linearUpdates, ...displacedWorkItem } : {}
+}
+
+function buildPrLinkUpdate(draft: WorktreeMetaDraft): Partial {
+ const trimmed = draft.prInput.trim()
+ if (trimmed === '') {
+ return { linkedPR: null }
+ }
+ const number = parseGitHubWorkItemNumberForMetaField(trimmed, 'pr')
+ return number === null ? {} : { linkedPR: number }
+}
+
+/** Pure save-payload builder for the worktree meta dialog: empty inputs clear
+ * the link (null), unparseable inputs leave it untouched (omitted). No key is
+ * ever emitted holding `undefined` — persistence raw-spreads updates, so a
+ * present-but-undefined key erases the stored value. */
+export function buildWorktreeMetaUpdates(
+ draft: WorktreeMetaDraft,
+ current: WorktreeMetaSnapshot
+): Partial {
+ return {
+ comment: draft.commentInput.trim(),
+ ...buildDisplayNameUpdate(draft, current),
+ ...buildIssueLinkUpdates(draft, current),
+ ...buildPrLinkUpdate(draft)
}
- return updates
}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index db95d954edd..9bff9276748 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -270,6 +270,7 @@
"4a18052018": "Local {{value0}} is behind {{value1}}",
"903b51c2ed": "Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}} because {{value2}}",
"0216895fb5": "Failed to delete branch",
+ "c6cf133786": "This workspace is no longer available.",
"19db0085fb": "Local branch deleted",
"2b0afc7f14": "Could not keep local {{value0}} up to date",
"670864ab52": "Keeping local {{value0}} up to date",
@@ -4688,7 +4689,8 @@
"65770ad0f0": "Edit GitHub links and notes for this workspace.",
"382fd11a3e": "Edit Worktree Details",
"2174f17011": "Save",
- "61d6f612cf": "Saving..."
+ "61d6f612cf": "Saving...",
+ "a0d191b7a7": "Edit issue links, pull request links, and notes for this workspace."
},
"WorktreeOpenInMenu": {
"localOnly": "Local only",
@@ -5124,6 +5126,22 @@
"3b7ea51793": "Clear search",
"7f1c2e94a5": "Search text is too long — the board is unfiltered",
"9a4d0f6b21": "Too long"
+ },
+ "WorktreeIssueLinkField": {
+ "25852bfc59": "Linear",
+ "5b440069e6": "GitHub",
+ "161b2d053a": "Open linked issue",
+ "d4785f9954": "Issue links are set when a folder workspace is created and can't be changed here yet.",
+ "964d9bc00a": "Not a Linear issue key or linear.app issue URL.",
+ "0a7a2c6efd": "Not a GitHub issue number or issue URL.",
+ "72486800ff": "Saving unlinks {{first}} and {{second}} — a workspace tracks one issue.",
+ "2c245ac134": "Saving unlinks {{link}} — a workspace tracks one issue.",
+ "d8c8a30d1f": "Couldn't open that issue. Check the identifier and your Linear connection.",
+ "269198eeda": "Couldn't open that issue. Check the number and your GitHub connection.",
+ "f047887705": "Paste a GitHub or Linear URL, or enter a number. Leave blank to remove the link.",
+ "ad78f9bee2": "Issue",
+ "662ae142f8": "Issue #, or a GitHub or Linear URL",
+ "929c98d05a": "Issue provider"
}
},
"shared": {
diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts
index fd548c91e04..948a6f945d8 100644
--- a/src/renderer/src/store/slices/worktree-helpers.ts
+++ b/src/renderer/src/store/slices/worktree-helpers.ts
@@ -236,11 +236,13 @@ export type WorktreeSlice = {
expectedHead: string
) => Promise<({ ok: true } & ForceDeleteWorktreeBranchResult) | { ok: false; error: string }>
clearWorktreeDeleteState: (worktreeId: string) => void
+ /** Never rejects — most callers fire-and-forget. Callers that own a surface
+ * the user is waiting on should read the result and say what went wrong. */
updateWorktreeMeta: (
worktreeId: string,
updates: Partial,
options?: WorktreeMetaUpdateOptions
- ) => Promise
+ ) => Promise<{ ok: true } | { ok: false; error: string }>
ensureHostedReviewPushTarget: (worktreeId: string) => Promise
updateWorktreesMeta: (
updatesByWorktreeId: ReadonlyMap>
diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts
index 66bf295a715..09248d3b3bd 100644
--- a/src/renderer/src/store/slices/worktrees.ts
+++ b/src/renderer/src/store/slices/worktrees.ts
@@ -50,7 +50,10 @@ import {
isRuntimeScopeForbiddenError,
RuntimeRpcCallError
} from '../../runtime/runtime-rpc-client'
-import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
+import {
+ TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY,
+ WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY
+} from '../../../../shared/protocol-version'
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
import { getHostedReviewCacheKey, refreshHostedReviewCard } from './hosted-review'
import { routeListingBranchSwitchesThroughGitIdentity } from './worktree-listing-branch-switch'
@@ -1710,16 +1713,26 @@ async function persistWorktreeMeta(
await window.api.worktrees.updateMeta({ worktreeId, updates })
return
}
- // Why: same gate as worktree.create — an older paired runtime would drop the Jira link silently.
+ // Why: `worktree.set` parses in strip mode, so an older runtime drops the key
+ // and applies the rest. Both gates key off presence, not value — a dropped
+ // *clear* strands a stale link that the Issue row then hides.
if (
target.kind === 'environment' &&
- (updates.linkedWorkItem?.provider === 'jira' ||
- updates.linkedTaskSourceContext?.provider === 'jira')
+ ('linkedWorkItem' in updates || 'linkedTaskSourceContext' in updates)
) {
await assertRuntimeEnvironmentCapability(
target.environmentId,
WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY,
- 'Update the remote runtime to link Jira'
+ 'Update the remote runtime to change this workspace’s linked issue'
+ )
+ }
+ // task-source-context.v1 is a sound proxy for the Linear keys: #5322 added them
+ // to the schema and is an ancestor of the commit introducing that capability.
+ if (target.kind === 'environment' && 'linkedLinearIssue' in updates) {
+ await assertRuntimeEnvironmentCapability(
+ target.environmentId,
+ TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY,
+ 'Update the remote runtime to link Linear issues'
)
}
await callRuntimeRpc(
@@ -4497,7 +4510,7 @@ export const createWorktreeSlice: StateCreator
const shouldApplyUpdate = options?.shouldApply
const existingWorktree = get().getKnownWorktreeById(worktreeId)
if (shouldApplyUpdate && !shouldApplyUpdate(existingWorktree)) {
- return
+ return { ok: true }
}
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
@@ -4505,7 +4518,7 @@ export const createWorktreeSlice: StateCreator
if (Object.keys(folderUpdates).length > 0) {
await get().updateFolderWorkspace(workspaceScope.folderWorkspaceId, folderUpdates)
}
- return
+ return { ok: true }
}
const normalizedUpdates = existingWorktree
? clearOlderHostedReviewLinksForReplacement(updates, existingWorktree)
@@ -4540,7 +4553,7 @@ export const createWorktreeSlice: StateCreator
existingHostedReviewPushTargetLookup.key !== nextHostedReviewPushTargetLookup?.key
const worktreeForUpdate = get().getKnownWorktreeById(worktreeId)
if (shouldApplyUpdate && !shouldApplyUpdate(worktreeForUpdate)) {
- return
+ return { ok: true }
}
const shouldRefreshHostedReview =
(normalizedUpdates.linkedPR === null && worktreeForUpdate?.linkedPR !== null) ||
@@ -4661,7 +4674,7 @@ export const createWorktreeSlice: StateCreator
}
})
if (shouldApplyUpdate && !didApply) {
- return
+ return { ok: true }
}
if (hasHostedReviewLinkUpdates(enriched)) {
bumpHostedReviewLinkMutationGeneration(worktreeId)
@@ -4709,11 +4722,21 @@ export const createWorktreeSlice: StateCreator
} catch (err) {
if (isRuntimeSelectorNotFoundError(err)) {
void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId))
- return
+ return {
+ ok: false,
+ error: translate(
+ 'auto.store.slices.worktrees.c6cf133786',
+ 'This workspace is no longer available.'
+ )
+ }
}
console.error('Failed to update worktree meta:', err)
void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId))
+ // Why: the refetch above reverts the optimistic write, so a caller that
+ // closes its surface on this path shows the user a save that undid itself.
+ return { ok: false, error: err instanceof Error ? err.message : String(err) }
}
+ return { ok: true }
},
ensureHostedReviewPushTarget: async (worktreeId) => {
diff --git a/src/shared/issue-link-input.test.ts b/src/shared/issue-link-input.test.ts
new file mode 100644
index 00000000000..4e51ef0efeb
--- /dev/null
+++ b/src/shared/issue-link-input.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from 'vitest'
+
+import { getIssueLinkProviderFromUrl, parseIssueLinkInput } from './issue-link-input'
+
+describe('getIssueLinkProviderFromUrl', () => {
+ it('detects GitHub issue URLs', () => {
+ expect(getIssueLinkProviderFromUrl('https://github.com/o/r/issues/12')).toBe('github')
+ })
+
+ it('does not flip the provider for a GitHub pull URL', () => {
+ expect(getIssueLinkProviderFromUrl('https://github.com/o/r/pull/12')).toBeNull()
+ })
+
+ it('detects Linear issue URLs with and without a slug', () => {
+ expect(getIssueLinkProviderFromUrl('https://linear.app/acme/issue/STA-335')).toBe('linear')
+ expect(getIssueLinkProviderFromUrl('https://linear.app/acme/issue/STA-335/some-slug')).toBe(
+ 'linear'
+ )
+ })
+
+ it('ignores non-issue paths on the Linear host', () => {
+ expect(getIssueLinkProviderFromUrl('https://linear.app/acme/team/ENG/all')).toBeNull()
+ })
+
+ it('rejects hosts that merely contain linear.app', () => {
+ expect(getIssueLinkProviderFromUrl('https://linear.app.evil.com/acme/issue/STA-335')).toBeNull()
+ })
+
+ // Linear and Jira issue-key shapes are byte-identical, so a bare key must
+ // never override the user's explicit provider choice.
+ it('is not decisive for bare issue keys', () => {
+ expect(getIssueLinkProviderFromUrl('STA-335')).toBeNull()
+ expect(getIssueLinkProviderFromUrl(' STA-335 ')).toBeNull()
+ })
+
+ it('is not decisive for bare numbers', () => {
+ expect(getIssueLinkProviderFromUrl('1234')).toBeNull()
+ expect(getIssueLinkProviderFromUrl('#1234')).toBeNull()
+ })
+
+ it('returns null for empty input', () => {
+ expect(getIssueLinkProviderFromUrl('')).toBeNull()
+ expect(getIssueLinkProviderFromUrl(' ')).toBeNull()
+ })
+})
+
+describe('parseIssueLinkInput', () => {
+ describe('github provider', () => {
+ it('accepts bare and hash-prefixed numbers', () => {
+ expect(parseIssueLinkInput('12', 'github')).toEqual({ provider: 'github', number: 12 })
+ expect(parseIssueLinkInput('#12', 'github')).toEqual({ provider: 'github', number: 12 })
+ })
+
+ it('accepts issue URLs', () => {
+ expect(parseIssueLinkInput('https://github.com/o/r/issues/12', 'github')).toEqual({
+ provider: 'github',
+ number: 12
+ })
+ })
+
+ it('rejects pull URLs', () => {
+ expect(parseIssueLinkInput('https://github.com/o/r/pull/12', 'github')).toBeNull()
+ })
+
+ it('rejects non-positive numbers and junk', () => {
+ expect(parseIssueLinkInput('0', 'github')).toBeNull()
+ expect(parseIssueLinkInput('-1', 'github')).toBeNull()
+ expect(parseIssueLinkInput('not an issue', 'github')).toBeNull()
+ expect(parseIssueLinkInput(' ', 'github')).toBeNull()
+ })
+
+ it('rejects Linear identifiers', () => {
+ expect(parseIssueLinkInput('STA-335', 'github')).toBeNull()
+ })
+ })
+
+ describe('linear provider', () => {
+ it('accepts bare identifiers and normalizes case', () => {
+ expect(parseIssueLinkInput('STA-335', 'linear')).toEqual({
+ provider: 'linear',
+ identifier: 'STA-335'
+ })
+ expect(parseIssueLinkInput('sta-335', 'linear')).toEqual({
+ provider: 'linear',
+ identifier: 'STA-335'
+ })
+ })
+
+ it('omits the organization key for bare identifiers', () => {
+ expect(parseIssueLinkInput('STA-335', 'linear')).not.toHaveProperty('organizationUrlKey')
+ })
+
+ it('accepts issue URLs and returns the organization key', () => {
+ expect(
+ parseIssueLinkInput('https://linear.app/acme/issue/STA-335/some-slug', 'linear')
+ ).toEqual({
+ provider: 'linear',
+ identifier: 'STA-335',
+ organizationUrlKey: 'acme'
+ })
+ })
+
+ it('rejects GitHub URLs', () => {
+ expect(parseIssueLinkInput('https://github.com/o/r/issues/12', 'linear')).toBeNull()
+ })
+
+ it('rejects junk and empty input', () => {
+ expect(parseIssueLinkInput('not an issue', 'linear')).toBeNull()
+ expect(parseIssueLinkInput(' ', 'linear')).toBeNull()
+ })
+ })
+})
diff --git a/src/shared/issue-link-input.ts b/src/shared/issue-link-input.ts
new file mode 100644
index 00000000000..37d98f55072
--- /dev/null
+++ b/src/shared/issue-link-input.ts
@@ -0,0 +1,75 @@
+import { parseGitHubIssueOrPRLink } from './github-links'
+import { parseLinearIssueInput } from './linear-links'
+import type { WorkspaceSourceProvider } from './new-workspace/workspace-source'
+
+// Why: narrows the canonical provider union instead of minting a parallel one,
+// so adding Jira here is a one-entry change rather than a new axis.
+export const ISSUE_LINK_PROVIDERS = [
+ 'github',
+ 'linear'
+] as const satisfies readonly WorkspaceSourceProvider[]
+
+export type IssueLinkProvider = (typeof ISSUE_LINK_PROVIDERS)[number]
+
+export function isIssueLinkProvider(value: unknown): value is IssueLinkProvider {
+ return ISSUE_LINK_PROVIDERS.includes(value as IssueLinkProvider)
+}
+
+/** URL input only. Linear and Jira issue keys are byte-identical in shape, so a
+ * bare `STA-335` can never decide a provider — it would override the chip. */
+export function getIssueLinkProviderFromUrl(input: string): IssueLinkProvider | null {
+ const trimmed = input.trim()
+ if (!/^https?:\/\//i.test(trimmed)) {
+ return null
+ }
+
+ // Why: a URL is decisive only if it names that provider's *issue*. A GitHub
+ // pull URL is a valid GitHub link but not an issue, and must not flip the
+ // provider into a state where the field then refuses to save it.
+ if (parseGitHubIssueOrPRLink(trimmed)?.type === 'issue') {
+ return 'github'
+ }
+ if (parseLinearIssueInput(trimmed)) {
+ return 'linear'
+ }
+ return null
+}
+
+export type ParsedIssueLinkInput =
+ | { provider: 'github'; number: number }
+ | { provider: 'linear'; identifier: string; organizationUrlKey?: string }
+
+/**
+ * Single parse shared by the dialog's save gate and its payload builder, so a
+ * value that enables Save is always the value that gets written.
+ */
+export function parseIssueLinkInput(
+ input: string,
+ provider: IssueLinkProvider
+): ParsedIssueLinkInput | null {
+ const trimmed = input.trim()
+ if (!trimmed) {
+ return null
+ }
+
+ if (provider === 'linear') {
+ const parsed = parseLinearIssueInput(trimmed)
+ return parsed ? { provider: 'linear', ...parsed } : null
+ }
+
+ const link = parseGitHubIssueOrPRLink(trimmed)
+ if (link) {
+ // Why: issue and PR numbers live in separate GitHub namespaces for refs; a
+ // pull URL pasted here must not silently become an issue link.
+ return link.type === 'issue' ? { provider: 'github', number: link.number } : null
+ }
+
+ const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
+ if (!/^\d+$/.test(numeric)) {
+ return null
+ }
+ const parsed = Number.parseInt(numeric, 10)
+ // Why: `/^\d+$/` happily accepts 400 digits, which parseInt turns into
+ // Infinity — Save would enable and JSON.stringify would persist `null`.
+ return Number.isSafeInteger(parsed) && parsed > 0 ? { provider: 'github', number: parsed } : null
+}
diff --git a/src/shared/linear-links.test.ts b/src/shared/linear-links.test.ts
index de7993714e0..24ac962df6e 100644
--- a/src/shared/linear-links.test.ts
+++ b/src/shared/linear-links.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
+ buildLinearIssueLinkUpdates,
+ buildLinearIssueUrl,
buildLinearPersonalApiKeySettingsUrl,
buildLinearTeamUrl,
buildLinearWorkspaceApiSettingsUrl,
@@ -62,4 +64,83 @@ describe('linear links', () => {
expect(parseLinearIssueInput('https://example.com/acme/issue/ENG-123')).toBeNull()
expect(parseLinearIssueInput('not an issue')).toBeNull()
})
+
+ // A hostname-only check accepts these, and the parsed link is later handed to
+ // shell.openUrl — so the protocol gate is load-bearing, not cosmetic.
+ it('rejects linear.app URLs on non-web protocols', () => {
+ for (const input of [
+ 'file://linear.app/acme/issue/ENG-123',
+ 'javascript://linear.app/acme/issue/ENG-123',
+ 'ftp://linear.app/acme/issue/ENG-123'
+ ]) {
+ expect(parseLinearIssueInput(input)).toBeNull()
+ }
+ })
+})
+
+describe('buildLinearIssueUrl', () => {
+ it('builds issue URLs from an identifier and organization key', () => {
+ expect(buildLinearIssueUrl({ identifier: 'STA-335', organizationUrlKey: 'acme' })).toBe(
+ 'https://linear.app/acme/issue/STA-335'
+ )
+ })
+
+ it('returns null when either part is missing or blank', () => {
+ expect(buildLinearIssueUrl({ identifier: 'STA-335' })).toBeNull()
+ expect(buildLinearIssueUrl({ organizationUrlKey: 'acme' })).toBeNull()
+ expect(buildLinearIssueUrl({ identifier: ' ', organizationUrlKey: 'acme' })).toBeNull()
+ expect(buildLinearIssueUrl({ identifier: 'STA-335', organizationUrlKey: null })).toBeNull()
+ })
+
+ it('encodes URL-unsafe path segments', () => {
+ expect(buildLinearIssueUrl({ identifier: 'A/B-1', organizationUrlKey: 'acme inc' })).toBe(
+ 'https://linear.app/acme%20inc/issue/A%2FB-1'
+ )
+ })
+})
+
+describe('buildLinearIssueLinkUpdates', () => {
+ function expectNoUndefinedValues(updates: Record): void {
+ expect(Object.keys(updates).filter((key) => updates[key] === undefined)).toEqual([])
+ }
+
+ it('clears every link field for empty input', () => {
+ for (const input of ['', ' ']) {
+ const result = buildLinearIssueLinkUpdates(input)
+ expect(result).toEqual({
+ linkedLinearIssue: null,
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: null
+ })
+ }
+ })
+
+ // Inheriting the previous issue's org key would let resolveLegacyLinearLinkWorkspace
+ // match it and persist a workspace id the new issue does not belong to.
+ it('clears the organization key for bare identifiers', () => {
+ const result = buildLinearIssueLinkUpdates('STA-335')
+
+ expect(result).toEqual({
+ linkedLinearIssue: 'STA-335',
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: null
+ })
+ expectNoUndefinedValues(result as Record)
+ })
+
+ it('takes the organization key from a Linear issue URL', () => {
+ const result = buildLinearIssueLinkUpdates('https://linear.app/acme/issue/sta-335/fix-auth')
+
+ expect(result).toEqual({
+ linkedLinearIssue: 'STA-335',
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: 'acme'
+ })
+ expectNoUndefinedValues(result as Record)
+ })
+
+ it('returns null for unparseable input', () => {
+ expect(buildLinearIssueLinkUpdates('not an issue')).toBeNull()
+ expect(buildLinearIssueLinkUpdates('https://github.com/o/r/issues/12')).toBeNull()
+ })
})
diff --git a/src/shared/linear-links.ts b/src/shared/linear-links.ts
index 462ac853061..0f1db592736 100644
--- a/src/shared/linear-links.ts
+++ b/src/shared/linear-links.ts
@@ -24,6 +24,18 @@ export function buildLinearWorkspaceApiSettingsUrl(organizationUrlKey?: string |
: 'https://linear.app/settings/api'
}
+export function buildLinearIssueUrl(args: {
+ identifier?: string | null
+ organizationUrlKey?: string | null
+}): string | null {
+ const identifier = args.identifier?.trim()
+ const organizationUrlKey = args.organizationUrlKey?.trim()
+ if (!identifier || !organizationUrlKey) {
+ return null
+ }
+ return `https://linear.app/${encodeURIComponent(organizationUrlKey)}/issue/${encodeURIComponent(identifier)}`
+}
+
export function getLinearOrganizationUrlKeyFromIssueUrl(issueUrl?: string | null): string | null {
if (!issueUrl) {
return null
@@ -44,6 +56,12 @@ export type ParsedLinearIssueInput = {
organizationUrlKey?: string
}
+export type LinearIssueLinkUpdates = {
+ linkedLinearIssue: string | null
+ linkedLinearIssueWorkspaceId: string | null
+ linkedLinearIssueOrganizationUrlKey: string | null
+}
+
const LINEAR_IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]*-\d+$/
export function parseLinearIssueInput(input: string): ParsedLinearIssueInput | null {
@@ -58,6 +76,12 @@ export function parseLinearIssueInput(input: string): ParsedLinearIssueInput | n
try {
const parsed = new URL(trimmed)
+ // Why: hostname alone accepts file://linear.app/..., which then passes
+ // validation and persists as a link. The GitHub parser already gates on
+ // protocol; this keeps the two in step.
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
+ return null
+ }
if (parsed.hostname !== 'linear.app') {
return null
}
@@ -80,3 +104,31 @@ export function parseLinearIssueInput(input: string): ParsedLinearIssueInput | n
return null
}
}
+
+export const LINEAR_ISSUE_LINK_CLEARED: LinearIssueLinkUpdates = {
+ linkedLinearIssue: null,
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: null
+}
+
+/** Shared by the CLI and the meta dialog so both persist identical state for
+ * identical input. Returns null when the input is neither empty nor parseable. */
+export function buildLinearIssueLinkUpdates(input: string): LinearIssueLinkUpdates | null {
+ if (input.trim() === '') {
+ return { ...LINEAR_ISSUE_LINK_CLEARED }
+ }
+
+ const parsed = parseLinearIssueInput(input)
+ if (!parsed) {
+ return null
+ }
+
+ return {
+ linkedLinearIssue: parsed.identifier,
+ // Both scoping fields describe the issue being REPLACED. A stale org key
+ // pins resolveLegacyLinearLinkWorkspace to the wrong org — not-found, or a
+ // silently wrong issue on a prefix collision. Nulling only costs a re-read.
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: parsed.organizationUrlKey ?? null
+ }
+}