- {displayPath}
+ {/* The full path: the chip already shows the shortened form, and a link
+ to the workspace root collapses to a bare label that repeats it. */}
+
+ {targetPath}
@@ -1467,9 +1525,16 @@ function ChatMarkdown({
const openPreview = useAtomCommand(previewEnvironment.open, {
reportFailure: false,
});
+ const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
+ reportFailure: false,
+ });
const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null);
const environmentId = useActiveEnvironmentId();
const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
+ const threadServerConfig = useAtomValue(
+ serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId),
+ );
+ const projects = useProjects();
const openInPreferredEditor = useOpenInPreferredEditor(
environmentId,
serverConfig?.availableEditors ?? [],
@@ -1523,6 +1588,54 @@ function ChatMarkdown({
event.clipboardData.setData("text/html", payload.html);
}, []);
const openChangeRequestLink = useOpenChangeRequestLink(threadRef);
+ const resolveThreadPullRequest = useCallback(
+ (href: string): ThreadLinkedPullRequest | null => {
+ if (
+ threadRef === undefined ||
+ readThreadShell(threadRef) === null ||
+ threadServerConfig?.environment.capabilities.threadPullRequestLinking !== true
+ ) {
+ return null;
+ }
+ const parsed = parseChangeRequestUrl(href);
+ if (parsed === null) return null;
+ const project = findProjectForChangeRequest(
+ projects.filter((candidate) => candidate.environmentId === threadRef.environmentId),
+ parsed,
+ );
+ if (project === undefined) return null;
+ return {
+ projectId: project.id,
+ repository: project.repositoryIdentity?.displayName ?? parsed.repository,
+ number: parsed.number,
+ url: href,
+ };
+ },
+ [projects, threadRef, threadServerConfig],
+ );
+ const updateThreadPullRequestLink = useCallback(
+ async (href: string, linked: boolean) => {
+ if (threadRef === undefined) return;
+ const linkedPullRequest = linked ? resolveThreadPullRequest(href) : null;
+ if (linked && linkedPullRequest === null) {
+ throw new Error("The pull request is not available in this environment.");
+ }
+ if (!linked) {
+ const currentPullRequest = readThreadShell(threadRef)?.linkedPullRequest;
+ if (currentPullRequest == null || !matchesLinkedPullRequestUrl(currentPullRequest, href)) {
+ return;
+ }
+ }
+ const result = await updateThreadMetadata({
+ environmentId: threadRef.environmentId,
+ input: { threadId: threadRef.threadId, linkedPullRequest },
+ });
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ throw squashAtomCommandFailure(result);
+ }
+ },
+ [resolveThreadPullRequest, threadRef, updateThreadMetadata],
+ );
const openExternalLinkInPreview = useCallback(
(url: string) => {
if (!threadRef) {
@@ -1609,7 +1722,9 @@ function ChatMarkdown({
copyMarkdown: string,
className?: string,
) => {
- const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath);
+ const parentSuffix = fileLinkParentSuffixByPath.get(
+ fileLinkMeta.filePath.replaceAll("\\", "/"),
+ );
const labelParts = [fileLinkMeta.basename];
if (typeof parentSuffix === "string" && parentSuffix.length > 0) {
labelParts.push(parentSuffix);
@@ -1647,30 +1762,6 @@ function ChatMarkdown({
};
return {
- img({ node: _node, src, alt, className, title: _title, ...props }) {
- const srcValue = typeof src === "string" ? src : undefined;
- if (srcValue && isLocalMarkdownImageSrc(srcValue) && threadRef) {
- return (
-
- );
- }
- if (!srcValue) return null;
- return (
-

- );
- },
p({ node: _node, children, ...props }) {
return
{renderSkillInlineMarkdownChildren(children, skills)}
;
},
@@ -1744,7 +1835,10 @@ function ChatMarkdown({
},
a({ node, href, children, title: _title, ...props }) {
const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
- const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;
+ const fileLinkMeta = normalizedHref
+ ? (markdownFileLinkMetaByHref.get(normalizedHref) ??
+ resolveMarkdownFileLinkMeta(normalizedHref, cwd))
+ : null;
if (!fileLinkMeta) {
const faviconHost = resolveExternalWebLinkHost(href);
const isSameDocumentLink = href?.startsWith("#") ?? false;
@@ -1774,9 +1868,20 @@ function ChatMarkdown({
event.stopPropagation();
const api = readLocalApi();
if (!api) return;
+ const pullRequest = resolveThreadPullRequest(href);
+ const currentPullRequest =
+ threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest;
+ const threadLinkAction =
+ currentPullRequest != null &&
+ matchesLinkedPullRequestUrl(currentPullRequest, href)
+ ? "unlink-from-thread"
+ : pullRequest === null
+ ? undefined
+ : "link-to-thread";
void showExternalLinkContextMenu({
href,
canOpenInPreview,
+ threadLinkAction,
position: { x: event.clientX, y: event.clientY },
showContextMenu: (items, position) => api.contextMenu.show(items, position),
openInPreview: async (target) => {
@@ -1790,8 +1895,25 @@ function ChatMarkdown({
},
openExternal: (target) => api.shell.openExternal(target),
copyLink: (target) => writeTextToClipboard(target, "link"),
+ updateThreadLink: updateThreadPullRequestLink,
reportFailure: (operation, cause) => {
reportMarkdownActionFailure({ operation, target: href }, cause);
+ if (
+ operation === "link-pull-request-to-thread" ||
+ operation === "unlink-pull-request-from-thread"
+ ) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title:
+ operation === "link-pull-request-to-thread"
+ ? "Unable to link pull request"
+ : "Unable to unlink pull request",
+ description:
+ cause instanceof Error ? cause.message : "The request failed.",
+ }),
+ );
+ }
},
});
}}
@@ -1843,6 +1965,43 @@ function ChatMarkdown({
);
},
+ img({ node: _node, title: _title, src, alt, ...props }) {
+ const srcString = typeof src === "string" ? normalizeMarkdownLinkDestination(src) : "";
+ const altText = alt ?? "";
+ const imageSource = classifyMarkdownImageSource(srcString, cwd);
+ if (imageSource._tag === "Direct") {
+ return (
+

+ );
+ }
+ if (imageSource._tag === "WorkspaceFile" && threadRef) {
+ return (
+
+ );
+ }
+ // Codex ACP uses `attachment:` / generated_images host paths that the
+ // shared classifier treats as blocked URI schemes.
+ if (isLocalMarkdownImageSrc(srcString) && threadRef) {
+ return (
+
+ );
+ }
+ return
;
+ },
table({ node: _node, ...props }) {
return
;
},
@@ -1888,12 +2047,15 @@ function ChatMarkdown({
onTaskListChange,
openFileInPanel,
openInPreferredEditor,
+ openChangeRequestLink,
openExternalLinkInPreview,
openMarkdownFileInPreview,
+ resolveThreadPullRequest,
resolvedTheme,
skills,
text,
threadRef,
+ updateThreadPullRequestLink,
]);
/* eslint-enable react/no-unstable-nested-components */
diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx
new file mode 100644
index 000000000000..e7b042a62c95
--- /dev/null
+++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx
@@ -0,0 +1,142 @@
+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { renderToStaticMarkup } from "react-dom/server";
+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+const testState = vi.hoisted(() => ({
+ resources: [] as Array
,
+ assetState: "success" as "success" | "loading",
+}));
+
+vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
+vi.mock("../assets/assetUrls", () => ({
+ useAssetUrlState: (_environmentId: unknown, resource: unknown) => {
+ testState.resources.push(resource);
+ return testState.assetState === "loading"
+ ? { _tag: "Loading" }
+ : { _tag: "Success", url: "https://signed.test/workspace-image.svg" };
+ },
+}));
+vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
+vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));
+vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
+vi.mock("../state/session", async (importOriginal) => ({
+ ...(await importOriginal()),
+ usePreparedConnection: () => ({ _tag: "Loading" }),
+}));
+vi.mock("../state/entities", () => ({
+ readThreadShell: () => null,
+ useActiveEnvironmentId: () => EnvironmentId.make("env-windows"),
+ useProjects: () => [],
+}));
+vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() }));
+vi.mock("~/lib/openPullRequestLink", () => ({
+ findProjectForChangeRequest: () => undefined,
+ matchesLinkedPullRequestUrl: () => false,
+ parseChangeRequestUrl: () => null,
+ useOpenChangeRequestLink: () => vi.fn(),
+}));
+
+import ChatMarkdown from "./ChatMarkdown";
+
+const threadRef = {
+ environmentId: EnvironmentId.make("env-windows"),
+ threadId: ThreadId.make("thread-windows"),
+};
+
+function render(markdown: string): string {
+ return renderToStaticMarkup(
+ ,
+ );
+}
+
+function renderWithoutThread(markdown: string): string {
+ return renderToStaticMarkup();
+}
+
+describe("ChatMarkdown workspace images", () => {
+ beforeEach(() => {
+ testState.resources = [];
+ testState.assetState = "success";
+ });
+
+ it("loads every Windows workspace path form through a signed asset URL", () => {
+ const imagePath = "C:/Users/shawn/project/.t3/workspace-image.svg";
+ const html = render(
+ [
+ "",
+ ``,
+ ``,
+ "",
+ ].join("\n\n"),
+ );
+
+ expect(testState.resources).toEqual([
+ {
+ _tag: "workspace-file",
+ threadId: threadRef.threadId,
+ path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg",
+ },
+ { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath },
+ { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath },
+ {
+ _tag: "workspace-file",
+ threadId: threadRef.threadId,
+ path: "\\\\server\\share\\workspace-image.svg",
+ },
+ ]);
+ expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(4);
+ expect(html.match(/max-w-\[min\(100%,30rem\)\]/g)).toHaveLength(4);
+ expect(html.match(/max-h-\[30rem\]/g)).toHaveLength(4);
+ expect(html).not.toContain("Image unavailable");
+ });
+
+ it("normalizes a drive-absolute src in raw image HTML", () => {
+ const html = render(String.raw`
`);
+
+ expect(testState.resources).toEqual([
+ {
+ _tag: "workspace-file",
+ threadId: threadRef.threadId,
+ path: "D:/screens/workspace-image.svg",
+ },
+ ]);
+ expect(html).toContain("https://signed.test/workspace-image.svg");
+ });
+
+ it("uses a static placeholder while a signed asset URL loads", () => {
+ testState.assetState = "loading";
+
+ const html = render("");
+
+ expect(html).toContain('aria-label="Loading image"');
+ expect(html).not.toContain("animate-pulse");
+ });
+
+ it("never passes a workspace source to a raw image when thread context is unavailable", () => {
+ const html = renderWithoutThread(
+ "",
+ );
+
+ expect(testState.resources).toEqual([]);
+ expect(html).toContain("Image unavailable");
+ expect(html).not.toContain("file://");
+ });
+
+ it("blocks unsupported image schemes instead of passing them to a raw image", () => {
+ const html = render("");
+
+ expect(testState.resources).toEqual([]);
+ expect(html).toContain("Image unavailable");
+ expect(html).not.toContain("content://");
+ });
+
+ it("keeps remote images directly loadable", () => {
+ const html = render("");
+
+ expect(testState.resources).toEqual([]);
+ expect(html).toContain('src="https://example.com/image.png"');
+ expect(html).toContain("max-w-[min(100%,30rem)]");
+ expect(html).toContain("max-h-[30rem]");
+ expect(html).not.toContain("Image unavailable");
+ });
+});
diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts
index 1f858b387a83..1d0e8b0eedc5 100644
--- a/apps/web/src/components/ChatView.logic.test.ts
+++ b/apps/web/src/components/ChatView.logic.test.ts
@@ -28,13 +28,18 @@ import {
resolvedSteeredMessageIds,
reconcileMountedTerminalThreadIds,
reconcileRetainedMountedThreadIds,
+ resolveBackgroundDraftWorkspaceOptions,
+ resolveDraftPromotionNavigationTarget,
resolveThreadMetadataUpdateForNextTurn,
resolveSendEnvMode,
+ resolveDraftHeroState,
+ resolveServerThreadError,
shouldRenderServerThreadRoute,
shouldTreatServerThreadAsActive,
- resolveServerThreadError,
scheduleEnvironmentReconnectWarning,
startNewThreadForProject,
+ shouldDockDraftHeroForSubmission,
+ shouldReleaseTimelineAnchorForToolActivity,
shouldShowBranchMismatchBanner,
shouldWriteThreadErrorToCurrentServerThread,
} from "./ChatView.logic";
@@ -50,6 +55,148 @@ const projectId = ProjectId.make("project-1");
const threadId = ThreadId.make("thread-1");
const now = "2026-03-29T00:00:00.000Z";
+describe("draft hero submission transition", () => {
+ it("does not dock the composer before a background submission", () => {
+ expect(
+ shouldDockDraftHeroForSubmission({
+ isDraftHeroState: true,
+ activeThreadKey: "environment-local:thread-1",
+ submissionIntent: "background",
+ }),
+ ).toBe(false);
+ });
+
+ it("keeps the composer in the hero layout until navigation after server promotion", () => {
+ expect(
+ resolveDraftHeroState({
+ isLocalDraftThread: false,
+ hasTimelineEntries: true,
+ isWorking: true,
+ draftHeroDockRequested: false,
+ backgroundSubmissionPending: true,
+ }),
+ ).toBe(true);
+ });
+
+ it("does not auto-navigate a background submission after server promotion", () => {
+ expect(
+ resolveDraftPromotionNavigationTarget({
+ serverThreadRef: { environmentId, threadId },
+ serverThreadStarted: true,
+ backgroundSubmissionPending: true,
+ }),
+ ).toBeNull();
+ });
+});
+
+describe("shouldReleaseTimelineAnchorForToolActivity", () => {
+ const activeTurnId = TurnId.make("active-turn");
+ const anchorMessageId = MessageId.make("anchored-message");
+ const activeToolEntry = {
+ id: "tool-entry",
+ kind: "work" as const,
+ createdAt: now,
+ entry: {
+ id: "active-tool",
+ createdAt: now,
+ turnId: activeTurnId,
+ label: "Run command",
+ tone: "tool" as const,
+ command: "git status",
+ },
+ };
+
+ it("releases the send anchor for tool activity in the active turn", () => {
+ expect(
+ shouldReleaseTimelineAnchorForToolActivity({
+ anchorMessageId,
+ liveFollowEnabled: true,
+ runningTurnId: activeTurnId,
+ timelineEntries: [activeToolEntry],
+ }),
+ ).toBe(true);
+ });
+
+ it("keeps the anchor while the user reads history", () => {
+ expect(
+ shouldReleaseTimelineAnchorForToolActivity({
+ anchorMessageId,
+ liveFollowEnabled: false,
+ runningTurnId: activeTurnId,
+ timelineEntries: [activeToolEntry],
+ }),
+ ).toBe(false);
+ });
+
+ it("ignores tool activity from earlier turns", () => {
+ expect(
+ shouldReleaseTimelineAnchorForToolActivity({
+ anchorMessageId,
+ liveFollowEnabled: true,
+ runningTurnId: activeTurnId,
+ timelineEntries: [
+ {
+ ...activeToolEntry,
+ entry: {
+ ...activeToolEntry.entry,
+ turnId: TurnId.make("previous-turn"),
+ },
+ },
+ ],
+ }),
+ ).toBe(false);
+ });
+
+ it("ignores thinking and error rows without tool activity", () => {
+ expect(
+ shouldReleaseTimelineAnchorForToolActivity({
+ anchorMessageId,
+ liveFollowEnabled: true,
+ runningTurnId: activeTurnId,
+ timelineEntries: [
+ {
+ ...activeToolEntry,
+ entry: {
+ id: "thinking-entry",
+ createdAt: now,
+ turnId: activeTurnId,
+ label: "Thinking",
+ tone: "thinking",
+ },
+ },
+ {
+ ...activeToolEntry,
+ id: "error-entry",
+ entry: {
+ id: "error-entry",
+ createdAt: now,
+ turnId: activeTurnId,
+ label: "Provider error",
+ tone: "error",
+ },
+ },
+ ],
+ }),
+ ).toBe(false);
+ });
+
+ it("does nothing without an anchor or running turn", () => {
+ const input = {
+ anchorMessageId,
+ liveFollowEnabled: true,
+ runningTurnId: activeTurnId,
+ timelineEntries: [activeToolEntry],
+ };
+
+ expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe(
+ false,
+ );
+ expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe(
+ false,
+ );
+ });
+});
+
describe("environment reconnect warning grace", () => {
afterEach(() => vi.useRealTimers());
@@ -445,6 +592,23 @@ describe("resolveSendEnvMode", () => {
});
});
+describe("resolveBackgroundDraftWorkspaceOptions", () => {
+ it("keeps New worktree selected without reusing the launched worktree", () => {
+ expect(
+ resolveBackgroundDraftWorkspaceOptions({
+ envMode: "worktree",
+ branch: "main",
+ startFromOrigin: true,
+ }),
+ ).toEqual({
+ envMode: "worktree",
+ branch: "main",
+ worktreePath: null,
+ startFromOrigin: true,
+ });
+ });
+});
+
describe("branchMismatchKey", () => {
it("builds a key from thread id and both branches", () => {
expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe(
@@ -658,6 +822,30 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
).toBe(false);
});
+ it("keeps a follow-up active while its provider session is starting", () => {
+ const localDispatch = createLocalDispatchSnapshot(
+ makeThread({ latestTurn: completedTurn, session: readySession }),
+ );
+
+ expect(
+ hasServerAcknowledgedLocalDispatch({
+ localDispatch,
+ phase: "connecting",
+ latestTurn: completedTurn,
+ latestUserMessageId: MessageId.make("message-followup"),
+ projectedMessageIds: new Set(),
+ session: {
+ ...readySession,
+ status: "starting",
+ updatedAt: "2026-03-29T00:01:00.000Z",
+ },
+ hasPendingApproval: false,
+ hasPendingUserInput: false,
+ threadError: null,
+ }),
+ ).toBe(false);
+ });
+
it("acknowledges a settled newer turn", () => {
const localDispatch = createLocalDispatchSnapshot(
makeThread({ latestTurn: completedTurn, session: readySession }),
diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts
index 0ba523d56f3e..b1a2168b7e19 100644
--- a/apps/web/src/components/ChatView.logic.ts
+++ b/apps/web/src/components/ChatView.logic.ts
@@ -22,6 +22,8 @@ import {
type TerminalContextDraft,
} from "../lib/terminalContext";
import type { DraftThreadEnvMode } from "../composerDraftStore";
+import type { ComposerSubmissionIntent } from "../composer-logic";
+import type { TimelineEntry } from "../session-logic";
export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
@@ -30,6 +32,72 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;
export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
+export function shouldDockDraftHeroForSubmission(input: {
+ isDraftHeroState: boolean;
+ activeThreadKey: string | null;
+ submissionIntent: ComposerSubmissionIntent;
+}): boolean {
+ return (
+ input.submissionIntent === "foreground" &&
+ input.isDraftHeroState &&
+ input.activeThreadKey !== null
+ );
+}
+
+export function shouldReleaseTimelineAnchorForToolActivity(input: {
+ anchorMessageId: MessageId | null;
+ liveFollowEnabled: boolean;
+ runningTurnId: TurnId | null;
+ timelineEntries: ReadonlyArray;
+}): boolean {
+ if (input.anchorMessageId === null || !input.liveFollowEnabled || input.runningTurnId === null) {
+ return false;
+ }
+
+ return input.timelineEntries.some((timelineEntry) => {
+ if (timelineEntry.kind !== "work" || timelineEntry.entry.turnId !== input.runningTurnId) {
+ return false;
+ }
+
+ const entry = timelineEntry.entry;
+ return (
+ entry.tone === "tool" ||
+ entry.itemType !== undefined ||
+ entry.requestKind !== undefined ||
+ (entry.command?.trim().length ?? 0) > 0
+ );
+ });
+}
+
+export function resolveDraftHeroState(input: {
+ isLocalDraftThread: boolean;
+ hasTimelineEntries: boolean;
+ isWorking: boolean;
+ draftHeroDockRequested: boolean;
+ backgroundSubmissionPending: boolean;
+}): boolean {
+ if (input.backgroundSubmissionPending) {
+ return true;
+ }
+ return (
+ input.isLocalDraftThread &&
+ !input.hasTimelineEntries &&
+ !input.isWorking &&
+ !input.draftHeroDockRequested
+ );
+}
+
+export function resolveDraftPromotionNavigationTarget(input: {
+ serverThreadRef: ScopedThreadRef | null;
+ serverThreadStarted: boolean;
+ backgroundSubmissionPending: boolean;
+}): ScopedThreadRef | null {
+ if (input.backgroundSubmissionPending) {
+ return null;
+ }
+ return input.serverThreadStarted ? input.serverThreadRef : null;
+}
+
export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void {
const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS);
return () => globalThis.clearTimeout(timeoutId);
@@ -347,6 +415,24 @@ export function resolveSendEnvMode(input: {
return input.isGitRepo ? input.requestedEnvMode : "local";
}
+export function resolveBackgroundDraftWorkspaceOptions(input: {
+ envMode: DraftThreadEnvMode;
+ branch: string | null;
+ startFromOrigin: boolean;
+}): {
+ envMode: DraftThreadEnvMode;
+ branch: string | null;
+ worktreePath: null;
+ startFromOrigin: boolean;
+} {
+ return {
+ envMode: input.envMode,
+ branch: input.branch,
+ worktreePath: null,
+ startFromOrigin: input.envMode === "worktree" && input.startFromOrigin,
+ };
+}
+
export function cloneComposerImageForRetry(
image: ComposerImageAttachment,
): ComposerImageAttachment {
@@ -588,6 +674,7 @@ export interface LocalDispatchSnapshot {
* that other clients' activity could satisfy.
*/
expectedMessageId: ChatMessage["id"] | null;
+ submissionIntent: ComposerSubmissionIntent;
latestUserMessageId: ChatMessage["id"] | null;
latestTurnTurnId: TurnId | null;
latestTurnRequestedAt: string | null;
@@ -599,7 +686,11 @@ export interface LocalDispatchSnapshot {
export function createLocalDispatchSnapshot(
activeThread: Thread | undefined,
- options?: { preparingWorktree?: boolean; messageId?: ChatMessage["id"] },
+ options?: {
+ preparingWorktree?: boolean;
+ messageId?: ChatMessage["id"];
+ submissionIntent?: ComposerSubmissionIntent;
+ },
): LocalDispatchSnapshot {
const latestTurn = activeThread?.latestTurn ?? null;
const session = activeThread?.session ?? null;
@@ -608,6 +699,7 @@ export function createLocalDispatchSnapshot(
startedAt: new Date().toISOString(),
preparingWorktree: Boolean(options?.preparingWorktree),
expectedMessageId: options?.messageId ?? null,
+ submissionIntent: options?.submissionIntent ?? "foreground",
latestUserMessageId: latestUserMessage?.id ?? null,
latestTurnTurnId: latestTurn?.turnId ?? null,
latestTurnRequestedAt: latestTurn?.requestedAt ?? null,
@@ -635,6 +727,9 @@ export function hasServerAcknowledgedLocalDispatch(input: {
if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) {
return true;
}
+ if (input.phase === "connecting") {
+ return false;
+ }
const latestTurn = input.latestTurn ?? null;
const session = input.session ?? null;
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 58abd7baa16e..f2b811de03cb 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -26,12 +26,19 @@ import {
connectionStatusTitle,
type EnvironmentConnectionPresentation,
} from "@t3tools/client-runtime/connection";
+import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
import {
changeRequestAutoSettles,
effectiveSettled,
effectiveSnoozed,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
+import {
+ codexFeedbackMessage,
+ parseCodexFeedbackCommand,
+ submitCodexFeedback,
+ type CodexFeedbackSubmission,
+} from "@t3tools/client-runtime/state/threads";
import {
parseScopedThreadKey,
scopedThreadKey,
@@ -76,6 +83,7 @@ import {
type AtomCommandResult,
} from "@t3tools/client-runtime/state/runtime";
import * as Cause from "effect/Cause";
+import * as Schema from "effect/Schema";
import { AsyncResult } from "effect/unstable/reactivity";
import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors";
import {
@@ -87,6 +95,7 @@ import { readLocalApi } from "../localApi";
import { useDiffPanelStore } from "../diffPanelStore";
import {
collapseExpandedComposerCursor,
+ type ComposerSubmissionIntent,
parseStandaloneComposerSlashCommand,
} from "../composer-logic";
import {
@@ -130,6 +139,7 @@ import {
} from "../types";
import { useTheme } from "../hooks/useTheme";
import { useNewThreadHandler } from "../hooks/useHandleNewThread";
+import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
import { isCommandPaletteOpen } from "../commandPaletteBus";
import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
@@ -181,6 +191,7 @@ import {
CheckCircle2Icon,
ChevronDownIcon,
GitBranchIcon,
+ Minimize2Icon,
PaperclipIcon,
WifiOffIcon,
} from "lucide-react";
@@ -205,13 +216,18 @@ import {
import { useBrowserHistoryStore } from "~/browserHistoryStore";
import { registerFaviconProjectForThread } from "~/browserFaviconStore";
import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
-import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
+import {
+ applyProviderInstanceSettings,
+ deriveProviderInstanceEntries,
+ NO_PROVIDER_MODEL_SELECTION,
+} from "../providerInstances";
import {
useClientSettings,
useClientSettingsHydrated,
useEnvironmentSettings,
} from "../hooks/useSettings";
import { useNowMinute } from "../hooks/useNowMinute";
+import { useThreadActions } from "../hooks/useThreadActions";
import { resolveAppModelSelectionForInstance } from "../modelSelection";
import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";
import { getTerminalFocusOwner } from "../lib/terminalFocus";
@@ -226,10 +242,15 @@ import {
selectProjectGroupingSettings,
} from "../logicalProject";
import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping";
-import { buildDraftThreadRouteParams } from "../threadRoutes";
+import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes";
import {
+ beginBackgroundDraftSubmissionByRef,
+ clearBackgroundDraftSubmissionByRef,
+ composerDraftHasUserContent,
type ComposerImageAttachment,
type DraftThreadEnvMode,
+ finalizePromotedDraftThreadByRef,
+ markPromotedDraftThreadByRef,
useComposerDraftStore,
type DraftId,
} from "../composerDraftStore";
@@ -309,9 +330,17 @@ import {
import {
resolveDisplayedThreadPr,
threadChangeRequestSnapshotsAtom,
+ useLinkedThreadPullRequest,
} from "./ThreadStatusIndicators";
import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
import { QueuedMessageChips, type DisplayQueuedMessage } from "./chat/QueuedMessageChips";
+import {
+ hasAvailableClaudeCompactionProvider,
+ hasDismissedResumeCompaction,
+ shouldOfferResumeCompaction,
+} from "./chat/ContextWindowMeter.logic";
+import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow";
+import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
import {
DRAFT_HERO_TRANSITION_ANIMATION_ID,
DRAFT_HERO_TRANSITION_DURATION_MS,
@@ -335,6 +364,8 @@ import {
scheduleEnvironmentReconnectWarning,
hasServerAcknowledgedLocalDispatch,
isBranchMismatchDismissedForSession,
+ shouldDockDraftHeroForSubmission,
+ shouldReleaseTimelineAnchorForToolActivity,
shouldShowBranchMismatchBanner,
getStartedThreadModelChangeBlockReason,
LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
@@ -345,6 +376,8 @@ import {
deriveLockedProvider,
readFileAsDataUrl,
reconcileMountedTerminalThreadIds,
+ resolveBackgroundDraftWorkspaceOptions,
+ resolveDraftHeroState,
resolveThreadMetadataUpdateForNextTurn,
resolveSendEnvMode,
revokeBlobPreviewUrl,
@@ -357,6 +390,12 @@ import {
} from "./ChatView.logic";
import { useLocalStorage } from "~/hooks/useLocalStorage";
import { useComposerHandleContext } from "../composerHandleContext";
+import {
+ awaitAttachmentUploads,
+ getUploadedAttachments,
+ releaseAttachmentUploads,
+ startAttachmentUpload,
+} from "../lib/attachmentUploadQueue";
import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
import { RightPanelSheet } from "./RightPanelSheet";
import { previewEnvironment } from "../state/preview";
@@ -646,14 +685,22 @@ function useLocalDispatchState(input: {
);
const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch;
const beginLocalDispatch = useCallback(
- (options?: { preparingWorktree?: boolean; messageId?: MessageId }) => {
+ (options?: {
+ preparingWorktree?: boolean;
+ messageId?: MessageId;
+ submissionIntent?: ComposerSubmissionIntent;
+ }) => {
const preparingWorktree = Boolean(options?.preparingWorktree);
setLocalDispatch((current) => {
const active = serverAcknowledgedLocalDispatch ? null : current;
if (active) {
- return active.preparingWorktree === preparingWorktree
+ const submissionIntent = options?.submissionIntent ?? active.submissionIntent;
+ const expectedMessageId = options?.messageId ?? active.expectedMessageId;
+ return active.preparingWorktree === preparingWorktree &&
+ active.submissionIntent === submissionIntent &&
+ active.expectedMessageId === expectedMessageId
? active
- : { ...active, preparingWorktree };
+ : { ...active, preparingWorktree, submissionIntent, expectedMessageId };
}
return createLocalDispatchSnapshot(input.activeThread, options);
});
@@ -668,6 +715,7 @@ function useLocalDispatchState(input: {
latestUserMessageAt: latestUserMessage?.createdAt ?? null,
isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false,
isSendBusy: activeLocalDispatch !== null,
+ backgroundSubmissionPending: localDispatch?.submissionIntent === "background",
};
}
@@ -1255,6 +1303,20 @@ function chatActionErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : "An error occurred.";
}
+/**
+ * Drops the send-time anchored end space. That space is what holds a sent
+ * message near the top while its turn streams, and it keeps LegendList's
+ * maintainScrollAtEnd switched off for as long as it is installed — ChatView
+ * drives the streaming scrolls itself, but only in "anchoring-new-turn" mode.
+ * So every return to the live edge has to release the anchor too, otherwise the
+ * timeline settles into "following-end" with nothing following anything.
+ */
+function releaseChatTimelineAnchor(
+ current: T,
+): T {
+ return current.messageId === null ? current : { ...current, messageId: null };
+}
+
function ChatViewContent(props: ChatViewProps) {
const {
environmentId,
@@ -1267,6 +1329,8 @@ function ChatViewContent(props: ChatViewProps) {
} = props;
const threadDetailLoading = threadSyncPhase === "loading";
const draftId = routeKind === "draft" ? props.draftId : null;
+ const handleNewThread = useNewThreadHandler();
+ const { settleThread } = useThreadActions();
const routeThreadRef = useMemo(
() => scopeThreadRef(environmentId, threadId),
[environmentId, threadId],
@@ -1299,6 +1363,9 @@ function ChatViewContent(props: ChatViewProps) {
reportFailure: false,
});
const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
+ const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, {
+ reportFailure: false,
+ });
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, {
reportFailure: false,
});
@@ -1322,7 +1389,6 @@ function ChatViewContent(props: ChatViewProps) {
const { environments } = useEnvironments();
const primaryEnvironment = usePrimaryEnvironment();
const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false });
- const handleNewThread = useNewThreadHandler();
const environmentById = useMemo(
() => new Map(environments.map((environment) => [environment.environmentId, environment])),
[environments],
@@ -1388,6 +1454,9 @@ function ChatViewContent(props: ChatViewProps) {
const composerActiveProvider = useComposerDraftStore(
(store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null,
);
+ const composerHasUnsentContent = useComposerDraftStore((store) =>
+ composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)),
+ );
const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
const setComposerDraftTerminalContexts = useComposerDraftStore(
@@ -1425,6 +1494,16 @@ function ChatViewContent(props: ChatViewProps) {
const [hasUnreadTimelineActivity, setHasUnreadTimelineActivity] = useState(false);
const [expandedImage, setExpandedImage] = useState(null);
const [optimisticUserMessages, setOptimisticUserMessages] = useState([]);
+ const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
+ Record>
+ >({});
+ const feedbackSubmissions = useMemo(
+ () => feedbackSubmissionsByThreadKey[routeThreadKey] ?? [],
+ [feedbackSubmissionsByThreadKey, routeThreadKey],
+ );
+ const feedbackUploading = feedbackSubmissions.some(
+ (submission) => submission.status === "uploading",
+ );
const optimisticUserMessagesRef = useRef(optimisticUserMessages);
optimisticUserMessagesRef.current = optimisticUserMessages;
// Optimistic sends the server will hold in the steering queue. They are the
@@ -1505,6 +1584,7 @@ function ChatViewContent(props: ChatViewProps) {
const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({});
const sendInFlightRef = useRef(false);
const queuedTurnDrainInFlightRef = useRef(false);
+ const feedbackUploadsInFlightRef = useRef(new Set());
const terminalUiOpenByThreadRef = useRef>({});
useLayoutEffect(() => {
@@ -1812,6 +1892,9 @@ function ChatViewContent(props: ChatViewProps) {
return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
}, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
const activeLatestTurn = activeThread?.latestTurn ?? null;
+ const activeRunningTurnId =
+ (activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null) ??
+ (activeLatestTurn?.state === "running" ? activeLatestTurn.turnId : null);
// Reading a finished thread clears the sidebar's Done badge. The visit is
// stamped at the turn's completion time — not now/updatedAt — so it clears
// exactly the completion the user is looking at: a wake or completion that
@@ -2207,6 +2290,10 @@ function ChatViewContent(props: ChatViewProps) {
: (primaryEnvironment?.serverConfig ?? null);
const pullRequestsCapabilityKnown = serverConfig !== null;
const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true;
+ const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null;
+ const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null;
+ const supportsAttachmentUploads =
+ attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true;
const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);
const versionMismatchDismissKey =
versionMismatch && activeThread
@@ -2402,6 +2489,10 @@ function ChatViewContent(props: ChatViewProps) {
const phase = derivePhase(activeThread?.session ?? null);
const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
+ const activeContextWindow = useMemo(
+ () => deriveLatestContextWindowSnapshot(threadActivities),
+ [threadActivities],
+ );
const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);
const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]);
// Native subagent fold: memoized by activity-list identity, shared by the
@@ -2499,6 +2590,7 @@ function ChatViewContent(props: ChatViewProps) {
latestUserMessageAt,
isPreparingWorktree,
isSendBusy,
+ backgroundSubmissionPending,
} = useLocalDispatchState({
activeThread,
activeLatestTurn,
@@ -2758,12 +2850,20 @@ function ChatViewContent(props: ChatViewProps) {
return changed ? { ...message, attachments } : message;
});
- if (optimisticUserMessages.length === 0) {
+ const localMessages = [
+ ...optimisticUserMessages,
+ ...feedbackSubmissions.flatMap((submission) =>
+ submission.status === "interrupted"
+ ? []
+ : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")],
+ ),
+ ];
+ if (localMessages.length === 0) {
return serverMessagesWithPreviewHandoff;
}
const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id));
// Queue-bound sends render as chips above the composer, never as rows.
- const pendingMessages = optimisticUserMessages.filter(
+ const pendingMessages = localMessages.filter(
(message) => !serverIds.has(message.id) && !optimisticQueuedMessageIds.has(message.id),
);
if (pendingMessages.length === 0) {
@@ -2773,6 +2873,7 @@ function ChatViewContent(props: ChatViewProps) {
}, [
attachmentPreviewHandoffByMessageId,
displayServerMessages,
+ feedbackSubmissions,
optimisticQueuedMessageIds,
optimisticUserMessages,
]);
@@ -2831,8 +2932,13 @@ function ChatViewContent(props: ChatViewProps) {
const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null);
const draftHeroDockRequested =
activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey;
- const isDraftHeroState =
- isLocalDraftThread && timelineEntries.length === 0 && !isWorking && !draftHeroDockRequested;
+ const isDraftHeroState = resolveDraftHeroState({
+ isLocalDraftThread,
+ hasTimelineEntries: timelineEntries.length > 0,
+ isWorking,
+ draftHeroDockRequested,
+ backgroundSubmissionPending,
+ });
const [
attachDraftHeroTransitionGroupRef,
attachDraftHeroComposerAnchorRef,
@@ -2911,6 +3017,29 @@ function ChatViewContent(props: ChatViewProps) {
activeThread?.modelSelection.instanceId ??
activeProject?.defaultModelSelection?.instanceId ??
null;
+ const compactionProviderAvailable = useMemo(
+ () =>
+ hasAvailableClaudeCompactionProvider({
+ providers: applyProviderInstanceSettings(
+ deriveProviderInstanceEntries(providerStatuses),
+ settings,
+ ),
+ instanceId: activeProviderInstanceId,
+ lockedInstanceId: lockedProvider
+ ? (activeThread?.session?.providerInstanceId ??
+ activeThread?.modelSelection.instanceId ??
+ null)
+ : null,
+ }),
+ [
+ activeProviderInstanceId,
+ activeThread?.modelSelection.instanceId,
+ activeThread?.session?.providerInstanceId,
+ lockedProvider,
+ providerStatuses,
+ settings,
+ ],
+ );
const activeProviderStatus = useMemo(() => {
if (activeProviderInstanceId) {
return (
@@ -2920,6 +3049,25 @@ function ChatViewContent(props: ChatViewProps) {
const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider);
return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null;
}, [activeProviderInstanceId, providerStatuses, selectedProvider]);
+ const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] =
+ useLocalStorage(
+ `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`,
+ false,
+ Schema.Boolean,
+ );
+ const nativeResumeCompactionDismissed = useMemo(
+ () => hasDismissedResumeCompaction(threadActivities),
+ [threadActivities],
+ );
+ useEffect(() => {
+ if (nativeResumeCompactionDismissed && !resumeCompactionPermanentlyDismissed) {
+ setResumeCompactionPermanentlyDismissed(true);
+ }
+ }, [
+ nativeResumeCompactionDismissed,
+ resumeCompactionPermanentlyDismissed,
+ setResumeCompactionPermanentlyDismissed,
+ ]);
const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus);
const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState<
string | null
@@ -3553,25 +3701,50 @@ function ChatViewContent(props: ChatViewProps) {
);
// The thread's own change request, placed against the project it belongs to. Without a
// project there is nothing to resolve it against, so the caller falls back to the browser.
- const threadRepository = activeProject?.repositoryIdentity?.displayName ?? null;
+ const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null;
+ const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null;
+ const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository;
const openThreadPullRequest = useCallback(
(number: number, repository: string | null = threadRepository) => {
- const selectedRepository = repository ?? threadRepository;
+ if (!supportsPullRequests || !activeThreadRef) {
+ return;
+ }
+ const projectId = linkedThreadPullRequest?.projectId ?? activeProject?.id;
+ const selectedRepository =
+ repository ?? linkedThreadPullRequest?.repository ?? activeProjectRepository;
+ if (projectId === undefined || selectedRepository === null) return;
+ useRightPanelStore.getState().openPullRequest(activeThreadRef, {
+ projectId,
+ repository: selectedRepository,
+ number,
+ });
+ },
+ [
+ activeProject,
+ activeProjectRepository,
+ activeThreadRef,
+ linkedThreadPullRequest,
+ supportsPullRequests,
+ threadRepository,
+ ],
+ );
+ const openProjectPullRequest = useCallback(
+ (number: number) => {
if (
!supportsPullRequests ||
!activeThreadRef ||
!activeProject ||
- selectedRepository === null
+ activeProjectRepository === null
) {
return;
}
useRightPanelStore.getState().openPullRequest(activeThreadRef, {
projectId: activeProject.id,
- repository: selectedRepository,
+ repository: activeProjectRepository,
number,
});
},
- [activeProject, activeThreadRef, supportsPullRequests, threadRepository],
+ [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests],
);
const togglePreviewPanel = useCallback(() => {
if (!activeThreadRef || !isPreviewSupportedInRuntime()) return;
@@ -4053,17 +4226,39 @@ function ChatViewContent(props: ChatViewProps) {
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
setTimelineLiveFollowEnabled(true);
pendingTimelineAnchorRef.current = null;
+ positionedTimelineAnchorRef.current = null;
+ settledTimelineAnchorRef.current = null;
activeTimelineAnchorIndexRef.current = null;
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
setHasUnreadTimelineActivity(false);
- setTimelineAnchor((current) =>
- current.messageId === null ? current : { ...current, messageId: null },
- );
+ setTimelineAnchor(releaseChatTimelineAnchor);
requestAnimationFrame(() => {
void legendListRef.current?.scrollToEnd?.({ animated });
});
}, []);
+ useLayoutEffect(() => {
+ if (timelineScrollModeRef.current !== "anchoring-new-turn") {
+ return;
+ }
+
+ if (
+ shouldReleaseTimelineAnchorForToolActivity({
+ anchorMessageId: timelineAnchorMessageId,
+ liveFollowEnabled: timelineLiveFollowEnabled,
+ runningTurnId: activeRunningTurnId,
+ timelineEntries,
+ })
+ ) {
+ scrollToEnd();
+ }
+ }, [
+ activeRunningTurnId,
+ scrollToEnd,
+ timelineAnchorMessageId,
+ timelineEntries,
+ timelineLiveFollowEnabled,
+ ]);
useEffect(() => {
let removeListeners: (() => void) | null = null;
let frame: number | null = null;
@@ -4234,6 +4429,11 @@ function ChatViewContent(props: ChatViewProps) {
timelineScrollModeRef.current = "following-end";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
setTimelineLiveFollowEnabled(true);
+ // Reachable only once manual navigation has already broken follow, so
+ // the anchored turn framing is over: the user scrolled back to the live
+ // edge and expects the stream to stick to it again, exactly like the
+ // scroll-to-bottom pill.
+ setTimelineAnchor(releaseChatTimelineAnchor);
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
setHasUnreadTimelineActivity(false);
@@ -4551,11 +4751,17 @@ function ChatViewContent(props: ChatViewProps) {
: null;
const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
+ const linkedPullRequestStatus = useLinkedThreadPullRequest(
+ activeThreadRef?.environmentId ?? null,
+ linkedThreadPullRequest,
+ );
const activeThreadPr = resolveDisplayedThreadPr({
threadBranch: activeThread?.branch ?? null,
gitStatus: gitStatusQuery.data ?? null,
snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined,
retainTerminalOnBranchMismatch: activeThread?.worktreePath === null,
+ linkedPullRequest: linkedThreadPullRequest,
+ linkedPullRequestStatus,
});
// The right panel offers the thread's own change request, so it can only offer it once the
// branch has one; until then the picker says so rather than opening an empty panel.
@@ -4731,18 +4937,6 @@ function ChatViewContent(props: ChatViewProps) {
// Dismissal lives in a module-level set (survives remounts); this tick just
// forces a re-render so the banner leaves immediately.
const [, setBranchMismatchDismissTick] = useState(0);
- const composerHasDraftContent = useComposerDraftStore((store) => {
- const draft = store.getComposerDraft(composerDraftTarget);
- return Boolean(
- draft &&
- (draft.prompt.trim().length > 0 ||
- draft.images.length > 0 ||
- draft.terminalContexts.length > 0 ||
- draft.elementContexts.length > 0 ||
- draft.previewAnnotations.length > 0 ||
- draft.reviewComments.length > 0),
- );
- });
const activeBranchMismatchKey = branchMismatchKey(
activeThread?.id ?? null,
localCheckoutBranchMismatch,
@@ -4750,7 +4944,7 @@ function ChatViewContent(props: ChatViewProps) {
const showBranchMismatchBanner = shouldShowBranchMismatchBanner({
hasMismatch: localCheckoutBranchMismatch !== null,
isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey),
- composerHasContent: composerHasDraftContent,
+ composerHasContent: composerHasUnsentContent,
wasShownForCurrentMismatch:
revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey,
});
@@ -4974,6 +5168,107 @@ function ChatViewContent(props: ChatViewProps) {
isUnsnoozing,
isUnsettling,
]);
+ // Session-scoped dismissals, one key per (thread, snapshot). A set rather
+ // than a single slot so dismissing the banner on one thread does not
+ // resurface it on another thread dismissed earlier.
+ const [dismissedResumeCompactionKeys, setDismissedResumeCompactionKeys] = useState<
+ ReadonlySet
+ >(new Set());
+ const resumeCompactionKey =
+ activeThread && activeContextWindow
+ ? `${activeThread.id}:${activeContextWindow.updatedAt}`
+ : null;
+ const compactDisabled =
+ !activeThread ||
+ !activeProject ||
+ !isServerThread ||
+ selectedProvider !== "claudeAgent" ||
+ !compactionProviderAvailable ||
+ isWorking ||
+ threadDetailLoading ||
+ isPreparingWorktree ||
+ activeEnvironmentUnavailable ||
+ feedbackUploading ||
+ pendingApprovals.length > 0 ||
+ pendingUserInputs.length > 0 ||
+ showPlanFollowUpPrompt ||
+ composerHasUnsentContent;
+ const compactDisabledReason = compactDisabled
+ ? composerHasUnsentContent
+ ? "Send or clear your draft before compacting"
+ : !activeProject
+ ? "Choose a project before compacting"
+ : !compactionProviderAvailable
+ ? "Enable a Claude provider before compacting"
+ : "Compacting is unavailable right now"
+ : null;
+ const resumeCompactionBannerItem = useMemo(() => {
+ if (
+ !activeThread ||
+ !activeContextWindow ||
+ resumeCompactionKey === null ||
+ dismissedResumeCompactionKeys.has(resumeCompactionKey) ||
+ resumeCompactionPermanentlyDismissed ||
+ nativeResumeCompactionDismissed ||
+ pendingUserInputs.length > 0 ||
+ phase === "running" ||
+ !shouldOfferResumeCompaction({
+ provider: selectedProvider,
+ usedTokens: activeContextWindow.usedTokens,
+ updatedAt: activeContextWindow.updatedAt,
+ now: `${nowMinute}:00.000Z`,
+ })
+ ) {
+ return null;
+ }
+
+ const dismiss = () =>
+ setDismissedResumeCompactionKeys((keys) => new Set(keys).add(resumeCompactionKey));
+ const compactAction = (
+
+ );
+ return {
+ id: `resume-compaction:${resumeCompactionKey}`,
+ variant: "info",
+ icon: ,
+ title: "Resume with less context",
+ description: `${formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from an older session`,
+ actions: compactDisabledReason ? (
+
+ {compactAction}} />
+ {compactDisabledReason}
+
+ ) : (
+ compactAction
+ ),
+ dismissLabel: "Keep full history",
+ onDismiss: dismiss,
+ };
+ }, [
+ activeContextWindow,
+ activeThread,
+ compactDisabled,
+ compactDisabledReason,
+ composerRef,
+ dismissedResumeCompactionKeys,
+ nativeResumeCompactionDismissed,
+ nowMinute,
+ pendingUserInputs.length,
+ phase,
+ resumeCompactionKey,
+ resumeCompactionPermanentlyDismissed,
+ selectedProvider,
+ ]);
const handleRestoreThreadBranch = useCallback(() => {
if (gitStatusQuery.data?.hasWorkingTreeChanges) {
setBranchRestoreConfirmOpen(true);
@@ -4988,6 +5283,8 @@ function ChatViewContent(props: ChatViewProps) {
const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item));
const backgroundLivenessItems =
backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
+ const resumeCompactionItems =
+ resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem];
const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];
const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
@@ -4995,6 +5292,7 @@ function ChatViewContent(props: ChatViewProps) {
...urgentSystemItems,
...backgroundLivenessItems,
...calmSystemItems,
+ ...resumeCompactionItems,
...wokeThreadItems,
...parkedThreadItems,
];
@@ -5003,6 +5301,7 @@ function ChatViewContent(props: ChatViewProps) {
...urgentSystemItems,
...backgroundLivenessItems,
...calmSystemItems,
+ ...resumeCompactionItems,
...wokeThreadItems,
{
id: `branch-mismatch:${activeBranchMismatchKey}`,
@@ -5052,6 +5351,7 @@ function ChatViewContent(props: ChatViewProps) {
isRestoringThreadBranch,
localCheckoutBranchMismatch,
parkedThreadBannerItem,
+ resumeCompactionBannerItem,
showBranchMismatchBanner,
systemComposerBannerItems,
wokeThreadBannerItem,
@@ -5191,6 +5491,29 @@ function ChatViewContent(props: ChatViewProps) {
});
if (!command) return;
+ if (command === "thread.settle") {
+ event.preventDefault();
+ event.stopPropagation();
+ if (!isServerThread || !activeThreadRef || !supportsSettlement) return;
+ if (activeThreadSettled) {
+ void handleUnsettleActiveThread();
+ return;
+ }
+
+ void settleThread(activeThreadRef).then((result) => {
+ if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
+ const error = squashAtomCommandFailure(result);
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Failed to settle thread",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ }),
+ );
+ });
+ return;
+ }
+
if (command === "terminal.toggle") {
event.preventDefault();
event.stopPropagation();
@@ -5294,6 +5617,8 @@ function ChatViewContent(props: ChatViewProps) {
activeProject,
activeRightPanelSurface,
addTerminalSurface,
+ activeThreadRef,
+ activeThreadSettled,
terminalUiState.terminalOpen,
terminalUiState.activeTerminalId,
activeThreadId,
@@ -5305,7 +5630,11 @@ function ChatViewContent(props: ChatViewProps) {
splitTerminal,
splitPanelTerminal,
keybindings,
+ handleUnsettleActiveThread,
+ isServerThread,
onToggleDiff,
+ settleThread,
+ supportsSettlement,
toggleRightPanel,
toggleRightPanelMaximized,
toggleTerminalVisibility,
@@ -5374,6 +5703,7 @@ function ChatViewContent(props: ChatViewProps) {
const onSend = async (
e?: { preventDefault: () => void },
+ submissionIntent: ComposerSubmissionIntent = "foreground",
directAnnotation?: {
annotation: PreviewAnnotationPayload;
image: ComposerImageAttachment | null;
@@ -5396,7 +5726,8 @@ function ChatViewContent(props: ChatViewProps) {
isConnecting ||
activeEnvironmentUnavailable ||
threadDetailLoading ||
- sendInFlightRef.current
+ sendInFlightRef.current ||
+ feedbackUploadsInFlightRef.current.has(routeThreadKey)
) {
notifyDirectAnnotationAttached();
return;
@@ -5471,6 +5802,101 @@ function ChatViewContent(props: ChatViewProps) {
composerPreviewAnnotations.length +
composerReviewComments.length,
});
+ const feedbackCommand =
+ ctxSelectedProvider === "codex" &&
+ composerImages.length === 0 &&
+ sendableComposerTerminalContexts.length === 0 &&
+ composerElementContexts.length === 0 &&
+ composerPreviewAnnotations.length === 0 &&
+ composerReviewComments.length === 0
+ ? parseCodexFeedbackCommand(trimmed)
+ : null;
+ if (feedbackCommand) {
+ if (!isServerThread || activeThread.session === null) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "warning",
+ title: "Start a Codex thread first",
+ description: "Send a message before you submit feedback.",
+ }),
+ );
+ return;
+ }
+ feedbackUploadsInFlightRef.current.add(routeThreadKey);
+ const result = await submitCodexFeedback({
+ submission: {
+ id: newMessageId(),
+ command: trimmed,
+ createdAt: new Date().toISOString(),
+ },
+ clearDraft: () => {
+ promptRef.current = "";
+ clearComposerDraftContent(composerDraftTarget);
+ composerRef.current?.resetCursorState();
+ scrollToEnd();
+ },
+ onUpdate: (submission) => {
+ setFeedbackSubmissionsByThreadKey((current) => {
+ const existing = current[routeThreadKey] ?? [];
+ const found = existing.some((entry) => entry.id === submission.id);
+ return {
+ ...current,
+ [routeThreadKey]: found
+ ? existing.map((entry) => (entry.id === submission.id ? submission : entry))
+ : [...existing, submission],
+ };
+ });
+ },
+ upload: () =>
+ uploadThreadFeedback({
+ environmentId,
+ input: {
+ threadId: activeThread.id,
+ ...feedbackCommand,
+ },
+ }),
+ }).finally(() => {
+ feedbackUploadsInFlightRef.current.delete(routeThreadKey);
+ });
+ if (result._tag === "Failure") {
+ if (!isAtomCommandInterrupted(result)) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Could not send feedback to OpenAI",
+ description: chatActionErrorMessage(squashAtomCommandFailure(result)),
+ }),
+ );
+ }
+ return;
+ }
+ const feedbackId = result.value.feedbackId;
+ toastManager.add(
+ stackedThreadToast({
+ type: "success",
+ title: "Feedback sent to OpenAI",
+ description: `Thread ID: ${feedbackId}`,
+ timeout: 0,
+ actionProps: {
+ children: "Copy ID",
+ onClick: () => {
+ void writeTextToClipboard(feedbackId, "Codex feedback thread ID").catch(
+ (error: unknown) => {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Could not copy thread ID",
+ description: chatActionErrorMessage(error),
+ }),
+ );
+ },
+ );
+ },
+ },
+ }),
+ );
+ return;
+ }
if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) {
const followUp = resolvePlanFollowUpSubmission({
draftText: trimmed,
@@ -5596,7 +6022,28 @@ function ChatViewContent(props: ChatViewProps) {
return new Set(current).add(threadIdForSend);
});
}
- if (isDraftHeroState && activeThreadKey) {
+ if (supportsAttachmentUploads && composerImagesSnapshot.length > 0) {
+ for (const image of composerImagesSnapshot) {
+ startAttachmentUpload({ environmentId, image });
+ }
+ await awaitAttachmentUploads(composerImagesSnapshot.map((image) => image.id));
+ if (getUploadedAttachments({ environmentId, images: composerImagesSnapshot }) === null) {
+ sendInFlightRef.current = false;
+ setThreadError(threadIdForSend, "Retry or remove failed image uploads before sending.");
+ return;
+ }
+ }
+
+ const resolvedSubmissionIntent =
+ submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground";
+ if (
+ shouldDockDraftHeroForSubmission({
+ isDraftHeroState,
+ activeThreadKey,
+ submissionIntent: resolvedSubmissionIntent,
+ }) &&
+ activeThreadKey
+ ) {
let resolveDockStarted: (() => void) | undefined;
const dockStarted = new Promise((resolve) => {
resolveDockStarted = resolve;
@@ -5615,19 +6062,29 @@ function ChatViewContent(props: ChatViewProps) {
beginLocalDispatch({
preparingWorktree: Boolean(baseBranchForWorktree),
messageId: messageIdForSend,
+ submissionIntent: resolvedSubmissionIntent,
});
let turnStartSucceeded = false;
try {
const messageCreatedAt = new Date().toISOString();
const turnAttachmentsPromise = Promise.all(
- composerImagesSnapshot.map(async (image) => ({
- type: "image" as const,
- name: image.name,
- mimeType: image.mimeType,
- sizeBytes: image.sizeBytes,
- dataUrl: await readFileAsDataUrl(image.file),
- })),
+ composerImagesSnapshot.map(async (image) => {
+ if (supportsAttachmentUploads) {
+ const uploaded = getUploadedAttachments({ environmentId, images: [image] })?.[0];
+ if (!uploaded) {
+ throw new Error(`Image '${image.name}' did not finish uploading.`);
+ }
+ return uploaded;
+ }
+ return {
+ type: "image" as const,
+ name: image.name,
+ mimeType: image.mimeType,
+ sizeBytes: image.sizeBytes,
+ dataUrl: await readFileAsDataUrl(image.file),
+ };
+ }),
);
const optimisticAttachments = composerImagesSnapshot.map((image) => ({
type: "image" as const,
@@ -5798,6 +6255,18 @@ function ChatViewContent(props: ChatViewProps) {
: {}),
}
: undefined;
+ beginLocalDispatch({
+ preparingWorktree: false,
+ messageId: messageIdForSend,
+ submissionIntent: resolvedSubmissionIntent,
+ });
+ const backgroundThreadRef =
+ resolvedSubmissionIntent === "background"
+ ? scopeThreadRef(activeThread.environmentId, threadIdForSend)
+ : null;
+ if (backgroundThreadRef) {
+ beginBackgroundDraftSubmissionByRef(backgroundThreadRef);
+ }
const queuedTurnInput = {
commandId: newCommandId(),
threadId: threadIdForSend,
@@ -5837,6 +6306,9 @@ function ChatViewContent(props: ChatViewProps) {
inFlightThreadTurnSends.delete(messageIdForSend);
}
if (startResult._tag === "Failure") {
+ if (backgroundThreadRef) {
+ clearBackgroundDraftSubmissionByRef(backgroundThreadRef);
+ }
const error = squashAtomCommandFailure(startResult);
const message = error instanceof Error ? error.message : String(error);
if (outboxPersisted && isTransportConnectionErrorMessage(message)) {
@@ -5855,7 +6327,57 @@ function ChatViewContent(props: ChatViewProps) {
console.warn("[thread-turn-outbox] failed to remove delivered turn", error);
});
turnStartSucceeded = true;
+ if (supportsAttachmentUploads) {
+ releaseAttachmentUploads(composerImagesSnapshot);
+ }
acknowledgeActiveThreadWoke();
+ if (backgroundThreadRef) {
+ markPromotedDraftThreadByRef(backgroundThreadRef);
+ try {
+ const nextDraft = await handleNewThread(
+ scopeProjectRef(activeProject.environmentId, activeProject.id),
+ resolveBackgroundDraftWorkspaceOptions({
+ envMode: sendEnvMode,
+ branch: activeThreadBranch,
+ startFromOrigin,
+ }),
+ );
+ if (nextDraft) {
+ finalizePromotedDraftThreadByRef(backgroundThreadRef);
+ toastManager.add(
+ stackedThreadToast({
+ type: "success",
+ title: "Started in background",
+ timeout: 5_000,
+ actionProps: {
+ children: "Open",
+ onClick: () => {
+ void navigate({
+ to: "/$environmentId/$threadId",
+ params: buildThreadRouteParams(backgroundThreadRef),
+ });
+ },
+ },
+ }),
+ );
+ } else {
+ clearBackgroundDraftSubmissionByRef(backgroundThreadRef);
+ }
+ } catch (error) {
+ clearBackgroundDraftSubmissionByRef(backgroundThreadRef);
+ resetLocalDispatch();
+ toastManager.add(
+ stackedThreadToast({
+ type: "warning",
+ title: "Task started in the background",
+ description:
+ error instanceof Error
+ ? `Could not open a fresh composer: ${error.message}`
+ : "Could not open a fresh composer.",
+ }),
+ );
+ }
+ }
}
}
@@ -5903,6 +6425,20 @@ function ChatViewContent(props: ChatViewProps) {
}
if (!isAtomCommandInterrupted(failure)) {
const error = squashAtomCommandFailure(failure);
+ if (isLocalDraftThread && draftId && wasBootstrapThreadDeleted(error)) {
+ const failedDraftSession = getDraftSession(draftId);
+ if (failedDraftSession?.threadId === threadIdForSend) {
+ setLogicalProjectDraftThreadId(
+ failedDraftSession.logicalProjectKey,
+ scopeProjectRef(failedDraftSession.environmentId, failedDraftSession.projectId),
+ draftId,
+ {
+ threadId: newThreadId(),
+ createdAt: new Date().toISOString(),
+ },
+ );
+ }
+ }
const message = error instanceof Error ? error.message : "Failed to send message.";
if (isIdentityClaimRequiredMessage(message)) {
requestIdentityClaimGate(activeThread.environmentId);
@@ -6833,7 +7369,7 @@ function ChatViewContent(props: ChatViewProps) {
configuredUrls={configuredPreviewUrls}
visible
onSendAnnotation={(annotation, image) => {
- void onSend(undefined, { annotation, image });
+ void onSend(undefined, "foreground", { annotation, image });
}}
/>
@@ -6888,7 +7424,7 @@ function ChatViewContent(props: ChatViewProps) {
context={
isThreadOwnPullRequest(
{
- projectId: activeProject?.id ?? null,
+ projectId: linkedThreadPullRequest?.projectId ?? activeProject?.id ?? null,
repository: threadPullRequestRepository,
number: activeThreadPr?.number ?? null,
},
@@ -6961,9 +7497,9 @@ function ChatViewContent(props: ChatViewProps) {
>
{!rightPanelOpen ? panelLayoutControls : null}
>
)}
+ {threadSyncPhase && !activeEnvironmentUnavailable ? (
+
+ ) : null}
+
{
+ event.preventDefault();
+ }}
+ onClick={() => {
+ void submitAddProjectCloneFlow();
+ }}
+ />
+ }
+ >
+ {isRemoteProjectPending ? "Working" : remoteProjectButtonLabel}
+
+ Enter
+
+
+
{remoteProjectButtonLabel ?? "Continue"} (Enter)
+
+ ) : isBrowsing ? (
+
+ {
+ event.preventDefault();
+ }}
+ onClick={() => {
+ if (relativePathNeedsActiveProject) {
+ return;
+ }
+ if (isCloneDestinationStep) {
+ void submitAddProjectCloneFlow(resolvedAddProjectPath);
+ } else {
+ void handleAddProject(resolvedAddProjectPath);
+ }
+ }}
+ />
+ }
+ >
+
+ {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel}
+
+
+ {hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}
+
+
+
+ {submitActionLabel} ({addShortcutLabel})
+
+
+ ) : !isSubmenu ? (
+
+ {
+ event.preventDefault();
+ }}
+ onClick={() => {
+ setIncludeArchived((previous) => !previous);
+ }}
+ />
+ }
+ >
+
+ Archived
+
+
+ {includeArchived
+ ? "Hide archived threads from search"
+ : "Include archived threads in search"}
+
+
+ ) : null;
+
+ const footerActionLabel =
+ addProjectCloneFlow?.step === "repository"
+ ? (remoteProjectButtonLabel ?? "Continue")
+ : !canSubmitBrowsePath || hasHighlightedBrowseItem
+ ? "Select"
+ : undefined;
+
+ const footerTrailing = canOpenProjectFromFileManager ? (
+
{
+ void handleOpenProjectFromFileManager();
+ }}
+ >
+ {`Open in ${fileManagerName}`}
+
+ ) : null;
+
return (
- {addProjectCloneFlow?.step === "repository" ? (
-
- {
- event.preventDefault();
- }}
- onClick={() => {
- void submitAddProjectCloneFlow();
- }}
- />
- }
- >
- {isRemoteProjectPending ? "Working" : remoteProjectButtonLabel}
-
- Enter
-
-
-
- {remoteProjectButtonLabel ?? "Continue"} (Enter)
-
-
- ) : isBrowsing ? (
-
- {
- event.preventDefault();
- }}
- onClick={() => {
- if (relativePathNeedsActiveProject) {
- return;
- }
- if (isCloneDestinationStep) {
- void submitAddProjectCloneFlow(resolvedAddProjectPath);
- } else {
- void handleAddProject(resolvedAddProjectPath);
- }
- }}
- />
- }
- >
-
- {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel}
-
-
- {hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}
-
-
-
- {submitActionLabel} ({addShortcutLabel})
-
-
- ) : !isSubmenu ? (
-
- {
- event.preventDefault();
- }}
- onClick={() => {
- setIncludeArchived((previous) => !previous);
- }}
- />
- }
- >
-
- Archived
-
-
- {includeArchived
- ? "Hide archived threads from search"
- : "Include archived threads in search"}
-
-
- ) : null}
+ {inputAccessory}
{remoteProjectContext ? (
@@ -2576,15 +2594,10 @@ function OpenCommandPaletteDialog(props: {
Navigate
- {addProjectCloneFlow?.step === "repository" ? (
-
- Enter
- {remoteProjectButtonLabel ?? "Continue"}
-
- ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? (
+ {footerActionLabel ? (
Enter
- Select
+ {footerActionLabel}
) : null}
{isSubmenu ? (
@@ -2598,19 +2611,7 @@ function OpenCommandPaletteDialog(props: {
Close
- {canOpenProjectFromFileManager ? (
-