diff --git a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionCanvas.kt b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionCanvas.kt index fc872101e6..6cfa51bfc1 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionCanvas.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionCanvas.kt @@ -145,6 +145,10 @@ internal fun NewSessionCanvas( state: NewSessionCanvasState = remember { NewSessionCanvasState() }, insets: CanvasInsets? = null, wallpaper: @Composable (Modifier, () -> Rect?) -> Unit = { m, c -> WallpaperHero(m, cutout = c) }, + /** android-next #06: a side chat's draft says "Ask on the side". */ + headline: String = NewSessionHeadline, + /** android-next #06: false when the host draws its own bar (the side-chat draft's session header). */ + navBar: Boolean = true, ) { ProvideHazeState { val haze = LocalHazeState.current!! @@ -191,6 +195,7 @@ internal fun NewSessionCanvas( Modifier .hazeSource(haze, zIndex = 1f) .testTag("new-session-hero"), + headline = headline, ) } val composerSlot: @Composable () -> Unit = { @@ -323,7 +328,7 @@ internal fun NewSessionCanvas( }, ) } - NewSessionNavBar(embedded, onClose, insets) + if (navBar) NewSessionNavBar(embedded, onClose, insets) } } } @@ -337,7 +342,7 @@ internal fun NewSessionCanvas( * the column is 76.6 dp (1 line) or 105.2 dp (2 lines) at font scale 1. */ @Composable -internal fun NewSessionHero(harness: String, modifier: Modifier = Modifier) { +internal fun NewSessionHero(harness: String, modifier: Modifier = Modifier, headline: String = NewSessionHeadline) { val palette = RobocoTheme.palette val typeScale = LocalTypeScale.current Column( @@ -354,7 +359,7 @@ internal fun NewSessionHero(harness: String, modifier: Modifier = Modifier) { .clearAndSetSemantics { }, ) BasicText( - "What are we building?", + headline, Modifier.testTag("new-session-headline"), style = uiStyle(Face.SansSemibold, typeScale.fixed(NewSessionLayout.HeadlineSize), palette.text) .copy(textAlign = TextAlign.Center), @@ -364,6 +369,12 @@ internal fun NewSessionHero(harness: String, modifier: Modifier = Modifier) { } } +/** The New Session hero's headline (`:122`). */ +const val NewSessionHeadline = "What are we building?" + +/** A side chat's draft headline (upstream #899's `sideOf` hero). */ +const val SideChatHeadline = "Ask on the side" + /** * The transparent nav bar (`:113-118`, `SplitRootController.swift:46-47`). * diff --git a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionChips.kt b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionChips.kt index b6e27d5d98..d07bad2ae1 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionChips.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionChips.kt @@ -171,6 +171,9 @@ fun newSessionChips( return chips } +/** The chips a side chat's draft keeps (iOS `refreshChips` with `sideOf`). */ +private val SideChatChipIds = setOf(NewSessionChipIds.Model, NewSessionChipIds.Effort) + /** * Foundation's `String.capitalized` (`CFStringCapitalize`), which the effort * titles (here and in ticket 114's effort rows) use: a letter is @@ -262,13 +265,16 @@ fun NewSessionChipsEffect( ) { val chips by remember(model, labels) { derivedStateOf { - newSessionChips( + val all = newSessionChips( model.draft, model.project, model.currentOptions?.hosts.orEmpty(), model.models, labels, ) + // android-next #06: a side chat runs in its parent's checkout + // on its parent's host — only the model and effort are picked. + if (model.side == null) all else all.filter { it.id in SideChatChipIds } } } // Seed synchronously so the first composition already has the chips. diff --git a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionCreate.kt b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionCreate.kt index 47ff3eb636..17dab3139d 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionCreate.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionCreate.kt @@ -77,7 +77,10 @@ internal object NewSessionRequests { * neither → `null`, and the caller fails **without** a core call (the * alert explains). Ids are the scoped ids the options carry. */ - fun target(d: NewSessionDraft): SessionTarget? { + fun target(d: NewSessionDraft, side: SideChatParent? = null): SessionTarget? { + // android-next #06: ignored by the core for a side chat (the + // parent's host fills it), but the record needs one (iOS `sideOf`). + if (side != null) return SessionTarget.Projectless(deviceId = side.deviceId) val projectId = d.projectId if (projectId != null) return SessionTarget.Project(spaceId = projectId) val hostId = d.hostId @@ -89,9 +92,11 @@ internal object NewSessionRequests { * `NewSession(target:config:branch:cwd:title:)` (`:629-630`): * `reasoning = effort` (nil ⇒ the engine chooses), `sandbox` is * always workspace-write on this canvas, `branch` only without a - * worktree (with one, the branch becomes the worktree's base). + * worktree (with one, the branch becomes the worktree's base). A side + * chat (android-next #06) names its parent and no branch: it runs in + * the parent's checkout (the core keeps the parent's sandbox too). */ - fun newSession(d: NewSessionDraft, target: SessionTarget): NewSession = NewSession( + fun newSession(d: NewSessionDraft, target: SessionTarget, side: SideChatParent? = null): NewSession = NewSession( target = target, config = ChatConfig( harness = d.harness, @@ -100,9 +105,10 @@ internal object NewSessionRequests { modelOptions = emptyMap(), sandbox = SandboxLevel.WORKSPACE_WRITE, ), - branch = if (d.worktree) null else d.branch, + branch = if (d.worktree || side != null) null else d.branch, cwd = null, title = null, + parentChatId = side?.chatId, ) /** @@ -117,6 +123,7 @@ internal object NewSessionRequests { text: String, images: List, projects: List, + side: SideChatParent? = null, ): SendRequest = SendRequest( text = text, attachments = images.map { image -> @@ -127,7 +134,8 @@ internal object NewSessionRequests { data = image.bytes, ) }, - worktree = if (d.worktree) { + // A side chat never makes a worktree (iOS `draft.worktree && parentId == nil`). + worktree = if (d.worktree && side == null) { projects.firstOrNull { it.id == d.projectId }?.let { project -> WorktreeSpec( repoPath = project.path, @@ -170,14 +178,15 @@ class NewSessionCreator( text: String, images: List, projects: List, + side: SideChatParent? = null, ): String? { // No target: fail without a core call and without a log — the // alert explains it, and there is no error to log. - val target = NewSessionRequests.target(d) ?: return null + val target = NewSessionRequests.target(d, side) ?: return null return withContext(io) { try { - val chatId = core.createSession(NewSessionRequests.newSession(d, target)) - core.sendFirst(chatId, NewSessionRequests.sendRequest(d, text, images, projects)) + val chatId = core.createSession(NewSessionRequests.newSession(d, target, side)) + core.sendFirst(chatId, NewSessionRequests.sendRequest(d, text, images, projects, side)) chatId } catch (cancelled: CancellationException) { throw cancelled @@ -201,6 +210,17 @@ val StartFailedAlert = RobocoAlert( buttons = listOf(AlertButton("OK", AlertButtonRole.Default)), ) +/** + * A side chat's failure alert (android-next #06, upstream #899's + * `create(sideOf:)` copy verbatim): the parent is gone or the create + * failed — the composer stays `Kept`. + */ +val SideChatStartFailedAlert = RobocoAlert( + title = "Couldn't start the side chat", + message = "Its session is no longer available.", + buttons = listOf(AlertButton("OK", AlertButtonRole.Default)), +) + /** * The canvas composer's offline gate (94's Roboco ADD, the parent * spec's "Send is disabled while the target engine is offline"): the diff --git a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionModel.kt b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionModel.kt index 06b723769e..d50d867e48 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionModel.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionModel.kt @@ -2,6 +2,7 @@ package sh.roboco.android.newsession import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -24,9 +25,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import sh.roboco.android.RobocoApplication import sh.roboco.android.composer.ComposerController +import sh.roboco.android.composer.DEFAULT_HARNESS import sh.roboco.android.composer.DraftKey import sh.roboco.android.composer.LocalDrafts import sh.roboco.android.composer.StagedImage +import uniffi.roboco_core.SessionRow /** * The New Session page's state holder (android-ui-parity #92) — the port @@ -69,13 +72,21 @@ class NewSessionModel( private val appScope: CoroutineScope, private val io: CoroutineDispatcher = Dispatchers.IO, private val main: CoroutineDispatcher = Dispatchers.Main.immediate, + /** + * android-next #06: a side chat's draft (upstream #899's + * `NewSessionViewController(sideOf:)`). It runs in the parent's + * checkout on the parent's host, so only the model and effort are + * picked (any harness, until the first send mints the row); it starts + * on the parent's model and never reads or writes the sticky store. + */ + val side: SideChatParent? = null, ) { /** The epoch the page opened with; a full reset while open freezes this page. */ private val openedEpoch: Int = store.resetEpoch - /** The page's copy of the sticky draft (iOS `self.draft`, `:75`). */ - var draft: NewSessionDraft by mutableStateOf(store.lastDraft) + /** The page's copy of the sticky draft (iOS `self.draft`, `:75`); a side chat's starts on its parent's model. */ + var draft: NewSessionDraft by mutableStateOf(side?.let(::sideChatDraft) ?: store.lastDraft) private set /** Models for the draft's device: cached or built-in at once, then the engine's own. */ @@ -83,10 +94,10 @@ class NewSessionModel( private set /** `prompt ?: store.text` — a non-null prompt (even `""`) wins over the saved text (`:78`). */ - val initialText: String = prompt ?: store.text + val initialText: String = prompt ?: if (side == null) store.text else "" /** The images staged when the page last closed unsent (`:79`). */ - val initialImages: List = store.images + val initialImages: List = if (side == null) store.images else emptyList() /** Snapshot state; assigned by the options collector (109/113 read it in `derivedStateOf`). */ var currentOptions: NewSessionOptions? by mutableStateOf(options.value) @@ -111,9 +122,10 @@ class NewSessionModel( init { // The defaults run once, here (iOS `viewDidLoad` `:177-181`) — - // without a coroutine, so the first frame already has them. + // without a coroutine, so the first frame already has them. A side + // chat picks no project or host (`sideOf == nil` guards on iOS). val initial = options.value - if (initial != null) { + if (initial != null && side == null) { val n = normalize(draft, initial) if (n != draft) updateInternal({ n }, userEdit = false) } @@ -123,7 +135,7 @@ class NewSessionModel( pageScope.launch { options.collect { o -> currentOptions = o - if (o != null) { + if (o != null && side == null) { val n = normalize(draft, o) if (n != draft) updateInternal({ n }, userEdit = false) else loadModels() } @@ -132,8 +144,9 @@ class NewSessionModel( // The late store load (the tablet launch page, 106, and a // `roboco://new` cold start can open the page before `warm()` // finished): once it lands, an untouched page takes over the - // stored draft; a touched one keeps the user's pick. - if (!store.isLoaded) { + // stored draft; a touched one keeps the user's pick. A side chat's + // draft never comes from the store. + if (!store.isLoaded && side == null) { pageScope.launch { store.awaitLoaded() if (!touched) { @@ -148,13 +161,13 @@ class NewSessionModel( } } - /** The picked project (iOS `:236`); null while the options are still null. */ + /** The picked project (iOS `:236`); null while the options are still null, and for a side chat. */ val project: ProjectOption? - get() = currentOptions?.projects?.firstOrNull { it.id == draft.projectId } + get() = if (side != null) null else currentOptions?.projects?.firstOrNull { it.id == draft.projectId } - /** The engine the session runs on (iOS `:237`). */ + /** The engine the session runs on (iOS `:237`); a side chat's parent's host. */ val deviceId: String - get() = project?.device ?: draft.hostId ?: "" + get() = side?.deviceId ?: project?.device ?: draft.hostId ?: "" /** * Checkouts of the picked project, one per row of the host menu @@ -194,7 +207,8 @@ class NewSessionModel( * reset while the page was open (epoch mismatch). */ fun rememberDraft(text: String, images: List) { - if (created || store.resetEpoch != openedEpoch) return + // A side chat's draft is its own: it never seeds the next new session. + if (created || side != null || store.resetEpoch != openedEpoch) return if (store.isLoaded || touched) store.lastDraft = draft store.text = text store.images = images @@ -206,7 +220,7 @@ class NewSessionModel( * draft, unconditionally. */ fun persistBeforeCreate() { - store.lastDraft = draft + if (side == null) store.lastDraft = draft } /** @@ -216,6 +230,7 @@ class NewSessionModel( */ fun markCreated() { created = true + if (side != null) return // the sticky text and images were never this page's store.text = "" store.images = emptyList() } @@ -268,11 +283,52 @@ class NewSessionModel( if (n == draft) return draft = n if (userEdit) touched = true - if (!created && store.isLoaded && store.resetEpoch == openedEpoch) store.lastDraft = n + if (!created && side == null && store.isLoaded && store.resetEpoch == openedEpoch) store.lastDraft = n loadModels() } } +/** + * The chat a side-chat draft hangs off (android-next #06, upstream #899's + * `NewSessionViewController(sideOf:)`), read from its workspace row when + * the page opens. The create sends `parentChatId` and the core takes the + * host, the checkout and the sandbox from the parent; this record only + * names the parent and seeds the page. + */ +@Immutable +data class SideChatParent( + /** The scoped chat id the new side chat hangs off (a side chat's own: a sibling, the core decides). */ + val chatId: String, + /** The parent's title — the nav subtitle "Side chat · ". */ + val title: String, + /** The parent's host device: the model catalog's device. */ + val deviceId: String, + /** The parent's project (scoped space id), for `@file` search; null when projectless. */ + val projectId: String?, + val harness: String?, + val model: String?, + val effort: String?, +) { + companion object { + fun of(row: SessionRow): SideChatParent = SideChatParent( + chatId = row.id, + title = row.title, + deviceId = row.deviceId, + projectId = row.project?.id, + harness = row.harness, + model = row.model, + effort = row.reasoning, + ) + } +} + +/** A side chat's starting draft: the parent's harness, model and effort (iOS `init(sideOf:)`). Pure. */ +fun sideChatDraft(parent: SideChatParent): NewSessionDraft = NewSessionDraft( + harness = parent.harness?.takeIf { it.isNotEmpty() } ?: DEFAULT_HARNESS, + model = parent.model, + effort = parent.effort, +) + /** * The stale-id rule and the defaults (ticket 92's Normalize table, the * iOS `viewDidLoad` defaults `:177-181` plus the Roboco stale rule), in @@ -312,10 +368,10 @@ fun normalize(d: NewSessionDraft, o: NewSessionOptions): NewSessionDraft { * composition; the fetches outlive it on the app scope. */ @Composable -fun rememberNewSessionModel(prompt: String?): NewSessionModel { +fun rememberNewSessionModel(prompt: String?, side: SideChatParent? = null): NewSessionModel { val app = LocalContext.current.applicationContext as RobocoApplication val pageScope = rememberCoroutineScope() - val model = remember(prompt) { + val model = remember(prompt, side) { val feed = app.workspaceFeed NewSessionModel( prompt = prompt, @@ -330,6 +386,7 @@ fun rememberNewSessionModel(prompt: String?): NewSessionModel { catalog = CoreModelCatalogSource { app.clientOrNull() }, pageScope = pageScope, appScope = app.appScope, + side = side, ) } DisposableEffect(model) { @@ -362,7 +419,9 @@ fun rememberNewSessionModel(prompt: String?): NewSessionModel { @Composable fun NewSessionDraftEffect(model: NewSessionModel, composer: ComposerController) { val drafts = LocalDrafts.current - if (remember { !drafts.isLoaded }) { + val loadPending = remember { !drafts.isLoaded } + // android-next #06: a side chat's composer never takes the new-session text. + if (loadPending && model.side == null) { LaunchedEffect(Unit) { val t = drafts.load(DraftKey.NewSession) if (composer.text.isEmpty() && model.initialText.isEmpty()) composer.text = t diff --git a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionScreen.kt b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionScreen.kt index 039de1bc32..b3ca254ac0 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionScreen.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionScreen.kt @@ -31,6 +31,7 @@ import sh.roboco.android.notify.NotificationPermission import sh.roboco.android.notify.findActivity import sh.roboco.android.shell.DraftHandoff import sh.roboco.android.shell.LocalPageSheetScope +import uniffi.roboco_core.FileMatch /** * The New Session page (android-ui-parity #93) — the port of @@ -87,6 +88,9 @@ internal fun NewSessionScreenBody( // The chip title chain's labels: the core's catalog in the app; tests and // previews pass GalleryLabels to stay FFI-free (FakeCatalog's intent). labels: ChipLabels = CoreChipLabels, + // android-next #06: the side-chat draft's hero line and its own bar. + headline: String = NewSessionHeadline, + navBar: Boolean = true, ) { val composer = rememberComposerController(initialText = ns.initialText) @@ -144,6 +148,9 @@ internal fun NewSessionScreenBody( // Runs once, before the first frame (iOS sets these in `init` and // `viewDidLoad`). bindNewSessionComposer(composer, project = { ns.project }, ns.initialImages, fileSearch) + // android-next #06: a side chat searches its parent's project (iOS + // `sideOf`: the parent's device and space; none when projectless). + ns.side?.let { side -> composer.mentionSearch = sideChatMentionSearch(side, fileSearch) } // 94: the real send handler replaces the `Kept` stub the bind // installs for direct-bind tests. composer.onSend = { text, images, _ -> handler.send(text, images) } @@ -207,6 +214,8 @@ internal fun NewSessionScreenBody( // outside). Chained after the caller's modifier so the recording // covers everything the sheet-sized node draws. modifier = modifier.handoffScene(capture), + headline = headline, + navBar = navBar, ) } @@ -250,6 +259,29 @@ internal fun bindNewSessionComposer( if (composer.onSend == null) composer.onSend = { _, _, _ -> SendResult.Kept } } +/** + * A side chat's `@` search (android-next #06, iOS `mentionSearch` with + * `sideOf`): the parent's project on the parent's device, `[]` for a + * projectless parent; errors read as no matches, cancellation propagates. + */ +internal fun sideChatMentionSearch( + side: SideChatParent, + fileSearch: NewSessionFileSearch, +): suspend (String) -> List<FileMatch> = { q -> + val space = side.projectId + if (space == null) { + emptyList() + } else { + try { + fileSearch.search(side.deviceId, space, q) + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + emptyList() + } + } +} + /** * Focus on appear (`viewDidAppear`, `:215-217`): the sheet waits for the * present motion to settle (Compose has no appear callback tied to the diff --git a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionSend.kt b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionSend.kt index 73530485a9..b1967b773c 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionSend.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/newsession/NewSessionSend.kt @@ -30,6 +30,9 @@ interface NewSessionPage { * `NewSessionViewController.swift:415-418`. */ fun markCreated() + + /** android-next #06: the chat a side-chat draft hangs off; `null` for a new session. */ + val side: SideChatParent? get() = null } /** The live [NewSessionPage] over ticket 92's model. */ @@ -38,6 +41,7 @@ class ModelNewSessionPage(private val m: NewSessionModel) : NewSessionPage { override val projects: List<ProjectOption> get() = m.currentOptions?.projects ?: emptyList() override fun persistBeforeCreate() = m.persistBeforeCreate() override fun markCreated() = m.markCreated() + override val side: SideChatParent? get() = m.side } /** @@ -82,14 +86,16 @@ class NewSessionSendHandler( // persist the options (sticky even on failure, iOS `:409`). val d = page.draft val projects = page.projects + val side = page.side page.persistBeforeCreate() - // 2. create → open → send on IO; null = failure. - val chatId = creator.createAndSend(d, text, images, projects) + // 2. create → open → send on IO; null = failure. A side chat + // (android-next #06) names its parent. + val chatId = creator.createAndSend(d, text, images, projects, side) // 3a. Failure: the alert, and the composer keeps everything. if (chatId == null) { - alerts.show(StartFailedAlert) + alerts.show(if (side != null) SideChatStartFailedAlert else StartFailedAlert) return@withContext SendResult.Kept } @@ -98,8 +104,9 @@ class NewSessionSendHandler( // 4. The one-time notification ask, fire-and-forget: it must // never throw into the sequence (the system dialog may open - // over the handoff, as on iOS — do not delay it). - runCatching { askAfterFirstSession() } + // over the handoff, as on iOS — do not delay it). A side chat + // is never a first session (iOS's side path asks nothing). + if (side == null) runCatching { askAfterFirstSession() } // 5. Capture before the composer clears (54 clears only after // this returns Sent; null when the page left composition). diff --git a/apps/android/app/src/main/java/sh/roboco/android/session/CoreSessionRouteFactory.kt b/apps/android/app/src/main/java/sh/roboco/android/session/CoreSessionRouteFactory.kt index 008815d540..db7152d559 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/session/CoreSessionRouteFactory.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/session/CoreSessionRouteFactory.kt @@ -9,6 +9,8 @@ import sh.roboco.android.composer.QueueActions import sh.roboco.android.core.TextEngine import sh.roboco.android.shell.RouterHooks import sh.roboco.android.shell.SessionRouteFactory +import sh.roboco.android.sidechats.CoreSideChatsSource +import sh.roboco.android.sidechats.SideChatsModel import sh.roboco.android.transcript.TranscriptState import uniffi.roboco_core.TextSystem @@ -98,12 +100,24 @@ class CoreSessionRouteFactory( val queueActions = (core as? CoreSessionCore)?.let { live -> QueueActions(CoreQueuePort(live.handle), queueScope) } + // android-next #06: the chat's side chats and subagents over the + // same handle (its spawn chips) and the workspace children; the + // screen starts it on first composition, the release stops it. + val sideChats = (core as? CoreSessionCore)?.let { live -> + SideChatsModel( + parentChatId = live.chatId, + source = CoreSideChatsSource({ live.client }, live.handle), + hub = hub, + logW = logW, + ) + } return SessionScreenRoute( controller = controller, core = core, state = SessionScreenState(core.chatId, openedAt) { TranscriptState(ts) }, text = ts, queueActions = queueActions, + sideChats = sideChats, ) } } diff --git a/apps/android/app/src/main/java/sh/roboco/android/session/SessionController.kt b/apps/android/app/src/main/java/sh/roboco/android/session/SessionController.kt index a3a635ca74..29a7e20d2b 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/session/SessionController.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/session/SessionController.kt @@ -138,6 +138,25 @@ class SessionController private constructor( var pinned: Boolean = false private set + /** + * The owning engine's reported version as of the last refresh + * (`EngineView.version`), `null` while unknown. android-next #06's ⋯ + * reads it on main to offer the side-chat rows only to engines that + * serve them; a stale read only until the next refresh. + */ + @Volatile + var engineVersion: String? = null + private set + + /** + * The chat's workspace row as of the last refresh (`null` before the + * workspace knows it). android-next #06's New Side Chat seeds its draft + * from it on main, with no FFI call. + */ + @Volatile + var sessionRow: SessionRow? = null + private set + /** * `SessionRow.harness` as of the last refresh — the session agent's * wire harness id. android-next#05's ⋯ menu and usage chip read it @@ -335,6 +354,8 @@ class SessionController private constructor( val engine = core.engine() hostDeviceId = c.host.deviceId pinned = row?.pinned == true + engineVersion = engine?.version + sessionRow = row harness = row?.harness // Ticket 89: the local config beside the row — the menus' // deferred loads re-read it at load time (#664), so it rides diff --git a/apps/android/app/src/main/java/sh/roboco/android/session/SessionHeader.kt b/apps/android/app/src/main/java/sh/roboco/android/session/SessionHeader.kt index 8f0de485d4..42370f9f49 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/session/SessionHeader.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/session/SessionHeader.kt @@ -173,6 +173,9 @@ object SessionHeaderDefaults { * sit below it * @param hazeState the screen's [HazeState] (ticket 07); `null` = no blur * anywhere (the edge effect's scrim-only fallback, glass fallback fills) + * @param accessory a second 44 dp bar item left of the ⋯ (android-next + * #06: the side chats button, iOS `rightBarButtonItems = [menu, …, + * sideChats]`); `null` = none. The title's cap narrows to clear it. */ @Composable fun SessionHeader( @@ -185,6 +188,9 @@ fun SessionHeader( onBack: () -> Unit = LocalNavStack.current.let { stack -> { stack.pop() } }, statusBarTop: Dp = WindowInsets.statusBars.asPaddingValues().calculateTopPadding(), hazeState: HazeState? = LocalHazeState.current, + accessory: (@Composable () -> Unit)? = null, + /** android-next #06: false drops the ⋯ (a side chat's draft has no menu). */ + showMore: Boolean = true, ) { val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current @@ -238,8 +244,11 @@ fun SessionHeader( .fillMaxWidth() .height(SessionHeaderDefaults.BarHeight), ) { - val titleMaxWidth = - SessionHeaderMetrics.titleMaxWidth(maxWidth.value, showBack).dp + val titleMaxWidth = SessionHeaderMetrics.titleMaxWidth( + maxWidth.value, + showBack, + extraTrailing = if (accessory != null) SessionHeaderMetrics.AccessoryExtent else 0f, + ).dp if (showBack) { Box( Modifier @@ -260,7 +269,7 @@ fun SessionHeader( ) } } - Box( + if (showMore) Box( Modifier .align(Alignment.CenterEnd) .padding(end = SessionHeaderDefaults.SideMargin + cutoutEnd), @@ -281,6 +290,20 @@ fun SessionHeader( weight = SymbolWeight.Medium, ) } + if (accessory != null) { + // The second bar item: one 44 dp circle plus UIKit's + // 8 dp item gap left of the ⋯. + Box( + Modifier + .align(Alignment.CenterEnd) + .padding( + end = SessionHeaderDefaults.SideMargin + cutoutEnd + + SessionHeaderDefaults.ButtonSize + SessionHeaderDefaults.TitleItemGap, + ), + ) { + accessory() + } + } SessionTitleView( title = title, subtitle = subtitle, diff --git a/apps/android/app/src/main/java/sh/roboco/android/session/SessionHeaderMetrics.kt b/apps/android/app/src/main/java/sh/roboco/android/session/SessionHeaderMetrics.kt index 13b6c8f85d..29a2e1dbf0 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/session/SessionHeaderMetrics.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/session/SessionHeaderMetrics.kt @@ -33,6 +33,10 @@ object SessionHeaderMetrics { * ⋯ circle always bounds the trailing side. * * Worked values: 402 → 240, 412 → 240, 360 → 224, 320 → 184, 100 → 0. + * + * [extraTrailing] widens the trailing side for a second bar item + * (android-next #06's side chats button: [AccessoryExtent], one more + * circle and the item gap) — 402 → 162, 360 → 120. */ fun titleMaxWidth( width: Float, @@ -41,12 +45,16 @@ object SessionHeaderMetrics { button: Float = SessionHeaderDefaults.ButtonSize.value, gap: Float = SessionHeaderDefaults.TitleItemGap.value, cap: Float = SessionHeaderDefaults.TitleMaxWidth.value, + extraTrailing: Float = 0f, ): Float { val lead = if (showBack) side + button else side - val trail = side + button + val trail = side + button + extraTrailing return minOf(cap, width - 2 * (maxOf(lead, trail) + gap)).coerceAtLeast(0f) } + /** A second trailing bar item's extent: its 44 circle plus the 8 item gap. */ + val AccessoryExtent: Float = SessionHeaderDefaults.ButtonSize.value + SessionHeaderDefaults.TitleItemGap.value + /** * The title view's height: 37.4 with a subtitle (20.8 + 1 + 15.6), the * title line's 20.8 without one. diff --git a/apps/android/app/src/main/java/sh/roboco/android/session/SessionMenu.kt b/apps/android/app/src/main/java/sh/roboco/android/session/SessionMenu.kt index eed829d859..51ee6b2e8f 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/session/SessionMenu.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/session/SessionMenu.kt @@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect @@ -34,6 +36,7 @@ import sh.roboco.android.design.glass.GlassMenuPanel import sh.roboco.android.design.glass.Menu import sh.roboco.android.design.glass.MenuIcon import sh.roboco.android.design.glass.MenuItem +import sh.roboco.android.design.glass.MenuSection import sh.roboco.android.design.glass.flattenLevel import sh.roboco.android.design.glass.rememberGlassMenuState import sh.roboco.android.sessions.ActionOutcome @@ -90,9 +93,10 @@ fun sessionMenu( onCopyTranscript: () -> Unit, onShowAccounts: () -> Unit, onArchive: () -> Unit, + sideChats: SideChatMenuRows? = null, ): Menu = Menu( title = null, - children = listOf( + children = listOfNotNull( MenuItem( title = if (pinned) "Unpin" else "Pin", icon = MenuIcon.Symbol(if (pinned) RobocoIcons.PinSlash else RobocoIcons.Pin), @@ -106,6 +110,7 @@ fun sessionMenu( testTag = "session-menu-copy", action = onCopyTranscript, ), + sideChats?.let { sideChatSection(it, canWrite) }, MenuItem( title = ACCOUNT_USAGE_ROW, icon = MenuIcon.Symbol(RobocoIcons.Gauge), @@ -123,6 +128,39 @@ fun sessionMenu( ), ) +/** + * android-next #06's ⋯ rows (upstream #899's inline group between Copy + * Transcript and Archive): New Side Chat and Fork into Side Chat. `null` + * — an engine without side chats — leaves the menu as it was. [forking] + * disables the fork while one is in flight (one at a time). + */ +class SideChatMenuRows( + val forking: Boolean, + val onNew: () -> Unit, + val onFork: () -> Unit, +) + +/** The inline group (hairlines above and below); both rows need a writable engine. */ +private fun sideChatSection(rows: SideChatMenuRows, canWrite: Boolean): MenuSection = MenuSection( + inline = true, + children = listOf( + MenuItem( + title = "New Side Chat", + icon = MenuIcon.Symbol(RobocoIcons.QuestionmarkBubble), + disabled = !canWrite, + testTag = "session-menu-new-side-chat", + action = rows.onNew, + ), + MenuItem( + title = "Fork into Side Chat", + icon = MenuIcon.Symbol(RobocoIcons.ArrowTriangleBranch), + disabled = !canWrite || rows.forking, + testTag = "session-menu-fork-side-chat", + action = rows.onFork, + ), + ), +) + /** * The [SessionHeader] `onMore` handler (80's step 7): returns a lambda * that builds a fresh [sessionMenu] from the **current** pin state and @@ -148,7 +186,10 @@ fun sessionMenu( fun rememberSessionMenu( controller: SessionController, transcript: TranscriptState, + /** android-next #06: the side-chat rows, read at open time; `null` result = none. */ + sideChats: () -> SideChatMenuRows? = { null }, ): (anchorInWindow: Rect) -> Unit { + val currentSideChats by rememberUpdatedState(sideChats) val menuState = rememberGlassMenuState() val actions = LocalRowActions.current val router = LocalAppRouter.current @@ -200,6 +241,7 @@ fun rememberSessionMenu( } } }, + sideChats = currentSideChats(), ) menuState.open(anchor, menu) } diff --git a/apps/android/app/src/main/java/sh/roboco/android/session/SessionScreen.kt b/apps/android/app/src/main/java/sh/roboco/android/session/SessionScreen.kt index 0e5bd4bd48..a01baef3d3 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/session/SessionScreen.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/session/SessionScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.ImageBitmap @@ -118,6 +119,13 @@ import sh.roboco.android.design.LocalToaster * (`DraftKey.Chat(engineId, chatId)`). The route passes `core.engineId`; * `null` — a fake core in tests — leaves the drafts unwired: no saver, * no seed text, exactly the pre-84 behaviour + * @param onOpenSubagent android-next #06: a subagent spawn card was tapped + * (the doc id, the spawn's title, whether it had finished). `null` — + * tests, previews — leaves the cards inert + * @param headerAccessory android-next #06: the side chats button, the + * header's second bar item left of the ⋯; `null` = none + * @param overlay android-next #06: composed last, over the whole screen — + * the Side Chats & Agents sheet */ @Composable fun SessionScreen( @@ -131,6 +139,9 @@ fun SessionScreen( queueEditPort: QueueEditPort? = null, queueActions: QueueActions? = null, engineId: String? = null, + onOpenSubagent: ((docId: String, title: String, frozen: Boolean) -> Unit)? = null, + headerAccessory: (@Composable () -> Unit)? = null, + overlay: @Composable () -> Unit = {}, ) { // 1. The per-screen state: the same instance the route holds. The // first main-thread read creates the transcript (76's factory @@ -251,6 +262,12 @@ fun SessionScreen( ) }, ) + // android-next #06: a spawn card opens its subagent through the route's + // opener, read at tap time (the actions are remembered once). + val currentOpenSubagent by rememberUpdatedState(onOpenSubagent) + val openSubagent: (String, String, Boolean) -> Unit = remember { + { docId: String, title: String, frozen: Boolean -> currentOpenSubagent?.invoke(docId, title, frozen) } + } val actions = remember(controller, attachments, viewer, router) { val openImage: (String, Rect, ImageBitmap?, String?) -> Unit = { reference, source, bitmap, label -> viewer?.show( @@ -276,9 +293,10 @@ fun SessionScreen( openImage = openImage, loadImage = binding::load, cachedImage = binding::cached, + openSubagent = openSubagent, ) } else { - TranscriptActions(openLink = router::open) + TranscriptActions(openLink = router::open, openSubagent = openSubagent) } } @@ -455,6 +473,9 @@ fun SessionScreen( // IME animation frame and the runway re-sizes in the same pass // (69). `covered` (not `contentInset`) is the effective viewport // bottom inset — iOS's `adjustedContentInset.bottom`. + // android-next #06: the [overlay] (the Side Chats & Agents sheet) is + // the root's last child, over the header and the bottom stack. + Box(Modifier.fillMaxSize()) { SessionScaffold( reveal = state.reveal, header = { @@ -464,6 +485,7 @@ fun SessionScreen( edgeEngaged = edgeEngaged, onMore = onMore, statusBarTop = statusBarTop, + accessory = headerAccessory, ) }, modifier = modifier, @@ -575,4 +597,6 @@ fun SessionScreen( modifier = Modifier.fillMaxSize(), ) } + overlay() + } } diff --git a/apps/android/app/src/main/java/sh/roboco/android/session/SessionScreenRoute.kt b/apps/android/app/src/main/java/sh/roboco/android/session/SessionScreenRoute.kt index 28d220b2f9..264ee1301d 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/session/SessionScreenRoute.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/session/SessionScreenRoute.kt @@ -2,7 +2,12 @@ package sh.roboco.android.session import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import sh.roboco.android.RobocoApplication import sh.roboco.android.composer.CoreQueueEditPort import sh.roboco.android.composer.CoreQuestionPort import sh.roboco.android.composer.DraftKey @@ -14,6 +19,19 @@ import sh.roboco.android.composer.QueueEditPort import sh.roboco.android.accounts.SessionAccountUsage import sh.roboco.android.composer.SessionChipMenus import sh.roboco.android.composer.sessionDraftSaver +import sh.roboco.android.design.LocalToaster +import sh.roboco.android.newsession.SideChatParent +import sh.roboco.android.sessions.rememberSessionListOpener +import sh.roboco.android.sidechats.SideChatDraftRoute +import sh.roboco.android.sidechats.SideChatFork +import sh.roboco.android.sidechats.SideChatsButton +import sh.roboco.android.sidechats.SideChatsModel +import sh.roboco.android.sidechats.SideChatsOverlay +import sh.roboco.android.sidechats.SideChatsSheetController +import sh.roboco.android.sidechats.SideChatsUi +import sh.roboco.android.sidechats.SubagentOpenRequest +import sh.roboco.android.sidechats.rememberSubagentOpener +import sh.roboco.android.sidechats.sideChatsSupported import sh.roboco.android.shell.LocalNavEntry import sh.roboco.android.shell.LocalNavStack import sh.roboco.android.shell.NavRoute @@ -58,6 +76,13 @@ class SessionScreenRoute internal constructor( * routes Edit to 83's QueueEditController and everything else here. */ val queueActions: QueueActions?, + /** + * android-next #06: this chat's side chats and subagents (the header + * button, the sheet, the ⋯ rows), built by [CoreSessionRouteFactory] + * over [core]'s handle; started on first composition, stopped on + * release or discard. Null for a test fake core. + */ + val sideChats: SideChatsModel? = null, ) : SessionRoute, NavRoute { /** @@ -129,6 +154,7 @@ class SessionScreenRoute internal constructor( // in-flight writes still finish — never cancelled). android-next // #05: same rule for the usage chip's loads. controller.filler<SessionChipMenus>()?.dispose() + sideChats?.stop() controller.filler<SessionAccountUsage>()?.dispose() val lifecycle = navLifecycle if (lifecycle != null) { @@ -228,6 +254,87 @@ class SessionScreenRoute internal constructor( entry.retain(QuestionsKey) { CoreQuestionPort(live.handle) } } } + // android-next #06: side chats and subagents — the header button + // and its sheet, the ⋯ rows, the spawn-card opener. The sheet state + // and the fork in flight live on the entry (a tab switch keeps them). + val sideChatsModel = sideChats + remember(entry) { + sideChatsModel?.start() + true + } + val sideUi = sideChatsModel?.ui?.collectAsState()?.value ?: SideChatsUi() + val sheet = remember(entry) { entry.retain(SideChatsSheetKey) { SideChatsSheetController() } } + val subagentOpener = rememberSubagentOpener() + val openChat = rememberSessionListOpener() + val app = LocalContext.current.applicationContext as? RobocoApplication + val toaster = LocalToaster.current + val fork: SideChatFork? = remember(entry) { + val live = core as? CoreSessionCore + if (live == null || app == null) { + null + } else { + entry.retain(SideChatForkKey) { + SideChatFork( + fork = { source -> withContext(Dispatchers.IO) { live.client.forkSideChat(source) } }, + scope = app.appScope, + toaster = toaster, + ) + } + } + } + // Read at tap time: an engine that predates side chats offers none. + val sideChatsOffered: () -> Boolean = { + sideChatsModel != null && fork != null && sideChatsSupported(controller.engineVersion) + } + val isTopAndIdle: () -> Boolean = { stack.top === entry && stack.transition == null } + val newSideChat: () -> Unit = { + // iOS `newSideChat()`: the draft pushed on top, seeded from the + // row the last refresh cached (no FFI on main). + val row = controller.sessionRow + if (row != null && isTopAndIdle()) stack.push(SideChatDraftRoute(SideChatParent.of(row)), animated = true) + } + val forkSideChat: () -> Unit = { + fork?.start(controller.chatId) { forkId -> + sideChatsModel?.invalidate() + // iOS opens the fork only while this screen is still shown. + if (isTopAndIdle()) openChat(forkId) + } + } + val openSubagent: (String, String, Boolean) -> Unit = { docId, title, frozen -> + subagentOpener?.open( + SubagentOpenRequest( + docId = docId, + title = title, + frozen = frozen, + spawnerId = controller.chatId, + parentTitle = controller.chrome.value.title, + ), + ) + } + val badge = sideUi.badge + val sideChatsButton: (@Composable () -> Unit)? = if (badge != null) { + { SideChatsButton(badge, onClick = { sheet.open() }) } + } else { + null + } + val sideChatsOverlay: @Composable () -> Unit = { + SideChatsOverlay( + controller = sheet, + ui = sideUi, + canAdd = sideChatsOffered(), + onOpenChat = openChat, + onOpenAgent = { agent -> openSubagent(agent.docId, agent.title, !agent.running) }, + onNew = newSideChat, + onFork = forkSideChat, + ) + } + val onMore = rememberSessionMenu(controller, transcript) { + if (sideChatsOffered()) { + SideChatMenuRows(forking = fork?.forking == true, onNew = newSideChat, onFork = forkSideChat) + } else { + null + } + } // Ticket 85: the push/pop choreography (the stack and jump // multipliers) and the will/did lifecycle wiring around the // screen. The alpha travels through [LocalSessionNavAlpha], so @@ -247,11 +354,14 @@ class SessionScreenRoute internal constructor( questionPort = questionPort, // Ticket 80: the ⋯ menu — the header's onMore opens the session // menu built fresh on every tap (pin state and canWrite read - // at open time). - onMore = rememberSessionMenu(controller, transcript), + // at open time); android-next #06 adds the side-chat rows. + onMore = onMore, // 84: the engine half of the draft key (the route's core has // it; SessionScreen pairs it with the controller's chatId). engineId = core.engineId, + onOpenSubagent = openSubagent, + headerAccessory = sideChatsButton, + overlay = sideChatsOverlay, ) } } @@ -279,6 +389,7 @@ class SessionScreenRoute internal constructor( override fun discard() { // Ticket 89: same rule as release — later loads resolve empty. controller.filler<SessionChipMenus>()?.dispose() + sideChats?.stop() controller.filler<SessionAccountUsage>()?.dispose() controller.dispose() if (state.transcriptCreated) state.transcript.close() @@ -294,6 +405,12 @@ class SessionScreenRoute internal constructor( /** 83's queue-edit-port retention key. */ private object QueueEditPortKey + + /** android-next #06's sheet-state retention key. */ + private object SideChatsSheetKey + + /** android-next #06's fork retention key. */ + private object SideChatForkKey /** 91's question-port retention key. */ private object QuestionsKey } diff --git a/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRow.kt b/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRow.kt index b038441c84..e6259ef610 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRow.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRow.kt @@ -1,12 +1,16 @@ package sh.roboco.android.sessions +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Size @@ -17,6 +21,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Constraints @@ -89,10 +95,19 @@ fun SessionRow( val glyph = c?.glyph(p) val shape = rememberSmoothRoundedShape(SessionRowTokens.CornerRadius) val hl by rememberUpdatedState(highlight) + // android-next #06: the "● N" pill — its text measured here (one + // measure per child per pass), so the frames know its width. + val agentsText = if (vm.runningSubagents > 0) "● ${vm.runningSubagents}" else null + val agentsStyle = uiStyle(Face.SansMedium, ts.scaled(11.5f), p.status.working) + val agentsMeasurer = rememberTextMeasurer() + val agentsTextWidthPx = remember(agentsText, agentsStyle, density) { + agentsText?.let { agentsMeasurer.measure(it, agentsStyle, maxLines = 1, softWrap = false).size.width } ?: 0 + } - // The content has exactly five children, always in this order, so the - // measure policy's indices are stable. The glyph stays an empty Box - // when there is nothing to draw, and the meta slot is always present. + // The content has exactly six children, always in this order, so the + // measure policy's indices are stable. The glyph and the pill stay an + // empty Box when there is nothing to draw, and the meta slot is always + // present. Layout( content = { HarnessMark(vm.harness ?: "claude-code", SessionRowGeometry.MARK_SIDE.dp, p.text) @@ -106,6 +121,7 @@ fun SessionRow( ) if (glyph != null) StatusGlyph(glyph) else Box(Modifier) Box { meta() } + if (agentsText != null) RunningAgentsPill(agentsText, agentsStyle) else Box(Modifier) }, modifier = modifier .fillMaxWidth() @@ -155,7 +171,14 @@ fun SessionRow( // measurable may only be measured once per pass, so the placeable // is right-aligned inside the ceil'd frame at placement instead. val wordP = measurables[2].measure(Constraints(maxWidth = SessionRowGeometry.WORD_MAX_WIDTH.px())) - val f = SessionRowGeometry.frames(W, ts.factor, titleFace, wordP.width / d, glyph != null) + val f = SessionRowGeometry.frames( + W, + ts.factor, + titleFace, + wordP.width / d, + glyph != null, + agentsTextWidth = agentsTextWidthPx / d, + ) val markP = measurables[0].measure(Constraints.fixed(f.mark.w.px(), f.mark.h.px())) val titleP = measurables[1].measure(Constraints.fixed(f.title.w.px(), f.title.h.px())) @@ -163,6 +186,10 @@ fun SessionRow( if (f.glyph != null) Constraints.fixed(f.glyph.w.px(), f.glyph.h.px()) else Constraints.fixed(0, 0), ) val metaP = measurables[4].measure(Constraints.fixed(f.metaSlot.w.px(), f.metaSlot.h.px())) + val agents = f.agents + val agentsP = measurables[5].measure( + if (agents != null) Constraints.fixed(agents.w.px(), agents.h.px()) else Constraints.fixed(0, 0), + ) layout(constraints.maxWidth, f.height.px()) { // Absolute place(), never placeRelative(): the iOS hand layout @@ -181,6 +208,23 @@ fun SessionRow( glyphP.place(0, 0) } metaP.place(f.metaSlot.x.px(), f.metaSlot.y.px()) + if (agents != null) agentsP.place(agents.x.px(), agents.y.px()) else agentsP.place(0, 0) } } } + +/** + * The "● N" running-agents pill (android-next #06, upstream #899's + * `SessionCell.agents`): the count in the working tone, centred on a + * `controlFill` capsule the row's measure sizes (`ceil(text) + 12` × + * `R(18k)`). Decorative — the row's label speaks the count. + */ +@Composable +private fun RunningAgentsPill(text: String, style: TextStyle) { + Box( + Modifier.background(RobocoTheme.palette.controlFill, RoundedCornerShape(50)), + contentAlignment = Alignment.Center, + ) { + BasicText(text, style = style.copy(textAlign = TextAlign.Center), maxLines = 1, softWrap = false) + } +} diff --git a/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRowGeometry.kt b/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRowGeometry.kt index 6bbc2c84af..39033bf1cb 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRowGeometry.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRowGeometry.kt @@ -34,6 +34,8 @@ data class SessionRowFrames( val metaSlot: RectDp, /** The background rect: the row bounds inset 8 left/right, 1 top/bottom. */ val highlight: RectDp, + /** android-next #06: the "● N" running-agents pill; null when none shows. */ + val agents: RectDp? = null, ) /** @@ -77,12 +79,33 @@ object SessionRowGeometry { /** `sizeThatFits(CGSize(width: 140, height: 40))`'s cap (a safety bound). */ const val WORD_MAX_WIDTH = 140f + /** android-next #06's pill: `(18k).rounded()` high (upstream #899's `SessionCell`). */ + const val AGENTS_HEIGHT = 18f + + /** The pill's text padding: `sizeThatFits(…).width + 12`. */ + const val AGENTS_PADDING = 12f + + /** The pill's overlap into the title gap (`trailing + 4 − w`). */ + const val AGENTS_OVERLAP = 4f + + /** The title's gap before the pill (`trailing = agents.minX − 8`). */ + const val AGENTS_GAP = 8f + + /** + * @param agentsTextWidth android-next #06: the "● N" text's single-line + * width (dp, unrounded); 0 = no pill. The pill leads the status glyph + * and word, never replaces them (upstream #899's `SessionCell`): it is + * `ceil(text) + 12` wide and `R(18k)` high, centred on the title line, + * its right edge 4 dp into the title's 10 dp gap, and the title ends + * 8 dp before it. + */ fun frames( width: Float, factor: Float, titleFace: Face, wordWidth: Float, hasGlyph: Boolean, + agentsTextWidth: Float = 0f, ): SessionRowFrames { val r = ::roundHalfAway val titleY = r(10f * factor) @@ -108,7 +131,15 @@ object SessionRowGeometry { } else { null } - val trailing = (glyph?.x ?: wordX) - TITLE_GAP + var trailing = (glyph?.x ?: wordX) - TITLE_GAP + val agents = if (agentsTextWidth > 0f) { + val h = r(AGENTS_HEIGHT * factor) + val w = ceil(agentsTextWidth) + AGENTS_PADDING + RectDp(trailing + AGENTS_OVERLAP - w, titleY + (titleH - h) / 2f, w, h) + } else { + null + } + if (agents != null) trailing = agents.x - AGENTS_GAP return SessionRowFrames( height = height, mark = RectDp(MARGIN, markY, MARK_SIDE, MARK_SIDE), @@ -117,6 +148,7 @@ object SessionRowGeometry { glyph = glyph, metaSlot = RectDp(TEXT_X, metaY, max(0f, width - MARGIN - TEXT_X), metaH), highlight = RectDp(8f, 1f, max(0f, width - 16f), max(0f, height - 2f)), + agents = agents, ) } } diff --git a/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRowVM.kt b/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRowVM.kt index 280a75dcbf..bfb614f98f 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRowVM.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/sessions/SessionRowVM.kt @@ -39,6 +39,12 @@ data class SessionRowVM( val unseen: Boolean, val pinned: Boolean, val sendFailed: Boolean, + /** + * android-next #06 (upstream #899's `runningSubagents`): agents running + * under this session right now — its open transcript's running + * subagents plus its working side chats. The "● N" pill. + */ + val runningSubagents: Int = 0, ) { /** * `SessionRowVM.Status` (`SessionCell.swift:4-6`). Not the FFI enum: @@ -74,6 +80,7 @@ data class SessionRowVM( unseen = r.unseen, pinned = r.pinned, sendFailed = r.sendState == SendState.FAILED, + runningSubagents = r.runningSubagents.toInt(), ) } } @@ -101,8 +108,9 @@ object HomeColorIndex { /** * The row's accessibility label — `SessionCell.swift:152`: title, project - * name, the corner word (never the time label), and the PR badge's label - * only when both `pr` and `prNumber` are non-nil. + * name, the corner word (never the time label), the running agents + * ("2 subagents running", android-next #06), and the PR badge's label only + * when both `pr` and `prNumber` are non-nil. */ fun sessionRowA11yLabel(vm: SessionRowVM): String { val pr = vm.pr @@ -111,6 +119,14 @@ fun sessionRowA11yLabel(vm: SessionRowVM): String { vm.title, vm.projectName, corner(vm)?.word, + runningAgentsLabel(vm.runningSubagents), if (pr != null && number != null) prBadgeA11yLabel(number, pr) else null, ).joinToString(", ") } + +/** The "● N" pill's spoken label (upstream #899's `SessionCell`); `null` at 0. */ +fun runningAgentsLabel(running: Int): String? = when { + running <= 0 -> null + running == 1 -> "1 subagent running" + else -> "$running subagents running" +} diff --git a/apps/android/app/src/main/java/sh/roboco/android/shell/ShellRoutes.kt b/apps/android/app/src/main/java/sh/roboco/android/shell/ShellRoutes.kt index 6cf7c89ab2..d66b26cc17 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/shell/ShellRoutes.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/shell/ShellRoutes.kt @@ -17,6 +17,8 @@ import sh.roboco.android.sessions.FolderRoute import sh.roboco.android.sessions.FolderRouteContent import sh.roboco.android.sessions.SearchScreen import sh.roboco.android.sessions.SessionsScreen +import sh.roboco.android.sidechats.SideChatDraftRoute +import sh.roboco.android.sidechats.SubagentRoute /** * A tab's root route (android-ui-parity #26): the first entry of the @@ -79,6 +81,12 @@ fun ShellRouteContent(route: NavRoute) { // net + 75's SessionScreen). Ticket 106's detail column falls // through to this same table. is SessionScreenRoute -> route.Content() + // android-next #06: a subagent's read-only transcript, pushed from + // a spawn card or the Side Chats & Agents sheet. + is SubagentRoute -> route.Content() + // android-next #06: a new side chat's draft, pushed from the ⋯ or + // the sheet's +; its first send swaps the chat in for it. + is SideChatDraftRoute -> route.Content() // Ticket 140: the Engine detail page — the status card, the // actions card (Rename…, Pair again) and the Forget confirm the // Devices row's push lands on. 104's tablet Settings dialog routes diff --git a/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatDraft.kt b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatDraft.kt new file mode 100644 index 0000000000..1678ff44dc --- /dev/null +++ b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatDraft.kt @@ -0,0 +1,103 @@ +package sh.roboco.android.sidechats + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import kotlinx.coroutines.launch +import sh.roboco.android.design.LocalToaster +import sh.roboco.android.newsession.NewSessionScreenBody +import sh.roboco.android.newsession.SideChatHeadline +import sh.roboco.android.newsession.SideChatParent +import sh.roboco.android.newsession.rememberCoreFileSearch +import sh.roboco.android.newsession.rememberNewSessionModel +import sh.roboco.android.session.SessionHeader +import sh.roboco.android.shell.LocalNavEntry +import sh.roboco.android.shell.LocalNavStack +import sh.roboco.android.shell.LocalRouterHooks +import sh.roboco.android.shell.NavRoute +import sh.roboco.android.shell.OPEN_ERROR_TOAST +import sh.roboco.android.shell.openSessionRoute + +/** The draft's bar title and subtitle (upstream #899's `sideOf` title view). */ +object SideChatDraftCopy { + const val Title = "New Side Chat" + + fun subtitle(parentTitle: String): String = + if (parentTitle.isBlank()) "Side chat" else "Side chat · $parentTitle" +} + +/** + * A new, empty side chat (android-next #06, upstream #899's + * `NewSessionViewController(sideOf:)`): the New Session canvas pushed over + * the session, starting on the parent's model. It runs in the parent's + * checkout on the parent's host, so only the model and effort chips show + * (any harness, until the first send mints the row). Nothing is written + * until that send — leaving first leaves nothing; a draft opened from a + * side chat makes a sibling (the core hangs it off the same root). + * + * The first send creates the chat (`createChat` with `parentChatId`), + * opens it, and swaps it in for this draft, in place (iOS `arrive(at:)`). + * + * Identity equality on purpose (a plain class): `NavStack.setRoutes` + * matches routes by equality, and two drafts are two screens. + */ +class SideChatDraftRoute(val parent: SideChatParent) : NavRoute { + override val hidesBottomChrome: Boolean get() = true + + override val paneTitle: String get() = SideChatDraftCopy.Title + + @Composable + fun Content() { + SideChatDraftScreen(this) + } +} + +@Composable +private fun SideChatDraftScreen(route: SideChatDraftRoute) { + val nav = LocalNavStack.current + val entry = LocalNavEntry.current + val hooks = LocalRouterHooks.current + val toaster = LocalToaster.current + val scope = rememberCoroutineScope() + val model = rememberNewSessionModel(prompt = null, side = route.parent) + Box(Modifier.fillMaxSize()) { + NewSessionScreenBody( + ns = model, + embedded = true, + onCreated = { chatId, _ -> + // iOS `arrive(at:)`: open the chat, then swap it in for the + // draft, in place (no push animation; the draft's own + // composer already cleared). A draft the user left + // meanwhile opens nothing — the side chat lists under its + // parent. + scope.launch { + val session = hooks.openSessionRoute(chatId) + when { + session == null -> { + toaster.show(OPEN_ERROR_TOAST) + if (nav.top === entry && nav.transition == null) nav.pop(animated = true) + } + entry !in nav.entries -> session.discard() + else -> nav.setRoutes( + nav.entries.map { if (it === entry) session as NavRoute else it.route }, + ) + } + } + }, + onClose = {}, + focusOnAppear = true, + fileSearch = rememberCoreFileSearch(), + headline = SideChatHeadline, + navBar = false, + ) + SessionHeader( + title = SideChatDraftCopy.Title, + subtitle = SideChatDraftCopy.subtitle(route.parent.title), + edgeEngaged = false, + onMore = {}, + showMore = false, + ) + } +} diff --git a/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatFork.kt b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatFork.kt new file mode 100644 index 0000000000..01e9b94884 --- /dev/null +++ b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatFork.kt @@ -0,0 +1,77 @@ +package sh.roboco.android.sidechats + +import android.util.Log +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import sh.roboco.android.design.Toaster +import uniffi.roboco_core.CoreException + +/** The fork's failure copy, verbatim from upstream #899's `AppModel.forkSideChat`. */ +object SideChatForkCopy { + const val Failed = "Couldn't fork this session." + const val Unsupported = "This engine doesn't support side chats. Update it to fork." + + fun unreachable(reason: String): String = "The host is unreachable ($reason)." +} + +/** + * The toast for a failed fork (iOS: the host's reason for a `HostError` — + * "nothing to fork yet" when no reply has completed —, "The host is + * unreachable (…)" offline, else the generic line). Pure. + */ +fun forkErrorText(error: Throwable): String = when (error) { + is CoreException.HostException -> error.reason.ifBlank { SideChatForkCopy.Failed } + is CoreException.HostUnavailable -> SideChatForkCopy.unreachable(error.reason) + is CoreException.Unsupported -> SideChatForkCopy.Unsupported + else -> SideChatForkCopy.Failed +} + +/** + * Fork into Side Chat (android-next #06, upstream #899's `forkSideChat`): + * the engine copies the session's history through its last completed + * reply into a side chat on its host (`ForkSideChat`; from a side chat, a + * sibling), and [onForked] opens it. One fork at a time, like the + * desktop's; [forking] is snapshot state for the ⋯ row's disabled flag. + * A failure toasts [forkErrorText]. + * + * [fork] is `CoreClient.forkSideChat` (a seam: the generated client is a + * JNA object tests cannot build); it runs on [scope], which must outlive + * the screen (the app scope), so a fork tapped just before Back still + * lands — [onForked] then decides whether to open it. + */ +@Stable +class SideChatFork( + private val fork: suspend (sourceId: String) -> String, + private val scope: CoroutineScope, + private val toaster: Toaster, + private val logW: (String, Throwable?) -> Unit = { message, error -> Log.w("RobocoSideChats", message, error) }, +) { + var forking: Boolean by mutableStateOf(false) + private set + + /** Main thread. A second call while one runs is ignored. */ + fun start(sourceId: String, onForked: (forkId: String) -> Unit) { + if (forking) return + forking = true + scope.launch { + val forkId = try { + fork(sourceId) + } catch (cancelled: CancellationException) { + forking = false + throw cancelled + } catch (t: Throwable) { + logW("fork side chat failed", t) + forking = false + toaster.show(forkErrorText(t)) + return@launch + } + forking = false + onForked(forkId) + } + } +} diff --git a/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsButton.kt b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsButton.kt new file mode 100644 index 0000000000..94f53f2482 --- /dev/null +++ b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsButton.kt @@ -0,0 +1,72 @@ +package sh.roboco.android.sidechats + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import sh.roboco.android.core.Face +import sh.roboco.android.design.LocalTypeScale +import sh.roboco.android.design.RobocoIcons +import sh.roboco.android.design.RobocoTheme +import sh.roboco.android.design.SymbolWeight +import sh.roboco.android.design.glass.GlassCircleButton +import sh.roboco.android.design.uiStyle +import sh.roboco.android.session.SessionHeaderDefaults + +/** "Side chats and agents" (iOS `accessibilityLabel`), plus "N running" while anything runs. */ +fun sideChatsButtonLabel(badge: SideChatsBadge): String = + listOfNotNull("Side chats and agents", badge.a11yValue).joinToString(", ") + +/** + * The session header's side chats item (android-next #06, upstream #899's + * `bubble.left.and.bubble.right` bar button): a 44 dp glass circle with the + * two-bubble glyph and a badge on its top-trailing corner — `● N` on the + * accent with white ink while subagents run, else a quiet count of + * everything hanging off the chat on `secondary` with background ink (the + * default red reads as unread). The session shows it only once the chat + * has something ([SideChatsUi.badge] non-null). + */ +@Composable +fun SideChatsButton(badge: SideChatsBadge, onClick: () -> Unit, modifier: Modifier = Modifier) { + val palette = RobocoTheme.palette + val fill = if (badge.accent) palette.accent else palette.secondary + val ink = if (badge.accent) Color.White else palette.background + Box(modifier) { + GlassCircleButton( + icon = RobocoIcons.TabChat, + contentDescription = sideChatsButtonLabel(badge), + onClick = onClick, + modifier = Modifier.testTag("side-chats"), + size = SessionHeaderDefaults.ButtonSize, + iconPt = SessionHeaderDefaults.IconPt, + weight = SymbolWeight.Medium, + ) + // The badge: decorative (the button's label carries the count), so + // it never takes a touch or a TalkBack stop of its own. + BasicText( + badge.text, + Modifier + .align(Alignment.TopEnd) + .offset(x = 4.dp, y = (-2).dp) + .background(fill, RoundedCornerShape(50)) + .defaultMinSize(minWidth = 16.dp, minHeight = 16.dp) + .padding(horizontal = 4.5.dp, vertical = 1.dp) + .testTag("side-chats-badge") + .clearAndSetSemantics { }, + style = uiStyle(Face.SansSemibold, LocalTypeScale.current.fixed(11f), ink).copy(textAlign = TextAlign.Center), + maxLines = 1, + softWrap = false, + ) + } +} diff --git a/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsModel.kt b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsModel.kt new file mode 100644 index 0000000000..310d141ab6 --- /dev/null +++ b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsModel.kt @@ -0,0 +1,226 @@ +package sh.roboco.android.sidechats + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sh.roboco.android.session.SessionEventHub +import sh.roboco.android.sessions.SessionRowVM +import sh.roboco.android.update.SemVer +import uniffi.roboco_core.CoreClient +import uniffi.roboco_core.SessionHandle +import uniffi.roboco_core.SubagentInfo +import uniffi.roboco_core.SubagentState + +/** Where a subagent stands — its spawn chip's lifecycle, never `resolved` alone. */ +enum class AgentState { Running, Done, Failed } + +/** + * One subagent of an open chat, as the sheet lists it (the port of + * upstream #899's `SubagentInfo`). A plain mirror of the FFI record so + * the sheet and its tests never touch the generated types. + */ +@Immutable +data class SideChatsAgent( + /** Its transcript doc (`{chatId}--sub--{id}`, scoped): the viewer's id. */ + val docId: String, + /** What the spawn asked for. */ + val title: String, + val state: AgentState, + /** One-line live tail of its output, while it runs. */ + val tail: String? = null, + /** The model the spawn picked, when it named one. */ + val model: String? = null, + /** When the spawning reply was written (epoch ms). */ + val spawnedAtMs: Long = 0L, +) { + val running: Boolean get() = state == AgentState.Running + + companion object { + fun from(info: SubagentInfo): SideChatsAgent = SideChatsAgent( + docId = info.docId, + title = info.title, + state = agentState(info.state), + tail = info.tail, + model = info.model, + spawnedAtMs = info.spawnedAtMs, + ) + } +} + +/** The FFI lifecycle → [AgentState]. Exhaustive: a new case fails to compile. */ +fun agentState(state: SubagentState): AgentState = when (state) { + SubagentState.RUNNING -> AgentState.Running + SubagentState.DONE -> AgentState.Done + SubagentState.FAILED -> AgentState.Failed +} + +/** + * The header button's badge (upstream #899's `syncBar`): `● N` on the + * accent while subagents run, else a quiet count of everything hanging + * off the chat (the default red would read as unread). + */ +@Immutable +data class SideChatsBadge(val running: Int, val total: Int) { + val text: String get() = if (running > 0) "● $running" else total.toString() + val accent: Boolean get() = running > 0 + + /** iOS `accessibilityValue`: "N running" while anything runs. */ + val a11yValue: String? get() = if (running > 0) "$running running" else null +} + +/** Everything the sheet and the header button render for one chat. */ +@Immutable +data class SideChatsUi( + /** Running subagents, newest spawn first. */ + val running: List<SideChatsAgent> = emptyList(), + /** Side chats (and agent-spawned chats) hanging off the chat, unarchived, recency order. */ + val chats: List<SessionRowVM> = emptyList(), + /** Finished (done or failed) subagents, newest spawn first. */ + val finished: List<SideChatsAgent> = emptyList(), +) { + val total: Int get() = running.size + chats.size + finished.size + + /** `null` — the button hides — while nothing hangs off the chat (iOS `total > 0`). */ + val badge: SideChatsBadge? get() = if (total > 0) SideChatsBadge(running.size, total) else null + + companion object { + /** + * The sections from one pull: the spawn chips split by lifecycle, + * each newest first (the chips arrive in spawn order), and the + * children as the workspace orders them. Pure. + */ + fun of(agents: List<SideChatsAgent>, children: List<SessionRowVM>): SideChatsUi = SideChatsUi( + running = agents.filter { it.running }.asReversed(), + chats = children, + finished = agents.filter { !it.running }.asReversed(), + ) + } +} + +/** + * The pull seam behind [SideChatsModel]: the generated `CoreClient` and + * `SessionHandle` are JNA objects the JVM cannot construct, so tests fake + * this. Blocking FFI — the model calls it on its IO dispatcher only. + */ +interface SideChatsSource { + /** The subagents the chat's open transcript spawned, in spawn order. */ + fun agents(): List<SideChatsAgent> + + /** + * `CoreClient.childSessions(parentId)` — the side chats hanging off + * [parentChatId], archived ones left out, as session-row view models. + */ + fun children(parentChatId: String): List<SessionRowVM> +} + +/** + * The live [SideChatsSource]: the spawn chips of the route's own open + * handle (the chip IS the index — no listing RPC) and the workspace's + * children of the chat. + */ +class CoreSideChatsSource( + private val client: () -> CoreClient?, + private val handle: SessionHandle, +) : SideChatsSource { + override fun agents(): List<SideChatsAgent> = handle.subagents().map { SideChatsAgent.from(it) } + + override fun children(parentChatId: String): List<SessionRowVM> = + client()?.childSessions(parentChatId).orEmpty() + .filter { !it.archived } + .map { SessionRowVM.from(it) } +} + +/** + * One chat's side chats and subagents (android-next #06): the header + * button's badge and the sheet's rows, as [ui]. + * + * The refresh shape is the session controller's (the #609/#621 rules): + * [SessionEventHub] events only `trySend` a conflated token (a + * `SessionChanged` for the chat moves the spawn chips, a + * `WorkspaceChanged` the children — both reach the same refresh), the + * pulls run on [io], and [ui] is written from [scope]'s dispatcher (main + * in production). A failed pull keeps the previous rows. + * + * [start] once when the screen first composes; [stop] on the route's + * release (idempotent; a stopped model never restarts). + */ +@Stable +class SideChatsModel( + private val parentChatId: String, + private val source: SideChatsSource, + private val hub: SessionEventHub, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + private val io: CoroutineDispatcher = Dispatchers.IO, + private val logW: (String, Throwable?) -> Unit = { _, _ -> }, +) { + private val trigger = Channel<Unit>(Channel.CONFLATED) + private val _ui = MutableStateFlow(SideChatsUi()) + + /** The current sections; empty (badge hidden) until the first pull lands. */ + val ui: StateFlow<SideChatsUi> = _ui.asStateFlow() + + private var started = false + private var stopped = false + private var unregister: (() -> Unit)? = null + + /** Register on the hub and pull once. Main thread; idempotent. */ + fun start() { + if (started || stopped) return + started = true + unregister = hub.register(parentChatId) { trigger.trySend(Unit) } + trigger.trySend(Unit) + scope.launch { + for (token in trigger) refresh() + } + } + + /** Another pull, coalesced with any pending one (e.g. right after a fork). */ + fun invalidate() { + if (!stopped) trigger.trySend(Unit) + } + + /** Unregister and stop pulling. Main thread; idempotent. */ + fun stop() { + if (stopped) return + stopped = true + unregister?.invoke() + unregister = null + trigger.close() + scope.cancel() + } + + private suspend fun refresh() { + val next = try { + withContext(io) { SideChatsUi.of(source.agents(), source.children(parentChatId)) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (t: Throwable) { + logW("side chats refresh failed", t) + return + } + _ui.value = next + } +} + +/** + * Whether an engine serves side chats (`ForkSideChat`, `createChat` with + * `parentChatId`), from its reported version: they shipped in Roboco + * 0.5.0. An unknown or unparsable version reads as supported (a fork on + * an engine without the RPC then fails with the engine's own reason). + * Pure. + */ +fun sideChatsSupported(engineVersion: String?): Boolean { + val v = SemVer.parse(engineVersion) ?: return true + return v.major > 0L || v.minor >= 5L +} diff --git a/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsSheet.kt b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsSheet.kt new file mode 100644 index 0000000000..f2e789c158 --- /dev/null +++ b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SideChatsSheet.kt @@ -0,0 +1,494 @@ +package sh.roboco.android.sidechats + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import sh.roboco.android.core.Face +import sh.roboco.android.design.FadingText +import sh.roboco.android.design.Haptic +import sh.roboco.android.design.LocalFeedback +import sh.roboco.android.design.LocalTypeScale +import sh.roboco.android.design.RobocoIcons +import sh.roboco.android.design.RobocoTheme +import sh.roboco.android.design.StatusGlyph +import sh.roboco.android.design.StatusGlyphKind +import sh.roboco.android.design.glass.GlassCircleButton +import sh.roboco.android.design.glass.Menu +import sh.roboco.android.design.glass.MenuIcon +import sh.roboco.android.design.glass.MenuItem +import sh.roboco.android.design.glass.rememberGlassMenuState +import sh.roboco.android.design.rememberSmoothRoundedShape +import sh.roboco.android.design.sheet.GlassSheet +import sh.roboco.android.design.sheet.GlassSheetDefaults +import sh.roboco.android.design.sheet.SheetDetent +import sh.roboco.android.design.sheet.rememberGlassSheetState +import sh.roboco.android.design.uiStyle +import sh.roboco.android.sessions.SectionHeaderRow +import sh.roboco.android.sessions.SectionHeaderState +import sh.roboco.android.sessions.SectionLive +import sh.roboco.android.sessions.SessionRow +import sh.roboco.android.sessions.rememberRowHighlight + +/** The sheet's three foldable sections, in order (upstream #899's `Section`). */ +enum class SideChatsSection(val id: String, val title: String) { + Running("running", "Running"), + Chats("chats", "Side chats"), + Finished("finished", "Finished agents"), +} + +/** + * Which sections are folded, remembered across sheets for the process + * (iOS keeps the set in `UserDefaults`; Roboco keeps it in memory — a + * relaunch unfolds everything). Snapshot state, main thread only. + */ +object SideChatsFolds { + private val folded = mutableStateMapOf<SideChatsSection, Boolean>() + + fun isFolded(section: SideChatsSection): Boolean = folded[section] == true + + fun toggle(section: SideChatsSection) { + folded[section] = !isFolded(section) + } + + /** Unfold everything (tests: the memory is process-wide). */ + internal fun clear() { + folded.clear() + } +} + +/** + * Whether the Side Chats & Agents sheet shows, plus the one action that + * waits for its dismissal (iOS `dismiss(animated:) { onNew() }`: the new + * side chat and the fork start once the sheet is gone). Main thread only. + */ +@Stable +class SideChatsSheetController { + var showing by mutableStateOf(false) + private set + + private var afterDismiss: (() -> Unit)? = null + + fun open() { + showing = true + } + + /** Run [action] once the sheet has animated away. */ + internal fun dismissThen(action: () -> Unit) { + afterDismiss = action + } + + /** `GlassSheet`'s `onDismiss`: the sheet reached `Hidden`. */ + internal fun onDismissed() { + showing = false + val action = afterDismiss + afterDismiss = null + action?.invoke() + } +} + +/** + * The Side Chats & Agents sheet (android-next #06, the port of upstream + * #899's `SideChatsViewController`): everything hanging off one session — + * the subagents its agent is running (live tail and model), its side + * chats (forked, started by hand or spawned through the MCP server), and + * the subagents that finished — in three foldable sections. Picking a row + * opens it on top of the session; the header's + offers New Side Chat and + * Fork into Side Chat; ✓ dismisses; the sheet closes itself once nothing + * is left in it. + * + * A `GlassSheet` at `[Medium, Large]` with a grabber. Host it as the + * **last child of the session screen** so it covers the header, the + * transcript and the bottom stack. + * + * @param canAdd whether the + shows: the engine serves side chats and is + * writable + */ +@Composable +fun SideChatsOverlay( + controller: SideChatsSheetController, + ui: SideChatsUi, + canAdd: Boolean, + onOpenChat: (chatId: String) -> Unit, + onOpenAgent: (SideChatsAgent) -> Unit, + onNew: () -> Unit, + onFork: () -> Unit, +) { + if (!controller.showing) return + val detents = remember { listOf(SheetDetent.Medium, SheetDetent.Large) } + GlassSheet( + onDismiss = { controller.onDismissed() }, + modifier = Modifier.testTag("side-chats-sheet"), + state = rememberGlassSheetState(detents), + grabber = true, + title = SideChatsCopy.Title, + header = { + SideChatsHeader( + canAdd = canAdd, + onNew = { + controller.dismissThen(onNew) + dismiss() + }, + onFork = { + controller.dismissThen(onFork) + dismiss() + }, + onDone = { dismiss() }, + ) + }, + ) { bottomInset -> + // iOS `reload`: an emptied sheet (the last side chat archived) + // closes itself. + val empty = ui.total == 0 + LaunchedEffect(empty) { if (empty) dismiss() } + SideChatsList( + ui = ui, + bottomInset = bottomInset.value, + onOpenChat = { id -> + onOpenChat(id) + dismiss() + }, + onOpenAgent = { agent -> + onOpenAgent(agent) + dismiss() + }, + ) + } +} + +/** The sheet's copy, verbatim from upstream #899. */ +object SideChatsCopy { + const val Title = "Side Chats & Agents" + const val NewSideChat = "New Side Chat" + const val ForkIntoSideChat = "Fork into Side Chat" + const val AddLabel = "Add side chat" +} + +/** The + menu (iOS `UIMenu` on the leading bar item). Pure. */ +fun sideChatsAddMenu(onNew: () -> Unit, onFork: () -> Unit): Menu = Menu( + title = null, + children = listOf( + MenuItem( + title = SideChatsCopy.NewSideChat, + icon = MenuIcon.Symbol(RobocoIcons.QuestionmarkBubble), + testTag = "side-chats-new", + action = onNew, + ), + MenuItem( + title = SideChatsCopy.ForkIntoSideChat, + icon = MenuIcon.Symbol(RobocoIcons.ArrowTriangleBranch), + testTag = "side-chats-fork", + action = onFork, + ), + ), +) + +/** + * The sheet's nav bar: [SheetHeaderRow][sh.roboco.android.design.sheet.SheetHeaderRow]'s + * geometry (52 dp, 16 dp insets, 44 dp glass circles, the centred + * SemiBold 17 title) with the + menu leading and the accent ✓ trailing. + */ +@Composable +private fun SideChatsHeader( + canAdd: Boolean, + onNew: () -> Unit, + onFork: () -> Unit, + onDone: () -> Unit, +) { + val palette = RobocoTheme.palette + val typeScale = LocalTypeScale.current + val menuState = rememberGlassMenuState() + // Written during layout, read only in the click lambda. + var addBounds by remember { mutableStateOf(Rect.Zero) } + Box(Modifier.fillMaxWidth().height(GlassSheetDefaults.HeaderHeight)) { + BasicText( + SideChatsCopy.Title, + Modifier + .align(Alignment.Center) + .semantics { heading() }, + style = uiStyle(Face.SansSemibold, typeScale.fixed(17f), palette.text), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (canAdd) { + GlassCircleButton( + icon = RobocoIcons.Plus, + contentDescription = SideChatsCopy.AddLabel, + onClick = { menuState.open(addBounds, sideChatsAddMenu(onNew, onFork)) }, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp) + .onGloballyPositioned { addBounds = it.boundsInWindow() } + .testTag("side-chats-add"), + ) + } + GlassCircleButton( + icon = RobocoIcons.Checkmark, + contentDescription = "Done", + onClick = onDone, + filled = palette.accent, + ink = Color.White, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 16.dp) + .testTag("sheet-done"), + ) + } +} + +/** + * The three sections (iOS's diffable snapshot): a header row each — the + * count over all its rows, folded or not; Running keeps its spinner while + * folded — then its rows unless folded. Empty sections are left out. + */ +@Composable +internal fun SideChatsList( + ui: SideChatsUi, + bottomInset: Float, + onOpenChat: (String) -> Unit, + onOpenAgent: (SideChatsAgent) -> Unit, + modifier: Modifier = Modifier, +) { + val openChat by rememberUpdatedState(onOpenChat) + val openAgent by rememberUpdatedState(onOpenAgent) + LazyColumn( + modifier.fillMaxSize().testTag("side-chats-list"), + contentPadding = PaddingValues(bottom = (bottomInset + 16f).dp), + ) { + section(SideChatsSection.Running, ui.running.size, live = true) { + items(ui.running, key = { "agent:${it.docId}" }) { agent -> + SubagentRowView(agent, onClick = { openAgent(agent) }) + } + } + section(SideChatsSection.Chats, ui.chats.size, live = false) { + items(ui.chats, key = { "chat:${it.id}" }) { vm -> + val src = remember { MutableInteractionSource() } + val highlight = rememberRowHighlight(src) + SessionRow( + vm, + modifier = Modifier.clickable(src, indication = null, role = Role.Button) { openChat(vm.id) }, + highlight = { highlight.value }, + ) + } + } + section(SideChatsSection.Finished, ui.finished.size, live = false) { + items(ui.finished, key = { "agent:${it.docId}" }) { agent -> + SubagentRowView(agent, onClick = { openAgent(agent) }) + } + } + } +} + +/** One section: its header (a tap folds it) and, unfolded, its rows. */ +private fun LazyListScope.section( + section: SideChatsSection, + count: Int, + live: Boolean, + rows: LazyListScope.() -> Unit, +) { + if (count == 0) return + val folded = SideChatsFolds.isFolded(section) + item(key = "header:${section.id}") { + val src = remember { MutableInteractionSource() } + val highlight = rememberRowHighlight(src, fadeOnRelease = false) + val feedback = LocalFeedback.current + SectionHeaderRow( + SectionHeaderState( + id = "side-${section.id}", + title = section.title, + count = count, + collapsed = folded, + live = if (live) SectionLive.Spinner else null, + ), + highlight = { highlight.value }, + modifier = Modifier.clickable(src, indication = null, role = Role.Button) { + feedback.haptic(Haptic.Select) // iOS `toggle`: UISelectionFeedbackGenerator + SideChatsFolds.toggle(section) + }, + ) + } + if (!folded) rows() +} + +/** `now` / `34m` / `4h` / `2d` (`mc::relative_time_label`, pure Kotlin so the sheet stays FFI-free). */ +internal fun relativeAgentTime(atMs: Long, nowMs: Long): String { + val secs = (nowMs - atMs).coerceAtLeast(0L) / 1000L + return when { + secs < 60L -> "now" + secs < 3_600L -> "${secs / 60L}m" + secs < 86_400L -> "${secs / 3_600L}h" + else -> "${secs / 86_400L}d" + } +} + +/** A subagent row's spoken label (iOS `SubagentCell.configure`). Pure. */ +internal fun subagentRowA11yLabel(agent: SideChatsAgent): String { + val word = when (agent.state) { + AgentState.Running -> "Running" + AgentState.Done -> "Done" + AgentState.Failed -> "Failed" + } + return listOfNotNull( + "Subagent", + agent.title, + word, + agent.model?.let(::capitalizedModel), + if (agent.running) agent.tail else null, + ).joinToString(", ") +} + +/** "haiku" → "Haiku" (iOS `prefix(1).uppercased() + dropFirst()`). */ +internal fun capitalizedModel(model: String): String = + if (model.isEmpty()) model else model.substring(0, 1).uppercase() + model.substring(1) + +/** + * A subagent row (the port of `SubagentCell`): its status glyph stands + * where a side chat shows its harness mark, so the two read apart — + * + * ``` + * ⠿ Audit the reduced-motion fallback ··········· Haiku + * Reading RowView.swift… (running: live tail) + * ✓ Check CJK line breaking ······················ 12m + * ``` + * + * 62k tall while running (title at 10k, tail at 35k), 46k once finished + * (the title centred). The pressed fill is the session row's: controlFill, + * 16 dp continuous corners, inset 8 × 1. + */ +@Composable +internal fun SubagentRowView( + agent: SideChatsAgent, + onClick: () -> Unit, + modifier: Modifier = Modifier, + nowMs: Long = System.currentTimeMillis(), +) { + val palette = RobocoTheme.palette + val ts = LocalTypeScale.current + val running = agent.running + val glyph = when (agent.state) { + AgentState.Running -> StatusGlyphKind.Spinner + AgentState.Done -> StatusGlyphKind.Check(palette.status.done) + AgentState.Failed -> StatusGlyphKind.Dot(palette.status.failed) + } + // Running: which model it runs on. Finished: when it was spawned. + val trailing = if (running) agent.model?.let(::capitalizedModel) else relativeAgentTime(agent.spawnedAtMs, nowMs) + val trailingColor = when { + running -> palette.secondary + agent.state == AgentState.Failed -> palette.status.failed + else -> palette.status.time + } + val src = remember { MutableInteractionSource() } + val highlight = rememberRowHighlight(src) + val shape = rememberSmoothRoundedShape(16.dp) + val titleH = ts.scaledDp(22f) + Box( + modifier + .fillMaxWidth() + .height(ts.scaledDp(if (running) 62f else 46f)) + .drawWithCache { + val inset = 8.dp.toPx() + val vInset = 1.dp.toPx() + val outline = shape.createOutline( + Size(size.width - 2f * inset, size.height - 2f * vInset), + layoutDirection, + this, + ) + onDrawBehind { + val h = highlight.value.coerceIn(0f, 1f) + if (h > 0f) translate(inset, vInset) { drawOutline(outline, palette.controlFill, alpha = h) } + } + } + .clickable(src, indication = null, role = Role.Button, onClick = onClick) + .clearAndSetSemantics { + testTag = "subagent-${agent.docId}" + contentDescription = subagentRowA11yLabel(agent) + }, + ) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .align(if (running) Alignment.TopStart else Alignment.CenterStart) + .padding(top = if (running) ts.scaledDp(10f) else 0.dp), + ) { + Row(Modifier.fillMaxWidth().height(titleH), verticalAlignment = Alignment.CenterVertically) { + // The 20 dp mark column, the 12 dp glyph centred in it. + Box(Modifier.width(20.dp), contentAlignment = Alignment.Center) { + Box(Modifier.size(12.dp)) { StatusGlyph(glyph) } + } + Spacer(Modifier.width(14.dp)) + FadingText( + agent.title, + uiStyle( + Face.SansMedium, + ts.scaled(if (running) 16.5f else 15.5f), + if (running) palette.text else palette.text.copy(alpha = 0.88f), + ), + Modifier.weight(1f), + ) + if (trailing != null) { + Spacer(Modifier.width(10.dp)) + BasicText( + trailing, + style = uiStyle(Face.SansMedium, ts.scaled(13f), trailingColor).copy(textAlign = TextAlign.End), + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) + } + } + val tail = agent.tail + if (running && tail != null) { + FadingText( + tail, + uiStyle(Face.Sans, ts.scaled(13.5f), palette.secondary), + Modifier + .padding(start = 34.dp, top = ts.scaledDp(3f)) + .height(ts.scaledDp(18f)), + ) + } + } + } +} diff --git a/apps/android/app/src/main/java/sh/roboco/android/sidechats/SubagentViewer.kt b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SubagentViewer.kt new file mode 100644 index 0000000000..569013a1d4 --- /dev/null +++ b/apps/android/app/src/main/java/sh/roboco/android/sidechats/SubagentViewer.kt @@ -0,0 +1,532 @@ +package sh.roboco.android.sidechats + +import android.content.ClipboardManager +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.hazeSource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sh.roboco.android.RobocoApplication +import sh.roboco.android.core.Face +import sh.roboco.android.core.TextEngine +import sh.roboco.android.design.LocalToaster +import sh.roboco.android.design.LocalTypeScale +import sh.roboco.android.design.RobocoIcons +import sh.roboco.android.design.RobocoTheme +import sh.roboco.android.design.StatusGlyph +import sh.roboco.android.design.StatusGlyphKind +import sh.roboco.android.design.Toaster +import sh.roboco.android.design.glass.LocalHazeState +import sh.roboco.android.design.glass.Menu +import sh.roboco.android.design.glass.MenuIcon +import sh.roboco.android.design.glass.MenuItem +import sh.roboco.android.design.glass.ProvideHazeState +import sh.roboco.android.design.glass.rememberGlassMenuState +import sh.roboco.android.design.uiStyle +import sh.roboco.android.image.ImageViewerRequest +import sh.roboco.android.image.LocalImageViewer +import sh.roboco.android.session.SessionEventHub +import sh.roboco.android.session.SessionHeader +import sh.roboco.android.session.SessionHeaderDefaults +import sh.roboco.android.session.copyTranscript +import sh.roboco.android.shell.LocalNavEntry +import sh.roboco.android.shell.LocalNavStack +import sh.roboco.android.shell.NavEntry +import sh.roboco.android.shell.NavRoute +import sh.roboco.android.shell.NavStack +import sh.roboco.android.transcript.AttachmentReader +import sh.roboco.android.transcript.TranscriptActions +import sh.roboco.android.transcript.TranscriptHost +import sh.roboco.android.transcript.TranscriptImageBinding +import sh.roboco.android.transcript.TranscriptImageLoader +import sh.roboco.android.transcript.TranscriptState +import sh.roboco.android.transcript.rememberLinkRouter +import sh.roboco.android.transcript.transcriptBottomInset +import sh.roboco.android.transcript.widgets.imageTileRadius +import uniffi.roboco_core.CoreClient +import uniffi.roboco_core.SessionHandle +import uniffi.roboco_core.TextSystem + +/** The viewer's copy, verbatim from upstream #899's `SubagentViewController`. */ +object SubagentCopy { + const val Subagent = "Subagent" + const val NothingYet = "Nothing from this subagent yet." + const val OpenFailed = "Couldn't open this subagent." +} + +/** + * The header subtitle: iOS's `"Subagent · <parent title>"`, with the + * Roboco addition of the live state while the subagent is not done — + * `"Running · …"` while it works (a background subagent keeps running + * after its spawn call resolved) and `"Failed · …"` once it failed. Pure. + */ +fun subagentSubtitle(state: AgentState, parentTitle: String): String { + val word = when (state) { + AgentState.Running -> "Running" + AgentState.Done -> SubagentCopy.Subagent + AgentState.Failed -> "Failed" + } + return if (parentTitle.isBlank()) word else "$word · $parentTitle" +} + +/** + * A subagent's transcript, read-only (android-next #06, the port of + * upstream #899's `SubagentViewController`): the doc a spawn card links + * to. Roboco's engine serves that doc itself — the same + * `WatchDocMessages` stream desktop's subagent tab subscribes to + * (`crates/ui` `watch_subagent_doc`), never #899's edge blob — so the + * transcript is live while the subagent runs, including the claude-code + * `run_in_background` shape whose spawn call resolved long before the + * subagent finished, stops changing once it is done, and streams again if + * it resumes. No composer: the parent agent drives the subagent. + * + * [state] follows the spawn chip in the transcript that opened this + * viewer ([spawnerId]: the chat, or the subagent above for a nested + * spawn), re-read on that transcript's `SessionChanged` — the same chip + * the card's running dots read, so the two never disagree. + * + * Built off main by [buildSubagentRoute]; the transcript state is made on + * the first main-thread read. [start] runs on first composition and + * [release] once, through the entry's close safety net (a committed pop). + */ +@Stable +class SubagentRoute internal constructor( + /** The subagent's doc id (scoped): the read-only session's key. */ + val docId: String, + /** The spawn's description — the bar title. */ + val title: String, + /** The transcript whose spawn chip this is (its chat, or the subagent above). */ + val spawnerId: String, + /** The spawner's display title, for the subtitle. */ + val parentTitle: String, + /** The engine device that serves attachments (`composer.host.deviceId`). */ + val hostDeviceId: String, + private val client: CoreClient, + private val handle: SessionHandle, + private val hub: SessionEventHub, + /** The app-lifetime scope teardown and state reads ride on (never the entry's). */ + private val appScope: CoroutineScope, + private val text: TextSystem, + initialState: AgentState, + /** One serial lane per viewer: attach, the state reads and detach keep their order. */ + private val io: CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1), +) : NavRoute { + + override val hidesBottomChrome: Boolean get() = true + + override val paneTitle: String get() = title + + /** The subagent's lifecycle, as its spawn chip says. Snapshot state (main). */ + var state: AgentState by mutableStateOf(initialState) + private set + + private var transcriptImpl: TranscriptState? = null + + /** The layout engine. MAIN THREAD ONLY: created on first read. */ + val transcript: TranscriptState + get() = transcriptImpl ?: TranscriptState(text).also { transcriptImpl = it } + + private var started = false + private var released = false + private var unregister: (() -> Unit)? = null + + /** + * First composition (main): bind the layout engine to the read-only + * session and mark it on screen — on the serial lane, attach first + * (iOS `setViewAttached` then `engine.attach`; the order is not + * observable, the frames come from the same handle) — then follow the + * spawn chip's state. + */ + internal fun start() { + if (started || released) return + started = true + val view = transcript.engine + appScope.launch(io) { + try { + if (!view.attach(client, docId)) Log.w(TAG, "subagent transcript attach returned false") + handle.setViewAttached(true) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (t: Throwable) { + Log.w(TAG, "subagent attach failed", t) + } + } + unregister = hub.register(spawnerId) { refreshState() } + refreshState() + } + + /** Re-read the spawn chip off main; publish on main. */ + private fun refreshState() { + if (released) return + appScope.launch(io) { + val next = try { + // A fresh wrapper per read: close it here (the core keeps the + // session itself warm), never leave it to the GC. + client.session(spawnerId)?.let { spawner -> + try { + spawner.subagents().firstOrNull { it.docId == docId }?.let { agentState(it.state) } + } finally { + spawner.close() + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (t: Throwable) { + Log.w(TAG, "subagent state read failed", t) + null + } ?: return@launch + withContext(Dispatchers.Main) { + if (!released) state = next + } + } + } + + /** + * The committed pop (the entry's close safety net): stop following the + * chip, close the layout engine (main), then detach and close the + * session on the lane — the doc stays warm in the core for a re-open. + * Idempotent. + */ + fun release() { + if (released) return + released = true + unregister?.invoke() + unregister = null + transcriptImpl?.close() + appScope.launch(io) { + try { + handle.setViewAttached(false) + client.closeSession(docId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (t: Throwable) { + Log.w(TAG, "subagent release failed", t) + } + } + } + + /** The attachment reader for the transcript's images (the host device's files). */ + internal val attachments: AttachmentReader = object : AttachmentReader { + override suspend fun read(engineDeviceId: String, reference: String): ByteArray = + client.readAttachment(engineDeviceId, reference) + } + + @Composable + fun Content() { + val entry = LocalNavEntry.current + // The close safety net (the NavEntry contract): any removal path + // releases exactly once. + remember(entry) { + entry.retain(CloseKey) { + object : AutoCloseable { + override fun close() = release() + } + } + } + remember(entry) { + start() + true + } + SubagentScreen(this) + } + + private object CloseKey + + private companion object { + const val TAG = "RobocoSubagent" + } +} + +/** What a spawn card (or a sheet row) asks to open. */ +data class SubagentOpenRequest( + val docId: String, + val title: String, + /** The card said it had finished (done or failed). */ + val frozen: Boolean, + /** The transcript the card sits in: the chat, or the subagent above. */ + val spawnerId: String, + val parentTitle: String, +) + +/** + * Off main: open the subagent's doc read-only (`CoreClient.openSubagent` — + * the cache seed and the subscription start, no round trip) and build its + * route. `null` when there is no client or the core refused the id. + */ +suspend fun buildSubagentRoute( + app: RobocoApplication, + request: SubagentOpenRequest, + io: CoroutineDispatcher = Dispatchers.IO, +): SubagentRoute? = withContext(io) { + try { + val client = app.clientOrNull() ?: return@withContext null + val handle = client.openSubagent(request.docId) + val host = try { + handle.composer().host.deviceId + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + "" + } + SubagentRoute( + docId = request.docId, + title = request.title, + spawnerId = request.spawnerId, + parentTitle = request.parentTitle, + hostDeviceId = host, + client = client, + handle = handle, + hub = app.coreStartup.sessionEvents, + appScope = app.appScope, + text = TextEngine.shared, + initialState = if (request.frozen) AgentState.Done else AgentState.Running, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (t: Throwable) { + Log.w("RobocoSubagent", "open subagent failed", t) + null + } +} + +/** + * Opens subagent viewers over one screen (the session, or a viewer for a + * nested spawn): the doc opens off main, then the route is pushed — only + * if this screen is still on top and idle (a late open never pushes over + * a screen the user already left, the `leaveAfterArchive` rule). One open + * at a time; a failed open toasts "Couldn't open this subagent." + */ +class SubagentOpener( + private val app: RobocoApplication, + private val nav: NavStack, + private val entry: NavEntry, + private val scope: CoroutineScope, + private val toaster: Toaster, +) { + private var opening = false + + /** Main thread. */ + fun open(request: SubagentOpenRequest) { + if (opening) return + opening = true + scope.launch { + try { + val route = buildSubagentRoute(app, request) + when { + route == null -> toaster.show(SubagentCopy.OpenFailed) + nav.top !== entry || nav.transition != null -> route.release() + else -> nav.push(route, animated = true) + } + } finally { + opening = false + } + } + } +} + +/** + * The opener for the screen composing this (the [LocalNavStack] / + * [LocalNavEntry] it lives in); `null` outside the app (previews, tests). + */ +@Composable +fun rememberSubagentOpener(): SubagentOpener? { + val app = LocalContext.current.applicationContext as? RobocoApplication ?: return null + val nav = LocalNavStack.current + val entry = LocalNavEntry.current + val toaster = LocalToaster.current + val scope = rememberCoroutineScope() + return remember(app, nav, entry, toaster, scope) { SubagentOpener(app, nav, entry, scope, toaster) } +} + +/** The ⋯ menu: one row, Copy Transcript (`SubagentViewController`). Pure. */ +fun subagentMenu(onCopyTranscript: () -> Unit): Menu = Menu( + title = null, + children = listOf( + MenuItem( + title = "Copy Transcript", + icon = MenuIcon.Symbol(RobocoIcons.DocOnDoc), + testTag = "subagent-menu-copy", + action = onCopyTranscript, + ), + ), +) + +/** + * The viewer screen: the read-only transcript under the session header + * (back, the spawn's title over [subagentSubtitle], a ⋯ with Copy + * Transcript), with iOS's reveal — the spinner until the first row lands, + * then the transcript; "Nothing from this subagent yet." after 8 s + * without one. Nested spawn cards open their own viewer on top. + */ +@Composable +internal fun SubagentScreen(route: SubagentRoute, modifier: Modifier = Modifier) { + val context = LocalContext.current + val palette = RobocoTheme.palette + val toaster = LocalToaster.current + val transcript = route.transcript + val scope = rememberCoroutineScope() + val menuState = rememberGlassMenuState() + val opener = rememberSubagentOpener() + + val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val navBarBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + SideEffect { transcript.topInset = SessionHeaderDefaults.contentTopInset(statusBarTop).value } + val edgeEngaged by remember(transcript) { derivedStateOf { transcript.viewport.scrollY > 0.5f } } + val revealed by remember(transcript) { derivedStateOf { (transcript.frame?.rowCount() ?: 0u) > 0u } } + var gaveUp by remember(route) { mutableStateOf(false) } + LaunchedEffect(route) { + delay(GiveUpMs) + gaveUp = true + } + + // The transcript's actions — SessionScreen's image and link wiring + // over this doc's host device, plus nested subagent cards. + val viewer = LocalImageViewer.current + val density = LocalDensity.current + val binding = remember(route) { + TranscriptImageBinding(TranscriptImageLoader.get(context), { route.hostDeviceId }, route.attachments) + } + val router = rememberLinkRouter( + loadFull = { reference -> binding.loadFull(reference) }, + showImage = { reference, source, bitmap -> + viewer?.show( + ImageViewerRequest( + reference = reference, + preview = bitmap, + sourceBounds = source, + sourceCornerRadius = 0.dp, + label = null, + loadFull = null, + ), + ) + }, + ) + val actions = remember(route, viewer, router, opener) { + val openImage: (String, Rect, ImageBitmap?, String?) -> Unit = { reference, source, bitmap, label -> + viewer?.show( + ImageViewerRequest( + reference = reference, + preview = bitmap, + sourceBounds = source, + sourceCornerRadius = with(density) { + imageTileRadius(source.width.toDp().value, source.height.toDp().value) + }, + label = label, + loadFull = { binding.loadFull(reference) }, + ), + ) + } + TranscriptActions( + openLink = router::open, + openImage = openImage, + loadImage = binding::load, + cachedImage = binding::cached, + openSubagent = { docId, title, frozen -> + opener?.open( + SubagentOpenRequest( + docId = docId, + title = title, + frozen = frozen, + spawnerId = route.docId, + parentTitle = route.title, + ), + ) + }, + ) + } + + Box( + modifier + .fillMaxSize() + .background(palette.background) + .testTag("subagent-viewer"), + ) { + ProvideHazeState { + val haze = LocalHazeState.current + // The transcript is always composed (rows lay out and the tail + // is placed while hidden); it shows with its first row. + Box( + Modifier + .fillMaxSize() + .then(if (haze != null) Modifier.hazeSource(haze) else Modifier) + .graphicsLayer { alpha = if (revealed) 1f else 0f }, + ) { + TranscriptHost( + transcript, + actions, + Modifier + .fillMaxSize() + .transcriptBottomInset(transcript.viewport, navBarBottom) + .testTag("subagent-transcript"), + ) + } + if (!revealed) { + if (!gaveUp) { + StatusGlyph( + StatusGlyphKind.Spinner, + Modifier.align(Alignment.Center).testTag("subagent-loading"), + scale = 1.75f, + ) + } else { + BasicText( + SubagentCopy.NothingYet, + Modifier.align(Alignment.Center).testTag("subagent-empty"), + style = uiStyle(Face.Sans, LocalTypeScale.current.fixed(15f), palette.secondary), + ) + } + } + SessionHeader( + title = route.title, + subtitle = subagentSubtitle(route.state, route.parentTitle), + edgeEngaged = edgeEngaged, + onMore = { anchor -> + menuState.open( + anchor, + subagentMenu(onCopyTranscript = { + scope.launch { + val clipboard = context.applicationContext + .getSystemService(ClipboardManager::class.java) ?: return@launch + copyTranscript(transcript, clipboard, toaster) + } + }), + ) + }, + statusBarTop = statusBarTop, + ) + } + } +} + +/** iOS `asyncAfter(deadline: .now() + 8)`: the empty state replaces the spinner. */ +private const val GiveUpMs = 8_000L diff --git a/apps/android/app/src/main/java/sh/roboco/android/transcript/RowMenu.kt b/apps/android/app/src/main/java/sh/roboco/android/transcript/RowMenu.kt index 3de7310d85..4f6f41b5b1 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/transcript/RowMenu.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/transcript/RowMenu.kt @@ -183,14 +183,15 @@ fun rememberRowMenuPerformer(actions: () -> TranscriptActions): RowMenuPerformer /** * The widget kinds that own their touches (`ios:Transcript/RowView.swift` * — `CopyCode` `:202-204`, `Disclosure` `:205-211`, `Image` `:214-234`, - * `Detail` `:239-249`, `ToolToggle` `:268-277`). Everything else + * `Detail` `:239-249`, `ToolToggle` `:268-277`, and upstream #899's + * `SubagentLink`, a `ToolToggleControl` over a spawn card). Everything else * (`Icon`, `ToolStatus`, `Spinner`, `Working`, `Chevron`, `ToolRail`, * `Shimmer`) is display-only (`isUserInteractionEnabled = false`) and a * long press over it lifts the row. */ internal fun WidgetKind.isControl(): Boolean = this is WidgetKind.CopyCode || this is WidgetKind.Disclosure || this is WidgetKind.ToolToggle || - this is WidgetKind.Detail || this is WidgetKind.Image + this is WidgetKind.Detail || this is WidgetKind.Image || this is WidgetKind.SubagentLink /** * True when a touch-down at row-local `(xDp, yDp)` lands on a control diff --git a/apps/android/app/src/main/java/sh/roboco/android/transcript/RowWidget.kt b/apps/android/app/src/main/java/sh/roboco/android/transcript/RowWidget.kt index df8106716b..a87cf2b457 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/transcript/RowWidget.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/transcript/RowWidget.kt @@ -11,6 +11,7 @@ import sh.roboco.android.transcript.widgets.ImageTileCopy import sh.roboco.android.transcript.widgets.RailSpec import sh.roboco.android.transcript.widgets.ShimmerWidget import sh.roboco.android.transcript.widgets.SpinnerWidget +import sh.roboco.android.transcript.widgets.SubagentLinkTarget import sh.roboco.android.transcript.widgets.ToolChevron import sh.roboco.android.transcript.widgets.ToolRail import sh.roboco.android.transcript.widgets.ToolStatusWidget @@ -54,6 +55,13 @@ internal fun RowWidget(ctx: RowContext, w: Widget) { onToggle = { ctx.state.toggleDetail(ctx.model.display.key, k.detail, k.open) }, ) is WidgetKind.Chevron -> ToolChevron(rowKey = ctx.model.display.key, expanded = k.expanded) + // android-next #06 (upstream #899's `subagentLink`): the whole spawn + // card opens the subagent's transcript; the actions are read at + // click time so a rebuilt TranscriptActions is honoured. + is WidgetKind.SubagentLink -> SubagentLinkTarget( + title = k.title, + onOpen = { ctx.actions().openSubagent(k.docId, k.title, k.frozen) }, + ) // ticket 123 (status glyphs): running beats failed; the Working / // Writing flip reuses this slot (same `#working` row key), so the // ticker's remember survives and the clock never restarts — no diff --git a/apps/android/app/src/main/java/sh/roboco/android/transcript/TranscriptActions.kt b/apps/android/app/src/main/java/sh/roboco/android/transcript/TranscriptActions.kt index d1dfd6468c..cc38db7be3 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/transcript/TranscriptActions.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/transcript/TranscriptActions.kt @@ -71,4 +71,11 @@ class TranscriptActions( * Initial-pass observer. */ val onTap: () -> Unit = {}, + /** + * A subagent spawn card was tapped (android-next #06, upstream #899's + * `onOpenSubagent`): open the subagent's read-only transcript. + * [docId] is the scoped subagent doc, [title] the spawn's description, + * [frozen] whether the subagent had already finished when tapped. + */ + val openSubagent: (docId: String, title: String, frozen: Boolean) -> Unit = { _, _, _ -> }, ) diff --git a/apps/android/app/src/main/java/sh/roboco/android/transcript/TranscriptControl.kt b/apps/android/app/src/main/java/sh/roboco/android/transcript/TranscriptControl.kt index 0cfb456c51..2b8a4bce4e 100644 --- a/apps/android/app/src/main/java/sh/roboco/android/transcript/TranscriptControl.kt +++ b/apps/android/app/src/main/java/sh/roboco/android/transcript/TranscriptControl.kt @@ -78,7 +78,8 @@ fun Modifier.transcriptControl(): Modifier = this then TranscriptControlElement */ val WidgetKind.isTranscriptControl: Boolean get() = when (this) { - WidgetKind.CopyCode, is WidgetKind.Disclosure, is WidgetKind.ToolToggle, is WidgetKind.Detail -> true + WidgetKind.CopyCode, is WidgetKind.Disclosure, is WidgetKind.ToolToggle, is WidgetKind.Detail, + is WidgetKind.SubagentLink -> true is WidgetKind.Image, is WidgetKind.Chevron, is WidgetKind.Icon, is WidgetKind.ToolStatus, WidgetKind.Spinner, is WidgetKind.Working, is WidgetKind.ToolRail, WidgetKind.Shimmer -> false } diff --git a/apps/android/app/src/main/java/sh/roboco/android/transcript/widgets/SubagentLink.kt b/apps/android/app/src/main/java/sh/roboco/android/transcript/widgets/SubagentLink.kt new file mode 100644 index 0000000000..c7fa0b9bf2 --- /dev/null +++ b/apps/android/app/src/main/java/sh/roboco/android/transcript/widgets/SubagentLink.kt @@ -0,0 +1,59 @@ +package sh.roboco.android.transcript.widgets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import sh.roboco.android.design.Haptic +import sh.roboco.android.design.LocalFeedback +import sh.roboco.android.design.RobocoTheme +import sh.roboco.android.design.rememberSmoothRoundedShape + +/** + * `WidgetKind.SubagentLink`'s view (android-next #06; upstream #899's + * `RowView` case — a `ToolToggleControl` with a 10 pt continuous corner): + * an invisible tap target over a whole subagent spawn card. The Rust rect + * is the card itself, so the card's icon, label, description and running + * dots stay Rust paint underneath and the dots keep following the chip's + * `subagent_status`. + * + * While the finger is down the card fills with `controlFill`, rounded 10 dp + * and instant both ways, like [ToolToggleTarget]. A tap plays the `Select` + * haptic (iOS `UISelectionFeedbackGenerator`) then [onOpen]. The label is + * iOS's "Open subagent: <title>". + */ +@Composable +fun SubagentLinkTarget(title: String, onOpen: () -> Unit, modifier: Modifier = Modifier) { + val feedback = LocalFeedback.current + val interactions = remember { MutableInteractionSource() } + val pressed = interactions.collectIsPressedAsState() + val fill = RobocoTheme.palette.controlFill + val shape = rememberSmoothRoundedShape(10.dp) + Box( + modifier + .fillMaxSize() + .testTag("subagent-link") + // The State is read inside the draw block: a press redraws, + // never recomposes. + .drawWithCache { + val outline = shape.createOutline(size, layoutDirection, this) + onDrawBehind { if (pressed.value) drawOutline(outline, fill) } + } + .clickable(interactionSource = interactions, indication = null, role = Role.Button) { + feedback.haptic(Haptic.Select) + onOpen() + } + .semantics { contentDescription = "Open subagent: $title" }, + ) +} diff --git a/apps/android/app/src/test/java/sh/roboco/android/newsession/SideChatDraftTest.kt b/apps/android/app/src/test/java/sh/roboco/android/newsession/SideChatDraftTest.kt new file mode 100644 index 0000000000..3ef20c6372 --- /dev/null +++ b/apps/android/app/src/test/java/sh/roboco/android/newsession/SideChatDraftTest.kt @@ -0,0 +1,220 @@ +package sh.roboco.android.newsession + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.job +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import sh.roboco.android.composer.DEFAULT_HARNESS +import sh.roboco.android.composer.Drafts +import sh.roboco.android.composer.FakePrefsStore +import sh.roboco.android.composer.foregroundAppScope +import uniffi.roboco_core.NewSession +import uniffi.roboco_core.SendRequest +import uniffi.roboco_core.SessionTarget + +/** + * A side chat's draft (android-next #06, upstream #899's + * `NewSessionViewController(sideOf:)`): the page model in side mode — it + * starts on the parent's model, picks no project or host, loads models + * for the parent's host and never touches the sticky new-session store — + * and the create it sends: `parentChatId`, the parent's host as the + * (ignored) target, no branch and never a worktree, whatever the draft + * says. + * + * Plain JVM with the same fakes as `NewSessionModelTest`. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SideChatDraftTest { + + @Before + fun clearCatalogCache() { + ModelCatalogCache.clear() + } + + private val parent = SideChatParent( + chatId = "engine:v1:k:chat", + title = "Streaming veil on transcript rows", + deviceId = "engine:v1:k:dev", + projectId = "engine:v1:k:space", + harness = "codex", + model = "x1", + effort = "low", + ) + + private class FakeCodec : NewSessionDraftCodec { + private val byToken = HashMap<String, NewSessionDraft>() + private var next = 0 + override fun encode(d: NewSessionDraft): String { + val token = "d${next++}" + byToken[token] = d + return token + } + + override fun decode(json: String?): NewSessionDraft = json?.let { byToken[it] } ?: NewSessionDraft() + } + + private class FakeCatalog(private val builtInModels: List<ModelChoice>) : ModelCatalogSource { + val fetches = mutableListOf<String>() + override fun builtIn(): List<ModelChoice> = builtInModels + override suspend fun fetch(deviceId: String): List<ModelChoice> { + fetches += deviceId + return emptyList() + } + } + + private val builtIn = listOf( + ModelChoice("claude-code", "Claude Code", "c1", "Claude 1", listOf("low", "medium", "high")), + ModelChoice("codex", "Codex", "x1", "Codex 1", listOf("low", "medium")), + ) + + private fun project(id: String) = ProjectOption( + id = id, + name = id, + path = "/$id", + device = "dev-$id", + deviceName = "dev-$id", + online = true, + git = true, + groupKey = "repo:$id", + groupName = id, + colorIndex = 0, + ) + + private fun TestScope.store(): NewSessionDraftStore { + val drafts = Drafts(FakePrefsStore(), foregroundAppScope(), StandardTestDispatcher(testScheduler)) + return NewSessionDraftStore( + FakePrefsStore(), + drafts, + foregroundAppScope(), + StandardTestDispatcher(testScheduler), + launchHarness = { null }, + codec = FakeCodec(), + ) + } + + private fun TestScope.page( + store: NewSessionDraftStore, + catalog: FakeCatalog, + side: SideChatParent?, + ): NewSessionModel { + store.warm() + advanceUntilIdle() + val appScope = foregroundAppScope() + val pageScope = CoroutineScope(appScope.coroutineContext + SupervisorJob(appScope.coroutineContext.job)) + return NewSessionModel( + prompt = null, + store = store, + options = MutableStateFlow(NewSessionOptions(listOf(project("A")), emptyList())), + catalog = catalog, + pageScope = pageScope, + appScope = appScope, + io = StandardTestDispatcher(testScheduler), + main = StandardTestDispatcher(testScheduler), + side = side, + ) + } + + // ------------------------------------------------------------ the page + + @Test + fun aSideDraftStartsOnTheParentsModelAndPicksNoCheckout() = runTest { + val store = store() + store.lastDraft = NewSessionDraft(projectId = "A", harness = "claude-code", model = "c1") + store.text = "a half-typed new session" + val catalog = FakeCatalog(builtIn) + val model = page(store, catalog, parent) + advanceUntilIdle() + + assertEquals(NewSessionDraft(harness = "codex", model = "x1", effort = "low"), model.draft) + assertNull("no project: the parent's checkout", model.project) + assertEquals("the parent's host", parent.deviceId, model.deviceId) + assertEquals("never the new-session text", "", model.initialText) + assertEquals(listOf(parent.deviceId), catalog.fetches) + assertEquals("x1", model.selectedModel?.id) + } + + @Test + fun aSideDraftNeverWritesTheStickyStore() = runTest { + val store = store() + store.lastDraft = NewSessionDraft(projectId = "A") + store.text = "kept" + val model = page(store, FakeCatalog(builtIn), parent) + advanceUntilIdle() + + model.update { it.copy(harness = "claude-code", model = "c1") } + model.persistBeforeCreate() + model.rememberDraft("side text", emptyList()) + assertEquals(NewSessionDraft(projectId = "A"), store.lastDraft) + assertEquals("kept", store.text) + model.markCreated() + assertEquals("a side chat's create leaves the new-session text alone", "kept", store.text) + } + + @Test + fun aParentWithoutAHarnessStartsOnTheDefault() { + assertEquals( + NewSessionDraft(harness = DEFAULT_HARNESS), + sideChatDraft(parent.copy(harness = null, model = null, effort = null)), + ) + } + + // ------------------------------------------------------------ the create + + @Test + fun theCreateNamesTheParentAndIgnoresTheDraftsCheckout() { + val draft = NewSessionDraft(projectId = "A", branch = "topic", worktree = true, harness = "codex", model = "x1") + val target = NewSessionRequests.target(draft, parent) + assertEquals(SessionTarget.Projectless(deviceId = parent.deviceId), target) + val create: NewSession = NewSessionRequests.newSession(draft, target!!, parent) + assertEquals(parent.chatId, create.parentChatId) + assertNull("the parent's checkout: no branch", create.branch) + assertEquals("codex", create.config?.harness) + assertEquals("x1", create.config?.model) + val send: SendRequest = NewSessionRequests.sendRequest(draft, "ask", emptyList(), listOf(project("A")), parent) + assertNull("a side chat never makes a worktree", send.worktree) + assertEquals("ask", send.text) + } + + @Test + fun aNewSessionStillCreatesTopLevel() { + val draft = NewSessionDraft(projectId = "A", branch = "main") + val target = NewSessionRequests.target(draft)!! + assertEquals(SessionTarget.Project(spaceId = "A"), target) + val create = NewSessionRequests.newSession(draft, target) + assertNull(create.parentChatId) + assertEquals("main", create.branch) + } + + @Test + fun theCreatorSendsTheSideCreateThenTheFirstMessage() = runTest { + val created = mutableListOf<NewSession>() + val sent = mutableListOf<Pair<String, SendRequest>>() + val creator = NewSessionCreator( + core = object : NewSessionCore { + override suspend fun createSession(newSession: NewSession): String { + created += newSession + return "engine:v1:k:side" + } + + override suspend fun sendFirst(chatId: String, request: SendRequest) { + sent += chatId to request + } + }, + io = StandardTestDispatcher(testScheduler), + logW = { _, _ -> }, + ) + val id = creator.createAndSend(NewSessionDraft(harness = "codex"), "ask", emptyList(), emptyList(), parent) + assertEquals("engine:v1:k:side", id) + assertEquals(parent.chatId, created.single().parentChatId) + assertEquals("engine:v1:k:side", sent.single().first) + } +} diff --git a/apps/android/app/src/test/java/sh/roboco/android/sessions/RunningAgentsPillTest.kt b/apps/android/app/src/test/java/sh/roboco/android/sessions/RunningAgentsPillTest.kt new file mode 100644 index 0000000000..3aefb2c091 --- /dev/null +++ b/apps/android/app/src/test/java/sh/roboco/android/sessions/RunningAgentsPillTest.kt @@ -0,0 +1,89 @@ +package sh.roboco.android.sessions + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import sh.roboco.android.core.Face +import uniffi.roboco_core.ChatIndicator +import uniffi.roboco_core.SessionRow + +/** + * The sessions list's "● N" pill (android-next #06, upstream #899's + * `SessionCell.agents`): the FFI count reaches the view model, the row's + * label speaks it, and the hand layout puts the pill before the status + * glyph and the word — `ceil(text) + 12` wide, `R(18k)` high, centred on + * the title line, 4 dp into the title's gap — with the title ending 8 dp + * before it. No pill, no change. + * + * Plain JUnit 4 (no JNA: every `from` passes `homeColorIndex = 6`). + */ +class RunningAgentsPillTest { + + private fun row(running: UInt): SessionRow = SessionRow( + id = "abc", + engineId = "engine:v1:alpha", + revision = 1uL, + title = "Streaming veil on transcript rows", + hasTitle = true, + preview = null, + project = null, + deviceId = "device", + deviceName = "MacBook Pro", + deviceOnline = true, + engineOnline = true, + harness = "claude-code", + harnessLabel = null, + model = null, + modelLabel = null, + reasoning = null, + branch = null, + cwd = null, + // Idle: no corner word, so the label is title, project, agents. + indicator = ChatIndicator.IDLE, + hostIndicator = ChatIndicator.IDLE, + workingSinceMs = null, + runningSubagents = running, + lastActivityMs = 0L, + timeLabel = "now", + createdAtMs = 0L, + unseen = false, + archived = false, + pinned = false, + sectionId = null, + pullRequest = null, + sendState = null, + parentChatId = null, + ) + + @Test + fun theCountReachesTheViewModelAndTheLabel() { + val vm = SessionRowVM.from(row(4u), homeColorIndex = 6) + assertEquals(4, vm.runningSubagents) + assertEquals("Streaming veil on transcript rows, MacBook Pro, 4 subagents running", sessionRowA11yLabel(vm)) + val one = SessionRowVM.from(row(1u), homeColorIndex = 6) + assertEquals("Streaming veil on transcript rows, MacBook Pro, 1 subagent running", sessionRowA11yLabel(one)) + val none = SessionRowVM.from(row(0u), homeColorIndex = 6) + assertEquals(0, none.runningSubagents) + assertEquals("Streaming veil on transcript rows, MacBook Pro", sessionRowA11yLabel(none)) + assertNull(runningAgentsLabel(0)) + } + + @Test + fun thePillLeadsTheGlyphAndShortensTheTitle() { + val medium = Face.SansMedium + val plain = SessionRowGeometry.frames(411f, 1f, medium, wordWidth = 13.8f, hasGlyph = true) + assertNull(plain.agents) + val f = SessionRowGeometry.frames(411f, 1f, medium, wordWidth = 13.8f, hasGlyph = true, agentsTextWidth = 17.3f) + val pill = f.agents!! + assertEquals("ceil(17.3) + 12", 30f, pill.w, 0.001f) + assertEquals(18f, pill.h, 0.001f) + // Its right edge sits 4 dp into the title's 10 dp gap before the glyph. + assertEquals(f.glyph!!.x - SessionRowGeometry.TITLE_GAP + 4f, pill.x + pill.w, 0.001f) + // Centred on the 22 dp title line at 10. + assertEquals(10f + (22f - 18f) / 2f, pill.y, 0.001f) + // The title ends 8 dp before the pill. + assertEquals(pill.x - 8f, f.title.x + f.title.w, 0.001f) + assertEquals(plain.word, f.word) + assertEquals(plain.glyph, f.glyph) + } +} diff --git a/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsEntryTest.kt b/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsEntryTest.kt new file mode 100644 index 0000000000..c4230d2853 --- /dev/null +++ b/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsEntryTest.kt @@ -0,0 +1,261 @@ +package sh.roboco.android.sidechats + +import androidx.activity.ComponentActivity +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import sh.roboco.android.design.Appearance +import sh.roboco.android.design.RobocoIcons +import sh.roboco.android.design.RobocoTheme +import sh.roboco.android.design.Toaster +import sh.roboco.android.design.glass.Glass +import sh.roboco.android.design.glass.MenuIcon +import sh.roboco.android.design.glass.MenuItem +import sh.roboco.android.design.glass.MenuSection +import sh.roboco.android.session.SessionHeader +import sh.roboco.android.session.SessionHeaderMetrics +import sh.roboco.android.session.SideChatMenuRows +import sh.roboco.android.session.sessionMenu +import uniffi.roboco_core.CoreException + +/** + * The side chats' entry points (android-next #06): the header's second + * bar item with its badge (shown only with something to show, its label + * carrying the running count, the title cap narrowed for it), the ⋯ + * group (New Side Chat / Fork into Side Chat between Copy Transcript and + * Archive, disabled offline or while a fork runs, absent on an engine + * without side chats), the fork's one-at-a-time run and its failure + * copy, and the subagent viewer's subtitle and menu. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w412dp-h900dp-xxhdpi") +@OptIn(ExperimentalCoroutinesApi::class) +class SideChatsEntryTest { + + @get:Rule + val compose = createAndroidComposeRule<ComponentActivity>() + + @Before + fun fallbackGlass() { + Glass.forceFallback = true + } + + @After + fun restoreGlass() { + Glass.forceFallback = false + } + + private class RecordingToaster : Toaster { + val texts = mutableListOf<String>() + override fun show(text: String, action: String?, onAction: (() -> Unit)?) { + texts += text + } + } + + // ------------------------------------------------------------ header button + + @Test + fun theHeaderButtonCarriesTheBadgeAndOpensTheSheet() { + var taps = 0 + compose.setContent { + RobocoTheme(Appearance.Light) { + SessionHeader( + title = "Streaming veil on transcript rows", + subtitle = "roboco @ MacBook Pro", + edgeEngaged = false, + onMore = {}, + showBack = true, + onBack = {}, + statusBarTop = 24.dp, + accessory = { SideChatsButton(SideChatsBadge(running = 4, total = 9), onClick = { taps++ }) }, + ) + } + } + compose.waitForIdle() + val button = compose.onNodeWithTag("side-chats") + assertEquals( + listOf("Side chats and agents, 4 running"), + button.fetchSemanticsNode().config[SemanticsProperties.ContentDescription], + ) + compose.onNodeWithTag("side-chats-badge", useUnmergedTree = true).assertExists() + compose.onNodeWithTag("session-more").assertExists() + button.performClick() + compose.waitForIdle() + assertEquals(1, taps) + } + + @Test + fun noAccessoryNoButton() { + compose.setContent { + RobocoTheme(Appearance.Light) { + SessionHeader( + title = "A chat with nothing hanging off it", + subtitle = "roboco @ studio", + edgeEngaged = false, + onMore = {}, + showBack = true, + onBack = {}, + statusBarTop = 24.dp, + ) + } + } + compose.waitForIdle() + compose.onNodeWithTag("side-chats").assertDoesNotExist() + } + + @Test + fun theTitleCapClearsTheSecondBarItem() { + assertEquals(240f, SessionHeaderMetrics.titleMaxWidth(402f, showBack = true), 0.001f) + val extra = SessionHeaderMetrics.AccessoryExtent + assertEquals(52f, extra, 0.001f) + assertEquals(162f, SessionHeaderMetrics.titleMaxWidth(402f, showBack = true, extraTrailing = extra), 0.001f) + assertEquals(120f, SessionHeaderMetrics.titleMaxWidth(360f, showBack = true, extraTrailing = extra), 0.001f) + } + + // ------------------------------------------------------------ the ⋯ group + + private fun menu(canWrite: Boolean, rows: SideChatMenuRows?) = sessionMenu( + pinned = false, + canWrite = canWrite, + onSetPinned = {}, + onCopyTranscript = {}, + onShowAccounts = {}, + onArchive = {}, + sideChats = rows, + ) + + @Test + fun theSessionMenuGroupsTheSideChatRowsBetweenCopyAndArchive() { + var news = 0 + var forks = 0 + val m = menu(canWrite = true, rows = SideChatMenuRows(forking = false, onNew = { news++ }, onFork = { forks++ })) + // Pin, Copy, the side-chat group, Account & Usage (#05), Archive. + assertEquals(5, m.children.size) + assertEquals("Copy Transcript", (m.children[1] as MenuItem).title) + val group = m.children[2] as MenuSection + assertTrue("an inline group: hairlines above and below", group.inline) + val rows = group.children.map { it as MenuItem } + assertEquals(listOf("New Side Chat", "Fork into Side Chat"), rows.map { it.title }) + assertEquals(RobocoIcons.ArrowTriangleBranch, (rows[1].icon as MenuIcon.Symbol).icon) + assertEquals( + listOf("session-menu-new-side-chat", "session-menu-fork-side-chat"), + rows.map { it.testTag }, + ) + assertFalse(rows[0].disabled) + assertFalse(rows[1].disabled) + rows[0].action() + rows[1].action() + assertEquals(1, news) + assertEquals(1, forks) + assertEquals("Archive", (m.children[4] as MenuItem).title) + } + + @Test + fun offlineOrForkingDisablesTheRowsAndAnOldEngineHasNone() { + val offline = (menu(false, SideChatMenuRows(false, {}, {})).children[2] as MenuSection) + .children.map { it as MenuItem } + assertTrue(offline.all { it.disabled }) + val forking = (menu(true, SideChatMenuRows(true, {}, {})).children[2] as MenuSection) + .children.map { it as MenuItem } + assertFalse("New stays live while a fork runs", forking[0].disabled) + assertTrue("one fork at a time", forking[1].disabled) + val old = menu(true, null) + assertEquals("an engine without side chats: the menu as it was", 4, old.children.size) + assertTrue(old.children.none { it is MenuSection }) + } + + // ------------------------------------------------------------ the fork + + @Test + fun aForkRunsOnceAtATimeAndOpensTheNewChat() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val gate = CompletableDeferred<String>() + val sources = mutableListOf<String>() + val opened = mutableListOf<String>() + val toaster = RecordingToaster() + val fork = SideChatFork( + fork = { source -> + sources += source + gate.await() + }, + scope = CoroutineScope(SupervisorJob() + dispatcher), + toaster = toaster, + logW = { _, _ -> }, + ) + fork.start("engine:v1:k:chat") { opened += it } + fork.start("engine:v1:k:chat") { opened += it } + advanceUntilIdle() + assertTrue(fork.forking) + assertEquals("the second tap is ignored", listOf("engine:v1:k:chat"), sources) + + gate.complete("engine:v1:k:fork") + advanceUntilIdle() + assertFalse(fork.forking) + assertEquals(listOf("engine:v1:k:fork"), opened) + assertTrue(toaster.texts.isEmpty()) + } + + @Test + fun aFailedForkToastsTheHostsReason() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val toaster = RecordingToaster() + val opened = mutableListOf<String>() + val fork = SideChatFork( + fork = { throw CoreException.HostException("nothing to fork yet: no reply has completed") }, + scope = CoroutineScope(SupervisorJob() + dispatcher), + toaster = toaster, + logW = { _, _ -> }, + ) + fork.start("engine:v1:k:chat") { opened += it } + advanceUntilIdle() + assertFalse(fork.forking) + assertTrue(opened.isEmpty()) + assertEquals(listOf("nothing to fork yet: no reply has completed"), toaster.texts) + } + + @Test + fun forkFailureCopyFollowsTheErrorKind() { + assertEquals("busy", forkErrorText(CoreException.HostException("busy"))) + assertEquals(SideChatForkCopy.Failed, forkErrorText(CoreException.HostException(" "))) + assertEquals( + "The host is unreachable (Engine connection lost).", + forkErrorText(CoreException.HostUnavailable("Engine connection lost")), + ) + assertEquals(SideChatForkCopy.Unsupported, forkErrorText(CoreException.Unsupported("ForkSideChat"))) + assertEquals(SideChatForkCopy.Failed, forkErrorText(IllegalStateException("boom"))) + } + + // ------------------------------------------------------------ the viewer + + @Test + fun theViewerSubtitleFollowsTheChip() { + assertEquals("Running · Streaming veil", subagentSubtitle(AgentState.Running, "Streaming veil")) + assertEquals("Subagent · Streaming veil", subagentSubtitle(AgentState.Done, "Streaming veil")) + assertEquals("Failed · Streaming veil", subagentSubtitle(AgentState.Failed, "Streaming veil")) + assertEquals("Subagent", subagentSubtitle(AgentState.Done, " ")) + val copy = subagentMenu(onCopyTranscript = {}).children.single() as MenuItem + assertEquals("Copy Transcript", copy.title) + assertEquals(RobocoIcons.DocOnDoc, (copy.icon as MenuIcon.Symbol).icon) + } +} diff --git a/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsModelTest.kt b/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsModelTest.kt new file mode 100644 index 0000000000..162c32cbd9 --- /dev/null +++ b/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsModelTest.kt @@ -0,0 +1,221 @@ +package sh.roboco.android.sidechats + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import sh.roboco.android.session.SessionEventHub +import sh.roboco.android.sessions.SessionRowVM +import uniffi.roboco_core.ClientEvent +import uniffi.roboco_core.SubagentInfo +import uniffi.roboco_core.SubagentState + +/** + * The side chats state holder (android-next #06): the sections a pull + * yields ([SideChatsUi.of]), the header badge, the FFI lifecycle mapping, + * the engine-version gate, and [SideChatsModel]'s refresh shape — a pull + * on start, one per hub event for the chat (a `SessionChanged` moves the + * spawn chips, a `WorkspaceChanged` the children), the previous rows kept + * on a failed pull, nothing after stop. + * + * Plain JUnit 4 on a [StandardTestDispatcher]: the hub posts inline and + * the uniffi records/enums touch no JNA. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SideChatsModelTest { + + private fun agent(id: String, state: AgentState, at: Long = 0L) = + SideChatsAgent(docId = "engine:v1:k:chat--sub--$id", title = "Agent $id", state = state, spawnedAtMs = at) + + private fun chat(id: String) = SessionRowVM( + id = id, + title = "Side $id", + projectName = "roboco", + hasProject = true, + colorIndex = 6, + harness = "claude-code", + branch = "main", + pr = null, + prNumber = null, + status = SessionRowVM.Status.Idle, + timeLabel = "5m", + unseen = false, + pinned = false, + sendFailed = false, + ) + + /** A scripted source: the next pull's rows, a failure switch and a pull counter. */ + private class FakeSource : SideChatsSource { + var agents: List<SideChatsAgent> = emptyList() + var children: List<SessionRowVM> = emptyList() + var fail = false + var pulls = 0 + val parents = mutableListOf<String>() + + override fun agents(): List<SideChatsAgent> { + pulls++ + if (fail) error("the session closed") + return agents + } + + override fun children(parentChatId: String): List<SessionRowVM> { + parents += parentChatId + return children + } + } + + // ------------------------------------------------------------ the sections + + @Test + fun runningAndFinishedSplitNewestSpawnFirst() { + val ui = SideChatsUi.of( + listOf( + agent("a", AgentState.Done), + agent("b", AgentState.Running), + agent("c", AgentState.Failed), + agent("d", AgentState.Running), + ), + listOf(chat("x"), chat("y")), + ) + assertEquals(listOf("d", "b"), ui.running.map { it.title.removePrefix("Agent ") }) + assertEquals(listOf("c", "a"), ui.finished.map { it.title.removePrefix("Agent ") }) + assertEquals(listOf("x", "y"), ui.chats.map { it.id }) + assertEquals(6, ui.total) + } + + @Test + fun theBadgeIsTheRunningPillOrAQuietCountAndHidesWhenEmpty() { + assertNull("nothing hangs off the chat: no button", SideChatsUi().badge) + + val running = SideChatsUi.of(listOf(agent("a", AgentState.Running), agent("b", AgentState.Running)), listOf(chat("x"))) + val badge = running.badge!! + assertEquals("● 2", badge.text) + assertTrue(badge.accent) + assertEquals("2 running", badge.a11yValue) + assertEquals("Side chats and agents, 2 running", sideChatsButtonLabel(badge)) + + val quiet = SideChatsUi.of(listOf(agent("a", AgentState.Done)), listOf(chat("x"), chat("y"))).badge!! + assertEquals("3", quiet.text) + assertFalse(quiet.accent) + assertNull(quiet.a11yValue) + assertEquals("Side chats and agents", sideChatsButtonLabel(quiet)) + } + + @Test + fun theFfiLifecycleMapsOneToOne() { + assertEquals(AgentState.Running, agentState(SubagentState.RUNNING)) + assertEquals(AgentState.Done, agentState(SubagentState.DONE)) + assertEquals(AgentState.Failed, agentState(SubagentState.FAILED)) + val mirrored = SideChatsAgent.from( + SubagentInfo( + docId = "engine:v1:k:chat--sub--t1", + title = "Audit the repo", + state = SubagentState.RUNNING, + tail = null, + model = "haiku", + spawnedAtMs = 1_000L, + ), + ) + assertEquals( + SideChatsAgent("engine:v1:k:chat--sub--t1", "Audit the repo", AgentState.Running, null, "haiku", 1_000L), + mirrored, + ) + assertTrue(mirrored.running) + } + + @Test + fun sideChatsNeedAnEngineFromHalfAYearAgoOrAnUnknownOne() { + assertFalse("0.4.x predates ForkSideChat", sideChatsSupported("0.4.3")) + assertTrue(sideChatsSupported("0.5.0")) + assertTrue(sideChatsSupported("v0.9.2")) + assertTrue(sideChatsSupported("1.0.0")) + assertTrue("unknown version: offered (the engine answers)", sideChatsSupported(null)) + assertTrue(sideChatsSupported("dev")) + } + + // ------------------------------------------------------------ the model + + @Test + fun pullsOnStartOnTheChatsEventsAndStopsAfterStop() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val hub = SessionEventHub(post = { it.run() }) + val source = FakeSource().apply { + agents = listOf(agent("a", AgentState.Running)) + children = listOf(chat("x")) + } + val model = SideChatsModel( + parentChatId = "engine:v1:k:chat", + source = source, + hub = hub, + scope = CoroutineScope(SupervisorJob() + dispatcher), + io = dispatcher, + ) + assertNull("no pull before start", model.ui.value.badge) + + model.start() + advanceUntilIdle() + assertEquals(1, source.pulls) + assertEquals(listOf("engine:v1:k:chat"), source.parents) + assertEquals(1, model.ui.value.running.size) + assertEquals(1, model.ui.value.chats.size) + + // The chip settles: a SessionChanged for this chat re-pulls. + source.agents = listOf(agent("a", AgentState.Done)) + hub.onClientEvent(ClientEvent.SessionChanged(chatId = "engine:v1:k:chat", revision = 2uL)) + advanceUntilIdle() + assertEquals(2, source.pulls) + assertTrue(model.ui.value.running.isEmpty()) + assertEquals(1, model.ui.value.finished.size) + + // Another chat's transcript changes nothing here. + hub.onClientEvent(ClientEvent.SessionChanged(chatId = "engine:v1:k:other", revision = 3uL)) + advanceUntilIdle() + assertEquals(2, source.pulls) + + // A new side chat lands with the workspace. + source.children = listOf(chat("x"), chat("y")) + hub.onClientEvent(ClientEvent.WorkspaceChanged(revision = 4uL)) + advanceUntilIdle() + assertEquals(3, source.pulls) + assertEquals(2, model.ui.value.chats.size) + + model.stop() + hub.onClientEvent(ClientEvent.WorkspaceChanged(revision = 5uL)) + model.invalidate() + advanceUntilIdle() + assertEquals("stopped: no more pulls", 3, source.pulls) + } + + @Test + fun aFailedPullKeepsThePreviousRows() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val source = FakeSource().apply { agents = listOf(agent("a", AgentState.Running)) } + val errors = mutableListOf<String>() + val model = SideChatsModel( + parentChatId = "engine:v1:k:chat", + source = source, + hub = SessionEventHub(post = { it.run() }), + scope = CoroutineScope(SupervisorJob() + dispatcher), + io = dispatcher, + logW = { message, _ -> errors += message }, + ) + model.start() + advanceUntilIdle() + assertEquals(1, model.ui.value.running.size) + + source.fail = true + model.invalidate() + advanceUntilIdle() + assertEquals(2, source.pulls) + assertEquals("the rows stay", 1, model.ui.value.running.size) + assertEquals(listOf("side chats refresh failed"), errors) + model.stop() + } +} diff --git a/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsSheetTest.kt b/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsSheetTest.kt new file mode 100644 index 0000000000..0148b3b5d4 --- /dev/null +++ b/apps/android/app/src/test/java/sh/roboco/android/sidechats/SideChatsSheetTest.kt @@ -0,0 +1,314 @@ +package sh.roboco.android.sidechats + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import sh.roboco.android.design.Appearance +import sh.roboco.android.design.RobocoIcons +import sh.roboco.android.design.RobocoTheme +import sh.roboco.android.design.glass.Glass +import sh.roboco.android.design.glass.GlassMenuHost +import sh.roboco.android.design.glass.MenuIcon +import sh.roboco.android.design.glass.MenuItem +import sh.roboco.android.sessions.SessionRowVM + +/** + * The Side Chats & Agents sheet (android-next #06, upstream #899's + * `SideChatsViewController`): the three foldable sections and their rows + * ([SideChatsList] — the header's count over every row, folded or not; a + * tap on a header folds it; empty sections left out), the subagent row + * (its spoken label, the trailing model or age), the row taps, the + + * menu, and the overlay's + hiding on an engine without side chats. + * + * Robolectric + compose-ui-test with the fallback glass (no blur), the + * clock auto-advancing (the sheet's present spring settles in + * `waitForIdle`; the spinners are infinite animations ui-test ignores). + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w412dp-h900dp-xxhdpi") +class SideChatsSheetTest { + + @get:Rule + val compose = createAndroidComposeRule<ComponentActivity>() + + @Before + fun setUp() { + Glass.forceFallback = true + SideChatsFolds.clear() + } + + @After + fun tearDown() { + Glass.forceFallback = false + SideChatsFolds.clear() + } + + private val openedChats = mutableListOf<String>() + private val openedAgents = mutableListOf<SideChatsAgent>() + + private val running = SideChatsAgent( + docId = "engine:v1:k:chat--sub--run", + title = "Audit the reduced-motion fallback", + state = AgentState.Running, + model = "haiku", + spawnedAtMs = 0L, + ) + private val done = SideChatsAgent( + docId = "engine:v1:k:chat--sub--done", + title = "Check CJK line breaking", + state = AgentState.Done, + spawnedAtMs = 0L, + ) + private val failed = SideChatsAgent( + docId = "engine:v1:k:chat--sub--fail", + title = "Run the veil test matrix", + state = AgentState.Failed, + spawnedAtMs = 0L, + ) + + private fun chat(id: String, title: String) = SessionRowVM( + id = id, + title = title, + projectName = "roboco", + hasProject = true, + colorIndex = 6, + harness = "claude-code", + branch = "main", + pr = null, + prNumber = null, + status = SessionRowVM.Status.Idle, + timeLabel = "5m", + unseen = false, + pinned = false, + sendFailed = false, + ) + + private val ui = SideChatsUi( + running = listOf(running), + chats = listOf(chat("engine:v1:k:side", "Veil under Reduce Motion")), + finished = listOf(failed, done), + ) + + private fun mountList(ui: SideChatsUi) { + compose.setContent { + RobocoTheme(Appearance.Light) { + Box(Modifier.fillMaxSize()) { + SideChatsList( + ui = ui, + bottomInset = 0f, + onOpenChat = { openedChats += it }, + onOpenAgent = { openedAgents += it }, + ) + } + } + } + compose.waitForIdle() + } + + // ------------------------------------------------------------ sections + + @Test + fun theThreeSectionsListTheirRowsUnderCountedHeaders() { + mountList(ui) + compose.onNodeWithTag("section-side-running").assertExists() + compose.onNodeWithTag("section-side-chats").assertExists() + compose.onNodeWithTag("section-side-finished").assertExists() + assertEquals( + listOf("Running, 1"), + compose.onNodeWithTag("section-side-running").fetchSemanticsNode().config[SemanticsProperties.ContentDescription], + ) + assertEquals( + listOf("Finished agents, 2"), + compose.onNodeWithTag("section-side-finished").fetchSemanticsNode().config[SemanticsProperties.ContentDescription], + ) + compose.onNodeWithTag("subagent-${running.docId}").assertExists() + compose.onNodeWithTag("subagent-${done.docId}").assertExists() + compose.onNodeWithTag("subagent-${failed.docId}").assertExists() + compose.onNodeWithTag("session-engine:v1:k:side").assertExists() + } + + @Test + fun anEmptySectionIsLeftOut() { + mountList(SideChatsUi(chats = listOf(chat("engine:v1:k:side", "Only a side chat")))) + compose.onNodeWithTag("section-side-chats").assertExists() + compose.onNodeWithTag("section-side-running").assertDoesNotExist() + compose.onNodeWithTag("section-side-finished").assertDoesNotExist() + } + + @Test + fun aHeaderTapFoldsItsSectionAndKeepsTheCount() { + mountList(ui) + compose.onNodeWithTag("section-side-finished").performClick() + compose.waitForIdle() + assertTrue(SideChatsFolds.isFolded(SideChatsSection.Finished)) + compose.onNodeWithTag("subagent-${done.docId}").assertDoesNotExist() + compose.onNodeWithTag("subagent-${failed.docId}").assertDoesNotExist() + compose.onNodeWithTag("subagent-${running.docId}").assertExists() + assertEquals( + "the count is over every row, folded or not", + listOf("Finished agents, 2"), + compose.onNodeWithTag("section-side-finished").fetchSemanticsNode().config[SemanticsProperties.ContentDescription], + ) + compose.onNodeWithTag("section-side-finished").performClick() + compose.waitForIdle() + assertFalse(SideChatsFolds.isFolded(SideChatsSection.Finished)) + compose.onNodeWithTag("subagent-${done.docId}").assertExists() + } + + // ------------------------------------------------------------ rows + + @Test + fun rowTapsOpenTheSubagentOrTheSideChat() { + mountList(ui) + compose.onNodeWithTag("subagent-${running.docId}").performClick() + compose.onNodeWithTag("session-engine:v1:k:side").performClick() + compose.waitForIdle() + assertEquals(listOf(running), openedAgents) + assertEquals(listOf("engine:v1:k:side"), openedChats) + } + + @Test + fun aSubagentRowSpeaksItsStateModelAndTail() { + assertEquals( + "Subagent, Audit the reduced-motion fallback, Running, Haiku", + subagentRowA11yLabel(running), + ) + assertEquals( + "Subagent, Audit, Running, Reading RowView.swift", + subagentRowA11yLabel(SideChatsAgent("d", "Audit", AgentState.Running, tail = "Reading RowView.swift")), + ) + assertEquals("Subagent, Run the veil test matrix, Failed", subagentRowA11yLabel(failed)) + mountList(ui) + assertEquals( + listOf("Subagent, Check CJK line breaking, Done"), + compose.onNodeWithTag("subagent-${done.docId}").fetchSemanticsNode().config[SemanticsProperties.ContentDescription], + ) + } + + @Test + fun finishedAgentsShowTheirAgeLikeTheSessionList() { + val now = 10_000_000L + assertEquals("now", relativeAgentTime(now - 59_000L, now)) + assertEquals("34m", relativeAgentTime(now - 34 * 60_000L, now)) + assertEquals("2h", relativeAgentTime(now - 2 * 3_600_000L - 5_000L, now)) + assertEquals("1d", relativeAgentTime(now - 86_400_000L, now - 0L)) + assertEquals("a future spawn reads now", "now", relativeAgentTime(now + 5_000L, now)) + assertEquals("Haiku", capitalizedModel("haiku")) + assertEquals("", capitalizedModel("")) + } + + // ------------------------------------------------------------ the + menu + + @Test + fun thePlusMenuOffersNewAndForkInOrder() { + var news = 0 + var forks = 0 + val menu = sideChatsAddMenu(onNew = { news++ }, onFork = { forks++ }) + assertNull(menu.title) + val items = menu.children.map { it as MenuItem } + assertEquals(listOf("New Side Chat", "Fork into Side Chat"), items.map { it.title }) + assertEquals(RobocoIcons.ArrowTriangleBranch, (items[1].icon as MenuIcon.Symbol).icon) + items[0].action() + items[1].action() + assertEquals(1, news) + assertEquals(1, forks) + } + + @Test + fun theOverlayHidesThePlusOnAnEngineWithoutSideChats() { + var canAdd by mutableStateOf(false) + val controller = SideChatsSheetController() + controller.open() + compose.setContent { + RobocoTheme(Appearance.Light) { + GlassMenuHost { + Box(Modifier.fillMaxSize()) { + SideChatsOverlay( + controller = controller, + ui = ui, + canAdd = canAdd, + onOpenChat = {}, + onOpenAgent = {}, + onNew = {}, + onFork = {}, + ) + } + } + } + } + compose.waitForIdle() + compose.onNodeWithText(SideChatsCopy.Title).assertExists() + compose.onNodeWithTag("side-chats-add").assertDoesNotExist() + compose.onNodeWithTag("sheet-done").assertExists() + + compose.runOnUiThread { canAdd = true } + compose.waitForIdle() + compose.onNodeWithTag("side-chats-add").assertExists() + } + + @Test + fun doneDismissesTheSheetThenRunsNothing() { + val controller = SideChatsSheetController() + controller.open() + var news = 0 + compose.setContent { + RobocoTheme(Appearance.Light) { + GlassMenuHost { + Box(Modifier.fillMaxSize()) { + SideChatsOverlay( + controller = controller, + ui = ui, + canAdd = true, + onOpenChat = {}, + onOpenAgent = {}, + onNew = { news++ }, + onFork = {}, + ) + } + } + } + } + compose.waitForIdle() + compose.onNodeWithTag("sheet-done").performClick() + compose.waitForIdle() + assertFalse("the sheet left after its exit animation", controller.showing) + assertEquals(0, news) + compose.onNodeWithTag("side-chats-sheet").assertDoesNotExist() + } + + @Test + fun theControllerRunsTheWaitingActionOnceDismissed() { + val controller = SideChatsSheetController() + controller.open() + var forks = 0 + controller.dismissThen { forks++ } + assertEquals("nothing before the sheet is gone", 0, forks) + controller.onDismissed() + assertFalse(controller.showing) + assertEquals(1, forks) + controller.onDismissed() + assertEquals("the action runs once", 1, forks) + } +} diff --git a/apps/android/app/src/test/java/sh/roboco/android/transcript/widgets/SubagentLinkTest.kt b/apps/android/app/src/test/java/sh/roboco/android/transcript/widgets/SubagentLinkTest.kt new file mode 100644 index 0000000000..0b4a886670 --- /dev/null +++ b/apps/android/app/src/test/java/sh/roboco/android/transcript/widgets/SubagentLinkTest.kt @@ -0,0 +1,92 @@ +package sh.roboco.android.transcript.widgets + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import sh.roboco.android.design.Appearance +import sh.roboco.android.design.RobocoTheme +import sh.roboco.android.transcript.TranscriptActions +import sh.roboco.android.transcript.isControl +import sh.roboco.android.transcript.isTranscriptControl +import uniffi.roboco_core.WidgetKind + +/** + * The subagent spawn card's tap target (android-next #06, upstream #899's + * `subagentLink` — a `ToolToggleControl` over the whole card): it opens + * the subagent once per tap, says "Open subagent: <title>" as a button, + * claims its down from the composer-dismiss tap like every transcript + * control, and keeps the row menu away (a control kind); the default + * actions leave a card inert. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w360dp-h300dp-mdpi") +class SubagentLinkTest { + + @get:Rule + val compose = createAndroidComposeRule<ComponentActivity>() + + private val link = WidgetKind.SubagentLink( + docId = "engine:v1:k:chat--sub--toolu_1", + title = "Audit the reduced-motion fallback", + frozen = false, + ) + + @Test + fun aTapOpensTheSubagentOnceAndSpeaksAsAButton() { + val opened = mutableListOf<String>() + compose.setContent { + RobocoTheme(Appearance.Light) { + Box(Modifier.fillMaxSize()) { + // The Rust rect of a 30 pt card in its 38 pt row (T = 1.125). + Box(Modifier.absoluteOffset(18.dp, 4.dp).size(324.dp, 33.75.dp)) { + SubagentLinkTarget(title = link.title, onOpen = { opened += link.docId }) + } + } + } + } + compose.waitForIdle() + val node = compose.onNodeWithTag("subagent-link") + val config = node.fetchSemanticsNode().config + assertEquals(listOf("Open subagent: Audit the reduced-motion fallback"), config[SemanticsProperties.ContentDescription]) + assertEquals(Role.Button, config[SemanticsProperties.Role]) + node.performClick() + compose.waitForIdle() + assertEquals(listOf(link.docId), opened) + } + + @Test + fun theLinkIsATranscriptControl() { + assertTrue("its down never dismisses the composer", link.isTranscriptControl) + assertTrue("a long press over it opens no row menu", link.isControl()) + assertFalse(WidgetKind.Spinner.isTranscriptControl) + } + + @Test + fun theDefaultActionsLeaveTheCardInert() { + // No crash, no effect: tests and previews build actions without it. + TranscriptActions().openSubagent(link.docId, link.title, link.frozen) + var seen: Triple<String, String, Boolean>? = null + TranscriptActions(openSubagent = { d, t, f -> seen = Triple(d, t, f) }) + .openSubagent(link.docId, link.title, true) + assertEquals(Triple(link.docId, link.title, true), seen) + } +} diff --git a/crates/mobile-client/src/lib.rs b/crates/mobile-client/src/lib.rs index 2f14c98d67..51eea7ce78 100644 --- a/crates/mobile-client/src/lib.rs +++ b/crates/mobile-client/src/lib.rs @@ -80,7 +80,8 @@ pub use session::{ BusyPolicy, ComposerState, HostCapabilities, HostInfo, InputRequest, LiveStatus, OutgoingAttachment, PendingKind, PendingSend, QueueEditAction, QueueEditFinish, QueueEditLease, QueueEditStart, QueueGate, QueueItem, RoomState, SendOutcome, SendRequest, SessionHandle, - SessionSnapshot, SnapshotWatch, UNDELIVERED_GRACE_MS, + SessionSnapshot, SnapshotWatch, SubagentRow, SubagentState, UNDELIVERED_GRACE_MS, + subagent_title, }; // `SendState` binds once at the crate root, through workspace (07's // canonical re-export): session/pending re-exports the same type, so diff --git a/crates/mobile-client/src/new_session.rs b/crates/mobile-client/src/new_session.rs index 35a1257d32..616cedcae7 100644 --- a/crates/mobile-client/src/new_session.rs +++ b/crates/mobile-client/src/new_session.rs @@ -14,13 +14,14 @@ //! helper here. 08's `send` reads the pending chat's `cwd`/`config`. use std::collections::HashMap; +use std::sync::Arc; use chrono::{DateTime, Utc}; use roboco_engine_client::registry::{EngineKey, ScopedId}; use roboco_proto::ChatIndicator; use crate::workspace::ProjectView; -use crate::{Client, ClientError}; +use crate::{Client, ClientError, ClientInner}; /// How long a pending chat stays in the workspace before the engine's /// `WatchChats` echo is assumed lost: the overlay drops after this, and @@ -51,6 +52,11 @@ pub struct NewSession { pub cwd: Option<String>, /// A non-blank title rides a `renameChat` after the create. pub title: Option<String>, + /// A side chat of this chat (upstream #899): `target`, `cwd` and + /// `branch` are ignored — it runs in its parent's checkout on its + /// parent's host, and lists under the parent. A side chat's own side + /// chats hang off the same root instead (siblings). + pub parent_chat_id: Option<String>, } /// One chat created but not yet echoed by `WatchChats` — the optimistic @@ -75,6 +81,10 @@ pub struct PendingChat { /// The chat's run configuration. pub config: Option<roboco_proto::ChatConfig>, pub branch: Option<String>, + /// The parent a side chat hangs off (scoped); `None` for a top-level + /// chat. The synthetic row lists under its parent, never the front + /// page. + pub parent_chat_id: Option<String>, pub created_at: DateTime<Utc>, } @@ -111,7 +121,7 @@ impl PendingChat { space_id: self.space_id.clone(), last_seen_at: None, room_gen: None, - parent_chat_id: None, + parent_chat_id: self.parent_chat_id.clone(), } } } @@ -245,6 +255,13 @@ impl Client { /// `WorktreeSpec` and the engine materializes it on run. pub async fn create_session(&self, new: NewSession) -> Result<String, ClientError> { let inner = self.inner_arc(); + // A side chat of an open chat (upstream #899): everything comes + // from the parent's row — target, cwd and branch are ignored. + if let Some(parent_id) = new.parent_chat_id.as_deref() { + return self + .create_side_chat(&inner, parent_id, new.config, new.title) + .await; + } // 1. Resolve the target: the project (its engine owns the row, the // default cwd is its folder) or an execution host. let target = match new.target { @@ -354,12 +371,181 @@ impl Client { cwd: target.cwd, config: new.config.clone(), branch: new.branch.clone().filter(|b| !b.is_empty()), + parent_chat_id: None, created_at: Utc::now(), }); } // 7. The scoped id: what every other client surface speaks. Ok(scoped_chat_id) } + + /// An empty side chat of `parent_id` (upstream #899's "New Side + /// Chat"): the parent's host, project and checkout with a fresh + /// identity, created through `Mutate createChat` with `parentChatId`. + /// A side chat of a side chat hangs off the same root — a sibling. + /// The run config is the caller's pick, but the sandbox stays the + /// parent's (the checkout is the parent's). Returns the scoped id. + async fn create_side_chat( + &self, + inner: &Arc<ClientInner>, + parent_id: &str, + config: Option<roboco_proto::ChatConfig>, + title: Option<String>, + ) -> Result<String, ClientError> { + // 1. The parent's row, and the root the new chat hangs off (the + // parent, or the parent's parent when the parent is itself a + // side chat — a sibling, like the fork button). + let workspace = self.workspace(); + let Some(parent) = workspace.session(parent_id) else { + return Err(ClientError::NotFound { + message: format!("Unknown session: {parent_id}"), + }); + }; + let root = parent + .parent_chat_id + .as_deref() + .and_then(|grand| workspace.session(grand)) + .unwrap_or(parent); + // 2. The engine that owns the row — must be connected, like any + // create (nothing is queued; story 24). + let handle = self.engine_handle(&root.engine_id)?; + crate::workspace::ensure_writable(&handle)?; + // 3. Mint the id (the engine's createChat is idempotent by it). + let raw_chat_id = uuid::Uuid::new_v4().to_string(); + // 4. Create the row in the parent's checkout: `spaceId` when the + // root has a project, else the root's host device; `cwd` the + // root's checkout (its own override, else the project folder — + // the pending overlay's cwd must match what the row will say, + // so 08's first send runs in the parent's folder). + let root_space = root.project.as_ref().and_then(|project| { + let raw = ScopedId::parse(&project.id).ok()?.raw_id; + workspace + .project(&project.id) + .map(|space| (raw, space.path.clone())) + }); + let root_raw_device = ScopedId::parse(&root.device_id) + .ok() + .map(|scoped| scoped.raw_id) + .ok_or_else(|| ClientError::NotFound { + message: format!("Unknown session: {parent_id}"), + })?; + let cwd = root + .cwd + .clone() + .or_else(|| root_space.as_ref().map(|(_, path)| path.clone())) + .unwrap_or_else(|| "~".to_owned()); + // 5. The sandbox stays the parent's (the run config is the pick). + let config = config.map(|mut config| { + let parent_sandbox = inner + .workspace + .get() + .and_then(|store| store.session_config(&root.id)) + .map(|wire| wire.sandbox); + if let Some(sandbox) = parent_sandbox { + config.sandbox = sandbox; + } + config + }); + let mut params = serde_json::Map::new(); + params.insert("op".into(), "createChat".into()); + params.insert("chatId".into(), raw_chat_id.clone().into()); + params.insert("parentChatId".into(), root.id.clone().into()); + if let Some((raw_space, _)) = root_space { + params.insert("spaceId".into(), raw_space.into()); + } else { + params.insert("deviceId".into(), root_raw_device.into()); + } + if let Some(config) = &config { + let wire = serde_json::to_value(config).map_err(|error| ClientError::Internal { + message: error.to_string(), + })?; + params.insert("config".into(), wire); + } + params.insert("cwd".into(), cwd.clone().into()); + handle + .call( + roboco_rpc::methods::MUTATE, + serde_json::Value::Object(params), + ) + .await?; + // 6. The title rides a rename, as on the New Session canvas. + if let Some(title) = title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + handle + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "renameChat", + "chatId": raw_chat_id, + "title": title, + }), + ) + .await?; + } + // 7. The optimistic overlay: the row (under its parent, never the + // front page) until the engine's `WatchChats` echo replaces + // it; 08's send reads its cwd. + let engine_id = handle.engine_id().to_owned(); + let scoped_chat_id = ScopedId::encode(&EngineKey(engine_id.clone()), &raw_chat_id); + if let Some(store) = inner.workspace.get() { + store.register_pending_chat(PendingChat { + engine_id, + chat_id: scoped_chat_id.clone(), + raw_chat_id, + space_id: root.project.as_ref().map(|p| p.id.clone()), + device_id: root.device_id.clone(), + cwd, + config: config.clone(), + branch: None, + parent_chat_id: Some(root.id.clone()), + created_at: Utc::now(), + }); + } + Ok(scoped_chat_id) + } + + /// Fork `source_id` through its latest completed response into a side + /// chat (upstream #899's "Fork into Side Chat"): the engine copies + /// the history, appends the fork seam and publishes the row. A side + /// chat's fork lists as its sibling (the source's parent becomes the + /// fork's parent). Fails with the engine's reason when nothing is + /// completed yet. Returns the scoped id of the fork. + pub async fn fork_side_chat(&self, source_id: &str) -> Result<String, ClientError> { + let handle = self.resolve(source_id)?.0; + // The parent the fork hangs under: the source, or the source's + // parent when the source is a side chat (a sibling). + let workspace = self.workspace(); + let parent = workspace + .session(source_id) + .and_then(|row| row.parent_chat_id.clone()) + .filter(|parent| !parent.is_empty()) + .and_then(|parent| { + // A dangling parent (the row left) falls back to the source. + workspace.session(&parent).map(|_| parent) + }) + .unwrap_or_else(|| source_id.to_owned()); + let raw_chat_id = uuid::Uuid::new_v4().to_string(); + let reply = handle + .call( + roboco_rpc::methods::FORK_SIDE_CHAT, + serde_json::json!({ + "chatId": raw_chat_id, + "sourceChatId": source_id, + "parentChatId": parent, + }), + ) + .await?; + let chat: roboco_proto::Chat = serde_json::from_value(reply).map_err(|error| { + ClientError::Internal { + message: format!("unexpected fork reply: {error}"), + } + })?; + // The engine published the row; this client's `WatchChats` echo + // replaces nothing here (the id is fresh) and the caller can open + // the fork at once — a scoped id naming a paired engine opens + // before the echo lands. + let scoped = ScopedId::encode(&EngineKey(handle.engine_id().to_owned()), &chat.id); + Ok(scoped) + } } #[cfg(test)] @@ -456,6 +642,7 @@ mod tests { cwd: "~".into(), config: None, branch: None, + parent_chat_id: None, created_at: DateTime::<Utc>::from_timestamp_millis(10_000).unwrap(), }; assert!(pending.finished(true, 10_050)); @@ -485,6 +672,7 @@ mod tests { sandbox: roboco_proto::SandboxLevel::WorkspaceWrite, }), branch: Some("main".into()), + parent_chat_id: None, created_at: DateTime::<Utc>::from_timestamp_millis(10_000).unwrap(), }; let chat = pending.synthetic_chat("engine:v1:xyz"); @@ -542,6 +730,7 @@ mod tests { branch: Some("main".into()), cwd: None, title: Some(" My chat ".into()), + parent_chat_id: None, }) .await .expect("create_session"); @@ -592,6 +781,7 @@ mod tests { branch: None, cwd: None, title: None, + parent_chat_id: None, }) .await .expect("create_session"); @@ -658,6 +848,7 @@ mod tests { branch: None, cwd: None, title: None, + parent_chat_id: None, }) .await .expect_err("phones never host sessions"); @@ -685,6 +876,7 @@ mod tests { branch: None, cwd: None, title: None, + parent_chat_id: None, }) .await .expect_err("offline refuses"); @@ -721,6 +913,7 @@ mod tests { branch: None, cwd: None, title: None, + parent_chat_id: None, }) .await .expect("create_session"); @@ -785,6 +978,7 @@ mod tests { branch: None, cwd: None, title: None, + parent_chat_id: None, }) .await .expect("create_session"); diff --git a/crates/mobile-client/src/session/mod.rs b/crates/mobile-client/src/session/mod.rs index 175d0d62bc..e78d75a743 100644 --- a/crates/mobile-client/src/session/mod.rs +++ b/crates/mobile-client/src/session/mod.rs @@ -25,6 +25,8 @@ use std::sync::{Arc, Mutex, Weak}; use std::time::Duration; use roboco_doc::MessageStatus; +use roboco_doc::MessagePart; +use roboco_doc::parts::SubagentStatus; use roboco_doc::schema::SessionMessageEntry; use roboco_engine_client::registry::ScopedId; use roboco_proto::ContextUsage; @@ -40,6 +42,166 @@ pub use pending::{PendingKind, PendingSend, SendState, UNDELIVERED_GRACE_MS}; pub use queue::{QueueEditAction, QueueEditFinish, QueueEditLease, QueueEditStart, QueueGate, QueueItem}; pub use transcript::apply_frame_arc; +// ── subagents (upstream #899, engine-local) ──────────────────────────────── + +/// Where a spawned subagent stands (its spawn chip's `subagent_status`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SubagentState { + Running, + Done, + Failed, +} + +/// A subagent this session's agent spawned: one linked spawn chip (the +/// chip IS the index — the engine stamps the doc id onto it; there is no +/// listing endpoint). Direct children only; nested spawns live in the +/// subagent's own transcript. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubagentRow { + /// Its transcript doc (`{chatId}--sub--{id}`, scoped on this client) + /// — what [`Client::open_subagent`] takes. + pub doc_id: String, + /// What the spawn asked for ("Audit grapheme handling"). + pub title: String, + pub state: SubagentState, + /// The chip's one-line live tail, when the doc carries one. Today's + /// engine never writes it (live tails were rejected: per-delta chip + /// rewrites grew the parent doc — `crates/engine/src/sessions.rs` + /// `subagent_chip_update`), so this reads `None` in practice; older or + /// future docs that stamp `subagentTail` still surface it. + pub tail: Option<String>, + /// The model the spawn picked, when it named one. + pub model: Option<String>, + /// When the spawning reply was written. + pub spawned_at_ms: i64, +} + +/// The subagents of one transcript, in spawn order: every spawn chip the +/// engine linked to a subagent doc. `resolved` alone is NOT "done" — +/// under the eager-done policy the spawn tool's own result lands while +/// the subagent still runs; `subagent_status` is the lifecycle. Pure. +pub(crate) fn subagents_of(entries: &[Arc<SessionMessageEntry>]) -> Vec<SubagentRow> { + let mut rows: Vec<SubagentRow> = Vec::new(); + for entry in entries { + let at = entry.created_at; + for part in &entry.parts { + let MessagePart::Tool { + call, + is_error, + resolved, + subagent_ref: Some(doc_id), + subagent_status, + subagent_tail, + .. + } = part + else { + continue; + }; + if !call.is_subagent_spawn() { + continue; + } + let state = match subagent_status { + Some(SubagentStatus::Running) => SubagentState::Running, + Some(SubagentStatus::Failed) => SubagentState::Failed, + _ if *is_error => SubagentState::Failed, + Some(SubagentStatus::Done) => SubagentState::Done, + None if *resolved => SubagentState::Done, + None => SubagentState::Running, + }; + // A re-spawn (a steered subagent) updates its row in place. + let row = SubagentRow { + doc_id: doc_id.clone(), + title: subagent_title(call), + state, + tail: subagent_tail + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(str::to_owned), + model: call.subagent_model().map(str::to_owned), + spawned_at_ms: at, + }; + match rows.iter_mut().find(|r| r.doc_id == row.doc_id) { + Some(existing) => *existing = row, + None => rows.push(row), + } + } + } + rows +} + +/// Max chars a subagent title keeps (desktop `SUBAGENT_TITLE_MAX`). +const SUBAGENT_TITLE_MAX: usize = 40; + +/// A spawn chip's subagent title — the desktop's `subagent_tab_title` +/// (`crates/ui/src/transcript.rs`), so the phone's sheet, card link and +/// viewer name a subagent exactly as the desktop's tab strip does: the +/// BARE task ("Agent: scan repo" → "scan repo"), backed up by the call +/// input's `description`/`prompt` (a bare "Agent"/"Task" spawn), first +/// line only, capped with an ellipsis; "Subagent" as the last resort. +/// Every native driver decodes its spawn as `Unknown { "Agent[: desc]" }` +/// (claude `Agent`/`Task` → `crates/harness/src/claude/normalize.rs`). +pub fn subagent_title(call: &roboco_proto::ToolCall) -> String { + use roboco_proto::ToolCall; + let (name, input) = match call { + ToolCall::Unknown { name, input } => (name.as_str(), input.as_ref()), + ToolCall::Mcp { tool, input, .. } => (tool.as_str(), input.as_ref()), + _ => return "Subagent".to_owned(), + }; + let candidates = [ + Some(name), + input.and_then(|i| i.get("description")?.as_str()), + input.and_then(|i| i.get("prompt")?.as_str()), + ]; + candidates + .into_iter() + .flatten() + .find_map(|text| title_line(strip_spawn_prefix(text), SUBAGENT_TITLE_MAX)) + .unwrap_or_else(|| "Subagent".to_owned()) +} + +/// First non-blank line of `text`, trimmed, capped at `max` chars with an +/// ellipsis (desktop `title_line`). +fn title_line(text: &str, max: usize) -> Option<String> { + let line = text.lines().find(|l| !l.trim().is_empty())?.trim(); + let mut out: String = line.chars().take(max).collect(); + if line.chars().count() > max { + out.push('…'); + } + Some(out) +} + +/// Drop a leading "Agent"/"Task" genus (with its `:` and spacing); only a +/// word boundary strips — "Taskmaster" keeps its name (desktop +/// `strip_spawn_prefix`). +fn strip_spawn_prefix(text: &str) -> &str { + let t = text.trim(); + for prefix in ["agent", "task"] { + if t.len() >= prefix.len() + && t.is_char_boundary(prefix.len()) + && t[..prefix.len()].eq_ignore_ascii_case(prefix) + { + let rest = &t[prefix.len()..]; + if rest.is_empty() { + return ""; + } + if rest.starts_with(':') || rest.starts_with(char::is_whitespace) { + return rest.trim_start_matches(':').trim(); + } + } + } + t +} + +/// Subagents whose spawn chips still run — the sessions-list "● N" pill +/// (plus the working side chats the view layers on top). Pure. +fn running_subagent_count(entries: &[Arc<SessionMessageEntry>]) -> u32 { + subagents_of(entries) + .into_iter() + .filter(|row| row.state == SubagentState::Running) + .count() as u32 +} + use crate::cache::EngineCacheHandle; use crate::{Client, ClientInner}; @@ -81,6 +243,13 @@ impl SessionSnapshot { pub fn host_entries(&self) -> &[Arc<SessionMessageEntry>] { &self.entries } + + /// Subagents spawned in this transcript, in spawn order (pull after + /// `SessionChanged`). Empty while the transcript has no linked spawn + /// chips. + pub fn subagents(&self) -> Vec<SubagentRow> { + subagents_of(&self.entries) + } } // ── mutable state ────────────────────────────────────────────────────────── @@ -123,6 +292,10 @@ pub(crate) struct SessionCore { pub(crate) chat_id: String, /// The engine's raw chat id — what the wire sees. pub(crate) raw_chat_id: String, + /// A subagent transcript (`{chatId}--sub--{id}`): read-only — no chat + /// row exists, so nothing marks it seen and no send is expected (the + /// UI shows no composer; upstream #899's viewer contract). + pub(crate) read_only: bool, pub(crate) client: Weak<ClientInner>, pub(crate) cache: EngineCacheHandle, pub(crate) state: Mutex<SessionState>, @@ -145,6 +318,7 @@ impl SessionCore { client: Weak<ClientInner>, cache: EngineCacheHandle, tasks: CancellationToken, + read_only: bool, ) -> Arc<Self> { // Seed from the offline cache: the chat paints before any frame. let seed = cache.load_transcript_state(raw_chat_id); @@ -182,6 +356,7 @@ impl SessionCore { engine_id: engine_id.to_owned(), chat_id: chat_id.to_owned(), raw_chat_id: raw_chat_id.to_owned(), + read_only, client, cache, state: Mutex::new(state), @@ -503,7 +678,7 @@ impl SessionCore { /// Publish the snapshot at `revision` and emit `SessionChanged`. pub(crate) fn publish_transcript(&self, revision: u64) { let inputs = self.row_inputs(); - let snapshot = { + let (snapshot, running_subagents) = { let state = lock(&self.state); let session = inputs.session.as_ref(); let streaming = state @@ -514,7 +689,8 @@ impl SessionCore { roboco_proto::view::effective_indicator(Some(session), chrono::Utc::now()) == roboco_proto::view::Indicator::Working }); - SessionSnapshot { + let running = running_subagent_count(&state.entries); + let snapshot = SessionSnapshot { chat_id: self.chat_id.clone(), revision, entries: state.entries.clone(), @@ -530,14 +706,39 @@ impl SessionCore { hydrated: state.hydrated, history_pending: state.history_pending, context_usage: state.context_usage, - } + }; + (snapshot, running) }; self.snapshot_tx.send_replace(Arc::new(snapshot)); + // The "● N" pill: the running-subagent count rides the same + // publication as the rows (a chip flipping to Done is a transcript + // change). Written after the state lock is gone — the store is + // last in the crate's lock order. + self.sync_running_subagents(running_subagents); if let Some(inner) = self.client.upgrade() { inner.pump.session(&self.chat_id, revision); } } + /// Push the running-subagent count to 07's workspace store (the + /// sessions-list "● N" pill). Read-only sessions never write: nested + /// spawns live in the subagent's own doc, which has no sidebar row. + pub(crate) fn sync_running_subagents(&self, running: u32) { + if self.read_only { + return; + } + if let Some(store) = self.workspace_store() { + store.set_running_subagents(&self.chat_id, running); + } + } + + /// The session left the registry (evicted or backgrounded): its pill + /// count is no longer maintained — clear it, so a stale "● N" never + /// outlives the subscription that proved it. + pub(crate) fn clear_running_subagents(&self) { + self.sync_running_subagents(0); + } + // ── echo maintenance ───────────────────────────────────────────────── pub(crate) fn remove_echo(&self, message_id: &str) { @@ -588,6 +789,11 @@ impl SessionCore { } pub(crate) fn mark_seen(&self) { + // A subagent transcript has no chat row; marking it seen would + // fire a `markChatSeen` for a doc id the engine does not know. + if self.read_only { + return; + } if let Some(inner) = self.client.upgrade() { // 07's mark_seen: the monotonic local stamp (never rolled // back) plus the fire-and-forget `Mutate markChatSeen`. @@ -725,11 +931,16 @@ fn spawn_session_tasks(core: &Arc<SessionCore>) { crate::runtime::shared().spawn(async move { transcript::run(transcript_core, transcript_cancel).await; }); - let queue_core = core.clone(); - let queue_cancel = cancel.clone(); - crate::runtime::shared().spawn(async move { - queue::run(queue_core, queue_cancel).await; - }); + // A read-only subagent doc has no composer and nothing queues on it: + // its `WatchQueue` would only hold a calls-connection stream open + // (and pin the doc on the engine) for a list that stays empty. + if !core.read_only { + let queue_core = core.clone(); + let queue_cancel = cancel.clone(); + crate::runtime::shared().spawn(async move { + queue::run(queue_core, queue_cancel).await; + }); + } let watch_core = core.clone(); crate::runtime::shared().spawn(async move { watch_task(watch_core, cancel).await; @@ -786,6 +997,30 @@ impl SessionRegistry { engine_id: &str, chat_id: &str, raw_chat_id: &str, + ) -> Arc<SessionCore> { + self.open_inner(inner, engine_id, chat_id, raw_chat_id, false) + } + + /// A subagent's transcript doc, read-only (upstream #899's viewer): + /// same seeding and subscription, but [`SessionCore::read_only`] — no + /// mark-seen, no pill count — and the doc id is the registry key. + pub(crate) fn open_read_only( + &self, + inner: &Arc<ClientInner>, + engine_id: &str, + doc_id: &str, + raw_doc_id: &str, + ) -> Arc<SessionCore> { + self.open_inner(inner, engine_id, doc_id, raw_doc_id, true) + } + + fn open_inner( + &self, + inner: &Arc<ClientInner>, + engine_id: &str, + chat_id: &str, + raw_chat_id: &str, + read_only: bool, ) -> Arc<SessionCore> { { let registry = lock(&self.inner); @@ -809,6 +1044,7 @@ impl SessionRegistry { Arc::downgrade(inner), cache, tasks, + read_only, ); { let mut registry = lock(&self.inner); @@ -823,48 +1059,61 @@ impl SessionRegistry { /// A session is never evicted while view-attached, while its tail is /// streaming, or while it has pending sends. pub(crate) fn evict(&self) { - let mut registry = lock(&self.inner); - let mut idle: Vec<(i64, String)> = registry - .sessions - .values() - .filter(|core| { - !core.view_attached() - && !core.has_pending_sends() - && !core.snapshot().streaming - }) - .map(|core| (core.touched_ms(), core.chat_id.clone())) - .collect(); - if idle.len() <= WARM_SESSION_CAP { - return; - } - idle.sort(); - let excess = idle.len() - WARM_SESSION_CAP; - for (_, chat_id) in idle.into_iter().take(excess) { - tracing::debug!(chat = %chat_id, "evicting warm session"); - if let Some(core) = registry.sessions.remove(&chat_id) { - core.cancel(); + let evicted: Vec<Arc<SessionCore>> = { + let mut registry = lock(&self.inner); + let mut idle: Vec<(i64, String)> = registry + .sessions + .values() + .filter(|core| { + !core.view_attached() + && !core.has_pending_sends() + && !core.snapshot().streaming + }) + .map(|core| (core.touched_ms(), core.chat_id.clone())) + .collect(); + if idle.len() <= WARM_SESSION_CAP { + return; } + idle.sort(); + let excess = idle.len() - WARM_SESSION_CAP; + idle.into_iter() + .take(excess) + .filter_map(|(_, chat_id)| registry.sessions.remove(&chat_id)) + .collect() + }; + // Off the registry lock (the store is last in the lock order): + // the pill count a dropped session proved is cleared with it. + for core in evicted { + tracing::debug!(chat = %core.chat_id, "evicting warm session"); + core.cancel(); + core.clear_running_subagents(); } } /// `on_background`: close the subscriptions of detached sessions that /// are not streaming and have no pending sends. pub(crate) fn close_background(&self) { - let mut registry = lock(&self.inner); - let quiet: Vec<String> = registry - .sessions - .values() - .filter(|core| { - !core.view_attached() - && !core.has_pending_sends() - && !core.snapshot().streaming - }) - .map(|core| core.chat_id.clone()) - .collect(); - for chat_id in quiet { - if let Some(core) = registry.sessions.remove(&chat_id) { - core.cancel(); - } + let dropped: Vec<Arc<SessionCore>> = { + let mut registry = lock(&self.inner); + let quiet: Vec<Arc<SessionCore>> = registry + .sessions + .values() + .filter(|core| { + !core.view_attached() + && !core.has_pending_sends() + && !core.snapshot().streaming + }) + .cloned() + .collect(); + quiet + .into_iter() + .filter_map(|core| registry.sessions.remove(&core.chat_id)) + .collect() + }; + // Off the registry lock: the pill counts go with the sessions. + for core in dropped { + core.cancel(); + core.clear_running_subagents(); } } @@ -931,6 +1180,18 @@ impl SessionHandle { self.core.snapshot() } + /// Subagents spawned in this transcript, in spawn order (pull after + /// `SessionChanged`). The spawn chips are the index — no listing RPC. + pub fn subagents(&self) -> Vec<SubagentRow> { + self.core.snapshot().subagents() + } + + /// Whether this handle is a read-only subagent transcript (no + /// composer, no sends, no seen marks) — the viewer's contract. + pub fn is_subagent(&self) -> bool { + self.core.read_only + } + /// The raw subscription (Rust consumers). pub fn subscribe(&self) -> watch::Receiver<Arc<SessionSnapshot>> { self.core.snapshot_tx_subscribe() @@ -981,6 +1242,13 @@ impl SessionHandle { /// Send: a new turn when idle; while working, queue (or steer, or /// stop-and-send, per `busy`). pub async fn send(&self, request: SendRequest) -> Result<SendOutcome, ClientError> { + // A subagent transcript is read-only (upstream #899's viewer): a + // send would queue a run on a doc that has no chat row. + if self.core.read_only { + return Err(ClientError::InvalidArgument { + message: "a subagent transcript is read-only".into(), + }); + } self.core.clone().send(request).await } @@ -1154,6 +1422,30 @@ impl Client { Some(SessionHandle { core }) } + /// Open a subagent's transcript read-only — the doc a spawn chip's + /// `subagent_ref` names (`{chatId}--sub--{suffix}`, scoped on this + /// client). The engine serves the doc like any chat doc, so a running + /// subagent streams live, a finished one reads its persisted copy, and + /// one that resumes streams its new output (upstream #899's fix, by + /// construction: there is no frozen copy to get stuck on). The doc + /// has no chat row, so the session is read-only — no `markChatSeen`, + /// no sends — and the transcript cache still persists it, so a + /// subagent already read shows offline after a restart. + pub fn open_subagent(&self, doc_id: &str) -> Result<SessionHandle, ClientError> { + let (handle, raw_doc_id) = self.resolve(doc_id)?; + if !raw_doc_id.contains("--sub--") { + return Err(ClientError::InvalidArgument { + message: format!("not a subagent doc: {doc_id}"), + }); + } + let engine_id = handle.engine_id().to_owned(); + let core = self + .inner + .sessions + .open_read_only(&self.inner, &engine_id, doc_id, &raw_doc_id); + Ok(SessionHandle { core }) + } + /// The view closed; the session stays warm until evicted. pub fn close_session(&self, chat_id: &str) { if let Some(handle) = self.session(chat_id) { @@ -1373,6 +1665,7 @@ pub(crate) mod testkit { std::sync::Weak::new(), cache_handle(), tokio_util::sync::CancellationToken::new(), + false, ); Arc::try_unwrap(core).unwrap_or_else(|_| unreachable!("the only reference")) } @@ -1389,6 +1682,177 @@ pub(crate) mod testkit { mod tests { use super::*; + /// One assistant entry carrying the given tool parts. + fn entry(parts: Vec<roboco_doc::MessagePart>) -> Arc<SessionMessageEntry> { + Arc::new(SessionMessageEntry { + id: "e1".into(), + role: roboco_doc::MessageRole::Assistant, + parts, + created_at: 1_000, + device_id: "dev".into(), + status: Some(roboco_doc::MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + }) + } + + /// One spawn chip (an `Agent` tool call) with the subagent stamps. + fn spawn( + doc_id: &str, + status: Option<roboco_doc::parts::SubagentStatus>, + resolved: bool, + is_error: bool, + ) -> roboco_doc::MessagePart { + roboco_doc::MessagePart::Tool { + id: format!("p-{doc_id}"), + call: roboco_proto::ToolCall::Unknown { + name: "Agent: Audit grapheme handling".into(), + input: Some(serde_json::json!({ "model": "haiku" })), + }, + is_error, + resolved, + output: None, + diff: None, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: None, + subagent_ref: Some(doc_id.into()), + subagent_status: status, + subagent_tail: Some(" Reading RowView.swift… ".into()), + } + } + + /// `subagents_of`: one row per linked spawn chip, in spawn order, + /// with the chip's lifecycle (never `resolved` alone), the model the + /// spawn named, the trimmed tail, and the description as the title. + #[test] + fn subagents_read_the_spawn_chips() { + use roboco_doc::parts::SubagentStatus as S; + let entries = vec![entry(vec![ + spawn("c--sub--a", Some(S::Running), true, false), + spawn("c--sub--b", None, false, false), + // Not a spawn: an ordinary tool with a stray ref must not + // surface (the genus gate). + roboco_doc::MessagePart::Tool { + id: "p-x".into(), + call: roboco_proto::ToolCall::Exec { command: "ls".into() }, + is_error: false, + resolved: true, + output: None, + diff: None, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: None, + subagent_ref: Some("c--sub--stray".into()), + subagent_status: None, + subagent_tail: None, + }, + ])]; + let rows = subagents_of(&entries); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].doc_id, "c--sub--a"); + assert_eq!(rows[0].title, "Audit grapheme handling"); + assert_eq!(rows[0].state, SubagentState::Running); + assert_eq!(rows[0].model.as_deref(), Some("haiku")); + assert_eq!(rows[0].tail.as_deref(), Some("Reading RowView.swift…")); + assert_eq!(rows[0].spawned_at_ms, 1_000); + // No status, unresolved: the chip is still live (running). + assert_eq!(rows[1].state, SubagentState::Running); + } + + /// The lifecycle table (upstream #899's `subagent_status` rules): + /// `resolved` alone is NOT done (eager-done); an error fails; a + /// resolved-with-no-status chip is done. + #[test] + fn subagent_states_follow_the_chip_lifecycle() { + use roboco_doc::parts::SubagentStatus as S; + let of = |status: Option<S>, resolved: bool, is_error: bool| { + subagents_of(&[entry(vec![spawn("c--sub--s", status, resolved, is_error)])]) + .pop() + .unwrap() + .state + }; + assert_eq!(of(Some(S::Running), true, false), SubagentState::Running); + assert_eq!(of(Some(S::Done), true, false), SubagentState::Done); + assert_eq!(of(Some(S::Failed), false, false), SubagentState::Failed); + assert_eq!(of(None, false, false), SubagentState::Running); + assert_eq!(of(None, true, false), SubagentState::Done); + assert_eq!(of(None, false, true), SubagentState::Failed); + assert_eq!( + of(None, true, true), + SubagentState::Failed, + "an error outranks a resolution" + ); + assert_eq!( + of(Some(S::Done), true, true), + SubagentState::Failed, + "an error outranks a done stamp" + ); + } + + /// A re-spawn (the same doc id, a steered subagent) updates its row + /// in place instead of listing twice. + #[test] + fn a_respawned_subagent_updates_its_row() { + use roboco_doc::parts::SubagentStatus as S; + let first = entry(vec![spawn("c--sub--s", Some(S::Done), true, false)]); + let mut second = (*first).clone(); + second.created_at = 2_000; + let rows = subagents_of(&[first, Arc::new(second)]); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].doc_id, "c--sub--s"); + assert_eq!(rows[0].spawned_at_ms, 2_000, "the newest spawn wins"); + } + + /// Subagent titles match the desktop's tab titles (`subagent_tab_title`): + /// the bare task, the input's description/prompt for a bare spawn, + /// word-boundary genus stripping, a capped first line. + #[test] + fn subagent_titles_match_the_desktop_tabs() { + use roboco_proto::ToolCall; + let unknown = |name: &str, input: Option<serde_json::Value>| ToolCall::Unknown { + name: name.into(), + input, + }; + assert_eq!(subagent_title(&unknown("Agent: scan repo", None)), "scan repo"); + assert_eq!( + subagent_title(&unknown( + "Task", + Some(serde_json::json!({ + "description": "Agent: audit the auth flow", + "prompt": "very long instructions…", + })) + )), + "audit the auth flow" + ); + assert_eq!( + subagent_title(&unknown("Agent", Some(serde_json::json!({ "prompt": "line one\nline two" })))), + "line one" + ); + assert_eq!(subagent_title(&unknown("Taskmaster", None)), "Taskmaster"); + assert_eq!(subagent_title(&unknown("agent", None)), "Subagent"); + let long = subagent_title(&unknown(&"x".repeat(120), None)); + assert_eq!(long.chars().count(), SUBAGENT_TITLE_MAX + 1); + assert!(long.ends_with('…')); + assert_eq!(subagent_title(&ToolCall::Exec { command: "ls".into() }), "Subagent"); + } + + /// The pill counts only running subagents. + #[test] + fn running_subagent_count_counts_only_the_running() { + use roboco_doc::parts::SubagentStatus as S; + let entries = vec![entry(vec![ + spawn("c--sub--a", Some(S::Running), true, false), + spawn("c--sub--b", Some(S::Done), true, false), + spawn("c--sub--c", Some(S::Failed), false, false), + spawn("c--sub--d", None, false, false), + ])]; + assert_eq!(running_subagent_count(&entries), 2); + assert_eq!(running_subagent_count(&[]), 0); + } + #[test] fn the_warm_cap_and_preload_cap_are_the_tickets_values() { assert_eq!(WARM_SESSION_CAP, 6); diff --git a/crates/mobile-client/src/workspace/mod.rs b/crates/mobile-client/src/workspace/mod.rs index bfbff1834f..5077567815 100644 --- a/crates/mobile-client/src/workspace/mod.rs +++ b/crates/mobile-client/src/workspace/mod.rs @@ -72,6 +72,10 @@ pub(crate) struct WorkspaceStore { /// source for the derive (the optimistic row before `WatchChats` /// echoes the id) and a `cwd`/`config` source for 08's send. pending_chats: Arc<Mutex<HashMap<String, PendingChat>>>, + /// The running-subagent count per scoped chat id (upstream #899's + /// "● N" pill): the open sessions write it from their transcripts, + /// the rows read it. Sessions that leave the registry clear theirs. + running_subagents: Arc<Mutex<HashMap<String, u32>>>, } /// The mutable, lock-guarded half of the store. @@ -105,6 +109,7 @@ impl WorkspaceStore { pr: change_requests::ChangeRequestWatches::new(weak.clone(), notify), send_states: Arc::new(Mutex::new(HashMap::new())), pending_chats: Arc::new(Mutex::new(HashMap::new())), + running_subagents: Arc::new(Mutex::new(HashMap::new())), }); // Seed with the derive of an empty fleet: a client with no engines // stays silent (its first recompute reproduces this content hash), @@ -116,6 +121,7 @@ impl WorkspaceStore { change_requests: &HashMap::new(), send_states: &HashMap::new(), pending_chats: &HashMap::new(), + running_subagents: &HashMap::new(), previous: None, }); *store @@ -175,6 +181,7 @@ impl WorkspaceStore { .collect(); let overlays = state.overlays.clone(); let send_states = lock(&self.send_states).clone(); + let running_subagents = lock(&self.running_subagents).clone(); // Ticket 09: drop pending chats the engine has echoed (or whose // 30 s TTL ran out); the survivors are the derive's extra rows. let pending_chats = { @@ -204,6 +211,7 @@ impl WorkspaceStore { change_requests: &statuses, send_states: &send_states, pending_chats: &pending_chats, + running_subagents: &running_subagents, previous: Some(&previous), }); if next.content_hash() == previous.content_hash() { @@ -286,6 +294,28 @@ impl WorkspaceStore { } } + /// The running-subagent count of a chat (upstream #899's "● N" pill + /// on session rows). The open session writes it on every transcript + /// publication; `0` clears it. Recomputes on every change — a count + /// that did not move changes nothing (the row hash holds it). + pub(crate) fn set_running_subagents(&self, chat_id: &str, count: u32) { + // A leaf lock: nothing else is taken while it is held, so the + // session's publish path may call this from any lock state the + // crate's order allows (it runs after the session state lock). + let mut counts = lock(&self.running_subagents); + // `0` removes the entry, so every chat ever opened does not leave + // a zero behind (and clearing an absent count is a no-op). + let changed = if count == 0 { + counts.remove(chat_id).is_some() + } else { + counts.insert(chat_id.to_owned(), count) != Some(count) + }; + drop(counts); + if changed { + self.notify.notify_one(); + } + } + // ── ticket 09's pending chats ────────────────────────────────────────── /// Register the optimistic pending chat of a just-created session diff --git a/crates/mobile-client/src/workspace/view.rs b/crates/mobile-client/src/workspace/view.rs index 2962a19423..91e6351b52 100644 --- a/crates/mobile-client/src/workspace/view.rs +++ b/crates/mobile-client/src/workspace/view.rs @@ -115,6 +115,11 @@ pub struct SessionRow { pub host_indicator: ChatIndicator, /// Run start of the live turn while Working/AwaitingInput. pub working_since_ms: Option<i64>, + /// Agents running under this chat right now — its open transcript's + /// running subagents plus its working side chats (upstream #899's + /// "● N" pill; the follow-up folds the side chats in). Counts only + /// while a session is open (the chip state lives in the transcript). + pub running_subagents: u32, /// `last_message_at`, falling back to `created_at` (the sort key). pub last_activity_ms: i64, /// [`relative_time_label`] of `last_activity_ms` at derive time (the 1 Hz @@ -468,6 +473,10 @@ pub struct DeriveContext<'a> { /// renders them as synthetic rows so the Sessions list shows a new /// chat at once. An engine row with the same id wins. pub pending_chats: &'a HashMap<String, PendingChat>, + /// Running subagents per scoped chat id (upstream #899): the open + /// sessions' spawn-chip state, written by the session tasks. The + /// derive folds each row's working side chats on top. + pub running_subagents: &'a HashMap<String, u32>, pub previous: Option<&'a WorkspaceSnapshot>, } @@ -512,6 +521,7 @@ fn hash_row(row: &SessionRow) -> u64 { attention_rank(row.indicator).hash(&mut h); attention_rank(row.host_indicator).hash(&mut h); row.working_since_ms.hash(&mut h); + row.running_subagents.hash(&mut h); row.last_activity_ms.hash(&mut h); row.time_label.hash(&mut h); row.created_at_ms.hash(&mut h); @@ -599,6 +609,9 @@ struct RowContext<'a> { sessions: HashMap<&'a str, &'a Session>, pinned: &'a HashSet<String>, section_of: &'a HashMap<String, String>, + /// Working (unarchived) side chats per parent (scoped) — the #899 + /// follow-up's half of the "● N" pill. + working_children: &'a HashMap<String, u32>, } fn build_row(chat: &Chat, rc: &RowContext<'_>, cx: &DeriveContext<'_>) -> Arc<SessionRow> { @@ -616,6 +629,10 @@ fn build_row(chat: &Chat, rc: &RowContext<'_>, cx: &DeriveContext<'_>) -> Arc<Se .map(|t| t.timestamp_millis()), _ => None, }; + // The "● N" pill (upstream #899): the open transcript's running + // subagents plus the working side chats folded in by the derive. + let running_subagents = cx.running_subagents.get(&chat.id).copied().unwrap_or(0) + + rc.working_children.get(chat.id.as_str()).copied().unwrap_or(0); let project = chat .space_id .as_deref() @@ -678,6 +695,7 @@ fn build_row(chat: &Chat, rc: &RowContext<'_>, cx: &DeriveContext<'_>) -> Arc<Se indicator, host_indicator, working_since_ms, + running_subagents, last_activity_ms: sort_key(chat).timestamp_millis(), time_label: relative_time_label(sort_key(chat).timestamp_millis(), now_ms), created_at_ms: chat.created_at.timestamp_millis(), @@ -787,7 +805,23 @@ pub fn derive(cx: &DeriveContext<'_>) -> WorkspaceSnapshot { } } - // 3. Rows, one context per engine. + // 3. Rows, one context per engine. First the working-children pre-pass + // (the #899 follow-up): a parent's working side chats count into its + // "● N" pill, so the map is built before any row. + let working_children: HashMap<String, u32> = merged + .chats + .values() + .filter(|chat| chat.parent_chat_id.is_some() && !chat.archived) + .filter_map(|chat| { + let parent = chat.parent_chat_id.as_deref()?; + let session = sessions.get(chat.id.as_str()).copied(); + (display_status(chat, session, cx.now) == ChatIndicator::Working) + .then(|| parent.to_owned()) + }) + .fold(HashMap::new(), |mut acc, parent| { + *acc.entry(parent).or_insert(0) += 1; + acc + }); let mut rows: HashMap<String, Arc<SessionRow>> = HashMap::new(); // Pending chats grouped by their engine — they render through their // engine's own row context (device names, engine online state). @@ -809,6 +843,7 @@ pub fn derive(cx: &DeriveContext<'_>) -> WorkspaceSnapshot { sessions: sessions.clone(), pinned: &pinned, section_of: §ion_of, + working_children: &working_children, }; for raw_chat in &engine.rows.chats { let scoped = ScopedId::encode(&key, &raw_chat.id); @@ -1308,6 +1343,7 @@ mod tests { change_requests: &HashMap::new(), send_states: &HashMap::new(), pending_chats: &HashMap::new(), + running_subagents: &HashMap::new(), previous: None, }) } @@ -1590,6 +1626,7 @@ mod tests { change_requests: &HashMap::new(), send_states: &send_states, pending_chats: &HashMap::new(), + running_subagents: &HashMap::new(), previous: None, }); snapshot @@ -1975,6 +2012,7 @@ mod tests { cwd: "/repo".into(), config: None, branch: Some("main".into()), + parent_chat_id: None, created_at: at(NOW_SECS - 5), } } @@ -1996,6 +2034,7 @@ mod tests { change_requests: &HashMap::new(), send_states: &HashMap::new(), pending_chats: &pending, + running_subagents: &HashMap::new(), previous: None, }); let row = snapshot @@ -2033,6 +2072,7 @@ mod tests { change_requests: &HashMap::new(), send_states: &HashMap::new(), pending_chats: &pending, + running_subagents: &HashMap::new(), previous: None, }); // One row, and it is the engine's (titled), not the synthetic one. @@ -2055,6 +2095,7 @@ mod tests { change_requests: &HashMap::new(), send_states: &HashMap::new(), pending_chats: &pending, + running_subagents: &HashMap::new(), previous: None, }); assert!(snapshot.sessions.is_empty()); diff --git a/crates/mobile-client/tests/side_chats_engine.rs b/crates/mobile-client/tests/side_chats_engine.rs new file mode 100644 index 0000000000..a3bc8aba8a --- /dev/null +++ b/crates/mobile-client/tests/side_chats_engine.rs @@ -0,0 +1,979 @@ +//! Real-engine side-chat and subagent tests (android-next #06, the port +//! of upstream PR #899): `fork_side_chat`, the empty side-chat create, +//! the subagent viewer's read-only docs (live while running, persisted +//! once finished, live again on a later write), and the "● N" running +//! counts on the workspace rows. Every frame comes from an in-process +//! `EngineCore` served over loopback — no protocol mocks. + +mod support; + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use futures::StreamExt; +use futures::stream::BoxStream; + +use roboco_engine::{EngineCore, HarnessRegistry, HarnessId}; +use roboco_harness::{Harness, HarnessError, RunControls}; +use roboco_mobile_client::session::{SendRequest, SubagentState}; +use roboco_mobile_client::{Client, NewSession, SessionTarget}; +use roboco_proto::{ + AgentEvent, ChatConfig, DoneStatus, HarnessId as ProtoHarnessId, Model, ReasoningLevel, + RunRequest, SandboxLevel, SteeringMode, ToolCall, +}; + +use support::{serve, test_client, wait_for_fleet, wait_for_view}; + +// ── the harness ──────────────────────────────────────────────────────────── + +/// What the harness's one reply does. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Reply { + /// A plain one-line turn (nothing nested). + Plain, + /// Spawn a subagent that streams one line and finishes inside the turn. + SpawnFinish, + /// Spawn a subagent and keep the turn (and it) running: the stream + /// stays open after the nested text. + SpawnLive, + /// The user's claude-code background shape: the harness's stream is a + /// channel the TEST drives — the parent turn completes (a parked, + /// steerable session), and the subagent's nested traffic arrives + /// afterwards, exactly like claude's `run_in_background` lane (the + /// `task_notification` re-keying of `agent_tasks`). + Background, +} + +/// A mock harness that records every `RunRequest` and answers with the +/// scenario's event stream. `SpawnLive` parks the stream (the turn never +/// completes), so the spawned subagent stays `Running`. `Background` +/// answers from a channel the test feeds. +struct ScenarioHarness { + reply: Reply, + requests: Arc<Mutex<Vec<RunRequest>>>, + /// `Reply::Background`: the events the test feeds. + background: std::sync::Mutex<Option<tokio::sync::mpsc::Receiver<Result<AgentEvent, HarnessError>>>>, +} + +#[async_trait] +impl Harness for ScenarioHarness { + fn id(&self) -> HarnessId { + HarnessId::Mock + } + fn display_name(&self) -> &str { + "Scenario" + } + fn supports_steering(&self) -> bool { + true + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::StepBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[ReasoningLevel::Medium] + } + async fn models(&self) -> Result<Vec<Model>, HarnessError> { + Ok(vec![Model { + id: "mock-1".into(), + label: "Mock 1".into(), + description: None, + reasoning_levels: vec![ReasoningLevel::Medium], + options: vec![], + }]) + } + async fn run( + &self, + request: RunRequest, + _controls: RunControls, + ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { + self.requests.lock().unwrap().push(request.clone()); + let background = self.background.lock().unwrap().take(); + if let Some(rx) = background { + // The test drives every event: the parent turn's completion, + // then the background subagent's nested traffic. The stream + // stays open until the test drops its sender. + return Ok(futures::stream::unfold(rx, |mut rx| async move { + let item = rx.recv().await?; + Some((item, rx)) + }) + .boxed()); + } + let reply = self.reply; + let events: Vec<Result<AgentEvent, HarnessError>> = match reply { + // A background rig's later runs (none today) answer plainly. + Reply::Plain | Reply::Background => vec![ + Ok(AgentEvent::SessionStarted { + harness: HarnessId::Mock, + model: "mock-1".into(), + tools: vec![], + cwd: request.cwd.clone(), + session_id: "sess-1".into(), + assistant_message_id: "a-1".into(), + }), + Ok(AgentEvent::TextDelta { + text: format!("ack: {}", request.prompt), + }), + Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: None, + }), + ], + Reply::SpawnFinish | Reply::SpawnLive => { + let sub_done = reply == Reply::SpawnFinish; + let mut events = vec![ + Ok(AgentEvent::SessionStarted { + harness: HarnessId::Mock, + model: "mock-1".into(), + tools: vec![], + cwd: request.cwd.clone(), + session_id: "sess-1".into(), + assistant_message_id: "a-1".into(), + }), + // The spawn call (the `Agent[: desc]` convention the + // genus gate keys on). + Ok(AgentEvent::ToolCall { + id: "tool-1".into(), + call: ToolCall::Unknown { + name: "Agent: Audit the repo".into(), + input: Some(serde_json::json!({ "prompt": "audit", "model": "mock-1" })), + }, + }), + // Nested traffic: routed to the subagent's own doc, + // never folded into the parent. + Ok(AgentEvent::Subagent { + parent_tool_use_id: "tool-1".into(), + event: Box::new(AgentEvent::TextDelta { + text: "subagent: reading the tree".into(), + }), + }), + Ok(AgentEvent::ToolResult { + id: "tool-1".into(), + is_error: false, + output: Some("spawned".into()), + diff: None, + }), + ]; + if sub_done { + events.push(Ok(AgentEvent::Subagent { + parent_tool_use_id: "tool-1".into(), + event: Box::new(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: None, + }), + })); + } + events.push(Ok(AgentEvent::TextDelta { + text: "parent done".into(), + })); + if sub_done { + events.push(Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: None, + })); + } + events + } + }; + if reply == Reply::SpawnLive { + // Park the turn: everything above is delivered, then the + // stream never ends — the subagent keeps running. + return Ok(futures::stream::select( + futures::stream::iter(events), + futures::stream::pending(), + ) + .boxed()); + } + Ok(futures::stream::iter(events).boxed()) + } +} + +// ── the rig ──────────────────────────────────────────────────────────────── + +struct Rig { + client: Arc<Client>, + core: EngineCore, + engine_id: String, + #[allow(dead_code)] + engine_dir: tempfile::TempDir, + #[allow(dead_code)] + data_dir: tempfile::TempDir, + /// Held for the rig's life: dropping it stops the engine listening, + /// and every call then fails with "Engine connection lost". + #[allow(dead_code)] + listener: roboco_engine::EngineListener, + requests: Arc<Mutex<Vec<RunRequest>>>, +} + +impl Rig { + fn scope(&self, raw: &str) -> String { + self.client.scope(&self.engine_id, raw) + } + + /// The parent chat this rig works on (its own checkout). + fn parent_id(&self) -> String { + self.scope("parent") + } +} + +/// Assemble the engine with the scenario harness, a parent chat with a +/// known checkout, serve it, and pair a client. +async fn rig(reply: Reply) -> Rig { + rig_with_background(reply).await.0 +} + +/// [`rig`] plus the background lane: when `reply` is `Reply::Background`, +/// the returned sender feeds the harness's event stream (the test drives +/// the parent turn and the subagent's nested traffic itself). +async fn rig_with_background( + reply: Reply, +) -> (Rig, Option<tokio::sync::mpsc::Sender<Result<AgentEvent, HarnessError>>>) { + let engine_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir().unwrap(); + let registry = HarnessRegistry::new(); + let requests = Arc::new(Mutex::new(Vec::new())); + let background = if reply == Reply::Background { + let (tx, rx) = tokio::sync::mpsc::channel(32); + (Some(tx), Some(rx)) + } else { + (None, None) + }; + let (background_tx, background_rx) = background; + registry.register(Arc::new(ScenarioHarness { + reply, + requests: requests.clone(), + background: std::sync::Mutex::new(background_rx), + })); + let core = EngineCore::assemble_with_profile( + roboco_engine::EngineProfile::local(engine_dir.path()).unwrap(), + Arc::new(registry), + HarnessId::Mock, + ) + .unwrap(); + core.workspace + .create_chat_with_parent( + "parent", + None, + Some(&core.device_id), + Some(ChatConfig { + harness: ProtoHarnessId::Mock, + model: Some("mock-1".into()), + reasoning: None, + model_options: Default::default(), + sandbox: SandboxLevel::WorkspaceWrite, + }), + Some("/checkout/parent".into()), + None, + ) + .unwrap(); + let listener = serve(&core, engine_dir.path()).await; + let url = support::pairing_url(&listener, engine_dir.path(), None); + let client = test_client(data_dir.path(), Vec::new(), Arc::new(support::Recorder::default())); + let record = tokio::time::timeout(Duration::from_secs(10), client.pair_engine(&url, None)) + .await + .expect("pairing completes") + .expect("pairing succeeds"); + wait_for_view(&client, &record.engine_id, "connected", |view| { + view.state == roboco_mobile_client::EngineState::Connected + }) + .await; + wait_for_fleet(&client, "parent row", |fleet| { + fleet + .engine(&record.engine_id) + .is_some_and(|engine| engine.rows.chats.iter().any(|chat| chat.id == "parent")) + }) + .await; + // The workspace store derives off the fleet asynchronously: the side + // chat create reads the parent's ROW (host, checkout, sandbox), so + // wait for the derived row too, not only the fleet frame. + let scoped_parent = client.scope(&record.engine_id, "parent"); + let deadline = Instant::now() + Duration::from_secs(15); + while client.workspace().session(&scoped_parent).is_none() { + assert!(Instant::now() < deadline, "the parent row never derived"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + let rig = Rig { + client, + core, + engine_id: record.engine_id, + engine_dir, + data_dir, + listener, + requests, + }; + (rig, background_tx) +} + +impl Rig { + /// Send one message on the parent chat and wait for the mock reply's + /// first text to land (the plain reply's `ack:` line). + async fn ask(&self, text: &str) { + let handle = self.client.open_session(&self.parent_id()).unwrap(); + let outcome = handle + .send(SendRequest::text(text)) + .await + .expect("send"); + assert!(matches!(outcome, roboco_mobile_client::SendOutcome::Started { .. })); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let answered = handle + .snapshot() + .entries + .iter() + .any(|entry| entry.role == roboco_doc::MessageRole::Assistant); + if answered { + return; + } + assert!(Instant::now() < deadline, "the reply never arrived"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + + /// Wait until the parent's reply is COMPLETE (not merely streaming): + /// the engine's fork boundary is the last completed assistant entry. + async fn wait_completed(&self) { + let handle = self.client.open_session(&self.parent_id()).unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let completed = handle.snapshot().entries.iter().any(|entry| { + entry.role == roboco_doc::MessageRole::Assistant + && entry.status == Some(roboco_doc::MessageStatus::Complete) + }); + if completed { + return; + } + assert!(Instant::now() < deadline, "the reply never completed"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + + /// The engine's own row for a raw chat id. + fn engine_row(&self, raw: &str) -> roboco_proto::Chat { + self.core + .workspace + .chat(raw) + .unwrap() + .unwrap_or_else(|| panic!("engine row for {raw}")) + } + + /// The subagent doc id the parent's chips carry (scoped). + fn subagent_doc(&self) -> String { + let handle = self.client.open_session(&self.parent_id()).unwrap(); + let rows = handle.subagents(); + assert_eq!(rows.len(), 1, "one subagent: {rows:?}"); + rows[0].doc_id.clone() + } +} + +// ── fork ─────────────────────────────────────────────────────────────────── + +/// Forking copies the history through the last completed reply and lists +/// the copy under its parent; forking before anything completed fails +/// with the engine's reason; a side chat's fork lists as a sibling. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fork_side_chat_copies_history_under_its_parent() { + let rig = rig(Reply::Plain).await; + let parent = rig.parent_id(); + + // Nothing completed yet: the engine refuses with its reason. + let error = rig + .client + .fork_side_chat(&parent) + .await + .expect_err("fork needs a completed reply"); + assert!( + matches!(&error, roboco_mobile_client::ClientError::HostError { message } if message.contains("completed")), + "the engine's reason carries over: {error:?}" + ); + + // A completed turn, then the fork. + rig.ask("hello").await; + rig.wait_completed().await; + let fork = rig.client.fork_side_chat(&parent).await.expect("fork"); + assert!(roboco_engine_client::registry::ScopedId::is_scoped(&fork)); + let raw_fork = roboco_engine_client::registry::ScopedId::parse(&fork) + .expect("scoped fork id") + .raw_id; + // The row: a child of the parent, in its checkout, not archived. + let row = rig.engine_row(&raw_fork); + assert_eq!(row.parent_chat_id.as_deref(), Some("parent")); + assert_eq!(row.cwd.as_deref(), Some("/checkout/parent")); + assert!(!row.archived); + // The history: the parent's entries plus the fork seam. + let entries = rig + .core + .doc_host + .open(&raw_fork) + .unwrap() + .doc() + .read_entries() + .unwrap(); + assert!( + entries + .iter() + .any(|entry| entry.role == roboco_doc::MessageRole::User), + "the source's history was copied" + ); + assert!( + entries.iter().any(|entry| entry + .parts + .iter() + .any(|part| matches!(part, roboco_doc::MessagePart::Fork { .. }))), + "the fork seam sits between the copy and the fork's own turns" + ); + + // The fork opens at once (before the WatchChats echo matters) and + // lists in the workspace children. + let handle = rig.client.open_session(&fork).expect("open the fork"); + assert!(!handle.is_subagent()); + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let listed = rig + .client + .workspace() + .children(&parent) + .iter() + .any(|row| row.id == fork); + if listed { + break; + } + assert!(Instant::now() < deadline, "the fork never listed as a child"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // A side chat's fork lists as its sibling: the fork's parent is the + // same root, not the fork itself. + let sibling = rig.client.fork_side_chat(&fork).await.expect("sibling fork"); + let raw_sibling = roboco_engine_client::registry::ScopedId::parse(&sibling) + .expect("scoped id") + .raw_id; + assert_eq!( + rig.engine_row(&raw_sibling).parent_chat_id.as_deref(), + Some("parent"), + "the sibling hangs off the fork's parent" + ); +} + +// ── the empty side chat ──────────────────────────────────────────────────── + +/// Creating a side chat takes the parent's host, checkout and sandbox, +/// lists under the parent (never the front page), and its first send +/// runs in the parent's folder. A side chat's own side chat is a sibling. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn create_side_chat_runs_in_the_parents_checkout() { + let rig = rig(Reply::Plain).await; + let parent = rig.parent_id(); + let config = ChatConfig { + harness: ProtoHarnessId::Mock, + model: Some("mock-1".into()), + reasoning: None, + model_options: Default::default(), + sandbox: SandboxLevel::ReadOnly, + }; + + let side = rig + .client + .create_session(NewSession { + target: SessionTarget::Projectless { + device_id: rig.client.scope(&rig.engine_id, "unused"), + }, + config: Some(config), + branch: Some("ignored".into()), + cwd: Some("/ignored".into()), + title: None, + parent_chat_id: Some(parent.clone()), + }) + .await + .expect("side chat"); + assert!(roboco_engine_client::registry::ScopedId::is_scoped(&side)); + let raw_side = roboco_engine_client::registry::ScopedId::parse(&side) + .expect("scoped id") + .raw_id; + // The engine row: the parent's checkout and host, the parent's + // sandbox (not the draft's read-only), the draft's model. + let row = rig.engine_row(&raw_side); + assert_eq!(row.parent_chat_id.as_deref(), Some("parent")); + assert_eq!(row.cwd.as_deref(), Some("/checkout/parent")); + assert_eq!(row.device_id, rig.core.device_id); + let row_config = row.config.expect("the row carries the config"); + assert_eq!(row_config.sandbox, SandboxLevel::WorkspaceWrite, "the parent's sandbox"); + assert_eq!(row_config.harness, ProtoHarnessId::Mock); + assert_eq!(row_config.model.as_deref(), Some("mock-1")); + + // The first send runs in the parent's checkout. + let handle = rig.client.open_session(&side).unwrap(); + handle + .send(SendRequest::text("side hello")) + .await + .expect("send"); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let ran_in_parent_folder = rig + .requests + .lock() + .unwrap() + .iter() + .any(|request| request.prompt.contains("side hello") + && request.cwd == "/checkout/parent"); + if ran_in_parent_folder { + break; + } + assert!( + Instant::now() < deadline, + "the side chat's first send never ran in the parent's checkout" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // The row lists under the parent (the pending overlay first, then the + // engine's echo) and never in the front page. + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let child = rig + .client + .workspace() + .children(&parent) + .iter() + .any(|row| row.id == side); + let front = rig + .client + .workspace() + .front + .recent + .iter() + .any(|row| row.id == side); + if child && !front { + break; + } + assert!( + Instant::now() < deadline, + "the side chat never listed under its parent (and off the front page)" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // A side chat of the side chat is a sibling: the same root parent. + let sibling = rig + .client + .create_session(NewSession { + target: SessionTarget::Projectless { + device_id: rig.client.scope(&rig.engine_id, "unused"), + }, + config: None, + branch: None, + cwd: None, + title: None, + parent_chat_id: Some(side.clone()), + }) + .await + .expect("sibling"); + let raw_sibling = roboco_engine_client::registry::ScopedId::parse(&sibling) + .expect("scoped id") + .raw_id; + assert_eq!( + rig.engine_row(&raw_sibling).parent_chat_id.as_deref(), + Some("parent"), + "a side chat's side chat hangs off the same root" + ); + + // An unknown parent is a NotFound, before any engine call. + let error = rig + .client + .create_session(NewSession { + target: SessionTarget::Projectless { + device_id: rig.client.scope(&rig.engine_id, "unused"), + }, + config: None, + branch: None, + cwd: None, + title: None, + parent_chat_id: Some("engine:v1:no-such-chat".into()), + }) + .await + .expect_err("unknown parent"); + assert!(matches!(error, roboco_mobile_client::ClientError::NotFound { .. }), "{error:?}"); +} + +// ── the subagent viewer ──────────────────────────────────────────────────── + +/// A spawned subagent lists on the open session, its doc opens read-only +/// and streams, and the parent's row carries the "● N" count while it +/// runs — cleared when the chip settles. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_subagent_doc_streams_and_counts_while_it_runs() { + let rig = rig(Reply::SpawnLive).await; + let parent = rig.parent_id(); + + // The turn spawns a subagent and parks: the chip stays running. + rig.ask("spawn one").await; + let deadline = Instant::now() + Duration::from_secs(20); + let rows = loop { + let handle = rig.client.open_session(&parent).unwrap(); + let rows = handle.subagents(); + if !rows.is_empty() { + break rows; + } + assert!(Instant::now() < deadline, "the subagent never spawned"); + tokio::time::sleep(Duration::from_millis(50)).await; + }; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, SubagentState::Running); + assert_eq!(rows[0].title, "Audit the repo"); + assert_eq!(rows[0].model.as_deref(), Some("mock-1")); + // No live tail: the engine's chip carries lifecycle only (tails were + // rejected — `subagent_chip_update` in crates/engine/src/sessions.rs); + // the nested text lands in the subagent's own doc instead. + assert_eq!(rows[0].tail, None); + assert_eq!(rows[0].doc_id, rig.scope("parent--sub--tool-1")); + + // The parent's row counts it (the "● N" pill). + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let count = rig + .client + .workspace() + .session(&parent) + .map(|row| row.running_subagents) + .unwrap_or(0); + if count == 1 { + break; + } + assert!(Instant::now() < deadline, "the running count never landed: {count}"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // The doc opens read-only and streams its transcript. + let doc = rig.subagent_doc(); + let viewer = rig.client.open_subagent(&doc).expect("open subagent"); + assert!(viewer.is_subagent()); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let text = viewer.snapshot(); + if text + .entries + .iter() + .any(|entry| entry + .parts + .iter() + .any(|part| matches!(part, roboco_doc::MessagePart::Text { text, .. } if text.contains("reading the tree")))) + { + break; + } + assert!(Instant::now() < deadline, "the subagent transcript never streamed"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // A doc id that is not a subagent doc is refused. + let error = rig + .client + .open_subagent(&parent) + .expect_err("a chat id is not a subagent doc"); + assert!( + matches!(error, roboco_mobile_client::ClientError::InvalidArgument { .. }), + "{error:?}" + ); + + // A later write to the doc (a resumed subagent) streams into the SAME + // handle — upstream #899's fix, by construction: there is no frozen + // copy to get stuck on. + let raw_doc = roboco_engine_client::registry::ScopedId::parse(&doc) + .expect("scoped doc id") + .raw_id; + rig.core + .doc_host + .open(&raw_doc) + .unwrap() + .doc() + .push_message(&roboco_doc::schema::SessionMessageEntry { + duration_ms: None, + id: "resumed".into(), + role: roboco_doc::MessageRole::Assistant, + parts: vec![roboco_doc::MessagePart::Text { + id: "p1".into(), + text: "resumed output".into(), + }], + created_at: 2_000, + device_id: rig.core.device_id.clone(), + status: Some(roboco_doc::MessageStatus::Complete), + continuation_of: None, + }) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let resumed = viewer.snapshot().entries.iter().any(|entry| entry + .parts + .iter() + .any(|part| matches!(part, roboco_doc::MessagePart::Text { text, .. } if text.contains("resumed output")))); + if resumed { + break; + } + assert!(Instant::now() < deadline, "the resumed subagent never streamed"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +/// A subagent that finishes settles its chip and clears the count; its +/// transcript still reads (persisted on the engine), and the session's +/// subagent list marks it done. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_finished_subagent_clears_the_count_and_still_reads() { + let rig = rig(Reply::SpawnFinish).await; + let parent = rig.parent_id(); + + rig.ask("spawn and finish").await; + // The chip settles to Done. + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let handle = rig.client.open_session(&parent).unwrap(); + let rows = handle.subagents(); + if rows.len() == 1 && rows[0].state == SubagentState::Done { + break; + } + assert!(Instant::now() < deadline, "the subagent never settled: {rows:?}"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + // The count cleared. + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let count = rig + .client + .workspace() + .session(&parent) + .map(|row| row.running_subagents) + .unwrap_or(0); + if count == 0 { + break; + } + assert!(Instant::now() < deadline, "the count never cleared: {count}"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + // The transcript still reads from the engine. + let doc = rig.subagent_doc(); + let viewer = rig.client.open_subagent(&doc).expect("open the finished subagent"); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let text = viewer.snapshot(); + if text + .entries + .iter() + .any(|entry| entry + .parts + .iter() + .any(|part| matches!(part, roboco_doc::MessagePart::Text { text, .. } if text.contains("reading the tree")))) + { + break; + } + assert!(Instant::now() < deadline, "the finished transcript never read"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +/// The user's claude-code background shape (run_in_background): the spawn +/// call's tool result returns at once ("Async agent launched") and the +/// parent turn completes, while the subagent's nested traffic arrives +/// afterwards — the run loop stays parked and routes it into the +/// subagent's doc (sessions.rs: "Handled BEFORE the parked gate so a +/// parked session's background traffic still reaches the subagent doc"). +/// The card binds to the doc (a link with running dots), the doc opens +/// live and keeps streaming, and the chip freezes when the agent's Done +/// lands. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_background_subagent_binds_after_its_tool_result_and_freezes_on_done() { + let (rig, sender) = rig_with_background(Reply::Background).await; + let sender = sender.expect("the background lane"); + let parent = rig.parent_id(); + let parent_handle = rig.client.open_session(&parent).unwrap(); + let request_cwd = "/checkout/parent".to_owned(); + let send = parent_handle + .send(SendRequest::text("run an agent in the background")) + .await + .expect("send"); + assert!(matches!(send, roboco_mobile_client::SendOutcome::Started { .. })); + + // Phase 1 — the parent turn: the spawn call, its IMMEDIATE result + // (the async-launch ack resolves the tool call), the parent's text, + // and the turn's Done (the steerable session parks, not ends). + let event = |e: AgentEvent| -> Result<AgentEvent, HarnessError> { Ok(e) }; + sender + .send(event(AgentEvent::SessionStarted { + harness: HarnessId::Mock, + model: "mock-1".into(), + tools: vec![], + cwd: request_cwd.clone(), + session_id: "sess-1".into(), + assistant_message_id: "a-1".into(), + })) + .await + .unwrap(); + sender + .send(event(AgentEvent::ToolCall { + id: "tool-1".into(), + call: ToolCall::Unknown { + name: "Agent: Audit the repo".into(), + input: Some(serde_json::json!({ "prompt": "audit", "run_in_background": true })), + }, + })) + .await + .unwrap(); + sender + .send(event(AgentEvent::ToolResult { + id: "tool-1".into(), + is_error: false, + output: Some("Async agent launched…".into()), + diff: None, + })) + .await + .unwrap(); + sender + .send(event(AgentEvent::TextDelta { + text: "parent turn done".into(), + })) + .await + .unwrap(); + sender + .send(event(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: None, + })) + .await + .unwrap(); + + // The parent's reply landed; the subagent has not spoken yet, so + // nothing is listed (the chip IS the index): the engine stamps + // `subagentRef` on the FIRST tagged event, never on the ToolCall + // (sessions.rs subagent routing). The real claude driver binds at spawn + // time because it seeds a tagged `UserMessage` with the spawn's prompt + // right after the call (claude/normalize.rs); this harness omits that + // seed, which exercises the in-place stamp on a finished entry. + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let answered = parent_handle + .snapshot() + .entries + .iter() + .any(|entry| entry + .parts + .iter() + .any(|part| matches!(part, roboco_doc::MessagePart::Text { text, .. } if text.contains("parent turn done")))); + if answered { + break; + } + assert!(Instant::now() < deadline, "the parent turn never landed"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert!(parent_handle.subagents().is_empty(), "no subagent yet"); + + // Phase 2 — the background agent speaks, AFTER the turn completed. + // The engine stamps the (already-resolved) chip with the doc ref and + // Running, and routes the text into the subagent's own doc. + sender + .send(event(AgentEvent::Subagent { + parent_tool_use_id: "tool-1".into(), + event: Box::new(AgentEvent::TextDelta { + text: "background: scanning the tree".into(), + }), + })) + .await + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + let rows = loop { + let rows = parent_handle.subagents(); + if !rows.is_empty() { + break rows; + } + assert!(Instant::now() < deadline, "the background subagent never bound"); + tokio::time::sleep(Duration::from_millis(50)).await; + }; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, SubagentState::Running); + assert_eq!(rows[0].doc_id, rig.scope("parent--sub--tool-1")); + // Lifecycle only on the chip (no tail); the text is in the doc below. + assert_eq!(rows[0].tail, None); + // The running count lands on the row (the card's dots). + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let count = rig + .client + .workspace() + .session(&parent) + .map(|row| row.running_subagents) + .unwrap_or(0); + if count == 1 { + break; + } + assert!(Instant::now() < deadline, "the background count never landed"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + // The doc opens read-only and streams the background output. + let viewer = rig + .client + .open_subagent(&rig.scope("parent--sub--tool-1")) + .expect("open the background subagent"); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let streaming = viewer.snapshot().entries.iter().any(|entry| entry + .parts + .iter() + .any(|part| matches!(part, roboco_doc::MessagePart::Text { text, .. } if text.contains("scanning the tree")))); + if streaming { + break; + } + assert!(Instant::now() < deadline, "the background doc never streamed"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Phase 3 — the agent finishes: the chip freezes (Done), the count + // clears, and the transcript still reads. + sender + .send(event(AgentEvent::Subagent { + parent_tool_use_id: "tool-1".into(), + event: Box::new(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: None, + }), + })) + .await + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let rows = parent_handle.subagents(); + if rows.len() == 1 && rows[0].state == SubagentState::Done { + break; + } + assert!(Instant::now() < deadline, "the background subagent never froze: {rows:?}"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let count = rig + .client + .workspace() + .session(&parent) + .map(|row| row.running_subagents) + .unwrap_or(0); + if count == 0 { + break; + } + assert!(Instant::now() < deadline, "the frozen count never cleared"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + let still_reads = viewer + .snapshot() + .entries + .iter() + .any(|entry| entry + .parts + .iter() + .any(|part| matches!(part, roboco_doc::MessagePart::Text { text, .. } if text.contains("scanning the tree")))); + assert!(still_reads, "the frozen transcript still reads"); +} diff --git a/crates/mobile/src/client_ffi/new_session.rs b/crates/mobile/src/client_ffi/new_session.rs index f8230cf20a..5e9cf6e4af 100644 --- a/crates/mobile/src/client_ffi/new_session.rs +++ b/crates/mobile/src/client_ffi/new_session.rs @@ -59,6 +59,17 @@ impl CoreClient { on_runtime(async move { client.create_session(new_session).await }).await } + /// Fork `source_id` through its last completed reply into a side chat + /// (upstream #899's "Fork into Side Chat"): the engine copies the + /// history and publishes the row; a side chat's fork lists as its + /// sibling. Returns the new **scoped** chat id. An engine without + /// the RPC answers `Unsupported` (hide the entry point); nothing + /// completed yet → `HostError` with the engine's reason. + pub async fn fork_side_chat(&self, source_id: String) -> CoreResult<String> { + let client = self.client.clone(); + on_runtime(async move { client.fork_side_chat(&source_id).await }).await + } + /// Create (or find) the project for `path` on an engine device /// (`Mutate createSpace`); returns the **scoped** space id. A space /// on the same device in the same folder dedupes to its id (case- and @@ -278,6 +289,7 @@ mod tests { branch: Some("main".into()), cwd: None, title: Some("My chat".into()), + parent_chat_id: None, } .try_into() .unwrap(); @@ -289,6 +301,21 @@ mod tests { ); assert!(ok.config.is_some()); assert_eq!(ok.branch.as_deref(), Some("main")); + assert_eq!(ok.parent_chat_id, None); + let side: mc::NewSession = NewSession { + target: SessionTarget::Projectless { + device_id: "engine:v1:dev".into(), + }, + config: None, + branch: None, + cwd: None, + title: None, + parent_chat_id: Some("engine:v1:chat".into()), + } + .try_into() + .unwrap(); + assert_eq!(side.parent_chat_id.as_deref(), Some("engine:v1:chat")); + assert!(matches!(side.target, mc::SessionTarget::Projectless { .. })); let bad = NewSession { target: SessionTarget::Projectless { device_id: "engine:v1:dev".into(), @@ -303,6 +330,7 @@ mod tests { branch: None, cwd: None, title: None, + parent_chat_id: None, }; let error = TryInto::<mc::NewSession>::try_into(bad).unwrap_err(); assert!(matches!(error, crate::client_ffi::CoreError::InvalidArgument { .. })); diff --git a/crates/mobile/src/client_ffi/session.rs b/crates/mobile/src/client_ffi/session.rs index dc45fb8074..5288c01345 100644 --- a/crates/mobile/src/client_ffi/session.rs +++ b/crates/mobile/src/client_ffi/session.rs @@ -494,6 +494,47 @@ pub struct SessionHandle { pub(crate) inner: mc::SessionHandle, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum SubagentState { + Running, + Done, + Failed, +} + +/// A subagent the session's agent spawned (one linked spawn chip), as the +/// side-chats sheet lists it (upstream #899). +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct SubagentInfo { + /// Its transcript doc — `CoreClient.open_subagent`. + pub doc_id: String, + /// What the spawn asked for ("Audit grapheme handling"). + pub title: String, + pub state: SubagentState, + /// One-line live tail of its output, while it runs. + pub tail: Option<String>, + /// The model the spawn picked, when it named one. + pub model: Option<String>, + /// When the spawning reply was written (epoch ms). + pub spawned_at_ms: i64, +} + +impl From<mc::SubagentRow> for SubagentInfo { + fn from(r: mc::SubagentRow) -> Self { + Self { + doc_id: r.doc_id, + title: r.title, + state: match r.state { + mc::SubagentState::Running => SubagentState::Running, + mc::SubagentState::Done => SubagentState::Done, + mc::SubagentState::Failed => SubagentState::Failed, + }, + tail: r.tail, + model: r.model, + spawned_at_ms: r.spawned_at_ms, + } + } +} + impl SessionHandle { pub(crate) fn new(inner: mc::SessionHandle) -> Arc<Self> { Arc::new(Self { inner }) @@ -522,6 +563,22 @@ impl SessionHandle { self.inner.composer().as_ref().clone().into() } + /// Subagents spawned in this transcript, in spawn order (pull after + /// `SessionChanged`). The spawn chips are the index — no listing RPC. + pub fn subagents(&self) -> Vec<SubagentInfo> { + self.inner + .subagents() + .into_iter() + .map(Into::into) + .collect() + } + + /// Whether this handle is a read-only subagent transcript (no + /// composer, no sends, no seen marks) — the viewer's contract. + pub fn is_subagent(&self) -> bool { + self.inner.is_subagent() + } + pub fn transcript_status(&self) -> TranscriptStatus { let facts = self.inner.transcript_status(); TranscriptStatus { @@ -714,6 +771,16 @@ impl CoreClient { )) } + /// Open (or return the open) subagent transcript — the doc id a + /// spawn chip carries (`{chatId}--sub--{id}`, scoped). Read-only: + /// the viewer shows no composer; live while the subagent runs, the + /// persisted doc once finished, live again if it resumes. + pub fn open_subagent(&self, doc_id: String) -> CoreResult<Arc<SessionHandle>> { + Ok(SessionHandle::new( + self.client.open_subagent(&doc_id)?, + )) + } + /// An already-open session. pub fn session(&self, chat_id: String) -> Option<Arc<SessionHandle>> { self.client.session(&chat_id).map(SessionHandle::new) @@ -1023,4 +1090,37 @@ mod tests { "[docs](roboco-file:docs/)" ); } + + /// The FFI `SubagentInfo` maps one-to-one off the client's + /// `SubagentRow` (upstream #899's sheet record). + #[test] + fn subagent_info_maps_off_the_client_row() { + let row = mc::SubagentRow { + doc_id: "engine:v1:chat--sub--s1".into(), + title: "Audit grapheme handling".into(), + state: mc::SubagentState::Running, + tail: Some("Reading RowView.swift…".into()), + model: Some("haiku".into()), + spawned_at_ms: 1_000, + }; + let info: SubagentInfo = row.clone().into(); + assert_eq!(info.doc_id, "engine:v1:chat--sub--s1"); + assert_eq!(info.title, "Audit grapheme handling"); + assert_eq!(info.state, SubagentState::Running); + assert_eq!(info.tail.as_deref(), Some("Reading RowView.swift…")); + assert_eq!(info.model.as_deref(), Some("haiku")); + assert_eq!(info.spawned_at_ms, 1_000); + let done: SubagentInfo = mc::SubagentRow { + state: mc::SubagentState::Done, + ..row.clone() + } + .into(); + assert_eq!(done.state, SubagentState::Done); + let failed: SubagentInfo = mc::SubagentRow { + state: mc::SubagentState::Failed, + ..row + } + .into(); + assert_eq!(failed.state, SubagentState::Failed); + } } diff --git a/crates/mobile/src/client_ffi/types.rs b/crates/mobile/src/client_ffi/types.rs index ce9afe3bb2..9c9b7f2d6c 100644 --- a/crates/mobile/src/client_ffi/types.rs +++ b/crates/mobile/src/client_ffi/types.rs @@ -628,6 +628,11 @@ pub struct SessionRow { pub host_indicator: ChatIndicator, /// Run start of the live turn while Working/AwaitingInput. pub working_since_ms: Option<i64>, + /// Agents running under this chat right now — running subagents of + /// its open transcript plus its working side chats (upstream #899's + /// "● N" pill). Counts only while a session is open. + #[uniffi(default = 0)] + pub running_subagents: u32, pub last_activity_ms: i64, /// `now` / `34m` / `4h` / `2d` at derive time. pub time_label: String, @@ -670,6 +675,7 @@ impl From<&mc::SessionRow> for SessionRow { indicator: r.indicator.into(), host_indicator: r.host_indicator.into(), working_since_ms: r.working_since_ms, + running_subagents: r.running_subagents, last_activity_ms: r.last_activity_ms, time_label: r.time_label.clone(), created_at_ms: r.created_at_ms, @@ -1191,6 +1197,11 @@ pub struct NewSession { pub branch: Option<String>, pub cwd: Option<String>, pub title: Option<String>, + /// A side chat of this chat (upstream #899): runs in its parent's + /// checkout and host (`target`, `cwd` and `branch` ignored), and + /// lists under the parent. + #[uniffi(default = None)] + pub parent_chat_id: Option<String>, } impl TryFrom<NewSession> for mc::NewSession { @@ -1208,6 +1219,7 @@ impl TryFrom<NewSession> for mc::NewSession { branch: new.branch, cwd: new.cwd, title: new.title, + parent_chat_id: new.parent_chat_id, }) } } diff --git a/crates/mobile/src/layout/display.rs b/crates/mobile/src/layout/display.rs index 89665d0506..eeea7b3ccd 100644 --- a/crates/mobile/src/layout/display.rs +++ b/crates/mobile/src/layout/display.rs @@ -189,6 +189,10 @@ pub enum WidgetKind { ToolRail { trunk_x: f32, bend: f32, branch_end: f32, row_mid: f32, tops: Vec<f32>, heights: Vec<f32> }, /// Tap target toggling one tool row's inline detail. ToolToggle { detail: u64, open: bool }, + /// Tap target over a whole subagent spawn card: opens the subagent's + /// transcript (upstream #899). `doc_id` is the scoped doc + /// (`{chatId}--sub--{id}`); `frozen` = it finished (done/failed). + SubagentLink { doc_id: String, title: String, frozen: bool }, /// Shimmer sweep over the active group's title (text drawn by the canvas /// underneath; the painter re-draws those runs brighter through a moving /// gradient). diff --git a/crates/mobile/src/layout/tests.rs b/crates/mobile/src/layout/tests.rs index 20a8ab0b2c..f7964d1be2 100644 --- a/crates/mobile/src/layout/tests.rs +++ b/crates/mobile/src/layout/tests.rs @@ -662,6 +662,87 @@ fn running_subagent_shows_a_spinner_after_its_spawn_resolves() { assert!(failed.display(0).unwrap().runs.iter().any(|r| r.color == display::ColorRole::Danger), "failed subagent is tinted danger"); } +/// A spawn card whose engine-stamped doc exists is a link (upstream +/// #899): the whole card is a tap target carrying the doc id, the spawn's +/// description as the title, and `frozen` once the subagent finished. A +/// spawn chip without a ref (the engine never linked one) renders no +/// link — nothing to open. +#[test] +fn spawn_cards_with_a_doc_ref_become_subagent_links() { + use roboco_doc::parts::{MessagePart, MessageStatus, SubagentStatus}; + use roboco_doc::schema::{MessageRole, SessionMessageEntry}; + let spawn = |subagent_ref: Option<String>, status: SubagentStatus| MessagePart::Tool { + id: "k1".into(), + call: roboco_proto::ToolCall::Unknown { + name: "Agent: Audit grapheme handling".into(), + input: Some(serde_json::json!({ "prompt": "x" })), + }, + is_error: false, + resolved: true, + output: None, + diff: None, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: None, + subagent_ref, + subagent_status: Some(status), + subagent_tail: None, + }; + let frame_for = |part: MessagePart| { + let mut w = worker(390.0); + w.input = TranscriptInput { + entries: vec![Arc::new(SessionMessageEntry { + id: "a".into(), + role: MessageRole::Assistant, + parts: vec![part], + created_at: 0, + device_id: String::new(), + status: Some(MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + })], + ..Default::default() + }; + w.pass() + }; + let link = |f: &LayoutFrame| { + f.display(0) + .unwrap() + .widgets + .iter() + .find_map(|w| match &w.kind { + display::WidgetKind::SubagentLink { doc_id, title, frozen } => { + Some((doc_id.clone(), title.clone(), *frozen, (w.x, w.y, w.w, w.h))) + } + _ => None, + }) + }; + // Running: a live link over the whole card. + let running = frame_for(spawn(Some("chat--sub--s1".into()), SubagentStatus::Running)); + let (doc_id, title, frozen, _bounds) = link(&running).expect("running spawn card is a link"); + assert_eq!(doc_id, "chat--sub--s1"); + assert_eq!(title, "Audit grapheme handling"); + assert!(!frozen); + // The link covers the card: as wide as the row's reading column and + // as tall as the 30pt card (T = 1.125) inside its 38pt row. + let (_, _, _, (lx, _, lw, lh)) = link(&running).expect("link bounds"); + let row = running.display(0).unwrap(); + assert!(lw > 300.0 && lw <= row.width, "link spans the card: {lw} of {}", row.width); + assert!(lx < 40.0, "the card starts at the row's margin: {lx}"); + assert!((lh - 33.75).abs() < 0.5, "link covers the 30pt card (T = 1.125): {lh}"); + // Done/Failed: frozen links. + let (_, _, frozen, _) = link(&frame_for(spawn(Some("chat--sub--s1".into()), SubagentStatus::Done))) + .expect("done spawn card is a link"); + assert!(frozen); + let (_, _, frozen, _) = link(&frame_for(spawn(Some("chat--sub--s1".into()), SubagentStatus::Failed))) + .expect("failed spawn card is a link"); + assert!(frozen); + // No doc ref: no link at all (a stray chip must not open anything). + let unlinked = frame_for(spawn(None, SubagentStatus::Running)); + assert!(link(&unlinked).is_none(), "a spawn without a doc ref is not a link"); +} + #[test] fn links_get_hit_regions() { for md in [ diff --git a/crates/mobile/src/layout/tools.rs b/crates/mobile/src/layout/tools.rs index ff802dc004..7ad5d67c24 100644 --- a/crates/mobile/src/layout/tools.rs +++ b/crates/mobile/src/layout/tools.rs @@ -54,6 +54,19 @@ pub(crate) struct ToolLine { pub key: u64, pub open: bool, pub body: Vec<DetailBlock>, + /// A spawn card whose subagent doc exists: tapping opens its + /// transcript (upstream #899). `None` for every non-agent line. + pub link: Option<SubagentLink>, +} + +/// The subagent doc a spawn card links to. +pub(crate) struct SubagentLink { + /// The doc id (`{chatId}--sub--{id}`, scoped on this client). + pub doc_id: String, + /// The spawn's description — the viewer's title. + pub title: String, + /// Done/Failed → the subagent is no longer live. + pub frozen: bool, } pub(crate) struct ToolGroup { @@ -786,6 +799,21 @@ impl RowBuilder { match part { MessagePart::Tool { call, is_error, resolved, subagent_ref, subagent_status, .. } => { let (label, detail) = roboco_proto::view::tool_chip_content(call); + // A spawn card whose doc exists is a link (upstream + // #899): tapping opens the subagent's transcript. + // Only for agent cards; titled like the client's + // subagent rows (the desktop's tab title). + let link = match (agents, subagent_ref) { + (true, Some(doc_id)) => Some(SubagentLink { + doc_id: doc_id.clone(), + title: roboco_mobile_client::subagent_title(call), + frozen: matches!( + subagent_status, + Some(SubagentStatus::Done | SubagentStatus::Failed) + ), + }), + _ => None, + }; // Subagent lifecycle is distinct from `resolved`: under // eager-done the spawn call resolves while the subagent // still runs (desktop transcript.rs `running`/`failed`). @@ -822,6 +850,7 @@ impl RowBuilder { key: dkey, open, body, + link, }); } MessagePart::Reasoning { text, .. } => { @@ -849,6 +878,7 @@ impl RowBuilder { key: dkey, open, body, + link: None, }); } _ => {} @@ -1108,6 +1138,19 @@ fn place_agents(t: &ToolGroup, px: Px, x: f32, y: f32, cw: f32, mut out: Option< let s = d(px, 14.0); o.widget(WidgetKind::Spinner, (x + cw - d(px, 8.0) - s, card_y + (card_h - s) / 2.0, s, s), None); } + // The spawn card is a link once the engine stamped the subagent's + // doc: a full-card tap target (upstream #899's `SubagentLink`). + if let Some(link) = &line.link { + o.widget( + WidgetKind::SubagentLink { + doc_id: link.doc_id.clone(), + title: link.title.clone(), + frozen: link.frozen, + }, + (x, card_y, cw, card_h), + None, + ); + } } h } @@ -1120,6 +1163,7 @@ pub(crate) fn heap_bytes(t: &ToolGroup) -> usize { l.label.p.heap_bytes() + l.detail.as_ref().map_or(0, |d| d.p.heap_bytes()) + l.badge.as_ref().map_or(0, |b| b.1.p.heap_bytes()) + + l.link.as_ref().map_or(0, |link| link.doc_id.capacity() + link.title.capacity()) + l.body .iter() .map(|b| match b {