diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9cbf039a..4b60d7ab 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -450,6 +450,9 @@ export default function Sidebar() { const seenThreadOverlays = useUiStateStore((store) => store.seenThreadOverlays); const threadSeedVisitedAtById = useUiStateStore((store) => store.threadSeedVisitedAtById); const doneThreadOverlays = useUiStateStore((store) => store.doneThreadOverlays); + const threadWrapUpOnPullRequestSettledById = useUiStateStore( + (store) => store.threadWrapUpOnPullRequestSettledById, + ); const inboxProjectScopeKey = useUiStateStore((store) => store.inboxProjectScopeKey); const setInboxProjectScope = useUiStateStore((store) => store.setInboxProjectScope); const inboxEnvironmentScopeId = useUiStateStore((store) => store.inboxEnvironmentScopeId); @@ -676,10 +679,12 @@ export default function Sidebar() { const isDone = isThreadDone({ ...thread, lastVisitedAt }, override, { now: nowIso, autoDoneAfterDays: INBOX_AUTO_DONE_AFTER_DAYS, - // A landing the host did not date is taken as now, which files the - // thread the way it always did. + // The thread's own word wins over the app setting. A landing the + // host did not date is taken as now, which files the thread the way + // it always did. pullRequestSettledAt: - wrapUpOnPullRequestSettled && pullRequestSettled + (threadWrapUpOnPullRequestSettledById[threadKey] ?? wrapUpOnPullRequestSettled) && + pullRequestSettled ? (pullRequest.settledAt ?? nowIso) : null, }); @@ -709,6 +714,7 @@ export default function Sidebar() { sidebarProjectByKey, wrapUpOnPullRequestSettled, threadSeedVisitedAtById, + threadWrapUpOnPullRequestSettledById, ], ); diff --git a/apps/web/src/components/chat/ComposerPullRequestRow.tsx b/apps/web/src/components/chat/ComposerPullRequestRow.tsx index 2d5a2e1b..9c48daa8 100644 --- a/apps/web/src/components/chat/ComposerPullRequestRow.tsx +++ b/apps/web/src/components/chat/ComposerPullRequestRow.tsx @@ -18,11 +18,11 @@ import type { PullRequestRef, } from "@threadlines/contracts"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; import { ChevronDownIcon, ExternalLinkIcon, WrenchIcon, XIcon } from "lucide-react"; import { useState } from "react"; import { isElectron } from "../../env"; -import { useSettings, updateSettings } from "../../hooks/useSettings"; import { readLocalApi } from "../../localApi"; import { pullRequestActionMutationOptions, @@ -68,6 +68,13 @@ export interface ComposerPullRequest { /** The thread's own switch: the server watches this pull request while it is on. */ readonly autoFix: boolean; readonly onAutoFixChange: (next: boolean) => void; + /** + * Whether this thread files itself under Wrapped once the pull request + * merges or closes: the thread's own word, or the app setting until it + * gives one. + */ + readonly wrapUpOnSettled: boolean; + readonly onWrapUpOnSettledChange: (next: boolean) => void; } const CHIP_TONE_CLASS: Readonly< @@ -253,7 +260,6 @@ function ComposerPullRequestChecksPopover({ const queryClient = useQueryClient(); const detail = pullRequest.detail; const buckets = composerPullRequestCheckBuckets(detail?.checks ?? []); - const wrapUpOnSettled = useSettings((settings) => settings.wrapUpThreadsOnPullRequestSettled); const autoMergeControl = composerAutoMergeControl(detail); const detailQueryKey = pullRequestQueryKeys.detail( pullRequest.environmentId, @@ -363,19 +369,28 @@ function ComposerPullRequestChecksPopover({ onAutoFixChange={pullRequest.onAutoFixChange} /> ) : null} - + {/* This thread's own choice. The link goes to the app-wide default it + stands in for until the box is clicked, and sits outside the label so + following it does not also flip the box. */} +
+ + + Settings + +
); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 119304eb..296be554 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -999,8 +999,9 @@ export function GeneralSettingsPanel({ surface = "full" }: { surface?: "full" | /> settings.wrapUpThreadsOnPullRequestSettled, + ); + const threadWrapUpOnSettled = useUiStateStore((store) => + currentThreadKey === null + ? undefined + : store.threadWrapUpOnPullRequestSettledById[currentThreadKey], + ); const composerPullRequest = useMemo( () => threadRef && + currentThreadKey !== null && threadPullRequest && threadPullRequestReference && composerPullRequestDismissalKey !== null && @@ -532,18 +543,27 @@ function ChatThreadRouteView() { onAutoFixChange: (next: boolean) => { void setThreadPullRequestAutoFix(threadRef, next); }, + wrapUpOnSettled: threadWrapUpOnSettled ?? wrapUpOnSettledDefault, + onWrapUpOnSettledChange: (next: boolean) => { + useUiStateStore + .getState() + .setThreadWrapUpOnPullRequestSettled(currentThreadKey, next); + }, } : null, [ activeProjectTitle, composerPullRequestDismissalKey, composerPullRequestDismissed, + currentThreadKey, selectTab, serverThread?.pullRequestAutoFix, threadPullRequest, threadPullRequestDetail, threadPullRequestReference, threadRef, + threadWrapUpOnSettled, + wrapUpOnSettledDefault, ], ); const closeTab = useCallback( diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index a6a07c76..173cec69 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -15,6 +15,7 @@ import { setDefaultAdvertisedEndpointKey, setProjectExpanded, setThreadChangedFilesExpanded, + setThreadWrapUpOnPullRequestSettled, syncProjects, syncThreads, type UiState, @@ -28,6 +29,7 @@ function makeUiState(overrides: Partial = {}): UiState { threadSeedVisitedAtById: {}, threadChangedFilesExpandedById: {}, doneThreadOverlays: {}, + threadWrapUpOnPullRequestSettledById: {}, inboxProjectScopeKey: null, inboxEnvironmentScopeId: null, defaultAdvertisedEndpointKey: null, @@ -340,6 +342,10 @@ describe("uiStateStore pure functions", () => { "turn-2": false, }, }, + threadWrapUpOnPullRequestSettledById: { + [thread1]: false, + [thread2]: true, + }, }); const next = syncThreads(initialState, [{ key: thread1 }]); @@ -352,6 +358,7 @@ describe("uiStateStore pure functions", () => { "turn-1": false, }, }); + expect(next.threadWrapUpOnPullRequestSettledById).toEqual({ [thread1]: false }); }); it("syncThreads seeds visit state for unseen snapshot threads", () => { @@ -644,6 +651,17 @@ describe("uiStateStore persistence round-trip", () => { expect(persisted.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http"); }); + it("keeps a thread's own wrap-up choice across a restart", () => { + const threadKey = "env-1:thread-1"; + const state = setThreadWrapUpOnPullRequestSettled(makeUiState(), threadKey, false); + + persistState(state); + + expect(readPersistedState().threadWrapUpOnPullRequestSettledById).toEqual({ + [threadKey]: false, + }); + }); + it("persists changed-files tree overrides for both default modes", () => { const thread1 = ThreadId.make("thread-1"); const state = makeUiState({ diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 91820e98..bffaf3a3 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -32,6 +32,8 @@ export interface PersistedUiState { threadLastVisitedAtById?: Record; inboxProjectScopeKey?: string | null; inboxEnvironmentScopeId?: string | null; + /** See `UiInboxState.threadWrapUpOnPullRequestSettledById`. */ + threadWrapUpOnPullRequestSettledById?: Record; } export interface UiProjectState { @@ -78,6 +80,13 @@ export interface UiInboxState { * the thread's live state, so activity blockers always win. */ doneThreadOverlays: Record; + /** + * Whether a thread files itself under Wrapped once its pull request merges + * or closes, for the threads the user said so about, by scoped thread key. + * Absent means follow the app setting. Kept on this device, like the + * setting it stands in for. + */ + threadWrapUpOnPullRequestSettledById: Record; /** Which project chip is selected; null is All. */ inboxProjectScopeKey: string | null; /** Which machine the list is narrowed to; null is All machines. */ @@ -113,6 +122,7 @@ const initialState: UiState = { threadSeedVisitedAtById: {}, threadChangedFilesExpandedById: {}, doneThreadOverlays: {}, + threadWrapUpOnPullRequestSettledById: {}, inboxProjectScopeKey: null, inboxEnvironmentScopeId: null, defaultAdvertisedEndpointKey: null, @@ -169,6 +179,9 @@ export function readPersistedState(): UiState { parsed.inboxEnvironmentScopeId.length > 0 ? parsed.inboxEnvironmentScopeId : null, + threadWrapUpOnPullRequestSettledById: sanitizePersistedBooleanRecord( + parsed.threadWrapUpOnPullRequestSettledById, + ), }; } catch { return initialState; @@ -245,6 +258,21 @@ export function dropLegacyInboxState(threadKeys: readonly string[]): void { } } +function sanitizePersistedBooleanRecord( + value: Record | undefined, +): Record { + if (value === undefined || value === null || typeof value !== "object") { + return {}; + } + const sanitized: Record = {}; + for (const [key, flag] of Object.entries(value)) { + if (typeof key === "string" && key.length > 0 && typeof flag === "boolean") { + sanitized[key] = flag; + } + } + return sanitized; +} + function sanitizePersistedVisitedAt( value: PersistedUiState["threadLastVisitedAtById"], ): Record { @@ -368,6 +396,7 @@ export function persistState(state: UiState): void { threadChangedFilesExpandedById, inboxProjectScopeKey: state.inboxProjectScopeKey, inboxEnvironmentScopeId: state.inboxEnvironmentScopeId, + threadWrapUpOnPullRequestSettledById: state.threadWrapUpOnPullRequestSettledById, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -626,6 +655,11 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) retainedThreadKeys.has(threadKey), ), ); + const nextThreadWrapUpOnPullRequestSettledById = Object.fromEntries( + Object.entries(state.threadWrapUpOnPullRequestSettledById).filter(([threadKey]) => + retainedThreadKeys.has(threadKey), + ), + ); if ( recordsEqual(state.threadSeedVisitedAtById, nextSeedVisitedAtById) && recordsEqual(state.seenThreadOverlays, nextSeenThreadOverlays) && @@ -633,6 +667,10 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) nestedBooleanRecordsEqual( state.threadChangedFilesExpandedById, nextThreadChangedFilesExpandedById, + ) && + recordsEqual( + state.threadWrapUpOnPullRequestSettledById, + nextThreadWrapUpOnPullRequestSettledById, ) ) { return state; @@ -643,6 +681,7 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) seenThreadOverlays: nextSeenThreadOverlays, doneThreadOverlays: nextDoneThreadOverlays, threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, + threadWrapUpOnPullRequestSettledById: nextThreadWrapUpOnPullRequestSettledById, }; } @@ -723,23 +762,56 @@ export function clearThreadUi(state: UiState, threadKey: string): UiState { const hasDoneOverlay = threadKey in state.doneThreadOverlays; const hasSeedState = threadKey in state.threadSeedVisitedAtById; const hasChangedFilesState = threadKey in state.threadChangedFilesExpandedById; - if (!hasSeenOverlay && !hasDoneOverlay && !hasSeedState && !hasChangedFilesState) { + const hasWrapUpState = threadKey in state.threadWrapUpOnPullRequestSettledById; + if ( + !hasSeenOverlay && + !hasDoneOverlay && + !hasSeedState && + !hasChangedFilesState && + !hasWrapUpState + ) { return state; } const nextSeenThreadOverlays = { ...state.seenThreadOverlays }; const nextDoneThreadOverlays = { ...state.doneThreadOverlays }; const nextSeedVisitedAtById = { ...state.threadSeedVisitedAtById }; const nextThreadChangedFilesExpandedById = { ...state.threadChangedFilesExpandedById }; + const nextThreadWrapUpOnPullRequestSettledById = { + ...state.threadWrapUpOnPullRequestSettledById, + }; delete nextSeenThreadOverlays[threadKey]; delete nextDoneThreadOverlays[threadKey]; delete nextSeedVisitedAtById[threadKey]; delete nextThreadChangedFilesExpandedById[threadKey]; + delete nextThreadWrapUpOnPullRequestSettledById[threadKey]; return { ...state, seenThreadOverlays: nextSeenThreadOverlays, doneThreadOverlays: nextDoneThreadOverlays, threadSeedVisitedAtById: nextSeedVisitedAtById, threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, + threadWrapUpOnPullRequestSettledById: nextThreadWrapUpOnPullRequestSettledById, + }; +} + +/** + * The user's word for one thread on wrapping up once its pull request + * settles. From here on the thread stops following the app setting. + */ +export function setThreadWrapUpOnPullRequestSettled( + state: UiState, + threadKey: string, + wrapUp: boolean, +): UiState { + if (state.threadWrapUpOnPullRequestSettledById[threadKey] === wrapUp) { + return state; + } + return { + ...state, + threadWrapUpOnPullRequestSettledById: { + ...state.threadWrapUpOnPullRequestSettledById, + [threadKey]: wrapUp, + }, }; } @@ -881,6 +953,7 @@ interface UiStateStore extends UiState { setInboxProjectScope: (projectKey: string | null) => void; setInboxEnvironmentScope: (environmentId: string | null) => void; clearThreadUi: (threadKey: string) => void; + setThreadWrapUpOnPullRequestSettled: (threadKey: string, wrapUp: boolean) => void; setThreadChangedFilesExpanded: ( threadId: string, turnId: string, @@ -919,6 +992,8 @@ export const useUiStateStore = create((set) => ({ : { ...state, inboxEnvironmentScopeId: environmentId }, ), clearThreadUi: (threadKey) => set((state) => clearThreadUi(state, threadKey)), + setThreadWrapUpOnPullRequestSettled: (threadKey, wrapUp) => + set((state) => setThreadWrapUpOnPullRequestSettled(state, threadKey, wrapUp)), setThreadChangedFilesExpanded: (threadId, turnId, expanded, defaultExpanded) => set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded, defaultExpanded),