diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 78a0156d1eaa1..cc922de448005 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -125,10 +125,16 @@ of `main` for that work, never the tag-pinned diagnosis clone. - Show the user the complete final issue text and get an explicit yes before posting. Never post without it. - Note at the end of the issue which model and agent produced it. -- If `gh` is not authenticated, offer `gh auth login`, or build a prefilled - https://github.com/pingdotgg/t3code/issues/new URL with title and body query - parameters; print the URL, and open it in their browser only after they - approve. +- Post with `gh issue create --repo pingdotgg/t3code --label via-triage` only + if `gh api repos/pingdotgg/t3code --jq .permissions.triage` prints `true`. +- Otherwise, or if `gh` is not authenticated, build a prefilled + https://github.com/pingdotgg/t3code/issues/new?template=via-triage.yml URL. + The form applies the label whoever submits it. Add `title` plus one + URL-encoded query parameter per form field id: `what-happened`, `diagnosis`, + `steps`, `version`, `environment`, `evidence`, `related`, `workaround` and + `agent`. Keep the full encoded URL below 6,000 characters by shortening any + fields as needed. Give the user any omitted text to paste into the form before + submitting. Print the URL, and open it in their browser only after they approve. - If the user pasted screenshots, remind them to drag the images into the issue after it is created; they cannot be attached from here. diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 507e013bf1c2a..6877f5c831a08 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -215,7 +215,7 @@ jobs: git config user.name "${APP_SLUG}[bot]" git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" git add apps/mobile/app.config.ts - git commit \ + git commit --no-verify \ -m "chore(mobile): bump app version to $RELEASE_VERSION" \ -m "Co-authored-by: codex " git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d89b7c7abe7a..c36d51bdadb1a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1388,7 +1388,7 @@ jobs: git config user.email "${{ steps.app_bot.outputs.email }}" git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml - git commit -m "chore(release): prepare $RELEASE_TAG" + git commit --no-verify -m "chore(release): prepare $RELEASE_TAG" git push origin HEAD:main announce_discord: diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5dfcec7d788cd..56b99f42996ab 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.1.5", + "version": "0.1.6", "private": true, "type": "module", "main": "dist-electron/boot.cjs", diff --git a/apps/desktop/resources/installer.nsh b/apps/desktop/resources/installer.nsh index 092f641c3359f..7f277fdcbf9c2 100644 --- a/apps/desktop/resources/installer.nsh +++ b/apps/desktop/resources/installer.nsh @@ -1,3 +1,35 @@ +!include "StrFunc.nsh" +!ifdef BUILD_UNINSTALLER + ${UnStrRep} + !define T3StrRep '${UnStrRep}' +!else + ${StrRep} + !define T3StrRep '${StrRep}' +!endif + +!macro customAppRunningMessages + Push $0 + Push $1 + ${If} $IsPowerShellAvailable == 0 + System::Call 'kernel32::SetEnvironmentVariable(t "T3CODE_INSTALL_DIR", t "$INSTDIR")' + nsExec::ExecToStack `"$PowerShellPath" -NoProfile -NonInteractive -C "Get-CimInstance -ClassName Win32_Process | Where-Object { $$_.ExecutablePath -and [IO.Path]::GetDirectoryName($$_.ExecutablePath) -eq $$env:T3CODE_INSTALL_DIR } | ForEach-Object { [IO.Path]::GetFileNameWithoutExtension($$_.ExecutablePath) } | Where-Object { $$_ -in 'T3 Code (Alpha)', 'T3 Code (Beta)', 'T3 Code (Nightly)' } | Select-Object -First 1 | ForEach-Object { [Console]::Write($$_) }"` + Pop $0 + Pop $1 + System::Call 'kernel32::SetEnvironmentVariable(t "T3CODE_INSTALL_DIR", p 0)' + ${If} $0 == 0 + ${If} $1 == "T3 Code (Alpha)" + ${OrIf} $1 == "T3 Code (Beta)" + ${OrIf} $1 == "T3 Code (Nightly)" + ${T3StrRep} $appRunningMessage $appRunningMessage "${PRODUCT_NAME}" $1 + ${T3StrRep} $appClosingMessage $appClosingMessage "${PRODUCT_NAME}" $1 + ${T3StrRep} $appCannotBeClosedMessage $appCannotBeClosedMessage "${PRODUCT_NAME}" $1 + ${EndIf} + ${EndIf} + ${EndIf} + Pop $1 + Pop $0 +!macroend + !macro customInstall !ifndef DO_NOT_CREATE_START_MENU_SHORTCUT ${IfNot} ${FileExists} "$newStartMenuLink" diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 2998c95d81821..41ac8abd37822 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -86,6 +86,12 @@ describe("previewWindowOpenAction", () => { ).toBe("popup"); }); + it("opens HTTP links in preview tabs", () => { + for (const disposition of ["foreground-tab", "background-tab"] as const) { + expect(PreviewManager.previewWindowOpenAction(details({ disposition }))).toBe("new-tab"); + } + }); + it("keeps a form POST with a body on the in-place path", () => { const postBody = { data: [], contentType: "application/x-www-form-urlencoded" }; expect( @@ -98,15 +104,32 @@ describe("previewWindowOpenAction", () => { it("does not hand a window to schemes that cannot be hardened", () => { for (const url of [ - "javascript:alert(1)", - "file:///etc/passwd", "vscode://vscode-remote/ssh-remote+box/tmp", - "not a url", + "blob:https://example.com/0b6c6a5e-55d4-4b4c-9d47-8f1b0c1d2e3f", ]) { expect(PreviewManager.previewWindowOpenAction(details({ url }))).toBe("navigate"); } }); + it("denies local and inline schemes that no page could open in place itself", () => { + for (const url of [ + "data:text/html,

hi

", + "DaTa:text/html,

hi

