Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
99e3b72
fix(connect): diagnose incomplete headless server setup (#9602)
juliusmarminge Sep 4, 2026
4cc800c
fix(web): keep command palette above composer menus (#9613)
Gigioxx Sep 4, 2026
93c3ab4
fix(web): snooze menu no longer overlaps thread details (#9601)
RakshithBhat03 Sep 4, 2026
7062315
fix(web): match composer pull request state icons (#9375)
flamboh Sep 4, 2026
2152d44
fix(server): load OpenCode workspace skills via SDK to avoid 64KB CLI…
BarryHenryJr Sep 4, 2026
ec3ec6f
fix(web): mute sidebar branch name to match worktree icon (#9622)
maria-rcks Sep 4, 2026
5f878d2
fix(web,mobile): fold context compaction under settled turn folds (#9…
maria-rcks Sep 4, 2026
09d13de
feat(mobile): make chat text selectable on Android (#8779)
SunkenInTime Sep 4, 2026
14bf3f6
fix(web): toggle a single stashed prompt with Cmd+S (#9644)
t3dotgg Sep 4, 2026
eb77683
fix(server): prevent duplicate desktop clients after restart
seeb1337 Sep 4, 2026
d487dfb
fix(web): resume Antigravity threads without repeated sign-in (#9647)
t3dotgg Sep 4, 2026
d5b9410
feat(mobile): paste the phone clipboard into the terminal (#9199)
lnieuwenhuis Sep 4, 2026
f034732
feat(web): show which sidebar threads hold an unsent draft (#9658)
t3dotgg Sep 4, 2026
01f3e50
fix(server): unblock OpenCode approvals and stop (#9653)
t3dotgg Sep 4, 2026
caa8a0d
fix(desktop): quit immediately on a second shortcut press (#9657)
t3dotgg Sep 4, 2026
560afff
fix(server): update Claude Agent SDK to 0.3.260 (#9135)
t3dotgg Sep 4, 2026
8ac5462
perf(server): stop loading message bodies for thread summaries (#9662)
t3dotgg Sep 4, 2026
cccd7e3
perf(web): speed up terminal snapshots (#9663)
t3dotgg Sep 4, 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
75 changes: 57 additions & 18 deletions apps/desktop/src/window/QuitHold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,24 +252,31 @@ describe("makeQuitShortcutHandler", () => {
expect(harness.notifications).toEqual([]);
});

it("honors a quick double press when both key releases beat their mode reads", async () => {
const resolvers: Array<(mode: QuitConfirmationMode) => void> = [];
const harness = makeHarness({
getMode: () => new Promise((resolve) => resolvers.push(resolve)),
});
await harness.send(makeInput({}));
await harness.send(makeInput({ type: "keyUp" }));
vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
await harness.send(makeInput({}));
await harness.send(makeInput({ type: "keyUp" }));

resolvers[1]?.("double-click");
await Promise.resolve();
await Promise.resolve();

expect(harness.quit).toHaveBeenCalledTimes(1);
expect(harness.notifications).toEqual([]);
});
it.each(["direct", "hold", "double-click"] as const)(
"quits on a quick second press without waiting for a pending %s mode read",
async (mode) => {
const resolvers: Array<(mode: QuitConfirmationMode) => void> = [];
const harness = makeHarness({
getMode: () => new Promise((resolve) => resolvers.push(resolve)),
});
await harness.send(makeInput({}));
await harness.send(makeInput({ type: "keyUp" }));
vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
await harness.send(makeInput({}));

expect(harness.quit).toHaveBeenCalledTimes(1);
expect(harness.notifications).toEqual([]);

await harness.send(makeInput({ type: "keyUp" }));

resolvers[0]?.(mode);
await Promise.resolve();
await Promise.resolve();

expect(harness.quit).toHaveBeenCalledTimes(1);
expect(harness.notifications).toEqual([]);
},
);

it("discards a stale mode resolution from a superseded press", async () => {
// Press #1's mode is still pending when the user releases and
Expand Down Expand Up @@ -378,6 +385,38 @@ describe("makeQuitShortcutHandler", () => {
expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
});

it("quits on a quick second press in hold mode when the first release is unseen", async () => {
const harness = makeHarness();
await harness.send(makeInput({}));
vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
await harness.send(makeInput({}));

expect(harness.concealWindow).not.toHaveBeenCalled();
expect(harness.quit).toHaveBeenCalledTimes(1);
expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
});

it("does not count auto-repeat as a second press", async () => {
const harness = makeHarness();
await harness.send(makeInput({}));
await harness.holdFor(QUIT_DOUBLE_PRESS_MS - 100);

expect(harness.quit).not.toHaveBeenCalled();
expect(harness.notifications).toEqual([HOLD_DOWN]);
});

it("does not count a released tap after another shortcut interrupts it", async () => {
const harness = makeHarness();
await harness.send(makeInput({}));
await harness.send(makeInput({ type: "keyUp" }));
await harness.send(makeInput({ key: "c" }));
vi.advanceTimersByTime(100);
await harness.send(makeInput({}));

expect(harness.quit).not.toHaveBeenCalled();
expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]);
});

it("cancels the hold when another key interrupts it", async () => {
const harness = makeHarness();
await harness.send(makeInput({}));
Expand Down
35 changes: 17 additions & 18 deletions apps/desktop/src/window/QuitHold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export const QUIT_DOUBLE_PRESS_MS = 500;
// tap release can go completely unseen and a release-based timer would quit
// anyway. Once held, quitting waits for Q keyUp or a quiet grace period after
// repeats stop so they cannot reach the next app. Keyboards with
// auto-repeat disabled fall back to the application menu Quit action.
// auto-repeat disabled must use a double press or the application menu Quit action.
// Supporting holds without repeats requires a native physical key-state check.
export const QUIT_HOLD_RELEASE_GRACE_MS = 600;
// A slow repeat rate can exceed the fixed grace. Waiting for two observed
// cadences keeps the timer behind the next repeat without slowing normal rates.
Expand Down Expand Up @@ -52,8 +53,8 @@ export function makeQuitShortcutHandler(
let lastRepeatAt = 0;
let repeatCadenceMs = 0;
// Incremented when a press is superseded or explicitly cancelled. A plain
// key release does not invalidate its pending mode read: direct mode and a
// completed second press must still be honored after that read settles.
// key release does not invalidate its pending mode read: a direct-mode
// press must still quit after that read settles.
let generation = 0;

const clearWatchdog = () => {
Expand All @@ -64,9 +65,9 @@ export function makeQuitShortcutHandler(
};

const release = (cancelPendingMode = true, keepDoublePressHint = false) => {
if (cancelPendingMode) generation += 1;
if (!holding && !notified) return;
const keepHint = keepDoublePressHint && mode === "double-click" && notified;
if (cancelPendingMode) generation += 1;
holding = false;
armed = false;
quitOnRelease = false;
Expand All @@ -85,6 +86,7 @@ export function makeQuitShortcutHandler(
// Dismisses any overlay first so a cancelled quit cannot leave a stale hint.
const quitNow = () => {
release();
lastPressAt = 0;
options.quit();
};

Expand Down Expand Up @@ -138,13 +140,10 @@ export function makeQuitShortcutHandler(
// quit shortcut, so it must not cancel an active double-press window.
if (key === modifierKey && !input.alt && !input.shift) return;

// Any other key (or an extra modifier) pressed mid-hold breaks the
// gesture; without this the hold timer keeps running through the
// interruption and the next qualifying repeat would quit early. The
// interrupted press also stops counting toward a double press, but only
// here, not in release(), which runs mid-restart on an unseen-release
// re-press and must not wipe that press's own tap timestamp.
if ((holding || notified) && !input.isAutoRepeat) {
// Other keys cancel the hold and the first tap, even after release.
// Keep this separate from release(), which also runs when a fresh Q
// keydown follows a keyUp that macOS did not deliver.
if (!input.isAutoRepeat) {
lastPressAt = 0;
release();
}
Expand All @@ -171,6 +170,13 @@ export function makeQuitShortcutHandler(
if (holding || notified) release();

generation += 1;
// Every mode accepts two presses. Quit before reading settings so a slow
// read cannot delay the second press. Repeats never reach this branch.
if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) {
quitNow();
return;
}

const pressGeneration = generation;
holding = true;
heldSince = now;
Expand All @@ -181,13 +187,6 @@ export function makeQuitShortcutHandler(
quitNow();
return;
}
// Keep a second press as an escape hatch when macOS misses the events
// that would complete a hold.
if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) {
quitNow();
return;
}

if (resolvedMode === "double-click") {
const remainingMs = QUIT_DOUBLE_PRESS_MS - (Date.now() - now);
if (remainingMs <= 0) {
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/modules/t3-markdown-text/android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
apply plugin: 'com.android.library'
apply plugin: 'org.jetbrains.kotlin.android'

group = 'com.t3tools.markdowntext'
version = '0.0.0'

android {
namespace 'expo.modules.t3markdowntext'
compileSdk rootProject.ext.compileSdkVersion

defaultConfig {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
}
}

dependencies {
implementation project(':expo-modules-core')
implementation 'com.facebook.react:react-android'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package expo.modules.t3markdowntext

import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.text.Spanned
import android.text.style.ReplacementSpan
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.widget.TextView
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.UIManagerHelper
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import kotlin.math.max
import kotlin.math.min

private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC"

private fun copyTextWithoutInlineImages(
text: CharSequence,
start: Int,
end: Int
): String {
if (text !is Spanned) return text.subSequence(start, end).toString()

return buildString {
for (index in start until end) {
val isInlineImage =
text[index].toString() == OBJECT_REPLACEMENT_CHARACTER &&
text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty()
if (!isInlineImage) append(text[index])
}
}
}

private class SanitizingSelectionActionModeCallback(
private val textView: TextView,
private val delegate: ActionMode.Callback?
) : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean =
delegate?.onCreateActionMode(mode, menu) ?: true

override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean =
delegate?.onPrepareActionMode(mode, menu) ?: false

override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
if (item.itemId == android.R.id.copy) {
val start = min(textView.selectionStart, textView.selectionEnd)
val end = max(textView.selectionStart, textView.selectionEnd)
if (start >= 0 && end > start) {
val originalText = textView.text.subSequence(start, end).toString()
val selectedText = copyTextWithoutInlineImages(textView.text, start, end)
if (selectedText != originalText) {
val clipboard =
textView.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(null, selectedText))
mode.finish()
return true
}
}
}
return delegate?.onActionItemClicked(mode, item) ?: false
}

override fun onDestroyActionMode(mode: ActionMode) {
delegate?.onDestroyActionMode(mode)
}
}

class T3MarkdownTextSelectionModule : Module() {
override fun definition() = ModuleDefinition {
Name("T3MarkdownTextSelection")

Function("installCopySanitizer") { reactTag: Int ->
val reactContext = appContext.reactContext as? ReactContext ?: return@Function
reactContext.runOnUiQueueThread {
val textView =
runCatching {
UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag)
}
.getOrNull() as? TextView ?: return@runOnUiQueueThread
val currentCallback = textView.customSelectionActionModeCallback
if (currentCallback is SanitizingSelectionActionModeCallback) {
return@runOnUiQueueThread
}
textView.customSelectionActionModeCallback =
SanitizingSelectionActionModeCallback(textView, currentCallback)
}
}
}
}
6 changes: 6 additions & 0 deletions apps/mobile/modules/t3-markdown-text/expo-module.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.t3markdowntext.T3MarkdownTextSelectionModule"]
}
}
3 changes: 3 additions & 0 deletions apps/mobile/modules/t3-markdown-text/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"private": true,
"source": "./index.ts",
"files": [
"android",
"assets",
"expo-module.config.json",
"ios",
"src",
"index.ts",
Expand All @@ -28,6 +30,7 @@
"peerDependencies": {
"@t3tools/client-runtime": "*",
"@t3tools/shared": "*",
"expo": "*",
"expo-asset": "*",
"expo-clipboard": "*",
"expo-haptics": "*",
Expand Down
17 changes: 12 additions & 5 deletions apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from "react";
import React, { type Ref } from "react";
import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native";
import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent";
import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent";
Expand Down Expand Up @@ -33,6 +33,7 @@ export type ContextMenuActionEvent = {
* while the React Native Text fallback reports measured `TextLayoutLine`s.
*/
export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & {
nativeTextRef?: Ref<RNText>;
uiTextView?: boolean;
contextMenuConfig?: string;
onContextMenuAction?: (event: ContextMenuActionEvent) => void;
Expand All @@ -45,7 +46,12 @@ export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & {
onSelectionChange?: (event: SelectionChangeEvent) => void;
};

function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPrimitiveProps) {
function MarkdownTextPrimitiveChild({
style,
children,
nativeTextRef: _nativeTextRef,
...rest
}: MarkdownTextPrimitiveProps) {
const [isAncestor, rootStyle] = useTextAncestorContext();

// Flatten the styles, and apply the root styles when needed
Expand Down Expand Up @@ -97,21 +103,22 @@ function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPr
return <>{nativeChildren}</>;
}

function MarkdownTextPrimitiveInner(props: MarkdownTextPrimitiveProps) {
function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPrimitiveProps) {
const [isAncestor] = useTextAncestorContext();

// Even if the uiTextView prop is set, we can still default to using
// normal selection (i.e. base RN text) if the text doesn't need to be
// selectable
if ((!props.selectable || !props.uiTextView) && !isAncestor) {
return <RNText {...props} />;
return <RNText ref={nativeTextRef} {...props} />;
}
return <MarkdownTextPrimitiveChild {...props} />;
}

export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) {
if (Platform.OS !== "ios") {
return <RNText {...props} />;
const { nativeTextRef, ...textProps } = props;
return <RNText ref={nativeTextRef} {...textProps} />;
}
return <MarkdownTextPrimitiveInner {...props} />;
}
Loading
Loading