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
62 changes: 44 additions & 18 deletions docs/ui-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,15 @@ bottom or is owned by the user.
meaningful thread-scoped protocol traffic in either direction advances it
immediately. Selection-driven `thread/read` and `thread/resume` hydration,
global connection traffic, and catalog traffic do not count as activity.
The same live activity advances the presentation model's effective
`updatedAt` and `recencyAt` values, while the provider payload remains
retained as received. These local values are not persisted by CodexUI.
- Once selected, a hydrated thread remains visible in the sidebar for the
session even when it is outside the ordinary top-level thread ordering; an
authoritative removal still removes it.
Live traffic does not alter thread ordering. Only local prompt admission
advances the presentation model's effective `updatedAt` and `recencyAt`;
these local values are not persisted by CodexUI.
- The visible sidebar order contains confirmed root threads only. Minimal
thread placeholders created by scoped protocol traffic remain retained but
invisible until an explicit list, read, resume, create, or fork admits them
as roots. A valued `parentThreadId` immediately assigns structural child
ownership, so child threads never flash in the root list while later agent
correlation is pending.
- The sidebar sorts all visible rows by a user-selected criterion. `Recent` is
the default and uses the app-server's provider-defined `recencyAt` value,
newest first. `Created` uses `createdAt` newest first, and `Last changed`
Expand Down Expand Up @@ -109,7 +112,12 @@ the same admission path as the Send button. Shift+Enter always inserts a new
line, including when Control or Meta is also held; Control+Enter and Meta+Enter
remain submission aliases, while Alt+Enter does not submit. Auto-repeated Enter
events and Enter used to confirm an active input-method composition never
submit a prompt.
submit a prompt. Auto-repeat is consumed instead of inserting an accidental
newline. Send and Steer are enabled only when admission is available and the
draft contains non-whitespace text. Focus uses the canonical blue composer
border without changing its geometry. Whitespace is used only for admission
validation: the exact authored text, including intentional leading and trailing
space and blank lines, is passed to the submission path unchanged.

Submitting a prompt creates a client-local pending prompt card at the bottom of
the destination thread immediately. The card begins with the calm blue
Expand Down Expand Up @@ -213,6 +221,8 @@ updates remain neutral and identify their phase in the header. Process cards
also remain neutral so they support rather than dominate the primary exchange.
Their lifecycle status is a normal-weight lowercase value at the right of the
header, immediately before Copy, and uses canonical semantic state colors.
An `imageView` item is completed once it materializes; generated-image and
fallback activity cards retain any lifecycle state supplied by the app-server.
Thread rows, conversation metadata, Inspector entries, and process cards share
the same vocabulary: `running`, `completed`, `failed`, `interrupted`, `pending`,
and `not loaded`. Command exit
Expand Down Expand Up @@ -271,6 +281,8 @@ Web thread refresh, rename, fork, archive, and delete actions are single-flight.
Mutation controls require current controller readiness, remain disabled while
their operation is pending, and report operation failures through the canonical
notice surface.
Transient notices overlay the conversation workspace in native and WebUI. Their
appearance, timeout, and dismissal never resize or reposition message content.

Folding is an explicit geometry transaction. Collapsing keeps the selected
title row fixed while the natural scroll range permits and shifts following
Expand Down Expand Up @@ -337,28 +349,40 @@ re-enable following. Composer contraction is the explicit exception: after its
trailing space is removed, CodexUI recomputes whether the resulting clamped
position is the new bottom.

The complete center region is wheel- and touchpad-scroll sensitive. Wheel
events over non-scrollable center chrome and the horizontal splitter handles
are forwarded to the message view. Command text and output retain a gesture
that started while they could scroll; only a fresh gesture begun at their
current boundary is handed to the conversation.
The complete unobscured center region is wheel- and touchpad-scroll sensitive.
Wheel events over non-scrollable conversation chrome and the horizontal
splitter handles are forwarded to the message view. A gesture begun in the
prompt editor or turn-settings surface is always consumed by that composer
region and never scrolls the conversation behind it. Command text and output
retain a gesture that started while they could scroll; only a fresh gesture
begun at their current boundary is handed to the conversation.

## Composer geometry

The upcoming-turn controls are anchored to the bottom of the center pane. The
prompt editor starts at one line, grows upward for multiline input, and stops at
its configured maximum height, after which it scrolls internally.
its configured maximum height, after which it scrolls internally. While all
content fits, its hidden scrollbar is clamped to the top so a fully visible
multiline draft cannot be displaced by a trailing blank-line offset. The
compact-to-multiline transition is decided by an invisible `QTextLayout` using
the editor's exact compact content width, including its document margins. The
live document is never resized for measurement, and its height is updated only
after the grid switch, so the first wrapping character moves directly into the
expanded grid without an intermediate row.

