authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-03 02:19:11-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 02:19:11-07:00
log263097a8da038a143f2b7b7817250f0ad390ea32
tree4473027cf8d4a55e56e19f8da42578ce01331f89
parentf68e24fb419c985269cd0acb841e203af1419c43
signature Signed by PGP key B5690EEEBB952194

fix(web): mod+enter starts a new thread in the background again (#15060)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

6 files changed, 178 insertions(+), 28 deletions(-)

apps/server/src/keybindings.test.ts+71-1
......@@ -11,7 +11,7 @@ import * as Path from "effect/Path";
1111import * as Schema from "effect/Schema";
1212import * as ServerConfig from "./config.ts";
1313import * as Keybindings from "./keybindings.ts";
14import { KeybindingsConfigError } from "@t3tools/contracts";
14import { KeybindingsConfigError, MAX_KEYBINDINGS_COUNT } from "@t3tools/contracts";
1515import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1616
1717const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
......@@ -283,6 +283,76 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
283283 }).pipe(Effect.provide(makeKeybindingsLayer())),
284284 );
285285
286 it.effect("adds a late default to an existing command once", () =>
287 Effect.gen(function* () {
288 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
289 const keybindings = yield* Keybindings.Keybindings;
290 const when = "composerFocus && draftThreadRoute";
291 const existing = { key: "mod+alt+enter", command: "composer.sendBackground", when } as const;
292 const backgroundRules = Effect.map(readKeybindingsConfig(keybindingsConfigPath), (rules) =>
293 rules.filter((entry) => entry.command === "composer.sendBackground"),
294 );
295 yield* writeKeybindingsConfig(keybindingsConfigPath, [existing]);
296
297 yield* keybindings.syncDefaultKeybindingsOnStartup;
298 assert.deepStrictEqual(yield* backgroundRules, [
299 existing,
300 { key: "mod+enter", command: "composer.sendBackground", when },
301 ]);
302
303 // Removing the added rule later must survive the next startup.
304 yield* writeKeybindingsConfig(keybindingsConfigPath, [existing]);
305 yield* keybindings.syncDefaultKeybindingsOnStartup;
306 assert.deepStrictEqual(yield* backgroundRules, [existing]);
307 }).pipe(Effect.provide(makeKeybindingsLayer())),
308 );
309
310 it.effect("leaves a customized command without the late default", () =>
311 Effect.gen(function* () {
312 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
313 const keybindings = yield* Keybindings.Keybindings;
314 const custom = {
315 key: "alt+b",
316 command: "composer.sendBackground",
317 when: "composerFocus && draftThreadRoute",
318 } as const;
319 yield* writeKeybindingsConfig(keybindingsConfigPath, [custom]);
320
321 yield* keybindings.syncDefaultKeybindingsOnStartup;
322 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
323 assert.deepStrictEqual(
324 persisted.filter((entry) => entry.command === "composer.sendBackground"),
325 [custom],
326 );
327 }).pipe(Effect.provide(makeKeybindingsLayer())),
328 );
329
330 it.effect("keeps a late default pending while the config is full", () =>
331 Effect.gen(function* () {
332 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
333 const keybindings = yield* Keybindings.Keybindings;
334 const when = "composerFocus && draftThreadRoute";
335 const existing = { key: "mod+alt+enter", command: "composer.sendBackground", when } as const;
336 const fillers = Array.from({ length: MAX_KEYBINDINGS_COUNT - 1 }, (_, index) => ({
337 key: "mod+alt+f1",
338 command: `script.filler-${index}.run` as const,
339 }));
340 const hasModEnter = Effect.map(readKeybindingsConfig(keybindingsConfigPath), (rules) =>
341 rules.some(
342 (entry) => entry.command === "composer.sendBackground" && entry.key === "mod+enter",
343 ),
344 );
345
346 yield* writeKeybindingsConfig(keybindingsConfigPath, [existing, ...fillers]);
347 yield* keybindings.syncDefaultKeybindingsOnStartup;
348 assert.isFalse(yield* hasModEnter);
349
350 yield* writeKeybindingsConfig(keybindingsConfigPath, [existing]);
351 yield* keybindings.syncDefaultKeybindingsOnStartup;
352 assert.isTrue(yield* hasModEnter);
353 }).pipe(Effect.provide(makeKeybindingsLayer())),
354 );
355
286356 it.effect("skips conflicting default keybindings on startup and logs a detailed warning", () => {
287357 const messages: string[] = [];
288358 const logger = Logger.make(({ message }) => {
apps/server/src/keybindings.ts+95-20
......@@ -104,6 +104,31 @@ function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): bool
104104 );
105105}
106106
107// Default rules added to a command after startup sync had already persisted
108// that command. Backfill skips commands a config already has, so startup adds
109// each rule once per config, only next to the untouched earlier default
110// (`alongside`), and records its id. Customized commands and later removals
111// stay as the user set them.
112const LATE_DEFAULT_KEYBINDINGS: ReadonlyArray<{
113 readonly id: string;
114 readonly alongside: KeybindingRule;
115 readonly rule: KeybindingRule;
116}> = [
117 {
118 id: "composer.sendBackground:mod+enter",
119 alongside: {
120 key: "mod+alt+enter",
121 command: "composer.sendBackground",
122 when: "composerFocus && draftThreadRoute",
123 },
124 rule: {
125 key: "mod+enter",
126 command: "composer.sendBackground",
127 when: "composerFocus && draftThreadRoute",
128 },
129 },
130];
131
107132function keybindingShortcutContext(rule: KeybindingRule): string | null {
108133 const parsed = parseKeybindingShortcut(rule.key);
109134 if (!parsed) return null;
......@@ -399,6 +424,32 @@ const make = Effect.gen(function* () {
399424 return { keybindings, issues };
400425 });
401426
427 // One applied migration id per line, next to the config file.
428 const appliedMigrationsPath = path.join(
429 path.dirname(keybindingsConfigPath),
430 "keybindings-migrations",
431 );
432 const readAppliedMigrationIds = fs.readFileString(appliedMigrationsPath).pipe(
433 Effect.map((contents) => new Set(contents.split("\n").filter((line) => line.length > 0))),
434 Effect.orElseSucceed(() => new Set<string>()),
435 );
436 const recordAppliedMigrations = (ids: Iterable<string>) =>
437 writeFileStringAtomically({
438 filePath: appliedMigrationsPath,
439 contents: `${[...new Set(ids)].join("\n")}\n`,
440 }).pipe(
441 Effect.provideService(FileSystem.FileSystem, fs),
442 Effect.provideService(Path.Path, path),
443 Effect.mapError(
444 (cause) =>
445 new KeybindingsConfigError({
446 configPath: keybindingsConfigPath,
447 detail: "failed to record keybinding migrations",
448 cause,
449 }),
450 ),
451 );
452
402453 const writeConfigAtomically = (rules: readonly KeybindingRule[]) => {
403454 return encodeKeybindingsConfigPrettyJson(rules).pipe(
404455 Effect.map((encoded) => `${encoded}\n`),
......@@ -450,9 +501,19 @@ const make = Effect.gen(function* () {
450501
451502 const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)(
452503 Effect.gen(function* () {
504 const appliedMigrationIds = yield* readAppliedMigrationIds;
505 const pendingLateDefaults = LATE_DEFAULT_KEYBINDINGS.filter(
506 (late) => !appliedMigrationIds.has(late.id),
507 );
453508 const configExists = yield* readConfigExists;
454509 if (!configExists) {
455510 yield* writeConfigAtomically(DEFAULT_KEYBINDINGS);
511 if (pendingLateDefaults.length > 0) {
512 yield* recordAppliedMigrations([
513 ...appliedMigrationIds,
514 ...LATE_DEFAULT_KEYBINDINGS.map((late) => late.id),
515 ]);
516 }
456517 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
457518 return;
458519 }
......@@ -496,6 +557,16 @@ const make = Effect.gen(function* () {
496557 }
497558 missingDefaults.push(defaultRule);
498559 }
560 // The loop above backfills whole missing commands. Late defaults join
561 // an untouched earlier default, when their shortcut is still free.
562 for (const { alongside, rule } of pendingLateDefaults) {
563 if (
564 customConfig.some((entry) => isSameKeybindingRule(entry, alongside)) &&
565 !customConfig.some((entry) => hasSameShortcutContext(entry, rule))
566 ) {
567 missingDefaults.push(rule);
568 }
569 }
499570 for (const conflict of shortcutConflictWarnings) {
500571 yield* Effect.logWarning("skipping default keybinding due to shortcut conflict", {
501572 path: keybindingsConfigPath,
......@@ -506,21 +577,18 @@ const make = Effect.gen(function* () {
506577 reason: "shortcut context already used by existing rule",
507578 });
508579 }
509 if (missingDefaults.length === 0) {
510 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
511 return;
512 }
513
514 const matchingDefaults = Array.filterMap(DEFAULT_KEYBINDINGS, (defaultRule) =>
515 customConfig.some((entry) => isSameKeybindingRule(entry, defaultRule))
516 ? Result.succeed(defaultRule.command)
517 : Result.failVoid,
518 );
519 if (matchingDefaults.length > 0) {
520 yield* Effect.logWarning("default keybinding rule already defined in user config", {
521 path: keybindingsConfigPath,
522 commands: matchingDefaults,
523 });
580 if (missingDefaults.length > 0) {
581 const matchingDefaults = Array.filterMap(DEFAULT_KEYBINDINGS, (defaultRule) =>
582 customConfig.some((entry) => isSameKeybindingRule(entry, defaultRule))
583 ? Result.succeed(defaultRule.command)
584 : Result.failVoid,
585 );
586 if (matchingDefaults.length > 0) {
587 yield* Effect.logWarning("default keybinding rule already defined in user config", {
588 path: keybindingsConfigPath,
589 commands: matchingDefaults,
590 });
591 }
524592 }
525593
526594 // Startup backfill must never evict persisted user rules: append only
......@@ -535,12 +603,19 @@ const make = Effect.gen(function* () {
535603 commands: skippedDefaults.map((rule) => rule.command),
536604 });
537605 }
538 if (defaultsToAppend.length === 0) {
539 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
540 return;
606 if (defaultsToAppend.length > 0) {
607 yield* writeConfigAtomically([...customConfig, ...defaultsToAppend]);
608 }
609 // A late default skipped at max entries stays pending for a later start.
610 const settledLateDefaults = pendingLateDefaults.filter(
611 (late) => !skippedDefaults.includes(late.rule),
612 );
613 if (settledLateDefaults.length > 0) {
614 yield* recordAppliedMigrations([
615 ...appliedMigrationIds,
616 ...settledLateDefaults.map((late) => late.id),
617 ]);
541618 }
542
543 yield* writeConfigAtomically([...customConfig, ...defaultsToAppend]);
544619 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
545620 }),
546621 );
apps/web/src/composer-logic.test.ts+1-1
......@@ -140,7 +140,7 @@ describe("composerSubmissionIntentForKey", () => {
140140 isDraftThread: true,
141141 event: modEnter,
142142 }),
143 ).toBe("foreground");
143 ).toBe("background");
144144 expect(
145145 composerSubmissionIntentForKey({ ...running, event: { ...enter, shiftKey: true } }),
146146 ).toBeNull();
apps/web/src/keybindings.test.ts+1-1
......@@ -1287,7 +1287,7 @@ describe("composer and pull request shortcuts", () => {
12871287 ctrlKey: platform !== "MacIntel",
12881288 };
12891289 const queuedKey = event({ key: "Enter", shiftKey: true, ...modifier });
1290 const backgroundKey = event({ key: "Enter", altKey: true, ...modifier });
1290 const backgroundKey = event({ key: "Enter", ...modifier });
12911291 assert.strictEqual(
12921292 resolveShortcutCommand(queuedKey, DEFAULT_RESOLVED_KEYBINDINGS, {
12931293 platform,
docs/user/keybindings.md+5-5
......@@ -12,11 +12,11 @@ inserts a new line. This applies to the web and desktop composer at desktop widt
1212**Follow-up behavior** chooses Queue or Steer while the agent runs. Use
1313`mod+Enter` to do the opposite for one message, even when the send shortcut
1414requires a modifier. `mod+Alt+Enter` sends, keeps that thread running in the
15background, and opens a fresh new-thread composer. Change these
16shortcuts in **Settings → Keybindings** under **Composer: Opposite Queue or
17Steer Action**, **Composer: Start in Background**, or **Composer: Send and Start
18New Thread**. These bindings take priority over the send shortcut. Click the
19send button to use the configured follow-up behavior.
15background, and opens a fresh new-thread composer. In a new thread, `mod+Enter`
16does the same. Change these shortcuts in **Settings → Keybindings** under
17**Composer: Opposite Queue or Steer Action**, **Composer: Start in Background**,
18or **Composer: Send and Start New Thread**. These bindings take priority over the
19send shortcut. Click the send button to use the configured follow-up behavior.
2020
2121When an active turn has queued messages, `mod+Shift+Enter` sends the first as a
2222steer. Change it under **Queue: Send First Queued Message as Steer** in Keybindings.
packages/shared/src/keybindings.ts+5
......@@ -48,6 +48,11 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
4848 { key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" },
4949 { key: "alt+arrowup", command: "thread.editQueuedMessage", when: "composerFocus" },
5050 { key: "mod+enter", command: "composer.sendAlternate", when: "composerFocus && turnRunning" },
51 {
52 key: "mod+enter",
53 command: "composer.sendBackground",
54 when: "composerFocus && draftThreadRoute",
55 },
5156 {
5257 key: "mod+alt+enter",
5358 command: "composer.sendBackground",