- {children}
-
- {leadingActions}
+ // The transparent border and header padding match the code frame, so the
+ // actions stay in place when switching views.
+
+
+ {viewToggle}
{copyButton}
+ {children}
);
}
@@ -991,7 +994,6 @@ function MarkdownCodeBlock({
headerProps={THREAD_FIND_IGNORE_PROPS}
actions={
- {leadingActions}
{canWrap ? (
Run in terminal
) : null}
+ {viewToggle}
{copyButton}
}
@@ -1072,7 +1075,7 @@ function MarkdownMermaidCodeBlock({
isStreaming={isStreaming}
canWrap={!showDiagram}
diagram={showDiagram}
- leadingActions={
+ viewToggle={
isStreaming ? null : (
();
+// The factories below build functions ChatView calls during render, or hands
+// to ChatComposer, which can call them after an await. They live outside the
+// component so each closure keeps only its inputs, not a whole render scope
+// (see useStableCallback), while still acting on the render that made them.
+
+/** Records a thread error in local state, keyed by the route the making render saw. */
+function makeThreadErrorSetter(input: {
+ readonly routeThreadRef: ScopedThreadRef;
+ readonly routeThreadKey: string;
+ readonly draftId: DraftId | null;
+ readonly serverThreadRef: ScopedThreadRef | null;
+ readonly setLocalServerErrorsByThreadKey: (
+ update: SetStateAction>,
+ ) => void;
+ readonly setLocalDraftErrorsByDraftId: (
+ update: SetStateAction>,
+ ) => void;
+}) {
+ return (targetThreadId: ThreadId | null, error: string | null) => {
+ if (!targetThreadId) return;
+ const nextError = sanitizeThreadErrorMessage(error);
+ const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() };
+ const { routeThreadRef, serverThreadRef } = input;
+ if (
+ serverThreadRef &&
+ targetThreadId === routeThreadRef.threadId &&
+ serverThreadRef.environmentId === routeThreadRef.environmentId &&
+ serverThreadRef.threadId === targetThreadId
+ ) {
+ input.setLocalServerErrorsByThreadKey((existing) => {
+ if ((existing[input.routeThreadKey]?.message ?? null) === nextError) {
+ return existing;
+ }
+ return { ...existing, [input.routeThreadKey]: nextEntry };
+ });
+ return;
+ }
+ const localDraftErrorKey = input.draftId ?? targetThreadId;
+ input.setLocalDraftErrorsByDraftId((existing) => {
+ if ((existing[localDraftErrorKey]?.message ?? null) === nextError) {
+ return existing;
+ }
+ return { ...existing, [localDraftErrorKey]: nextEntry };
+ });
+ };
+}
+
+/** Writes the custom answer for a question of the pending user input the making render saw. */
+function makePendingCustomAnswerSetter(input: {
+ readonly activePendingUserInput: PendingUserInput | null;
+ readonly activePendingRequestKey: string;
+ readonly promptRef: { current: string };
+ readonly setPendingUserInputAnswersByRequestId: (
+ update: SetStateAction>>,
+ ) => void;
+}) {
+ return (questionId: string, value: string) => {
+ const { activePendingUserInput, activePendingRequestKey } = input;
+ if (!activePendingUserInput) {
+ return;
+ }
+ const question = activePendingUserInput.questions.find((entry) => entry.id === questionId);
+ if (!question || question.allowCustomAnswer === false) {
+ return;
+ }
+ input.promptRef.current = value;
+ input.setPendingUserInputAnswersByRequestId((existing) => ({
+ ...existing,
+ [activePendingRequestKey]: {
+ ...existing[activePendingRequestKey],
+ [questionId]: setPendingUserInputCustomAnswer(
+ existing[activePendingRequestKey]?.[questionId],
+ value,
+ question,
+ ),
+ },
+ }));
+ // The editor owns this selection. Resetting it before the answer renders
+ // can re-enter Lexical with offsets from the previous skill token.
+ };
+}
+
+/** Draft id for editing a queued run of `threadRef`. */
+function makeQueuedEditDraftTarget(threadRef: ScopedThreadRef) {
+ return (runId: RunId) => DraftId.make(`queued-edit:${scopedThreadKey(threadRef)}:${runId}`);
+}
+
+/** Why the model picker should disable a model, or null when it can be chosen. */
+function makeModelDisabledReason(
+ context: Omit<
+ Parameters[0],
+ "nextModelSelection"
+ > | null,
+) {
+ return (instanceId: ProviderInstanceId, model: string): string | null => {
+ if (!context) return null;
+ const reason = getStartedThreadModelChangeBlockReason({
+ ...context,
+ nextModelSelection: { instanceId, model },
+ });
+ return reason ? `${reason.description} Start a new thread to use this model.` : null;
+ };
+}
+
export default function ChatView(props: ChatViewProps) {
const {
environmentId,
@@ -1591,10 +1699,8 @@ export default function ChatView(props: ChatViewProps) {
} = props;
const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
- const hasTerminalWriteAccess = useCallback(
- () => readEnvironmentScope(environmentId, AuthTerminalOperateScope),
- [environmentId],
- );
+ const hasTerminalWriteAccess = () =>
+ readEnvironmentScope(environmentId, AuthTerminalOperateScope);
const canReadTerminal = useEnvironmentScope(environmentId, AuthTerminalReadScope);
const draftId = routeKind === "draft" ? props.draftId : null;
const handleNewThread = useNewThreadHandler();
@@ -1695,8 +1801,8 @@ export default function ChatView(props: ChatViewProps) {
readonly existingAttachments: ReadonlyArray;
readonly context?: import("@t3tools/contracts").OrchestrationMessageContext | undefined;
} | null>(null);
- const queuedEditDraftTargetFor = useCallback(
- (runId: RunId) => DraftId.make(`queued-edit:${scopedThreadKey(routeThreadRef)}:${runId}`),
+ const queuedEditDraftTargetFor = useMemo(
+ () => makeQueuedEditDraftTarget(routeThreadRef),
[routeThreadRef],
);
const baseComposerDraftTarget: ScopedThreadRef | DraftId =
@@ -1860,21 +1966,21 @@ export default function ChatView(props: ChatViewProps) {
const [restingComposerControlsHost, setRestingComposerControlsHost] =
useState(null);
const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false);
- const citeAssistantText = useCallback(
- (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => {
- const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false;
- if (!inserted) {
- toastManager.add({
- type: "warning",
- title: "The composer is not ready",
- description:
- "Try citing the selection after the connection or pending input is resolved.",
- });
- }
- return inserted;
- },
- [composerRef],
- );
+ const citeAssistantText = (
+ citation: AssistantCitation,
+ sourceAnchor: AssistantCitationSourceAnchor,
+ ) => {
+ const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false;
+ if (!inserted) {
+ toastManager.add({
+ type: "warning",
+ title: "The composer is not ready",
+ description: "Try citing the selection after the connection or pending input is resolved.",
+ });
+ }
+ return inserted;
+ };
+ const citeAssistantTextStable = useStableCallback(citeAssistantText);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false);
const [expandedImage, setExpandedImage] = useState(null);
@@ -1975,10 +2081,8 @@ export default function ChatView(props: ChatViewProps) {
LastInvokedScriptByProjectSchema,
);
const legendListRef = useRef(null);
- const getTimelineScrollableNode = useCallback(
- () => legendListRef.current?.getScrollableNode() ?? null,
- [],
- );
+ const getTimelineScrollableNode = () => legendListRef.current?.getScrollableNode() ?? null;
+ const getTimelineScrollableNodeStable = useStableCallback(getTimelineScrollableNode);
const [composerOverlayElement, setComposerOverlayElement] = useState(null);
// Space the timeline keeps clear above its end. Tracks the overlay while the
// composer is expanded and holds that height while it rests, so the resting
@@ -1990,10 +2094,9 @@ export default function ChatView(props: ChatViewProps) {
const composerMountedRef = useRef(true);
const [scrollToEndClearance, setScrollToEndClearance] = useState(0);
const isAtEndRef = useRef(true);
- const isTimelineAtLogicalEnd = useCallback(
- () => resolveTimelineIsAtEnd(legendListRef.current?.getState()) ?? isAtEndRef.current,
- [],
- );
+ const isTimelineAtLogicalEnd = () =>
+ resolveTimelineIsAtEnd(legendListRef.current?.getState()) ?? isAtEndRef.current;
+ const isTimelineAtLogicalEndStable = useStableCallback(isTimelineAtLogicalEnd);
// Whether the timeline's rows extend past the viewport above the composer.
// The composer only rests when there is reading space to give back.
const [timelineOverflows, setTimelineOverflows] = useState(false);
@@ -2008,15 +2111,15 @@ export default function ChatView(props: ChatViewProps) {
const isResuming = resumingThreadKeys.has(routeThreadKey);
const composerSendGenerationRef = useRef(0);
const multipleModelSelections = fanoutState.selections;
- const setMultipleModelSelections = useCallback(
- (selections: SetStateAction | null>) => {
- appAtomRegistry.update(fanoutStateAtom, (current) => ({
- ...current,
- selections: typeof selections === "function" ? selections(current.selections) : selections,
- }));
- },
- [fanoutStateAtom],
- );
+ const setMultipleModelSelections = (
+ selections: SetStateAction | null>,
+ ) => {
+ appAtomRegistry.update(fanoutStateAtom, (current) => ({
+ ...current,
+ selections: typeof selections === "function" ? selections(current.selections) : selections,
+ }));
+ };
+ const setMultipleModelSelectionsStable = useStableCallback(setMultipleModelSelections);
const multipleModelSelectionsRef = useRef(multipleModelSelections);
multipleModelSelectionsRef.current = multipleModelSelections;
const uncertainMultipleSubmissionsRef = fanoutState.uncertainSubmissions;
@@ -2358,16 +2461,13 @@ export default function ChatView(props: ChatViewProps) {
[activeKnownTerminalIds, panelTerminalIds],
);
const canReuseTerminal = activeThreadKnownSessionsRaw !== null;
- const allocateTerminalId = useCallback(
- () =>
- nextTerminalId(
- allocatableActiveTerminalIds,
- canReuseTerminal && readEnvironmentScope(environmentId, AuthTerminalReadScope)
- ? undefined
- : randomUUID(),
- ),
- [allocatableActiveTerminalIds, canReuseTerminal, environmentId],
- );
+ const allocateTerminalId = () =>
+ nextTerminalId(
+ allocatableActiveTerminalIds,
+ canReuseTerminal && readEnvironmentScope(environmentId, AuthTerminalReadScope)
+ ? undefined
+ : randomUUID(),
+ );
const previewPanelOpen = activeRightPanelKind === "preview" && browserAvailable;
const rightPanelOpen = rightPanelState.isOpen;
const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
@@ -2518,25 +2618,23 @@ export default function ChatView(props: ChatViewProps) {
const removeClonedProject = useRemoveClonedProject();
// The banner mirrors the server's clone state, so a request that never got
// there needs its own feedback.
- const runProjectCloneAction = useCallback(
- async (
- title: string,
- action: () => Promise>,
- ): Promise => {
- const result = await action();
- if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title,
- description: error instanceof Error ? error.message : "An error occurred.",
- }),
- );
- }
- },
- [],
- );
+ const runProjectCloneAction = async (
+ title: string,
+ action: () => Promise>,
+ ): Promise => {
+ const result = await action();
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title,
+ description: error instanceof Error ? error.message : "An error occurred.",
+ }),
+ );
+ }
+ };
+ const runProjectCloneActionStable = useStableCallback(runProjectCloneAction);
const projectCloneSendBlockReason =
activeProjectClone === null
? null
@@ -2564,7 +2662,7 @@ export default function ChatView(props: ChatViewProps) {
size="xs"
variant="ghost"
onClick={() =>
- void runProjectCloneAction("Failed to cancel clone", () =>
+ void runProjectCloneActionStable("Failed to cancel clone", () =>
cancelProjectClone({ environmentId, input: { projectId } }),
)
}
@@ -2594,7 +2692,7 @@ export default function ChatView(props: ChatViewProps) {
size="xs"
variant="ghost"
onClick={() =>
- void runProjectCloneAction("Failed to retry clone", () =>
+ void runProjectCloneActionStable("Failed to retry clone", () =>
retryProjectClone({ environmentId, input: { projectId } }),
)
}
@@ -2610,24 +2708,26 @@ export default function ChatView(props: ChatViewProps) {
cancelProjectClone,
removeClonedProject,
retryProjectClone,
- runProjectCloneAction,
+ runProjectCloneActionStable,
]);
const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection;
- const handleNewThreadInActiveProject = useCallback(() => {
+ const handleNewThreadInActiveProject = () => {
startNewThreadForProject(activeProjectRef, handleNewThread);
- }, [activeProjectRef, handleNewThread]);
+ };
+ const handleNewThreadInActiveProjectStable = useStableCallback(handleNewThreadInActiveProject);
const projectGroupingSettings = selectProjectGroupingSettings(settings);
const activeDraftLogicalProjectKey =
!isServerThread && activeProject
? deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings)
: undefined;
- const handleOpenDraftProjectSettings = useCallback(() => {
+ const handleOpenDraftProjectSettings = () => {
if (!activeDraftLogicalProjectKey) return;
void navigate({
to: "/projects/$projectKey",
params: { projectKey: activeDraftLogicalProjectKey },
});
- }, [activeDraftLogicalProjectKey, navigate]);
+ };
+ const handleOpenDraftProjectSettingsStable = useStableCallback(handleOpenDraftProjectSettings);
const activeEnvironmentShell = useEnvironmentQuery(
activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null,
);
@@ -2641,24 +2741,22 @@ export default function ChatView(props: ChatViewProps) {
const pendingFileSurfaceIds = activeProjectKey
? (pendingFileSurfaceIdsByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS)
: EMPTY_PENDING_FILE_SURFACE_IDS;
- const handleFilePendingChange = useCallback(
- (relativePath: string, pending: boolean) => {
- if (!activeProjectKey) return;
- setPendingFileSurfaceIdsByProject((currentByProject) => {
- const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS;
- const surfaceId = `file:${relativePath}`;
- if (current.has(surfaceId) === pending) return currentByProject;
- const next = new Set(current);
- if (pending) next.add(surfaceId);
- else next.delete(surfaceId);
- const nextByProject = new Map(currentByProject);
- if (next.size === 0) nextByProject.delete(activeProjectKey);
- else nextByProject.set(activeProjectKey, next);
- return nextByProject;
- });
- },
- [activeProjectKey],
- );
+ const handleFilePendingChange = (relativePath: string, pending: boolean) => {
+ if (!activeProjectKey) return;
+ setPendingFileSurfaceIdsByProject((currentByProject) => {
+ const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS;
+ const surfaceId = `file:${relativePath}`;
+ if (current.has(surfaceId) === pending) return currentByProject;
+ const next = new Set(current);
+ if (pending) next.add(surfaceId);
+ else next.delete(surfaceId);
+ const nextByProject = new Map(currentByProject);
+ if (next.size === 0) nextByProject.delete(activeProjectKey);
+ else nextByProject.set(activeProjectKey, next);
+ return nextByProject;
+ });
+ };
+ const handleFilePendingChangeStable = useStableCallback(handleFilePendingChange);
const configuredPreviewUrls = useMemo(
() => getConfiguredPreviewUrls(activeProjectScripts),
[activeProjectScripts],
@@ -2712,21 +2810,21 @@ export default function ChatView(props: ChatViewProps) {
connection: activeEnvironment.connection,
};
}, [activeEnvironment, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel]);
- const handleReconnectActiveEnvironment = useCallback(
- async (environmentId: EnvironmentId) => {
- const result = await retryEnvironment(environmentId);
- if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Could not reconnect environment",
- description: error instanceof Error ? error.message : "Failed to reconnect.",
- }),
- );
- }
- },
- [retryEnvironment],
+ const handleReconnectActiveEnvironment = async (environmentId: EnvironmentId) => {
+ const result = await retryEnvironment(environmentId);
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Could not reconnect environment",
+ description: error instanceof Error ? error.message : "Failed to reconnect.",
+ }),
+ );
+ }
+ };
+ const handleReconnectActiveEnvironmentStable = useStableCallback(
+ handleReconnectActiveEnvironment,
);
const disconnectDelayElapsed = useEnvironmentDisconnectDelay(
activeEnvironmentUnavailable ? activeEnvironment.environmentId : null,
@@ -2737,27 +2835,27 @@ export default function ChatView(props: ChatViewProps) {
activeEnvironment.entry.target._tag !== "PrimaryConnectionTarget" &&
!isDesktopLocalConnectionTarget(activeEnvironment.entry.target);
const [disconnectingEnvironment, setDisconnectingEnvironment] = useState(false);
- const handleDisconnectActiveEnvironment = useCallback(
- async (environmentId: EnvironmentId) => {
- setDisconnectingEnvironment(true);
- const result = await setEnvironmentEnabled({ environmentId, enabled: false });
- setDisconnectingEnvironment(false);
- if (result._tag === "Failure") {
- if (!isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Could not disconnect server",
- description: error instanceof Error ? error.message : "Failed to disconnect.",
- }),
- );
- }
- return;
+ const handleDisconnectActiveEnvironment = async (environmentId: EnvironmentId) => {
+ setDisconnectingEnvironment(true);
+ const result = await setEnvironmentEnabled({ environmentId, enabled: false });
+ setDisconnectingEnvironment(false);
+ if (result._tag === "Failure") {
+ if (!isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Could not disconnect server",
+ description: error instanceof Error ? error.message : "Failed to disconnect.",
+ }),
+ );
}
- void navigate({ to: "/", replace: true });
- },
- [navigate, setEnvironmentEnabled],
+ return;
+ }
+ void navigate({ to: "/", replace: true });
+ };
+ const handleDisconnectActiveEnvironmentStable = useStableCallback(
+ handleDisconnectActiveEnvironment,
);
const { scratchWorkspaceRootFor, openScratchProject } = useScratchProject();
const activeProjectIsScratch =
@@ -2839,112 +2937,96 @@ export default function ChatView(props: ChatViewProps) {
activeEnvironment: activeEnvironmentOption,
canPickEnvironment: hasMultipleEnvironments,
});
- const openPullRequestDialog = useCallback(
- (reference?: string) => {
- if (!canCheckoutPullRequestIntoThread) {
- return;
- }
- setPullRequestDialogState({
- initialReference: reference ?? null,
- key: Date.now(),
- });
- },
- [canCheckoutPullRequestIntoThread],
- );
+ const openPullRequestDialog = (reference?: string) => {
+ if (!canCheckoutPullRequestIntoThread) {
+ return;
+ }
+ setPullRequestDialogState({
+ initialReference: reference ?? null,
+ key: Date.now(),
+ });
+ };
+ const openPullRequestDialogStable = useStableCallback(openPullRequestDialog);
- const closePullRequestDialog = useCallback(() => {
+ const closePullRequestDialog = () => {
setPullRequestDialogState(null);
- }, []);
-
- const openOrReuseProjectDraftThread = useCallback(
- async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => {
- if (!activeProject) {
- throw new Error("No active project is available for this pull request.");
- }
- const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id);
- const logicalProjectKey = deriveLogicalProjectKeyFromSettings(
- activeProject,
- projectGroupingSettings,
- );
- const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey);
- if (storedDraftSession) {
- setDraftThreadContext(storedDraftSession.draftId, input);
- setLogicalProjectDraftThreadId(
- logicalProjectKey,
- activeProjectRef,
- storedDraftSession.draftId,
- {
- threadId: storedDraftSession.threadId,
- ...input,
- },
- );
- if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) {
- await navigate({
- to: "/draft/$draftId",
- params: buildDraftThreadRouteParams(storedDraftSession.draftId),
- });
- }
- return storedDraftSession.threadId;
- }
+ };
- const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null;
- if (
- !isServerThread &&
- activeDraftSession?.logicalProjectKey === logicalProjectKey &&
- draftId
- ) {
- setDraftThreadContext(draftId, input);
- setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, {
- threadId: activeDraftSession.threadId,
- createdAt: activeDraftSession.createdAt,
- runtimeMode: activeDraftSession.runtimeMode,
- interactionMode: activeDraftSession.interactionMode,
+ const openOrReuseProjectDraftThread = async (input: {
+ branch: string;
+ worktreePath: string | null;
+ envMode: DraftThreadEnvMode;
+ }) => {
+ if (!activeProject) {
+ throw new Error("No active project is available for this pull request.");
+ }
+ const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id);
+ const logicalProjectKey = deriveLogicalProjectKeyFromSettings(
+ activeProject,
+ projectGroupingSettings,
+ );
+ const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey);
+ if (storedDraftSession) {
+ setDraftThreadContext(storedDraftSession.draftId, input);
+ setLogicalProjectDraftThreadId(
+ logicalProjectKey,
+ activeProjectRef,
+ storedDraftSession.draftId,
+ {
+ threadId: storedDraftSession.threadId,
...input,
+ },
+ );
+ if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) {
+ await navigate({
+ to: "/draft/$draftId",
+ params: buildDraftThreadRouteParams(storedDraftSession.draftId),
});
- return activeDraftSession.threadId;
}
+ return storedDraftSession.threadId;
+ }
- const nextDraftId = newDraftId();
- const nextThreadId = newThreadId();
- setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, {
- threadId: nextThreadId,
- createdAt: new Date().toISOString(),
- runtimeMode: resolveProjectSettings(settings, activeProject.id, activeProject).settings
- .defaultRuntimeMode,
- interactionMode: DEFAULT_INTERACTION_MODE,
+ const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null;
+ if (!isServerThread && activeDraftSession?.logicalProjectKey === logicalProjectKey && draftId) {
+ setDraftThreadContext(draftId, input);
+ setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, {
+ threadId: activeDraftSession.threadId,
+ createdAt: activeDraftSession.createdAt,
+ runtimeMode: activeDraftSession.runtimeMode,
+ interactionMode: activeDraftSession.interactionMode,
...input,
});
- await navigate({
- to: "/draft/$draftId",
- params: buildDraftThreadRouteParams(nextDraftId),
- });
- return nextThreadId;
- },
- [
- activeProject,
- draftId,
- getDraftSession,
- getDraftSessionByLogicalProjectKey,
- isServerThread,
- navigate,
- projectGroupingSettings,
- routeKind,
- settings,
- setDraftThreadContext,
- setLogicalProjectDraftThreadId,
- ],
- );
+ return activeDraftSession.threadId;
+ }
- const handlePreparedPullRequestThread = useCallback(
- async (input: { branch: string; worktreePath: string | null }) => {
- await openOrReuseProjectDraftThread({
- branch: input.branch,
- worktreePath: input.worktreePath,
- envMode: input.worktreePath ? "worktree" : "local",
- });
- },
- [openOrReuseProjectDraftThread],
- );
+ const nextDraftId = newDraftId();
+ const nextThreadId = newThreadId();
+ setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, {
+ threadId: nextThreadId,
+ createdAt: new Date().toISOString(),
+ runtimeMode: resolveProjectSettings(settings, activeProject.id, activeProject).settings
+ .defaultRuntimeMode,
+ interactionMode: DEFAULT_INTERACTION_MODE,
+ ...input,
+ });
+ await navigate({
+ to: "/draft/$draftId",
+ params: buildDraftThreadRouteParams(nextDraftId),
+ });
+ return nextThreadId;
+ };
+
+ const handlePreparedPullRequestThread = async (input: {
+ branch: string;
+ worktreePath: string | null;
+ }) => {
+ await openOrReuseProjectDraftThread({
+ branch: input.branch,
+ worktreePath: input.worktreePath,
+ envMode: input.worktreePath ? "worktree" : "local",
+ });
+ };
+ const handlePreparedPullRequestThreadStable = useStableCallback(handlePreparedPullRequestThread);
useEffect(() => {
if (!serverThread?.id) return;
@@ -3111,7 +3193,9 @@ export default function ChatView(props: ChatViewProps) {
disabled={disconnectingEnvironment}
title="Hide this server's threads. Switch it on again in Connections."
onClick={() =>
- void handleDisconnectActiveEnvironment(activeEnvironmentUnavailableState.environmentId)
+ void handleDisconnectActiveEnvironmentStable(
+ activeEnvironmentUnavailableState.environmentId,
+ )
}
>
Disconnect server
@@ -3139,7 +3223,7 @@ export default function ChatView(props: ChatViewProps) {
size="xs"
variant="ghost"
onClick={() =>
- void handleReconnectActiveEnvironment(
+ void handleReconnectActiveEnvironmentStable(
activeEnvironmentUnavailableState.environmentId,
)
}
@@ -3238,10 +3322,10 @@ export default function ChatView(props: ChatViewProps) {
automaticEnvironment,
autoBalanceUpdateBanner,
activeEnvironmentUnavailableState,
- handleReconnectActiveEnvironment,
+ handleReconnectActiveEnvironmentStable,
canDisconnectActiveEnvironment,
disconnectingEnvironment,
- handleDisconnectActiveEnvironment,
+ handleDisconnectActiveEnvironmentStable,
setDismissedVersionMismatchKey,
showVersionMismatchBanner,
reconnectWarningGraceElapsed,
@@ -3559,7 +3643,7 @@ export default function ChatView(props: ChatViewProps) {
activeProviderStatus !== null &&
hasProviderUsageLimits(activeProviderStatus.driver, providerStatuses, usageLimitSources);
// Answered locally from the last Limits snapshot; the agent never sees it.
- const openUsageLimits = useCallback(() => {
+ const openUsageLimits = () => {
const now = Date.now();
const report =
activeProviderInstanceId !== null && usageLimitsKey !== null
@@ -3577,21 +3661,13 @@ export default function ChatView(props: ChatViewProps) {
setUsageLimitsPanel(null);
toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" });
return false;
- }, [
- activeProviderInstanceId,
- providerStatuses,
- routeThreadKey,
- usageLimitSources,
- usageLimitsKey,
- ]);
+ };
+ const openUsageLimitsStable = useStableCallback(openUsageLimits);
// Responses can resolve after navigating away; only the originating thread's panel clears.
- const clearUsageLimitsFor = useCallback(
- (threadKey: string) =>
- setUsageLimitsPanel((current) =>
- current !== null && current.threadKey === threadKey ? null : current,
- ),
- [],
- );
+ const clearUsageLimitsFor = (threadKey: string) =>
+ setUsageLimitsPanel((current) =>
+ current !== null && current.threadKey === threadKey ? null : current,
+ );
const {
beginLocalDispatch,
resetLocalDispatch,
@@ -3693,27 +3769,28 @@ export default function ChatView(props: ChatViewProps) {
useEffect(() => {
attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
}, [attachmentPreviewHandoffByMessageId]);
- const clearAttachmentPreviewHandoff = useCallback(
- (messageId: MessageId, previewUrls?: ReadonlyArray) => {
- delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId];
- const currentPreviewUrls =
- previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? [];
- setAttachmentPreviewHandoffByMessageId((existing) => {
- if (!(messageId in existing)) {
- return existing;
- }
- const next = { ...existing };
- delete next[messageId];
- attachmentPreviewHandoffByMessageIdRef.current = next;
- return next;
- });
- for (const previewUrl of currentPreviewUrls) {
- revokeBlobPreviewUrl(previewUrl);
+ const clearAttachmentPreviewHandoff = (
+ messageId: MessageId,
+ previewUrls?: ReadonlyArray,
+ ) => {
+ delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId];
+ const currentPreviewUrls =
+ previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? [];
+ setAttachmentPreviewHandoffByMessageId((existing) => {
+ if (!(messageId in existing)) {
+ return existing;
}
- },
- [],
- );
- const clearAttachmentPreviewHandoffs = useCallback(() => {
+ const next = { ...existing };
+ delete next[messageId];
+ attachmentPreviewHandoffByMessageIdRef.current = next;
+ return next;
+ });
+ for (const previewUrl of currentPreviewUrls) {
+ revokeBlobPreviewUrl(previewUrl);
+ }
+ };
+ const clearAttachmentPreviewHandoffStable = useStableCallback(clearAttachmentPreviewHandoff);
+ const clearAttachmentPreviewHandoffs = () => {
attachmentPreviewPromotionInFlightByMessageIdRef.current = {};
for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) {
for (const previewUrl of previewUrls) {
@@ -3722,16 +3799,17 @@ export default function ChatView(props: ChatViewProps) {
}
attachmentPreviewHandoffByMessageIdRef.current = {};
setAttachmentPreviewHandoffByMessageId({});
- }, []);
+ };
+ const clearAttachmentPreviewHandoffsStable = useStableCallback(clearAttachmentPreviewHandoffs);
useEffect(() => {
return () => {
- clearAttachmentPreviewHandoffs();
+ clearAttachmentPreviewHandoffsStable();
for (const message of optimisticUserMessagesRef.current) {
revokeUserMessagePreviewUrls(message);
}
};
- }, [clearAttachmentPreviewHandoffs]);
- const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
+ }, [clearAttachmentPreviewHandoffsStable]);
+ const handoffAttachmentPreviews = (messageId: MessageId, previewUrls: string[]) => {
if (previewUrls.length === 0) return;
const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? [];
@@ -3749,45 +3827,42 @@ export default function ChatView(props: ChatViewProps) {
attachmentPreviewHandoffByMessageIdRef.current = next;
return next;
});
- }, []);
- const downloadFileAttachment = useCallback(
- async (attachment: ChatFileAttachment) => {
- const connection = readPreparedConnection(environmentId);
- if (!connection) {
- toastManager.add({ type: "error", title: "The environment is not connected." });
- return;
- }
+ };
+ const handoffAttachmentPreviewsStable = useStableCallback(handoffAttachmentPreviews);
+ const downloadFileAttachment = async (attachment: ChatFileAttachment) => {
+ const connection = readPreparedConnection(environmentId);
+ if (!connection) {
+ toastManager.add({ type: "error", title: "The environment is not connected." });
+ return;
+ }
- try {
- const url = await resolveFileAttachmentUrl({
- attachment,
- environmentId,
- httpBaseUrl: connection.httpBaseUrl,
- createAssetUrl: createAttachmentAssetUrl,
- });
- const anchor = document.createElement("a");
- anchor.href = url;
- anchor.download = attachment.name;
- anchor.click();
- } catch (error) {
- toastManager.add({
- type: "error",
- title: "Could not download " + attachment.name,
- description: error instanceof Error ? error.message : "The attachment is unavailable.",
- });
- }
- },
- [createAttachmentAssetUrl, environmentId],
- );
- const openFileAttachment = useCallback(
- (attachment: ChatFileAttachment) => {
- if (activeThreadRef) {
- useRightPanelStore.getState().openAttachment(activeThreadRef, attachment);
- return;
- }
- },
- [activeThreadRef],
- );
+ try {
+ const url = await resolveFileAttachmentUrl({
+ attachment,
+ environmentId,
+ httpBaseUrl: connection.httpBaseUrl,
+ createAssetUrl: createAttachmentAssetUrl,
+ });
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = attachment.name;
+ anchor.click();
+ } catch (error) {
+ toastManager.add({
+ type: "error",
+ title: "Could not download " + attachment.name,
+ description: error instanceof Error ? error.message : "The attachment is unavailable.",
+ });
+ }
+ };
+ const downloadFileAttachmentStable = useStableCallback(downloadFileAttachment);
+ const openFileAttachment = (attachment: ChatFileAttachment) => {
+ if (activeThreadRef) {
+ useRightPanelStore.getState().openAttachment(activeThreadRef, attachment);
+ return;
+ }
+ };
+ const openFileAttachmentStable = useStableCallback(openFileAttachment);
const serverAttachmentResources = useMemo(
() =>
selectHandoffImageResources(
@@ -3878,7 +3953,7 @@ export default function ChatView(props: ChatViewProps) {
if (cancelled) {
return;
}
- clearAttachmentPreviewHandoff(messageId as MessageId, handoffPreviewUrls);
+ clearAttachmentPreviewHandoffStable(messageId as MessageId, handoffPreviewUrls);
})
.catch(() => {
if (!cancelled) {
@@ -3902,7 +3977,7 @@ export default function ChatView(props: ChatViewProps) {
};
}, [
attachmentPreviewHandoffByMessageId,
- clearAttachmentPreviewHandoff,
+ clearAttachmentPreviewHandoffStable,
serverAttachmentUrlById,
serverVisibleTurnItems,
]);
@@ -4107,25 +4182,24 @@ export default function ChatView(props: ChatViewProps) {
() => new Set(retryableRunIdsKey === "" ? [] : (retryableRunIdsKey.split("\n") as RunId[])),
[retryableRunIdsKey],
);
- const onRetryWorkspacePreparation = useCallback(
- (runId: RunId) => {
- // One retry per failed run: a second click lands after the run is preparing again.
- if (!activeThreadRef || retryingWorkspacePreparationRunIds.has(runId)) return;
- retryingWorkspacePreparationRunIds.add(runId);
- void retryWorkspacePreparation({
- environmentId: activeThreadRef.environmentId,
- input: { threadId: activeThreadRef.threadId, runId },
- }).finally(() => retryingWorkspacePreparationRunIds.delete(runId));
- },
- [activeThreadRef, retryWorkspacePreparation],
- );
- const onCancelWorktreeSetup = useCallback(() => {
+ const onRetryWorkspacePreparation = (runId: RunId) => {
+ // One retry per failed run: a second click lands after the run is preparing again.
+ if (!activeThreadRef || retryingWorkspacePreparationRunIds.has(runId)) return;
+ retryingWorkspacePreparationRunIds.add(runId);
+ void retryWorkspacePreparation({
+ environmentId: activeThreadRef.environmentId,
+ input: { threadId: activeThreadRef.threadId, runId },
+ }).finally(() => retryingWorkspacePreparationRunIds.delete(runId));
+ };
+ const onRetryWorkspacePreparationStable = useStableCallback(onRetryWorkspacePreparation);
+ const onCancelWorktreeSetup = () => {
if (!worktreeSetup || worktreeSetup.phase !== "running") return;
void cancelWorktreeSetup({
environmentId: setupTarget.environmentId,
input: { threadId: worktreeSetup.threadId },
});
- }, [cancelWorktreeSetup, setupTarget.environmentId, worktreeSetup]);
+ };
+ const onCancelWorktreeSetupStable = useStableCallback(onCancelWorktreeSetup);
// The setup terminal belongs to the thread that was set up. A failed
// bootstrap deletes that thread and closes its terminals, so only offer the
// terminal while the setup thread is still the active one.
@@ -4392,7 +4466,7 @@ export default function ChatView(props: ChatViewProps) {
() => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions),
[keybindings, terminalShortcutLabelOptions],
);
- const onToggleDiff = useCallback(() => {
+ const onToggleDiff = () => {
if (!isServerThread) {
return;
}
@@ -4402,7 +4476,8 @@ export default function ChatView(props: ChatViewProps) {
if (activeThreadRef) {
useRightPanelStore.getState().toggle(activeThreadRef, "diff");
}
- }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]);
+ };
+ const onToggleDiffStable = useStableCallback(onToggleDiff);
const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId;
const loadBalancingCandidates = useMemo(
@@ -4468,7 +4543,7 @@ export default function ChatView(props: ChatViewProps) {
logicalProjectEnvironments,
setDraftThreadContext,
]);
- const onAutoEnvironment = useCallback(() => {
+ const onAutoEnvironment = () => {
if (envLocked || !draftId) return;
if (composerHasAttachments) {
toastManager.add({
@@ -4489,14 +4564,8 @@ export default function ChatView(props: ChatViewProps) {
branch: null,
worktreePath: null,
});
- }, [
- envLocked,
- draftId,
- setDraftThreadContext,
- loadBalancing.refresh,
- logicalProjectEnvironments,
- composerHasAttachments,
- ]);
+ };
+ const onAutoEnvironmentStable = useStableCallback(onAutoEnvironment);
const autoEnvironmentLabel = automaticEnvironment
? draftThread?.loadBalancedEnvironmentId
? "Auto balance"
@@ -4517,83 +4586,69 @@ export default function ChatView(props: ChatViewProps) {
};
}, [draftId, activeProjectKey]);
- const onEnvironmentChange = useCallback(
- (nextEnvironmentId: EnvironmentId) => {
- if (envLocked || !draftId || sendInFlightRef.current) return;
- const originalDraft = getDraftSession(draftId);
- if (!originalDraft || originalDraft.promotedTo) return;
- const target = logicalProjectEnvironments.find(
- (env) => env.environmentId === nextEnvironmentId,
- );
- if (!target) return;
- const request = { environmentId: target.environmentId };
- environmentChangeRef.current = request;
- setIsEnvironmentChanging(false);
- const retarget = (project: (typeof allProjects)[number]) => {
- const currentDraft = getDraftSession(draftId);
- if (
- environmentChangeRef.current !== request ||
- sendInFlightRef.current ||
- !currentDraft ||
- currentDraft.promotedTo ||
- currentDraft.environmentId !== originalDraft.environmentId ||
- currentDraft.projectId !== originalDraft.projectId
- )
- return;
- const projectRef = scopeProjectRef(target.environmentId, project.id);
- if (activeProjectIsScratch) {
- // Scratch projects are machine-local, so move their logical mapping too.
- setLogicalProjectDraftThreadId(
- deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings),
- projectRef,
- draftId,
- { environmentSelection: "manual", loadBalancedEnvironmentId: null },
- );
- } else {
- setDraftThreadContext(draftId, {
- projectRef,
- environmentSelection: "manual",
- loadBalancedEnvironmentId: null,
- });
- }
- };
- const finish = () => {
- if (environmentChangeRef.current === request) {
- environmentChangeRef.current = null;
- setIsEnvironmentChanging(false);
- }
- };
- if (target.projectId !== null) {
- const project = allProjects.find(
- (project) =>
- project.environmentId === target.environmentId && project.id === target.projectId,
- );
- if (project) retarget(project);
- finish();
+ const onEnvironmentChange = (nextEnvironmentId: EnvironmentId) => {
+ if (envLocked || !draftId || sendInFlightRef.current) return;
+ const originalDraft = getDraftSession(draftId);
+ if (!originalDraft || originalDraft.promotedTo) return;
+ const target = logicalProjectEnvironments.find(
+ (env) => env.environmentId === nextEnvironmentId,
+ );
+ if (!target) return;
+ const request = { environmentId: target.environmentId };
+ environmentChangeRef.current = request;
+ setIsEnvironmentChanging(false);
+ const retarget = (project: (typeof allProjects)[number]) => {
+ const currentDraft = getDraftSession(draftId);
+ if (
+ environmentChangeRef.current !== request ||
+ sendInFlightRef.current ||
+ !currentDraft ||
+ currentDraft.promotedTo ||
+ currentDraft.environmentId !== originalDraft.environmentId ||
+ currentDraft.projectId !== originalDraft.projectId
+ )
return;
+ const projectRef = scopeProjectRef(target.environmentId, project.id);
+ if (activeProjectIsScratch) {
+ // Scratch projects are machine-local, so move their logical mapping too.
+ setLogicalProjectDraftThreadId(
+ deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings),
+ projectRef,
+ draftId,
+ { environmentSelection: "manual", loadBalancedEnvironmentId: null },
+ );
+ } else {
+ setDraftThreadContext(draftId, {
+ projectRef,
+ environmentSelection: "manual",
+ loadBalancedEnvironmentId: null,
+ });
}
- // Keep send disabled until the destination Scratch project is ready.
- setIsEnvironmentChanging(true);
- void openScratchProject(target.environmentId, "Could not switch machine")
- .then((project) => {
- if (project) retarget(project);
- })
- .finally(finish);
- },
- [
- activeProjectIsScratch,
- allProjects,
- draftId,
- envLocked,
- getDraftSession,
- logicalProjectEnvironments,
- openScratchProject,
- projectGroupingSettings,
- sendInFlightRef,
- setDraftThreadContext,
- setLogicalProjectDraftThreadId,
- ],
- );
+ };
+ const finish = () => {
+ if (environmentChangeRef.current === request) {
+ environmentChangeRef.current = null;
+ setIsEnvironmentChanging(false);
+ }
+ };
+ if (target.projectId !== null) {
+ const project = allProjects.find(
+ (project) =>
+ project.environmentId === target.environmentId && project.id === target.projectId,
+ );
+ if (project) retarget(project);
+ finish();
+ return;
+ }
+ // Keep send disabled until the destination Scratch project is ready.
+ setIsEnvironmentChanging(true);
+ void openScratchProject(target.environmentId, "Could not switch machine")
+ .then((project) => {
+ if (project) retarget(project);
+ })
+ .finally(finish);
+ };
+ const onEnvironmentChangeStable = useStableCallback(onEnvironmentChange);
const activeTerminalGroup =
terminalUiState.terminalGroups.find(
@@ -4605,48 +4660,28 @@ export default function ChatView(props: ChatViewProps) {
null;
const hasReachedSplitLimit =
(activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP;
- const setThreadError = useCallback(
- (targetThreadId: ThreadId | null, error: string | null) => {
- if (!targetThreadId) return;
- const nextError = sanitizeThreadErrorMessage(error);
- const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() };
- if (
- serverThread &&
- targetThreadId === routeThreadRef.threadId &&
- serverThread.environmentId === routeThreadRef.environmentId &&
- serverThread.id === targetThreadId
- ) {
- setLocalServerErrorsByThreadKey((existing) => {
- if ((existing[routeThreadKey]?.message ?? null) === nextError) {
- return existing;
- }
- return {
- ...existing,
- [routeThreadKey]: nextEntry,
- };
- });
- return;
- }
- const localDraftErrorKey = draftId ?? targetThreadId;
- setLocalDraftErrorsByDraftId((existing) => {
- if ((existing[localDraftErrorKey]?.message ?? null) === nextError) {
- return existing;
- }
- return {
- ...existing,
- [localDraftErrorKey]: nextEntry,
- };
- });
- },
- [draftId, routeThreadKey, routeThreadRef, serverThread],
+ const setThreadError = useMemo(
+ () =>
+ makeThreadErrorSetter({
+ routeThreadRef,
+ routeThreadKey,
+ draftId,
+ serverThreadRef: serverThread
+ ? { threadId: serverThread.id, environmentId: serverThread.environmentId }
+ : null,
+ setLocalServerErrorsByThreadKey,
+ setLocalDraftErrorsByDraftId,
+ }),
+ [draftId, routeThreadKey, routeThreadRef, serverThread?.id, serverThread?.environmentId],
);
- const focusComposer = useCallback(() => {
+ const focusComposer = () => {
composerRef.current?.focusAtEnd();
- }, [composerRef]);
+ };
+ const focusComposerStable = useStableCallback(focusComposer);
const canInterruptRunningThread =
canOperateThread && deriveCanInterruptRunningThread(activeThread !== undefined, activeRuntime);
- const onInterrupt = useCallback(async () => {
+ const onInterrupt = async () => {
if (
!activeThread ||
!readEnvironmentScope(activeThread.environmentId, AuthOrchestrationOperateScope)
@@ -4663,56 +4698,54 @@ export default function ChatView(props: ChatViewProps) {
error instanceof Error ? error.message : "Failed to interrupt the current turn.",
);
}
- }, [activeThread, environmentId, interruptThreadTurn, setThreadError]);
- useEffect(() => subscribeSnapShotComposerFocus(focusComposer), [focusComposer]);
- const scheduleComposerFocus = useCallback(() => {
+ };
+ const onInterruptStable = useStableCallback(onInterrupt);
+ useEffect(() => subscribeSnapShotComposerFocus(focusComposerStable), [focusComposerStable]);
+ const scheduleComposerFocus = () => {
window.requestAnimationFrame(() => {
focusComposer();
});
- }, [focusComposer]);
- const useArtifactTemplate = useCallback(
- (template: CodexArtifactTemplate) => {
- const composer = composerRef.current;
- if (!composer) return;
-
- const currentDraft = composer.getSendContext().prompt;
- const prompt = codexArtifactTemplatePromptToAppend(currentDraft, template);
- if (prompt !== null && !composer.insertTextAtEnd(prompt, { ensureLeadingBoundary: true })) {
- toastManager.add({
- type: "error",
- title: "Unable to add to chat",
- description: "The composer is busy; try again once it is ready.",
- });
- return;
- }
- scheduleComposerFocus();
- },
- [composerRef, scheduleComposerFocus],
- );
+ };
+ const scheduleComposerFocusStable = useStableCallback(scheduleComposerFocus);
+ const useArtifactTemplate = (template: CodexArtifactTemplate) => {
+ const composer = composerRef.current;
+ if (!composer) return;
+
+ const currentDraft = composer.getSendContext().prompt;
+ const prompt = codexArtifactTemplatePromptToAppend(currentDraft, template);
+ if (prompt !== null && !composer.insertTextAtEnd(prompt, { ensureLeadingBoundary: true })) {
+ toastManager.add({
+ type: "error",
+ title: "Unable to add to chat",
+ description: "The composer is busy; try again once it is ready.",
+ });
+ return;
+ }
+ scheduleComposerFocus();
+ };
+ const useArtifactTemplateStable = useStableCallback(useArtifactTemplate);
// An MCP App's approved `ui/message`: queued like a typed message, so it
// never steers or interrupts a running turn.
- const sendAppMessage = useCallback(
- async (text: string) => {
- if (!isServerThread || activeThreadId === null) {
- throw new Error("Messages from apps need a started thread.");
- }
- const result = await startThreadTurn({
- environmentId,
- input: {
- threadId: activeThreadId,
- message: { messageId: newMessageId(), role: "user", text, attachments: [] },
- runtimeMode,
- interactionMode,
- dispatchMode: "queue",
- },
- });
- if (result._tag === "Failure") {
- const error = squashAtomCommandFailure(result);
- throw error instanceof Error ? error : new Error("Could not send the app's message.");
- }
- },
- [activeThreadId, environmentId, interactionMode, isServerThread, runtimeMode, startThreadTurn],
- );
+ const sendAppMessage = async (text: string) => {
+ if (!isServerThread || activeThreadId === null) {
+ throw new Error("Messages from apps need a started thread.");
+ }
+ const result = await startThreadTurn({
+ environmentId,
+ input: {
+ threadId: activeThreadId,
+ message: { messageId: newMessageId(), role: "user", text, attachments: [] },
+ runtimeMode,
+ interactionMode,
+ dispatchMode: "queue",
+ },
+ });
+ if (result._tag === "Failure") {
+ const error = squashAtomCommandFailure(result);
+ throw error instanceof Error ? error : new Error("Could not send the app's message.");
+ }
+ };
+ const sendAppMessageStable = useStableCallback(sendAppMessage);
const editQueuedRunCommand = useAtomCommand(threadEnvironment.editQueuedRun, {
reportFailure: false,
});
@@ -4740,48 +4773,34 @@ export default function ChatView(props: ChatViewProps) {
url: urlByAttachmentId.get(attachment.id) ?? null,
}));
}, [editingQueuedRun, queuedEditImageResources, queuedEditImageUrls]);
- const beginEditingQueuedRun = useCallback(
- (request: EditQueuedRunRequest) => {
- if (!activeThread) return;
- if (editingQueuedRun !== null && editingQueuedRun.runId !== request.runId) {
- clearComposerDraftContent(queuedEditDraftTargetFor(editingQueuedRun.runId));
- }
- const target = queuedEditDraftTargetFor(request.runId);
- clearComposerDraftContent(target);
- setComposerDraftPrompt(target, request.text);
- setEditingQueuedRun({
- threadId: activeThread.id,
- runId: request.runId,
- messageId: request.messageId,
- originalText: request.text,
- existingAttachments: request.attachments,
- context: serverProjection?.messages.find((message) => message.id === request.messageId)
- ?.context,
- });
- scheduleComposerFocus();
- },
- [
- activeThread,
- clearComposerDraftContent,
- editingQueuedRun,
- queuedEditDraftTargetFor,
- serverProjection,
- scheduleComposerFocus,
- setComposerDraftPrompt,
- ],
- );
- const cancelEditingQueuedRun = useCallback(() => {
+ const beginEditingQueuedRun = (request: EditQueuedRunRequest) => {
+ if (!activeThread) return;
+ if (editingQueuedRun !== null && editingQueuedRun.runId !== request.runId) {
+ clearComposerDraftContent(queuedEditDraftTargetFor(editingQueuedRun.runId));
+ }
+ const target = queuedEditDraftTargetFor(request.runId);
+ clearComposerDraftContent(target);
+ setComposerDraftPrompt(target, request.text);
+ setEditingQueuedRun({
+ threadId: activeThread.id,
+ runId: request.runId,
+ messageId: request.messageId,
+ originalText: request.text,
+ existingAttachments: request.attachments,
+ context: serverProjection?.messages.find((message) => message.id === request.messageId)
+ ?.context,
+ });
+ scheduleComposerFocus();
+ };
+ const beginEditingQueuedRunStable = useStableCallback(beginEditingQueuedRun);
+ const cancelEditingQueuedRun = () => {
if (editingQueuedRun === null) return;
clearComposerDraftContent(queuedEditDraftTargetFor(editingQueuedRun.runId));
setEditingQueuedRun(null);
scheduleComposerFocus();
- }, [
- clearComposerDraftContent,
- editingQueuedRun,
- queuedEditDraftTargetFor,
- scheduleComposerFocus,
- ]);
- const removeEditingQueuedAttachment = useCallback((attachmentId: string) => {
+ };
+ const cancelEditingQueuedRunStable = useStableCallback(cancelEditingQueuedRun);
+ const removeEditingQueuedAttachment = (attachmentId: string) => {
setEditingQueuedRun((current) =>
current === null
? current
@@ -4792,7 +4811,8 @@ export default function ChatView(props: ChatViewProps) {
),
},
);
- }, []);
+ };
+ const removeEditingQueuedAttachmentStable = useStableCallback(removeEditingQueuedAttachment);
// Exit edit mode when the edited run leaves the queue (it started, or was
// cancelled from another client). A dirty edit moves into the thread's own
// draft when that draft is empty; otherwise it is dropped with a toast.
@@ -4835,20 +4855,16 @@ export default function ChatView(props: ChatViewProps) {
queuedEditDraftTargetFor,
serverProjection,
]);
- const addTerminalContextToDraft = useCallback(
- (selection: TerminalContextSelection) => {
- composerRef.current?.addTerminalContext(selection);
- },
- [composerRef],
- );
- const setTerminalOpen = useCallback(
- (open: boolean) => {
- if (!activeThreadRef) return;
- storeSetTerminalOpen(activeThreadRef, open);
- },
- [activeThreadRef, storeSetTerminalOpen],
- );
- const toggleTerminalVisibility = useCallback(() => {
+ const addTerminalContextToDraft = (selection: TerminalContextSelection) => {
+ composerRef.current?.addTerminalContext(selection);
+ };
+ const addTerminalContextToDraftStable = useStableCallback(addTerminalContextToDraft);
+ const setTerminalOpen = (open: boolean) => {
+ if (!activeThreadRef) return;
+ storeSetTerminalOpen(activeThreadRef, open);
+ };
+ const setTerminalOpenStable = useStableCallback(setTerminalOpen);
+ const toggleTerminalVisibility = () => {
if (!activeThreadRef) return;
const nextOpen = !terminalUiState.terminalOpen;
if (
@@ -4892,76 +4908,45 @@ export default function ChatView(props: ChatViewProps) {
return;
}
setTerminalOpen(nextOpen);
- }, [
- activeServerOrderedTerminalIds,
- activeProject,
- activeThreadId,
- activeThreadRef,
- activeThreadWorktreePath,
- allocateTerminalId,
- environmentId,
- gitCwd,
- openTerminal,
- panelTerminalIds,
- runningTerminalIds,
- hasTerminalWriteAccess,
- setTerminalOpen,
- storeEnsureTerminal,
- terminalUiState.terminalIds.length,
- terminalUiState.terminalOpen,
- ]);
- const splitTerminal = useCallback(
- (direction: "horizontal" | "vertical" = "horizontal") => {
- if (
- !hasTerminalWriteAccess() ||
- !activeThreadRef ||
- hasReachedSplitLimit ||
- !activeThreadId ||
- !activeProject
- ) {
- return;
- }
- const cwdForOpen = gitCwd ?? activeProject.workspaceRoot;
- if (!cwdForOpen) {
- return;
- }
- const terminalId = allocateTerminalId();
- if (direction === "vertical") {
- storeSplitTerminalVertical(activeThreadRef, terminalId);
- } else {
- storeSplitTerminal(activeThreadRef, terminalId);
- }
- setTerminalFocusRequestId((value) => value + 1);
- void openTerminal({
- environmentId,
- input: {
- threadId: activeThreadId,
- terminalId,
- cwd: cwdForOpen,
- ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
- env: projectScriptRuntimeEnv({
- project: { cwd: activeProject.workspaceRoot },
- worktreePath: activeThreadWorktreePath,
- }),
- },
- });
- },
- [
- activeProject,
- activeThreadId,
- allocateTerminalId,
- activeThreadRef,
- openTerminal,
- hasTerminalWriteAccess,
- activeThreadWorktreePath,
+ };
+ const toggleTerminalVisibilityStable = useStableCallback(toggleTerminalVisibility);
+ const splitTerminal = (direction: "horizontal" | "vertical" = "horizontal") => {
+ if (
+ !hasTerminalWriteAccess() ||
+ !activeThreadRef ||
+ hasReachedSplitLimit ||
+ !activeThreadId ||
+ !activeProject
+ ) {
+ return;
+ }
+ const cwdForOpen = gitCwd ?? activeProject.workspaceRoot;
+ if (!cwdForOpen) {
+ return;
+ }
+ const terminalId = allocateTerminalId();
+ if (direction === "vertical") {
+ storeSplitTerminalVertical(activeThreadRef, terminalId);
+ } else {
+ storeSplitTerminal(activeThreadRef, terminalId);
+ }
+ setTerminalFocusRequestId((value) => value + 1);
+ void openTerminal({
environmentId,
- gitCwd,
- hasReachedSplitLimit,
- storeSplitTerminal,
- storeSplitTerminalVertical,
- ],
- );
- const createNewTerminal = useCallback(() => {
+ input: {
+ threadId: activeThreadId,
+ terminalId,
+ cwd: cwdForOpen,
+ ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
+ env: projectScriptRuntimeEnv({
+ project: { cwd: activeProject.workspaceRoot },
+ worktreePath: activeThreadWorktreePath,
+ }),
+ },
+ });
+ };
+ const splitTerminalStable = useStableCallback(splitTerminal);
+ const createNewTerminal = () => {
if (!hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject) {
return;
}
@@ -4985,206 +4970,162 @@ export default function ChatView(props: ChatViewProps) {
}),
},
});
- }, [
- activeProject,
- activeThreadId,
- allocateTerminalId,
- activeThreadRef,
- openTerminal,
- hasTerminalWriteAccess,
- activeThreadWorktreePath,
- environmentId,
- gitCwd,
- storeNewTerminal,
- ]);
- const closeTerminal = useCallback(
- (terminalId: string) => {
- if (!hasTerminalWriteAccess() || !activeThreadId || !activeThreadRef) return;
- const fallbackExitWrite = () =>
- writeTerminal({
- environmentId,
- input: { threadId: activeThreadId, terminalId, data: "exit\n" },
- });
- void (async () => {
- const closeResult = await closeTerminalMutation({
- environmentId,
- input: {
- threadId: activeThreadId,
- terminalId,
- deleteHistory: true,
- },
- });
- if (
- closeResult._tag === "Failure" &&
- !isAtomCommandInterrupted(closeResult) &&
- hasTerminalWriteAccess()
- ) {
- await fallbackExitWrite();
- }
- })();
- storeCloseTerminal(activeThreadRef, terminalId);
- setTerminalFocusRequestId((value) => value + 1);
- },
- [
- activeThreadId,
- activeThreadRef,
- closeTerminalMutation,
- hasTerminalWriteAccess,
- environmentId,
- storeCloseTerminal,
- writeTerminal,
- ],
- );
- const runProjectScript = useCallback(
- async (
- script: ProjectScript,
- options?: {
- cwd?: string;
- env?: Record;
- worktreePath?: string | null;
- preferNewTerminal?: boolean;
- rememberAsLastInvoked?: boolean;
- },
- ) => {
- if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return;
- if (options?.rememberAsLastInvoked !== false) {
- setLastInvokedScriptByProjectId((current) => {
- if (current[activeProject.id] === script.id) return current;
- return { ...current, [activeProject.id]: script.id };
- });
- }
- const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot;
- const baseTerminalId =
- terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID;
- const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId);
- const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
- const shouldCreateNewTerminal =
- wantsNewTerminal ||
- !canReuseTerminal ||
- !readEnvironmentScope(environmentId, AuthTerminalReadScope);
- const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null;
-
- setTerminalUiLaunchContext({
- threadId: activeThreadId,
- cwd: targetCwd,
- worktreePath: targetWorktreePath,
- });
- setTerminalOpen(true);
- if (!activeThreadRef) {
- return;
- }
- setTerminalFocusRequestId((value) => value + 1);
-
- const runtimeEnv = projectScriptRuntimeEnv({
- project: {
- cwd: activeProject.workspaceRoot,
- },
- worktreePath: targetWorktreePath,
- ...(options?.env ? { extraEnv: options.env } : {}),
+ };
+ const createNewTerminalStable = useStableCallback(createNewTerminal);
+ const closeTerminal = (terminalId: string) => {
+ if (!hasTerminalWriteAccess() || !activeThreadId || !activeThreadRef) return;
+ const fallbackExitWrite = () =>
+ writeTerminal({
+ environmentId,
+ input: { threadId: activeThreadId, terminalId, data: "exit\n" },
});
- const targetTerminalId = shouldCreateNewTerminal ? allocateTerminalId() : baseTerminalId;
- const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal
- ? {
- threadId: activeThreadId,
- terminalId: targetTerminalId,
- cwd: targetCwd,
- ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}),
- env: runtimeEnv,
- cols: SCRIPT_TERMINAL_COLS,
- rows: SCRIPT_TERMINAL_ROWS,
- }
- : {
- threadId: activeThreadId,
- terminalId: targetTerminalId,
- cwd: targetCwd,
- ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}),
- env: runtimeEnv,
- };
-
- if (shouldCreateNewTerminal) {
- storeNewTerminal(activeThreadRef, targetTerminalId);
- } else {
- storeSetActiveTerminal(activeThreadRef, targetTerminalId);
- }
-
- const openResult = await openTerminal({ environmentId, input: openTerminalInput });
- if (openResult._tag === "Failure") {
- if (!isAtomCommandInterrupted(openResult)) {
- const error = squashAtomCommandFailure(openResult);
- setThreadError(
- activeThreadId,
- error instanceof Error ? error.message : `Failed to run script "${script.name}".`,
- );
- }
- return;
- }
-
- if (!hasTerminalWriteAccess()) return;
- const writeResult = await writeTerminal({
+ void (async () => {
+ const closeResult = await closeTerminalMutation({
environmentId,
input: {
threadId: activeThreadId,
- terminalId: targetTerminalId,
- data: `${script.command}\r`,
+ terminalId,
+ deleteHistory: true,
},
});
- if (writeResult._tag === "Failure") {
- if (!isAtomCommandInterrupted(writeResult)) {
- const error = squashAtomCommandFailure(writeResult);
- setThreadError(
- activeThreadId,
- error instanceof Error ? error.message : `Failed to run script "${script.name}".`,
- );
- }
- return;
+ if (
+ closeResult._tag === "Failure" &&
+ !isAtomCommandInterrupted(closeResult) &&
+ hasTerminalWriteAccess()
+ ) {
+ await fallbackExitWrite();
}
- if (script.autoOpenPreview && script.previewUrl && isPreviewSupportedInRuntime()) {
- const previewResult = await openUrlInPreview({
- threadRef: activeThreadRef,
- url: resolveDiscoveredServerUrl(activeThreadRef.environmentId, script.previewUrl),
- openPreview,
- });
- if (previewResult._tag === "Failure" && !isAtomCommandInterrupted(previewResult)) {
- const error = squashAtomCommandFailure(previewResult);
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Could not open preview",
- description: error instanceof Error ? error.message : "An unexpected error occurred.",
- }),
- );
+ })();
+ storeCloseTerminal(activeThreadRef, terminalId);
+ setTerminalFocusRequestId((value) => value + 1);
+ };
+ const runProjectScript = async (
+ script: ProjectScript,
+ options?: {
+ cwd?: string;
+ env?: Record;
+ worktreePath?: string | null;
+ preferNewTerminal?: boolean;
+ rememberAsLastInvoked?: boolean;
+ },
+ ) => {
+ if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return;
+ if (options?.rememberAsLastInvoked !== false) {
+ setLastInvokedScriptByProjectId((current) => {
+ if (current[activeProject.id] === script.id) return current;
+ return { ...current, [activeProject.id]: script.id };
+ });
+ }
+ const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot;
+ const baseTerminalId =
+ terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID;
+ const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId);
+ const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
+ const shouldCreateNewTerminal =
+ wantsNewTerminal ||
+ !canReuseTerminal ||
+ !readEnvironmentScope(environmentId, AuthTerminalReadScope);
+ const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null;
+
+ setTerminalUiLaunchContext({
+ threadId: activeThreadId,
+ cwd: targetCwd,
+ worktreePath: targetWorktreePath,
+ });
+ setTerminalOpen(true);
+ if (!activeThreadRef) {
+ return;
+ }
+ setTerminalFocusRequestId((value) => value + 1);
+
+ const runtimeEnv = projectScriptRuntimeEnv({
+ project: {
+ cwd: activeProject.workspaceRoot,
+ },
+ worktreePath: targetWorktreePath,
+ ...(options?.env ? { extraEnv: options.env } : {}),
+ });
+ const targetTerminalId = shouldCreateNewTerminal ? allocateTerminalId() : baseTerminalId;
+ const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal
+ ? {
+ threadId: activeThreadId,
+ terminalId: targetTerminalId,
+ cwd: targetCwd,
+ ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}),
+ env: runtimeEnv,
+ cols: SCRIPT_TERMINAL_COLS,
+ rows: SCRIPT_TERMINAL_ROWS,
}
+ : {
+ threadId: activeThreadId,
+ terminalId: targetTerminalId,
+ cwd: targetCwd,
+ ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}),
+ env: runtimeEnv,
+ };
+
+ if (shouldCreateNewTerminal) {
+ storeNewTerminal(activeThreadRef, targetTerminalId);
+ } else {
+ storeSetActiveTerminal(activeThreadRef, targetTerminalId);
+ }
+
+ const openResult = await openTerminal({ environmentId, input: openTerminalInput });
+ if (openResult._tag === "Failure") {
+ if (!isAtomCommandInterrupted(openResult)) {
+ const error = squashAtomCommandFailure(openResult);
+ setThreadError(
+ activeThreadId,
+ error instanceof Error ? error.message : `Failed to run script "${script.name}".`,
+ );
}
- },
- [
- activeProject,
- activeThread,
- activeThreadId,
- activeThreadRef,
- gitCwd,
- setTerminalOpen,
- setThreadError,
- storeNewTerminal,
- storeSetActiveTerminal,
- setLastInvokedScriptByProjectId,
+ return;
+ }
+
+ if (!hasTerminalWriteAccess()) return;
+ const writeResult = await writeTerminal({
environmentId,
- openTerminal,
- openPreview,
- hasTerminalWriteAccess,
- activeKnownTerminalIds,
- canReuseTerminal,
- allocateTerminalId,
- runningTerminalIds,
- terminalUiState.activeTerminalId,
- writeTerminal,
- ],
- );
+ input: {
+ threadId: activeThreadId,
+ terminalId: targetTerminalId,
+ data: `${script.command}\r`,
+ },
+ });
+ if (writeResult._tag === "Failure") {
+ if (!isAtomCommandInterrupted(writeResult)) {
+ const error = squashAtomCommandFailure(writeResult);
+ setThreadError(
+ activeThreadId,
+ error instanceof Error ? error.message : `Failed to run script "${script.name}".`,
+ );
+ }
+ return;
+ }
+ if (script.autoOpenPreview && script.previewUrl && isPreviewSupportedInRuntime()) {
+ const previewResult = await openUrlInPreview({
+ threadRef: activeThreadRef,
+ url: resolveDiscoveredServerUrl(activeThreadRef.environmentId, script.previewUrl),
+ openPreview,
+ });
+ if (previewResult._tag === "Failure" && !isAtomCommandInterrupted(previewResult)) {
+ const error = squashAtomCommandFailure(previewResult);
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Could not open preview",
+ description: error instanceof Error ? error.message : "An unexpected error occurred.",
+ }),
+ );
+ }
+ }
+ };
+ const runProjectScriptStable = useStableCallback(runProjectScript);
- const runProjectScriptRef = useRef(runProjectScript);
+ const runProjectScriptRef = useRef(runProjectScriptStable);
useLayoutEffect(() => {
- runProjectScriptRef.current = runProjectScript;
- }, [runProjectScript]);
- const runShellCommand = useCallback((command: string) => {
+ runProjectScriptRef.current = runProjectScriptStable;
+ }, [runProjectScriptStable]);
+ const runShellCommand = (command: string) => {
void runProjectScriptRef.current(
{
id: "chat-code-block",
@@ -5195,338 +5136,306 @@ export default function ChatView(props: ChatViewProps) {
},
{ rememberAsLastInvoked: false },
);
- }, []);
+ };
+ const runShellCommandStable = useStableCallback(runShellCommand);
const supportsProjectSettingsOverrides =
environmentById.get(environmentId)?.serverConfig?.environment.capabilities
.projectSettingsOverrides === true;
- const persistProjectScripts = useCallback(
- async (input: {
- projectId: ProjectId;
- projectCwd: string;
- previousScripts: ReadonlyArray;
- nextScripts: ReadonlyArray;
- keybinding: NewProjectScriptInput["keybinding"];
- keybindingCommand: KeybindingCommand | null;
- }): Promise> => {
- const previousKeybinding = keybindingValueForCommand(
- appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? [],
- input.keybindingCommand,
- );
- const isDeletingScript = !input.nextScripts.some(
- (script) => commandForProjectScript(script.id) === input.keybindingCommand,
+ const persistProjectScripts = async (input: {
+ projectId: ProjectId;
+ projectCwd: string;
+ previousScripts: ReadonlyArray;
+ nextScripts: ReadonlyArray;
+ keybinding: NewProjectScriptInput["keybinding"];
+ keybindingCommand: KeybindingCommand | null;
+ }): Promise> => {
+ const previousKeybinding = keybindingValueForCommand(
+ appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? [],
+ input.keybindingCommand,
+ );
+ const isDeletingScript = !input.nextScripts.some(
+ (script) => commandForProjectScript(script.id) === input.keybindingCommand,
+ );
+ const changesKeybinding =
+ input.keybinding !== undefined &&
+ (input.keybinding?.trim() || null) !== previousKeybinding &&
+ (!isDeletingScript || readEnvironmentScope(environmentId, AuthSettingsWriteScope));
+ if (changesKeybinding && !readEnvironmentScope(environmentId, AuthSettingsWriteScope)) {
+ return AsyncResult.failure(
+ Cause.fail(
+ new EnvironmentAuthorizationError({
+ requiredScope: AuthSettingsWriteScope,
+ message: "This connection cannot change keyboard shortcuts.",
+ }),
+ ),
);
- const changesKeybinding =
- input.keybinding !== undefined &&
- (input.keybinding?.trim() || null) !== previousKeybinding &&
- (!isDeletingScript || readEnvironmentScope(environmentId, AuthSettingsWriteScope));
- if (changesKeybinding && !readEnvironmentScope(environmentId, AuthSettingsWriteScope)) {
- return AsyncResult.failure(
- Cause.fail(
- new EnvironmentAuthorizationError({
- requiredScope: AuthSettingsWriteScope,
- message: "This connection cannot change keyboard shortcuts.",
- }),
- ),
- );
- }
- const keybindingRule = changesKeybinding
- ? decodeProjectScriptKeybindingRule({
- keybinding: input.keybinding,
- command: input.keybindingCommand,
- })
- : null;
- const updateResult = mapAtomCommandResult(
- await updateProjectScriptSettings({
- environmentId,
- input: {
- // The canonical key on servers that understand it; the legacy
- // per-project map is still translated on older ones.
- patch: supportsProjectSettingsOverrides
- ? {
- projectSettingsOverrides: {
- [input.projectId]: {
- ...settings.projectSettingsOverrides[input.projectId],
- defaultProjectScripts: input.nextScripts,
- },
- },
- }
- : {
- projectScriptOverrides: {
- [input.projectId]: input.nextScripts,
+ }
+ const keybindingRule = changesKeybinding
+ ? decodeProjectScriptKeybindingRule({
+ keybinding: input.keybinding,
+ command: input.keybindingCommand,
+ })
+ : null;
+ const updateResult = mapAtomCommandResult(
+ await updateProjectScriptSettings({
+ environmentId,
+ input: {
+ // The canonical key on servers that understand it; the legacy
+ // per-project map is still translated on older ones.
+ patch: supportsProjectSettingsOverrides
+ ? {
+ projectSettingsOverrides: {
+ [input.projectId]: {
+ ...settings.projectSettingsOverrides[input.projectId],
+ defaultProjectScripts: input.nextScripts,
},
},
- },
- }),
- () => undefined,
- );
- if (updateResult._tag === "Failure") {
- return updateResult;
- }
+ }
+ : {
+ projectScriptOverrides: {
+ [input.projectId]: input.nextScripts,
+ },
+ },
+ },
+ }),
+ () => undefined,
+ );
+ if (updateResult._tag === "Failure") {
+ return updateResult;
+ }
- if (!changesKeybinding) return updateResult;
- if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) {
- return AsyncResult.failure(
- Cause.fail(
- new EnvironmentAuthorizationError({
- requiredScope: AuthSettingsWriteScope,
- message: isDeletingScript
- ? "The script was deleted, but its keyboard shortcut could not be removed because permission changed."
- : "The script was saved, but this connection can no longer change keyboard shortcuts.",
- }),
- ),
- );
- }
+ if (!changesKeybinding) return updateResult;
+ if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) {
+ return AsyncResult.failure(
+ Cause.fail(
+ new EnvironmentAuthorizationError({
+ requiredScope: AuthSettingsWriteScope,
+ message: isDeletingScript
+ ? "The script was deleted, but its keyboard shortcut could not be removed because permission changed."
+ : "The script was saved, but this connection can no longer change keyboard shortcuts.",
+ }),
+ ),
+ );
+ }
- const scriptId = input.keybindingCommand
- ? projectScriptIdFromCommand(input.keybindingCommand)
- : null;
- if (!keybindingRule && !input.previousScripts.some((script) => script.id === scriptId)) {
+ const scriptId = input.keybindingCommand
+ ? projectScriptIdFromCommand(input.keybindingCommand)
+ : null;
+ if (!keybindingRule && !input.previousScripts.some((script) => script.id === scriptId)) {
+ return updateResult;
+ }
+ const retainedElsewhere =
+ !input.nextScripts.some((script) => script.id === scriptId) &&
+ (settings.defaultProjectScripts.some((script) => script.id === scriptId) ||
+ Object.entries(settings.projectSettingsOverrides).some(
+ ([projectId, entry]) =>
+ projectId !== input.projectId &&
+ entry.defaultProjectScripts?.some((script) => script.id === scriptId),
+ ) ||
+ allProjects.some(
+ (other) =>
+ other.environmentId === environmentId &&
+ other.id !== input.projectId &&
+ resolveProjectScripts(settings, other).some((script) => script.id === scriptId),
+ ));
+ if (!keybindingRule && retainedElsewhere) return updateResult;
+
+ const previousRules = (
+ environmentById.get(environmentId)?.serverConfig?.keybindings ?? []
+ ).flatMap((binding) => {
+ if (binding.command !== input.keybindingCommand || binding.whenAst) return [];
+ const previous = decodeProjectScriptKeybindingRule({
+ keybinding: keybindingValueForCommand([binding], input.keybindingCommand),
+ command: input.keybindingCommand,
+ });
+ return previous ? [previous] : [];
+ });
+ const previous = previousRules.at(-1);
+ for (const rule of keybindingRule ? previousRules.slice(0, -1) : previousRules) {
+ const result = await removeKeybinding({ environmentId, input: rule });
+ if (result._tag === "Failure") {
+ toastManager.add(PROJECT_SCRIPT_KEYBINDING_SAVE_WARNING);
return updateResult;
}
- const retainedElsewhere =
- !input.nextScripts.some((script) => script.id === scriptId) &&
- (settings.defaultProjectScripts.some((script) => script.id === scriptId) ||
- Object.entries(settings.projectSettingsOverrides).some(
- ([projectId, entry]) =>
- projectId !== input.projectId &&
- entry.defaultProjectScripts?.some((script) => script.id === scriptId),
- ) ||
- allProjects.some(
- (other) =>
- other.environmentId === environmentId &&
- other.id !== input.projectId &&
- resolveProjectScripts(settings, other).some((script) => script.id === scriptId),
- ));
- if (!keybindingRule && retainedElsewhere) return updateResult;
-
- const previousRules = (
- environmentById.get(environmentId)?.serverConfig?.keybindings ?? []
- ).flatMap((binding) => {
- if (binding.command !== input.keybindingCommand || binding.whenAst) return [];
- const previous = decodeProjectScriptKeybindingRule({
- keybinding: keybindingValueForCommand([binding], input.keybindingCommand),
- command: input.keybindingCommand,
- });
- return previous ? [previous] : [];
+ }
+ if (keybindingRule) {
+ const result = await upsertKeybinding({
+ environmentId,
+ input:
+ previous && previous.key !== keybindingRule.key
+ ? { ...keybindingRule, replace: previous }
+ : keybindingRule,
});
- const previous = previousRules.at(-1);
- for (const rule of keybindingRule ? previousRules.slice(0, -1) : previousRules) {
- const result = await removeKeybinding({ environmentId, input: rule });
- if (result._tag === "Failure") {
- toastManager.add(PROJECT_SCRIPT_KEYBINDING_SAVE_WARNING);
- return updateResult;
- }
- }
- if (keybindingRule) {
- const result = await upsertKeybinding({
- environmentId,
- input:
- previous && previous.key !== keybindingRule.key
- ? { ...keybindingRule, replace: previous }
- : keybindingRule,
- });
- if (result._tag === "Failure") {
- toastManager.add(PROJECT_SCRIPT_KEYBINDING_SAVE_WARNING);
- }
- }
- return updateResult;
- },
- [
- allProjects,
- environmentById,
- environmentId,
- removeKeybinding,
- settings,
- supportsProjectSettingsOverrides,
- updateProjectScriptSettings,
- upsertKeybinding,
- ],
- );
- const saveProjectScript = useCallback(
- async (input: NewProjectScriptInput): Promise> => {
- if (!activeProject) {
- return AsyncResult.success(undefined);
+ if (result._tag === "Failure") {
+ toastManager.add(PROJECT_SCRIPT_KEYBINDING_SAVE_WARNING);
}
- const nextId = nextProjectScriptId(
- input.name,
- activeProjectScripts.map((script) => script.id),
- );
- const nextScript = buildProjectScript(nextId, input);
- const nextScripts = [
- ...activeProjectScripts.map((script) => releaseClaimedRoles(script, input)),
- nextScript,
- ];
+ }
+ return updateResult;
+ };
+ const saveProjectScript = async (
+ input: NewProjectScriptInput,
+ ): Promise> => {
+ if (!activeProject) {
+ return AsyncResult.success(undefined);
+ }
+ const nextId = nextProjectScriptId(
+ input.name,
+ activeProjectScripts.map((script) => script.id),
+ );
+ const nextScript = buildProjectScript(nextId, input);
+ const nextScripts = [
+ ...activeProjectScripts.map((script) => releaseClaimedRoles(script, input)),
+ nextScript,
+ ];
- return persistProjectScripts({
- projectId: activeProject.id,
- projectCwd: activeProject.workspaceRoot,
- previousScripts: activeProjectScripts,
- nextScripts,
- keybinding: input.keybinding,
- keybindingCommand: commandForProjectScript(nextId),
- });
- },
- [activeProject, activeProjectScripts, persistProjectScripts],
- );
- const updateProjectScript = useCallback(
- async (
- scriptId: string,
- input: NewProjectScriptInput,
- ): Promise> => {
- if (!activeProject) {
- return AsyncResult.success(undefined);
- }
- const existingScript = activeProjectScripts.find((script) => script.id === scriptId);
- if (!existingScript) {
- return AsyncResult.failure(Cause.fail(new Error("Script not found.")));
- }
+ return persistProjectScripts({
+ projectId: activeProject.id,
+ projectCwd: activeProject.workspaceRoot,
+ previousScripts: activeProjectScripts,
+ nextScripts,
+ keybinding: input.keybinding,
+ keybindingCommand: commandForProjectScript(nextId),
+ });
+ };
+ const saveProjectScriptStable = useStableCallback(saveProjectScript);
+ const updateProjectScript = async (
+ scriptId: string,
+ input: NewProjectScriptInput,
+ ): Promise> => {
+ if (!activeProject) {
+ return AsyncResult.success(undefined);
+ }
+ const existingScript = activeProjectScripts.find((script) => script.id === scriptId);
+ if (!existingScript) {
+ return AsyncResult.failure(Cause.fail(new Error("Script not found.")));
+ }
- const updatedScript = buildProjectScript(existingScript.id, input);
- const nextScripts = activeProjectScripts.map((script) =>
- script.id === scriptId ? updatedScript : releaseClaimedRoles(script, input),
- );
+ const updatedScript = buildProjectScript(existingScript.id, input);
+ const nextScripts = activeProjectScripts.map((script) =>
+ script.id === scriptId ? updatedScript : releaseClaimedRoles(script, input),
+ );
- return persistProjectScripts({
- projectId: activeProject.id,
- projectCwd: activeProject.workspaceRoot,
- previousScripts: activeProjectScripts,
- nextScripts,
- keybinding: input.keybinding,
- keybindingCommand: commandForProjectScript(scriptId),
- });
- },
- [activeProject, activeProjectScripts, persistProjectScripts],
- );
- const deleteProjectScript = useCallback(
- async (scriptId: string): Promise> => {
- if (!activeProject) {
- return AsyncResult.success(undefined);
- }
- const nextScripts = activeProjectScripts.filter((script) => script.id !== scriptId);
+ return persistProjectScripts({
+ projectId: activeProject.id,
+ projectCwd: activeProject.workspaceRoot,
+ previousScripts: activeProjectScripts,
+ nextScripts,
+ keybinding: input.keybinding,
+ keybindingCommand: commandForProjectScript(scriptId),
+ });
+ };
+ const updateProjectScriptStable = useStableCallback(updateProjectScript);
+ const deleteProjectScript = async (
+ scriptId: string,
+ ): Promise> => {
+ if (!activeProject) {
+ return AsyncResult.success(undefined);
+ }
+ const nextScripts = activeProjectScripts.filter((script) => script.id !== scriptId);
- const deletedName = activeProjectScripts.find((s) => s.id === scriptId)?.name;
+ const deletedName = activeProjectScripts.find((s) => s.id === scriptId)?.name;
- const result = await persistProjectScripts({
- projectId: activeProject.id,
- projectCwd: activeProject.workspaceRoot,
- previousScripts: activeProjectScripts,
- nextScripts,
- keybinding: null,
- keybindingCommand: commandForProjectScript(scriptId),
+ const result = await persistProjectScripts({
+ projectId: activeProject.id,
+ projectCwd: activeProject.workspaceRoot,
+ previousScripts: activeProjectScripts,
+ nextScripts,
+ keybinding: null,
+ keybindingCommand: commandForProjectScript(scriptId),
+ });
+ if (result._tag === "Success") {
+ toastManager.add({
+ type: "success",
+ title: `Deleted action "${deletedName ?? "Unknown"}"`,
});
- if (result._tag === "Success") {
- toastManager.add({
- type: "success",
- title: `Deleted action "${deletedName ?? "Unknown"}"`,
- });
- } else if (!isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Could not delete action",
- description: error instanceof Error ? error.message : "An unexpected error occurred.",
- }),
- );
- }
- return result;
- },
- [activeProject, activeProjectScripts, persistProjectScripts],
- );
+ } else if (!isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Could not delete action",
+ description: error instanceof Error ? error.message : "An unexpected error occurred.",
+ }),
+ );
+ }
+ return result;
+ };
+ const deleteProjectScriptStable = useStableCallback(deleteProjectScript);
- const handleRuntimeModeChange = useCallback(
- (mode: RuntimeMode) => {
- if (mode === runtimeMode) return;
- setComposerDraftRuntimeMode(composerDraftTarget, mode);
- if (isLocalDraftThread) {
- setDraftThreadContext(composerDraftTarget, { runtimeMode: mode });
- }
- scheduleComposerFocus();
- },
- [
- isLocalDraftThread,
- runtimeMode,
- scheduleComposerFocus,
- composerDraftTarget,
- setComposerDraftRuntimeMode,
- setDraftThreadContext,
- ],
- );
+ const handleRuntimeModeChange = (mode: RuntimeMode) => {
+ if (mode === runtimeMode) return;
+ setComposerDraftRuntimeMode(composerDraftTarget, mode);
+ if (isLocalDraftThread) {
+ setDraftThreadContext(composerDraftTarget, { runtimeMode: mode });
+ }
+ scheduleComposerFocus();
+ };
+ const handleRuntimeModeChangeStable = useStableCallback(handleRuntimeModeChange);
- const handleInteractionModeChange = useCallback(
- (mode: ProviderInteractionMode) => {
- if (mode === "plan" && !interactionModeEnabled) return;
- if (mode === interactionMode) return;
- setComposerDraftInteractionMode(composerDraftTarget, mode);
- if (isLocalDraftThread) {
- setDraftThreadContext(composerDraftTarget, { interactionMode: mode });
- }
- scheduleComposerFocus();
- },
- [
- interactionMode,
- interactionModeEnabled,
- isLocalDraftThread,
- scheduleComposerFocus,
- composerDraftTarget,
- setComposerDraftInteractionMode,
- setDraftThreadContext,
- ],
- );
- const toggleInteractionMode = useCallback(() => {
+ const handleInteractionModeChange = (mode: ProviderInteractionMode) => {
+ if (mode === "plan" && !interactionModeEnabled) return;
+ if (mode === interactionMode) return;
+ setComposerDraftInteractionMode(composerDraftTarget, mode);
+ if (isLocalDraftThread) {
+ setDraftThreadContext(composerDraftTarget, { interactionMode: mode });
+ }
+ scheduleComposerFocus();
+ };
+ const handleInteractionModeChangeStable = useStableCallback(handleInteractionModeChange);
+ const toggleInteractionMode = () => {
if (!interactionModeEnabled) return;
handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
- }, [handleInteractionModeChange, interactionMode, interactionModeEnabled]);
- const openProviderSetup = useCallback(
- (instanceId: ProviderInstanceId) => {
- void navigate({
- to: "/settings/providers",
- search: { environmentId, instanceId },
- });
- },
- [environmentId, navigate],
- );
+ };
+ const toggleInteractionModeStable = useStableCallback(toggleInteractionMode);
+ const openProviderSetup = (instanceId: ProviderInstanceId) => {
+ void navigate({
+ to: "/settings/providers",
+ search: { environmentId, instanceId },
+ });
+ };
+ const openProviderSetupStable = useStableCallback(openProviderSetup);
const canOperatePreview = useEnvironmentScope(
activeThreadRef?.environmentId ?? null,
AuthPreviewOperateScope,
);
- const createBrowserSurface = useCallback(
- (profileId?: string) => {
- if (!activeThreadRef || !canOperatePreview) return;
- void addBrowserSurface({
- threadRef: activeThreadRef,
- openPreview,
- ...(profileId === undefined ? {} : { profileId }),
- }).then((result) => {
- if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
- const error = squashAtomCommandFailure(result);
- if (error instanceof BrowserSettingsReadError) {
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Unable to open browser",
- description: error.message,
- }),
- );
- }
- });
- },
- [activeThreadRef, canOperatePreview, openPreview],
- );
- const addDiffSurface = useCallback(() => {
+ const createBrowserSurface = (profileId?: string) => {
+ if (!activeThreadRef || !canOperatePreview) return;
+ void addBrowserSurface({
+ threadRef: activeThreadRef,
+ openPreview,
+ ...(profileId === undefined ? {} : { profileId }),
+ }).then((result) => {
+ if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
+ const error = squashAtomCommandFailure(result);
+ if (error instanceof BrowserSettingsReadError) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Unable to open browser",
+ description: error.message,
+ }),
+ );
+ }
+ });
+ };
+ const createBrowserSurfaceStable = useStableCallback(createBrowserSurface);
+ const addDiffSurface = () => {
if (!activeThreadRef || !isServerThread || !isGitRepo) return;
useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch");
useRightPanelStore.getState().open(activeThreadRef, "diff");
onDiffPanelOpen?.();
- }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]);
- const openChangesFromThreadPanel = useCallback(() => {
+ };
+ const addDiffSurfaceStable = useStableCallback(addDiffSurface);
+ const openChangesFromThreadPanel = () => {
addDiffSurface();
- }, [addDiffSurface]);
- const addFilesSurface = useCallback(() => {
+ };
+ const openChangesFromThreadPanelStable = useStableCallback(openChangesFromThreadPanel);
+ const addFilesSurface = () => {
if (!activeThreadRef || !activeProject) return;
useRightPanelStore.getState().open(activeThreadRef, "files");
- }, [activeProject, activeThreadRef]);
+ };
+ const addFilesSurfaceStable = useStableCallback(addFilesSurface);
const supportsThreadPullRequests =
serverConfig?.environment.capabilities.threadPullRequests === true;
const visiblePullRequests = visibleThreadPullRequests(
@@ -5535,22 +5444,24 @@ export default function ChatView(props: ChatViewProps) {
const visiblePullRequestCount = visiblePullRequests.length;
const pullRequestsSurfaceAvailable =
isServerThread && supportsThreadPullRequests && visiblePullRequestCount > 0;
- const addPullRequestsSurface = useCallback(() => {
+ const addPullRequestsSurface = () => {
if (!activeThreadRef || !pullRequestsSurfaceAvailable) return;
useRightPanelStore.getState().open(activeThreadRef, "pull-requests");
- }, [activeThreadRef, pullRequestsSurfaceAvailable]);
+ };
+ const addPullRequestsSurfaceStable = useStableCallback(addPullRequestsSurface);
const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState(
activeThreadRef?.environmentId ?? null,
);
const [deviceSetupThread, setDeviceSetupThread] = useState(null);
- const addDeviceSurface = useCallback(() => {
+ const addDeviceSurface = () => {
if (!activeThreadRef) return;
if (!deviceState.onboardingCompleted || deviceState.hostStatus === "disabled") {
setDeviceSetupThread(activeThreadRef);
return;
}
useRightPanelStore.getState().open(activeThreadRef, "device");
- }, [activeThreadRef, deviceState.onboardingCompleted, deviceState.hostStatus]);
+ };
+ const addDeviceSurfaceStable = useStableCallback(addDeviceSurface);
// A device the agent opens floats over chat like an agent-driven browser,
// or becomes a panel tab when floating previews are off. Sessions opened by
// another client arrive the same way; sheet layouts get neither. The first
@@ -5664,13 +5575,11 @@ export default function ChatView(props: ChatViewProps) {
);
if (!sessionStillExists) usePreviewMiniPlayerStore.getState().close(activeThreadRef);
}, [activePreviewMiniPlayer, activeThreadRef, deviceState.sessions, deviceStateLoaded]);
- const openFileSurface = useCallback(
- (relativePath: string) => {
- if (!activeThreadRef || !activeProject) return;
- useRightPanelStore.getState().openFile(activeThreadRef, relativePath);
- },
- [activeProject, activeThreadRef],
- );
+ const openFileSurface = (relativePath: string) => {
+ if (!activeThreadRef || !activeProject) return;
+ useRightPanelStore.getState().openFile(activeThreadRef, relativePath);
+ };
+ const openFileSurfaceStable = useStableCallback(openFileSurface);
// 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 persistedLinkedThreadPullRequest = isServerThread
@@ -5936,7 +5845,7 @@ export default function ChatView(props: ChatViewProps) {
shouldUsePlanSidebarSheet,
threadDetailLoading,
]);
- const closePreviewPanel = useCallback(() => {
+ const closePreviewPanel = () => {
if (activeThreadRef) {
// Closing the panel on a live browser or device floats it instead of dropping it.
if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) {
@@ -5951,8 +5860,9 @@ export default function ChatView(props: ChatViewProps) {
useRightPanelStore.getState().setMaximized(activeThreadRef, false);
useRightPanelStore.getState().close(activeThreadRef);
}
- }, [activeRightPanelSurface, activeThreadRef]);
- const togglePreviewPanel = useCallback(() => {
+ };
+ const closePreviewPanelStable = useStableCallback(closePreviewPanel);
+ const togglePreviewPanel = () => {
if (!activeThreadRef || !browserAvailable) return;
if (previewPanelOpen) {
closePreviewPanel();
@@ -5965,16 +5875,9 @@ export default function ChatView(props: ChatViewProps) {
} else {
createBrowserSurface();
}
- }, [
- activePreviewState.activeTabId,
- activeThreadRef,
- browserAvailable,
- canOperatePreview,
- closePreviewPanel,
- createBrowserSurface,
- previewPanelOpen,
- ]);
- const addTerminalSurface = useCallback(() => {
+ };
+ const togglePreviewPanelStable = useStableCallback(togglePreviewPanel);
+ const addTerminalSurface = () => {
if (!hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject) return;
const cwd = gitCwd ?? activeProject.workspaceRoot;
const terminalId = allocateTerminalId();
@@ -5993,151 +5896,118 @@ export default function ChatView(props: ChatViewProps) {
}),
},
});
- }, [
- activeProject,
- activeThreadId,
- activeThreadRef,
- activeThreadWorktreePath,
- allocateTerminalId,
- gitCwd,
- openTerminal,
- hasTerminalWriteAccess,
- ]);
- const splitPanelTerminal = useCallback(
- (direction: "horizontal" | "vertical" = "horizontal") => {
- if (
- !hasTerminalWriteAccess() ||
- !activeThreadRef ||
- !activeThreadId ||
- !activeProject ||
- activeRightPanelSurface?.kind !== "terminal" ||
- activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP
- ) {
- return;
- }
- const terminalId = allocateTerminalId();
- const cwd = gitCwd ?? activeProject.workspaceRoot;
- useRightPanelStore
- .getState()
- .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction);
- setTerminalFocusRequestId((value) => value + 1);
- void openTerminal({
- environmentId: activeThreadRef.environmentId,
- input: {
- threadId: activeThreadId,
- terminalId,
- cwd,
- ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
- env: projectScriptRuntimeEnv({
- project: { cwd: activeProject.workspaceRoot },
- worktreePath: activeThreadWorktreePath,
- }),
- },
- });
- },
- [
- activeProject,
- activeRightPanelSurface,
- activeThreadId,
- activeThreadRef,
- activeThreadWorktreePath,
- allocateTerminalId,
- gitCwd,
- openTerminal,
- hasTerminalWriteAccess,
- ],
- );
- const splitPanelTerminalVertical = useCallback(() => {
+ };
+ const addTerminalSurfaceStable = useStableCallback(addTerminalSurface);
+ const splitPanelTerminal = (direction: "horizontal" | "vertical" = "horizontal") => {
+ if (
+ !hasTerminalWriteAccess() ||
+ !activeThreadRef ||
+ !activeThreadId ||
+ !activeProject ||
+ activeRightPanelSurface?.kind !== "terminal" ||
+ activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP
+ ) {
+ return;
+ }
+ const terminalId = allocateTerminalId();
+ const cwd = gitCwd ?? activeProject.workspaceRoot;
+ useRightPanelStore
+ .getState()
+ .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction);
+ setTerminalFocusRequestId((value) => value + 1);
+ void openTerminal({
+ environmentId: activeThreadRef.environmentId,
+ input: {
+ threadId: activeThreadId,
+ terminalId,
+ cwd,
+ ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
+ env: projectScriptRuntimeEnv({
+ project: { cwd: activeProject.workspaceRoot },
+ worktreePath: activeThreadWorktreePath,
+ }),
+ },
+ });
+ };
+ const splitPanelTerminalStable = useStableCallback(splitPanelTerminal);
+ const splitPanelTerminalVertical = () => {
splitPanelTerminal("vertical");
- }, [splitPanelTerminal]);
- const activatePanelTerminal = useCallback(
- (terminalId: string) => {
- if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return;
- useRightPanelStore
- .getState()
- .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId);
- setTerminalFocusRequestId((value) => value + 1);
- },
- [activeRightPanelSurface, activeThreadRef],
- );
- const closePanelTerminal = useCallback(
- (terminalId: string) => {
- if (
- !hasTerminalWriteAccess() ||
- !activeThreadRef ||
- activeRightPanelSurface?.kind !== "terminal"
- )
- return;
- void closeTerminalMutation({
- environmentId: activeThreadRef.environmentId,
- input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true },
- });
- storeCloseTerminal(activeThreadRef, terminalId);
- useRightPanelStore
- .getState()
- .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId);
- setTerminalFocusRequestId((value) => value + 1);
- },
- [
- hasTerminalWriteAccess,
- activeRightPanelSurface,
- activeThreadRef,
- closeTerminalMutation,
- storeCloseTerminal,
- ],
- );
- const requestCloseTerminal = useCallback(
- (terminalId: string) => {
- if (!hasTerminalWriteAccess()) return;
- const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId);
- void confirmTerminalClose([label]).then((confirmed) => {
- if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
- closeTerminal(terminalId);
- }
- });
- },
- [hasTerminalWriteAccess, activeTerminalLabelsById, closeTerminal, environmentId],
- );
- const requestClosePanelTerminal = useCallback(
- (terminalId: string) => {
- if (!hasTerminalWriteAccess()) return;
- const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId);
- void confirmTerminalClose([label]).then((confirmed) => {
- if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
- closePanelTerminal(terminalId);
- }
- });
- },
- [hasTerminalWriteAccess, activeTerminalLabelsById, closePanelTerminal, environmentId],
- );
- const activateRightPanelSurface = useCallback(
- (surface: RightPanelSurface) => {
- if (!activeThreadRef) return;
- useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id);
- if (surface.kind === "preview" && surface.resourceId) {
- setActivePreviewTab(activeThreadRef, surface.resourceId);
- }
- if (surface.kind === "terminal") {
- setTerminalFocusRequestId((value) => value + 1);
+ };
+ const splitPanelTerminalVerticalStable = useStableCallback(splitPanelTerminalVertical);
+ const activatePanelTerminal = (terminalId: string) => {
+ if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return;
+ useRightPanelStore
+ .getState()
+ .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId);
+ setTerminalFocusRequestId((value) => value + 1);
+ };
+ const activatePanelTerminalStable = useStableCallback(activatePanelTerminal);
+ const closePanelTerminal = (terminalId: string) => {
+ if (
+ !hasTerminalWriteAccess() ||
+ !activeThreadRef ||
+ activeRightPanelSurface?.kind !== "terminal"
+ )
+ return;
+ void closeTerminalMutation({
+ environmentId: activeThreadRef.environmentId,
+ input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true },
+ });
+ storeCloseTerminal(activeThreadRef, terminalId);
+ useRightPanelStore
+ .getState()
+ .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId);
+ setTerminalFocusRequestId((value) => value + 1);
+ };
+ const closePanelTerminalStable = useStableCallback(closePanelTerminal);
+ const requestCloseTerminal = (terminalId: string) => {
+ if (!hasTerminalWriteAccess()) return;
+ const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId);
+ void confirmTerminalClose([label]).then((confirmed) => {
+ if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
+ closeTerminal(terminalId);
}
- if (surface.kind === "diff" && !diffOpen) {
- onDiffPanelOpen?.();
+ });
+ };
+ const requestCloseTerminalStable = useStableCallback(requestCloseTerminal);
+ const requestClosePanelTerminal = (terminalId: string) => {
+ if (!hasTerminalWriteAccess()) return;
+ const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId);
+ void confirmTerminalClose([label]).then((confirmed) => {
+ if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
+ closePanelTerminal(terminalId);
}
- },
- [activeThreadRef, diffOpen, onDiffPanelOpen],
- );
- const toggleRightPanel = useCallback(() => {
+ });
+ };
+ const requestClosePanelTerminalStable = useStableCallback(requestClosePanelTerminal);
+ const activateRightPanelSurface = (surface: RightPanelSurface) => {
+ if (!activeThreadRef) return;
+ useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id);
+ if (surface.kind === "preview" && surface.resourceId) {
+ setActivePreviewTab(activeThreadRef, surface.resourceId);
+ }
+ if (surface.kind === "terminal") {
+ setTerminalFocusRequestId((value) => value + 1);
+ }
+ if (surface.kind === "diff" && !diffOpen) {
+ onDiffPanelOpen?.();
+ }
+ };
+ const activateRightPanelSurfaceStable = useStableCallback(activateRightPanelSurface);
+ const toggleRightPanel = () => {
if (!activeThreadRef) return;
if (rightPanelOpen) {
closePreviewPanel();
return;
}
useRightPanelStore.getState().toggleVisibility(activeThreadRef);
- }, [activeThreadRef, closePreviewPanel, rightPanelOpen]);
- const toggleThreadPanel = useCallback(() => {
+ };
+ const toggleRightPanelStable = useStableCallback(toggleRightPanel);
+ const toggleThreadPanel = () => {
if (!activeThreadRef) return;
useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation);
- }, [activeThreadRef, threadPanelPresentation]);
+ };
+ const toggleThreadPanelStable = useStableCallback(toggleThreadPanel);
// A thread started for a link the OS opened shows its browser maximized, like a browser window.
useEffect(() => {
if (!canMaximizeRightPanel) return;
@@ -6145,70 +6015,61 @@ export default function ChatView(props: ChatViewProps) {
useRightPanelStore.getState().setMaximized(routeThreadRef, true);
}
}, [canMaximizeRightPanel, routeThreadRef]);
- const toggleRightPanelMaximized = useCallback(() => {
+ const toggleRightPanelMaximized = () => {
if (!canMaximizeRightPanel || !activeThreadRef) return;
useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized);
- }, [activeThreadRef, canMaximizeRightPanel, rightPanelMaximized]);
- const cleanupRightPanelSurfaces = useCallback(
- (surfaces: readonly RightPanelSurface[]) => {
- if (!activeThreadRef) return;
- for (const surface of surfaces) {
- // Without preview access only the local surface goes away. The
- // server record is in-memory bookkeeping, not a live view, and stays
- // listed until a client with the scope closes it or the server restarts.
- if (canOperatePreview && surface.kind === "preview" && surface.resourceId) {
- void closePreviewSession({
- closePreview,
- snapshot: activePreviewState.sessions[surface.resourceId] ?? null,
- tabId: surface.resourceId,
- threadRef: activeThreadRef,
+ };
+ const toggleRightPanelMaximizedStable = useStableCallback(toggleRightPanelMaximized);
+ const cleanupRightPanelSurfaces = (surfaces: readonly RightPanelSurface[]) => {
+ if (!activeThreadRef) return;
+ for (const surface of surfaces) {
+ // Without preview access only the local surface goes away. The
+ // server record is in-memory bookkeeping, not a live view, and stays
+ // listed until a client with the scope closes it or the server restarts.
+ if (canOperatePreview && surface.kind === "preview" && surface.resourceId) {
+ void closePreviewSession({
+ closePreview,
+ snapshot: activePreviewState.sessions[surface.resourceId] ?? null,
+ tabId: surface.resourceId,
+ threadRef: activeThreadRef,
+ });
+ }
+ if (
+ surface.kind === "terminal" &&
+ readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope)
+ ) {
+ for (const terminalId of surface.terminalIds) {
+ storeCloseTerminal(activeThreadRef, terminalId);
+ void closeTerminalMutation({
+ environmentId: activeThreadRef.environmentId,
+ input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true },
});
}
- if (
- surface.kind === "terminal" &&
- readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope)
- ) {
- for (const terminalId of surface.terminalIds) {
- storeCloseTerminal(activeThreadRef, terminalId);
- void closeTerminalMutation({
- environmentId: activeThreadRef.environmentId,
- input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true },
- });
- }
- }
}
- },
- [
- activeThreadRef,
- activePreviewState.sessions,
- canOperatePreview,
- closePreview,
- closeTerminalMutation,
- storeCloseTerminal,
- ],
- );
- const closeAfterAgentBrowserConfirmation = useCallback(
- (surfaces: readonly RightPanelSurface[], closeSurfaces: () => void) => {
- const message = agentControlledBrowserCloseConfirmation(
- surfaces,
- activePreviewState.desktopByTabId,
- );
- if (!message) {
- closeSurfaces();
- return;
- }
- const localApi = readLocalApi();
- if (!localApi) return;
- void localApi.dialogs.confirm(message, { variant: "destructive" }).then(
- (confirmed) => {
- if (confirmed) closeSurfaces();
- },
- () => undefined,
- );
- },
- [activePreviewState.desktopByTabId],
- );
- const syncActivePreviewSurface = useCallback(() => {
+ }
+ };
+ const closeAfterAgentBrowserConfirmation = (
+ surfaces: readonly RightPanelSurface[],
+ closeSurfaces: () => void,
+ ) => {
+ const message = agentControlledBrowserCloseConfirmation(
+ surfaces,
+ activePreviewState.desktopByTabId,
+ );
+ if (!message) {
+ closeSurfaces();
+ return;
+ }
+ const localApi = readLocalApi();
+ if (!localApi) return;
+ void localApi.dialogs.confirm(message, { variant: "destructive" }).then(
+ (confirmed) => {
+ if (confirmed) closeSurfaces();
+ },
+ () => undefined,
+ );
+ };
+ const syncActivePreviewSurface = () => {
if (!activeThreadRef) return;
const nextActiveSurface = selectActiveRightPanelSurface(
useRightPanelStore.getState().byThreadKey,
@@ -6217,110 +6078,80 @@ export default function ChatView(props: ChatViewProps) {
if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) {
setActivePreviewTab(activeThreadRef, nextActiveSurface.resourceId);
}
- }, [activeThreadRef]);
- const finishRightPanelSurfaceClose = useCallback(
- (surfaces: readonly RightPanelSurface[]) => {
- if (!activeThreadRef) return;
- const store = useRightPanelStore.getState();
- const activeId = selectThreadRightPanelState(
- store.byThreadKey,
- activeThreadRef,
- ).activeSurfaceId;
- const ordered = surfaces.toSorted(
- (left, right) => Number(left.id === activeId) - Number(right.id === activeId),
+ };
+ const finishRightPanelSurfaceClose = (surfaces: readonly RightPanelSurface[]) => {
+ if (!activeThreadRef) return;
+ const store = useRightPanelStore.getState();
+ const activeId = selectThreadRightPanelState(
+ store.byThreadKey,
+ activeThreadRef,
+ ).activeSurfaceId;
+ const ordered = surfaces.toSorted(
+ (left, right) => Number(left.id === activeId) - Number(right.id === activeId),
+ );
+ for (const surface of ordered) {
+ cleanupRightPanelSurfaces([surface]);
+ store.closeSurface(activeThreadRef, surface.id);
+ }
+ syncActivePreviewSurface();
+ };
+ const closeRightPanelSurface = (surface: RightPanelSurface) => {
+ if (!activeThreadRef) return;
+ const finishClose = () => finishRightPanelSurfaceClose([surface]);
+ if (surface.kind === "preview") {
+ closeAfterAgentBrowserConfirmation([surface], finishClose);
+ return;
+ }
+ if (
+ surface.kind !== "terminal" ||
+ !readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope)
+ ) {
+ finishClose();
+ return;
+ }
+ const activeLabel =
+ activeTerminalLabelsById.get(surface.activeTerminalId) ??
+ getTerminalLabel(surface.activeTerminalId);
+ const otherLabels = surface.terminalIds
+ .filter((terminalId) => terminalId !== surface.activeTerminalId)
+ .map(
+ (terminalId) => activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId),
);
- for (const surface of ordered) {
- cleanupRightPanelSurfaces([surface]);
- store.closeSurface(activeThreadRef, surface.id);
- }
- syncActivePreviewSurface();
- },
- [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface],
- );
- const closeRightPanelSurface = useCallback(
- (surface: RightPanelSurface) => {
- if (!activeThreadRef) return;
- const finishClose = () => finishRightPanelSurfaceClose([surface]);
- if (surface.kind === "preview") {
- closeAfterAgentBrowserConfirmation([surface], finishClose);
- return;
- }
- if (
- surface.kind !== "terminal" ||
- !readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope)
- ) {
+ void confirmTerminalClose([activeLabel, ...otherLabels]).then((confirmed) => {
+ if (confirmed) {
finishClose();
- return;
}
- const activeLabel =
- activeTerminalLabelsById.get(surface.activeTerminalId) ??
- getTerminalLabel(surface.activeTerminalId);
- const otherLabels = surface.terminalIds
- .filter((terminalId) => terminalId !== surface.activeTerminalId)
- .map(
- (terminalId) => activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId),
- );
- void confirmTerminalClose([activeLabel, ...otherLabels]).then((confirmed) => {
- if (confirmed) {
- finishClose();
- }
- });
- },
- [
- activeThreadRef,
- activeTerminalLabelsById,
- closeAfterAgentBrowserConfirmation,
- finishRightPanelSurfaceClose,
- ],
- );
- const closeOtherRightPanelSurfaces = useCallback(
- (surface: RightPanelSurface) => {
- if (!activeThreadRef) return;
- const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id);
- const finishClose = () => finishRightPanelSurfaceClose(surfaces);
- closeAfterAgentBrowserConfirmation(surfaces, finishClose);
- },
- [
- activeThreadRef,
- closeAfterAgentBrowserConfirmation,
- finishRightPanelSurfaceClose,
- rightPanelState.surfaces,
- ],
- );
- const closeRightPanelSurfacesToRight = useCallback(
- (surface: RightPanelSurface) => {
- if (!activeThreadRef) return;
- const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id);
- if (surfaceIndex < 0) return;
- const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1);
- const finishClose = () => finishRightPanelSurfaceClose(surfaces);
- closeAfterAgentBrowserConfirmation(surfaces, finishClose);
- },
- [
- activeThreadRef,
- closeAfterAgentBrowserConfirmation,
- finishRightPanelSurfaceClose,
- rightPanelState.surfaces,
- ],
- );
- const closeAllRightPanelSurfaces = useCallback(() => {
+ });
+ };
+ const closeRightPanelSurfaceStable = useStableCallback(closeRightPanelSurface);
+ const closeOtherRightPanelSurfaces = (surface: RightPanelSurface) => {
+ if (!activeThreadRef) return;
+ const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id);
+ const finishClose = () => finishRightPanelSurfaceClose(surfaces);
+ closeAfterAgentBrowserConfirmation(surfaces, finishClose);
+ };
+ const closeOtherRightPanelSurfacesStable = useStableCallback(closeOtherRightPanelSurfaces);
+ const closeRightPanelSurfacesToRight = (surface: RightPanelSurface) => {
+ if (!activeThreadRef) return;
+ const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id);
+ if (surfaceIndex < 0) return;
+ const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1);
+ const finishClose = () => finishRightPanelSurfaceClose(surfaces);
+ closeAfterAgentBrowserConfirmation(surfaces, finishClose);
+ };
+ const closeRightPanelSurfacesToRightStable = useStableCallback(closeRightPanelSurfacesToRight);
+ const closeAllRightPanelSurfaces = () => {
if (!activeThreadRef) return;
const finishClose = () => finishRightPanelSurfaceClose(rightPanelState.surfaces);
closeAfterAgentBrowserConfirmation(rightPanelState.surfaces, finishClose);
- }, [
- activeThreadRef,
- closeAfterAgentBrowserConfirmation,
- finishRightPanelSurfaceClose,
- rightPanelState.surfaces,
- ]);
- const moveRightPanelSurface = useCallback(
- (surfaceId: string, toIndex: number) => {
- if (activeThreadRef)
- useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex);
- },
- [activeThreadRef],
- );
- const copyRightPanelFilePath = useCallback((relativePath: string) => {
+ };
+ const closeAllRightPanelSurfacesStable = useStableCallback(closeAllRightPanelSurfaces);
+ const moveRightPanelSurface = (surfaceId: string, toIndex: number) => {
+ if (activeThreadRef)
+ useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex);
+ };
+ const moveRightPanelSurfaceStable = useStableCallback(moveRightPanelSurface);
+ const copyRightPanelFilePath = (relativePath: string) => {
if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
toastManager.add(
stackedThreadToast({
@@ -6350,88 +6181,80 @@ export default function ChatView(props: ChatViewProps) {
);
},
);
- }, []);
+ };
+ const copyRightPanelFilePathStable = useStableCallback(copyRightPanelFilePath);
useEffect(
() =>
subscribePreviewAction((action) => {
- if (action === "toggle-panel") togglePreviewPanel();
+ if (action === "toggle-panel") togglePreviewPanelStable();
}),
- [togglePreviewPanel],
+ [togglePreviewPanelStable],
);
- const persistThreadSettingsForNextTurn = useCallback(
- async (input: {
- threadId: ThreadId;
- createdAt: string;
- branch?: string;
- runtimeMode: RuntimeMode;
- interactionMode: ProviderInteractionMode;
- }): Promise> => {
- if (!serverThread) {
- return AsyncResult.success(undefined);
- }
-
- let result: AtomCommandResult = AsyncResult.success(undefined);
- const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({
- currentModelSelection: serverThread.modelSelection,
- currentBranch: serverThread.branch,
- ...(input.branch ? { nextBranch: input.branch } : {}),
- });
- if (metadataUpdate) {
- result = mapAtomCommandResult(
- await updateThreadMetadata({
- environmentId,
- input: {
- threadId: input.threadId,
- ...metadataUpdate,
- },
- }),
- () => undefined,
- );
- if (result._tag === "Failure") {
- return result;
- }
+ const persistThreadSettingsForNextTurn = async (input: {
+ threadId: ThreadId;
+ createdAt: string;
+ branch?: string;
+ runtimeMode: RuntimeMode;
+ interactionMode: ProviderInteractionMode;
+ }): Promise> => {
+ if (!serverThread) {
+ return AsyncResult.success(undefined);
+ }
+
+ let result: AtomCommandResult = AsyncResult.success(undefined);
+ const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({
+ currentModelSelection: serverThread.modelSelection,
+ currentBranch: serverThread.branch,
+ ...(input.branch ? { nextBranch: input.branch } : {}),
+ });
+ if (metadataUpdate) {
+ result = mapAtomCommandResult(
+ await updateThreadMetadata({
+ environmentId,
+ input: {
+ threadId: input.threadId,
+ ...metadataUpdate,
+ },
+ }),
+ () => undefined,
+ );
+ if (result._tag === "Failure") {
+ return result;
}
+ }
- if (input.runtimeMode !== serverThread.runtimeMode) {
- result = mapAtomCommandResult(
- await setThreadRuntimeMode({
- environmentId,
- input: {
- threadId: input.threadId,
- runtimeMode: input.runtimeMode,
- createdAt: input.createdAt,
- },
- }),
- () => undefined,
- );
- if (result._tag === "Failure") {
- return result;
- }
+ if (input.runtimeMode !== serverThread.runtimeMode) {
+ result = mapAtomCommandResult(
+ await setThreadRuntimeMode({
+ environmentId,
+ input: {
+ threadId: input.threadId,
+ runtimeMode: input.runtimeMode,
+ createdAt: input.createdAt,
+ },
+ }),
+ () => undefined,
+ );
+ if (result._tag === "Failure") {
+ return result;
}
+ }
- if (input.interactionMode !== serverThread.interactionMode) {
- result = mapAtomCommandResult(
- await setThreadInteractionMode({
- environmentId,
- input: {
- threadId: input.threadId,
- interactionMode: input.interactionMode,
- createdAt: input.createdAt,
- },
- }),
- () => undefined,
- );
- }
- return result;
- },
- [
- environmentId,
- serverThread,
- setThreadInteractionMode,
- setThreadRuntimeMode,
- updateThreadMetadata,
- ],
- );
+ if (input.interactionMode !== serverThread.interactionMode) {
+ result = mapAtomCommandResult(
+ await setThreadInteractionMode({
+ environmentId,
+ input: {
+ threadId: input.threadId,
+ interactionMode: input.interactionMode,
+ createdAt: input.createdAt,
+ },
+ }),
+ () => undefined,
+ );
+ }
+ return result;
+ };
// Debounce *showing* the scroll-to-bottom pill so it doesn't flash during
// thread switches. LegendList fires scroll events with isAtEnd=false while
@@ -6459,7 +6282,7 @@ export default function ChatView(props: ChatViewProps) {
readonly userScrollGeneration: number;
} | null>(null);
const anchorScrollRestoreFrameRef = useRef(null);
- const cancelTimelineLiveFollowForUserNavigation = useCallback(() => {
+ const cancelTimelineLiveFollowForUserNavigation = () => {
cancelPositionRestoreRef.current?.();
anchorUserScrollGenerationRef.current += 1;
const wasProgrammaticScrollMode = timelineScrollModeRef.current !== "free-scrolling";
@@ -6499,14 +6322,17 @@ export default function ChatView(props: ChatViewProps) {
scrollNode.scrollTop = currentScrollTop;
}
}
- }, []);
- const cancelTimelineLiveFollowForUserNavigationRef = useRef(
+ };
+ const cancelTimelineLiveFollowForUserNavigationStable = useStableCallback(
cancelTimelineLiveFollowForUserNavigation,
);
+ const cancelTimelineLiveFollowForUserNavigationRef = useRef(
+ cancelTimelineLiveFollowForUserNavigationStable,
+ );
useEffect(() => {
cancelTimelineLiveFollowForUserNavigationRef.current =
- cancelTimelineLiveFollowForUserNavigation;
- }, [cancelTimelineLiveFollowForUserNavigation]);
+ cancelTimelineLiveFollowForUserNavigationStable;
+ }, [cancelTimelineLiveFollowForUserNavigationStable]);
useEffect(() => {
const previous = observedTimelineActivityRef.current;
const dispatchedUserItem =
@@ -6544,13 +6370,13 @@ export default function ChatView(props: ChatViewProps) {
setShowScrollToBottom(false);
setTimelineAnchor({ threadKey: activeThreadKey, messageId });
}, [activeActivityRun, activeThreadKey, isServerThread, serverProjection, threadStatus]);
- const timelineRealContentOverflowsViewport = useCallback(
- (list?: LegendListRef | null) =>
- timelineContentOverflowsViewport((list ?? legendListRef.current)?.getState(), {
- composerInset: composerTimelineInset,
- anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET,
- }),
- [composerTimelineInset],
+ const timelineRealContentOverflowsViewport = (list?: LegendListRef | null) =>
+ timelineContentOverflowsViewport((list ?? legendListRef.current)?.getState(), {
+ composerInset: composerTimelineInset,
+ anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET,
+ });
+ const timelineRealContentOverflowsViewportStable = useStableCallback(
+ timelineRealContentOverflowsViewport,
);
const pageScrollControllerRef = useRef | null>(
null,
@@ -6558,8 +6384,11 @@ export default function ChatView(props: ChatViewProps) {
const handlePageScrollStart = useEffectEvent((key: PageScrollKey) => {
timelineScrollIntentRef.current = key === "PageUp" ? "away-from-end" : "toward-end";
composerRef.current?.collapseForTimelineScrollKey(key);
- if ((key === "PageUp" && timelineRealContentOverflowsViewport()) || !isTimelineAtLogicalEnd()) {
- cancelTimelineLiveFollowForUserNavigation();
+ if (
+ (key === "PageUp" && timelineRealContentOverflowsViewportStable()) ||
+ !isTimelineAtLogicalEndStable()
+ ) {
+ cancelTimelineLiveFollowForUserNavigationStable();
}
});
useEffect(() => {
@@ -6577,18 +6406,21 @@ export default function ChatView(props: ChatViewProps) {
}
};
}, [composerOverlayElement]);
- const onComposerPageScrollKeyDown = useCallback((key: PageScrollKey) => {
+ const onComposerPageScrollKeyDown = (key: PageScrollKey) => {
pageScrollControllerRef.current?.handleKeyDown(key);
- }, []);
- const onComposerPageScrollKeyUp = useCallback((key: string) => {
+ };
+ const onComposerPageScrollKeyDownStable = useStableCallback(onComposerPageScrollKeyDown);
+ const onComposerPageScrollKeyUp = (key: string) => {
pageScrollControllerRef.current?.handleKeyUp(key);
- }, []);
- const onComposerPageScrollRelease = useCallback(() => {
+ };
+ const onComposerPageScrollKeyUpStable = useStableCallback(onComposerPageScrollKeyUp);
+ const onComposerPageScrollRelease = () => {
pageScrollControllerRef.current?.releaseActiveKey();
- }, []);
+ };
+ const onComposerPageScrollReleaseStable = useStableCallback(onComposerPageScrollRelease);
// Live-follow stays active after send/thread-open until an actual list scroll
// gesture opts out.
- const scrollToEnd = useCallback((animated = false) => {
+ const scrollToEnd = (animated: boolean = false) => {
cancelPositionRestoreRef.current?.();
isAtEndRef.current = true;
timelineScrollModeRef.current = "following-end";
@@ -6604,7 +6436,8 @@ export default function ChatView(props: ChatViewProps) {
requestAnimationFrame(() => {
void legendListRef.current?.scrollToEnd?.({ animated });
});
- }, []);
+ };
+ const scrollToEndStable = useStableCallback(scrollToEnd);
useEffect(() => {
let removeListeners: (() => void) | null = null;
let frame: number | null = null;
@@ -6629,7 +6462,7 @@ export default function ChatView(props: ChatViewProps) {
// while pinned at the end produces no scroll event, never re-arms,
// and streaming silently stops following. Underflowing content can't
// scroll at all, so nothing there should break follow.
- const contentScrollsUp = () => timelineRealContentOverflowsViewport();
+ const contentScrollsUp = () => timelineRealContentOverflowsViewportStable();
// The follow re-arm band, not the strict flag: streaming growth makes
// isAtEnd flicker false for a frame before the follow scroll catches
// up, and a gesture landing in that window while still pinned would
@@ -6725,7 +6558,7 @@ export default function ChatView(props: ChatViewProps) {
handleManualNavigation();
}
composerRef.current?.collapseForTimelineScrollKey(event.key);
- if (isTimelineAtLogicalEnd()) {
+ if (isTimelineAtLogicalEndStable()) {
composerRef.current?.restoreAfterTimelineReachedEnd();
}
break;
@@ -6759,9 +6592,9 @@ export default function ChatView(props: ChatViewProps) {
}
removeListeners?.();
};
- }, [activeThread?.id, isTimelineAtLogicalEnd, timelineRealContentOverflowsViewport]);
+ }, [activeThread?.id, isTimelineAtLogicalEndStable, timelineRealContentOverflowsViewportStable]);
- const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => {
+ const onTimelineAnchorReady = (messageId: MessageId, anchorIndex: number) => {
if (pendingTimelineAnchorRef.current === messageId) {
pendingTimelineAnchorRef.current = null;
}
@@ -6810,8 +6643,9 @@ export default function ChatView(props: ChatViewProps) {
});
};
requestAnimationFrame(() => positionAnchor(12));
- }, []);
- const onTimelineAnchorSizeChanged = useCallback((messageId: MessageId) => {
+ };
+ const onTimelineAnchorReadyStable = useStableCallback(onTimelineAnchorReady);
+ const onTimelineAnchorSizeChanged = (messageId: MessageId) => {
if (settledTimelineAnchorRef.current !== messageId) {
return;
}
@@ -6851,13 +6685,15 @@ export default function ChatView(props: ChatViewProps) {
}
}
});
- }, []);
+ };
+ const onTimelineAnchorSizeChangedStable = useStableCallback(onTimelineAnchorSizeChanged);
- const onToolOutputCollapsedAtEnd = useCallback(() => {
+ const onToolOutputCollapsedAtEnd = () => {
composerRef.current?.restoreAfterTimelineReachedEnd();
- }, [composerRef]);
+ };
+ const onToolOutputCollapsedAtEndStable = useStableCallback(onToolOutputCollapsedAtEnd);
- const onIsAtEndChange = useCallback((isAtEnd: boolean) => {
+ const onIsAtEndChange = (isAtEnd: boolean) => {
if (
!isAtEnd &&
liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current
@@ -6887,7 +6723,8 @@ export default function ChatView(props: ChatViewProps) {
liveFollowUserScrollGenerationRef.current = null;
showScrollDebouncer.current.maybeExecute();
}
- }, []);
+ };
+ const onIsAtEndChangeStable = useStableCallback(onIsAtEndChange);
useLayoutEffect(() => {
if (timelineScrollModeRef.current !== "anchoring-new-turn") return;
@@ -6899,11 +6736,11 @@ export default function ChatView(props: ChatViewProps) {
timelineEntries,
})
) {
- scrollToEnd();
+ scrollToEndStable();
}
}, [
activeRunningTurnId,
- scrollToEnd,
+ scrollToEndStable,
timelineAnchorMessageId,
timelineEntries,
timelineLiveFollowEnabled,
@@ -6931,12 +6768,12 @@ export default function ChatView(props: ChatViewProps) {
useEffect(() => {
if (!activeThread?.id || terminalUiState.terminalOpen) return;
const frame = window.requestAnimationFrame(() => {
- focusComposer();
+ focusComposerStable();
});
return () => {
window.cancelAnimationFrame(frame);
};
- }, [activeThread?.id, focusComposer, terminalUiState.terminalOpen]);
+ }, [activeThread?.id, focusComposerStable, terminalUiState.terminalOpen]);
// Tabbing back into the app lands focus wherever it last was, often the right panel or the
// body. Put it in the composer unless something that takes typing already holds it. The
@@ -6956,7 +6793,7 @@ export default function ChatView(props: ChatViewProps) {
frame = null;
// A full-screen app owns the page; refocusing the composer would close it.
if (document.querySelector("[data-mcp-app-fullscreen]") !== null) return;
- if (shouldRefocusComposerOnWindowFocus(document.activeElement)) focusComposer();
+ if (shouldRefocusComposerOnWindowFocus(document.activeElement)) focusComposerStable();
});
});
};
@@ -6965,7 +6802,7 @@ export default function ChatView(props: ChatViewProps) {
window.removeEventListener("focus", onWindowFocus);
if (frame !== null) window.cancelAnimationFrame(frame);
};
- }, [activeThread?.id, focusComposer, isMobileViewport, terminalUiState.terminalOpen]);
+ }, [activeThread?.id, focusComposerStable, isMobileViewport, terminalUiState.terminalOpen]);
useEffect(() => {
if (!activeThread?.id) return;
@@ -6986,7 +6823,7 @@ export default function ChatView(props: ChatViewProps) {
for (const removedMessage of removedMessages) {
const previewUrls = collectUserMessageBlobPreviewUrls(removedMessage);
if (previewUrls.length > 0) {
- handoffAttachmentPreviews(removedMessage.id, previewUrls);
+ handoffAttachmentPreviewsStable(removedMessage.id, previewUrls);
continue;
}
revokeUserMessagePreviewUrls(removedMessage);
@@ -6998,7 +6835,7 @@ export default function ChatView(props: ChatViewProps) {
activeMessageCount,
activeThread?.id,
serverAcknowledgedUserMessageIds,
- handoffAttachmentPreviews,
+ handoffAttachmentPreviewsStable,
optimisticUserMessages,
]);
@@ -7016,9 +6853,10 @@ export default function ChatView(props: ChatViewProps) {
setExpandedImage(null);
}, [resetLocalDispatch, threadId]);
- const closeExpandedImage = useCallback(() => {
+ const closeExpandedImage = () => {
setExpandedImage(null);
- }, []);
+ };
+ const closeExpandedImageStable = useStableCallback(closeExpandedImage);
const activeWorktreePath = activeThread?.worktreePath ?? null;
const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({
@@ -7063,87 +6901,86 @@ export default function ChatView(props: ChatViewProps) {
: null,
[activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread],
);
- const publishComposerOverlayHeight = useCallback(
- (height: number) => {
- const nextHeight = Math.ceil(height);
- if (nextHeight <= 0) return;
- const isResting = overlayComposerIsResting({
- composerMounted: composerMountedRef.current,
- composerReportedResting: composerRestingRef.current,
- });
- const modelStrip = composerOverlayElement?.querySelector(
- '[data-composer-model-strip="true"]',
- );
- // The model-only strip disappears on expansion; counting it again would
- // add 32px of timeline padding as soon as the empty composer collapses.
- const restingOnlyHeight =
- modelStrip && isResting
- ? Math.max(
- 0,
- modelStrip.offsetHeight + Number.parseFloat(getComputedStyle(modelStrip).marginTop),
- )
- : 0;
- const nextInset = resolveComposerTimelineInset({
- currentInset: composerTimelineInsetRef.current,
- overlayHeight: nextHeight,
- isResting,
- restingOnlyHeight,
- });
- if (composerTimelineInsetRef.current !== nextInset) {
- composerTimelineInsetRef.current = nextInset;
- setComposerTimelineInset(nextInset);
- }
- const mainSurface = composerOverlayElement?.querySelector(
- '[data-chat-composer-main-surface="true"]',
- );
- const button = composerOverlayElement?.parentElement?.querySelector(
- 'button[aria-label="Scroll to end"]',
- );
- const clearance =
- composerOverlayElement && mainSurface && button
- ? resolveScrollToEndClearance({
- overlayHeight: nextHeight,
- mainSurfaceTop: mainSurface.getBoundingClientRect().top,
- button: button.getBoundingClientRect(),
- attachments: Array.from(
- composerOverlayElement.querySelectorAll(
- '[data-composer-banner-surface="attached"]',
- ),
- (element) => element.getBoundingClientRect(),
+ const publishComposerOverlayHeight = (height: number) => {
+ const nextHeight = Math.ceil(height);
+ if (nextHeight <= 0) return;
+ const isResting = overlayComposerIsResting({
+ composerMounted: composerMountedRef.current,
+ composerReportedResting: composerRestingRef.current,
+ });
+ const modelStrip = composerOverlayElement?.querySelector(
+ '[data-composer-model-strip="true"]',
+ );
+ // The model-only strip disappears on expansion; counting it again would
+ // add 32px of timeline padding as soon as the empty composer collapses.
+ const restingOnlyHeight =
+ modelStrip && isResting
+ ? Math.max(
+ 0,
+ modelStrip.offsetHeight + Number.parseFloat(getComputedStyle(modelStrip).marginTop),
+ )
+ : 0;
+ const nextInset = resolveComposerTimelineInset({
+ currentInset: composerTimelineInsetRef.current,
+ overlayHeight: nextHeight,
+ isResting,
+ restingOnlyHeight,
+ });
+ if (composerTimelineInsetRef.current !== nextInset) {
+ composerTimelineInsetRef.current = nextInset;
+ setComposerTimelineInset(nextInset);
+ }
+ const mainSurface = composerOverlayElement?.querySelector(
+ '[data-chat-composer-main-surface="true"]',
+ );
+ const button = composerOverlayElement?.parentElement?.querySelector(
+ 'button[aria-label="Scroll to end"]',
+ );
+ const clearance =
+ composerOverlayElement && mainSurface && button
+ ? resolveScrollToEndClearance({
+ overlayHeight: nextHeight,
+ mainSurfaceTop: mainSurface.getBoundingClientRect().top,
+ button: button.getBoundingClientRect(),
+ attachments: Array.from(
+ composerOverlayElement.querySelectorAll(
+ '[data-composer-banner-surface="attached"]',
),
- })
- : nextHeight;
- setScrollToEndClearance(clearance);
- },
- [composerOverlayElement],
- );
+ (element) => element.getBoundingClientRect(),
+ ),
+ })
+ : nextHeight;
+ setScrollToEndClearance(clearance);
+ };
+ const publishComposerOverlayHeightStable = useStableCallback(publishComposerOverlayHeight);
// The composer reports its resting flag from a layout effect, which runs
// before this component's own layout effects and before any resize
// observation, so every measurement below sees the flag for its layout.
// Only the flag is stored here: the stored height still belongs to the
// previous layout, and the composer publishes the new layout's height
// itself once it has measured it.
- const onComposerRestingChange = useCallback((resting: boolean) => {
+ const onComposerRestingChange = (resting: boolean) => {
composerRestingRef.current = resting;
- }, []);
+ };
+ const onComposerRestingChangeStable = useStableCallback(onComposerRestingChange);
// A held reservation belongs to the previous thread's draft. Rebuild it from
// this thread's overlay so a tall draft elsewhere does not pad this one.
useLayoutEffect(() => {
if (!composerOverlayElement) return;
composerTimelineInsetRef.current = 0;
- publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height);
- }, [activeThreadKey, composerOverlayElement, publishComposerOverlayHeight]);
+ publishComposerOverlayHeightStable(composerOverlayElement.getBoundingClientRect().height);
+ }, [activeThreadKey, composerOverlayElement, publishComposerOverlayHeightStable]);
useLayoutEffect(() => {
if (!composerOverlayElement) return;
const updateHeight = () => {
- publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height);
+ publishComposerOverlayHeightStable(composerOverlayElement.getBoundingClientRect().height);
};
updateHeight();
return observeResize(composerOverlayElement, updateHeight);
- }, [composerOverlayElement, publishComposerOverlayHeight, showScrollToBottom]);
+ }, [composerOverlayElement, publishComposerOverlayHeightStable, showScrollToBottom]);
// Swapping the composer for the status bar (or back) changes what the
// overlay holds, so rebuild the reservation from the new content.
useLayoutEffect(() => {
@@ -7151,8 +6988,8 @@ export default function ChatView(props: ChatViewProps) {
composerMountedRef.current = composerMounted;
if (!composerOverlayElement) return;
composerTimelineInsetRef.current = 0;
- publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height);
- }, [composerMounted, composerOverlayElement, publishComposerOverlayHeight]);
+ publishComposerOverlayHeightStable(composerOverlayElement.getBoundingClientRect().height);
+ }, [composerMounted, composerOverlayElement, publishComposerOverlayHeightStable]);
const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef);
const activeThreadReferenceCopyTarget = useMemo(
() =>
@@ -7172,7 +7009,7 @@ export default function ChatView(props: ChatViewProps) {
openPanelPullRequestUrl,
],
);
- const copyActiveThreadReference = useCallback(() => {
+ const copyActiveThreadReference = () => {
const target = activeThreadReferenceCopyTarget;
if (target === null) return;
void writeTextToClipboard(target.value, target.clipboardTarget).then(
@@ -7195,7 +7032,8 @@ export default function ChatView(props: ChatViewProps) {
);
},
);
- }, [activeThreadReferenceCopyTarget]);
+ };
+ const copyActiveThreadReferenceStable = useStableCallback(copyActiveThreadReference);
const pullRequestPanelTarget = activeThread
? threadPullRequestPanelTarget({
projectId: activeThread.projectId,
@@ -7204,11 +7042,12 @@ export default function ChatView(props: ChatViewProps) {
branchPullRequest: activeThreadShell?.branchPullRequest ?? activeThread.branchPullRequest,
})
: null;
- const addPullRequestSurface = useCallback(() => {
+ const addPullRequestSurface = () => {
if (!supportsPullRequests || activeThreadRef === null || pullRequestPanelTarget === null)
return;
useRightPanelStore.getState().openPullRequest(activeThreadRef, pullRequestPanelTarget);
- }, [activeThreadRef, pullRequestPanelTarget, supportsPullRequests]);
+ };
+ const addPullRequestSurfaceStable = useStableCallback(addPullRequestSurface);
const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null;
const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
@@ -7235,10 +7074,11 @@ export default function ChatView(props: ChatViewProps) {
? threadWokeAt(activeThreadShell, { now: new Date().toISOString() })
: null;
const acknowledgeThreadWoke = useAcknowledgeThreadWoke();
- const acknowledgeActiveThreadWoke = useCallback(() => {
+ const acknowledgeActiveThreadWoke = () => {
if (activeThreadRef === null || activeThreadWokeAt === null) return;
acknowledgeThreadWoke(activeThreadRef, activeThreadWokeAt);
- }, [acknowledgeThreadWoke, activeThreadRef, activeThreadWokeAt]);
+ };
+ const acknowledgeActiveThreadWokeStable = useStableCallback(acknowledgeActiveThreadWoke);
// Mirror of the sidebar's Woke pill for the open thread. Same watermark as
// the sidebar, so an acknowledgement from any device hides it.
const activeThreadLocalVisitedAt = useUiStateStore((store) =>
@@ -7283,7 +7123,7 @@ export default function ChatView(props: ChatViewProps) {
// must never clear (or re-enable) thread B's button.
const [unsettlingThreadKey, setUnsettlingThreadKey] = useState(null);
const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey;
- const handleUnsettleActiveThread = useCallback(async () => {
+ const handleUnsettleActiveThread = async () => {
if (
!activeThreadRef ||
!readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope)
@@ -7309,13 +7149,14 @@ export default function ChatView(props: ChatViewProps) {
} finally {
setUnsettlingThreadKey((current) => (current === threadKey ? null : current));
}
- }, [activeThreadRef, unsettleThreadMutation]);
+ };
+ const handleUnsettleActiveThreadStable = useStableCallback(handleUnsettleActiveThread);
const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, {
reportFailure: false,
});
const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState(null);
const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey;
- const handleUnsnoozeActiveThread = useCallback(async () => {
+ const handleUnsnoozeActiveThread = async () => {
if (
!activeThreadRef ||
!readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope)
@@ -7355,7 +7196,8 @@ export default function ChatView(props: ChatViewProps) {
} finally {
setUnsnoozingThreadKey((current) => (current === threadKey ? null : current));
}
- }, [activeThreadRef, activeThreadShell, unsnoozeThreadMutation, updateThreadMetadata]);
+ };
+ const handleUnsnoozeActiveThreadStable = useStableCallback(handleUnsnoozeActiveThread);
const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false);
const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false);
// Once revealed for a given mismatch, the banner stays mounted until the
@@ -7389,7 +7231,7 @@ export default function ChatView(props: ChatViewProps) {
return revealed !== null && revealed !== activeBranchMismatchKey ? null : revealed;
});
}, [activeBranchMismatchKey, showBranchMismatchBanner]);
- const handleSwitchCheckoutToThread = useCallback(async () => {
+ const handleSwitchCheckoutToThread = async () => {
if (!canWriteSourceControl) return;
if (
!activeProjectCwd ||
@@ -7449,18 +7291,7 @@ export default function ChatView(props: ChatViewProps) {
gitStatusQuery.refresh();
setIsRestoringThreadBranch(false);
scheduleComposerFocus();
- }, [
- canWriteSourceControl,
- activeProjectCwd,
- activeThread,
- environmentId,
- gitStatusQuery,
- isRestoringThreadBranch,
- localCheckoutBranchMismatch,
- scheduleComposerFocus,
- switchGitRef,
- updateThreadMetadata,
- ]);
+ };
// The stack renders items[0] front-most and tucks the rest behind hover, so
// ordering is priority: system banners, then the branch-mismatch notice,
// and the informational parked-thread banner last — it must never cover another.
@@ -7472,7 +7303,7 @@ export default function ChatView(props: ChatViewProps) {
const [stoppingBackgroundWorkKey, setStoppingBackgroundWorkKey] = useState(null);
const isStoppingBackgroundWork =
stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`;
- const handleStopBackgroundWork = useCallback(async () => {
+ const handleStopBackgroundWork = async () => {
if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
return;
const requestKey = `${environmentId}:${activeThread.id}`;
@@ -7493,23 +7324,21 @@ export default function ChatView(props: ChatViewProps) {
);
}
}
- }, [activeThread, environmentId, interruptThreadTurn, setThreadError]);
- const onOpenRelatedThread = useCallback(
- (threadId: ThreadId) => {
- // Related threads live here, except a message Home sent from another environment.
- const owner = serverThreadRefs.some(
- (ref) => ref.environmentId === environmentId && ref.threadId === threadId,
- )
- ? environmentId
- : (serverThreadRefs.find((ref) => ref.threadId === threadId)?.environmentId ??
- environmentId);
- void navigate({
- to: "/$environmentId/$threadId",
- params: buildThreadRouteParams(scopeThreadRef(owner, threadId)),
- });
- },
- [environmentId, navigate, serverThreadRefs],
- );
+ };
+ const handleStopBackgroundWorkStable = useStableCallback(handleStopBackgroundWork);
+ const onOpenRelatedThread = (threadId: ThreadId) => {
+ // Related threads live here, except a message Home sent from another environment.
+ const owner = serverThreadRefs.some(
+ (ref) => ref.environmentId === environmentId && ref.threadId === threadId,
+ )
+ ? environmentId
+ : (serverThreadRefs.find((ref) => ref.threadId === threadId)?.environmentId ?? environmentId);
+ void navigate({
+ to: "/$environmentId/$threadId",
+ params: buildThreadRouteParams(scopeThreadRef(owner, threadId)),
+ });
+ };
+ const onOpenRelatedThreadStable = useStableCallback(onOpenRelatedThread);
const childThreadInputs = useChildThreadInputs(activeThreadRef);
const childInputBannerItem = useMemo(() => {
const first = childThreadInputs[0];
@@ -7520,22 +7349,22 @@ export default function ChatView(props: ChatViewProps) {
id: `child-input:${first.id}`,
variant: "info",
priority: "activity",
- icon: ,
- title:
- childThreadInputs.length === 1
- ? "Subagent needs input"
- : `${childThreadInputs.length} subagents need input`,
+ icon: first.hasPendingApprovals ? : ,
+ title: childThreadInputsTitle(childThreadInputs),
description: childThreadInputs.map((child, index) => (
- {index > 0 ? ", " : null}
- onOpenRelatedThread(child.id)}>
- {child.title}
-
+ {index > 0 ? " " : null}
+
+ onOpenRelatedThreadStable(child.id)}>
+ {child.title}
+
+ {index < childThreadInputs.length - 1 ? "," : null}
+
)),
actions: (
-
@@ -7730,9 +7551,9 @@ export default function ChatView(props: ChatViewProps) {
activeBackgroundTasks,
activeThread,
canOperateThread,
- handleStopBackgroundWork,
+ handleStopBackgroundWorkStable,
isStoppingBackgroundWork,
- onOpenRelatedThread,
+ onOpenRelatedThreadStable,
]);
// Settled, snoozed, and woke are thread state, not composer actions: each
// gets one quiet line after the last message instead of a banner. A woken
@@ -7753,7 +7574,7 @@ export default function ChatView(props: ChatViewProps) {
}
actionLabel={isUnsnoozing ? "Waking..." : "Wake now"}
actionDisabled={!canOperateThread || isUnsnoozing}
- onAction={() => void handleUnsnoozeActiveThread()}
+ onAction={() => void handleUnsnoozeActiveThreadStable()}
/>
) : activeThreadSettled ? (
void handleUnsettleActiveThread()}
+ onAction={() => void handleUnsettleActiveThreadStable()}
/>
) : activeThreadWokeVisible ? (
}
label="Woke from snooze"
actionLabel="Dismiss"
- onAction={acknowledgeActiveThreadWoke}
+ onAction={acknowledgeActiveThreadWokeStable}
/>
) : null;
}, [
- acknowledgeActiveThreadWoke,
+ acknowledgeActiveThreadWokeStable,
activeThreadSettled,
activeThreadShell?.settledAt,
activeThreadShell?.snoozedUntil,
activeThreadSnoozed,
activeThreadWokeVisible,
canOperateThread,
- handleUnsettleActiveThread,
- handleUnsnoozeActiveThread,
+ handleUnsettleActiveThreadStable,
+ handleUnsnoozeActiveThreadStable,
isUnsettling,
isUnsnoozing,
nowMinute,
@@ -7861,7 +7682,7 @@ export default function ChatView(props: ChatViewProps) {
() => new Set(),
);
const keepFullHistory = fullHistoryThreadKeys.has(routeThreadKey);
- const setKeepFullHistory = useCallback((threadKey: string, keep: boolean) => {
+ const setKeepFullHistory = (threadKey: string, keep: boolean) => {
setFullHistoryThreadKeys((current) => {
if (current.has(threadKey) === keep) return current;
const next = new Set(current);
@@ -7869,23 +7690,18 @@ export default function ChatView(props: ChatViewProps) {
else next.delete(threadKey);
return next;
});
- }, []);
- const toggleKeepFullHistory = useCallback(
- () => setKeepFullHistory(routeThreadKey, !keepFullHistory),
- [keepFullHistory, routeThreadKey, setKeepFullHistory],
- );
- const handleRestoreThreadBranch = useCallback(() => {
+ };
+ const toggleKeepFullHistory = () => setKeepFullHistory(routeThreadKey, !keepFullHistory);
+ const toggleKeepFullHistoryStable = useStableCallback(toggleKeepFullHistory);
+ const handleRestoreThreadBranch = () => {
if (!canWriteSourceControl) return;
if (gitStatusQuery.data?.hasWorkingTreeChanges) {
setBranchRestoreConfirmOpen(true);
return;
}
void handleSwitchCheckoutToThread();
- }, [
- canWriteSourceControl,
- gitStatusQuery.data?.hasWorkingTreeChanges,
- handleSwitchCheckoutToThread,
- ]);
+ };
+ const handleRestoreThreadBranchStable = useStableCallback(handleRestoreThreadBranch);
const feedbackBannerItems = useMemo(
() =>
feedbackSubmissions.flatMap((submission) => {
@@ -7975,7 +7791,7 @@ export default function ChatView(props: ChatViewProps) {
size="xs"
variant="ghost"
disabled={!canWriteSourceControl || isRestoringThreadBranch}
- onClick={handleRestoreThreadBranch}
+ onClick={handleRestoreThreadBranchStable}
>
{isRestoringThreadBranch ? "Restoring..." : "Restore branch"}
@@ -7994,7 +7810,7 @@ export default function ChatView(props: ChatViewProps) {
canWriteSourceControl,
feedbackBannerItems,
limitRecoveryBanner,
- handleRestoreThreadBranch,
+ handleRestoreThreadBranchStable,
isRestoringThreadBranch,
backgroundWorkBannerItem,
childInputBannerItem,
@@ -8074,7 +7890,7 @@ export default function ChatView(props: ChatViewProps) {
} else if (previous && !current) {
terminalUiOpenByThreadRef.current[activeThreadKey] = current;
const frame = window.requestAnimationFrame(() => {
- focusComposer();
+ focusComposerStable();
});
return () => {
window.cancelAnimationFrame(frame);
@@ -8082,31 +7898,31 @@ export default function ChatView(props: ChatViewProps) {
}
terminalUiOpenByThreadRef.current[activeThreadKey] = current;
- }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]);
-
- const getShortcutContext = useCallback(
- (eventTarget: EventTarget | null = document.activeElement) => ({
- terminalFocus: getTerminalFocusOwner() !== null,
- terminalOpen: Boolean(terminalUiState.terminalOpen),
- previewFocus: isPreviewFocused(),
- previewOpen: previewPanelOpen,
- editableFocus: isEditableFocused(eventTarget),
- modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
- composerFocus: document.activeElement?.getAttribute("data-testid") === "composer-editor",
- draftThreadRoute: routeKind === "draft",
- turnRunning: phase === "running",
- isWeb: !isElectron,
- isDesktop: isElectron,
- }),
- [composerRef, previewPanelOpen, terminalUiState.terminalOpen, routeKind, phase],
- );
+ }, [activeThreadKey, focusComposerStable, terminalUiState.terminalOpen]);
+
+ const getShortcutContext = (eventTarget: EventTarget | null = document.activeElement) => ({
+ terminalFocus: getTerminalFocusOwner() !== null,
+ terminalOpen: Boolean(terminalUiState.terminalOpen),
+ previewFocus: isPreviewFocused(),
+ previewOpen: previewPanelOpen,
+ editableFocus: isEditableFocused(eventTarget),
+ modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
+ composerFocus: document.activeElement?.getAttribute("data-testid") === "composer-editor",
+ draftThreadRoute: routeKind === "draft",
+ turnRunning: phase === "running",
+ isWeb: !isElectron,
+ isDesktop: isElectron,
+ });
+ const getShortcutContextStable = useStableCallback(getShortcutContext);
const timelineSkills = activeProviderStatus
? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd)
: EMPTY_PROVIDER_SKILLS;
const threadFindControlsRef = useRef(null);
const [isThreadFindActive, setIsThreadFindActive] = useState(false);
- const openThreadFind = useCallback(() => threadFindControlsRef.current?.open(), []);
- const closeThreadFind = useCallback(() => threadFindControlsRef.current?.close(), []);
+ const openThreadFind = () => threadFindControlsRef.current?.open();
+ const openThreadFindStable = useStableCallback(openThreadFind);
+ const closeThreadFind = () => threadFindControlsRef.current?.close();
+ const closeThreadFindStable = useStableCallback(closeThreadFind);
useEffect(() => {
const handler = (event: globalThis.KeyboardEvent) => {
@@ -8127,7 +7943,7 @@ export default function ChatView(props: ChatViewProps) {
if (event.defaultPrevented && terminalFocusOwner === null) {
return;
}
- const shortcutContext = getShortcutContext(event.target);
+ const shortcutContext = getShortcutContextStable(event.target);
if (
!shortcutContext.terminalFocus &&
@@ -8149,7 +7965,7 @@ export default function ChatView(props: ChatViewProps) {
if (command === "thread.copyReference") {
event.preventDefault();
event.stopPropagation();
- if (!event.repeat) copyActiveThreadReference();
+ if (!event.repeat) copyActiveThreadReferenceStable();
return;
}
@@ -8159,7 +7975,7 @@ export default function ChatView(props: ChatViewProps) {
event.stopPropagation();
if (!isServerThread || !activeThreadRef || !supportsSettlement) return;
if (activeThreadSettled) {
- void handleUnsettleActiveThread();
+ void handleUnsettleActiveThreadStable();
return;
}
// Same as the sidebar Settle button: settle, then open the next thread.
@@ -8205,7 +8021,7 @@ export default function ChatView(props: ChatViewProps) {
if (!terminalUiState.terminalOpen && !canReadTerminal && !canOperateTerminal) return;
event.preventDefault();
event.stopPropagation();
- toggleTerminalVisibility();
+ toggleTerminalVisibilityStable();
return;
}
@@ -8213,28 +8029,28 @@ export default function ChatView(props: ChatViewProps) {
if (command === "chat.find" && isServerThread && serverConfig?.threadFind === true) {
event.preventDefault();
event.stopPropagation();
- openThreadFind();
+ openThreadFindStable();
return;
}
if (command === "rightPanel.toggle") {
event.preventDefault();
event.stopPropagation();
- toggleRightPanel();
+ toggleRightPanelStable();
return;
}
if (command === "rightPanel.toggleMaximized") {
event.preventDefault();
event.stopPropagation();
- if (!event.repeat) toggleRightPanelMaximized();
+ if (!event.repeat) toggleRightPanelMaximizedStable();
return;
}
if (command === "threadPanel.toggle") {
event.preventDefault();
event.stopPropagation();
- toggleThreadPanel();
+ toggleThreadPanelStable();
return;
}
@@ -8244,7 +8060,7 @@ export default function ChatView(props: ChatViewProps) {
if (!activeRightPanelSurface) return;
event.preventDefault();
event.stopPropagation();
- if (!event.repeat) closeRightPanelSurface(activeRightPanelSurface);
+ if (!event.repeat) closeRightPanelSurfaceStable(activeRightPanelSurface);
return;
}
@@ -8255,13 +8071,13 @@ export default function ChatView(props: ChatViewProps) {
event.preventDefault();
event.stopPropagation();
if (terminalFocusOwner === "right-panel") {
- splitPanelTerminal();
+ splitPanelTerminalStable();
return;
}
if (!terminalUiState.terminalOpen) {
- setTerminalOpen(true);
+ setTerminalOpenStable(true);
}
- splitTerminal();
+ splitTerminalStable();
return;
}
@@ -8272,13 +8088,13 @@ export default function ChatView(props: ChatViewProps) {
event.preventDefault();
event.stopPropagation();
if (terminalFocusOwner === "right-panel") {
- splitPanelTerminal("vertical");
+ splitPanelTerminalStable("vertical");
return;
}
if (!terminalUiState.terminalOpen) {
- setTerminalOpen(true);
+ setTerminalOpenStable(true);
}
- splitTerminal("vertical");
+ splitTerminalStable("vertical");
return;
}
@@ -8289,11 +8105,11 @@ export default function ChatView(props: ChatViewProps) {
event.preventDefault();
event.stopPropagation();
if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") {
- requestClosePanelTerminal(activeRightPanelSurface.activeTerminalId);
+ requestClosePanelTerminalStable(activeRightPanelSurface.activeTerminalId);
return;
}
if (!terminalUiState.terminalOpen) return;
- requestCloseTerminal(terminalUiState.activeTerminalId);
+ requestCloseTerminalStable(terminalUiState.activeTerminalId);
return;
}
@@ -8304,20 +8120,20 @@ export default function ChatView(props: ChatViewProps) {
event.preventDefault();
event.stopPropagation();
if (terminalFocusOwner === "right-panel") {
- addTerminalSurface();
+ addTerminalSurfaceStable();
return;
}
if (!terminalUiState.terminalOpen) {
- setTerminalOpen(true);
+ setTerminalOpenStable(true);
}
- createNewTerminal();
+ createNewTerminalStable();
return;
}
if (command === "diff.toggle") {
event.preventDefault();
event.stopPropagation();
- onToggleDiff();
+ onToggleDiffStable();
return;
}
@@ -8351,7 +8167,7 @@ export default function ChatView(props: ChatViewProps) {
(env) => env.environmentId === currentId,
);
const next = logicalProjectEnvironments[(index + 1) % logicalProjectEnvironments.length];
- if (next) onEnvironmentChange(next.environmentId);
+ if (next) onEnvironmentChangeStable(next.environmentId);
return;
}
@@ -8394,7 +8210,7 @@ export default function ChatView(props: ChatViewProps) {
event.preventDefault();
event.stopPropagation();
if (event.repeat) return;
- void onInterrupt();
+ void onInterruptStable();
return;
}
@@ -8404,7 +8220,7 @@ export default function ChatView(props: ChatViewProps) {
if (!script) return;
event.preventDefault();
event.stopPropagation();
- void runProjectScript(script);
+ void runProjectScriptStable(script);
};
const dismissFind = (event: KeyboardEvent) => {
if (
@@ -8416,11 +8232,11 @@ export default function ChatView(props: ChatViewProps) {
isCommandPaletteOpen()
)
return;
- const context = getShortcutContext(event.target);
+ const context = getShortcutContextStable(event.target);
if (context.terminalFocus || context.previewFocus || context.modelPickerOpen) return;
event.preventDefault();
- closeThreadFind();
- focusComposer();
+ closeThreadFindStable();
+ focusComposerStable();
};
window.addEventListener("keydown", handler, true);
window.addEventListener("keydown", dismissFind);
@@ -8434,7 +8250,7 @@ export default function ChatView(props: ChatViewProps) {
activeProjectScripts,
canReadTerminal,
canOperateTerminal,
- addTerminalSurface,
+ addTerminalSurfaceStable,
activeThreadRef,
activeThreadPinned,
activeThreadSettled,
@@ -8442,51 +8258,51 @@ export default function ChatView(props: ChatViewProps) {
terminalUiState.terminalOpen,
terminalUiState.activeTerminalId,
activeThreadId,
- closeRightPanelSurface,
- requestCloseTerminal,
- requestClosePanelTerminal,
- createNewTerminal,
- setTerminalOpen,
- runProjectScript,
- splitTerminal,
- splitPanelTerminal,
+ closeRightPanelSurfaceStable,
+ requestCloseTerminalStable,
+ requestClosePanelTerminalStable,
+ createNewTerminalStable,
+ setTerminalOpenStable,
+ runProjectScriptStable,
+ splitTerminalStable,
+ splitPanelTerminalStable,
keybindings,
scriptKeybindings,
- handleUnsettleActiveThread,
+ handleUnsettleActiveThreadStable,
isServerThread,
serverConfig?.threadFind,
- onInterrupt,
- onToggleDiff,
+ onInterruptStable,
+ onToggleDiffStable,
pinThread,
settleThread,
supportsPinning,
supportsSettlement,
confirmAndUnpinThread,
- copyActiveThreadReference,
- getShortcutContext,
- openThreadFind,
- closeThreadFind,
+ copyActiveThreadReferenceStable,
+ getShortcutContextStable,
+ openThreadFindStable,
+ closeThreadFindStable,
isThreadFindActive,
- toggleRightPanel,
- toggleRightPanelMaximized,
- toggleThreadPanel,
- toggleTerminalVisibility,
+ toggleRightPanelStable,
+ toggleRightPanelMaximizedStable,
+ toggleThreadPanelStable,
+ toggleTerminalVisibilityStable,
composerRef,
draftId,
environmentId,
envLocked,
hasMultipleEnvironments,
logicalProjectEnvironments,
- onEnvironmentChange,
+ onEnvironmentChangeStable,
]);
// A focused desktop browser page forwards these chords as menu actions.
useEffect(() => {
return window.desktopBridge?.onMenuAction((action) => {
- if (action === "rightPanel.toggle") toggleRightPanel();
- else if (action === "rightPanel.toggleMaximized") toggleRightPanelMaximized();
+ if (action === "rightPanel.toggle") toggleRightPanelStable();
+ else if (action === "rightPanel.toggleMaximized") toggleRightPanelMaximizedStable();
});
- }, [toggleRightPanel, toggleRightPanelMaximized]);
+ }, [toggleRightPanelStable, toggleRightPanelMaximizedStable]);
// Paste-to-focus: the resting composer blurs on a click into the timeline,
// so a paste that follows has no editable target and would be dropped.
@@ -8536,266 +8352,233 @@ export default function ChatView(props: ChatViewProps) {
setPendingRevert(null);
}
- const onRevertToTurnCount = useCallback(
- async (turnCount: number, messageId: MessageId, restoreFiles?: boolean) => {
- const localApi = readLocalApi();
- if (
- !localApi ||
- !activeThread ||
- isRevertingCheckpoint ||
- !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
- )
- return;
- const sourceMessage = serverProjection?.messages.find((message) => message.id === messageId);
- const message = sourceMessage
- ? {
- id: sourceMessage.id,
- role: sourceMessage.role,
- text: sourceMessage.text,
- attachments: sourceMessage.attachments,
- context: sourceMessage.context,
- runId: sourceMessage.runId,
- streaming: false,
- createdAt: DateTime.formatIso(sourceMessage.createdAt),
- updatedAt: DateTime.formatIso(sourceMessage.updatedAt),
- }
- : undefined;
- if (!message || message.role !== "user") return;
+ const onRevertToTurnCount = async (
+ turnCount: number,
+ messageId: MessageId,
+ restoreFiles?: boolean,
+ ) => {
+ const localApi = readLocalApi();
+ if (
+ !localApi ||
+ !activeThread ||
+ isRevertingCheckpoint ||
+ !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
+ )
+ return;
+ const sourceMessage = serverProjection?.messages.find((message) => message.id === messageId);
+ const message = sourceMessage
+ ? {
+ id: sourceMessage.id,
+ role: sourceMessage.role,
+ text: sourceMessage.text,
+ attachments: sourceMessage.attachments,
+ context: sourceMessage.context,
+ runId: sourceMessage.runId,
+ streaming: false,
+ createdAt: DateTime.formatIso(sourceMessage.createdAt),
+ updatedAt: DateTime.formatIso(sourceMessage.updatedAt),
+ }
+ : undefined;
+ if (!message || message.role !== "user") return;
- if (!supportsConversationRollback) {
- setThreadError(
- activeThread.id,
- "This provider does not support reverting conversation history. Start a new thread instead.",
- );
- return;
+ if (!supportsConversationRollback) {
+ setThreadError(
+ activeThread.id,
+ "This provider does not support reverting conversation history. Start a new thread instead.",
+ );
+ return;
+ }
+ if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) {
+ setThreadError(
+ activeThread.id,
+ `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`,
+ );
+ return;
+ }
+ if (phase === "running" || isSendBusy || isConnecting) {
+ setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
+ return;
+ }
+ if (restoreFiles === undefined) {
+ setPendingRevert({ turnCount, messageId, routeThreadKey });
+ return;
+ }
+
+ useComposerDraftStore.setState((store) => ({
+ rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey),
+ }));
+ setThreadError(activeThread.id, null);
+ try {
+ if (composerRef.current?.hasPendingAttachments()) {
+ throw new Error("Wait for attachments to finish preparing before rewinding.");
}
- if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) {
- setThreadError(
- activeThread.id,
- `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`,
+ const connection = readPreparedConnection(environmentId);
+ if (!connection) throw new Error("The environment is not connected.");
+ const files = await prepareRevertedMessageAttachments({
+ message,
+ environmentId,
+ httpBaseUrl: connection.httpBaseUrl,
+ createAssetUrl: createAttachmentAssetUrl,
+ });
+ const store = useComposerDraftStore.getState();
+ const draft = store.getComposerDraft(composerDraftTarget);
+ if (
+ (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + files.length >
+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS
+ ) {
+ throw new Error(
+ "Make room for this message's attachments in the composer before rewinding.",
);
- return;
- }
- if (phase === "running" || isSendBusy || isConnecting) {
- setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
- return;
- }
- if (restoreFiles === undefined) {
- setPendingRevert({ turnCount, messageId, routeThreadKey });
- return;
}
-
- useComposerDraftStore.setState((store) => ({
- rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey),
- }));
- setThreadError(activeThread.id, null);
- try {
- if (composerRef.current?.hasPendingAttachments()) {
- throw new Error("Wait for attachments to finish preparing before rewinding.");
- }
- const connection = readPreparedConnection(environmentId);
- if (!connection) throw new Error("The environment is not connected.");
- const files = await prepareRevertedMessageAttachments({
- message,
+ const commandId = CommandId.make(randomUUID());
+ await waitForRevertedMessage(routeThreadRef, messageId, turnCount, commandId, async () => {
+ const result = await revertThreadCheckpoint({
environmentId,
- httpBaseUrl: connection.httpBaseUrl,
- createAssetUrl: createAttachmentAssetUrl,
+ input: { commandId, threadId: activeThread.id, turnCount, restoreFiles },
});
- const store = useComposerDraftStore.getState();
- const draft = store.getComposerDraft(composerDraftTarget);
- if (
- (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + files.length >
- PROVIDER_SEND_TURN_MAX_ATTACHMENTS
- ) {
- throw new Error(
- "Make room for this message's attachments in the composer before rewinding.",
- );
- }
- const commandId = CommandId.make(randomUUID());
- await waitForRevertedMessage(routeThreadRef, messageId, turnCount, commandId, async () => {
- const result = await revertThreadCheckpoint({
- environmentId,
- input: { commandId, threadId: activeThread.id, turnCount, restoreFiles },
- });
- if (result._tag === "Failure") throw squashAtomCommandFailure(result);
- });
- const currentPrompt = store.getComposerDraft(composerDraftTarget)?.prompt ?? "";
- const restoredPrompt = recallableComposerPrompt(message.text);
- const nextPrompt =
- restoredPrompt.length === 0
- ? currentPrompt
- : currentPrompt.length > 0
- ? `${currentPrompt}\n\n${restoredPrompt}`
- : restoredPrompt;
- store.setPrompt(composerDraftTarget, nextPrompt);
- const images: ComposerImageAttachment[] = [];
- const restoredFiles: ComposerFileAttachment[] = [];
- files.forEach((file, index) => {
- const attachment = {
- id: randomUUID(),
- name: file.name,
- mimeType: file.type,
- sizeBytes: file.size,
- file,
- };
- if (message.attachments?.[index]?.type === "image") {
- images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) });
- } else {
- restoredFiles.push({ ...attachment, type: "file" });
- }
- });
- store.addImages(composerDraftTarget, images, { allowDuplicates: true });
- store.addFiles(composerDraftTarget, restoredFiles, { allowDuplicates: true });
- if (currentRouteThreadKeyRef.current === routeThreadKey) {
- promptRef.current = nextPrompt;
- composerRef.current?.resetCursorState({ prompt: nextPrompt, cursor: nextPrompt.length });
- requestAnimationFrame(() => {
- if (currentRouteThreadKeyRef.current === routeThreadKey)
- composerRef.current?.focusAtEnd();
- });
+ if (result._tag === "Failure") throw squashAtomCommandFailure(result);
+ });
+ const currentPrompt = store.getComposerDraft(composerDraftTarget)?.prompt ?? "";
+ const restoredPrompt = recallableComposerPrompt(message.text);
+ const nextPrompt =
+ restoredPrompt.length === 0
+ ? currentPrompt
+ : currentPrompt.length > 0
+ ? `${currentPrompt}\n\n${restoredPrompt}`
+ : restoredPrompt;
+ store.setPrompt(composerDraftTarget, nextPrompt);
+ const images: ComposerImageAttachment[] = [];
+ const restoredFiles: ComposerFileAttachment[] = [];
+ files.forEach((file, index) => {
+ const attachment = {
+ id: randomUUID(),
+ name: file.name,
+ mimeType: file.type,
+ sizeBytes: file.size,
+ file,
+ };
+ if (message.attachments?.[index]?.type === "image") {
+ images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) });
+ } else {
+ restoredFiles.push({ ...attachment, type: "file" });
}
- } catch (error) {
- setThreadError(
- activeThread.id,
- error instanceof Error ? error.message : "Failed to revert thread state.",
- );
- } finally {
- useComposerDraftStore.setState((store) => {
- const remaining = new Set(store.rewindingThreadKeys);
- remaining.delete(routeThreadKey);
- return { rewindingThreadKeys: remaining };
- });
- }
- },
- [
- activeThread,
- activeEnvironmentUnavailable,
- activeEnvironmentUnavailableLabel,
- composerDraftTarget,
- composerRef,
- createAttachmentAssetUrl,
- environmentId,
- isConnecting,
- isRevertingCheckpoint,
- isSendBusy,
- phase,
- revertThreadCheckpoint,
- routeThreadKey,
- routeThreadRef,
- setThreadError,
- supportsConversationRollback,
- serverProjection,
- ],
- );
-
- const onRollbackCheckpoint = useCallback(
- async (input: { readonly checkpointId: string; readonly scopeId: string }) => {
- if (!activeThread || isRevertingCheckpoint) return;
- if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) {
- setThreadError(
- activeThread.id,
- `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`,
- );
- return;
- }
- if (phase === "running" || isSendBusy || isConnecting) {
- setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
- return;
- }
- const localApi = readLocalApi();
- const confirmed =
- localApi == null
- ? window.confirm("Roll back this thread to the selected checkpoint?")
- : await localApi.dialogs.confirm(
- "Roll back this thread to the selected checkpoint?\nThis action cannot be undone.",
- );
- if (!confirmed) return;
-
- useComposerDraftStore.setState((store) => ({
- rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey),
- }));
- setThreadError(activeThread.id, null);
- const result = await revertThreadCheckpoint({
- environmentId,
- input: {
- threadId: activeThread.id,
- checkpointId: input.checkpointId,
- scopeId: input.scopeId,
- },
});
- if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- setThreadError(
- activeThread.id,
- error instanceof Error ? error.message : "Failed to revert thread state.",
- );
+ store.addImages(composerDraftTarget, images, { allowDuplicates: true });
+ store.addFiles(composerDraftTarget, restoredFiles, { allowDuplicates: true });
+ if (currentRouteThreadKeyRef.current === routeThreadKey) {
+ promptRef.current = nextPrompt;
+ composerRef.current?.resetCursorState({ prompt: nextPrompt, cursor: nextPrompt.length });
+ requestAnimationFrame(() => {
+ if (currentRouteThreadKeyRef.current === routeThreadKey)
+ composerRef.current?.focusAtEnd();
+ });
}
+ } catch (error) {
+ setThreadError(
+ activeThread.id,
+ error instanceof Error ? error.message : "Failed to revert thread state.",
+ );
+ } finally {
useComposerDraftStore.setState((store) => {
const remaining = new Set(store.rewindingThreadKeys);
remaining.delete(routeThreadKey);
return { rewindingThreadKeys: remaining };
});
- },
- [
- activeEnvironmentUnavailable,
- activeEnvironmentUnavailableLabel,
- activeThread,
+ }
+ };
+ const onRevertToTurnCountStable = useStableCallback(onRevertToTurnCount);
+
+ const onRollbackCheckpoint = async (input: {
+ readonly checkpointId: string;
+ readonly scopeId: string;
+ }) => {
+ if (!activeThread || isRevertingCheckpoint) return;
+ if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) {
+ setThreadError(
+ activeThread.id,
+ `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`,
+ );
+ return;
+ }
+ if (phase === "running" || isSendBusy || isConnecting) {
+ setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
+ return;
+ }
+ const localApi = readLocalApi();
+ const confirmed =
+ localApi == null
+ ? window.confirm("Roll back this thread to the selected checkpoint?")
+ : await localApi.dialogs.confirm(
+ "Roll back this thread to the selected checkpoint?\nThis action cannot be undone.",
+ );
+ if (!confirmed) return;
+
+ useComposerDraftStore.setState((store) => ({
+ rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey),
+ }));
+ setThreadError(activeThread.id, null);
+ const result = await revertThreadCheckpoint({
environmentId,
- isConnecting,
- isRevertingCheckpoint,
- isSendBusy,
- phase,
- revertThreadCheckpoint,
- setThreadError,
- ],
- );
+ input: {
+ threadId: activeThread.id,
+ checkpointId: input.checkpointId,
+ scopeId: input.scopeId,
+ },
+ });
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ setThreadError(
+ activeThread.id,
+ error instanceof Error ? error.message : "Failed to revert thread state.",
+ );
+ }
+ useComposerDraftStore.setState((store) => {
+ const remaining = new Set(store.rewindingThreadKeys);
+ remaining.delete(routeThreadKey);
+ return { rewindingThreadKeys: remaining };
+ });
+ };
- const onForkFromRun = useCallback(
- async (input: { readonly sourceThreadId: ThreadId; readonly runId: RunId }) => {
- if (!activeThread || activeEnvironmentUnavailable) return;
- const targetThreadId = newThreadId();
- const targetThreadRef = scopeThreadRef(environmentId, targetThreadId);
- const result = await forkThreadFromRun({
- environmentId,
- input: {
- sourceThreadId: input.sourceThreadId,
- targetThreadId,
- runId: input.runId,
- title: `${activeThread.title} fork`,
- },
- });
- if (result._tag === "Failure") {
- if (!isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- setThreadError(
- activeThread.id,
- error instanceof Error ? error.message : "Failed to fork this response.",
- );
- }
- return;
- }
- const targetThreadReady = await waitForThreadShell(targetThreadRef);
- if (!targetThreadReady) {
+ const onForkFromRun = async (input: {
+ readonly sourceThreadId: ThreadId;
+ readonly runId: RunId;
+ }) => {
+ if (!activeThread || activeEnvironmentUnavailable) return;
+ const targetThreadId = newThreadId();
+ const targetThreadRef = scopeThreadRef(environmentId, targetThreadId);
+ const result = await forkThreadFromRun({
+ environmentId,
+ input: {
+ sourceThreadId: input.sourceThreadId,
+ targetThreadId,
+ runId: input.runId,
+ title: `${activeThread.title} fork`,
+ },
+ });
+ if (result._tag === "Failure") {
+ if (!isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
setThreadError(
activeThread.id,
- "The fork was created, but its thread data did not reach this client. Reconnect and try opening it from the sidebar.",
+ error instanceof Error ? error.message : "Failed to fork this response.",
);
- return;
}
- await navigate({
- to: "/$environmentId/$threadId",
- params: buildThreadRouteParams(targetThreadRef),
- });
- },
- [
- activeEnvironmentUnavailable,
- activeThread,
- environmentId,
- forkThreadFromRun,
- navigate,
- setThreadError,
- ],
- );
+ return;
+ }
+ const targetThreadReady = await waitForThreadShell(targetThreadRef);
+ if (!targetThreadReady) {
+ setThreadError(
+ activeThread.id,
+ "The fork was created, but its thread data did not reach this client. Reconnect and try opening it from the sidebar.",
+ );
+ return;
+ }
+ await navigate({
+ to: "/$environmentId/$threadId",
+ params: buildThreadRouteParams(targetThreadRef),
+ });
+ };
+ const onForkFromRunStable = useStableCallback(onForkFromRun);
const onCompactContext = () => {
if (compactDisabled) return;
void sendStandaloneCommand("/compact", "Failed to compact context.");
@@ -10271,245 +10054,214 @@ export default function ChatView(props: ChatViewProps) {
}
};
- const onRespondToApproval = useCallback(
- async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => {
- if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
- return;
- if (
- pendingApprovals.find((approval) => approval.requestId === requestId)
- ?.responseCapability !== "live"
- )
- return;
+ const onRespondToApproval = async (
+ requestId: RuntimeRequestId,
+ decision: ProviderApprovalDecision,
+ ) => {
+ if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
+ return;
+ if (
+ pendingApprovals.find((approval) => approval.requestId === requestId)?.responseCapability !==
+ "live"
+ )
+ return;
- setRespondingRequestIds((existing) =>
- existing.includes(requestId) ? existing : [...existing, requestId],
+ setRespondingRequestIds((existing) =>
+ existing.includes(requestId) ? existing : [...existing, requestId],
+ );
+ const result = await respondToThreadApproval({
+ environmentId,
+ input: {
+ threadId: activeThreadId,
+ requestId,
+ decision,
+ },
+ });
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ setThreadError(
+ activeThreadId,
+ error instanceof Error ? error.message : "Failed to submit approval decision.",
);
- const result = await respondToThreadApproval({
- environmentId,
- input: {
- threadId: activeThreadId,
- requestId,
- decision,
- },
- });
- if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- setThreadError(
- activeThreadId,
- error instanceof Error ? error.message : "Failed to submit approval decision.",
- );
- }
- setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
- return result;
- },
- [activeThreadId, environmentId, pendingApprovals, respondToThreadApproval, setThreadError],
- );
-
- const onRespondToUserInput = useCallback(
- async (requestId: RuntimeRequestId, answers: Record) => {
- if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
- return;
- const pendingInput = pendingUserInputs.find((input) => input.requestId === requestId);
- if (!pendingInput || pendingInput.responseCapability === "not_resumable") return;
- const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]);
- if (userInputResponsesInFlight.current.has(responseKey)) return;
- const attachmentsByQuestionId = new Map<
- string,
- import("@t3tools/contracts").UserInputAttachments[string]
- >();
- const providerAnswers = { ...answers };
- for (const question of pendingInput.questions) {
- const target = questionAttachmentDraftId(
- environmentId,
- activeThreadId,
- requestId,
- question.id,
- );
- if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return;
- const draft = useComposerDraftStore.getState().getComposerDraft(target);
- providerAnswers[question.id] = projectPendingUserInputAnswer(
- answers[question.id],
- draft?.threadContexts ?? [],
- );
- const attachments = draft ? [...draft.images, ...draft.files] : [];
- if (attachments.length === 0) continue;
- const uploaded = getUploadedAttachments({ environmentId, images: attachments });
- if (!uploaded) {
- setThreadError(
- activeThreadId,
- "Wait for attachments to finish uploading, or remove failed uploads.",
- );
- return;
- }
- attachmentsByQuestionId.set(
- question.id,
- uploaded as import("@t3tools/contracts").UserInputAttachments[string],
- );
- }
- userInputResponsesInFlight.current.add(responseKey);
+ }
+ setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
+ return result;
+ };
+ const onRespondToApprovalStable = useStableCallback(onRespondToApproval);
- setRespondingUserInputRequestIds((existing) =>
- existing.includes(requestId) ? existing : [...existing, requestId],
- );
- const result = await respondToThreadUserInput({
+ const onRespondToUserInput = async (
+ requestId: RuntimeRequestId,
+ answers: Record,
+ ) => {
+ if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
+ return;
+ const pendingInput = pendingUserInputs.find((input) => input.requestId === requestId);
+ if (!pendingInput || pendingInput.responseCapability === "not_resumable") return;
+ const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]);
+ if (userInputResponsesInFlight.current.has(responseKey)) return;
+ const attachmentsByQuestionId = new Map<
+ string,
+ import("@t3tools/contracts").UserInputAttachments[string]
+ >();
+ const providerAnswers = { ...answers };
+ for (const question of pendingInput.questions) {
+ const target = questionAttachmentDraftId(
environmentId,
- input: {
- threadId: activeThreadId,
- requestId,
- answers: providerAnswers,
- ...(attachmentsByQuestionId.size > 0
- ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) }
- : {}),
- },
- });
- if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- setThreadError(
- activeThreadId,
- error instanceof Error ? error.message : "Failed to submit user input.",
- );
- }
- userInputResponsesInFlight.current.delete(responseKey);
- setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
- return result;
- },
- [activeThreadId, environmentId, pendingUserInputs, respondToThreadUserInput, setThreadError],
- );
-
- // Closes an async question without messaging the agent. The server records
- // the dismissal so every client releases the composer.
- const onDismissUserInput = useCallback(
- async (requestId: RuntimeRequestId) => {
- if (!activeThreadId) return;
-
- setRespondingUserInputRequestIds((existing) =>
- existing.includes(requestId) ? existing : [...existing, requestId],
+ activeThreadId,
+ requestId,
+ question.id,
);
- const result = await dismissThreadUserInput({
- environmentId,
- input: { threadId: activeThreadId, requestId },
- });
- if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
+ if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return;
+ const draft = useComposerDraftStore.getState().getComposerDraft(target);
+ providerAnswers[question.id] = projectPendingUserInputAnswer(
+ answers[question.id],
+ draft?.threadContexts ?? [],
+ );
+ const attachments = draft ? [...draft.images, ...draft.files] : [];
+ if (attachments.length === 0) continue;
+ const uploaded = getUploadedAttachments({ environmentId, images: attachments });
+ if (!uploaded) {
setThreadError(
activeThreadId,
- error instanceof Error ? error.message : "Failed to dismiss the question.",
+ "Wait for attachments to finish uploading, or remove failed uploads.",
);
- }
- setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
- return result;
- },
- [activeThreadId, dismissThreadUserInput, environmentId, setThreadError],
- );
-
- const setActivePendingUserInputQuestionIndex = useCallback(
- (nextQuestionIndex: number) => {
- if (!activePendingUserInput) {
return;
}
- setPendingUserInputQuestionIndexByRequestId((existing) => ({
- ...existing,
- [activePendingRequestKey]: nextQuestionIndex,
- }));
- },
- [activePendingUserInput, activePendingRequestKey],
- );
+ attachmentsByQuestionId.set(
+ question.id,
+ uploaded as import("@t3tools/contracts").UserInputAttachments[string],
+ );
+ }
+ userInputResponsesInFlight.current.add(responseKey);
- const onSelectActivePendingUserInputOption = useCallback(
- (questionId: string, optionValue: string) => {
- if (!activePendingUserInput) {
- return;
- }
- // The option replaces the custom answer. Anything typed there is the
- // user's text, so it goes back to the thread draft instead of vanishing.
- const displacedAnswer =
- pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer;
- const currentPrompt =
- useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? "";
- const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer);
- if (nextPrompt !== currentPrompt) {
- setComposerDraftPrompt(composerDraftTarget, nextPrompt);
- if (activeThreadId) {
- const store = useComposerDraftStore.getState();
- const questionTarget = questionAttachmentDraftId(
- environmentId,
- activeThreadId,
- activePendingUserInput.requestId,
- questionId,
- );
- store.addThreadContexts(
- composerDraftTarget,
- store.getComposerDraft(questionTarget)?.threadContexts ?? [],
- { appendReference: false },
- );
- }
- }
- setPendingUserInputAnswersByRequestId((existing) => {
- const question =
- (activePendingProgress?.activeQuestion?.id === questionId
- ? activePendingProgress.activeQuestion
- : undefined) ??
- activePendingUserInput.questions.find((entry) => entry.id === questionId);
- if (!question) {
- return existing;
- }
+ setRespondingUserInputRequestIds((existing) =>
+ existing.includes(requestId) ? existing : [...existing, requestId],
+ );
+ const result = await respondToThreadUserInput({
+ environmentId,
+ input: {
+ threadId: activeThreadId,
+ requestId,
+ answers: providerAnswers,
+ ...(attachmentsByQuestionId.size > 0
+ ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) }
+ : {}),
+ },
+ });
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ setThreadError(
+ activeThreadId,
+ error instanceof Error ? error.message : "Failed to submit user input.",
+ );
+ }
+ userInputResponsesInFlight.current.delete(responseKey);
+ setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
+ return result;
+ };
- return {
- ...existing,
- [activePendingRequestKey]: {
- ...existing[activePendingRequestKey],
- [questionId]: togglePendingUserInputOptionSelection(
- question,
- existing[activePendingRequestKey]?.[questionId],
- optionValue,
- ),
- },
- };
- });
- promptRef.current = "";
- composerRef.current?.resetCursorState({ cursor: 0 });
- },
- [
- activePendingProgress?.activeQuestion,
- activePendingUserInput,
- activePendingRequestKey,
- activeThreadId,
- composerDraftTarget,
- composerRef,
+ // Closes an async question without messaging the agent. The server records
+ // the dismissal so every client releases the composer.
+ const onDismissUserInput = async (requestId: RuntimeRequestId) => {
+ if (!activeThreadId) return;
+
+ setRespondingUserInputRequestIds((existing) =>
+ existing.includes(requestId) ? existing : [...existing, requestId],
+ );
+ const result = await dismissThreadUserInput({
environmentId,
- pendingUserInputAnswersByRequestId,
- setComposerDraftPrompt,
- ],
- );
+ input: { threadId: activeThreadId, requestId },
+ });
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ setThreadError(
+ activeThreadId,
+ error instanceof Error ? error.message : "Failed to dismiss the question.",
+ );
+ }
+ setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
+ return result;
+ };
+ const onDismissUserInputStable = useStableCallback(onDismissUserInput);
- const onChangeActivePendingUserInputCustomAnswer = useCallback(
- (questionId: string, value: string) => {
- if (!activePendingUserInput) {
- return;
+ const setActivePendingUserInputQuestionIndex = (nextQuestionIndex: number) => {
+ if (!activePendingUserInput) {
+ return;
+ }
+ setPendingUserInputQuestionIndexByRequestId((existing) => ({
+ ...existing,
+ [activePendingRequestKey]: nextQuestionIndex,
+ }));
+ };
+
+ const onSelectActivePendingUserInputOption = (questionId: string, optionValue: string) => {
+ if (!activePendingUserInput) {
+ return;
+ }
+ // The option replaces the custom answer. Anything typed there is the
+ // user's text, so it goes back to the thread draft instead of vanishing.
+ const displacedAnswer =
+ pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer;
+ const currentPrompt =
+ useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? "";
+ const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer);
+ if (nextPrompt !== currentPrompt) {
+ setComposerDraftPrompt(composerDraftTarget, nextPrompt);
+ if (activeThreadId) {
+ const store = useComposerDraftStore.getState();
+ const questionTarget = questionAttachmentDraftId(
+ environmentId,
+ activeThreadId,
+ activePendingUserInput.requestId,
+ questionId,
+ );
+ store.addThreadContexts(
+ composerDraftTarget,
+ store.getComposerDraft(questionTarget)?.threadContexts ?? [],
+ { appendReference: false },
+ );
}
- const question = activePendingUserInput.questions.find((entry) => entry.id === questionId);
- if (!question || question.allowCustomAnswer === false) {
- return;
+ }
+ setPendingUserInputAnswersByRequestId((existing) => {
+ const question =
+ (activePendingProgress?.activeQuestion?.id === questionId
+ ? activePendingProgress.activeQuestion
+ : undefined) ?? activePendingUserInput.questions.find((entry) => entry.id === questionId);
+ if (!question) {
+ return existing;
}
- promptRef.current = value;
- setPendingUserInputAnswersByRequestId((existing) => ({
+
+ return {
...existing,
[activePendingRequestKey]: {
...existing[activePendingRequestKey],
- [questionId]: setPendingUserInputCustomAnswer(
- existing[activePendingRequestKey]?.[questionId],
- value,
+ [questionId]: togglePendingUserInputOptionSelection(
question,
+ existing[activePendingRequestKey]?.[questionId],
+ optionValue,
),
},
- }));
- // The editor owns this selection. Resetting it before the answer renders
- // can re-enter Lexical with offsets from the previous skill token.
- },
+ };
+ });
+ promptRef.current = "";
+ composerRef.current?.resetCursorState({ cursor: 0 });
+ };
+ const onSelectActivePendingUserInputOptionStable = useStableCallback(
+ onSelectActivePendingUserInputOption,
+ );
+
+ const onChangeActivePendingUserInputCustomAnswer = useMemo(
+ () =>
+ makePendingCustomAnswerSetter({
+ activePendingUserInput,
+ activePendingRequestKey,
+ promptRef,
+ setPendingUserInputAnswersByRequestId,
+ }),
[activePendingUserInput, activePendingRequestKey],
);
- const onAdvanceActivePendingUserInput = useCallback(() => {
+ const onAdvanceActivePendingUserInput = () => {
if (
!activePendingUserInput ||
activePendingUserInput.responseCapability === "not_resumable" ||
@@ -10524,21 +10276,18 @@ export default function ChatView(props: ChatViewProps) {
return;
}
setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1);
- }, [
- activePendingProgress,
- activePendingResolvedAnswers,
- activePendingUserInput,
- activePendingIsResponding,
- onRespondToUserInput,
- setActivePendingUserInputQuestionIndex,
- ]);
+ };
+ const onAdvanceActivePendingUserInputStable = useStableCallback(onAdvanceActivePendingUserInput);
- const onPreviousActivePendingUserInputQuestion = useCallback(() => {
+ const onPreviousActivePendingUserInputQuestion = () => {
if (!activePendingProgress) {
return;
}
setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0));
- }, [activePendingProgress, setActivePendingUserInputQuestionIndex]);
+ };
+ const onPreviousActivePendingUserInputQuestionStable = useStableCallback(
+ onPreviousActivePendingUserInputQuestion,
+ );
async function onSubmitPlanFollowUp({
text,
@@ -10688,7 +10437,7 @@ export default function ChatView(props: ChatViewProps) {
return false;
}
- const onImplementPlanInNewThread = useCallback(async () => {
+ const onImplementPlanInNewThread = async () => {
if (
!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
!activeThread ||
@@ -10826,25 +10575,8 @@ export default function ChatView(props: ChatViewProps) {
}
}
finish();
- }, [
- activeProject,
- activeProposedPlan,
- activeThreadBranch,
- activeThread,
- beginLocalDispatch,
- activeEnvironmentUnavailable,
- createThread,
- deleteThread,
- isConnecting,
- isSendBusy,
- isServerThread,
- navigate,
- resetLocalDispatch,
- defaultRuntimeMode,
- startThreadTurn,
- environmentId,
- composerRef,
- ]);
+ };
+ const onImplementPlanInNewThreadStable = useStableCallback(onImplementPlanInNewThread);
const newTaskGraph = useNewTaskGraph(environmentId);
// On a new chat the graph is drafted in the client; its thread and the
@@ -10852,7 +10584,7 @@ export default function ChatView(props: ChatViewProps) {
const [draftTaskGraphOpen, setDraftTaskGraphOpen] = useState(false);
// The composer's model when the draft opened: shown as the graph default and used on Run.
const [draftTaskGraphModel, setDraftTaskGraphModel] = useState(null);
- const onNewTaskGraph = useCallback(async () => {
+ const onNewTaskGraph = async () => {
if (!activeThread) return;
if (!isServerThread) {
setDraftTaskGraphModel(composerRef.current?.getSendContext()?.selectedModelSelection ?? null);
@@ -10867,213 +10599,177 @@ export default function ChatView(props: ChatViewProps) {
description: error,
});
}
- }, [activeThread, composerRef, isServerThread, newTaskGraph]);
- const onRunDraftTaskGraph = useCallback(
- async (nodes: ReadonlyArray): Promise => {
- const modelSelection = draftTaskGraphModel;
- if (!activeThread || !activeProject || !modelSelection) {
- return "Pick a project and model before running the graph.";
- }
- const nextThreadId = newThreadId();
- const createResult = await createThread({
- environmentId,
- input: {
- threadId: nextThreadId,
- projectId: activeProject.id,
- title: "New task graph",
- modelSelection,
- runtimeMode,
- interactionMode: "default",
- branch: activeThreadBranch,
- worktreePath: activeThread.worktreePath,
- createdAt: new Date().toISOString(),
- },
- });
- if (createResult._tag === "Failure") {
- const failure = squashAtomCommandFailure(createResult);
- return failure instanceof Error ? failure.message : "Could not create the thread.";
- }
- const created = await newTaskGraph.createAndRun(nextThreadId, nodes);
- if (created.outcome === "failed") {
- await deleteThread({ environmentId, input: { threadId: nextThreadId } });
- return created.error;
- }
- // An interrupted request may have started the graph: keep its thread and open it
- // rather than risk running the same tasks twice from the draft.
- if (created.outcome === "uncertain") {
- toastManager.add({
- type: "warning",
- title: "The task graph request was interrupted",
- description: "Check the new chat to see whether the graph started.",
- });
- }
- // The graph is running now, so its thread stays even if the shell is slow to arrive;
- // waiting only keeps the route from opening before the thread is known.
- await waitForServerThreadShell(scopeThreadRef(environmentId, nextThreadId));
- setDraftTaskGraphOpen(false);
- await navigate({
- to: "/$environmentId/$threadId",
- params: { environmentId, threadId: nextThreadId },
- });
- return null;
- },
- [
- activeProject,
- activeThread,
- activeThreadBranch,
- createThread,
- deleteThread,
- draftTaskGraphModel,
+ };
+ const onRunDraftTaskGraph = async (
+ nodes: ReadonlyArray,
+ ): Promise => {
+ const modelSelection = draftTaskGraphModel;
+ if (!activeThread || !activeProject || !modelSelection) {
+ return "Pick a project and model before running the graph.";
+ }
+ const nextThreadId = newThreadId();
+ const createResult = await createThread({
environmentId,
- navigate,
- newTaskGraph,
- runtimeMode,
- ],
- );
-
- const getModelDisabledReason = useCallback(
- (instanceId: ProviderInstanceId, model: string): string | null => {
- if (!activeThread) {
- return null;
- }
- const reason = getStartedThreadModelChangeBlockReason({
- providers: providerStatuses,
- hasStartedSession: activeRuntime !== null,
- supportsProviderSwitchingViaHandoff,
- currentModelSelection: activeThread.modelSelection,
- currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null,
- nextModelSelection: { instanceId, model },
+ input: {
+ threadId: nextThreadId,
+ projectId: activeProject.id,
+ title: "New task graph",
+ modelSelection,
+ runtimeMode,
+ interactionMode: "default",
+ branch: activeThreadBranch,
+ worktreePath: activeThread.worktreePath,
+ createdAt: new Date().toISOString(),
+ },
+ });
+ if (createResult._tag === "Failure") {
+ const failure = squashAtomCommandFailure(createResult);
+ return failure instanceof Error ? failure.message : "Could not create the thread.";
+ }
+ const created = await newTaskGraph.createAndRun(nextThreadId, nodes);
+ if (created.outcome === "failed") {
+ await deleteThread({ environmentId, input: { threadId: nextThreadId } });
+ return created.error;
+ }
+ // An interrupted request may have started the graph: keep its thread and open it
+ // rather than risk running the same tasks twice from the draft.
+ if (created.outcome === "uncertain") {
+ toastManager.add({
+ type: "warning",
+ title: "The task graph request was interrupted",
+ description: "Check the new chat to see whether the graph started.",
});
- return reason ? `${reason.description} Start a new thread to use this model.` : null;
- },
+ }
+ // The graph is running now, so its thread stays even if the shell is slow to arrive;
+ // waiting only keeps the route from opening before the thread is known.
+ await waitForServerThreadShell(scopeThreadRef(environmentId, nextThreadId));
+ setDraftTaskGraphOpen(false);
+ await navigate({
+ to: "/$environmentId/$threadId",
+ params: { environmentId, threadId: nextThreadId },
+ });
+ return null;
+ };
+
+ const getModelDisabledReason = useMemo(
+ () =>
+ makeModelDisabledReason(
+ activeThread
+ ? {
+ providers: providerStatuses,
+ hasStartedSession: activeRuntime !== null,
+ supportsProviderSwitchingViaHandoff,
+ currentModelSelection: activeThread.modelSelection,
+ currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null,
+ }
+ : null,
+ ),
[activeRuntime, activeThread, providerStatuses, supportsProviderSwitchingViaHandoff],
);
- const onProviderModelSelect = useCallback(
- (instanceId: ProviderInstanceId, model: string, options?: { focusComposer?: boolean }) => {
- if (!activeThread) return;
- // Look up the configured instance so model normalization and custom
- // model lookup stay scoped to that exact instance. Unknown instance ids
- // are rejected by returning early; the server remains authoritative too.
- const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId);
- const resolvedDriverKind = entry?.driver ?? null;
- if (
- !supportsProviderSwitchingViaHandoff &&
- lockedProvider !== null &&
- resolvedDriverKind !== null &&
- resolvedDriverKind !== lockedProvider
- ) {
- if (options?.focusComposer !== false) scheduleComposerFocus();
- return;
- }
+ const onProviderModelSelect = (
+ instanceId: ProviderInstanceId,
+ model: string,
+ options?: { focusComposer?: boolean },
+ ) => {
+ if (!activeThread) return;
+ // Look up the configured instance so model normalization and custom
+ // model lookup stay scoped to that exact instance. Unknown instance ids
+ // are rejected by returning early; the server remains authoritative too.
+ const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId);
+ const resolvedDriverKind = entry?.driver ?? null;
+ if (
+ !supportsProviderSwitchingViaHandoff &&
+ lockedProvider !== null &&
+ resolvedDriverKind !== null &&
+ resolvedDriverKind !== lockedProvider
+ ) {
+ if (options?.focusComposer !== false) scheduleComposerFocus();
+ return;
+ }
+ if (
+ !supportsProviderSwitchingViaHandoff &&
+ lockedProvider !== null &&
+ activeRuntime?.providerInstanceId
+ ) {
+ const currentEntry = providerStatuses.find(
+ (snapshot) => snapshot.instanceId === activeRuntime.providerInstanceId,
+ );
if (
- !supportsProviderSwitchingViaHandoff &&
- lockedProvider !== null &&
- activeRuntime?.providerInstanceId
+ currentEntry?.continuation?.groupKey &&
+ entry?.continuation?.groupKey &&
+ currentEntry.continuation.groupKey !== entry.continuation.groupKey
) {
- const currentEntry = providerStatuses.find(
- (snapshot) => snapshot.instanceId === activeRuntime.providerInstanceId,
- );
- if (
- currentEntry?.continuation?.groupKey &&
- entry?.continuation?.groupKey &&
- currentEntry.continuation.groupKey !== entry.continuation.groupKey
- ) {
- if (options?.focusComposer !== false) scheduleComposerFocus();
- return;
- }
- }
- const resolvedModel = resolveAppModelSelectionForInstance(
- instanceId,
- settings,
- providerStatuses,
- model,
- );
- if (!resolvedModel) {
- if (options?.focusComposer !== false) scheduleComposerFocus();
- return;
- }
- // Restore this model's own remembered options; without any, start it
- // from its default rather than carrying the previous model's over.
- const rememberedOptions =
- useComposerDraftStore.getState().stickyOptionsByModelByProvider[instanceId]?.[
- resolvedModel
- ];
- const nextModelSelection: ModelSelection =
- rememberedOptions !== undefined && rememberedOptions.length > 0
- ? { instanceId, model: resolvedModel, options: [...rememberedOptions] }
- : { instanceId, model: resolvedModel };
- const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({
- providers: providerStatuses,
- hasStartedSession: activeRuntime !== null,
- supportsProviderSwitchingViaHandoff,
- currentModelSelection: activeThread.modelSelection,
- currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null,
- nextModelSelection,
- });
- if (modelChangeBlockReason) {
- toastManager.add({
- type: "warning",
- title: modelChangeBlockReason.title,
- description: modelChangeBlockReason.description,
- });
if (options?.focusComposer !== false) scheduleComposerFocus();
return;
}
- setComposerDraftModelSelection(
- scopeThreadRef(activeThread.environmentId, activeThread.id),
- nextModelSelection,
- // A complete snapshot: an absent options field means "start from the
- // model default", not "keep the previous model's options".
- { explicit: true, replaceOptions: true },
- );
- setStickyComposerModelSelection(nextModelSelection);
+ }
+ const resolvedModel = resolveAppModelSelectionForInstance(
+ instanceId,
+ settings,
+ providerStatuses,
+ model,
+ );
+ if (!resolvedModel) {
if (options?.focusComposer !== false) scheduleComposerFocus();
- },
- [
- activeThread,
- activeRuntime,
- lockedProvider,
+ return;
+ }
+ // Restore this model's own remembered options; without any, start it
+ // from its default rather than carrying the previous model's over.
+ const rememberedOptions =
+ useComposerDraftStore.getState().stickyOptionsByModelByProvider[instanceId]?.[resolvedModel];
+ const nextModelSelection: ModelSelection =
+ rememberedOptions !== undefined && rememberedOptions.length > 0
+ ? { instanceId, model: resolvedModel, options: [...rememberedOptions] }
+ : { instanceId, model: resolvedModel };
+ const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({
+ providers: providerStatuses,
+ hasStartedSession: activeRuntime !== null,
supportsProviderSwitchingViaHandoff,
- scheduleComposerFocus,
- setComposerDraftModelSelection,
- setStickyComposerModelSelection,
- providerStatuses,
- settings,
- ],
- );
- const onEnvModeChange = useCallback(
- (mode: DraftThreadEnvMode) => {
- if (multipleModelSelections !== null) return;
- if (canOverrideServerThreadEnvMode) {
- setPendingServerThreadEnvMode(mode);
- scheduleComposerFocus();
- return;
- }
- if (isLocalDraftThread) {
- setDraftThreadContext(composerDraftTarget, {
- envMode: mode,
- startFromOrigin: resolveNewDraftStartFromOrigin({
- envMode: mode,
- newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin,
- }),
- ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}),
- });
- }
+ currentModelSelection: activeThread.modelSelection,
+ currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null,
+ nextModelSelection,
+ });
+ if (modelChangeBlockReason) {
+ toastManager.add({
+ type: "warning",
+ title: modelChangeBlockReason.title,
+ description: modelChangeBlockReason.description,
+ });
+ if (options?.focusComposer !== false) scheduleComposerFocus();
+ return;
+ }
+ setComposerDraftModelSelection(
+ scopeThreadRef(activeThread.environmentId, activeThread.id),
+ nextModelSelection,
+ // A complete snapshot: an absent options field means "start from the
+ // model default", not "keep the previous model's options".
+ { explicit: true, replaceOptions: true },
+ );
+ setStickyComposerModelSelection(nextModelSelection);
+ if (options?.focusComposer !== false) scheduleComposerFocus();
+ };
+ const onProviderModelSelectStable = useStableCallback(onProviderModelSelect);
+ const onEnvModeChange = (mode: DraftThreadEnvMode) => {
+ if (multipleModelSelections !== null) return;
+ if (canOverrideServerThreadEnvMode) {
+ setPendingServerThreadEnvMode(mode);
scheduleComposerFocus();
- },
- [
- canOverrideServerThreadEnvMode,
- composerDraftTarget,
- draftThread?.worktreePath,
- isLocalDraftThread,
- multipleModelSelections,
- activeProjectSettings.settings.newWorktreesStartFromOrigin,
- setPendingServerThreadEnvMode,
- scheduleComposerFocus,
- setDraftThreadContext,
- ],
- );
+ return;
+ }
+ if (isLocalDraftThread) {
+ setDraftThreadContext(composerDraftTarget, {
+ envMode: mode,
+ startFromOrigin: resolveNewDraftStartFromOrigin({
+ envMode: mode,
+ newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin,
+ }),
+ ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}),
+ });
+ }
+ scheduleComposerFocus();
+ };
+ const onEnvModeChangeStable = useStableCallback(onEnvModeChange);
// "Work locally" on the setup card: cancel the bootstrap and remember the
// draft. The cancelled dispatch deletes the half-made thread and puts the
@@ -11081,7 +10777,7 @@ export default function ChatView(props: ChatViewProps) {
// local mode and resends. The draft is a server thread for the whole
// setup (the bootstrap created it), so this keys off the route, not
// `isLocalDraftThread`.
- const onWorktreeSetupWorkLocally = useCallback(() => {
+ const onWorktreeSetupWorkLocally = () => {
if (!worktreeSetup || worktreeSetup.phase !== "running" || !draftId) {
return;
}
@@ -11094,19 +10790,20 @@ export default function ChatView(props: ChatViewProps) {
if (result._tag !== "Success" || !result.value.cancelled) return;
setWorkLocallyResendDraftId(draftId);
})();
- }, [cancelWorktreeSetup, draftId, setupTarget.environmentId, worktreeSetup]);
+ };
+ const onWorktreeSetupWorkLocallyStable = useStableCallback(onWorktreeSetupWorkLocally);
const onSendRef = useRef(onSend);
onSendRef.current = onSend;
const onResumeRef = useRef(onResume);
onResumeRef.current = onResume;
const onCompactContextRef = useRef(onCompactContext);
onCompactContextRef.current = onCompactContext;
- const onComposerSend = useCallback(
- (...args: Parameters) => onSendRef.current(...args),
- [],
- );
- const onComposerResume = useCallback(() => onResumeRef.current(), []);
- const onComposerCompactContext = useCallback(() => onCompactContextRef.current(), []);
+ const onComposerSend = (...args: Parameters) => onSendRef.current(...args);
+ const onComposerSendStable = useStableCallback(onComposerSend);
+ const onComposerResume = () => onResumeRef.current();
+ const onComposerResumeStable = useStableCallback(onComposerResume);
+ const onComposerCompactContext = () => onCompactContextRef.current();
+ const onComposerCompactContextStable = useStableCallback(onComposerCompactContext);
const queuedRunsControl = useMemo(
() =>
isServerThread && activeThread ? (
@@ -11124,15 +10821,15 @@ export default function ChatView(props: ChatViewProps) {
threadId={activeThread.id}
optimisticMessages={optimisticUserMessages}
editingRunId={editingQueuedRun?.runId ?? null}
- onEditQueuedRun={beginEditingQueuedRun}
- onCancelEdit={cancelEditingQueuedRun}
+ onEditQueuedRun={beginEditingQueuedRunStable}
+ onCancelEdit={cancelEditingQueuedRunStable}
/>
>
) : null,
[
activeThread,
- beginEditingQueuedRun,
- cancelEditingQueuedRun,
+ beginEditingQueuedRunStable,
+ cancelEditingQueuedRunStable,
editingQueuedRun?.runId,
isServerThread,
keybindings,
@@ -11197,35 +10894,35 @@ export default function ChatView(props: ChatViewProps) {
}
};
- const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => {
+ const onExpandTimelineImage = (preview: ExpandedImagePreview) => {
setExpandedImage(preview);
- }, []);
- const onOpenTurnDiff = useCallback(
- (runId: RunId, filePath?: string) => {
- if (!isServerThread || !activeThreadRef) return;
- const diffOpen =
- selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) ===
- "diff";
- explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef;
- useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath);
- useRightPanelStore.getState().open(activeThreadRef, "diff");
- onDiffPanelOpen?.();
- },
- [activeThreadRef, isServerThread, onDiffPanelOpen],
- );
- const onTimelineRollbackCheckpoint = useCallback(
- (input: { readonly checkpointId: string; readonly scopeId: string }) => {
- if (!paintOnlyDisplayedTimeline) void onRollbackCheckpoint(input);
- },
- [onRollbackCheckpoint, paintOnlyDisplayedTimeline],
- );
+ };
+ const onExpandTimelineImageStable = useStableCallback(onExpandTimelineImage);
+ const onOpenTurnDiff = (runId: RunId, filePath?: string) => {
+ if (!isServerThread || !activeThreadRef) return;
+ const diffOpen =
+ selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) === "diff";
+ explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef;
+ useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath);
+ useRightPanelStore.getState().open(activeThreadRef, "diff");
+ onDiffPanelOpen?.();
+ };
+ const onOpenTurnDiffStable = useStableCallback(onOpenTurnDiff);
+ const onTimelineRollbackCheckpoint = (input: {
+ readonly checkpointId: string;
+ readonly scopeId: string;
+ }) => {
+ if (!paintOnlyDisplayedTimeline) void onRollbackCheckpoint(input);
+ };
+ const onTimelineRollbackCheckpointStable = useStableCallback(onTimelineRollbackCheckpoint);
// The revert handler is read from a ref at call-time so the callback
// reference is fully stable and never busts TimelineRowCtx identity.
- const onRevertToTurnCountRef = useRef(onRevertToTurnCount);
- onRevertToTurnCountRef.current = onRevertToTurnCount;
- const onRevertTimelineTurn = useCallback((targetTurnCount: number, messageId: MessageId) => {
+ const onRevertToTurnCountRef = useRef(onRevertToTurnCountStable);
+ onRevertToTurnCountRef.current = onRevertToTurnCountStable;
+ const onRevertTimelineTurn = (targetTurnCount: number, messageId: MessageId) => {
void onRevertToTurnCountRef.current(targetTurnCount, messageId);
- }, []);
+ };
+ const onRevertTimelineTurnStable = useStableCallback(onRevertTimelineTurn);
const pendingSidebarFileDrops = useSidebarPendingFileDropStore((state) => state.pending);
const consumePendingFileDrop = useSidebarPendingFileDropStore(
@@ -11287,12 +10984,12 @@ export default function ChatView(props: ChatViewProps) {
launchContext={activeTerminalLaunchContext ?? null}
focusRequestId={terminalFocusRequestId}
keybindings={keybindings}
- onAddTerminalContext={addTerminalContextToDraft}
- onSplitTerminal={splitPanelTerminal}
- onSplitTerminalVertical={splitPanelTerminalVertical}
- onNewTerminal={addTerminalSurface}
- onActiveTerminalChange={activatePanelTerminal}
- onCloseTerminal={closePanelTerminal}
+ onAddTerminalContext={addTerminalContextToDraftStable}
+ onSplitTerminal={splitPanelTerminalStable}
+ onSplitTerminalVertical={splitPanelTerminalVerticalStable}
+ onNewTerminal={addTerminalSurfaceStable}
+ onActiveTerminalChange={activatePanelTerminalStable}
+ onCloseTerminal={closePanelTerminalStable}
splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined}
newShortcutLabel={newTerminalShortcutLabel ?? undefined}
@@ -11321,7 +11018,7 @@ export default function ChatView(props: ChatViewProps) {
// reader's feet. A link the agent wrote can open any other one here, and that one has to be
// checkable out like it is anywhere else.
1
- ? addPullRequestsSurface
+ ? addPullRequestsSurfaceStable
: undefined
}
/>
@@ -11412,8 +11109,8 @@ export default function ChatView(props: ChatViewProps) {
? renderedRightPanelSurface.revealRequestId
: 0
}
- onOpenFile={openFileSurface}
- onPendingChange={handleFilePendingChange}
+ onOpenFile={openFileSurfaceStable}
+ onPendingChange={handleFilePendingChangeStable}
selectedFilePending={
renderedRightPanelSurface.kind === "file" &&
pendingFileSurfaceIds.has(renderedRightPanelSurface.id)
@@ -11449,10 +11146,10 @@ export default function ChatView(props: ChatViewProps) {
!envLocked &&
canAutoBalanceEnvironments &&
loadBalancingSettings.loadBalancingEnabled
- ? onAutoEnvironment
+ ? onAutoEnvironmentStable
: undefined,
- onEnvironmentChange,
- onEnvModeChange,
+ onEnvironmentChange: onEnvironmentChangeStable,
+ onEnvModeChange: onEnvModeChangeStable,
envMode,
...(canOverrideServerThreadEnvMode
? {
@@ -11463,14 +11160,14 @@ export default function ChatView(props: ChatViewProps) {
startFromOrigin,
onStartFromOriginChange,
...(canCheckoutPullRequestIntoThread
- ? { onCheckoutPullRequestRequest: openPullRequestDialog }
+ ? { onCheckoutPullRequestRequest: openPullRequestDialogStable }
: {}),
- onComposerFocusRequest: scheduleComposerFocus,
- ...(isServerThread && isGitRepo ? { onOpenChanges: openChangesFromThreadPanel } : {}),
- onRunProjectScript: runProjectScript,
- onAddProjectScript: saveProjectScript,
- onUpdateProjectScript: updateProjectScript,
- onDeleteProjectScript: deleteProjectScript,
+ onComposerFocusRequest: scheduleComposerFocusStable,
+ ...(isServerThread && isGitRepo ? { onOpenChanges: openChangesFromThreadPanelStable } : {}),
+ onRunProjectScript: runProjectScriptStable,
+ onAddProjectScript: saveProjectScriptStable,
+ onUpdateProjectScript: updateProjectScriptStable,
+ onDeleteProjectScript: deleteProjectScriptStable,
};
const panelToggleControlProps = {
terminalAvailable: activeProject !== null,
@@ -11483,9 +11180,9 @@ export default function ChatView(props: ChatViewProps) {
rightPanelAvailable: activeProject !== null,
rightPanelOpen,
rightPanelShortcutLabel: shortcutLabelForCommand(keybindings, "rightPanel.toggle"),
- onToggleTerminal: toggleTerminalVisibility,
- onToggleThreadPanel: toggleThreadPanel,
- onToggleRightPanel: toggleRightPanel,
+ onToggleTerminal: toggleTerminalVisibilityStable,
+ onToggleThreadPanel: toggleThreadPanelStable,
+ onToggleRightPanel: toggleRightPanelStable,
} satisfies PanelLayoutControlsProps;
const panelToggleControls = (
) : null}
@@ -11615,11 +11312,11 @@ export default function ChatView(props: ChatViewProps) {
activeThreadTitle={activeThread.title}
activeProject={activeProject ?? null}
parentThreadLink={parentThreadLink}
- onOpenThread={onOpenRelatedThread}
+ onOpenThread={onOpenRelatedThreadStable}
rightPanelOpen={inlineRightPanelOwnsTitleBar}
- onNewThreadInProject={handleNewThreadInActiveProject}
+ onNewThreadInProject={handleNewThreadInActiveProjectStable}
{...(activeDraftLogicalProjectKey
- ? { onOpenProjectSettings: handleOpenDraftProjectSettings }
+ ? { onOpenProjectSettings: handleOpenDraftProjectSettingsStable }
: {})}
/>
@@ -11646,7 +11343,7 @@ export default function ChatView(props: ChatViewProps) {
onDragLeave={workspaceFileDropHandlers.onDragLeave}
onDrop={workspaceFileDropHandlers.onDrop}
>
-
+
{isWorkspaceFileDragActive ? (
setDismissedProviderStatusBannerKey(providerStatusBannerKey)}
- onOpenProviderSetup={openProviderSetup}
+ onOpenProviderSetup={openProviderSetupStable}
/>
{} : onForkFromRun}
- onRollbackCheckpoint={onTimelineRollbackCheckpoint}
+ onForkFromRun={paintOnlyDisplayedTimeline ? async () => {} : onForkFromRunStable}
+ onRollbackCheckpoint={onTimelineRollbackCheckpointStable}
supportsConversationRollback={
!paintOnlyDisplayedTimeline && supportsConversationRollback
}
onRevertToTurnCount={
- paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurn
+ paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurnStable
}
{...(!paintOnlyDisplayedTimeline
- ? { onUseArtifactTemplate: useArtifactTemplate, onSendAppMessage: sendAppMessage }
+ ? {
+ onUseArtifactTemplate: useArtifactTemplateStable,
+ onSendAppMessage: sendAppMessageStable,
+ }
: {})}
isRevertingCheckpoint={isRevertingCheckpoint}
- onImageExpand={onExpandTimelineImage}
- onFileOpen={paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachment}
+ onImageExpand={onExpandTimelineImageStable}
+ onFileOpen={
+ paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachmentStable
+ }
onFileDownload={
- paintOnlyDisplayedTimeline ? noopHeldAttachment : downloadFileAttachment
+ paintOnlyDisplayedTimeline ? noopHeldAttachment : downloadFileAttachmentStable
}
markdownCwd={
paintOnlyDisplayedTimeline
@@ -11785,14 +11494,14 @@ export default function ChatView(props: ChatViewProps) {
}
skills={timelineSkills}
anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId}
- onAnchorReady={onTimelineAnchorReady}
- onAnchorSizeChanged={onTimelineAnchorSizeChanged}
+ onAnchorReady={onTimelineAnchorReadyStable}
+ onAnchorSizeChanged={onTimelineAnchorSizeChangedStable}
contentInsetEndAdjustment={composerTimelineInset}
liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled}
- onIsAtEndChange={onIsAtEndChange}
+ onIsAtEndChange={onIsAtEndChangeStable}
onContentOverflowChange={setTimelineOverflows}
- onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd}
- onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
+ onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEndStable}
+ onManualNavigation={cancelTimelineLiveFollowForUserNavigationStable}
cancelPositionRestoreRef={cancelPositionRestoreRef}
hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
topFadeEnabled={!hasTimelineTopBanner}
@@ -11908,7 +11617,7 @@ export default function ChatView(props: ChatViewProps) {
serverConfig?.environment.capabilities.requiredWorktreeBootstrap ===
true
}
- onMultipleModelSelectionsChange={setMultipleModelSelections}
+ onMultipleModelSelectionsChange={setMultipleModelSelectionsStable}
composerRef={composerRef}
composerDraftTarget={composerDraftTarget}
environmentId={environmentId}
@@ -11971,14 +11680,14 @@ export default function ChatView(props: ChatViewProps) {
onDismissPromptSuggestion={dismissPromptSuggestion}
resumeCompactionTokens={resumeCompactionTokens}
keepFullHistory={keepFullHistory}
- onToggleKeepFullHistory={toggleKeepFullHistory}
+ onToggleKeepFullHistory={toggleKeepFullHistoryStable}
// With attachments or contexts aboard the pick just inserts the
// text, so it sends as a prompt like the typed path would.
onUsageLimitsCommand={
usageLimitsOffered &&
usageLimitsKey !== null &&
!composerHasNonPromptContent
- ? openUsageLimits
+ ? openUsageLimitsStable
: undefined
}
environmentUnavailable={activeEnvironmentUnavailableState}
@@ -12030,48 +11739,50 @@ export default function ChatView(props: ChatViewProps) {
(isGitRepo || showComposerEnvironmentIndicator)
}
onRestingControlsVisibilityChange={setRestingComposerControlsVisible}
- getTimelineScrollableNode={getTimelineScrollableNode}
- isTimelineAtLogicalEnd={isTimelineAtLogicalEnd}
+ getTimelineScrollableNode={getTimelineScrollableNodeStable}
+ isTimelineAtLogicalEnd={isTimelineAtLogicalEndStable}
timelineOverflows={timelineOverflows}
- onComposerOverlayHeightChange={publishComposerOverlayHeight}
- onRestingChange={onComposerRestingChange}
+ onComposerOverlayHeightChange={publishComposerOverlayHeightStable}
+ onRestingChange={onComposerRestingChangeStable}
promptRef={promptRef}
composerImagesRef={composerImagesRef}
composerFilesRef={composerFilesRef}
composerTerminalContextsRef={composerTerminalContextsRef}
- onPageScrollKeyDown={onComposerPageScrollKeyDown}
- onPageScrollKeyUp={onComposerPageScrollKeyUp}
- onPageScrollRelease={onComposerPageScrollRelease}
- onCompactContext={onComposerCompactContext}
- onSend={onComposerSend}
- onResume={onComposerResume}
- onInterrupt={onInterrupt}
- onImplementPlanInNewThread={onImplementPlanInNewThread}
- onRespondToApproval={onRespondToApproval}
+ onPageScrollKeyDown={onComposerPageScrollKeyDownStable}
+ onPageScrollKeyUp={onComposerPageScrollKeyUpStable}
+ onPageScrollRelease={onComposerPageScrollReleaseStable}
+ onCompactContext={onComposerCompactContextStable}
+ onSend={onComposerSendStable}
+ onResume={onComposerResumeStable}
+ onInterrupt={onInterruptStable}
+ onImplementPlanInNewThread={onImplementPlanInNewThreadStable}
+ onRespondToApproval={onRespondToApprovalStable}
onSelectActivePendingUserInputOption={
- onSelectActivePendingUserInputOption
+ onSelectActivePendingUserInputOptionStable
}
- onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
- onDismissActivePendingUserInput={onDismissUserInput}
+ onAdvanceActivePendingUserInput={
+ onAdvanceActivePendingUserInputStable
+ }
+ onDismissActivePendingUserInput={onDismissUserInputStable}
onPreviousActivePendingUserInputQuestion={
- onPreviousActivePendingUserInputQuestion
+ onPreviousActivePendingUserInputQuestionStable
}
onChangeActivePendingUserInputCustomAnswer={
onChangeActivePendingUserInputCustomAnswer
}
- onProviderModelSelect={onProviderModelSelect}
- onOpenProviderSetup={openProviderSetup}
+ onProviderModelSelect={onProviderModelSelectStable}
+ onOpenProviderSetup={openProviderSetupStable}
getModelDisabledReason={getModelDisabledReason}
- toggleInteractionMode={toggleInteractionMode}
- handleRuntimeModeChange={handleRuntimeModeChange}
- handleInteractionModeChange={handleInteractionModeChange}
- focusComposer={focusComposer}
- scheduleComposerFocus={scheduleComposerFocus}
+ toggleInteractionMode={toggleInteractionModeStable}
+ handleRuntimeModeChange={handleRuntimeModeChangeStable}
+ handleInteractionModeChange={handleInteractionModeChangeStable}
+ focusComposer={focusComposerStable}
+ scheduleComposerFocus={scheduleComposerFocusStable}
setThreadError={setThreadError}
- onExpandImage={onExpandTimelineImage}
- onFileOpen={openFileAttachment}
+ onExpandImage={onExpandTimelineImageStable}
+ onFileOpen={openFileAttachmentStable}
editingQueuedAttachments={composerEditingQueuedAttachments}
- onRemoveEditingQueuedAttachment={removeEditingQueuedAttachment}
+ onRemoveEditingQueuedAttachment={removeEditingQueuedAttachmentStable}
/>
)}
@@ -12107,7 +11818,7 @@ export default function ChatView(props: ChatViewProps) {
threadId={activeThread.id}
showGitControls={isGitRepo}
{...(routeKind === "draft" && draftId ? { draftId } : {})}
- onEnvModeChange={onEnvModeChange}
+ onEnvModeChange={onEnvModeChangeStable}
startFromOrigin={startFromOrigin}
onStartFromOriginChange={onStartFromOriginChange}
envMode={envMode}
@@ -12119,18 +11830,20 @@ export default function ChatView(props: ChatViewProps) {
}
: {})}
envLocked={envLocked}
- onComposerFocusRequest={scheduleComposerFocus}
+ onComposerFocusRequest={scheduleComposerFocusStable}
{...(canCheckoutPullRequestIntoThread
- ? { onCheckoutPullRequestRequest: openPullRequestDialog }
+ ? { onCheckoutPullRequestRequest: openPullRequestDialogStable }
+ : {})}
+ {...(hasMultipleEnvironments
+ ? { onEnvironmentChange: onEnvironmentChangeStable }
: {})}
- {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})}
autoEnvironmentLabel={autoEnvironmentLabel}
onAutoEnvironment={
draftId &&
!envLocked &&
canAutoBalanceEnvironments &&
loadBalancingSettings.loadBalancingEnabled
- ? onAutoEnvironment
+ ? onAutoEnvironmentStable
: undefined
}
availableEnvironments={logicalProjectEnvironments}
@@ -12211,7 +11924,7 @@ export default function ChatView(props: ChatViewProps) {
closePullRequestDialog();
}
}}
- onPrepared={handlePreparedPullRequestThread}
+ onPrepared={handlePreparedPullRequestThreadStable}
/>
) : null}
@@ -12244,7 +11957,7 @@ export default function ChatView(props: ChatViewProps) {
mode="inline"
open={rightPanelOpen}
keybindings={keybindings}
- getShortcutContext={getShortcutContext}
+ getShortcutContext={getShortcutContextStable}
maximized={rightPanelMaximized}
inlineSize={previewPanelInlineSize}
surfaces={renderedRightPanelSurfaces}
@@ -12255,25 +11968,25 @@ export default function ChatView(props: ChatViewProps) {
desktopByTabId={activePreviewState.desktopByTabId}
previewRuntimeTabId={resolvePreviewRuntimeTabId}
terminalLabelsById={activeTerminalLabelsById}
- onActivate={activateRightPanelSurface}
- onCloseSurface={closeRightPanelSurface}
+ onActivate={activateRightPanelSurfaceStable}
+ onCloseSurface={closeRightPanelSurfaceStable}
onRenameDevice={(surfaceId, title) => {
if (activeThreadRef)
useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title);
}}
- onCloseOtherSurfaces={closeOtherRightPanelSurfaces}
- onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
- onCloseAllSurfaces={closeAllRightPanelSurfaces}
- onMoveSurface={moveRightPanelSurface}
- onCopyFilePath={copyRightPanelFilePath}
+ onCloseOtherSurfaces={closeOtherRightPanelSurfacesStable}
+ onCloseSurfacesToRight={closeRightPanelSurfacesToRightStable}
+ onCloseAllSurfaces={closeAllRightPanelSurfacesStable}
+ onMoveSurface={moveRightPanelSurfaceStable}
+ onCopyFilePath={copyRightPanelFilePathStable}
onAddBrowser={() => createBrowserSurface()}
- onAddBrowserInProfile={createBrowserSurface}
- onAddTerminal={addTerminalSurface}
- onAddDiff={addDiffSurface}
- onAddFiles={addFilesSurface}
- onAddPullRequest={addPullRequestSurface}
- onAddPullRequests={addPullRequestsSurface}
- onAddDevice={addDeviceSurface}
+ onAddBrowserInProfile={createBrowserSurfaceStable}
+ onAddTerminal={addTerminalSurfaceStable}
+ onAddDiff={addDiffSurfaceStable}
+ onAddFiles={addFilesSurfaceStable}
+ onAddPullRequest={addPullRequestSurfaceStable}
+ onAddPullRequests={addPullRequestsSurfaceStable}
+ onAddDevice={addDeviceSurfaceStable}
browserAvailable={canOperatePreview && browserAvailable}
terminalAvailable={activeProject !== null && canOperateTerminal}
diffAvailable={isServerThread && isGitRepo}
@@ -12289,13 +12002,13 @@ export default function ChatView(props: ChatViewProps) {
{
if (activeThreadRef)
useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title);
}}
- onCloseOtherSurfaces={closeOtherRightPanelSurfaces}
- onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
- onCloseAllSurfaces={closeAllRightPanelSurfaces}
- onMoveSurface={moveRightPanelSurface}
- onCopyFilePath={copyRightPanelFilePath}
+ onCloseOtherSurfaces={closeOtherRightPanelSurfacesStable}
+ onCloseSurfacesToRight={closeRightPanelSurfacesToRightStable}
+ onCloseAllSurfaces={closeAllRightPanelSurfacesStable}
+ onMoveSurface={moveRightPanelSurfaceStable}
+ onCopyFilePath={copyRightPanelFilePathStable}
onAddBrowser={() => createBrowserSurface()}
- onAddBrowserInProfile={createBrowserSurface}
- onAddTerminal={addTerminalSurface}
- onAddDiff={addDiffSurface}
- onAddFiles={addFilesSurface}
- onAddPullRequest={addPullRequestSurface}
- onAddPullRequests={addPullRequestsSurface}
- onAddDevice={addDeviceSurface}
+ onAddBrowserInProfile={createBrowserSurfaceStable}
+ onAddTerminal={addTerminalSurfaceStable}
+ onAddDiff={addDiffSurfaceStable}
+ onAddFiles={addFilesSurfaceStable}
+ onAddPullRequest={addPullRequestSurfaceStable}
+ onAddPullRequests={addPullRequestsSurfaceStable}
+ onAddDevice={addDeviceSurfaceStable}
browserAvailable={canOperatePreview && browserAvailable}
terminalAvailable={activeProject !== null && canOperateTerminal}
diffAvailable={isServerThread && isGitRepo}
@@ -12401,7 +12114,7 @@ export default function ChatView(props: ChatViewProps) {
)}
diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.navigation.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.navigation.test.tsx
new file mode 100644
index 000000000000..53bc7104cbc3
--- /dev/null
+++ b/apps/web/src/components/ComposerPromptEditorTiptap.navigation.test.tsx
@@ -0,0 +1,126 @@
+// @vitest-environment jsdom
+
+import type { Editor } from "@tiptap/core";
+import { TextSelection } from "@tiptap/pm/state";
+import { act, createRef } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
+
+import { serializeEditorDoc } from "~/composer-rich-text-doc";
+import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
+
+const draft =
+ "> **Assistant and reasoning text still save intermediate full snapshots**, using the existing 50 ms batching. Compaction still preserves their `turn-item.updated` events. [Code](/Users/yashsingh/p/projects/t3code/apps/server/src/orchestration-v2/ProjectionMaintenance.ts:235)";
+
+let root: Root;
+let container: HTMLDivElement;
+
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+});
+
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+});
+
+async function mountEditor(value: string, richTextEnabled = true) {
+ await act(async () =>
+ root.render(
+
()}
+ value={value}
+ cursor={0}
+ richTextEnabled={richTextEnabled}
+ contextRecords={new Map()}
+ skills={[]}
+ disabled={false}
+ placeholder=""
+ onChange={() => {}}
+ onPaste={() => {}}
+ />,
+ ),
+ );
+ const element = container.querySelector(".tiptap");
+ if (!element) throw new Error("Composer did not mount");
+ return element.editor;
+}
+
+function press(editor: Editor, key: string, options: KeyboardEventInit = {}) {
+ const event = new KeyboardEvent("keydown", { key, cancelable: true, ...options });
+ const handled = editor.view.props.handleKeyDown?.(editor.view, event);
+ expect(event.defaultPrevented).toBe(Boolean(handled));
+ return handled;
+}
+
+it("keeps Up before the opening bold marker in a quote, including while typing", async () => {
+ const editor = await mountEditor(draft);
+ const position = editor.state.selection.from;
+ await act(async () => {
+ editor.view.focus();
+ expect(press(editor, "ArrowUp")).toBe(true);
+ expect(editor.state.selection.from).toBe(position);
+ editor.view.updateState(editor.state);
+ const marker = editor.view.dom.querySelector(".composer-rich-marker");
+ const caret = window.getSelection()?.getRangeAt(0);
+ if (!marker || !caret) throw new Error("Caret or bold marker is missing");
+ const markerRange = document.createRange();
+ markerRange.selectNode(marker);
+ expect(caret.compareBoundaryPoints(Range.START_TO_START, markerRange)).toBeLessThanOrEqual(0);
+ // A repeated Up stays at the outside stop, ready for native navigation.
+ expect(press(editor, "ArrowUp")).toBe(false);
+ editor.commands.insertContent("x");
+ });
+ expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> x**"));
+});
+
+it("keeps Down after a closing marker, including while typing", async () => {
+ const editor = await mountEditor("> **bold**");
+ await act(async () => {
+ editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc)));
+ expect(press(editor, "ArrowDown")).toBe(true);
+ expect(press(editor, "ArrowDown")).toBe(false);
+ editor.commands.insertContent("x");
+ });
+ expect(serializeEditorDoc(editor.state.doc).value).toBe("> **bold**x");
+});
+
+it("leaves vertical navigation between paragraphs to the browser", async () => {
+ const editor = await mountEditor("**first**\n**second**");
+ await act(async () => {
+ const first = editor.state.doc.child(0);
+ editor.commands.setTextSelection(first.nodeSize + 1);
+ expect(press(editor, "ArrowUp")).toBe(false);
+ editor.commands.setTextSelection(first.nodeSize - 1);
+ expect(press(editor, "ArrowDown")).toBe(false);
+ });
+});
+
+it.each([
+ { shiftKey: true },
+ { altKey: true },
+ { metaKey: true },
+ { ctrlKey: true },
+ { isComposing: true },
+])("preserves modified Up behavior for %j", async (options) => {
+ const editor = await mountEditor(draft);
+ await act(async () => {
+ expect(press(editor, "ArrowUp", options)).toBe(false);
+ editor.commands.insertContent("x");
+ });
+ expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> **x"));
+});
+
+it("leaves literal Markdown navigation to the browser in plain mode", async () => {
+ const editor = await mountEditor(draft, false);
+ await act(async () => {
+ editor.commands.setTextSelection(5);
+ expect(press(editor, "ArrowUp")).toBe(false);
+ editor.commands.insertContent("x");
+ });
+ expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> **x"));
+});
diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
index d7d267db2168..d8dd98e1b005 100644
--- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx
+++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
@@ -1274,7 +1274,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
}
}
if (
- (event.key === "ArrowLeft" || event.key === "ArrowRight") &&
+ ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key) &&
!event.shiftKey &&
!event.altKey &&
!event.metaKey &&
@@ -1283,10 +1283,22 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
view.state.selection.empty
) {
const { $from } = view.state.selection;
- const direction = event.key === "ArrowLeft" ? -1 : 1;
+ const direction = event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 1;
+ const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight";
+ // At the document's ends, native Up/Down can cross a marker without
+ // changing the document position. Record the stop so DOM selection
+ // synchronization does not put the caret back inside the styling.
+ const atDocumentEdge =
+ $from.pos ===
+ (direction === -1
+ ? TextSelection.atStart(view.state.doc).from
+ : TextSelection.atEnd(view.state.doc).to);
// Take the other stop of a styled edge before skipping a chip, so
// the plain stop between styled text and a chip stays reachable.
- const step = stepCaretAcrossStyledEdge(view.state, direction);
+ const step =
+ horizontal || atDocumentEdge
+ ? stepCaretAcrossStyledEdge(view.state, direction)
+ : null;
if (step) {
event.preventDefault();
event.stopPropagation();
@@ -1294,7 +1306,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
return true;
}
const adjacent = direction === -1 ? $from.nodeBefore : $from.nodeAfter;
- if (adjacent?.type.name.startsWith("composer-")) {
+ if (horizontal && adjacent?.type.name.startsWith("composer-")) {
event.preventDefault();
event.stopPropagation();
view.dispatch(
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index f3790132a23e..ca239833a5f6 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -665,6 +665,7 @@ type ThreadStatusInput = Pick<
| "hasActionableProposedPlan"
| "hasPendingApprovals"
| "hasPendingUserInput"
+ | "pendingSubagentRequest"
| "interactionMode"
| "latestRun"
| "runtime"
@@ -990,14 +991,14 @@ export function shouldRecedeSidebarThread(input: {
type SidebarThreadStatusInput = Pick<
SidebarThreadSummary,
- "hasPendingApprovals" | "hasPendingUserInput" | "runtime"
+ "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" | "runtime"
>;
export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus {
- if (thread.hasPendingApprovals) {
+ if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
return "approval";
}
- if (thread.hasPendingUserInput) {
+ if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
return "input";
}
if (
@@ -1204,7 +1205,7 @@ export function resolveThreadStatusPill(input: {
}): ThreadStatusPill | null {
const { thread } = input;
- if (thread.hasPendingApprovals) {
+ if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
return {
label: "Pending Approval",
colorClass: "text-amber-600 dark:text-amber-300/90",
@@ -1213,7 +1214,7 @@ export function resolveThreadStatusPill(input: {
};
}
- if (thread.hasPendingUserInput) {
+ if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
return {
label: "Awaiting Input",
colorClass: "text-indigo-600 dark:text-indigo-300/90",
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 35b4d8eab8ef..32a2c01c02b3 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -73,6 +73,7 @@ import {
CircleAlertIcon,
CircleCheckIcon,
CircleDashedIcon,
+ CircleDotDashedIcon,
ClockIcon,
EyeIcon,
FolderIcon,
@@ -131,21 +132,21 @@ import { isMacPlatform } from "~/lib/utils";
import { useOpenPrLink } from "../lib/openPullRequestLink";
import { readLocalApi } from "../localApi";
import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
-import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
+import { selectProjectGroupingSettings } from "../logicalProject";
import {
- buildSidebarProjectSnapshots,
projectGroupsSpanEnvironments,
resolveSidebarProjectScopeKey,
getSidebarProjectSettingsKey,
type SidebarProjectSnapshot,
} from "../sidebarProjectGrouping";
-import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
+import { useUiStateStore } from "../uiStateStore";
import {
getThreadKeysToDeselectAfterDelete,
useThreadSelectionStore,
} from "../threadSelectionStore";
import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
+import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups";
import { useTerminalFocus } from "../hooks/useTerminalFocus";
import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
import { registerSettleAndAdvance } from "../settleAndAdvance";
@@ -235,7 +236,6 @@ import {
sidebarThreadKeyAtY,
sortInboxThreadsByReturn,
sortPinnedThreadsForSidebar,
- sortSidebarV2ProjectGroups,
sortThreadsForSidebar,
sortWorkingThreadsBySend,
useThreadJumpHintVisibility,
@@ -279,6 +279,7 @@ import {
type ProviderInstanceEntry,
} from "../providerInstances";
import { useThreadRunningTerminalIds } from "../state/terminalSessions";
+import { useStableCallback } from "../hooks/useStableCallback";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { Button, InlineButton } from "./ui/button";
import {
@@ -1133,7 +1134,7 @@ const SIDEBAR_TOP_STATUS: Record<
working: { label: "Working", icon: "working", Icon: CircleDashedIcon, className: "text-info" },
// Waiting is calm background presence (post-settle background roster), not
// active progress, so the label keeps full strength.
- waiting: { label: "Waiting", icon: null, Icon: null, className: "text-muted-foreground" },
+ waiting: { label: "Waiting", icon: "waiting", Icon: CircleDotDashedIcon, className: "text-muted-foreground" },
approval: {
label: "Approval",
icon: "approval",
@@ -2119,6 +2120,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
>
{topStatus.icon === "working" ? (
+ ) : topStatus.icon === "waiting" ? (
+
) : topStatus.icon === "input" ? (
) : topStatus.icon === "approval" ? (
@@ -2136,6 +2139,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
+ ) : status === "waiting" ? (
+ // Waiting starts when the turn ends and background
+ // work keeps the thread held.
+
+
+
) : null}
)
@@ -2477,14 +2486,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
export default function Sidebar() {
const projects = useProjects();
- const projectOrder = useUiStateStore((store) => store.projectOrder);
const threads = useThreadShells();
const router = useRouter();
const { isMobile, setOpenMobile } = useSidebar();
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
- const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
const timestampFormat = useClientSettings((s) => s.timestampFormat);
const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled);
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
@@ -2619,41 +2626,7 @@ export default function Sidebar() {
[environments],
);
const environmentMachineById = useEnvironmentMachines();
- const orderedProjects = useMemo(
- () =>
- orderItemsByPreferredIds({
- items: projects,
- preferredIds: projectOrder,
- getId: getProjectOrderKey,
- getPreferenceIds: (project) => [
- getProjectOrderKey(project),
- legacyProjectCwdPreferenceKey(project.workspaceRoot),
- ],
- }),
- [projectOrder, projects],
- );
- const unsortedProjectGroups = useMemo(
- () =>
- buildSidebarProjectSnapshots({
- projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects,
- groupScratchProjects: true,
- settings: projectGroupingSettings,
- primaryEnvironmentId,
- resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
- }),
- [
- environmentLabelById,
- orderedProjects,
- primaryEnvironmentId,
- projectGroupingSettings,
- projects,
- sidebarProjectSortOrder,
- ],
- );
- const projectGroups = useMemo(
- () => sortSidebarV2ProjectGroups(unsortedProjectGroups, threads, sidebarProjectSortOrder),
- [sidebarProjectSortOrder, threads, unsortedProjectGroups],
- );
+ const projectGroups = useSidebarProjectGroups();
const projectGroupsRef = useRef(projectGroups);
projectGroupsRef.current = projectGroups;
// Threads on non-primary environments (T3 Connect, hosted) resolve their
@@ -2741,20 +2714,19 @@ export default function Sidebar() {
() => projectGroupsSpanEnvironments(projectGroups),
[projectGroups],
);
- const scratchMachineLabelFor = useCallback(
- (thread: Pick) => {
- if (!showProjectEnvironments) return null;
- const project = projectByKey.get(`${thread.environmentId}:${thread.projectId}`);
- if (
- !project ||
- !isScratchProject(project, serverConfigs.get(thread.environmentId)?.scratchWorkspaceRoot)
- ) {
- return null;
- }
- return environmentLabelById.get(thread.environmentId) ?? null;
- },
- [environmentLabelById, projectByKey, serverConfigs, showProjectEnvironments],
- );
+ const scratchMachineLabelFor = (
+ thread: Pick,
+ ) => {
+ if (!showProjectEnvironments) return null;
+ const project = projectByKey.get(`${thread.environmentId}:${thread.projectId}`);
+ if (
+ !project ||
+ !isScratchProject(project, serverConfigs.get(thread.environmentId)?.scratchWorkspaceRoot)
+ ) {
+ return null;
+ }
+ return environmentLabelById.get(thread.environmentId) ?? null;
+ };
const projectGroupByScopeKey = useMemo(
() => new Map(projectGroups.map((project) => [project.projectKey, project] as const)),
[projectGroups],
@@ -2858,7 +2830,7 @@ export default function Sidebar() {
clearSelection();
}, [clearSelection, projectScopeKey]);
- const openProjectSettings = useCallback(
+ const openProjectSettings = useStableCallback(
(projectGroup: SidebarProjectSnapshot, projectRef?: ScopedProjectRef) => {
if (isMobile) {
setOpenMobile(false);
@@ -2875,7 +2847,6 @@ export default function Sidebar() {
},
});
},
- [isMobile, projectGroupingSettings, projects, router, setOpenMobile],
);
// Anchor for the scope popup: the header search field, not its icon trigger.
const headerSearchRef = useRef(null);
@@ -2883,7 +2854,7 @@ export default function Sidebar() {
// selection, then clear the guard when the picker opens again.
const suppressNextScopeChangeRef = useRef(false);
const highlightedProjectScopeKeyRef = useRef(null);
- const handleProjectSettings = useCallback(
+ const handleProjectSettings = useStableCallback(
(
event: ReactMouseEvent | ReactKeyboardEvent,
projectGroup: SidebarProjectSnapshot,
@@ -2894,7 +2865,6 @@ export default function Sidebar() {
dispatchProjectScopeMenu({ type: "project-settings-opened" });
openProjectSettings(projectGroup);
},
- [openProjectSettings],
);
// Keep a dropped row at its destination while its server applies the
@@ -3722,7 +3692,7 @@ export default function Sidebar() {
action: SidebarSweepAction;
keys: ReadonlySet;
} | null>(null);
- const startActionSweep = useCallback(
+ const startActionSweep = useStableCallback(
(threadRef: ScopedThreadRef, action: SidebarSweepAction, event: PointerEvent) => {
const originKey = scopedThreadKey(threadRef);
const originSection = sectionByThreadKeyRef.current.get(originKey);
@@ -3781,7 +3751,6 @@ export default function Sidebar() {
onAbort: () => {},
});
},
- [attemptUnsettle, attemptUnsnooze, serverConfigs, settleThreads],
);
const pinnedKeys = useMemo(
() =>
@@ -4400,7 +4369,7 @@ export default function Sidebar() {
);
const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
- const handleMultiSelectContextMenu = useCallback(
+ const handleMultiSelectContextMenu = useStableCallback(
async (position: { x: number; y: number }) => {
const api = readLocalApi();
if (!api) return;
@@ -4660,22 +4629,9 @@ export default function Sidebar() {
}),
);
},
- [
- attemptUnpin,
- clearSelection,
- confirmThreadDelete,
- deleteThread,
- markThreadUnread,
- performSnooze,
- removeFromSelection,
- serverConfigs,
- settleThreads,
- updateThreadMetadata,
- timestampFormat,
- ],
);
- const handleDraftContextMenu = useCallback(
+ const handleDraftContextMenu = useStableCallback(
(draftId: DraftId, position: { x: number; y: number }) => {
void (async () => {
const api = readLocalApi();
@@ -4725,10 +4681,9 @@ export default function Sidebar() {
}
})();
},
- [copyBranchToClipboard, copyPathToClipboard, openProjectSettings, projectByKey],
);
- const handleThreadContextMenu = useCallback(
+ const handleThreadContextMenu = useStableCallback(
(threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
void (async () => {
const api = readLocalApi();
@@ -4998,32 +4953,6 @@ export default function Sidebar() {
}
})();
},
- [
- archiveThread,
- attemptPin,
- attemptSettle,
- attemptSnooze,
- attemptUnpin,
- attemptUnsettle,
- attemptUnsnooze,
- confirmThreadArchive,
- confirmThreadDelete,
- copyBranchToClipboard,
- copyPathToClipboard,
- copyThreadIdToClipboard,
- deleteThread,
- handleMultiSelectContextMenu,
- markThreadUnread,
- openProjectSettings,
- projectScopeKey,
- projectByKey,
- serverConfigs,
- setProjectScopeKey,
- setThreadAutoSettle,
- startThreadRename,
- updateThreadMetadata,
- timestampFormat,
- ],
);
// Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as
diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx
index 36709813ef0e..89ad8c722125 100644
--- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx
+++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx
@@ -21,6 +21,7 @@ const state = vi.hoisted(() => ({
limited: false,
subagent: false,
background: [] as Array<{ taskId: string; kind: "command" | "monitor" }>,
+ delegatedChild: null as "running" | "approval" | null,
add: vi.fn(
(_toast: { title: string; description: string; actionProps: { onClick: () => void } }) =>
"toast-1",
@@ -87,10 +88,33 @@ function mockThreadShell() {
};
}
+// A delegated child changes while its parent's shell object stays the same.
+let stableParent: ReturnType | null = null;
+function mockThreads() {
+ if (state.delegatedChild === null) return [mockThreadShell()];
+ stableParent ??= mockThreadShell();
+ const child = {
+ ...mockThreadShell(),
+ id: "child-1",
+ title: "Review the login form",
+ creationSource: "mcp",
+ lineage: {
+ rootThreadId: "thread-1",
+ parentThreadId: "thread-1",
+ relationshipToParent: "subagent",
+ },
+ pendingRuntimeRequest:
+ state.delegatedChild === "approval"
+ ? { id: "request-2", kind: "command", createdAt: SHELL_NOW }
+ : null,
+ };
+ return [stableParent, child];
+}
+
vi.mock("@effect/atom-react", () => ({
useAtomValue: () => ({
status: state.live ? "live" : "disconnected",
- snapshot: Option.some({ threads: [mockThreadShell()] }),
+ snapshot: Option.some({ threads: mockThreads() }),
}),
}));
vi.mock("@tanstack/react-router", () => ({
@@ -154,7 +178,9 @@ beforeEach(() => {
limited: false,
subagent: false,
background: [],
+ delegatedChild: null,
});
+ stableParent = null;
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", new EventTarget());
vi.stubGlobal("document", {
@@ -188,6 +214,19 @@ describe("thread notifications", () => {
expect(state.notification).not.toHaveBeenCalled();
});
+ it("alerts on the parent when its delegated subagent asks for approval", async () => {
+ state.mode = "notifications-and-sound";
+ state.delegatedChild = "running";
+ await render();
+ state.delegatedChild = "approval";
+ await render();
+ await render();
+ expect(state.add).toHaveBeenCalledTimes(1);
+ expect(state.add).toHaveBeenLastCalledWith(
+ expect.objectContaining({ title: "Approval needed", description: "Fix the login form" }),
+ );
+ });
+
it("alerts once with system alerts off and opens the completed thread", async () => {
await render();
await complete();
diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx
index d1f31bc1f33d..ac15d8aeefca 100644
--- a/apps/web/src/components/ThreadNotificationCoordinator.tsx
+++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx
@@ -1,4 +1,8 @@
-import { presentThreadShell } from "@t3tools/client-runtime/state/models";
+import {
+ type PendingSubagentRequest,
+ presentThreadShell,
+} from "@t3tools/client-runtime/state/models";
+import { pendingSubagentRequestsByThread } from "@t3tools/client-runtime/state/threads";
import { useAtomValue } from "@effect/atom-react";
import { useNavigate, useParams } from "@tanstack/react-router";
import {
@@ -102,6 +106,7 @@ export function ThreadNotificationCoordinator() {
interface NotificationState {
readonly raw: OrchestrationV2ThreadShell;
+ readonly subagentRequest: PendingSubagentRequest | null;
readonly attention: string | null;
readonly completion: number | null;
}
@@ -136,15 +141,18 @@ function EnvironmentNotifications({
return;
}
const next = new Map();
+ // Subagents are skipped below, so their requests notify through the thread above them.
+ const subagentRequests = pendingSubagentRequestsByThread(threads);
for (const rawThread of threads) {
if (rawThread.lineage.relationshipToParent === "subagent") continue;
const prior = previous.current.get(rawThread.id);
+ const subagentRequest = subagentRequests.get(rawThread.id) ?? null;
// The same object cannot produce a new notification.
- if (prior?.raw === rawThread) {
+ if (prior?.raw === rawThread && prior.subagentRequest === subagentRequest) {
next.set(rawThread.id, prior);
continue;
}
- const thread = presentThreadShell(environmentId, rawThread);
+ const thread = presentThreadShell(environmentId, rawThread, subagentRequest);
let status = resolveSidebarThreadStatus(thread);
if (status === "ready" && thread.latestRun?.status === "failed") status = "failed";
const attention =
@@ -159,7 +167,7 @@ function EnvironmentNotifications({
Number.isFinite(completedAt)
? completedAt
: (prior?.completion ?? null);
- next.set(thread.id, { raw: rawThread, attention, completion });
+ next.set(thread.id, { raw: rawThread, subagentRequest, attention, completion });
if (!prior || thread.archivedAt !== null) continue;
const kind =
attention && attention !== prior.attention
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index fab0530a39fc..fd87d4f74050 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -47,6 +47,7 @@ import type {
SnapShotSource,
} from "@t3tools/contracts";
import {
+ EnvironmentRequestInvalidError,
AuthOrchestrationOperateScope,
ProviderDriverKind,
ProviderInstanceId,
@@ -56,6 +57,8 @@ import {
PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS,
} from "@t3tools/contracts";
import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
+import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
+import * as Schema from "effect/Schema";
import {
isPasteAsTextShortcut,
nextPastedTextFileName,
@@ -72,6 +75,7 @@ import {
type ReactNode,
useCallback,
useEffect,
+ useEffectEvent,
useId,
useImperativeHandle,
useLayoutEffect,
@@ -199,7 +203,10 @@ import {
type TerminalContextSelection,
} from "../../lib/terminalContext";
import { useComposerPathSearch } from "../../lib/composerPathSearchState";
-import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
+import {
+ replaceComposerContextReferences,
+ toKindScopedComposerContextId,
+} from "@t3tools/shared/composerContextReferences";
import {
getRestingComposerImagePreviewCounts,
resolveRestingComposerControlsLayout,
@@ -228,7 +235,6 @@ import {
formatInlineContextReference,
insertInlineContextReference,
inlineContextReferenceReplacement,
- toKindScopedComposerContextId,
} from "~/lib/composerContextReferences";
import {
asKnownContextRecord,
@@ -251,7 +257,7 @@ import {
threadContextReference,
} from "~/lib/composerContextRecords";
import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems";
-import { THREAD_CONTEXT_DROP_EVENT, threadContextDropTargetProps } from "./threadContextDrag";
+import { useThreadContextDrop, threadContextDropTargetProps } from "./threadContextDrag";
import { readThreadShell, useThreadShells } from "~/state/entities";
import { requestConfirmDialog } from "~/confirmDialog";
import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
@@ -265,6 +271,8 @@ import { resolveAssetUrl } from "~/assets/assetUrls";
import { assetEnvironment } from "~/state/assets";
import { readPreparedConnection } from "~/state/session";
import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
+import { orchestrationEnvironment } from "~/state/orchestration";
+import { threadContextAttachment } from "~/lib/threadContextAttachment";
import {
pullRequestEnvironment,
usePullRequestList,
@@ -347,6 +355,10 @@ import {
import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
import { PierreEntryIcon } from "./PierreEntryIcon";
import { pendingDraftWork } from "./pendingDraftWork";
+import {
+ importComposerThreadAttachment,
+ remainingComposerAttachmentSlots,
+} from "./composerThreadImport";
import { isTimelineScrollTarget } from "./timelineScrollTarget";
import {
createComposerScrollGestureState,
@@ -428,6 +440,7 @@ function SnapShotAttachmentFrame({
}
const COMPOSER_PULL_REQUEST_LIST_LIMIT = 99;
+const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError);
const COMPOSER_PULL_REQUEST_RESULT_LIMIT = 12;
const EMPTY_PULL_REQUEST_LIST_TARGETS: ReadonlyArray> =
[];
@@ -1154,6 +1167,7 @@ import {
import { searchProviderSkills } from "../../providerSkillSearch";
import { useDelayedStatus } from "../../hooks/useDelayedStatus";
import { useMediaQuery } from "../../hooks/useMediaQuery";
+import { useStableCallback } from "../../hooks/useStableCallback";
import { usePanelAnimationSettings } from "../../panelAnimations";
import { useAtomCommand } from "../../state/use-atom-command";
import { readEnvironmentScope } from "../../state/session";
@@ -2600,6 +2614,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
* the next draft.
*/
const pendingImageCompressionsRef = useRef
) : null}
@@ -7947,11 +7776,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
canResume={showResumeAction}
preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting}
isEditingQueuedMessage={isEditingQueuedMessage}
- onSubmitMessage={handleSubmitMessage}
+ onSubmitMessage={handleSubmitMessageStable}
onResume={onResume}
onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
- onInterrupt={handleInterruptPrimaryAction}
- onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
+ onInterrupt={handleInterruptPrimaryActionStable}
+ onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryActionStable}
compactBeforeSendTokens={props.resumeCompactionTokens}
keepFullHistory={props.keepFullHistory}
onToggleKeepFullHistory={props.onToggleKeepFullHistory}
@@ -7959,7 +7788,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
compactDisabled || noProviderAvailable || isSendBusy || isConnecting
}
compactDisabledReason={resolvedCompactDisabledReason}
- {...(compactCommandAvailable ? { onCompactContext: compactThreadContext } : {})}
+ {...(compactCommandAvailable
+ ? { onCompactContext: compactThreadContextStable }
+ : {})}
/>
diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx
index 514f379d9f33..dbc352c673ce 100644
--- a/apps/web/src/components/chat/ComposerUsageLimits.tsx
+++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx
@@ -1,5 +1,5 @@
import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts";
-import { limitsNotice } from "@t3tools/shared/usageLimits";
+import { limitsNotice, usageCreditSummary } from "@t3tools/shared/usageLimits";
import { GaugeIcon } from "lucide-react";
import { ensureLocalApi } from "../../localApi";
@@ -88,6 +88,9 @@ function UsageLimitsBannerBody({
(account.instanceId ? { instanceId: account.instanceId } : undefined);
const notice = limitsNotice(account.limits);
const externalUsage = account.limits.externalUsage;
+ const credits = account.limits.extraUsage
+ ? usageCreditSummary(account.limits.extraUsage)
+ : null;
return (
{report.accounts.length > 1 ? (
@@ -105,6 +108,12 @@ function UsageLimitsBannerBody({
now={now}
/>
)}
+ {credits ? (
+
+ Usage credits: {credits.amount}
+ {credits.remainingPercent !== null ? " left" : ""} · {credits.description}
+
+ ) : null}
{externalUsage ? (
void): () => void {
+ const check = (event: globalThis.MouseEvent) => {
+ if (event.buttons !== 0) return;
+ stop();
+ onRelease();
+ };
+ const stop = () => {
+ window.removeEventListener("mouseup", check);
+ window.removeEventListener("mousemove", check);
+ };
+ window.addEventListener("mouseup", check);
+ window.addEventListener("mousemove", check);
+ return stop;
+}
+
function TimelineMinimap({
hasPersistentGutter,
hitStripWidth,
@@ -1795,6 +1813,18 @@ function TimelineMinimap({
[onWheel],
);
+ // A held drag that started on the minimap (the strip or its turn buttons) is a
+ // scrub; one that started anywhere else is a text selection. While a selection
+ // crosses the minimap, the minimap lets the pointer through so the selection
+ // keeps following the text underneath, until the button is released.
+ const pressedOnStripRef = useRef(false);
+ const [passThrough, setPassThrough] = useState(false);
+
+ useEffect(() => {
+ if (!passThrough) return;
+ return onMouseButtonsReleased(() => setPassThrough(false));
+ }, [passThrough]);
+
const resolvedActiveIndex =
activeIndex !== null && activeIndex < items.length ? activeIndex : null;
const activeItem = useMemo(
@@ -1804,7 +1834,8 @@ function TimelineMinimap({
),
[items, resolvedActiveIndex],
);
- const navigationInteractive = resolveTimelineMinimapNavigationInteractive(hitStripWidth);
+ const navigationInteractive =
+ resolveTimelineMinimapNavigationInteractive(hitStripWidth) && !passThrough;
const activeTopPercent =
resolvedActiveIndex === null
? 0
@@ -1876,8 +1907,18 @@ function TimelineMinimap({
"absolute top-1/2 left-3 -translate-y-1/2",
// The strip is width-capped to the side gutter so it never overlays
// the centered content column; with no usable gutter it goes inert.
- hitStripWidth > 0 ? "pointer-events-auto" : "pointer-events-none",
+ hitStripWidth > 0 && !passThrough ? "pointer-events-auto" : "pointer-events-none",
)}
+ onMouseDown={() => {
+ if (pressedOnStripRef.current) return;
+ pressedOnStripRef.current = true;
+ onMouseButtonsReleased(() => {
+ pressedOnStripRef.current = false;
+ });
+ }}
+ onMouseMove={(event) => {
+ if (event.buttons !== 0 && !pressedOnStripRef.current) setPassThrough(true);
+ }}
style={{
height: resolveTimelineMinimapHeightStyle(items.length),
width: resolveTimelineMinimapInteractiveWidth(hitStripWidth, activeItem !== null),
@@ -1928,7 +1969,10 @@ function TimelineMinimap({
}
}}
onMouseLeave={() => setActiveIndex(null)}
- onMouseMove={updateActiveIndexFromPointer}
+ onMouseMove={(event) => {
+ if (event.buttons !== 0 && !pressedOnStripRef.current) return;
+ updateActiveIndexFromPointer(event);
+ }}
onMouseDown={(event) => {
if (timelineMinimapEventTargetsPreview(event.target)) {
return;
@@ -2663,7 +2707,7 @@ function UserMessageIntentMarker({
const presentation =
intent === "queued_turn"
? {
- label: "Queued",
+ label: "From queue",
icon: null,
}
: intent === "promoted_queued_to_steer"
@@ -2691,7 +2735,7 @@ function UserMessageIntentMarker({
{intent === "queued_turn"
- ? "Queued behind the active turn"
+ ? "Queued while the agent was busy"
: intent === "promoted_queued_to_steer"
? "Originally queued, then promoted to steer the active turn"
: "Steered the active turn"}
diff --git a/apps/web/src/components/chat/ProviderPackageIcon.tsx b/apps/web/src/components/chat/ProviderPackageIcon.tsx
index 98c27693dc1e..675c619a702d 100644
--- a/apps/web/src/components/chat/ProviderPackageIcon.tsx
+++ b/apps/web/src/components/chat/ProviderPackageIcon.tsx
@@ -1,4 +1,4 @@
-import type { CSSProperties, SVGProps } from "react";
+import { useId, type CSSProperties, type SVGProps } from "react";
import type { ProviderIcon } from "@t3tools/provider-core/client";
@@ -13,11 +13,14 @@ export function ProviderPackageIcon({
className,
...props
}: { readonly icon: ProviderIcon } & SVGProps) {
+ const gradientId = useId();
return (