diff --git a/ios/TaskWraithKit/Sources/TaskWraithUI/ComposerView.swift b/ios/TaskWraithKit/Sources/TaskWraithUI/ComposerView.swift index 570421fa..bc51b4db 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/ComposerView.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/ComposerView.swift @@ -40,9 +40,17 @@ struct Composer: View { /// Existing chats normally keep their provider. Empty transcript welcome /// screens may still choose the first-turn provider before dispatch. var allowsProviderChange: Bool? = nil + /// Idle-collapse plumbing: the host passes `onExpandedChange` to mirror the + /// composer's expanded state (so it can hide its secondary rows + telemetry + /// rail when the composer is idle); `forcesExpanded` keeps the composer + /// always-open (welcome hero, ensemble roster). + var onExpandedChange: ((Bool) -> Void)? = nil + var forcesExpanded: Bool = false @Binding var text: String @State private var approvalMode = "default" + /// Drives compact (idle) vs full composer — focusing the field expands it. + @FocusState private var inputFocused: Bool /// Scope-global chat — every phone-origin turn is clamped to plan mode /// (no file mutation) by the Mac; the composer pins the picker to match. private var isGlobalChat: Bool { @@ -110,6 +118,15 @@ struct Composer: View { private var isEmpty: Bool { text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + private var hasQueued: Bool { + !(card.queuedComposerPrompts ?? []).isEmpty + } + /// Compact (idle) when the field is unfocused, empty, and has nothing + /// pending. Anything queued/typed/attached — or a host that forces it — + /// keeps the composer expanded so controls + rows + rail stay visible. + private var isExpanded: Bool { + forcesExpanded || inputFocused || !isEmpty || hasImageAttachments || hasQueued + } /// Run id of a live stream for this thread, if one is in flight. The /// Mac-pushed `card.runId` is snapshot-throttled and lags the un-throttled /// stream, so we fall back to this so Stop is targetable the moment tokens @@ -190,7 +207,9 @@ struct Composer: View { // hairline divider. Unchanged (default parity). if !card.isEnsemble { composerControlsRow - Rectangle().fill(TWTheme.border).frame(height: 1) + if isExpanded { + Rectangle().fill(TWTheme.border).frame(height: 1) + } } composerInputBody } @@ -201,6 +220,12 @@ struct Composer: View { // one-shot seed would leak thread A's model/reasoning into thread B. .onChange(of: card.id, initial: true) { resyncPickerToThread() + // Composer is reused across threads on iPhone (no per-thread .id) — + // clear focus so thread B doesn't inherit thread A's expanded state. + inputFocused = false + } + .onChange(of: isExpanded, initial: true) { _, expanded in + onExpandedChange?(expanded) } .onChange(of: selectedProvider) { _, newValue in providerEcho?.wrappedValue = newValue @@ -265,13 +290,17 @@ struct Composer: View { private var composerControlsRow: some View { HStack(spacing: 8) { modelPickerControl - composerControlSeparator - approvalControl - if !canChangeProvider, card.parentChatId == nil, newTaskWorkspaceId == nil { + // Compact idle bar: only the model pill shows. Approval + guest + // controls appear once the composer is focused/expanded. + if isExpanded { composerControlSeparator - // Guest participant: + invites, chip shows/changes, - // × removes (desktop guest-picker parity). - GuestParticipantControl(model: model, card: card) + approvalControl + if !canChangeProvider, card.parentChatId == nil, newTaskWorkspaceId == nil { + composerControlSeparator + // Guest participant: + invites, chip shows/changes, + // × removes (desktop guest-picker parity). + GuestParticipantControl(model: model, card: card) + } } Spacer(minLength: 0) } @@ -424,6 +453,7 @@ struct Composer: View { .foregroundStyle(shell.sendButton.tint) } TextField(placeholder, text: $text, axis: .vertical) + .focused($inputFocused) .lineLimit(1...2) .font(twComposerFont(shell.fontDesign)) .foregroundStyle(shell.palette.textPrimary) diff --git a/ios/TaskWraithKit/Sources/TaskWraithUI/TWSharedViews.swift b/ios/TaskWraithKit/Sources/TaskWraithUI/TWSharedViews.swift index 7c11cdfa..fa73829c 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/TWSharedViews.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/TWSharedViews.swift @@ -5771,6 +5771,9 @@ struct MiniThreadView: View { attachedTop: !(card.queuedComposerPrompts ?? []).isEmpty, attachedBottom: true, navigateOnSend: false, + // Side-chat mini composer stays full for v1 (its queued stack + + // rail show unconditionally); idle-collapse here is a follow-up. + forcesExpanded: true, text: $draft) Rectangle().fill(TWTheme.border).frame(height: 1) TelemetryFooterRail( diff --git a/ios/TaskWraithKit/Sources/TaskWraithUI/ThreadDetailViews.swift b/ios/TaskWraithKit/Sources/TaskWraithUI/ThreadDetailViews.swift index 99a3141b..a30dd3a4 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/ThreadDetailViews.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/ThreadDetailViews.swift @@ -21,6 +21,10 @@ struct ThreadDetailView: View { @ObservedObject var model: RemoteSessionModel let taskId: String @State private var followUp = "" + /// Mirrors the Composer's expanded state (focused / drafting / queued / + /// ensemble) so the host hides the secondary rows + telemetry rail when the + /// composer is idle — i.e. the compact one-line composer. + @State private var composerExpanded = false /// Follow the transcript tail as content streams in — disabled the /// moment the user drags, re-enabled by the jump-to-latest pill. @State private var autoFollow = true @@ -911,7 +915,7 @@ struct ThreadDetailView: View { // them as their own cards above the composer; codex (tuck) // re-homes them into the core card below — so render them // as siblings here only when NOT tucking. - if !tuck { + if !tuck && composerExpanded { composerSecondaryRows( card: card, hasAttachedRows: hasAttachedRows, onOwnCards: detached, @@ -936,7 +940,7 @@ struct ThreadDetailView: View { VStack(spacing: bareTelemetry ? 6 : 0) { // codex tucks the roster/queued rows INTO this core // card (above the input), as merged segments. - if tuck { + if tuck && composerExpanded { composerSecondaryRows( card: card, hasAttachedRows: hasAttachedRows, onOwnCards: false, suppressFill: true, @@ -949,26 +953,35 @@ struct ThreadDetailView: View { ? false : (hasAttachedRows || card.isEnsemble || !(card.queuedComposerPrompts ?? []).isEmpty), - attachedBottom: true, + // Idle (rail hidden) → round the composer's bottom; + // expanded (rail present) → flatten to fuse the rail. + attachedBottom: composerExpanded, extraWorkspaceIds: extraWorkspaceIdsForSend(card: card), allowsProviderChange: allowsFirstTurnProviderChange, + onExpandedChange: { composerExpanded = $0 }, + // Ensemble stays expanded (its @-direct roster is core + // context); solo threads collapse when idle. + forcesExpanded: card.isEnsemble, text: $followUp) - if !bareTelemetry { - Rectangle().fill(TWTheme.border).frame(height: 1) + if composerExpanded { + if !bareTelemetry { + Rectangle().fill(TWTheme.border).frame(height: 1) + } + TelemetryFooterRail( + run: snapshot?.runSummary, + workspaceName: model.workspaceName(for: card.workspaceId), + workspaceOptions: model.workspaces.map { + (id: $0.id, name: $0.displayName) + }, + primaryWorkspaceId: card.workspaceId, + secondaryWorkspaceId: secondaryWorkspaceBinding, + activeGoal: card.activeGoal, + onGoalUpdate: { op, objective, reason in + model.updateGoal( + card, op: op, objective: objective, reason: reason) + }, + planLanes: card.todoLanes ?? []) } - TelemetryFooterRail( - run: snapshot?.runSummary, - workspaceName: model.workspaceName(for: card.workspaceId), - workspaceOptions: model.workspaces.map { - (id: $0.id, name: $0.displayName) - }, - primaryWorkspaceId: card.workspaceId, - secondaryWorkspaceId: secondaryWorkspaceBinding, - activeGoal: card.activeGoal, - onGoalUpdate: { op, objective, reason in - model.updateGoal(card, op: op, objective: objective, reason: reason) - }, - planLanes: card.todoLanes ?? []) } .composerShellIf((detached && !inputOwnsSurface) || tuckedTab, resolved) .zIndex(tuckedTab ? 1 : 0) @@ -1331,6 +1344,8 @@ struct ThreadEmptyWelcomeCanvas: View { attachedBottom: true, providerEcho: $draftProvider, allowsProviderChange: !card.isEnsemble, + // Welcome hero stays full (its roster + rail show unconditionally). + forcesExpanded: true, text: $draft) Rectangle().fill(TWTheme.border).frame(height: 1) TelemetryFooterRail(