diff --git a/src/codex/DiffViewer.cpp b/src/codex/DiffViewer.cpp index f47e2eb..40339ca 100644 --- a/src/codex/DiffViewer.cpp +++ b/src/codex/DiffViewer.cpp @@ -241,31 +241,6 @@ QStringList stringListSetting(const QString &key) { return result; } -QByteArray fingerprint(const GitDiffSnapshot &snapshot) { - QByteArray value = snapshot.repositoryRoot.toUtf8(); - value += '\0'; - value += snapshot.error.toUtf8(); - value += static_cast(snapshot.scope); - value += static_cast(snapshot.context); - value += snapshot.repository ? '\1' : '\0'; - value += snapshot.truncated ? '\1' : '\0'; - for (const GitDiffFile &file : snapshot.files) { - value += '\0'; - value += file.repositoryRoot.toUtf8(); - value += '\0'; - value += file.path.toUtf8(); - value += '\0'; - value += file.absolutePath.toUtf8(); - value += '\0'; - value += file.previousPath.toUtf8(); - value += '\0'; - value += file.status.toUtf8(); - value += '\0'; - value += file.patch.toUtf8(); - } - return QCryptographicHash::hash(value, QCryptographicHash::Sha256); -} - struct SideBySideText { QString left; QString right; @@ -439,7 +414,7 @@ class GitDiffReviewWindow final : public QDialog { reload(); }); connect(provider, &GitDiffProvider::loadingChanged, this, [this](bool value) { - if (value && snapshot.files.empty()) + if (value && (!snapshot || snapshot->files.empty())) subtitle->setText(QStringLiteral("Loading repository changes…")); }); connect(provider, &GitDiffProvider::snapshotReady, this, @@ -494,10 +469,8 @@ class GitDiffReviewWindow final : public QDialog { } void apply(const GitDiffSnapshot &value) { - const QByteArray nextFingerprint = fingerprint(value); - if (nextFingerprint == snapshotFingerprint) + if (snapshot && *snapshot == value) return; - snapshotFingerprint = nextFingerprint; snapshot = value; subtitle->setText(value.error.isEmpty() ? QStringLiteral("%1 | %2") @@ -532,11 +505,12 @@ class GitDiffReviewWindow final : public QDialog { void renderSelected() { const int index = reviewFiles->currentRow(); - if (index < 0 || static_cast(index) >= snapshot.files.size()) + if (!snapshot || index < 0 || + static_cast(index) >= snapshot->files.size()) return; - const GitDiffFile &file = snapshot.files[static_cast(index)]; + const GitDiffFile &file = snapshot->files[static_cast(index)]; requestedPath = file.absolutePath; - title->setText(fileTitle(file, snapshot.repositoryRoots.size() > 1)); + title->setText(fileTitle(file, snapshot->repositoryRoots.size() > 1)); const QString content = file.patch.isEmpty() ? QStringLiteral("No textual patch is available for this file.") : file.patch; @@ -550,7 +524,7 @@ class GitDiffReviewWindow final : public QDialog { } GitDiffProvider *provider = nullptr; - GitDiffSnapshot snapshot; + std::optional snapshot; QString workspace; QStringList commandDirectories; QStringList changedPaths; @@ -559,7 +533,6 @@ class GitDiffReviewWindow final : public QDialog { QString requestedPath; GitDiffScope scope = GitDiffScope::Unstaged; GitDiffContext context = GitDiffContext::Compact; - QByteArray snapshotFingerprint; QLabel *title = nullptr; QLabel *subtitle = nullptr; QListWidget *reviewFiles = nullptr; @@ -681,7 +654,9 @@ DiffViewer::DiffViewer(QWidget *parent) : QWidget(parent) { scopeValue(scope), GitDiffContext::Compact); }); connect(provider, &GitDiffProvider::loadingChanged, this, [this](bool loading) { - if (loading && snapshot.files.empty() && snapshot.error.isEmpty()) { + if (loading && + (!snapshot || + (snapshot->files.empty() && snapshot->error.isEmpty()))) { summary->setText(QStringLiteral("Loading changes…")); } }); @@ -754,16 +729,16 @@ void DiffViewer::setRepositoryContext(QString nextThreadId, selectedRepository = QSettings().value(base + QStringLiteral("/selected")).toString(); } - snapshot = {}; + snapshot.reset(); updateFileWatches(); - snapshotFingerprint.clear(); files->clear(); diff->clear(); refreshRepository(); } const GitDiffSnapshot &DiffViewer::currentSnapshot() const noexcept { - return snapshot; + static const GitDiffSnapshot empty; + return snapshot ? *snapshot : empty; } void DiffViewer::refreshRepository() { @@ -784,19 +759,17 @@ QStringList DiffViewer::repositoryCandidates() const { QString DiffViewer::selectedPath() const { const int index = files->currentRow(); - return index >= 0 && static_cast(index) < snapshot.files.size() - ? snapshot.files[static_cast(index)].absolutePath - : QString{}; + if (!snapshot || index < 0 || + static_cast(index) >= snapshot->files.size()) + return {}; + return snapshot->files[static_cast(index)].absolutePath; } void DiffViewer::applySnapshot(const GitDiffSnapshot &value) { - const QByteArray nextFingerprint = fingerprint(value); - if (nextFingerprint == snapshotFingerprint) { - snapshot = value; + if (snapshot && *snapshot == value) { updateFileWatches(); return; } - snapshotFingerprint = nextFingerprint; const QString previous = selectedPath(); const int previousScroll = diff->verticalScrollBar()->value(); snapshot = value; @@ -882,13 +855,15 @@ void DiffViewer::applySnapshot(const GitDiffSnapshot &value) { void DiffViewer::updateFileWatches() { QStringList desired; - for (const GitDiffFile &file : snapshot.files) { - const QFileInfo info(file.absolutePath); - if (info.exists()) - desired.push_back(info.absoluteFilePath()); - const QString parent = info.absolutePath(); - if (!parent.isEmpty() && QFileInfo(parent).isDir()) - desired.push_back(parent); + if (snapshot) { + for (const GitDiffFile &file : snapshot->files) { + const QFileInfo info(file.absolutePath); + if (info.exists()) + desired.push_back(info.absoluteFilePath()); + const QString parent = info.absolutePath(); + if (!parent.isEmpty() && QFileInfo(parent).isDir()) + desired.push_back(parent); + } } desired.removeDuplicates(); const QStringList existing = fileWatcher->files() + fileWatcher->directories(); @@ -910,14 +885,15 @@ void DiffViewer::updateFileWatches() { void DiffViewer::showSelectedFile() { const int index = files->currentRow(); - if (index < 0 || static_cast(index) >= snapshot.files.size()) { + if (!snapshot || index < 0 || + static_cast(index) >= snapshot->files.size()) { selectedFile->setText(QStringLiteral("Select a changed file")); diff->clear(); return; } - const GitDiffFile &file = snapshot.files[static_cast(index)]; + const GitDiffFile &file = snapshot->files[static_cast(index)]; selectedFile->setText( - fileTitle(file, snapshot.repositoryRoots.size() > 1)); + fileTitle(file, snapshot->repositoryRoots.size() > 1)); diff->setPlainText(file.patch.isEmpty() ? QStringLiteral("No textual patch is available for this file.") : file.patch); diff --git a/src/codex/DiffViewer.h b/src/codex/DiffViewer.h index 0f3f20b..9ae85f6 100644 --- a/src/codex/DiffViewer.h +++ b/src/codex/DiffViewer.h @@ -9,6 +9,8 @@ #include #include +#include + class QComboBox; class QFileSystemWatcher; class QLabel; @@ -49,8 +51,7 @@ class DiffViewer final : public QWidget { QStringList changedPaths; QStringList persistedRepositoryRoots; QString selectedRepository; - GitDiffSnapshot snapshot; - QByteArray snapshotFingerprint; + std::optional snapshot; QComboBox *scope = nullptr; QComboBox *repositories = nullptr; QPushButton *hiddenRepositories = nullptr; diff --git a/src/codex/GitDiffProvider.h b/src/codex/GitDiffProvider.h index 2a19203..15953e6 100644 --- a/src/codex/GitDiffProvider.h +++ b/src/codex/GitDiffProvider.h @@ -27,6 +27,8 @@ struct GitDiffFile { int additions = 0; int deletions = 0; bool binary = false; + + bool operator==(const GitDiffFile &) const = default; }; struct GitDiffSnapshot { @@ -39,6 +41,8 @@ struct GitDiffSnapshot { std::vector files; bool repository = false; bool truncated = false; + + bool operator==(const GitDiffSnapshot &) const = default; }; class GitDiffProvider final : public QObject { diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index 88dc135..0419689 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -122,11 +122,6 @@ QFrame *statusDot() { return dot; } -std::string recoveryKey(const std::string &threadId, - std::uint64_t submissionId) { - return threadId + ':' + std::to_string(submissionId); -} - } // namespace struct ShellWidget::Impl final { @@ -138,6 +133,47 @@ struct ShellWidget::Impl final { Hydrated, Failed }; + struct ThreadRuntimeState { + Hydration hydration = Hydration::NotHydrated; + SettingsHydration settingsHydration = SettingsHydration::Unknown; + std::uint64_t readRevision = 0; + bool operationReady = false; + bool resumeInFlight = false; + bool dispatchScheduled = false; + std::unordered_set recoveryAttemptedSubmissions; + + void resetForConnection() noexcept { + hydration = Hydration::NotHydrated; + settingsHydration = SettingsHydration::Unknown; + readRevision = 0; + operationReady = false; + dispatchScheduled = false; + } + }; + struct SettingsUiSnapshot { + std::string identity; + nlohmann::json canonical; + nlohmann::json modelCatalog; + nlohmann::json permissionProfiles; + std::uint64_t settingsRevision = 0; + nlohmann::json settingsUpdate; + + bool operator==(const SettingsUiSnapshot &) const = default; + }; + struct StatusUiSnapshot { + bool connected = false; + bool retrying = false; + std::string role; + QString selectedTransport; + QString workspace; + QString threadContext; + QString agentActivity; + bool active = false; + std::size_t selectedPending = 0; + std::size_t totalPending = 0; + + bool operator==(const StatusUiSnapshot &) const = default; + }; struct HistoryWindow { std::size_t requested = middle::ConversationProjection::DefaultAuthoritativeItemLimit; @@ -174,6 +210,7 @@ struct ShellWidget::Impl final { void refreshStatus(); void hydrateHistoricalAgents(); void showNotice(QString message, bool error = true); + void resetRuntimeForConnection(); void selectThread(std::string threadId); void beginNewThread(); @@ -181,8 +218,6 @@ struct ShellWidget::Impl final { void ensureThreadHydrated(const std::string &threadId); void ensureThreadSettingsHydrated(const std::string &threadId); void resumeThreadForSettings(const std::string &threadId); - [[nodiscard]] bool threadIsHydrated(const std::string &threadId) const; - [[nodiscard]] bool threadRequiresResume(const std::string &threadId) const; void renameThread(const std::string &threadId); void forkThread(const std::string &threadId); void toggleThreadArchive(const std::string &threadId); @@ -221,20 +256,14 @@ struct ShellWidget::Impl final { QString newThreadName; QString newThreadWorkspace; - std::unordered_map hydration; - std::unordered_map settingsHydration; - std::unordered_map readRevisions; + std::unordered_map runtimeByThread; std::unordered_set staleReadResultCorrelations; std::uint64_t nextReadRevision = 1; - std::unordered_set operationReadyThreads; - std::unordered_set resumeInFlightThreads; - std::unordered_set dispatchScheduledThreads; - std::unordered_set promptRecoveryAttempted; std::unordered_map historyWindows; std::uint64_t observedConnectionGeneration = 0; std::uint64_t observedProviderGeneration = 0; - QByteArray settingsSnapshot; - QByteArray statusSnapshot; + std::optional settingsSnapshot; + std::optional statusSnapshot; bool renderScheduled = false; middle::MiddleRegionWidget *middleRegion = nullptr; @@ -380,7 +409,7 @@ void ShellWidget::Impl::connectUi() { selectThread(id); }; threadActions.reload = [this](const std::string &id) { - settingsHydration.erase(id); + runtimeByThread[id].settingsHydration = SettingsHydration::Unknown; readThread(id, true); ensureThreadSettingsHydrated(id); }; @@ -444,6 +473,13 @@ void ShellWidget::Impl::showNotice(QString message, bool error) { middleRegion->showNotice(std::move(message), error); } +void ShellWidget::Impl::resetRuntimeForConnection() { + for (auto &[threadId, runtime] : runtimeByThread) { + static_cast(threadId); + runtime.resetForConnection(); + } +} + void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { middleRegion->inspector().appendProtocolFrame(event); @@ -459,19 +495,11 @@ void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { const ConnectionPresentation &connection = model.connection(); if (connection.generation != observedConnectionGeneration) { observedConnectionGeneration = connection.generation; - hydration.clear(); - settingsHydration.clear(); - readRevisions.clear(); - operationReadyThreads.clear(); - dispatchScheduledThreads.clear(); + resetRuntimeForConnection(); } if (connection.providerGeneration != observedProviderGeneration) { observedProviderGeneration = connection.providerGeneration; - hydration.clear(); - settingsHydration.clear(); - readRevisions.clear(); - operationReadyThreads.clear(); - dispatchScheduledThreads.clear(); + resetRuntimeForConnection(); } const std::string type = stringValue(event, "type"); @@ -480,11 +508,7 @@ void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { const std::string eventThreadId = stringValue(scope, "threadId"); if (kind == "event" && type == "connection.provider" && stringValue(data, "state") == "disconnected") { - hydration.clear(); - settingsHydration.clear(); - readRevisions.clear(); - operationReadyThreads.clear(); - dispatchScheduledThreads.clear(); + resetRuntimeForConnection(); } if (kind == "result" && !event.value("ok", false) && action != "turn.start" && @@ -533,12 +557,7 @@ void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { if (type == "thread.removed" && !eventThreadId.empty()) { prompts.clearThread(eventThreadId); - hydration.erase(eventThreadId); - settingsHydration.erase(eventThreadId); - readRevisions.erase(eventThreadId); - operationReadyThreads.erase(eventThreadId); - resumeInFlightThreads.erase(eventThreadId); - dispatchScheduledThreads.erase(eventThreadId); + runtimeByThread.erase(eventThreadId); historyWindows.erase(eventThreadId); if (selectedThreadId == eventThreadId) { selectedThreadId.clear(); @@ -672,20 +691,20 @@ void ShellWidget::Impl::refreshSettings() { model.globalDomains().find("operation.permission-profiles.list"); if (found != model.globalDomains().end()) profiles = found->second; - const std::string serialized = nlohmann::json{ - {"identity", identity}, - {"canonical", canonical}, - {"settingsRevision", settingsRevision}, - {"models", model.modelCatalog()}, - {"profiles", profiles}}.dump(); - const QByteArray next(serialized.data(), - static_cast(serialized.size())); - if (next == settingsSnapshot) + SettingsUiSnapshot next{std::move(identity), + std::move(canonical), + model.modelCatalog(), + std::move(profiles), + settingsRevision, + std::move(settingsUpdate)}; + if (settingsSnapshot && *settingsSnapshot == next) return; - settingsSnapshot = next; + settingsSnapshot = std::move(next); + const SettingsUiSnapshot &snapshot = *settingsSnapshot; middleRegion->composer().turnSettings()->setContext( - identity, canonical, model.modelCatalog(), profiles, settingsRevision, - settingsUpdate); + snapshot.identity, snapshot.canonical, snapshot.modelCatalog, + snapshot.permissionProfiles, snapshot.settingsRevision, + snapshot.settingsUpdate); } void ShellWidget::Impl::refreshStatus() { @@ -707,41 +726,6 @@ void ShellWidget::Impl::refreshStatus() { return entry.second.threadId == selectedThreadId; })); const std::size_t totalPending = model.pendingRequestCount(); - const std::string serialized = nlohmann::json{ - {"connected", connection.connected}, - {"retrying", connection.retrying}, - {"role", connection.role}, - {"settings", connection.settings}, - {"selectedThreadId", selectedThreadId}, - {"newThreadIntent", newThreadIntent}, - {"newThreadWorkspace", newThreadWorkspace.toStdString()}, - {"threadTitle", thread ? thread->title : std::string{}}, - {"threadCwd", thread ? thread->cwd : std::string{}}, - {"threadStatus", thread ? thread->status : std::string{}}, - {"agentCount", thread ? thread->agents.size() : 0U}, - {"runningAgents", runningAgents}, - {"active", active}, - {"selectedPending", selectedPending}, - {"totalPending", totalPending}}.dump(); - const QByteArray next(serialized.data(), - static_cast(serialized.size())); - if (next == statusSnapshot) - return; - statusSnapshot = next; - QString dotStyle; - QString dotTip; - if (connection.connected) { - dotStyle = QStringLiteral("background:#18865e;border-radius:5px;"); - dotTip = QStringLiteral("Connected"); - } else if (connection.retrying) { - dotStyle = QStringLiteral("background:#a85d0c;border-radius:5px;"); - dotTip = QStringLiteral("Disconnected, retrying"); - } else { - dotStyle = QStringLiteral("background:#c43d4d;border-radius:5px;"); - dotTip = QStringLiteral("Disconnected"); - } - connectionStatusDot->setStyleSheet(dotStyle); - connectionStatusDot->setToolTip(dotTip); QString selectedTransport; const std::string selectedKey = stringValue(connection.settings, "selected"); const nlohmann::json available = @@ -754,58 +738,86 @@ void ShellWidget::Impl::refreshStatus() { } } } - connectionButton->setText(selectedTransport.isEmpty() - ? QStringLiteral("Connection") - : selectedTransport); - connectionButton->setToolTip( - connection.connected ? QStringLiteral("Connected bridge transport") - : QStringLiteral("Disconnected bridge transport")); - connectAction->setEnabled(!connection.connected); - disconnectAction->setEnabled(connection.connected); - reconnectAction->setEnabled(connection.connected); - controllerLabel->setText(connection.role.empty() ? QStringLiteral("No role") - : text(connection.role)); - controllerButton->setText(connection.role == "controller" - ? QStringLiteral("Release control") - : QStringLiteral("Claim control")); - controllerButton->setEnabled(connection.connected); - - requestButton->setText(QStringLiteral("Requests (%1)") - .arg(static_cast(totalPending))); - requestButton->setVisible(totalPending != 0); - middleRegion->composer().setAttentionVisible(selectedPending != 0); - QString workspace = QStringLiteral("No workspace"); + QString threadContext = QStringLiteral("No thread context"); + QString agentActivity = QStringLiteral("No agent activity"); if (thread) { workspace = text(thread->cwd); - threadContextStatus->setText( + threadContext = QStringLiteral("%1 | %2") .arg(text(thread->title), - text(classifyStatus(thread->status).text))); - agentActivityStatus->setText( - thread->agents.empty() - ? QStringLiteral("No agent activity") - : QStringLiteral("%1 agents | %2 active") - .arg(static_cast(thread->agents.size())) - .arg(static_cast(runningAgents))); + text(classifyStatus(thread->status).text)); + if (!thread->agents.empty()) { + agentActivity = QStringLiteral("%1 agents | %2 active") + .arg(static_cast(thread->agents.size())) + .arg(static_cast(runningAgents)); + } + } else if (newThreadIntent) { + workspace = text(middleRegion->composer().turnSettings()->workspace( + QDir::currentPath().toStdString())); + threadContext = QStringLiteral("New thread"); + } + StatusUiSnapshot next{connection.connected, + connection.retrying, + connection.role, + std::move(selectedTransport), + std::move(workspace), + std::move(threadContext), + std::move(agentActivity), + active, + selectedPending, + totalPending}; + if (statusSnapshot && *statusSnapshot == next) + return; + statusSnapshot = std::move(next); + const StatusUiSnapshot &snapshot = *statusSnapshot; + QString dotStyle; + QString dotTip; + if (snapshot.connected) { + dotStyle = QStringLiteral("background:#18865e;border-radius:5px;"); + dotTip = QStringLiteral("Connected"); + } else if (snapshot.retrying) { + dotStyle = QStringLiteral("background:#a85d0c;border-radius:5px;"); + dotTip = QStringLiteral("Disconnected, retrying"); } else { - if (newThreadIntent) - workspace = text(middleRegion->composer().turnSettings()->workspace( - QDir::currentPath().toStdString())); - threadContextStatus->setText(newThreadIntent - ? QStringLiteral("New thread") - : QStringLiteral("No thread context")); - agentActivityStatus->setText(QStringLiteral("No agent activity")); + dotStyle = QStringLiteral("background:#c43d4d;border-radius:5px;"); + dotTip = QStringLiteral("Disconnected"); } - workspaceBreadcrumb->setToolTip(workspace); + connectionStatusDot->setStyleSheet(dotStyle); + connectionStatusDot->setToolTip(dotTip); + connectionButton->setText(snapshot.selectedTransport.isEmpty() + ? QStringLiteral("Connection") + : snapshot.selectedTransport); + connectionButton->setToolTip( + snapshot.connected ? QStringLiteral("Connected bridge transport") + : QStringLiteral("Disconnected bridge transport")); + connectAction->setEnabled(!snapshot.connected); + disconnectAction->setEnabled(snapshot.connected); + reconnectAction->setEnabled(snapshot.connected); + controllerLabel->setText(snapshot.role.empty() ? QStringLiteral("No role") + : text(snapshot.role)); + controllerButton->setText(snapshot.role == "controller" + ? QStringLiteral("Release control") + : QStringLiteral("Claim control")); + controllerButton->setEnabled(snapshot.connected); + + requestButton->setText( + QStringLiteral("Requests (%1)") + .arg(static_cast(snapshot.totalPending))); + requestButton->setVisible(snapshot.totalPending != 0); + middleRegion->composer().setAttentionVisible(snapshot.selectedPending != 0); + + threadContextStatus->setText(snapshot.threadContext); + agentActivityStatus->setText(snapshot.agentActivity); + workspaceBreadcrumb->setToolTip(snapshot.workspace); workspaceBreadcrumb->setText(workspaceBreadcrumb->fontMetrics().elidedText( - workspace, Qt::ElideMiddle, workspaceBreadcrumb->maximumWidth())); + snapshot.workspace, Qt::ElideMiddle, + workspaceBreadcrumb->maximumWidth())); - const bool canSubmit = - connection.connected && connection.role == "controller"; - middleRegion->composer().setActiveTurn(active); + const bool canSubmit = snapshot.connected && snapshot.role == "controller"; + middleRegion->composer().setActiveTurn(snapshot.active); middleRegion->composer().setCanSubmit(canSubmit); - middleRegion->composer().setSettingsEnabled(canSubmit && !active); + middleRegion->composer().setSettingsEnabled(canSubmit && !snapshot.active); } void ShellWidget::Impl::hydrateHistoricalAgents() { @@ -869,7 +881,7 @@ void ShellWidget::Impl::beginNewThread() { draft.developerInstructions.toStdString(); if (draft.ephemeral) newThreadOptions["ephemeral"] = true; - settingsSnapshot.clear(); + settingsSnapshot.reset(); middleRegion->composer().clearDraft(); middleRegion->composer().turnSettings()->setWorkspace(draft.workspace); middleRegion->composer().promptEditor()->setFocus(); @@ -877,36 +889,37 @@ void ShellWidget::Impl::beginNewThread() { } void ShellWidget::Impl::readThread(const std::string &threadId, bool forced) { - if (threadId.empty() || resumeInFlightThreads.contains(threadId)) + if (threadId.empty()) + return; + ThreadRuntimeState &runtime = runtimeByThread[threadId]; + if (runtime.resumeInFlight) return; if (!forced) { - const auto existing = hydration.find(threadId); - if (existing != hydration.end() && - (existing->second == Hydration::InFlight || - existing->second == Hydration::Hydrated || - existing->second == Hydration::Failed)) + if (runtime.hydration == Hydration::InFlight || + runtime.hydration == Hydration::Hydrated || + runtime.hydration == Hydration::Failed) return; } - hydration[threadId] = Hydration::InFlight; + runtime.hydration = Hydration::InFlight; const auto token = alive; const std::uint64_t revision = nextReadRevision++; - readRevisions[threadId] = revision; + runtime.readRevision = revision; session.readThread(threadId, [this, token, threadId, revision](const nlohmann::json &result) { if (!*token) return; - const auto current = readRevisions.find(threadId); - if (current == readRevisions.end() || current->second != revision) { + const auto current = runtimeByThread.find(threadId); + if (current == runtimeByThread.end() || + current->second.readRevision != revision) { const std::string correlationId = stringValue(result, "correlationId"); if (!correlationId.empty()) staleReadResultCorrelations.insert(correlationId); return; } + ThreadRuntimeState &runtime = current->second; if (result.value("ok", false)) { - hydration[threadId] = Hydration::Hydrated; - const auto settings = settingsHydration.find(threadId); - if (settings != settingsHydration.end() && - settings->second == SettingsHydration::WaitingForRead) + runtime.hydration = Hydration::Hydrated; + if (runtime.settingsHydration == SettingsHydration::WaitingForRead) resumeThreadForSettings(threadId); QTimer::singleShot(0, owner, [this, threadId] { dispatchNextPrompt(threadId); }); @@ -915,11 +928,9 @@ void ShellWidget::Impl::readThread(const std::string &threadId, bool forced) { // A non-forced hydration is attempted once per connection generation. // Explicit Reload bypasses this terminal state, while a new generation // clears it together with the other hydration bookkeeping. - hydration[threadId] = Hydration::Failed; - const auto settings = settingsHydration.find(threadId); - if (settings != settingsHydration.end() && - settings->second == SettingsHydration::WaitingForRead) - settings->second = SettingsHydration::Failed; + runtime.hydration = Hydration::Failed; + if (runtime.settingsHydration == SettingsHydration::WaitingForRead) + runtime.settingsHydration = SettingsHydration::Failed; const std::string message = safeMessage(result.value("error", nlohmann::json::object())); const QString displayed = @@ -935,13 +946,13 @@ void ShellWidget::Impl::ensureThreadSettingsHydrated( if (threadId.empty() || !model.connection().connected || model.connection().role != "controller") return; - SettingsHydration &state = settingsHydration[threadId]; - if (state == SettingsHydration::WaitingForRead || - state == SettingsHydration::InFlight || - state == SettingsHydration::Hydrated) + ThreadRuntimeState &runtime = runtimeByThread[threadId]; + if (runtime.settingsHydration == SettingsHydration::WaitingForRead || + runtime.settingsHydration == SettingsHydration::InFlight || + runtime.settingsHydration == SettingsHydration::Hydrated) return; - if (!threadIsHydrated(threadId)) { - state = SettingsHydration::WaitingForRead; + if (runtime.hydration != Hydration::Hydrated) { + runtime.settingsHydration = SettingsHydration::WaitingForRead; ensureThreadHydrated(threadId); return; } @@ -950,17 +961,22 @@ void ShellWidget::Impl::ensureThreadSettingsHydrated( void ShellWidget::Impl::resumeThreadForSettings( const std::string &threadId) { - if (resumeInFlightThreads.contains(threadId)) + ThreadRuntimeState &runtime = runtimeByThread[threadId]; + if (runtime.resumeInFlight) return; - settingsHydration[threadId] = SettingsHydration::InFlight; + runtime.settingsHydration = SettingsHydration::InFlight; const auto token = alive; session.resumeThread( threadId, {{"excludeTurns", true}}, [this, token, threadId](const nlohmann::json &result) { if (!*token) return; + const auto found = runtimeByThread.find(threadId); + if (found == runtimeByThread.end()) + return; + ThreadRuntimeState &runtime = found->second; if (!result.value("ok", false)) { - settingsHydration[threadId] = SettingsHydration::Failed; + runtime.settingsHydration = SettingsHydration::Failed; if (selectedThreadId == threadId) { const std::string message = safeMessage(result.value("error", nlohmann::json::object())); @@ -969,9 +985,9 @@ void ShellWidget::Impl::resumeThreadForSettings( : message)); } } else { - settingsHydration[threadId] = SettingsHydration::Hydrated; - hydration[threadId] = Hydration::Hydrated; - operationReadyThreads.insert(threadId); + runtime.settingsHydration = SettingsHydration::Hydrated; + runtime.hydration = Hydration::Hydrated; + runtime.operationReady = true; } QTimer::singleShot(0, owner, [this, threadId] { dispatchNextPrompt(threadId); }); @@ -979,28 +995,16 @@ void ShellWidget::Impl::resumeThreadForSettings( } void ShellWidget::Impl::ensureThreadHydrated(const std::string &threadId) { - if (threadId.empty() || threadIsHydrated(threadId) || - !model.connection().connected) + if (threadId.empty() || !model.connection().connected) return; - const auto found = hydration.find(threadId); - if (found != hydration.end() && found->second == Hydration::InFlight) + const auto found = runtimeByThread.find(threadId); + if (found != runtimeByThread.end() && + (found->second.hydration == Hydration::Hydrated || + found->second.hydration == Hydration::InFlight)) return; readThread(threadId); } -bool ShellWidget::Impl::threadIsHydrated(const std::string &threadId) const { - const auto found = hydration.find(threadId); - return found != hydration.end() && found->second == Hydration::Hydrated; -} - -bool ShellWidget::Impl::threadRequiresResume( - const std::string &threadId) const { - if (operationReadyThreads.contains(threadId)) - return false; - const ThreadPresentation *thread = model.thread(threadId); - return thread && thread->status == "notLoaded"; -} - void ShellWidget::Impl::renameThread(const std::string &threadId) { const ThreadPresentation *thread = model.thread(threadId); if (!thread) @@ -1084,8 +1088,9 @@ bool ShellWidget::Impl::submitPrompt(QString prompt, } if (destination != DraftThreadId) { - const auto state = hydration.find(destination); - if (state != hydration.end() && state->second == Hydration::Failed) { + const auto runtime = runtimeByThread.find(destination); + if (runtime != runtimeByThread.end() && + runtime->second.hydration == Hydration::Failed) { showNotice(QStringLiteral("Thread loading failed. Reload the thread " "before sending; your message was not sent.")); middleRegion->composer().promptEditor()->setFocus(); @@ -1169,9 +1174,10 @@ void ShellWidget::Impl::startThreadForDraft() { render(); return; } - hydration[threadId] = Hydration::Hydrated; - settingsHydration[threadId] = SettingsHydration::Hydrated; - operationReadyThreads.insert(threadId); + ThreadRuntimeState &runtime = runtimeByThread[threadId]; + runtime.hydration = Hydration::Hydrated; + runtime.settingsHydration = SettingsHydration::Hydrated; + runtime.operationReady = true; const bool viewingDraft = selectedThreadId.empty() && newThreadIntent; if (viewingDraft) { selectedThreadId = threadId; @@ -1180,7 +1186,7 @@ void ShellWidget::Impl::startThreadForDraft() { newThreadOptions = nlohmann::json::object(); newThreadName.clear(); newThreadWorkspace.clear(); - settingsSnapshot.clear(); + settingsSnapshot.reset(); if (!requestedName.isEmpty()) session.renameThread(threadId, requestedName.toStdString()); render(); @@ -1192,9 +1198,9 @@ void ShellWidget::Impl::startThreadForDraft() { void ShellWidget::Impl::dispatchNextPrompt(const std::string &threadId) { if (threadId.empty() || !model.connection().connected) return; - const auto settings = settingsHydration.find(threadId); - if (settings != settingsHydration.end() && - settings->second == SettingsHydration::InFlight) + auto runtime = runtimeByThread.find(threadId); + if (runtime != runtimeByThread.end() && + runtime->second.settingsHydration == SettingsHydration::InFlight) return; const auto submissions = prompts.submissions(threadId); if (std::ranges::none_of( @@ -1202,33 +1208,42 @@ void ShellWidget::Impl::dispatchNextPrompt(const std::string &threadId) { return submission.state == middle::PromptState::Queued; })) return; - if (resumeInFlightThreads.contains(threadId)) + if (runtime != runtimeByThread.end() && runtime->second.resumeInFlight) return; - if (!threadIsHydrated(threadId)) { + if (runtime == runtimeByThread.end() || + runtime->second.hydration != Hydration::Hydrated) { ensureThreadHydrated(threadId); return; } if (prompts.hasInFlight(threadId)) return; - if (threadRequiresResume(threadId)) { + const ThreadPresentation *thread = model.thread(threadId); + if (!runtime->second.operationReady && thread && + thread->status == "notLoaded") { resumePromptQueue(threadId); return; } - if (!dispatchScheduledThreads.insert(threadId).second) + if (runtime->second.dispatchScheduled) return; + runtime->second.dispatchScheduled = true; // The admitted card already presents the awaiting state. Queueing transport // gives Qt one normal paint turn, then samples start-versus-steer at the // actual send boundary without a forced repaint or reentrant event drain. const std::uint64_t generation = observedConnectionGeneration; QTimer::singleShot(0, owner, [this, threadId, generation] { - dispatchScheduledThreads.erase(threadId); + const auto runtime = runtimeByThread.find(threadId); + if (runtime == runtimeByThread.end()) + return; + runtime->second.dispatchScheduled = false; if (observedConnectionGeneration != generation) return; - if (!model.connection().connected || - resumeInFlightThreads.contains(threadId)) + if (!model.connection().connected || runtime->second.resumeInFlight) return; - if (!threadIsHydrated(threadId) || threadRequiresResume(threadId)) { + const ThreadPresentation *thread = model.thread(threadId); + if (runtime->second.hydration != Hydration::Hydrated || + (!runtime->second.operationReady && thread && + thread->status == "notLoaded")) { dispatchNextPrompt(threadId); return; } @@ -1276,17 +1291,23 @@ void ShellWidget::Impl::dispatchPrompt(middle::PromptDispatch dispatch) { } void ShellWidget::Impl::resumePromptQueue(const std::string &threadId) { - if (!resumeInFlightThreads.insert(threadId).second) + ThreadRuntimeState &runtime = runtimeByThread[threadId]; + if (runtime.resumeInFlight) return; + runtime.resumeInFlight = true; const auto token = alive; session.resumeThread( threadId, nlohmann::json::object(), [this, token, threadId](const nlohmann::json &result) { if (!*token) return; - resumeInFlightThreads.erase(threadId); + const auto found = runtimeByThread.find(threadId); + if (found == runtimeByThread.end()) + return; + ThreadRuntimeState &runtime = found->second; + runtime.resumeInFlight = false; if (!result.value("ok", false)) { - settingsHydration[threadId] = SettingsHydration::Failed; + runtime.settingsHydration = SettingsHydration::Failed; const std::string message = safeMessage(result.value("error", nlohmann::json::object())); const QString displayed = text( @@ -1296,9 +1317,9 @@ void ShellWidget::Impl::resumePromptQueue(const std::string &threadId) { render(); return; } - hydration[threadId] = Hydration::Hydrated; - settingsHydration[threadId] = SettingsHydration::Hydrated; - operationReadyThreads.insert(threadId); + runtime.hydration = Hydration::Hydrated; + runtime.settingsHydration = SettingsHydration::Hydrated; + runtime.operationReady = true; QTimer::singleShot(0, owner, [this, threadId] { dispatchNextPrompt(threadId); }); }); @@ -1309,9 +1330,12 @@ void ShellWidget::Impl::completePrompt(const std::string &threadId, const nlohmann::json &result) { if (attemptThreadRecovery(threadId, submissionId, result)) return; - promptRecoveryAttempted.erase(recoveryKey(threadId, submissionId)); + const auto runtime = runtimeByThread.find(threadId); + if (runtime != runtimeByThread.end()) + runtime->second.recoveryAttemptedSubmissions.erase(submissionId); if (result.value("ok", false)) { - operationReadyThreads.insert(threadId); + if (runtime != runtimeByThread.end()) + runtime->second.operationReady = true; static_cast(prompts.acknowledge(threadId, submissionId, resultTurnId(result), QDateTime::currentMSecsSinceEpoch())); @@ -1335,24 +1359,31 @@ bool ShellWidget::Impl::attemptThreadRecovery(const std::string &threadId, const nlohmann::json &result) { if (!isThreadNotFoundResult(result)) return false; - const std::string key = recoveryKey(threadId, submissionId); - if (!promptRecoveryAttempted.insert(key).second) + const auto found = runtimeByThread.find(threadId); + if (found == runtimeByThread.end()) + return false; + ThreadRuntimeState &runtime = found->second; + if (!runtime.recoveryAttemptedSubmissions.insert(submissionId).second) return false; if (!prompts.requeue(threadId, submissionId)) return false; - hydration[threadId] = Hydration::NotHydrated; - operationReadyThreads.erase(threadId); + runtime.hydration = Hydration::NotHydrated; + runtime.operationReady = false; render(); - resumeInFlightThreads.insert(threadId); + runtime.resumeInFlight = true; const auto token = alive; session.resumeThread( threadId, nlohmann::json::object(), [this, token, threadId](const nlohmann::json &resumeResult) { if (!*token) return; - resumeInFlightThreads.erase(threadId); + const auto found = runtimeByThread.find(threadId); + if (found == runtimeByThread.end()) + return; + ThreadRuntimeState &runtime = found->second; + runtime.resumeInFlight = false; if (!resumeResult.value("ok", false)) { - settingsHydration[threadId] = SettingsHydration::Failed; + runtime.settingsHydration = SettingsHydration::Failed; const std::string message = safeMessage( resumeResult.value("error", nlohmann::json::object())); const QString displayed = @@ -1363,9 +1394,9 @@ bool ShellWidget::Impl::attemptThreadRecovery(const std::string &threadId, render(); return; } - hydration[threadId] = Hydration::Hydrated; - settingsHydration[threadId] = SettingsHydration::Hydrated; - operationReadyThreads.insert(threadId); + runtime.hydration = Hydration::Hydrated; + runtime.settingsHydration = SettingsHydration::Hydrated; + runtime.operationReady = true; QTimer::singleShot(0, owner, [this, threadId] { dispatchNextPrompt(threadId); }); }); diff --git a/src/codex/middle/InspectorPane.cpp b/src/codex/middle/InspectorPane.cpp index f8da57f..2e69637 100644 --- a/src/codex/middle/InspectorPane.cpp +++ b/src/codex/middle/InspectorPane.cpp @@ -22,7 +22,6 @@ #include #include -#include #include #include @@ -46,17 +45,6 @@ QStringList texts(const std::vector &values) { return result; } -QString joinedStrings(const nlohmann::json &value) { - if (!value.is_array()) - return {}; - QStringList result; - for (const auto &item : value) { - if (item.is_string()) - result.push_back(text(item.get())); - } - return result.join(QStringLiteral(", ")); -} - std::string stringValue(const nlohmann::json &object, const char *key) { if (!object.is_object()) return {}; @@ -114,12 +102,6 @@ void clearLayout(QLayout *layout) { } } -QByteArray bytes(const nlohmann::json &value) { - const std::string serialized = value.dump(); - return QByteArray(serialized.data(), - static_cast(serialized.size())); -} - class InfoChoiceButton final : public QPushButton { protected: void paintEvent(QPaintEvent *event) override { @@ -137,57 +119,6 @@ class InfoChoiceButton final : public QPushButton { } }; -QFrame *agentFrame(const AgentPresentation &agent) { - auto *frame = new QFrame; - frame->setProperty("kind", "raised"); - auto *layout = new QVBoxLayout(frame); - layout->setContentsMargins(12, 10, 12, 10); - layout->setSpacing(6); - const std::string tool = stringValue(agent.raw, "tool"); - const QString title = - !agent.childThreadId.empty() ? QStringLiteral("Subagent") - : tool.empty() ? QStringLiteral("Agent activity") - : QStringLiteral("Agent %1").arg(text(tool)); - layout->addWidget(makeLabel(title, "title")); - QStringList metadata; - for (const char *key : {"agentPath", "tool", "model", "reasoningEffort"}) { - const QString value = text(stringValue(agent.raw, key)); - if (!value.isEmpty()) - metadata << value; - } - auto *metadataRow = new QHBoxLayout; - metadataRow->setContentsMargins(0, 0, 0, 0); - metadataRow->setSpacing(6); - metadataRow->addWidget(statusLabel(agent.status)); - if (!metadata.isEmpty()) - metadataRow->addWidget( - makeLabel(QStringLiteral("| ") + - metadata.join(QStringLiteral(" | ")), - "meta")); - metadataRow->addStretch(); - layout->addLayout(metadataRow); - const QString prompt = text(stringValue(agent.raw, "prompt")); - if (!prompt.isEmpty()) - layout->addWidget(makeLabel(prompt)); - const QString result = text(stringValue(agent.raw, "resultText")); - if (!result.isEmpty()) - layout->addWidget(makeMarkdownLabel(result)); - QStringList identities; - if (!agent.childThreadId.empty()) - identities << QStringLiteral("thread %1").arg(text(agent.childThreadId)); - const QString sender = text(stringValue(agent.raw, "senderThreadId")); - if (!sender.isEmpty()) - identities << QStringLiteral("sender %1").arg(sender); - const QString receivers = joinedStrings( - agent.raw.value("receiverThreadIds", nlohmann::json::array())); - if (!receivers.isEmpty()) - identities << QStringLiteral("receivers %1").arg(receivers); - if (!identities.isEmpty()) - layout->addWidget( - makeLabel(identities.join(QStringLiteral(" | ")), "meta")); - return frame; -} - QPushButton *infoChoice(const QString &title, const QString &description) { auto *button = new InfoChoiceButton; button->setProperty("kind", "infoChoice"); @@ -247,6 +178,58 @@ void restoreScrollPosition(QPlainTextEdit *view, } // namespace +QFrame *InspectorPane::agentFrame(const AgentSnapshot &agent) { + auto *frame = new QFrame; + frame->setProperty("kind", "raised"); + 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); + } + auto *metadataRow = new QHBoxLayout; + metadataRow->setContentsMargins(0, 0, 0, 0); + metadataRow->setSpacing(6); + metadataRow->addWidget(statusLabel(agent.status)); + if (!metadata.isEmpty()) + metadataRow->addWidget( + makeLabel(QStringLiteral("| ") + + metadata.join(QStringLiteral(" | ")), + "meta")); + metadataRow->addStretch(); + layout->addLayout(metadataRow); + if (!agent.prompt.empty()) + layout->addWidget(makeLabel(text(agent.prompt))); + if (!agent.resultText.empty()) { + auto *result = makeMarkdownLabel(text(agent.resultText)); + result->setObjectName(QStringLiteral("agentResult")); + result->setAlignment(Qt::AlignLeft | Qt::AlignTop); + layout->addWidget(result); + } + QStringList identities; + if (!agent.childThreadId.empty()) + identities << QStringLiteral("thread %1").arg(text(agent.childThreadId)); + if (!agent.senderThreadId.empty()) + identities << QStringLiteral("sender %1").arg(text(agent.senderThreadId)); + const QString receivers = + texts(agent.receiverThreadIds).join(QStringLiteral(", ")); + if (!receivers.isEmpty()) + identities << QStringLiteral("receivers %1").arg(receivers); + if (!identities.isEmpty()) + layout->addWidget( + makeLabel(identities.join(QStringLiteral(" | ")), "meta")); + return frame; +} + InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { setObjectName(QStringLiteral("inspector")); setStyleSheet(QStringLiteral("QFrame#inspector{background:#fbfcfe;}")); @@ -437,9 +420,9 @@ void InspectorPane::refreshCurrentTab() { void InspectorPane::refreshPlan() { const ThreadPresentation *thread = currentModel->thread(currentThreadId); - nlohmann::json snapshot{{"threadId", currentThreadId}}; - const TurnPresentation *planTurn = nullptr; - const ItemPresentation *planItem = nullptr; + PlanSnapshot next; + next.threadId = currentThreadId; + next.threadPresent = thread != nullptr; if (thread) { for (auto id = thread->turnOrder.rbegin(); id != thread->turnOrder.rend(); ++id) { @@ -448,15 +431,14 @@ void InspectorPane::refreshPlan() { continue; if (turn->second.plan.is_object() && turn->second.plan.contains("steps")) { - planTurn = &turn->second; - snapshot["plan"]["explanation"] = - stringValue(planTurn->plan, "explanation"); - snapshot["plan"]["steps"] = nlohmann::json::array(); + PlanContentSnapshot plan; + plan.explanation = stringValue(turn->second.plan, "explanation"); for (const auto &step : - planTurn->plan.value("steps", nlohmann::json::array())) - snapshot["plan"]["steps"].push_back( - {{"step", stringValue(step, "step")}, - {"status", stringValue(step, "status")}}); + turn->second.plan.value("steps", nlohmann::json::array())) { + plan.steps.push_back( + {stringValue(step, "step"), stringValue(step, "status")}); + } + next.plan = std::move(plan); break; } for (auto itemId = turn->second.itemOrder.rbegin(); @@ -464,42 +446,39 @@ void InspectorPane::refreshPlan() { const auto item = turn->second.items.find(*itemId); if (item != turn->second.items.end() && stringValue(item->second.raw, "type") == "plan") { - planItem = &item->second; - snapshot["planItem"] = stringValue(planItem->raw, "text"); + next.planItem = stringValue(item->second.raw, "text"); break; } } - if (planItem) + if (next.planItem) break; } } - const QByteArray next = bytes(snapshot); - if (next == planSnapshot) + if (planSnapshot && *planSnapshot == next) return; - planSnapshot = next; + planSnapshot = std::move(next); + const PlanSnapshot &snapshot = *planSnapshot; setUpdatesEnabled(false); clearLayout(planLayout); - if (!thread) { + if (!snapshot.threadPresent) { planLayout->addWidget( makeLabel(QStringLiteral("No selected thread."), "muted")); - } else if (planTurn) { - const QString explanation = - text(stringValue(planTurn->plan, "explanation")); + } else if (snapshot.plan) { + const QString explanation = text(snapshot.plan->explanation); if (!explanation.isEmpty()) planLayout->addWidget(makeMarkdownLabel(explanation)); - for (const auto &step : - planTurn->plan.value("steps", nlohmann::json::array())) { + for (const PlanStepSnapshot &step : snapshot.plan->steps) { auto *row = new QFrame; row->setProperty("kind", "raised"); auto *layout = new QVBoxLayout(row); layout->setContentsMargins(12, 10, 12, 10); layout->setSpacing(6); - layout->addWidget(makeLabel(text(stringValue(step, "step")))); - layout->addWidget(statusLabel(stringValue(step, "status"))); + layout->addWidget(makeLabel(text(step.step))); + layout->addWidget(statusLabel(step.status)); planLayout->addWidget(row); } - } else if (planItem) { - const QString value = text(stringValue(planItem->raw, "text")); + } else if (snapshot.planItem) { + const QString value = text(*snapshot.planItem); planLayout->addWidget( value.isEmpty() ? makeLabel(QStringLiteral("Plan is being prepared."), "muted") @@ -514,48 +493,54 @@ void InspectorPane::refreshPlan() { void InspectorPane::refreshAgents() { const ThreadPresentation *thread = currentModel->thread(currentThreadId); - nlohmann::json snapshot = nlohmann::json::array(); + AgentsSnapshot next; + next.threadId = currentThreadId; + next.threadPresent = thread != nullptr; if (thread) { + next.agents.reserve(thread->agentOrder.size()); for (const std::string &id : thread->agentOrder) { const auto agent = thread->agents.find(id); - if (agent != thread->agents.end()) - snapshot.push_back( - {{"id", id}, - {"status", agent->second.status}, - {"childThreadId", agent->second.childThreadId}, - {"agentPath", stringValue(agent->second.raw, "agentPath")}, - {"tool", stringValue(agent->second.raw, "tool")}, - {"model", stringValue(agent->second.raw, "model")}, - {"reasoningEffort", - stringValue(agent->second.raw, "reasoningEffort")}, - {"prompt", stringValue(agent->second.raw, "prompt")}, - {"resultText", stringValue(agent->second.raw, "resultText")}, - {"senderThreadId", - stringValue(agent->second.raw, "senderThreadId")}, - {"receiverThreadIds", - agent->second.raw.value("receiverThreadIds", - nlohmann::json::array())}}); + if (agent == thread->agents.end()) + continue; + AgentSnapshot snapshot; + snapshot.id = id; + snapshot.status = agent->second.status; + snapshot.childThreadId = agent->second.childThreadId; + snapshot.agentPath = stringValue(agent->second.raw, "agentPath"); + snapshot.tool = stringValue(agent->second.raw, "tool"); + snapshot.model = stringValue(agent->second.raw, "model"); + snapshot.reasoningEffort = + stringValue(agent->second.raw, "reasoningEffort"); + snapshot.prompt = stringValue(agent->second.raw, "prompt"); + snapshot.resultText = stringValue(agent->second.raw, "resultText"); + snapshot.senderThreadId = + stringValue(agent->second.raw, "senderThreadId"); + const auto receivers = agent->second.raw.find("receiverThreadIds"); + if (receivers != agent->second.raw.end() && receivers->is_array()) { + for (const auto &receiver : *receivers) { + if (receiver.is_string()) + snapshot.receiverThreadIds.push_back( + receiver.get()); + } + } + next.agents.push_back(std::move(snapshot)); } } - const QByteArray next = - bytes({{"threadId", currentThreadId}, {"agents", snapshot}}); - if (next == agentsSnapshot) + if (agentsSnapshot && *agentsSnapshot == next) return; - agentsSnapshot = next; + agentsSnapshot = std::move(next); + const AgentsSnapshot &snapshot = *agentsSnapshot; setUpdatesEnabled(false); clearLayout(agentsLayout); - if (!thread) + if (!snapshot.threadPresent) agentsLayout->addWidget( makeLabel(QStringLiteral("No selected thread."), "muted")); - else if (snapshot.empty()) + else if (snapshot.agents.empty()) agentsLayout->addWidget(makeLabel( QStringLiteral("No agent activity for this thread."), "muted")); else - for (const std::string &id : thread->agentOrder) { - const auto agent = thread->agents.find(id); - if (agent != thread->agents.end()) - agentsLayout->addWidget(agentFrame(agent->second)); - } + for (const AgentSnapshot &agent : snapshot.agents) + agentsLayout->addWidget(agentFrame(agent)); agentsLayout->addStretch(); setUpdatesEnabled(true); } @@ -570,29 +555,34 @@ void InspectorPane::refreshChanges() { } void InspectorPane::refreshRequests() { - nlohmann::json snapshot = nlohmann::json::array(); + std::vector next; + next.reserve(currentModel->pendingRequestCount()); for (const auto &[id, request] : currentModel->pendingRequestPresentations()) { - const nlohmann::json questions = - request.raw.value("questions", nlohmann::json::array()); - snapshot.push_back( - {{"id", id}, - {"kind", request.kind}, - {"threadId", request.threadId}, - {"generation", request.generation}, - {"command", stringValue(request.raw, "command")}, - {"reason", stringValue(request.raw, "reason")}, - {"message", stringValue(request.raw, "message")}, - {"questionCount", questions.is_array() ? questions.size() : 0U}}); + RequestSnapshot snapshot; + snapshot.id = id; + snapshot.kind = request.kind; + snapshot.threadContext = request.threadId; + if (const ThreadPresentation *thread = + currentModel->thread(request.threadId); + thread && !thread->title.empty()) + snapshot.threadContext = thread->title; + snapshot.generation = request.generation; + snapshot.command = stringValue(request.raw, "command"); + snapshot.reason = stringValue(request.raw, "reason"); + snapshot.message = stringValue(request.raw, "message"); + const auto questions = request.raw.find("questions"); + if (questions != request.raw.end() && questions->is_array()) + snapshot.questionCount = questions->size(); + next.push_back(std::move(snapshot)); } - const QByteArray next = bytes(snapshot); - if (next == requestsSnapshot) + if (requestsSnapshot && *requestsSnapshot == next) return; - requestsSnapshot = next; + requestsSnapshot = std::move(next); + const std::vector &snapshot = *requestsSnapshot; setUpdatesEnabled(false); clearLayout(requestsLayout); - for (const auto &[id, request] : - currentModel->pendingRequestPresentations()) { + for (const RequestSnapshot &request : snapshot) { auto *frame = new QFrame; frame->setProperty("kind", "raised"); frame->setProperty("tone", "warning"); @@ -600,32 +590,26 @@ void InspectorPane::refreshRequests() { layout->setContentsMargins(12, 10, 12, 10); layout->setSpacing(6); layout->addWidget(makeLabel(text(request.kind), "title")); - QString threadContext = text(request.threadId); - if (const ThreadPresentation *thread = - currentModel->thread(request.threadId); - thread && !thread->title.empty()) - threadContext = text(thread->title); layout->addWidget( makeLabel(QStringLiteral("thread %1 | generation %2 | request %3") - .arg(threadContext) + .arg(text(request.threadContext)) .arg(static_cast(request.generation)) - .arg(text(id)), + .arg(text(request.id)), "meta")); - for (const auto &[key, prefix] : - std::array, 3>{ - {{"command", "Command: "}, - {"reason", "Reason: "}, - {"message", ""}}}) { - const QString value = text(stringValue(request.raw, key)); - if (!value.isEmpty()) + const auto addMetadata = [layout](const std::string &value, + const char *prefix) { + const QString displayed = text(value); + if (!displayed.isEmpty()) layout->addWidget( - makeLabel(QString::fromLatin1(prefix) + value, "meta")); - } - const auto questions = request.raw.find("questions"); - if (questions != request.raw.end() && questions->is_array()) + makeLabel(QString::fromLatin1(prefix) + displayed, "meta")); + }; + addMetadata(request.command, "Command: "); + addMetadata(request.reason, "Reason: "); + addMetadata(request.message, ""); + if (request.questionCount) layout->addWidget( makeLabel(QStringLiteral("%1 questions") - .arg(static_cast(questions->size())), + .arg(static_cast(*request.questionCount)), "meta")); auto *actions = new QHBoxLayout; actions->setContentsMargins(0, 2, 0, 0); @@ -635,11 +619,11 @@ void InspectorPane::refreshRequests() { review->setProperty("kind", "request"); deny->setFixedHeight(28); review->setFixedHeight(28); - connect(deny, &QPushButton::clicked, this, [this, id] { + connect(deny, &QPushButton::clicked, this, [this, id = request.id] { if (rejectRequest) rejectRequest(id); }); - connect(review, &QPushButton::clicked, this, [this, id] { + connect(review, &QPushButton::clicked, this, [this, id = request.id] { if (reviewRequest) reviewRequest(id); }); diff --git a/src/codex/middle/InspectorPane.h b/src/codex/middle/InspectorPane.h index 34757ea..b151bd6 100644 --- a/src/codex/middle/InspectorPane.h +++ b/src/codex/middle/InspectorPane.h @@ -9,10 +9,13 @@ #include +#include #include #include #include +#include #include +#include class QLabel; class QPlainTextEdit; @@ -45,6 +48,62 @@ class InspectorPane final : public QFrame { [[nodiscard]] QTabWidget *tabs() const noexcept { return inspectorTabs; } private: + struct PlanStepSnapshot { + std::string step; + std::string status; + + bool operator==(const PlanStepSnapshot &) const = default; + }; + struct PlanContentSnapshot { + std::string explanation; + std::vector steps; + + bool operator==(const PlanContentSnapshot &) const = default; + }; + struct PlanSnapshot { + std::string threadId; + bool threadPresent = false; + std::optional plan; + std::optional planItem; + + bool operator==(const PlanSnapshot &) const = default; + }; + struct AgentSnapshot { + std::string id; + std::string status; + std::string childThreadId; + std::string agentPath; + std::string tool; + std::string model; + std::string reasoningEffort; + std::string prompt; + std::string resultText; + std::string senderThreadId; + std::vector receiverThreadIds; + + bool operator==(const AgentSnapshot &) const = default; + }; + struct AgentsSnapshot { + std::string threadId; + bool threadPresent = false; + std::vector agents; + + bool operator==(const AgentsSnapshot &) const = default; + }; + struct RequestSnapshot { + std::string id; + std::string kind; + std::string threadContext; + std::uint64_t generation = 0; + std::string command; + std::string reason; + std::string message; + std::optional questionCount; + + bool operator==(const RequestSnapshot &) const = default; + }; + + static QFrame *agentFrame(const AgentSnapshot &agent); void refreshCurrentTab(); void refreshPlan(); void refreshAgents(); @@ -74,9 +133,9 @@ class InspectorPane final : public QFrame { QPlainTextEdit *protocolLog = nullptr; QLabel *protocolStats = nullptr; - QByteArray planSnapshot; - QByteArray agentsSnapshot; - QByteArray requestsSnapshot; + std::optional planSnapshot; + std::optional agentsSnapshot; + std::optional> requestsSnapshot; QByteArray stateSnapshot; QByteArray protocolStatsSnapshot; std::deque protocolLines; diff --git a/src/codex/middle/ThreadPane.cpp b/src/codex/middle/ThreadPane.cpp index 2ef7ffe..2fb1768 100644 --- a/src/codex/middle/ThreadPane.cpp +++ b/src/codex/middle/ThreadPane.cpp @@ -79,18 +79,19 @@ QFrame *statusDot() { return dot; } -void updateRow(QWidget *row, const ThreadPresentation &thread, - std::size_t requestCount) { +void updateRow(QWidget *row, const std::string &threadId, + const std::string &threadTitle, + const std::string &threadStatus, std::size_t requestCount) { auto *title = row->findChild(QStringLiteral("threadTitle")); auto *status = row->findChild(QStringLiteral("threadStatus")); auto *dot = row->findChild(QStringLiteral("threadStatusDot")); - QString titleText = text(thread.title); + QString titleText = text(threadTitle); if (titleText.isEmpty()) - titleText = text(thread.id.substr(0, 12)); + titleText = text(threadId.substr(0, 12)); if (requestCount != 0) titleText.prepend(QStringLiteral("! ")); title->setText(titleText); - const PresentationStatus classified = classifyStatus(thread.status); + const PresentationStatus classified = classifyStatus(threadStatus); status->setText(text(classified.text)); const QString tone = requestCount != 0 ? QStringLiteral("warning") : text(classified.tone); @@ -267,7 +268,7 @@ void ThreadPane::setSortCriterion(SortCriterion criterion) { return; sortCriterion = criterion; updateSortButton(); - visibleSnapshot.clear(); + visibleSnapshot.reset(); if (currentModel) refresh(*currentModel, projectedSelectedThreadId); } @@ -389,26 +390,19 @@ void ThreadPane::refresh(const PresentationModel &model, return found == pendingByThread.end() ? std::size_t{} : found->second; }; - nlohmann::json visible = nlohmann::json::array(); + ThreadPaneSnapshot next{selectedThreadId, sortCriterion, {}}; + next.rows.reserve(visibleOrder.size()); for (const std::string &id : visibleOrder) { const ThreadPresentation *thread = model.thread(id); if (!thread) continue; - visible.push_back({{"id", id}, - {"title", thread->title}, - {"cwd", thread->cwd}, - {"status", thread->status}, - {"pending", pendingCount(id)}}); + next.rows.push_back({id, thread->title, thread->cwd, thread->status, + pendingCount(id)}); } - const std::string serialized = nlohmann::json{ - {"selected", selectedThreadId}, - {"sort", static_cast(sortCriterion)}, - {"rows", visible}}.dump(); - const QByteArray next(serialized.data(), - static_cast(serialized.size())); - if (next == visibleSnapshot) + if (visibleSnapshot && *visibleSnapshot == next) return; - visibleSnapshot = next; + visibleSnapshot = std::move(next); + const ThreadPaneSnapshot &snapshot = *visibleSnapshot; list->blockSignals(true); list->setUpdatesEnabled(false); // Selection is a projection of selectedThreadId, never retained widget @@ -417,8 +411,10 @@ void ThreadPane::refresh(const PresentationModel &model, list->clearSelection(); list->setCurrentRow(-1); - const std::unordered_set wanted(visibleOrder.begin(), - visibleOrder.end()); + std::unordered_set wanted; + wanted.reserve(snapshot.rows.size()); + for (const ThreadRowSnapshot &row : snapshot.rows) + wanted.insert(row.id); for (int index = list->count() - 1; index >= 0; --index) { QListWidgetItem *item = list->item(index); const std::string id = @@ -432,9 +428,9 @@ void ThreadPane::refresh(const PresentationModel &model, std::unordered_map existingPositions; existingPositions.reserve(rows.size()); int existingIndex = 0; - for (const std::string &id : visibleOrder) { - if (rows.contains(id)) - existingPositions.emplace(id, existingIndex++); + for (const ThreadRowSnapshot &row : snapshot.rows) { + if (rows.contains(row.id)) + existingPositions.emplace(row.id, existingIndex++); } std::unordered_set moved; @@ -452,26 +448,26 @@ void ThreadPane::refresh(const PresentationModel &model, moved.insert(id); } int wantedIndex = 0; - for (const std::string &id : visibleOrder) { - auto found = rows.find(id); + for (const ThreadRowSnapshot &row : snapshot.rows) { + auto found = rows.find(row.id); if (found == rows.end()) { auto *item = new QListWidgetItem; item->setSizeHint(QSize(0, 54)); - item->setData(Qt::UserRole, text(id)); + item->setData(Qt::UserRole, text(row.id)); list->insertItem(wantedIndex, item); list->setItemWidget(item, createRow()); - found = rows.emplace(id, item).first; - } else if (moved.contains(id)) { + found = rows.emplace(row.id, item).first; + } else if (moved.contains(row.id)) { list->insertItem(wantedIndex, found->second); list->setItemWidget(found->second, createRow()); } QListWidgetItem *item = found->second; - const ThreadPresentation *thread = model.thread(id); - item->setToolTip(text(thread->cwd)); - updateRow(list->itemWidget(item), *thread, pendingCount(id)); - if (id == contextThreadId) - setContextHighlight(id, true); - if (id == selectedThreadId) + item->setToolTip(text(row.cwd)); + updateRow(list->itemWidget(item), row.id, row.title, row.status, + row.pending); + if (row.id == contextThreadId) + setContextHighlight(row.id, true); + if (row.id == snapshot.selectedThreadId) list->setCurrentItem(item); ++wantedIndex; } diff --git a/src/codex/middle/ThreadPane.h b/src/codex/middle/ThreadPane.h index 9e56d36..f17db42 100644 --- a/src/codex/middle/ThreadPane.h +++ b/src/codex/middle/ThreadPane.h @@ -3,10 +3,11 @@ #ifndef CODEXUI_CODEX_MIDDLE_THREADPANE_H #define CODEXUI_CODEX_MIDDLE_THREADPANE_H -#include #include +#include #include +#include #include #include #include @@ -47,6 +48,23 @@ class ThreadPane final : public QFrame { [[nodiscard]] std::string visiblySelectedThreadId() const; private: + struct ThreadRowSnapshot { + std::string id; + std::string title; + std::string cwd; + std::string status; + std::size_t pending = 0; + + bool operator==(const ThreadRowSnapshot &) const = default; + }; + struct ThreadPaneSnapshot { + std::string selectedThreadId; + SortCriterion sortCriterion = SortCriterion::Recency; + std::vector rows; + + bool operator==(const ThreadPaneSnapshot &) const = default; + }; + void updateSortButton(); void sortVisibleThreads(std::vector &ids, const PresentationModel &model) const; @@ -63,7 +81,7 @@ class ThreadPane final : public QFrame { std::string projectedSelectedThreadId; std::string contextThreadId; QMenu *contextMenu = nullptr; - QByteArray visibleSnapshot; + std::optional visibleSnapshot; }; } // namespace middle diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index 0abfff0..b76589c 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -923,7 +923,8 @@ bool testInfoViewerLayout() { bool testInspectorDetailParity() { PresentationModel model; model.applyEvent(presentation::event( - 1, 1, "thread.upsert", {{"thread", {{"id", "owner-thread"}}}}, + 1, 1, "thread.upsert", + {{"thread", {{"id", "owner-thread"}, {"name", "Original title"}}}}, presentation::Authority::Merge, {{"threadId", "owner-thread"}})); model.applyEvent(presentation::event( 2, 1, "agents.activity.upsert", @@ -932,6 +933,9 @@ bool testInspectorDetailParity() { {"type", "subAgentActivity"}, {"status", "inProgress"}, {"agentThreadId", "child-thread"}, + {"resultText", + "Agent result summary.\n\n* First rendered finding.\n* Second " + "rendered finding."}, {"senderThreadId", "sender-thread"}, {"receiverThreadIds", nlohmann::json::array({"receiver-one", "receiver-two"})}}}}, @@ -970,10 +974,27 @@ bool testInspectorDetailParity() { } result &= expect(agentStatus && agentStatus->property("tone") == "active", "running agent status uses the canonical active tone"); + auto *agentResult = + inspector.findChild(QStringLiteral("agentResult")); + result &= expect( + agentResult && agentResult->alignment().testFlag(Qt::AlignTop), + "agent Markdown starts at the top of any surplus result-label height"); inspector.tabs()->setCurrentIndex(3); spin(20); - result &= expect(hasLabelContaining(inspector, QStringLiteral("3 questions")), - "Requests show their retained question count"); + result &= expect( + hasLabelContaining(inspector, QStringLiteral("thread Original title")) && + hasLabelContaining(inspector, QStringLiteral("3 questions")), + "Requests show their thread title and retained question count"); + model.applyEvent(presentation::event( + 4, 1, "thread.name.changed", {{"name", "Renamed title"}}, + presentation::Authority::Replace, {{"threadId", "owner-thread"}})); + inspector.refresh(model, "owner-thread"); + spin(20); + result &= expect( + hasLabelContaining(inspector, QStringLiteral("thread Renamed title")) && + !hasLabelContaining(inspector, + QStringLiteral("thread Original title")), + "Requests update their thread label after a thread rename"); QFrame *requestFrame = nullptr; for (QFrame *frame : inspector.findChildren()) { if (frame->property("tone") == "warning") { diff --git a/tests/codex/GitChangesLiveTest.cpp b/tests/codex/GitChangesLiveTest.cpp index b21108f..f2c0549 100644 --- a/tests/codex/GitChangesLiveTest.cpp +++ b/tests/codex/GitChangesLiveTest.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,7 @@ namespace { using codexui::codex::DiffViewer; using codexui::codex::GitDiffFile; +using codexui::codex::GitDiffProvider; using codexui::codex::GitDiffSnapshot; bool expect(bool condition, const char *message) { @@ -99,6 +101,47 @@ bool hasFile(const GitDiffSnapshot &snapshot, const QString &path, return false; } +bool testSnapshotMetadataRefresh() { + DiffViewer viewer; + auto *provider = viewer.findChild(); + auto *files = + viewer.findChild(QStringLiteral("codexDiffFiles")); + const bool updated = [provider, files] { + if (!provider || !files) + return false; + GitDiffFile file; + file.repositoryRoot = QStringLiteral("/repository"); + file.path = QStringLiteral("changed.txt"); + file.absolutePath = QStringLiteral("/repository/changed.txt"); + file.status = QStringLiteral("Modified"); + file.patch = QStringLiteral("@@ -1 +1 @@\n-before\n+after"); + file.additions = 1; + file.deletions = 2; + GitDiffSnapshot snapshot; + snapshot.workspace = QStringLiteral("/workspace"); + snapshot.repositoryRoot = file.repositoryRoot; + snapshot.repositoryRoots = {file.repositoryRoot}; + snapshot.files = {file}; + snapshot.repository = true; + provider->snapshotReady(snapshot); + const bool initialRendered = + files->count() == 1 && + files->item(0)->text().contains(QStringLiteral("+1")) && + files->item(0)->text().contains(QStringLiteral("−2")); + + snapshot.files.front().additions = 7; + snapshot.files.front().deletions = 5; + provider->snapshotReady(snapshot); + const bool metadataUpdated = + files->count() == 1 && + files->item(0)->text().contains(QStringLiteral("+7")) && + files->item(0)->text().contains(QStringLiteral("−5")); + return initialRendered && metadataUpdated; + }(); + return expect(updated, + "diff presentation updates when only line totals change"); +} + bool testLiveWorkingTreeChanges() { QTemporaryDir directory; if (!expect(directory.isValid(), "creates a temporary repository")) @@ -238,7 +281,8 @@ bool testLiveWorkingTreeChanges() { int main(int argc, char **argv) { QApplication application(argc, argv); git_libgit2_init(); - const bool result = testLiveWorkingTreeChanges(); + bool result = testSnapshotMetadataRefresh(); + result &= testLiveWorkingTreeChanges(); git_libgit2_shutdown(); return result ? 0 : 1; }