From 01fb4b0e0da2077eed9b8ce771c502cf9d1c5f03 Mon Sep 17 00:00:00 2001 From: Chris Izatt Date: Thu, 18 Jun 2026 16:20:11 +0100 Subject: [PATCH 1/2] fix(ensemble): Task-complete summary reflects the whole round, not the last participant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildRunSummary returned summarizeRun(runs[last]) — for an ensemble round (one run per participant, all sharing an ensembleRoundId) that meant the headline Task-complete card showed only whichever participant finished last. Continuous ensembles with many turns made the tally badly understate the round. Now when the last run belongs to a multi-participant round, fold the whole round: SUM tokens (in/out/total) and cost across participants (cost summed numerically then formatted once; "~" if any was estimated), UNION file changes (per-file rows dedupe by path with summed churn; per-workspace rows dedupe by path), and span the duration earliest-start → latest-end. The round-boundary (last) run supplies the representative runId/provider/model/status. Extracted extractRunCostUsd() so per-run and round-fold share the exact cost logic. Solo chats + single-run rounds are unchanged. Tests: ensemble-round aggregation (summed tokens/cost, spanned duration, folded duplicate file) + single-run-round no-op. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/RemoteThreadProjection.test.ts | 72 ++++++++ src/main/RemoteThreadProjection.ts | 232 ++++++++++++++++++++++-- 2 files changed, 288 insertions(+), 16 deletions(-) diff --git a/src/main/RemoteThreadProjection.test.ts b/src/main/RemoteThreadProjection.test.ts index 18a850c9..46237a6b 100644 --- a/src/main/RemoteThreadProjection.test.ts +++ b/src/main/RemoteThreadProjection.test.ts @@ -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, { diff --git a/src/main/RemoteThreadProjection.ts b/src/main/RemoteThreadProjection.ts index 67c3a82f..32c454c8 100644 --- a/src/main/RemoteThreadProjection.ts +++ b/src/main/RemoteThreadProjection.ts @@ -906,7 +906,23 @@ export function buildRunSummary( messages?: ChatMessage[] ): RemoteRunSummary | undefined { if (!Array.isArray(runs) || runs.length === 0) return undefined - return summarizeRun(runs[runs.length - 1], costDisplay, messages) + const last = runs[runs.length - 1] + // An ensemble round dispatches one run per participant (all sharing an + // `ensembleRoundId`). The headline Task-complete card renders at the round + // boundary, so it must reflect the WHOLE round — summed tokens/cost, unioned + // file changes, the round's wall-clock span — not just whichever participant + // finished last. Continuous ensembles (many rounds) made the last-only tally + // badly understate the round. + if (last && typeof last.ensembleRoundId === 'string' && last.ensembleRoundId) { + const roundRuns = runs.filter( + (run) => typeof run?.runId === 'string' && run.ensembleRoundId === last.ensembleRoundId + ) + if (roundRuns.length > 1) { + const aggregate = summarizeEnsembleRound(roundRuns, costDisplay, messages) + if (aggregate) return aggregate + } + } + return summarizeRun(last, costDisplay, messages) } /** Per-run projection — powers the per-run Task-complete cards. */ @@ -935,27 +951,14 @@ export function summarizeRun( // outputTokens / totalTokens; cost via cost_usd / total_cost_usd). const stats = run.stats as Record | undefined if (stats) { - const num = (...keys: string[]): number | undefined => { - for (const key of keys) { - const v = stats[key] - if (typeof v === 'number' && Number.isFinite(v)) return v - if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v))) return Number(v) - } - return undefined - } const usage = extractRemoteUsageCounts(stats) if (usage.inputTokens > 0) summary.tokensIn = usage.inputTokens if (usage.outputTokens > 0) summary.tokensOut = usage.outputTokens if (usage.totalTokens > 0) summary.totalTokens = usage.totalTokens - const cost = num('cost_usd', 'total_cost_usd', 'costUsd', 'totalCostUsd') - const explicitCostUsd = cost !== undefined && cost > 0 ? cost : 0 - const hasExplicitCost = explicitCostUsd > 0 - const costUsd = hasExplicitCost - ? explicitCostUsd - : estimateRemoteRunCostUsd(costDisplay, run.provider, model, usage) + const { usd: costUsd, estimated: costEstimated } = extractRunCostUsd(run, costDisplay) if (costUsd > 0) { const formatted = formatRemoteCost(costUsd, costDisplay) - if (formatted) summary.costText = hasExplicitCost ? formatted : `~${formatted}` + if (formatted) summary.costText = costEstimated ? `~${formatted}` : formatted } } const fileChanges = summarizeRunFileChanges(run, messages) @@ -963,6 +966,203 @@ export function summarizeRun( return summary } +/** Numeric run cost in USD + whether it was estimated (vs reported). Shared by + * the per-run summary and the ensemble-round fold so costs can be summed before + * formatting. Mirrors summarizeRun's cost logic exactly. */ +function extractRunCostUsd( + run: ChatRun | undefined, + costDisplay?: RemoteCostDisplayOptions +): { usd: number; estimated: boolean } { + if (!run) return { usd: 0, estimated: false } + const stats = run.stats as Record | undefined + if (!stats) return { usd: 0, estimated: false } + const num = (...keys: string[]): number | undefined => { + for (const key of keys) { + const v = stats[key] + if (typeof v === 'number' && Number.isFinite(v)) return v + if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v))) return Number(v) + } + return undefined + } + const explicit = num('cost_usd', 'total_cost_usd', 'costUsd', 'totalCostUsd') + if (explicit !== undefined && explicit > 0) return { usd: explicit, estimated: false } + const usage = extractRemoteUsageCounts(stats) + const model = run.actualModel || run.requestedModel + const estimated = estimateRemoteRunCostUsd(costDisplay, run.provider, model, usage) + return estimated > 0 ? { usd: estimated, estimated: true } : { usd: 0, estimated: false } +} + +/** Fold every participant run of one ensemble round into a single headline + * summary. Tokens and cost are SUMMED across participants, file changes are + * UNIONED, and the duration spans the whole round (earliest start → latest + * end). The round-boundary (last) run supplies the representative + * runId/provider/model/status/exitCode. */ +export function summarizeEnsembleRound( + roundRuns: ChatRun[], + costDisplay?: RemoteCostDisplayOptions, + messages?: ChatMessage[] +): RemoteRunSummary | undefined { + const perRun = roundRuns + .map((run) => summarizeRun(run, costDisplay, messages)) + .filter((entry): entry is RemoteRunSummary => Boolean(entry)) + if (perRun.length === 0) return undefined + const base = summarizeRun(roundRuns[roundRuns.length - 1], costDisplay, messages) + if (!base) return undefined + const summary: RemoteRunSummary = { ...base } + + // Summed token tallies across every participant. + const sumTokens = (key: 'tokensIn' | 'tokensOut' | 'totalTokens'): number => + perRun.reduce((acc, entry) => acc + (typeof entry[key] === 'number' ? entry[key]! : 0), 0) + const tokensIn = sumTokens('tokensIn') + const tokensOut = sumTokens('tokensOut') + const totalTokens = sumTokens('totalTokens') + if (tokensIn > 0) summary.tokensIn = tokensIn + else delete summary.tokensIn + if (tokensOut > 0) summary.tokensOut = tokensOut + else delete summary.tokensOut + if (totalTokens > 0) summary.totalTokens = totalTokens + else delete summary.totalTokens + + // Summed cost (numeric, formatted once). "~" if ANY participant was estimated. + let costUsd = 0 + let anyEstimated = false + for (const run of roundRuns) { + const { usd, estimated } = extractRunCostUsd(run, costDisplay) + if (usd > 0) { + costUsd += usd + if (estimated) anyEstimated = true + } + } + if (costUsd > 0) { + const formatted = formatRemoteCost(costUsd, costDisplay) + if (formatted) summary.costText = anyEstimated ? `~${formatted}` : formatted + else delete summary.costText + } else { + delete summary.costText + } + + // Wall-clock span across the round. + let minStart = Number.POSITIVE_INFINITY + let maxEnd = Number.NEGATIVE_INFINITY + let minStartStr: string | undefined + let maxEndStr: string | undefined + for (const run of roundRuns) { + const started = parseTime(run.startedAt) + const ended = parseTime(run.endedAt) + if (Number.isFinite(started) && started < minStart) { + minStart = started + minStartStr = run.startedAt + } + if (Number.isFinite(ended) && ended > maxEnd) { + maxEnd = ended + maxEndStr = run.endedAt + } + } + if (minStartStr) summary.startedAt = minStartStr + if (maxEndStr) summary.endedAt = maxEndStr + if (Number.isFinite(minStart) && Number.isFinite(maxEnd) && maxEnd >= minStart) { + summary.durationMs = maxEnd - minStart + } else { + delete summary.durationMs + } + + // Unioned file changes across participants. + const merged = mergeEnsembleFileChanges( + perRun + .map((entry) => entry.fileChanges) + .filter((entry): entry is RemoteRunFileChangeCounts => Boolean(entry)) + ) + if (merged) summary.fileChanges = merged + else delete summary.fileChanges + + return summary +} + +/** Union per-participant file-change tallies for an ensemble round: per-file + * rows dedupe by path (churn from the same file across participants folds into + * one row with summed ±), per-workspace rows dedupe by workspacePath, and the + * scalar counts sum. */ +function mergeEnsembleFileChanges( + parts: RemoteRunFileChangeCounts[] +): RemoteRunFileChangeCounts | undefined { + if (parts.length === 0) return undefined + const sumKey = (key: keyof RemoteRunFileChangeCounts): number => + parts.reduce((acc, part) => acc + (typeof part[key] === 'number' ? (part[key] as number) : 0), 0) + const sumOptional = (key: keyof RemoteRunFileChangeCounts): number | undefined => { + let total = 0 + let present = false + for (const part of parts) { + const v = part[key] + if (typeof v === 'number') { + total += v + present = true + } + } + return present ? total : undefined + } + + const fileByPath = new Map() + for (const part of parts) { + for (const file of part.files ?? []) { + const existing = fileByPath.get(file.path) + if (!existing) { + fileByPath.set(file.path, { ...file }) + } else { + if (typeof file.additions === 'number') { + existing.additions = (existing.additions ?? 0) + file.additions + } + if (typeof file.deletions === 'number') { + existing.deletions = (existing.deletions ?? 0) + file.deletions + } + if (!existing.status && file.status) existing.status = file.status + } + } + } + + const workspaceByPath = new Map() + for (const part of parts) { + for (const workspace of part.workspaces ?? []) { + const key = workspace.workspacePath ?? '' + const existing = workspaceByPath.get(key) + if (!existing) { + workspaceByPath.set(key, { ...workspace }) + } else { + existing.filesChanged += workspace.filesChanged + existing.additions += workspace.additions + existing.deletions += workspace.deletions + const addOpt = (a?: number, b?: number): number | undefined => + a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0) + existing.createdFiles = addOpt(existing.createdFiles, workspace.createdFiles) + existing.modifiedFiles = addOpt(existing.modifiedFiles, workspace.modifiedFiles) + existing.deletedFiles = addOpt(existing.deletedFiles, workspace.deletedFiles) + existing.preExistingFiles = addOpt(existing.preExistingFiles, workspace.preExistingFiles) + } + } + } + + const merged: RemoteRunFileChangeCounts = { + // Unique files across the round when per-file rows are present; otherwise + // fall back to the summed per-run counts. + filesChanged: fileByPath.size > 0 ? fileByPath.size : sumKey('filesChanged'), + additions: sumKey('additions'), + deletions: sumKey('deletions') + } + const createdFiles = sumOptional('createdFiles') + if (createdFiles !== undefined) merged.createdFiles = createdFiles + const modifiedFiles = sumOptional('modifiedFiles') + if (modifiedFiles !== undefined) merged.modifiedFiles = modifiedFiles + const deletedFiles = sumOptional('deletedFiles') + if (deletedFiles !== undefined) merged.deletedFiles = deletedFiles + const preExistingFiles = sumOptional('preExistingFiles') + if (preExistingFiles !== undefined) merged.preExistingFiles = preExistingFiles + if (fileByPath.size > 0) merged.files = [...fileByPath.values()] + if (workspaceByPath.size > 0) { + merged.workspaces = [...workspaceByPath.values()] + merged.workspaceCount = workspaceByPath.size + } + return merged +} + function summarizeRunFileChanges( run: ChatRun, messages?: ChatMessage[] From e81dcac47373bdec9afc82778b788e614e113f58 Mon Sep 17 00:00:00 2001 From: Chris Izatt Date: Thu, 18 Jun 2026 16:34:14 +0100 Subject: [PATCH 2/2] feat(ios): collapse the whole composer to one line when the keyboard drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The above-composer rows lingered while a draft/queue existed because they gated on composerExpanded (which stays true on content). Per request, tie them to raw FOCUS instead: when the composer loses focus, hide everything above the input — diff/changes rows, roster, queued prompts — AND the telemetry rail, leaving just the one-line input + model pill + send. - ComposerView: add onFocusChange (reports raw inputFocused, distinct from onExpandedChange which lingers on draft/queue). - ThreadDetailViews: new composerFocused state drives the above-rows group (now wrapped in `if composerFocused`), the tucked secondary rows, the telemetry rail, and the attachedTop/attachedBottom seams via hasAboveContent (now focus-gated). Main composer forcesExpanded → false so ensemble collapses on blur too (its roster reappears on focus for @-direct). A draft still keeps the input itself expanded (Composer's own isExpanded), so text isn't hidden. Verified: swift build + 73 tests + iPhone-17 simulator build. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/TaskWraithUI/ComposerView.swift | 7 ++++ .../TaskWraithUI/ThreadDetailViews.swift | 37 ++++++++++++------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/ios/TaskWraithKit/Sources/TaskWraithUI/ComposerView.swift b/ios/TaskWraithKit/Sources/TaskWraithUI/ComposerView.swift index bc51b4db..bc591ad8 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/ComposerView.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/ComposerView.swift @@ -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 @@ -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 } diff --git a/ios/TaskWraithKit/Sources/TaskWraithUI/ThreadDetailViews.swift b/ios/TaskWraithKit/Sources/TaskWraithUI/ThreadDetailViews.swift index a30dd3a4..8bdd82a4 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/ThreadDetailViews.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/ThreadDetailViews.swift @@ -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 @@ -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 @@ -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 @@ -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, @@ -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 @@ -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, @@ -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) }