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
92 changes: 87 additions & 5 deletions apps/web/src/appInit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,20 @@ import { applyAppearanceContrast } from "./appearanceContrast";
import { applyAppearanceFontVariables } from "./appearanceFonts";
import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "./branding";
import { resolveServerBackedAppDisplayName } from "./branding.logic";
import { subscribeComposerDraftModelReconciliation } from "./composerDraftStore";
import {
subscribeComposerDraftModelReconciliation,
subscribeDraftThreadPromotion,
useComposerDraftStore,
} from "./composerDraftStore";
import { startDraftAttachmentUploads } from "./attachmentUploads";
import { retainThreadOverlaysForThread } from "./components/chat/threadOverlayRetention";
import { focusTerminalOnThreadEnter } from "./lib/terminalFocus";
import { startBrowserProfileReporting } from "./browser/BrowserProfileReporter";
import { startBackgroundThreadAnnouncements } from "./components/BackgroundThreadAnnouncer";
import { startElectronBrowserHost } from "./browser/ElectronBrowserHost";
import { startIndexDraftStart } from "./components/indexDraftStart";
import { startNewThreadLinks } from "./components/promptLinks/openNewThreadLink";
import { startThreadLinkPrompts } from "./components/promptLinks/threadLinkPrompts";
import { startHostFreezeToasts } from "./components/HostFreezeToastCoordinator";
import { KeybindingsConfigWarning } from "./components/KeybindingsConfigWarning";
import { createKeybindingsUpdateToastController } from "./components/KeybindingsUpdateToast.logic";
Expand All @@ -23,10 +32,13 @@ import { startGuideRunNotifications } from "./components/pullRequest/guide/PullR
import { installReopenClosedViewCommands } from "./components/ReopenClosedViewShortcut";
import { startSlowRpcRequestToasts } from "./components/SlowRpcRequestToastCoordinator";
import { startThreadNotifications } from "./components/ThreadNotificationCoordinator";
import { startMainAppLocationTracking } from "./components/sidebar/mainAppLocation";
import { startDesktopUpdateLaunchNotice } from "./components/sidebar/updateEverythingRun";
import { startDesktopAppActivation } from "./components/desktop/DesktopAppActivationCoordinator";
import { startDesktopWebLinks } from "./components/desktop/DesktopWebLinkCoordinator";
import { startSnapShotCoordinator } from "./components/desktop/SnapShotCoordinator";
import { startFirstRunGate, whileFirstRunAppOpen } from "./components/onboarding/firstRunGateStore";
import { startAgentOpenedPreviews } from "./components/preview/agentOpenedPreviews";
import { startPreviewTitleRecording } from "./browserHistoryStore";
import { stackedThreadToast, toastManager } from "./components/ui/toast";
import { isElectron } from "./env";
Expand All @@ -38,7 +50,8 @@ import {
subscribeClientSettings,
} from "./hooks/useSettings";
import { syncBrowserChromeTheme } from "./hooks/useTheme";
import { installDesktopPasteAsText } from "./lib/desktopPasteAsText";
import { startThreadVisitedMigration } from "./hooks/useThreadVisitedMigration";
import "./lib/desktopPasteAsText";
import {
syncDocumentElectronPlatformClasses,
syncDocumentWindowControlsOverlayClass,
Expand All @@ -51,16 +64,24 @@ import {
import { startProviderAuthDelivery } from "./providerAuthDelivery";
import { getRouter, type AppRouter } from "./router";
import { appAtomRegistry } from "./rpc/atomRegistry";
import { installChatRouteCommands } from "./shortcuts/chatRouteCommands";
import { installCommandPaletteCommands } from "./shortcuts/commandPaletteCommands";
import { retainPendingFileDropsForThread } from "./sidebarPendingFileDropStore";
import { retainThreadFindForThread } from "./components/chat/threadFindStore";
import { primaryEnvironmentIdAtom } from "./state/primaryEnvironment";
import { readProject } from "./state/entities";
import { environmentProjects } from "./state/projects";
import {
primaryServerConfigAtom,
primaryServerConfigEventAtom,
primaryServerWelcomeAtom,
} from "./state/server";
import { environmentShell, liveShellEnvironmentIdsAtom } from "./state/shell";
import { environmentThreadShells } from "./state/threads";
import { resolveThreadRouteTarget } from "./threadRoutes";
import { useUiStateStore } from "./uiStateStore";
import { useRightPanelStore } from "./rightPanelStore";
import { Atom } from "effect/reactivity";

/** Writes appearance settings onto the document, touching only the groups that changed. */
function startClientAppearance(): void {
Expand Down Expand Up @@ -221,13 +242,72 @@ function startComposerDraftServices(): void {
threadRefsAtom: environmentThreadShells.threadRefsAtom,
shellStateAtom: environmentShell.stateAtom,
});
subscribeDraftThreadPromotion({
registry: appAtomRegistry,
threadShellAtom: environmentThreadShells.quietThreadShellAtom,
threadRefsAtom: environmentThreadShells.threadRefsAtom,
});
startDraftAttachmentUploads();
}

/** A thread's workspace file tabs close once its project is gone from a loaded snapshot. */
function startFileSurfaceReconciliation(): void {
const projectsChangedAtom = Atom.make((get) => [
get(environmentProjects.projectsAtom),
get(liveShellEnvironmentIdsAtom),
]);
appAtomRegistry.subscribe(
projectsChangedAtom,
() =>
useRightPanelStore.getState().reconcileAllFileSurfaces((ref) => {
if (!appAtomRegistry.get(environmentShell.hasSnapshotAtom(ref.environmentId))) return null;
const projectId =
appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref))?.projectId ??
useComposerDraftStore.getState().getDraftThread(ref)?.projectId;
if (projectId === undefined) return null;
return readProject(scopeProjectRef(ref.environmentId, projectId)) !== null;
}),
{ immediate: true },
);
}

