Keep your New Linear and Jira creation drafts after an accidental dismissal (#12167)

* Keep your New Linear and Jira creation drafts after an accidental dismissal

* Avoid draft store writes while typing

* Update draft retention refs after commit
This commit is contained in:
Brennan Benson 2026-08-02 19:57:23 -07:00 committed by GitHub
parent 6f28c118bb
commit 4481b9b241
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 380 additions and 10 deletions

View File

@ -239,6 +239,13 @@ import {
resolveUserRepoSwitchReset,
resolveVanishedNewIssueRepoReset
} from '@/components/task-page-new-issue-draft'
import {
isTaskCreationDraftContentful,
type NewJiraIssueDraft,
type NewLinearIssueDraft,
type NewLinearProjectDraft
} from '@/store/slices/task-creation-drafts'
import { useTaskCreationDraftRetention } from '@/components/use-task-creation-draft-retention'
import { findTaskPageJiraIssue } from '@/components/task-page-jira-cache-selectors'
import { getRepoBackedTaskEmptyState } from '@/components/task-page-empty-state'
import {
@ -3045,6 +3052,33 @@ const hasUpstreamCandidateDivergence = (
!!s.sources.upstreamCandidate &&
!sameGitHubOwnerRepo(s.sources.originCandidate, s.sources.upstreamCandidate)
function writeNewLinearProjectDraft(draft: NewLinearProjectDraft | null): void {
const state = useAppStore.getState()
if (draft && isTaskCreationDraftContentful(draft)) {
state.setNewLinearProjectDraft(draft)
} else {
state.clearNewLinearProjectDraft()
}
}
function writeNewLinearIssueDraft(draft: NewLinearIssueDraft | null): void {
const state = useAppStore.getState()
if (draft && isTaskCreationDraftContentful(draft)) {
state.setNewLinearIssueDraft(draft)
} else {
state.clearNewLinearIssueDraft()
}
}
function writeNewJiraIssueDraft(draft: NewJiraIssueDraft | null): void {
const state = useAppStore.getState()
if (draft && isTaskCreationDraftContentful(draft)) {
state.setNewJiraIssueDraft(draft)
} else {
state.clearNewJiraIssueDraft()
}
}
export default function TaskPage(): React.JSX.Element {
useTranslation()
const settings = useAppStore((s) => s.settings)
@ -5638,6 +5672,16 @@ export default function TaskPage(): React.JSX.Element {
setNewLinearProjectLabelIds([])
}, [newLinearProjectTargetTeam?.id, newLinearProjectTargetTeam?.workspaceId])
const discardNewLinearProjectDraft = useTaskCreationDraftRetention({
open: newLinearProjectOpen,
draft: {
name: newLinearProjectName,
description: newLinearProjectDescription,
content: newLinearProjectContent
},
writeDraft: writeNewLinearProjectDraft
})
// New Linear issue dialog state
const [newLinearIssueOpen, setNewLinearIssueOpen] = useState(false)
const [newLinearIssueTitle, setNewLinearIssueTitle] = useState('')
@ -5651,6 +5695,12 @@ export default function TaskPage(): React.JSX.Element {
const [newLinearIssueProjectId, setNewLinearIssueProjectId] = useState<string | null>(null)
const [newLinearIssueLabelIds, setNewLinearIssueLabelIds] = useState<string[]>([])
const discardNewLinearIssueDraft = useTaskCreationDraftRetention({
open: newLinearIssueOpen,
draft: { title: newLinearIssueTitle, body: newLinearIssueBody },
writeDraft: writeNewLinearIssueDraft
})
const newLinearIssueTargetTeam = useMemo(
() => availableTeams.find((t) => t.id === newLinearIssueTeamId) ?? availableTeams[0] ?? null,
[availableTeams, newLinearIssueTeamId]
@ -5788,6 +5838,12 @@ export default function TaskPage(): React.JSX.Element {
const [newJiraIssueCustomFieldValues, setNewJiraIssueCustomFieldValues] = useState<
Record<string, string>
>({})
const discardNewJiraIssueDraft = useTaskCreationDraftRetention({
open: newJiraIssueOpen,
draft: { title: newJiraIssueTitle, body: newJiraIssueBody },
writeDraft: writeNewJiraIssueDraft
})
const includeJiraSiteNameInProjectLabel = selectedJiraSiteId === 'all'
const previousProviderRuntimeContextKeyRef = useRef(providerRuntimeContextKey)
@ -6929,6 +6985,7 @@ export default function TaskPage(): React.JSX.Element {
: undefined
}
)
discardNewLinearProjectDraft()
setNewLinearProjectOpen(false)
setNewLinearProjectName('')
setNewLinearProjectDescription('')
@ -6971,7 +7028,8 @@ export default function TaskPage(): React.JSX.Element {
newLinearProjectTargetTeam,
openLinearProjectContext,
linearTaskSourceContext,
settings
settings,
discardNewLinearProjectDraft
])
const handleCreateNewLinearIssue = useCallback(async (): Promise<void> => {
@ -7032,6 +7090,7 @@ export default function TaskPage(): React.JSX.Element {
: undefined
}
)
discardNewLinearIssueDraft()
setNewLinearIssueOpen(false)
setNewLinearIssueTitle('')
setNewLinearIssueBody('')
@ -7077,7 +7136,8 @@ export default function TaskPage(): React.JSX.Element {
selectedLinearProject,
setSelectedLinearIssue,
linearTaskSourceContext,
settings
settings,
discardNewLinearIssueDraft
])
const handleCreateNewJiraIssue = useCallback(async (): Promise<void> => {
@ -7126,6 +7186,7 @@ export default function TaskPage(): React.JSX.Element {
: undefined
}
)
discardNewJiraIssueDraft()
setNewJiraIssueOpen(false)
setNewJiraIssueTitle('')
setNewJiraIssueBody('')
@ -7166,7 +7227,8 @@ export default function TaskPage(): React.JSX.Element {
jiraTaskSourceContext,
settings,
setSelectedJiraIssue,
visibleJiraCreateFields
visibleJiraCreateFields,
discardNewJiraIssueDraft
])
const githubTasksBusy = tasksLoading || tasksRefreshing || tasksFiltering
@ -8595,9 +8657,11 @@ export default function TaskPage(): React.JSX.Element {
size="icon"
onClick={() => {
if (linearMode === 'projects' && !selectedLinearProject) {
setNewLinearProjectName('')
setNewLinearProjectDescription('')
setNewLinearProjectContent('')
// 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([])
@ -8608,8 +8672,10 @@ export default function TaskPage(): React.JSX.Element {
setNewLinearProjectOpen(true)
return
}
setNewLinearIssueTitle('')
setNewLinearIssueBody('')
// 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(
@ -8826,8 +8892,10 @@ export default function TaskPage(): React.JSX.Element {
variant="outline"
size="icon"
onClick={() => {
setNewJiraIssueTitle('')
setNewJiraIssueBody('')
// 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])

View File

@ -0,0 +1,55 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const taskPageSource = readFileSync(new URL('./TaskPage.tsx', import.meta.url), 'utf8')
function sectionBetween(startAnchor: string, endAnchor: string): string {
const start = taskPageSource.indexOf(startAnchor)
expect(start, `missing anchor: ${startAnchor}`).toBeGreaterThanOrEqual(0)
const end = taskPageSource.indexOf(endAnchor, start)
expect(end, `missing anchor: ${endAnchor}`).toBeGreaterThan(start)
return taskPageSource.slice(start, end)
}
describe('TaskPage Linear/Jira creation drafts', () => {
it('uses the contentful gate for each session draft writer', () => {
expect(taskPageSource.split('isTaskCreationDraftContentful(draft)')).toHaveLength(4)
})
it('retains all three drafts on dismissal without subscribing TaskPage to draft actions', () => {
expect(taskPageSource.split('useTaskCreationDraftRetention({')).toHaveLength(4)
expect(taskPageSource).not.toMatch(
/useAppStore\(\(s\) => s\.(?:set|clear)New(?:LinearProject|LinearIssue|JiraIssue)Draft\)/
)
})
it('restores dismissed typed text when each dialog reopens', () => {
expect(taskPageSource).toContain("setNewLinearProjectName(draft?.name ?? '')")
expect(taskPageSource).toContain("setNewLinearProjectDescription(draft?.description ?? '')")
expect(taskPageSource).toContain("setNewLinearProjectContent(draft?.content ?? '')")
expect(taskPageSource).toContain("setNewLinearIssueTitle(issueDraft?.title ?? '')")
expect(taskPageSource).toContain("setNewLinearIssueBody(issueDraft?.body ?? '')")
expect(taskPageSource).toContain("setNewJiraIssueTitle(draft?.title ?? '')")
expect(taskPageSource).toContain("setNewJiraIssueBody(draft?.body ?? '')")
})
it('discards each recovery draft only on a successful create', () => {
const linearProjectSection = sectionBetween(
'const handleCreateNewLinearProject',
'const handleCreateNewLinearIssue'
)
expect(linearProjectSection).toContain('discardNewLinearProjectDraft()')
const linearIssueSection = sectionBetween(
'const handleCreateNewLinearIssue',
'const handleCreateNewJiraIssue'
)
expect(linearIssueSection).toContain('discardNewLinearIssueDraft()')
const jiraIssueSection = sectionBetween(
'const handleCreateNewJiraIssue',
'const githubTasksBusy'
)
expect(jiraIssueSection).toContain('discardNewJiraIssueDraft()')
})
})

View File

@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useTaskCreationDraftRetention } from './use-task-creation-draft-retention'
type Draft = { title: string; body: string }
const emptyDraft = (): Draft => ({ title: '', body: '' })
describe('useTaskCreationDraftRetention', () => {
it('does not fan out writes while typing and saves the latest text on dismissal', () => {
const writeDraft = vi.fn()
const view = renderHook(
({ open, draft }) => useTaskCreationDraftRetention({ open, draft, writeDraft }),
{ initialProps: { open: true, draft: emptyDraft() } }
)
view.rerender({ open: true, draft: { title: 'Bug', body: '' } })
view.rerender({ open: true, draft: { title: 'Bug', body: 'Latest details' } })
expect(writeDraft).not.toHaveBeenCalled()
view.rerender({ open: false, draft: emptyDraft() })
expect(writeDraft).toHaveBeenCalledOnce()
expect(writeDraft).toHaveBeenCalledWith({ title: 'Bug', body: 'Latest details' })
})
it('saves the latest text when an open composer unmounts', () => {
const writeDraft = vi.fn()
const view = renderHook(
({ draft }) => useTaskCreationDraftRetention({ open: true, draft, writeDraft }),
{ initialProps: { draft: { title: 'First', body: '' } } }
)
view.rerender({ draft: { title: 'Latest', body: 'Details' } })
view.unmount()
expect(writeDraft).toHaveBeenCalledOnce()
expect(writeDraft).toHaveBeenCalledWith({ title: 'Latest', body: 'Details' })
})
it('clears a successful draft without resurrecting it during close cleanup', () => {
const writeDraft = vi.fn()
const view = renderHook(
({ open }) =>
useTaskCreationDraftRetention({
open,
draft: { title: 'Created', body: 'Details' },
writeDraft
}),
{ initialProps: { open: true } }
)
act(() => view.result.current())
view.rerender({ open: false })
view.unmount()
expect(writeDraft).toHaveBeenCalledOnce()
expect(writeDraft).toHaveBeenCalledWith(null)
})
it('does not create a draft when an unopened composer unmounts', () => {
const writeDraft = vi.fn()
const view = renderHook(() =>
useTaskCreationDraftRetention({ open: false, draft: emptyDraft(), writeDraft })
)
view.unmount()
expect(writeDraft).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,44 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'
type TaskCreationDraftRetentionOptions<Draft> = {
open: boolean
draft: Draft
writeDraft: (draft: Draft | null) => void
}
export function useTaskCreationDraftRetention<Draft>({
open,
draft,
writeDraft
}: TaskCreationDraftRetentionOptions<Draft>): () => void {
const draftRef = useRef(draft)
const writeDraftRef = useRef(writeDraft)
const discardRef = useRef(false)
// Why: every committed open render refreshes refs without letting discarded renders leak into cleanup.
useLayoutEffect(() => {
if (!open) {
return
}
draftRef.current = draft
writeDraftRef.current = writeDraft
})
// Why: closing cleanup captures the latest render once without a global store write per keystroke.
useEffect(() => {
if (!open) {
return
}
discardRef.current = false
return () => {
if (!discardRef.current) {
writeDraftRef.current(draftRef.current)
}
}
}, [open])
return useCallback(() => {
discardRef.current = true
writeDraftRef.current(null)
}, [])
}

View File

@ -39,6 +39,7 @@ import { createPinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-conf
import { createRecentlyClosedTabsSlice } from './slices/recently-closed-tabs'
import { createOrcaProfilesSlice } from './slices/orca-profiles'
import { createNewIssueDraftSlice } from './slices/new-issue-draft'
import { createTaskCreationDraftsSlice } from './slices/task-creation-drafts'
import { createRemoteServerUpdatesSlice } from './slices/remote-server-updates'
import { e2eConfig } from '@/lib/e2e-config'
import type { createWebRuntimeSessionTerminal } from '@/runtime/web-runtime-session'
@ -92,6 +93,7 @@ export const useAppStore = create<AppState>()((...a) => {
...createRecentlyClosedTabsSlice(...a),
...createOrcaProfilesSlice(...a),
...createNewIssueDraftSlice(...a),
...createTaskCreationDraftsSlice(...a),
...createRemoteServerUpdatesSlice(...a)
}
})

View File

@ -144,6 +144,7 @@ import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm'
import { createRecentlyClosedTabsSlice } from './recently-closed-tabs'
import { createOrcaProfilesSlice } from './orca-profiles'
import { createNewIssueDraftSlice } from './new-issue-draft'
import { createTaskCreationDraftsSlice } from './task-creation-drafts'
import { createRemoteServerUpdatesSlice } from './remote-server-updates'
function createTestStore() {
@ -187,6 +188,7 @@ function createTestStore() {
...createRecentlyClosedTabsSlice(...a),
...createOrcaProfilesSlice(...a),
...createNewIssueDraftSlice(...a),
...createTaskCreationDraftsSlice(...a),
...createRemoteServerUpdatesSlice(...a)
}))
}

View File

@ -47,6 +47,7 @@ import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm'
import { createRecentlyClosedTabsSlice } from './recently-closed-tabs'
import { createOrcaProfilesSlice } from './orca-profiles'
import { createNewIssueDraftSlice } from './new-issue-draft'
import { createTaskCreationDraftsSlice } from './task-creation-drafts'
import { createRemoteServerUpdatesSlice } from './remote-server-updates'
import { translate } from '@/i18n/i18n'
@ -99,6 +100,7 @@ export function createTestStore() {
...createRecentlyClosedTabsSlice(...a),
...createOrcaProfilesSlice(...a),
...createNewIssueDraftSlice(...a),
...createTaskCreationDraftsSlice(...a),
...createRemoteServerUpdatesSlice(...a)
}))
}

View File

@ -0,0 +1,80 @@
import { create } from 'zustand'
import { describe, expect, it } from 'vitest'
import {
createTaskCreationDraftsSlice,
isTaskCreationDraftContentful
} from './task-creation-drafts'
import type { AppState } from '../types'
function makeStore() {
return create<
Pick<
AppState,
| 'newLinearIssueDraft'
| 'setNewLinearIssueDraft'
| 'clearNewLinearIssueDraft'
| 'newLinearProjectDraft'
| 'setNewLinearProjectDraft'
| 'clearNewLinearProjectDraft'
| 'newJiraIssueDraft'
| 'setNewJiraIssueDraft'
| 'clearNewJiraIssueDraft'
>
>()((...args) =>
createTaskCreationDraftsSlice(...(args as Parameters<typeof createTaskCreationDraftsSlice>))
)
}
describe('createTaskCreationDraftsSlice', () => {
it('starts with no drafts', () => {
const state = makeStore().getState()
expect(state.newLinearIssueDraft).toBeNull()
expect(state.newLinearProjectDraft).toBeNull()
expect(state.newJiraIssueDraft).toBeNull()
})
it('stores and clears each draft independently', () => {
const store = makeStore()
store.getState().setNewLinearIssueDraft({ title: 'Linear bug', body: 'details' })
store.getState().setNewLinearProjectDraft({
name: 'Roadmap',
description: 'summary',
content: 'brief'
})
store.getState().setNewJiraIssueDraft({ title: 'Jira bug', body: 'steps' })
store.getState().clearNewLinearIssueDraft()
expect(store.getState().newLinearIssueDraft).toBeNull()
expect(store.getState().newLinearProjectDraft).toEqual({
name: 'Roadmap',
description: 'summary',
content: 'brief'
})
expect(store.getState().newJiraIssueDraft).toEqual({ title: 'Jira bug', body: 'steps' })
})
it('replaces a draft wholesale on set', () => {
const store = makeStore()
store.getState().setNewJiraIssueDraft({ title: 'first', body: 'text' })
store.getState().setNewJiraIssueDraft({ title: 'second', body: '' })
expect(store.getState().newJiraIssueDraft).toEqual({ title: 'second', body: '' })
})
})
describe('isTaskCreationDraftContentful', () => {
it('rejects an all-empty or whitespace-only form', () => {
expect(isTaskCreationDraftContentful({ title: '', body: '' })).toBe(false)
expect(isTaskCreationDraftContentful({ title: ' ', body: '\n\t' })).toBe(false)
})
it('accepts any field with typed text', () => {
expect(isTaskCreationDraftContentful({ title: 'Bug', body: '' })).toBe(true)
expect(isTaskCreationDraftContentful({ name: '', description: '', content: 'brief' })).toBe(
true
)
})
})

View File

@ -0,0 +1,41 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
/** Session-only text drafts for Linear/Jira creation dialogs; picker selections stay fresh. */
export type NewLinearIssueDraft = { title: string; body: string }
export type NewLinearProjectDraft = { name: string; description: string; content: string }
export type NewJiraIssueDraft = { title: string; body: string }
/** Empty forms do not replace a later open with a meaningless draft. */
export function isTaskCreationDraftContentful(fields: Record<string, string>): boolean {
return Object.values(fields).some((value) => value.trim().length > 0)
}
export type TaskCreationDraftsSlice = {
newLinearIssueDraft: NewLinearIssueDraft | null
setNewLinearIssueDraft: (draft: NewLinearIssueDraft) => void
clearNewLinearIssueDraft: () => void
newLinearProjectDraft: NewLinearProjectDraft | null
setNewLinearProjectDraft: (draft: NewLinearProjectDraft) => void
clearNewLinearProjectDraft: () => void
newJiraIssueDraft: NewJiraIssueDraft | null
setNewJiraIssueDraft: (draft: NewJiraIssueDraft) => void
clearNewJiraIssueDraft: () => void
}
export const createTaskCreationDraftsSlice: StateCreator<
AppState,
[],
[],
TaskCreationDraftsSlice
> = (set) => ({
newLinearIssueDraft: null,
setNewLinearIssueDraft: (draft) => set({ newLinearIssueDraft: draft }),
clearNewLinearIssueDraft: () => set({ newLinearIssueDraft: null }),
newLinearProjectDraft: null,
setNewLinearProjectDraft: (draft) => set({ newLinearProjectDraft: draft }),
clearNewLinearProjectDraft: () => set({ newLinearProjectDraft: null }),
newJiraIssueDraft: null,
setNewJiraIssueDraft: (draft) => set({ newJiraIssueDraft: draft }),
clearNewJiraIssueDraft: () => set({ newJiraIssueDraft: null })
})

View File

@ -37,6 +37,7 @@ import type { PinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-confi
import type { RecentlyClosedTabsSlice } from './slices/recently-closed-tabs'
import type { OrcaProfilesSlice } from './slices/orca-profiles'
import type { NewIssueDraftSlice } from './slices/new-issue-draft'
import type { TaskCreationDraftsSlice } from './slices/task-creation-drafts'
import type { RemoteServerUpdatesSlice } from './slices/remote-server-updates'
export type AppState = RepoSlice &
@ -78,4 +79,5 @@ export type AppState = RepoSlice &
RecentlyClosedTabsSlice &
OrcaProfilesSlice &
NewIssueDraftSlice &
TaskCreationDraftsSlice &
RemoteServerUpdatesSlice