Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
2a12f0b
feat(web): add reasoning cycle keybindings
ipanasenko Aug 16, 2026
940d383
Merge branch 'main' into t3code/implement-reasoning-cycle-handoff
ipanasenko Aug 27, 2026
f2d5521
fix(web): preserve composer state when changing reasoning
ipanasenko Aug 27, 2026
a011c09
fix(web): keep reasoning cycles scoped to visible drafts
ipanasenko Aug 27, 2026
6019931
fix(shared): replace stale reasoning selections
ipanasenko Aug 27, 2026
5d5d7c3
fix(shared): preserve user ultrathink labels
ipanasenko Aug 27, 2026
48147b4
fix(web): keep reasoning shortcuts on draft state
ipanasenko Aug 27, 2026
95955d9
fix(web): align ultrathink blocked state
ipanasenko Aug 27, 2026
574e044
fix(web): claim repeated reasoning shortcuts
ipanasenko Aug 27, 2026
0afb93e
fix(keybindings): avoid printable reasoning shortcuts
ipanasenko Aug 27, 2026
50fd1c9
Merge branch 'main' into t3code/implement-reasoning-cycle-handoff
ipanasenko Aug 28, 2026
f9af5e8
merge origin/main
ipanasenko Aug 29, 2026
a171b7a
merge origin/main
ipanasenko Aug 29, 2026
b207d03
Merge branch 'main' into t3code/implement-reasoning-cycle-handoff
ipanasenko Aug 30, 2026
2e83020
merge origin/main
ipanasenko Sep 2, 2026
c51fa86
merge origin/main
ipanasenko Sep 2, 2026
7a57cd6
Merge remote-tracking branch 'origin/main' into t3code/implement-reas…
ipanasenko Sep 4, 2026
6525e3e
fix(web): restore composer draft after pending answers
ipanasenko Sep 4, 2026
55388e6
Merge branch 'main' into t3code/implement-reasoning-cycle-handoff
ipanasenko Sep 4, 2026
3831cd7
Merge branch 'main' into t3code/implement-reasoning-cycle-handoff
ipanasenko Sep 4, 2026
f3962be
fix(web): preserve AltGr punctuation in shortcuts
ipanasenko Sep 4, 2026
7869147
fix(web): keep macOS option shortcuts detectable
ipanasenko Sep 4, 2026
29afea0
fix(web): match AltGr by layout key
ipanasenko Sep 4, 2026
541aefd
Merge origin/main into t3code/implement-reasoning-cycle-handoff
ipanasenko Sep 6, 2026
344aaf6
fix(web): remove unused thread jump hints export
ipanasenko Sep 6, 2026
6369190
Merge branch 'main' into t3code/implement-reasoning-cycle-handoff
ipanasenko Sep 7, 2026
e5398ac
fix(web): respect plan mode in reasoning shortcuts
ipanasenko Sep 7, 2026
7b98b1c
Merge origin/main into t3code/implement-reasoning-cycle-handoff
ipanasenko Sep 8, 2026
02d8868
Merge commit '47eed9face4186635488fba5f2494eddd13f6491' into t3code/i…
ipanasenko Sep 8, 2026
cf1b640
Merge branch 'main' into t3code/implement-reasoning-cycle-handoff
ipanasenko Sep 9, 2026
c9634ef
Merge branch 'main' into t3code/implement-reasoning-cycle-handoff
ipanasenko Sep 11, 2026
044b6d5
fix(web): keep reasoning shortcuts in sync with hidden drafts
ipanasenko Sep 11, 2026
ee9a581
refactor(web): simplify reasoning shortcut handling
ipanasenko Sep 12, 2026
9c5e85f
fix(web): rebuild composer state after hidden reasoning changes
ipanasenko Sep 12, 2026
73f7ffb
Merge remote-tracking branch 'origin/main' into t3code/implement-reas…
ipanasenko Sep 12, 2026
e42e275
Merge branch 'main' into t3code/implement-reasoning-cycle-handoff
ipanasenko Sep 12, 2026
b20f9b4
Merge branch 'main' into t3code/implement-reasoning-cycle-handoff
ipanasenko Sep 15, 2026
b388902
fix(web): resolve AltGraph merge conflict in keybindings
ipanasenko Sep 15, 2026
5a90d51
Merge branch 'main' into t3code/implement-reasoning-cycle-handoff
ipanasenko Sep 16, 2026
8f65bc5
Merge remote-tracking branch 'origin/main' into t3code/implement-reas…
ipanasenko Sep 17, 2026
897c3c0
Merge branch 'main' into t3code/implement-reasoning-cycle-handoff
ipanasenko Sep 22, 2026
1fd96e6
Merge branch 'main' into t3code/implement-reasoning-cycle-handoff
ipanasenko Sep 26, 2026
b6119ef
fix(web): pass platform to resolveEventKeys in rich-text bold shortcut
ipanasenko Sep 26, 2026
5d334e9
Merge branch 'main' into t3code/implement-reasoning-cycle-handoff
ipanasenko Sep 28, 2026
dd5b6f1
Merge branch 'main' into t3code/implement-reasoning-cycle-handoff
ipanasenko Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,35 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
}).pipe(Effect.provide(makeKeybindingsLayer())),
);

