Skip to content
Closed
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
7 changes: 7 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1783,8 +1783,15 @@ export default function ChatView(props: ChatViewProps) {
state.threadPanelVisibilityByThreadKey,
activeThreadRef,
threadPanelPresentation,
state.threadDetailsOpenByDefault,
),
);
useLayoutEffect(() => {
if (!clientSettingsHydrated) return;
useRightPanelStore
.getState()
.setThreadDetailsOpenByDefault(settings.threadDetailsOpenByDefault);
}, [clientSettingsHydrated, settings.threadDetailsOpenByDefault]);

useEffect(() => {
if (!activeThreadRef || !previewSessionsReady) return;
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/chat/ThreadDetailsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ export function ThreadDetailsCard({
const mode = placement ? "inline" : "popover";
const topInset = canvas?.detailsCardTopInset ?? 0;
const inlineOpen = useRightPanelStore((state) =>
selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "inline"),
selectThreadPanelOpen(
state.threadPanelVisibilityByThreadKey,
threadRef,
"inline",
state.threadDetailsOpenByDefault,
),
);
const popoverOpen = useRightPanelStore((state) =>
selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "popover"),
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,9 @@ export function useSettingsRestore(onRestored?: () => void) {
DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip
? ["Composer context"]
: []),
...(settings.threadDetailsOpenByDefault !== DEFAULT_UNIFIED_SETTINGS.threadDetailsOpenByDefault
? ["Thread details"]
: []),
...getChangedTypographySettingLabels(settings),
...(settings.diffFilesCollapsed !== DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed
? ["Default diff file state"]
Expand Down Expand Up @@ -726,6 +729,7 @@ export function useSettingsRestore(onRestored?: () => void) {
inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled,
wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
persistComposerContextStrip: DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip,
threadDetailsOpenByDefault: DEFAULT_UNIFIED_SETTINGS.threadDetailsOpenByDefault,
diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed,
diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout,
Expand Down Expand Up @@ -1346,6 +1350,33 @@ export function AppearanceSettingsPanel() {
}
/>

<SettingsRow
{...searchableSetting("thread-details")}
description="Show the workspace card at the top right when a thread opens."
resetAction={
settings.threadDetailsOpenByDefault !==
DEFAULT_UNIFIED_SETTINGS.threadDetailsOpenByDefault ? (
<SettingResetButton
label="thread details"
onClick={() =>
updateSettings({
threadDetailsOpenByDefault: DEFAULT_UNIFIED_SETTINGS.threadDetailsOpenByDefault,
})
}
/>
) : null
}
control={
<Switch
checked={settings.threadDetailsOpenByDefault}
onCheckedChange={(checked) =>
updateSettings({ threadDetailsOpenByDefault: Boolean(checked) })
}
aria-label="Show thread details when a thread opens"
/>
}
/>

<SettingsRow
{...searchableSetting("chat-width")}
description="Set how wide messages and the composer can grow on large screens."
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,12 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Composer context",
to: "/settings/appearance",
},
{
id: "thread-details",
title: "Thread details",
to: "/settings/appearance",
searchTerms: ["workspace card panel hide default new thread git scripts open"],
},
{
id: "project-grouping",
title: "Project grouping",
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/rightPanelStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ beforeEach(() => {
useRightPanelStore.setState({
byThreadKey: {},
threadPanelVisibilityByThreadKey: {},
threadDetailsOpenByDefault: true,
userActionRevisionByThreadKey: {},
closeRevisionByThreadKey: {},
});
Expand Down Expand Up @@ -762,6 +763,50 @@ describe("rightPanelStore", () => {
});
});

it("keeps an explicit thread-details choice when the default changes", async () => {
const store = useRightPanelStore.getState();
const inline = (ref: typeof refA, openByDefault: boolean) =>
selectThreadPanelOpen(
useRightPanelStore.getState().threadPanelVisibilityByThreadKey,
ref,
"inline",
openByDefault,
);

store.setThreadDetailsOpenByDefault(false);
expect(inline(refA, false)).toBe(false);
expect(inline(refB, false)).toBe(false);

store.setThreadPanelOpen(refA, "inline", true);
await reloadRightPanelStore();
useRightPanelStore.getState().setThreadDetailsOpenByDefault(false);
expect(inline(refA, false)).toBe(true);
expect(inline(refB, false)).toBe(false);

useRightPanelStore.getState().setThreadPanelOpen(refA, "inline", false);
useRightPanelStore.getState().setThreadDetailsOpenByDefault(true);
expect(inline(refA, true)).toBe(false);
expect(inline(refB, true)).toBe(true);

useRightPanelStore.getState().setThreadDetailsOpenByDefault(false);
useRightPanelStore.getState().open(refA, "diff");
useRightPanelStore.getState().setThreadDetailsOpenByDefault(true);
expect(inline(refA, true)).toBe(false);

useRightPanelStore.getState().setThreadDetailsOpenByDefault(false);
useRightPanelStore.getState().setThreadPanelOpen(refB, "popover", true);
useRightPanelStore.getState().setThreadDetailsOpenByDefault(true);
useRightPanelStore.getState().toggleThreadPanel(refB, "inline");
expect(inline(refB, true)).toBe(false);

const refC = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-C"));
useRightPanelStore.getState().setThreadDetailsOpenByDefault(true);
useRightPanelStore.getState().setThreadPanelOpen(refC, "inline", true);
await reloadRightPanelStore();
useRightPanelStore.getState().setThreadDetailsOpenByDefault(false);
expect(inline(refC, false)).toBe(true);
});

it("tracks inline and popover visibility independently", () => {
const store = useRightPanelStore.getState();

Expand Down
87 changes: 66 additions & 21 deletions apps/web/src/rightPanelStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ export interface ThreadRightPanelState {
}

export interface ThreadPanelVisibility {
inlineOpen: boolean;
// Absent follows the setting. A boolean is this thread's choice, even when
// it matches the setting.
inlineOpen?: boolean;
popoverOpen: boolean;
}

Expand All @@ -112,6 +114,9 @@ interface RightPanelStoreState {
requestMaximize: (ref: ScopedThreadRef) => void;
consumeMaximizeRequest: (ref: ScopedThreadRef) => boolean;
threadPanelVisibilityByThreadKey: Record<string, ThreadPanelVisibility>;
// Synced from client settings. Not persisted here; missing thread entries follow it.
threadDetailsOpenByDefault: boolean;
setThreadDetailsOpenByDefault: (open: boolean) => void;
userActionRevisionByThreadKey: Record<string, number>;
closeRevisionByThreadKey: Record<string, number>;
getUserActionRevision: (ref: ScopedThreadRef) => number;
Expand Down Expand Up @@ -210,10 +215,11 @@ const EMPTY_THREAD_STATE: ThreadRightPanelState = {
surfaces: [],
};

const DEFAULT_THREAD_PANEL_VISIBILITY: ThreadPanelVisibility = {
inlineOpen: true,
popoverOpen: false,
};
interface ResolvedThreadPanelVisibility {
readonly inlineOpen: boolean;
readonly popoverOpen: boolean;
readonly explicit: boolean;
}

const singletonSurface = (
kind: Exclude<RightPanelKind, "file" | "preview" | "terminal" | "pull-request">,
Expand Down Expand Up @@ -339,17 +345,30 @@ const updateThreadStateMap = (
const updateThreadPanelVisibilityMap = (
byThreadKey: Record<string, ThreadPanelVisibility>,
threadKey: string,
updater: (current: ThreadPanelVisibility) => ThreadPanelVisibility,
openByDefault: boolean,
updater: (current: ResolvedThreadPanelVisibility) => ResolvedThreadPanelVisibility,
): Record<string, ThreadPanelVisibility> => {
const current = byThreadKey[threadKey] ?? DEFAULT_THREAD_PANEL_VISIBILITY;
const had = byThreadKey[threadKey];
const current: ResolvedThreadPanelVisibility = {
inlineOpen: had?.inlineOpen ?? openByDefault,
popoverOpen: had?.popoverOpen ?? false,
explicit: had?.inlineOpen !== undefined,
};
const next = updater(current);
if (next.inlineOpen && !next.popoverOpen) {
if (!(threadKey in byThreadKey)) return byThreadKey;
if (next === current) return byThreadKey;

if (!next.explicit && !next.popoverOpen) {
if (!had) return byThreadKey;
const { [threadKey]: _removed, ...rest } = byThreadKey;
return rest;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
if (next === current) return byThreadKey;
return { ...byThreadKey, [threadKey]: next };
const stored: ThreadPanelVisibility = next.explicit
? { inlineOpen: next.inlineOpen, popoverOpen: next.popoverOpen }
: { popoverOpen: next.popoverOpen };
if (had?.inlineOpen === stored.inlineOpen && had?.popoverOpen === stored.popoverOpen) {
return byThreadKey;
}
return { ...byThreadKey, [threadKey]: stored };
};

type RightPanelStoreData = Pick<
Expand All @@ -358,28 +377,31 @@ type RightPanelStoreData = Pick<
>;

const updateThread = (
state: RightPanelStoreData,
state: RightPanelStoreData & { readonly threadDetailsOpenByDefault: boolean },
threadKey: string,
updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
): RightPanelStoreData => {
const current = state.byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
const byThreadKey = updateThreadStateMap(state.byThreadKey, threadKey, updater);
const next = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
let threadPanelVisibilityByThreadKey = state.threadPanelVisibilityByThreadKey;
const openByDefault = state.threadDetailsOpenByDefault;

if (!current.isOpen && next.isOpen) {
threadPanelVisibilityByThreadKey = updateThreadPanelVisibilityMap(
threadPanelVisibilityByThreadKey,
threadKey,
openByDefault,
(visibility) => (visibility.popoverOpen ? { ...visibility, popoverOpen: false } : visibility),
);
} else if (current.isOpen && !next.isOpen) {
threadPanelVisibilityByThreadKey = updateThreadPanelVisibilityMap(
threadPanelVisibilityByThreadKey,
threadKey,
openByDefault,
(visibility) =>
visibility.popoverOpen && !visibility.inlineOpen
? { ...visibility, inlineOpen: true }
? { ...visibility, inlineOpen: true, explicit: true }
: visibility,
);
}
Expand Down Expand Up @@ -608,6 +630,11 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
return true;
},
threadPanelVisibilityByThreadKey: {},
threadDetailsOpenByDefault: true,
setThreadDetailsOpenByDefault: (open) =>
set((state) =>
state.threadDetailsOpenByDefault === open ? state : { threadDetailsOpenByDefault: open },
),
userActionRevisionByThreadKey: {},
closeRevisionByThreadKey: {},
getUserActionRevision: (ref) =>
Expand Down Expand Up @@ -1068,9 +1095,17 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
threadPanelVisibilityByThreadKey: updateThreadPanelVisibilityMap(
state.threadPanelVisibilityByThreadKey,
scopedThreadKey(ref),
state.threadDetailsOpenByDefault,
(visibility) => {
const key = presentation === "inline" ? "inlineOpen" : "popoverOpen";
return visibility[key] === open ? visibility : { ...visibility, [key]: open };
if (presentation === "inline") {
// Matching the inherited default is still an explicit choice.
return visibility.explicit && visibility.inlineOpen === open
? visibility
: { ...visibility, inlineOpen: open, explicit: true };
}
return visibility.popoverOpen === open
? visibility
: { ...visibility, popoverOpen: open };
},
),
})),
Expand All @@ -1079,9 +1114,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
threadPanelVisibilityByThreadKey: updateThreadPanelVisibilityMap(
state.threadPanelVisibilityByThreadKey,
scopedThreadKey(ref),
state.threadDetailsOpenByDefault,
(visibility) =>
presentation === "inline"
? { ...visibility, inlineOpen: !visibility.inlineOpen }
? { ...visibility, inlineOpen: !visibility.inlineOpen, explicit: true }
: { ...visibility, popoverOpen: !visibility.popoverOpen },
),
})),
Expand Down Expand Up @@ -1123,10 +1159,13 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
([threadKey]) => !isPullRequestsPanelKey(threadKey),
),
),
// Popovers do not survive reload. Only an explicit inline choice does.
threadPanelVisibilityByThreadKey: Object.fromEntries(
Object.entries(state.threadPanelVisibilityByThreadKey).flatMap(
([threadKey, visibility]) =>
visibility.inlineOpen ? [] : [[threadKey, { inlineOpen: false, popoverOpen: false }]],
visibility.inlineOpen === undefined
? []
: [[threadKey, { inlineOpen: visibility.inlineOpen, popoverOpen: false }]],
),
),
}),
Expand Down Expand Up @@ -1172,17 +1211,23 @@ export function selectThreadRightPanelState(
export function selectThreadPanelVisibility(
byThreadKey: Record<string, ThreadPanelVisibility>,
ref: ScopedThreadRef | null | undefined,
): ThreadPanelVisibility {
if (!ref) return DEFAULT_THREAD_PANEL_VISIBILITY;
return byThreadKey[scopedThreadKey(ref)] ?? DEFAULT_THREAD_PANEL_VISIBILITY;
openByDefault = true,
): { inlineOpen: boolean; popoverOpen: boolean } {
if (!ref) return { inlineOpen: openByDefault, popoverOpen: false };
const stored = byThreadKey[scopedThreadKey(ref)];
return {
inlineOpen: stored?.inlineOpen ?? openByDefault,
popoverOpen: stored?.popoverOpen ?? false,
};
}

export function selectThreadPanelOpen(
byThreadKey: Record<string, ThreadPanelVisibility>,
ref: ScopedThreadRef | null | undefined,
presentation: ThreadPanelPresentation,
openByDefault = true,
): boolean {
const visibility = selectThreadPanelVisibility(byThreadKey, ref);
const visibility = selectThreadPanelVisibility(byThreadKey, ref, openByDefault);
return presentation === "inline" ? visibility.inlineOpen : visibility.popoverOpen;
}

Expand Down
6 changes: 6 additions & 0 deletions docs/user/appearance.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ The controls retreat as the composer docks after you send the first message.
Turn on **Composer context** to keep those controls visible after the thread starts. This preference
applies to the web and desktop clients.

## Thread details

Turn off **Thread details** in **Settings → Appearance** to leave the workspace card at the top
right closed until you open it. The top-right button and its shortcut still toggle the card. This
preference applies to the web and desktop clients.

## Math

Turn on **Render math** in **Settings → Appearance** to typeset LaTeX in messages and Markdown
Expand Down
12 changes: 12 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,18 @@ describe("ClientSettings composer context strip", () => {
});
});

describe("ClientSettings thread details", () => {
it("stays open by default and accepts a closed preference", () => {
expect(decodeClientSettings({}).threadDetailsOpenByDefault).toBe(true);
expect(
decodeClientSettingsPatch({ threadDetailsOpenByDefault: false }).threadDetailsOpenByDefault,
).toBe(false);
expect(
decodeClientSettings({ threadDetailsOpenByDefault: false }).threadDetailsOpenByDefault,
).toBe(false);
});
});

describe("ClientSettings word wrap", () => {
it("defaults word wrap on", () => {
expect(decodeClientSettings({}).wordWrap).toBe(true);
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ export const ClientSettingsSchema = Schema.Struct({
persistComposerContextStrip: Schema.Boolean.pipe(
Schema.withDecodingDefault(Effect.succeed(false)),
),
threadDetailsOpenByDefault: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),

favorites: ModelFavorites.pipe(Schema.withDecodingDefault(Effect.succeed([]))),
providerModelPreferences: ProviderModelPreferences.pipe(
Expand Down Expand Up @@ -1624,6 +1625,7 @@ export const ClientSettingsPatch = Schema.Struct({
fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference),
fontSmoothing: Schema.optionalKey(Schema.Boolean),
persistComposerContextStrip: Schema.optionalKey(Schema.Boolean),
threadDetailsOpenByDefault: Schema.optionalKey(Schema.Boolean),
favorites: Schema.optionalKey(ModelFavorites),
providerModelPreferences: Schema.optionalKey(ProviderModelPreferences),
pullRequestMergeMethodOverrides: Schema.optionalKey(
Expand Down
Loading