The message-view layout reserves only the composer's canonical height. When
prompt text, attachments, settings, or attention controls increase that height,
the composer grows upward as an overlay: the viewport keeps its normal geometry
and may be partly covered. An equal logical trailing extent is added to the
scrollable conversation content so the final card can still be moved to the
overlay boundary. The conversation owns no permanent bottom padding; the moving
composer uses the canonical Changes-tab treatment of 8 px space, a standard
divider extending 10 px beyond the adjacent content on each side, and another
8 px space. This boundary remains identical whether the conversation is at its
bottom or paused higher in history.
overlay boundary. The visible conversation scrollbar track is inset by the same
extra height, so its lower endpoint remains at the uncovered message boundary
rather than disappearing beneath the composer. Its value, range, and anchoring
semantics remain those of the full conversation. The conversation owns no
permanent bottom padding; the moving composer uses the canonical Changes-tab
treatment of 8 px space, a standard divider extending 10 px beyond the adjacent
content on each side, and another 8 px space. This boundary remains identical
whether the conversation is at its bottom or paused higher in history.

Growing this extent preserves the current scrollbar value and does not move the
messages automatically. Reaching its new maximum re-enables bottom-follow for
Expand All @@ -377,6 +401,8 @@ has no non-content minimum height, grows from zero to a maximum of 220 pixels,
and exposes a styled vertical scrollbar only when content exceeds that limit.
The command surface uses the same content-height behavior with its existing
90-pixel maximum. Trailing empty lines are omitted from both displayed texts.
Executed command text opens at its beginning and never follows its bottom;
tail-following belongs only to the streaming output surface.
Their wrapped content height is measured at the final viewport width during the
outer layout transaction. While the conversation follows its bottom, streaming
output growth holds the card bottom and metadata in place and expands upward.
Expand Down
76 changes: 65 additions & 11 deletions src/codex/PresentationModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -406,10 +406,6 @@ void PresentationModel::noteThreadActivity(const std::string &threadId,
break;
ThreadPresentation &thread = iterator->second;
retainActivity(thread, timestamp);
if (!thread.updatedAt || timestamp > *thread.updatedAt)
thread.updatedAt = timestamp;
if (!thread.recencyAt || timestamp > *thread.recencyAt)
thread.recencyAt = timestamp;
const auto ownership = childOwnerships.find(current);
if (ownership == childOwnerships.end())
break;
Expand All @@ -426,7 +422,21 @@ void PresentationModel::notePromptActivity(const std::string &threadId,
if (thread.recencyAt && *thread.recencyAt >= timestamp)
timestamp = *thread.recencyAt + 1;
}
noteThreadActivity(threadId, timestamp);
std::string current = threadId;
std::unordered_set<std::string> visited;
while (!current.empty() && visited.insert(current).second) {
const auto iterator = threads.find(current);
if (iterator == threads.end())
break;
ThreadPresentation &thread = iterator->second;
retainActivity(thread, timestamp);
thread.updatedAt = timestamp;
thread.recencyAt = timestamp;
const auto ownership = childOwnerships.find(current);
if (ownership == childOwnerships.end())
break;
current = ownership->second.parentThreadId;
}
}

