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
16 changes: 5 additions & 11 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1744,7 +1744,6 @@ describe("proactive completed diff guard", () => {
resolveProactiveTurnDiffAction({
checkpoint: changedCheckpoint,
isGitRepo: true,
activeSurfaceKind: null,
}),
).toBe(action);
},
Expand All @@ -1764,41 +1763,37 @@ describe("proactive completed diff guard", () => {
resolveProactiveTurnDiffAction({
checkpoint: undefined,
isGitRepo: true,
activeSurfaceKind: null,
}),
).toBe("defer");
expect(
resolveProactiveTurnDiffAction({
checkpoint: missingCheckpoint,
isGitRepo: true,
activeSurfaceKind: null,
}),
).toBe("defer");
expect(
resolveProactiveTurnDiffAction({
checkpoint: changedCheckpoint,
isGitRepo: undefined,
activeSurfaceKind: null,
}),
).toBe("defer");
});

it("keeps an active pull request above a completed turn diff", () => {
it("delegates a qualifying completed diff to the store while a pull request is active", () => {
const changedCheckpoint = {
status: "ready",
files: [{ path: "src/app.ts", kind: "modified", additions: 1, deletions: 0 }],
files: [{ path: "src/app.ts", kind: "modified", additions: 50, deletions: 0 }],
} satisfies Pick<TurnDiffSummary, "status" | "files">;

expect(
resolveProactiveTurnDiffAction({
checkpoint: changedCheckpoint,
isGitRepo: true,
activeSurfaceKind: "pull-request",
}),
).toBe("ignore");
).toBe("open");
});

it("leaves an already open diff and its chosen scope alone", () => {
it("delegates a qualifying completion to the store while a diff is already open", () => {
const largeCheckpoint = {
status: "ready",
files: Array.from({ length: 3 }, (_, index) => ({
Expand All @@ -1813,9 +1808,8 @@ describe("proactive completed diff guard", () => {
resolveProactiveTurnDiffAction({
checkpoint: largeCheckpoint,
isGitRepo: true,
activeSurfaceKind: "diff",
}),
).toBe("ignore");
).toBe("open");
});
});

Expand Down
5 changes: 0 additions & 5 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,12 +181,7 @@ export function shouldOpenProactiveTurnDiff(input: {
export function resolveProactiveTurnDiffAction(input: {
checkpoint: Pick<TurnDiffSummary, "status" | "files"> | undefined;
isGitRepo: boolean | undefined;
activeSurfaceKind: RightPanelSurface["kind"] | null;
}): "defer" | "ignore" | "open" {
// 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
9 changes: 7 additions & 2 deletions apps/web/src/hooks/useProactivePanels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,6 @@ export function useProactivePanels(input: {
? resolveProactiveTurnDiffAction({
checkpoint: completedCheckpoint,
isGitRepo,
activeSurfaceKind: openSurface?.kind ?? null,
})
: "ignore";
proactivePanelObservationRef.current = {
Expand All @@ -179,7 +178,13 @@ export function useProactivePanels(input: {
diffAction === "defer" || shouldDeferLink ? previousRunningTurnId : activeRunningTurnId,
};
if (diffAction !== "open" || newlyCompletedTurnId === null) return;
if (!panels.openProactive(threadRef, { id: "diff", kind: "diff" }, userActionRevision)) {
if (
!panels.openProactive(
threadRef,
{ kind: "diff", turnId: newlyCompletedTurnId },
userActionRevision,
)
) {
return;
}
useDiffPanelStore.getState().selectGitScope(threadRef, "branch");
Expand Down
146 changes: 141 additions & 5 deletions apps/web/src/rightPanelStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { type EnvironmentId, ThreadId } from "@t3tools/contracts";
import { type EnvironmentId, RunId, ThreadId } from "@t3tools/contracts";
import { beforeEach, describe, expect, it } from "vite-plus/test";

import { useClosedViewStore } from "./closedViewStore";
Expand Down Expand Up @@ -29,6 +29,16 @@ beforeEach(() => {
});
});

async function reloadRightPanelStore() {
const { storage, name } = useRightPanelStore.persist.getOptions();
const persisted = await storage!.getItem(name!);
expect(persisted).not.toBeNull();
useRightPanelStore.setState(useRightPanelStore.getInitialState());
await storage!.setItem(name!, persisted!);
await useRightPanelStore.persist.rehydrate();
expect(useRightPanelStore.persist.hasHydrated()).toBe(true);
}

describe("rightPanelStore", () => {
it("records single and bulk tab closes, newest first", () => {
const store = useRightPanelStore.getState();
Expand Down Expand Up @@ -208,7 +218,7 @@ describe("rightPanelStore", () => {
},
);

const completedDiff = { id: "diff", kind: "diff" } as const;
const completedDiff = { kind: "diff", turnId: RunId.make("turn-1") } as const;
const linkedPullRequest = pullRequestSurface({
projectId: "project-a",
repository: "pingdotgg/t3code",
Expand Down Expand Up @@ -281,9 +291,15 @@ describe("rightPanelStore", () => {
expect(
store.openProactive(refA, { id: "pull-requests", kind: "pull-requests" }, revision),
).toBe(false);
expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toBe(
chosen,
const { isOpen, activeSurfaceId, surfaces } = selectThreadRightPanelState(
useRightPanelStore.getState().byThreadKey,
refA,
);
expect({ isOpen, activeSurfaceId, surfaces }).toEqual({
isOpen: chosen.isOpen,
activeSurfaceId: chosen.activeSurfaceId,
surfaces: chosen.surfaces,
});
});

it("allows automatic panels for a later turn after a manual choice", () => {
Expand All @@ -293,7 +309,127 @@ describe("rightPanelStore", () => {
expect(store.openProactive(refA, completedDiff, firstTurnRevision)).toBe(false);

const nextTurnRevision = store.getUserActionRevision(refA);
expect(store.openProactive(refA, completedDiff, nextTurnRevision)).toBe(true);
expect(
store.openProactive(
refA,
{ ...completedDiff, turnId: RunId.make("turn-2") },
nextTurnRevision,
),
).toBe(true);
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff");
});

it("offers each completed diff once across dismissal, panel changes, and reload", async () => {
const store = useRightPanelStore.getState();
expect(store.openProactive(refA, completedDiff, store.getUserActionRevision(refA))).toBe(true);
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff");

store.close(refA);
await reloadRightPanelStore();
expect(store.openProactive(refA, completedDiff, store.getUserActionRevision(refA))).toBe(false);
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();

store.open(refA, "files");
store.openFile(refA, "src/app.ts");
await reloadRightPanelStore();
expect(store.openProactive(refA, completedDiff, store.getUserActionRevision(refA))).toBe(false);
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("file");

store.closeAllSurfaces(refA);
await reloadRightPanelStore();
expect(store.openProactive(refA, completedDiff, store.getUserActionRevision(refA))).toBe(false);
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();

const otherEnvironment = scopeThreadRef("env-2" as EnvironmentId, refA.threadId);
for (const ref of [refB, otherEnvironment]) {
expect(store.openProactive(ref, completedDiff, store.getUserActionRevision(ref))).toBe(true);
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, ref)).toBe("diff");
}

const nextTurn = { ...completedDiff, turnId: RunId.make("turn-2") };
expect(store.openProactive(refA, nextTurn, store.getUserActionRevision(refA))).toBe(true);
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff");

const completedWhileOpen = { ...completedDiff, turnId: RunId.make("turn-3") };
const existingDiff = selectActiveRightPanelSurface(
useRightPanelStore.getState().byThreadKey,
refA,
);
expect(store.openProactive(refA, completedWhileOpen, store.getUserActionRevision(refA))).toBe(
false,
);
expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual(
existingDiff,
);
store.closeSurface(refA, "diff");
await reloadRightPanelStore();
expect(store.openProactive(refA, completedWhileOpen, store.getUserActionRevision(refA))).toBe(
false,
);
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();

// A later storage migration must preserve the dismissal, just like an ordinary reload.
const { storage, name, version } = useRightPanelStore.persist.getOptions();
const persisted = await storage!.getItem(name!);
await storage!.setItem(name!, { ...persisted!, version: version! - 1 });
await reloadRightPanelStore();
expect(store.openProactive(refA, completedWhileOpen, store.getUserActionRevision(refA))).toBe(
false,
);
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();

expect(
store.openProactive(
refA,
{ ...completedDiff, turnId: RunId.make("turn-4") },
store.getUserActionRevision(refA),
),
).toBe(true);
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff");
store.closeSurface(refA, "diff");
store.open(refA, "diff");
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff");
});

it("does not retry declined diff offers after manual choices or PR priority clear", async () => {
const store = useRightPanelStore.getState();
const revision = store.getUserActionRevision(refA);
store.openFile(refA, "src/app.ts");
expect(store.openProactive(refA, completedDiff, revision)).toBe(false);
await reloadRightPanelStore();
expect(store.openProactive(refA, completedDiff, store.getUserActionRevision(refA))).toBe(false);
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("file");

store.closeAllSurfaces(refA);
expect(store.openProactive(refA, completedDiff, store.getUserActionRevision(refA))).toBe(false);
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();

const nextTurn = { ...completedDiff, turnId: RunId.make("turn-2") };
store.openPullRequest(refA, linkedPullRequest);
expect(store.openProactive(refA, nextTurn, store.getUserActionRevision(refA))).toBe(false);
expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual(
linkedPullRequest,
);
expect(
selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
).toEqual([linkedPullRequest]);
await reloadRightPanelStore();
expect(store.openProactive(refA, nextTurn, store.getUserActionRevision(refA))).toBe(false);
expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual(
linkedPullRequest,
);
store.closeSurface(refA, linkedPullRequest.id);
await reloadRightPanelStore();
expect(store.openProactive(refA, nextTurn, store.getUserActionRevision(refA))).toBe(false);
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();

expect(
store.openProactive(
refA,
{ ...completedDiff, turnId: RunId.make("turn-3") },
store.getUserActionRevision(refA),
),
).toBe(true);
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff");
});

Expand Down
41 changes: 29 additions & 12 deletions apps/web/src/rightPanelStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
scopedThreadKey,
scopeThreadRef,
} from "@t3tools/client-runtime/environment";
import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
import { EnvironmentId, RunId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";

Expand Down Expand Up @@ -95,6 +95,7 @@ export interface ThreadRightPanelState {
surfaces: RightPanelSurface[];
dismissedDeviceSurfaceIds?: string[];
maximized?: true;
proactiveDiffTurnId?: RunId;
}

export interface ThreadPanelVisibility {
Expand All @@ -117,7 +118,9 @@ interface RightPanelStoreState {
*/
openProactive: (
ref: ScopedThreadRef,
surface: Extract<RightPanelSurface, { kind: "diff" | "pull-request" | "pull-requests" }>,
request:
| Extract<RightPanelSurface, { kind: "pull-request" | "pull-requests" }>
| { kind: "diff"; turnId: RunId },
expectedUserActionRevision: number,
) => boolean;
open: (
Expand Down Expand Up @@ -310,7 +313,8 @@ const updateThreadStateMap = (
!next.isOpen &&
next.activeSurfaceId === null &&
next.surfaces.length === 0 &&
!next.dismissedDeviceSurfaceIds?.length
!next.dismissedDeviceSurfaceIds?.length &&
next.proactiveDiffTurnId === undefined
) {
if (!(threadKey in byThreadKey)) return byThreadKey;
const { [threadKey]: _removed, ...rest } = byThreadKey;
Expand Down Expand Up @@ -545,6 +549,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): {
isOpen,
surfaces,
activeSurfaceId,
...(typeof validThreadState?.proactiveDiffTurnId === "string"
? { proactiveDiffTurnId: RunId.make(validThreadState.proactiveDiffTurnId) }
: {}),
...(Array.isArray(validThreadState?.dismissedDeviceSurfaceIds)
? {
dismissedDeviceSurfaceIds:
Expand Down Expand Up @@ -593,24 +600,34 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
closeRevisionByThreadKey: {},
getUserActionRevision: (ref) =>
get().userActionRevisionByThreadKey[scopedThreadKey(ref)] ?? 0,
openProactive: (ref, surface, expectedUserActionRevision) => {
openProactive: (ref, request, expectedUserActionRevision) => {
let opened = false;
set((state) => {
const threadKey = scopedThreadKey(ref);
if (
(state.userActionRevisionByThreadKey[threadKey] ?? 0) !== expectedUserActionRevision
) {
return state;
if (request.kind === "diff") {
const current = selectThreadRightPanelState(state.byThreadKey, ref);
if (current.proactiveDiffTurnId === request.turnId) return state;
const activePanel = selectActiveRightPanel(state.byThreadKey, ref);
const pullRequestActive =
activePanel === "pull-request" || activePanel === "pull-requests";
opened =
(state.userActionRevisionByThreadKey[threadKey] ?? 0) ===
expectedUserActionRevision &&
activePanel !== "diff" &&
!pullRequestActive;
// Record the offer even when refused, so a revisit does not retry this turn.
return automaticUpdate(state, threadKey, (thread) => ({
...(opened ? upsertSurface(thread, singletonSurface("diff")) : thread),
proactiveDiffTurnId: request.turnId,
}));
}
if (
surface.kind === "diff" &&
(selectActiveRightPanel(state.byThreadKey, ref) === "pull-request" ||
selectActiveRightPanel(state.byThreadKey, ref) === "pull-requests")
(state.userActionRevisionByThreadKey[threadKey] ?? 0) !== expectedUserActionRevision
) {
return state;
}
opened = true;
return automaticUpdate(state, threadKey, (current) => upsertSurface(current, surface));
return automaticUpdate(state, threadKey, (current) => upsertSurface(current, request));
});
return opened;
},
Expand Down
Loading