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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions ios/TaskWraithKit/Sources/TaskWraithUI/ComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions ios/TaskWraithKit/Sources/TaskWraithUI/TWSharedViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
51 changes: 33 additions & 18 deletions ios/TaskWraithKit/Sources/TaskWraithUI/ThreadDetailViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
Loading