Skip to content
102 changes: 57 additions & 45 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,23 @@
import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model";
import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
import { truncate } from "@t3tools/shared/String";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearch } from "@tanstack/react-router";
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ComponentProps,
} from "react";
import { useNavigate } from "@tanstack/react-router";
import { useShallow } from "zustand/react/shallow";
import { useGitStatus } from "~/lib/gitStatusState";
import { usePrimaryEnvironmentId } from "../environments/primary";
import { readEnvironmentApi } from "../environmentApi";
import { isElectron } from "../env";
import { readLocalApi } from "../localApi";
import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
import {
collapseExpandedComposerCursor,
parseStandaloneComposerSlashCommand,
Expand Down Expand Up @@ -168,6 +176,12 @@
useServerKeybindings,
} from "~/rpc/serverState";
import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
import { useWorkspaceSecondarySurface } from "./workspace/WorkspaceProvider";
import { useWorkspaceSecondarySurfaceActions } from "./workspace/useWorkspaceSecondarySurfaceActions";
import {
createConversationDiffSurface,
createTurnDiffSurface,
} from "../workspace/surfaces/diffSurface";

const IMAGE_ONLY_BOOTSTRAP_PROMPT =
"[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
Expand All @@ -179,6 +193,32 @@

type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;

type WorkspaceAwareChatHeaderProps = Omit<
ComponentProps<typeof ChatHeader>,
"diffOpen" | "onToggleDiff"
> & {
isServerThread: boolean;
threadRef: ScopedThreadRef;
};

const WorkspaceAwareChatHeader = memo(function WorkspaceAwareChatHeader(
props: WorkspaceAwareChatHeaderProps,
) {
const secondarySurface = useWorkspaceSecondarySurface();
const { toggleSecondarySurface } = useWorkspaceSecondarySurfaceActions();
const onToggleDiff = useCallback(() => {
if (!props.isServerThread) {
return;
}

toggleSecondarySurface(createConversationDiffSurface(props.threadRef), { replace: true });
}, [props.isServerThread, props.threadRef, toggleSecondarySurface]);

return (
<ChatHeader {...props} diffOpen={secondarySurface?.id === "diff"} onToggleDiff={onToggleDiff} />
);
});

function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] {
return useStore(
useMemo(() => {
Expand Down Expand Up @@ -312,14 +352,12 @@
| {
environmentId: EnvironmentId;
threadId: ThreadId;
onDiffPanelOpen?: () => void;
routeKind: "server";
draftId?: never;
}
| {
environmentId: EnvironmentId;
threadId: ThreadId;
onDiffPanelOpen?: () => void;
routeKind: "draft";
draftId: DraftId;
};
Expand Down Expand Up @@ -573,7 +611,7 @@
});

export default function ChatView(props: ChatViewProps) {
const { environmentId, threadId, routeKind, onDiffPanelOpen } = props;
const { environmentId, threadId, routeKind } = props;
const draftId = routeKind === "draft" ? props.draftId : null;
const routeThreadRef = useMemo(
() => scopeThreadRef(environmentId, threadId),
Expand Down Expand Up @@ -608,10 +646,7 @@
);
const timestampFormat = settings.timestampFormat;
const navigate = useNavigate();
const rawSearch = useSearch({
strict: false,
select: (params) => parseDiffRouteSearch(params),
});
const { openSecondarySurface, toggleSecondarySurface } = useWorkspaceSecondarySurfaceActions();
const { resolvedTheme } = useTheme();
// Granular store selectors — avoid subscribing to prompt changes.
const composerRuntimeMode = useComposerDraftStore(
Expand Down Expand Up @@ -795,7 +830,6 @@
composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
const canCheckoutPullRequestIntoThread = isLocalDraftThread;
const diffOpen = rawSearch.diff === "1";
const activeThreadId = activeThread?.id ?? null;
const activeThreadRef = useMemo(
() => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
Expand Down Expand Up @@ -1475,22 +1509,9 @@
if (!isServerThread) {
return;
}
if (!diffOpen) {
onDiffPanelOpen?.();
}
void navigate({
to: "/$environmentId/$threadId",
params: {
environmentId,
threadId,
},
replace: true,
search: (previous) => {
const rest = stripDiffSearchParams(previous);
return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };
},
});
}, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]);

toggleSecondarySurface(createConversationDiffSurface(routeThreadRef), { replace: true });
}, [isServerThread, routeThreadRef, toggleSecondarySurface]);

const envLocked = Boolean(
activeThread &&
Expand Down Expand Up @@ -1553,7 +1574,7 @@
);