function startRouteServices(router: AppRouter): void {
installChatRouteCommands(router);
installCommandPaletteCommands(router);
installReopenClosedViewCommands(router);
startMainAppLocationTracking(router);
startIndexDraftStart(router);
startThreadLinkPrompts(router);
startNewThreadLinks(router);

let enteredThreadKey: string | null = null;
router.subscribe("onResolved", () => {
// Draft-promotion redirects and sidebar file drops only serve the route they lead to.
const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {});
useComposerDraftStore.getState().retainPromotedDraftRedirectsForRoute(target);
retainPendingFileDropsForThread(target?.kind === "server" ? target.threadRef : null);
retainThreadFindForThread(target?.kind === "server" ? target.threadRef : null);
// Chat overlays belong to the thread they were opened in.
const draftSession =
target?.kind === "draft"
? useComposerDraftStore.getState().getDraftSession(target.draftId)
: null;
const threadRef =
target?.kind === "server"
? target.threadRef
: draftSession
? scopeThreadRef(draftSession.environmentId, draftSession.threadId)
: null;
const threadKey = threadRef ? scopedThreadKey(threadRef) : null;
retainThreadOverlaysForThread(threadKey);
// Arriving at a thread with its drawer open focuses the drawer terminal;
// otherwise ChatView focuses the composer.
if (threadKey !== enteredThreadKey) {
enteredThreadKey = threadKey;
if (threadRef) focusTerminalOnThreadEnter(threadRef);
}
});

// Each route paints its own surface, which the browser chrome samples once it is on screen.
// onRendered only fires for a new href; onResolved also covers `router.invalidate()`, which
// can swap the root between the bare outlet and the shell at the same address.
Expand All @@ -236,8 +316,6 @@ function startRouteServices(router: AppRouter): void {
router.subscribe("onResolved", resyncBrowserChrome);
}

