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
4 changes: 3 additions & 1 deletion src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
import type { TerminalSessionCreateOptions } from "@/browser/utils/terminal";
import { useAPI } from "@/browser/contexts/API";
import { useChatTranscriptFullWidth } from "@/browser/hooks/useChatTranscriptFullWidth";
import { useReviews } from "@/browser/hooks/useReviews";
import { ReviewsBanner } from "../ReviewsBanner/ReviewsBanner";
import type { ReviewNoteData } from "@/common/types/review";
Expand Down Expand Up @@ -187,6 +188,7 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {
workspaceState,
immersiveHidden = false,
} = props;
const chatTranscriptFullWidth = useChatTranscriptFullWidth();
const { api } = useAPI();
const { workspaceMetadata } = useWorkspaceContext();
const chatAreaRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -1044,7 +1046,7 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {
>
<div
className={cn(
"max-w-4xl mx-auto",
chatTranscriptFullWidth ? "w-full" : "max-w-4xl mx-auto",
Comment thread
ibetitsmike marked this conversation as resolved.
(showTranscriptHydrationPlaceholder || showEmptyTranscriptPlaceholder) && "h-full"
)}
>
Expand Down
74 changes: 7 additions & 67 deletions src/browser/components/SshPromptDialog/SshPromptDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import "../../../../tests/ui/dom";
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import type { SshPromptEvent, SshPromptRequest } from "@/common/orpc/schemas/ssh";
import {
createControllableAsyncIterable,
type ControllableAsyncIterable,
} from "@/browser/testUtils";
import type { ReactNode } from "react";
import { installDom } from "../../../../tests/ui/dom";

Expand All @@ -25,79 +29,15 @@ void mock.module("@/browser/components/Dialog/Dialog", () => ({

import { SshPromptDialog } from "../SshPromptDialog/SshPromptDialog";

interface ControlledSubscription<T> {
iterable: AsyncIterable<T>;
push: (value: T) => void;
close: () => void;
interface ControlledSubscription<T> extends ControllableAsyncIterable<T> {
returnSpy: ReturnType<typeof mock>;
}

function createMockIterableSubscription<T>(): ControlledSubscription<T> {
const buffered: T[] = [];
const pending: Array<(result: IteratorResult<T>) => void> = [];
let closed = false;

const doneResult = (): IteratorResult<T> => ({
value: undefined as unknown as T,
done: true,
});

const flushDone = () => {
while (pending.length > 0) {
const resolve = pending.shift();
resolve?.(doneResult());
}
};

const returnSpy = mock((_value?: unknown) => {
closed = true;
flushDone();
return Promise.resolve(doneResult());
});

const iterator: AsyncIterator<T> = {
next() {
if (closed) {
return Promise.resolve(doneResult());
}

if (buffered.length > 0) {
return Promise.resolve({ value: buffered.shift()!, done: false });
}

return new Promise((resolve) => {
pending.push(resolve);
});
},
return: returnSpy,
};

const returnSpy = mock(() => undefined);
return {
iterable: {
[Symbol.asyncIterator]: () => iterator,
},
...createControllableAsyncIterable<T>({ onReturn: returnSpy }),
returnSpy,
push(value: T) {
if (closed) {
return;
}

const resolve = pending.shift();
if (resolve) {
resolve({ value, done: false });
return;
}

buffered.push(value);
},
close() {
if (closed) {
return;
}

closed = true;
flushDone();
},
};
}

Expand Down
38 changes: 36 additions & 2 deletions src/browser/features/Settings/Sections/GeneralSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
interface MockConfig {
coderWorkspaceArchiveBehavior: CoderWorkspaceArchiveBehavior;
worktreeArchiveBehavior: WorktreeArchiveBehavior;
chatTranscriptFullWidth: boolean;
llmDebugLogs: boolean;
}

Expand All @@ -27,6 +28,7 @@ interface MockAPIClient {
coderWorkspaceArchiveBehavior: CoderWorkspaceArchiveBehavior;
worktreeArchiveBehavior: WorktreeArchiveBehavior;
}) => Promise<void>;
updateChatTranscriptFullWidth: (input: { enabled: boolean }) => Promise<void>;
updateLlmDebugLogs: (input: { enabled: boolean }) => Promise<void>;
};
server: {
Expand Down Expand Up @@ -168,6 +170,7 @@ import { GeneralSection } from "./GeneralSection";
interface RenderGeneralSectionOptions {
coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior;
worktreeArchiveBehavior?: WorktreeArchiveBehavior;
chatTranscriptFullWidth?: boolean;
}

interface MockAPISetup {
Expand All @@ -181,12 +184,16 @@ interface MockAPISetup {
}) => Promise<void>
>
>;
updateChatTranscriptFullWidthMock: ReturnType<
typeof mock<(input: { enabled: boolean }) => Promise<void>>
>;
}

function createMockAPI(configOverrides: Partial<MockConfig> = {}): MockAPISetup {
const config: MockConfig = {
coderWorkspaceArchiveBehavior: DEFAULT_CODER_ARCHIVE_BEHAVIOR,
worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR,
chatTranscriptFullWidth: false,
llmDebugLogs: false,
...configOverrides,
};
Expand All @@ -204,11 +211,18 @@ function createMockAPI(configOverrides: Partial<MockConfig> = {}): MockAPISetup
}
);

const updateChatTranscriptFullWidthMock = mock(({ enabled }: { enabled: boolean }) => {
config.chatTranscriptFullWidth = enabled;

return Promise.resolve();
});

return {
api: {
config: {
getConfig: getConfigMock,
updateCoderPrefs: updateCoderPrefsMock,
updateChatTranscriptFullWidth: updateChatTranscriptFullWidthMock,
updateLlmDebugLogs: mock(({ enabled }: { enabled: boolean }) => {
config.llmDebugLogs = enabled;

Expand All @@ -226,6 +240,7 @@ function createMockAPI(configOverrides: Partial<MockConfig> = {}): MockAPISetup
},
getConfigMock,
updateCoderPrefsMock,
updateChatTranscriptFullWidthMock,
};
}

Expand All @@ -248,7 +263,8 @@ describe("GeneralSection", () => {
});

function renderGeneralSection(options: RenderGeneralSectionOptions = {}) {
const { api, updateCoderPrefsMock } = createMockAPI({
const { api, updateCoderPrefsMock, updateChatTranscriptFullWidthMock } = createMockAPI({
chatTranscriptFullWidth: options.chatTranscriptFullWidth,
coderWorkspaceArchiveBehavior: options.coderWorkspaceArchiveBehavior,
worktreeArchiveBehavior: options.worktreeArchiveBehavior,
});
Expand All @@ -260,7 +276,7 @@ describe("GeneralSection", () => {
</ThemeProvider>
);

return { updateCoderPrefsMock, view };
return { updateCoderPrefsMock, updateChatTranscriptFullWidthMock, view };
}

function getSelectTrigger(view: ReturnType<typeof render>, label: string): HTMLElement {
Expand Down Expand Up @@ -319,6 +335,24 @@ describe("GeneralSection", () => {
);
});

test("loads and persists the full-width chat transcript toggle", async () => {
const { updateChatTranscriptFullWidthMock, view } = renderGeneralSection({
chatTranscriptFullWidth: true,
});

const toggle = view.getByRole("switch", { name: "Toggle full-width chat transcript" });
await waitFor(() => {
expect(toggle.getAttribute("aria-checked")).toBe("true");
});

fireEvent.click(toggle);

await waitFor(() => {
expect(toggle.getAttribute("aria-checked")).toBe("false");
expect(updateChatTranscriptFullWidthMock).toHaveBeenCalledWith({ enabled: false });
});
});

test("renders the worktree archive behavior copy and loads the saved value", async () => {
const { view } = renderGeneralSection({
coderWorkspaceArchiveBehavior: "delete",
Expand Down
55 changes: 53 additions & 2 deletions src/browser/features/Settings/Sections/GeneralSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from "@/browser/components/SelectPrimitive/SelectPrimitive";
import { Input } from "@/browser/components/Input/Input";
import { Switch } from "@/browser/components/Switch/Switch";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
import { updatePersistedState, usePersistedState } from "@/browser/hooks/usePersistedState";
import { useAPI } from "@/browser/contexts/API";
import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
import {
Expand All @@ -20,6 +20,7 @@ import {
LAUNCH_BEHAVIOR_KEY,
BASH_COLLAPSED_SUMMARY_MODE_KEY,
BASH_COLLAPSED_SUMMARY_MODES,
CHAT_TRANSCRIPT_FULL_WIDTH_KEY,
DEFAULT_BASH_COLLAPSED_SUMMARY_MODE,
normalizeBashCollapsedSummaryMode,
type BashCollapsedSummaryMode,
Expand Down Expand Up @@ -224,18 +225,21 @@ export function GeneralSection() {
DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR
);
const [archiveSettingsLoaded, setArchiveSettingsLoaded] = useState(false);
const [chatTranscriptFullWidth, setChatTranscriptFullWidth] = useState(false);
const [llmDebugLogs, setLlmDebugLogs] = useState(false);
const archiveBehaviorLoadNonceRef = useRef(0);
const archiveBehaviorRef = useRef<CoderWorkspaceArchiveBehavior>(DEFAULT_CODER_ARCHIVE_BEHAVIOR);
const worktreeArchiveBehaviorRef = useRef<WorktreeArchiveBehavior>(
DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR
);

const chatTranscriptFullWidthLoadNonceRef = useRef(0);
const llmDebugLogsLoadNonceRef = useRef(0);

// updateCoderPrefs writes config.json on the backend. Serialize (and coalesce) updates so rapid
// selections can't race and persist a stale value via out-of-order writes.
const archiveBehaviorUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
const chatTranscriptFullWidthUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
const llmDebugLogsUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
const archiveBehaviorPendingUpdateRef = useRef<CoderWorkspaceArchiveBehavior | undefined>(
undefined
Expand All @@ -251,6 +255,7 @@ export function GeneralSection() {

setArchiveSettingsLoaded(false);
const archiveBehaviorNonce = ++archiveBehaviorLoadNonceRef.current;
const chatTranscriptFullWidthNonce = ++chatTranscriptFullWidthLoadNonceRef.current;
const llmDebugLogsNonce = ++llmDebugLogsLoadNonceRef.current;

void api.config
Expand All @@ -274,7 +279,16 @@ export function GeneralSection() {
setArchiveSettingsLoaded(true);
}

// Use an independent nonce so debug-log toggles do not discard archive-setting updates.
// Use independent nonces so appearance/debug toggles do not discard archive updates.
if (chatTranscriptFullWidthNonce === chatTranscriptFullWidthLoadNonceRef.current) {
const enabled = cfg.chatTranscriptFullWidth === true;
setChatTranscriptFullWidth(enabled);
updatePersistedState<boolean | undefined>(
CHAT_TRANSCRIPT_FULL_WIDTH_KEY,
enabled ? true : undefined
);
}

if (llmDebugLogsNonce === llmDebugLogsLoadNonceRef.current) {
setLlmDebugLogs(cfg.llmDebugLogs === true);
}
Expand Down Expand Up @@ -361,6 +375,29 @@ export function GeneralSection() {
[api, archiveSettingsLoaded, queueArchiveBehaviorUpdate]
);

const handleChatTranscriptFullWidthChange = (checked: boolean) => {
// Invalidate any in-flight config load so it does not overwrite the user's selection.
chatTranscriptFullWidthLoadNonceRef.current++;
setChatTranscriptFullWidth(checked);
updatePersistedState<boolean | undefined>(
CHAT_TRANSCRIPT_FULL_WIDTH_KEY,
checked ? true : undefined
);

if (!api?.config?.updateChatTranscriptFullWidth) {
return;
}

chatTranscriptFullWidthUpdateChainRef.current = chatTranscriptFullWidthUpdateChainRef.current
.catch(() => {
// Best-effort only.
})
.then(() => api.config.updateChatTranscriptFullWidth({ enabled: checked }))
.catch(() => {
// Best-effort persistence.
});
};

const handleLlmDebugLogsChange = (checked: boolean) => {
// Invalidate any in-flight debug-log load so it doesn't overwrite the user's selection.
llmDebugLogsLoadNonceRef.current++;
Expand Down Expand Up @@ -521,6 +558,20 @@ export function GeneralSection() {
</Select>
</div>

<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<div className="text-foreground text-sm">Full-width chat transcript</div>
<div className="text-muted text-xs">
Let messages use the full chat pane instead of the default readable column.
</div>
</div>
<Switch
checked={chatTranscriptFullWidth}
onCheckedChange={handleChatTranscriptFullWidthChange}
aria-label="Toggle full-width chat transcript"
/>
</div>

<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<div className="text-foreground text-sm">Collapsed bash summaries</div>
Comment thread
ibetitsmike marked this conversation as resolved.
Expand Down
8 changes: 2 additions & 6 deletions src/browser/features/Tools/BashToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,14 +161,10 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
<ExpandIcon expanded={expanded}>â–¶</ExpandIcon>
<ToolIcon toolName="bash" />
{bashCollapsedSummary.kind === "intent-command" ? (
// Two lines: intent (primary) on top, command (muted mono) below.
// The duration chip sits on the command row so it inherits the same
// line-height and stays vertically centered on the command, not
// floating between the two lines.
<span className="flex max-w-[28rem] min-w-0 flex-col leading-tight">
<span className="flex min-w-0 flex-1 flex-col leading-tight">
<span className="text-text truncate">{bashCollapsedSummary.intent}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="text-muted font-monospace min-w-0 truncate text-[10px]">
<span className="text-muted font-monospace min-w-0 flex-1 truncate text-[10px]">
{bashCollapsedSummary.command}
</span>
{!isBackground && (
Expand Down
Loading
Loading