", + "file:///etc/passwd", + "javascript:alert(1)", + "view-source:https://example.com", + "chrome://gpu", + "about:srcdoc", + "devtools://devtools/bundled/inspector.html", + "filesystem:https://example.com/temporary/page.html", + "not a url", + ]) { + for (const disposition of ["new-window", "foreground-tab", "background-tab"] as const) { + expect(PreviewManager.previewWindowOpenAction(details({ url, disposition }))).toBe("deny"); + } + } + }); + it("denies a blank window instead of blanking the opener", () => { for (const url of ["about:blank", ""]) { for (const disposition of ["new-window", "foreground-tab"] as const) { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index b1f7a6d93914f..dc05f1a8c82e5 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -423,13 +423,17 @@ type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect { - try { - return POPUP_PROTOCOLS.has(new URL(rawUrl).protocol); - } catch { - return false; - } -}; +// Main-process loadURL bypasses Chromium's renderer-side restrictions on local and inline URLs. +const IN_PLACE_DENIED_PROTOCOLS = new Set([ + "about:", + "chrome:", + "data:", + "devtools:", + "file:", + "filesystem:", + "javascript:", + "view-source:", +]); /** Popups bypass will-attach-webview hardening and would inherit the picker's contextIsolation:false. */ const POPUP_WINDOW_OPTIONS = { @@ -440,15 +444,19 @@ const POPUP_WINDOW_OPTIONS = { }, } satisfies Electron.BrowserWindowConstructorOptions; -/** OAuth needs a surviving popup opener; unhardenable schemes and form POSTs must stay in place. */ +/** + * OAuth needs a surviving popup opener. `blob:`, app links (behind the session's `openExternal` + * prompt) and form POSTs stay in place. + */ export const previewWindowOpenAction = (details: { readonly url: string; readonly disposition: Electron.HandlerDetails["disposition"]; readonly postBody?: Electron.PostBody; }): "popup" | "new-tab" | "navigate" | "deny" => { + const protocol = URL.parse(details.url)?.protocol; // MSAL redirects on a denied blank popup; navigating the tab would destroy that fallback. - if (details.url === "" || details.url === "about:blank") return "deny"; - if (!isPopupUrl(details.url)) return "navigate"; + if (protocol === undefined || IN_PLACE_DENIED_PROTOCOLS.has(protocol)) return "deny"; + if (!POPUP_PROTOCOLS.has(protocol)) return "navigate"; if (details.disposition === "new-window") return "popup"; return !details.postBody && (details.disposition === "foreground-tab" || details.disposition === "background-tab") diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 16d345f97e4b6..0dcb2b85ac2c5 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -78,6 +78,12 @@ function makeFakeBrowserWindow() { zoomLevel = level; }), isLoadingMainFrame: vi.fn(() => false), + navigationHistory: { + canGoBack: vi.fn(() => true), + canGoForward: vi.fn(() => true), + goBack: vi.fn(), + goForward: vi.fn(), + }, on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { webContentsListeners.set(eventName, listener); }), @@ -136,6 +142,7 @@ function makeFakeBrowserWindow() { reload: webContents.reload, reloadIgnoringCache: webContents.reloadIgnoringCache, toggleDevTools: webContents.toggleDevTools, + navigationHistory: webContents.navigationHistory, send: webContents.send, setZoomLevel: webContents.setZoomLevel, setWindowButtonPosition: window.setWindowButtonPosition, @@ -934,6 +941,39 @@ describe("DesktopWindow", () => { }), ); + it.effect("maps macOS horizontal swipes to available history navigation", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + const swipe = fakeWindow.windowListeners.get("swipe"); + assert.isDefined(swipe); + if (!swipe) return; + const history = fakeWindow.navigationHistory; + + swipe({}, "left"); + assert.equal(history.goBack.mock.calls.length, 1); + assert.equal(history.goForward.mock.calls.length, 0); + swipe({}, "right"); + assert.equal(history.goForward.mock.calls.length, 1); + + swipe({}, "up"); + swipe({}, "down"); + history.canGoBack.mockReturnValue(false); + history.canGoForward.mockReturnValue(false); + swipe({}, "left"); + swipe({}, "right"); + assert.equal(history.goBack.mock.calls.length, 1); + assert.equal(history.goForward.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("persists the current main window bounds before the window closes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 0d1db93290ee4..10801089841d1 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -685,6 +685,12 @@ export const make = Effect.gen(function* () { }); if (environment.platform === "darwin") { + // Mouse drivers can deliver Back/Forward as macOS swipe gestures instead of buttons. + window.on("swipe", (_event, direction) => { + const history = window.webContents.navigationHistory; + if (direction === "left" && history.canGoBack()) history.goBack(); + else if (direction === "right" && history.canGoForward()) history.goForward(); + }); window.on("enter-full-screen", () => { window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); }); diff --git a/apps/mobile/app.config.test.ts b/apps/mobile/app.config.test.ts new file mode 100644 index 0000000000000..b8111f45587cf --- /dev/null +++ b/apps/mobile/app.config.test.ts @@ -0,0 +1,33 @@ +import { afterEach, expect, it, vi } from "vite-plus/test"; + +// Keep config parsing independent of local env files and build credentials. +vi.mock("../../scripts/lib/public-config.ts", () => ({ + loadRepoEnv: () => ({ APP_VARIANT: process.env.APP_VARIANT }), +})); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +async function loadConfig(variant: string | undefined) { + vi.resetModules(); + vi.stubEnv("APP_VARIANT", variant); + return (await import("./app.config.ts")).default; +} + +it.each(["prod", ""])("rejects APP_VARIANT=%j before selecting production IDs", async (value) => { + await expect(loadConfig(value)).rejects.toThrow( + "APP_VARIANT must be development, preview, or production.", + ); +}); + +it.each([ + [undefined, "com.t3tools.t3code"], + ["development", "com.t3tools.t3code.dev"], + ["preview", "com.t3tools.t3code.preview"], + ["production", "com.t3tools.t3code"], +])("keeps the iOS and Android identifiers for APP_VARIANT=%j", async (value, identifier) => { + const config = await loadConfig(value); + expect(config.ios?.bundleIdentifier).toBe(identifier); + expect(config.android?.package).toBe(identifier); +}); diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ea885f819984f..e6d388b9a9856 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -108,8 +108,10 @@ function resolveAppVariant(value: string | undefined): AppVariant { case "preview": case "production": return value; - default: + case undefined: return "production"; + default: + throw new Error("APP_VARIANT must be development, preview, or production."); } } diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json index 22fcb74a36cfb..5ff3009ef4d20 100644 --- a/apps/mobile/modules/t3-native-controls/expo-module.config.json +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -4,7 +4,8 @@ "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"], "appDelegateSubscribers": [ "T3WorkspaceOrientationSubscriber", - "T3MenuDismissTouchGateSubscriber" + "T3MenuDismissTouchGateSubscriber", + "T3BackgroundGraceSubscriber" ] }, "android": { diff --git a/apps/mobile/modules/t3-native-controls/ios/T3BackgroundGraceSubscriber.swift b/apps/mobile/modules/t3-native-controls/ios/T3BackgroundGraceSubscriber.swift new file mode 100644 index 0000000000000..62eb5d86ad12e --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3BackgroundGraceSubscriber.swift @@ -0,0 +1,23 @@ +import ExpoModulesCore +import UIKit + +public final class T3BackgroundGraceSubscriber: ExpoAppDelegateSubscriber { + private var task = UIBackgroundTaskIdentifier.invalid + + public func applicationWillResignActive(_ application: UIApplication) { + guard task == .invalid else { return } + task = application.beginBackgroundTask(withName: "T3 connection grace") { [weak self] in + self?.endTask(application) + } + } + + public func applicationDidBecomeActive(_ application: UIApplication) { + endTask(application) + } + + private func endTask(_ application: UIApplication) { + guard task != .invalid else { return } + application.endBackgroundTask(task) + task = .invalid + } +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 8cb7ad17e0d0b..801f1c1ee64e6 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -483,6 +483,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont ) postInvalidateOnAnimation() } else { + verticalScroller.forceFinished(true) canvasView.setVerticalOffset(target) } } diff --git a/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt index 10d9a45107c73..4a39467cbdc26 100644 --- a/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt +++ b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt @@ -22,6 +22,30 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(sdk = [35], manifest = Config.NONE) class ReviewDiffViewportTest { + @Test + fun immediateNavigationStopsAnActiveFling() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val view = T3ReviewDiffView(activity, AppContext()) + val scroller = view.javaClass.getDeclaredField("verticalScroller").apply { + isAccessible = true + }.get(view) as android.widget.OverScroller + try { + scroller.fling(0, 100, 0, 2000, 0, 0, 0, 10000) + assertTrue(!scroller.isFinished) + view.scrollToTop(false) + view.computeScroll() + val canvas = view.getChildAt(0) + val offset = canvas.javaClass.getDeclaredMethod("verticalOffset").apply { + isAccessible = true + }.invoke(canvas) + assertEquals(0, offset) + assertTrue(scroller.isFinished) + } finally { + view.cleanup() + activity.finish() + } + } + @Test fun resetDiscardsOldRowsAndPendingDecodeBeforePublishingTheNewDestination() { val activity = Robolectric.buildActivity(Activity::class.java).setup().get() diff --git a/apps/mobile/modules/t3-source-text/src/util.test.ts b/apps/mobile/modules/t3-source-text/src/util.test.ts new file mode 100644 index 0000000000000..c3af9a1f8ff85 --- /dev/null +++ b/apps/mobile/modules/t3-source-text/src/util.test.ts @@ -0,0 +1,15 @@ +import { expect, it, vi } from "vite-plus/test"; + +import { flattenStyles } from "./util"; + +vi.mock("react-native", () => ({ + I18nManager: { isRTL: false }, + StyleSheet: { + flatten: (styles: ReadonlyArray) => Object.assign({}, ...styles), + }, +})); + +it("encodes numeric bold weights as bold source-text runs", () => { + expect(flattenStyles({ fontWeight: 700 }, undefined).fontWeight).toBe("bold"); + expect(flattenStyles({ fontWeight: "400" }, { fontWeight: "700" }).fontWeight).toBe("bold"); +}); diff --git a/apps/mobile/modules/t3-source-text/src/util.ts b/apps/mobile/modules/t3-source-text/src/util.ts index 461fc7684b4b6..47f219bd3aad6 100644 --- a/apps/mobile/modules/t3-source-text/src/util.ts +++ b/apps/mobile/modules/t3-source-text/src/util.ts @@ -64,7 +64,7 @@ function fontWeightToNativeProp(fontWeight: TextStyle["fontWeight"]): NativeFont return "semibold"; case 700: case "700": - return "semibold"; + return "bold"; case 800: case "800": return "bold"; diff --git a/apps/mobile/modules/t3-terminal/android/build.gradle b/apps/mobile/modules/t3-terminal/android/build.gradle index ba79f390bd655..b7d24702b0a38 100644 --- a/apps/mobile/modules/t3-terminal/android/build.gradle +++ b/apps/mobile/modules/t3-terminal/android/build.gradle @@ -9,6 +9,10 @@ android { ndkVersion rootProject.ext.ndkVersion compileSdk rootProject.ext.compileSdkVersion + testOptions { + unitTests.includeAndroidResources = true + } + defaultConfig { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion @@ -37,4 +41,12 @@ android { dependencies { implementation project(':expo-modules-core') + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' +} + +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index 1deb3a6170350..5646ab8c5884b 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -271,21 +271,12 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS inputView.setPadding(0, 0, 0, 0) - inputView.setOnEditorActionListener { _, actionId, event -> - if (readOnly) return@setOnEditorActionListener true - val isKeyUp = event?.action == KeyEvent.ACTION_UP - val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp - val isHardwareEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER && - event.action == KeyEvent.ACTION_DOWN - val isEnter = isImeSend || isHardwareEnter - if (isEnter) { + inputView.setOnEditorActionListener( + TerminalEnterListener({ readOnly }) { // Enter must send CR: raw-mode TUIs treat LF as Ctrl+J (insert newline). onInput(mapOf("data" to "\r")) - true - } else { - false - } - } + }, + ) inputView.setOnKeyListener { _, keyCode, event -> if (readOnly) return@setOnKeyListener true if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalEnterListener.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalEnterListener.kt new file mode 100644 index 0000000000000..6eec4ba97ece2 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalEnterListener.kt @@ -0,0 +1,22 @@ +package expo.modules.t3terminal + +import android.view.KeyEvent +import android.view.inputmethod.EditorInfo +import android.widget.TextView + +internal class TerminalEnterListener( + private val isReadOnly: () -> Boolean, + private val sendEnter: () -> Unit +) : TextView.OnEditorActionListener { + override fun onEditorAction(view: TextView?, actionId: Int, event: KeyEvent?): Boolean { + if (isReadOnly()) return true + val isKeyUp = event?.action == KeyEvent.ACTION_UP + val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp + val isHardwareEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER || + event?.keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER + if (isImeSend || (isHardwareEnter && !isKeyUp)) sendEnter() + // An unconsumed Enter key-up makes a single-line TextView move focus to + // the next view, so the terminal stops receiving keys. + return isImeSend || isHardwareEnter + } +} diff --git a/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalEnterListenerTest.kt b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalEnterListenerTest.kt new file mode 100644 index 0000000000000..de04c7adfe4c0 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalEnterListenerTest.kt @@ -0,0 +1,51 @@ +package expo.modules.t3terminal + +import android.app.Activity +import android.view.KeyEvent +import android.view.View +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], manifest = Config.NONE) +class TerminalEnterListenerTest { + @Test + fun hardwareEnterKeepsFocusOnTheTerminalInput() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + var sent = 0 + val input = EditText(activity).apply { + setSingleLine(true) + isFocusableInTouchMode = true + setOnEditorActionListener(TerminalEnterListener({ false }) { sent += 1 }) + } + val nextControl = Button(activity).apply { isFocusableInTouchMode = true } + val root = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + addView(input) + addView(nextControl) + } + activity.setContentView(root) + // Focus search below the input only finds controls that have bounds. + root.measure( + View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY), + ) + root.layout(0, 0, 1080, 1920) + assertTrue(input.requestFocus()) + + for (keyCode in listOf(KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER)) { + input.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) + input.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode)) + assertTrue("focus left the input after key $keyCode", input.isFocused) + } + assertEquals(2, sent) + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f5c1c6521812e..a49c3856864f1 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -176,7 +176,8 @@ "@react-native-menu/menu", "expo-audio", "expo-video", - "expo-network" + "expo-network", + "expo-sharing" ] } }, diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts index 92512bd2c3e87..47aed622569f6 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts @@ -105,6 +105,56 @@ describe("extractAgentNotificationDeepLink", () => { }); describe("routeAgentNotificationResponseOnce", () => { + it("retries a failed initial response and deduplicates it after navigation succeeds", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const response = responseWithData({ deepLink: "/threads/env/thread" }) as NotificationResponse; + const handledResponseIds = new Set(); + const navigate = vi.fn().mockImplementationOnce(() => { + throw new Error("navigation not ready"); + }); + const clearLastResponse = vi.fn(() => Promise.resolve()); + const consume = () => + consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.resolve(response), + clearLastResponse, + handleResponse: (response) => + routeAgentNotificationResponseOnce({ handledResponseIds, response, navigate }), + }); + + await consume(); + expect(handledResponseIds.size).toBe(0); + expect(clearLastResponse).not.toHaveBeenCalled(); + + await consume(); + expect(navigate).toHaveBeenCalledTimes(2); + expect(navigate).toHaveBeenLastCalledWith("/threads/env/thread"); + expect(clearLastResponse).toHaveBeenCalledOnce(); + + routeAgentNotificationResponseOnce({ handledResponseIds, response, navigate }); + expect(navigate).toHaveBeenCalledTimes(2); + }); + + it("discards malformed destination ids without leaving the response pending", async () => { + const response = responseWithData({ + environmentId: "env", + threadId: "\ud800", + }) as NotificationResponse; + const handledResponseIds = new Set(); + const navigate = vi.fn(); + const clearLastResponse = vi.fn(() => Promise.resolve()); + + await consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.resolve(response), + clearLastResponse, + handleResponse: (response) => + routeAgentNotificationResponseOnce({ handledResponseIds, response, navigate }), + }); + + expect(navigate).not.toHaveBeenCalled(); + expect(clearLastResponse).toHaveBeenCalledOnce(); + expect(handledResponseIds.has("notification-1")).toBe(true); + }); + it("does not navigate twice when the initial and listener responses refer to one notification", () => { const handledResponseIds = new Set(); const navigations: Array = []; diff --git a/apps/mobile/src/features/agent-awareness/notificationPayload.ts b/apps/mobile/src/features/agent-awareness/notificationPayload.ts index a9dfe80369374..a86d1c5c14ba4 100644 --- a/apps/mobile/src/features/agent-awareness/notificationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/notificationPayload.ts @@ -41,7 +41,11 @@ function encodeThreadDeepLink(input: { if (input.environmentId.length === 0 || input.threadId.length === 0) { return null; } - return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`; + try { + return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`; + } catch { + return null; + } } function normalizeThreadDeepLink(value: string): string | null { @@ -104,11 +108,11 @@ export function routeAgentNotificationResponseOnce(input: { if (responseId && input.handledResponseIds.has(responseId)) { return; } - if (responseId) { - input.handledResponseIds.add(responseId); - } const deepLink = extractAgentNotificationDeepLink(input.response); if (deepLink) { input.navigate(deepLink); } + if (responseId) { + input.handledResponseIds.add(responseId); + } } diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index fe1401326ab56..c522da701e75c 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -10,7 +10,10 @@ import { useMemo, useState } from "react"; import { Platform, useWindowDimensions } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping"; +import { + pickableProjectGroups, + resolveScratchProjectScopeKey, +} from "@t3tools/client-runtime/state/project-grouping"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects } from "../../state/entities"; @@ -138,11 +141,13 @@ export function HomeRouteScreen() { ); const projectFilterOptions = useMemo( () => - buildHomeProjectScopes({ - projects, - environmentId: selectedEnvironmentId, - projectGroupingMode: listOptions.projectGroupingMode, - }).map((scope) => ({ + pickableProjectGroups( + buildHomeProjectScopes({ + projects, + environmentId: selectedEnvironmentId, + projectGroupingMode: listOptions.projectGroupingMode, + }), + ).map((scope) => ({ key: scope.key, label: scope.title, })), diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 192755ec435df..243e693d1b893 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -9,6 +9,7 @@ import { type EnvironmentProject, type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; @@ -376,7 +377,11 @@ export function HomeScreen(props: HomeScreenProps) { })(); const v2ProjectScopeKey = props.selectedProjectKey; - const v2ScopedProjectGroup = findHomeProjectScope(projectScopes, v2ProjectScopeKey); + // An archived project's filter reads as all projects, matching the filter menu. + const v2ScopedProjectGroup = findHomeProjectScope( + pickableProjectGroups(projectScopes), + v2ProjectScopeKey, + ); const threadSearch = useThreadSearch( searchEnvironmentIds, props.searchQuery, @@ -711,7 +716,10 @@ export function HomeScreen(props: HomeScreenProps) { ({ key: scope.key, label: scope.title }))} + projects={pickableProjectGroups(projectScopes).map((scope) => ({ + key: scope.key, + label: scope.title, + }))} selectedEnvironmentId={props.selectedEnvironmentId} selectedProjectKey={props.selectedProjectKey} onEnvironmentChange={props.onEnvironmentChange} diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 82eab6b384b9c..1b2e1a8c541f7 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -27,6 +27,8 @@ export interface HomeProjectScope { readonly representative: EnvironmentProject; readonly projects: ReadonlyArray; readonly projectRefs: ReadonlyArray; + readonly pinned: boolean; + readonly archived: boolean; } function getProjectSortTimestamp( @@ -62,6 +64,8 @@ export function buildHomeProjectScopes(input: { representative: group.representative, projects: group.members.map((member) => member.project), projectRefs: group.memberProjectRefs, + pinned: group.pinned, + archived: group.archived, }; }); } diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 88987819ddf00..841eaae7c6dc2 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -8,7 +8,7 @@ import { type EnvironmentThreadSearchMatch, type ShownOrder, } from "@t3tools/client-runtime/state/thread-search"; -import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; +import { isListedThreadLineage, THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; import { foldSearchQuery, PALETTE_MESSAGE_LIMIT, @@ -401,7 +401,11 @@ export function CommandPalette(props: { })), ); } - const projectItems: CommandPaletteItem[] = projects.map((project) => ({ + const openProjects = projects.filter((project) => project.archivedAt == null); + const projectItems: CommandPaletteItem[] = [ + ...openProjects.filter((project) => project.pinnedAt != null), + ...openProjects.filter((project) => project.pinnedAt == null), + ].map((project) => ({ key: `project:${scopedProjectKey(project.environmentId, project.id)}`, kind: "project", project, @@ -422,10 +426,7 @@ export function CommandPalette(props: { }), })); const threadItems: CommandPaletteItem[] = threads - .filter( - (thread) => - thread.archivedAt === null && thread.lineage.relationshipToParent !== "subagent", - ) + .filter((thread) => thread.archivedAt === null && isListedThreadLineage(thread.lineage)) .map((thread) => { const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)); const environment = diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 218d01041561d..98f6a909899a9 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -713,6 +713,9 @@ function useCreateProject(environment: EnvironmentOption | null) { const openNewTaskDraft = useOpenNewTaskDraft(); const navigation = useNavigation(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const updateProject = useAtomCommand(projectEnvironment.update, { + label: "project unarchive", + }); const projects = useProjects(); return async (workspaceRoot: string, originRoute = getActiveRoute(navigation)) => { @@ -734,7 +737,19 @@ function useCreateProject(environment: EnvironmentOption | null) { path: workspaceRoot, }); if (existing) { - Dialog.alert("Project already exists", existing.title); + // Adding an archived project's folder again brings the project back. + const unarchived = + existing.archivedAt == null || + ( + await updateProject({ + environmentId: existing.environmentId, + input: { projectId: existing.id, archived: false }, + }) + )._tag === "Success"; + Dialog.alert( + "Project already exists", + unarchived ? existing.title : `${existing.title} is still archived. Try again later.`, + ); openNewTaskDraft( { environmentId: existing.environmentId, diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 9f66f5f337b92..4e55ed4ef651b 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -873,19 +873,25 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps - + {/* UIKit draws the tapped control's glass lens outside the hidden + tab's opacity, so only the visible tab mounts the control. */} + {Platform.OS !== "ios" || section === tab ? ( + + ) : ( + + )} ) : null} diff --git a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx index b2f51a9bd0abb..cbb751c74c4c7 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx @@ -4,6 +4,7 @@ import { Platform, View } from "react-native"; import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; import { AppText as Text } from "../../components/ui/AppText"; import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls"; +import { pullRequestLayerParams } from "./pullRequestStack"; import { pullRequestStatus } from "./pullRequestStatus"; import type { PullRequestParams } from "./usePullRequestDiff"; import type { PullRequestStackState } from "./usePullRequestStack"; @@ -66,7 +67,7 @@ export function PullRequestStackCard(props: { selected ? undefined : () => { - const params = { ...props.params, number: layer.number }; + const params = pullRequestLayerParams(props.params, layer.number); if (Platform.OS === "android") selectDetail({ name: "PullRequest", params }); else navigation.dispatch(StackActions.push("PullRequest", params)); diff --git a/apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx b/apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx index 6b4eef34abd60..8a35fb7b2ee45 100644 --- a/apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx +++ b/apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx @@ -39,18 +39,15 @@ export function GuideNotice({ state }: { readonly state: ReturnType {notice.description} - + {state.canWrite || busy || notice.kind === "unavailable" ? ( - - + + ); +} diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index f1b5520d4216a..ce187f5db5e96 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -91,6 +91,7 @@ export function ReopenClosedViewShortcut() { environmentKnown: true, catalogReady: true, ownerExists: true, + sideChatExists: true, shellLive: true, panel, }; @@ -99,6 +100,10 @@ export function ReopenClosedViewShortcut() { environmentKnown: catalog.entries.has(ref.environmentId), catalogReady: catalog.isReady, ownerExists: readThreadShell(ref) !== null || drafts.getDraftThreadByRef(ref) !== null, + sideChatExists: + entry.kind !== "panel-tab" || + entry.surface.kind !== "side-chat" || + readThreadShell(scopeThreadRef(ref.environmentId, entry.surface.threadId)) !== null, shellLive: appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live", panel, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index d74e03ea842ab..596bdee96ad1f 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,15 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + threadRuntimeIsActive, + type EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { + useProjects, + useQuietThreadShell, + useServerConfigs, + useThreadShells, +} from "~/state/entities"; import { threadPullRequestKeysEqual, visibleThreadPullRequests, @@ -12,6 +21,7 @@ import type { ProjectId, PullRequestState, ResolvedKeybindingsConfig, + ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { @@ -33,6 +43,7 @@ import { FileDiff, Files, Globe2, + MessagesSquare, Plus, TerminalSquare, } from "lucide-react"; @@ -130,6 +141,8 @@ export interface RightPanelTabsProps { onAddPullRequest: () => void; onAddPullRequests: () => void; onAddDevice: () => void; + /** Absent where side chats are not offered. */ + onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; @@ -137,6 +150,9 @@ export interface RightPanelTabsProps { pullRequestAvailable: boolean; pullRequestsAvailable: boolean; deviceAvailable: boolean; + sideChatAvailable?: boolean; + /** Overrides the default reason while side chats are available but cannot start. */ + sideChatDisabledReason?: string | null; pullRequestStatusSeeds?: Readonly>; children: ReactNode; } @@ -163,6 +179,7 @@ const SURFACE_DISABLED_REASONS = { pullRequest: "This thread's branch has no pull request yet.", pullRequests: "No linked pull requests are available for this thread.", device: "Devices are only available from a thread.", + sideChat: "Side chats start after the first message.", } as const; // Closed keepMounted popups must stop blocking shortcuts once their exit transition ends. @@ -187,6 +204,7 @@ const SURFACE_UNAVAILABLE_HINTS = { pullRequest: "No pull request on this branch yet.", pullRequests: "No linked pull requests available.", device: "Available from a thread.", + sideChat: "Available after the first message.", } as const; type TabContextMenuAction = @@ -304,6 +322,8 @@ function RightPanelEmptyState(props: { onAddPullRequest: () => void; onAddPullRequests: () => void; onAddDevice: () => void; + /** Absent where side chats are not offered. */ + onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; @@ -311,6 +331,8 @@ function RightPanelEmptyState(props: { pullRequestAvailable: boolean; pullRequestsAvailable: boolean; deviceAvailable: boolean; + sideChatAvailable: boolean; + sideChatDisabledReason: string | null; }) { const [highlight, setHighlight] = useState(-1); @@ -372,6 +394,19 @@ function RightPanelEmptyState(props: { disabledReason: SURFACE_UNAVAILABLE_HINTS.device, onClick: props.onAddDevice, }, + ...(props.onAddSideChat + ? [ + { + label: "Side chat", + description: "Ask a side question while this thread keeps working.", + icon: MessagesSquare, + shortcut: "S", + available: props.sideChatAvailable && !props.sideChatDisabledReason, + disabledReason: props.sideChatDisabledReason ?? SURFACE_UNAVAILABLE_HINTS.sideChat, + onClick: props.onAddSideChat, + }, + ] + : []), ] as const; type SurfaceAction = (typeof actions)[number]; @@ -557,6 +592,8 @@ function surfaceTitle( return "Pull requests"; case "device": return surface.title ?? surface.target?.name ?? "Device"; + case "side-chat": + return "Side chat"; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -645,6 +682,8 @@ function SurfaceIcon({ ); case "pull-requests": return ; + case "side-chat": + return ; case "device": return surface.target?.platform === "ios" ? ( @@ -656,6 +695,41 @@ function SurfaceIcon({ } } +/** What a side chat tab's dot shows and its title's accessible name says. */ +function sideChatTabState(shell: EnvironmentThreadShell | null) { + if (shell === null) return null; + if (shell.hasPendingApprovals || shell.pendingSubagentRequest === "approval") { + return "needs approval"; + } + if (shell.hasPendingUserInput || shell.pendingSubagentRequest === "user_input") { + return "needs input"; + } + return threadRuntimeIsActive(shell.runtime) ? "running" : null; +} + +// Static dots: a side chat tab can stay open for a whole run. +function SideChatTabIcon(props: { environmentId: EnvironmentId | null; threadId: ThreadId }) { + const state = sideChatTabState( + useQuietThreadShell( + props.environmentId === null ? null : scopeThreadRef(props.environmentId, props.threadId), + ), + ); + return ( + <> + + {state === null ? null : ( + + )} + + ); +} + export function resolvePullRequestTabLink( threads: readonly Pick[], environmentId: EnvironmentId | null, @@ -813,7 +887,6 @@ function RightPanelTab( ) { const { surface, - title, audio, audioRuntimeTabId, onActivate, @@ -827,6 +900,13 @@ function RightPanelTab( id: surface.id, disabled: props.dragDisabled, }); + const sideChatShell = useQuietThreadShell( + surface.kind === "side-chat" && props.environmentId !== null + ? scopeThreadRef(props.environmentId, surface.threadId) + : null, + ); + const title = sideChatShell?.title ?? props.title; + const sideChatState = sideChatTabState(sideChatShell); const content = ( <> onClose(surface)}> @@ -896,6 +976,7 @@ function RightPanelTab( if (surface.kind === "device" && props.canRename) onStartRename(surface.id); }} className="cursor-pointer flex min-w-0 items-center" + aria-label={sideChatState === null ? undefined : `${title}, ${sideChatState}`} onClick={() => onActivate(surface)} > {title} @@ -1066,6 +1147,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.device, onClick: props.onAddDevice, }, + ...(props.onAddSideChat + ? [ + { + label: "Side chat", + icon: MessagesSquare, + shortcut: "S", + available: props.sideChatAvailable === true && !props.sideChatDisabledReason, + disabledReason: props.sideChatDisabledReason ?? SURFACE_DISABLED_REASONS.sideChat, + onClick: props.onAddSideChat, + }, + ] + : []), ] as const; const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { @@ -1440,6 +1533,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddPullRequest={props.onAddPullRequest} onAddPullRequests={props.onAddPullRequests} onAddDevice={props.onAddDevice} + onAddSideChat={props.onAddSideChat} browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} diffAvailable={props.diffAvailable} @@ -1447,6 +1541,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) { pullRequestAvailable={props.pullRequestAvailable} pullRequestsAvailable={props.pullRequestsAvailable} deviceAvailable={props.deviceAvailable} + sideChatAvailable={props.sideChatAvailable === true} + sideChatDisabledReason={props.sideChatDisabledReason ?? null} /> ) : ( props.children diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 68d8577690643..f6e66a1ac6506 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { + shouldCreateNewThreadInCurrentProject, archiveSelectedThreadEntries, deleteSelectedThreadEntries, getFallbackThreadIdAfterDelete, @@ -1630,6 +1631,12 @@ describe("groupSidebarThreadsByProject", () => { projectKey: "fleet", memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: fleet }], }, + { + projectKey: "notes", + memberProjectRefs: [ + { environmentId: localEnvironmentId, projectId: ProjectId.make("notes") }, + ], + }, ]; const threads = [ @@ -1637,17 +1644,23 @@ describe("groupSidebarThreadsByProject", () => { thread("orphan", localEnvironmentId, ProjectId.make("deleted")), thread("b", remoteEnvironmentId, t3codeRemote), ]; - const summarize = (keepEmpty: boolean) => + const summarize = (keepEmpty: (group: (typeof groups)[number]) => boolean) => groupSidebarThreadsByProject(groups, threads, keepEmpty).map((folder) => ({ projectKey: folder.group.projectKey, threads: folder.threads.map((entry) => entry.id), })); - expect(summarize(true)).toEqual([ + expect(summarize(() => true)).toEqual([ + { projectKey: "t3code", threads: ["a", "b"] }, + { projectKey: "fleet", threads: [] }, + { projectKey: "notes", threads: [] }, + ]); + expect(summarize(() => false)).toEqual([{ projectKey: "t3code", threads: ["a", "b"] }]); + // A pinned project keeps its empty folder while other empty projects drop out. + expect(summarize((group) => group.projectKey === "fleet")).toEqual([ { projectKey: "t3code", threads: ["a", "b"] }, { projectKey: "fleet", threads: [] }, ]); - expect(summarize(false)).toEqual([{ projectKey: "t3code", threads: ["a", "b"] }]); }); }); @@ -1687,3 +1700,13 @@ describe("resolveSidebarFolderDot", () => { expect(resolveSidebarFolderDot([unread, limited])).toBe("attention"); }); }); + +describe("shouldCreateNewThreadInCurrentProject", () => { + it("opens the picker when several projects are open, or only archived ones exist", () => { + expect(shouldCreateNewThreadInCurrentProject(false, 2, 3)).toBe(false); + expect(shouldCreateNewThreadInCurrentProject(false, 0, 2)).toBe(false); + expect(shouldCreateNewThreadInCurrentProject(false, 1, 3)).toBe(true); + expect(shouldCreateNewThreadInCurrentProject(false, 0, 0)).toBe(true); + expect(shouldCreateNewThreadInCurrentProject(true, 2, 2)).toBe(true); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 43c87f530e454..730a3508bb8cc 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -16,7 +16,12 @@ import { type ThreadSearchShell, } from "@t3tools/client-runtime/state/thread-search"; import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch"; -import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + isListedThreadLineage, + type ContextMenuItem, + type EnvironmentId, + type ThreadId, +} from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/reactivity"; import { @@ -590,12 +595,12 @@ export async function archiveSelectedThreadEntries< return { archivedThreadKeys, mutationFailure: null, followupFailures }; } -export function isSidebarSubagentThread(thread: Pick): boolean { - return thread.lineage.relationshipToParent === "subagent"; +export function isSidebarHiddenThread(thread: Pick): boolean { + return !isListedThreadLineage(thread.lineage); } -// Subagent threads have no sidebar row, so an open subagent is represented by -// its nearest non-subagent ancestor. A broken chain (missing parent, cycle, +// Subagent and side-chat threads have no sidebar row, so an open one is represented by +// its nearest listed ancestor. A broken chain (missing parent, cycle, // other environment) resolves to the thread itself, which then matches no row. export function resolveSidebarRowThreadId( threadId: ThreadId, @@ -606,7 +611,7 @@ export function resolveSidebarRowThreadId( threads.find((thread) => thread.id === id && thread.environmentId === environmentId); const visited = new Set(); let thread = findThread(threadId); - while (thread !== undefined && isSidebarSubagentThread(thread)) { + while (thread !== undefined && isSidebarHiddenThread(thread)) { if (thread.lineage.parentThreadId === null || visited.has(thread.id)) return threadId; visited.add(thread.id); thread = findThread(thread.lineage.parentThreadId); @@ -777,11 +782,13 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { return nodeClosest(parent, "a[href]") !== null; } +/** The "New thread in" picker opens unless there is one open project or none; then the current context decides. */ export function shouldCreateNewThreadInCurrentProject( shiftKey: boolean, + openProjectGroupCount: number, projectGroupCount: number, ): boolean { - return shiftKey || projectGroupCount <= 1; + return shiftKey || openProjectGroupCount === 1 || projectGroupCount === 0; } export function orderItemsByPreferredIds(input: { @@ -968,7 +975,7 @@ export function sidebarFolderExpansionKeys(group: { /** * Boring sidebar folders. Folders follow `groups` order and threads keep their - * input order. A project with no threads gets a folder only with `keepEmpty`. + * input order. A project with no threads gets a folder only when `keepEmpty` says so. * A thread whose project is in no group has no folder and is left out. */ export function groupSidebarThreadsByProject< @@ -982,7 +989,7 @@ export function groupSidebarThreadsByProject< >( groups: readonly TGroup[], threads: readonly TThread[], - keepEmpty: boolean, + keepEmpty: (group: TGroup) => boolean, ): Array<{ group: TGroup; threads: TThread[] }> { const folders = groups.map((group) => ({ group, threads: [] as TThread[] })); const folderByProject = new Map(); @@ -994,7 +1001,7 @@ export function groupSidebarThreadsByProject< for (const thread of threads) { folderByProject.get(`${thread.environmentId}:${thread.projectId}`)?.threads.push(thread); } - return keepEmpty ? folders : folders.filter((folder) => folder.threads.length > 0); + return folders.filter((folder) => folder.threads.length > 0 || keepEmpty(folder.group)); } export type SidebarV2TopStatusKind = diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3c09b32bd2db3..e6f23149dfedb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -103,7 +103,10 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { isMacPlatform } from "~/lib/utils"; import { readLocalApi } from "../localApi"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; -import { selectProjectGroupingSettings } from "@t3tools/client-runtime/state/project-grouping"; +import { + pickableProjectGroups, + selectProjectGroupingSettings, +} from "@t3tools/client-runtime/state/project-grouping"; import { projectGroupsSpanEnvironments, resolveSidebarProjectScopeKeys, @@ -121,6 +124,8 @@ import { useThreadActions, } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useOrderWhileOpen } from "../hooks/useOrderWhileOpen"; +import { useProjectPin } from "../hooks/useProjectGroupUpdate"; import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; @@ -230,6 +235,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { copyThreadReference } from "../lib/copyThreadReference"; import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { Button } from "./ui/button"; +import { isProjectPinShortcut, ProjectPinButton } from "./ProjectPinButton"; import { Combobox, ComboboxEmpty, @@ -408,8 +414,8 @@ function useSidebarThreads(input: { readonly routeRowThreadKey: string | null; // Set in boring mode: the project groups to fold active threads into, in order. readonly folderGroups: readonly SidebarProjectSnapshot[] | null; - // Keeps folders with no active threads, so a project the user filtered to can start one. - readonly keepEmptyFolders: boolean; + // Keeps folders with no active threads, so a project the user filtered to or pinned can start one. + readonly keepEmptyFolders: (group: SidebarProjectSnapshot) => boolean; }) { const { projectScopeKey, @@ -811,7 +817,10 @@ function useSidebarLookups() { ); const projectScopeItems = [ { value: "all", label: "All projects" }, - ...projectGroups.map((project) => ({ value: project.projectKey, label: project.displayName })), + ...pickableProjectGroups(projectGroups).map((project) => ({ + value: project.projectKey, + label: project.displayName, + })), ]; return { projects, @@ -886,7 +895,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve projectByKey, projectDisplayNameByKey, projectGroupByScopeKey, - projectScopeItems, + projectScopeItems: lookupProjectScopeItems, } = useSidebarLookups(); const delegatedUnderParent = useDelegatedUnderParent(); const router = useRouter(); @@ -1054,13 +1063,18 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }; // A scope whose project is gone reads as all projects once every snapshot has // loaded. The stored keys are left alone, so the scope returns with its project. + // An archived project's scope reads as all projects, matching the menu. + const scopeGroups = pickableProjectGroups(projectGroups); + const archivedProjectKeys = new Set( + projectGroups.filter((project) => project.archived).map((project) => project.projectKey), + ); const chosenProjectScopeKeys = resolveSidebarProjectScopeKeys({ groups: projectGroups, keys: persistedProjectScopeKeys, settings: projectGroupingSettings, canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady, - }); - const chosenProjectGroups = projectGroups.filter((project) => + }).filter((key) => !archivedProjectKeys.has(key)); + const chosenProjectGroups = scopeGroups.filter((project) => chosenProjectScopeKeys.includes(project.projectKey), ); const chosenScopeProjectKeys = @@ -1134,6 +1148,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } return environmentLabelById.get(thread.environmentId) ?? null; }; + // Pinning from a row keeps rows in place while the menu is open. + const projectScopeItems = useOrderWhileOpen( + lookupProjectScopeItems, + (item) => item.value, + projectScopeMenuState.open, + ); const selectedProjectScopeItem = projectScopeKeys.length > 1 ? null @@ -1194,6 +1214,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }); }, ); + const { + canPinProjectGroup: canPinProject, + setProjectGroupPinned, + toggleProjectGroupPin, + } = useProjectPin(); const handleProjectSettings = useStableCallback( ( event: ReactMouseEvent | ReactKeyboardEvent, @@ -1220,6 +1245,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve [ { id: "new-thread", label: "New thread" }, { id: "copy-path", label: "Copy path" }, + ...(canPinProject(group) + ? [ + group.pinned + ? { id: "unpin", label: "Unpin project", icon: "pin-off" } + : { id: "pin", label: "Pin project", icon: "pin" }, + ] + : []), { id: "project-settings", label: "Project settings", icon: "settings" }, ], position, @@ -1233,6 +1265,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve case "copy-path": copyPathToClipboard(group.workspaceRoot, { path: group.workspaceRoot }); return; + case "pin": + case "unpin": + setProjectGroupPinned(group, clicked.value === "pin"); + return; case "project-settings": openProjectSettings(group); return; @@ -1288,10 +1324,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve routeRowThreadKey, folderGroups: boringSidebarEnabled ? scopedProjectGroups.length === 0 - ? projectGroups + ? // An archived project keeps a folder for its active threads, below the rest. + [...scopeGroups, ...projectGroups.filter((project) => project.archived)] : scopedProjectGroups : null, - keepEmptyFolders: scopedProjectGroups.length > 0, + keepEmptyFolders: (group) => + scopedProjectGroups.length > 0 || (group.pinned && !group.archived), }); // Ranked across environments as the palette ranks them, so one @@ -1862,7 +1900,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); return { kind: "thread", key, section }; }); - // Boring mode keeps a project the user filtered to, so it can start a thread. + // Boring mode keeps a pinned open project or one the user filtered to, so it can start a thread. if ( !hasProjectFolders && pinnedThreads.length + @@ -2552,12 +2590,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve AuthOrchestrationOperateScope, ), branch: thread.branch ?? null, - projectFilter: threadProjectGroup - ? { - label: threadProjectGroup.displayName, - isActive: projectScopeKeys.includes(threadProjectGroup.projectKey), - } - : null, + // An archived project is not a filter choice. + projectFilter: + threadProjectGroup && !threadProjectGroup.archived + ? { + label: threadProjectGroup.displayName, + isActive: projectScopeKeys.includes(threadProjectGroup.projectKey), + } + : null, isPinned, isSettled, autoSettleEnabled: thread.autoSettleDisabledAt == null, @@ -2875,14 +2915,21 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const handleNewThreadClick = (event?: ReactMouseEvent) => { if ( boringSidebarEnabled || - shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length) + shouldCreateNewThreadInCurrentProject( + event?.shiftKey ?? false, + scopeGroups.length, + projectGroups.length, + ) ) { if (isMobile) setOpenMobile(false); + // With no current or open project, such as when every project is archived, pick one. void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, activeThread: newThreadContext.activeThread ?? undefined, defaultProjectRef: newThreadContext.defaultProjectRef, handleNewThread, + }).then((started) => { + if (!started) openCommandPalette({ open: "new-thread-in" }); }); return; } @@ -2892,7 +2939,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new") ?? - (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); + (shouldCreateNewThreadInCurrentProject(false, scopeGroups.length, projectGroups.length) + ? shortcutLabelForCommand(keybindings, "chat.newLocal") + : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -2984,15 +3033,24 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if ( event.defaultPrevented || event.nativeEvent.isComposing || - event.keyCode === 229 || - event.ctrlKey || - event.altKey || - event.metaKey + event.keyCode === 229 ) { return; } const scopeKey = highlightedProjectScopeKeyRef.current; const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null; + // The thread pin shortcut pins the highlighted project here. + if ( + project && + canPinProject(project) && + isProjectPinShortcut(event, keybindings) + ) { + event.preventDefault(); + event.stopPropagation(); + toggleProjectGroupPin(project); + return; + } + if (event.ctrlKey || event.altKey || event.metaKey) return; if (event.shiftKey && event.key === "Enter") { if (!project) return; event.preventDefault(); @@ -3048,6 +3106,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve machineByEnvironmentId={environmentMachineById} /> ) : null} + {project && canPinProject(project) ? ( + toggleProjectGroupPin(project)} + /> + ) : null} {project ? ( ; + return ( + + }>{content} + + {props.description} + + + ); +} + +function moveBetweenRows(event: KeyboardEvent) { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + const rows = Array.from( + event.currentTarget.querySelectorAll("[data-effort-row]:not(:disabled)"), + ); + const index = rows.findIndex((row) => row === document.activeElement); + const next = + rows[Math.min(rows.length - 1, Math.max(0, index + (event.key === "ArrowDown" ? 1 : -1)))]; + if (!next) return; + event.preventDefault(); + event.stopPropagation(); + next.focus(); + next.scrollIntoView({ block: "nearest" }); +} + +function EffortLevels(props: { traits: TraitsSelectionInput }) { + const traits = useTraitsSelection(props.traits); + const { effort, others } = columnControls( + props.traits.provider, + traits.selectDescriptors, + traits.booleanDescriptors, + ); + if (traits.modelIsUnavailable) { + return ( + <> + {traits.descriptors.map((descriptor) => { + const value = traits.currentLabel(descriptor); + return value ? ( +
+
{descriptor.label}
+
{value}
+
+ ) : null; + })} + + ); + } + if (!effort && others.length === 0) return ; + const renderControl = (control: ColumnControl) => { + const isEffort = control === effort; + const { descriptor } = control; + if (control.kind === "boolean") { + return ( +
+ {isEffort ? null :
{descriptor.label}
} + {([true, false] as const).map((value) => ( + traits.changeBoolean(descriptor.id, value)} + /> + ))} +
+ ); + } + const locked = + descriptor.id === traits.primarySelectDescriptor?.id && traits.ultrathinkInBodyText; + const value = traits.selectValue(control.descriptor); + return ( +
+ {isEffort ? null :
{descriptor.label}
} + {locked ? ( +
+ {ULTRATHINK_IN_BODY_NOTE} +
+ ) : null} + {selectableProviderOptionChoices(control.descriptor).map((option) => ( + traits.changeSelect(control.descriptor, option.id)} + /> + ))} +
+ ); + }; + return ( + <> + {effort ? renderControl(effort) : null} + {others.map(renderControl)} + + ); +} + +function NoEffortRow() { + return ( +
No effort levels
+ ); +} + +/** The model picker's effort column: the selected model's levels and its other options. */ +export function ComposerEffortColumn(props: { traits: TraitsSelectionInput | null }) { + const picker = use(DockedPickerContext); + const visibility = props.traits ? getTraitsSectionVisibility(props.traits) : null; + const effort = + props.traits && visibility + ? columnControls( + props.traits.provider, + visibility.selectDescriptors, + visibility.booleanDescriptors, + ).effort + : null; + return ( + <> + {/* Matches the search row's box, so one rule runs across the top of the popup. */} +
+
+ {effort?.descriptor.label ?? "Effort"} + + {picker.modelLabel} + +
+
+ {/* pt-2.5 puts the first level on the same line as the first model. */} +
+ {props.traits ? : } +
+ + ); +} + +const FOOTER_SEGMENT = + "flex h-6 cursor-pointer items-center justify-center gap-1 px-2 text-muted-foreground text-xs outline-none first:rounded-l-md last:rounded-r-md hover:text-foreground data-checked:bg-input/72 data-checked:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"; + +function SpeedSegment(props: { + checked: boolean; + label: string; + description?: string | undefined; + onPick: () => void; +}) { + const buttonProps = { + type: "button", + role: "radio", + "aria-checked": props.checked, + "data-checked": props.checked || undefined, + className: FOOTER_SEGMENT, + onClick: props.onPick, + } as const; + const content = ( + <> + {props.label === "Fast" || props.label === "Ultrafast" ? ( + + ) : null} + {props.label} + + ); + if (!props.description) return ; + return ( + + }>{content} + {props.description} + + ); +} + +function SpeedControl(props: { traits: TraitsSelectionInput }) { + const traits = useTraitsSelection(props.traits); + const { speedSelect, fastToggle } = splitSpeed( + props.traits.provider, + traits.selectDescriptors, + traits.booleanDescriptors, + ); + if (traits.modelIsUnavailable || (!speedSelect && !fastToggle)) return null; + return ( +
+ {speedSelect + ? selectableProviderOptionChoices(speedSelect).map((option) => ( + traits.changeSelect(speedSelect, option.id)} + /> + )) + : fastToggle + ? ([false, true] as const).map((value) => ( + traits.changeBoolean(fastToggle.id, value)} + /> + )) + : null} +
+ ); +} + +/** Permissions change rarely, so the footer names the mode and nests the choice in a menu. */ +function PermissionsMenu(props: { + runtimeMode: RuntimeMode; + options: ReadonlyArray; + onChange: (mode: RuntimeMode) => void; +}) { + const triggerRef = useRef(null); + const current = props.options.find((option) => option.mode === props.runtimeMode); + if (!current) return null; + const CurrentIcon = current.icon; + return ( + { + if (open) focusCheckedPermission(triggerRef.current); + }} + > + + } + > + + + + + + ); +} + +/** The model picker's footer: speed stays one click away, permissions sit one menu deeper. */ +export function ComposerPickerFooter(props: { + traits: TraitsSelectionInput | null; + runtimeMode: RuntimeMode; + runtimeModeOptions: ReadonlyArray; + onRuntimeModeChange: (mode: RuntimeMode) => void; +}) { + return ( +
+ {props.traits ? : null} + +
+ ); +} diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 60ee931fe5f04..37a002e2c2708 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -9,6 +9,7 @@ import { CheckboxMark } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Kbd } from "../ui/kbd"; import { cn } from "~/lib/utils"; +import { isInSideChatPane } from "~/lib/sideChatPane"; import { ComposerBanner } from "./ComposerBanner"; import ChatMarkdown from "../ChatMarkdown"; @@ -151,6 +152,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; + if (isInSideChatPane(target)) return; if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { return; } diff --git a/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx b/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx index 74ab036f411e1..850256b28904a 100644 --- a/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx @@ -16,20 +16,20 @@ import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; const options = runtimeModeOptions.map((mode) => ({ mode, ...runtimeModeConfig[mode] })); -// Mirrors the effort menu: effort radio items first, then the Permissions section. -function EffortMenu() { - const [effort, setEffort] = useState("high"); +// Mirrors the overflow menu: mode radio items first, then the Permissions section. +function OverflowMenu() { + const [interaction, setInteraction] = useState("plan"); const [mode, setMode] = useState("full-access"); const composerMenuProps = useComposerMenuProps(); const triggerRef = useRef(null); return ( <> focusRequestedPermission(open, triggerRef.current)}> - Effort + More - - Low - High + + Chat + Plan { - await using screen = await render(, { router: false }); // no routes involved - const trigger = screen.getByRole("button", { name: "Effort" }); +it("reaches Permissions from the mode items with arrow keys, previews, and picks with Enter", async () => { + await using screen = await render(, { router: false }); // no routes involved + const trigger = screen.getByRole("button", { name: "More" }); await userEvent.tab(); await expect.element(trigger).toHaveFocus(); await userEvent.keyboard("{ArrowDown}"); - await expect.element(page.getByRole("menuitemradio", { name: "Low" })).toHaveFocus(); + await expect.element(page.getByRole("menuitemradio", { name: "Chat" })).toHaveFocus(); await userEvent.keyboard("{ArrowDown}{ArrowDown}"); const supervised = page.getByRole("menuitemradio", { name: "Supervised" }); @@ -66,7 +66,7 @@ it("reaches Permissions from the effort items with arrow keys, previews, and pic await expect.element(page.getByRole("menuitemradio", { name: "Supervised" })).toHaveFocus(); await userEvent.keyboard("{ArrowUp}"); - await expect.element(page.getByRole("menuitemradio", { name: "High" })).toHaveFocus(); + await expect.element(page.getByRole("menuitemradio", { name: "Plan" })).toHaveFocus(); await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}"); await expect.element(screen.getByRole("status")).toHaveTextContent("auto-accept-edits"); @@ -77,14 +77,14 @@ it("reaches Permissions from the effort items with arrow keys, previews, and pic }); it("marks the current mode as checked and closes on Escape", async () => { - await using screen = await render(, { router: false }); // no routes involved + await using screen = await render(, { router: false }); // no routes involved await userEvent.tab(); await userEvent.keyboard("{ArrowDown}"); const fullAccess = page.getByRole("menuitemradio", { name: "Full access" }); await expect.element(fullAccess).toHaveAttribute("aria-checked", "true"); await userEvent.keyboard("{Escape}"); await expect.element(fullAccess).not.toBeInTheDocument(); - await expect.element(screen.getByRole("button", { name: "Effort" })).toHaveFocus(); + await expect.element(screen.getByRole("button", { name: "More" })).toHaveFocus(); }); // The real composer menu focus rule (useComposerMenuProps), with a stub composer handle. @@ -95,19 +95,19 @@ function withComposer(children: ReactNode) { } it("returns focus to the editor after a pick", async () => { - const { handle, ui } = withComposer(); + const { handle, ui } = withComposer(); await using screen = await render(ui, { router: false }); // no routes involved await userEvent.tab(); await userEvent.keyboard("{ArrowDown}"); - await expect.element(page.getByRole("menuitemradio", { name: "Low" })).toHaveFocus(); + await expect.element(page.getByRole("menuitemradio", { name: "Chat" })).toHaveFocus(); await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}"); await expect.element(screen.getByRole("status")).toHaveTextContent("approval-required"); await vi.waitFor(() => expect(handle.focusAtEnd).toHaveBeenCalled()); }); it("opens on the checked permission when composer.mode marked the trigger", async () => { - await using screen = await render(, { router: false }); // no routes involved - const trigger = screen.getByRole("button", { name: "Effort" }); + await using screen = await render(, { router: false }); // no routes involved + const trigger = screen.getByRole("button", { name: "More" }); markOpenToPermissions(trigger.element() as HTMLElement); await userEvent.click(trigger); await expect.element(page.getByRole("menuitemradio", { name: "Full access" })).toHaveFocus(); @@ -124,7 +124,7 @@ function PermissionsChip() { return (
{mode === "full-access" ? null : ( expect(handle.focusAtEnd).toHaveBeenCalled()); - await expect.element(screen.getByRole("button", { name: "Effort" })).not.toHaveFocus(); + await expect.element(screen.getByRole("button", { name: "Model" })).not.toHaveFocus(); }); diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx index 12c11d213cdf7..b46614a8b418d 100644 --- a/apps/web/src/components/chat/ComposerUsageLimits.tsx +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -132,6 +132,7 @@ function UsageLimitsBannerBody({ diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index d2e259692204b..250152c4d8501 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -11,12 +11,15 @@ import { openCommandPalette } from "~/commandPaletteBus"; import { shortcutLabelForCommand } from "~/keybindings"; import { primaryServerKeybindingsAtom } from "~/state/server"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; +import { useOrderWhileOpen } from "~/hooks/useOrderWhileOpen"; +import { useProjectPin } from "~/hooks/useProjectGroupUpdate"; import { useScratchProject } from "~/hooks/useScratchProject"; import { isTriagePrompt } from "~/hooks/useStartTriage"; import { useClientSettings } from "~/hooks/useSettings"; import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, + pickableProjectGroups, selectProjectGroupingSettings, } from "@t3tools/client-runtime/state/project-grouping"; import { @@ -32,6 +35,7 @@ import { } from "~/state/environments"; import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge"; import { ProjectFavicon } from "../ProjectFavicon"; +import { isProjectPinShortcut, ProjectPinButton } from "../ProjectPinButton"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; import { Combobox, @@ -94,6 +98,8 @@ export function DraftHeroHeadline({ const [pickerOpen, setPickerOpen] = useState(false); const [pickerQuery, setPickerQuery] = useState(""); const pickerFilter = useComboboxFilter(); + const highlightedPickerValueRef = useRef(null); + const { canPinProjectGroup, toggleProjectGroupPin } = useProjectPin(); const environmentLabelById = new Map( environments.map((environment) => [environment.environmentId, environment.label] as const), @@ -119,7 +125,7 @@ export function DraftHeroHeadline({ ), ); const projectPickerEntries = buildSidebarProjectPickerEntries({ - groups: projectGroups, + groups: pickableProjectGroups(projectGroups), preferredProjectRef: activeProjectRef, }); const projectEntryByKey = new Map( @@ -156,10 +162,15 @@ export function DraftHeroHeadline({ activeProject !== null && isScratchProject(activeProject, scratchWorkspaceRootFor(activeProject.environmentId)); - const pickerItems: readonly PickerItem[] = [ - ...menuEntries.map(({ group }) => ({ value: group.projectKey, label: group.displayName })), - { value: ADD_PROJECT_VALUE, label: "Add project" }, - ]; + // Pinning from a row keeps rows in place while the picker is open. + const pickerItems: readonly PickerItem[] = useOrderWhileOpen( + [ + ...menuEntries.map(({ group }) => ({ value: group.projectKey, label: group.displayName })), + { value: ADD_PROJECT_VALUE, label: "Add project" }, + ], + (item) => item.value, + pickerOpen, + ); const pickerSearch = pickerQuery.trim(); const filteredPickerItems = pickerSearch.length === 0 @@ -258,6 +269,9 @@ export function DraftHeroHeadline({ itemToStringLabel={(item) => item.label} isItemEqualToValue={(a, b) => a.value === b.value} open={pickerOpen} + onItemHighlighted={(item) => { + highlightedPickerValueRef.current = item?.value ?? null; + }} onOpenChange={(open) => { setPickerOpen(open); setPickerQuery(""); @@ -302,6 +316,16 @@ export function DraftHeroHeadline({ placeholder="Search projects..." value={pickerQuery} onChange={(event) => setPickerQuery(event.target.value)} + onKeyDown={(event) => { + const highlighted = highlightedPickerValueRef.current; + const group = highlighted ? projectEntryByKey.get(highlighted)?.group : undefined; + if (!group || !canPinProjectGroup(group) || !isProjectPinShortcut(event, keybindings)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + toggleProjectGroupPin(group); + }} /> No matching projects. @@ -327,6 +351,14 @@ export function DraftHeroHeadline({ machineByEnvironmentId={environmentMachineById} /> ) : null} + {entry && canPinProjectGroup(entry.group) ? ( + toggleProjectGroupPin(entry.group)} + /> + ) : null} ); }} diff --git a/apps/web/src/components/chat/EffortGlyph.tsx b/apps/web/src/components/chat/EffortGlyph.tsx index 2e033c6caa8da..39a46d2564901 100644 --- a/apps/web/src/components/chat/EffortGlyph.tsx +++ b/apps/web/src/components/chat/EffortGlyph.tsx @@ -1,4 +1,5 @@ import type { ProviderOptionDescriptor } from "@t3tools/contracts"; +import { ZapIcon } from "lucide-react"; import type { ComposerControlSize } from "./ComposerControl"; @@ -84,3 +85,21 @@ export function EffortGlyph(props: { count: number; filled: number; size?: Compo ); } + +/** One bolt for Fast. Ultrafast adds an outlined echo behind it, cut away by the surface color. */ +export function FastBolt(props: { ultra: boolean }) { + if (!props.ultra) { + return ( +