diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index 6a7fc4a6d5b5f..d7ebb1a0790cd 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -5,6 +5,8 @@ // node ci-changes.cjs --paths --full run every Linux job, gate only the macOS lint // node ci-changes.cjs changed paths unknown: run everything // +// scripts/check-changed.ts requires the same decisions so local runs match CI. +// // Every changed path maps to workspace packages, which then pull in their // dependents from package.json. A path this file does not recognize runs // everything, so a new root file or directory can only cost minutes. @@ -201,4 +203,6 @@ function main() { for (const [key, value] of Object.entries(outputs)) console.log(`${key}=${value}`); } -main(); +module.exports = { readWorkspace, decide }; + +if (require.main === module) main(); diff --git a/.github/scripts/ci-changes.d.cts b/.github/scripts/ci-changes.d.cts new file mode 100644 index 0000000000000..afe6f61339362 --- /dev/null +++ b/.github/scripts/ci-changes.d.cts @@ -0,0 +1,27 @@ +interface WorkspacePackage { + readonly name: string; + readonly dir: string; + readonly hasTest: boolean; + readonly deps: ReadonlyArray; +} + +export function readWorkspace(): Array; + +export function decide( + files: ReadonlyArray | null, + packages: ReadonlyArray, + full: boolean, +): { + affected: Array; + outputs: { + typecheck: boolean; + build: boolean; + test: boolean; + test_packages: string; + test_web: boolean; + test_server: boolean; + rust: boolean; + release_smoke: boolean; + mobile_native: boolean; + }; +}; diff --git a/.github/scripts/vercel-deployment-output.test.cjs b/.github/scripts/vercel-deployment-output.test.cjs new file mode 100644 index 0000000000000..a249653350f22 --- /dev/null +++ b/.github/scripts/vercel-deployment-output.test.cjs @@ -0,0 +1,87 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const { mkdtempSync, readFileSync, rmSync, existsSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join } = require("node:path"); +const { test } = require("node:test"); + +const release = readFileSync(join(__dirname, "../workflows/release.yml"), "utf8"); +const preview = readFileSync(join(__dirname, "../workflows/web-preview.yml"), "utf8"); +const stepPattern = + / name: (?:Build staged deployment|Deploy preview)\n shell: bash\n run: \|\n((?: .*\n|\n)+)/g; +const steps = [ + ...Array.from(release.matchAll(stepPattern), (match, index) => [ + index === 0 ? "hosted web" : "marketing", + match[1], + ]), + ...Array.from(preview.matchAll(stepPattern), (match) => ["web preview", match[1]]), +]; +assert.equal(steps.length, 3, "Expected both release steps and the preview step"); + +const url = "https://t3code-fixture-pinglabs.vercel.app"; +const warning = 'Error while parsing config file: "/runner/t3code/pnpm-lock.yaml"'; + +function runStep(script, stdout, exitCode = 0) { + const directory = mkdtempSync(join(tmpdir(), "t3-vercel-output-")); + const outputFile = join(directory, "output"); + try { + const result = spawnSync( + "bash", + [ + "-c", + 'vp() { printf "%s\\n" "$FIXTURE_STDOUT"; return "$FIXTURE_EXIT"; }\n' + + 'curl() { printf \'{"id":"fixture-project"}\\n\'; }\n' + + script.replace(/^ /gm, "").replace(/\$\{\{.*?\}\}/g, "fixture"), + ], + { + encoding: "utf8", + env: { + PATH: process.env.PATH, + VERCEL_TOKEN: "fixture-token", + VERCEL_ORG_ID: "fixture-org", + VERCEL_PROJECT_ID: "fixture-project", + GITHUB_OUTPUT: outputFile, + FIXTURE_STDOUT: stdout, + FIXTURE_EXIT: String(exitCode), + }, + }, + ); + assert.ifError(result.error); + return { + ...result, + output: existsSync(outputFile) ? readFileSync(outputFile, "utf8") : "", + }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +for (const [name, script] of steps) { + for (const [label, stdout] of [ + ["plain URL", url], + ["lockfile warning", `${warning}\n${url}`], + ["trailing diagnostic", `${url}\nDone`], + ]) { + test(`${name}: saves one deployment URL after ${label}`, () => { + const result = runStep(script, stdout); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual( + result.output.split("\n").filter((line) => !line.startsWith("project_id=")), + [`deployment_url=${url}`, ""], + ); + }); + } + + for (const [label, stdout, exitCode] of [ + ["missing URL", warning, 0], + ["invalid URL", "https://example.invalid/deployment", 0], + ["multiple URLs", `${url}\nhttps://other.vercel.app`, 0], + ["failed deployment", url, 1], + ]) { + test(`${name}: rejects ${label} without saving output`, () => { + const result = runStep(script, stdout, exitCode); + assert.notEqual(result.status, 0); + assert.equal(result.output, ""); + }); + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75917c69dddcf..7c1d22052c2f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,7 +256,7 @@ jobs: run: python3 -B .github/scripts/stage-preview-bundle.test.py - name: Test release checks - run: node --test .github/scripts/check-nightly-release.test.cjs + run: node --test .github/scripts/check-nightly-release.test.cjs .github/scripts/vercel-deployment-output.test.cjs - name: Finish installing browser secret helper build libraries timeout-minutes: 5 diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 65cfa08321c87..516a0a7785a95 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -253,18 +253,13 @@ jobs: if [[ "${{ inputs.version }}" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then brand=nightly; fi node scripts/apply-web-brand-assets.ts "$brand" apps/server/dist/client - # The single-executable is built with a Node that supports --build-sea - # (25.7+); the repo itself stays on the engines.node version. A native - # build injects into the runner's own Node; a Windows target downloads its - # node.exe. + # Setup Vite+ selects the exact engines.node version from package.json. + # Native builds embed that executable; cross-target builds download the + # same version for their target. - name: Build CLI single-executable if: inputs.cli_archive shell: bash env: - # The exact version, not a major: vp downloads it from nodejs.org/dist on - # the runner, and only exact versions have a dist directory. Keep in - # step with SEA_NODE_VERSION in apps/server/vite.config.ts. - VP_NODE_VERSION: "26.8.2" # The executable bundles the server again, so it needs the same stamp # as release.yml's JS bundle. T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && inputs.release_channel || 'dev' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 65ea056e85a19..6a80220c28a87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1072,7 +1072,7 @@ jobs: fi echo "Building hosted web app for $channel_name channel." - deployment_url="$( + deployment_output="$( vp dlx vercel@53.1.1 deploy \ --archive=tgz \ --prod \ @@ -1091,6 +1091,13 @@ jobs: --build-env "VITE_HOSTED_APP_URL=$router_url" \ --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" )" + printf '%s\n' "$deployment_output" + # Vercel can print lockfile warnings to stdout before the deployment URL. + deployment_url="$(printf '%s\n' "$deployment_output" | grep -E '^https://[[:alnum:].-]+\.vercel\.app$')" + if [[ "$deployment_url" == *$'\n'* ]]; then + echo "Vercel returned multiple deployment URLs." >&2 + exit 1 + fi echo "Staged $deployment_url." echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" @@ -1219,7 +1226,7 @@ jobs: )" export VERCEL_PROJECT_ID - deployment_url="$( + deployment_output="$( vp dlx vercel@53.1.1 deploy \ --archive=tgz \ --prod \ @@ -1228,6 +1235,13 @@ jobs: --token "$VERCEL_TOKEN" \ --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" )" + printf '%s\n' "$deployment_output" + # Vercel can print lockfile warnings to stdout before the deployment URL. + deployment_url="$(printf '%s\n' "$deployment_output" | grep -E '^https://[[:alnum:].-]+\.vercel\.app$')" + if [[ "$deployment_url" == *$'\n'* ]]; then + echo "Vercel returned multiple deployment URLs." >&2 + exit 1 + fi echo "Staged $deployment_url." echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" echo "project_id=$VERCEL_PROJECT_ID" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml index f9cc3b063fcdb..3def8478bbdc2 100644 --- a/.github/workflows/web-preview.yml +++ b/.github/workflows/web-preview.yml @@ -77,13 +77,20 @@ jobs: vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" - deployment_url="$( + deployment_output="$( vp dlx vercel@53.1.1 deploy \ --archive=tgz \ --yes \ --token "$VERCEL_TOKEN" \ --scope "$vercel_scope" )" + printf '%s\n' "$deployment_output" + # Vercel can print lockfile warnings to stdout before the deployment URL. + deployment_url="$(printf '%s\n' "$deployment_output" | grep -E '^https://[[:alnum:].-]+\.vercel\.app$')" + if [[ "$deployment_url" == *$'\n'* ]]; then + echo "Vercel returned multiple deployment URLs." >&2 + exit 1 + fi echo "Deployed $deployment_url" echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs index ff75832369d8d..e79d0e71dc863 100644 --- a/.pnpmfile.cjs +++ b/.pnpmfile.cjs @@ -5,6 +5,15 @@ // Release scripts stage their own installs with explicit architectures. module.exports = { hooks: { + // `typescript` is @tsc-rs/typescript (the catalog entry). Its npm version (0.8.x) is outside + // vite-plus-core's typescript peer range, so pnpm gave the vite-plus that @effect/vitest + // imports apps/marketing's typescript@6.0.3 instead, and tests loaded a second vitest. + readPackage(pkg) { + if (pkg.name === "@voidzero-dev/vite-plus-core" && pkg.peerDependencies?.typescript) { + pkg.peerDependencies.typescript = "*"; + } + return pkg; + }, updateConfig(config) { // oxlint-disable-next-line t3code/no-global-process-runtime -- pnpm loads this file before any Effect runtime exists. if (process.platform !== "win32") return config; diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000000000..e3576c995f40a --- /dev/null +++ b/.vercelignore @@ -0,0 +1,2 @@ +# Vercel's default .pnp* rule also excludes pnpm's hook, whose checksum is in the lockfile. +!/.pnpmfile.cjs diff --git a/AGENTS.md b/AGENTS.md index f7e4dd0f37209..00d8c6f7bfe2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,12 +98,14 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real ## Verifying -- Smallest proof that the change works. `vp test run ` for the tests you touched (`-t ""` in big files), `vp lint `, and `vp run typecheck` in the package you changed. Not `npx tsc`; it is 3x slower than tsc-rs. -- Run `vp run knip:check` (about 35 s) when you add, remove, or rename exports, files, or dependencies. +- Before you push, run `vp run check:changed`. It runs CI's fast checks for changed files: format, lint, knip, typecheck of affected packages and dependents, and tests beside changes. CI still runs the full test suites. +- While you iterate, run `vp test run `. For big files, add `-t ""`. +- Use `vp run typecheck`, not `npx tsc`; it caches per package. Both run tsc-rs (`typescript` is the `@tsc-rs/typescript` drop-in). - Run `node -e` snippets from `apps/server` or the owning package. `effect` does not resolve from the repo root. - Test meaningful logic or observable behavior. Do not render components to static markup to assert props or attributes, or add tests that merely assert callback wiring or mirror the implementation. - **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. - Backend behavior changes ship with focused tests for that behavior. +- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in the module's existing test file), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. - The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. Concretely: - `it.effect` runs on a `TestClock`; `Effect.sleep` only advances through `TestClock.adjust`. `it.live` is wall-clock and only for subprocess or network fixtures. - No `yieldNow` or `sleep` polling loops. Await the `Deferred`, take from the `Queue`, or drain the worker. @@ -184,7 +186,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ - `packages/contracts` - Effect/Schema contracts plus small derived helpers. No heavy runtime logic. - `packages/shared` - shared runtime utils, subpath exports, no barrel. - `packages/client-runtime` - client code shared by web and mobile. -- `.repos/` - vendored read-only references. Prefer their patterns over invented ones. Never edit or import from them. Sync with `vpr sync:repos` when bumping the matching dependency. +- `.repos/` - vendored read-only references. Prefer their patterns over invented ones. Never edit or import from them. Sync with `vpr sync:repos` when bumping the matching dependency. If it is missing (sparse checkout), read a file with `git show origin/main:.repos/`. ## Taste diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index c0a77e9a48b77..4404dddbaa3f5 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -43,9 +43,9 @@ export default defineConfig({ dependsOn: ["t3#build"], cache: false, }, - // tsc-rs rewrites the tsbuildinfo it reads, which keeps a task out of the cache. + // tsc (tsc-rs) rewrites the tsbuildinfo it reads, which keeps a task out of the cache. typecheck: { - command: "tsc-rs --noEmit", + command: "tsc --noEmit", cache: { input: [{ auto: true }, "!*.tsbuildinfo"] }, }, }, diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz deleted file mode 100644 index 581307cf91be1..0000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.36.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.36.tgz new file mode 100644 index 0000000000000..071a4022a7b3f Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.36.tgz differ diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 9a5dae1db3f20..919902b051ca8 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -13,7 +13,7 @@ private struct ComposerTokenPayload: Decodable { let end: Int } -private struct ComposerSelectionPayload: Decodable { +private struct ComposerSelectionPayload: Decodable, Equatable { let start: Int let end: Int } @@ -449,6 +449,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro private var tokensNeedRebuild = false private var chipsNeedMeasuredWidth = false private var hasDeferredForcedRebuild = false + private var pendingContentOffset: CGPoint? + private var needsSelectionReveal = false + private var selectionBeforeFocusingTap: (selection: ComposerSelectionPayload, value: String)? let onComposerChange = EventDispatcher() let onComposerSelectionChange = EventDispatcher() @@ -464,7 +467,8 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro public required init(appContext: AppContext? = nil) { super.init(appContext: appContext) - clipsToBounds = false + clipsToBounds = true + textView.clipsToBounds = true textView.delegate = self textView.textDropDelegate = self textView.backgroundColor = .clear @@ -520,6 +524,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro @objc private func openContext(_ recognizer: UITapGestureRecognizer) { guard let (index, attachment) = contextAttachment(at: recognizer.location(in: textView)) else { textView.becomeFirstResponder() + if selectionBeforeFocusingTap != nil { + // UIKit places the caret from its own tap recognizer during this same touch. + DispatchQueue.main.async { [weak self] in + self?.restoreSelectionBeforeFocusingTap() + } + } return } openContext(index: index, attachment: attachment) @@ -558,7 +568,11 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - true + // The collapsed editor only shows the draft's first line, so a tap that expands an + // overflowing draft returns to the caret it was left at instead of moving it there. + let collapsedOverflow = !textView.isFirstResponder && !textView.isScrollEnabled && draftOverflowsBounds() + selectionBeforeFocusingTap = collapsedOverflow ? (sourceSelection(), value) : nil + return true } public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { @@ -583,11 +597,27 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro public override func layoutSubviews() { super.layoutSubviews() + let previousSize = textView.bounds.size textView.frame = bounds if chipsNeedMeasuredWidth, bounds.width > 0 { chipsNeedMeasuredWidth = false applyControlledDocument(force: true) } + if textView.isScrollEnabled, bounds.width > 0, bounds.height > 0 { + textView.layoutIfNeeded() + if let offset = pendingContentOffset { + let minY = -textView.adjustedContentInset.top + let maxY = max(minY, textView.contentSize.height - bounds.height + textView.adjustedContentInset.bottom) + textView.setContentOffset(CGPoint(x: offset.x, y: min(maxY, max(minY, offset.y))), animated: false) + pendingContentOffset = nil + } + if needsSelectionReveal || (previousSize != bounds.size && textView.isFirstResponder) { + needsSelectionReveal = false + if let selection = textView.selectedTextRange { + textView.scrollRectToVisible(textView.caretRect(for: selection.end), animated: false) + } + } + } let placeholderX = textView.textContainerInset.left + textView.textContainer.lineFragmentPadding let placeholderY = textView.textContainerInset.top let placeholderWidth = max( @@ -632,10 +662,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro tokens = decode([ComposerTokenPayload].self, from: document.tokensJson) ?? [] tokensNeedRebuild = true } + let previousSelection = sourceSelection() value = document.value requestedSelection = document.selection applyControlledDocument(force: tokensNeedRebuild) applyRequestedSelection() + cancelFocusingTapRestoreIfSelectionMoved(from: previousSelection) if tokensMatchCurrentValue() { tokensNeedRebuild = false } @@ -694,7 +726,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } func setScrollEnabled(_ scrollEnabled: Bool) { + guard textView.isScrollEnabled != scrollEnabled else { return } + if !scrollEnabled { + // Disabling scrolling discards UITextView's offset. Restore it after + // expansion has laid out the text, regardless of prop update order. + pendingContentOffset = pendingContentOffset ?? textView.contentOffset + } textView.isScrollEnabled = scrollEnabled + if scrollEnabled { + revealSelectionAfterLayout() + } } func setAutoFocus(_ autoFocus: Bool) { @@ -738,8 +779,10 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } func setSelection(start: Int, end: Int) { + let previousSelection = sourceSelection() requestedSelection = ComposerSelectionPayload(start: start, end: end) applyRequestedSelection() + cancelFocusingTapRestoreIfSelectionMoved(from: previousSelection) } public func textViewDidChange(_ textView: UITextView) { @@ -811,6 +854,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } public func textViewDidBeginEditing(_ textView: UITextView) { + revealSelectionAfterLayout() onComposerFocus() } @@ -836,6 +880,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro hasDeferredForcedRebuild = false let previousSelection = sourceSelection() + if textView.isScrollEnabled { + pendingContentOffset = pendingContentOffset ?? textView.contentOffset + } isApplyingControlledValue = true textView.attributedText = makeAttributedDocument() let targetSelection = requestedSelection ?? previousSelection @@ -843,6 +890,11 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro textView.selectedRange = displayRange(for: targetSelection) restoreBaseTypingAttributes() isApplyingControlledValue = false + if currentSource != value || previousSelection.start != targetSelection.start || previousSelection.end != targetSelection.end { + revealSelectionAfterLayout() + } else { + setNeedsLayout() + } updatePlaceholderVisibility() emitContentSizeIfNeeded() } @@ -1053,6 +1105,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro return } value = textView.serializedText() + revealSelectionAfterLayout() let selection = sourceSelection() nativeEventCount += 1 onComposerChange([ @@ -1122,6 +1175,40 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro isApplyingControlledValue = true textView.selectedRange = nextRange isApplyingControlledValue = false + revealSelectionAfterLayout() + } + + private func restoreSelectionBeforeFocusingTap() { + guard let snapshot = selectionBeforeFocusingTap else { + return + } + selectionBeforeFocusingTap = nil + // A draft that changed during the touch, such as a transcript landing, owns the caret now. + guard textView.isFirstResponder, textView.markedTextRange == nil, value == snapshot.value else { + return + } + let range = displayRange(for: snapshot.selection) + guard !NSEqualRanges(range, textView.selectedRange) else { + return + } + textView.selectedRange = range + revealSelectionAfterLayout() + } + + private func cancelFocusingTapRestoreIfSelectionMoved(from previousSelection: ComposerSelectionPayload) { + if sourceSelection() != previousSelection { + selectionBeforeFocusingTap = nil + } + } + + private func draftOverflowsBounds() -> Bool { + let size = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude)) + return size.height > textView.bounds.height + 1 + } + + private func revealSelectionAfterLayout() { + needsSelectionReveal = true + setNeedsLayout() } private func updatePlaceholderVisibility() { @@ -1129,28 +1216,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } private func emitContentSizeIfNeeded() { - // The laid-out text without insets. A one-line draft reports its natural width, so - // JS can tell whether it fits a narrower row than the one it is laid out in; past - // one line only the height matters, so layout stops at the second line. The width - // comes from the line fragment because usedRect spans the container once the view - // scrolls. A trailing newline's empty line only exists as the extra fragment. - let layout = textView.layoutManager - let container = textView.textContainer - var lines = 0 - var width: CGFloat = 0 - var height: CGFloat = 0 - let glyphs = NSRange(location: 0, length: layout.numberOfGlyphs) - layout.enumerateLineFragments(forGlyphRange: glyphs) { _, used, _, _, stop in - lines += 1 - width = used.width - height = used.maxY - if lines > 1 { stop.pointee = true } - } - if lines < 2, layout.extraLineFragmentTextContainer === container { - lines += 1 - height = max(height, layout.extraLineFragmentUsedRect.maxY) - } - let nextSize = CGSize(width: lines == 1 ? ceil(width) : 0, height: ceil(height)) + guard bounds.width > 0 else { return } + // Fit independently of the animated viewport: when scrolling is disabled, + // its current height can clip TextKit's laid-out line fragments. + let fittedHeight = textView.sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)).height + - textView.textContainerInset.top - textView.textContainerInset.bottom + let measuredHeight = min(lineHeight * 4, fittedHeight) + let width = fittedHeight <= lineHeight + 0.5 + ? textView.layoutManager.usedRect(for: textView.textContainer).width + : 0 + let nextSize = CGSize(width: ceil(width), height: ceil(measuredHeight)) guard abs(nextSize.width - lastContentSize.width) > 0.5 || abs(nextSize.height - lastContentSize.height) > 0.5 else { return diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request.png new file mode 100644 index 0000000000000..7443f8e0096dc Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@2x.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@2x.png new file mode 100644 index 0000000000000..efc4cb3a093d5 Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@2x.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@3x.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@3x.png new file mode 100644 index 0000000000000..ee63420dba56d Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@3x.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift index 936efe7676e95..b970936b1fe8f 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift @@ -69,6 +69,7 @@ final class T3LayoutMetricsView: ExpoView { } var verticalBarEdge = "none" var regions: [[String: Any]] = [] + var bottomBarInset: CGFloat? #if canImport(UIKit, _version: 9127.0.85) if #available(iOS 27.1, *) { let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft @@ -77,6 +78,8 @@ final class T3LayoutMetricsView: ExpoView { case .trailing: verticalBarEdge = rtl ? "left" : "right" default: break } + // Where UIKit floats a bar as tall as the app's bottom controls, level with its tab bar. + bottomBarInset = edgeInsets(for: .bar(onEdge: UIRectEdge.bottom, extent: 48)).bottom for kind in [UIView.ReservedRegion.Kind.division, .occlusion] { for region in reservedRegions(kind: kind) where region.isActive { let frame = region.frame.intersection(bounds) @@ -90,7 +93,7 @@ final class T3LayoutMetricsView: ExpoView { } } #endif - let metrics: [String: Any] = [ + var metrics: [String: Any] = [ "width": bounds.width, "height": bounds.height, "hasHinge": hasHinge, "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact", @@ -101,6 +104,7 @@ final class T3LayoutMetricsView: ExpoView { ], "reservedRegions": regions, ] + metrics["bottomBarInset"] = bottomBarInset let next = metrics as NSDictionary guard lastMetrics?.isEqual(next) != true else { return } lastMetrics = next diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec b/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec index 29735c301cc0e..c32f88b0dacb3 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec @@ -15,5 +15,6 @@ Pod::Spec.new do |s| s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', } + s.resource_bundles = { 'T3NativeControlIcons' => ['Resources/Icons/pull-request*.png'] } s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}' end diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 479b5fed0ce51..81a892c8becf8 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -32,6 +32,14 @@ public final class T3NativeControlsModule: Module { Prop("appearance") { (view: T3SegmentedControlView, value: String) in view.appearance = value } } + View(T3SidebarTabBarView.self) { + ViewName("SidebarTabBar") + Events("onSelectionChange") + Prop("selectedIndex") { (view: T3SidebarTabBarView, value: Int) in view.selectedIndex = value } + Prop("selectedColor") { (view: T3SidebarTabBarView, value: UIColor) in view.selectedColor = value } + Prop("appearance") { (view: T3SidebarTabBarView, value: String) in view.appearance = value } + } + View(T3LayoutMetricsView.self) { ViewName("LayoutMetrics") Events("onMetricsChange") diff --git a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift new file mode 100644 index 0000000000000..3bfd730facd17 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift @@ -0,0 +1,60 @@ +import ExpoModulesCore +import UIKit + +final class T3SidebarTabBarView: ExpoView, UITabBarDelegate { + let onSelectionChange = EventDispatcher() + private let tabBar = UITabBar() + var selectedIndex = 0 { didSet { updateSelection() } } + var selectedColor: UIColor = .systemBlue { didSet { tabBar.tintColor = selectedColor } } + var appearance = "light" { + didSet { overrideUserInterfaceStyle = appearance == "dark" ? .dark : .light } + } + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + let iconBundle = Bundle(for: Self.self).url(forResource: "T3NativeControlIcons", withExtension: "bundle").flatMap(Bundle.init(url:)) + let pullRequestIcon = UIImage(named: "pull-request", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate) + tabBar.items = [ + UITabBarItem(title: "Threads", image: tabIcon(UIImage(systemName: "text.bubble")), tag: 0), + UITabBarItem(title: "Pulls", image: tabIcon(pullRequestIcon), tag: 1), + ] + tabBar.items?.last?.accessibilityLabel = "Pull requests" + tabBar.items?.first?.selectedImage = tabIcon(UIImage(systemName: "text.bubble.fill")) + tabBar.itemPositioning = .fill + tabBar.delegate = self + addSubview(tabBar) + updateSelection() + } + + override func layoutSubviews() { + super.layoutSubviews() + tabBar.frame = bounds + updateSelection() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + updateSelection() + } + + // Give symbols and bitmap assets the same image box; UIKit otherwise scales symbols independently. + private func tabIcon(_ image: UIImage?) -> UIImage? { + guard let image else { return nil } + let canvas = CGSize(width: 28, height: 28) + let scale = min(24 / image.size.width, 24 / image.size.height) + let size = CGSize(width: image.size.width * scale, height: image.size.height * scale) + return UIGraphicsImageRenderer(size: canvas).image { _ in + image.draw(in: CGRect(x: (canvas.width - size.width) / 2, y: (canvas.height - size.height) / 2, width: size.width, height: size.height)) + }.withRenderingMode(.alwaysTemplate) + } + + private func updateSelection() { + guard let items = tabBar.items, items.indices.contains(selectedIndex) else { return } + guard tabBar.selectedItem !== items[selectedIndex] else { return } + tabBar.selectedItem = items[selectedIndex] + } + + func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { + onSelectionChange(["index": item.tag]) + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 441ee7beb6d35..9f1a944485088 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -45,7 +45,7 @@ "measure-renders": "node scripts/measure-renders.mts", "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts", "test": "vp test run --project unit --project react", - "typecheck": "tsc-rs --noEmit" + "typecheck": "tsc --noEmit" }, "dependencies": { "@clerk/expo": "catalog:", @@ -134,7 +134,7 @@ "react-native-pdf-jsi": "5.0.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.30.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.36.tgz", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.16.0", "react-native-webview": "14.0.1", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 93c17338a7695..72bb44b9e5571 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -26,7 +26,7 @@ import { subscribeComposerDraftModelReconciliation } from "./state/use-composer- import { environmentThreadShells } from "./state/threads"; import { environmentShell } from "./state/shell"; import { OverlayPortalHost } from "./components/ui/OverlayPortal"; -import { shouldHandleAppLink } from "./lib/appLinking"; +import { shouldHandleAppLink, restoreDraftLinkState } from "./lib/appLinking"; import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; @@ -47,7 +47,7 @@ const appLinking = { config: { initialRouteName: "Home" }, filter: shouldHandleAppLink, getStateFromPath: (path: string, options?: Parameters[1]) => - getStateFromPath(githubPullRequestAppPath(path) ?? path, options), + restoreDraftLinkState(getStateFromPath(githubPullRequestAppPath(path) ?? path, options), path), }; const Navigation = createStaticNavigation(RootStack); diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index d7cafd5ba21b8..f2db0e837782c 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1,11 +1,9 @@ +import { isDraftThreadRoute } from "./features/threads/thread-route-state"; import { ComponentCatalogScreen } from "./features/development/ComponentCatalogScreen"; import { PullRequestGuideStopScreen } from "./features/pull-requests/guide/PullRequestGuideScreen"; import { PullRequestInlineCommentScreen } from "./features/pull-requests/PullRequestInlineCommentScreen"; import { PullRequestDiscussionScreen } from "./features/pull-requests/PullRequestDiscussionScreen"; -import { - createV5StackNavigator as createNativeStackNavigator, - createV5SheetStackNavigator, -} from "./native/createV5StackNavigator"; +import { createV5SheetStackNavigator } from "./native/createV5StackNavigator"; import { createWorkspaceStackNavigator } from "./features/layout/createWorkspaceStackNavigator"; import { createPathConfigForStaticNavigation, @@ -19,14 +17,7 @@ import { type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; import { useEffect, useRef, type ReactNode } from "react"; -import { - Platform, - Pressable, - ScrollView, - StyleSheet, - View, - useWindowDimensions, -} from "react-native"; +import { Platform, Pressable, ScrollView, StyleSheet, useWindowDimensions } from "react-native"; import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/ui/AppText"; @@ -82,7 +73,6 @@ import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; import { AddProjectNewRoute } from "./features/projects/AddProjectNewRoute"; import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; -import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; import { NewTaskBranchPickerRouteScreen, NewTaskEnvironmentPickerRouteScreen, @@ -150,7 +140,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { deriveLayout } from "./lib/layout"; import { useNativeLayoutMetrics } from "./native/native-layout-metrics"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; -import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; +import { HORIZONTAL_FORM_SHEET_OPTIONS, formSheetDetents } from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads"; @@ -211,6 +201,15 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { const NESTED_NAVIGATOR_OPTIONS = { nativeContentInsetHorizontally: false, headerShown: false }; +// Thread settings keep the Duo's vertical bar so cancel, search, filter and confirm sit in the +// rail. The picker stack spans the sheet so its header reaches the rail, and insets its content. +const THREAD_SETTINGS_SHEET_OPTIONS = { + presentation: "formSheet" as const, + sheetAllowedDetents: [1], + sheetGrabberVisible: true, + nativeContentInsetHorizontally: false, +}; + const SettingsContentStack = createV5SheetStackNavigator({ initialRouteName: "Settings", screenOptions: { @@ -374,9 +373,7 @@ const SettingsContentStack = createV5SheetStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [1], - sheetGrabberVisible: true, + ...THREAD_SETTINGS_SHEET_OPTIONS, }), }, }), @@ -460,97 +457,6 @@ const SettingsSheetStack = createV5SheetStackNavigator({ // across two bars the whole screen slides). const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId"; -const NewTaskSheetStack = createNativeStackNavigator({ - initialRouteName: "NewTask", - screenOptions: { - ...SHEET_GLASS_HEADER_OPTIONS, - contentStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined, - // UIKit's default push adds a dimming shadow and independently transitions - // the navigation bar. Both read as mismatched sheet backgrounds here. - animation: Platform.OS === "ios" ? "simple_push" : undefined, - animationDuration: Platform.OS === "ios" ? 350 : undefined, - }, - screens: { - NewTask: createNativeStackScreen({ - screen: NewTaskRouteScreen, - linking: "", - options: { - title: "Choose project", - }, - }), - NewTaskDraft: createNativeStackScreen({ - screen: NewTaskDraftRouteScreen, - linking: "draft", - options: { - headerBackVisible: false, - title: "", - }, - }), - NewTaskEnvironment: createNativeStackScreen({ - screen: NewTaskEnvironmentPickerRouteScreen, - linking: "draft/environment", - options: { - title: "Environment", - }, - }), - NewTaskBranch: createNativeStackScreen({ - screen: NewTaskBranchPickerRouteScreen, - linking: "draft/branch", - options: { - title: "Branch", - }, - }), - NewTaskFile: createNativeStackScreen({ - screen: ThreadFileScreen, - linking: "draft/files/:path*", - options: SOLID_HEADER_OPTIONS, - }), - NewTaskAttachment: createNativeStackScreen({ - screen: AttachmentFileScreen, - linking: "draft/attachments/:attachmentId", - options: SOLID_HEADER_OPTIONS, - }), - ThreadSettings: createNativeStackScreen({ - screen: NewTaskThreadSettingsRouteScreen, - linking: "draft/settings", - options: { - gestureEnabled: true, - headerShown: false, - ...(Platform.OS === "android" - ? { presentation: "card" as const } - : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [1], - sheetGrabberVisible: true, - }), - }, - }), - AddProject: createNativeStackScreen({ - screen: AddProjectSourceRoute, - linking: "add-project", - options: { - title: "Add Project", - }, - }), - AddProjectRepository: createNativeStackScreen({ - screen: AddProjectRepositoryRoute, - linking: "add-project/repository", - }), - AddProjectDestination: createNativeStackScreen({ - screen: AddProjectDestinationRoute, - linking: "add-project/destination", - }), - AddProjectLocal: createNativeStackScreen({ - screen: AddProjectLocalRoute, - linking: "add-project/local", - }), - AddProjectNew: createNativeStackScreen({ - screen: AddProjectNewRoute, - linking: "add-project/new", - }), - }, -}); - const WORKSPACE_OVERLAY_ROUTES = new Set([ "PullRequestReview", "PullRequestInlineComment", @@ -561,7 +467,18 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ "GitCommit", "GitConfirm", "GitOverview", - "NewTaskSheet", + "NewTaskProject", + "NewTaskFiles", + "NewTaskFile", + "NewTaskAttachment", + "NewTaskThreadSettings", + "AddProject", + "AddProjectRepository", + "AddProjectDestination", + "AddProjectLocal", + "AddProjectNew", + "NewTaskEnvironment", + "NewTaskBranch", "SettingsLegal", "SettingsSheet", "ThreadAgents", @@ -612,44 +529,58 @@ function RootStackLayout(props: { const path = getPathFromState(props.state, navigationPathConfig); const pathname = path.startsWith("/") ? path : `/${path}`; const workspaceLocation = workspaceLocationFromState(props.state); + const draftRoute = props.state.routes.findLast((route) => route.name === "Thread"); + const draftParams = ( + draftRoute?.params as + | { draft?: { draftId?: string | string[]; pendingTaskId?: string | string[] } } + | undefined + )?.draft; + const routeDraftId = Array.isArray(draftParams?.draftId) + ? draftParams.draftId[0] + : draftParams?.draftId; + const routePendingTaskId = Array.isArray(draftParams?.pendingTaskId) + ? draftParams.pendingTaskId[0] + : draftParams?.pendingTaskId; useAgentNotificationNavigation(workspaceLocation.pathname); useConnectOnboardingNavigation(); useAppShortcuts(props.state); useEffect(() => { - const topRouteName = props.state.routes[props.state.index]?.name; const transition = transitionIncomingSharePresentation(sharePresentationRef.current, { - isShareSheetPresented: topRouteName === "NewTaskSheet", + isShareSheetPresented: isDraftThreadRoute(props.state.routes[props.state.index]), pendingShareId: pendingShare?.id ?? null, }); sharePresentationRef.current = transition.state; if (!transition.shareIdToPresent) { return; } - navigation.navigate("NewTaskSheet", { - screen: "NewTask", - params: { incomingShareId: transition.shareIdToPresent }, - }); + navigation.navigate("Thread", { draft: { incomingShareId: transition.shareIdToPresent } }); }, [navigation, pendingShare, props.state]); return ( - - - - - - - - {props.children} - - - - - - + + + + + + + + + {props.children} + + + + + + + ); } @@ -740,8 +671,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: PullRequestFilesSheet, options: { ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.6, 0.92], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.6), sheetGrabberVisible: true, sheetCornerRadius: Platform.OS === "android" ? 28 : undefined, }, @@ -750,8 +681,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: PullRequestReviewersScreen, options: { ...DETAIL_HEADER_OPTIONS, - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.75, 0.92], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.75), sheetGrabberVisible: true, headerBackVisible: false, }, @@ -765,8 +696,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ options: { ...(Platform.OS === "android" ? { presentation: "fullScreenModal" as const } - : { ...SHEET_GLASS_HEADER_OPTIONS, ...FORM_SHEET_PRESENTATION_OPTIONS }), - sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.75, 0.92], + : { ...SHEET_GLASS_HEADER_OPTIONS, ...HORIZONTAL_FORM_SHEET_OPTIONS }), + sheetAllowedDetents: Platform.OS === "android" ? undefined : formSheetDetents(0.75), sheetGrabberVisible: Platform.OS !== "android", }, }), @@ -774,16 +705,86 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: PullRequestReviewSheet, options: { ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.75, 0.92], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.75), sheetGrabberVisible: true, }, }), Thread: createNativeStackScreen({ screen: ThreadRouteScreen, - linking: THREAD_LINKING_PREFIX, + linking: { path: "threads/:environmentId?/:threadId?", alias: ["new/draft"] }, options: DETAIL_HEADER_OPTIONS, }), + NewTaskProject: createNativeStackScreen({ + screen: NewTaskRouteScreen, + linking: { path: "new", alias: ["new/project"] }, + options: { ...GLASS_HEADER_OPTIONS, title: "Choose project" }, + }), + NewTaskEnvironment: createNativeStackScreen({ + linking: "new/draft/environment", + screen: NewTaskEnvironmentPickerRouteScreen, + options: { ...GLASS_HEADER_OPTIONS, title: "Environment" }, + }), + NewTaskBranch: createNativeStackScreen({ + linking: "new/draft/branch", + screen: NewTaskBranchPickerRouteScreen, + options: { ...GLASS_HEADER_OPTIONS, title: "Branch" }, + }), + AddProject: createNativeStackScreen({ + linking: "new/add-project", + screen: AddProjectSourceRoute, + + options: { + ...GLASS_HEADER_OPTIONS, + title: "Add project", + }, + }), + AddProjectRepository: createNativeStackScreen({ + linking: "new/add-project/repository", + screen: AddProjectRepositoryRoute, + options: GLASS_HEADER_OPTIONS, + }), + AddProjectDestination: createNativeStackScreen({ + linking: "new/add-project/destination", + screen: AddProjectDestinationRoute, + options: GLASS_HEADER_OPTIONS, + }), + AddProjectLocal: createNativeStackScreen({ + linking: "new/add-project/local", + screen: AddProjectLocalRoute, + options: GLASS_HEADER_OPTIONS, + }), + AddProjectNew: createNativeStackScreen({ + linking: "new/add-project/new", + screen: AddProjectNewRoute, + options: GLASS_HEADER_OPTIONS, + }), + NewTaskFiles: createNativeStackScreen({ + linking: "new/draft/files", + screen: ThreadFilesTreeScreen, + options: SOLID_HEADER_OPTIONS, + }), + NewTaskFile: createNativeStackScreen({ + linking: "new/draft/files/:path*", + screen: ThreadFileScreen, + options: SOLID_HEADER_OPTIONS, + }), + NewTaskAttachment: createNativeStackScreen({ + screen: AttachmentFileScreen, + linking: "new/draft/attachments/:attachmentId", + options: SOLID_HEADER_OPTIONS, + }), + NewTaskThreadSettings: createNativeStackScreen({ + screen: NewTaskThreadSettingsRouteScreen, + linking: "new/draft/settings", + options: { + gestureEnabled: true, + headerShown: false, + ...(Platform.OS === "android" + ? { presentation: "card" as const } + : THREAD_SETTINGS_SHEET_OPTIONS), + }, + }), ThreadTerminal: createNativeStackScreen({ screen: ThreadTerminalRouteScreen, linking: `${THREAD_LINKING_PREFIX}/terminal`, @@ -822,8 +823,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ // formSheet; use a full-screen modal there instead. ...(Platform.OS === "android" ? { presentation: "fullScreenModal" as const } - : FORM_SHEET_PRESENTATION_OPTIONS), - sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92], + : HORIZONTAL_FORM_SHEET_OPTIONS), + sheetAllowedDetents: Platform.OS === "android" ? undefined : formSheetDetents(0.55), sheetGrabberVisible: Platform.OS !== "android", }, }), @@ -862,36 +863,34 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [1], - sheetGrabberVisible: true, + ...THREAD_SETTINGS_SHEET_OPTIONS, }), }, }), ThreadQueue: createNativeStackScreen({ screen: ThreadQueueSheet, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, headerShown: false, - sheetAllowedDetents: [0.65, 0.95], + sheetAllowedDetents: formSheetDetents(0.65, 0.95), sheetGrabberVisible: true, }, }), ThreadBackgroundWork: createNativeStackScreen({ screen: ThreadBackgroundWorkSheet, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, headerShown: false, - sheetAllowedDetents: [0.5, 0.9], + sheetAllowedDetents: formSheetDetents(0.5, 0.9), sheetGrabberVisible: true, }, }), ThreadAgents: createNativeStackScreen({ screen: ThreadAgentsSheet, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, headerShown: false, - sheetAllowedDetents: [0.5, 0.9], + sheetAllowedDetents: formSheetDetents(0.5, 0.9), sheetGrabberVisible: true, }, }), @@ -899,8 +898,9 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: GitOverviewSheet, linking: `${THREAD_LINKING_PREFIX}/git`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.55, 0.92], + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.55), sheetGrabberVisible: true, }, }), @@ -908,8 +908,9 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: ThreadPullRequestsSheet, linking: `${THREAD_LINKING_PREFIX}/pull-requests`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.55, 0.92], + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.55), sheetGrabberVisible: true, }, }), @@ -918,8 +919,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/git/commit`, options: { ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.8, 0.92], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.8), sheetGrabberVisible: true, }, }), @@ -927,8 +928,9 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: GitBranchesSheet, linking: `${THREAD_LINKING_PREFIX}/git/branches`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.55, 0.92], + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.55), sheetGrabberVisible: true, }, }), @@ -936,8 +938,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: GitConfirmSheet, linking: `${THREAD_LINKING_PREFIX}/git-confirm`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.45, 0.7], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }, }), @@ -970,8 +972,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS), title: "Set up T3 Connect", gestureEnabled: true, - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.6, 0.95], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.6, 0.95), sheetGrabberVisible: true, }, }), @@ -983,7 +985,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const, headerShown: false } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }), @@ -997,28 +999,11 @@ const RootStackConfig = createWorkspaceStackNavigator({ // native header stack mounts without remounting the pairing form. ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), title: "Add Environment", - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }, }), - NewTaskSheet: createNativeStackScreen({ - screen: NewTaskSheetStack, - linking: "new", - // A screen's layout replaces the navigator's screenLayout. - layout: ({ children, route }) => ( - - - {children} - - - ), - options: { - ...NESTED_NAVIGATOR_OPTIONS, - gestureEnabled: true, - headerShown: false, - }, - }), NotFound: createNativeStackScreen({ screen: NotFoundScreen, linking: "*", @@ -1058,22 +1043,38 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig const nativeMetrics = useNativeLayoutMetrics(); const usesSplitView = deriveLayout({ width, height, nativeMetrics }).usesSplitView; const usesWorkspaceFlowScreens = Platform.OS === "android" || usesSplitView; + const flowPresentations = useRef(new Map()); return ( } + screenListeners={{ + state: ({ data }) => { + const keys = new Set(data.state.routes.map((route) => route.key)); + for (const key of flowPresentations.current.keys()) { + if (!keys.has(key)) flowPresentations.current.delete(key); + } + }, + }} screenOptions={({ route }) => { - if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") { + if (route.name !== "SettingsSheet") { return Platform.OS === "android" ? { animation: androidWorkspaceDestinationAnimation(route.name, usesSplitView) } : {}; } - return usesWorkspaceFlowScreens + // Moving an open flow between a sheet and a split column remounts its nested + // navigator. Keep its presentation until dismissal so folding preserves the settings flow. + let usesCard = flowPresentations.current.get(route.key); + if (usesCard === undefined) { + usesCard = usesWorkspaceFlowScreens; + flowPresentations.current.set(route.key, usesCard); + } + return usesCard ? { presentation: "card" } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.92], + presentation: "formSheet", + sheetAllowedDetents: [1], sheetGrabberVisible: true, }; }} diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 9cd2d85cef570..ebbfbe9881477 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -347,6 +347,8 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { onPressDocument={props.onPressDocument} /> void; }) { const descriptors = resolveProviderOptionDescriptors({ @@ -48,6 +48,7 @@ export function ComposerModelControl(props: { ] .filter(Boolean) .join(", ")} + size={props.size} disabled={props.disabled} emphasized renderIcon={(size) => @@ -72,8 +73,15 @@ export function ComposerModelControl(props: { label={label} iconOnly={props.iconOnly} suffix={ - props.showSummary !== false && summary ? ( - + summary ? ( + {summary} ) : undefined diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index f8e3c1447f04f..74708eff8d8c0 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -1,7 +1,6 @@ import type { ComponentProps, ReactNode } from "react"; import { useCallback, useMemo, useState } from "react"; import { - Platform, Pressable, ScrollView, View, @@ -13,7 +12,7 @@ import { type ViewStyle, } from "react-native"; -import Animated, { FadeIn, LinearTransition } from "react-native-reanimated"; +import Animated, { FadeIn } from "react-native-reanimated"; import { cn } from "../lib/cn"; import { AppText as Text } from "./ui/AppText"; @@ -24,11 +23,8 @@ const COMPOSER_TOOLBAR_GAP = 8; const COMPOSER_TOOLBAR_FADE_WIDTH = 18; const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4; const INLINE_CONTROL_LABEL_ENTERING = FadeIn.duration(160); -// Android runs no composer layout transitions, so the toolbar never trails the dock. -const INLINE_CONTROL_WIDTH_TRANSITION = - Platform.OS === "android" ? undefined : LinearTransition.duration(180); - export function ComposerInlineControl(props: { + readonly size?: "regular" | "large"; readonly accessibilityHint?: string; readonly accessibilityLabel?: string; readonly disabled?: boolean; @@ -47,13 +43,11 @@ export function ComposerInlineControl(props: { readonly showChevron?: boolean; }) { const { scale, smallIconSize } = useAndroidControlSizing(); - // The label fades in as the control widens, and leaves at once: an exiting view keeps - // drawing at its old place while the chevron moves into it. The clip keeps content laid out - // at the final width inside the frame while it eases there. + // Keep the picker anchored to its parent while the editor changes height. const animatesLabel = props.iconOnly !== undefined; + const iconSize = props.size === "large" ? 20 : smallIconSize; return ( - {props.renderIcon(smallIconSize)} + {props.renderIcon(iconSize)} ) : props.icon ? ( ["name"]; @@ -263,13 +256,14 @@ export function ComposerActionButton(props: { readonly onTouchStart?: PressableProps["onTouchStart"]; }) { const { scale, smallIconSize } = useAndroidControlSizing(); - const circleSize = Math.round(30 * scale); + const circleSize = Math.round((props.size === "large" ? 44 : 30) * scale); return ( void; }) { const insets = useSafeAreaInsets(); + // NativeColumnContent already insets the sides, so only a book fold moves the reveal handle. + const handleBounds = deriveBottomControlInsets(useNativeColumnLayoutMetrics()); const { themeVariables } = useAppearancePreferences(); const focused = useIsFocused(); const [foreground, setForeground] = useState(AppState.currentState !== "background"); @@ -318,7 +322,10 @@ function DevicePreviewScreen({ accessibilityHint="Shaking the phone also shows them" hitSlop={12} className="absolute h-6 w-16 items-center justify-center self-center" - style={{ top: Math.max(insets.top - 18, 4) }} + style={{ + top: Math.max(insets.top - 18, 4), + transform: [{ translateX: (handleBounds.left - handleBounds.right) / 2 }], + }} onPress={() => setControlsVisible(true)} > diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 82de8974654ef..9ef668a9fa1c5 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -337,7 +337,9 @@ function FileContent(props: { type ThreadFilesRouteScreenProps = StaticScreenProps<{ readonly environmentId: string; - readonly threadId: string; + readonly threadId?: string; + readonly cwd?: string; + readonly projectName?: string; }>; type ThreadFileRouteScreenProps = StaticScreenProps<{ @@ -430,6 +432,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( props.route.params, ); + const hasFileInspector = fileInspector.supported && threadId !== null; const revealedInspectorRef = useRef(false); const fileAccessSession = useEnvironmentQuery( environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, @@ -444,7 +447,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { const { canReadFiles } = fileAccess; const entriesQuery = useFileTreeEntries({ environmentId, - cwd: !canReadFiles || fileInspector.supported ? null : cwd, + cwd: !canReadFiles || hasFileInspector ? null : cwd, searchQuery, }); const handleReturnToThread = () => { @@ -463,16 +466,20 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { }; const handleSelectFile = (path: string) => { - if (environmentId === null || threadId === null) { + if (environmentId === null || cwd === null) { return; } const params = { environmentId: String(environmentId), - threadId: String(threadId), + ...(threadId === null ? { cwd, projectName } : { threadId: String(threadId) }), path: path.split("/").filter((segment) => segment.length > 0), }; + if (threadId === null) { + navigation.navigate("NewTaskFile", params); + return; + } const navigationAction = resolveFileSelectionNavigationAction({ - hasPersistentFileInspector: fileInspector.supported, + hasPersistentFileInspector: hasFileInspector, }); if (navigationAction === "replace") { navigation.dispatch(StackActions.replace("ThreadFile", params)); @@ -503,14 +510,14 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { }); }; useEffect(() => { - if (fileInspector.supported && cwd !== null && !revealedInspectorRef.current) { + if (hasFileInspector && cwd !== null && !revealedInspectorRef.current) { revealedInspectorRef.current = true; showAuxiliaryPane("inspector"); } - }, [cwd, fileInspector.supported, showAuxiliaryPane]); + }, [cwd, hasFileInspector, showAuxiliaryPane]); - if (selectedThread === null || environmentId === null || threadId === null) { - if (fileInspector.supported) { + if (environmentId === null || (threadId !== null && selectedThread === null)) { + if (hasFileInspector) { return ( ; } - if (fileInspector.supported) { + if (hasFileInspector) { return ( - {fileTree} + {fileTree} >(); - const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); - const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); - const deviceType = useDeviceType(); - const primaryColumn = use(NativePrimaryColumnContext); - const sidebarHeader = - Platform.OS === "ios" && - primaryColumn !== null && - (deviceType === "pad" || !usesNativeMailSearchToolbar); - const showsCompactCompose = deviceType === "pad" && !layout.usesSplitView; - const searchBarRef = useRef(null); - const focusAfterReveal = useRef(false); - useEffect( - () => - navigation.addListener("transitionEnd", (event) => { - if (focusAfterReveal.current && !event.data.closing) { - focusAfterReveal.current = false; - searchBarRef.current?.focus(); - } - }), - [navigation], - ); - const iconColor = useUniwindTheme()["--color-icon"]; - const hasCustomListOptions = - props.selectedEnvironmentId !== null || - props.selectedProjectKey !== null || - props.organisationKeys.length > 0 || - !isDefaultThreadListPages(props.pages); - const focusSearch = () => { - if (primaryColumn && !panes.primarySidebarVisible) { - focusAfterReveal.current = true; - togglePrimarySidebar(); - return true; - } - searchBarRef.current?.focus(); - return searchBarRef.current !== null; - }; - useHardwareKeyboardCommand("focusSearch", focusSearch); - const filterMenu = buildHomeListFilterMenu(props); - - if (props.usesWorkspaceToolbar) { - return deviceType === "duo-unfolded" ? ( - - ) : null; - } - - return ( - <> - - sidebarHeader - ? [ - ...createSidebarHeaderItems({ - filterIcon: hasCustomListOptions - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease.circle", - filterMenu, - onOpenSettings: props.onOpenSettings, - }), - ...(showsCompactCompose - ? [ - withNativeGlassHeaderItem({ - accessibilityLabel: "New task", - axisBehavior: "horizontalOnly", - icon: { name: "square.and.pencil", type: "sfSymbol" } as const, - identifier: "home-new-task", - label: "", - onPress: props.onStartNewTask, - type: "button", - }), - ] - : []), - ] - : [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Open settings", - icon: { name: "ellipsis", type: "sfSymbol" } as const, - identifier: "home-settings", - label: "", - onPress: props.onOpenSettings, - type: "button", - }), - ], - // The keys below are set per-branch (not `undefined`) so a later - // reapply cannot clobber options owned by Toolbar. - ...(sidebarHeader - ? { - headerSearchBarOptions: { - ref: searchBarRef, - autoCapitalize: "none" as const, - hideNavigationBar: false, - hideWhenScrolling: false, - obscureBackground: false, - placement: "stacked" as const, - allowToolbarIntegration: false, - placeholder: "Search", - onCancelButtonPress: () => props.onSearchQueryChange(""), - onChangeText: (event) => props.onSearchQueryChange(event.nativeEvent.text), - }, - unstable_headerToolbarItems: () => [], - } - : usesNativeMailSearchToolbar - ? { - headerSearchBarOptions: { - ref: searchBarRef, - autoCapitalize: "none" as const, - onCancelButtonPress: () => props.onSearchQueryChange(""), - }, - unstable_headerToolbarItems: () => [ - createNativeMailSearchToolbarItem({ - composeButtonId: "home-new-task", - composeSystemImageName: "square.and.pencil", - filterMenu, - filterButtonId: "home-filter", - filterSystemImageName: hasCustomListOptions - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease", - onComposePress: props.onStartNewTask, - onSearchTextChange: props.onSearchQueryChange, - placeholder: "Search", - searchTextChangeId: "home-search-text", - showsSearchDismissButton: true, - }), - ], - } - : { - headerSearchBarOptions: { - ref: searchBarRef, - autoCapitalize: "none" as const, - hideNavigationBar: false, - placeholder: "Search", - onCancelButtonPress: () => { - props.onSearchQueryChange(""); - }, - onChangeText: (event) => { - props.onSearchQueryChange(event.nativeEvent.text); - }, - }, - }), - }} - /> - - {sidebarHeader || usesNativeMailSearchToolbar ? null : ( - - - - Environment - props.onEnvironmentChange(null)} - subtitle="Show threads from every environment" - > - All environments - - {props.environments.map((environment) => ( - props.onEnvironmentChange(environment.environmentId)} - > - - {filterChoiceTitle(environment.label, environment.count)} - - - ))} - - - {props.organisations.length > 0 ? ( - - Organisations - - All organisations - - {props.organisations.map((organisation) => ( - props.onToggleOrganisation(organisation.key)} - > - - {filterChoiceTitle(organisation.label, organisation.count)} - - - ))} - - ) : null} - - {props.projects.length > 0 ? ( - - Project - props.onProjectChange(null)} - subtitle="Show threads from every project" - > - All projects - - {props.projects.map((project) => ( - props.onProjectChange(project.key)} - > - {filterChoiceTitle(project.label, project.count)} - - ))} - - ) : null} - - Show - {props.availablePages.map((page) => ( - props.onTogglePage(page)} - > - - {filterChoiceTitle( - threadListPageLabel(page, props.groups), - props.pageCounts?.get(page), - )} - - - ))} - {props.onCreateGroup ? ( - - New group… - - ) : null} - - - - - - )} - - ); +/** The iOS thread list lives in the workspace, whose WorkspaceToolbar owns its header and search. */ +export function HomeHeader(_props: HomeHeaderProps) { + return null; } diff --git a/apps/mobile/src/features/home/HomeHeader.types.ts b/apps/mobile/src/features/home/HomeHeader.types.ts index 579e449bfe348..6ae7c359c2be8 100644 --- a/apps/mobile/src/features/home/HomeHeader.types.ts +++ b/apps/mobile/src/features/home/HomeHeader.types.ts @@ -10,7 +10,6 @@ import type { export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; export interface HomeHeaderProps { - readonly usesWorkspaceToolbar?: boolean; readonly environments: ReadonlyArray; readonly projects: ReadonlyArray; readonly searchQuery: string; diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 5a0a2f6bc556f..220318646ac48 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -1,3 +1,4 @@ +import { uuidv4 } from "../../lib/uuid"; import { ThreadUndoNotice } from "./ThreadUndoNotice"; import { Toolbar } from "../../components/ui/Toolbar"; import { use } from "react"; @@ -5,7 +6,7 @@ import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Platform, useWindowDimensions } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; @@ -30,7 +31,7 @@ import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; import { useHomeListOptions } from "./home-list-options"; import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility"; -import { useHomeThreadSelection } from "./home-thread-navigation"; +import { useHomeThreadRouteSelection, useHomeThreadSelection } from "./home-thread-navigation"; import { buildHomeProjectScopes } from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; @@ -44,7 +45,7 @@ export function HomeRouteScreen() { const columnMetrics = useNativeColumnLayoutMetrics(); const deviceType = useDeviceType(); const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth; - const { layout, panes, primarySidebarSearchQuery, setPrimarySidebarSearchQuery } = + const { layout, panes, primarySidebarSearchQuery, setPrimarySidebarSearchQuery, startNewTask } = useAdaptiveWorkspaceLayout(); const projects = useProjects(); // Streaming turns rewrite thread shells many times a second. While a Thread @@ -77,17 +78,21 @@ export function HomeRouteScreen() { else setLocalSearchQuery(query); }; const handleSelectThread = useHomeThreadSelection(); - const handleNewThreadOnBranch = (thread: EnvironmentThreadShell) => { - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { - environmentId: String(thread.environmentId), - projectId: String(thread.projectId), - branch: thread.branch, - worktreePath: thread.worktreePath, - }, - }); - }; + const selectThreadRoute = useHomeThreadRouteSelection(); + const handleNewThreadOnBranch = useCallback( + (thread: EnvironmentThreadShell) => { + selectThreadRoute({ + draft: { + requestId: uuidv4(), + environmentId: String(thread.environmentId), + projectId: String(thread.projectId), + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + }); + }, + [selectThreadRoute], + ); useEffect(() => { void checkForAppUpdateOnLaunch(); @@ -219,7 +224,7 @@ export function HomeRouteScreen() { navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onPress={startNewTask} /> ) : null} @@ -235,9 +240,7 @@ export function HomeRouteScreen() { : undefined } onStartNewTask={ - Platform.OS === "android" && panes.primarySidebarVisible - ? undefined - : () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }) + Platform.OS === "android" && panes.primarySidebarVisible ? undefined : startNewTask } /> @@ -247,7 +250,7 @@ export function HomeRouteScreen() { return ( navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onStartNewTask={startNewTask} > <> {/* Restore the header after leaving split view; screen options are @@ -270,7 +273,6 @@ export function HomeRouteScreen() { /> )} navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onStartNewTask={startNewTask} /> { - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { + selectThreadRoute({ + draft: { + requestId: uuidv4(), environmentId: String(project.environmentId), projectId: String(project.id), title: project.title, }, }); }} - onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onStartNewTask={startNewTask} pendingTasks={pendingTasks} projectGroupingMode={listOptions.projectGroupingMode} projects={projects} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 3611a01174fd4..28f1d78c84c8e 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,9 +1,12 @@ import type { ThreadGroups } from "@t3tools/contracts/settings"; +import { useActiveNewTask } from "../../state/active-new-task"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; import type { ThreadMoveDestination } from "../threads/threadOrder"; import { computeThreadMoveAvailability } from "../threads/threadOrder"; -import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; +import type { LegendListRef } from "@legendapp/list/react-native"; +import { AnimatedLegendList } from "@legendapp/list/reanimated"; +import { THREAD_SIDEBAR_ROW_LAYOUT } from "../threads/threadListAnimations"; import { type EnvironmentProject, type EnvironmentThreadShell, @@ -47,6 +50,7 @@ import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects"; +import { useRestingTopOffset } from "../../native/useRestingTopOffset"; import { useNativeColumnLayoutMetrics, useNativeLayoutMetrics, @@ -261,6 +265,7 @@ function HomeTopContentSpacer() { function HomeScrollView(props: ComponentProps) { const insets = useSafeAreaInsets(); const primaryColumn = use(NativePrimaryColumnContext); + const restingOffset = useRestingTopOffset(insets.top, props); if (Platform.OS !== "ios") return ; // v5 needs the actual content scroll view registered with its screen controller. @@ -275,6 +280,7 @@ function HomeScrollView(props: ComponentProps) { > ) { // past its edge, activating scroll-edge protection before any scroll. automaticallyAdjustContentInsets={false} contentInsetAdjustmentBehavior="never" - contentOffset={props.contentOffset ?? { x: 0, y: -insets.top }} /> ); @@ -305,7 +310,8 @@ export function HomeScreen(props: HomeScreenProps) { Platform.OS === "android" ? "bg-header" : contentBackground, ); const columnMetrics = useNativeColumnLayoutMetrics(); - const selectedThreadKey = primaryColumn?.selectedThreadKey ?? null; + const activeNewTask = useActiveNewTask(); + const selectedThreadKey = activeNewTask ? null : (primaryColumn?.selectedThreadKey ?? null); const fullSwipeWidth = primaryColumn && columnMetrics ? columnMetrics.width - 20 : undefined; const queuedThreadKeys = useQueuedThreadKeys(); const draftCounts = useMessageDraftCounts(); @@ -677,7 +683,14 @@ export function HomeScreen(props: HomeScreenProps) { ); return ( ); } @@ -814,6 +832,7 @@ export function HomeScreen(props: HomeScreenProps) { // every HomeScreen render — and the minute clock must stay out of it for // the same reason: the clock text is precomputed per item instead. const v2ExtraData = (() => ({ + activeNewTask, projectByKey, projectTitleByProjectKey: v2ProjectTitleByProjectKey, listEnvironments, @@ -969,7 +988,8 @@ export function HomeScreen(props: HomeScreenProps) { > - activateVisibleRows(threadListV2Items)} onTouchStart={(event) => trackListTouches(event, true)} diff --git a/apps/mobile/src/features/home/home-thread-navigation.test.ts b/apps/mobile/src/features/home/home-thread-navigation.test.ts index 3f2b4a4fee4ff..820a666650548 100644 --- a/apps/mobile/src/features/home/home-thread-navigation.test.ts +++ b/apps/mobile/src/features/home/home-thread-navigation.test.ts @@ -26,7 +26,10 @@ vi.mock("@react-navigation/native", () => { return { CommonActions, StackActions }; }); -import { createHomeThreadNavigationAction } from "./home-thread-navigation"; +import { + createHomeThreadRouteAction, + createHomeThreadNavigationAction, +} from "./home-thread-navigation"; const { StackActions, StackRouter } = loadRouters(); const routeNames = ["Home", "Thread"]; @@ -196,3 +199,36 @@ describe("createHomeThreadNavigationAction", () => { }); }); }); + +describe("draft and live thread selection", () => { + it("keeps the screen key and clears old identity when switching both ways", () => { + const live = selectThread(initialState(), thread("thread-a")); + const key = live.routes[live.index]!.key; + const draft = apply( + live, + createHomeThreadRouteAction({ + state: live, + dismissingRouteKey: null, + params: { draft: { draftId: "new-task:draft-a" } }, + }), + ); + expect(draft.routes[draft.index]).toMatchObject({ + key, + params: { + environmentId: undefined, + threadId: undefined, + draft: { draftId: "new-task:draft-a" }, + }, + }); + const restored = selectThread(draft, thread("thread-b")); + expect(restored.routes[restored.index]).toMatchObject({ + key, + params: { + environmentId: "environment-1", + threadId: "thread-b", + draft: undefined, + }, + }); + expect(restored.routes).toHaveLength(live.routes.length); + }); +}); diff --git a/apps/mobile/src/features/home/home-thread-navigation.ts b/apps/mobile/src/features/home/home-thread-navigation.ts index 317b680591571..94948bb8f5ac8 100644 --- a/apps/mobile/src/features/home/home-thread-navigation.ts +++ b/apps/mobile/src/features/home/home-thread-navigation.ts @@ -12,27 +12,44 @@ import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSe type ThreadSelection = Pick; -export function createHomeThreadNavigationAction(input: { +export function createHomeThreadRouteAction(input: { readonly state: Pick; readonly dismissingRouteKey: string | null; - readonly thread: ThreadSelection; + readonly params: ReactNavigation.RootParamList["Thread"]; }) { const currentRoute = input.state.routes[input.state.index]; - const params = { - environmentId: input.thread.environmentId, - threadId: input.thread.id, - }; - - // Native swipe-back pops the outgoing route after its animation. Reusing - // that key would also discard this selection when the dismissal arrives. if (input.dismissingRouteKey !== null && currentRoute?.key === input.dismissingRouteKey) { - return StackActions.push("Thread", params); + return StackActions.push("Thread", input.params); + } + if (currentRoute?.name === "Thread") { + return { + ...CommonActions.setParams({ + environmentId: undefined, + threadId: undefined, + draft: undefined, + ...input.params, + }), + source: currentRoute.key, + }; } + return CommonActions.navigate("Thread", input.params); +} - return CommonActions.navigate("Thread", params); +export function createHomeThreadNavigationAction(input: { + readonly state: Pick; + readonly dismissingRouteKey: string | null; + readonly thread: ThreadSelection; +}) { + return createHomeThreadRouteAction({ + ...input, + params: { + environmentId: input.thread.environmentId, + threadId: input.thread.id, + }, + }); } -export function useHomeThreadSelection() { +export function useHomeThreadRouteSelection() { const navigation = useNavigation>(); const dismissingRouteKey = useRef(null); @@ -60,22 +77,31 @@ export function useHomeThreadSelection() { }, [navigation]); return useCallback( - (thread: ThreadSelection) => { + (params: ReactNavigation.RootParamList["Thread"]) => { if (Platform.OS === "android") { selectAndroidWorkspace({ name: "Thread", - params: { environmentId: String(thread.environmentId), threadId: String(thread.id) }, + params, }); return; } navigation.dispatch((state) => - createHomeThreadNavigationAction({ + createHomeThreadRouteAction({ state, dismissingRouteKey: dismissingRouteKey.current, - thread, + params, }), ); }, [navigation, selectAndroidWorkspace], ); } + +export function useHomeThreadSelection() { + const selectRoute = useHomeThreadRouteSelection(); + return useCallback( + (thread: ThreadSelection) => + selectRoute({ environmentId: thread.environmentId, threadId: thread.id }), + [selectRoute], + ); +} diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index a320e7b21e37b..33c4a44d05244 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -62,6 +62,7 @@ interface ThreadSwipeAction { readonly accessibilityLabel: string; readonly icon: ComponentProps["name"]; readonly label: string; + readonly destructive?: boolean; readonly menu?: { readonly actions: MenuAction[]; readonly onPressAction: NonNullable["onPressAction"]>; @@ -726,7 +727,7 @@ export function ThreadSwipeActions(props: { void, +): { readonly openPendingTask: (pendingTask: PendingNewTask) => void; readonly confirmDeletePendingTask: (pendingTask: PendingNewTask) => void; } { - const navigation = useNavigation(); + const selectHomeRoute = useHomeThreadRouteSelection(); + const selectThreadRoute = selectRoute ?? selectHomeRoute; const openPendingTask = useCallback( (pendingTask: PendingNewTask) => { - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { - environmentId: String(pendingTask.environmentId), - projectId: String(pendingTask.projectId), - ...(pendingTask.kind === "pending" - ? { pendingTaskId: String(pendingTask.message.messageId) } - : { draftId: pendingTask.draftKey }), - }, - }); + selectThreadRoute( + pendingTask.kind === "pending" + ? { + environmentId: String(pendingTask.environmentId), + threadId: String(pendingTask.message.threadId), + } + : { + draft: { + environmentId: String(pendingTask.environmentId), + projectId: String(pendingTask.projectId), + draftId: pendingTask.draftKey, + }, + }, + ); }, - [navigation], + [selectThreadRoute], ); const confirmDeletePendingTask = useCallback((pendingTask: PendingNewTask) => { @@ -37,10 +50,15 @@ export function usePendingTaskListActions(): { text: "Discard", style: "destructive", onPress: () => { - clearComposerDraftContent(pendingTask.draftKey, { - clearModelSelection: true, - clearWorkspaceSelection: true, - }); + void withThreadDismissal( + pendingTask.key, + async () => + clearComposerDraftContent(pendingTask.draftKey, { + clearModelSelection: true, + clearWorkspaceSelection: true, + }), + () => true, + ); }, }, ]); @@ -55,12 +73,28 @@ export function usePendingTaskListActions(): { text: "Delete", style: "destructive", onPress: () => { - // Release the edit lock only after removal succeeds, and only if - // it is held for THIS task — clearing it up front (or for another - // task) would let the drain deliver a mid-edit payload. - void removeThreadOutboxMessage(pendingTask.message) - .then(() => releaseEditingQueuedMessage(pendingTask.message.messageId)) + // The row animates out before removal runs, so hold delivery from + // confirmation on: a reconnect must not send a task being deleted. + // The hold is shared with the editor, which could release it + // mid-animation, so a task open in the editor is removed at once + // and keeps its hold unless removal succeeds. + const messageId = pendingTask.message.messageId; + const heldByEditor = appAtomRegistry.get(editingQueuedMessageIdsAtom)[messageId]; + holdEditingQueuedMessage(messageId); + const releaseUnlessEditing = () => { + if (!heldByEditor) releaseEditingQueuedMessage(messageId); + }; + const remove = () => removeThreadOutboxMessage(pendingTask.message); + void ( + heldByEditor + ? remove() + : withThreadDismissal(pendingTask.key, remove, (removed) => removed) + ) + .then((removed) => + removed ? releaseEditingQueuedMessage(messageId) : releaseUnlessEditing(), + ) .catch((error) => { + releaseUnlessEditing(); Dialog.alert( "Could not delete pending task", error instanceof Error ? error.message : "The pending task could not be removed.", diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 3f22b27202d91..7a4e2c96ac2d2 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -504,10 +504,15 @@ export function useThreadListActions(): { // Pinning clears snooze, so older Settled or Snoozed undos would fight this pin. ThreadUndo.invalidate("settle", scopedThreadKey(thread.environmentId, thread.id)); ThreadUndo.invalidate("snooze", scopedThreadKey(thread.environmentId, thread.id)); - const result = await pinMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) }, - }); + const result = await withThreadDismissal( + scopedThreadKey(thread.environmentId, thread.id), + () => + pinMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { const error = Cause.squash(result.cause); Dialog.alert( @@ -532,10 +537,15 @@ export function useThreadListActions(): { selectionHaptic(); const plan = ThreadUndo.planThreadUndo("unpin", thread); const claim = ThreadUndo.begin("pin", scopedThreadKey(thread.environmentId, thread.id)); - const result = await unpinMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id }, - }); + const result = await withThreadDismissal( + scopedThreadKey(thread.environmentId, thread.id), + () => + unpinMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { claim.finish(); const error = Cause.squash(result.cause); diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 819dbf9c8614c..7cff89b0466a5 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -1,8 +1,9 @@ +import { uuidv4 } from "../../lib/uuid"; import { useNavigation } from "@react-navigation/native"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { FlatList, KeyboardAvoidingView, @@ -22,7 +23,9 @@ import { RowPressable } from "../../components/ui/RowPressable"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; import { cn } from "../../lib/cn"; +import { deriveBottomControlInsets } from "../../lib/reserved-regions"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; +import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; import { useProjects, useThreadShell, useThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; @@ -140,7 +143,7 @@ export function CommandPalette(props: { }) { const navigation = useNavigation(); const { themeVariables } = useAppearancePreferences(); - const { selectThread } = useAdaptiveWorkspaceLayout(); + const { selectThread, selectThreadRoute } = useAdaptiveWorkspaceLayout(); const runCommand = props.onCommand; const projects = useProjects(); const threads = useThreadShells(); @@ -156,6 +159,7 @@ export function CommandPalette(props: { const inputRef = useRef(null); const listRef = useRef>(null); const { width, height } = useWindowDimensions(); + const bounds = deriveBottomControlInsets(useNativeLayoutMetrics()); const searchEnvironmentIds = (() => environments .filter((environment) => environment.connectionState === "connected") @@ -181,14 +185,14 @@ export function CommandPalette(props: { kind: "action", title: "New thread in…", searchTerms: ["new task", "chat", "create", "project"], - run: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), + run: () => runCommand("newTask"), }, { key: "addProject", kind: "action", title: "Add project", searchTerms: ["folder", "clone", "repository", "git"], - run: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }), + run: () => navigation.navigate("AddProject"), }, { key: "settings", @@ -270,9 +274,9 @@ export function CommandPalette(props: { title: `New thread in ${activeProject.title}`, searchTerms: ["new task", "chat", "create"], run: () => - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { + selectThreadRoute({ + draft: { + requestId: uuidv4(), environmentId: activeProject.environmentId, projectId: activeProject.id, title: activeProject.title, @@ -304,9 +308,9 @@ export function CommandPalette(props: { detail: `New thread · ${savedConnectionsById[project.environmentId]?.environmentLabel ?? project.environmentId}`, searchTerms: [project.workspaceRoot, "new thread", "project"], run: () => - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { + selectThreadRoute({ + draft: { + requestId: uuidv4(), environmentId: project.environmentId, projectId: project.id, title: project.title, @@ -407,7 +411,8 @@ export function CommandPalette(props: { void; + readonly deferThreadSelectionUntil: (operation: Promise) => void; readonly selectThread: (thread: EnvironmentThreadShell) => void; + readonly selectThreadRoute: (params: ReactNavigation.RootParamList["Thread"]) => void; readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void; readonly setPrimarySidebarSearchQuery: (query: string) => void; @@ -126,7 +135,10 @@ const AdaptiveWorkspaceContext = createContext({ panes: compactPanes, fileInspector: compactFileInspector, primarySidebarSearchQuery: "", + startNewTask: () => undefined, + deferThreadSelectionUntil: () => undefined, selectThread: () => undefined, + selectThreadRoute: () => undefined, activateAuxiliaryPaneRole: () => () => undefined, registerWorkspaceInspector: () => () => undefined, setPrimarySidebarSearchQuery: () => undefined, @@ -485,10 +497,94 @@ function AdaptiveWorkspaceLayoutContent( }); }; + const selectAndroidWorkspace = useAndroidWorkspaceSelection(); + const deferredThreadSelection = useMemo(() => createDeferredNavigation(), []); + function handleSelectThreadRoute(params: ReactNavigation.RootParamList["Thread"]) { + deferredThreadSelection.run(() => applyThreadRouteSelection(params)); + } + function applyThreadRouteSelection(params: ReactNavigation.RootParamList["Thread"]) { + if (Platform.OS === "android") { + setFileInspectorPreferredVisible(false); + selectAndroidWorkspace({ name: "Thread", params }); + return; + } + const navigationAction = resolveThreadSelectionNavigationAction({ + usesSplitView: layout.usesSplitView, + pathname, + workspaceRouteName: navigation + .getState() + ?.routes.find((route) => route.key === props.workspaceRouteKey)?.name, + }); + const overlayState = resolveThreadSelectionOverlayState({ + state: navigation.getState(), + workspaceRouteKey: props.workspaceRouteKey, + action: navigationAction, + params, + }); + if (overlayState !== null) { + setFileInspectorPreferredVisible(false); + navigation.dispatch(CommonActions.reset(overlayState)); + return; + } + if (navigationAction === "set-params") { + setFileInspectorPreferredVisible(false); + const state = navigation.getState(); + const route = state?.routes.find((candidate) => candidate.key === props.workspaceRouteKey); + if (route?.name === "Thread") { + navigation.dispatch({ + ...CommonActions.setParams({ + environmentId: undefined, + threadId: undefined, + draft: undefined, + ...params, + }), + source: route.key, + }); + } else { + navigation.navigate("Thread", params); + } + return; + } + if (navigationAction === "replace") { + setFileInspectorPreferredVisible(false); + navigation.dispatch(StackActions.replace("Thread", params)); + return; + } + navigation.navigate("Thread", params); + } + + const projects = useProjects(); const handleStartNewTask = () => { - navigation.navigate("NewTaskSheet", { screen: "NewTask" }); + const thread = appAtomRegistry + .get(environmentThreadShells.navigationThreadShellsAtom) + .find((candidate) => candidate.environmentId === environmentId && candidate.id === threadId); + const currentRoute = navigation + .getState() + ?.routes.find((route) => route.key === props.workspaceRouteKey); + const draft = + currentRoute?.name === "Thread" + ? (currentRoute.params as ReactNavigation.RootParamList["Thread"])?.draft + : undefined; + const first = (value: string | string[] | undefined) => + Array.isArray(value) ? value[0] : value; + const draftEnvironmentId = first(draft?.environmentId); + const draftProjectId = first(draft?.projectId); + const currentProject = thread + ? { environmentId: thread.environmentId, id: thread.projectId } + : projects.find( + (project) => + project.environmentId === draftEnvironmentId && project.id === draftProjectId, + ); + handleSelectThreadRoute( + newTaskDestination(layout.usesSplitView, projects, currentProject, uuidv4()), + ); }; + useHardwareKeyboardCommand("newTask", () => { + handleStartNewTask(); + return true; + }); + // Minted here (root stack navigation) so the sidebar pane stays free of // navigation hooks — on iOS it renders inside an independent nav tree. const handleOpenEnvironmentSettings = () => { @@ -499,12 +595,13 @@ function AdaptiveWorkspaceLayoutContent( }; const handleOpenThreadPullRequests = useOpenThreadPullRequests(); - const { openPendingTask: handleOpenPendingTask } = usePendingTaskListActions(); + const { openPendingTask: handleOpenPendingTask } = + usePendingTaskListActions(handleSelectThreadRoute); const handleNewThreadOnBranch = (thread: EnvironmentThreadShell) => { - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { + handleSelectThreadRoute({ + draft: { + requestId: uuidv4(), environmentId: String(thread.environmentId), projectId: String(thread.projectId), branch: thread.branch, @@ -514,9 +611,9 @@ function AdaptiveWorkspaceLayoutContent( }; const handleNewThreadInProject = (project: EnvironmentProject) => { - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { + handleSelectThreadRoute({ + draft: { + requestId: uuidv4(), environmentId: String(project.environmentId), projectId: String(project.id), title: project.title, @@ -684,47 +781,11 @@ function AdaptiveWorkspaceLayoutContent( ), ); - const selectAndroidWorkspace = useAndroidWorkspaceSelection(); const handleSelectThread = (thread: EnvironmentThreadShell) => { - const params = { + handleSelectThreadRoute({ environmentId: String(thread.environmentId), threadId: String(thread.id), - }; - if (Platform.OS === "android") { - setFileInspectorPreferredVisible(false); - selectAndroidWorkspace({ name: "Thread", params }); - return; - } - const navigationAction = resolveThreadSelectionNavigationAction({ - usesSplitView: layout.usesSplitView, - pathname, - }); - const overlayState = resolveThreadSelectionOverlayState({ - state: navigation.getState(), - workspaceRouteKey: props.workspaceRouteKey, - action: navigationAction, - params, }); - if (overlayState !== null) { - setFileInspectorPreferredVisible(false); - navigation.dispatch(CommonActions.reset(overlayState)); - return; - } - if (navigationAction === "set-params") { - const nextThreadKey = scopedThreadKey(thread.environmentId, thread.id); - if (nextThreadKey === selectedThreadKey) { - return; - } - setFileInspectorPreferredVisible(false); - navigation.navigate("Thread", params); - return; - } - if (navigationAction === "replace") { - setFileInspectorPreferredVisible(false); - navigation.dispatch(StackActions.replace("Thread", params)); - return; - } - navigation.navigate("Thread", params); }; const contextValue = { @@ -732,7 +793,10 @@ function AdaptiveWorkspaceLayoutContent( panes, fileInspector, primarySidebarSearchQuery, + startNewTask: handleStartNewTask, + deferThreadSelectionUntil: deferredThreadSelection.waitFor, selectThread: handleSelectThread, + selectThreadRoute: handleSelectThreadRoute, activateAuxiliaryPaneRole, registerWorkspaceInspector, setPrimarySidebarSearchQuery, @@ -881,6 +945,7 @@ function AdaptiveWorkspaceLayoutContent( style={railOverlay ? { right: 0 } : { width: navigationRailWidth }} > void; readonly onAddConnection?: () => void; }) { + const pullRequests = props.tab === "pullRequests"; return ( - Select a thread + + {pullRequests ? "Select a pull request" : "Select a thread"} + - {Platform.OS === "android" - ? "Choose a thread from the sidebar or start a new thread." - : "Choose a thread from the sidebar or start a new task."} + {pullRequests + ? "Choose a pull request from the sidebar to review it." + : Platform.OS === "android" + ? "Choose a thread from the sidebar or start a new thread." + : "Choose a thread from the sidebar or start a new task."} - {props.onStartNewTask ? ( + {props.onStartNewTask && !pullRequests ? ( Platform.OS === "android" ? ( ) : ( @@ -58,7 +65,7 @@ export function WorkspaceEmptyDetail(props: { className="mt-2 flex-row items-center gap-2 rounded-full bg-primary px-5 py-3 active:opacity-70" onPress={props.onStartNewTask} > - New Task + New thread ) ) : null} diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index 1bb5f80facb0e..28b816992f9bd 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -14,7 +14,6 @@ import { } from "@react-navigation/native"; import { NativeStackView, - type NativeStackHeaderItemMenu, type NativeStackNavigationEventMap, type NativeStackNavigationOptions, type NativeStackNavigatorProps, @@ -25,9 +24,10 @@ import { View } from "react-native"; import { Split, type SplitHostCommands } from "react-native-screens"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import { WorkspaceTabsProvider } from "../workspace/WorkspaceTabsContext"; +import { WorkspaceTabsProvider, useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { WorkspaceNativeTabs } from "../workspace/WorkspaceNativeTabs.ios"; -import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; +import { isDuoDevice } from "../../lib/device-type"; +import { useDeviceType, useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; import { WorkspaceEmptyDetail } from "./WorkspaceEmptyDetail"; @@ -48,6 +48,7 @@ import { nativeWorkspacePopAction, projectWorkspaceStack, partitionStackPresentations, + reconcileStackScreens, type CollapsedSplitColumn, } from "../../native/workspace-stack-projection"; import { @@ -60,14 +61,20 @@ type ViewProps = ComponentProps; type Descriptor = ViewProps["descriptors"][string]; type Route = ViewProps["state"]["routes"][number]; +const isPullRequestRoute = (route: Route) => + route.name === "PullRequest" || + route.name === "PullRequestFile" || + route.name === "PullRequestFiles"; + function ColumnScreen(props: { readonly descriptor: Descriptor; readonly primary?: boolean; + readonly attached?: boolean; readonly selectedThreadKey?: string | null; readonly selectedPullRequestKey?: string | null; - readonly sidebarFilter?: NativeStackHeaderItemMenu; - readonly onDismiss: (routeKey: string) => void; + readonly onDismiss: (routeKey: string, native: boolean) => void; readonly onAppearance: (type: "willAppear" | "didAppear" | "willDisappear", key: string) => void; + readonly onCompose?: () => void; readonly navigation: ViewProps["navigation"]; }) { const { descriptor } = props; @@ -82,8 +89,9 @@ function ColumnScreen(props: { return ( { - if (event.nativeEvent.isNativeDismiss) props.onDismiss(key); + props.onDismiss(key, event.nativeEvent.isNativeDismiss); }} onWillAppear={() => { props.onAppearance("willAppear", key); @@ -106,8 +114,14 @@ function ColumnScreen(props: { (), + }); + if ( + screens.observedRoutes !== props.detail || + screens.observedDescriptors !== props.descriptors + ) { + const routes = reconcileStackScreens(screens.routes, props.detail, screens.completed); + const retained = new Set(routes.map((route) => route.key)); + const active = new Set(props.detail.map((route) => route.key)); + setScreens({ + routes, + descriptors: Object.fromEntries( + Object.entries({ ...screens.descriptors, ...props.descriptors }).filter(([key]) => + retained.has(key), + ), + ), + observedRoutes: props.detail, + observedDescriptors: props.descriptors, + completed: new Set([...screens.completed].filter((key) => active.has(key))), + }); + } + + const selectedTab = useWorkspaceTabs()?.state.selectedTab; + const openNewTask = startNewTask; + // The Duo keeps compose in the detail column's bar, which is its vertical bar in landscape + // and folded, so it sits with the column's other actions whichever screen is shown. Pull + // requests leave it out, including one opened from a thread; their bar is already full of + // review actions. + const detailCompose = + isDuoDevice(deviceType) && selectedTab !== "pullRequests" ? openNewTask : undefined; const inspector = use(NativeWorkspaceInspectorContext); const hostRef = useRef(null); const activeDetailKey = props.detail.at(-1)?.key; @@ -136,12 +188,7 @@ function WorkspaceColumns( const collapsed = useRef(!layout.usesSplitView); const collapsedColumn = useRef({ visible: compactColumn, leaving: null }); const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params; - const pullRequestParams = props.detail.findLast( - (route) => - route.name === "PullRequest" || - route.name === "PullRequestFile" || - route.name === "PullRequestFiles", - )?.params; + const pullRequestParams = props.detail.findLast(isPullRequestRoute)?.params; const selectedPullRequestKey = pullRequestParams && "environmentId" in pullRequestParams && @@ -166,10 +213,17 @@ function WorkspaceColumns( ) : null; const handleNativeDismiss = useCallback( - (key: string) => { - const state = props.navigation.getState(); - const action = nativeWorkspacePopAction(state, key); - if (action) props.navigation.dispatch(action); + (key: string, native: boolean) => { + setScreens((current) => ({ + ...current, + routes: current.routes.filter((route) => route.key !== key), + completed: new Set([...current.completed, key]), + })); + if (native) { + const state = props.navigation.getState(); + const action = nativeWorkspacePopAction(state, key); + if (action) props.navigation.dispatch(action); + } }, [props.navigation], ); @@ -196,7 +250,6 @@ function WorkspaceColumns( const primary = props.descriptors[props.primary.key]; if (!primary) return null; - const sidebarFilter = (primary.options as AppNativeStackNavigationOptions).nativeSidebarFilter; return ( - props.navigation.navigate("NewTaskSheet", { screen: "NewTask" }) + tab={selectedTab} + onStartNewTask={ + // Duo provides compose in the detail bar; iPad uses the empty-state action. + detailCompose ? undefined : openNewTask } /> - {props.detail.map((route) => { - const descriptor = props.descriptors[route.key]; + {screens.routes.map((route) => { + const descriptor = props.descriptors[route.key] ?? screens.descriptors[route.key]; return descriptor ? ( current.key === route.key)} onDismiss={handleNativeDismiss} onAppearance={followCollapsedSplit} + onCompose={isPullRequestRoute(route) ? undefined : detailCompose} navigation={props.navigation} - sidebarFilter={sidebarFilter} /> ) : null; })} @@ -288,7 +348,9 @@ function WorkspaceColumns( @@ -297,7 +359,7 @@ function WorkspaceColumns( } function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean }) { - const { layout } = useAdaptiveWorkspaceLayout(); + const { layout, startNewTask } = useAdaptiveWorkspaceLayout(); // Native dismiss commits the route pop after the transition. Restore the dock // when the primary screen starts appearing, including interactive back gestures. const [primaryVisible, setPrimaryVisible] = useState(false); @@ -324,7 +386,7 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean options: { headerShown: false }, render: () => ( props.navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onNewTask={startNewTask} hidden={ projection.detail.length > 0 && !primaryVisible && diff --git a/apps/mobile/src/features/layout/deferred-navigation.test.ts b/apps/mobile/src/features/layout/deferred-navigation.test.ts new file mode 100644 index 0000000000000..4e4c54cdb5945 --- /dev/null +++ b/apps/mobile/src/features/layout/deferred-navigation.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vite-plus/test"; +import { createDeferredNavigation } from "./deferred-navigation"; + +describe("workspace mutation navigation", () => { + it("keeps only the latest selection until all checkouts settle", async () => { + const navigation = createDeferredNavigation(); + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + const selected: string[] = []; + navigation.waitFor(first.promise); + navigation.waitFor(second.promise); + navigation.run(() => selected.push("old draft")); + navigation.run(() => selected.push("latest draft")); + first.resolve(); + await first.promise; + expect(selected).toEqual([]); + second.resolve(); + await second.promise; + expect(selected).toEqual(["latest draft"]); + navigation.run(() => selected.push("thread")); + expect(selected).toEqual(["latest draft", "thread"]); + }); + + it("waits for the removal guard commit after checkout completes", async () => { + const navigation = createDeferredNavigation(); + const checkout = Promise.withResolvers(); + const guardCommit = Promise.withResolvers(); + let selected = false; + const ready = Promise.allSettled([checkout.promise, guardCommit.promise]); + navigation.waitFor(ready); + navigation.run(() => { + selected = true; + }); + checkout.resolve(); + await checkout.promise; + expect(selected).toBe(false); + guardCommit.resolve(); + await ready; + expect(selected).toBe(true); + }); + + it("releases selection after a failed checkout", async () => { + const navigation = createDeferredNavigation(); + const checkout = Promise.withResolvers(); + let selected = false; + navigation.waitFor(checkout.promise); + navigation.run(() => { + selected = true; + }); + checkout.reject(new Error("Checkout failed")); + await checkout.promise.catch(() => undefined); + expect(selected).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/layout/deferred-navigation.ts b/apps/mobile/src/features/layout/deferred-navigation.ts new file mode 100644 index 0000000000000..8da68b10b394c --- /dev/null +++ b/apps/mobile/src/features/layout/deferred-navigation.ts @@ -0,0 +1,22 @@ +/** Hold in-place route changes while a workspace mutation is still running. */ +export function createDeferredNavigation() { + let pending = 0; + let latest: (() => void) | null = null; + return { + run(action: () => void) { + if (pending > 0) latest = action; + else action(); + }, + waitFor(operation: Promise) { + pending += 1; + const release = () => { + pending -= 1; + if (pending !== 0) return; + const action = latest; + latest = null; + action?.(); + }; + void operation.then(release, release); + }, + }; +} diff --git a/apps/mobile/src/features/layout/useWorkspaceSidebarHeaderItem.ts b/apps/mobile/src/features/layout/useWorkspaceSidebarHeaderItem.ts new file mode 100644 index 0000000000000..f1304ed276633 --- /dev/null +++ b/apps/mobile/src/features/layout/useWorkspaceSidebarHeaderItem.ts @@ -0,0 +1,27 @@ +import { useMemo } from "react"; +import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; +import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; +import { withNativeGlassHeaderItem } from "./native-glass-header-items"; + +export function useWorkspaceSidebarHeaderItem() { + const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); + const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + return useMemo( + () => + withNativeGlassHeaderItem({ + axisBehavior: usesNativeWorkspaceColumns ? "horizontalOnly" : undefined, + accessibilityLabel: panes.primarySidebarVisible + ? "Maximize content" + : "Show thread sidebar", + icon: { + name: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left", + type: "sfSymbol" as const, + }, + identifier: "thread-left-sidebar", + label: panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar", + onPress: togglePrimarySidebar, + type: "button" as const, + }), + [panes.primarySidebarVisible, togglePrimarySidebar, usesNativeWorkspaceColumns], + ); +} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 8dc8750ab0c52..66a68b2ebae27 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -54,7 +54,13 @@ import { resolveEnvironmentMachineKind, SourceControlProviderKind, } from "@t3tools/contracts"; -import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; +import { + CommonActions, + StackActions, + useNavigation, + type NavigationState, + type ParamListBase, +} from "@react-navigation/native"; import { SymbolView } from "../../components/ui/AppSymbol"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { Platform, Pressable, View } from "react-native"; @@ -648,7 +654,10 @@ export function AddProjectSourceScreen() { } function openNewTaskDraft( - navigation: { dispatch: (action: ReturnType) => void }, + navigation: { + dispatch: (action: ReturnType) => void; + getState: () => NavigationState | undefined; + }, params: { environmentId: EnvironmentId; projectId: ProjectId; @@ -657,9 +666,14 @@ function openNewTaskDraft( draftId?: string; }, ) { - navigation.dispatch( - CommonActions.reset({ index: 0, routes: [{ name: "NewTaskDraft", params }] }), - ); + const state = navigation.getState(); + if (!state) return; + const threadIndex = state.routes.findLastIndex((route) => route.name === "Thread"); + const routes = [ + ...state.routes.slice(0, threadIndex < 0 ? state.index : threadIndex), + { key: uuidv4(), name: "Thread", params: { draft: params } }, + ]; + navigation.dispatch(CommonActions.reset({ ...state, index: routes.length - 1, routes })); } async function draftWithPrompt( @@ -698,21 +712,11 @@ function useCreateProject(environment: EnvironmentOption | null) { }); if (existing) { Dialog.alert("Project already exists", existing.title); - navigation.dispatch( - CommonActions.reset({ - index: 0, - routes: [ - { - name: "NewTaskDraft", - params: { - environmentId: existing.environmentId, - projectId: existing.id, - title: existing.title, - }, - }, - ], - }), - ); + openNewTaskDraft(navigation, { + environmentId: existing.environmentId, + projectId: existing.id, + title: existing.title, + }); return; } @@ -729,21 +733,11 @@ function useCreateProject(environment: EnvironmentOption | null) { if (AsyncResult.isFailure(result)) { return result; } - navigation.dispatch( - CommonActions.reset({ - index: 0, - routes: [ - { - name: "NewTaskDraft", - params: { - environmentId: environment.environmentId, - projectId, - title: inferProjectTitleFromPath(workspaceRoot), - }, - }, - ], - }), - ); + openNewTaskDraft(navigation, { + environmentId: environment.environmentId, + projectId, + title: inferProjectTitleFromPath(workspaceRoot), + }); return result; }; } diff --git a/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts b/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts index 4fa783d0be9e9..2673a6ff20825 100644 --- a/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts +++ b/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts @@ -131,9 +131,11 @@ export function usePromptLinkResolver(params: Readonly> } const modelUnavailable = writeDraft(draftKey, project.environmentId, request); navigation.dispatch( - StackActions.push("NewTaskDraft", { - draftId: draftKey, - title: request.pullRequest ? `PR #${request.pullRequest.number}` : project.title, + StackActions.push("Thread", { + draft: { + draftId: draftKey, + title: request.pullRequest ? `PR #${request.pullRequest.number}` : project.title, + }, }), ); if (request.prompt || pullRequestUnavailable !== null || modelUnavailable) { diff --git a/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx index 4f9b9316d434d..af471abf2c869 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx @@ -20,6 +20,7 @@ import { ScrollViewMarker } from "react-native-screens"; import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText"; import { EmptyState } from "../../components/ui/EmptyState"; import { Button } from "../../components/ui/Button"; +import { useRestingTopOffset } from "../../native/useRestingTopOffset"; import { SymbolView } from "../../components/ui/AppSymbol"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; @@ -35,14 +36,15 @@ import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff" function ReviewerScrollView(props: ScrollViewProps) { const insets = useSafeAreaInsets(); + const restingOffset = useRestingTopOffset(insets.top, props); return ( ); diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 10cb2d7a37202..a513fd424ced1 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -56,6 +56,7 @@ import { View, } from "react-native"; import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker"; +import { useRestingTopOffset } from "../../native/useRestingTopOffset"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; @@ -117,13 +118,14 @@ const EMPTY_IDS: readonly string[] = []; function PullRequestTabScrollView(props: ScrollViewProps) { const insets = useSafeAreaInsets(); + const restingOffset = useRestingTopOffset(insets.top, props); return ( ); @@ -299,6 +301,18 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps>>({}); + // AnimatedLegendList re-attaches its handle and sets state whenever its ref changes, + // so an inline ref in the tab map loops the screen into "Maximum update depth exceeded". + const [listRefs] = useState(() => { + const listRef = (tab: typeof section) => (list: LegendListRef | null) => { + scrollViews.current[tab] = list; + }; + return { + overview: listRef("overview"), + guide: listRef("guide"), + activity: listRef("activity"), + }; + }); const selectSection = (next: typeof section) => { if (next === section) return; scrollOffsets.current[section] = currentScrollOffset.current; @@ -545,10 +559,10 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps { - scrollViews.current[tab] = list; - }} - initialScrollOffset={-insets.top} + ref={listRefs[tab]} + // The scroll view owns its resting offset. LegendList keeps the first + // initialScrollOffset it gets, and the first render has the window's top + // inset (0 on the folded iPhone Duo), not the header's. onScroll={(event) => { const offset = event.nativeEvent.contentOffset.y; scrollOffsets.current[tab] = offset; diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index 32344c84966a9..3eb24ff808192 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -21,6 +21,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; import { EmptyState } from "../../components/ui/EmptyState"; +import { useRestingTopOffset } from "../../native/useRestingTopOffset"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import { useWorkspaceState } from "../../state/workspace"; import { useProjects } from "../../state/entities"; @@ -46,13 +47,14 @@ import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSe function PullRequestScrollView(props: ScrollViewProps) { const insets = useSafeAreaInsets(); + const restingOffset = useRestingTopOffset(insets.top, props); return ( ); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts index 225db0147497f..e845c960e58ac 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts @@ -146,9 +146,8 @@ export function usePullRequestThreads( previousPrompts.current.set(draftKey, pullRequestAgentPrompt(task)); if (thread) openThread(thread); else - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { draftId: draftKey, title: `Ask about #${pr.number}` }, + navigation.navigate("Thread", { + draft: { draftId: draftKey, title: `Ask about #${pr.number}` }, }); }); pending.current = false; diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 5660301371d7d..3dd9667f9b5a5 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -30,6 +30,7 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; +import { NativeInspectorContent } from "../../native/NativeInspectorContent"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; @@ -40,7 +41,6 @@ import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { useAtomCommand } from "../../state/use-atom-command"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -307,7 +307,6 @@ function ReviewFileNavigator({ onSelectFile, ref, }: ReviewFileNavigatorProps) { - const insets = useSafeAreaInsets(); const theme = useUniwindTheme(); const sheetColor = theme["--color-sheet"]; const foregroundColor = theme["--color-foreground"]; @@ -359,15 +358,8 @@ function ReviewFileNavigator({ data={files} extraData={selectedFileId} keyExtractor={(file) => file.id} - contentContainerStyle={{ - paddingHorizontal: 8, - paddingBottom: 8, - - paddingTop: Platform.OS === "ios" ? insets.top + IOS_NAV_BAR_HEIGHT + 8 : 8, - }} - scrollIndicatorInsets={ - Platform.OS === "ios" ? { top: insets.top + IOS_NAV_BAR_HEIGHT } : undefined - } + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + contentContainerStyle={{ paddingHorizontal: 8, paddingVertical: 8 }} renderItem={renderFile} /> ); @@ -384,7 +376,7 @@ function ReviewFileNavigator({ scrollEdgeEffects={headerScrollEdgeEffects} style={{ backgroundColor: sheetColor, flex: 1 }} > - {fileList} + {fileList} [ withNativeGlassHeaderItem({ - axisBehavior: "horizontalOnly", accessibilityLabel: "Filter settings environments and projects", icon: { name: filterIcon, type: "sfSymbol" }, label: "", @@ -121,7 +120,6 @@ export function SettingsEnvironmentFilterHeader(props: { ...(closeSettings ? [ withNativeGlassHeaderItem({ - axisBehavior: "horizontalOnly", accessibilityLabel: "Close settings", icon: { name: "xmark", type: "sfSymbol" }, identifier: "settings-close", diff --git a/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx b/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx new file mode 100644 index 0000000000000..faebfa5ddf927 --- /dev/null +++ b/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx @@ -0,0 +1,69 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + CHATGPT_USAGE_LIMIT_CODE, + CHATGPT_USAGE_URL, + CHATGPT_WORKSPACE_ADMIN_REQUEST, + CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, +} from "@t3tools/shared/usageLimits"; +import * as Clipboard from "expo-clipboard"; +import { useState } from "react"; +import { Linking, View } from "react-native"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { AppText as Text } from "../../components/ui/AppText"; +import { Button } from "../../components/ui/Button"; + +export function ChatGptUsageLimitNotice({ thread }: { thread: EnvironmentThreadShell }) { + const [status, setStatus] = useState<"copied" | "copy-failed" | "open-failed" | null>(null); + const code = thread.runtime?.lastErrorCode; + const workspaceLimit = code === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE; + if (thread.runtime?.status !== "failed" || (!workspaceLimit && code !== CHATGPT_USAGE_LIMIT_CODE)) + return null; + return ( + + + + ChatGPT usage limit reached + + + {workspaceLimit + ? status === "copied" + ? "Request copied. Send it to your workspace admin." + : "You've reached your workspace's limit for using your ChatGPT plan in other apps. Copy a request to ask your workspace admin for increased usage limits." + : "Review your usage settings in ChatGPT."} + + {workspaceLimit ? ( + - ), - }; - } - const cancelled = activeProjectClone.phase === "cancelled"; - return { - id: `project-clone:${projectId}`, - variant: cancelled ? "warning" : "error", - icon: , - title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`, - description: cancelled ? "Retry to bring in the repository." : activeProjectClone.error, - actions: ( - <> - - - - ), - }; - })() as ComposerBannerStackItem | null; + const { projectCloneBannerItem, projectCloneSendBlockReason } = + useProjectCloneBanner(activeProjectRef); const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = () => { startNewThreadForProject(activeProjectRef, handleNewThread); @@ -2237,7 +1266,7 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef || !activeEnvironmentBootstrapComplete) return; useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null); }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); - const allProjects = useProjects(); + const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; useEffect(() => { if (!activeThreadRef || !activeProjectRef) return; @@ -2245,94 +1274,20 @@ export default function ChatView(props: ChatViewProps) { }, [activeProjectRef, activeThreadRef]); const activeEnvironment = activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); - const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; const activeEnvironmentUnavailable = - activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; - const activeReconnectingEnvironmentId = - activeEnvironmentConnectionPhase === "connecting" || - activeEnvironmentConnectionPhase === "reconnecting" - ? (activeEnvironment?.environmentId ?? null) - : null; - const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] = - useState(null); - // Each reconnect attempt gets its own grace period, including a later one to - // the same environment. - const [graceReconnectingEnvironmentId, setGraceReconnectingEnvironmentId] = useState( - activeReconnectingEnvironmentId, - ); - if (graceReconnectingEnvironmentId !== activeReconnectingEnvironmentId) { - setGraceReconnectingEnvironmentId(activeReconnectingEnvironmentId); - setReconnectWarningGraceElapsedEnvironmentId(null); - } - const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed( - activeReconnectingEnvironmentId, - reconnectWarningGraceElapsedEnvironmentId, - ); - useEffect(() => { - if (activeReconnectingEnvironmentId === null) return; - return scheduleEnvironmentReconnectWarning(() => - setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId), - ); - }, [activeReconnectingEnvironmentId]); + activeEnvironment !== null && activeEnvironment.connection.phase !== "connected"; const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; const activeEnvironmentUnavailableState = (() => { if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { return null; } + return { environmentId: activeEnvironment.environmentId, label: activeEnvironmentUnavailableLabel, connection: activeEnvironment.connection, }; })() as EnvironmentUnavailableState | null; - const handleReconnectActiveEnvironment = async (environmentId: EnvironmentId) => { - const result = await retryEnvironment(environmentId); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not reconnect environment", - description: error instanceof Error ? error.message : "Failed to reconnect.", - }), - ); - } - }; - const handleReconnectActiveEnvironmentStable = useStableCallback( - handleReconnectActiveEnvironment, - ); - const disconnectDelayElapsed = useEnvironmentDisconnectDelay( - activeEnvironmentUnavailable ? activeEnvironment.environmentId : null, - ); - const canDisconnectActiveEnvironment = - disconnectDelayElapsed && - activeEnvironment !== null && - activeEnvironment.entry.target._tag !== "PrimaryConnectionTarget" && - !isDesktopLocalConnectionTarget(activeEnvironment.entry.target); - const [disconnectingEnvironment, setDisconnectingEnvironment] = useState(false); - const handleDisconnectActiveEnvironment = async (environmentId: EnvironmentId) => { - setDisconnectingEnvironment(true); - const result = await setEnvironmentEnabled({ environmentId, enabled: false }); - setDisconnectingEnvironment(false); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not disconnect server", - description: error instanceof Error ? error.message : "Failed to disconnect.", - }), - ); - } - return; - } - void navigate({ to: "/", replace: true }); - }; - const handleDisconnectActiveEnvironmentStable = useStableCallback( - handleDisconnectActiveEnvironment, - ); - const { openScratchProject } = useScratchProject(); const activeProjectIsScratch = activeProject !== null && isScratchProject( @@ -2353,93 +1308,18 @@ export default function ChatView(props: ChatViewProps) { activeEnvironment: activeEnvironmentOption, canPickEnvironment: hasMultipleEnvironments, }); - const openPullRequestDialog = (reference?: string) => { - if (!canCheckoutPullRequestIntoThread) { - return; - } - if (activeThreadKey === null) return; - openPullRequestThreadDialog(activeThreadKey, reference); - }; - const openPullRequestDialogStable = useStableCallback(openPullRequestDialog); - const openOrReuseProjectDraftThread = async (input: { - branch: string; - worktreePath: string | null; - envMode: DraftThreadEnvMode; - }) => { - if (!activeProject) { - throw new Error("No active project is available for this pull request."); - } - const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); - const logicalProjectKey = deriveLogicalProjectKeyFromSettings( - activeProject, + const { handlePreparedPullRequestThreadStable, openPullRequestDialogStable } = + usePullRequestDraftThread({ + routeKind, + draftId, + settings, projectGroupingSettings, - ); - const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); - if (storedDraftSession) { - setDraftThreadContext(storedDraftSession.draftId, input); - setLogicalProjectDraftThreadId( - logicalProjectKey, - activeProjectRef, - storedDraftSession.draftId, - { - threadId: storedDraftSession.threadId, - ...input, - }, - ); - if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) { - await navigate({ - to: "/draft/$draftId", - params: buildDraftThreadRouteParams(storedDraftSession.draftId), - }); - } - return storedDraftSession.threadId; - } - const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null; - if (!isServerThread && activeDraftSession?.logicalProjectKey === logicalProjectKey && draftId) { - setDraftThreadContext(draftId, input); - setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, { - threadId: activeDraftSession.threadId, - createdAt: activeDraftSession.createdAt, - runtimeMode: activeDraftSession.runtimeMode, - interactionMode: activeDraftSession.interactionMode, - ...input, - }); - return activeDraftSession.threadId; - } - const nextDraftId = newDraftId(); - const nextThreadId = newThreadId(); - setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { - threadId: nextThreadId, - createdAt: new Date().toISOString(), - runtimeMode: resolveProjectSettings(settings, activeProject.id, activeProject).settings - .defaultRuntimeMode, - interactionMode: DEFAULT_INTERACTION_MODE, - ...input, - }); - await navigate({ - to: "/draft/$draftId", - params: buildDraftThreadRouteParams(nextDraftId), - }); - return nextThreadId; - }; - const handlePreparedPullRequestThread = async (input: { - branch: string; - worktreePath: string | null; - }) => { - await openOrReuseProjectDraftThread({ - branch: input.branch, - worktreePath: input.worktreePath, - envMode: input.worktreePath ? "worktree" : "local", + activeProject, + activeThreadKey, + isServerThread, + canCheckoutPullRequestIntoThread, }); - }; - const handlePreparedPullRequestThreadStable = useStableCallback(handlePreparedPullRequestThread); - // The start-from picker only opens on a new thread's draft, so it points that draft. - const pointDraftAtCheckout = useStableCallback( - (checkout: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { - setDraftThreadContext(composerDraftTarget, checkout); - }, - ); const serverConfig = activeThread ? (activeEnvironment?.serverConfig ?? null) : (primaryEnvironment?.serverConfig ?? null); @@ -2506,182 +1386,22 @@ export default function ChatView(props: ChatViewProps) { return environment ? [environment] : []; }) : EMPTY_AUTO_UPDATE_ENVIRONMENTS; - const autoBalanceUpdateBanner = useAutoBalanceUpdateBanner(autoUpdateEnvironments); - const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); - const versionMismatchDismissKey = - versionMismatch && activeThread - ? buildVersionMismatchDismissalKey(activeThread.environmentId, versionMismatch) - : null; - const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState( - null, - ); - const versionMismatchDismissed = - versionMismatchDismissKey === dismissedVersionMismatchKey || - isVersionMismatchDismissed(versionMismatchDismissKey); - const showVersionMismatchBanner = - versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed; - const hasMultipleRegisteredEnvironments = environments.length > 1; - const versionMismatchServerLabel = - hasMultipleRegisteredEnvironments && activeThread - ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` - : "server"; - const serverUpdateEnvironmentId = activeThread?.environmentId ?? null; - const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); - const versionMismatchInstallation = serverConfig?.environment.capabilities.serverInstallation; - const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig); - const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig); - const serverUpdateState = useAtomValue( - serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), - ); - const [dismissedServerUpdateState, setDismissedServerUpdateState] = useState< - typeof serverUpdateState | null - >(null); - const serverUpdateFailureDismissed = - serverUpdateState === dismissedServerUpdateState || - isServerUpdateFailureDismissed(serverUpdateState); - const systemComposerBannerItems = (() => { - const items: ComposerBannerStackItem[] = []; - const updateRunning = serverUpdateState.status === "running"; - const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; - const disconnectAction = - canDisconnectActiveEnvironment && activeEnvironmentUnavailableState ? ( - - ) : undefined; - const environmentReconnecting = - unavailableConnection !== null && - (unavailableConnection.phase === "connecting" || - unavailableConnection.phase === "reconnecting"); - const suppressUnavailableBanner = - environmentReconnecting && (updateRunning || !reconnectWarningGraceElapsed); - if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { - items.push({ - id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: unavailableConnection.phase === "error" ? "error" : "warning", - icon: , - title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`, - description: activeEnvironment ? ( - - ) : undefined, - actions: ( - <> - {!environmentReconnecting ? ( - - ) : null} - {disconnectAction} - - ), - }); - } - if ( - !automaticEnvironment && - serverUpdateEnvironmentId && - (serverUpdateState.status === "idle" - ? showVersionMismatchBanner - : !serverUpdateFailureDismissed) - ) { - const updateInProgress = serverUpdateState.status === "running"; - const updateFailed = serverUpdateState.status === "failed"; - items.push({ - id: `server-version:${serverUpdateEnvironmentId}`, - variant: updateFailed ? "error" : "default", - priority: updateInProgress ? "urgent" : "notice", - icon: , - title: - updateInProgress || updateFailed ? ( - `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}` - ) : versionMismatch ? ( - - - Server update available - - } - /> - - {versionMismatchServerLabel} {versionMismatch.serverVersion}{" "} - {versionMismatch.clientVersion} - - - ) : ( - "Server update available" - ), - description: - serverUpdateState.status === "failed" - ? serverUpdateState.message - : serverUpdateState.status === "running" - ? serverUpdateStageLabel(serverUpdateState.stage) - : versionMismatchSelfUpdate !== null && - (versionMismatchSelfUpdate !== "desktop-managed" || - !versionMismatchDesktopAppUpdate) - ? serverUpdateGuidance(versionMismatchSelfUpdate) - : undefined, - actions: updateInProgress ? ( - disconnectAction - ) : !versionMismatch || - (versionMismatchSelfUpdate === "desktop-managed" && - !versionMismatchDesktopAppUpdate) ? undefined : ( - - ), - ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey) - ? {} - : { - dismissLabel: "Dismiss update notice", - onDismiss: () => { - if (updateFailed) { - dismissServerUpdateFailure(serverUpdateState); - setDismissedServerUpdateState(serverUpdateState); - } - dismissVersionMismatch(versionMismatchDismissKey); - setDismissedVersionMismatchKey(versionMismatchDismissKey); - }, - }), - }); - } - if (autoBalanceUpdateBanner) items.push(autoBalanceUpdateBanner); - return items; - })() as ComposerBannerStackItem[]; - const providerInstanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings( - deriveProviderInstanceEntries(providerStatuses), - settings, - activeProject?.id ?? null, - ), + const systemComposerBannerItems = useSystemComposerBanners({ + activeEnvironment, + activeEnvironmentUnavailable, + activeEnvironmentUnavailableState, + activeThreadEnvironmentId: activeThread?.environmentId ?? null, + serverConfig, + hasMultipleRegisteredEnvironments: environments.length > 1, + automaticEnvironment, + autoUpdateEnvironments, + }); + const providerInstanceEntries = sortProviderInstanceEntries( + applyProviderInstanceSettings( + deriveProviderInstanceEntries(providerStatuses), + settings, + activeProject?.id ?? null, + ), ); const activeThreadModelInstanceId = activeThread?.modelSelection.instanceId; const { selectedProviderEntry, requestedDriverKind } = resolveComposerProviderSelection({ @@ -2755,13 +1475,6 @@ export default function ChatView(props: ChatViewProps) { composerRef, setThreadError, }); - // A send awaits uploads before it declines, so it reads the approvals pending by then. - const pendingApprovalsRef = useRef(pendingApprovals); - const respondingRequestIdsRef = useRef(respondingRequestIds); - useLayoutEffect(() => { - pendingApprovalsRef.current = pendingApprovals; - respondingRequestIdsRef.current = respondingRequestIds; - }, [pendingApprovals, respondingRequestIds]); const activeProposedPlan = (() => { if (!latestRunSettled) { return null; @@ -2883,31 +1596,8 @@ export default function ChatView(props: ChatViewProps) { subagents: usageSubagents, }) : null; - const pendingBackgroundTasks = (() => { - if (serverProjection === null || serverProjection === undefined) { - return []; - } - const sessionError = - serverProjection.providerSessions.findLast( - (session) => session.providerInstanceId === serverProjection.thread.providerInstanceId, - )?.lastError ?? null; - const latestRun = - usageLimitRunPresentedAsLatest( - serverProjection.runs, - serverProjection.turnItems, - sessionError, - ) ?? latestUnheldRun(serverProjection.runs); - return [ - ...derivePendingBackgroundWork({ - latestRun, - providerThreads: serverProjection.providerThreads, - turnItems: serverProjection.turnItems, - activeProviderThreadId: serverProjection.thread.activeProviderThreadId, - runs: serverProjection.runs, - pullRequests: serverProjection.thread.pullRequests, - }), - ]; - })(); + + const pendingBackgroundTasks = deriveThreadPendingBackgroundTasks(serverProjection); // Delegated tasks run in threads of their own, so Stop asks whether to keep them. const runningWorkers = serverProjection == null @@ -2923,74 +1613,13 @@ export default function ChatView(props: ChatViewProps) { const sendQueuesBehindSetup = isServerThread && (activeRunPreparing || activeActivityRun?.status === "starting"); const [setupQueueSendThreadKey, setSetupQueueSendThreadKey] = useState(null); - useEffect(() => { - attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; - }, [attachmentPreviewHandoffByMessageId]); - const clearAttachmentPreviewHandoff = ( - messageId: MessageId, - previewUrls?: ReadonlyArray, - ) => { - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - const currentPreviewUrls = - previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - setAttachmentPreviewHandoffByMessageId((existing) => { - if (!(messageId in existing)) { - return existing; - } - const next = { ...existing }; - delete next[messageId]; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - for (const previewUrl of currentPreviewUrls) { - revokeBlobPreviewUrl(previewUrl); - } - }; - const clearAttachmentPreviewHandoffStable = useStableCallback(clearAttachmentPreviewHandoff); - const clearAttachmentPreviewHandoffs = () => { - attachmentPreviewPromotionInFlightByMessageIdRef.current = {}; - for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { - for (const previewUrl of previewUrls) { - revokeBlobPreviewUrl(previewUrl); - } - } - attachmentPreviewHandoffByMessageIdRef.current = {}; - setAttachmentPreviewHandoffByMessageId({}); - }; - const clearAttachmentPreviewHandoffsStable = useStableCallback(clearAttachmentPreviewHandoffs); - useEffect(() => { - return () => { - clearAttachmentPreviewHandoffsStable(); - for (const message of optimisticUserMessagesRef.current) { - revokeUserMessagePreviewUrls(message); - } - }; - }, [clearAttachmentPreviewHandoffsStable]); - const handoffAttachmentPreviews = (messageId: MessageId, previewUrls: string[]) => { - if (previewUrls.length === 0) return; - const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - const nextPreviewUrlSet = new Set(previewUrls); - for (const previewUrl of previousPreviewUrls) { - if (!nextPreviewUrlSet.has(previewUrl)) { - revokeBlobPreviewUrl(previewUrl); - } - } - setAttachmentPreviewHandoffByMessageId((existing) => { - const next = { - ...existing, - [messageId]: previewUrls, - }; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - }; - const handoffAttachmentPreviewsStable = useStableCallback(handoffAttachmentPreviews); const downloadFileAttachment = async (attachment: ChatFileAttachment) => { const connection = readPreparedConnection(environmentId); if (!connection) { toastManager.add({ type: "error", title: "The environment is not connected." }); return; } + try { const url = await resolveFileAttachmentUrl({ attachment, @@ -3018,122 +1647,12 @@ export default function ChatView(props: ChatViewProps) { } }; const openFileAttachmentStable = useStableCallback(openFileAttachment); - const serverAttachmentResources = selectHandoffImageResources( - Object.keys(attachmentPreviewHandoffByMessageId).length === 0 - ? undefined - : serverVisibleTurnItems.flatMap(({ item }) => - item.type === "user_message" - ? [{ id: item.messageId, role: "user" as const, attachments: item.attachments }] - : [], - ), - attachmentPreviewHandoffByMessageId, - ); - const serverAttachmentUrls = useAssetUrls(environmentId, serverAttachmentResources); - const serverAttachmentUrlById = new Map( - serverAttachmentResources.flatMap((resource, index) => { - const url = serverAttachmentUrls[index]; - return url ? [[resource.attachmentId, url] as const] : []; - }), - ); - useEffect(() => { - if (typeof Image === "undefined" || serverVisibleTurnItems.length === 0) { - return; - } - const cleanups: Array<() => void> = []; - const userMessagesById = new Map( - serverVisibleTurnItems.flatMap((row) => - row.item.type === "user_message" ? [[String(row.item.messageId), row.item] as const] : [], - ), - ); - for (const [messageId, handoffPreviewUrls] of Object.entries( - attachmentPreviewHandoffByMessageId, - )) { - if (attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]) { - continue; - } - const serverMessage = userMessagesById.get(messageId); - if (serverMessage === undefined || serverMessage.attachments.length === 0) { - continue; - } - const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) => - attachment.type === "image" - ? [serverAttachmentUrlById.get(attachment.id)].filter( - (previewUrl): previewUrl is string => previewUrl !== undefined, - ) - : [], - ); - if ( - serverPreviewUrls.length === 0 || - serverPreviewUrls.length !== handoffPreviewUrls.length || - serverPreviewUrls.some((previewUrl) => previewUrl.startsWith("blob:")) - ) { - continue; - } - attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId] = true; - let cancelled = false; - const imageInstances: HTMLImageElement[] = []; - const preloadServerPreviews = Promise.all( - serverPreviewUrls.map( - (previewUrl) => - new Promise((resolve, reject) => { - const image = new Image(); - imageInstances.push(image); - const handleLoad = () => resolve(); - const handleError = () => - reject(new Error(`Failed to load server preview for ${messageId}.`)); - image.addEventListener("load", handleLoad, { once: true }); - image.addEventListener("error", handleError, { once: true }); - image.src = previewUrl; - }), - ), - ); - void preloadServerPreviews - .then(() => { - if (cancelled) { - return; - } - clearAttachmentPreviewHandoffStable(messageId as MessageId, handoffPreviewUrls); - }) - .catch(() => { - if (!cancelled) { - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - } - }); - cleanups.push(() => { - cancelled = true; - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - for (const image of imageInstances) { - image.src = ""; - } - }); - } - return () => { - for (const cleanup of cleanups) { - cleanup(); - } - }; - }, [ - attachmentPreviewHandoffByMessageId, - clearAttachmentPreviewHandoffStable, - serverAttachmentUrlById, - serverVisibleTurnItems, - ]); - const timelineAttachmentUrlById = (() => { - const urls = new Map(serverAttachmentUrlById); - for (const row of serverVisibleTurnItems) { - if (row.item.type !== "user_message") continue; - const handoffUrls = attachmentPreviewHandoffByMessageId[row.item.messageId]; - if (handoffUrls === undefined) continue; - let imageIndex = 0; - for (const attachment of row.item.attachments) { - if (attachment.type !== "image") continue; - const handoffUrl = handoffUrls[imageIndex]; - imageIndex += 1; - if (handoffUrl !== undefined) urls.set(attachment.id, handoffUrl); - } - } - return urls; - })(); + const { handoffAttachmentPreviewsStable, timelineAttachmentUrlById } = + useAttachmentPreviewHandoff({ + environmentId, + serverVisibleTurnItems, + optimisticUserMessages, + }); const anchoredTimelineMessages = feedbackSubmissions.flatMap((submission) => submission.status === "interrupted" ? [] @@ -3149,11 +1668,11 @@ export default function ChatView(props: ChatViewProps) { } = usePendingModelChangeTimeline({ draftTarget: composerDraftTarget, projection: serverProjection, + updatedAt: activeThread?.updatedAt ?? null, visibleTurnItems: serverVisibleTurnItems, providers: providerStatuses, }); - const serverTimelineEntries = useIncrementalTimelineEntries(activeThreadKey, { - visibleTurnItems: timelineVisibleTurnItems, + const timelineEntriesInput = { optimisticMessages: optimisticUserMessages, anchoredMessages: anchoredTimelineMessages, attachmentUrlById: timelineAttachmentUrlById, @@ -3164,6 +1683,16 @@ export default function ChatView(props: ChatViewProps) { nodes: serverProjection.nodes, plans: serverProjection.plans, }), + }; + // The structure has no assistant or reasoning text, so those rows stay out of + // ChatView's own entries (anchoring, rewind, prompt history); ThreadTimeline + // renders every row from the live turn items. + const serverTimelineRows = timelineVisibleTurnItems.filter(isCompleteTurnItemRow); + const hasStreamedTimelineRows = + isServerThread && serverTimelineRows.length !== serverVisibleTurnItems.length; + const serverTimelineEntries = useIncrementalTimelineEntries(activeThreadKey, { + ...timelineEntriesInput, + visibleTurnItems: serverTimelineRows, }); const draftTimelineEntries = optimisticUserMessages.map( (message) => @@ -3178,8 +1707,9 @@ export default function ChatView(props: ChatViewProps) { const timelineMessages = timelineEntries.flatMap((entry) => entry.kind === "message" ? [entry.message] : [], ); + const timelineEmpty = timelineEntries.length === 0 && !hasStreamedTimelineRows; const displayedTimeline = resolveThreadSwitchTimeline({ - loading: timelineEntries.length === 0 && threadSyncPhase !== null, + loading: timelineEmpty && threadSyncPhase !== null, activeThreadKey, nextEntries: timelineEntries, rememberedForActive: peekRememberedThreadTimeline(activeThreadKey), @@ -3220,7 +1750,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; const isDraftHeroState = resolveDraftHeroState({ isLocalDraftThread, - hasTimelineEntries: timelineEntries.length > 0, + hasTimelineEntries: !timelineEmpty, isWorking, draftHeroDockRequested, backgroundSubmissionPending, @@ -3236,70 +1766,16 @@ export default function ChatView(props: ChatViewProps) { panelAnimationDurationMs, ); const turnDiffSummaries = useThreadCheckpointSummaries(routeThreadDetailRef); - const availableRevertTurnCounts = (() => { - const available = new Set(); - const providerThread = serverProjection?.providerThreads.find( - (candidate) => candidate.id === serverProjection.thread.activeProviderThreadId, - ); - if ( - !providerThread?.providerSessionId || - providerThread.providerInstanceId !== serverProjection?.thread.modelSelection.instanceId - ) - return available; - available.add(0); - const attemptsById = new Map(serverProjection.attempts.map((attempt) => [attempt.id, attempt])); - const providerTurnsById = new Map< - OrchestrationV2ProviderTurn["id"], - OrchestrationV2ProviderTurn - >(); - const latestTurnByAttempt = new Map(); - for (const turn of serverProjection.providerTurns) { - providerTurnsById.set(turn.id, turn); - if (turn.runAttemptId === null) continue; - const latest = latestTurnByAttempt.get(turn.runAttemptId); - if (latest === undefined || turn.ordinal > latest.ordinal) { - latestTurnByAttempt.set(turn.runAttemptId, turn); - } - } - for (const run of serverProjection.runs) { - if (run.activeAttemptId === null) continue; - const attempt = attemptsById.get(run.activeAttemptId); - const providerTurn = - latestTurnByAttempt.get(run.activeAttemptId) ?? - (attempt?.providerTurnId == null - ? undefined - : providerTurnsById.get(attempt.providerTurnId)); - // A ready file checkpoint alone cannot restore the provider conversation. - if (providerTurn?.providerThreadId === providerThread.id) available.add(run.ordinal); - } - return available; - })(); + const availableRevertTurnCounts = deriveAvailableRevertTurnCounts(serverProjection); // A paged history window cannot identify the first loaded run as the thread start. const threadStartCheckpointReady = !serverThreadHistory.hasMoreHistory && findRevertCheckpoint(serverProjection?.checkpoints ?? [], 0) !== undefined; - /** - * File tools are explicit; completed commands are included because a shell - * command can mutate the workspace without reporting the paths it touched. - */ - const workspaceMutationId = (() => { - let itemId: string | null = null; - for (let index = serverVisibleTurnItems.length - 1; index >= 0; index -= 1) { - const item = serverVisibleTurnItems[index]?.item; - if (!item) continue; - if (item.type !== "command_execution" && item.type !== "file_change") continue; - if (item.status === "pending" || item.status === "running" || item.status === "waiting") { - continue; - } - itemId = item.id; - break; - } - const latestCheckpointCompletedAt = turnDiffSummaries.at(-1)?.completedAt ?? null; - return itemId === null && latestCheckpointCompletedAt === null - ? null - : JSON.stringify([itemId, latestCheckpointCompletedAt]); - })(); + const workspaceMutationId = deriveWorkspaceMutationId( + serverVisibleTurnItems, + turnDiffSummaries.at(-1)?.completedAt ?? null, + ); const gitCwd = activeProject ? projectScriptCwd({ @@ -3392,21 +1868,6 @@ export default function ChatView(props: ChatViewProps) { // GitHub issues are listed with `gh` in the thread's checkout; any checkout of the repository // answers the same, so a draft headed for a new worktree uses the project folder. const githubIssueCwd = isGitHubProject(activeProject) ? (activeWorkspaceRoot ?? null) : null; - useLayoutEffect(() => { - if ( - threadDetailLoading || - timelineEntries.length === 0 || - timelineHasEphemeralPreviewUrls(timelineEntries) - ) { - return; - } - rememberReadyThreadTimeline({ - threadKey: activeThreadKey, - entries: timelineEntries, - markdownCwd: gitCwd, - workspaceRoot: activeWorkspaceRoot ?? null, - }); - }, [activeThreadKey, activeWorkspaceRoot, gitCwd, threadDetailLoading, timelineEntries]); const heldPaintContext = paintOnlyDisplayedTimeline ? peekHeldThreadTimeline() : null; @@ -3495,210 +1956,33 @@ export default function ChatView(props: ChatViewProps) { "terminal.close", terminalShortcutLabelOptions, ); - const onToggleDiff = () => { - if (!isServerThread) { - return; - } - if (!diffOpen) { - onDiffPanelOpen?.(); - } - if (activeThreadRef) { - useRightPanelStore.getState().toggle(activeThreadRef, "diff"); - } - }; - const onToggleDiffStable = useStableCallback(onToggleDiff); - const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; - const loadBalancingCandidates = needsLoadBalancing - ? logicalProjectEnvironments - .filter((candidate) => { - const environment = environmentById.get(candidate.environmentId); - return ( - environment?.connection.phase === "connected" && - (loadBalancingSettings.loadBalancingWeights[candidate.environmentId] ?? 50) > 0 && - referencedThreadContexts.every( - (record) => record.environmentId === candidate.environmentId, - ) && - providersByEnvironment - .get(candidate.environmentId) - ?.some( - (provider) => - (activeProviderInstanceId === null || - provider.instanceId === activeProviderInstanceId) && - provider.driver === selectedProvider && - (environment.serverConfig?.settings - ? resolveProjectProviderInstanceEnabled( - environment.serverConfig.settings, - candidate.projectId, - provider.instanceId, - ) - : provider.enabled) && - provider.installed && - provider.status !== "error" && - provider.auth.status !== "unauthenticated" && - provider.availability !== "unavailable", - ) - ); - }) - .map((candidate) => candidate.environmentId) - : []; - const loadBalancing = useLoadBalancedEnvironment( - loadBalancingCandidates, - loadBalancingSettings.loadBalancingWeights, - ); - useEffect(() => { - if (!needsLoadBalancing || loadBalancing.pending || !draftId || sendInFlightRef.current) return; - const target = logicalProjectEnvironments.find( - (environment) => environment.environmentId === loadBalancing.environmentId, - ); - if (!target?.projectId) return; - setDraftThreadContext(draftId, { - projectRef: scopeProjectRef(target.environmentId, target.projectId), - environmentSelection: "auto", - loadBalancedEnvironmentId: target.environmentId, - }); - }, [ + + const { needsLoadBalancing, - loadBalancing.pending, - loadBalancing.environmentId, + loadBalancing, + onAutoEnvironmentStable, + autoEnvironmentLabel, + isEnvironmentChanging, + readPendingEnvironmentChange, + onEnvironmentChangeStable, + } = useDraftEnvironmentRouting({ draftId, + draftThread, + envLocked, + automaticEnvironment, logicalProjectEnvironments, + environmentById, + providersByEnvironment, + loadBalancingSettings, + referencedThreadContexts, + activeProviderInstanceId, + selectedProvider, + composerHasAttachments, + activeProjectKey, + activeProjectIsScratch, + projectGroupingSettings, sendInFlightRef, - setDraftThreadContext, - ]); - const onAutoEnvironment = () => { - if (envLocked || !draftId) return; - if (composerHasAttachments) { - toastManager.add({ - type: "warning", - id: "load-balancing-attachments", - title: "Keep attachments on this machine", - description: - "Remove attachments before choosing automatic routing, then attach them on the selected machine.", - }); - return; - } - loadBalancing.refresh( - logicalProjectEnvironments.map((environment) => environment.environmentId), - ); - setDraftThreadContext(draftId, { - environmentSelection: "auto", - loadBalancedEnvironmentId: null, - branch: null, - worktreePath: null, - }); - }; - const onAutoEnvironmentStable = useStableCallback(onAutoEnvironment); - const autoEnvironmentLabel = automaticEnvironment - ? draftThread?.loadBalancedEnvironmentId - ? "Auto balance" - : loadBalancing.pending - ? "Checking machines…" - : loadBalancing.failed - ? "Auto balance unavailable" - : "Auto balance" - : undefined; - - // A change in flight belongs to the draft and project it started on. - const environmentChangeScope = `${draftId ?? ""}:${activeProjectKey ?? ""}`; - const environmentChangeRef = useRef<{ - readonly environmentId: EnvironmentId; - readonly scope: string; - } | null>(null); - const environmentChangeScopeRef = useRef(""); - useLayoutEffect(() => { - environmentChangeScopeRef.current = environmentChangeScope; }); - // Leaving the scope, or unmounting, abandons a change still in flight. - useLayoutEffect( - () => () => { - if (environmentChangeRef.current?.scope === environmentChangeScope) { - environmentChangeRef.current = null; - } - }, - [environmentChangeScope], - ); - const readPendingEnvironmentChange = () => { - const pending = environmentChangeRef.current; - return pending !== null && pending.scope === environmentChangeScopeRef.current ? pending : null; - }; - // The busy state belongs to the request that set it, so an older request settling late - // (abandoned, or superseded by a newer switch) can never clear a newer one's. - const [busyEnvironmentChange, setBusyEnvironmentChange] = useState<{ - readonly environmentId: EnvironmentId; - readonly scope: string; - } | null>(null); - const isEnvironmentChanging = busyEnvironmentChange?.scope === environmentChangeScope; - - const onEnvironmentChange = (nextEnvironmentId: EnvironmentId) => { - if (envLocked || !draftId || sendInFlightRef.current) return; - const originalDraft = getDraftSession(draftId); - if (!originalDraft || originalDraft.promotedTo) return; - const target = logicalProjectEnvironments.find( - (env) => env.environmentId === nextEnvironmentId, - ); - if (!target) return; - const request = { environmentId: target.environmentId, scope: environmentChangeScope }; - environmentChangeRef.current = request; - setBusyEnvironmentChange(null); - const retarget = (project: (typeof allProjects)[number]) => { - const currentDraft = getDraftSession(draftId); - if ( - environmentChangeRef.current !== request || - environmentChangeScopeRef.current !== request.scope || - sendInFlightRef.current || - !currentDraft || - currentDraft.promotedTo || - currentDraft.environmentId !== originalDraft.environmentId || - currentDraft.projectId !== originalDraft.projectId - ) - return; - const projectRef = scopeProjectRef(target.environmentId, project.id); - if (activeProjectIsScratch) { - setLogicalProjectDraftThreadId( - deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), - projectRef, - draftId, - { environmentSelection: "manual", loadBalancedEnvironmentId: null }, - ); - } else { - setDraftThreadContext(draftId, { - projectRef, - environmentSelection: "manual", - loadBalancedEnvironmentId: null, - }); - } - }; - const finish = () => { - if (environmentChangeRef.current === request) environmentChangeRef.current = null; - setBusyEnvironmentChange((busy) => (busy === request ? null : busy)); - }; - if (target.projectId !== null) { - const project = allProjects.find( - (project) => - project.environmentId === target.environmentId && project.id === target.projectId, - ); - if (project) retarget(project); - finish(); - return; - } - setBusyEnvironmentChange(request); - void openScratchProject(target.environmentId, "Could not switch machine") - .then((project) => { - if (project) retarget(project); - }) - .finally(finish); - }; - const onEnvironmentChangeStable = useStableCallback(onEnvironmentChange); - const activeTerminalGroup = - terminalUiState.terminalGroups.find( - (group) => group.id === terminalUiState.activeTerminalGroupId, - ) ?? - terminalUiState.terminalGroups.find((group) => - group.terminalIds.includes(terminalUiState.activeTerminalId), - ) ?? - null; - const hasReachedSplitLimit = - (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; const focusComposer = () => { composerRef.current?.focusAtEnd(); @@ -3744,6 +2028,7 @@ export default function ChatView(props: ChatViewProps) { const insertArtifactTemplate = (template: CodexArtifactTemplate) => { const composer = composerRef.current; if (!composer) return; + const currentDraft = composer.getSendContext().prompt; const prompt = codexArtifactTemplatePromptToAppend(currentDraft, template); if (prompt !== null && !composer.insertTextAtEnd(prompt, { ensureLeadingBoundary: true })) { @@ -3758,255 +2043,41 @@ export default function ChatView(props: ChatViewProps) { }; const insertArtifactTemplateStable = useStableCallback(insertArtifactTemplate); const queuedRunsControlRef = useRef(null); - const addTerminalContextToDraft = (selection: TerminalContextSelection) => { - composerRef.current?.addTerminalContext(selection); - }; - const addTerminalContextToDraftStable = useStableCallback(addTerminalContextToDraft); - const setTerminalOpen = (open: boolean) => { - if (!activeThreadRef) return; - storeSetTerminalOpen(activeThreadRef, open); - }; - const setTerminalOpenStable = useStableCallback(setTerminalOpen); - const toggleTerminalVisibility = () => { - if (!activeThreadRef) return; - const nextOpen = !terminalUiState.terminalOpen; - if ( - nextOpen && - !readEnvironmentScope(environmentId, AuthTerminalReadScope) && - !hasTerminalWriteAccess() - ) - return; - if (nextOpen && hasTerminalWriteAccess() && terminalUiState.terminalIds.length === 0) { - // Server-created setup terminals exist before this client's drawer mounts. - // Reuse them before allocating a shell from the still-empty UI state. - const existingTerminalId = - runningTerminalIds.find((id) => !panelTerminalIds.has(id)) ?? - activeServerOrderedTerminalIds.find((id) => !panelTerminalIds.has(id)); - if (existingTerminalId !== undefined) { - storeEnsureTerminal(activeThreadRef, existingTerminalId, { open: true, active: true }); - return; - } - void terminalActions.launchTerminal({ - place: (terminalId) => storeEnsureTerminal(activeThreadRef, terminalId, { open: true }), - })?.result; - return; - } - setTerminalOpen(nextOpen); - }; - const toggleTerminalVisibilityStable = useStableCallback(toggleTerminalVisibility); - const splitTerminal = (direction: "horizontal" | "vertical" = "horizontal") => { - if (!activeThreadRef || hasReachedSplitLimit) return; - const launched = terminalActions.launchTerminal({ - place: (terminalId) => - direction === "vertical" - ? storeSplitTerminalVertical(activeThreadRef, terminalId) - : storeSplitTerminal(activeThreadRef, terminalId), - }); - if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); - void launched.result; - }; - const splitTerminalStable = useStableCallback(splitTerminal); - const createNewTerminal = () => { - if (!activeThreadRef) return; - const launched = terminalActions.launchTerminal({ - place: (terminalId) => storeNewTerminal(activeThreadRef, terminalId), - }); - if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); - void launched.result; - }; - const createNewTerminalStable = useStableCallback(createNewTerminal); - const closeTerminal = (terminalId: string) => { - if (!hasTerminalWriteAccess() || !activeThreadRef) return; - terminalActions.closeTerminalSession(terminalId); - storeCloseTerminal(activeThreadRef, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }; - const runProjectScript = async ( - script: ProjectScript, - options?: { - cwd?: string; - env?: Record; - worktreePath?: string | null; - preferNewTerminal?: boolean; - rememberAsLastInvoked?: boolean; - /** Runs in this terminal, opening it if needed, instead of the active one. */ - terminalId?: string; - shellHistory?: "preserve" | "suppress"; - }, - ) => { - if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return; - if (options?.rememberAsLastInvoked !== false) { - setLastInvokedScriptByProjectId((current) => { - if (current[activeProject.id] === script.id) return current; - return { ...current, [activeProject.id]: script.id }; - }); - } - const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot; - const reusableTerminalId = - options?.preferNewTerminal || - !canReuseTerminal || - !readEnvironmentScope(environmentId, AuthTerminalReadScope) - ? null - : findReusableProjectScriptTerminal({ - // Server sessions cover drawer terminals this client has not opened yet, such as a - // worktree setup shell; the helper drops the panel-owned ones. - drawerTerminalIds: [ - ...new Set([...terminalUiState.terminalIds, ...activeServerOrderedTerminalIds]), - ], - activeTerminalId: terminalUiState.activeTerminalId, - panelTerminalIds, - runningTerminalIds, - }); - const targetScriptTerminalId = options?.terminalId ?? reusableTerminalId; - const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; - setTerminalUiLaunchContext({ - threadId: activeThreadId, - cwd: targetCwd, - worktreePath: targetWorktreePath, - }); - if (!activeThreadRef) { - return; - } - setTerminalFocusRequestId((value) => value + 1); - - const launched = terminalActions.launchTerminal({ - // The drawer opens with the target already placed: opening it empty would claim - // DEFAULT_THREAD_TERMINAL_ID, which a right-panel terminal may already own. - place: (terminalId) => - targetScriptTerminalId === null - ? storeNewTerminal(activeThreadRef, terminalId) - : storeEnsureTerminal(activeThreadRef, terminalId, { open: true }), - ...(targetScriptTerminalId === null - ? { size: { cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS } } - : { terminalId: targetScriptTerminalId }), - location: { cwd: targetCwd, worktreePath: targetWorktreePath }, - ...(options?.env ? { extraEnv: options.env } : {}), - }); - if (!launched) return; - const targetTerminalId = launched.terminalId; - const openResult = await launched.result; - if (openResult._tag === "Failure") { - if (!isAtomCommandInterrupted(openResult)) { - const error = squashAtomCommandFailure(openResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); - } - return; - } - const writeResult = await terminalActions.writeToTerminal( - targetTerminalId, - options?.shellHistory === "preserve" - ? `${script.command}\r` - : projectScriptTerminalInput(script.command), - ); - if (!writeResult) return; - if (writeResult._tag === "Failure") { - if (!isAtomCommandInterrupted(writeResult)) { - const error = squashAtomCommandFailure(writeResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); - } - return; - } - if (script.autoOpenPreview && script.previewUrl && isPreviewSupportedInRuntime()) { - const previewResult = await openUrlInPreview({ - threadRef: activeThreadRef, - url: resolveDiscoveredServerUrl(activeThreadRef.environmentId, script.previewUrl), - openPreview, - }); - if (previewResult._tag === "Failure" && !isAtomCommandInterrupted(previewResult)) { - const error = squashAtomCommandFailure(previewResult); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not open preview", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - } - } - }; - const runProjectScriptStable = useStableCallback(runProjectScript); - const runProjectScriptRef = useRef(runProjectScriptStable); - useLayoutEffect(() => { - runProjectScriptRef.current = runProjectScriptStable; - }, [runProjectScriptStable]); - const runShellCommand = (command: string) => { - void runProjectScriptRef.current( - { - id: "chat-code-block", - name: "Chat code block", - command, - icon: "play", - runOnWorktreeCreate: false, - }, - { rememberAsLastInvoked: false, shellHistory: "preserve" }, - ); - }; - const runShellCommandStable = useStableCallback(runShellCommand); - // The repository's Conductor run scripts. Each runs in its own terminal so - // the header can show it running and stop it, like Conductor's Run button. - const conductorSettings = useConductorSettings( - environmentId, - activeProject?.workspaceRoot ?? null, - ); const { - runScripts: conductorRunScripts, - runMode: conductorRunMode, - environment: conductorEnvironment, - } = conductorSettings.resolved; - const conductorScriptItems = conductorRunScripts.map((script) => ({ - id: script.id, - name: script.name, - command: script.command, - icon: conductorScriptIcon(script.icon), - running: runningTerminalIds.includes(conductorRunTerminalId(script.id)), - })); - const runConductorScript = useStableCallback((scriptId: string) => { - const script = conductorRunScripts.find((candidate) => candidate.id === scriptId); - if (!script || !activeProject) return; - if (conductorRunMode === "nonconcurrent") { - for (const other of conductorRunScripts) { - const otherTerminalId = conductorRunTerminalId(other.id); - if (other.id !== scriptId && runningTerminalIds.includes(otherTerminalId)) { - closeTerminal(otherTerminalId); - } - } - } - const launch = conductorRunScriptLaunch({ - script, - projectRoot: activeProject.workspaceRoot, - worktreePath: activeThreadWorktreePath, - environment: conductorEnvironment, - }); - void runProjectScript( - { - id: `conductor-${script.id}`, - name: script.name, - command: launch.command, - icon: launch.icon, - runOnWorktreeCreate: false, - }, - { - terminalId: launch.terminalId, - cwd: launch.cwd, - worktreePath: activeThreadWorktreePath, - env: launch.env, - rememberAsLastInvoked: false, - }, - ); + activatePanelTerminalStable, + addTerminalContextToDraftStable, + addTerminalSurfaceStable, + closePanelTerminalStable, + lastInvokedScriptByProjectId, + runProjectScriptStable, + runShellCommandStable, + splitPanelTerminalStable, + splitPanelTerminalVerticalStable, + toggleTerminalVisibilityStable, + shortcuts: terminalShortcuts, + conductorScriptItems, + runConductorScript, + stopConductorScript, + } = useChatTerminalActions({ + environmentId, + composerRef, + terminalUiState, + activeThread, + activeThreadId, + activeThreadRef, + activeProject, + activeRightPanelSurface, + runningTerminalIds, + activeServerOrderedTerminalIds, + activeTerminalLabelsById, + panelTerminalIds, + canReuseTerminal, + gitCwd, + setTerminalFocusRequestId, + setTerminalUiLaunchContext, + setThreadError, }); - const stopConductorScript = useStableCallback((scriptId: string) => - closeTerminal(conductorRunTerminalId(scriptId)), - ); - const { saveProjectScript: saveProjectScriptStable, updateProjectScript: updateProjectScriptStable, @@ -4017,79 +2088,10 @@ export default function ChatView(props: ChatViewProps) { scripts: activeProjectScripts, settings, }); - - const handleRuntimeModeChange = (mode: RuntimeMode) => { - if (mode === runtimeMode) return; - setComposerDraftRuntimeMode(composerDraftTarget, mode); - if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); - } - scheduleComposerFocus(); - }; - const handleRuntimeModeChangeStable = useStableCallback(handleRuntimeModeChange); - const handleInteractionModeChange = (mode: ProviderInteractionMode) => { - if (mode === "plan" && !interactionModeEnabled) return; - if (mode === interactionMode) return; - setComposerDraftInteractionMode(composerDraftTarget, mode); - if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { interactionMode: mode }); - } - scheduleComposerFocus(); - }; - const handleInteractionModeChangeStable = useStableCallback(handleInteractionModeChange); - const toggleInteractionMode = () => { - if (!interactionModeEnabled) return; - handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); - }; - const toggleInteractionModeStable = useStableCallback(toggleInteractionMode); - const openProviderSetup = (instanceId: ProviderInstanceId) => { - void navigate({ - to: "/settings/providers", - search: { environmentId, instanceId }, - }); - }; - const openProviderSetupStable = useStableCallback(openProviderSetup); const canOperatePreview = useEnvironmentScope( activeThreadRef?.environmentId ?? null, AuthPreviewOperateScope, ); - const createBrowserSurface = (profileId?: string) => { - if (!activeThreadRef || !canOperatePreview) return; - void addBrowserSurface({ - threadRef: activeThreadRef, - openPreview, - ...(profileId === undefined ? {} : { profileId }), - }).then((result) => { - if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; - const error = squashAtomCommandFailure(result); - if (error instanceof BrowserSettingsReadError) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open browser", - description: error.message, - }), - ); - } - }); - }; - const createBrowserSurfaceStable = useStableCallback(createBrowserSurface); - const addDiffSurface = () => { - if (!activeThreadRef || !isServerThread || !isGitRepo) return; - useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); - useRightPanelStore.getState().open(activeThreadRef, "diff"); - onDiffPanelOpen?.(); - }; - const addDiffSurfaceStable = useStableCallback(addDiffSurface); - const openChangesFromThreadPanel = () => { - addDiffSurface(); - }; - const openChangesFromThreadPanelStable = useStableCallback(openChangesFromThreadPanel); - const addFilesSurface = () => { - if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().open(activeThreadRef, "files"); - }; - const addFilesSurfaceStable = useStableCallback(addFilesSurface); const supportsThreadPullRequests = serverConfig?.environment.capabilities.threadPullRequests === true; const visiblePullRequests = visibleThreadPullRequests( @@ -4098,44 +2100,9 @@ export default function ChatView(props: ChatViewProps) { const visiblePullRequestCount = visiblePullRequests.length; const pullRequestsSurfaceAvailable = isServerThread && supportsThreadPullRequests && visiblePullRequestCount > 0; - const addPullRequestsSurface = () => { - if (!activeThreadRef || !pullRequestsSurfaceAvailable) return; - useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); - }; - const addPullRequestsSurfaceStable = useStableCallback(addPullRequestsSurface); - // Fork: the Agents panel, the thread's whole agent fleet beside Lineage. - const addAgentsSurface = useStableCallback(() => { - if (!activeThreadRef) return; - useRightPanelStore.getState().open(activeThreadRef, "agents"); - }); - const showLineage = useStableCallback(() => { - if (!activeThreadRef) return; - useRightPanelStore - .getState() - .setThreadPanelOpen(activeThreadRef, threadPanelPresentation, true); - requestAnimationFrame(() => - document - .querySelector("[data-thread-relationships-panel]") - ?.scrollIntoView({ block: "nearest" }), - ); - }); - // Fork: nested agents and live follow-up runs count too, as the Agents panel shows them. - const liveAgentCount = useWorkingAgentCount(activeThreadRef, serverProjection?.subagents); - const agentsSurfaceVisible = rightPanelOpen && activeRightPanelSurface?.kind === "agents"; - // The roster itself is on screen; a badge would point at nothing. - const launcherLiveAgentCount = agentsSurfaceVisible ? 0 : liveAgentCount; const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( activeThreadRef?.environmentId ?? null, ); - const addDeviceSurface = () => { - if (!activeThreadRef) return; - if (!deviceState.onboardingCompleted || deviceState.hostStatus === "disabled") { - openDeviceSetupDialog(scopedThreadKey(activeThreadRef)); - return; - } - useRightPanelStore.getState().open(activeThreadRef, "device"); - }; - const addDeviceSurfaceStable = useStableCallback(addDeviceSurface); useAgentOpenedPreviews({ threadRef: activeThreadRef, deviceState, @@ -4146,11 +2113,6 @@ export default function ChatView(props: ChatViewProps) { miniPlayerSource: activePreviewMiniPlayer?.source ?? null, sheetLayout: shouldUsePlanSidebarSheet, }); - const openFileSurface = (relativePath: string) => { - if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().openFile(activeThreadRef, relativePath); - }; - const openFileSurfaceStable = useStableCallback(openFileSurface); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. const activeProjectRepository = sourceControlRepositorySelector( @@ -4196,159 +2158,12 @@ export default function ChatView(props: ChatViewProps) { visiblePullRequestCount, onDiffPanelOpen, }); - const closePreviewPanel = () => { - if (activeThreadRef) { - if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) { - usePreviewMiniPlayerStore - .getState() - .open(activeThreadRef, browserMiniPlayerSource(activeRightPanelSurface.resourceId)); - } else if (activeRightPanelSurface?.kind === "device" && activeRightPanelSurface.target) { - usePreviewMiniPlayerStore - .getState() - .open(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target }); - } - useRightPanelStore.getState().setMaximized(activeThreadRef, false); - useRightPanelStore.getState().close(activeThreadRef); - } - }; - const closePreviewPanelStable = useStableCallback(closePreviewPanel); - const togglePreviewPanel = () => { - if (!activeThreadRef || !browserAvailable) return; - if (previewPanelOpen) { - closePreviewPanel(); - return; - } - if (!canOperatePreview) return; - const activeTabId = activePreviewState.activeTabId; - if (activeTabId) { - useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); - } else { - createBrowserSurface(); - } - }; - const togglePreviewPanelStable = useStableCallback(togglePreviewPanel); - const addTerminalSurface = () => { - if (!activeThreadRef) return; - const launched = terminalActions.launchTerminal({ - place: (terminalId) => - useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId), - }); - if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); - void launched.result; - }; - const addTerminalSurfaceStable = useStableCallback(addTerminalSurface); - const splitPanelTerminal = (direction: "horizontal" | "vertical" = "horizontal") => { - if ( - !activeThreadRef || - activeRightPanelSurface?.kind !== "terminal" || - activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP - ) { - return; - } - const surfaceId = activeRightPanelSurface.id; - const launched = terminalActions.launchTerminal({ - place: (terminalId) => - useRightPanelStore - .getState() - .splitTerminal(activeThreadRef, surfaceId, terminalId, direction), - }); - if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); - void launched.result; - }; - const splitPanelTerminalStable = useStableCallback(splitPanelTerminal); - const splitPanelTerminalVertical = () => { - splitPanelTerminal("vertical"); - }; - const splitPanelTerminalVerticalStable = useStableCallback(splitPanelTerminalVertical); - const activatePanelTerminal = (terminalId: string) => { - if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; - useRightPanelStore - .getState() - .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }; - const activatePanelTerminalStable = useStableCallback(activatePanelTerminal); - const closePanelTerminal = (terminalId: string) => { - if ( - !hasTerminalWriteAccess() || - !activeThreadRef || - activeRightPanelSurface?.kind !== "terminal" - ) - return; - terminalActions.closeTerminalSession(terminalId); - storeCloseTerminal(activeThreadRef, terminalId); - useRightPanelStore - .getState() - .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }; - const closePanelTerminalStable = useStableCallback(closePanelTerminal); - const requestCloseTerminal = (terminalId: string) => { - if (!hasTerminalWriteAccess()) return; - const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); - void confirmTerminalClose([label]).then((confirmed) => { - if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { - closeTerminal(terminalId); - } - }); - }; - const requestCloseTerminalStable = useStableCallback(requestCloseTerminal); - const requestClosePanelTerminal = (terminalId: string) => { - if (!hasTerminalWriteAccess()) return; - const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); - void confirmTerminalClose([label]).then((confirmed) => { - if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { - closePanelTerminal(terminalId); - } - }); - }; - const requestClosePanelTerminalStable = useStableCallback(requestClosePanelTerminal); - const activateRightPanelSurface = (surface: RightPanelSurface) => { - if (!activeThreadRef) return; - explicitDiffOpenRef.current = resolveExplicitDiffOpenOnActivate({ - surfaceKind: surface.kind, - diffOpen, - activeThreadRef, - }); - useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); - if (surface.kind === "preview" && surface.resourceId) { - setActivePreviewTab(activeThreadRef, surface.resourceId); - } - if (surface.kind === "terminal") { - setTerminalFocusRequestId((value) => value + 1); - } - if (surface.kind === "diff" && !diffOpen) { - onDiffPanelOpen?.(); - } - }; - const activateRightPanelSurfaceStable = useStableCallback(activateRightPanelSurface); - const toggleRightPanel = () => { - if (!activeThreadRef) return; - if (rightPanelOpen) { - closePreviewPanel(); - return; - } - useRightPanelStore.getState().toggleVisibility(activeThreadRef); - }; - const toggleRightPanelStable = useStableCallback(toggleRightPanel); - const toggleThreadPanel = () => { - if (!activeThreadRef) return; - useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation); - }; - const toggleThreadPanelStable = useStableCallback(toggleThreadPanel); useEffect(() => { if (!canMaximizeRightPanel) return; if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) { useRightPanelStore.getState().setMaximized(routeThreadRef, true); } }, [canMaximizeRightPanel, routeThreadRef]); - const toggleRightPanelMaximized = () => { - if (!canMaximizeRightPanel || !activeThreadRef) return; - useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized); - }; - const toggleRightPanelMaximizedStable = useStableCallback(toggleRightPanelMaximized); const { closeSurface: closeRightPanelSurface, closeOtherSurfaces: closeOtherRightPanelSurfacesStable, @@ -4359,53 +2174,6 @@ export default function ChatView(props: ChatViewProps) { terminalLabelsById: activeTerminalLabelsById, }); const closeRightPanelSurfaceStable = closeRightPanelSurface; - const moveRightPanelSurface = (surfaceId: string, toIndex: number) => { - if (activeThreadRef) - useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex); - }; - const moveRightPanelSurfaceStable = useStableCallback(moveRightPanelSurface); - const copyRightPanelFilePath = (relativePath: string) => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: "Clipboard API unavailable.", - }), - ); - return; - } - - const clipboardPath = formatFilePathForClipboard( - relativePath, - resolvePathLinkTarget(relativePath, gitCwd ?? activeProjectCwd ?? ""), - ); - void navigator.clipboard.writeText(clipboardPath).then( - () => { - toastManager.add({ - type: "success", - title: "Path copied", - description: clipboardPath, - }); - }, - (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - ); - }; - const copyRightPanelFilePathStable = useStableCallback(copyRightPanelFilePath); - useEffect(() => { - if (unfocusedSplitPane) return; - return subscribePreviewAction((action) => { - if (action === "toggle-panel") togglePreviewPanelStable(); - }); - }, [togglePreviewPanelStable, unfocusedSplitPane]); const persistThreadSettingsForNextTurn = async (input: { threadId: ThreadId; createdAt: string; @@ -4416,6 +2184,7 @@ export default function ChatView(props: ChatViewProps) { if (!serverThread) { return AsyncResult.success(undefined); } + let result: AtomCommandResult = AsyncResult.success(undefined); const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({ currentModelSelection: serverThread.modelSelection, @@ -4437,6 +2206,7 @@ export default function ChatView(props: ChatViewProps) { return result; } } + if (input.runtimeMode !== serverThread.runtimeMode) { result = mapAtomCommandResult( await setThreadRuntimeMode({ @@ -4453,6 +2223,7 @@ export default function ChatView(props: ChatViewProps) { return result; } } + if (input.interactionMode !== serverThread.interactionMode) { result = mapAtomCommandResult( await setThreadInteractionMode({ @@ -4468,537 +2239,40 @@ export default function ChatView(props: ChatViewProps) { } return result; }; - // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during - // thread switches. LegendList fires scroll events with isAtEnd=false while - // initialScrollAtEnd is settling; hiding is always immediate. - const showScrollDebouncer = useRef( - new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), - ); - const timelineScrollIntentRef = useRef<"toward-end" | "away-from-end" | null>(null); - const timelineScrollModeRef = useRef("following-end"); - // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd - // re-pins on its own (independent of the refs), so the timeline needs a - // render-visible flag to switch it off once the user scrolls away. - const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true); - const pendingTimelineAnchorRef = useRef(null); - const positionedTimelineAnchorRef = useRef(null); - const settledTimelineAnchorRef = useRef(null); - const activeTimelineAnchorIndexRef = useRef(null); - const observedTimelineActivityRef = useRef(null); - const anchorUserScrollGenerationRef = useRef(0); - const cancelPositionRestoreRef = useRef<(() => void) | null>(null); - const liveFollowUserScrollGenerationRef = useRef(0); - const pendingAnchorScrollRestoreRef = useRef<{ - readonly messageId: MessageId; - readonly offset: number; - readonly userScrollGeneration: number; - } | null>(null); - const anchorScrollRestoreFrameRef = useRef(null); - const cancelTimelineLiveFollowForUserNavigation = () => { - cancelPositionRestoreRef.current?.(); - anchorUserScrollGenerationRef.current += 1; - const wasProgrammaticScrollMode = timelineScrollModeRef.current !== "free-scrolling"; - timelineScrollModeRef.current = "free-scrolling"; - liveFollowUserScrollGenerationRef.current = null; - setTimelineLiveFollowEnabled(false); - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - pendingAnchorScrollRestoreRef.current = null; - if (anchorScrollRestoreFrameRef.current !== null) { - cancelAnimationFrame(anchorScrollRestoreFrameRef.current); - anchorScrollRestoreFrameRef.current = null; - } - if (wasProgrammaticScrollMode) { - // While following or anchoring, our scrollToEnd/scrollToOffset calls can - // sit in LegendList's pending-imperative-scroll queue (it defers them - // while layout settles) and fire seconds later with stale targets, - // yanking the view away after the user scrolled. Starting a new - // imperative scroll cancels everything queued; targeting an item that - // is not in the data makes the new request itself resolve without ever - // scrolling, so this is a pure cancel. - void legendListRef.current?.scrollToItem({ - item: TIMELINE_SCROLL_CANCEL_SENTINEL, - animated: false, - }); - // An already-started animated scroll (behavior: smooth) keeps running in - // the browser regardless of the queue; a same-position instant write is - // the only way to halt it where it is. - const scrollNode = legendListRef.current?.getScrollableNode() as - | { - scrollTop?: number; - } - | null - | undefined; - const currentScrollTop = scrollNode?.scrollTop; - if (scrollNode && typeof currentScrollTop === "number") { - scrollNode.scrollTop = currentScrollTop; - } - } - }; - const cancelTimelineLiveFollowForUserNavigationStable = useStableCallback( - cancelTimelineLiveFollowForUserNavigation, - ); - const cancelTimelineLiveFollowForUserNavigationRef = useRef( + + const { + showScrollToBottom, + timelineLiveFollowEnabled, + cancelPositionRestoreRef, + isTimelineAtLogicalEndStable, cancelTimelineLiveFollowForUserNavigationStable, - ); - useEffect(() => { - cancelTimelineLiveFollowForUserNavigationRef.current = - cancelTimelineLiveFollowForUserNavigationStable; - }, [cancelTimelineLiveFollowForUserNavigationStable]); - useEffect(() => { - const previous = observedTimelineActivityRef.current; - const dispatchedUserItem = - previous?.hydrated && - previous.threadKey === activeThreadKey && - activeActivityRun !== null && - previous.runId !== activeActivityRun.runId - ? serverProjection?.visibleTurnItems.find( - (row) => row.item.type === "user_message" && row.item.runId === activeActivityRun.runId, - ) - : undefined; - const { observation, anchorMessageId: messageId } = observeTimelineRun(previous, { - threadKey: activeThreadKey, - hydrated: !isServerThread || (serverProjection !== null && threadStatus === "live"), - runId: activeActivityRun?.runId ?? null, - queued: activeActivityRun?.status === "queued", - messageId: - dispatchedUserItem?.item.type === "user_message" ? dispatchedUserItem.item.messageId : null, - }); - observedTimelineActivityRef.current = observation; - if (messageId === null) return; - if ( - pendingTimelineAnchorRef.current !== null || - timelineScrollModeRef.current === "free-scrolling" - ) { - return; - } - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = messageId; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor({ threadKey: activeThreadKey, messageId }); - }, [activeActivityRun, activeThreadKey, isServerThread, serverProjection, threadStatus]); - const timelineRealContentOverflowsViewport = (list?: LegendListRef | null) => - timelineContentOverflowsViewport((list ?? legendListRef.current)?.getState(), { - composerInset: composerTimelineInset, - anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, - }); - const timelineRealContentOverflowsViewportStable = useStableCallback( - timelineRealContentOverflowsViewport, - ); - const pageScrollControllerRef = useRef | null>( - null, - ); - const handlePageScrollStart = useEffectEvent((key: PageScrollKey) => { - timelineScrollIntentRef.current = key === "PageUp" ? "away-from-end" : "toward-end"; - composerRef.current?.collapseForTimelineScrollKey(key); - if ( - (key === "PageUp" && timelineRealContentOverflowsViewportStable()) || - !isTimelineAtLogicalEndStable() - ) { - cancelTimelineLiveFollowForUserNavigationStable(); - } - }); - useEffect(() => { - const controller = createPageScrollController({ - getContainer: () => legendListRef.current?.getScrollableNode() ?? null, - getScrollPaddingBottomPx: () => composerOverlayElement?.getBoundingClientRect().height ?? 0, - onScrollStart: handlePageScrollStart, - }); - pageScrollControllerRef.current = controller; - return () => { - controller.dispose(); - if (pageScrollControllerRef.current === controller) { - pageScrollControllerRef.current = null; - } - }; - }, [composerOverlayElement]); - const onComposerPageScrollKeyDown = (key: PageScrollKey) => { - pageScrollControllerRef.current?.handleKeyDown(key); - }; - const onComposerPageScrollKeyDownStable = useStableCallback(onComposerPageScrollKeyDown); - const onComposerPageScrollKeyUp = (key: string) => { - pageScrollControllerRef.current?.handleKeyUp(key); - }; - const onComposerPageScrollKeyUpStable = useStableCallback(onComposerPageScrollKeyUp); - const onComposerPageScrollRelease = () => { - pageScrollControllerRef.current?.releaseActiveKey(); - }; - const onComposerPageScrollReleaseStable = useStableCallback(onComposerPageScrollRelease); - const scrollToEnd = (animated: boolean = false) => { - cancelPositionRestoreRef.current?.(); - isAtEndRef.current = true; - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor(releaseChatTimelineAnchor); - // The anchored end space must be gone before the scroll measures, or the - // list lands short of the real end (#6519). - requestAnimationFrame(() => { - void legendListRef.current?.scrollToEnd?.({ - animated: animated && !window.matchMedia("(prefers-reduced-motion: reduce)").matches, - }); - }); - }; - const scrollToEndStable = useStableCallback(scrollToEnd); - useEffect(() => { - const composer = composerRef; - let removeListeners: (() => void) | null = null; - let frame: number | null = null; - const attach = (remainingAttempts: number) => { - frame = requestAnimationFrame(() => { - frame = null; - const scrollNode = legendListRef.current?.getScrollableNode(); - if (!scrollNode) { - // The list may not have mounted on the first frame after a thread - // switch — without a retry the opt-out listeners never attach and - // live-follow becomes impossible to escape for the whole thread. - if (remainingAttempts > 0) { - attach(remainingAttempts - 1); - } - return; - } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); - }; - // The gestures below must only break follow when they can actually - // move the viewport away from the live edge (#5566): a spurious break - // while pinned at the end produces no scroll event, never re-arms, - // and streaming silently stops following. Underflowing content can't - // scroll at all, so nothing there should break follow. - const contentScrollsUp = () => timelineRealContentOverflowsViewportStable(); - // The follow re-arm band, not the strict flag: streaming growth makes - // isAtEnd flicker false for a frame before the follow scroll catches - // up, and a gesture landing in that window while still pinned would - // otherwise break follow with no scroll event left to re-arm it. - const viewportIsAwayFromEnd = () => - resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; - const handleWheel = (event: WheelEvent) => { - if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY)) - return; - if (event.deltaY > 0) { - timelineScrollIntentRef.current = "toward-end"; - if (isAtEndRef.current) { - composer.current?.restoreAfterTimelineReachedEnd(); - } - } else if (event.deltaY < 0) { - timelineScrollIntentRef.current = "away-from-end"; - } - if (event.deltaY < 0 && contentScrollsUp()) { - handleManualNavigation(); - } - }; - // Touch direction isn't observable here (touchmove fires on any - // finger motion, scrolling or not), so break only once the drag has - // actually carried the viewport out of the end band — an upward flick - // gets there within its first few events and later touchmoves break. - const handleTouchMove = () => { - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - }; - // Scrollbar drags produce no wheel/touch events; they are the only - // pointerdowns whose target is the scroll node itself rather than a - // message row. - const handlePointerDown = (event: PointerEvent) => { - if (event.target === scrollNode) { - if (contentScrollsUp()) { - handleManualNavigation(); - } - return; - } - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - }; - // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and - // pointer events entirely; without this the timeline yanks back to - // the end on the next stream chunk. Clicking message text can leave - // DOM focus on body, so these keys must also be heard at document. - const handleKeyDown = (event: KeyboardEvent) => { - if ( - !(event.target instanceof Node) || - (!scrollNode.contains(event.target) && - (unfocusedSplitPaneRef.current || - (event.target !== document.body && event.target !== document.documentElement))) || - event.defaultPrevented || - event.isComposing || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR) || - document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR) - ) { - return; - } - if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(event.key)) - return; - const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(event.key) ? -1 : 1; - if ( - scrollNode.contains(event.target) && - !isTimelineScrollTarget(event.target, scrollNode, scrollDirection) - ) - return; - switch (event.key) { - case "PageUp": - case "Home": - case "ArrowUp": - timelineScrollIntentRef.current = "away-from-end"; - if (contentScrollsUp()) { - handleManualNavigation(); - composer.current?.collapseForTimelineScrollKey(event.key); - } - break; - case "PageDown": - case "End": - case "ArrowDown": - timelineScrollIntentRef.current = "toward-end"; - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - composer.current?.collapseForTimelineScrollKey(event.key); - if (isTimelineAtLogicalEndStable()) { - composer.current?.restoreAfterTimelineReachedEnd(); - } - break; - default: - break; - } - }; - // Input inside an embedded frame reaches none of the listeners above, - // but its scroll still chains out to the timeline (#17056). - const scrolledUp = createUpwardScrollDetector({ - top: scrollNode.scrollTop, - height: scrollNode.scrollHeight, - }); - const handleScroll = () => { - if ( - scrolledUp({ - top: scrollNode.scrollTop, - height: scrollNode.scrollHeight, - programmatic: - positionedTimelineAnchorRef.current !== null && - positionedTimelineAnchorRef.current !== settledTimelineAnchorRef.current, - }) && - timelineScrollModeRef.current !== "free-scrolling" && - // LegendList updates its scroll state on the next animation frame. - resolveTimelineIsAtEnd({ - contentLength: scrollNode.scrollHeight, - scroll: scrollNode.scrollTop, - scrollLength: scrollNode.clientHeight, - }) === false - ) { - handleManualNavigation(); - } - }; - scrollNode.addEventListener("scroll", handleScroll, { passive: true }); - scrollNode.addEventListener("wheel", handleWheel, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleTouchMove, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handlePointerDown, { - passive: true, - }); - document.addEventListener("keydown", handleKeyDown); - removeListeners = () => { - scrollNode.removeEventListener("scroll", handleScroll); - scrollNode.removeEventListener("wheel", handleWheel); - scrollNode.removeEventListener("touchmove", handleTouchMove); - scrollNode.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }); - }; - attach(12); - return () => { - if (frame !== null) { - cancelAnimationFrame(frame); - } - removeListeners?.(); - }; - }, [ - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A thread switch remounts the list's scroll node, so the listeners reattach. - activeThread?.id, + onComposerPageScrollKeyDownStable, + onComposerPageScrollKeyUpStable, + onComposerPageScrollReleaseStable, + scrollToEnd, + onTimelineAnchorReadyStable, + onTimelineAnchorSizeChangedStable, + onToolOutputCollapsedAtEndStable, + onIsAtEndChangeStable, + anchorNewTurn, + } = useTimelineFollow({ + unfocusedSplitPaneRef, + legendListRef, composerRef, - isTimelineAtLogicalEndStable, - timelineRealContentOverflowsViewportStable, - ]); - - const onTimelineAnchorReady = (messageId: MessageId, anchorIndex: number) => { - if (pendingTimelineAnchorRef.current === messageId) { - pendingTimelineAnchorRef.current = null; - } - activeTimelineAnchorIndexRef.current = anchorIndex; - if (positionedTimelineAnchorRef.current === messageId) { - return; - } - positionedTimelineAnchorRef.current = messageId; - settledTimelineAnchorRef.current = null; - const positionAnchor = (remainingAttempts: number) => { - requestAnimationFrame(() => { - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - const list = legendListRef.current; - if (!list) { - if (remainingAttempts > 0) { - positionAnchor(remainingAttempts - 1); - } - return; - } - // LegendList waits for queued layout and the animation before resolving. - void list - .scrollToIndex({ - index: anchorIndex, - animated: !window.matchMedia("(prefers-reduced-motion: reduce)").matches, - viewPosition: 0, - viewOffset: CHAT_LIST_ANCHOR_OFFSET, - }) - .then(() => { - if ( - legendListRef.current === list && - positionedTimelineAnchorRef.current === messageId - ) { - settledTimelineAnchorRef.current = messageId; - } - }); - }); - }; - requestAnimationFrame(() => positionAnchor(12)); - }; - const onTimelineAnchorReadyStable = useStableCallback(onTimelineAnchorReady); - const onTimelineAnchorSizeChanged = (messageId: MessageId) => { - if (settledTimelineAnchorRef.current !== messageId) { - return; - } - if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { - return; - } - const scrollOffset = legendListRef.current?.getState().scroll; - if (scrollOffset === undefined) { - return; - } - if (pendingAnchorScrollRestoreRef.current === null) { - pendingAnchorScrollRestoreRef.current = { - messageId, - offset: scrollOffset, - userScrollGeneration: anchorUserScrollGenerationRef.current, - }; - } - if (anchorScrollRestoreFrameRef.current !== null) { - return; - } - anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => { - anchorScrollRestoreFrameRef.current = null; - const pending = pendingAnchorScrollRestoreRef.current; - pendingAnchorScrollRestoreRef.current = null; - if ( - pending && - settledTimelineAnchorRef.current === pending.messageId && - pending.userScrollGeneration === anchorUserScrollGenerationRef.current - ) { - const list = legendListRef.current; - const currentScrollOffset = list?.getState().scroll; - if ( - typeof currentScrollOffset === "number" && - Math.abs(currentScrollOffset - pending.offset) <= 2 - ) { - void list?.scrollToOffset({ offset: pending.offset, animated: false }); - } - } - }); - }; - const onTimelineAnchorSizeChangedStable = useStableCallback(onTimelineAnchorSizeChanged); - const onToolOutputCollapsedAtEnd = () => { - composerRef.current?.restoreAfterTimelineReachedEnd(); - }; - const onToolOutputCollapsedAtEndStable = useStableCallback(onToolOutputCollapsedAtEnd); - const onIsAtEndChange = (isAtEnd: boolean) => { - if ( - !isAtEnd && - liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current - ) { - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - return; - } - if (isAtEndRef.current === isAtEnd) return; - isAtEndRef.current = isAtEnd; - if (isAtEnd) { - if (timelineScrollIntentRef.current === "toward-end") { - composerRef.current?.restoreAfterTimelineReachedEnd(); - } - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - setTimelineAnchor(releaseChatTimelineAnchor); - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - } else { - timelineScrollModeRef.current = "free-scrolling"; - liveFollowUserScrollGenerationRef.current = null; - showScrollDebouncer.current.maybeExecute(); - } - }; - const onIsAtEndChangeStable = useStableCallback(onIsAtEndChange); - useLayoutEffect(() => { - if (timelineScrollModeRef.current !== "anchoring-new-turn") return; - if ( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId: timelineAnchorMessageId, - liveFollowEnabled: timelineLiveFollowEnabled, - runningTurnId: activeRunningTurnId, - timelineEntries, - }) - ) { - scrollToEndStable(); - } - }, [ + composerOverlayElement, + composerTimelineInset, + routeThreadKey, + activeThreadKey, + activeThreadId: activeThread?.id, + isServerThread, + serverProjection, + threadStatus, + activeActivityRun, activeRunningTurnId, - scrollToEndStable, - timelineAnchorMessageId, timelineEntries, - timelineLiveFollowEnabled, - ]); - - // A thread switch restores that thread's remembered follow state; the refs - // the scroll handlers read catch up after commit. - const [timelineThreadReset, setTimelineThreadReset] = useState({ - threadKey: activeThreadKey, - followEnd: true, + timelineAnchorMessageId, + setTimelineAnchor, }); - if (timelineThreadReset.threadKey !== activeThreadKey) { - const followEnd = readTimelinePosition(routeThreadKey)?.atEnd !== false; - setTimelineThreadReset({ threadKey: activeThreadKey, followEnd }); - setTimelineLiveFollowEnabled(followEnd); - setShowScrollToBottom(!followEnd); - } - useEffect(() => { - const { followEnd } = timelineThreadReset; - isAtEndRef.current = followEnd; - timelineScrollIntentRef.current = null; - timelineScrollModeRef.current = followEnd ? "following-end" : "free-scrolling"; - liveFollowUserScrollGenerationRef.current = followEnd - ? anchorUserScrollGenerationRef.current - : null; - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - }, [timelineThreadReset]); useEffect(() => { // Opening a split mounts the other pane; it must not take focus from the routed chat. @@ -5088,6 +2362,7 @@ export default function ChatView(props: ChatViewProps) { handoffAttachmentPreviewsStable, optimisticUserMessages, ]); + // Keyed on the thread, not the draft: a draft's promotion to its server // route keeps this instance and drops `draftId`, and the send it is still // dispatching must survive that swap. @@ -5157,6 +2432,12 @@ export default function ChatView(props: ChatViewProps) { requestedEnvMode: envMode, isGitRepo, }); + // The start-from picker only opens on a new thread's draft, so it points that draft. + const pointDraftAtCheckout = useStableCallback( + (checkout: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { + setDraftThreadContext(composerDraftTarget, checkout); + }, + ); const readAutomationSource = useStableCallback((): ComposerAutomationSource | null => { const context = composerRef.current?.getSendContext(); if (!context || !activeProject) return null; @@ -5292,11 +2573,14 @@ export default function ChatView(props: ChatViewProps) { publishComposerOverlayHeightStable(composerOverlayElement.getBoundingClientRect().height); // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A thread switch must re-measure the overlay for the new timeline. }, [activeThreadKey, composerOverlayElement, publishComposerOverlayHeightStable]); + useLayoutEffect(() => { if (!composerOverlayElement) return; + const updateHeight = () => { publishComposerOverlayHeightStable(composerOverlayElement.getBoundingClientRect().height); }; + updateHeight(); // The hero overlay fills the pane, so the composer's own growth only shows on its stack. const stack = isDraftHeroState @@ -5356,7 +2640,6 @@ export default function ChatView(props: ChatViewProps) { }, ); }; - const copyActiveThreadReferenceStable = useStableCallback(copyActiveThreadReference); const pullRequestPanelTarget = activeThread ? threadPullRequestPanelTarget({ projectId: activeThread.projectId, @@ -5365,727 +2648,120 @@ export default function ChatView(props: ChatViewProps) { branchPullRequest: activeThreadShell?.branchPullRequest ?? activeThread.branchPullRequest, }) : null; - const addPullRequestSurface = () => { - if (!supportsPullRequests || activeThreadRef === null || pullRequestPanelTarget === null) - return; - useRightPanelStore.getState().openPullRequest(activeThreadRef, pullRequestPanelTarget); - }; - const addPullRequestSurfaceStable = useStableCallback(addPullRequestSurface); + const { + activateRightPanelSurfaceStable, + addDeviceSurfaceStable, + addDiffSurfaceStable, + addFilesSurfaceStable, + addPullRequestSurfaceStable, + addPullRequestsSurfaceStable, + closePreviewPanelStable, + copyRightPanelFilePathStable, + createBrowserSurfaceStable, + moveRightPanelSurfaceStable, + onOpenTurnDiffStable, + openChangesFromThreadPanelStable, + openFileSurfaceStable, + toggleRightPanelMaximizedStable, + toggleRightPanelStable, + toggleThreadPanelStable, + shortcuts: rightPanelShortcuts, + addAgentsSurface, + showLineage, + launcherLiveAgentCount, + agentsSurfaceVisible, + } = useRightPanelActions({ + unfocusedSplitPane, + subagents: serverProjection?.subagents, + activeThreadRef, + activeProject, + activeRightPanelSurface, + activePreviewState, + deviceState, + pullRequestPanelTarget, + threadPanelPresentation, + isServerThread, + isGitRepo, + browserAvailable, + canOperatePreview, + diffOpen, + previewPanelOpen, + rightPanelOpen, + canMaximizeRightPanel, + rightPanelMaximized, + supportsPullRequests, + pullRequestsSurfaceAvailable, + gitCwd, + activeProjectCwd, + explicitDiffOpenRef, + setTerminalFocusRequestId, + onDiffPanelOpen, + closeRightPanelSurface, + }); const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; - const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; - const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; - const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; - const activeThreadPinned = supportsPinning && activeThreadShell?.pinnedAt != null; - // The compiler caches render results on their inputs, so a clock read in render - // stays frozen until the shell changes. The clock is state: reset with the shell, - // and moved by the wake timer. - const [snoozeClockState, setSnoozeClockState] = useState(() => ({ - shell: activeThreadShell, - now: new Date().toISOString(), - })); - if (snoozeClockState.shell !== activeThreadShell) { - setSnoozeClockState({ shell: activeThreadShell, now: new Date().toISOString() }); - } - const snoozeClock = snoozeClockState.now; - const activeThreadSnoozed = - activeThreadShell !== null && - supportsSnooze && - effectiveSnoozed(activeThreadShell, { now: snoozeClock }); - useEffect(() => { - if (!activeThreadSnoozed) return; - const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? ""); - if (!Number.isFinite(wakeAtMs)) return; - const id = window.setTimeout( - () => setSnoozeClockState((clock) => ({ ...clock, now: new Date().toISOString() })), - Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647), - ); - return () => window.clearTimeout(id); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A clock that lands before the wake time (timer clamping, clock skew) re-arms the timer. - }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeClock]); - const activeThreadWokeAt = - activeThreadShell !== null && supportsSnooze - ? threadWokeAt(activeThreadShell, { now: snoozeClock }) - : null; - const acknowledgeThreadWoke = useAcknowledgeThreadWoke(); - const acknowledgeActiveThreadWoke = () => { - if (activeThreadRef === null || activeThreadWokeAt === null) return; - acknowledgeThreadWoke(activeThreadRef, activeThreadWokeAt); - }; - const acknowledgeActiveThreadWokeStable = useStableCallback(acknowledgeActiveThreadWoke); - const activeThreadLocalVisitedAt = useUiStateStore((store) => - activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], - ); - const activeThreadLastVisitedAt = resolveThreadLastVisitedAt( - activeThreadShell?.lastVisitedAt, - activeThreadLocalVisitedAt, - ); - const activeThreadWokeVisible = isThreadWokeUnseen({ - wokeAt: activeThreadWokeAt, - settled: activeThreadShell?.settledOverride === "settled", - lastVisitedAt: activeThreadLastVisitedAt, - latestRunCompletedAt: activeLatestRun?.completedAt ?? null, - }); - const activeThreadSettled = - supportsSettlement && activeThreadShell?.settledOverride === "settled"; - const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, { - reportFailure: false, - }); - const [unsettlingThreadKey, setUnsettlingThreadKey] = useState(null); - const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; - const handleUnsettleActiveThread = async () => { - if ( - !activeThreadRef || - !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) - ) - return; - const threadKey = scopedThreadKey(activeThreadRef); - setUnsettlingThreadKey(threadKey); - // Commands settle into a result, so this always runs. - const result = await unsettleThreadMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, reason: "user" }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to un-settle thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); - }; - const handleUnsettleActiveThreadStable = useStableCallback(handleUnsettleActiveThread); - const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { - reportFailure: false, - }); - const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState(null); - const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; - const handleUnsnoozeActiveThread = async () => { - if ( - !activeThreadRef || - !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) - ) - return; - const threadKey = scopedThreadKey(activeThreadRef); - setUnsnoozingThreadKey(threadKey); - // Commands settle into a result, so this always runs. - const recovery = activeThreadShell?.limitRecovery; - const recoveryOwnsSnooze = - recovery?.snooze === true && - recovery.runId === activeThreadShell?.latestRun?.runId && - activeThreadShell?.snoozedUntil != null && - Date.parse(activeThreadShell.snoozedUntil) === Date.parse(recovery.resetAt); - const result = recoveryOwnsSnooze - ? await updateThreadMetadata({ - environmentId: activeThreadRef.environmentId, - input: { - threadId: activeThreadRef.threadId, - limitRecovery: { runId: recovery.runId, resetAt: recovery.resetAt, snooze: false }, - }, - }) - : await unsnoozeThreadMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, reason: "user" }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to wake thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); - }; - const handleUnsnoozeActiveThreadStable = useStableCallback(handleUnsnoozeActiveThread); - const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); - const [revealedBranchMismatchKey, setRevealedBranchMismatchKey] = useState(null); - const [, setBranchMismatchDismissTick] = useState(0); - const composerHasDraftContent = useComposerDraftStore((store) => - composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)), - ); const nowMinute = useNowMinute(); - const activeBranchMismatchKey = branchMismatchKey( - activeThread?.id ?? null, + const { + acknowledgeActiveThreadWoke, + activeBranchMismatchKey, + backgroundWorkBanner, + childInputBanner, + goalBanner, + handleSwitchCheckoutToThread, + handleSwitchCheckoutToThreadStable, + isRestoringThreadBranch, + onOpenRelatedThread, + onOpenRelatedThreadStable, + sendStandaloneCommand, + setBranchMismatchDismissTick, + showBranchMismatchBanner, + threadStatusLine, + shortcuts: threadLifecycleShortcuts, + sendAppMessageStable, + sendCitationStable, + sendCitationElsewhereStable, + } = useThreadLifecycle({ + onViewAgents: agentsSurfaceVisible ? null : addAgentsSurface, + isServerThread, + interactionMode, + phase, + activeProject, + activeProjectRef, + activeThreadBranch, + settings, + messageDraftActions, + environmentId, + routeThreadKey, + currentRouteThreadKeyRef, + canOperateThread, + clientSettingsHydrated, + activeThread, + activeThreadId, + activeThreadRef, + activeThreadKey, + activeThreadShell, + activeLatestRun, + activeProjectCwd, + serverConfig, + runtimeMode, + isWorking, + pendingBackgroundTasks, + activePendingApproval, + activePendingUserInput, localCheckoutBranchMismatch, - ); - const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ - hasMismatch: localCheckoutBranchMismatch !== null, - isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey), - composerHasContent: composerHasDraftContent, - wasShownForCurrentMismatch: - revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey, - }); - const nextRevealedBranchMismatchKey = showBranchMismatchBanner - ? activeBranchMismatchKey - : revealedBranchMismatchKey !== activeBranchMismatchKey - ? null - : revealedBranchMismatchKey; - if (nextRevealedBranchMismatchKey !== revealedBranchMismatchKey) { - setRevealedBranchMismatchKey(nextRevealedBranchMismatchKey); - } - const handleSwitchCheckoutToThread = async () => { - if (!canWriteSourceControl) return; - if ( - !activeProjectCwd || - !activeThread || - !localCheckoutBranchMismatch || - isRestoringThreadBranch - ) { - return; - } - setIsRestoringThreadBranch(true); - const checkoutResult = await switchGitRef({ - environmentId, - input: { - cwd: activeProjectCwd, - refName: localCheckoutBranchMismatch.threadBranch, - }, - }); - if (checkoutResult._tag === "Failure") { - setIsRestoringThreadBranch(false); - if (!isAtomCommandInterrupted(checkoutResult)) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to switch checkout", - description: chatActionErrorMessage(squashAtomCommandFailure(checkoutResult)), - }), - ); - } - return; - } - if (!readEnvironmentScope(environmentId, AuthSourceControlWriteScope)) { - setIsRestoringThreadBranch(false); - return; - } - const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch; - if (nextBranch !== activeThread.branch) { - const updateResult = await updateThreadMetadata({ - environmentId, - input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null }, - }); - if (updateResult._tag === "Failure") { - setIsRestoringThreadBranch(false); - if (!isAtomCommandInterrupted(updateResult)) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Checkout switched, but the thread could not be updated", - description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)), - }), - ); - } - gitStatusQuery.refresh(); - return; - } - } - gitStatusQuery.refresh(); - setIsRestoringThreadBranch(false); - scheduleComposerFocus(); - }; - const handleSwitchCheckoutToThreadStable = useStableCallback(() => { - void handleSwitchCheckoutToThread(); + gitStatusQuery, + nowMinute, + composerRef, + composerDraftTarget, + sendInFlightRef, + setOptimisticUserMessages, + setThreadError, + clearUsageLimitsFor, + beginLocalDispatch, + resetLocalDispatch, + scheduleComposerFocus, + scrollToEnd, + persistThreadSettingsForNextTurn, }); - // The interrupt path also accepts a completed run while its provider still - // has background work. - const activeBackgroundTasks = !isWorking && activeThread ? pendingBackgroundTasks : []; - const [stoppingBackgroundWorkKey, setStoppingBackgroundWorkKey] = useState(null); - const isStoppingBackgroundWork = - stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`; - const handleStopBackgroundWork = async () => { - if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) - return; - const requestKey = `${environmentId}:${activeThread.id}`; - setStoppingBackgroundWorkKey(requestKey); - const result = await interruptThreadTurn({ - environmentId, - input: { threadId: activeThread.id }, - }); - // Acceptance does not confirm termination. Allow retry while the provider - // finishes stopping the tasks or reports a failure. - setStoppingBackgroundWorkKey((current) => (current === requestKey ? null : current)); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to stop background work.", - ); - } - } - }; - const handleStopBackgroundWorkStable = useStableCallback(handleStopBackgroundWork); - const openRelatedThreadGenerationRef = useRef(0); - const onOpenRelatedThread = async (threadId: ThreadId) => { - const generation = ++openRelatedThreadGenerationRef.current; - const threadRef = scopeThreadRef(environmentId, threadId); - const isEnvironmentLive = () => - appAtomRegistry.get(environmentShell.stateValueAtom(environmentId)).status === "live"; - // A just-launched child can reach this timeline before its shell syncs. - if (isEnvironmentLive() && readThreadShell(threadRef) === null) { - const location = router.latestLocation; - await waitForThreadShell(threadRef, RELATED_THREAD_SHELL_WAIT_MS); - if ( - generation !== openRelatedThreadGenerationRef.current || - currentRouteThreadKeyRef.current !== routeThreadKey || - router.latestLocation !== location - ) { - return; - } - } - const shell = readThreadShell(threadRef); - if (shell === null ? isEnvironmentLive() : shell.deletedAt != null) { - toastManager.add({ type: "info", title: "This thread is no longer available" }); - return; - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - }); - }; - const onOpenRelatedThreadStable = useStableCallback(onOpenRelatedThread); - const childThreadInputs = useChildThreadInputs(activeThreadRef); - const childInputBannerItem = (() => { - const first = childThreadInputs[0]; - const parentAwaitingUser = - activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput; - if (!first || parentAwaitingUser) return null; - return { - id: `child-input:${first.id}`, - variant: "info", - priority: "activity", - icon: first.hasPendingApprovals ? : , - title: childThreadInputsTitle(childThreadInputs), - description: childThreadInputs.map((child, index) => ( - - {index > 0 ? " " : null} - - onOpenRelatedThreadStable(child.id)}> - {child.title} - - {index < childThreadInputs.length - 1 ? "," : null} - - - )), - actions: ( - - ), - }; - })() as ComposerBannerStackItem | null; - - const sendAppMessage = async (text: string) => { - if (!isServerThread || activeThreadId === null) { - throw new Error("Messages from apps need a started thread."); - } - const settingsResult = await persistThreadSettingsForNextTurn({ - threadId: activeThreadId, - createdAt: new Date().toISOString(), - ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), - runtimeMode, - interactionMode, - }); - const result = - settingsResult._tag === "Failure" - ? settingsResult - : await startThreadTurn({ - environmentId, - input: { - threadId: activeThreadId, - message: { messageId: newMessageId(), role: "user", text, attachments: [] }, - runtimeMode, - interactionMode, - dispatchMode: "queue", - }, - }); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - throw error instanceof Error ? error : new Error("Could not send the app's message."); - } - }; - const sendAppMessageStable = useStableCallback(sendAppMessage); - - const sendStandaloneCommand = async ( - text: string, - failureMessage: string, - dispatchMode?: ComposerDispatchMode, - ): Promise => { - if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return false; - if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return false; - const context = composerRef.current?.getSendContext(); - if (!context?.providerAvailable) return false; - const queued = phase === "running" && dispatchMode === "queue"; - const threadId = activeThread.id; - const messageId = newMessageId(); - const createdAt = new Date().toISOString(); - sendInFlightRef.current = true; - beginLocalDispatch(); - setThreadError(threadId, null); - setOptimisticUserMessages((messages) => [ - ...messages, - { - id: messageId, - role: "user", - text, - runId: null, - createdAt, - updatedAt: createdAt, - streaming: false, - ...(queued - ? { inputIntent: "queued_turn" as const } - : phase === "running" && dispatchMode === "steer" - ? { inputIntent: "steer" as const } - : {}), - }, - ]); - scrollToEnd(); - // Commands settle into a result, so this always runs. - const settingsResult = await persistThreadSettingsForNextTurn({ - threadId, - createdAt, - ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), - runtimeMode, - interactionMode: context.interactionMode, - }); - const result = - settingsResult._tag === "Failure" - ? settingsResult - : await startThreadTurn({ - environmentId, - input: { - threadId, - message: { messageId, role: "user", text, attachments: [] }, - modelSelection: context.selectedModelSelection, - runtimeMode, - interactionMode: context.interactionMode, - ...(dispatchMode ? { dispatchMode } : {}), - createdAt, - }, - }); - if (result._tag === "Failure") { - setOptimisticUserMessages((messages) => - messages.filter((message) => message.id !== messageId), - ); - resetLocalDispatch(); - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError(threadId, error instanceof Error ? error.message : failureMessage); - } - } else { - clearUsageLimitsFor(routeThreadKey); - } - sendInFlightRef.current = false; - return result._tag !== "Failure"; - }; - const sendStandaloneCommandStable = useStableCallback(sendStandaloneCommand); - // A citation comment sends on its own, leaving the rest of the composer for later. - const sendCitationStable = useStableCallback((text: string, dispatchMode: ComposerDispatchMode) => - sendStandaloneCommand(text, "Failed to send the citation.", dispatchMode), - ); - const announceCitationDelivered = (title: string, threadRef: ScopedThreadRef) => { - toastManager.add( - stackedThreadToast({ - type: "success", - title, - timeout: 5000, - actionProps: { - children: "Open", - onClick: () => { - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - }); - }, - }, - }), - ); - }; - // A citation the comment panel sends to a new thread starts it in this project and checkout, - // with this composer's model, and leaves the reader here. - const startCitationThread = async (text: string): Promise => { - if (!activeThread || !activeProject || !isServerThread) return false; - const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable) return false; - const createdAt = new Date().toISOString(); - const nextThreadId = newThreadId(); - const nextThreadRef = scopeThreadRef(activeThread.environmentId, nextThreadId); - const title = deriveThreadTitleSeed({ text, attachments: [] }); - const createResult = await createThread({ - environmentId, - input: { - threadId: nextThreadId, - projectId: activeProject.id, - title, - modelSelection: sendCtx.selectedModelSelection, - runtimeMode, - interactionMode: "default", - branch: activeThreadBranch, - worktreePath: activeThread.worktreePath, - createdAt, - }, - }); - const result = - createResult._tag === "Failure" - ? createResult - : await startThreadTurn({ - environmentId, - input: { - threadId: nextThreadId, - message: { messageId: newMessageId(), role: "user", text, attachments: [] }, - modelSelection: sendCtx.selectedModelSelection, - titleSeed: title, - runtimeMode, - interactionMode: "default", - createdAt, - }, - }); - if (result._tag === "Failure") { - if (createResult._tag === "Success") { - void deleteThread({ environmentId, input: { threadId: nextThreadId } }); - } - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not start the thread", - description: error instanceof Error ? error.message : undefined, - }), - ); - } - return false; - } - announceCitationDelivered("Started a new thread", nextThreadRef); - return true; - }; - // Saving to a new thread opens one with the quote in its composer, unsent. - const openCitationInNewThread = async (text: string): Promise => { - if (!activeProjectRef) return false; - const opened = await handleNewThread(activeProjectRef); - if (!opened) return false; - const store = useComposerDraftStore.getState(); - store.setPrompt( - opened.draftId, - appendCitationToPrompt(store.getComposerDraft(opened.draftId)?.prompt ?? "", text), - ); - return true; - }; - // The comment panel's other destinations. The quote travels as the same serialized link a - // composer citation sends, so the destination shows its chip and can open the source. - const sendCitationElsewhere = async ( - destination: Exclude, - action: CitationDestinationAction, - text: string, - ): Promise => { - if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) { - return false; - } - if (destination.kind === "new-thread") { - return action === "send" ? startCitationThread(text) : openCitationInNewThread(text); - } - const targetRef = scopeThreadRef(environmentId, destination.threadId); - const targetTitle = destination.title || "Untitled thread"; - if (action === "save") { - const store = useComposerDraftStore.getState(); - store.setPrompt( - targetRef, - appendCitationToPrompt(store.getComposerDraft(targetRef)?.prompt ?? "", text), - ); - announceCitationDelivered(`Added to the composer in ${targetTitle}`, targetRef); - return true; - } - if (action === "draft") { - const saved = await messageDraftActions.save(environmentId, { - threadId: destination.threadId, - text, - attachments: [], - }); - if (saved === null) return false; - announceCitationDelivered(`Saved as a draft in ${targetTitle}`, targetRef); - return true; - } - const target = readThreadShell(targetRef); - if (!target) return false; - const dispatchMode = resolveComposerDispatchMode({ - running: threadRuntimeIsActive(target.runtime), - alternateModifier: false, - activeTurnDefault: settings.followUpBehavior, - }); - const result = await startThreadTurn({ - environmentId, - input: { - threadId: target.id, - message: { messageId: newMessageId(), role: "user", text, attachments: [] }, - modelSelection: target.modelSelection, - runtimeMode: target.runtimeMode, - interactionMode: target.interactionMode, - dispatchMode, - createdAt: new Date().toISOString(), - }, - }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Could not send to ${targetTitle}`, - description: error instanceof Error ? error.message : undefined, - }), - ); - } - return false; - } - announceCitationDelivered( - dispatchMode === "queue" ? `Queued in ${targetTitle}` : `Sent to ${targetTitle}`, - targetRef, - ); - return true; - }; - const sendCitationElsewhereStable = useStableCallback(sendCitationElsewhere); - const activeGoal = activeThreadShell?.goal ?? null; - const goalBannerItem = (() => { - if (!activeThread || activeGoal === null) return null; - const presentation = presentProviderGoal(activeGoal, isWorking); - return { - id: `goal:${activeThread.id}`, - variant: "default", - priority: "activity", - icon: , - title: - presentation.usage === null - ? presentation.title - : `${presentation.title} · ${presentation.usage}`, - description: presentation.objective, - actions: isWorking ? undefined : ( - <> - {presentation.canResume ? ( - - ) : null} - - - ), - }; - })() as ComposerBannerStackItem | null; - - const backgroundWorkBannerItem = (() => { - const presentation = presentPendingBackgroundWork(activeBackgroundTasks); - if (presentation === null || !activeThread) { - return null; - } - return { - id: `background-work:${activeThread.id}`, - variant: "default", - priority: "activity", - icon: ( -