it.effect("ships configurable thread navigation defaults", () =>
Effect.sync(() => {
const defaultsByCommand = new Map(
Keybindings.DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const),
);

assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+[");
assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]");
assert.equal(defaultsByCommand.get("thread.copyReference"), "mod+shift+c");
assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s");
assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p");
assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
assert.equal(defaultsByCommand.get("themeEditor.toggle"), "mod+alt+shift+t");
assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p");
assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f");
assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");
assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");
assert.isFalse(defaultsByCommand.has("rightPanel.toggleMaximized"));
assert.equal(defaultsByCommand.get("rightPanel.close"), "mod+w");
assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");
assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1");
assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9");
assert.equal(defaultsByCommand.get("reasoning.decrease"), "mod+alt+,");
assert.equal(defaultsByCommand.get("reasoning.increase"), "mod+alt+.");
}),
);

it.effect("uses defaults in runtime when config is malformed without overriding file", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
128 changes: 122 additions & 6 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,12 @@ import {
wouldTextPasteExceedLimit,
} from "@t3tools/client-runtime/text-paste";
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
import {
createModelSelection,
normalizeModelSlug,
resolveReasoningTransition,
} from "@t3tools/shared/model";
import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop";
import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
import {
memo,
Expand All @@ -76,6 +80,7 @@ import {
detectComposerTrigger,
expandCollapsedComposerCursor,
formatAssistantCitationForComposer,
mapComposerCursorAcrossLeadingPromptChange,
replaceTextRange,
} from "../../composer-logic";
import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
Expand Down Expand Up @@ -178,7 +183,11 @@ import {
import { isCommandPaletteOpen } from "../../commandPaletteBus";
import { getTerminalFocusOwner } from "../../lib/terminalFocus";
import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
import { resolveShortcutCommand, shortcutLabelForCommand } from "../../keybindings";
import {
reasoningCycleDirectionFromCommand,
resolveShortcutCommand,
shortcutLabelForCommand,
} from "../../keybindings";
import {
type TerminalContextDraft,
type TerminalContextSelection,
Expand Down Expand Up @@ -943,6 +952,7 @@ import {
XIcon,
} from "lucide-react";
import { proposedPlanTitle } from "../../proposedPlan";
import { getProviderModelCapabilities } from "../../providerModels";
import { hasProviderSetup } from "./ProviderStatusBanner";
import {
applyProviderInstanceSettings,
Expand Down Expand Up @@ -1745,6 +1755,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
})
: null);
const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);
const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);
const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);
Expand Down Expand Up @@ -3054,10 +3065,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
// ------------------------------------------------------------------
// Sync refs back to parent
// ------------------------------------------------------------------
const restoreDraftAfterPendingRef = useRef(false);
useEffect(() => {
// While a pending question is visible, promptRef is temporarily the
// custom-answer buffer. Restore the draft as soon as that editor state
// ends, even when the draft prompt itself did not change.
if (activePendingProgress !== null) {
restoreDraftAfterPendingRef.current = true;
return;
}
promptRef.current = prompt;
if (restoreDraftAfterPendingRef.current) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// The cursor and trigger still describe the custom-answer buffer, or a
// draft that changed while hidden behind an approval, so rebuild both
// from the restored draft instead of clamping them.
restoreDraftAfterPendingRef.current = false;
setComposerCursor(collapseExpandedComposerCursor(prompt, prompt.length));
setComposerTrigger(detectComposerTrigger(prompt, prompt.length));
return;
}
setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing));
}, [prompt, promptRef]);
}, [activePendingProgress, prompt, promptRef]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

useEffect(() => {
if (composerSubmissionError === null) return;
Expand Down Expand Up @@ -5207,6 +5235,87 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
setIsStashMenuOpen(false);
}, [prompt]);

