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
20 changes: 20 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1786,6 +1786,26 @@ describe("proactive completed diff guard", () => {
}),
).toBe("ignore");
});

it("leaves an already open diff and its chosen scope alone", () => {
const largeCheckpoint = {
status: "ready",
files: Array.from({ length: 3 }, (_, index) => ({
path: `src/app-${index}.ts`,
kind: "modified" as const,
additions: 20,
deletions: 0,
})),
} satisfies Pick<TurnDiffSummary, "status" | "files">;

expect(
resolveProactiveTurnDiffAction({
checkpoint: largeCheckpoint,
isGitRepo: true,
activeSurfaceKind: "diff",
}),
).toBe("ignore");
});
});

describe("shouldRefocusComposerOnWindowFocus", () => {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,10 @@ export function resolveProactiveTurnDiffAction(input: {
isGitRepo: boolean | undefined;
activeSurfaceKind: RightPanelSurface["kind"] | null;
}): "defer" | "ignore" | "open" {
if (input.activeSurfaceKind === "pull-request") return "ignore";
// An open diff already shows the work; reopening it would reset the chosen scope.
if (input.activeSurfaceKind === "pull-request" || input.activeSurfaceKind === "diff") {
return "ignore";
}
if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer";
if (input.isGitRepo === undefined) return "defer";
if (
Expand Down
6 changes: 2 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ import { AsyncResult } from "effect/reactivity";
import { isElectron } from "../env";
import { readLocalApi } from "../localApi";
import { useDiffPanelStore } from "../diffPanelStore";
import { useActiveThreadRef } from "../hooks/useActiveThreadRef";
import {
type ComposerSubmissionIntent,
collapseExpandedComposerCursor,
Expand Down Expand Up @@ -2192,10 +2193,7 @@ export default function ChatView(props: ChatViewProps) {
}
return labels;
}, [activeThreadKnownSessions]);
const activeThreadRef = useMemo(
() => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
[activeThread],
);
const activeThreadRef = useActiveThreadRef(activeThread);
const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
const activeEnvironmentServerBrowser = useEnvironmentSupportsServerBrowser(
activeThreadRef?.environmentId ?? null,
Expand Down
61 changes: 61 additions & 0 deletions apps/web/src/hooks/useActiveThreadRef.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
import { act } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { useActiveThreadRef } from "./useActiveThreadRef";

type ThreadLike = { environmentId: EnvironmentId; id: ThreadId; status: string } | null;

let renderer: ReactTestRenderer | null = null;
let observed: Array<ScopedThreadRef | null> = [];

function Probe({ thread }: { thread: ThreadLike }) {
observed.push(useActiveThreadRef(thread));
return null;
}

const thread = (id: string, status = "idle"): ThreadLike => ({
environmentId: "env-1" as EnvironmentId,
id: id as ThreadId,
status,
});

async function render(value: ThreadLike) {
await act(() => {
if (renderer) renderer.update(<Probe thread={value} />);
else renderer = create(<Probe thread={value} />);
});
return observed.at(-1);
}

beforeEach(() => {
observed = [];
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
});

afterEach(async () => {
await act(() => renderer?.unmount());
renderer = null;
vi.unstubAllGlobals();
});

describe("useActiveThreadRef", () => {
it("keeps the same ref while the thread object is replaced during a run", async () => {
const first = await render(thread("t1", "running"));
const afterUpdate = await render(thread("t1", "waiting"));

expect(first).toEqual({ environmentId: "env-1", threadId: "t1" });
expect(afterUpdate).toBe(first);
});

it("returns a new ref when the thread changes or goes away", async () => {
const first = await render(thread("t1"));
const switched = await render(thread("t2"));
const gone = await render(null);

expect(switched).not.toBe(first);
expect(switched).toEqual({ environmentId: "env-1", threadId: "t2" });
expect(gone).toBeNull();
});
});
20 changes: 20 additions & 0 deletions apps/web/src/hooks/useActiveThreadRef.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
import { useMemo } from "react";

/**
* The scoped ref of the thread a view shows, stable for as long as it shows that thread.
* Takes the thread object but keys on its ids: the shell changes identity on every update
* during a run, and effects keyed on this ref must not re-run for that.
*/
export function useActiveThreadRef(
thread: { readonly environmentId: EnvironmentId; readonly id: ThreadId } | null | undefined,
): ScopedThreadRef | null {
const environmentId = thread?.environmentId ?? null;
const threadId = thread?.id ?? null;
return useMemo(
() =>
environmentId !== null && threadId !== null ? scopeThreadRef(environmentId, threadId) : null,
[environmentId, threadId],
);
}
Loading