From a08dddeee090872b79df1eef6c77a5dcc69cc995 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 20:45:26 +0200 Subject: [PATCH 1/9] Index child thread ownership --- src/codex/PresentationModel.cpp | 238 ++++++++++++++++----- src/codex/PresentationModel.h | 25 ++- src/codex/ShellWidget.cpp | 27 ++- tests/codex/PresentationPipelineTest.cpp | 259 +++++++++++++++++++++++ 4 files changed, 486 insertions(+), 63 deletions(-) diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp index a27e732..f64d876 100644 --- a/src/codex/PresentationModel.cpp +++ b/src/codex/PresentationModel.cpp @@ -111,11 +111,13 @@ std::string childThreadIdentity(const nlohmann::json &activity) { std::string agentIdentity(const nlohmann::json &activity, const nlohmann::json &scope) { - const std::string childThreadId = childThreadIdentity(activity); - if (!childThreadId.empty()) - return childThreadId; const std::string itemId = stringValue(scope, "itemId"); - return itemId.empty() ? stringValue(activity, "id") : itemId; + if (!itemId.empty()) + return itemId; + const std::string activityId = stringValue(activity, "id"); + if (!activityId.empty()) + return activityId; + return childThreadIdentity(activity); } void mergePreservingCompleteness(nlohmann::json &target, @@ -263,9 +265,7 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { } else if (action == "thread.read") { const nlohmann::json thread = memberValue(data, "thread", nlohmann::json::object()); - ThreadPresentation &hydrated = - upsertThread(thread, stringValue(event, "authority") == "replace"); - correlateAgentThread(hydrated.id); + upsertThread(thread, stringValue(event, "authority") == "replace"); } else if (action == "thread.create" || action == "thread.resume" || action == "thread.fork") { upsertThread(memberValue(data, "thread", nlohmann::json::object()), @@ -384,7 +384,7 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { if (thread != threads.end()) { thread->second.status = statusValue(memberValue(data, "status")); thread->second.raw["status"] = memberValue(data, "status"); - correlateAgentThread(thread->first); + updateOwningAgentStatus(thread->first, thread->second.status); } return; } @@ -461,7 +461,6 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { TurnPresentation &updated = upsertTurn(thread, turn, false); if (lifecycle == "started" && isActiveStatus(updated.status)) thread.status = "active"; - correlateAgentThread(threadId); return; } if (type == "plan.replaced") { @@ -479,7 +478,6 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { nlohmann::json minimalTurn{{"id", turnId}}; TurnPresentation &turn = upsertTurn(thread, minimalTurn, false); upsertItem(thread, turn, data["item"], true); - correlateAgentThread(threadId); } return; } @@ -544,6 +542,8 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { appendIndexedText(item->raw, "content", data, "contentIndex"); else if (!field.empty()) appendText(item->raw, field.c_str(), identity); + if (stringValue(item->raw, "type") == "agentMessage") + updateOwningAgentResult(threadId, stringValue(item->raw, "text")); } const std::vector & @@ -557,6 +557,12 @@ PresentationModel::thread(const std::string &threadId) const noexcept { return iterator == threads.end() ? nullptr : &iterator->second; } +const ChildThreadOwnership *PresentationModel::childOwnership( + const std::string &childThreadId) const noexcept { + const auto iterator = childOwnerships.find(childThreadId); + return iterator == childOwnerships.end() ? nullptr : &iterator->second; +} + std::optional PresentationModel::activeTurnId(const std::string &threadId) const { const ThreadPresentation *value = thread(threadId); @@ -613,7 +619,7 @@ void PresentationModel::mergeThreadList(const nlohmann::json &listedThreads) { if (id.empty()) continue; upsertThread(raw, false, false); - if (listedIds.insert(id).second) + if (!childOwnerships.contains(id) && listedIds.insert(id).second) nextOrder.push_back(id); } @@ -676,7 +682,11 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, const auto turns = raw.find("turns"); if (turns != raw.end() && turns->is_array()) { + std::vector previouslyOwnedChildren; if (replaceTurns) { + previouslyOwnedChildren = result.childThreadOrder; + for (const std::string &childThreadId : previouslyOwnedChildren) + releaseChildOwnership(childThreadId, false); result.turnOrder.clear(); result.turns.clear(); result.agentOrder.clear(); @@ -687,6 +697,13 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, for (const auto &turn : *turns) upsertTurn(result, turn, replaceTurns); if (replaceTurns) { + for (const std::string &childThreadId : previouslyOwnedChildren) { + if (!childOwnerships.contains(childThreadId) && + threads.contains(childThreadId) && + std::find(orderedThreads.begin(), orderedThreads.end(), + childThreadId) == orderedThreads.end()) + orderedThreads.push_back(childThreadId); + } for (const auto &[turnId, terminalStatus] : terminalTurnStatuses) { const auto turn = result.turns.find(turnId); if (turn != result.turns.end() && isActiveStatus(turn->second.status)) { @@ -705,6 +722,10 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, result.raw["status"] = previousThreadStatus; } } + if (replaceTurns) + synchronizeOwningAgent(id, true); + else if (status != raw.end()) + updateOwningAgentStatus(id, result.status); return result; } @@ -743,6 +764,7 @@ TurnPresentation &PresentationModel::upsertTurn(ThreadPresentation &thread, for (const auto &item : *items) upsertItem(thread, result, item); } + updateOwningAgentStatus(thread.id, result.status); return result; } @@ -772,6 +794,8 @@ ItemPresentation &PresentationModel::upsertItem(ThreadPresentation &thread, {{"threadId", thread.id}, {"turnId", turn.id}, {"itemId", result.id}}, result.raw, live); } + if (type == "agentMessage") + updateOwningAgentResult(thread.id, stringValue(result.raw, "text")); return result; } @@ -786,17 +810,16 @@ void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, if (!states.is_object()) return; for (const auto &[childThreadId, state] : states.items()) { - const auto existing = owner.agents.find(childThreadId); - if (existing == owner.agents.end() || !state.is_object()) + AgentPresentation *existing = owningAgent(childThreadId); + if (!existing || !state.is_object()) continue; const std::string status = stringValue(state, "status"); const std::string message = stringValue(state, "message"); if (!status.empty()) - existing->second.status = status; + existing->status = status; if (!message.empty()) - existing->second.raw["resultText"] = message; - existing->second.raw["agentState"] = state; - correlateAgentThread(childThreadId); + existing->raw["resultText"] = message; + existing->raw["agentState"] = state; } return; } @@ -819,9 +842,6 @@ void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, agent.ownerTurnId = stringValue(scope, "turnId"); mergePreservingCompleteness(agent.raw, activity); - if (!childThreadId.empty()) - agent.childThreadId = childThreadId; - const std::string activityStatus = stringValue(activity, "status"); const std::string activityKind = stringValue(activity, "kind"); if (!activityStatus.empty()) @@ -831,63 +851,177 @@ void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, else if (!activityKind.empty()) agent.status = activityKind; - if (!agent.childThreadId.empty()) { - auto [child, childInserted] = threads.try_emplace(agent.childThreadId); - if (childInserted) - child->second.id = agent.childThreadId; - child->second.agentThread = true; - std::erase(orderedThreads, agent.childThreadId); - correlateAgentThread(agent.childThreadId); + if (!childThreadId.empty()) + assignChildOwnership(owner, agent, childThreadId); +} + +void PresentationModel::assignChildOwnership(ThreadPresentation &parent, + AgentPresentation &agent, + const std::string &childThreadId) { + if (childThreadId == parent.id) + return; + std::string ancestorId = parent.id; + std::unordered_set visited; + while (visited.insert(ancestorId).second) { + const auto ancestor = childOwnerships.find(ancestorId); + if (ancestor == childOwnerships.end()) + break; + ancestorId = ancestor->second.parentThreadId; + if (ancestorId == childThreadId) + return; + } + if (!agent.childThreadId.empty() && agent.childThreadId != childThreadId) { + const auto previous = childOwnerships.find(agent.childThreadId); + if (previous != childOwnerships.end() && + previous->second.parentThreadId == parent.id && + previous->second.agentId == agent.id) + releaseChildOwnership(agent.childThreadId, true); + } + + const auto previous = childOwnerships.find(childThreadId); + if (previous != childOwnerships.end() && + (previous->second.parentThreadId != parent.id || + previous->second.agentId != agent.id)) + releaseChildOwnership(childThreadId, false); + + agent.childThreadId = childThreadId; + agent.raw["childThreadId"] = childThreadId; + childOwnerships[childThreadId] = {parent.id, agent.id}; + if (std::find(parent.childThreadOrder.begin(), parent.childThreadOrder.end(), + childThreadId) == parent.childThreadOrder.end()) + parent.childThreadOrder.push_back(childThreadId); + + auto [child, inserted] = threads.try_emplace(childThreadId); + if (inserted) + child->second.id = childThreadId; + std::erase(orderedThreads, childThreadId); + synchronizeOwningAgent(childThreadId); +} + +void PresentationModel::releaseChildOwnership(const std::string &childThreadId, + bool promoteToRoot) { + const auto ownership = childOwnerships.find(childThreadId); + if (ownership == childOwnerships.end()) + return; + const ChildThreadOwnership previous = ownership->second; + const auto parent = threads.find(previous.parentThreadId); + if (parent != threads.end()) { + std::erase(parent->second.childThreadOrder, childThreadId); + const auto agent = parent->second.agents.find(previous.agentId); + if (agent != parent->second.agents.end() && + agent->second.childThreadId == childThreadId) { + agent->second.childThreadId.clear(); + agent->second.raw.erase("childThreadId"); + } } + childOwnerships.erase(ownership); + if (promoteToRoot && threads.contains(childThreadId) && + std::find(orderedThreads.begin(), orderedThreads.end(), childThreadId) == + orderedThreads.end()) + orderedThreads.push_back(childThreadId); } -void PresentationModel::correlateAgentThread(const std::string &childThreadId) { +AgentPresentation * +PresentationModel::owningAgent(const std::string &childThreadId) { + const auto ownership = childOwnerships.find(childThreadId); + if (ownership == childOwnerships.end()) + return nullptr; + const auto parent = threads.find(ownership->second.parentThreadId); + if (parent == threads.end()) + return nullptr; + const auto agent = parent->second.agents.find(ownership->second.agentId); + return agent == parent->second.agents.end() ? nullptr : &agent->second; +} + +void PresentationModel::updateOwningAgentStatus( + const std::string &childThreadId, const std::string &status) { + if (status.empty()) + return; + if (AgentPresentation *agent = owningAgent(childThreadId)) { + if (isTerminalTurnStatus(agent->status) && isActiveStatus(status)) + return; + agent->status = status; + } +} + +void PresentationModel::updateOwningAgentResult( + const std::string &childThreadId, const std::string &resultText) { + if (resultText.empty()) + return; + if (AgentPresentation *agent = owningAgent(childThreadId)) + agent->raw["resultText"] = resultText; +} + +void PresentationModel::synchronizeOwningAgent( + const std::string &childThreadId, bool clearMissingResult) { + AgentPresentation *agent = owningAgent(childThreadId); const auto child = threads.find(childThreadId); - if (child == threads.end()) + if (!agent || child == threads.end()) return; - std::string childStatus = child->second.status; + std::string childStatus; std::string resultText; - for (const std::string &turnId : child->second.turnOrder) { - const auto turn = child->second.turns.find(turnId); + for (auto turnId = child->second.turnOrder.rbegin(); + turnId != child->second.turnOrder.rend(); ++turnId) { + const auto turn = child->second.turns.find(*turnId); if (turn == child->second.turns.end()) continue; - if (!turn->second.status.empty()) + if (childStatus.empty() && !turn->second.status.empty()) childStatus = turn->second.status; - for (const std::string &itemId : turn->second.itemOrder) { - const auto item = turn->second.items.find(itemId); + for (auto itemId = turn->second.itemOrder.rbegin(); + itemId != turn->second.itemOrder.rend(); ++itemId) { + const auto item = turn->second.items.find(*itemId); if (item == turn->second.items.end() || stringValue(item->second.raw, "type") != "agentMessage") continue; - const std::string text = stringValue(item->second.raw, "text"); - if (!text.empty()) - resultText = text; - } - } - - for (auto &[ownerId, owner] : threads) { - static_cast(ownerId); - for (auto &[agentId, agent] : owner.agents) { - static_cast(agentId); - if (agent.childThreadId != childThreadId) - continue; - if (!childStatus.empty()) - agent.status = childStatus; + resultText = stringValue(item->second.raw, "text"); if (!resultText.empty()) - agent.raw["resultText"] = resultText; - agent.raw["childThreadId"] = childThreadId; + break; } + if (!resultText.empty() && !childStatus.empty()) + break; } + if (childStatus.empty()) + childStatus = child->second.status; + updateOwningAgentStatus(childThreadId, childStatus); + if (resultText.empty() && clearMissingResult) + agent->raw.erase("resultText"); + else if (!resultText.empty()) + agent->raw["resultText"] = resultText; } void PresentationModel::removeThread(const std::string &threadId) { - threads.erase(threadId); + const auto thread = threads.find(threadId); + if (thread == threads.end()) + return; + const std::vector children = thread->second.childThreadOrder; + const auto root = std::find(orderedThreads.begin(), orderedThreads.end(), + threadId); + const std::size_t rootIndex = + root == orderedThreads.end() + ? orderedThreads.size() + : static_cast(std::distance(orderedThreads.begin(), root)); + for (const std::string &childThreadId : children) + releaseChildOwnership(childThreadId, false); + releaseChildOwnership(threadId, false); + threads.erase(thread); std::erase(orderedThreads, threadId); + std::size_t insertion = std::min(rootIndex, orderedThreads.size()); + for (const std::string &childThreadId : children) { + if (!threads.contains(childThreadId) || + childOwnerships.contains(childThreadId)) + continue; + orderedThreads.insert(orderedThreads.begin() + + static_cast(insertion), + childThreadId); + ++insertion; + } } void PresentationModel::clearProviderState() { orderedThreads.clear(); threads.clear(); + childOwnerships.clear(); pendingRequests.clear(); models = nlohmann::json::array(); retainedGlobalDomains.clear(); diff --git a/src/codex/PresentationModel.h b/src/codex/PresentationModel.h index 551dd08..03815bf 100644 --- a/src/codex/PresentationModel.h +++ b/src/codex/PresentationModel.h @@ -38,6 +38,13 @@ struct AgentPresentation { nlohmann::json raw = nlohmann::json::object(); }; +struct ChildThreadOwnership { + std::string parentThreadId; + std::string agentId; + + bool operator==(const ChildThreadOwnership &) const = default; +}; + struct ThreadPresentation { std::string id; std::string title; @@ -57,8 +64,8 @@ struct ThreadPresentation { std::uint64_t settingsRevision = 0; std::vector agentOrder; std::unordered_map agents; + std::vector childThreadOrder; bool archived = false; - bool agentThread = false; }; struct PendingRequestPresentation { @@ -98,6 +105,8 @@ class PresentationModel final { [[nodiscard]] const std::vector &threadOrder() const noexcept; [[nodiscard]] const ThreadPresentation * thread(const std::string &threadId) const noexcept; + [[nodiscard]] const ChildThreadOwnership * + childOwnership(const std::string &childThreadId) const noexcept; [[nodiscard]] std::optional activeTurnId(const std::string &threadId) const; [[nodiscard]] std::size_t pendingRequestCount() const noexcept; @@ -125,7 +134,18 @@ class PresentationModel final { void upsertAgentActivity(ThreadPresentation &owner, const nlohmann::json &scope, const nlohmann::json &activity, bool live = true); - void correlateAgentThread(const std::string &childThreadId); + void assignChildOwnership(ThreadPresentation &parent, + AgentPresentation &agent, + const std::string &childThreadId); + void releaseChildOwnership(const std::string &childThreadId, + bool promoteToRoot); + void synchronizeOwningAgent(const std::string &childThreadId, + bool clearMissingResult = false); + AgentPresentation *owningAgent(const std::string &childThreadId); + void updateOwningAgentStatus(const std::string &childThreadId, + const std::string &status); + void updateOwningAgentResult(const std::string &childThreadId, + const std::string &resultText); void removeThread(const std::string &threadId); void clearProviderState(); void retainDomainEvent(const std::string &type, const nlohmann::json &data, @@ -137,6 +157,7 @@ class PresentationModel final { std::vector orderedThreads; std::unordered_map threads; + std::unordered_map childOwnerships; std::unordered_map pendingRequests; ConnectionPresentation connectionState; nlohmann::json models = nlohmann::json::array(); diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index 0419689..dd4cc27 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -208,7 +208,7 @@ struct ShellWidget::Impl final { void renderConversation(); void refreshSettings(); void refreshStatus(); - void hydrateHistoricalAgents(); + void hydrateHistoricalChildren(const std::string &parentThreadId); void showNotice(QString message, bool error = true); void resetRuntimeForConnection(); @@ -576,7 +576,11 @@ void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { ensureThreadSettingsHydrated(selectedThreadId); } - hydrateHistoricalAgents(); + if (kind == "result" && action == "thread.read" && + event.value("ok", false)) + hydrateHistoricalChildren(eventThreadId); + else if (kind == "event" && type == "agents.activity.upsert") + hydrateHistoricalChildren(eventThreadId); scheduleRender(); } @@ -820,19 +824,24 @@ void ShellWidget::Impl::refreshStatus() { middleRegion->composer().setSettingsEnabled(canSubmit && !snapshot.active); } -void ShellWidget::Impl::hydrateHistoricalAgents() { - const ThreadPresentation *thread = model.thread(selectedThreadId); +void ShellWidget::Impl::hydrateHistoricalChildren( + const std::string &parentThreadId) { + const ThreadPresentation *thread = model.thread(parentThreadId); if (!thread) return; - for (const std::string &id : thread->agentOrder) { - const auto agent = thread->agents.find(id); - if (agent == thread->agents.end() || agent->second.childThreadId.empty() || - agent->second.status != "started") + for (const std::string &childThreadId : thread->childThreadOrder) { + const ChildThreadOwnership *ownership = + model.childOwnership(childThreadId); + if (!ownership || ownership->parentThreadId != parentThreadId) + continue; + const auto agent = thread->agents.find(ownership->agentId); + if (agent == thread->agents.end() || + !isActiveStatus(agent->second.status)) continue; // Historical child hydration shares the same monotonic read boundary as // user-selected threads, so a pre-reconnect result cannot replace newer // child/agent presentation state. - readThread(agent->second.childThreadId); + readThread(childThreadId); } } diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp index d20c6dd..e5fbbdc 100644 --- a/tests/codex/PresentationPipelineTest.cpp +++ b/tests/codex/PresentationPipelineTest.cpp @@ -344,6 +344,265 @@ int main() { "retained-a"}, "thread discovery preserves provider order and one retained tail"); + PresentationModel ownershipModel; + ownershipModel.applyEvent(codexui::codex::presentation::result( + 1, 1, "threads.list", "ownership-roots", true, + {{"threads", + nlohmann::json::array({{{"id", "parent"}}, + {{"id", "child-one"}}, + {{"id", "second-root"}}})}}, + codexui::codex::presentation::Authority::Merge)); + ownershipModel.applyEvent(codexui::codex::presentation::event( + 2, 1, "agents.activity.upsert", + {{"activity", + {{"id", "spawn-one"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "child-one"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "parent"}, + {"turnId", "parent-turn"}, + {"itemId", "spawn-one"}})); + ownershipModel.applyEvent(codexui::codex::presentation::event( + 3, 1, "agents.activity.upsert", + {{"activity", + {{"id", "spawn-one"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "child-one"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "parent"}, + {"turnId", "parent-turn"}, + {"itemId", "spawn-one"}})); + ownershipModel.applyEvent(codexui::codex::presentation::event( + 4, 1, "agents.activity.upsert", + {{"activity", + {{"id", "spawn-two"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "child-two"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "parent"}, + {"turnId", "parent-turn"}, + {"itemId", "spawn-two"}})); + ownershipModel.applyEvent(codexui::codex::presentation::event( + 5, 1, "agents.activity.upsert", + {{"activity", + {{"id", "spawn-grandchild"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "grandchild"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "child-one"}, + {"turnId", "child-turn"}, + {"itemId", "spawn-grandchild"}})); + const auto *childOneOwnership = + ownershipModel.childOwnership("child-one"); + const auto *grandchildOwnership = + ownershipModel.childOwnership("grandchild"); + const auto *parent = ownershipModel.thread("parent"); + const auto *childOne = ownershipModel.thread("child-one"); + passed &= expect( + childOneOwnership && childOneOwnership->parentThreadId == "parent" && + childOneOwnership->agentId == "spawn-one" && + grandchildOwnership && + grandchildOwnership->parentThreadId == "child-one" && + parent && + parent->childThreadOrder == + std::vector{"child-one", "child-two"} && + childOne && childOne->childThreadOrder == + std::vector{"grandchild"} && + ownershipModel.threadOrder() == + std::vector{"parent", "second-root"}, + "ownership is unique, ordered, nested, and excluded from root order"); + + ownershipModel.applyEvent(codexui::codex::presentation::event( + 6, 1, "turn.upsert", + {{"turn", {{"id", "child-turn"}, {"status", "completed"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "child-one"}, {"turnId", "child-turn"}})); + ownershipModel.applyEvent(codexui::codex::presentation::event( + 7, 1, "conversation.item.upsert", + {{"item", + {{"id", "child-answer"}, + {"type", "agentMessage"}, + {"text", "direct child result"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "child-one"}, + {"turnId", "child-turn"}, + {"itemId", "child-answer"}})); + parent = ownershipModel.thread("parent"); + const auto ownerAgent = parent == nullptr + ? nullptr + : [&]() -> const codexui::codex::AgentPresentation * { + const auto found = + parent->agents.find("spawn-one"); + return found == parent->agents.end() + ? nullptr + : &found->second; + }(); + passed &= expect( + ownerAgent && ownerAgent->status == "completed" && + stringMember(ownerAgent->raw, "resultText") == + "direct child result" && + parent->agents.at("spawn-two").status == "started", + "child completion and results route only to the indexed owning agent"); + + ownershipModel.applyEvent(codexui::codex::presentation::result( + 8, 1, "thread.read", "replace-child", true, + {{"thread", + {{"id", "child-one"}, + {"status", {{"type", "idle"}}}, + {"turns", nlohmann::json::array()}}}}, + codexui::codex::presentation::Authority::Replace, + {{"threadId", "child-one"}})); + parent = ownershipModel.thread("parent"); + passed &= expect( + parent && parent->agents.at("spawn-one").status == "idle" && + !parent->agents.at("spawn-one").raw.contains("resultText") && + ownershipModel.childOwnership("child-one") != nullptr, + "authoritative child hydration clears stale results without losing ownership"); + + ownershipModel.applyEvent(codexui::codex::presentation::result( + 9, 1, "threads.list", "relisted-owned-child", true, + {{"threads", + nlohmann::json::array({{{"id", "child-one"}}, + {{"id", "second-root"}}, + {{"id", "parent"}}})}}, + codexui::codex::presentation::Authority::Merge)); + passed &= expect( + ownershipModel.threadOrder() == + std::vector{"second-root", "parent", "grandchild"}, + "thread relisting cannot reintroduce an owned child as a root"); + + ownershipModel.applyEvent(codexui::codex::presentation::result( + 10, 1, "thread.read", "replace-parent", true, + {{"thread", + {{"id", "parent"}, + {"turns", + nlohmann::json::array( + {{{"id", "replacement-turn"}, + {"items", + nlohmann::json::array( + {{{"id", "spawn-two"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "child-two"}}, + {{"id", "spawn-one"}, + {"type", "subAgentActivity"}, + {"status", "completed"}, + {"agentThreadId", "child-one"}}})}}})}}}}, + codexui::codex::presentation::Authority::Replace, + {{"threadId", "parent"}})); + parent = ownershipModel.thread("parent"); + passed &= expect( + parent && + parent->childThreadOrder == + std::vector{"child-two", "child-one"} && + ownershipModel.childOwnership("child-one") && + ownershipModel.childOwnership("child-two") && + ownershipModel.threadOrder() == + std::vector{"second-root", "parent", + "grandchild"}, + "authoritative parent hydration rebuilds ordered ownership in one pass"); + + ownershipModel.applyEvent(codexui::codex::presentation::event( + 11, 1, "thread.removed", nlohmann::json::object(), + codexui::codex::presentation::Authority::Remove, + {{"threadId", "child-one"}})); + parent = ownershipModel.thread("parent"); + passed &= expect( + ownershipModel.thread("child-one") == nullptr && parent && + parent->childThreadOrder == + std::vector{"child-two"} && + ownershipModel.childOwnership("child-one") == nullptr && + ownershipModel.childOwnership("grandchild") == nullptr && + ownershipModel.threadOrder() == + std::vector{"second-root", "parent", + "grandchild"}, + "authoritative child removal prunes ownership and promotes surviving descendants"); + + ownershipModel.applyEvent(codexui::codex::presentation::event( + 12, 1, "thread.removed", nlohmann::json::object(), + codexui::codex::presentation::Authority::Remove, + {{"threadId", "parent"}})); + passed &= expect( + ownershipModel.thread("parent") == nullptr && + ownershipModel.childOwnership("child-two") == nullptr && + ownershipModel.threadOrder() == + std::vector{"second-root", "child-two", + "grandchild"}, + "authoritative parent removal promotes children in retained root order"); + + ownershipModel.applyEvent(codexui::codex::presentation::event( + 13, 1, "connection.provider", + {{"generation", std::uint64_t{1}}, {"state", "disconnected"}}, + codexui::codex::presentation::Authority::Replace)); + passed &= expect(ownershipModel.threadOrder().empty() && + ownershipModel.thread("child-two") == nullptr && + ownershipModel.childOwnership("child-two") == nullptr, + "provider loss clears threads and ownership atomically"); + + PresentationModel reconnectOwnershipModel; + reconnectOwnershipModel.applyEvent(codexui::codex::presentation::result( + 1, 1, "thread.read", "hydrate-owner", true, + {{"thread", + {{"id", "hydrated-parent"}, + {"turns", + nlohmann::json::array( + {{{"id", "hydrated-turn"}, + {"items", + nlohmann::json::array( + {{{"id", "hydrated-spawn"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "hydrated-child"}}})}}})}}}}, + codexui::codex::presentation::Authority::Replace, + {{"threadId", "hydrated-parent"}})); + reconnectOwnershipModel.applyEvent(codexui::codex::presentation::result( + 2, 1, "thread.read", "hydrate-child", true, + {{"thread", + {{"id", "hydrated-child"}, + {"turns", + nlohmann::json::array( + {{{"id", "child-turn"}, + {"status", "completed"}, + {"items", + nlohmann::json::array( + {{{"id", "nested-spawn"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "hydrated-grandchild"}}, + {{"id", "hydrated-result"}, + {"type", "agentMessage"}, + {"text", "hydrated answer"}}})}}})}}}}, + codexui::codex::presentation::Authority::Replace, + {{"threadId", "hydrated-child"}})); + const auto *hydratedParent = + reconnectOwnershipModel.thread("hydrated-parent"); + passed &= expect( + hydratedParent && + reconnectOwnershipModel.childOwnership("hydrated-child") && + reconnectOwnershipModel.childOwnership("hydrated-grandchild") && + hydratedParent->agents.at("hydrated-spawn").status == "completed" && + stringMember(hydratedParent->agents.at("hydrated-spawn").raw, + "resultText") == "hydrated answer", + "parent-first and nested child hydration retain direct correlation"); + reconnectOwnershipModel.applyEvent(codexui::codex::presentation::event( + 1, 2, "connection.lifecycle", {{"state", "connected"}}, + codexui::codex::presentation::Authority::Replace)); + reconnectOwnershipModel.applyEvent(codexui::codex::presentation::result( + 2, 2, "threads.list", "post-reconnect-roots", true, + {{"threads", + nlohmann::json::array({{{"id", "hydrated-child"}}, + {{"id", "hydrated-parent"}}})}}, + codexui::codex::presentation::Authority::Merge)); + passed &= expect( + reconnectOwnershipModel.childOwnership("hydrated-child") && + reconnectOwnershipModel.threadOrder() == + std::vector{"hydrated-parent"}, + "connection-generation reconnect preserves ownership and root filtering"); + normalizer.bridgeEvent({{"kind", "bridge.provider"}, {"state", "disconnected"}, {"providerGeneration", std::uint64_t{1}}, From af32e62d8b54f1bd7d7274126321e89e0e2368cf Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 20:52:35 +0200 Subject: [PATCH 2/9] Render nested thread hierarchy --- src/codex/middle/ThreadPane.cpp | 185 +++++++++++++++++++++----- src/codex/middle/ThreadPane.h | 18 ++- tests/codex/ApplicationLayoutTest.cpp | 184 ++++++++++++++++++++++++- 3 files changed, 345 insertions(+), 42 deletions(-) diff --git a/src/codex/middle/ThreadPane.cpp b/src/codex/middle/ThreadPane.cpp index 2fb1768..ba24c12 100644 --- a/src/codex/middle/ThreadPane.cpp +++ b/src/codex/middle/ThreadPane.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -28,8 +29,17 @@ namespace codexui::codex::middle { namespace { constexpr int ContextMenuRole = Qt::UserRole + 1; +constexpr int DepthRole = Qt::UserRole + 2; +constexpr int HasChildrenRole = Qt::UserRole + 3; +constexpr int ExpandedRole = Qt::UserRole + 4; +constexpr int ParentIdRole = Qt::UserRole + 5; +constexpr int ChildIndent = 16; class ThreadListWidget final : public QListWidget { +public: + std::function toggleExpansion; + std::function navigateHierarchy; + protected: QItemSelectionModel::SelectionFlags selectionCommand(const QModelIndex &index, @@ -42,6 +52,35 @@ class ThreadListWidget final : public QListWidget { } return QListWidget::selectionCommand(index, event); } + + void mousePressEvent(QMouseEvent *event) override { + QListWidgetItem *item = itemAt(event->position().toPoint()); + if (event->button() == Qt::LeftButton && item && + item->data(HasChildrenRole).toBool()) { + const QRect itemRect = visualItemRect(item); + const int indicatorRight = + itemRect.left() + 5 + item->data(DepthRole).toInt() * ChildIndent + + 16; + if (event->position().x() <= indicatorRight) { + if (toggleExpansion) + toggleExpansion( + item->data(Qt::UserRole).toString().toStdString()); + event->accept(); + return; + } + } + QListWidget::mousePressEvent(event); + } + + void keyPressEvent(QKeyEvent *event) override { + if ((event->key() == Qt::Key_Left || event->key() == Qt::Key_Right) && + navigateHierarchy) { + navigateHierarchy(event->key()); + event->accept(); + return; + } + QListWidget::keyPressEvent(event); + } }; class ThreadItemDelegate final : public QStyledItemDelegate { @@ -81,10 +120,21 @@ QFrame *statusDot() { void updateRow(QWidget *row, const std::string &threadId, const std::string &threadTitle, - const std::string &threadStatus, std::size_t requestCount) { + const std::string &threadStatus, std::size_t requestCount, + std::size_t depth, bool hasChildren, bool expanded) { auto *title = row->findChild(QStringLiteral("threadTitle")); auto *status = row->findChild(QStringLiteral("threadStatus")); auto *dot = row->findChild(QStringLiteral("threadStatusDot")); + auto *indent = row->findChild(QStringLiteral("threadIndent")); + auto *indicator = + row->findChild(QStringLiteral("threadExpansionIndicator")); + indent->setFixedWidth(static_cast(depth) * ChildIndent); + indicator->setText(hasChildren ? (expanded ? QStringLiteral("⌄") + : QStringLiteral("›")) + : QString{}); + indicator->setToolTip(hasChildren ? (expanded ? QStringLiteral("Collapse") + : QStringLiteral("Expand")) + : QString{}); QString titleText = text(threadTitle); if (titleText.isEmpty()) titleText = text(threadId.substr(0, 12)); @@ -119,6 +169,17 @@ QWidget *createRow() { auto *layout = new QHBoxLayout(row); layout->setContentsMargins(5, 2, 5, 2); layout->setSpacing(8); + auto *indent = new QWidget; + indent->setObjectName(QStringLiteral("threadIndent")); + indent->setFixedWidth(0); + indent->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); + layout->addWidget(indent); + auto *indicator = makeLabel({}, "meta"); + indicator->setObjectName(QStringLiteral("threadExpansionIndicator")); + indicator->setAlignment(Qt::AlignCenter); + indicator->setFixedWidth(8); + indicator->setTextInteractionFlags(Qt::NoTextInteraction); + layout->addWidget(indicator); layout->addWidget(statusDot()); auto *copy = new QVBoxLayout; copy->setContentsMargins(0, 0, 0, 0); @@ -234,6 +295,11 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { layout->addLayout(toolbar); list = new ThreadListWidget; + auto *threadList = static_cast(list); + threadList->toggleExpansion = + [this](const std::string &id) { toggleExpanded(id); }; + threadList->navigateHierarchy = + [this](int key) { navigateHierarchy(key); }; list->setObjectName(QStringLiteral("threadList")); list->setItemDelegate(new ThreadItemDelegate(list)); list->setSelectionMode(QAbstractItemView::SingleSelection); @@ -307,8 +373,8 @@ void ThreadPane::updateSortButton() { : QStringLiteral("Recent"))); } -void ThreadPane::sortVisibleThreads(std::vector &ids, - const PresentationModel &model) const { +void ThreadPane::sortRootThreads(std::vector &ids, + const PresentationModel &model) const { QCollator collator(QLocale::system().language() == QLocale::C ? QLocale(QLocale::English) : QLocale::system()); @@ -348,6 +414,75 @@ void ThreadPane::sortVisibleThreads(std::vector &ids, }); } +void ThreadPane::appendVisibleThread( + ThreadPaneSnapshot &snapshot, const PresentationModel &model, + const std::unordered_map &pendingByThread, + const std::string &threadId, const std::string &parentId, + std::size_t depth, std::unordered_set &visited) const { + if (!visited.insert(threadId).second) + return; + const ThreadPresentation *thread = model.thread(threadId); + if (!thread) + return; + const bool hasChildren = std::ranges::any_of( + thread->childThreadOrder, + [&model](const std::string &id) { return model.thread(id) != nullptr; }); + const bool expanded = hasChildren && !collapsedThreads.contains(threadId); + const auto pending = pendingByThread.find(threadId); + snapshot.rows.push_back( + {threadId, thread->title, thread->cwd, thread->status, parentId, + pending == pendingByThread.end() ? std::size_t{} : pending->second, + depth, hasChildren, expanded}); + if (!expanded) + return; + for (const std::string &childThreadId : thread->childThreadOrder) + appendVisibleThread(snapshot, model, pendingByThread, childThreadId, + threadId, depth + 1, visited); +} + +void ThreadPane::toggleExpanded(const std::string &threadId) { + if (collapsedThreads.contains(threadId)) + collapsedThreads.erase(threadId); + else + collapsedThreads.insert(threadId); + visibleSnapshot.reset(); + if (currentModel) + refresh(*currentModel, projectedSelectedThreadId); +} + +void ThreadPane::navigateHierarchy(int key) { + QListWidgetItem *current = list->currentItem(); + if (!current) + return; + const std::string id = + current->data(Qt::UserRole).toString().toStdString(); + const bool hasChildren = current->data(HasChildrenRole).toBool(); + const bool expanded = current->data(ExpandedRole).toBool(); + if (key == Qt::Key_Right && hasChildren) { + if (!expanded) { + toggleExpanded(id); + return; + } + const int nextRow = list->row(current) + 1; + if (nextRow < list->count() && + list->item(nextRow)->data(ParentIdRole).toString().toStdString() == id) + list->setCurrentRow(nextRow); + return; + } + if (key != Qt::Key_Left) + return; + if (hasChildren && expanded) { + toggleExpanded(id); + return; + } + const QString parentId = current->data(ParentIdRole).toString(); + if (parentId.isEmpty()) + return; + const auto parent = rows.find(parentId.toStdString()); + if (parent != rows.end()) + list->setCurrentItem(parent->second); +} + void ThreadPane::setContextHighlight(const std::string &threadId, bool highlighted) { const auto found = rows.find(threadId); @@ -360,23 +495,12 @@ void ThreadPane::refresh(const PresentationModel &model, const std::string &selectedThreadId) { currentModel = &model; projectedSelectedThreadId = selectedThreadId; - const std::vector &authoritativeOrder = model.threadOrder(); - const std::unordered_set authoritativeIds( - authoritativeOrder.begin(), authoritativeOrder.end()); - std::erase_if(retainedVisibleThreads, [&](const std::string &id) { - return !model.thread(id) || authoritativeIds.contains(id); + std::erase_if(collapsedThreads, [&model](const std::string &id) { + const ThreadPresentation *thread = model.thread(id); + return !thread || thread->childThreadOrder.empty(); }); - if (!selectedThreadId.empty() && model.thread(selectedThreadId) && - !authoritativeIds.contains(selectedThreadId) && - std::find(retainedVisibleThreads.begin(), retainedVisibleThreads.end(), - selectedThreadId) == retainedVisibleThreads.end()) { - retainedVisibleThreads.insert(retainedVisibleThreads.begin(), - selectedThreadId); - } - std::vector visibleOrder = retainedVisibleThreads; - visibleOrder.insert(visibleOrder.end(), authoritativeOrder.begin(), - authoritativeOrder.end()); - sortVisibleThreads(visibleOrder, model); + std::vector rootOrder = model.threadOrder(); + sortRootThreads(rootOrder, model); std::unordered_map pendingByThread; pendingByThread.reserve(model.pendingRequestCount()); @@ -385,20 +509,11 @@ void ThreadPane::refresh(const PresentationModel &model, static_cast(requestId); ++pendingByThread[request.threadId]; } - const auto pendingCount = [&pendingByThread](const std::string &id) { - const auto found = pendingByThread.find(id); - return found == pendingByThread.end() ? std::size_t{} : found->second; - }; - ThreadPaneSnapshot next{selectedThreadId, sortCriterion, {}}; - next.rows.reserve(visibleOrder.size()); - for (const std::string &id : visibleOrder) { - const ThreadPresentation *thread = model.thread(id); - if (!thread) - continue; - next.rows.push_back({id, thread->title, thread->cwd, thread->status, - pendingCount(id)}); - } + std::unordered_set visited; + visited.reserve(rootOrder.size()); + for (const std::string &id : rootOrder) + appendVisibleThread(next, model, pendingByThread, id, {}, 0, visited); if (visibleSnapshot && *visibleSnapshot == next) return; visibleSnapshot = std::move(next); @@ -463,8 +578,12 @@ void ThreadPane::refresh(const PresentationModel &model, } QListWidgetItem *item = found->second; item->setToolTip(text(row.cwd)); + item->setData(DepthRole, static_cast(row.depth)); + item->setData(HasChildrenRole, row.hasChildren); + item->setData(ExpandedRole, row.expanded); + item->setData(ParentIdRole, text(row.parentId)); updateRow(list->itemWidget(item), row.id, row.title, row.status, - row.pending); + row.pending, row.depth, row.hasChildren, row.expanded); if (row.id == contextThreadId) setContextHighlight(row.id, true); if (row.id == snapshot.selectedThreadId) diff --git a/src/codex/middle/ThreadPane.h b/src/codex/middle/ThreadPane.h index f17db42..b92e343 100644 --- a/src/codex/middle/ThreadPane.h +++ b/src/codex/middle/ThreadPane.h @@ -10,6 +10,7 @@ #include #include #include +#include #include class QListWidget; @@ -53,7 +54,11 @@ class ThreadPane final : public QFrame { std::string title; std::string cwd; std::string status; + std::string parentId; std::size_t pending = 0; + std::size_t depth = 0; + bool hasChildren = false; + bool expanded = false; bool operator==(const ThreadRowSnapshot &) const = default; }; @@ -66,8 +71,15 @@ class ThreadPane final : public QFrame { }; void updateSortButton(); - void sortVisibleThreads(std::vector &ids, - const PresentationModel &model) const; + void sortRootThreads(std::vector &ids, + const PresentationModel &model) const; + void appendVisibleThread( + ThreadPaneSnapshot &snapshot, const PresentationModel &model, + const std::unordered_map &pendingByThread, + const std::string &threadId, const std::string &parentId, + std::size_t depth, std::unordered_set &visited) const; + void toggleExpanded(const std::string &threadId); + void navigateHierarchy(int key); void setContextHighlight(const std::string &threadId, bool highlighted); void showContextMenu(const QPoint &position); @@ -77,7 +89,7 @@ class ThreadPane final : public QFrame { QToolButton *sortButton = nullptr; QListWidget *list = nullptr; std::unordered_map rows; - std::vector retainedVisibleThreads; + std::unordered_set collapsedThreads; std::string projectedSelectedThreadId; std::string contextThreadId; QMenu *contextMenu = nullptr; diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index 85452b7..5d9f043 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -167,6 +168,17 @@ std::vector threadOrder(const ThreadPane &pane) { return result; } +QListWidgetItem *threadItem(QListWidget *list, std::string_view id) { + if (!list) + return nullptr; + for (int row = 0; row < list->count(); ++row) { + QListWidgetItem *item = list->item(row); + if (item && item->data(Qt::UserRole).toString().toStdString() == id) + return item; + } + return nullptr; +} + bool testOverlayGeometryAndRegionRouting() { MiddleRegionWidget region; bool result = @@ -456,7 +468,7 @@ bool testThreadSelectionProjection() { selected->data(Qt::UserRole).toString() == QStringLiteral("thread-b") && pane.visiblySelectedThreadId() == "thread-b", - "a hydrated selected child thread remains visible outside root ordering"); + "a hydrated selected child thread is visible beneath its parent"); QWidget *row = selected && list ? list->itemWidget(selected) : nullptr; auto *title = row ? row->findChild(QStringLiteral("threadTitle")) : nullptr; @@ -484,16 +496,16 @@ bool testThreadSelectionProjection() { "thread cards keep their status dot and shared chevron styling inside " "the UI contract"); pane.refresh(model, "thread-a"); - bool retainedSupplement = false; + bool childPresent = false; if (list) { for (int index = 0; index < list->count(); ++index) { - retainedSupplement |= list->item(index)->data(Qt::UserRole).toString() == - QStringLiteral("thread-b"); + childPresent |= list->item(index)->data(Qt::UserRole).toString() == + QStringLiteral("thread-b"); } } result &= - expect(retainedSupplement && pane.visiblySelectedThreadId() == "thread-a", - "a previously selected retained thread survives navigation"); + expect(childPresent && pane.visiblySelectedThreadId() == "thread-a", + "a child thread remains nested while its parent is selected"); model.applyEvent(presentation::event( 4, 1, "thread.removed", nlohmann::json::object(), presentation::Authority::Remove, {{"threadId", "thread-b"}})); @@ -587,6 +599,165 @@ bool testIncrementalThreadSettings() { return result; } +bool testThreadHierarchyExpansionAndNavigation() { + PresentationModel model; + model.applyEvent(presentation::result( + 1, 1, "threads.list", "hierarchy-roots", true, + {{"threads", + nlohmann::json::array({{{"id", "root-z"}, {"name", "Z root"}}, + {{"id", "root-a"}, {"name", "A root"}}})}}, + presentation::Authority::Merge)); + const auto addChild = [&model](std::uint64_t sequence, + const std::string &parent, + const std::string &child, + const std::string &title) { + model.applyEvent(presentation::event( + sequence, 1, "thread.upsert", + {{"thread", {{"id", child}, {"name", title}}}}, + presentation::Authority::Merge, {{"threadId", child}})); + model.applyEvent(presentation::event( + sequence + 1, 1, "agents.activity.upsert", + {{"activity", + {{"id", "spawn-" + child}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", child}}}}, + presentation::Authority::Merge, + {{"threadId", parent}, + {"turnId", "turn-" + parent}, + {"itemId", "spawn-" + child}})); + }; + addChild(2, "root-a", "child-z", "Z child"); + addChild(4, "root-a", "child-a", "A child"); + addChild(6, "child-z", "grandchild", "Nested child"); + model.applyEvent(presentation::event( + 8, 1, "pending-request.upsert", + {{"requestId", "nested-request"}, + {"category", "userInput"}, + {"request", {{"message", "Review nested work"}}}}, + presentation::Authority::Merge, + {{"threadId", "grandchild"}, {"requestId", "nested-request"}})); + + ThreadPane pane; + pane.setSortCriterion(ThreadPane::SortCriterion::Alphanumeric); + std::string selectedThread = "grandchild"; + int selections = 0; + ThreadPane::Actions actions; + actions.select = [&](const std::string &id) { + selectedThread = id; + ++selections; + pane.refresh(model, selectedThread); + }; + pane.setActions(std::move(actions)); + pane.resize(340, 620); + pane.show(); + pane.refresh(model, selectedThread); + spin(20); + + auto *list = pane.findChild(QStringLiteral("threadList")); + QListWidgetItem *rootA = threadItem(list, "root-a"); + QListWidgetItem *childZ = threadItem(list, "child-z"); + QListWidgetItem *childA = threadItem(list, "child-a"); + QListWidgetItem *grandchild = threadItem(list, "grandchild"); + QWidget *grandchildRow = + list && grandchild ? list->itemWidget(grandchild) : nullptr; + QLabel *grandchildTitle = grandchildRow + ? grandchildRow->findChild( + QStringLiteral("threadTitle")) + : nullptr; + bool result = expect( + list && + threadOrder(pane) == + std::vector{"root-a", "child-z", "grandchild", + "child-a", "root-z"} && + rootA && rootA->data(Qt::UserRole + 2).toInt() == 0 && childZ && + childZ->data(Qt::UserRole + 2).toInt() == 1 && grandchild && + grandchild->data(Qt::UserRole + 2).toInt() == 2 && childA && + grandchildTitle && grandchildTitle->text().startsWith("! ") && + pane.visiblySelectedThreadId() == "grandchild", + "thread hierarchy keeps root sorting, child order, nesting, requests, " + "and selected navigation"); + if (!list || !rootA || !childZ || !grandchild) + return false; + + const auto clickExpansion = [list](QListWidgetItem *item) { + const QRect rectangle = list->visualItemRect(item); + const int depth = item->data(Qt::UserRole + 2).toInt(); + const QPoint position(rectangle.left() + 7 + depth * 16, + rectangle.center().y()); + QMouseEvent press(QEvent::MouseButtonPress, position, + list->viewport()->mapToGlobal(position), Qt::LeftButton, + Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(list->viewport(), &press); + spin(); + }; + + clickExpansion(childZ); + result &= expect( + threadOrder(pane) == + std::vector{"root-a", "child-z", "child-a", + "root-z"} && + pane.visiblySelectedThreadId().empty() && selections == 0, + "collapsing a nested parent hides descendants without selecting it"); + childZ = threadItem(list, "child-z"); + clickExpansion(childZ); + result &= expect( + threadOrder(pane) == + std::vector{"root-a", "child-z", "grandchild", + "child-a", "root-z"} && + pane.visiblySelectedThreadId() == "grandchild" && selections == 0, + "expanding restores arbitrary nesting and projected child selection"); + + rootA = threadItem(list, "root-a"); + clickExpansion(rootA); + result &= expect(threadOrder(pane) == + std::vector{"root-a", "root-z"} && + selections == 0, + "collapsing a root hides its complete descendant subtree"); + rootA = threadItem(list, "root-a"); + clickExpansion(rootA); + + childZ = threadItem(list, "child-z"); + list->setCurrentItem(childZ); + spin(); + const int beforeKeyboard = selections; + QKeyEvent rightToChild(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier); + QApplication::sendEvent(list, &rightToChild); + spin(); + result &= expect(selectedThread == "grandchild" && + pane.visiblySelectedThreadId() == "grandchild" && + selections == beforeKeyboard + 1, + "Right navigates from an expanded parent to its first child"); + QKeyEvent leftToParent(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier); + QApplication::sendEvent(list, &leftToParent); + spin(); + result &= expect(selectedThread == "child-z" && + pane.visiblySelectedThreadId() == "child-z" && + selections == beforeKeyboard + 2, + "Left navigates from a nested child to its parent"); + QKeyEvent leftCollapse(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier); + QApplication::sendEvent(list, &leftCollapse); + spin(); + result &= expect( + threadOrder(pane) == + std::vector{"root-a", "child-z", "child-a", + "root-z"} && + pane.visiblySelectedThreadId() == "child-z" && + selections == beforeKeyboard + 2, + "Left collapses an expanded parent without changing selection"); + QKeyEvent rightExpand(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier); + QApplication::sendEvent(list, &rightExpand); + spin(); + result &= expect( + threadOrder(pane) == + std::vector{"root-a", "child-z", "grandchild", + "child-a", "root-z"} && + pane.visiblySelectedThreadId() == "child-z" && + selections == beforeKeyboard + 2, + "Right expands a collapsed parent without changing selection"); + return result; +} + bool testThreadAlphanumericSort() { PresentationModel model; model.applyEvent(presentation::result( @@ -1281,6 +1452,7 @@ int main(int argc, char **argv) { using namespace codexui::codex::middle; bool result = testOverlayGeometryAndRegionRouting(); result &= testThreadSelectionProjection(); + result &= testThreadHierarchyExpansionAndNavigation(); result &= testIncrementalThreadSettings(); result &= testThreadAlphanumericSort(); result &= testThreadCreatedSort(); From f94e5736310a0112b5311e45e5a7a53fc127a50a Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 20:58:56 +0200 Subject: [PATCH 3/9] Preserve terminal child ownership state --- src/codex/PresentationModel.cpp | 39 ++++++--- src/codex/ShellWidget.cpp | 2 +- tests/codex/PresentationPipelineTest.cpp | 105 +++++++++++++++++++++-- 3 files changed, 127 insertions(+), 19 deletions(-) diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp index f64d876..87e05bb 100644 --- a/src/codex/PresentationModel.cpp +++ b/src/codex/PresentationModel.cpp @@ -816,7 +816,7 @@ void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, const std::string status = stringValue(state, "status"); const std::string message = stringValue(state, "message"); if (!status.empty()) - existing->status = status; + updateOwningAgentStatus(childThreadId, status); if (!message.empty()) existing->raw["resultText"] = message; existing->raw["agentState"] = state; @@ -842,14 +842,26 @@ void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, agent.ownerTurnId = stringValue(scope, "turnId"); mergePreservingCompleteness(agent.raw, activity); + const bool changesChild = !childThreadId.empty() && + !agent.childThreadId.empty() && + agent.childThreadId != childThreadId; + if (changesChild) { + agent.status.clear(); + agent.raw.erase("resultText"); + agent.raw.erase("agentState"); + } + const std::string activityStatus = stringValue(activity, "status"); const std::string activityKind = stringValue(activity, "kind"); - if (!activityStatus.empty()) - agent.status = activityStatus; - else if (live && activityKind == "started") - agent.status = "inProgress"; - else if (!activityKind.empty()) - agent.status = activityKind; + std::string candidateStatus = activityStatus; + if (candidateStatus.empty() && live && activityKind == "started") + candidateStatus = "inProgress"; + else if (candidateStatus.empty() && !activityKind.empty()) + candidateStatus = activityKind; + if (!candidateStatus.empty() && + !(isTerminalTurnStatus(agent.status) && + isActiveStatus(candidateStatus))) + agent.status = candidateStatus; if (!childThreadId.empty()) assignChildOwnership(owner, agent, childThreadId); @@ -900,25 +912,26 @@ void PresentationModel::assignChildOwnership(ThreadPresentation &parent, void PresentationModel::releaseChildOwnership(const std::string &childThreadId, bool promoteToRoot) { - const auto ownership = childOwnerships.find(childThreadId); + const std::string releasedChildId = childThreadId; + const auto ownership = childOwnerships.find(releasedChildId); if (ownership == childOwnerships.end()) return; const ChildThreadOwnership previous = ownership->second; const auto parent = threads.find(previous.parentThreadId); if (parent != threads.end()) { - std::erase(parent->second.childThreadOrder, childThreadId); + std::erase(parent->second.childThreadOrder, releasedChildId); const auto agent = parent->second.agents.find(previous.agentId); if (agent != parent->second.agents.end() && - agent->second.childThreadId == childThreadId) { + agent->second.childThreadId == releasedChildId) { agent->second.childThreadId.clear(); agent->second.raw.erase("childThreadId"); } } childOwnerships.erase(ownership); - if (promoteToRoot && threads.contains(childThreadId) && - std::find(orderedThreads.begin(), orderedThreads.end(), childThreadId) == + if (promoteToRoot && threads.contains(releasedChildId) && + std::find(orderedThreads.begin(), orderedThreads.end(), releasedChildId) == orderedThreads.end()) - orderedThreads.push_back(childThreadId); + orderedThreads.push_back(releasedChildId); } AgentPresentation * diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index dd4cc27..27a463a 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -576,7 +576,7 @@ void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { ensureThreadSettingsHydrated(selectedThreadId); } - if (kind == "result" && action == "thread.read" && + if (!staleReadResult && kind == "result" && action == "thread.read" && event.value("ok", false)) hydrateHistoricalChildren(eventThreadId); else if (kind == "event" && type == "agents.activity.upsert") diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp index e5fbbdc..ba146da 100644 --- a/tests/codex/PresentationPipelineTest.cpp +++ b/tests/codex/PresentationPipelineTest.cpp @@ -453,14 +453,23 @@ int main() { {{"thread", {{"id", "child-one"}, {"status", {{"type", "idle"}}}, - {"turns", nlohmann::json::array()}}}}, + {"turns", + nlohmann::json::array( + {{{"id", "child-turn"}, + {"items", + nlohmann::json::array( + {{{"id", "spawn-grandchild"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "grandchild"}}})}}})}}}}, codexui::codex::presentation::Authority::Replace, {{"threadId", "child-one"}})); parent = ownershipModel.thread("parent"); passed &= expect( parent && parent->agents.at("spawn-one").status == "idle" && !parent->agents.at("spawn-one").raw.contains("resultText") && - ownershipModel.childOwnership("child-one") != nullptr, + ownershipModel.childOwnership("child-one") != nullptr && + ownershipModel.childOwnership("grandchild") != nullptr, "authoritative child hydration clears stale results without losing ownership"); ownershipModel.applyEvent(codexui::codex::presentation::result( @@ -472,7 +481,7 @@ int main() { codexui::codex::presentation::Authority::Merge)); passed &= expect( ownershipModel.threadOrder() == - std::vector{"second-root", "parent", "grandchild"}, + std::vector{"second-root", "parent"}, "thread relisting cannot reintroduce an owned child as a root"); ownershipModel.applyEvent(codexui::codex::presentation::result( @@ -502,8 +511,7 @@ int main() { ownershipModel.childOwnership("child-one") && ownershipModel.childOwnership("child-two") && ownershipModel.threadOrder() == - std::vector{"second-root", "parent", - "grandchild"}, + std::vector{"second-root", "parent"}, "authoritative parent hydration rebuilds ordered ownership in one pass"); ownershipModel.applyEvent(codexui::codex::presentation::event( @@ -603,6 +611,93 @@ int main() { std::vector{"hydrated-parent"}, "connection-generation reconnect preserves ownership and root filtering"); + PresentationModel reboundOwnershipModel; + reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( + 1, 1, "thread.upsert", {{"thread", {{"id", "rebind-parent"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "rebind-parent"}})); + reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( + 2, 1, "agents.activity.upsert", + {{"activity", + {{"type", "subAgentActivity"}, + {"status", "completed"}, + {"resultText", "old result"}, + {"agentThreadId", "old-child"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "rebind-parent"}, + {"turnId", "rebind-turn"}, + {"itemId", "stable-agent"}})); + reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( + 3, 1, "agents.activity.upsert", + {{"activity", + {{"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "new-child"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "rebind-parent"}, + {"turnId", "rebind-turn"}, + {"itemId", "stable-agent"}})); + const auto *rebindParent = + reboundOwnershipModel.thread("rebind-parent"); + passed &= expect( + rebindParent && + rebindParent->childThreadOrder == + std::vector{"new-child"} && + reboundOwnershipModel.childOwnership("old-child") == nullptr && + reboundOwnershipModel.childOwnership("new-child") && + reboundOwnershipModel.threadOrder() == + std::vector{"rebind-parent", "old-child"}, + "rebinding one stable agent detaches and promotes the old child"); + passed &= expect( + rebindParent && + rebindParent->agents.at("stable-agent").childThreadId == + "new-child" && + rebindParent->agents.at("stable-agent").status == "started" && + !rebindParent->agents.at("stable-agent").raw.contains("resultText"), + "rebinding one stable agent resets its stale completion and result"); + reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( + 4, 1, "turn.upsert", + {{"turn", {{"id", "new-child-turn"}, {"status", "completed"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "new-child"}, {"turnId", "new-child-turn"}})); + reboundOwnershipModel.applyEvent(codexui::codex::presentation::result( + 5, 1, "thread.read", "stale-parent-merge", true, + {{"thread", + {{"id", "rebind-parent"}, + {"turns", + nlohmann::json::array( + {{{"id", "rebind-turn"}, + {"items", + nlohmann::json::array( + {{{"id", "stable-agent"}, + {"type", "subAgentActivity"}, + {"status", "inProgress"}, + {"kind", "started"}, + {"agentThreadId", "new-child"}}})}}})}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "rebind-parent"}})); + rebindParent = reboundOwnershipModel.thread("rebind-parent"); + passed &= expect( + rebindParent && + rebindParent->agents.at("stable-agent").status == "completed", + "stale merged parent hydration cannot reactivate a completed child agent"); + reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( + 6, 1, "agents.activity.upsert", + {{"activity", + {{"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "rebind-parent"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "new-child"}, + {"turnId", "cycle-turn"}, + {"itemId", "cycle-agent"}})); + passed &= expect( + reboundOwnershipModel.childOwnership("rebind-parent") == nullptr && + reboundOwnershipModel.thread("new-child") && + reboundOwnershipModel.thread("new-child") + ->childThreadOrder.empty(), + "ancestor ownership cycles are rejected without disturbing the tree"); + normalizer.bridgeEvent({{"kind", "bridge.provider"}, {"state", "disconnected"}, {"providerGeneration", std::uint64_t{1}}, From 29566132f05c8c0093c6060f9d358328f86cc349 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 21:19:00 +0200 Subject: [PATCH 4/9] Polish presentation style consistency --- src/codex/middle/InspectorPane.cpp | 61 ++++++---- src/codex/middle/ThreadPane.cpp | 113 +++++++++++++----- src/codex/middle/ThreadPane.h | 2 +- src/codex/ui/UiStyle.cpp | 1 + tests/codex/ApplicationLayoutTest.cpp | 159 +++++++++++++++++++++----- 5 files changed, 255 insertions(+), 81 deletions(-) diff --git a/src/codex/middle/InspectorPane.cpp b/src/codex/middle/InspectorPane.cpp index 647aff6..c85b6cc 100644 --- a/src/codex/middle/InspectorPane.cpp +++ b/src/codex/middle/InspectorPane.cpp @@ -184,33 +184,48 @@ QFrame *InspectorPane::agentFrame(const AgentSnapshot &agent) { auto *layout = new QVBoxLayout(frame); layout->setContentsMargins(12, 10, 12, 10); layout->setSpacing(6); - const QString title = - !agent.childThreadId.empty() - ? QStringLiteral("Subagent") - : agent.tool.empty() ? QStringLiteral("Agent activity") - : QStringLiteral("Agent %1").arg(text(agent.tool)); - layout->addWidget(makeLabel(title, "title")); - QStringList metadata; - for (const std::string *value : - {&agent.agentPath, &agent.tool, &agent.model, &agent.reasoningEffort}) { - if (!value->empty()) - metadata << text(*value); + const QString agentPath = text(agent.agentPath); + const QStringList pathParts = agentPath.split('/', Qt::SkipEmptyParts); + QString agentName; + if (!pathParts.isEmpty()) + agentName = pathParts.back(); + else if (!agent.tool.empty()) + agentName = text(agent.tool); + auto *heading = new QHBoxLayout; + heading->setContentsMargins(0, 0, 0, 0); + heading->setSpacing(6); + auto *titleLabel = makeLabel(QStringLiteral("Agent"), "title"); + titleLabel->setObjectName(QStringLiteral("agentTitle")); + titleLabel->setWordWrap(false); + titleLabel->setContentsMargins(0, 0, 0, 1); + titleLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); + heading->addWidget(titleLabel, 0, Qt::AlignBaseline); + if (!agentName.isEmpty()) { + auto *nameLabel = makeLabel(agentName, "code"); + nameLabel->setObjectName(QStringLiteral("agentName")); + nameLabel->setWordWrap(false); + // The fixed-width font's descent sits one pixel below the proportional + // labels. Preserve their visual baseline without changing its font. + nameLabel->setContentsMargins(0, 0, 0, 1); + nameLabel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); + if (!agentPath.isEmpty()) + nameLabel->setToolTip(agentPath); + heading->addWidget(nameLabel, 0, Qt::AlignBottom); } - auto *metadataRow = new QHBoxLayout; - metadataRow->setContentsMargins(0, 0, 0, 0); - metadataRow->setSpacing(6); + heading->addStretch(); auto *status = statusLabel(agent.status); status->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - metadataRow->addWidget(status); - if (!metadata.isEmpty()) { - auto *details = makeLabel( - QStringLiteral("| ") + metadata.join(QStringLiteral(" | ")), - "meta"); - details->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - metadataRow->addWidget(details); + heading->addWidget(status, 0, Qt::AlignBaseline); + layout->addLayout(heading); + QStringList metadata; + if (!agent.tool.empty() && !agentPath.isEmpty()) + metadata << text(agent.tool); + for (const std::string *value : {&agent.model, &agent.reasoningEffort}) { + if (!value->empty()) + metadata << text(*value); } - metadataRow->addStretch(); - layout->addLayout(metadataRow); + if (!metadata.isEmpty()) + layout->addWidget(makeLabel(metadata.join(QStringLiteral(" · ")), "meta")); if (!agent.prompt.empty()) layout->addWidget(makeLabel(text(agent.prompt))); if (!agent.resultText.empty()) { diff --git a/src/codex/middle/ThreadPane.cpp b/src/codex/middle/ThreadPane.cpp index ba24c12..5bc6296 100644 --- a/src/codex/middle/ThreadPane.cpp +++ b/src/codex/middle/ThreadPane.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -34,6 +35,8 @@ constexpr int HasChildrenRole = Qt::UserRole + 3; constexpr int ExpandedRole = Qt::UserRole + 4; constexpr int ParentIdRole = Qt::UserRole + 5; constexpr int ChildIndent = 16; +constexpr int DisclosureWidth = 16; +constexpr int DisclosureExtent = 24; class ThreadListWidget final : public QListWidget { public: @@ -57,11 +60,18 @@ class ThreadListWidget final : public QListWidget { QListWidgetItem *item = itemAt(event->position().toPoint()); if (event->button() == Qt::LeftButton && item && item->data(HasChildrenRole).toBool()) { - const QRect itemRect = visualItemRect(item); - const int indicatorRight = - itemRect.left() + 5 + item->data(DepthRole).toInt() * ChildIndent + - 16; - if (event->position().x() <= indicatorRight) { + QWidget *row = itemWidget(item); + QWidget *indicator = + row ? row->findChild( + QStringLiteral("threadExpansionIndicator")) + : nullptr; + const QRect indicatorRect = + indicator + ? QRect(indicator->mapTo(viewport(), QPoint()), indicator->size()) + .adjusted(-(DisclosureExtent - DisclosureWidth) / 2, 0, + (DisclosureExtent - DisclosureWidth) / 2, 0) + : QRect{}; + if (indicatorRect.contains(event->position().toPoint())) { if (toggleExpansion) toggleExpansion( item->data(Qt::UserRole).toString().toStdString()); @@ -96,6 +106,48 @@ class ThreadItemDelegate final : public QStyledItemDelegate { } }; +class ThreadDisclosureIndicator final : public QWidget { +public: + explicit ThreadDisclosureIndicator(QWidget *parent = nullptr) + : QWidget(parent) { + setObjectName(QStringLiteral("threadExpansionIndicator")); + setFixedSize(DisclosureWidth, DisclosureExtent); + setAttribute(Qt::WA_TransparentForMouseEvents); + } + + void setState(bool hasChildren, bool expanded) { + if (hasChildren_ == hasChildren && expanded_ == expanded) + return; + hasChildren_ = hasChildren; + expanded_ = expanded; + const QString action = !hasChildren + ? QString{} + : expanded ? QStringLiteral("Collapse branch") + : QStringLiteral("Expand branch"); + setAccessibleName(action); + setToolTip(action); + setProperty("chevronDirection", + !hasChildren ? QString{} + : expanded ? QStringLiteral("down") + : QStringLiteral("right")); + update(); + } + +protected: + void paintEvent(QPaintEvent *event) override { + static_cast(event); + if (!hasChildren_) + return; + UiStyle::drawChevron(this, rect().adjusted(3, 3, -3, -3), isEnabled(), + false, expanded_ ? UiStyle::ChevronDirection::Down + : UiStyle::ChevronDirection::Right); + } + +private: + bool hasChildren_ = false; + bool expanded_ = false; +}; + QString text(std::string_view value) { return QString::fromUtf8(value.data(), static_cast(value.size())); } @@ -126,15 +178,11 @@ void updateRow(QWidget *row, const std::string &threadId, auto *status = row->findChild(QStringLiteral("threadStatus")); auto *dot = row->findChild(QStringLiteral("threadStatusDot")); auto *indent = row->findChild(QStringLiteral("threadIndent")); - auto *indicator = - row->findChild(QStringLiteral("threadExpansionIndicator")); + auto *indicator = static_cast( + row->findChild( + QStringLiteral("threadExpansionIndicator"))); indent->setFixedWidth(static_cast(depth) * ChildIndent); - indicator->setText(hasChildren ? (expanded ? QStringLiteral("⌄") - : QStringLiteral("›")) - : QString{}); - indicator->setToolTip(hasChildren ? (expanded ? QStringLiteral("Collapse") - : QStringLiteral("Expand")) - : QString{}); + indicator->setState(hasChildren, expanded); QString titleText = text(threadTitle); if (titleText.isEmpty()) titleText = text(threadId.substr(0, 12)); @@ -167,20 +215,18 @@ QWidget *createRow() { row->setAttribute(Qt::WA_TransparentForMouseEvents); row->setStyleSheet(QStringLiteral("background:transparent;")); auto *layout = new QHBoxLayout(row); - layout->setContentsMargins(5, 2, 5, 2); - layout->setSpacing(8); + layout->setContentsMargins(0, 2, 0, 2); + layout->setSpacing(0); auto *indent = new QWidget; indent->setObjectName(QStringLiteral("threadIndent")); indent->setFixedWidth(0); indent->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); layout->addWidget(indent); - auto *indicator = makeLabel({}, "meta"); - indicator->setObjectName(QStringLiteral("threadExpansionIndicator")); - indicator->setAlignment(Qt::AlignCenter); - indicator->setFixedWidth(8); - indicator->setTextInteractionFlags(Qt::NoTextInteraction); - layout->addWidget(indicator); - layout->addWidget(statusDot()); + auto *indicator = new ThreadDisclosureIndicator; + layout->addWidget(indicator, 0, Qt::AlignVCenter); + layout->addSpacing(2); + layout->addWidget(statusDot(), 0, Qt::AlignVCenter); + layout->addSpacing(8); auto *copy = new QVBoxLayout; copy->setContentsMargins(0, 0, 0, 0); copy->setSpacing(1); @@ -427,7 +473,7 @@ void ThreadPane::appendVisibleThread( const bool hasChildren = std::ranges::any_of( thread->childThreadOrder, [&model](const std::string &id) { return model.thread(id) != nullptr; }); - const bool expanded = hasChildren && !collapsedThreads.contains(threadId); + const bool expanded = hasChildren && expandedThreads.contains(threadId); const auto pending = pendingByThread.find(threadId); snapshot.rows.push_back( {threadId, thread->title, thread->cwd, thread->status, parentId, @@ -441,10 +487,10 @@ void ThreadPane::appendVisibleThread( } void ThreadPane::toggleExpanded(const std::string &threadId) { - if (collapsedThreads.contains(threadId)) - collapsedThreads.erase(threadId); + if (expandedThreads.contains(threadId)) + expandedThreads.erase(threadId); else - collapsedThreads.insert(threadId); + expandedThreads.insert(threadId); visibleSnapshot.reset(); if (currentModel) refresh(*currentModel, projectedSelectedThreadId); @@ -493,9 +539,22 @@ void ThreadPane::setContextHighlight(const std::string &threadId, void ThreadPane::refresh(const PresentationModel &model, const std::string &selectedThreadId) { + const bool selectionChanged = selectedThreadId != projectedSelectedThreadId; currentModel = &model; projectedSelectedThreadId = selectedThreadId; - std::erase_if(collapsedThreads, [&model](const std::string &id) { + if (selectionChanged) { + std::unordered_set visited; + std::string descendantId = selectedThreadId; + while (!descendantId.empty() && visited.insert(descendantId).second) { + const ChildThreadOwnership *ownership = + model.childOwnership(descendantId); + if (!ownership) + break; + expandedThreads.insert(ownership->parentThreadId); + descendantId = ownership->parentThreadId; + } + } + std::erase_if(expandedThreads, [&model](const std::string &id) { const ThreadPresentation *thread = model.thread(id); return !thread || thread->childThreadOrder.empty(); }); diff --git a/src/codex/middle/ThreadPane.h b/src/codex/middle/ThreadPane.h index b92e343..197d9b0 100644 --- a/src/codex/middle/ThreadPane.h +++ b/src/codex/middle/ThreadPane.h @@ -89,7 +89,7 @@ class ThreadPane final : public QFrame { QToolButton *sortButton = nullptr; QListWidget *list = nullptr; std::unordered_map rows; - std::unordered_set collapsedThreads; + std::unordered_set expandedThreads; std::string projectedSelectedThreadId; std::string contextThreadId; QMenu *contextMenu = nullptr; diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index abe22ce..d9ed12c 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -103,6 +103,7 @@ QString applicationStyleSheet() { QLabel[kind="title"] { font-size: %2pt; font-weight: 600; } QLabel[kind="messagePhase"] { font-size: %2pt; font-weight: 400; } QLabel[kind="body"] { font-size: %2pt; } + QLabel[kind="code"] { font-family: monospace; font-size: %2pt; font-weight: 400; } QLabel[kind="meta"] { color: #667085; font-size: %1pt; } QLabel[kind="small"] { color: #667085; font-size: %1pt; } QLabel[tone="active"] { color: #285fca; } diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index 5d9f043..51b147e 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -468,7 +468,8 @@ bool testThreadSelectionProjection() { selected->data(Qt::UserRole).toString() == QStringLiteral("thread-b") && pane.visiblySelectedThreadId() == "thread-b", - "a hydrated selected child thread is visible beneath its parent"); + "navigating to a nested thread reveals and selects it beneath its " + "parent"); QWidget *row = selected && list ? list->itemWidget(selected) : nullptr; auto *title = row ? row->findChild(QStringLiteral("threadTitle")) : nullptr; @@ -480,11 +481,28 @@ bool testThreadSelectionProjection() { auto *rowLayout = row ? qobject_cast(row->layout()) : nullptr; auto *sortButton = pane.findChild(QStringLiteral("threadSortButton")); + QListWidgetItem *parentItem = threadItem(list, "thread-a"); + QWidget *parentRow = + list && parentItem ? list->itemWidget(parentItem) : nullptr; + QWidget *disclosure = + parentRow ? parentRow->findChild( + QStringLiteral("threadExpansionIndicator")) + : nullptr; + auto *parentDot = + parentRow ? parentRow->findChild( + QStringLiteral("threadStatusDot")) + : nullptr; result &= expect( selected && selected->sizeHint().height() == 54 && rowLayout && - rowLayout->contentsMargins() == QMargins(5, 2, 5, 2) && - rowLayout->spacing() == 8 && title && status && dot && + rowLayout->contentsMargins() == QMargins(0, 2, 0, 2) && + rowLayout->spacing() == 0 && title && status && dot && dot->size() == QSize(10, 10) && rowLayout->indexOf(dot) >= 0 && + disclosure && disclosure->size() == QSize(16, 24) && + disclosure->geometry().left() == 0 && parentDot && + parentDot->geometry().left() - disclosure->geometry().right() - 1 == + 2 && + disclosure->property("chevronDirection").toString() == + QStringLiteral("down") && sortButton && dynamic_cast(sortButton) && sortButton->property("codexChevron").toBool() && @@ -493,8 +511,8 @@ bool testThreadSelectionProjection() { status->property("tone").toString() == QStringLiteral("active") && title->textInteractionFlags().testFlag(Qt::TextSelectableByMouse) && status->textInteractionFlags().testFlag(Qt::TextSelectableByMouse), - "thread cards keep their status dot and shared chevron styling inside " - "the UI contract"); + "thread cards keep their status dot and canonical disclosure styling " + "inside the UI contract"); pane.refresh(model, "thread-a"); bool childPresent = false; if (list) { @@ -640,7 +658,7 @@ bool testThreadHierarchyExpansionAndNavigation() { ThreadPane pane; pane.setSortCriterion(ThreadPane::SortCriterion::Alphanumeric); - std::string selectedThread = "grandchild"; + std::string selectedThread; int selections = 0; ThreadPane::Actions actions; actions.select = [&](const std::string &id) { @@ -656,8 +674,67 @@ bool testThreadHierarchyExpansionAndNavigation() { auto *list = pane.findChild(QStringLiteral("threadList")); QListWidgetItem *rootA = threadItem(list, "root-a"); + QWidget *rootRow = list && rootA ? list->itemWidget(rootA) : nullptr; + QWidget *rootDisclosure = + rootRow ? rootRow->findChild( + QStringLiteral("threadExpansionIndicator")) + : nullptr; + bool result = expect( + list && threadOrder(pane) == + std::vector{"root-a", "root-z"} && + rootA && rootA->data(Qt::UserRole + 2).toInt() == 0 && + rootDisclosure && rootDisclosure->size() == QSize(16, 24) && + rootDisclosure->property("chevronDirection").toString() == + QStringLiteral("right") && + pane.visiblySelectedThreadId().empty(), + "thread branches default to a canonical collapsed disclosure without " + "selecting a hidden descendant"); + if (!list || !rootA) + return false; + + const auto clickExpansion = [list](QListWidgetItem *item) { + QWidget *row = item ? list->itemWidget(item) : nullptr; + QWidget *indicator = + row ? row->findChild( + QStringLiteral("threadExpansionIndicator")) + : nullptr; + const QPoint position = + indicator + ? indicator->mapTo(list->viewport(), indicator->rect().center()) + : QPoint{}; + QMouseEvent press(QEvent::MouseButtonPress, position, + list->viewport()->mapToGlobal(position), Qt::LeftButton, + Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(list->viewport(), &press); + spin(); + }; + + clickExpansion(rootA); QListWidgetItem *childZ = threadItem(list, "child-z"); QListWidgetItem *childA = threadItem(list, "child-a"); + rootA = threadItem(list, "root-a"); + rootRow = rootA ? list->itemWidget(rootA) : nullptr; + rootDisclosure = + rootRow ? rootRow->findChild( + QStringLiteral("threadExpansionIndicator")) + : nullptr; + result &= expect( + threadOrder(pane) == + std::vector{"root-a", "child-z", "child-a", + "root-z"} && + childZ && childZ->data(Qt::UserRole + 2).toInt() == 1 && childA && + rootDisclosure && + rootDisclosure->property("chevronDirection").toString() == + QStringLiteral("down") && + pane.visiblySelectedThreadId().empty() && selections == 0, + "expanding a root reveals its ordered children and updates the " + "canonical disclosure"); + if (!childZ) + return false; + + selectedThread = "grandchild"; + pane.refresh(model, selectedThread); + spin(); QListWidgetItem *grandchild = threadItem(list, "grandchild"); QWidget *grandchildRow = list && grandchild ? list->itemWidget(grandchild) : nullptr; @@ -665,33 +742,19 @@ bool testThreadHierarchyExpansionAndNavigation() { ? grandchildRow->findChild( QStringLiteral("threadTitle")) : nullptr; - bool result = expect( - list && - threadOrder(pane) == + result &= expect( + threadOrder(pane) == std::vector{"root-a", "child-z", "grandchild", "child-a", "root-z"} && - rootA && rootA->data(Qt::UserRole + 2).toInt() == 0 && childZ && - childZ->data(Qt::UserRole + 2).toInt() == 1 && grandchild && - grandchild->data(Qt::UserRole + 2).toInt() == 2 && childA && + grandchild && grandchild->data(Qt::UserRole + 2).toInt() == 2 && grandchildTitle && grandchildTitle->text().startsWith("! ") && - pane.visiblySelectedThreadId() == "grandchild", - "thread hierarchy keeps root sorting, child order, nesting, requests, " - "and selected navigation"); - if (!list || !rootA || !childZ || !grandchild) + pane.visiblySelectedThreadId() == "grandchild" && selections == 0, + "nested navigation expands only its ancestor path and restores nesting, " + "requests, and selection"); + if (!grandchild) return false; - const auto clickExpansion = [list](QListWidgetItem *item) { - const QRect rectangle = list->visualItemRect(item); - const int depth = item->data(Qt::UserRole + 2).toInt(); - const QPoint position(rectangle.left() + 7 + depth * 16, - rectangle.center().y()); - QMouseEvent press(QEvent::MouseButtonPress, position, - list->viewport()->mapToGlobal(position), Qt::LeftButton, - Qt::LeftButton, Qt::NoModifier); - QApplication::sendEvent(list->viewport(), &press); - spin(); - }; - + childZ = threadItem(list, "child-z"); clickExpansion(childZ); result &= expect( threadOrder(pane) == @@ -1092,6 +1155,8 @@ bool testInfoViewerLayout() { } bool testInspectorDetailParity() { + const QString previousStyleSheet = qApp->styleSheet(); + qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); PresentationModel model; model.applyEvent(presentation::event( 1, 1, "thread.upsert", @@ -1103,6 +1168,7 @@ bool testInspectorDetailParity() { {{"id", "agent-one"}, {"type", "subAgentActivity"}, {"status", "inProgress"}, + {"agentPath", "/root/lifecycle_review"}, {"agentThreadId", "child-thread"}, {"resultText", "No blocking Inspector findings.\n\n" @@ -1132,6 +1198,7 @@ bool testInspectorDetailParity() { {{"id", "agent-two"}, {"type", "subAgentActivity"}, {"status", "completed"}, + {"agentPath", "/root/hierarchy_ui_review"}, {"agentThreadId", "child-thread-two"}, {"resultText", "No blocking Git snapshot issues found.\n\n" @@ -1179,10 +1246,28 @@ bool testInspectorDetailParity() { inspector.findChild(QStringLiteral("agentResult")); auto *agentFrame = agentResult ? qobject_cast(agentResult->parentWidget()) : nullptr; + auto *agentTitle = agentFrame + ? agentFrame->findChild( + QStringLiteral("agentTitle")) + : nullptr; + auto *agentName = agentFrame + ? agentFrame->findChild( + QStringLiteral("agentName")) + : nullptr; const int statusBottom = agentStatus && agentFrame ? agentStatus->mapTo(agentFrame, QPoint()).y() + agentStatus->height() : 0; + const int headingBottom = + std::max({agentTitle && agentFrame + ? agentTitle->mapTo(agentFrame, QPoint()).y() + + agentTitle->height() + : 0, + agentName && agentFrame + ? agentName->mapTo(agentFrame, QPoint()).y() + + agentName->height() + : 0, + statusBottom}); const int resultTop = agentResult && agentFrame ? agentResult->mapTo(agentFrame, QPoint()).y() : 0; @@ -1190,11 +1275,24 @@ bool testInspectorDetailParity() { agentResult ? agentResult->heightForWidth(agentResult->width()) : -1; result &= expect(agentStatus && agentStatus->width() > 0 && !agentStatus->visibleRegion().isEmpty(), - "agent status occupies its metadata row instead of " + "agent status occupies the card heading instead of " "leaving an invisible gap"); + result &= expect( + agentTitle && agentTitle->text() == QStringLiteral("Agent") && + agentTitle->property("kind").toString() == QStringLiteral("title") && + agentTitle->contentsMargins().bottom() == 1 && agentName && + agentName->text() == QStringLiteral("lifecycle_review") && + agentName->property("kind").toString() == QStringLiteral("code") && + agentName->toolTip() == QStringLiteral("/root/lifecycle_review") && + agentStatus && + agentStatus->geometry().left() > agentName->geometry().left() && + !hasLabelContaining(inspector, + QStringLiteral("/root/lifecycle_review")), + "agent cards show a regular monospace identity and right-aligned status " + "while retaining the full path as a tooltip"); result &= expect( agentFrame && agentFrame->layout() && agentResult && - resultTop - statusBottom <= agentFrame->layout()->spacing() && + resultTop - headingBottom <= agentFrame->layout()->spacing() && agentResult->alignment().testFlag(Qt::AlignTop) && resultHeightForWidth >= 0 && agentResult->height() >= resultHeightForWidth - 1 && @@ -1236,6 +1334,7 @@ bool testInspectorDetailParity() { denyButton->property("kind") == "destructive" && reviewButton->property("kind") == "request", "pending requests use warning surfaces and semantic actions"); + qApp->setStyleSheet(previousStyleSheet); return result; } From c63fb21add5d46f1216973bf90bb0fe5a3853c56 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 21:49:48 +0200 Subject: [PATCH 5/9] Preserve sibling ownership across interactions --- src/codex/PresentationModel.cpp | 18 ++++++++-- tests/codex/PresentationPipelineTest.cpp | 45 +++++++++++++++++++----- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp index 87e05bb..7748b8b 100644 --- a/src/codex/PresentationModel.cpp +++ b/src/codex/PresentationModel.cpp @@ -88,8 +88,10 @@ void retainRepositoryHints(ThreadPresentation &thread, bool isSpawnActivity(const nlohmann::json &activity) { const std::string type = stringValue(activity, "type"); - if (type == "subAgentActivity") - return true; + if (type == "subAgentActivity") { + const std::string kind = stringValue(activity, "kind"); + return kind.empty() || kind == "started"; + } if (type != "collabAgentToolCall") return false; const std::string tool = stringValue(activity, "tool"); @@ -804,6 +806,18 @@ void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, const nlohmann::json &activity, bool live) { const std::string type = stringValue(activity, "type"); + if (type == "subAgentActivity" && !isSpawnActivity(activity)) { + const std::string childThreadId = childThreadIdentity(activity); + AgentPresentation *existing = owningAgent(childThreadId); + if (!existing) + return; + const std::string agentPath = stringValue(activity, "agentPath"); + if (!agentPath.empty()) + existing->raw["agentPath"] = agentPath; + if (stringValue(activity, "kind") == "interrupted") + updateOwningAgentStatus(childThreadId, "interrupted"); + return; + } if (type == "collabAgentToolCall" && !isSpawnActivity(activity)) { const nlohmann::json states = memberValue(activity, "agentsStates", nlohmann::json::object()); diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp index ba146da..675316a 100644 --- a/tests/codex/PresentationPipelineTest.cpp +++ b/tests/codex/PresentationPipelineTest.cpp @@ -417,12 +417,41 @@ int main() { "ownership is unique, ordered, nested, and excluded from root order"); ownershipModel.applyEvent(codexui::codex::presentation::event( - 6, 1, "turn.upsert", + 6, 1, "agents.activity.upsert", + {{"activity", + {{"id", "peer-interaction"}, + {"type", "subAgentActivity"}, + {"kind", "interacted"}, + {"agentPath", "/root/child-two"}, + {"agentThreadId", "child-two"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "child-one"}, + {"turnId", "child-turn"}, + {"itemId", "peer-interaction"}})); + parent = ownershipModel.thread("parent"); + childOne = ownershipModel.thread("child-one"); + passed &= expect( + parent && childOne && + parent->childThreadOrder == + std::vector{"child-one", "child-two"} && + childOne->childThreadOrder == + std::vector{"grandchild"} && + ownershipModel.childOwnership("child-two") && + ownershipModel.childOwnership("child-two")->parentThreadId == + "parent" && + !childOne->agents.contains("peer-interaction") && + parent->agents.at("spawn-two").status == "started" && + stringMember(parent->agents.at("spawn-two").raw, "agentPath") == + "/root/child-two", + "peer interaction cannot reparent a sibling as a nested child"); + + ownershipModel.applyEvent(codexui::codex::presentation::event( + 7, 1, "turn.upsert", {{"turn", {{"id", "child-turn"}, {"status", "completed"}}}}, codexui::codex::presentation::Authority::Merge, {{"threadId", "child-one"}, {"turnId", "child-turn"}})); ownershipModel.applyEvent(codexui::codex::presentation::event( - 7, 1, "conversation.item.upsert", + 8, 1, "conversation.item.upsert", {{"item", {{"id", "child-answer"}, {"type", "agentMessage"}, @@ -449,7 +478,7 @@ int main() { "child completion and results route only to the indexed owning agent"); ownershipModel.applyEvent(codexui::codex::presentation::result( - 8, 1, "thread.read", "replace-child", true, + 9, 1, "thread.read", "replace-child", true, {{"thread", {{"id", "child-one"}, {"status", {{"type", "idle"}}}, @@ -473,7 +502,7 @@ int main() { "authoritative child hydration clears stale results without losing ownership"); ownershipModel.applyEvent(codexui::codex::presentation::result( - 9, 1, "threads.list", "relisted-owned-child", true, + 10, 1, "threads.list", "relisted-owned-child", true, {{"threads", nlohmann::json::array({{{"id", "child-one"}}, {{"id", "second-root"}}, @@ -485,7 +514,7 @@ int main() { "thread relisting cannot reintroduce an owned child as a root"); ownershipModel.applyEvent(codexui::codex::presentation::result( - 10, 1, "thread.read", "replace-parent", true, + 11, 1, "thread.read", "replace-parent", true, {{"thread", {{"id", "parent"}, {"turns", @@ -515,7 +544,7 @@ int main() { "authoritative parent hydration rebuilds ordered ownership in one pass"); ownershipModel.applyEvent(codexui::codex::presentation::event( - 11, 1, "thread.removed", nlohmann::json::object(), + 12, 1, "thread.removed", nlohmann::json::object(), codexui::codex::presentation::Authority::Remove, {{"threadId", "child-one"}})); parent = ownershipModel.thread("parent"); @@ -531,7 +560,7 @@ int main() { "authoritative child removal prunes ownership and promotes surviving descendants"); ownershipModel.applyEvent(codexui::codex::presentation::event( - 12, 1, "thread.removed", nlohmann::json::object(), + 13, 1, "thread.removed", nlohmann::json::object(), codexui::codex::presentation::Authority::Remove, {{"threadId", "parent"}})); passed &= expect( @@ -543,7 +572,7 @@ int main() { "authoritative parent removal promotes children in retained root order"); ownershipModel.applyEvent(codexui::codex::presentation::event( - 13, 1, "connection.provider", + 14, 1, "connection.provider", {{"generation", std::uint64_t{1}}, {"state", "disconnected"}}, codexui::codex::presentation::Authority::Replace)); passed &= expect(ownershipModel.threadOrder().empty() && From c396b219661f2343b23a91e6fc73b226e1bd6285 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 22:19:10 +0200 Subject: [PATCH 6/9] Fix stale child agent lifecycle presentation --- src/codex/PresentationModel.cpp | 90 ++++++++++++++++++++---- src/codex/PresentationModel.h | 7 ++ src/codex/ShellWidget.cpp | 35 ++++++--- tests/codex/PresentationPipelineTest.cpp | 90 +++++++++++++++++++----- tests/codex/ShellIntegrationTest.cpp | 32 +++++++++ 5 files changed, 211 insertions(+), 43 deletions(-) diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp index 7748b8b..9195dec 100644 --- a/src/codex/PresentationModel.cpp +++ b/src/codex/PresentationModel.cpp @@ -832,7 +832,7 @@ void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, if (!status.empty()) updateOwningAgentStatus(childThreadId, status); if (!message.empty()) - existing->raw["resultText"] = message; + updateOwningAgentResult(childThreadId, message); existing->raw["agentState"] = state; } return; @@ -861,7 +861,10 @@ void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, agent.childThreadId != childThreadId; if (changesChild) { agent.status.clear(); - agent.raw.erase("resultText"); + agent.raw.erase("status"); + if (ItemPresentation *item = agentSourceItem(owner, agent)) + item->raw.erase("status"); + clearAgentResult(owner, agent); agent.raw.erase("agentState"); } @@ -872,10 +875,13 @@ void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, candidateStatus = "inProgress"; else if (candidateStatus.empty() && !activityKind.empty()) candidateStatus = activityKind; - if (!candidateStatus.empty() && - !(isTerminalTurnStatus(agent.status) && - isActiveStatus(candidateStatus))) - agent.status = candidateStatus; + if (!candidateStatus.empty()) { + if (isTerminalTurnStatus(agent.status) && + isActiveStatus(candidateStatus)) + setAgentStatus(owner, agent, agent.status); + else + setAgentStatus(owner, agent, candidateStatus); + } if (!childThreadId.empty()) assignChildOwnership(owner, agent, childThreadId); @@ -960,23 +966,73 @@ PresentationModel::owningAgent(const std::string &childThreadId) { return agent == parent->second.agents.end() ? nullptr : &agent->second; } +ItemPresentation * +PresentationModel::agentSourceItem(ThreadPresentation &parent, + const AgentPresentation &agent) { + const auto turn = parent.turns.find(agent.ownerTurnId); + if (turn == parent.turns.end()) + return nullptr; + const auto item = turn->second.items.find(agent.itemId); + return item == turn->second.items.end() ? nullptr : &item->second; +} + +void PresentationModel::setAgentStatus(ThreadPresentation &parent, + AgentPresentation &agent, + const std::string &status) { + agent.status = status; + agent.raw["status"] = status; + if (ItemPresentation *item = agentSourceItem(parent, agent)) + item->raw["status"] = status; +} + +void PresentationModel::setAgentResult(ThreadPresentation &parent, + AgentPresentation &agent, + const std::string &resultText) { + agent.raw["resultText"] = resultText; + if (ItemPresentation *item = agentSourceItem(parent, agent)) + item->raw["resultText"] = resultText; +} + +void PresentationModel::clearAgentResult(ThreadPresentation &parent, + AgentPresentation &agent) { + agent.raw.erase("resultText"); + if (ItemPresentation *item = agentSourceItem(parent, agent)) + item->raw.erase("resultText"); +} + void PresentationModel::updateOwningAgentStatus( const std::string &childThreadId, const std::string &status) { if (status.empty()) return; - if (AgentPresentation *agent = owningAgent(childThreadId)) { - if (isTerminalTurnStatus(agent->status) && isActiveStatus(status)) - return; - agent->status = status; + const auto ownership = childOwnerships.find(childThreadId); + if (ownership == childOwnerships.end()) + return; + const auto parent = threads.find(ownership->second.parentThreadId); + if (parent == threads.end()) + return; + const auto agent = parent->second.agents.find(ownership->second.agentId); + if (agent == parent->second.agents.end()) + return; + if (isTerminalTurnStatus(agent->second.status) && isActiveStatus(status)) { + setAgentStatus(parent->second, agent->second, agent->second.status); + return; } + setAgentStatus(parent->second, agent->second, status); } void PresentationModel::updateOwningAgentResult( const std::string &childThreadId, const std::string &resultText) { if (resultText.empty()) return; - if (AgentPresentation *agent = owningAgent(childThreadId)) - agent->raw["resultText"] = resultText; + const auto ownership = childOwnerships.find(childThreadId); + if (ownership == childOwnerships.end()) + return; + const auto parent = threads.find(ownership->second.parentThreadId); + if (parent == threads.end()) + return; + const auto agent = parent->second.agents.find(ownership->second.agentId); + if (agent != parent->second.agents.end()) + setAgentResult(parent->second, agent->second, resultText); } void PresentationModel::synchronizeOwningAgent( @@ -1011,10 +1067,16 @@ void PresentationModel::synchronizeOwningAgent( if (childStatus.empty()) childStatus = child->second.status; updateOwningAgentStatus(childThreadId, childStatus); + const auto ownership = childOwnerships.find(childThreadId); + const auto parent = ownership == childOwnerships.end() + ? threads.end() + : threads.find(ownership->second.parentThreadId); + if (parent == threads.end()) + return; if (resultText.empty() && clearMissingResult) - agent->raw.erase("resultText"); + clearAgentResult(parent->second, *agent); else if (!resultText.empty()) - agent->raw["resultText"] = resultText; + setAgentResult(parent->second, *agent, resultText); } void PresentationModel::removeThread(const std::string &threadId) { diff --git a/src/codex/PresentationModel.h b/src/codex/PresentationModel.h index 03815bf..135d527 100644 --- a/src/codex/PresentationModel.h +++ b/src/codex/PresentationModel.h @@ -142,6 +142,13 @@ class PresentationModel final { void synchronizeOwningAgent(const std::string &childThreadId, bool clearMissingResult = false); AgentPresentation *owningAgent(const std::string &childThreadId); + ItemPresentation *agentSourceItem(ThreadPresentation &parent, + const AgentPresentation &agent); + void setAgentStatus(ThreadPresentation &parent, AgentPresentation &agent, + const std::string &status); + void setAgentResult(ThreadPresentation &parent, AgentPresentation &agent, + const std::string &resultText); + void clearAgentResult(ThreadPresentation &parent, AgentPresentation &agent); void updateOwningAgentStatus(const std::string &childThreadId, const std::string &status); void updateOwningAgentResult(const std::string &childThreadId, diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index 27a463a..c26b983 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -208,7 +208,8 @@ struct ShellWidget::Impl final { void renderConversation(); void refreshSettings(); void refreshStatus(); - void hydrateHistoricalChildren(const std::string &parentThreadId); + void hydrateHistoricalChildren(const std::string &parentThreadId, + bool retryFailed = false); void showNotice(QString message, bool error = true); void resetRuntimeForConnection(); @@ -216,6 +217,7 @@ struct ShellWidget::Impl final { void beginNewThread(); void readThread(const std::string &threadId, bool forced = false); void ensureThreadHydrated(const std::string &threadId); + void hydrateThreadForSelection(const std::string &threadId); void ensureThreadSettingsHydrated(const std::string &threadId); void resumeThreadForSettings(const std::string &threadId); void renameThread(const std::string &threadId); @@ -825,7 +827,7 @@ void ShellWidget::Impl::refreshStatus() { } void ShellWidget::Impl::hydrateHistoricalChildren( - const std::string &parentThreadId) { + const std::string &parentThreadId, bool retryFailed) { const ThreadPresentation *thread = model.thread(parentThreadId); if (!thread) return; @@ -838,10 +840,13 @@ void ShellWidget::Impl::hydrateHistoricalChildren( if (agent == thread->agents.end() || !isActiveStatus(agent->second.status)) continue; - // Historical child hydration shares the same monotonic read boundary as - // user-selected threads, so a pre-reconnect result cannot replace newer - // child/agent presentation state. - readThread(childThreadId); + const auto runtime = runtimeByThread.find(childThreadId); + const bool failed = runtime != runtimeByThread.end() && + runtime->second.hydration == Hydration::Failed; + // Background activity never retries a failed read. Explicit navigation + // supplies a new bounded retry boundary without creating a retry loop. + if (!failed || retryFailed) + readThread(childThreadId, failed); } } @@ -849,8 +854,7 @@ void ShellWidget::Impl::selectThread(std::string threadId) { if (threadId.empty()) return; if (threadId == selectedThreadId) { - ensureThreadHydrated(threadId); - ensureThreadSettingsHydrated(threadId); + hydrateThreadForSelection(threadId); return; } selectedThreadId = std::move(threadId); @@ -859,8 +863,7 @@ void ShellWidget::Impl::selectThread(std::string threadId) { newThreadName.clear(); newThreadWorkspace.clear(); historyWindows.try_emplace(selectedThreadId); - ensureThreadHydrated(selectedThreadId); - ensureThreadSettingsHydrated(selectedThreadId); + hydrateThreadForSelection(selectedThreadId); render(); } @@ -1014,6 +1017,18 @@ void ShellWidget::Impl::ensureThreadHydrated(const std::string &threadId) { readThread(threadId); } +void ShellWidget::Impl::hydrateThreadForSelection( + const std::string &threadId) { + const auto runtime = runtimeByThread.find(threadId); + if (runtime != runtimeByThread.end() && + runtime->second.hydration == Hydration::Failed) + readThread(threadId, true); + else + ensureThreadHydrated(threadId); + ensureThreadSettingsHydrated(threadId); + hydrateHistoricalChildren(threadId, true); +} + void ShellWidget::Impl::renameThread(const std::string &threadId) { const ThreadPresentation *thread = model.thread(threadId); if (!thread) diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp index 675316a..2ba71b0 100644 --- a/tests/codex/PresentationPipelineTest.cpp +++ b/tests/codex/PresentationPipelineTest.cpp @@ -353,8 +353,8 @@ int main() { {{"id", "second-root"}}})}}, codexui::codex::presentation::Authority::Merge)); ownershipModel.applyEvent(codexui::codex::presentation::event( - 2, 1, "agents.activity.upsert", - {{"activity", + 2, 1, "conversation.item.upsert", + {{"item", {{"id", "spawn-one"}, {"type", "subAgentActivity"}, {"status", "started"}, @@ -364,8 +364,8 @@ int main() { {"turnId", "parent-turn"}, {"itemId", "spawn-one"}})); ownershipModel.applyEvent(codexui::codex::presentation::event( - 3, 1, "agents.activity.upsert", - {{"activity", + 3, 1, "conversation.item.upsert", + {{"item", {{"id", "spawn-one"}, {"type", "subAgentActivity"}, {"status", "started"}, @@ -375,8 +375,8 @@ int main() { {"turnId", "parent-turn"}, {"itemId", "spawn-one"}})); ownershipModel.applyEvent(codexui::codex::presentation::event( - 4, 1, "agents.activity.upsert", - {{"activity", + 4, 1, "conversation.item.upsert", + {{"item", {{"id", "spawn-two"}, {"type", "subAgentActivity"}, {"status", "started"}, @@ -386,8 +386,8 @@ int main() { {"turnId", "parent-turn"}, {"itemId", "spawn-two"}})); ownershipModel.applyEvent(codexui::codex::presentation::event( - 5, 1, "agents.activity.upsert", - {{"activity", + 5, 1, "conversation.item.upsert", + {{"item", {{"id", "spawn-grandchild"}, {"type", "subAgentActivity"}, {"status", "started"}, @@ -470,15 +470,48 @@ int main() { ? nullptr : &found->second; }(); + const auto *ownerSourceItem = + parent ? &parent->turns.at("parent-turn").items.at("spawn-one") + : nullptr; passed &= expect( ownerAgent && ownerAgent->status == "completed" && stringMember(ownerAgent->raw, "resultText") == "direct child result" && + ownerSourceItem && + stringMember(ownerSourceItem->raw, "resultText") == + "direct child result" && parent->agents.at("spawn-two").status == "started", "child completion and results route only to the indexed owning agent"); + ownershipModel.applyEvent(codexui::codex::presentation::event( + 9, 1, "agents.activity.upsert", + {{"activity", + {{"id", "wait-state"}, + {"type", "collabAgentToolCall"}, + {"tool", "wait_agent"}, + {"agentsStates", + {{"child-one", + {{"status", "completed"}, + {"message", "state-correlated result"}}}}}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "parent"}, + {"turnId", "parent-turn"}, + {"itemId", "wait-state"}})); + parent = ownershipModel.thread("parent"); + ownerSourceItem = + parent ? &parent->turns.at("parent-turn").items.at("spawn-one") + : nullptr; + passed &= expect( + parent && + stringMember(parent->agents.at("spawn-one").raw, "resultText") == + "state-correlated result" && + ownerSourceItem && + stringMember(ownerSourceItem->raw, "resultText") == + "state-correlated result", + "agent state results route to the indexed owner and its source item"); + ownershipModel.applyEvent(codexui::codex::presentation::result( - 9, 1, "thread.read", "replace-child", true, + 10, 1, "thread.read", "replace-child", true, {{"thread", {{"id", "child-one"}, {"status", {{"type", "idle"}}}, @@ -502,7 +535,7 @@ int main() { "authoritative child hydration clears stale results without losing ownership"); ownershipModel.applyEvent(codexui::codex::presentation::result( - 10, 1, "threads.list", "relisted-owned-child", true, + 11, 1, "threads.list", "relisted-owned-child", true, {{"threads", nlohmann::json::array({{{"id", "child-one"}}, {{"id", "second-root"}}, @@ -514,7 +547,7 @@ int main() { "thread relisting cannot reintroduce an owned child as a root"); ownershipModel.applyEvent(codexui::codex::presentation::result( - 11, 1, "thread.read", "replace-parent", true, + 12, 1, "thread.read", "replace-parent", true, {{"thread", {{"id", "parent"}, {"turns", @@ -544,7 +577,7 @@ int main() { "authoritative parent hydration rebuilds ordered ownership in one pass"); ownershipModel.applyEvent(codexui::codex::presentation::event( - 12, 1, "thread.removed", nlohmann::json::object(), + 13, 1, "thread.removed", nlohmann::json::object(), codexui::codex::presentation::Authority::Remove, {{"threadId", "child-one"}})); parent = ownershipModel.thread("parent"); @@ -560,7 +593,7 @@ int main() { "authoritative child removal prunes ownership and promotes surviving descendants"); ownershipModel.applyEvent(codexui::codex::presentation::event( - 13, 1, "thread.removed", nlohmann::json::object(), + 14, 1, "thread.removed", nlohmann::json::object(), codexui::codex::presentation::Authority::Remove, {{"threadId", "parent"}})); passed &= expect( @@ -572,7 +605,7 @@ int main() { "authoritative parent removal promotes children in retained root order"); ownershipModel.applyEvent(codexui::codex::presentation::event( - 14, 1, "connection.provider", + 15, 1, "connection.provider", {{"generation", std::uint64_t{1}}, {"state", "disconnected"}}, codexui::codex::presentation::Authority::Replace)); passed &= expect(ownershipModel.threadOrder().empty() && @@ -617,13 +650,22 @@ int main() { {{"threadId", "hydrated-child"}})); const auto *hydratedParent = reconnectOwnershipModel.thread("hydrated-parent"); + const auto *hydratedSourceItem = + hydratedParent + ? &hydratedParent->turns.at("hydrated-turn") + .items.at("hydrated-spawn") + : nullptr; passed &= expect( hydratedParent && reconnectOwnershipModel.childOwnership("hydrated-child") && reconnectOwnershipModel.childOwnership("hydrated-grandchild") && hydratedParent->agents.at("hydrated-spawn").status == "completed" && stringMember(hydratedParent->agents.at("hydrated-spawn").raw, - "resultText") == "hydrated answer", + "resultText") == "hydrated answer" && + hydratedSourceItem && + stringMember(hydratedSourceItem->raw, "status") == "completed" && + stringMember(hydratedSourceItem->raw, "resultText") == + "hydrated answer", "parent-first and nested child hydration retain direct correlation"); reconnectOwnershipModel.applyEvent(codexui::codex::presentation::event( 1, 2, "connection.lifecycle", {{"state", "connected"}}, @@ -646,9 +688,10 @@ int main() { codexui::codex::presentation::Authority::Merge, {{"threadId", "rebind-parent"}})); reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( - 2, 1, "agents.activity.upsert", - {{"activity", + 2, 1, "conversation.item.upsert", + {{"item", {{"type", "subAgentActivity"}, + {"id", "stable-agent"}, {"status", "completed"}, {"resultText", "old result"}, {"agentThreadId", "old-child"}}}}, @@ -682,7 +725,10 @@ int main() { rebindParent->agents.at("stable-agent").childThreadId == "new-child" && rebindParent->agents.at("stable-agent").status == "started" && - !rebindParent->agents.at("stable-agent").raw.contains("resultText"), + !rebindParent->agents.at("stable-agent").raw.contains("resultText") && + !rebindParent->turns.at("rebind-turn") + .items.at("stable-agent") + .raw.contains("resultText"), "rebinding one stable agent resets its stale completion and result"); reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( 4, 1, "turn.upsert", @@ -706,9 +752,15 @@ int main() { codexui::codex::presentation::Authority::Merge, {{"threadId", "rebind-parent"}})); rebindParent = reboundOwnershipModel.thread("rebind-parent"); + const auto *reboundSourceItem = + rebindParent + ? &rebindParent->turns.at("rebind-turn").items.at("stable-agent") + : nullptr; passed &= expect( rebindParent && - rebindParent->agents.at("stable-agent").status == "completed", + rebindParent->agents.at("stable-agent").status == "completed" && + reboundSourceItem && + stringMember(reboundSourceItem->raw, "status") == "completed", "stale merged parent hydration cannot reactivate a completed child agent"); reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( 6, 1, "agents.activity.upsert", diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index 50dfaa7..2f6bde1 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -581,6 +581,38 @@ bool ShellFlow::verifyBoundedChildHydration() { result &= expect(!peer.waitFor("thread.read", "child-failure", 100).has_value(), "a failed child hydration does not enter an automatic retry loop"); + + result &= expect(selectThread(list, "thread-a") && + selectThread(list, "thread-b"), + "explicit navigation returns to the failed child's parent"); + const auto retriedChildRead = + peer.waitFor("thread.read", "child-failure"); + result &= expect(retriedChildRead.has_value(), + "explicit parent navigation retries one failed child read"); + if (!retriedChildRead) + return false; + result &= peer.send(presentation::result( + sequence++, 2, "thread.read", + retriedChildRead->value("correlationId", std::string{}), true, + {{"thread", threadWithAgentMessage("child-failure", "Child", + "completed child result")}}, + Authority::Replace, {{"threadId", "child-failure"}})); + spin(10); + + auto *inspector = shell.findChild(QStringLiteral("inspector")); + auto *tabs = inspector ? inspector->findChild( + QString{}, Qt::FindDirectChildrenOnly) + : nullptr; + if (tabs) { + tabs->setCurrentIndex(1); + spin(); + } + result &= expect(inspector && tabs && + hasPresentedText(*inspector, + QStringLiteral("Completed")) && + !hasPresentedText(*inspector, + QStringLiteral("Running")), + "retried child completion replaces the stale Running badge"); return result; } From 0bcc20c1f2a6e7f5da1dd9f9cb965d6d1e2dd3f6 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 22:20:43 +0200 Subject: [PATCH 7/9] Use canonical Inspector scrollbars --- src/codex/middle/InspectorPane.cpp | 2 ++ tests/codex/ApplicationLayoutTest.cpp | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/codex/middle/InspectorPane.cpp b/src/codex/middle/InspectorPane.cpp index c85b6cc..9fe7c17 100644 --- a/src/codex/middle/InspectorPane.cpp +++ b/src/codex/middle/InspectorPane.cpp @@ -302,6 +302,8 @@ InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { scroll->setFrameShape(QFrame::NoFrame); scroll->setWidgetResizable(true); scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + scroll->verticalScrollBar()->setProperty("kind", "infoViewer"); scroll->setWidget(content); return scroll; }; diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index 51b147e..b5986c9 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -1069,6 +1070,17 @@ bool testInfoViewerLayout() { "Info exposes State and Protocol through choice navigation"); if (!infoStack || !protocolChoice || !protocol || !state || !statistics) return false; + const auto inspectorScrolls = inspector.findChildren(); + result &= expect( + inspectorScrolls.size() == 3 && + std::ranges::all_of(inspectorScrolls, [](QScrollArea *scroll) { + return scroll && scroll->property("kind") == "inspectorScroll" && + scroll->verticalScrollBarPolicy() == + Qt::ScrollBarAsNeeded && + scroll->verticalScrollBar()->property("kind") == + "infoViewer"; + }), + "Plan, Agents, and Requests use the canonical Inspector scrollbar"); protocolChoice->click(); inspector.appendProtocolFrame( {{"kind", "event"}, From abb1baef762f982c0d9aa463d12b2dc14b397493 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 22:32:38 +0200 Subject: [PATCH 8/9] Prefer authoritative child lifecycle status --- src/codex/PresentationModel.cpp | 7 ++++--- tests/codex/PresentationPipelineTest.cpp | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp index 9195dec..faf8c04 100644 --- a/src/codex/PresentationModel.cpp +++ b/src/codex/PresentationModel.cpp @@ -1042,7 +1042,10 @@ void PresentationModel::synchronizeOwningAgent( if (!agent || child == threads.end()) return; - std::string childStatus; + // A thread-level lifecycle is authoritative. Turn status is only a fallback + // for incremental payloads that do not carry the thread lifecycle; an old + // or interrupted turn must not make an idle child appear active. + std::string childStatus = child->second.status; std::string resultText; for (auto turnId = child->second.turnOrder.rbegin(); turnId != child->second.turnOrder.rend(); ++turnId) { @@ -1064,8 +1067,6 @@ void PresentationModel::synchronizeOwningAgent( if (!resultText.empty() && !childStatus.empty()) break; } - if (childStatus.empty()) - childStatus = child->second.status; updateOwningAgentStatus(childThreadId, childStatus); const auto ownership = childOwnerships.find(childThreadId); const auto parent = ownership == childOwnerships.end() diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp index 2ba71b0..3d4b3a5 100644 --- a/tests/codex/PresentationPipelineTest.cpp +++ b/tests/codex/PresentationPipelineTest.cpp @@ -518,6 +518,7 @@ int main() { {"turns", nlohmann::json::array( {{{"id", "child-turn"}, + {"status", "inProgress"}, {"items", nlohmann::json::array( {{{"id", "spawn-grandchild"}, From 3b0bba7d1a244497058a1794fc013ab98b9f1727 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Fri, 28 Aug 2026 00:05:24 +0200 Subject: [PATCH 9/9] Preserve child ownership across inherited history --- src/codex/PresentationModel.cpp | 69 +++++++++--- src/codex/PresentationModel.h | 2 +- tests/codex/PresentationPipelineTest.cpp | 127 ++++++++++++++++++++++- 3 files changed, 179 insertions(+), 19 deletions(-) diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp index faf8c04..4a90630 100644 --- a/src/codex/PresentationModel.cpp +++ b/src/codex/PresentationModel.cpp @@ -122,6 +122,19 @@ std::string agentIdentity(const nlohmann::json &activity, return childThreadIdentity(activity); } +bool isStaleAgentReplay(const ThreadPresentation &owner, + const nlohmann::json &scope, + const nlohmann::json &activity, bool live) { + if (live) + return false; + const std::string id = agentIdentity(activity, scope); + const std::string childThreadId = childThreadIdentity(activity); + const auto agent = owner.agents.find(id); + return !childThreadId.empty() && agent != owner.agents.end() && + !agent->second.childThreadId.empty() && + agent->second.childThreadId != childThreadId; +} + void mergePreservingCompleteness(nlohmann::json &target, const nlohmann::json &update) { if (!target.is_object() || !update.is_object()) { @@ -724,9 +737,9 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, result.raw["status"] = previousThreadStatus; } } - if (replaceTurns) - synchronizeOwningAgent(id, true); - else if (status != raw.end()) + if (turns != raw.end() && turns->is_array()) + synchronizeOwningAgent(id, replaceTurns); + else if (status != raw.end() && result.status != "notLoaded") updateOwningAgentStatus(id, result.status); return result; } @@ -779,6 +792,19 @@ ItemPresentation &PresentationModel::upsertItem(ThreadPresentation &thread, static ItemPresentation ignored; return ignored; } + const nlohmann::json scope{{"threadId", thread.id}, + {"turnId", turn.id}, + {"itemId", id}}; + const std::string incomingType = stringValue(raw, "type"); + if ((incomingType == "subAgentActivity" || + incomingType == "collabAgentToolCall") && + isStaleAgentReplay(thread, scope, raw, live)) { + const auto existing = turn.items.find(id); + if (existing != turn.items.end()) + return existing->second; + static ItemPresentation ignored; + return ignored; + } auto [iterator, inserted] = turn.items.try_emplace(id); ItemPresentation &result = iterator->second; if (inserted) { @@ -791,10 +817,7 @@ ItemPresentation &PresentationModel::upsertItem(ThreadPresentation &thread, const std::string type = stringValue(result.raw, "type"); retainRepositoryHints(thread, result.raw); if (type == "subAgentActivity" || type == "collabAgentToolCall") { - upsertAgentActivity( - thread, - {{"threadId", thread.id}, {"turnId", turn.id}, {"itemId", result.id}}, - result.raw, live); + upsertAgentActivity(thread, scope, result.raw, live); } if (type == "agentMessage") updateOwningAgentResult(thread.id, stringValue(result.raw, "text")); @@ -845,6 +868,8 @@ void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, const std::string id = agentIdentity(activity, scope); if (id.empty()) return; + if (isStaleAgentReplay(owner, scope, activity, live)) + return; auto [iterator, inserted] = owner.agents.try_emplace(id); AgentPresentation &agent = iterator->second; @@ -852,13 +877,13 @@ void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, agent.id = id; owner.agentOrder.push_back(id); } + const bool changesChild = !childThreadId.empty() && + !agent.childThreadId.empty() && + agent.childThreadId != childThreadId; agent.itemId = stringValue(scope, "itemId"); agent.ownerTurnId = stringValue(scope, "turnId"); mergePreservingCompleteness(agent.raw, activity); - const bool changesChild = !childThreadId.empty() && - !agent.childThreadId.empty() && - agent.childThreadId != childThreadId; if (changesChild) { agent.status.clear(); agent.raw.erase("status"); @@ -884,12 +909,13 @@ void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, } if (!childThreadId.empty()) - assignChildOwnership(owner, agent, childThreadId); + assignChildOwnership(owner, agent, childThreadId, live); } void PresentationModel::assignChildOwnership(ThreadPresentation &parent, AgentPresentation &agent, - const std::string &childThreadId) { + const std::string &childThreadId, + bool live) { if (childThreadId == parent.id) return; std::string ancestorId = parent.id; @@ -913,8 +939,21 @@ void PresentationModel::assignChildOwnership(ThreadPresentation &parent, const auto previous = childOwnerships.find(childThreadId); if (previous != childOwnerships.end() && (previous->second.parentThreadId != parent.id || - previous->second.agentId != agent.id)) + previous->second.agentId != agent.id)) { + const auto previousParent = threads.find(previous->second.parentThreadId); + // A child read contains inherited ancestor and sibling activity. Those + // replayed items are historical context, not a new ownership authority. + // Replace/removal releases a vanished owner before reconstruction, while + // live activity may still authoritatively rebind an existing child. + if (!live && previousParent != threads.end()) { + const auto previousAgent = + previousParent->second.agents.find(previous->second.agentId); + if (previousAgent != previousParent->second.agents.end() && + previousAgent->second.childThreadId == childThreadId) + return; + } releaseChildOwnership(childThreadId, false); + } agent.childThreadId = childThreadId; agent.raw["childThreadId"] = childThreadId; @@ -1045,7 +1084,9 @@ void PresentationModel::synchronizeOwningAgent( // A thread-level lifecycle is authoritative. Turn status is only a fallback // for incremental payloads that do not carry the thread lifecycle; an old // or interrupted turn must not make an idle child appear active. - std::string childStatus = child->second.status; + std::string childStatus = + child->second.status == "notLoaded" ? std::string{} + : child->second.status; std::string resultText; for (auto turnId = child->second.turnOrder.rbegin(); turnId != child->second.turnOrder.rend(); ++turnId) { diff --git a/src/codex/PresentationModel.h b/src/codex/PresentationModel.h index 135d527..d178313 100644 --- a/src/codex/PresentationModel.h +++ b/src/codex/PresentationModel.h @@ -136,7 +136,7 @@ class PresentationModel final { const nlohmann::json &activity, bool live = true); void assignChildOwnership(ThreadPresentation &parent, AgentPresentation &agent, - const std::string &childThreadId); + const std::string &childThreadId, bool live); void releaseChildOwnership(const std::string &childThreadId, bool promoteToRoot); void synchronizeOwningAgent(const std::string &childThreadId, diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp index 3d4b3a5..68f159f 100644 --- a/tests/codex/PresentationPipelineTest.cpp +++ b/tests/codex/PresentationPipelineTest.cpp @@ -634,9 +634,13 @@ int main() { 2, 1, "thread.read", "hydrate-child", true, {{"thread", {{"id", "hydrated-child"}, + {"status", {{"type", "notLoaded"}}}, {"turns", nlohmann::json::array( - {{{"id", "child-turn"}, + {{{"id", "stale-outer-turn"}, + {"status", "interrupted"}, + {"items", nlohmann::json::array()}}, + {{"id", "child-turn"}, {"status", "completed"}, {"items", nlohmann::json::array( @@ -647,7 +651,7 @@ int main() { {{"id", "hydrated-result"}, {"type", "agentMessage"}, {"text", "hydrated answer"}}})}}})}}}}, - codexui::codex::presentation::Authority::Replace, + codexui::codex::presentation::Authority::Merge, {{"threadId", "hydrated-child"}})); const auto *hydratedParent = reconnectOwnershipModel.thread("hydrated-parent"); @@ -683,6 +687,74 @@ int main() { std::vector{"hydrated-parent"}, "connection-generation reconnect preserves ownership and root filtering"); + PresentationModel inheritedHistoryModel; + inheritedHistoryModel.applyEvent(codexui::codex::presentation::result( + 1, 1, "thread.read", "hydrate-sibling-parent", true, + {{"thread", + {{"id", "sibling-parent"}, + {"turns", + nlohmann::json::array( + {{{"id", "parent-turn"}, + {"items", + nlohmann::json::array( + {{{"id", "spawn-a"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "sibling-a"}}, + {{"id", "spawn-b"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "sibling-b"}}})}}})}}}}, + codexui::codex::presentation::Authority::Replace, + {{"threadId", "sibling-parent"}})); + inheritedHistoryModel.applyEvent(codexui::codex::presentation::result( + 2, 1, "thread.read", "hydrate-sibling-b", true, + {{"thread", + {{"id", "sibling-b"}, + {"turns", + nlohmann::json::array( + {{{"id", "inherited-parent-turn"}, + {"items", + nlohmann::json::array( + {{{"id", "spawn-a"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "sibling-a"}}})}}})}}}}, + codexui::codex::presentation::Authority::Replace, + {{"threadId", "sibling-b"}})); + inheritedHistoryModel.applyEvent(codexui::codex::presentation::result( + 3, 1, "thread.read", "complete-sibling-a", true, + {{"thread", + {{"id", "sibling-a"}, + {"status", {{"type", "notLoaded"}}}, + {"turns", + nlohmann::json::array( + {{{"id", "sibling-a-turn"}, + {"status", "completed"}, + {"items", + nlohmann::json::array( + {{{"id", "sibling-a-answer"}, + {"type", "agentMessage"}, + {"text", "sibling answer"}}})}}})}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "sibling-a"}})); + const auto *siblingParent = inheritedHistoryModel.thread("sibling-parent"); + const auto *siblingB = inheritedHistoryModel.thread("sibling-b"); + const auto *siblingOwnership = + inheritedHistoryModel.childOwnership("sibling-a"); + passed &= expect( + siblingParent && siblingB && siblingOwnership && + siblingOwnership->parentThreadId == "sibling-parent" && + siblingOwnership->agentId == "spawn-a" && + siblingParent->childThreadOrder == + std::vector{"sibling-a", "sibling-b"} && + siblingParent->agents.at("spawn-a").childThreadId == "sibling-a" && + siblingParent->agents.at("spawn-a").status == "completed" && + stringMember(siblingParent->agents.at("spawn-a").raw, + "resultText") == "sibling answer" && + siblingB->agents.at("spawn-a").childThreadId.empty(), + "inherited sibling history cannot steal direct child ownership"); + PresentationModel reboundOwnershipModel; reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( 1, 1, "thread.upsert", {{"thread", {{"id", "rebind-parent"}}}}, @@ -736,8 +808,18 @@ int main() { {{"turn", {{"id", "new-child-turn"}, {"status", "completed"}}}}, codexui::codex::presentation::Authority::Merge, {{"threadId", "new-child"}, {"turnId", "new-child-turn"}})); + reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( + 5, 1, "conversation.item.upsert", + {{"item", + {{"id", "new-child-answer"}, + {"type", "agentMessage"}, + {"text", "new child result"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "new-child"}, + {"turnId", "new-child-turn"}, + {"itemId", "new-child-answer"}})); reboundOwnershipModel.applyEvent(codexui::codex::presentation::result( - 5, 1, "thread.read", "stale-parent-merge", true, + 6, 1, "thread.read", "stale-parent-merge", true, {{"thread", {{"id", "rebind-parent"}, {"turns", @@ -763,8 +845,45 @@ int main() { reboundSourceItem && stringMember(reboundSourceItem->raw, "status") == "completed", "stale merged parent hydration cannot reactivate a completed child agent"); + reboundOwnershipModel.applyEvent(codexui::codex::presentation::result( + 7, 1, "thread.read", "stale-former-child-merge", true, + {{"thread", + {{"id", "rebind-parent"}, + {"turns", + nlohmann::json::array( + {{{"id", "rebind-turn"}, + {"items", + nlohmann::json::array( + {{{"id", "stable-agent"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "old-child"}}})}}})}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "rebind-parent"}})); + rebindParent = reboundOwnershipModel.thread("rebind-parent"); + const auto *reboundSourceAfterFormerChild = + rebindParent + ? &rebindParent->turns.at("rebind-turn").items.at("stable-agent") + : nullptr; + passed &= expect( + rebindParent && + rebindParent->agents.at("stable-agent").childThreadId == + "new-child" && + rebindParent->agents.at("stable-agent").status == "completed" && + stringMember(rebindParent->agents.at("stable-agent").raw, + "resultText") == "new child result" && + reboundSourceAfterFormerChild && + stringMember(reboundSourceAfterFormerChild->raw, "status") == + "completed" && + stringMember(reboundSourceAfterFormerChild->raw, "resultText") == + "new child result" && + reboundOwnershipModel.childOwnership("old-child") == nullptr && + reboundOwnershipModel.childOwnership("new-child") && + reboundOwnershipModel.childOwnership("new-child")->parentThreadId == + "rebind-parent", + "stale merged identity cannot undo a live child rebind"); reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( - 6, 1, "agents.activity.upsert", + 8, 1, "agents.activity.upsert", {{"activity", {{"type", "subAgentActivity"}, {"status", "started"},