Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ android {
applicationId = "com.sigkitten.litter.android"
minSdk = 26
targetSdk = 36
versionCode = 200000254
versionCode = 200000260
versionName = "2.0.0"
buildConfigField("boolean", "ENABLE_ON_DEVICE_BRIDGE", "true")
buildConfigField("String", "RUNTIME_STARTUP_MODE", "\"hybrid\"")
Expand Down
6 changes: 3 additions & 3 deletions apps/ios/Litter.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2909,7 +2909,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 200000254;
CURRENT_PROJECT_VERSION = 200000260;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = TZ447KHNZL;
ENABLE_NS_ASSERTIONS = NO;
Expand Down Expand Up @@ -3053,7 +3053,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 200000254;
CURRENT_PROJECT_VERSION = 200000260;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = TZ447KHNZL;
ENABLE_STRICT_OBJC_MSGSEND = YES;
Expand Down Expand Up @@ -3148,7 +3148,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 200000254;
CURRENT_PROJECT_VERSION = 200000260;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = TZ447KHNZL;
ENABLE_NS_ASSERTIONS = NO;
Expand Down
4 changes: 2 additions & 2 deletions apps/ios/Sources/Litter/Views/ConversationTimelineView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,11 @@ struct ConversationTurnTimeline: View {
}

private var commandDisplayMode: ConversationDetailDisplayMode {
ConversationDetailDisplayMode.resolve(commandDisplayModeRaw)
ConversationDetailDisplayMode.resolveRequiredActivity(commandDisplayModeRaw)
}

private var toolDisplayMode: ConversationDetailDisplayMode {
ConversationDetailDisplayMode.resolve(toolDisplayModeRaw)
ConversationDetailDisplayMode.resolveRequiredActivity(toolDisplayModeRaw)
}

// Returns AnyView rather than `some View` with @ViewBuilder so the result
Expand Down
14 changes: 9 additions & 5 deletions apps/ios/Sources/Litter/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,21 +146,24 @@ struct SettingsView: View {
title: "Internal Thinking",
subtitle: "Reasoning and analysis blocks",
systemImage: "brain.head.profile",
selection: $reasoningDisplayMode
selection: $reasoningDisplayMode,
modes: ConversationDetailDisplayMode.allCases
)

transcriptDisplayPicker(
title: "Commands",
subtitle: "Shell commands and command output",
systemImage: "terminal",
selection: $commandDisplayMode
selection: $commandDisplayMode,
modes: ConversationDetailDisplayMode.requiredActivityCases
)

transcriptDisplayPicker(
title: "Tools",
subtitle: "MCP, web, image, and file-change cards",
systemImage: "wrench.and.screwdriver",
selection: $toolDisplayMode
selection: $toolDisplayMode,
modes: ConversationDetailDisplayMode.requiredActivityCases
)
} header: {
Text("Conversation")
Expand All @@ -172,10 +175,11 @@ struct SettingsView: View {
title: String,
subtitle: String,
systemImage: String,
selection: Binding<String>
selection: Binding<String>,
modes: [ConversationDetailDisplayMode]
) -> some View {
Picker(selection: selection) {
ForEach(ConversationDetailDisplayMode.allCases) { mode in
ForEach(modes) { mode in
Text(mode.displayName).tag(mode.rawValue)
}
} label: {
Expand Down
14 changes: 14 additions & 0 deletions apps/ios/Sources/Litter/Views/ToolCallModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ enum ConversationDetailDisplayMode: String, CaseIterable, Identifiable, Equatabl
ConversationDetailDisplayMode(rawValue: rawValue) ?? .collapsed
}

/// Commands and tool results are part of the durable conversation record,
/// not optional decoration. Older builds allowed persisting `hidden` for
/// those rows; migrate that value to collapsed so reconnecting to a thread
/// can never make its work disappear from the transcript.
static func resolveRequiredActivity(_ rawValue: String) -> ConversationDetailDisplayMode {
let mode = resolve(rawValue)
return mode == .hidden ? .collapsed : mode
}

static let requiredActivityCases: [ConversationDetailDisplayMode] = [
.expanded,
.collapsed
]

func defaultExpanded(isFailed: Bool = false) -> Bool {
switch self {
case .expanded:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ final class ConversationDisplayPreferenceTests: XCTestCase {
XCTAssertEqual(ConversationDetailDisplayMode.resolve("not-a-mode"), .collapsed)
}

func testRequiredActivityMigratesHiddenToCollapsed() {
XCTAssertEqual(ConversationDetailDisplayMode.resolveRequiredActivity("expanded"), .expanded)
XCTAssertEqual(ConversationDetailDisplayMode.resolveRequiredActivity("collapsed"), .collapsed)
XCTAssertEqual(ConversationDetailDisplayMode.resolveRequiredActivity("hidden"), .collapsed)
XCTAssertEqual(
ConversationDetailDisplayMode.requiredActivityCases,
[.expanded, .collapsed]
)
}

func testCollapsedModeOnlyExpandsFailuresByDefault() {
XCTAssertTrue(ConversationDetailDisplayMode.expanded.defaultExpanded())
XCTAssertFalse(ConversationDetailDisplayMode.collapsed.defaultExpanded())
Expand Down
8 changes: 4 additions & 4 deletions apps/ios/Tests/LitterUITests/LitterUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,19 @@ final class LitterUITests: XCTestCase {
}

@MainActor
func testConversationDisplayHiddenModeRemovesDetailRows() throws {
func testConversationDisplayHiddenLegacyValueStillShowsToolActivity() throws {
let app = conversationDisplayHarnessApp(reasoning: "hidden", commands: "hidden", tools: "hidden")
app.launch()

XCTAssertTrue(app.staticTexts["UITEST_USER_MESSAGE"].waitForExistence(timeout: 10))
XCTAssertTrue(app.staticTexts["UITEST_ASSISTANT_MESSAGE"].exists)
XCTAssertFalse(app.staticTexts["Thinking"].exists)
XCTAssertFalse(app.staticTexts["Internal reasoning"].exists)
XCTAssertFalse(app.staticTexts["printf UITEST_COMMAND_HEADER"].exists)
XCTAssertFalse(app.staticTexts["uiTest.fixtureTool"].exists)
XCTAssertTrue(app.staticTexts["printf UITEST_COMMAND_HEADER"].exists)
XCTAssertTrue(app.staticTexts["uiTest.fixtureTool"].exists)
XCTAssertFalse(app.staticTexts["UITEST_REASONING_DETAIL"].exists)
XCTAssertFalse(app.staticTexts["UITEST_COMMAND_OUTPUT"].exists)
XCTAssertFalse(app.staticTexts["UITEST_TOOL_DETAIL"].exists)
XCTAssertTrue(app.staticTexts["UITEST_TOOL_DETAIL"].exists)
}

@MainActor
Expand Down
11 changes: 6 additions & 5 deletions apps/ios/fastlane/metadata/en-US/release_notes.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
Local Studio sessions now stay visible, correctly labeled, and connected to the right runtime.
Local Studio conversations are now faster and fully faithful across live turns and reconnects.

- Start every new Local Studio conversation through its built-in Pi runtime.
- Keep newly synchronized sessions visible even when older sessions are pinned.
- Resume and reconnect without sessions disappearing or changing into Codex sessions.
- Show the correct connected status without asking Local Studio users to sign in to OpenAI.
- Show command, tool-call, and tool-result activity in every conversation.
- Preserve session identity and ordering without replayed messages.
- Queue rapid follow-up messages reliably so a second message is never lost or sent twice.
- Restore exact tool output and completed turns after reconnecting.
- Batch streaming updates at display cadence for smoother, more responsive conversations.
2 changes: 1 addition & 1 deletion apps/ios/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ settings:
SWIFT_VERSION: "5.9"
SWIFT_STRICT_CONCURRENCY: targeted
MARKETING_VERSION: "2.0.0"
CURRENT_PROJECT_VERSION: "200000254"
CURRENT_PROJECT_VERSION: "200000260"
SWIFT_OBJC_BRIDGING_HEADER: Sources/Litter/Bridge/codex_bridge_objc.h
OTHER_LDFLAGS: "$(inherited) -lc++ -lz -lresolv -lcodex_mobile_client"
HEADER_SEARCH_PATHS: "$(inherited) $(PROJECT_DIR)/GeneratedRust/Headers"
Expand Down
25 changes: 13 additions & 12 deletions docs/releases/testflight-whats-new.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
Summary

- Preserved Local Studio session identity across listing, streaming, hydration, and reconnect.
- Routed new Local Studio conversations through its built-in Pi runtime from the first request.
- Kept newly synchronized Local Studio sessions visible alongside legacy pinned sessions.
- Removed false OpenAI sign-in warnings from Local Studio connections.
- Enforced full filesystem access with no approval prompts for Pi and Local Studio Pi sessions.
- Restored Local Studio command, tool-call, and tool-result lifecycle events from legacy controllers.
- Made command and tool activity permanently visible, including for users with an older hidden preference.
- Closed the turn-start race that could lose, duplicate, or misorder a rapid second message.
- Preserved exact queued-turn model, mode, effort, and permission settings.
- Kept session identity, item ordering, and tool output stable across reconnect and hydration.
- Batched streaming deltas at display cadence to reduce native UI churn while preserving exact text.

What to test

- Upgrade from 1.6 or 1.7 with existing Local Studio pins and confirm current sessions remain visible and every Local Studio row keeps its label.
- Start a Local Studio conversation from mobile, stream the reply, use shell and file tools, then reopen it in both Litter and Local Studio.
- Background and foreground the app during and after a turn; confirm the session and completed content survive reconnect and resume.
- Confirm Local Studio shows connected without an OpenAI sign-in warning.
- Confirm Pi and Local Studio Pi never request approval and can read and write the selected workspace.
- Confirm ordinary Codex, Pi, and generic KittyLitter connections retain their existing discovery and permission behavior.
- Verify reasoning, tool calls, streaming text, images, compaction, and older-turn hydration on iOS and Android.
- Pair with a Local Studio controller, run a shell command, and confirm the command card and completed output appear live.
- Send a second message immediately after the first tool-producing turn; confirm it appears immediately, runs once, and receives one response.
- Background and foreground during streaming, then reconnect; confirm no user, assistant, command, or tool rows replay or disappear.
- Reopen the same session from Litter and Local Studio and confirm the exact thread identity, order, and output match.
- Compare long streaming replies with 1.7.0 and confirm scrolling and token rendering remain smooth and responsive.
- Confirm Pi and Local Studio Pi continue to use no approvals with full workspace access.
- Verify the same session, tool, and streaming behavior on iOS and Android.
97 changes: 97 additions & 0 deletions shared/rust-bridge/codex-mobile-client/src/ffi/app_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ pub(crate) struct AppStoreSubscriptionState {
}

const MAX_COALESCED_STREAMING_TEXT_BYTES: usize = 8 * 1024;
/// Bound native/FFI streaming publishes to the display cadence. Without a
/// short collection window a fast producer and Swift/Kotlin consumer run in
/// lockstep, so `try_recv` sees only one tiny delta and every token copies the
/// platform snapshot. Sixteen milliseconds keeps first-token latency within a
/// single frame while capping that cross-boundary churn near 60 Hz.
const STREAMING_COALESCE_WINDOW: std::time::Duration = std::time::Duration::from_millis(16);

#[cfg(test)]
mod tests {
Expand All @@ -41,6 +47,7 @@ mod tests {
use codex_app_server_protocol as upstream;
use serde_json::json;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;

#[test]
fn thread_item_parses_mcp_arguments_json() {
Expand Down Expand Up @@ -172,6 +179,68 @@ mod tests {
));
}

#[tokio::test]
async fn app_store_subscription_batches_interleaved_streaming_at_display_cadence() {
let reducer = Arc::new(AppStoreReducer::new());
let key = ThreadKey {
server_id: "srv".to_string(),
thread_id: "thread-1".to_string(),
};
let subscription = AppStoreSubscription {
state: std::sync::Mutex::new(Some(AppStoreSubscriptionState {
rx: reducer.subscribe(),
buffered: VecDeque::new(),
})),
};

const DELTA_COUNT: usize = 48;
reducer.emit_thread_streaming_delta(
&key,
"assistant-1",
ThreadStreamingDeltaKind::AssistantText,
"x",
);
let producer = {
let reducer = Arc::clone(&reducer);
let key = key.clone();
tokio::spawn(async move {
for _ in 1..DELTA_COUNT {
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
reducer.emit_thread_streaming_delta(
&key,
"assistant-1",
ThreadStreamingDeltaKind::AssistantText,
"x",
);
}
})
};

let mut delivered_text = String::new();
let mut update_count = 0usize;
while delivered_text.len() < DELTA_COUNT {
let update = subscription
.next_update()
.await
.expect("streaming update");
let AppStoreUpdateRecord::ThreadStreamingDelta { text, .. } = update else {
panic!("expected streaming delta");
};
delivered_text.push_str(&text);
update_count += 1;
}
producer.await.expect("producer");

eprintln!(
"batched {DELTA_COUNT} streaming deltas into {update_count} native updates"
);
assert_eq!(delivered_text, "x".repeat(DELTA_COUNT));
assert!(
update_count <= 12,
"expected display-cadence batching, got {update_count} native updates"
);
}

#[test]
fn app_store_subscription_coalesces_refresh_only_updates_into_full_resync() {
let reducer = AppStoreReducer::new();
Expand Down Expand Up @@ -726,9 +795,37 @@ async fn receive_next_update(
state.rx.recv().await?
};

let first = coalesce_streaming_window(state, first).await?;
coalesce_ready_updates(state, first)
}

async fn coalesce_streaming_window(
state: &mut AppStoreSubscriptionState,
mut update: AppStoreUpdateRecord,
) -> Result<AppStoreUpdateRecord, tokio::sync::broadcast::error::RecvError> {
if !matches!(update, AppStoreUpdateRecord::ThreadStreamingDelta { .. }) {
return Ok(update);
}

let deadline = tokio::time::Instant::now() + STREAMING_COALESCE_WINDOW;
loop {
let next = if let Some(update) = state.buffered.pop_front() {
update
} else {
match tokio::time::timeout_at(deadline, state.rx.recv()).await {
Ok(Ok(update)) => update,
Ok(Err(error)) => return Err(error),
Err(_) => return Ok(update),
}
};

if let Err(next) = merge_app_update(&mut update, next) {
state.buffered.push_front(next);
return Ok(update);
}
}
}

fn coalesce_ready_updates(
state: &mut AppStoreSubscriptionState,
mut update: AppStoreUpdateRecord,
Expand Down
Loading
Loading