void PresentationModel::applyValidatedEvent(const nlohmann::json &event) {
Expand Down Expand Up @@ -655,7 +665,7 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) {
if (authority == "none" || authority == "remove")
return;
nlohmann::json minimal{{"id", threadId}};
upsertThread(minimal, false);
upsertThread(minimal, false, false);
threadIterator = threads.find(threadId);
if (threadIterator == threads.end())
return;
Expand Down Expand Up @@ -864,11 +874,8 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw,
}
auto [iterator, inserted] = threads.try_emplace(id);
ThreadPresentation &result = iterator->second;
if (inserted) {
if (inserted)
result.id = id;
if (prependNewThread)
orderedThreads.insert(orderedThreads.begin(), id);
}
const std::string previousThreadStatus = result.status;
std::unordered_map<std::string, std::string> terminalTurnStatuses;
if (replaceTurns) {
Expand Down Expand Up @@ -908,11 +915,32 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw,
retainActivity(result, *result.recencyAt);
result.archived = boolValue(raw, "archived", result.archived);

if (raw.contains("parentThreadId")) {
const std::string parentThreadId = stringValue(raw, "parentThreadId");
if (!parentThreadId.empty())
retainStructuralOwnership(id, parentThreadId);
else {
const auto ownership = childOwnerships.find(id);
if (ownership != childOwnerships.end() &&
ownership->second.agentId.empty())
releaseChildOwnership(id, false);
}
}
if (prependNewThread && !childOwnerships.contains(id) &&
std::find(orderedThreads.begin(), orderedThreads.end(), id) ==
orderedThreads.end())
orderedThreads.insert(orderedThreads.begin(), id);

const auto turns = raw.find("turns");
if (turns != raw.end() && turns->is_array()) {
std::vector<std::string> previouslyOwnedChildren;
if (replaceTurns) {
previouslyOwnedChildren = result.childThreadOrder;
for (const std::string &childThreadId : result.childThreadOrder) {
const auto ownership = childOwnerships.find(childThreadId);
if (ownership != childOwnerships.end() &&
!ownership->second.agentId.empty())
previouslyOwnedChildren.push_back(childThreadId);
}
for (const std::string &childThreadId : previouslyOwnedChildren)
releaseChildOwnership(childThreadId, false);
result.turnOrder.clear();
Expand Down Expand Up @@ -1184,6 +1212,32 @@ void PresentationModel::assignChildOwnership(ThreadPresentation &parent,
synchronizeOwningAgent(childThreadId);
}

void PresentationModel::retainStructuralOwnership(
const std::string &childThreadId, const std::string &parentThreadId) {
if (childThreadId.empty() || parentThreadId.empty() ||
childThreadId == parentThreadId)
return;
const auto existing = childOwnerships.find(childThreadId);
if (existing != childOwnerships.end() &&
existing->second.parentThreadId == parentThreadId)
return;
if (existing != childOwnerships.end())
releaseChildOwnership(childThreadId, false);

auto [parent, parentInserted] = threads.try_emplace(parentThreadId);
if (parentInserted)
parent->second.id = parentThreadId;
auto [child, childInserted] = threads.try_emplace(childThreadId);
if (childInserted)
child->second.id = childThreadId;
childOwnerships[childThreadId] = {parentThreadId, {}};
if (std::find(parent->second.childThreadOrder.begin(),
parent->second.childThreadOrder.end(), childThreadId) ==
parent->second.childThreadOrder.end())
parent->second.childThreadOrder.push_back(childThreadId);
std::erase(orderedThreads, childThreadId);
}

void PresentationModel::releaseChildOwnership(const std::string &childThreadId,
bool promoteToRoot) {
const std::string releasedChildId = childThreadId;
Expand Down
2 changes: 2 additions & 0 deletions src/codex/PresentationModel.h
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ class PresentationModel final {
void assignChildOwnership(ThreadPresentation &parent,
AgentPresentation &agent,
const std::string &childThreadId, bool live);
void retainStructuralOwnership(const std::string &childThreadId,
const std::string &parentThreadId);
void releaseChildOwnership(const std::string &childThreadId,
bool promoteToRoot);
void synchronizeOwningAgent(const std::string &childThreadId,
Expand Down
21 changes: 18 additions & 3 deletions src/codex/middle/ComposerPane.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ ComposerPane::ComposerPane(QWidget *anchor)

promptEditor_ = new codexui::ExpandingPromptEditor(composerBody_);
sendButton_ = new QPushButton(QStringLiteral("Send"), composerBody_);
sendButton_->setObjectName(QStringLiteral("composerSendButton"));
sendButton_->setProperty("kind", "primary");
sendButton_->setToolTip(QStringLiteral("Send prompt (Enter)"));
sendButton_->setFixedSize(62, ControlHeight);
Expand All @@ -204,9 +205,15 @@ ComposerPane::ComposerPane(QWidget *anchor)
connect(promptEditor_, &codexui::ExpandingPromptEditor::submitRequested, this,
[this] { submitDraft(); });
connect(promptEditor_, &QPlainTextEdit::textChanged, this, [this] {
refreshSubmissionEnabled();
refreshAdaptiveLayout();
synchronizeGeometry();
});
connect(promptEditor_, &codexui::ExpandingPromptEditor::focusStateChanged,
this, [this](bool focused) {
composer_->setProperty("focused", focused);
repolish(composer_);
});
connect(promptEditor_, &codexui::ExpandingPromptEditor::editorHeightChanged,
this, [this](int) {
refreshAdaptiveLayout();
Expand All @@ -222,6 +229,7 @@ ComposerPane::ComposerPane(QWidget *anchor)
});

refreshAttachments();
refreshSubmissionEnabled();
synchronizeGeometry();
QTimer::singleShot(0, this, [this] {
// The compact reserve is measured only after the splitter has assigned
Expand Down Expand Up @@ -306,7 +314,8 @@ void ComposerPane::setCanSubmit(bool canSubmit) {
// Admission never locks or greys the editor; independent prompts may be
// entered while earlier submissions await their real app-server callback.
promptEditor_->setEnabled(true);
sendButton_->setEnabled(canSubmit);
canSubmit_ = canSubmit;
refreshSubmissionEnabled();
attachmentButton_->setEnabled(canSubmit);
for (QPushButton *button : attachmentPanel_->findChildren<QPushButton *>())
button->setEnabled(true);
Expand Down Expand Up @@ -395,8 +404,9 @@ bool ComposerPane::eventFilter(QObject *watched, QEvent *event) {
}

void ComposerPane::submitDraft() {
const QString prompt = promptEditor_->toPlainText().trimmed();
if (prompt.isEmpty() || !sendButton_->isEnabled() || !actions_.submit)
const QString prompt = promptEditor_->toPlainText();
if (prompt.trimmed().isEmpty() || !sendButton_->isEnabled() ||
!actions_.submit)
return;
std::vector<AttachmentDraft> attachments = attachments_;
if (actions_.submit(prompt, std::move(attachments)))
Expand Down Expand Up @@ -502,4 +512,9 @@ void ComposerPane::refreshActionStyle() {
repolish(sendButton_);
}

void ComposerPane::refreshSubmissionEnabled() {
sendButton_->setEnabled(
canSubmit_ && !promptEditor_->toPlainText().trimmed().isEmpty());
}

} // namespace codexui::codex::middle
2 changes: 2 additions & 0 deletions src/codex/middle/ComposerPane.h
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class ComposerPane final : public QWidget {
void refreshAttachments();
void refreshAdaptiveLayout();
void refreshActionStyle();
void refreshSubmissionEnabled();

QWidget *anchor_ = nullptr;
QWidget *reserve_ = nullptr;
Expand Down Expand Up @@ -108,6 +109,7 @@ class ComposerPane final : public QWidget {
int canonicalHeight_ = 0;
int extraHeight_ = 0;
bool activeTurn_ = false;
bool canSubmit_ = false;
bool expanded_ = false;
bool synchronizing_ = false;
bool canonicalCaptureEnabled_ = false;
Expand Down
3 changes: 2 additions & 1 deletion src/codex/middle/ConversationCards.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ constexpr int ViewerMaximumImageExtent = 4096;
constexpr qsizetype MaximumGenericActivityCharacters = 4096;
constexpr int CardHeaderActionSpacing = 4;
constexpr int CopyMorphDurationMilliseconds = 160;
constexpr int CopyCheckHoldMilliseconds = 1500;
constexpr int CopyCheckHoldMilliseconds = 500;

QString text(std::string_view value) {
return QString::fromUtf8(value.data(), static_cast<qsizetype>(value.size()));
Expand Down Expand Up @@ -1403,6 +1403,7 @@ class ConversationCard::Impl final {
title->setText(activity.type.empty()
? QStringLiteral("Activity")
: UiStyle::humanizeLabel(text(activity.type)));
showStatus(text(activity.status), QStringLiteral("genericActivityStatus"));
metadata->setText(boundedGenericActivity(activity.raw));
metadata->setObjectName(QStringLiteral("genericActivityMetadata"));
metadata->show();
Expand Down
17 changes: 15 additions & 2 deletions src/codex/middle/ConversationProjection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ std::string stringValue(const nlohmann::json &object, const char *key) {
: std::string{};
}

std::string statusValue(const nlohmann::json &object) {
const auto status = object.find("status");
if (status == object.end())
return {};
if (status->is_string())
return status->get<std::string>();
return stringValue(*status, "type");
}

std::optional<std::int64_t> integerValue(const nlohmann::json &object,
const char *key) {
if (!object.is_object())
Expand Down Expand Up @@ -209,7 +218,8 @@ VisibleCardData authoritativeCard(const AuthoritativeItemKey &identity,
const std::string type = stringValue(item, "type");
VisibleCardData result{std::move(visualKey), CardKind::GenericActivity,
identity.threadId, identity.turnId,
identity.itemId, GenericActivityData{type, item}};
identity.itemId,
GenericActivityData{type, item, statusValue(item)}};

if (type == "userMessage") {
result.kind = CardKind::UserMessage;
Expand Down Expand Up @@ -295,7 +305,10 @@ VisibleCardData authoritativeCard(const AuthoritativeItemKey &identity,
revisedPrompt = stringValue(item, "revised_prompt");
result.kind = CardKind::ImageGeneration;
result.payload =
ImageGenerationData{path, stringValue(item, "status"), revisedPrompt};
ImageGenerationData{path,
type == "imageView" ? "completed"
: stringValue(item, "status"),
revisedPrompt};
} else if (type == "plan") {
const std::string plan = withTruncationNotice(
messageText(item), omittedTextBytes(presentation, "text"), "plan text",
Expand Down
Loading
Loading