const applyReasoningCycle = useCallback(
(direction: "increase" | "decrease") => {
const editorShowsDraftPrompt = !isComposerApprovalState && activePendingProgress === null;
const currentPrompt = editorShowsDraftPrompt
? promptRef.current
: (getComposerDraft(composerDraftTarget)?.prompt ?? "");
const currentModelOptions = composerModelOptions?.[selectedInstanceId];
const transition = resolveReasoningTransition({
capabilities: getProviderModelCapabilities(
selectedProviderModels,
selectedModel,
selectedProvider,
settings.planModeEnabled,
),
modelOptions: currentModelOptions,
prompt: currentPrompt,
action: { type: "cycle", direction },
});
if (transition.status === "blocked") {
toastManager.add({
type: "info",
title: "Remove “ultrathink” from the prompt text to change reasoning.",
});
return;
}
if (transition.status === "unsupported") {
toastManager.add({
type: "info",
title: "This model has no reasoning levels to switch between.",
});
return;
}
if (transition.status !== "changed") return;
if (transition.prompt !== currentPrompt) {
setPrompt(transition.prompt);
// When a pending question or approval hides the editor, the draft
// is updated in the store only. The cursor and trigger belong to
// the visible editor state and are rebuilt from the draft by the
// sync effect once it is shown again.
if (!editorShowsDraftPrompt) {
restoreDraftAfterPendingRef.current = true;
} else {
// The editor is mounted whenever it shows the draft; the fallback
// only guards the ref during teardown.
const currentExpandedCursor =
composerEditorRef.current?.readSnapshot().expandedCursor ?? currentPrompt.length;
const nextExpandedCursor = mapComposerCursorAcrossLeadingPromptChange(
currentPrompt,
transition.prompt,
currentExpandedCursor,
);
promptRef.current = transition.prompt;
setComposerCursor(collapseExpandedComposerCursor(transition.prompt, nextExpandedCursor));
setComposerTrigger(detectComposerTrigger(transition.prompt, nextExpandedCursor));
}
}
if (transition.modelOptions !== currentModelOptions) {
setProviderModelOptions(composerDraftTarget, selectedProvider, transition.modelOptions, {
instanceId: selectedInstanceId,
model: selectedModel,
persistSticky: true,
});
}
},
[
activePendingProgress,
composerDraftTarget,
composerModelOptions,
getComposerDraft,
isComposerApprovalState,
promptRef,
selectedInstanceId,
selectedModel,
selectedProvider,
selectedProviderModels,
setPrompt,
setProviderModelOptions,
settings.planModeEnabled,
],
);

useEffect(() => {
const handler = (event: globalThis.KeyboardEvent) => {
const command = resolveShortcutCommand(event, keybindings, {
Expand All @@ -5216,14 +5325,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
modelPickerOpen: isComposerModelPickerOpen,
},
});
if (command !== "composer.stash") return;
// Always claim the shortcut so the browser save dialog never opens,
// even when the composer is in a state that can't stash.
const reasoningDirection = reasoningCycleDirectionFromCommand(command);
if (command !== "composer.stash" && reasoningDirection === null) return;
// Always claim recognized composer shortcuts, including in states where
// the requested mutation is unavailable.
event.preventDefault();
event.stopPropagation();
if (reasoningDirection !== null && event.repeat) return;
if (isCommandPaletteOpen() || isRevertingCheckpoint) {
return;
}
if (reasoningDirection !== null) {
applyReasoningCycle(reasoningDirection);
return;
}
if (pendingUserInputs.length > 0 && !isComposerApprovalState) {
setIsStashMenuOpen((open) => !open);
return;
Expand All @@ -5237,6 +5352,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
return () => window.removeEventListener("keydown", handler, true);
}, [
activePendingProgress,
applyReasoningCycle,
isComposerApprovalState,
isComposerModelPickerOpen,
keybindings,
Expand Down
57 changes: 56 additions & 1 deletion apps/web/src/components/chat/TraitsPicker.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vite-plus/test";
import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts";
import { buildTraitsTriggerDisplay, buildUnavailableModelOptionDescriptors } from "./TraitsPicker";
import {
buildTraitsTriggerDisplay,
buildUnavailableModelOptionDescriptors,
resolveTraitsSelectChange,
} from "./TraitsPicker";

function selectDescriptor(
id: string,
Expand Down Expand Up @@ -156,6 +160,57 @@ describe("buildTraitsTriggerDisplay", () => {
});
});

describe("resolveTraitsSelectChange", () => {
it("routes reasoning selections through the shared preserving transition", () => {
const modelOptions = [
{ id: "future", value: "keep" },
{ id: "reasoningEffort", value: "high" },
{ id: "contextWindow", value: "1m" },
] as const;

expect(
resolveTraitsSelectChange({
caps: { optionDescriptors: [EFFORT, CONTEXT_WINDOW] },
descriptors: [EFFORT, CONTEXT_WINDOW],
descriptor: EFFORT,
value: "max",
prompt: "unchanged prompt",
modelOptions,
}),
).toEqual({
prompt: "unchanged prompt",
modelOptions: [
{ id: "future", value: "keep" },
{ id: "reasoningEffort", value: "max" },
{ id: "contextWindow", value: "1m" },
],
});
expect(modelOptions[1]?.value).toBe("high");
});

it("retains the generic descriptor path for non-reasoning traits", () => {
expect(
resolveTraitsSelectChange({
caps: { optionDescriptors: [EFFORT, CONTEXT_WINDOW] },
descriptors: [EFFORT, CONTEXT_WINDOW],
descriptor: CONTEXT_WINDOW,
value: "200k",
prompt: "unchanged prompt",
modelOptions: [
{ id: "reasoningEffort", value: "high" },
{ id: "contextWindow", value: "1m" },
],
}),
).toEqual({
prompt: "unchanged prompt",
modelOptions: [
{ id: "reasoningEffort", value: "high" },
{ id: "contextWindow", value: "200k" },
],
});
});
});

describe("buildUnavailableModelOptionDescriptors", () => {
it("shows only saved values without inventing alternatives", () => {
expect(
Expand Down
Loading
Loading