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
7 changes: 7 additions & 0 deletions ios/TaskWraithKit/Sources/TaskWraithUI/ComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ struct Composer: View {
/// rail when the composer is idle); `forcesExpanded` keeps the composer
/// always-open (welcome hero, ensemble roster).
var onExpandedChange: ((Bool) -> Void)? = nil
/// Mirrors raw input focus (keyboard up). Distinct from `onExpandedChange`,
/// which also stays true while a draft/queued prompt lingers after blur —
/// the host uses focus to hide the ABOVE rows when the keyboard drops.
var onFocusChange: ((Bool) -> Void)? = nil
var forcesExpanded: Bool = false
@Binding var text: String

Expand Down Expand Up @@ -227,6 +231,9 @@ struct Composer: View {
.onChange(of: isExpanded, initial: true) { _, expanded in
onExpandedChange?(expanded)
}
.onChange(of: inputFocused, initial: true) { _, focused in
onFocusChange?(focused)
}
.onChange(of: selectedProvider) { _, newValue in
providerEcho?.wrappedValue = newValue
}
Expand Down
37 changes: 23 additions & 14 deletions ios/TaskWraithKit/Sources/TaskWraithUI/ThreadDetailViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ struct ThreadDetailView: View {
/// 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
@State private var composerFocused = 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 @@ -840,9 +840,14 @@ struct ThreadDetailView: View {
// composer-core overlaps by 10pt (a tab peeking behind). Gated on
// above-content (no empty tab). For non-tuck shells every condition
// below collapses to its current value → byte-identical layout.
// The above rows + telemetry collapse when the composer is not
// focused (keyboard down) — a blurred composer is just the
// one-line input + model pill + send. Focus is the gate, NOT
// composerExpanded (which lingers while a draft/queue exists).
let hasAboveContent =
hasAttachedRows || card.isEnsemble
|| !(card.queuedComposerPrompts ?? []).isEmpty
composerFocused
&& (hasAttachedRows || card.isEnsemble
|| !(card.queuedComposerPrompts ?? []).isEmpty)
let tuckedTab = resolved.layout.tuckedAboveTab && hasAboveContent
VStack(spacing: tuckedTab ? -10 : (detached ? 6 : 0)) {
// Above-rows group: inner VStack spacing matches the outer so
Expand All @@ -851,6 +856,9 @@ struct ThreadDetailView: View {
// shifts non-tuck layout): the inner spacing MUST equal the outer
// (always an explicit 0/6, never adaptive), and every above-row
// must be full-width (else the inner VStack's .center re-centers).
// The whole above-rows group collapses with the keyboard
// (gated on focus); see hasAboveContent.
if composerFocused {
VStack(spacing: detached ? 6 : 0) {
if hasWorkspaceBreakdown {
// One attached row per granted workspace
Expand Down Expand Up @@ -915,7 +923,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 && composerExpanded {
if !tuck {
composerSecondaryRows(
card: card, hasAttachedRows: hasAttachedRows,
onOwnCards: detached,
Expand All @@ -931,6 +939,7 @@ struct ThreadDetailView: View {
.padding(.bottom, tuckedTab ? 14 : 0)
.composerShellIf(tuckedTab, resolved, topCornersOnly: true)
.padding(.horizontal, tuckedTab ? 18 : 0)
} // end focus-gated above-rows group
// Composer core (input + telemetry rail). In detached
// mode this is its OWN card under the floating above-rows;
// merged mode keeps it as the final segments of the one
Expand All @@ -940,7 +949,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 && composerExpanded {
if tuck && composerFocused {
composerSecondaryRows(
card: card, hasAttachedRows: hasAttachedRows,
onOwnCards: false, suppressFill: true,
Expand All @@ -951,19 +960,19 @@ struct ThreadDetailView: View {
runStatus: snapshot?.runSummary?.status,
attachedTop: (detached || tuckedTab)
? false
: (hasAttachedRows || card.isEnsemble
|| !(card.queuedComposerPrompts ?? []).isEmpty),
: hasAboveContent,
// Idle (rail hidden) → round the composer's bottom;
// expanded (rail present) → flatten to fuse the rail.
attachedBottom: composerExpanded,
// focused (rail present) → flatten to fuse the rail.
attachedBottom: composerFocused,
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,
onFocusChange: { composerFocused = $0 },
// Every chat (incl. ensemble) collapses to one line
// when the keyboard drops — above rows + telemetry
// follow focus, not draft/queue presence.
forcesExpanded: false,
text: $followUp)
if composerExpanded {
if composerFocused {
if !bareTelemetry {
Rectangle().fill(TWTheme.border).frame(height: 1)
}
Expand Down
72 changes: 72 additions & 0 deletions src/main/RemoteThreadProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,78 @@ describe('RemoteThreadProjection', () => {
})
})

it('aggregates an ensemble round — sums tokens + cost, spans duration, unions files', () => {
const roundRun = (
runId: string,
provider: string,
started: string,
ended: string,
tokensIn: number,
tokensOut: number,
costUsd: number,
file: string
) =>
({
runId,
provider,
ensembleRoundId: 'round-7',
startedAt: started,
endedAt: ended,
stats: {
inputTokens: tokensIn,
outputTokens: tokensOut,
totalTokens: tokensIn + tokensOut,
cost_usd: costUsd
},
runDiff: {
runId,
preSnapshot: { capturedAt: started, isGitRepo: true, workspacePath: '/repo' },
postSnapshot: { capturedAt: ended, isGitRepo: true, workspacePath: '/repo' },
createdFiles: [],
modifiedFiles: [
{ path: file, status: 'modified', additions: 2, deletions: 1, previewKind: 'git_diff' }
],
deletedFiles: [],
preExistingFiles: []
}
}) as unknown as ChatRun
const summary = buildRunSummary([
roundRun('r-a', 'claude', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:05.000Z', 100, 50, 0.1, 'a.ts'),
roundRun('r-b', 'codex', '2026-01-01T00:00:01.000Z', '2026-01-01T00:00:09.000Z', 200, 80, 0.2, 'b.ts'),
roundRun('r-c', 'gemini', '2026-01-01T00:00:02.000Z', '2026-01-01T00:00:07.000Z', 40, 10, 0.05, 'a.ts')
])
// tokens summed across all 3 participants (not just the last)
expect(summary?.tokensIn).toBe(340)
expect(summary?.tokensOut).toBe(140)
expect(summary?.totalTokens).toBe(480)
// cost summed (0.10 + 0.20 + 0.05); explicit → no ~ estimate prefix
expect(summary?.costText).toContain('0.35')
expect(summary?.costText?.startsWith('~')).toBe(false)
// span: earliest start (:00) → latest end (:09)
expect(summary?.durationMs).toBe(9000)
expect(summary?.startedAt).toBe('2026-01-01T00:00:00.000Z')
expect(summary?.endedAt).toBe('2026-01-01T00:00:09.000Z')
// a.ts (touched by r-a + r-c) folds to ONE row with summed churn; b.ts once
expect(summary?.fileChanges?.filesChanged).toBe(2)
expect(summary?.fileChanges?.files?.find((f) => f.path === 'a.ts')).toEqual({
path: 'a.ts',
status: 'modified',
additions: 4,
deletions: 2
})
// representative identity = round-boundary (last) run
expect(summary?.runId).toBe('r-c')
expect(summary?.ensembleRoundId).toBe('round-7')
})

it('does not aggregate a single-run round (keeps last-run behaviour)', () => {
const summary = buildRunSummary([
{ runId: 'solo', ensembleRoundId: 'round-x', stats: { totalTokens: 11 } } as unknown as ChatRun
])
expect(summary?.runId).toBe('solo')
expect(summary?.totalTokens).toBe(11)
})

it('falls back to successful write tool summaries when run diff is not available', () => {
const messages = [
msg(1, {
Expand Down
Loading
Loading