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
24 changes: 14 additions & 10 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import {
} from "../session-logic";
import { isScrollContainerNearBottom } from "../chat-scroll";
import { useStore } from "../store";
import type { ChatImageAttachment } from "../types";
import { MAX_THREAD_TERMINAL_COUNT, type ChatImageAttachment } from "../types";
import BranchToolbar from "./BranchToolbar";
import GitActionsControl from "./GitActionsControl";
import {
Expand Down Expand Up @@ -294,6 +294,8 @@ export default function ChatView() {
(activeThread.messages.length > 0 ||
(activeThread.session !== null && activeThread.session.status !== "closed")),
);
const hasReachedTerminalLimit =
(activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT;

const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {
for (const image of images) {
Expand Down Expand Up @@ -322,23 +324,23 @@ export default function ChatView() {
});
}, [activeThread?.terminalOpen, activeThreadId, dispatch]);
const splitTerminal = useCallback(() => {
if (!activeThreadId) return;
if (!activeThreadId || hasReachedTerminalLimit) return;
dispatch({
type: "SPLIT_THREAD_TERMINAL",
threadId: activeThreadId,
terminalId: `terminal-${crypto.randomUUID()}`,
});
setTerminalFocusRequestId((value) => value + 1);
}, [activeThreadId, dispatch]);
}, [activeThreadId, dispatch, hasReachedTerminalLimit]);
const createNewTerminal = useCallback(() => {
if (!activeThreadId) return;
if (!activeThreadId || hasReachedTerminalLimit) return;
dispatch({
type: "NEW_THREAD_TERMINAL",
threadId: activeThreadId,
terminalId: `terminal-${crypto.randomUUID()}`,
});
setTerminalFocusRequestId((value) => value + 1);
}, [activeThreadId, dispatch]);
}, [activeThreadId, dispatch, hasReachedTerminalLimit]);
const activateTerminal = useCallback(
(terminalId: string) => {
if (!activeThreadId) return;
Expand Down Expand Up @@ -969,7 +971,7 @@ export default function ChatView() {
// Empty state: no active thread
if (!activeThread) {
return (
<div className="flex min-h-0 flex-1 flex-col bg-background text-muted-foreground/40">
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40">
{isElectron && (
<div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
<span className="text-xs text-muted-foreground/50">No active thread</span>
Expand All @@ -985,7 +987,7 @@ export default function ChatView() {
}

return (
<div className="flex min-h-0 flex-1 flex-col bg-background">
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
{/* Top bar */}
<header
className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`}
Expand Down Expand Up @@ -1304,11 +1306,13 @@ const ChatHeader = memo(function ChatHeader({
}: ChatHeaderProps) {
return (
<>
<div className="flex items-center gap-3">
<h2 className="text-sm font-medium text-foreground">{activeThreadTitle}</h2>
<div className="flex min-w-0 flex-1 items-center gap-3">
<h2 className="truncate text-sm font-medium text-foreground" title={activeThreadTitle}>
{activeThreadTitle}
</h2>
{activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>}
</div>
<div className="flex items-center gap-3">
<div className="shrink-0 flex items-center gap-3">
{activeProjectName && <OpenInPicker keybindings={keybindings} />}
{activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />}
<Button
Expand Down
68 changes: 47 additions & 21 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keyb
import {
DEFAULT_THREAD_TERMINAL_HEIGHT,
DEFAULT_THREAD_TERMINAL_ID,
MAX_THREAD_TERMINAL_COUNT,
type ThreadTerminalGroup,
} from "../types";

Expand Down Expand Up @@ -581,16 +582,31 @@ export default function ThreadTerminalDrawer({
const showGroupHeaders =
resolvedTerminalGroups.length > 1 ||
resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);
const hasReachedTerminalLimit = normalizedTerminalIds.length >= MAX_THREAD_TERMINAL_COUNT;
const terminalLabelById = useMemo(
() =>
new Map(
normalizedTerminalIds.map((terminalId, index) => [terminalId, `Terminal ${index + 1}`]),
),
[normalizedTerminalIds],
);
const splitTerminalActionLabel = hasReachedTerminalLimit
? `Split Terminal (max ${MAX_THREAD_TERMINAL_COUNT})`
: (splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal");
const newTerminalActionLabel = hasReachedTerminalLimit
? `New Terminal (max ${MAX_THREAD_TERMINAL_COUNT})`
: (newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal");
const closeTerminalActionLabel = closeShortcutLabel
? `Close Terminal (${closeShortcutLabel})`
: "Close Terminal";
const onSplitTerminalAction = useCallback(() => {
if (hasReachedTerminalLimit) return;
onSplitTerminal();
}, [hasReachedTerminalLimit, onSplitTerminal]);
const onNewTerminalAction = useCallback(() => {
if (hasReachedTerminalLimit) return;
onNewTerminal();
}, [hasReachedTerminalLimit, onNewTerminal]);

useEffect(() => {
onHeightChangeRef.current = onHeightChange;
Expand Down Expand Up @@ -685,7 +701,7 @@ export default function ThreadTerminalDrawer({

return (
<aside
className="thread-terminal-drawer relative flex shrink-0 flex-col border-t border-border/80 bg-background"
className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"
style={{ height: `${drawerHeight}px` }}
>
<div
Expand All @@ -700,19 +716,25 @@ export default function ThreadTerminalDrawer({
<div className="pointer-events-none absolute right-2 top-2 z-20">
<div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background/70">
<TerminalActionButton
className="p-1 text-foreground/90 transition-colors hover:bg-accent"
onClick={onSplitTerminal}
label={
splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"
}
className={`p-1 text-foreground/90 transition-colors ${
hasReachedTerminalLimit
? "cursor-not-allowed opacity-45 hover:bg-transparent"
: "hover:bg-accent"
}`}
onClick={onSplitTerminalAction}
label={splitTerminalActionLabel}
>
<SquareSplitHorizontal className="size-3.25" />
</TerminalActionButton>
<div className="h-4 w-px bg-border/80" />
<TerminalActionButton
className="p-1 text-foreground/90 transition-colors hover:bg-accent"
onClick={onNewTerminal}
label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"}
className={`p-1 text-foreground/90 transition-colors ${
hasReachedTerminalLimit
? "cursor-not-allowed opacity-45 hover:bg-transparent"
: "hover:bg-accent"
}`}
onClick={onNewTerminalAction}
label={newTerminalActionLabel}
>
<Plus className="size-3.25" />
</TerminalActionButton>
Expand All @@ -733,9 +755,9 @@ export default function ThreadTerminalDrawer({
<div className="min-w-0 flex-1">
{isSplitView ? (
<div
className="grid h-full w-full gap-0 overflow-x-auto"
className="grid h-full w-full min-w-0 gap-0 overflow-hidden"
style={{
gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(260px, 1fr))`,
gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
Comment thread
juliusmarminge marked this conversation as resolved.
}}
>
{visibleTerminalIds.map((terminalId) => (
Expand Down Expand Up @@ -787,20 +809,24 @@ export default function ThreadTerminalDrawer({
<div className="flex h-[22px] items-stretch justify-end border-b border-border/70">
<div className="inline-flex h-full items-stretch">
<TerminalActionButton
className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70"
onClick={onSplitTerminal}
label={
splitShortcutLabel
? `Split Terminal (${splitShortcutLabel})`
: "Split Terminal"
}
className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${
hasReachedTerminalLimit
? "cursor-not-allowed opacity-45 hover:bg-transparent"
: "hover:bg-accent/70"
}`}
onClick={onSplitTerminalAction}
label={splitTerminalActionLabel}
>
<SquareSplitHorizontal className="size-3.25" />
</TerminalActionButton>
<TerminalActionButton
className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
onClick={onNewTerminal}
label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"}
className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${
hasReachedTerminalLimit
? "cursor-not-allowed opacity-45 hover:bg-transparent"
: "hover:bg-accent/70"
}`}
onClick={onNewTerminalAction}
label={newTerminalActionLabel}
>
<Plus className="size-3.25" />
</TerminalActionButton>
Expand Down
70 changes: 69 additions & 1 deletion apps/web/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/con
import { describe, expect, it } from "vitest";

import { type AppState, reducer } from "./store";
import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
import {
DEFAULT_THREAD_TERMINAL_HEIGHT,
DEFAULT_THREAD_TERMINAL_ID,
MAX_THREAD_TERMINAL_COUNT,
} from "./types";
import type { Thread } from "./types";

type TerminalStartedEvent = Extract<TerminalEvent, { type: "started" }>;
Expand Down Expand Up @@ -255,6 +259,70 @@ describe("store reducer thread continuity", () => {
expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
});

it("caps split terminals at four per thread", () => {
const state = makeState(
makeThread({
terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"],
activeTerminalId: "term-4",
terminalGroups: [
{
id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"],
},
],
activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
}),
);
const next = reducer(state, {
type: "SPLIT_THREAD_TERMINAL",
threadId: "thread-local-1",
terminalId: "term-5",
});

expect(next.threads[0]?.terminalIds).toHaveLength(MAX_THREAD_TERMINAL_COUNT);
expect(next.threads[0]?.terminalIds).toEqual([
DEFAULT_THREAD_TERMINAL_ID,
"term-2",
"term-3",
"term-4",
]);
expect(next.threads[0]?.activeTerminalId).toBe("term-4");
});

it("caps new terminals at four per thread", () => {
const state = makeState(
makeThread({
terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"],
activeTerminalId: "term-4",
terminalGroups: [
{
id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
},
{ id: "group-term-2", terminalIds: ["term-2"] },
{ id: "group-term-3", terminalIds: ["term-3"] },
{ id: "group-term-4", terminalIds: ["term-4"] },
],
activeTerminalGroupId: "group-term-4",
}),
);
const next = reducer(state, {
type: "NEW_THREAD_TERMINAL",
threadId: "thread-local-1",
terminalId: "term-5",
});

expect(next.threads[0]?.terminalIds).toHaveLength(MAX_THREAD_TERMINAL_COUNT);
expect(next.threads[0]?.terminalIds).toEqual([
DEFAULT_THREAD_TERMINAL_ID,
"term-2",
"term-3",
"term-4",
]);
expect(next.threads[0]?.activeTerminalId).toBe("term-4");
expect(next.threads[0]?.activeTerminalGroupId).toBe("group-term-4");
});

it("closes a terminal and keeps grouped layout coherent", () => {
const state = makeState(
makeThread({
Expand Down
24 changes: 22 additions & 2 deletions apps/web/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type ChatAttachment,
DEFAULT_THREAD_TERMINAL_ID,
DEFAULT_RUNTIME_MODE,
MAX_THREAD_TERMINAL_COUNT,
type Project,
type RuntimeMode,
type Thread,
Expand Down Expand Up @@ -110,7 +111,12 @@ function readPersistedState(): AppState {
const hydrated = hydratePersistedState(raw, !rawCurrent && raw === rawCodethingV1);
if (!hydrated) return initialState;

return { ...hydrated, diffOpen: false };
const threads = hydrated.threads.map((thread) => normalizeThreadTerminals(thread));
const activeThreadId = threads.some((thread) => thread.id === hydrated.activeThreadId)
? hydrated.activeThreadId
: (threads[0]?.id ?? null);

return { ...hydrated, threads, activeThreadId, diffOpen: false };
} catch {
return initialState;
}
Expand Down Expand Up @@ -139,7 +145,7 @@ function updateThread(

function normalizeTerminalIds(terminalIds: string[]): string[] {
const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);
const unique = [...new Set(ids)];
const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT);
if (unique.length > 0) {
return unique;
}
Expand Down Expand Up @@ -550,6 +556,13 @@ export function reducer(state: AppState, action: Action): AppState {
...state,
threads: updateThread(state.threads, action.threadId, (thread) => {
const normalizedThread = normalizeThreadTerminals(thread);
const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);
if (
isNewTerminal &&
normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT
) {
return normalizedThread;
}
const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
? normalizedThread.terminalIds
: [...normalizedThread.terminalIds, action.terminalId];
Expand Down Expand Up @@ -616,6 +629,13 @@ export function reducer(state: AppState, action: Action): AppState {
...state,
threads: updateThread(state.threads, action.threadId, (thread) => {
const normalizedThread = normalizeThreadTerminals(thread);
const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);
if (
isNewTerminal &&
normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT
) {
return normalizedThread;
}
const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
? normalizedThread.terminalIds
: [...normalizedThread.terminalIds, action.terminalId];
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type RuntimeMode = "approval-required" | "full-access";
export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
export const DEFAULT_THREAD_TERMINAL_ID = "default";
export const MAX_THREAD_TERMINAL_COUNT = 4;

export interface ThreadTerminalGroup {
id: string;
Expand Down