// Compat shim until the web-chat-view layer: services the base chat view and
// sidebar still run themselves are not started here yet.
/**
* Starts every app-lifetime service once, before the first render, and returns the router.
* They run for the life of the page, so their disposers (kept for tests) are dropped here.
Expand All @@ -254,14 +332,15 @@ export function startAppServices(): AppRouter {
syncDocumentWindowControlsOverlayClass();
}

installDesktopPasteAsText(window.desktopBridge, window);
startClientAppearance();
startDocumentTitle();
startEnvironmentThemeRepaint();
startDefaultThemeAdoption();
startModelPreferencesMigration();

startComposerDraftServices();
startFileSurfaceReconciliation();
startAgentOpenedPreviews();
startPreviewTitleRecording();
startBrowserProfileReporting();
if (isElectron) startElectronBrowserHost();
Expand All @@ -271,6 +350,7 @@ export function startAppServices(): AppRouter {
if (isElectron) {
startDesktopAppActivation(router);
startDesktopWebLinks(router);
whileFirstRunAppOpen(() => startSnapShotCoordinator(router));
}

startKeybindingsUpdateToasts();
Expand All @@ -280,6 +360,8 @@ export function startAppServices(): AppRouter {
startPermissionUpdateNotices(router);
startProjectCloneToasts(router);
startThreadNotifications(appAtomRegistry, router);
startBackgroundThreadAnnouncements(appAtomRegistry, router);
startThreadVisitedMigration(appAtomRegistry);
startGuideRunNotifications(appAtomRegistry, router);
if (isElectron) startDesktopUpdateLaunchNotice();

Expand Down
202 changes: 202 additions & 0 deletions apps/web/src/attachmentUploads.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";

import {
type AttachmentUploadPolicy,
planAttachmentUploads,
planDraftUploadsForPolicies,
} from "./attachmentUploads";
import {
DraftId,
QUEUED_EDIT_DRAFT_PREFIX,
type ComposerFileAttachment,
type ComposerImageAttachment,
type ComposerThreadDraftState,
} from "./composerDraftStore";
import { questionAttachmentDraftId } from "./questionAttachments";

vi.mock("./lib/attachmentUploadQueue", () => ({
releaseAttachmentUpload: vi.fn(),
releaseDraftAttachments: vi.fn(),
startAttachmentUpload: vi.fn(),
}));
vi.mock("./state/server", async () => {
const { Atom } = await import("effect/reactivity");
return { environmentServerConfigsAtom: Atom.make(new Map()) };
});
vi.mock("./state/session", async () => {
const { Atom } = await import("effect/reactivity");
return { environmentSession: { sessionStateAtom: () => Atom.make(null) } };
});

const environmentId = EnvironmentId.make("environment-1");
const otherEnvironmentId = EnvironmentId.make("environment-2");
const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread-1"));
const threadKey = scopedThreadKey(threadRef);
const policy: AttachmentUploadPolicy = { supported: true, maxFileBytes: 10, canOperate: true };

function image(id: string): ComposerImageAttachment {
return {
type: "image",
id,
name: `${id}.png`,
mimeType: "image/png",
sizeBytes: 4,
previewUrl: `blob:${id}`,
file: new File(["png"], `${id}.png`, { type: "image/png" }),
};
}

function file(
id: string,
sizeBytes = 4,
extra: Partial<ComposerFileAttachment> = {},
): ComposerFileAttachment {
return {
type: "file",
id,
name: `${id}.pdf`,
mimeType: "application/pdf",
sizeBytes,
file: new File(["pdf"], `${id}.pdf`, { type: "application/pdf" }),
...extra,
} as ComposerFileAttachment;
}

function draft(
attachments: ReadonlyArray<ComposerImageAttachment | ComposerFileAttachment>,
): ComposerThreadDraftState {
return {
images: attachments.filter((attachment) => attachment.type === "image"),
files: attachments.filter((attachment) => attachment.type === "file"),
} as ComposerThreadDraftState;
}

const ids = (attachments: ReadonlyArray<{ readonly id: string }>) =>
attachments.map((attachment) => attachment.id);

describe("planAttachmentUploads", () => {
it("starts images and files that fit, releases oversized files", () => {
const plan = planAttachmentUploads(policy, [image("a"), file("small"), file("big", 11)]);
expect(ids(plan.start)).toEqual(["a", "small"]);
expect(ids(plan.release)).toEqual(["big"]);
});

it("releases only unpersisted uploads when the capability is gone", () => {
const persisted = file("persisted", 4, {
uploadEnvironmentId: environmentId,
uploadedAttachmentId: "server-copy",
});
const plan = planAttachmentUploads({ ...policy, supported: false }, [image("a"), persisted]);
expect(ids(plan.release)).toEqual(["a"]);
expect(plan.start).toEqual([]);
});

it("starts nothing without operate access, and no files without a size limit", () => {
expect(
planAttachmentUploads({ ...policy, canOperate: false }, [image("a"), file("f")]),
).toEqual({ release: [], start: [] });
const unlimited = planAttachmentUploads({ ...policy, maxFileBytes: null }, [
image("a"),
file("f"),
]);
expect(ids(unlimited.start)).toEqual(["a"]);
expect(ids(unlimited.release)).toEqual(["f"]);
});
});

describe("planDraftUploadsForPolicies", () => {
const policies = new Map([
[environmentId, policy],
[otherEnvironmentId, policy],
]);

it("re-plans every attachment of drafts in a changed environment only", () => {
const otherKey = scopedThreadKey(scopeThreadRef(otherEnvironmentId, ThreadId.make("t2")));
const state = {
draftsByThreadKey: {
[threadKey]: draft([image("a"), file("f")]),
[otherKey]: draft([image("b")]),
},
draftThreadsByThreadKey: {},
};
const work = planDraftUploadsForPolicies(new Map(), state, policies, new Set([environmentId]));
expect(work.map((entry) => [entry.draftTarget, ids(entry.start)])).toEqual([
[threadRef, ["a", "f"]],
]);
});

it("uses the environment the add named for a queued-edit draft", () => {
const key = `${QUEUED_EDIT_DRAFT_PREFIX}${threadKey}:run-1`;
const draftTarget = DraftId.make(key);
const state = {
draftsByThreadKey: { [key]: draft([image("a")]) },
draftThreadsByThreadKey: {},
};
expect(planDraftUploadsForPolicies(new Map(), state, policies, null)).toEqual([]);
const targets = new Map([[key, { environmentId, draftTarget }]]);
const work = planDraftUploadsForPolicies(targets, state, policies, new Set([environmentId]));
expect(work.map((entry) => [entry.environmentId, entry.draftTarget, ids(entry.start)])).toEqual(
[[environmentId, draftTarget, ["a"]]],
);
});

it("restores a draft session's environment and releases on capability loss", () => {
const key = "draft-1";
const state = {
draftsByThreadKey: { [key]: draft([image("a")]) },
draftThreadsByThreadKey: { [key]: { environmentId: otherEnvironmentId } },
};
const lost = planDraftUploadsForPolicies(
new Map(),
state,
new Map([[otherEnvironmentId, { ...policy, supported: false }]]),
null,
);
expect(lost.map((work) => [work.environmentId, ids(work.release)])).toEqual([
[otherEnvironmentId, ["a"]],
]);
});

it("plans a draft session in its current environment, not the one its adds named", () => {
const key = "draft-moved";
const draftTarget = DraftId.make(key);
const state = {
draftsByThreadKey: { [key]: draft([image("a")]) },
draftThreadsByThreadKey: { [key]: { environmentId: otherEnvironmentId } },
};
const targets = new Map([[key, { environmentId, draftTarget }]]);
expect(planDraftUploadsForPolicies(targets, state, policies, new Set([environmentId]))).toEqual(
[],
);
const work = planDraftUploadsForPolicies(targets, state, policies, null);
expect(work.map((entry) => [entry.environmentId, entry.draftTarget])).toEqual([
[otherEnvironmentId, draftTarget],
]);
});

it("uploads question drafts to the environment encoded in their key", () => {
const key = questionAttachmentDraftId(environmentId, threadRef.threadId, "request", "q1");
const work = planDraftUploadsForPolicies(
new Map(),
{ draftsByThreadKey: { [key]: draft([image("a")]) }, draftThreadsByThreadKey: {} },
policies,
null,
);
expect(work.map((entry) => [entry.environmentId, entry.draftTarget])).toEqual([
[environmentId, key],
]);
});

it("forgets targets of drafts that no longer exist", () => {
const targets = new Map([["gone", { environmentId, draftTarget: DraftId.make("gone") }]]);
planDraftUploadsForPolicies(
targets,
{ draftsByThreadKey: {}, draftThreadsByThreadKey: {} },
policies,
null,
);
expect(targets.size).toBe(0);
});
});
Loading
Loading