const focusComposer = useCallback(() => {
composerRef.current?.focusAtEnd();

Check warning on line 1577 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
}, []);
const scheduleComposerFocus = useCallback(() => {
window.requestAnimationFrame(() => {
Expand All @@ -1561,7 +1582,7 @@
});
}, [focusComposer]);
const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => {
composerRef.current?.addTerminalContext(selection);

Check warning on line 1585 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
}, []);
const setTerminalOpen = useCallback(
(open: boolean) => {
Expand Down Expand Up @@ -2853,7 +2874,7 @@
};
});
promptRef.current = "";
composerRef.current?.resetCursorState({ cursor: 0 });

Check warning on line 2877 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
},
[activePendingProgress?.activeQuestion, activePendingUserInput],
);
Expand All @@ -2880,7 +2901,7 @@
),
},
}));
const snapshot = composerRef.current?.readSnapshot();

Check warning on line 2904 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (
snapshot?.value !== value ||
snapshot.cursor !== nextCursor ||
Expand Down Expand Up @@ -2943,7 +2964,7 @@
return;
}

const sendCtx = composerRef.current?.getSendContext();

Check warning on line 2967 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (!sendCtx) {
return;
}
Expand Down Expand Up @@ -3074,7 +3095,7 @@
return;
}

const sendCtx = composerRef.current?.getSendContext();

Check warning on line 3098 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (!sendCtx) {
return;
}
Expand Down Expand Up @@ -3255,22 +3276,11 @@
if (!isServerThread) {
return;
}
onDiffPanelOpen?.();
void navigate({
to: "/$environmentId/$threadId",
params: {
environmentId,
threadId,
},
search: (previous) => {
const rest = stripDiffSearchParams(previous);
return filePath
? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath }
: { ...rest, diff: "1", diffTurnId: turnId };
},
openSecondarySurface(createTurnDiffSurface(routeThreadRef, turnId, filePath), {
replace: false,
});
},
[environmentId, isServerThread, navigate, onDiffPanelOpen, threadId],
[isServerThread, openSecondarySurface, routeThreadRef],
);
const onRevertUserMessage = useCallback(
(messageId: MessageId) => {
Expand All @@ -3297,7 +3307,7 @@
isElectron ? "drag-region flex h-[52px] items-center" : "py-2 sm:py-3",
)}
>
<ChatHeader
<WorkspaceAwareChatHeader
activeThreadEnvironmentId={activeThread.environmentId}
activeThreadId={activeThread.id}
{...(routeKind === "draft" && draftId ? { draftId } : {})}
Expand All @@ -3316,13 +3326,15 @@
terminalToggleShortcutLabel={terminalToggleShortcutLabel}
diffToggleShortcutLabel={diffPanelShortcutLabel}
gitCwd={gitCwd}
diffOpen={diffOpen}
onRunProjectScript={runProjectScript}
isServerThread={isServerThread}
threadRef={routeThreadRef}
onRunProjectScript={(script) => {
void runProjectScript(script);
}}
onAddProjectScript={saveProjectScript}
onUpdateProjectScript={updateProjectScript}
onDeleteProjectScript={deleteProjectScript}
onToggleTerminal={toggleTerminalVisibility}
onToggleDiff={onToggleDiff}
/>
</header>

Expand Down
46 changes: 46 additions & 0 deletions apps/web/src/components/DiffPanel.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { TurnId } from "@t3tools/contracts";
import { describe, expect, it } from "vitest";

import { normalizeDiffSurfaceFocus } from "./DiffPanel.logic";

describe("normalizeDiffSurfaceFocus", () => {
it("keeps conversation focus unchanged", () => {
expect(normalizeDiffSurfaceFocus({ scope: "conversation" }, [])).toEqual({
scope: "conversation",
});
});

it("keeps turn focus when the requested turn exists", () => {
const turnId = TurnId.make("turn-1");

expect(
normalizeDiffSurfaceFocus(
{
scope: "turn",
turnId,
filePath: "src/app.ts",
},
[turnId],
),
).toEqual({
scope: "turn",
turnId,
filePath: "src/app.ts",
});
});

it("falls back to conversation focus when the requested turn no longer exists", () => {
expect(
normalizeDiffSurfaceFocus(
{
scope: "turn",
turnId: TurnId.make("missing-turn"),
filePath: "src/app.ts",
},
[TurnId.make("different-turn")],
),
).toEqual({
scope: "conversation",
});
});
});
19 changes: 19 additions & 0 deletions apps/web/src/components/DiffPanel.logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { TurnId } from "@t3tools/contracts";

import { type DiffSurfaceFocus } from "../workspace/types";

export function normalizeDiffSurfaceFocus(
focus: DiffSurfaceFocus,
availableTurnIds: readonly TurnId[],
): DiffSurfaceFocus {
if (focus.scope !== "turn") {
return focus;
}

// Preserve explicit turn deep links until we have at least one summary to validate against.
if (availableTurnIds.length === 0 || availableTurnIds.includes(focus.turnId)) {
return focus;
}

return { scope: "conversation" };
}
Loading
Loading