diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 99d0785c7..b430b0c33 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -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(null) const [newLinearIssueLabelIds, setNewLinearIssueLabelIds] = useState([]) + 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 >({}) + + 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 => { @@ -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 => { @@ -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]) diff --git a/src/renderer/src/components/task-page-task-creation-drafts.test.ts b/src/renderer/src/components/task-page-task-creation-drafts.test.ts new file mode 100644 index 000000000..c3491e423 --- /dev/null +++ b/src/renderer/src/components/task-page-task-creation-drafts.test.ts @@ -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()') + }) +}) diff --git a/src/renderer/src/components/use-task-creation-draft-retention.test.ts b/src/renderer/src/components/use-task-creation-draft-retention.test.ts new file mode 100644 index 000000000..fc95e7d33 --- /dev/null +++ b/src/renderer/src/components/use-task-creation-draft-retention.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/components/use-task-creation-draft-retention.ts b/src/renderer/src/components/use-task-creation-draft-retention.ts new file mode 100644 index 000000000..42ac8f4d8 --- /dev/null +++ b/src/renderer/src/components/use-task-creation-draft-retention.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react' + +type TaskCreationDraftRetentionOptions = { + open: boolean + draft: Draft + writeDraft: (draft: Draft | null) => void +} + +export function useTaskCreationDraftRetention({ + open, + draft, + writeDraft +}: TaskCreationDraftRetentionOptions): () => 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) + }, []) +} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 7565246db..97dc494da 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -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()((...a) => { ...createRecentlyClosedTabsSlice(...a), ...createOrcaProfilesSlice(...a), ...createNewIssueDraftSlice(...a), + ...createTaskCreationDraftsSlice(...a), ...createRemoteServerUpdatesSlice(...a) } }) diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index feb98289c..1718beca9 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -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) })) } diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index 6ad9d61bb..44e1f5125 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -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) })) } diff --git a/src/renderer/src/store/slices/task-creation-drafts.test.ts b/src/renderer/src/store/slices/task-creation-drafts.test.ts new file mode 100644 index 000000000..09ef94579 --- /dev/null +++ b/src/renderer/src/store/slices/task-creation-drafts.test.ts @@ -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)) + ) +} + +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 + ) + }) +}) diff --git a/src/renderer/src/store/slices/task-creation-drafts.ts b/src/renderer/src/store/slices/task-creation-drafts.ts new file mode 100644 index 000000000..cf9a64eec --- /dev/null +++ b/src/renderer/src/store/slices/task-creation-drafts.ts @@ -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): 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 }) +}) diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index 8d64bead5..aae3b171c 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -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