Skip to content
Closed
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
70 changes: 70 additions & 0 deletions apps/web/src/composerDraftStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
markPromotedDraftThreadByRef,
markPromotedDraftThreads,
markPromotedDraftThreadsByRef,
retireProjectDraftMappingForThread,
type ComposerImageAttachment,
useComposerDraftStore,
DraftId,
Expand Down Expand Up @@ -823,6 +824,75 @@ describe("composerDraftStore project draft thread mapping", () => {
});
});

it("retires a project draft mapping without wiping aliased composer content", () => {
const store = useComposerDraftStore.getState();
const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
const originalRevokeObjectUrl = URL.revokeObjectURL;
const revokeSpy = vi.fn<(url: string) => void>();
URL.revokeObjectURL = revokeSpy;

try {
// Composer lives under the draft id and is only reachable from the
// server thread via alias. Archive must not delete that entry.
store.setProjectDraftThreadId(projectRef, draftId, { threadId });
store.setPrompt(draftId, "keep the archived thread composer");
store.addImage(
draftId,
makeImage({ id: "img-archive-keep", previewUrl: "blob:archive-keep" }),
);
store.setProjectDraftThreadId(otherProjectRef, otherDraftId, { threadId: otherThreadId });

retireProjectDraftMappingForThread(threadRef);

const next = useComposerDraftStore.getState();
expect(next.getDraftSessionByLogicalProjectKey(scopedProjectKey(projectRef))).toBeNull();
expect(next.getComposerDraft(draftId)?.prompt).toBe("keep the archived thread composer");
expect(next.getComposerDraft(draftId)?.images).toHaveLength(1);
expect(next.getDraftThread(draftId)?.threadId).not.toBe(threadId);
expect(next.getDraftThread(draftId)?.threadId).toEqual(expect.any(String));
expect(next.getDraftThread(threadRef)).toBeNull();
expect(next.getDraftThreadByProjectRef(otherProjectRef)?.threadId).toBe(otherThreadId);
expect(revokeSpy).not.toHaveBeenCalled();
} finally {
URL.revokeObjectURL = originalRevokeObjectUrl;
}
});

it("does not reuse a deleted or promoted thread id as the project's next draft", () => {
const store = useComposerDraftStore.getState();
const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
store.setProjectDraftThreadId(projectRef, draftId, { threadId });
store.setPrompt(draftId, "already sent");

markPromotedDraftThreadByRef(threadRef);
expect(
useComposerDraftStore
.getState()
.getDraftSessionByLogicalProjectKey(scopedProjectKey(projectRef)),
).toBeNull();

retireProjectDraftMappingForThread(threadRef);

const next = useComposerDraftStore.getState();
expect(next.getDraftSessionByLogicalProjectKey(scopedProjectKey(projectRef))).toBeNull();
expect(
next.logicalProjectDraftThreadKeyByLogicalProjectKey[scopedProjectKey(projectRef)],
).toBeUndefined();
expect(next.getDraftThread(draftId)?.promotedTo).toEqual(threadRef);
expect(next.getComposerDraft(threadRef)?.prompt).toBe("already sent");
});

it("leaves a mapping alone when it points at a different thread", () => {
const store = useComposerDraftStore.getState();
store.setProjectDraftThreadId(projectRef, draftId, { threadId });

retireProjectDraftMappingForThread(scopeThreadRef(TEST_ENVIRONMENT_ID, otherThreadId));

expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
threadId,
);
});

