Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -709,6 +714,7 @@ export default function Sidebar() {
sidebarProjectByKey,
wrapUpOnPullRequestSettled,
threadSeedVisitedAtById,
threadWrapUpOnPullRequestSettledById,
],
);

Expand Down
45 changes: 30 additions & 15 deletions apps/web/src/components/chat/ComposerPullRequestRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -363,19 +369,28 @@ function ComposerPullRequestChecksPopover({
onAutoFixChange={pullRequest.onAutoFixChange}
/>
) : null}
<label className="flex cursor-pointer items-center gap-2 px-3 py-1 transition-colors hover:bg-accent">
<Checkbox
className="size-3.5"
checked={wrapUpOnSettled}
onCheckedChange={(checked) => {
updateSettings({ wrapUpThreadsOnPullRequestSettled: Boolean(checked) });
}}
/>
Wrap up thread after merge or close
{/* This one switch is not about this pull request: it is the app-wide
setting, and the hint says so before it is flipped. */}
<span className="ml-auto text-[11px] text-muted-foreground">Settings</span>
</label>
{/* 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. */}
<div className="flex items-center gap-2 px-3 py-1 transition-colors hover:bg-accent">
<label className="flex min-w-0 flex-1 cursor-pointer items-center gap-2">
<Checkbox
className="size-3.5"
checked={pullRequest.wrapUpOnSettled}
onCheckedChange={(checked) => {
pullRequest.onWrapUpOnSettledChange(Boolean(checked));
}}
/>
Wrap up thread after merge or close
</label>
<Link
to="/settings/general"
hash="wrap-up-merged-threads"
className="shrink-0 rounded-sm text-[11px] text-muted-foreground transition-colors hover:text-foreground focus-ring"
>
Settings
</Link>
</div>
</div>
);
}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -999,8 +999,9 @@ export function GeneralSettingsPanel({ surface = "full" }: { surface?: "full" |
/>

<SettingsRow
id="wrap-up-merged-threads"
title="Wrap up merged threads"
description="File a thread under Wrapped once its pull request merges or closes. A new message in the thread brings it back."
description="File a thread under Wrapped once its pull request merges or closes; a thread's pull request row can say otherwise. A new message in the thread brings it back."
resetAction={
settings.wrapUpThreadsOnPullRequestSettled !==
DEFAULT_UNIFIED_SETTINGS.wrapUpThreadsOnPullRequestSettled ? (
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/routes/_chat.$environmentId.$threadId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { useAgentsPanelSource } from "../agentsPanelStore";
import { preloadDiffPanel, schedulePreloadDiffPanel } from "../diffPanelPreload";
import { useMediaQuery } from "../hooks/useMediaQuery";
import { useSettings } from "../hooks/useSettings";
import { useUiStateStore } from "../uiStateStore";
import {
gitWorkingTreeDiffQueryOptions,
invalidateGitWorkingTreeDiffQueries,
Expand Down Expand Up @@ -513,9 +514,19 @@ function ChatThreadRouteView() {
composerPullRequestDismissalKey,
);
const activeProjectTitle = activeProject?.name ?? null;
// The app setting is the default; a thread that has said otherwise wins.
const wrapUpOnSettledDefault = useSettings(
(settings) => settings.wrapUpThreadsOnPullRequestSettled,
);
const threadWrapUpOnSettled = useUiStateStore((store) =>
currentThreadKey === null
? undefined
: store.threadWrapUpOnPullRequestSettledById[currentThreadKey],
);
const composerPullRequest = useMemo<ComposerPullRequest | null>(
() =>
threadRef &&
currentThreadKey !== null &&
threadPullRequest &&
threadPullRequestReference &&
composerPullRequestDismissalKey !== null &&
Expand All @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/uiStateStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
setDefaultAdvertisedEndpointKey,
setProjectExpanded,
setThreadChangedFilesExpanded,
setThreadWrapUpOnPullRequestSettled,
syncProjects,
syncThreads,
type UiState,
Expand All @@ -28,6 +29,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState {
threadSeedVisitedAtById: {},
threadChangedFilesExpandedById: {},
doneThreadOverlays: {},
threadWrapUpOnPullRequestSettledById: {},
inboxProjectScopeKey: null,
inboxEnvironmentScopeId: null,
defaultAdvertisedEndpointKey: null,
Expand Down Expand Up @@ -340,6 +342,10 @@ describe("uiStateStore pure functions", () => {
"turn-2": false,
},
},
threadWrapUpOnPullRequestSettledById: {
[thread1]: false,
[thread2]: true,
},
});

const next = syncThreads(initialState, [{ key: thread1 }]);
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading