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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 49 additions & 54 deletions src/ui/ConversationWidget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@ namespace
namespace sdk = ai::openai::codex::frontend::client;
namespace frontend = ai::openai::codex::frontend;

constexpr qsizetype maximumRenderedTimelineTurns = 32;
constexpr qsizetype maximumRenderedTimelineItems = 256;
// Incomplete replacement proofs stay bounded even though a fully loaded
// thread materializes all retained history. A partial publication that would
// require more work simply keeps the existing presentation until recovery.
constexpr qsizetype recoveryInspectionItemBudget = 256;
constexpr qsizetype recoveryInspectionTurnBudget = 32;
constexpr std::size_t maximumActivityItemsPerSegment = 16;
constexpr qsizetype largeMessageEditorThreshold = 64 * 1024;
constexpr int largeMessageEditorHeight = 240;
Expand Down Expand Up @@ -2072,7 +2075,6 @@ struct TimelineWindow
std::vector<TimelineTurnSlice> turns;
qsizetype renderedItems = 0;
qsizetype totalItems = 0;
bool earlierItemsOmitted = false;
};

QString segmentStorageKey(const QString& turnId, const QString& segmentId)
Expand Down Expand Up @@ -2166,34 +2168,22 @@ qsizetype timelineItemCount(const TimelineSegment& segment)
return qMax<qsizetype>(1, static_cast<qsizetype>(segment.items.size()));
}

TimelineWindow latestTimelineWindow(const sdk::State& state, const sdk::ThreadState& thread)
TimelineWindow retainedTimelineWindow(const sdk::State& state, const sdk::ThreadState& thread)
{
TimelineWindow result;
qsizetype remainingItems = maximumRenderedTimelineItems;
for (qsizetype index = static_cast<qsizetype>(thread.orderedTurns.size());
index > 0 && remainingItems > 0
&& static_cast<qsizetype>(result.turns.size()) < maximumRenderedTimelineTurns;
--index)
result.turns.reserve(thread.orderedTurns.size());
for (qsizetype index = 0;
index < static_cast<qsizetype>(thread.orderedTurns.size());
++index)
{
const auto* turn = state.turn(thread.id, thread.orderedTurns.at(index - 1));
const auto* turn = state.turn(thread.id, thread.orderedTurns.at(index));
if (!turn)
continue;
const qsizetype itemCount = qMax<qsizetype>(1, static_cast<qsizetype>(turn->orderedItems.size()));
const qsizetype selectedItems = qMin(itemCount, remainingItems);
const qsizetype firstItem = turn->orderedItems.empty()
? 0
: static_cast<qsizetype>(turn->orderedItems.size()) - selectedItems;
result.turns.insert(result.turns.begin(), {turn, index, firstItem});
result.renderedItems += selectedItems;
remainingItems -= selectedItems;
result.turns.push_back({turn, index + 1, 0});
result.renderedItems += itemCount;
}
result.earlierItemsOmitted = !result.turns.empty()
&& (result.turns.front().turnNumber > 1
|| result.turns.front().firstItem > 0);
// Keep this presentation-only count a bounded lower bound. Computing the
// exact retained count required an all-turn scan on every live item event.
result.totalItems = result.renderedItems
+ (result.earlierItemsOmitted ? 1 : 0);
result.totalItems = result.renderedItems;
return result;
}

Expand All @@ -2208,9 +2198,9 @@ bool incompleteStateContainsRenderedTimeline(
{
inspectedItems = 0;
constexpr qsizetype maximumRecoveryInspectedItems =
maximumRenderedTimelineItems
recoveryInspectionItemBudget
+ (static_cast<qsizetype>(maximumActivityItemsPerSegment) - 1)
* maximumRenderedTimelineTurns;
* recoveryInspectionTurnBudget;
for (const QString& renderedTurnId : renderedTurnIds)
{
const sdk::TurnState* retainedTurn = state.turn(
Expand Down Expand Up @@ -2887,8 +2877,10 @@ ConversationWidget::ConversationWidget(QWidget* parent) : QWidget(parent)

timelineHost = new QWidget;
timelineHost->setObjectName(QStringLiteral("conversationTimeline"));
timelineHost->setProperty("maximumRenderedTurns", maximumRenderedTimelineTurns);
timelineHost->setProperty("maximumRenderedItems", maximumRenderedTimelineItems);
timelineHost->setProperty(
"recoveryInspectionTurnBudget", recoveryInspectionTurnBudget);
timelineHost->setProperty(
"recoveryInspectionItemBudget", recoveryInspectionItemBudget);
timeline = new QVBoxLayout(timelineHost);
timeline->setContentsMargins(0, 0, 0, 0);
timeline->setSpacing(0);
Expand Down Expand Up @@ -3022,11 +3014,12 @@ void ConversationWidget::render(const sdk::State& state,
state, threadId, *exactContentChanges);
if (exactContentApplied && !structuralReconciliation)
return;
// A bounded replacement is not deletion authority. Keep the same-thread
// widgets until an incomplete publication can account for every rendered
// descendant; requester-local Merge will make that true, while Replace is
// necessarily fullyLoaded and exact Absent changes the selection.
if (!structuralReconciliation)
// A bounded replacement is not deletion authority. This proof also gates
// structural item upserts: their scope says that topology may have grown,
// not that an incomplete State is authoritative for deleting every item it
// omitted. Keep the same-thread widgets until the publication can account
// for every rendered descendant; requester-local Merge will make that
// true, while Replace is fullyLoaded and exact Absent changes selection.
{
const bool renderedTimelineRetained = !renderedTurnIds.isEmpty();
qsizetype recoveryInspectedItems = 0;
Expand Down Expand Up @@ -3199,7 +3192,7 @@ void ConversationWidget::render(const sdk::State& state,
std::optional<TimelineWindow> structuralWindow;
if (structuralReconciliation)
{
structuralWindow.emplace(latestTimelineWindow(state, *thread));
structuralWindow.emplace(retainedTimelineWindow(state, *thread));
if (!structuralWindow->turns.empty())
currentTurn = structuralWindow->turns.back().turn;
}
Expand Down Expand Up @@ -3265,7 +3258,7 @@ void ConversationWidget::render(const sdk::State& state,
{
const TimelineWindow window = structuralWindow
? std::move(*structuralWindow)
: latestTimelineWindow(state, *thread);
: retainedTimelineWindow(state, *thread);
std::vector<TimelineEntry> entries;
entries.reserve(static_cast<std::size_t>(window.renderedItems));
for (const TimelineTurnSlice& slice : window.turns)
Expand All @@ -3276,19 +3269,7 @@ void ConversationWidget::render(const sdk::State& state,
}
timelineHost->setProperty("renderedTimelineItems", window.renderedItems);
timelineHost->setProperty("retainedTimelineItems", window.totalItems);
if (window.earlierItemsOmitted)
{
timelineWindowDetail->setText(
QStringLiteral("Showing the latest %1 synchronized timeline entries. "
"Earlier entries remain in canonical AISuite State and are not "
"materialized in this live view.")
.arg(window.renderedItems));
timelineWindowNotice->show();
}
else
{
timelineWindowNotice->hide();
}
timelineWindowNotice->hide();

struct VisibleTimelineTurn
{
Expand Down Expand Up @@ -3368,14 +3349,23 @@ void ConversationWidget::render(const sdk::State& state,
else
{
removedTurnPrefix = renderedTurnIds.indexOf(visibleTurnIds.front());
compatibleTurns = removedTurnPrefix >= 0
&& renderedTurnIds.size() - removedTurnPrefix <= visibleTurnIds.size();
qsizetype visibleTurnPrefix = 0;
if (removedTurnPrefix < 0)
{
removedTurnPrefix = 0;
visibleTurnPrefix = visibleTurnIds.indexOf(
renderedTurnIds.front());
}
compatibleTurns = visibleTurnPrefix >= 0
&& renderedTurnIds.size() - removedTurnPrefix
<= visibleTurnIds.size() - visibleTurnPrefix;
for (qsizetype index = 0;
compatibleTurns && index < renderedTurnIds.size() - removedTurnPrefix;
compatibleTurns
&& index < renderedTurnIds.size() - removedTurnPrefix;
++index)
{
const QString oldId = renderedTurnIds.at(removedTurnPrefix + index);
compatibleTurns = oldId == visibleTurnIds.at(index)
compatibleTurns = oldId == visibleTurnIds.at(visibleTurnPrefix + index)
&& renderedTurnWidgets.contains(oldId)
&& renderedTurnLabels.contains(oldId)
&& renderedTurnItemLayouts.contains(oldId)
Expand All @@ -3397,8 +3387,12 @@ void ConversationWidget::render(const sdk::State& state,
}
}

for (const VisibleTimelineTurn& visibleTurn : visibleTurns)
for (qsizetype visibleTurnIndex = 0;
visibleTurnIndex < static_cast<qsizetype>(visibleTurns.size());
++visibleTurnIndex)
{
const VisibleTimelineTurn& visibleTurn = visibleTurns.at(
static_cast<std::size_t>(visibleTurnIndex));
const auto* turn = visibleTurn.turn;
const QString turnId = fromUtf8(turn->id.value);
const auto windowSlice = std::ranges::find_if(
Expand Down Expand Up @@ -3427,7 +3421,8 @@ void ConversationWidget::render(const sdk::State& state,
turnLabel,
statusLabel,
[this, turnId] { emit turnDetailsRequested(turnId); });
timeline->addWidget(turnWidget, 0, Qt::AlignTop);
timeline->insertWidget(
visibleTurnIndex, turnWidget, 0, Qt::AlignTop);
renderedTurnWidgets.insert(turnId, turnWidget);
renderedTurnLabels.insert(turnId, turnLabel);
renderedTurnItemLayouts.insert(turnId, itemLayout);
Expand Down
114 changes: 100 additions & 14 deletions src/ui/InspectorWidget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#include <QVBoxLayout>

#include <algorithm>
#include <iterator>
#include <optional>
#include <utility>
#include <variant>
Expand All @@ -38,6 +39,7 @@ struct AgentPresentation

struct CollaborationPresentation
{
QString itemId;
QString title;
QString status;
QString detail;
Expand Down Expand Up @@ -550,6 +552,7 @@ std::vector<CollaborationPresentation> collaborationPresentations(const sdk::Sta
if (!collab)
continue;
CollaborationPresentation presentation;
presentation.itemId = fromUtf8(item->id.value);
presentation.title = collab->tool ? humanize(fromUtf8(*collab->tool)) : QStringLiteral("Collaboration activity");
presentation.status = collab->status ? humanize(fromUtf8(*collab->status)) : itemStatus(*item);
QStringList detail;
Expand All @@ -568,6 +571,62 @@ std::vector<CollaborationPresentation> collaborationPresentations(const sdk::Sta
return result;
}

std::vector<AgentPresentation> retainedAgentPresentations(
const sdk::State& state,
const sdk::ThreadState& thread)
{
std::vector<AgentPresentation> result;
for (const auto& turnId : thread.orderedTurns) {
const auto* turn = state.turn(thread.id, turnId);
if (!turn)
continue;
for (AgentPresentation projected : agentPresentations(state, thread, *turn)) {
const auto existing = std::find_if(
result.begin(),
result.end(),
[&projected](const AgentPresentation& retained) {
if (!projected.agentThreadId.isEmpty())
return retained.agentThreadId == projected.agentThreadId;
return retained.agentThreadId.isEmpty()
&& !projected.agentPath.isEmpty()
&& retained.agentPath == projected.agentPath;
});
if (existing == result.end()) {
result.push_back(std::move(projected));
continue;
}
existing->itemIds.append(projected.itemIds);
if (!projected.kind.isEmpty())
existing->kind = std::move(projected.kind);
if (!projected.status.isEmpty())
existing->status = std::move(projected.status);
if (!projected.summary.isEmpty())
existing->summary = std::move(projected.summary);
if (!projected.duration.isEmpty())
existing->duration = std::move(projected.duration);
}
}
return result;
}

std::vector<CollaborationPresentation> retainedCollaborationPresentations(
const sdk::State& state,
const sdk::ThreadState& thread)
{
std::vector<CollaborationPresentation> result;
for (const auto& turnId : thread.orderedTurns) {
const auto* turn = state.turn(thread.id, turnId);
if (!turn)
continue;
auto projected = collaborationPresentations(state, thread, *turn);
result.insert(
result.end(),
std::make_move_iterator(projected.begin()),
std::make_move_iterator(projected.end()));
}
return result;
}

void addPresentationValue(QCryptographicHash& hash, const QByteArray& value)
{
hash.addData(QByteArray::number(value.size()));
Expand Down Expand Up @@ -667,6 +726,7 @@ void InspectorWidget::renderUnavailable(const QString& title, const QString& det
unavailablePresentationKey = presentationKey;
inspectedThreadId.clear();
dependentThreadIds.clear();
presentedAgentActivityItemIds.clear();
selectedAgentItemId.clear();
planPresentationKey.clear();
agentsPresentationKey.clear();
Expand Down Expand Up @@ -752,6 +812,13 @@ void InspectorWidget::render(const sdk::State& state,
}
const auto* thread = state.thread(threadId.toStdString());
if (!thread) {
const auto capacity = state.capacityProvenance();
const bool boundedSelectionUnresolved = threadId == inspectedThreadId
&& ((capacity && capacity->omittedThreads > 0)
|| (state.threadList().value
&& !state.threadList().value->complete));
if (boundedSelectionUnresolved)
return;
renderUnavailable(QStringLiteral("Thread unavailable"),
QStringLiteral("The selected thread is not retained in the current State."));
return;
Expand All @@ -760,6 +827,7 @@ void InspectorWidget::render(const sdk::State& state,
if (inspectedThreadId != threadId) {
inspectedThreadId = threadId;
dependentThreadIds.clear();
presentedAgentActivityItemIds.clear();
selectedAgentItemId.clear();
planPresentationKey.clear();
agentsPresentationKey.clear();
Expand Down Expand Up @@ -946,20 +1014,36 @@ void InspectorWidget::render(const sdk::State& state,
// representation flat rather than inferring a parent/child tree.
std::vector<AgentPresentation> agents;
std::vector<CollaborationPresentation> collaborations;
if (turn) {
agents = agentPresentations(state, *thread, *turn);
collaborations = collaborationPresentations(state, *thread, *turn);
}
dependentThreadIds.clear();
agents = retainedAgentPresentations(state, *thread);
collaborations = retainedCollaborationPresentations(state, *thread);
QSet<QString> projectedAgentActivityItemIds;
for (const AgentPresentation& agent : agents) {
if (!agent.agentThreadId.isEmpty())
dependentThreadIds.insert(agent.agentThreadId);
for (const QString& itemId : agent.itemIds)
projectedAgentActivityItemIds.insert(itemId);
}
const auto selected = std::find_if(agents.begin(), agents.end(), [this](const AgentPresentation& agent) {
return agent.itemIds.contains(selectedAgentItemId);
});
if (selected == agents.end())
selectedAgentItemId = agents.empty() ? QString{} : agents.front().itemIds.back();
for (const CollaborationPresentation& collaboration : collaborations)
projectedAgentActivityItemIds.insert(collaboration.itemId);
const bool incompleteAgentsRegressed = !thread->fullyLoaded
&& !presentedAgentActivityItemIds.isEmpty()
&& std::ranges::any_of(
presentedAgentActivityItemIds,
[&projectedAgentActivityItemIds](const QString& itemId) {
return !projectedAgentActivityItemIds.contains(itemId);
});

bool agentsChanged = false;
if (!incompleteAgentsRegressed) {
presentedAgentActivityItemIds = projectedAgentActivityItemIds;
dependentThreadIds.clear();
for (const AgentPresentation& agent : agents) {
if (!agent.agentThreadId.isEmpty())
dependentThreadIds.insert(agent.agentThreadId);
}
const auto selected = std::find_if(agents.begin(), agents.end(), [this](const AgentPresentation& agent) {
return agent.itemIds.contains(selectedAgentItemId);
});
if (selected == agents.end())
selectedAgentItemId = agents.empty() ? QString{} : agents.front().itemIds.back();

QCryptographicHash agentsHash(QCryptographicHash::Sha256);
addPresentationValue(agentsHash, turn != nullptr);
Expand All @@ -986,6 +1070,7 @@ void InspectorWidget::render(const sdk::State& state,
}
}
for (const auto& collaboration : collaborations) {
addPresentationValue(agentsHash, collaboration.itemId);
addPresentationValue(agentsHash, collaboration.title);
addPresentationValue(agentsHash, collaboration.status);
addPresentationValue(agentsHash, collaboration.detail);
Expand All @@ -998,13 +1083,13 @@ void InspectorWidget::render(const sdk::State& state,
}
}
const QByteArray nextAgentsKey = agentsHash.result();
const bool agentsChanged = nextAgentsKey != agentsPresentationKey;
agentsChanged = nextAgentsKey != agentsPresentationKey;
if (agentsChanged) {
agentsPresentationKey = nextAgentsKey;
clearLayout(agentsContent);
if (agents.empty() && collaborations.empty()) {
addEmpty(agentsContent, QStringLiteral("No agent activity"),
turn ? QStringLiteral("No collab or subagent activity is projected for the latest turn.")
turn ? QStringLiteral("No retained collab or subagent activity is projected for this thread.")
: QStringLiteral("This thread has no retained turns."));
} else {
if (!agents.empty()) {
Expand Down Expand Up @@ -1151,6 +1236,7 @@ void InspectorWidget::render(const sdk::State& state,
agentsContent->addStretch();
}
}
}

// Changes: only canonical projected metadata is shown. The installed view
// does not expose path strings, typed change kinds, or line counts.
Expand Down
Loading