it("clears only matching project draft mapping entries", () => {
const store = useComposerDraftStore.getState();
store.setProjectDraftThreadId(projectRef, draftId, { threadId });
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/composerDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,8 @@ interface ComposerDraftStoreState {
projectRef: ScopedProjectRef,
threadRef: ComposerThreadTarget,
) => void;
/** Drops the project new-thread mapping for this server thread; composer state stays. */
retireProjectDraftMappingForThread: (threadRef: ScopedThreadRef) => void;
/** Marks a draft session as being promoted to a real server thread. */
markDraftThreadPromoting: (threadRef: ComposerThreadTarget, promotedTo?: ScopedThreadRef) => void;
/** Removes draft-session metadata after promotion is complete. */
Expand Down Expand Up @@ -2541,6 +2543,58 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
return removeDraftThreadReferences(state, threadKey);
});
},
retireProjectDraftMappingForThread: (threadRef) => {
set((state) => {
// Archive/delete only need the project to stop treating this
// server thread as the next `+` target. Composer content and
// draft-session metadata stay so an unsent prompt is not wiped.
const matchingDraftIds = new Set(
Object.entries(state.draftThreadsByThreadKey)
.filter(
([, draftThread]) =>
draftThread.environmentId === threadRef.environmentId &&
draftThread.threadId === threadRef.threadId,
)
.map(([draftId]) => draftId),
);
if (matchingDraftIds.size === 0) {
return state;
}
const nextLogicalMappings = Object.fromEntries(
Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter(
([, draftId]) => !matchingDraftIds.has(draftId),
),
) as Record<string, string>;
// Unpromoted leftovers keep their composer, but the archived
// thread id is burned — remint so sidebar send cannot recreate it.
let nextDraftThreads: Record<string, DraftThreadState> | undefined;
for (const draftId of matchingDraftIds) {
const existing = state.draftThreadsByThreadKey[draftId];
if (!existing || isDraftThreadPromoting(existing)) {
continue;
}
nextDraftThreads ??= { ...state.draftThreadsByThreadKey };
nextDraftThreads[draftId] = {
...existing,
threadId: ThreadId.make(globalThis.crypto.randomUUID()),
};
}
const mappingUnchanged =
Object.keys(nextLogicalMappings).length ===
Object.keys(state.logicalProjectDraftThreadKeyByLogicalProjectKey).length;
if (mappingUnchanged && nextDraftThreads === undefined) {
return state;
}
return {
...(mappingUnchanged
? {}
: { logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings }),
...(nextDraftThreads === undefined
? {}
: { draftThreadsByThreadKey: nextDraftThreads }),
};
Comment thread
cursor[bot] marked this conversation as resolved.
});
},
markDraftThreadPromoting: (threadRef, promotedTo) => {
const threadKey = resolveComposerDraftKey(get(), threadRef);
if (!threadKey) {
Expand Down Expand Up @@ -3694,6 +3748,14 @@ export function useEffectiveComposerModelState(input: {
);
}

/**
* Drop the project new-thread mapping for this server thread without touching
* composer content. Used after archive or delete so the next `+` mints a new id.
*/
export function retireProjectDraftMappingForThread(threadRef: ScopedThreadRef): void {
useComposerDraftStore.getState().retireProjectDraftMappingForThread(threadRef);
}

/**
* Mark a draft thread as promoting once the server has materialized the same thread id.
*
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/hooks/useHandleNewThread.logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { ScopedThreadRef } from "@t3tools/contracts";

export type StoredDraftReuseDecision = "reuse" | "mint";

/**
* Decide whether a project's stored new-thread draft can keep its thread id.
*
* An id that already exists in any lifecycle (live, archived, deleted) is
* burned. Promoted/sent drafts are never reused, even when both snapshots miss
* them. An unloaded archive snapshot is unknown, not absent.
*/
export function decideStoredDraftReuse(input: {
readonly storedDraftThreadRef: ScopedThreadRef | null;
readonly liveShellExists: boolean;
readonly archivedShellExists: boolean | null;
readonly deletedShellExists?: boolean | null;
readonly promoted: boolean;
}): StoredDraftReuseDecision {
if (input.storedDraftThreadRef === null) {
return "mint";
}
if (input.liveShellExists) {
return "mint";
}
if (input.archivedShellExists === true) {
return "mint";
}
if (input.deletedShellExists === true) {
return "mint";
}
if (input.promoted) {
return "mint";
}
return "reuse";
}
104 changes: 104 additions & 0 deletions apps/web/src/hooks/useHandleNewThread.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { describe, expect, it } from "vite-plus/test";

import { decideStoredDraftReuse } from "./useHandleNewThread.logic";

const storedDraftThreadRef = scopeThreadRef(
EnvironmentId.make("environment-1"),
ThreadId.make("thread-1"),
);

describe("decideStoredDraftReuse", () => {
it("mints a new draft when the stored id is still a live shell", () => {
expect(
decideStoredDraftReuse({
storedDraftThreadRef,
liveShellExists: true,
archivedShellExists: false,
promoted: false,
}),
).toBe("mint");
});

it("mints a new draft when the stored id is only in archived shells", () => {
expect(
decideStoredDraftReuse({
storedDraftThreadRef,
liveShellExists: false,
archivedShellExists: true,
promoted: false,
}),
).toBe("mint");
});

it("reuses an empty draft when the stored id is unknown and not promoted", () => {
expect(
decideStoredDraftReuse({
storedDraftThreadRef,
liveShellExists: false,
archivedShellExists: false,
deletedShellExists: false,
promoted: false,
}),
).toBe("reuse");
});

it("mints a new draft when a promoted id is missing from both snapshots", () => {
expect(
decideStoredDraftReuse({
storedDraftThreadRef,
liveShellExists: false,
archivedShellExists: null,
deletedShellExists: null,
promoted: true,
}),
).toBe("mint");
});

it("mints a new draft when a previously promoted id later disappears after delete", () => {
expect(
decideStoredDraftReuse({
storedDraftThreadRef,
liveShellExists: false,
archivedShellExists: false,
deletedShellExists: false,
promoted: true,
}),
).toBe("mint");
});

it("mints a new draft when the stored id is visible as deleted", () => {
expect(
decideStoredDraftReuse({
storedDraftThreadRef,
liveShellExists: false,
archivedShellExists: false,
deletedShellExists: true,
promoted: false,
}),
).toBe("mint");
});

it("reuses an unpromoted draft when the archived snapshot is not loaded", () => {
expect(
decideStoredDraftReuse({
storedDraftThreadRef,
liveShellExists: false,
archivedShellExists: null,
promoted: false,
}),
).toBe("reuse");
});

it("mints a new draft when there is no stored draft to reuse", () => {
expect(
decideStoredDraftReuse({
storedDraftThreadRef: null,
liveShellExists: false,
archivedShellExists: null,
promoted: false,
}),
).toBe("mint");
});
});
34 changes: 28 additions & 6 deletions apps/web/src/hooks/useHandleNewThread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import {
scopeProjectRef,
scopeThreadRef,
} from "@t3tools/client-runtime/environment";
import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts";
import {
DEFAULT_RUNTIME_MODE,
type ScopedProjectRef,
type ScopedThreadRef,
type ThreadId,
} from "@t3tools/contracts";
import { useParams, useRouter } from "@tanstack/react-router";
import { useCallback, useMemo } from "react";
import {
Expand All @@ -15,6 +20,7 @@ import {
type DraftThreadState,
useComposerDraftStore,
} from "../composerDraftStore";
import { readArchivedThreadExists } from "../lib/archivedThreadsState";
import { newDraftId, newThreadId } from "../lib/utils";
import { orderItemsByPreferredIds } from "../components/Sidebar.logic";
import {
Expand All @@ -29,6 +35,7 @@ import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefau
import { primaryServerSettingsAtom } from "../state/server";
import { resolveThreadRouteTarget } from "../threadRoutes";
import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
import { decideStoredDraftReuse, type StoredDraftReuseDecision } from "./useHandleNewThread.logic";
import { useClientSettings } from "./useSettings";

interface NewThreadWorkspaceOptions {
Expand All @@ -50,6 +57,19 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef
};
}

function resolveStoredDraftReuse(threadRef: ScopedThreadRef | null): StoredDraftReuseDecision {
if (threadRef === null) {
return "mint";
}
const draftSession = useComposerDraftStore.getState().getDraftSessionByRef(threadRef);
return decideStoredDraftReuse({
storedDraftThreadRef: threadRef,
liveShellExists: readThreadShell(threadRef) !== null,
archivedShellExists: readArchivedThreadExists(threadRef),
promoted: draftSession?.promotedTo != null,
});
Comment thread
cursor[bot] marked this conversation as resolved.
}

export function useNewThreadHandler() {
const projects = useProjects();
// New-thread defaults are a user preference, and the settings UI only ever
Expand Down Expand Up @@ -189,9 +209,7 @@ export function useNewThreadHandler() {
? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId)
: null;
const reusableStoredDraftThread =
storedDraftThreadRef && readThreadShell(storedDraftThreadRef) !== null
? null
: storedDraftThread;
resolveStoredDraftReuse(storedDraftThreadRef) === "reuse" ? storedDraftThread : null;
if (storedDraftThreadRef && reusableStoredDraftThread === null) {
markPromotedDraftThreadByRef(storedDraftThreadRef);
}
Expand Down Expand Up @@ -247,7 +265,8 @@ export function useNewThreadHandler() {
routeTargetNow?.kind === "draft" &&
routeTargetNow.draftId === emptyStoredDraftThread.draftId;
const promotedMeanwhile =
storedDraftThreadRef !== null && readThreadShell(storedDraftThreadRef) !== null;
storedDraftThreadRef !== null &&
resolveStoredDraftReuse(storedDraftThreadRef) === "mint";
const remappedMeanwhile =
getDraftSessionByLogicalProjectKey(logicalProjectKey)?.draftId !==
emptyStoredDraftThread.draftId;
Expand Down Expand Up @@ -361,14 +380,17 @@ export function useNewThreadHandler() {
// too would evict that draft while its navigation is in flight —
// reuse the winner instead, like the synchronous path above does.
const racedDraft = getDraftSessionByLogicalProjectKey(logicalProjectKey);
const racedDraftThreadRef = racedDraft
? scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)
: null;
if (
racedDraft &&
// Only a draft REGISTERED during the await counts as a raced
// winner. An invested draft this invocation deliberately declined
// to reuse is still mapped at this point — reusing it here would
// silently undo mint-fresh semantics.
racedDraft.draftId !== storedDraftThread?.draftId &&
readThreadShell(scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)) === null
resolveStoredDraftReuse(racedDraftThreadRef) === "reuse"
) {
// Same remap the reuse paths above perform: point the draft at the
// caller's project member and apply explicit workspace options if
Expand Down
Loading
Loading