import QtQuick import QtQuick.Controls import QtQuick.Layouts // The files a message or a tool call carries. // // A picture is a thumbnail from the session's own store (image://cstattach/, // app/src/ui/SessionImages.cpp) and opens with whatever the system uses for // images when it is tapped. A file the agent delivered (deliver_file) is a card // with the name, the type, the size and two buttons: open and share. The names // are the ones the engine wrote next to the session -- a card never invents a // path. // // One line per file, written by AgentCore.cpp (filesText): // ||||||x // A session saved by an older version wrote "||| // x" for pictures; both shapes are read here. Flow { id: strip objectName: "imageStrip" /* A row carries this as text: a ListModel role cannot hold a list. */ property string text: "" property var files: decode(text) /* The last refusal, so a tap that cannot work says why (the header's own * status line is the engine's). */ property string errorText: "" spacing: 6 function decode(t) { const out = [] const lines = String(t).split("\n") for (let i = 0; i < lines.length; i++) { if (!lines[i].length) continue const f = lines[i].split("|") if (!f[0].length) continue const old = f.length < 7 // a picture line from an old session const dim = String(old ? f[3] : f[6]).split("x") let w = 0, h = 0 if (dim.length === 2) { w = parseInt(dim[0], 10) || 0 h = parseInt(dim[1], 10) || 0 } const rel = old ? f[0] : f[1] out.push({ kind: old ? "img" : f[0], rel: rel, name: (old ? f[1] : f[2]) || rel, origin: (old ? f[2] : f[3]) || "", mime: old ? "image/*" : (f[4] || ""), bytes: old ? 0 : (parseInt(f[5], 10) || 0), w: w, h: h, url: "image://cstattach/" + rel }) } return out } function humanSize(n) { if (!n || n <= 0) return "" if (n >= 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB" if (n >= 1024) return Math.round(n / 1024) + " KB" return n + " B" } /* The two things a file card does. The engine answers with "" when it * worked (it puts the line in the header itself) or a reason. */ function openFile(rel) { errorText = agent.openArtifact(rel) } function shareFile(rel) { errorText = agent.shareArtifact(rel) } Repeater { model: strip.files delegate: Rectangle { id: cell required property var modelData required property int index readonly property bool isFile: String(modelData.kind) !== "img" readonly property bool hovered: mouse.containsMouse /* Images keep their own shape, capped; a file card is a fixed * width so a long name cannot stretch the transcript. */ readonly property real ratio: { const w = Number(modelData.w), h = Number(modelData.h) return (w > 0 && h > 0) ? w / h : 1.5 } readonly property int cap: 150 objectName: isFile ? ("fileCard" + index) : ("imageThumb" + index) width: isFile ? 300 : Math.max(56, Math.min(Math.round(cap * ratio), 320)) height: isFile ? 52 : Math.max(40, Math.min(cap, Math.round(width / ratio))) radius: Theme.radiusSm color: Theme.bg border.width: 1 border.color: hovered ? Theme.accentDim : Theme.border clip: true MouseArea { id: mouse anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.NoButton } // --- a picture: tap it to look at it --------------------------- Image { id: pic anchors.fill: parent anchors.margins: 2 visible: !cell.isFile && status === Image.Ready source: String(cell.modelData.url) sourceSize.width: 512 sourceSize.height: 512 fillMode: Image.PreserveAspectFit asynchronous: false } Text { anchors.centerIn: parent width: parent.width - 8 visible: !cell.isFile && pic.status === Image.Error text: String(cell.modelData.name) elide: Text.ElideMiddle horizontalAlignment: Text.AlignHCenter color: Theme.textFaint font.pixelSize: Theme.smallSize } MouseArea { anchors.fill: parent enabled: !cell.isFile cursorShape: Qt.PointingHandCursor onClicked: strip.openFile(String(cell.modelData.rel)) } // --- a delivered file: name, type, open, share ----------------- RowLayout { id: row visible: cell.isFile anchors.fill: parent anchors.leftMargin: 8 anchors.rightMargin: 6 spacing: 6 /* The extension stands in for an icon: no theme lookup, and a * ZIP that says ZIP is more use than a generic sheet. */ Rectangle { implicitWidth: 40 implicitHeight: 30 radius: Theme.radiusSm color: Theme.surfaceHi Text { anchors.centerIn: parent text: { const n = String(cell.modelData.name) const dot = n.lastIndexOf(".") if (dot < 0 || dot === n.length - 1) return "FILE" return n.slice(dot + 1, dot + 5).toUpperCase() } color: Theme.textDim font.pixelSize: Theme.smallSize font.bold: true } } ColumnLayout { Layout.fillWidth: true spacing: 0 Text { Layout.fillWidth: true text: String(cell.modelData.name) color: Theme.text font.pixelSize: Theme.bodySize elide: Text.ElideMiddle } Text { Layout.fillWidth: true text: { const m = cell.modelData let s = m.mime ? String(m.mime) : "" const size = strip.humanSize(m.bytes) if (size) s += (s ? " · " : "") + size if (m.origin && m.origin !== "user" && m.origin !== "tool") s += (s ? " · " : "") + m.origin return s } color: Theme.textFaint font.pixelSize: Theme.smallSize elide: Text.ElideRight } } ToolButton { objectName: "fileOpen" + cell.index implicitWidth: Theme.touchTarget implicitHeight: Theme.touchTarget onClicked: strip.openFile(String(cell.modelData.rel)) contentItem: Text { text: "Open" color: parent.hovered ? Theme.text : Theme.textDim font.pixelSize: Theme.smallSize horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } background: Rectangle { radius: Theme.radiusSm color: parent.hovered ? Theme.surfaceHi : "transparent" border.width: 1 border.color: Theme.border } } ToolButton { objectName: "fileShare" + cell.index implicitWidth: Theme.touchTarget implicitHeight: Theme.touchTarget onClicked: strip.shareFile(String(cell.modelData.rel)) contentItem: Text { text: "Share" color: parent.hovered ? Theme.text : Theme.textDim font.pixelSize: Theme.smallSize horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } background: Rectangle { radius: Theme.radiusSm color: parent.hovered ? Theme.surfaceHi : "transparent" border.width: 1 border.color: Theme.border } } } ToolTip.visible: hovered ToolTip.delay: 400 ToolTip.text: { const m = cell.modelData let s = String(m.name) if (m.w > 0 && m.h > 0) s += " · " + m.w + "×" + m.h if (m.mime && m.kind !== "img") s += " · " + m.mime return s } } } /* A tap that could not work says why, right where the card is: a phone * with nothing installed for the type is the common case ("no app on the * phone opens report.md"), and a silence there looks like a dead button. */ Text { objectName: "fileError" visible: strip.errorText.length > 0 width: strip.width wrapMode: Text.WordWrap text: strip.errorText color: Theme.err font.pixelSize: Theme.smallSize } }