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
29 changes: 29 additions & 0 deletions docs/desktop-design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,35 @@ Keep feature state in its existing pane. Avoid building a generic page framework
List rows stay flat; menus, dialogs, and the composer carry elevation. Status labels
use the existing Label rather than a new badge wrapper unless a badge is needed.

## Streaming transcripts

Keep Qt's native Markdown renderer, but apply paragraph styling synchronously when
text changes, before the next ListView layout/paint. Qt's
[`setMarkdown()`](https://doc.qt.io/qt-6/qtextdocument.html#setMarkdown) replaces the
whole document; deferring our styling with
[`Qt.callLater()`](https://doc.qt.io/qt-6/qml-qtqml-qt.html#callLater-method) can expose
unstyled paragraph heights for a frame on every SSE update. Keep accumulating the
Markdown source: [`TextEdit.append()`](https://doc.qt.io/qt-6/qml-qtquick-textedit.html#append-method)
adds a paragraph, and parsing each arbitrary SSE fragment separately breaks syntax
split across chunks (for example, bold delimiters or code fences).

Qt documents that [variable-height ListView delegates](https://doc.qt.io/qt-6/qml-qtquick-listview.html#variable-delegate-size-and-section-labels)
make its content-size estimate unstable. Follow the measured last row plus the
running-status height, not the estimated footer position. Geometry callbacks must
not re-enter layout with `forceLayout()`. Transcript delegates remain virtualized,
but are not pooled: unloading their lazy content while pooled exposes zero/stale
heights when reused. Scrolling up opts out of following; Jump to latest resumes it.

The `switch_to_running_session` acceptance scenario replays durable mixed-height
history through the real backend, switches via the sidebar, holds the provider open,
then streams at 5/25 ms intervals. It checks per-frame tail/paragraph stability
within one logical pixel, delegate identity, Markdown formatting, and reading
position. The `jump_to_latest_renders_message_pixels` scenario drags back to the
beginning, receives more output while the latest message is offscreen, then clicks
the down arrow. It checks actual glyph pixels at the destination: `atYEnd` alone
can briefly report success even when the message is outside the viewport. Run both
scenarios on the native and offscreen renderers described below.

## Evaluation

Use the current implementation as the baseline, with the deterministic acceptance
Expand Down
44 changes: 26 additions & 18 deletions src/ava/app/desktop/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -519,9 +519,10 @@ ApplicationWindow {
anchors.margins: 24
spacing: 22
clip: true
reuseItems: true
// Keep the tail alive while following: its measured bottom is stable
// even when pooled messages change the estimated content height.
// Pooling unloads the variable-height Loaders, feeding zero/stale
// heights back into ListView. Destroy offscreen delegates instead.
reuseItems: false
// Follow the measured last row, not the estimated footer position.
currentIndex: follow ? count - 1 : -1
model: window.backend.transcript
footer: Item {
Expand Down Expand Up @@ -644,30 +645,39 @@ ApplicationWindow {
}
}
property bool follow: true
function alignTail() {
if (!follow || !currentItem)
return;
contentY = Math.max(originY, currentItem.y + currentItem.height + (footerItem ? footerItem.height : 0) - height);
}
function followLatest() {
if (!follow)
return;
const tail = footerItem && footerItem.visible ? footerItem : currentItem;
if (tail) {
forceLayout();
contentY = Math.max(originY, tail.y + tail.height - height);
}
forceLayout();
alignTail();
}
// Geometry changes during polish must align in the same frame,
// without forceLayout() re-entering the layout that emitted them.
Connections {
target: conversation.currentItem
function onHeightChanged() { conversation.alignTail(); }
function onYChanged() { conversation.alignTail(); }
}
Connections {
target: conversation.footerItem
function onHeightChanged() { conversation.alignTail(); }
}
onMovementStarted: follow = false
onMovementEnded: follow = atYEnd
onHeightChanged: Qt.callLater(followLatest)
onWidthChanged: Qt.callLater(followLatest)
onCurrentItemChanged: Qt.callLater(followLatest)
onContentHeightChanged: Qt.callLater(followLatest)
onContentHeightChanged: alignTail()
onCountChanged: {
if (count === 0)
follow = true;
Qt.callLater(followLatest);
}
Connections {
target: window.backend.transcript
function onDataChanged() { Qt.callLater(conversation.followLatest); }
}
ScrollBar.vertical: ScrollBar {
onPressedChanged: conversation.follow = !pressed && conversation.atYEnd
}
Expand All @@ -684,7 +694,6 @@ ApplicationWindow {
required property int groupRunning
required property int groupFailed
required property bool outputExpanded
property bool pooled: false
width: conversation.width
height: messageColumn.implicitHeight
Column {
Expand Down Expand Up @@ -738,7 +747,7 @@ ApplicationWindow {
Loader {
id: messageLoader
width: parent.width
active: !transcriptRow.pooled && (transcriptRow.groupCount <= 1 || transcriptRow.groupExpanded)
active: transcriptRow.groupCount <= 1 || transcriptRow.groupExpanded
visible: active
sourceComponent: TranscriptMessage {
kind: transcriptRow.kind
Expand All @@ -757,8 +766,6 @@ ApplicationWindow {
}
}
}
ListView.onPooled: pooled = true
ListView.onReused: pooled = false
}
}
NativeButton {
Expand All @@ -772,7 +779,8 @@ ApplicationWindow {
tip: "Jump to latest message"
onClicked: {
conversation.follow = true;
conversation.followLatest();
conversation.positionViewAtEnd();
Qt.callLater(conversation.followLatest);
}
}
ColumnLayout {
Expand Down
4 changes: 3 additions & 1 deletion src/ava/app/desktop/qml/MarkdownText.qml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ TextArea {
decorations = backend.formatMarkdown(textDocument, linkColor, codeBackground, codeFont);
formatting = false;
}
onTextChanged: scheduleFormat()
// Style before ListView measures this update, not a frame later: otherwise
// every streamed chunk briefly restores Qt's unstyled paragraph heights.
onTextChanged: formatDocument()
onTextFormatChanged: {
decorations = [];
scheduleFormat();
Expand Down
Loading
Loading