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
24 changes: 23 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ include(GNUInstallDirs)
set(CMAKE_AUTOMOC ON)

find_package(AISuite 0.5.0 CONFIG REQUIRED)
find_package(Qt6 REQUIRED COMPONENTS Network Widgets)
find_package(Qt6 REQUIRED COMPONENTS Concurrent Network Widgets)

qt_add_executable(
codex-ui
Expand All @@ -35,6 +35,8 @@ qt_add_executable(
src/ui/InteractiveRequestDialog.h
src/ui/MainWindow.cpp
src/ui/MainWindow.h
src/ui/PresentationRefreshAccumulator.cpp
src/ui/PresentationRefreshAccumulator.h
src/ui/SidebarWidget.cpp
src/ui/SidebarWidget.h
src/ui/ThreadSetupDialog.cpp
Expand All @@ -52,6 +54,7 @@ target_link_libraries(
codex-ui
PRIVATE
AISuite::OpenAICodexFrontendClient
Qt6::Concurrent
Qt6::Network
Qt6::Widgets
)
Expand Down Expand Up @@ -110,6 +113,25 @@ if(BUILD_TESTING)
)
add_test(NAME CodexUIFrontendSessionTest COMMAND CodexUIFrontendSessionTest)

add_executable(
CodexUIPresentationRefreshAccumulatorTest
tests/PresentationRefreshAccumulatorTest.cpp
src/ui/PresentationRefreshAccumulator.cpp
src/ui/PresentationRefreshAccumulator.h
)
target_compile_features(CodexUIPresentationRefreshAccumulatorTest PRIVATE cxx_std_20)
target_include_directories(CodexUIPresentationRefreshAccumulatorTest PRIVATE src)
target_link_libraries(
CodexUIPresentationRefreshAccumulatorTest
PRIVATE
AISuite::OpenAICodexFrontendClient
Qt6::Widgets
)
add_test(
NAME CodexUIPresentationRefreshAccumulatorTest
COMMAND CodexUIPresentationRefreshAccumulatorTest
)

add_executable(
CodexUIConversationLayoutTest
tests/ConversationLayoutTest.cpp
Expand Down
48 changes: 45 additions & 3 deletions src/app/AttachmentManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,18 @@ QString uniqueDestinationName(const QString& requested, QSet<QString>& occupiedN

bool copyFileAtomically(const QString& sourcePath,
const QString& destinationPath,
QString* errorMessage)
QString* errorMessage,
const AttachmentManager::CancellationCheck& cancelled)
{
const auto reportCancellation = [errorMessage]() {
if (errorMessage)
*errorMessage = QStringLiteral("Attachment preparation was cancelled.");
};
if (cancelled && cancelled()) {
reportCancellation();
return false;
}

QFile source(sourcePath);
if (!source.open(QIODevice::ReadOnly)) {
if (errorMessage)
Expand All @@ -85,9 +95,20 @@ bool copyFileAtomically(const QString& sourcePath,
return false;
}

const auto cancelDestination = [&]() {
destination.cancelWriting();
// QSaveFile's direct-write fallback cannot roll back by itself. This
// path is always a fresh file inside a fresh staging directory.
(void)QFile::remove(destinationPath);
reportCancellation();
return false;
};

constexpr qint64 chunkSize = 1024 * 1024;
QByteArray buffer(static_cast<qsizetype>(chunkSize), Qt::Uninitialized);
while (!source.atEnd()) {
if (cancelled && cancelled())
return cancelDestination();
const qint64 count = source.read(buffer.data(), chunkSize);
if (count < 0 || (count > 0 && destination.write(buffer.constData(), count) != count)) {
destination.cancelWriting();
Expand All @@ -102,7 +123,11 @@ bool copyFileAtomically(const QString& sourcePath,
}
if (count == 0)
break;
if (cancelled && cancelled())
return cancelDestination();
}
if (cancelled && cancelled())
return cancelDestination();
if (!destination.commit()) {
if (errorMessage)
*errorMessage = errorWithPath(
Expand Down Expand Up @@ -493,14 +518,20 @@ bool AttachmentManager::prepare(const QList<AttachmentInfo>& attachments,
const QString& workspace,
const QString& threadId,
AttachmentPreparation* result,
QString* errorMessage)
QString* errorMessage,
CancellationCheck cancelled)
{
if (!result) {
if (errorMessage)
*errorMessage = QStringLiteral("No attachment preparation result object was provided.");
return false;
}
*result = {};
if (cancelled && cancelled()) {
if (errorMessage)
*errorMessage = QStringLiteral("Attachment preparation was cancelled.");
return false;
}
if (!validateForWorkspace(attachments, workspace, errorMessage))
return false;

Expand Down Expand Up @@ -533,6 +564,14 @@ bool AttachmentManager::prepare(const QList<AttachmentInfo>& attachments,
QSet<QString> occupiedNames;
QStringList promptLines;
for (const AttachmentInfo& attachment : attachments) {
if (cancelled && cancelled()) {
if (errorMessage)
*errorMessage = QStringLiteral("Attachment preparation was cancelled.");
if (result->stagingLease)
(void)result->stagingLease->cleanup();
*result = {};
return false;
}
PreparedAttachment prepared;
prepared.source = attachment;
if (attachment.kind == AttachmentInfo::Kind::Image) {
Expand All @@ -543,7 +582,10 @@ bool AttachmentManager::prepare(const QList<AttachmentInfo>& attachments,
safeFileName(attachment.displayName), occupiedNames);
prepared.effectivePath = QDir(stagingDirectory).filePath(destinationName);
prepared.staged = true;
if (!copyFileAtomically(attachment.sourcePath, prepared.effectivePath, errorMessage)) {
if (!copyFileAtomically(attachment.sourcePath,
prepared.effectivePath,
errorMessage,
cancelled)) {
(void)result->stagingLease->cleanup();
*result = {};
return false;
Expand Down
12 changes: 8 additions & 4 deletions src/app/AttachmentManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <QString>
#include <QStringList>

#include <functional>
#include <memory>

class QSettings;
Expand Down Expand Up @@ -82,12 +83,14 @@ struct PersistedAttachmentStaging
class AttachmentManager final
{
public:
// Staging is deliberately synchronous and therefore bounded well below
// filesystem limits. Image contents travel by local path, not on the
// frontend protocol wire.
// Generic-file staging is bounded and may run off the GUI thread. Callers
// can cooperatively cancel it between fixed-size copy chunks. Image
// contents travel by local path, not on the frontend protocol wire.
static constexpr qint64 MaximumSingleFileBytes = 64LL * 1024LL * 1024LL;
static constexpr qint64 MaximumTotalBytes = 256LL * 1024LL * 1024LL;

using CancellationCheck = std::function<bool()>;

[[nodiscard]] static bool inspectFile(const QString& path,
AttachmentInfo* result,
QString* errorMessage = nullptr);
Expand All @@ -98,7 +101,8 @@ class AttachmentManager final
const QString& workspace,
const QString& threadId,
AttachmentPreparation* result,
QString* errorMessage = nullptr);
QString* errorMessage = nullptr,
CancellationCheck cancelled = {});
[[nodiscard]] static QString composePrompt(const QString& userPrompt,
const AttachmentPreparation& preparation);
[[nodiscard]] static QString formatSize(qint64 sizeBytes);
Expand Down
53 changes: 38 additions & 15 deletions src/app/FrontendSession.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,31 +22,42 @@ namespace sdk = ai::openai::codex::frontend::client;

namespace {

constexpr qsizetype maximumCoalescedPresentationIdentities = 1'024;

void appendUnique(QStringList& destination, const QStringList& source)
bool appendUniqueBounded(QStringList& destination, const QStringList& source)
{
for (const QString& value : source) {
if (!destination.contains(value))
destination.push_back(value);
if (destination.contains(value))
continue;
if (destination.size()
>= detail::maximumCoalescedPresentationIdentities)
return false;
destination.push_back(value);
}
return true;
}

void mergeScope(detail::StateUpdateScope& destination,
const detail::StateUpdateScope& source)
{
destination.allThreadsAffected |= source.allThreadsAffected;
destination.allInspectorsAffected |= source.allInspectorsAffected;
destination.allSidebarThreadsAffected |= source.allSidebarThreadsAffected;
destination.sidebarAffected |= source.sidebarAffected;
destination.hasPresentationChange |= source.hasPresentationChange;
if (!destination.allThreadsAffected) {
appendUnique(destination.affectedThreadIds, source.affectedThreadIds);
appendUnique(destination.fullyAffectedThreadIds,
source.fullyAffectedThreadIds);
if (!appendUniqueBounded(destination.affectedThreadIds,
source.affectedThreadIds)
|| !appendUniqueBounded(destination.fullyAffectedThreadIds,
source.fullyAffectedThreadIds))
destination.allThreadsAffected = true;
}
if (!destination.allInspectorsAffected)
appendUnique(destination.affectedInspectorThreadIds,
source.affectedInspectorThreadIds);
if (!destination.allInspectorsAffected
&& !appendUniqueBounded(destination.affectedInspectorThreadIds,
source.affectedInspectorThreadIds))
destination.allInspectorsAffected = true;
if (!destination.allSidebarThreadsAffected
&& !appendUniqueBounded(destination.affectedSidebarThreadIds,
source.affectedSidebarThreadIds))
destination.allSidebarThreadsAffected = true;

const auto sameContent = [](const auto& left, const auto& right) {
return left.threadId == right.threadId && left.turnId == right.turnId
Expand All @@ -60,6 +71,12 @@ void mergeScope(detail::StateUpdateScope& destination,
return sameContent(candidate, identity);
});
if (existing == destination.affectedItemContents.end()) {
if (static_cast<qsizetype>(
destination.affectedItemContents.size())
>= detail::maximumCoalescedPresentationIdentities) {
destination.allThreadsAffected = true;
break;
}
auto bounded = identity;
if (bounded.append) {
const std::uint64_t bytes =
Expand Down Expand Up @@ -131,17 +148,21 @@ void mergeScope(detail::StateUpdateScope& destination,
}

if (destination.affectedThreadIds.size()
> maximumCoalescedPresentationIdentities
> detail::maximumCoalescedPresentationIdentities
|| destination.fullyAffectedThreadIds.size()
> maximumCoalescedPresentationIdentities
> detail::maximumCoalescedPresentationIdentities
|| static_cast<qsizetype>(destination.affectedItemContents.size())
> maximumCoalescedPresentationIdentities) {
> detail::maximumCoalescedPresentationIdentities) {
destination.allThreadsAffected = true;
}
if (destination.affectedInspectorThreadIds.size()
> maximumCoalescedPresentationIdentities) {
> detail::maximumCoalescedPresentationIdentities) {
destination.allInspectorsAffected = true;
}
if (destination.affectedSidebarThreadIds.size()
> detail::maximumCoalescedPresentationIdentities) {
destination.allSidebarThreadsAffected = true;
}
if (destination.allThreadsAffected) {
destination.affectedThreadIds.clear();
destination.fullyAffectedThreadIds.clear();
Expand All @@ -150,6 +171,8 @@ void mergeScope(detail::StateUpdateScope& destination,
}
if (destination.allInspectorsAffected)
destination.affectedInspectorThreadIds.clear();
if (destination.allSidebarThreadsAffected)
destination.affectedSidebarThreadIds.clear();
}

template<typename Completion>
Expand Down
3 changes: 3 additions & 0 deletions src/app/FrontendSession.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ namespace codexui::detail {
// performs an authoritative replacement refresh instead of retaining deltas.
inline constexpr std::uint64_t maximumCoalescedContentDeltaBytes =
1024U * 1024U;
inline constexpr qsizetype maximumCoalescedPresentationIdentities = 1'024;

struct StateUpdateScope {
struct ItemContentAppend {
Expand All @@ -49,10 +50,12 @@ struct StateUpdateScope {
QStringList affectedThreadIds;
QStringList fullyAffectedThreadIds;
QStringList affectedInspectorThreadIds;
QStringList affectedSidebarThreadIds;
std::vector<ItemContentIdentity> affectedItemContents;
std::uint64_t coalescedContentDeltaBytes = 0;
bool allThreadsAffected = false;
bool allInspectorsAffected = false;
bool allSidebarThreadsAffected = false;
bool sidebarAffected = false;
bool hasPresentationChange = false;
};
Expand Down
Loading