diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a94b9e9497c6b..37cc4b26be339 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -265,7 +265,11 @@ import { type SidebarProviderEntry, type SidebarSweepAction, } from "./sidebar/SidebarThreadRow"; -import { SidebarDraftBlock } from "./sidebar/SidebarDraftBlock"; +import { + draftTerminalLabel, + SidebarDraftBlock, + useDraftTerminalLabels, +} from "./sidebar/SidebarDraftBlock"; import { SidebarProjectFolderHeader, sidebarFolderRowId, @@ -1214,13 +1218,17 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve : { projectIds: scopedProjectRefs.map((projectRef) => projectRef.projectId) }, ); const routeDraftIdForRows = routeTarget?.kind === "draft" ? routeTarget.draftId : null; + const draftTerminalLabels = useDraftTerminalLabels(); const visibleDraftSessionCount = useComposerDraftStore((store) => { let count = 0; for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) { if (session.promotedTo != null) { continue; } - if (!composerDraftHasUserContent(store.draftsByThreadKey[draftKey])) { + if ( + draftTerminalLabel(session, draftTerminalLabels) === null && + !composerDraftHasUserContent(store.draftsByThreadKey[draftKey]) + ) { continue; } if ( diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx index 41cd795013e45..3a2c5eae4850a 100644 --- a/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx @@ -3,12 +3,46 @@ import { EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3tools/contract import { Profiler } from "react"; import { expect, it, vi } from "vite-plus/test"; +const terminalLabels = vi.hoisted(() => ({ byThreadId: new Map() })); + +vi.mock(import("~/state/terminalSessions"), async (original) => ({ + ...(await original()), + useTerminalLabelsByThreadKey: (environmentIds) => + new Map( + environmentIds.flatMap((environmentId) => + [...terminalLabels.byThreadId].map( + ([threadId, label]) => [`${environmentId}:${threadId}`, label] as const, + ), + ), + ), +})); + import { act, render } from "../../../test/render"; import { formatAssistantCitationForComposer } from "../../composer-logic"; import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; import { SidebarDraftBlock } from "./SidebarDraftBlock"; +function setup(labels: Record = {}) { + terminalLabels.byThreadId = new Map(Object.entries(labels)); +} + +function draftBlock(routeDraftId: string | null) { + return ( + + ); +} + it("keeps draft previews current through edits, promotion and removal", async () => { + setup(); const initial = useComposerDraftStore.getState(); using _store = { [Symbol.dispose]: () => useComposerDraftStore.setState(initial, true), @@ -25,16 +59,7 @@ it("keeps draft previews current through edits, promotion and removal", async () const onRender = vi.fn(); await using screen = await render( -
    - -
+ {draftBlock(null)}
, ); const row = screen.getByTestId("sidebar-draft-row"); @@ -82,3 +107,37 @@ it("keeps draft previews current through edits, promotion and removal", async () await act(() => initial.clearDraftThread(nextDraftId)); await expect.element(row).not.toBeInTheDocument(); }); + +it("lists empty drafts by terminal label while their terminal exists, active or not", async () => { + setup({ "terminal-thread": "dev" }); + const initial = useComposerDraftStore.getState(); + using _store = { + [Symbol.dispose]: () => useComposerDraftStore.setState(initial, true), + }; + useComposerDraftStore.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + }); + const environmentId = EnvironmentId.make("draft-terminal-environment"); + const terminalDraftId = DraftId.make("draft-with-terminal"); + initial.setProjectDraftThreadId( + scopeProjectRef(environmentId, ProjectId.make("terminal-project")), + terminalDraftId, + { threadId: ThreadId.make("terminal-thread") }, + ); + initial.setProjectDraftThreadId( + scopeProjectRef(environmentId, ProjectId.make("idle-project")), + DraftId.make("draft-without-terminal"), + { threadId: ThreadId.make("idle-thread") }, + ); + + await using screen = await render(draftBlock(terminalDraftId)); + const rows = screen.getByTestId("sidebar-draft-row"); + await expect.element(rows).toHaveAccessibleName("dev, Unsent draft"); + expect(rows.elements()).toHaveLength(1); + + await screen.rerender(draftBlock(null)); + await expect.element(rows).toHaveAccessibleName("dev, Unsent draft"); + expect(rows.elements()).toHaveLength(1); +}); diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx index c4868c4776ea7..0bc13111bbca3 100644 --- a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx @@ -9,8 +9,10 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; import { useShallow } from "zustand/react/shallow"; import { cn } from "~/lib/utils"; +import { useTerminalLabelsByThreadKey } from "~/state/terminalSessions"; import { resolveSidebarRowAccessibility } from "../Sidebar.logic"; import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -23,9 +25,41 @@ import { } from "../../composerDraftStore"; import { draftPenClassName, draftSurfaceClassName } from "./SidebarThreadRow"; +const EMPTY_COMPOSER_PARTS = { + prompt: "", + images: [], + persistedAttachments: [], + files: [], + terminalContexts: [], + previewAnnotations: [], + reviewComments: [], +} as const; + +/** Terminal labels for draft threads; an unsent draft stays listed while it has a terminal. */ +export function useDraftTerminalLabels(): ReadonlyMap { + const environmentIds = useComposerDraftStore((store) => { + const ids = new Set(); + for (const session of Object.values(store.draftThreadsByThreadKey)) { + if (session.promotedTo == null) ids.add(session.environmentId); + } + return [...ids].toSorted().join("\n"); + }); + return useTerminalLabelsByThreadKey( + environmentIds === "" ? [] : (environmentIds.split("\n") as EnvironmentId[]), + ); +} + +export function draftTerminalLabel( + session: DraftSessionState, + terminalLabels: ReadonlyMap, +): string | null { + return terminalLabels.get(`${session.environmentId}:${session.threadId}`) ?? null; +} + const SidebarDraftRow = memo(function SidebarDraftRow(props: { draftId: DraftId; - composer: ComposerThreadDraftState; + composer: ComposerThreadDraftState | null; + terminalLabel: string | null; project: ProjectFaviconProject | null; projectDisplayName: string | null; isActive: boolean; @@ -33,7 +67,8 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { onDiscard: (draftId: DraftId) => void; onContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; }) { - const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props; + const { draftId, onContextMenu, onDiscard, onNavigate } = props; + const composer = props.composer ?? EMPTY_COMPOSER_PARTS; const promptPreview = assistantCitationsToPlainText( replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label), @@ -51,7 +86,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { const preview = promptPreview.length > 0 ? promptPreview - : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; + : attachmentCount === 0 && props.terminalLabel !== null + ? props.terminalLabel + : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; const accessibility = resolveSidebarRowAccessibility({ title: preview, statusLabel: "Unsent draft", @@ -139,7 +176,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { interface SidebarDraftRowData { draftId: DraftId; session: DraftSessionState; - composer: ComposerThreadDraftState; + composer: ComposerThreadDraftState | null; } export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { @@ -160,6 +197,7 @@ export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { ), ), ); + const terminalLabels = useDraftTerminalLabels(); const [frozenActive, setFrozenActive] = useState<{ routeDraftId: string | null; row: SidebarDraftRowData | null; @@ -189,14 +227,18 @@ export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { ) { continue; } + const composer = draftsByThreadKey[draftKey] ?? null; + const hasTerminal = draftTerminalLabel(session, terminalLabels) !== null; if (draftKey === props.routeDraftId) { + // Composer edits stay frozen while active; terminals come and go live. if (frozenActive.routeDraftId === draftKey && frozenActive.row !== null) { rows.push(frozenActive.row); + } else if (hasTerminal) { + rows.push({ draftId: DraftId.make(draftKey), session, composer }); } continue; } - const composer = draftsByThreadKey[draftKey]; - if (!composer || !composerDraftHasUserContent(composer)) { + if (!hasTerminal && !composerDraftHasUserContent(composer)) { continue; } rows.push({ draftId: DraftId.make(draftKey), session, composer }); @@ -216,6 +258,7 @@ export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { key={draftId} draftId={draftId} composer={composer} + terminalLabel={draftTerminalLabel(session, terminalLabels)} project={props.projectByKey.get(projectKey) ?? null} projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null} isActive={draftId === props.routeDraftId} diff --git a/apps/web/src/lib/discardComposerDraft.test.ts b/apps/web/src/lib/discardComposerDraft.test.ts index 6c63482dfe52b..340ed6b2606e3 100644 --- a/apps/web/src/lib/discardComposerDraft.test.ts +++ b/apps/web/src/lib/discardComposerDraft.test.ts @@ -80,6 +80,23 @@ describe("discardComposerDraft", () => { } }); + it("discards and restores a draft session that has no composer record", async () => { + useComposerDraftStore.getState().setProjectDraftThreadId(projectRef, draftId); + const before = useComposerDraftStore.getState(); + expect(before.draftsByThreadKey).toEqual({}); + + discardComposerDraft(draftId); + expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull(); + + await undoNotice().undo(); + const after = useComposerDraftStore.getState(); + expect(after.getDraftSession(draftId)).toEqual(before.getDraftSession(draftId)); + expect(after.draftsByThreadKey).toEqual({}); + expect(after.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual( + before.logicalProjectDraftThreadKeyByLogicalProjectKey, + ); + }); + it("releases a discarded existing-thread preview when Undo is committed", async () => { using preview = imagePreview(); useComposerDraftStore.getState().addImage(threadRef, preview.image); diff --git a/apps/web/src/lib/discardComposerDraft.ts b/apps/web/src/lib/discardComposerDraft.ts index ecfc44978f132..ecb7decd7d285 100644 --- a/apps/web/src/lib/discardComposerDraft.ts +++ b/apps/web/src/lib/discardComposerDraft.ts @@ -15,8 +15,9 @@ export function discardComposerDraft(target: ComposerThreadTarget): void { const store = useComposerDraftStore.getState(); const key = resolveComposerDraftKey(store, target); const draft = key === null ? undefined : store.draftsByThreadKey[key]; - if (key === null || !draft) return; + if (key === null) return; const session = store.draftThreadsByThreadKey[key]; + if (!draft && !session) return; const logicalProjectKeys = Object.entries(store.logicalProjectDraftThreadKeyByLogicalProjectKey) .filter(([, draftKey]) => draftKey === key) .map(([logicalProjectKey]) => logicalProjectKey); @@ -30,6 +31,7 @@ export function discardComposerDraft(target: ComposerThreadTarget): void { } const releaseDiscardedDraft = () => { + if (!draft) return; releaseDraftAttachments([...draft.images, ...draft.files]); // Draft-session removal already revoked these previews. if (!discardsSession) { @@ -57,19 +59,21 @@ export function discardComposerDraft(target: ComposerThreadTarget): void { logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] ??= key; } return { - draftsByThreadKey: { - ...state.draftsByThreadKey, - [key]: discardsSession - ? { - ...draft, - images: draft.images.map((image) => - image.previewUrl.startsWith("blob:") - ? { ...image, previewUrl: URL.createObjectURL(image.file) } - : image, - ), - } - : draft, - }, + draftsByThreadKey: draft + ? { + ...state.draftsByThreadKey, + [key]: discardsSession + ? { + ...draft, + images: draft.images.map((image) => + image.previewUrl.startsWith("blob:") + ? { ...image, previewUrl: URL.createObjectURL(image.file) } + : image, + ), + } + : draft, + } + : state.draftsByThreadKey, draftThreadsByThreadKey: session ? { ...state.draftThreadsByThreadKey, [key]: session } : state.draftThreadsByThreadKey, diff --git a/apps/web/src/state/terminalSessions.ts b/apps/web/src/state/terminalSessions.ts index 56fa59b794daf..f200d5ce4d331 100644 --- a/apps/web/src/state/terminalSessions.ts +++ b/apps/web/src/state/terminalSessions.ts @@ -22,7 +22,13 @@ import { AsyncResult, Atom } from "effect/reactivity"; import { formatEnvironmentQueryError } from "./query"; import { terminalEnvironment } from "./terminal"; -import { environmentSession, sessionHasScope, useEnvironmentScope } from "./session"; +import { environmentAtomList, environmentIdsKey } from "./environmentAtomList"; +import { + environmentSession, + sessionHasScope, + useEnvironmentScope, + useEnvironmentsWithScope, +} from "./session"; const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze>([]); @@ -238,3 +244,43 @@ export function useThreadRunningTerminalIds(input: { const sessions = useKnownTerminalSessions(input); return selectRunningSubprocessTerminalIds(sessions ?? []); } + +// A string so status updates that keep every label notify no one. +const terminalLabelsFamily = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const metadata = get(terminalEnvironment.metadata({ environmentId, input: null })); + if (metadata._tag !== "Success") return "[]"; + return JSON.stringify( + Array.from(terminalMetadataIndex(metadata.value).byThreadId, ([threadId, summaries]) => [ + threadId, + summaries[0]!.label, + ]), + ); + }).pipe(Atom.withLabel(`web-terminal-labels:${environmentId}`)), +); +const terminalLabelsListAtom = environmentAtomList("web-terminal-labels", terminalLabelsFamily); +const terminalLabelsByResults = new WeakMap, ReadonlyMap>(); + +/** First terminal label of each thread that has one, keyed `${environmentId}:${threadId}`. */ +export function useTerminalLabelsByThreadKey( + environmentIds: ReadonlyArray, +): ReadonlyMap { + const readable = useEnvironmentsWithScope( + environmentIds.map((environmentId) => ({ environmentId })), + AuthTerminalReadScope, + ); + const readableIds = environmentIds.filter((environmentId) => readable.has(environmentId)); + const results = useAtomValue(terminalLabelsListAtom(environmentIdsKey(readableIds))); + let labels = terminalLabelsByResults.get(results); + if (!labels) { + const next = new Map(); + results.forEach((encoded, index) => { + for (const [threadId, label] of JSON.parse(encoded) as Array<[string, string]>) { + next.set(`${readableIds[index]}:${threadId}`, label); + } + }); + labels = next; + terminalLabelsByResults.set(results, labels); + } + return labels; +}