flat kvps in step detail modals

3clyp50 committed Jan 29, 2026 at 12:54 UTC c25815f752da02771f2675863a30c4b331abdc74
2 files changed +67 -56
webui/components/modals/process-step-detail/process-step-detail.html
+18 -23
@@ -60,12 +60,16 @@
60 </div>
61 </template>
62
63 - <!-- Generic KVPs for all types -->
63 + <!-- Flattened KVPs -->
64 <template x-if="$store.stepDetail.selectedStepForDetail?.kvps">
65 - <div class="content-block">
66 - <h4>Details</h4>
67 - <div class="kvps-display"><template x-for="[key, value] in Object.entries($store.stepDetail.selectedStepForDetail?.kvps || {})" :key="key"><span class="kvp-line"><span class="kvp-key" x-text="$store.stepDetail.formatKey(key)"></span><span class="kvp-value" x-text="$store.stepDetail.formatValue(value)"></span></span></template></div>
68 - <div class="step-action-buttons" data-segment="details"></div>
65 + <div class="kvp-boxes">
66 + <template x-for="[key, value] in Object.entries($store.stepDetail.flattenKvps($store.stepDetail.selectedStepForDetail?.kvps) || {})" :key="key">
67 + <div class="content-block kvp-box">
68 + <h4 x-text="key"></h4>
69 + <pre class="kvp-value-content" x-text="$store.stepDetail.formatFlatValue(value)"></pre>
70 + <div class="step-action-buttons kvp-box-actions" x-init="$store.stepDetail.renderKvpCopyButton($el, key, value)"></div>
71 + </div>
72 + </template>
73 </div>
74 </template>
75
@@ -225,31 +229,22 @@
229 word-break: break-word;
230 }
231
228 - .kvps-display {
232 + .kvp-boxes {
233 display: flex;
234 flex-direction: column;
231 - gap: 0.25rem;
232 - font-size: 0.9rem;
233 - line-height: 1.5;
234 - }
235 -
236 - .kvp-line {
237 - display: block;
238 - }
239 -
240 - .kvp-key {
241 - color: var(--color-text-muted);
242 - margin-right: 0.5rem;
243 - }
244 -
245 - .kvp-key::after {
246 - content: ':';
235 + gap: 1rem;
236 }
237
249 - .kvp-value {
238 + .kvp-value-content {
239 color: var(--color-text);
240 white-space: pre-wrap;
241 word-break: break-word;
242 + margin: 0;
243 + font-size: 0.85rem;
244 + }
245 +
246 + .kvp-box-actions {
247 + margin-top: 0.5rem;
248 }
249
250 .step-action-buttons {
webui/components/modals/process-step-detail/step-detail-store.js
+49 -33
@@ -22,22 +22,21 @@ const model = {
22 renderSegmentButtons() {
23 const step = this.selectedStepForDetail;
24
25 - // heading
26 - document.querySelector('[data-segment="heading"]')?.replaceChildren(
27 - createActionButton("copy", "", () => copyToClipboard(this.cleanHeading(step.heading)))
28 - );
29 -
30 - // details (kvps)
31 - const kvpsText = Object.entries(step.kvps || {})
32 - .map(([k, v]) => `${this.formatKey(k)}: ${this.formatValue(v)}`)
33 - .join('\n');
34 - document.querySelector('[data-segment="details"]')?.replaceChildren(
35 - createActionButton("copy", "", () => copyToClipboard(kvpsText))
36 - );
25 + [
26 + { segment: "heading", value: this.cleanHeading(step?.heading) },
27 + { segment: "content", value: step?.content ?? "" }
28 + ].forEach(({ segment, value }) => {
29 + document.querySelector(`[data-segment="${segment}"]`)?.replaceChildren(
30 + createActionButton("copy", "", () => copyToClipboard(value))
31 + );
32 + });
33 + },
34
38 - // content
39 - document.querySelector('[data-segment="content"]')?.replaceChildren(
40 - createActionButton("copy", "", () => copyToClipboard(step.content))
35 + // render copy buttons for individual kvp boxes
36 + renderKvpCopyButton(container, key, value) {
37 + const copyText = this.formatFlatValue(value);
38 + container?.replaceChildren(
39 + createActionButton("copy", "", () => copyToClipboard(copyText))
40 );
41 },
42
@@ -59,7 +58,8 @@ const model = {
58 if (!step) return "";
59 const lines = [];
60 lines.push(`Type: ${step.type || "unknown"}`);
62 - if (step.heading) lines.push(`Heading: ${step.heading}`);
61 + const heading = this.cleanHeading(step.heading);
62 + if (heading) lines.push(`Heading: ${heading}`);
63 if (step.timestamp) {
64 const date = new Date(parseFloat(step.timestamp) * 1000);
65 lines.push(`Timestamp: ${date.toISOString()}`);
@@ -68,9 +68,9 @@ const model = {
68 if (step.kvps) {
69 lines.push("");
70 lines.push("--- Data ---");
71 - for (const [key, value] of Object.entries(step.kvps)) {
72 - const formattedValue = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value);
73 - lines.push(`${key}: ${formattedValue}`);
71 + const flattenedKvps = this.flattenKvps(step.kvps);
72 + for (const [key, value] of Object.entries(flattenedKvps)) {
73 + lines.push(this.buildKvpCopyText(key, value));
74 }
75 }
76 if (step.content) {
@@ -183,9 +183,9 @@ const model = {
183
184 // Format value for display (handles objects)
185 formatValue(value) {
186 - if (value === null || value === undefined) return '';
187 - if (typeof value === 'object') return JSON.stringify(value, null, 2);
188 - return String(value);
186 + return value == null
187 + ? ''
188 + : (typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
189 },
190
191 // Format key for display (title case)
@@ -193,17 +193,33 @@ const model = {
193 return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
194 },
195
196 - // Clean text value (handle arrays, remove brackets)
197 - cleanTextValue(value) {
198 - if (Array.isArray(value)) {
199 - return value
200 - .filter(item => item && String(item).trim() && !/^[\[\]]$/.test(String(item).trim()))
201 - .join('\n');
202 - }
203 - if (typeof value === 'object' && value !== null) {
204 - return JSON.stringify(value, null, 2);
205 - }
206 - return String(value).replace(/^\s*[\[\]]\s*$/gm, '').trim();
196 + // flatten nested kvps into top-level entries
197 + flattenKvps(kvps, parentKey = "") {
198 + const isPlainObject = value => value && typeof value === "object" && !Array.isArray(value);
199 + return Object.entries(kvps || {}).reduce((acc, [key, value]) => {
200 + const fullKey = [parentKey, this.formatKey(key)].filter(Boolean).join(" / ");
201 + return {
202 + ...acc,
203 + ...(isPlainObject(value) ? this.flattenKvps(value, fullKey) : { [fullKey]: value })
204 + };
205 + }, {});
206 + },
207 +
208 + // format values
209 + formatFlatValue(value) {
210 + return Array.isArray(value)
211 + ? value
212 + .map(item => this.formatValue(item))
213 + .filter(item => item.trim())
214 + .join('\n')
215 + : this.formatValue(value);
216 + },
217 +
218 + // build copy text
219 + buildKvpCopyText(key, value) {
220 + const formattedValue = this.formatFlatValue(value);
221 + const separator = formattedValue.includes("\n") ? ":\n" : ": ";
222 + return `${key}${separator}${formattedValue}`;
223 },
224
225 // Clean heading by removing icon:// prefixes