diff --git a/AGENTS.md b/AGENTS.md index 3c6d40a..6a56f97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -354,7 +354,11 @@ The order below is our current best guess; it is not a contract. 7. **Embedded codex (done).** Desktop builds compile codex in-process behind the `embedded-codex` feature, so a machine can host without a separate `codex` install; see §8.1 for the shims this requires. -8. **Strongly-typed JSON-RPC client (next).** Replace the +8. **App-server protocol sync (2026-09-07).** Codex fork merged upstream main + `db0568dbb`; CLI and UI share the acknowledged initialization handshake. + Flutter reads v2 accounts and current thread model/effort, and answers + asynchronous questions with `turn/steer` while running or `turn/start` when idle. + **Strongly-typed JSON-RPC client (next).** Replace the `serde_json::Value` surface in `pocket-codex-codex::protocol` with the upstream `codex-app-server-protocol` types so the Flutter UI gets compile-time-checked methods. diff --git a/Cargo.lock b/Cargo.lock index 377840d..a95a6ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -407,6 +407,27 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "appcontainer_common" +version = "0.8.0" +source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +dependencies = [ + "flatbuffers", + "getrandom 0.2.17", + "learning_mode_core", + "learning_mode_windows", + "process_security_environment_spec", + "sandbox_spec", + "serde", + "serde_json", + "thiserror 2.0.18", + "widestring", + "windows 0.62.2", + "windows-core 0.62.2", + "winreg 0.55.0", + "wxc_common", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -1628,6 +1649,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" +[[package]] +name = "cidr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579504560394e388085d0c080ea587dfa5c15f7e251b4d5247d1e1a61d1d6928" + [[package]] name = "cipher" version = "0.4.4" @@ -1918,6 +1945,7 @@ dependencies = [ "sha2 0.10.9", "tempfile", "thiserror 2.0.18", + "tikv-jemallocator", "time", "tokio", "tokio-util", @@ -2042,6 +2070,14 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "codex-attachment-store" +version = "0.0.0" +dependencies = [ + "codex-utils-image", + "serde", +] + [[package]] name = "codex-aws-auth" version = "0.0.0" @@ -2081,6 +2117,15 @@ dependencies = [ "serde_with", ] +[[package]] +name = "codex-build-info" +version = "0.0.0" +dependencies = [ + "codex-install-context", + "semver", + "serde", +] + [[package]] name = "codex-chatgpt" version = "0.0.0" @@ -2275,7 +2320,6 @@ dependencies = [ "base64 0.22.1", "bm25", "chrono", - "clap", "codex-agent-graph-store", "codex-agent-roles", "codex-analytics", @@ -2283,6 +2327,7 @@ dependencies = [ "codex-app-server-protocol", "codex-apply-patch", "codex-async-utils", + "codex-attachment-store", "codex-client", "codex-code-mode", "codex-config", @@ -2330,6 +2375,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-audio", "codex-utils-cache", + "codex-utils-git-discovery", "codex-utils-home-dir", "codex-utils-image", "codex-utils-output-truncation", @@ -2419,7 +2465,6 @@ dependencies = [ "tracing", "url", "uuid", - "which 8.0.4", "zip", ] @@ -2440,6 +2485,7 @@ dependencies = [ "bytes", "clatter", "codex-api", + "codex-build-info", "codex-config", "codex-exec-server-protocol", "codex-file-system", @@ -2459,6 +2505,7 @@ dependencies = [ "futures", "http 1.4.0", "libc", + "opentelemetry", "prost", "rustix 1.1.4", "serde", @@ -2521,6 +2568,7 @@ dependencies = [ "codex-config", "codex-context-fragments", "codex-exec-server-protocol", + "codex-history", "codex-mcp", "codex-protocol", "codex-tools", @@ -2693,6 +2741,7 @@ dependencies = [ name = "codex-guardian-context" version = "0.0.0" dependencies = [ + "codex-history", "codex-protocol", "serde_json", ] @@ -2701,10 +2750,12 @@ dependencies = [ name = "codex-guardian-v2" version = "0.0.0" dependencies = [ + "codex-analytics", "codex-api", "codex-core", "codex-extension-api", "codex-features", + "codex-guardian-context", "codex-history", "codex-http-client", "codex-login", @@ -2728,12 +2779,14 @@ dependencies = [ "codex-protocol", "schemars 0.8.22", "serde", + "serde_json", ] [[package]] name = "codex-history-notes-extension" version = "0.0.0" dependencies = [ + "codex-analytics", "codex-api", "codex-client", "codex-core", @@ -2943,6 +2996,7 @@ dependencies = [ name = "codex-mcp-extension" version = "0.0.0" dependencies = [ + "anyhow", "codex-config", "codex-connectors", "codex-connectors-extension", @@ -3064,13 +3118,24 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-utils-output-truncation", - "codex-utils-template", "serde", "serde_json", "tokio", "tracing", ] +[[package]] +name = "codex-mxc-sandbox" +version = "0.0.0" +dependencies = [ + "anyhow", + "appcontainer_common", + "codex-protocol", + "learning_mode_windows", + "tracelogging", + "wxc_common", +] + [[package]] name = "codex-network-proxy" version = "0.0.0" @@ -3083,6 +3148,7 @@ dependencies = [ "codex-utils-home-dir", "codex-utils-rustls-provider", "globset", + "opentelemetry", "rama-core", "rama-http", "rama-http-backend", @@ -3321,7 +3387,9 @@ name = "codex-sandboxing" version = "0.0.0" dependencies = [ "anyhow", + "codex-mxc-sandbox", "codex-network-proxy", + "codex-otel", "codex-protocol", "codex-utils-absolute-path", "codex-utils-home-dir", @@ -3525,6 +3593,7 @@ dependencies = [ "tokio", "tokio-util", "uds_windows", + "windows-sys 0.52.0", ] [[package]] @@ -3571,6 +3640,17 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "codex-utils-git-discovery" +version = "0.0.0" +dependencies = [ + "codex-git-utils", + "codex-utils-absolute-path", + "futures", + "tokio", + "tracing", +] + [[package]] name = "codex-utils-home-dir" version = "0.0.0" @@ -3614,6 +3694,7 @@ dependencies = [ "codex-utils-absolute-path", "dunce", "tempfile", + "windows-sys 0.52.0", ] [[package]] @@ -5143,6 +5224,16 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags 2.13.1", + "rustc_version", +] + [[package]] name = "flate2" version = "1.1.9" @@ -7757,6 +7848,32 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "learning_mode_core" +version = "0.8.0" +source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +dependencies = [ + "same-file", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "learning_mode_windows" +version = "0.8.0" +source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +dependencies = [ + "learning_mode_core", + "sha2 0.10.9", + "thiserror 2.0.18", + "windows 0.62.2", + "windows-core 0.62.2", + "wxc_common", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -8158,6 +8275,25 @@ dependencies = [ "serde", ] +[[package]] +name = "mxc_config_contract" +version = "0.8.0" +source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "mxc_telemetry" +version = "0.8.0" +source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +dependencies = [ + "tracelogging", + "uuid", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -9559,7 +9695,7 @@ dependencies = [ "shared_library", "shell-words", "winapi", - "winreg", + "winreg 0.10.1", ] [[package]] @@ -9699,6 +9835,14 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "process_security_environment_spec" +version = "0.8.0" +source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +dependencies = [ + "flatbuffers", +] + [[package]] name = "prodash" version = "31.0.0" @@ -10644,9 +10788,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "3.1.3" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f17072af977b0f86f714dbd64b3d37d0715bb63064f9d13483f0a1775813374" +checksum = "42b6914fac0be956fe704a38239c3f44a9f841d1b06a5713d2f638065593f5b5" dependencies = [ "async-trait", "base64 0.23.1", @@ -10680,9 +10824,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.1.4" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a85d45508e9b4ba024fe996c2638799635d75b6dd0ba8f32ccf08f8026f0c780" +checksum = "cdf1c49bd4d52014b94db0877410db273c2008f01628b0252a2e9460ad9b7fda" dependencies = [ "darling 0.24.1", "proc-macro2", @@ -10908,6 +11052,14 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sandbox_spec" +version = "0.8.0" +source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +dependencies = [ + "flatbuffers", +] + [[package]] name = "schannel" version = "0.1.29" @@ -12515,6 +12667,26 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "tikv-jemalloc-sys" +version = "0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2825c78386b4ae0314074867860ba9577875de945f05992c38815cbec327f0" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "249f09e49ab1609436f34c776e84231bead18d6a955f119f939bdc1d847561bd" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + [[package]] name = "time" version = "0.3.51" @@ -12965,6 +13137,21 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tracelogging" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4314470f3f54b29d582ff6776fceb7c819b023141828d559f19c790cee40e94" +dependencies = [ + "tracelogging_macros", +] + +[[package]] +name = "tracelogging_macros" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95e2d891464ff33bc1814c4cbbb251bae7800458b1efdb6ac8b7c01ee6382563" + [[package]] name = "tracing" version = "0.1.44" @@ -13302,6 +13489,12 @@ version = "2.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -14303,6 +14496,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winsafe" version = "0.0.19" @@ -14409,6 +14612,30 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wxc_common" +version = "0.8.0" +source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +dependencies = [ + "base64 0.22.1", + "cidr", + "getrandom 0.2.17", + "libc", + "mxc_config_contract", + "mxc_telemetry", + "semver", + "serde", + "serde_json", + "serde_path_to_error", + "thiserror 2.0.18", + "unicode-general-category", + "url", + "widestring", + "windows 0.62.2", + "windows-core 0.62.2", + "winreg 0.55.0", +] + [[package]] name = "x25519-dalek" version = "2.0.1" diff --git a/README.md b/README.md index 589e373..1ecbbf8 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ or a per-account GitHub login (hosted). | `pocket-codex` CLI | `login`, `logout`, `account`, `init`, `serve`, `connect`, `api {serve,connect}`, `services {list,default set}`, top-level `status`/`stop`, `codex {start,stop,status}`, `pb {register,subscribe,status}`, `remote-hint`, `version` | | `pb-mapper` register/subscribe | the published `pb-mapper` client SDK | | `codex app-server` supervision | spawn/stop/status via PID + state.toml | +| App-server protocol | synced to upstream main `db0568dbb` (2026-09-07); acknowledged initialization, v2 account reads, current thread model/effort, and asynchronous question replies | | Embedded codex (desktop) | desktop builds compile codex **in-process** behind the `embedded-codex` feature, so a machine can host without a separate `codex` install (Windows/macOS) | | Direct Responses API proxy | local HTTP/WS proxy registered through pb-mapper | | Hosted account (GitHub) | optional `pocket-codex-backend`: GitHub login, then a short-lived per-account relay credential (`/v1/relay`) scoped to a `pcxu::…` namespace — clients register/connect against the relay **directly** and the administrator key never leaves the server; self-host preserved behind `--relay`. See [`deploy/`](deploy/README.md) | diff --git a/apps/flutter/lib/src/bridge_api.dart b/apps/flutter/lib/src/bridge_api.dart index ff042b6..cdc1873 100644 --- a/apps/flutter/lib/src/bridge_api.dart +++ b/apps/flutter/lib/src/bridge_api.dart @@ -632,8 +632,12 @@ class ThreadItem { this.turnId = '', this.turnCompletedAt, this.turnDurationMs, + this.questionsJson, }); + /// Structured asynchronous questions from an agent message, as JSON. + final String? questionsJson; + /// Item id. final String id; @@ -1316,6 +1320,14 @@ abstract interface class BridgeApi { String? reasoningEffort, }); + /// Send an asynchronous answer to the expected active turn. + Future appTurnSteer( + String serviceKey, + String threadId, + String? turnId, + String text, + ); + /// Interrupt the running turn. [turnId] (from the latest `turn/started`) is /// required by the server to identify which turn to abort. Future appTurnInterrupt( diff --git a/apps/flutter/lib/src/bridge_api_rust.dart b/apps/flutter/lib/src/bridge_api_rust.dart index 50ab643..c7fff7c 100644 --- a/apps/flutter/lib/src/bridge_api_rust.dart +++ b/apps/flutter/lib/src/bridge_api_rust.dart @@ -468,6 +468,7 @@ class RustBridgeApi implements BridgeApi { turnId: i.turnId, turnCompletedAt: i.turnCompletedAt?.toInt(), turnDurationMs: i.turnDurationMs?.toInt(), + questionsJson: i.questionsJson, ); @override @@ -570,6 +571,19 @@ class RustBridgeApi implements BridgeApi { reasoningEffort: reasoningEffort, ); + @override + Future appTurnSteer( + String serviceKey, + String threadId, + String? turnId, + String text, + ) => frb.appTurnSteer( + serviceKey: serviceKey, + threadId: threadId, + turnId: turnId, + text: text, + ); + @override Future appTurnInterrupt( String serviceKey, @@ -675,6 +689,7 @@ class RustBridgeApi implements BridgeApi { turnId: i.turnId, turnCompletedAt: i.turnCompletedAt?.toInt(), turnDurationMs: i.turnDurationMs?.toInt(), + questionsJson: i.questionsJson, ), ) .toList(); @@ -784,6 +799,7 @@ class RustBridgeApi implements BridgeApi { turnId: i.turnId, turnCompletedAt: i.turnCompletedAt?.toInt(), turnDurationMs: i.turnDurationMs?.toInt(), + questionsJson: i.questionsJson, ), ) .toList(); diff --git a/apps/flutter/lib/src/providers.dart b/apps/flutter/lib/src/providers.dart index 29e3d7e..ad5e401 100644 --- a/apps/flutter/lib/src/providers.dart +++ b/apps/flutter/lib/src/providers.dart @@ -53,6 +53,11 @@ final sessionDefaultsProvider = StateProvider.family( (ref, serviceKey) => const SessionDefaults(), ); +/// Answered or dismissed live questions, retained while the bridge can replay +/// its connection buffer across screen navigation. Keys are threadId:itemId. +final answeredAsyncQuestionsProvider = + StateProvider.family, String>((ref, serviceKey) => {}); + /// Current persisted config (relay + whether a key is set). final configProvider = FutureProvider((ref) async { return ref.watch(bridgeApiProvider).getConfig(); diff --git a/apps/flutter/lib/src/rust/api/bridge.dart b/apps/flutter/lib/src/rust/api/bridge.dart index 0e69779..73e6f1b 100644 --- a/apps/flutter/lib/src/rust/api/bridge.dart +++ b/apps/flutter/lib/src/rust/api/bridge.dart @@ -302,6 +302,19 @@ Future appThreadStart({ sandbox: sandbox, ); +/// Append an asynchronous answer to the expected active turn. +Future appTurnSteer({ + required String serviceKey, + required String threadId, + String? turnId, + required String text, +}) => RustLib.instance.api.crateApiBridgeAppTurnSteer( + serviceKey: serviceKey, + threadId: threadId, + turnId: turnId, + text: text, +); + /// Answer a server approval request. `decision` is the wire value the session /// layer recognises: `accept` or `acceptForSession` to grant, any other value /// (e.g. `decline`) to decline. @@ -2042,6 +2055,9 @@ class ThreadItemDto { /// Body / detail text. final String text; + /// Structured asynchronous questions on an agent message, as JSON. + final String? questionsJson; + /// Image URLs attached to a `userMessage`: `data:image/...` URLs render /// inline; a host-local path (from a `localImage` input) renders as a /// filename chip. Empty for every other item kind. @@ -2064,6 +2080,7 @@ class ThreadItemDto { required this.itemType, required this.title, required this.text, + this.questionsJson, required this.images, required this.turnId, this.turnCompletedAt, @@ -2076,6 +2093,7 @@ class ThreadItemDto { itemType.hashCode ^ title.hashCode ^ text.hashCode ^ + questionsJson.hashCode ^ images.hashCode ^ turnId.hashCode ^ turnCompletedAt.hashCode ^ @@ -2090,6 +2108,7 @@ class ThreadItemDto { itemType == other.itemType && title == other.title && text == other.text && + questionsJson == other.questionsJson && images == other.images && turnId == other.turnId && turnCompletedAt == other.turnCompletedAt && diff --git a/apps/flutter/lib/src/rust/frb_generated.dart b/apps/flutter/lib/src/rust/frb_generated.dart index 5402807..1c3074c 100644 --- a/apps/flutter/lib/src/rust/frb_generated.dart +++ b/apps/flutter/lib/src/rust/frb_generated.dart @@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -216609835; + int get rustContentHash => -246576737; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -271,6 +271,13 @@ abstract class RustLibApi extends BaseApi { String? reasoningEffort, }); + Future crateApiBridgeAppTurnSteer({ + required String serviceKey, + required String threadId, + String? turnId, + required String text, + }); + String crateApiSimpleBridgeVersion(); Future crateApiBridgeCodexAuthStatus({ @@ -1964,13 +1971,51 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ], ); + @override + Future crateApiBridgeAppTurnSteer({ + required String serviceKey, + required String threadId, + String? turnId, + required String text, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(serviceKey, serializer); + sse_encode_String(threadId, serializer); + sse_encode_opt_String(turnId, serializer); + sse_encode_String(text, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 47, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiBridgeAppTurnSteerConstMeta, + argValues: [serviceKey, threadId, turnId, text], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiBridgeAppTurnSteerConstMeta => const TaskConstMeta( + debugName: "app_turn_steer", + argNames: ["serviceKey", "threadId", "turnId", "text"], + ); + @override String crateApiSimpleBridgeVersion() { return handler.executeSync( SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 47)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 48)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -1998,7 +2043,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 48, + funcId: 49, port: port_, ); }, @@ -2028,7 +2073,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 49, + funcId: 50, port: port_, ); }, @@ -2060,7 +2105,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 50, + funcId: 51, port: port_, ); }, @@ -2093,7 +2138,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 51, + funcId: 52, port: port_, ); }, @@ -2124,7 +2169,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 52, + funcId: 53, port: port_, ); }, @@ -2151,7 +2196,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 53, + funcId: 54, port: port_, ); }, @@ -2179,7 +2224,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 54, + funcId: 55, port: port_, ); }, @@ -2216,7 +2261,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 55, + funcId: 56, port: port_, ); }, @@ -2246,7 +2291,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 56, + funcId: 57, port: port_, ); }, @@ -2273,7 +2318,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 57, + funcId: 58, port: port_, ); }, @@ -2300,7 +2345,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 58, + funcId: 59, port: port_, ); }, @@ -2327,7 +2372,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 59, + funcId: 60, port: port_, ); }, @@ -2354,7 +2399,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 60, + funcId: 61, port: port_, ); }, @@ -2379,7 +2424,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(name, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 61)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 62)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -2405,7 +2450,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 62, + funcId: 63, port: port_, ); }, @@ -2432,7 +2477,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 63, + funcId: 64, port: port_, ); }, @@ -2460,7 +2505,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 64, + funcId: 65, port: port_, ); }, @@ -2490,7 +2535,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 65, + funcId: 66, port: port_, ); }, @@ -2524,7 +2569,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 66, + funcId: 67, port: port_, ); }, @@ -2559,7 +2604,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 67, + funcId: 68, port: port_, ); }, @@ -2593,7 +2638,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 68, + funcId: 69, port: port_, ); }, @@ -2626,7 +2671,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 69, + funcId: 70, port: port_, ); }, @@ -2661,7 +2706,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 70, + funcId: 71, port: port_, ); }, @@ -2697,7 +2742,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 71, + funcId: 72, port: port_, ); }, @@ -2730,7 +2775,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 72, + funcId: 73, port: port_, ); }, @@ -2770,7 +2815,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 73, + funcId: 74, port: port_, ); }, @@ -2807,7 +2852,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 74, + funcId: 75, port: port_, ); }, @@ -2842,7 +2887,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 75, + funcId: 76, port: port_, ); }, @@ -2875,7 +2920,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 76, + funcId: 77, port: port_, ); }, @@ -2909,7 +2954,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 77, + funcId: 78, port: port_, ); }, @@ -2944,7 +2989,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 78, + funcId: 79, port: port_, ); }, @@ -2981,7 +3026,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 79, + funcId: 80, port: port_, ); }, @@ -3018,7 +3063,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 80, + funcId: 81, port: port_, ); }, @@ -3057,7 +3102,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 81, + funcId: 82, port: port_, ); }, @@ -3088,7 +3133,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 82, + funcId: 83, port: port_, ); }, @@ -3116,7 +3161,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 83, + funcId: 84, port: port_, ); }, @@ -3144,7 +3189,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 84, + funcId: 85, port: port_, ); }, @@ -3171,7 +3216,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 85, + funcId: 86, port: port_, ); }, @@ -3830,17 +3875,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ThreadItemDto dco_decode_thread_item_dto(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 8) - throw Exception('unexpected arr length: expect 8 but see ${arr.length}'); + if (arr.length != 9) + throw Exception('unexpected arr length: expect 9 but see ${arr.length}'); return ThreadItemDto( id: dco_decode_String(arr[0]), itemType: dco_decode_String(arr[1]), title: dco_decode_String(arr[2]), text: dco_decode_String(arr[3]), - images: dco_decode_list_String(arr[4]), - turnId: dco_decode_String(arr[5]), - turnCompletedAt: dco_decode_opt_box_autoadd_i_64(arr[6]), - turnDurationMs: dco_decode_opt_box_autoadd_i_64(arr[7]), + questionsJson: dco_decode_opt_String(arr[4]), + images: dco_decode_list_String(arr[5]), + turnId: dco_decode_String(arr[6]), + turnCompletedAt: dco_decode_opt_box_autoadd_i_64(arr[7]), + turnDurationMs: dco_decode_opt_box_autoadd_i_64(arr[8]), ); } @@ -4782,6 +4828,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_itemType = sse_decode_String(deserializer); var var_title = sse_decode_String(deserializer); var var_text = sse_decode_String(deserializer); + var var_questionsJson = sse_decode_opt_String(deserializer); var var_images = sse_decode_list_String(deserializer); var var_turnId = sse_decode_String(deserializer); var var_turnCompletedAt = sse_decode_opt_box_autoadd_i_64(deserializer); @@ -4791,6 +4838,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { itemType: var_itemType, title: var_title, text: var_text, + questionsJson: var_questionsJson, images: var_images, turnId: var_turnId, turnCompletedAt: var_turnCompletedAt, @@ -5629,6 +5677,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(self.itemType, serializer); sse_encode_String(self.title, serializer); sse_encode_String(self.text, serializer); + sse_encode_opt_String(self.questionsJson, serializer); sse_encode_list_String(self.images, serializer); sse_encode_String(self.turnId, serializer); sse_encode_opt_box_autoadd_i_64(self.turnCompletedAt, serializer); diff --git a/apps/flutter/lib/src/screens/app_session/async_questions.dart b/apps/flutter/lib/src/screens/app_session/async_questions.dart new file mode 100644 index 0000000..6548f53 --- /dev/null +++ b/apps/flutter/lib/src/screens/app_session/async_questions.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; + +import 'package:pocket_codex/src/bridge_api.dart'; + +/// Nonblocking questions delivered as an agent message, answered with user text. +class AsyncQuestionPrompt { + AsyncQuestionPrompt(this.id, this.questions); + + final String id; + final List<({String title, List options})> questions; + + static AsyncQuestionPrompt? fromEvent(AppEvent event) { + try { + final params = jsonDecode(event.raw); + if (params is! Map || params['item'] is! Map) return null; + return parse(event.itemId ?? '', jsonEncode(params['item']['questions'])); + } catch (_) { + return null; + } + } + + static AsyncQuestionPrompt? parse(String id, String? raw) { + if (id.isEmpty || raw == null) return null; + try { + final value = jsonDecode(raw); + if (value is! List) return null; + final questions = <({String title, List options})>[]; + for (final question in value) { + if (question is! Map || question['title'] is! String) continue; + final title = (question['title'] as String).trim(); + if (title.isEmpty) continue; + final options = question['options']; + questions.add(( + title: title, + options: options is List ? options.whereType().toList() : [], + )); + } + return questions.isEmpty ? null : AsyncQuestionPrompt(id, questions); + } catch (_) { + return null; + } + } + + /// Reuse the question editor without inventing a server request id. + AppEvent get cardEvent => AppEvent( + kind: 'agentMessage/questions', + itemId: id, + raw: jsonEncode({ + 'questions': [ + for (var index = 0; index < questions.length; index++) + { + 'id': '$index', + 'question': questions[index].title, + 'isOther': true, + 'options': [ + for (final option in questions[index].options) {'label': option}, + ], + }, + ], + }), + ); + + String answerText(Map> answers) => [ + for (var index = 0; index < questions.length; index++) + if (answers['$index'] case final answer?) + '${questions[index].title}\n${answer.join('\n')}', + ].join('\n\n'); +} diff --git a/apps/flutter/lib/src/screens/app_session/composer_cards.dart b/apps/flutter/lib/src/screens/app_session/composer_cards.dart index 0299413..43bd590 100644 --- a/apps/flutter/lib/src/screens/app_session/composer_cards.dart +++ b/apps/flutter/lib/src/screens/app_session/composer_cards.dart @@ -140,12 +140,20 @@ class _UserInputCardState extends State { if (a != null) answers[q.id] = [a]; } setState(() => _submitting = true); - await widget.onAnswer(widget.prompt, answers); + try { + await widget.onAnswer(widget.prompt, answers); + } finally { + if (mounted) setState(() => _submitting = false); + } } Future _cancel() async { setState(() => _submitting = true); - await widget.onAnswer(widget.prompt, const {}); + try { + await widget.onAnswer(widget.prompt, const {}); + } finally { + if (mounted) setState(() => _submitting = false); + } } @override diff --git a/apps/flutter/lib/src/screens/app_session_screen.dart b/apps/flutter/lib/src/screens/app_session_screen.dart index a413082..3d127e2 100644 --- a/apps/flutter/lib/src/screens/app_session_screen.dart +++ b/apps/flutter/lib/src/screens/app_session_screen.dart @@ -29,6 +29,7 @@ import 'package:pocket_codex/src/ide_context.dart'; import 'package:pocket_codex/src/image_attachments.dart'; import 'package:pocket_codex/src/providers.dart'; import 'package:pocket_codex/src/service_key.dart'; +import 'package:pocket_codex/src/screens/app_session/async_questions.dart'; import 'package:pocket_codex/src/screens/app_session/activity_cards.dart'; import 'package:pocket_codex/src/screens/app_session/composer_cards.dart'; import 'package:pocket_codex/src/screens/app_session/transcript_model.dart'; @@ -200,6 +201,15 @@ class _AppSessionState extends ConsumerState final List _items = []; final Map _itemIndex = {}; int _localSeq = 0; // ids for optimistic local user messages + final Map _asyncQuestions = {}; + Set get _answeredAsyncQuestions => + ref.read(answeredAsyncQuestionsProvider(widget.serviceKey)); + + void _markAsyncQuestionAnswered(String threadId, String itemId) { + ref.read(answeredAsyncQuestionsProvider(widget.serviceKey).notifier).state = + {..._answeredAsyncQuestions, '$threadId:$itemId'}; + } + final List _approvals = []; // pending command-approval prompts StreamSubscription? _sub; @@ -975,6 +985,7 @@ class _AppSessionState extends ConsumerState _historyLoad = null; _historyGeneration++; _approvals.clear(); + _asyncQuestions.clear(); _ctx = null; _diff = null; _branch = null; @@ -1198,7 +1209,13 @@ class _AppSessionState extends ConsumerState void _replaceTranscriptItems(List items) { _items.clear(); _itemIndex.clear(); + _asyncQuestions.clear(); for (final item in items) { + final questions = AsyncQuestionPrompt.parse(item.id, item.questionsJson); + if (questions != null && + !_answeredAsyncQuestions.contains('$_threadId:${item.id}')) { + _asyncQuestions[item.id] = questions; + } // Defensively collapse a back-to-back duplicate user message. A genuine // re-ask has the model's reply in between, so it remains distinct. if (item.itemType == 'userMessage' && @@ -1472,15 +1489,16 @@ class _AppSessionState extends ConsumerState final hadPendingToggle = _plan != _planActive; _planActive = restored; if (!hadPendingToggle) _plan = _planActive; - // Restore the thread's current effort: prefer the server value (from the - // resume response), else the persisted store, else our per-thread - // memory. A pending pick (_effort) is left untouched — the chip shows + // A reported runtime model makes even a null effort authoritative. + // Only older servers without runtime metadata need the persisted value. + // A pending pick (_effort) is left untouched — the chip shows // `_effort ?? _effortActive`, so it survives a drop/reload unclobbered. final serverEffort = ReasoningEffort.fromWire(history.reasoningEffort); - _effortActive = - serverEffort ?? - ReasoningEffort.fromWire(persisted.reasoningEffort) ?? - (tid != null ? _effortByThread[_threadKey(tid)] : null); + _effortActive = history.model != null || history.configConfirmed + ? serverEffort + : serverEffort ?? + ReasoningEffort.fromWire(persisted.reasoningEffort) ?? + (tid != null ? _effortByThread[_threadKey(tid)] : null); // Drop a restored effort the restored model can't run (mirrors the guard // in _pickModel/_seedDefaults) so a stale persisted pairing never asserts // an unsupported level on the next turn. @@ -1561,6 +1579,7 @@ class _AppSessionState extends ConsumerState _error = null; _retry = null; _approvals.clear(); + _asyncQuestions.clear(); }); _loadGit(); _subscribeExternalWriter(threadId, epoch); @@ -1764,6 +1783,15 @@ class _AppSessionState extends ConsumerState setState(() => _approvals.add(e)); return; } + if (e.kind == 'item/completed' && + e.itemType == 'agentMessage' && + e.itemId != null) { + final questions = AsyncQuestionPrompt.fromEvent(e); + if (questions != null && + !_answeredAsyncQuestions.contains('$_threadId:${e.itemId}')) { + setState(() => _asyncQuestions[e.itemId!] = questions); + } + } // Status-bar feeds: token usage + quota updates carry their data in `raw` // (map_event is a generic passthrough, so no item fields are set). if (e.kind == 'thread/tokenUsage/updated') { @@ -2861,6 +2889,44 @@ class _AppSessionState extends ConsumerState } } + Future _answerAsyncInput( + AsyncQuestionPrompt prompt, + Map> answers, + ) async { + final threadId = _threadId; + if (threadId == null || _sending || _reconnecting) return; + if (answers.isEmpty) { + setState(() { + _asyncQuestions.remove(prompt.id); + _markAsyncQuestionAnswered(threadId, prompt.id); + }); + return; + } + final text = prompt.answerText(answers); + setState(() => _sending = true); + try { + final api = ref.read(bridgeApiProvider); + if (_streaming) { + final turnId = _turnId; + await api.appTurnSteer(widget.serviceKey, threadId, turnId, text); + } else { + await api.appTurnStart(widget.serviceKey, threadId, text); + } + if (!mounted || _threadId != threadId) return; + setState(() { + _asyncQuestions.remove(prompt.id); + _markAsyncQuestionAnswered(threadId, prompt.id); + _error = null; + }); + } catch (e) { + if (mounted && _threadId == threadId) { + setState(() => _error = friendlyError(e)); + } + } finally { + if (mounted) setState(() => _sending = false); + } + } + /// Scroll to the latest message. Auto-follow only when already pinned to the /// bottom (so reading earlier messages isn't interrupted); [force] overrides /// that (e.g. right after the user sends or taps the jump button). @@ -3085,13 +3151,21 @@ class _AppSessionState extends ConsumerState // Neither half has anything to show — an empty card that only occludes the // conversation. The tick stays; it just has no preview. if (user.isEmpty && reply == null) { - out.add(TurnMinimapItem(rowIndex: i, turnId: row.turnId, userText: '')); + out.add( + TurnMinimapItem( + rowIndex: i, + turnId: row.turnId, + messageId: row.id, + userText: '', + ), + ); continue; } out.add( TurnMinimapItem( rowIndex: i, turnId: row.turnId, + messageId: row.id, userText: user, assistantText: reply, ), @@ -4611,6 +4685,25 @@ class _AppSessionState extends ConsumerState prompt: a, onDecide: _decide, ), + if (!_externalWriterMode && _asyncQuestions.isNotEmpty) + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.4, + ), + child: SingleChildScrollView( + child: Column( + children: [ + for (final prompt in _asyncQuestions.values) + UserInputCard( + key: ValueKey('async-${prompt.id}'), + prompt: prompt.cardEvent, + onAnswer: (_, answers) => + _answerAsyncInput(prompt, answers), + ), + ], + ), + ), + ), // After a plan-mode turn, offer to implement the plan (persists across // restart since it's derived from the trailing plan item). if (!_externalWriterMode && _planReady) _implementBar(l10n), diff --git a/apps/flutter/lib/src/widgets/turn_minimap.dart b/apps/flutter/lib/src/widgets/turn_minimap.dart index 6a31754..86406c3 100644 --- a/apps/flutter/lib/src/widgets/turn_minimap.dart +++ b/apps/flutter/lib/src/widgets/turn_minimap.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:flutter/gestures.dart' show PointerDeviceKind; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:pocket_codex/src/desktop_theme.dart'; @@ -16,6 +17,7 @@ class TurnMinimapItem { required this.userText, this.assistantText, this.turnId = '', + this.messageId = '', }); /// Index of this turn's user message in the transcript's row list — what the @@ -28,6 +30,10 @@ class TurnMinimapItem { /// Empty when the caller derived entries from rows alone. final String turnId; + /// Stable user-message id for read-only transcripts that have no turn ids. + /// Keeps the same target across snapshot refreshes and row regrouping. + final String messageId; + /// The user's own message, one line, whitespace already collapsed. final String userText; @@ -49,7 +55,7 @@ const int kTurnMinimapMinItems = 4; /// Nominal spacing between ticks. The rail's natural height is this times the /// gaps, then capped to the space available — past that the ticks compress and /// the rail stops growing. -const double _kTickSpacing = 8; +const double _kTickSpacing = 10; /// Left inset of the rail within the gutter. Exported because the transcript /// needs it to tell whether the gutter can hold the rail at all — with less than @@ -60,9 +66,10 @@ const double kTurnMinimapRailInset = 12; /// the strip can never reach over the centred column and swallow a selection. const double _kHitStripMaxWidth = 40; -/// How wide the pointer region grows while a preview is open, so travelling -/// toward the card doesn't leave the strip and dismiss it. -const double _kExpandedHitWidth = 352; +/// Invisible tolerance around the rail ends and the preview card. +const double _kRailHitPadding = 12; +const double _kPreviewHitPadding = 8; +const double _kPreviewGap = 10; /// Gutter at or above which the rail simply stays visible. Narrower than this /// there isn't room for it to rest without crowding the text, so it fades in on @@ -80,28 +87,17 @@ const double _kPreviewWidth = 300; const double _kMinPreviewWidth = 150; const double _kMaxPreviewOverhang = 160; -/// Extra width a tick gets for being on screen, so the rail's shape shows where -/// you are and reshapes as you scroll — the thumb of a scrollbar, drawn as a -/// bulge in the scale. -const double _kInViewBulge = 6; +const double _kTickWidth = 6; +const double _kCurrentTickWidth = 13; +const double _kPreviewTickWidth = 26; -/// Tick widths by distance from the hovered one: the pointed-at tick, its -/// neighbours, then the rest. A falloff rather than a single highlight, so the -/// rail reads as one object responding to the cursor instead of a row of -/// independent marks. -/// -/// [inView] adds [_kInViewBulge] on top. The two cues compose deliberately: the -/// pointer's falloff is much larger, so a hovered tick still stands out from the -/// on-screen band it may sit inside. -double _tickWidth(int? distance, {bool inView = false}) { - final base = switch (distance) { - 0 => 22.0, - 1 => 15.0, - 2 => 10.0, - _ => 7.0, - }; - return inView ? base + _kInViewBulge : base; -} +double _hoverTickWidth(int distance) => switch (distance) { + 0 => _kPreviewTickWidth, + 1 => 20, + 2 => 14, + 3 => 10, + _ => _kTickWidth, +}; /// A left-gutter rail of one tick per conversation turn: hover a tick to preview /// that turn, click to jump to it. @@ -153,7 +149,7 @@ class TurnMinimap extends StatefulWidget { class _TurnMinimapState extends State { /// The tick the pointer (or the keyboard) is on, or null when neither is. - /// Drives the width falloff and the preview. + /// Takes over the single position highlight and opens the preview. int? _active; /// Whether the pointer is anywhere near the rail. Only used to fade the rail @@ -161,7 +157,7 @@ class _TurnMinimapState extends State { bool _hovering = false; final _focus = FocusNode(debugLabel: 'turn-minimap'); - final _previewKey = GlobalKey(); + TurnMinimapItem? _pressed; @override void dispose() { @@ -172,6 +168,13 @@ class _TurnMinimapState extends State { @override void didUpdateWidget(TurnMinimap old) { super.didUpdateWidget(old); + final active = _active; + final item = active == null ? null : old.items[active]; + if (item != null && (item.turnId.isNotEmpty || item.messageId.isNotEmpty)) { + final index = _indexOf(item); + _active = index < 0 ? null : index; + return; + } // A turn was removed (a rewind, a reload) — an index past the end would // otherwise resolve to nothing and leave a stuck preview. _active = _clampIndex(_active); @@ -226,6 +229,20 @@ class _TurnMinimapState extends State { return (progress * (count - 1)).round().clamp(0, count - 1); } + /// The turn containing the viewport's first row, even when its user message + /// has scrolled offscreen. Unloaded turns have no row to compare against. + int? _currentIndex((int, int)? range) { + if (range == null) return null; + int? current; + for (var i = 0; i < widget.items.length; i++) { + final row = widget.items[i].rowIndex; + if (row < 0) continue; + if (row > range.$1) return current ?? (row <= range.$2 ? i : null); + current = i; + } + return current; + } + void _move(int delta) { setState(() { final base = _active ?? 0; @@ -288,25 +305,37 @@ class _TurnMinimapState extends State { (widget.items.length - 1) * _kTickSpacing, ); final railHeight = math.min(natural, available); + final railTop = (constraints.maxHeight - railHeight) / 2; final open = _active != null; - return Align( - alignment: Alignment.centerLeft, - child: Padding( - // At the frame, where a scrollbar sits — see `_railLeft`. - padding: EdgeInsets.only(left: _railLeft), + final scheme = Theme.of(context).colorScheme; + return Focus( + focusNode: _focus, + onKeyEvent: _onKey, + onFocusChange: (has) => + setState(() => _active = has ? (_active ?? 0) : null), + child: MouseRegion( + opaque: false, + hitTestBehavior: HitTestBehavior.deferToChild, + cursor: clickable, + onEnter: (event) { + setState(() => _hovering = true); + if (_active == null) { + _hoverAt(event.localPosition.dy - railTop, railHeight); + } + }, + onExit: (_) => _leave(), child: AnimatedOpacity( - // A wide window has room to show the rail at rest; a narrow one - // would have it crowding the prose, so there it waits for the - // pointer to come looking. opacity: _persistent || _hovering || open ? 1 : 0, duration: const Duration(milliseconds: 150), - child: SizedBox( - key: const Key('turn-minimap-rail'), - height: railHeight, - // Only the resting strip takes the pointer; the preview extends - // past it and is allowed to overhang. - width: open ? _kExpandedHitWidth : hitWidth, - child: _rail(railHeight, hitWidth), + // Both hit targets live in the viewport, since a short rail can + // have a preview taller than itself. Empty space stays pass-through. + child: Stack( + clipBehavior: Clip.none, + children: [ + _rail(railHeight, railTop, hitWidth, scheme), + _railTarget(railHeight, railTop, hitWidth), + ?_preview(railHeight, railTop), + ], ), ), ), @@ -315,59 +344,91 @@ class _TurnMinimapState extends State { ); } - Widget _rail(double railHeight, double hitWidth) { - final scheme = Theme.of(context).colorScheme; - return Focus( - focusNode: _focus, - onKeyEvent: _onKey, - onFocusChange: (has) => - setState(() => _active = has ? (_active ?? 0) : null), - child: MouseRegion( - cursor: clickable, - onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => _leave(), - onHover: (event) { - if (event.localPosition.dx > hitWidth) { - final card = _previewKey.currentContext?.findRenderObject(); - // The expanded box also covers empty space beside other ticks. - // Only the actual card should keep a preview open off the rail. - if (card is RenderBox && - card.hasSize && - (Offset.zero & card.size).contains( - card.globalToLocal(event.position), - )) { - return; - } - _leave(); - return; - } - final next = _indexAt(event.localPosition.dy, railHeight); - if (next != _active) { - setState(() => _active = next); - if (next != null && next < widget.items.length) { - widget.onPreview?.call(widget.items[next]); - } - } - }, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTapDown: (details) { - if (details.localPosition.dx > hitWidth) return; - final index = _indexAt(details.localPosition.dy, railHeight); - if (index != null) _select(index); - }, - // No spine behind the ticks: it read as a stray vertical rule against - // the page's left edge, and the ticks already line up into a scale on - // their own. - child: Stack( + Widget _rail( + double railHeight, + double railTop, + double hitWidth, + ColorScheme scheme, + ) => Positioned( + left: _railLeft, + top: railTop, + width: hitWidth, + height: railHeight, + child: IgnorePointer( + child: SizedBox( + key: const Key('turn-minimap-rail'), + child: ValueListenableBuilder<(int, int)?>( + valueListenable: widget.visibleRange, + builder: (context, range, _) => Stack( clipBehavior: Clip.none, - children: [..._ticks(railHeight, scheme), ?_preview(railHeight)], + children: _ticks( + railHeight, + scheme, + _active ?? _currentIndex(range), + ), ), ), ), - ); + ), + ); + + Widget _railTarget(double railHeight, double railTop, double hitWidth) => + Positioned( + left: 0, + top: railTop - _kRailHitPadding, + width: _railLeft + hitWidth, + height: railHeight + 2 * _kRailHitPadding, + child: MouseRegion( + onHover: (event) => + _hoverAt(event.localPosition.dy - _kRailHitPadding, railHeight), + child: _tapTarget( + indexAt: (details) => + details.kind == PointerDeviceKind.mouse && _active != null + ? _active + : _indexAt( + details.localPosition.dy - _kRailHitPadding, + railHeight, + ), + child: const SizedBox.expand(), + ), + ), + ); + + void _hoverAt(double localY, double railHeight) { + final next = _indexAt(localY, railHeight); + if (next == _active) return; + setState(() => _active = next); + if (next != null) widget.onPreview?.call(widget.items[next]); } + int _indexOf(TurnMinimapItem item) => widget.items.indexWhere((entry) { + if (item.turnId.isNotEmpty) return entry.turnId == item.turnId; + if (item.messageId.isNotEmpty) return entry.messageId == item.messageId; + return identical(entry, item); + }); + + Widget _tapTarget({ + required int? Function(TapDownDetails) indexAt, + required Widget child, + }) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) { + final index = indexAt(details); + _pressed = index == null ? null : widget.items.elementAtOrNull(index); + }, + onTapCancel: () => _pressed = null, + onTap: () { + final item = _pressed; + _pressed = null; + if (item == null) return; + // Loading a preview can replace entries and shift row indices between + // press and release. Resolve the same turn in the latest list. + final index = _indexOf(item); + if (index >= 0) _select(index); + }, + child: child, + ); + void _leave() { if (!_hovering && _active == null) return; setState(() { @@ -378,43 +439,29 @@ class _TurnMinimapState extends State { /// The ticks. Each repaints on scroll through [TurnMinimap.visibleRange] /// alone, so following a streaming reply never rebuilds the transcript. - List _ticks(double railHeight, ColorScheme scheme) { - final active = _active; + List _ticks(double railHeight, ColorScheme scheme, int? highlighted) { return [ for (var i = 0; i < widget.items.length; i++) Positioned( left: 0, top: railHeight * _fractionOf(i) - 1, - child: ValueListenableBuilder<(int, int)?>( - valueListenable: widget.visibleRange, - builder: (context, range, _) { - final row = widget.items[i].rowIndex; - final inView = - range != null && row >= range.$1 && row <= range.$2; - final distance = active == null ? null : (i - active).abs(); - return AnimatedContainer( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOutCubic, - height: 2, - // Scrolling reshapes the rail, it does not only re-ink it. The - // on-screen turns bulge outward, so the rail carries a visible - // "you are here" band that travels as you scroll — which is the - // job a scrollbar thumb does, and the reason this sits where a - // scrollbar would. Ink alone was too quiet to read in passing. - width: _tickWidth(distance, inView: inView), - decoration: BoxDecoration( - // Width and ink both track position; the hovered tick is the - // widest thing on the rail, so the two cues stay legible - // together rather than competing. - color: inView - ? scheme.onSurface.withValues(alpha: 0.85) - : distance == 0 - ? scheme.onSurfaceVariant - : scheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(1), - ), - ); - }, + // Switch immediately so the pointed-at tick is always the longest, + // including while the pointer moves between ticks. + child: Container( + key: ValueKey('turn-minimap-tick-$i'), + height: 2, + width: _active != null + ? _hoverTickWidth((i - _active!).abs()) + : i == highlighted + ? _kCurrentTickWidth + : _kTickWidth, + decoration: BoxDecoration( + color: i == highlighted + ? scheme.onSurface + : scheme.onSurface.withValues( + alpha: scheme.brightness == Brightness.dark ? 0.15 : 0.25, + ), + ), ), ), ]; @@ -422,7 +469,7 @@ class _TurnMinimapState extends State { /// The hover card, anchored to its tick rather than to the cursor, so it holds /// still while the pointer travels into it. - Widget? _preview(double railHeight) { + Widget? _preview(double railHeight, double railTop) { final active = _active; if (active == null) return null; final item = widget.items.elementAtOrNull(active); @@ -442,7 +489,7 @@ class _TurnMinimapState extends State { ? -1.0 : -0.5; // Clear of the widest tick, so the card never sits on the mark it describes. - final left = _tickWidth(0) + 10; + const left = _kPreviewTickWidth + _kPreviewGap; // The card is allowed to overhang the gutter — it has to be readable, and a // 300 px card cannot fit a 60 px margin — but not by so much that it buries // the conversation. Past this it narrows instead, and if it cannot stay @@ -451,11 +498,28 @@ class _TurnMinimapState extends State { final width = math.min(_kPreviewWidth, room); if (width < _kMinPreviewWidth) return null; return Positioned( - left: left, - top: railHeight * fraction, + left: _railLeft + _kPreviewTickWidth, + // Compensate for the invisible padding to keep the visual anchor fixed. + top: + railTop + + railHeight * fraction - + _kPreviewHitPadding * (1 + 2 * align), child: FractionalTranslation( translation: Offset(0, align), - child: _TurnPreviewCard(key: _previewKey, item: item, width: width), + child: _tapTarget( + indexAt: (_) => _active, + child: Padding( + // The gap is part of the card's target: moving sideways latches the + // preview instead of scanning more tightly packed ticks on the way. + padding: const EdgeInsets.fromLTRB( + _kPreviewGap, + _kPreviewHitPadding, + _kPreviewHitPadding, + _kPreviewHitPadding, + ), + child: _TurnPreviewCard(item: item, width: width), + ), + ), ), ); } @@ -463,7 +527,7 @@ class _TurnMinimapState extends State { /// The floating preview: what the user asked, and how the turn answered. class _TurnPreviewCard extends StatelessWidget { - const _TurnPreviewCard({super.key, required this.item, required this.width}); + const _TurnPreviewCard({required this.item, required this.width}); final TurnMinimapItem item; diff --git a/apps/flutter/test/async_questions_test.dart b/apps/flutter/test/async_questions_test.dart new file mode 100644 index 0000000..03c26cc --- /dev/null +++ b/apps/flutter/test/async_questions_test.dart @@ -0,0 +1,228 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pocket_codex/src/bridge_api.dart'; +import 'package:pocket_codex/src/screens/app_session_screen.dart'; + +import 'fake_bridge_api.dart'; +import 'support/screen_harness.dart'; + +const service = 'pcx:host:app:default'; +const questions = [ + { + 'title': 'Which platform?', + 'options': ['macOS', 'Windows'], + }, +]; + +Future mount( + WidgetTester tester, { + ThreadHistory? history, + ThreadConfig? config, +}) async { + final api = FakeBridgeApi( + config: const ConfigInfo(relay: 'localhost:7666', hasKey: true), + ); + if (history != null) api.readResult = history; + if (config != null) api.threadConfigs['thread-1'] = config; + await api.appConnect(service, 28080); + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(400, 900); + addTearDown(tester.view.reset); + await tester.pumpWidget( + host( + const AppSessionScreen(serviceKey: service, threadId: 'thread-1'), + api, + ), + ); + await tester.pumpAndSettle(); + return api; +} + +void ask(FakeBridgeApi api) => api.pushEvent( + service, + AppEvent( + kind: 'item/completed', + threadId: 'thread-1', + itemId: 'question-1', + itemType: 'agentMessage', + raw: jsonEncode({ + 'item': { + 'id': 'question-1', + 'type': 'agentMessage', + 'text': '', + 'questions': questions, + }, + }), + ), +); + +void main() { + setUp(AppSessionScreen.debugResetThreadMemory); + + testWidgets('async answer steers its running turn and preserves the draft', ( + t, + ) async { + final api = await mount(t); + await t.enterText(find.byType(TextField), 'unfinished draft'); + api.pushEvent( + service, + const AppEvent( + kind: 'turn/started', + threadId: 'thread-1', + raw: '{"turn":{"id":"turn-1"}}', + ), + ); + ask(api); + ask(api); + await t.pump(); + await t.pump(const Duration(milliseconds: 50)); + expect(find.byKey(const Key('user-input-card')), findsOneWidget); + expect( + t + .widget(find.byKey(const Key('user-input-submit'))) + .onPressed, + isNull, + ); + await t.tap(find.text('macOS')); + await t.pump(); + await t.tap(find.byKey(const Key('user-input-submit'))); + await t.pump(); + expect(api.lastSteerText, 'Which platform?\nmacOS'); + expect(api.lastSteerTurnId, 'turn-1'); + expect(api.turnStartCount, 0); + expect(find.byKey(const Key('user-input-card')), findsNothing); + expect(find.text('unfinished draft'), findsOneWidget); + await t.pumpWidget(const SizedBox()); + }); + + testWidgets('idle async answer starts a turn with free text', (t) async { + final api = await mount(t); + ask(api); + await t.pumpAndSettle(); + await t.tap(find.text('其他…')); + await t.pumpAndSettle(); + await t.enterText( + find.descendant( + of: find.byKey(const Key('user-input-card')), + matching: find.byType(TextField), + ), + 'Linux', + ); + await t.pump(); + await t.tap(find.byKey(const Key('user-input-submit'))); + await t.pumpAndSettle(); + expect(api.lastTurnText, 'Which platform?\nLinux'); + expect(api.turnStartCount, 1); + expect(api.lastSteerText, isNull); + expect(find.byKey(const Key('user-input-card')), findsNothing); + }); + + testWidgets('a stale turn keeps the async answer editable for retry', ( + t, + ) async { + final api = await mount(t); + api.steerError = 'expectedTurnId does not match'; + api.pushEvent( + service, + const AppEvent( + kind: 'turn/started', + threadId: 'thread-1', + raw: '{"turn":{"id":"stale"}}', + ), + ); + ask(api); + await t.pump(); + await t.pump(const Duration(milliseconds: 50)); + await t.tap(find.text('Windows')); + await t.pump(); + await t.tap(find.byKey(const Key('user-input-submit'))); + await t.pump(); + expect(find.byKey(const Key('user-input-card')), findsOneWidget); + expect( + t + .widget(find.byKey(const Key('user-input-submit'))) + .onPressed, + isNotNull, + ); + expect(api.turnStartCount, 0); + expect(api.lastSteerText, isNull); + await t.pumpWidget(const SizedBox()); + }); + testWidgets('buffered live questions survive a history reload', (t) async { + await mount( + t, + history: ThreadHistory( + items: [ + ThreadItem( + id: 'question-1', + itemType: 'agentMessage', + title: '', + text: '', + questionsJson: jsonEncode(questions), + ), + ], + running: false, + ), + ); + expect(find.text('Which platform?'), findsOneWidget); + expect(find.text('macOS'), findsOneWidget); + }); + + testWidgets( + 'answered buffered questions stay hidden after screen navigation', + (t) async { + final api = await mount( + t, + history: ThreadHistory( + items: [ + ThreadItem( + id: 'question-1', + itemType: 'agentMessage', + title: '', + text: '', + questionsJson: jsonEncode(questions), + ), + ], + running: false, + ), + ); + await t.tap(find.text('macOS')); + await t.pump(); + await t.tap(find.byKey(const Key('user-input-submit'))); + await t.pumpAndSettle(); + expect(api.turnStartCount, 1); + await t.pumpWidget(host(const SizedBox(), api)); + await t.pumpAndSettle(); + await t.pumpWidget( + host( + const AppSessionScreen(serviceKey: service, threadId: 'thread-1'), + api, + ), + ); + await t.pumpAndSettle(); + expect(find.byKey(const Key('user-input-card')), findsNothing); + }, + ); + + testWidgets( + 'a cleared server effort does not restore stale persisted effort', + (t) async { + final api = await mount( + t, + history: const ThreadHistory( + items: [], + running: false, + model: 'gpt-5.5', + ), + config: const ThreadConfig(reasoningEffort: 'high'), + ); + await t.enterText(find.byType(TextField), 'continue'); + await t.pump(); + await t.tap(find.byKey(const Key('send-btn'))); + await t.pumpAndSettle(); + expect(api.lastReasoningEffort, isNull); + }, + ); +} diff --git a/apps/flutter/test/fake_bridge_api.dart b/apps/flutter/test/fake_bridge_api.dart index 15200af..5b85ab0 100644 --- a/apps/flutter/test/fake_bridge_api.dart +++ b/apps/flutter/test/fake_bridge_api.dart @@ -805,6 +805,22 @@ class FakeBridgeApi implements BridgeApi { c.add(AppEvent(kind: 'turn/completed', threadId: threadId, raw: '{}')); } + String? lastSteerText; + String? lastSteerTurnId; + String? steerError; + + @override + Future appTurnSteer( + String serviceKey, + String threadId, + String? turnId, + String text, + ) async { + if (steerError != null) throw StateError(steerError!); + lastSteerText = text; + lastSteerTurnId = turnId; + } + /// Records the last turn id passed to [appTurnInterrupt]. String? lastInterruptTurnId; bool interrupted = false; diff --git a/apps/flutter/test/screens/app_session_test.dart b/apps/flutter/test/screens/app_session_test.dart index 0205569..f80bd80 100644 --- a/apps/flutter/test/screens/app_session_test.dart +++ b/apps/flutter/test/screens/app_session_test.dart @@ -5097,6 +5097,108 @@ void main() { expect(find.textContaining('old', findRichText: true), findsOneWidget); }); + for (final refreshAt in ['none', 'while pressed', 'after release']) { + testWidgets( + 'external writer first-turn navigation survives snapshot refresh: $refreshAt', + (t) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + await t.binding.setSurfaceSize(const Size(1600, 900)); + addTearDown(() => t.binding.setSurfaceSize(null)); + const service = 'pcx:lb7666:app:default'; + const thread = 'external-navigation'; + final api = + FakeBridgeApi( + config: const ConfigInfo( + relay: 'lb7666.top:7666', + hasKey: true, + ), + ) + ..appThreadResumeError = StateError( + 'thread already has an active writer', + ); + await api.appConnect(service, 28080); + final items = [ + for (var i = 0; i < 12; i++) ...[ + ThreadItem( + id: 'user-$i', + itemType: 'userMessage', + title: '', + text: 'external question $i', + ), + ThreadItem( + id: 'reply-$i', + itemType: 'agentMessage', + title: '', + text: List.filled(20, 'reply $i').join('\n\n'), + ), + ], + ]; + const liveness = SessionLiveness( + threadId: thread, + turnState: 'completed', + heldOpen: true, + safety: 'ownedIdle', + allowsResume: true, + requiresTakeover: true, + holders: [], + ); + api.transcripts[thread] = items; + api.liveness[thread] = liveness; + await t.pumpWidget( + host( + const AppSessionScreen(serviceKey: service, threadId: thread), + api, + ), + ); + await t.pumpAndSettle(); + expect(find.byKey(const Key('chat-takeover-action')), findsOneWidget); + final scroll = t + .widget(find.byType(MiddleClickScroll)) + .controller; + expect(scroll.offset, greaterThan(500)); + final rail = t.getRect(find.byKey(const Key('turn-minimap-rail'))); + final mouse = await t.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + addTearDown(mouse.removePointer); + final point = Offset(rail.left + 4, rail.top); + await mouse.moveTo(point); + await t.pumpAndSettle(); + await mouse.down(point); + await t.pump(const Duration(milliseconds: 150)); + if (refreshAt == 'while pressed') { + api.pushMetaSessionUpdate( + thread, + SessionFollowUpdate(liveness: liveness, items: [...items]), + ); + await t.pumpAndSettle(); + } + await mouse.up(); + if (refreshAt == 'after release') { + api.pushMetaSessionUpdate( + thread, + SessionFollowUpdate(liveness: liveness, items: [...items]), + ); + } + await mouse.moveTo(const Offset(1500, 850)); + await t.pumpAndSettle(); + expect(scroll.offset, lessThan(20)); + expect(find.text('external question 0'), findsOneWidget); + final firstTick = t.widget( + find.byKey(const ValueKey('turn-minimap-tick-0')), + ); + expect(firstTick.constraints!.maxWidth, 13); + expect(find.byKey(const Key('chat-takeover-action')), findsOneWidget); + expect(api.lastMetaResumedThread, isNull); + await t.pumpWidget(const SizedBox()); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + } + testWidgets('Active writer stays in chat read-only, then can be taken over', ( t, ) async { diff --git a/apps/flutter/test/turn_minimap_test.dart b/apps/flutter/test/turn_minimap_test.dart index 461b7d0..b7e3cde 100644 --- a/apps/flutter/test/turn_minimap_test.dart +++ b/apps/flutter/test/turn_minimap_test.dart @@ -11,6 +11,7 @@ List _items(int count) => [ for (var i = 0; i < count; i++) TurnMinimapItem( rowIndex: i * 3, + turnId: 'turn-$i', userText: 'question $i', assistantText: 'answer $i', ), @@ -47,17 +48,24 @@ Future> _pump( } /// The rail's tick widgets, in transcript order. -List _ticks(WidgetTester t) => t - .widgetList( +List _ticks(WidgetTester t) => t + .widgetList( find.descendant( of: find.byType(TurnMinimap), - matching: find.byType(AnimatedContainer), + matching: find.byWidgetPredicate( + (widget) => + widget is Container && + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'turn-minimap-tick-', + ), + ), ), ) .toList(); double _tickWidthAt(WidgetTester t, int index) => - _ticks(t)[index].constraints!.maxWidth; + t.getSize(find.byKey(ValueKey('turn-minimap-tick-$index'))).width; /// Hover the rail at [fraction] of its height, which is how the widget resolves /// which tick the pointer is on. @@ -208,7 +216,7 @@ void main() { await gesture.moveTo(const Offset(399, 599)); await t.pumpAndSettle(); expect(find.text('question 0'), findsNothing); - expect(_tickWidthAt(t, 0), 7); + expect(_tickWidthAt(t, 0), 6); expect(t.getSize(find.byKey(const Key('turn-minimap-rail'))).width, 40); }); @@ -224,7 +232,7 @@ void main() { await gesture.moveTo(Offset(rail.left + 200, rail.bottom - 2)); await t.pumpAndSettle(); expect(find.byKey(const Key('turn-minimap-preview')), findsNothing); - expect(_tickWidthAt(t, 0), 7); + expect(_tickWidthAt(t, 0), 6); expect(t.getSize(find.byKey(const Key('turn-minimap-rail'))).width, 40); }); @@ -236,41 +244,255 @@ void main() { ); await t.pumpAndSettle(); expect(find.byKey(const Key('turn-minimap-preview')), findsOneWidget); - expect(_tickWidthAt(t, 0), 22); + expect(_tickWidthAt(t, 0), 26); + }); + + for (final count in [4, 12, 400]) { + testWidgets('$count turns: padded rail ends remain clickable', (t) async { + final items = _items(count); + final selected = await _pump(t, items: items, height: 300); + final rail = t.getRect(find.byKey(const Key('turn-minimap-rail'))); + final mouse = await _hoverTick(t, 0); + for (final (point, index) in [ + (Offset(rail.left - 8, rail.top - 8), 0), + (Offset(rail.left - 8, rail.bottom + 8), count - 1), + ]) { + await mouse.moveTo(point); + await t.pumpAndSettle(); + expect(find.text('question $index'), findsOneWidget); + await mouse.down(point); + await mouse.up(); + await t.pumpAndSettle(); + expect(selected.last, same(items[index])); + } + expect(selected, hasLength(2)); + }); + + testWidgets('$count turns: crossing the gap keeps the preview target', ( + t, + ) async { + final items = _items(count); + final selected = await _pump(t, items: items, height: 300); + final index = count ~/ 2; + final mouse = await _hoverTick(t, index / (count - 1)); + final tick = t.getRect(find.byKey(ValueKey('turn-minimap-tick-$index'))); + final gap = Offset(tick.right + 5, tick.center.dy + 8); + await mouse.moveTo(gap); + await t.pumpAndSettle(); + expect(find.text('question $index'), findsOneWidget); + expect(_tickWidthAt(t, index), 26); + await mouse.down(gap); + await mouse.up(); + await t.pumpAndSettle(); + expect(selected.single, same(items[index])); + expect(find.byKey(const Key('turn-minimap-preview')), findsNothing); + }); + + for (final index in [0, count - 1]) { + testWidgets( + '$count turns: end preview $index accepts an imprecise click', + (t) async { + final items = _items(count); + final selected = await _pump(t, items: items, height: 300); + final rail = t.getRect(find.byKey(const Key('turn-minimap-rail'))); + final mouse = await _hoverTick(t, index / (count - 1)); + final card = t.getRect(find.byKey(const Key('turn-minimap-preview'))); + final point = index == 0 + ? Offset(card.right + 4, card.bottom + 4) + : Offset(card.right + 4, card.top - 4); + if (count == 4) { + expect(rail.contains(point), isFalse); + } + await mouse.moveTo(point); + await t.pumpAndSettle(); + expect(find.text('question $index'), findsOneWidget); + await mouse.down(point); + await mouse.up(); + await t.pumpAndSettle(); + expect(selected.single, same(items[index])); + }, + ); + } + } + + for (final hasTurnId in [true, false]) { + testWidgets( + 'a preview keeps its target across row updates, turn id: $hasTurnId', + (t) async { + var items = [ + for (var i = 0; i < 12; i++) + TurnMinimapItem( + rowIndex: i * 3, + userText: i == 0 ? 'question 5' : 'question $i', + assistantText: 'answer $i', + turnId: hasTurnId ? 'turn-$i' : '', + messageId: 'user-$i', + ), + ]; + late StateSetter rebuild; + final selected = []; + final visible = ValueNotifier<(int, int)?>(null); + addTearDown(visible.dispose); + await t.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + height: 300, + child: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return TurnMinimap( + items: items, + visibleRange: visible, + gutterWidth: 120, + onSelect: selected.add, + ); + }, + ), + ), + ), + ), + ); + final mouse = await _hoverTick(t, 5 / 11); + final card = t.getCenter(find.byKey(const Key('turn-minimap-preview'))); + await mouse.moveTo(card); + await t.pumpAndSettle(); + await mouse.down(card); + await t.pump(const Duration(milliseconds: 150)); + rebuild(() { + items = [ + const TurnMinimapItem( + rowIndex: 0, + userText: 'older', + turnId: 'older', + ), + for (final item in items) + TurnMinimapItem( + rowIndex: item.rowIndex + 20, + userText: item.userText, + assistantText: item.assistantText, + turnId: item.turnId, + messageId: item.messageId, + ), + ]; + }); + await t.pumpAndSettle(); + expect(find.text('question 5'), findsOneWidget); + await mouse.up(); + await t.pumpAndSettle(); + expect(selected.single.turnId, hasTurnId ? 'turn-5' : ''); + expect(selected.single.messageId, 'user-5'); + expect(selected.single.rowIndex, 35); + }, + ); + } + + testWidgets('a cancelled press does not jump', (t) async { + final selected = await _pump(t, items: _items(400), height: 300); + final mouse = await _hoverTick(t, 0.5); + final card = t.getCenter(find.byKey(const Key('turn-minimap-preview'))); + await mouse.down(card); + await t.pump(const Duration(milliseconds: 150)); + expect(selected, isEmpty); + await mouse.moveBy(const Offset(60, 0)); + await mouse.up(); + await t.pumpAndSettle(); + expect(selected, isEmpty); + }); + + testWidgets('empty space beside a preview passes clicks to the transcript', ( + t, + ) async { + var backgroundClicks = 0; + final selected = []; + final visible = ValueNotifier<(int, int)?>(null); + addTearDown(visible.dispose); + await t.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + height: 300, + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => backgroundClicks++, + ), + ), + Positioned.fill( + child: TurnMinimap( + items: _items(400), + visibleRange: visible, + gutterWidth: 120, + onSelect: selected.add, + ), + ), + ], + ), + ), + ), + ), + ); + await _hoverTick(t, 0); + final rail = t.getRect(find.byKey(const Key('turn-minimap-rail'))); + await t.tapAt(Offset(rail.left + 200, rail.bottom - 2)); + await t.pumpAndSettle(); + expect(backgroundClicks, 1); + expect(selected, isEmpty); }); - testWidgets('an on-screen turn is marked whatever the pointer is doing', ( + testWidgets('hover takes over one position highlight and exit restores it', ( t, ) async { - // Position drives BOTH cues: the on-screen turns are inked strongly and - // bulge outward, so the rail's shape shows where you are and reshapes as you - // scroll. Hovering is a much larger widening on top, so the two remain - // distinguishable. - final items = _items(4); + final items = _items(8); await _pump( t, items: items, - visible: (items[2].rowIndex, items[2].rowIndex), + visible: (items[2].rowIndex, items[6].rowIndex), ); - final ticks = t - .widgetList( - find.descendant( - of: find.byType(TurnMinimap), - matching: find.byType(AnimatedContainer), - ), - ) - .toList(); - Color colorOf(int i) => (ticks[i].decoration! as BoxDecoration).color!; - // The visible turn is the strong mark; the others are quiet. - expect(colorOf(2).a, greaterThan(colorOf(0).a)); - // …and it is wider, with nothing hovered: the shape alone says where you are. - expect(_tickWidthAt(t, 2), greaterThan(_tickWidthAt(t, 0))); + List widths() => [ + for (var i = 0; i < items.length; i++) _tickWidthAt(t, i), + ]; + List darkTicks() { + final ticks = _ticks(t); + final alphas = [ + for (final tick in ticks) (tick.decoration! as BoxDecoration).color!.a, + ]; + final strongest = alphas.reduce((a, b) => a > b ? a : b); + return [ + for (var i = 0; i < alphas.length; i++) + if (alphas[i] == strongest) i, + ]; + } + + final resting = widths(); + expect(resting, [6, 6, 13, 6, 6, 6, 6, 6]); + expect(darkTicks(), [2]); + final rail = t.getRect(find.byKey(const Key('turn-minimap-rail'))); + final gesture = await _hoverTick(t, 1 / 7); + expect(widths(), [20, 26, 20, 14, 10, 6, 6, 6]); + expect(darkTicks(), [1]); + + // Inspect rendered geometry before a transition could settle: the new + // pointed-at tick must already be the sole longest/darkest one. + await gesture.moveTo(Offset(rail.left + 4, rail.top + rail.height * 5 / 7)); + await t.pump(); + await t.pump(const Duration(milliseconds: 30)); + expect(widths(), [6, 6, 10, 14, 20, 26, 20, 14]); + expect(darkTicks(), [5]); + + await gesture.moveTo(const Offset(2000, 2000)); + await t.pump(); + expect(widths(), resting); + expect(darkTicks(), [2]); + expect(find.byKey(const Key('turn-minimap-preview')), findsNothing); }); testWidgets('the rail reshapes as the transcript scrolls', (t) async { - // The bulge has to TRAVEL, not just exist — that is what makes the rail read - // as a position indicator rather than a static scale with one odd mark. final items = _items(5); final visible = ValueNotifier<(int, int)?>(( items[1].rowIndex, @@ -296,13 +518,19 @@ void main() { await t.pumpAndSettle(); expect(_tickWidthAt(t, 1), greaterThan(_tickWidthAt(t, 4))); + // The user message can leave the viewport while its reply is still being + // read. Other visible turns must not take over that position marker. + visible.value = (items[1].rowIndex + 1, items[3].rowIndex); + await t.pumpAndSettle(); + expect(_tickWidthAt(t, 1), greaterThan(_tickWidthAt(t, 3))); + // Scroll to the end of the conversation. visible.value = (items[4].rowIndex, items[4].rowIndex); await t.pumpAndSettle(); expect( _tickWidthAt(t, 4), greaterThan(_tickWidthAt(t, 1)), - reason: 'the bulge follows the viewport', + reason: 'the single position marker follows the viewport', ); }); @@ -341,7 +569,7 @@ void main() { testWidgets('many turns compress instead of overflowing the window', ( t, ) async { - // 200 turns at the nominal 8 px spacing would be a 1600 px rail in a 300 px + // 200 turns at the nominal 10 px spacing would be a 2000 px rail in a 300 px // window. The rail caps and the ticks pack tighter. await _pump(t, items: _items(200), height: 300); final rail = t.getRect(find.byKey(const Key('turn-minimap-rail'))); diff --git a/crates/pocket-codex-bridge/src/api/bridge.rs b/crates/pocket-codex-bridge/src/api/bridge.rs index d336dcd..2c19ae9 100644 --- a/crates/pocket-codex-bridge/src/api/bridge.rs +++ b/crates/pocket-codex-bridge/src/api/bridge.rs @@ -557,6 +557,8 @@ pub struct ThreadItemDto { pub title: String, /// Body / detail text. pub text: String, + /// Structured asynchronous questions on an agent message, as JSON. + pub questions_json: Option, /// Image URLs attached to a `userMessage`: `data:image/...` URLs render /// inline; a host-local path (from a `localImage` input) renders as a /// filename chip. Empty for every other item kind. @@ -903,6 +905,16 @@ pub fn app_thread_start( app_session::thread_start(&service_key, model, cwd, approval_policy, sandbox) } +/// Append an asynchronous answer to the expected active turn. +pub fn app_turn_steer( + service_key: String, + thread_id: String, + turn_id: Option, + text: String, +) -> Result<()> { + app_session::turn_steer(&service_key, &thread_id, turn_id.as_deref(), &text) +} + /// Answer a server approval request. `decision` is the wire value the session /// layer recognises: `accept` or `acceptForSession` to grant, any other value /// (e.g. `decline`) to decline. @@ -945,6 +957,7 @@ fn item_dto(i: app_session::ThreadItem) -> ThreadItemDto { turn_id: i.turn_id, turn_completed_at: i.turn_completed_at, turn_duration_ms: i.turn_duration_ms, + questions_json: i.questions_json, } } @@ -1271,6 +1284,7 @@ pub fn app_local_session_transcript(thread_id: String) -> Result, /// Image URLs attached to a `userMessage`: `data:image/...` URLs render /// inline; a host-local path (from a `localImage` input) renders as a /// filename chip. Empty for every other item kind. @@ -263,28 +266,10 @@ fn establish(service_key: String, local_addr: &str) -> Result<()> { // registrant alive, codex app-server gone) fails fast instead of hanging // the connecting UI forever. runtime::runtime().block_on(async { - tokio::time::timeout( - CONNECT_TIMEOUT, - client.request( - "initialize", - json!({ - "clientInfo": { - "name": "pocket-codex", - "title": "Pocket-Codex", - "version": env!("CARGO_PKG_VERSION"), - }, - // `experimentalApi` unlocks v2 features the UI relies on, - // notably `turn/start.collaborationMode` (plan mode). - // Without it the server rejects plan turns with - // "turn/start.collaborationMode requires experimentalApi - // capability". - "capabilities": { "experimentalApi": true }, - }), - ), - ) - .await - .context("app-server initialize timed out")? - .context("app-server initialize") + tokio::time::timeout(CONNECT_TIMEOUT, client.initialize("pocket-codex", true)) + .await + .context("app-server initialize timed out")? + .context("app-server initialize") })?; let (events_tx, _) = broadcast::channel::(512); @@ -383,12 +368,13 @@ fn buffer_item(transcript: &Mutex>>, inbound: &I .map(|ms| ms / 1000), duration_ms: None, }; - let parsed = if inbound.method == "turn/plan/updated" { + let mut parsed = if inbound.method == "turn/plan/updated" { ThreadItem { id: plan_item_id(params), item_type: "plan".to_string(), title: String::new(), text: encode_plan(params), + questions_json: None, images: Vec::new(), turn_id: live_turn.id.clone(), turn_completed_at: None, @@ -403,6 +389,13 @@ fn buffer_item(transcript: &Mutex>>, inbound: &I }; parsed }; + if inbound.method == "item/completed" { + parsed.questions_json = params + .get("item") + .and_then(|item| item.get("questions")) + .filter(|value| value.is_array()) + .map(Value::to_string); + } if parsed.id.is_empty() { return; } @@ -1739,9 +1732,15 @@ fn thread_read_inner(service_key: &str, thread_id: &str) -> Result = items.iter().map(|i| i.id.clone()).collect(); + let positions: HashMap = items + .iter() + .enumerate() + .map(|(index, item)| (item.id.clone(), index)) + .collect(); for buffered in buffered_items(service_key, thread_id) { - if !seen.contains(&buffered.id) { + if let Some(index) = positions.get(&buffered.id) { + items[*index].questions_json = buffered.questions_json; + } else { items.push(buffered); } } @@ -1774,8 +1773,8 @@ fn thread_read_inner(service_key: &str, thread_id: &str) -> Result Result Result, +) -> ThreadRuntimeConfig { + if let Some(thread) = thread { + if let Some(model) = thread.get("model") { + cached.model = nonempty_str(Some(model)); + } + if let Some(effort) = thread.get("reasoningEffort") { + cached.reasoning_effort = nonempty_str(Some(effort)); + } + if let Some(provider) = thread.get("modelProvider") { + cached.model_provider = nonempty_str(Some(provider)); + } + } + cached +} + /// A one-line gist of where a thread got to: the opening sentence of its most /// recent agent message, or `None` when it has produced none yet. /// @@ -2035,8 +2047,8 @@ pub fn parse_token_usage(usage: Option<&Value>) -> (Option, Option) { /// shape is nested and volatile, so the raw JSON is returned for Dart to parse. pub fn rate_limits(service_key: &str) -> Result { let client = client_for(service_key)?; - // No-params method: the server types `params` as `Option<()>` and rejects an - // empty `{}` body, so omit `params` entirely. + // Omission remains supported by both the new capability-bearing request + // and older servers that required Option<()> here. let res = runtime::runtime().block_on(client.request_no_params("account/rateLimits/read"))?; Ok(res.to_string()) } @@ -2144,14 +2156,14 @@ fn is_login_server_bind_failure(e: &anyhow::Error) -> bool { /// [`login_chatgpt_start`] to learn when the browser flow completed. pub fn auth_status(service_key: &str) -> Result<(bool, Option)> { let client = client_for(service_key)?; - // GetAuthStatusParams: both fields optional; `{}` reads status without a token. - let res = runtime::runtime().block_on(client.request("account/getAuthStatus", json!({})))?; - let method = res - .get("authMethod") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .map(str::to_string); - Ok((method.is_some(), method)) + let res = runtime::runtime().block_on(client.request("account/read", json!({})))?; + Ok(auth_status_from_response(&res)) +} + +fn auth_status_from_response(res: &Value) -> (bool, Option) { + let method = nonempty_str(res.get("account").and_then(|account| account.get("type"))) + .map(|method| if method == "apiKey" { "apikey".into() } else { method }); + (method.is_some(), method) } /// Cancel an in-flight browser login (identified by the `login_id` from @@ -2305,6 +2317,40 @@ pub fn turn_start( Ok(()) } +/// Deliver an asynchronous answer to the expected running turn. +/// A stale turn id is reported to the caller instead of starting another turn. +pub fn turn_steer( + service_key: &str, + thread_id: &str, + turn_id: Option<&str>, + text: &str, +) -> Result<()> { + let (client, expected_turn) = { + let map = sessions().lock().unwrap_or_else(|e| e.into_inner()); + let session = map + .get(service_key) + .ok_or_else(|| anyhow!("not connected to {service_key}"))?; + let tracked = session + .active_turns + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(thread_id) + .cloned(); + (Arc::clone(&session.client), turn_id.map(Value::from).or(tracked)) + }; + let expected_turn = + expected_turn.context("the active turn is not available; reload the thread")?; + runtime::runtime().block_on(client.request( + "turn/steer", + json!({ + "threadId": thread_id, + "expectedTurnId": expected_turn, + "input": build_turn_input(text, &[])?, + }), + ))?; + Ok(()) +} + /// Map a kebab sandbox mode to a `turn/start` `sandboxPolicy` tagged object. fn sandbox_policy(mode: &str) -> Option { match mode { @@ -2578,6 +2624,7 @@ fn parse_turn_item(item: &Value, turn: &TurnStamp) -> Option { item_type, title, text, + questions_json: None, images: item_images(item), turn_id: turn.id.clone(), turn_completed_at: turn.completed_at, @@ -3550,3 +3597,7 @@ mod tests { assert_eq!(stamp.duration_ms, None); } } + +#[cfg(test)] +#[path = "app_session_protocol_tests.rs"] +mod protocol_tests; diff --git a/crates/pocket-codex-bridge/src/engine/app_session_protocol_tests.rs b/crates/pocket-codex-bridge/src/engine/app_session_protocol_tests.rs new file mode 100644 index 0000000..9ca25df --- /dev/null +++ b/crates/pocket-codex-bridge/src/engine/app_session_protocol_tests.rs @@ -0,0 +1,57 @@ +use super::*; + +#[test] +fn account_read_distinguishes_signed_out_and_supported_auth_methods() { + for (response, expected) in [ + (json!({"account": null, "requiresOpenaiAuth": true}), (false, None)), + ( + json!({"account": {"type": "chatgpt", "email": null, "planType": "pro"}}), + (true, Some("chatgpt".into())), + ), + (json!({"account": {"type": "apiKey"}}), (true, Some("apikey".into()))), + (json!({"account": {"type": "amazonBedrock"}}), (true, Some("amazonBedrock".into()))), + ] { + assert_eq!(auth_status_from_response(&response), expected); + } +} + +#[test] +fn thread_metadata_refreshes_model_and_clears_unset_effort() { + let cached = ThreadRuntimeConfig { + model: Some("old-model".into()), + reasoning_effort: Some("high".into()), + approval_policy: Some("on-request".into()), + ..Default::default() + }; + let current = runtime_config_with_thread( + cached.clone(), + Some(&json!({ + "model": "current-model", "reasoningEffort": null, "modelProvider": "openai" + })), + ); + assert_eq!(current.model.as_deref(), Some("current-model")); + assert_eq!(current.reasoning_effort, None); + assert_eq!(current.approval_policy, cached.approval_policy); + let legacy = runtime_config_with_thread(cached, Some(&json!({"id": "thread-1"}))); + assert_eq!(legacy.model.as_deref(), Some("old-model")); + assert_eq!(legacy.reasoning_effort.as_deref(), Some("high")); +} + +#[test] +fn async_questions_survive_live_buffering_but_history_does_not_reopen_them() { + let questions = json!([{"title": "Which platform?", "options": ["macOS", "Windows"]}]); + let item = json!({ + "id": "question-1", "type": "agentMessage", "text": "", "delivery": "async", + "questions": questions, + }); + let history = flatten_turns(&[json!({"id": "turn-1", "items": [item.clone()]})]); + let transcript = Mutex::new(HashMap::new()); + buffer_item(&transcript, &Inbound { + method: "item/completed".into(), + params: Some(json!({"threadId": "thread-1", "turnId": "turn-1", "item": item})), + request_id: None, + }); + let buffered = transcript.lock().expect("transcript lock"); + assert_eq!(history[0].questions_json, None); + assert_eq!(buffered["thread-1"][0].questions_json, Some(questions.to_string())); +} diff --git a/crates/pocket-codex-bridge/src/frb_generated.rs b/crates/pocket-codex-bridge/src/frb_generated.rs index 7ebef4d..76db991 100644 --- a/crates/pocket-codex-bridge/src/frb_generated.rs +++ b/crates/pocket-codex-bridge/src/frb_generated.rs @@ -42,7 +42,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -216609835; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -246576737; // Section: executor @@ -1759,6 +1759,49 @@ fn wire__crate__api__bridge__app_turn_start_impl( }, ) } +fn wire__crate__api__bridge__app_turn_steer_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "app_turn_steer", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_service_key = ::sse_decode(&mut deserializer); + let api_thread_id = ::sse_decode(&mut deserializer); + let api_turn_id = >::sse_decode(&mut deserializer); + let api_text = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || { + let output_ok = crate::api::bridge::app_turn_steer( + api_service_key, + api_thread_id, + api_turn_id, + api_text, + )?; + Ok(output_ok) + })(), + ) + } + }, + ) +} fn wire__crate__api__simple__bridge_version_impl( ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -4000,6 +4043,7 @@ impl SseDecode for crate::api::bridge::ThreadItemDto { let mut var_itemType = ::sse_decode(deserializer); let mut var_title = ::sse_decode(deserializer); let mut var_text = ::sse_decode(deserializer); + let mut var_questionsJson = >::sse_decode(deserializer); let mut var_images = >::sse_decode(deserializer); let mut var_turnId = ::sse_decode(deserializer); let mut var_turnCompletedAt = >::sse_decode(deserializer); @@ -4009,6 +4053,7 @@ impl SseDecode for crate::api::bridge::ThreadItemDto { item_type: var_itemType, title: var_title, text: var_text, + questions_json: var_questionsJson, images: var_images, turn_id: var_turnId, turn_completed_at: var_turnCompletedAt, @@ -4216,77 +4261,78 @@ fn pde_ffi_dispatcher_primary_impl( }, 45 => wire__crate__api__bridge__app_turn_interrupt_impl(port, ptr, rust_vec_len, data_len), 46 => wire__crate__api__bridge__app_turn_start_impl(port, ptr, rust_vec_len, data_len), - 48 => wire__crate__api__bridge__codex_auth_status_impl(port, ptr, rust_vec_len, data_len), - 49 => wire__crate__api__bridge__codex_locate_impl(port, ptr, rust_vec_len, data_len), - 50 => wire__crate__api__bridge__codex_login_cancel_impl(port, ptr, rust_vec_len, data_len), - 51 => wire__crate__api__bridge__codex_login_chatgpt_start_impl( + 47 => wire__crate__api__bridge__app_turn_steer_impl(port, ptr, rust_vec_len, data_len), + 49 => wire__crate__api__bridge__codex_auth_status_impl(port, ptr, rust_vec_len, data_len), + 50 => wire__crate__api__bridge__codex_locate_impl(port, ptr, rust_vec_len, data_len), + 51 => wire__crate__api__bridge__codex_login_cancel_impl(port, ptr, rust_vec_len, data_len), + 52 => wire__crate__api__bridge__codex_login_chatgpt_start_impl( port, ptr, rust_vec_len, data_len, ), - 52 => wire__crate__api__bridge__codex_logout_impl(port, ptr, rust_vec_len, data_len), - 53 => { + 53 => wire__crate__api__bridge__codex_logout_impl(port, ptr, rust_vec_len, data_len), + 54 => { wire__crate__api__bridge__codex_prompt_variant_impl(port, ptr, rust_vec_len, data_len) }, - 54 => wire__crate__api__bridge__codex_set_prompt_variant_impl( + 55 => wire__crate__api__bridge__codex_set_prompt_variant_impl( port, ptr, rust_vec_len, data_len, ), - 55 => { + 56 => { wire__crate__api__bridge__codex_setup_provider_impl(port, ptr, rust_vec_len, data_len) }, - 56 => wire__crate__api__bridge__codex_setup_status_impl(port, ptr, rust_vec_len, data_len), - 57 => wire__crate__api__bridge__discover_services_impl(port, ptr, rust_vec_len, data_len), - 58 => { + 57 => wire__crate__api__bridge__codex_setup_status_impl(port, ptr, rust_vec_len, data_len), + 58 => wire__crate__api__bridge__discover_services_impl(port, ptr, rust_vec_len, data_len), + 59 => { wire__crate__api__bridge__embedded_codex_version_impl(port, ptr, rust_vec_len, data_len) }, - 59 => wire__crate__api__bridge__export_config_impl(port, ptr, rust_vec_len, data_len), - 60 => wire__crate__api__bridge__get_config_impl(port, ptr, rust_vec_len, data_len), - 62 => wire__crate__api__bridge__import_config_impl(port, ptr, rust_vec_len, data_len), - 63 => wire__crate__api__simple__init_app_impl(port, ptr, rust_vec_len, data_len), - 64 => wire__crate__api__bridge__init_bridge_impl(port, ptr, rust_vec_len, data_len), - 65 => wire__crate__api__bridge__log_events_impl(port, ptr, rust_vec_len, data_len), - 66 => wire__crate__api__bridge__meta_force_resume_impl(port, ptr, rust_vec_len, data_len), - 67 => wire__crate__api__bridge__meta_list_dir_impl(port, ptr, rust_vec_len, data_len), - 68 => wire__crate__api__bridge__meta_list_files_impl(port, ptr, rust_vec_len, data_len), - 69 => wire__crate__api__bridge__meta_project_config_impl(port, ptr, rust_vec_len, data_len), - 70 => wire__crate__api__bridge__meta_read_file_impl(port, ptr, rust_vec_len, data_len), - 71 => { + 60 => wire__crate__api__bridge__export_config_impl(port, ptr, rust_vec_len, data_len), + 61 => wire__crate__api__bridge__get_config_impl(port, ptr, rust_vec_len, data_len), + 63 => wire__crate__api__bridge__import_config_impl(port, ptr, rust_vec_len, data_len), + 64 => wire__crate__api__simple__init_app_impl(port, ptr, rust_vec_len, data_len), + 65 => wire__crate__api__bridge__init_bridge_impl(port, ptr, rust_vec_len, data_len), + 66 => wire__crate__api__bridge__log_events_impl(port, ptr, rust_vec_len, data_len), + 67 => wire__crate__api__bridge__meta_force_resume_impl(port, ptr, rust_vec_len, data_len), + 68 => wire__crate__api__bridge__meta_list_dir_impl(port, ptr, rust_vec_len, data_len), + 69 => wire__crate__api__bridge__meta_list_files_impl(port, ptr, rust_vec_len, data_len), + 70 => wire__crate__api__bridge__meta_project_config_impl(port, ptr, rust_vec_len, data_len), + 71 => wire__crate__api__bridge__meta_read_file_impl(port, ptr, rust_vec_len, data_len), + 72 => { wire__crate__api__bridge__meta_read_thread_image_impl(port, ptr, rust_vec_len, data_len) }, - 72 => wire__crate__api__bridge__meta_retry_events_impl(port, ptr, rust_vec_len, data_len), - 73 => wire__crate__api__bridge__meta_session_events_impl(port, ptr, rust_vec_len, data_len), - 74 => { + 73 => wire__crate__api__bridge__meta_retry_events_impl(port, ptr, rust_vec_len, data_len), + 74 => wire__crate__api__bridge__meta_session_events_impl(port, ptr, rust_vec_len, data_len), + 75 => { wire__crate__api__bridge__meta_session_liveness_impl(port, ptr, rust_vec_len, data_len) }, - 75 => wire__crate__api__bridge__meta_session_transcript_impl( + 76 => wire__crate__api__bridge__meta_session_transcript_impl( port, ptr, rust_vec_len, data_len, ), - 76 => wire__crate__api__bridge__meta_sessions_impl(port, ptr, rust_vec_len, data_len), - 77 => wire__crate__api__bridge__meta_set_project_config_impl( + 77 => wire__crate__api__bridge__meta_sessions_impl(port, ptr, rust_vec_len, data_len), + 78 => wire__crate__api__bridge__meta_set_project_config_impl( port, ptr, rust_vec_len, data_len, ), - 78 => { + 79 => { wire__crate__api__bridge__meta_thread_config_get_impl(port, ptr, rust_vec_len, data_len) }, - 79 => { + 80 => { wire__crate__api__bridge__meta_thread_config_set_impl(port, ptr, rust_vec_len, data_len) }, - 80 => wire__crate__api__bridge__meta_upload_file_impl(port, ptr, rust_vec_len, data_len), - 81 => wire__crate__api__bridge__meta_write_file_impl(port, ptr, rust_vec_len, data_len), - 82 => wire__crate__api__bridge__set_key_impl(port, ptr, rust_vec_len, data_len), - 83 => wire__crate__api__bridge__set_locale_impl(port, ptr, rust_vec_len, data_len), - 84 => wire__crate__api__bridge__set_relay_impl(port, ptr, rust_vec_len, data_len), - 85 => wire__crate__api__bridge__subscriptions_impl(port, ptr, rust_vec_len, data_len), + 81 => wire__crate__api__bridge__meta_upload_file_impl(port, ptr, rust_vec_len, data_len), + 82 => wire__crate__api__bridge__meta_write_file_impl(port, ptr, rust_vec_len, data_len), + 83 => wire__crate__api__bridge__set_key_impl(port, ptr, rust_vec_len, data_len), + 84 => wire__crate__api__bridge__set_locale_impl(port, ptr, rust_vec_len, data_len), + 85 => wire__crate__api__bridge__set_relay_impl(port, ptr, rust_vec_len, data_len), + 86 => wire__crate__api__bridge__subscriptions_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -4301,8 +4347,8 @@ fn pde_ffi_dispatcher_sync_impl( match func_id { 19 => wire__crate__api__bridge__app_is_connected_impl(ptr, rust_vec_len, data_len), 41 => wire__crate__api__bridge__app_thread_runtime_config_impl(ptr, rust_vec_len, data_len), - 47 => wire__crate__api__simple__bridge_version_impl(ptr, rust_vec_len, data_len), - 61 => wire__crate__api__simple__greet_impl(ptr, rust_vec_len, data_len), + 48 => wire__crate__api__simple__bridge_version_impl(ptr, rust_vec_len, data_len), + 62 => wire__crate__api__simple__greet_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -4955,6 +5001,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::bridge::ThreadItemDto { self.item_type.into_into_dart().into_dart(), self.title.into_into_dart().into_dart(), self.text.into_into_dart().into_dart(), + self.questions_json.into_into_dart().into_dart(), self.images.into_into_dart().into_dart(), self.turn_id.into_into_dart().into_dart(), self.turn_completed_at.into_into_dart().into_dart(), @@ -5654,6 +5701,7 @@ impl SseEncode for crate::api::bridge::ThreadItemDto { ::sse_encode(self.item_type, serializer); ::sse_encode(self.title, serializer); ::sse_encode(self.text, serializer); + >::sse_encode(self.questions_json, serializer); >::sse_encode(self.images, serializer); ::sse_encode(self.turn_id, serializer); >::sse_encode(self.turn_completed_at, serializer); diff --git a/crates/pocket-codex-bridge/src/lib.rs b/crates/pocket-codex-bridge/src/lib.rs index db58928..dec6726 100644 --- a/crates/pocket-codex-bridge/src/lib.rs +++ b/crates/pocket-codex-bridge/src/lib.rs @@ -1,3 +1,7 @@ +// The desktop host instantiates the embedded app-server's deeply nested +// futures. +#![recursion_limit = "256"] + pub mod api; mod engine; mod frb_generated; diff --git a/crates/pocket-codex-codex/examples/app_server_probe.rs b/crates/pocket-codex-codex/examples/app_server_probe.rs index 84c31b8..4f3a100 100644 --- a/crates/pocket-codex-codex/examples/app_server_probe.rs +++ b/crates/pocket-codex-codex/examples/app_server_probe.rs @@ -63,15 +63,7 @@ async fn run() -> Result<()> { }; let notifications = tokio::spawn(async move { while events.recv().await.is_some() {} }); println!("connection {round}"); - request( - &client, - "initialize", - json!({ - "clientInfo": {"name": "pocket-codex", "version": env!("CARGO_PKG_VERSION")}, - "capabilities": {"experimentalApi": true} - }), - ) - .await?; + client.initialize("pocket-codex", true).await?; for _ in 0..3 { let list = request(&client, "thread/list", json!({"limit": 100, "sortKey": "updated_at"})) diff --git a/crates/pocket-codex-codex/src/client.rs b/crates/pocket-codex-codex/src/client.rs index 8e31a44..a8a755a 100644 --- a/crates/pocket-codex-codex/src/client.rs +++ b/crates/pocket-codex-codex/src/client.rs @@ -254,6 +254,26 @@ impl AppClient { !self.closed.is_cancelled() } + /// Initialize this connection and acknowledge the server before sending + /// RPCs. + pub async fn initialize(&self, name: &str, experimental_api: bool) -> Result { + let response = self + .request( + "initialize", + serde_json::json!({ + "clientInfo": { + "name": name, + "title": "Pocket-Codex", + "version": env!("CARGO_PKG_VERSION"), + }, + "capabilities": { "experimentalApi": experimental_api }, + }), + ) + .await?; + self.notify("initialized", serde_json::json!({})).await?; + Ok(response) + } + /// Answer a server→client request (identified by the `request_id` token /// from an [`Inbound`]) with `result`. No-op if the token is unknown. pub async fn respond(&self, token: &str, result: Value) -> Result<()> { @@ -278,7 +298,7 @@ impl AppClient { } /// Like [`request`](Self::request) but omits the `params` field entirely. - /// A few methods (e.g. `account/rateLimits/read`) are typed no-params + /// A few methods (e.g. `account/logout`) are typed no-params /// upstream (`Option<()>`, skipped when absent) and reject an empty `{}` /// body as invalid params, so they must be sent with no `params` key. pub async fn request_no_params(&self, method: &str) -> Result { diff --git a/crates/pocket-codex-codex/src/lib.rs b/crates/pocket-codex-codex/src/lib.rs index 98e232e..ba037e2 100644 --- a/crates/pocket-codex-codex/src/lib.rs +++ b/crates/pocket-codex-codex/src/lib.rs @@ -15,6 +15,8 @@ //! decide whether to talk over stdio, a unix socket, or a websocket. #![forbid(unsafe_code)] +// The embedded app-server's dispatch future exceeds the default layout-query depth. +#![recursion_limit = "256"] /// The `deps/codex` submodule commit the embedded (自带) app-server was built /// from, baked at compile time by `build.rs`. codex's own crate version is a diff --git a/crates/pocket-codex-codex/src/readiness.rs b/crates/pocket-codex-codex/src/readiness.rs index 9a5634c..7fb40e4 100644 --- a/crates/pocket-codex-codex/src/readiness.rs +++ b/crates/pocket-codex-codex/src/readiness.rs @@ -247,18 +247,9 @@ pub async fn probe_rpc(ws_url: &str, budget: Duration) -> anyhow::Result<()> { tokio::time::timeout_at(deadline, crate::client::AppClient::connect(ws_url)) .await .context("probe: websocket connect timed out")??; - tokio::time::timeout_at( - deadline, - client.request( - "initialize", - json!({ - "clientInfo": {"name": "pocket-codex", "version": env!("CARGO_PKG_VERSION")}, - "capabilities": {"experimentalApi": true} - }), - ), - ) - .await - .context("probe: initialize timed out")??; + tokio::time::timeout_at(deadline, client.initialize("pocket-codex", true)) + .await + .context("probe: initialize timed out")??; tokio::time::timeout_at(deadline, client.request("thread/list", json!({"limit": 1}))) .await .context("probe: thread/list timed out")??; diff --git a/crates/pocket-codex-codex/src/readiness_rpc_tests.rs b/crates/pocket-codex-codex/src/readiness_rpc_tests.rs index b70939c..afa07f4 100644 --- a/crates/pocket-codex-codex/src/readiness_rpc_tests.rs +++ b/crates/pocket-codex-codex/src/readiness_rpc_tests.rs @@ -23,7 +23,7 @@ async fn adopted_server(answer_list: bool, wildcard: bool) -> Result<(), Startup let mut ws = accept_async(listener.accept().await.expect("RPC connection").0) .await .expect("websocket handshake"); - for method in ["initialize", "thread/list"] { + for method in ["initialize", "initialized", "thread/list"] { let text = ws .next() .await @@ -33,6 +33,10 @@ async fn adopted_server(answer_list: bool, wildcard: bool) -> Result<(), Startup .expect("text frame"); let request: Value = serde_json::from_str(&text).expect("JSON request"); assert_eq!(request["method"], method); + if method == "initialized" { + assert!(request.get("id").is_none()); + continue; + } if method == "thread/list" && !answer_list { std::future::pending::<()>().await; } diff --git a/crates/pocket-codex-host-svc/src/resume.rs b/crates/pocket-codex-host-svc/src/resume.rs index 216ae1f..98198cf 100644 --- a/crates/pocket-codex-host-svc/src/resume.rs +++ b/crates/pocket-codex-host-svc/src/resume.rs @@ -96,17 +96,7 @@ async fn resume_into(app_ws_addr: SocketAddr, thread_id: &str) -> Result<()> { .await .context("connecting colocated app-server")?; client - .request( - "initialize", - json!({ - "clientInfo": { - "name": "pocket-codex-host-svc", - "title": "Pocket-Codex", - "version": env!("CARGO_PKG_VERSION"), - }, - "capabilities": { "experimentalApi": true }, - }), - ) + .initialize("pocket-codex-host-svc", true) .await .context("app-server initialize")?; client diff --git a/deps/codex b/deps/codex index 05daa82..852cac1 160000 --- a/deps/codex +++ b/deps/codex @@ -1 +1 @@ -Subproject commit 05daa82dea055af698d44f3bc2e5e94a8fdfe764 +Subproject commit 852cac102eb260b3996ab51375ee9bc338c7206e