- ${sequences.map((sequence) => {
- const start = clamp(Number(sequence.from || 0), 0, duration - 1);
- const length = clamp(Number(sequence.durationInFrames || sequence.duration || 1), 1, duration - start);
- const left = duration > 1 ? start / (duration - 1) * 100 : 0;
- const width = duration > 1 ? length / (duration - 1) * 100 : 100;
- return `
`;
+
+
+ ${ticks.map((tick) => {
+ const left = duration > 1 ? tick / (duration - 1) * 100 : 0;
+ const obscured = Math.abs(left - percent) < 4.5;
+ return `${tick}`;
}).join('')}
-
-
+
+
+ ${sequences.map((sequence, index) => {
+ const start = clamp(Number(sequence.from || 0), 0, duration - 1);
+ const length = clamp(Number(sequence.durationInFrames || sequence.duration || 1), 1, duration - start);
+ const left = duration > 1 ? start / (duration - 1) * 100 : 0;
+ const width = duration > 1 ? length / (duration - 1) * 100 : 100;
+ const label = sequence.name || sequence.id || `${t('composition')} ${index + 1}`;
+ return ``;
+ }).join('')}
+
+
+
+ ${frame}
+
+
-
0${frame}${duration - 1}
`;
}
diff --git a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/style.css b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/style.css
index fb2f47b8..e56e83b6 100644
--- a/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/style.css
+++ b/bundles/product-apps/builtin-remotion-live/19.0.2/components/surfaces/builtin-remotion-live-surface/source/style.css
@@ -14,8 +14,10 @@
--rl-success: var(--color-success, var(--sparo-success, Highlight));
--rl-warning: var(--color-warning, var(--sparo-warning, Highlight));
--rl-error: var(--color-error, var(--sparo-error, Highlight));
- --rl-canvas: color-mix(in srgb, var(--rl-bg) 58%, black);
- --rl-stage: color-mix(in srgb, var(--rl-canvas) 72%, black);
+ --rl-canvas: var(--rl-bg);
+ --rl-stage: color-mix(in srgb, var(--rl-bg) 96%, var(--rl-text));
+ --rl-timeline-track: color-mix(in srgb, var(--rl-control) 76%, var(--rl-bg));
+ --rl-hairline: color-mix(in srgb, var(--rl-border-subtle) 72%, transparent);
--rl-focus: color-mix(in srgb, var(--rl-accent) 58%, transparent);
--rl-radius-sm: var(--size-radius-sm, var(--sparo-app-radius-sm, 6px));
--rl-radius: var(--size-radius-base, var(--sparo-app-radius, 10px));
@@ -78,53 +80,126 @@ select:focus-visible,
.rl-header {
display: grid;
- grid-template-columns: minmax(0, 1fr) auto auto;
+ grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
- min-height: 48px;
- padding: 7px 10px;
- border-bottom: 1px solid var(--rl-border-subtle);
- background: var(--rl-surface);
+ min-height: 58px;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--rl-hairline);
+ background: color-mix(in srgb, var(--rl-surface) 88%, var(--rl-bg));
}
.rl-header__identity,
.rl-header__actions {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+}
+
+.rl-header__identity { gap: 0; }
+
+.rl-header__title-group {
+ display: grid;
min-width: 0;
+ gap: 2px;
}
.rl-header__workspace,
.rl-header__composition {
overflow: hidden;
- color: var(--rl-text-secondary);
- font-size: 13px;
- font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
-.rl-header__workspace { max-width: 18ch; }
-.rl-header__composition { max-width: 22ch; color: var(--rl-text); }
-.rl-header__slash { color: var(--rl-muted); }
+.rl-header__workspace {
+ max-width: 24ch;
+ color: var(--rl-muted);
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: .01em;
+}
+
+.rl-header__composition-row {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+}
+
+.rl-header__composition {
+ max-width: 32ch;
+ color: var(--rl-text);
+ font-size: 13.5px;
+ font-weight: 600;
+}
+
+.rl-header__meta-row {
+ display: flex;
+ align-items: baseline;
+ min-width: 0;
+ gap: 7px;
+}
+
+.rl-header__format {
+ overflow: hidden;
+ color: var(--rl-muted);
+ font-family: var(--rl-mono);
+ font-size: 9px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
.rl-composition-select {
- width: min(220px, 28vw);
+ width: min(220px, 24vw);
min-width: 0;
- height: 30px;
- padding: 0 26px 0 8px;
+ height: 22px;
+ padding: 0 22px 0 0;
border: 1px solid transparent;
- border-radius: var(--rl-radius-sm);
- background: var(--rl-control);
+ border-radius: 4px;
+ background-color: transparent;
color: var(--rl-text);
cursor: pointer;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.rl-composition-select:hover {
+ border-color: var(--rl-hairline);
+ background-color: var(--rl-control);
+}
+
+.rl-header__actions {
+ justify-self: end;
+ gap: 4px;
+}
+
+.rl-header__action-group {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+}
+
+.rl-header__output-actions {
+ margin-left: 4px;
+ padding-left: 8px;
+ border-left: 1px solid var(--rl-hairline);
}
-.rl-composition-select:hover { border-color: var(--rl-border); }
-.rl-header__status { min-width: 0; }
-.rl-status__label { overflow: hidden; max-width: 18ch; text-overflow: ellipsis; white-space: nowrap; }
-.rl-header__actions { justify-self: end; }
.rl-labelled-action { white-space: nowrap; }
.rl-expand-action { display: none; }
+.rl-live-region {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+}
+
.rl-status-strip {
position: relative;
overflow: hidden;
@@ -158,7 +233,7 @@ select:focus-visible,
.rl-workbench {
display: grid;
- grid-template-rows: minmax(180px, 1fr) auto auto auto;
+ grid-template-rows: minmax(200px, 1fr) auto auto auto;
width: 100%;
height: 100%;
min-width: 0;
@@ -172,11 +247,9 @@ select:focus-visible,
justify-content: center;
min-width: 0;
min-height: 0;
- padding: 16px;
+ padding: clamp(18px, 3vw, 34px);
overflow: hidden;
- background:
- radial-gradient(circle at center, color-mix(in srgb, var(--rl-text) 5%, transparent), transparent 66%),
- var(--rl-canvas);
+ background: var(--rl-canvas);
}
.rl-stage {
@@ -185,15 +258,19 @@ select:focus-visible,
max-width: 100%;
max-height: 100%;
overflow: hidden;
- border: 1px solid color-mix(in srgb, var(--rl-text) 16%, transparent);
- border-radius: var(--rl-radius-sm);
+ border: 1px solid color-mix(in srgb, var(--rl-border) 54%, transparent);
+ border-radius: var(--rl-radius);
background: var(--rl-stage);
- box-shadow: 0 12px 40px color-mix(in srgb, black 34%, transparent);
+ box-shadow:
+ 0 1px 2px color-mix(in srgb, var(--rl-text) 9%, transparent),
+ 0 18px 48px color-mix(in srgb, var(--rl-text) 13%, transparent);
}
.rl-stage.is-inspecting {
border-color: color-mix(in srgb, var(--rl-accent) 72%, transparent);
- box-shadow: 0 0 0 1px color-mix(in srgb, var(--rl-accent) 24%, transparent), 0 12px 40px color-mix(in srgb, black 34%, transparent);
+ box-shadow:
+ 0 0 0 2px color-mix(in srgb, var(--rl-accent) 16%, transparent),
+ 0 18px 48px color-mix(in srgb, var(--rl-text) 13%, transparent);
}
.rl-player-frame,
@@ -240,20 +317,6 @@ select:focus-visible,
pointer-events: none;
}
-.rl-stage-meta {
- position: absolute;
- inset: auto 8px 8px 8px;
- z-index: var(--rl-z-overlay);
- display: flex;
- justify-content: space-between;
- gap: 8px;
- color: color-mix(in srgb, white 78%, transparent);
- font-family: var(--rl-mono);
- font-size: 10px;
- pointer-events: none;
- text-shadow: 0 1px 3px black;
-}
-
.rl-preview-click-layer {
position: absolute;
inset: 0;
@@ -345,7 +408,7 @@ select:focus-visible,
min-width: 0;
min-height: 38px;
padding: 5px 10px;
- border-top: 1px solid var(--rl-border-subtle);
+ border-top: 1px solid var(--rl-hairline);
background: color-mix(in srgb, var(--rl-accent) 7%, var(--rl-surface));
color: var(--rl-text-secondary);
font-size: 12px;
@@ -368,10 +431,10 @@ select:focus-visible,
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
- gap: 12px;
- min-height: 54px;
- padding: 7px 10px;
- border-top: 1px solid var(--rl-border-subtle);
+ gap: 14px;
+ min-height: 52px;
+ padding: 7px 14px;
+ border-top: 1px solid var(--rl-hairline);
background: var(--rl-surface);
}
@@ -383,9 +446,9 @@ select:focus-visible,
}
.rl-play-btn {
- width: 40px;
- height: 40px;
- border-radius: 50%;
+ width: 36px;
+ height: 36px;
+ border-radius: 9px;
}
.rl-play-btn.is-pending { opacity: .72; }
@@ -394,8 +457,13 @@ select:focus-visible,
display: flex;
align-items: center;
justify-content: center;
- gap: 10px;
+ justify-self: center;
+ gap: 8px;
min-width: 0;
+ padding: 3px 5px 3px 10px;
+ border: 1px solid var(--rl-hairline);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--rl-control) 70%, transparent);
font-family: var(--rl-mono);
font-variant-numeric: tabular-nums;
}
@@ -404,44 +472,52 @@ select:focus-visible,
color: var(--rl-text);
font-size: 13px;
font-weight: 600;
- letter-spacing: .03em;
+ letter-spacing: .045em;
}
.rl-timecode__frame {
display: inline-flex;
align-items: center;
+ gap: 3px;
+ padding-left: 7px;
+ border-left: 1px solid var(--rl-hairline);
color: var(--rl-muted);
font-size: 11px;
white-space: nowrap;
}
.rl-timecode__frame input {
- width: 54px;
- height: 28px;
- padding: 0 5px;
- border: 1px solid var(--rl-border-subtle);
- border-radius: var(--rl-radius-sm);
- background: var(--rl-control);
+ width: 48px;
+ height: 24px;
+ padding: 0 3px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ background: transparent;
color: var(--rl-text);
text-align: right;
font-family: var(--rl-mono);
}
+.rl-timecode__frame input:hover,
+.rl-timecode__frame input:focus-visible {
+ border-color: var(--rl-hairline);
+ background: var(--rl-surface);
+}
+
.rl-volume {
- width: 76px;
+ width: 68px;
accent-color: var(--rl-accent);
cursor: pointer;
}
.rl-review {
min-width: 0;
- padding: 8px 10px 10px;
- border-top: 1px solid var(--rl-border-subtle);
- background: var(--rl-panel);
+ padding: 0 14px 13px;
+ border-top: 1px solid var(--rl-hairline);
+ background: var(--rl-surface);
}
-.rl-review__header,
-.rl-review__ticks {
+.rl-review__header {
display: flex;
align-items: center;
justify-content: space-between;
@@ -449,7 +525,7 @@ select:focus-visible,
}
.rl-review__header {
- margin-bottom: 6px;
+ min-height: 32px;
color: var(--rl-muted);
font-size: 10px;
}
@@ -460,79 +536,161 @@ select:focus-visible,
font-weight: 600;
}
+.rl-review__summary {
+ flex: 0 0 auto;
+ font-family: var(--rl-mono);
+ font-size: 9px;
+}
+
+.rl-timeline-shell {
+ position: relative;
+ min-width: 0;
+}
+
+.rl-review__ticks {
+ position: relative;
+ height: 17px;
+ color: var(--rl-muted);
+ font-family: var(--rl-mono);
+ font-size: 8px;
+ font-variant-numeric: tabular-nums;
+}
+
+.rl-review__ticks span {
+ position: absolute;
+ top: 0;
+ transform: translateX(-50%);
+}
+
+.rl-review__ticks span:first-child { transform: none; }
+.rl-review__ticks span:last-child { transform: translateX(-100%); }
+.rl-review__ticks span.is-obscured { visibility: hidden; }
+
+.rl-review__ticks span::after {
+ content: "";
+ position: absolute;
+ top: 11px;
+ left: 50%;
+ width: 1px;
+ height: 4px;
+ background: var(--rl-border);
+ opacity: .58;
+}
+
+.rl-review__ticks span:first-child::after { left: 0; }
+.rl-review__ticks span:last-child::after { left: 100%; }
+
.rl-review-track {
position: relative;
- height: 28px;
+ height: 32px;
}
.rl-review-track::before {
content: "";
position: absolute;
- inset: 10px 0;
- border-radius: 999px;
- background: var(--rl-control);
+ inset: 3px 0;
+ border: 1px solid var(--rl-hairline);
+ border-radius: 6px;
+ background: var(--rl-timeline-track);
}
.rl-review-track__segments {
position: absolute;
- inset: 8px 0;
+ inset: 7px 0;
pointer-events: none;
}
.rl-review-track__segments span {
position: absolute;
- height: 12px;
- border-left: 1px solid color-mix(in srgb, var(--rl-accent) 58%, transparent);
- background: color-mix(in srgb, var(--rl-accent) 13%, transparent);
+ height: 18px;
+ min-width: 2px;
+ overflow: hidden;
+ border: 1px solid color-mix(in srgb, var(--rl-accent) 34%, transparent);
+ border-radius: 3px;
+ background: color-mix(in srgb, var(--rl-accent) 14%, var(--rl-surface));
+}
+
+.rl-review-track__segments span:nth-child(even) {
+ background: color-mix(in srgb, var(--rl-accent) 9%, var(--rl-surface));
}
.rl-review-scrub {
position: absolute;
inset: 0;
width: 100%;
- height: 28px;
+ height: 32px;
margin: 0;
appearance: none;
background: transparent;
cursor: ew-resize;
+ opacity: 0;
}
.rl-review-scrub::-webkit-slider-runnable-track {
- height: 4px;
- border-radius: 999px;
- background: linear-gradient(to right, var(--rl-accent) 0 var(--rl-progress), transparent var(--rl-progress) 100%);
+ height: 32px;
+ background: transparent;
}
.rl-review-scrub::-webkit-slider-thumb {
- width: 14px;
- height: 14px;
- margin-top: -5px;
+ width: 16px;
+ height: 32px;
appearance: none;
- border: 2px solid var(--rl-panel);
- border-radius: 50%;
- background: var(--rl-accent);
- box-shadow: 0 0 0 1px var(--rl-accent);
+ border: 0;
+ background: transparent;
}
-.rl-review-scrub::-moz-range-track { height: 4px; border-radius: 999px; background: var(--rl-control); }
-.rl-review-scrub::-moz-range-progress { height: 4px; border-radius: 999px; background: var(--rl-accent); }
-.rl-review-scrub::-moz-range-thumb { width: 12px; height: 12px; border: 2px solid var(--rl-panel); border-radius: 50%; background: var(--rl-accent); }
+.rl-review-scrub::-moz-range-track { height: 32px; background: transparent; }
+.rl-review-scrub::-moz-range-progress { height: 32px; background: transparent; }
+.rl-review-scrub::-moz-range-thumb { width: 16px; height: 32px; border: 0; background: transparent; }
+
+.rl-review-scrub:focus-visible {
+ outline: 2px solid var(--rl-focus);
+ outline-offset: 2px;
+ opacity: 1;
+}
.rl-review-playhead {
position: absolute;
- top: 2px;
+ top: 0;
+ z-index: var(--rl-z-overlay);
width: 1px;
- height: 24px;
+ height: 32px;
background: var(--rl-accent);
pointer-events: none;
}
-.rl-review__ticks {
- color: var(--rl-muted);
+.rl-review-playhead::after {
+ content: "";
+ position: absolute;
+ top: 1px;
+ left: 50%;
+ width: 6px;
+ height: 6px;
+ border-radius: 1px;
+ background: var(--rl-accent);
+ transform: translateX(-50%) rotate(45deg);
+}
+
+.rl-review-playhead__label {
+ position: absolute;
+ bottom: calc(100% + 3px);
+ left: 50%;
+ min-width: 18px;
+ padding: 1px 4px;
+ border-radius: 3px;
+ background: var(--rl-accent);
+ color: var(--color-text-on-accent, white);
font-family: var(--rl-mono);
- font-size: 9px;
+ font-size: 8px;
+ font-weight: 600;
+ line-height: 1.35;
+ text-align: center;
+ transform: translateX(-50%);
}
+.rl-review-playhead.is-start .rl-review-playhead__label { transform: none; }
+.rl-review-playhead.is-end .rl-review-playhead__label { transform: translateX(-100%); }
+
.rl-empty,
.rl-detection {
width: min(520px, calc(100% - 32px));
@@ -591,49 +749,54 @@ select:focus-visible,
@media (max-width: 719px) {
.rl-expand-action { display: inline-flex; }
.rl-header {
- grid-template-columns: minmax(0, 1fr) auto auto;
+ grid-template-columns: minmax(0, 1fr) auto;
gap: 7px;
padding-inline: 8px;
}
- .rl-header__workspace { max-width: 12ch; }
+ .rl-header__workspace { max-width: 16ch; }
.rl-header__composition { max-width: 14ch; }
.rl-composition-select { width: min(150px, 26vw); }
+ .rl-header__format { display: none; }
.rl-header__actions { gap: 2px; }
.rl-labelled-action span { display: none; }
.rl-labelled-action[data-action="start-export"] span { display: inline; }
- .rl-stage-area { padding: 8px; }
+ .rl-stage-area { padding: 14px; }
.rl-volume { display: none; }
- .rl-transport { gap: 6px; padding-inline: 7px; }
+ .rl-transport { gap: 8px; padding-inline: 10px; }
.rl-timecode { gap: 6px; }
.rl-timecode__frame input { width: 46px; }
+ .rl-review { padding-inline: 10px; }
}
@media (max-width: 479px) {
.remotion-live { grid-template-rows: auto 2px minmax(0, 1fr); }
.rl-header {
- grid-template-columns: minmax(0, 1fr) auto;
- grid-template-areas: "identity status" "actions actions";
- min-height: 76px;
- padding: 5px 7px;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-areas: "identity" "actions";
+ min-height: 82px;
+ padding: 7px 8px 6px;
}
.rl-header__identity { grid-area: identity; }
- .rl-header__status { grid-area: status; }
- .rl-header__actions { grid-area: actions; justify-self: stretch; justify-content: flex-end; }
- .rl-header__workspace { max-width: 9ch; }
- .rl-header__composition { max-width: 11ch; }
+ .rl-header__actions {
+ grid-area: actions;
+ justify-self: stretch;
+ justify-content: flex-end;
+ padding-top: 4px;
+ border-top: 1px solid var(--rl-hairline);
+ }
+ .rl-header__workspace { max-width: 14ch; }
+ .rl-header__composition { max-width: 12ch; }
.rl-composition-select { width: min(130px, 37vw); }
- .rl-status__label { max-width: 10ch; }
- .rl-workbench { grid-template-rows: minmax(140px, 1fr) auto auto auto; }
- .rl-stage-area { padding: 5px; }
- .rl-stage-meta span:first-child { display: none; }
+ .rl-workbench { grid-template-rows: minmax(150px, 1fr) auto auto auto; }
+ .rl-stage-area { padding: 9px; }
.rl-transport { grid-template-columns: auto minmax(0, 1fr) auto; min-height: 50px; }
- .rl-timecode { flex-direction: column; gap: 1px; }
.rl-timecode__frame { display: none; }
- .rl-review { padding-inline: 7px; }
- .rl-review__header > span { display: none; }
+ .rl-review { padding-inline: 8px; }
+ .rl-review__summary { display: none; }
+ .rl-review__ticks span:nth-child(even) { display: none; }
.rl-context-tray { align-items: flex-start; }
.rl-context-tray__copy { align-items: flex-start; flex-direction: column; gap: 3px; }
}
@@ -641,14 +804,12 @@ select:focus-visible,
@media (max-width: 359px) {
.rl-header { gap: 4px; padding-inline: 5px; }
.rl-header__workspace { max-width: 7ch; }
- .rl-header__slash { display: none; }
.rl-header__composition { max-width: 8ch; }
.rl-composition-select { width: 96px; }
.rl-header__actions { justify-content: space-between; }
.rl-header__actions .btn { min-width: 30px; height: 30px; padding-inline: 7px; }
.rl-header__actions [data-action="start-export"] { padding-inline: 8px; font-size: 12px; }
- .rl-status__label { max-width: 8ch; }
- .rl-stage-area { padding: 3px; }
+ .rl-stage-area { padding: 6px; }
.rl-stage { border-radius: 3px; }
.rl-transport { grid-template-columns: auto 1fr auto; gap: 3px; padding-inline: 4px; }
.rl-transport__playback { gap: 0; }
@@ -656,9 +817,7 @@ select:focus-visible,
.rl-play-btn { width: 36px; height: 36px; min-height: 36px; }
.rl-timecode__value { font-size: 11px; }
.rl-transport__audio .btn { width: 30px; min-height: 30px; }
- .rl-review { padding-top: 6px; padding-bottom: 7px; }
- .rl-review__header { margin-bottom: 2px; }
- .rl-review-track { height: 25px; }
+ .rl-review { padding-bottom: 8px; }
.rl-context-tray { padding-inline: 6px; }
}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/app.json b/bundles/product-apps/builtin-spark-board/1.0.0/app.json
deleted file mode 100644
index cf9fe304..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/app.json
+++ /dev/null
@@ -1,220 +0,0 @@
-{
- "id": "builtin-spark-board",
- "version": "1.0.0",
- "name": "Spark Board",
- "description": "A lightweight thinking canvas for capturing inspiration, organizing threads, and turning scattered ideas into drafts and next steps.",
- "authors": [
- {
- "name": "GCWing",
- "url": "https://github.com/GCWing"
- }
- ],
- "i18n": {
- "locales": {
- "en-US": {
- "name": "Spark Board",
- "description": "A lightweight thinking canvas for capturing inspiration, organizing threads, and turning scattered ideas into drafts and next steps.",
- "tags": [
- "built-in",
- "canvas",
- "ideation",
- "drafting"
- ]
- },
- "zh-CN": {
- "name": "Spark Board",
- "description": "轻量思考画布,用来捕捉灵感、梳理脉络,把零散想法沉淀成草稿和下一步。",
- "tags": [
- "内置",
- "画布",
- "灵感",
- "草稿"
- ]
- }
- }
- },
- "interactionModel": "interactiveWorkspace",
- "workMultiplicity": "singleton",
- "workObjectKinds": [
- {
- "id": "board",
- "label": "Board",
- "scope": "asset",
- "identitySchema": {
- "type": "object",
- "properties": {
- "boardId": {
- "type": "string"
- },
- "workspacePath": {
- "type": "string"
- }
- }
- },
- "contextSchema": {
- "type": "object",
- "properties": {
- "title": {
- "type": "string"
- },
- "clusters": {
- "type": "array"
- },
- "selection": {
- "type": "array"
- }
- }
- }
- },
- {
- "id": "idea-card",
- "label": "Idea Card",
- "scope": "asset",
- "identitySchema": {
- "type": "object",
- "properties": {
- "boardId": {
- "type": "string"
- },
- "cardId": {
- "type": "string"
- }
- }
- },
- "contextSchema": {
- "type": "object",
- "properties": {
- "text": {
- "type": "string"
- },
- "clusterId": {
- "type": "string"
- },
- "position": {
- "type": "object"
- }
- }
- }
- },
- {
- "id": "draft",
- "label": "Draft",
- "scope": "asset",
- "identitySchema": {
- "type": "object",
- "properties": {
- "draftId": {
- "type": "string"
- },
- "artifactId": {
- "type": "string"
- }
- }
- },
- "contextSchema": {
- "type": "object",
- "properties": {
- "sourceCards": {
- "type": "array"
- },
- "format": {
- "type": "string"
- },
- "status": {
- "type": "string"
- }
- }
- }
- }
- ],
- "truthSource": "ownedObjectState",
- "primarySurface": {
- "componentId": "builtin-spark-board-surface",
- "surfaceId": "primary"
- },
- "primarySurfaceMode": "immersivePrimary",
- "components": [
- {
- "componentId": "builtin-spark-board-surface",
- "kind": "surface",
- "source": "private",
- "role": "primarySurface",
- "capabilities": [
- "interactiveSurface"
- ],
- "usesCapabilities": [
- "session.read",
- "component.run"
- ]
- },
- {
- "componentId": "builtin-spark-board-agent",
- "kind": "agent",
- "source": "private",
- "role": "assistant",
- "capabilities": [
- "expandIdeas",
- "draftFromSelection"
- ],
- "usesCapabilities": [
- "session.read",
- "model.invoke",
- "tool.invoke"
- ]
- },
- {
- "componentId": "product-app-runtime-host",
- "kind": "runtime",
- "source": "shared",
- "role": "runtimeHost",
- "version": "^1.0.0",
- "capabilities": [
- "uiWorker"
- ],
- "usesCapabilities": [
- "component.run"
- ]
- }
- ],
- "componentLockId": "",
- "permissions": {
- "fs": true,
- "net": false,
- "shell": false,
- "gui": false,
- "secrets": false,
- "ai": true
- },
- "installScope": "system",
- "catalogVisibility": "discoverable",
- "enabled": true,
- "icon": {
- "kind": "packageAsset",
- "path": "assets/icon.png"
- },
- "category": "creative",
- "tags": [
- "built-in",
- "canvas",
- "ideation",
- "drafting"
- ],
- "launch": {
- "kind": "applicationSurface",
- "targetId": "builtin-spark-board",
- "scopeRequirement": "workspaceOptional",
- "surfaceId": "primary"
- },
- "osCapabilities": [
- "session.read",
- "component.run",
- "model.invoke",
- "tool.invoke"
- ],
- "dataLifecycle": {
- "retention": "userManaged",
- "deletion": "deleteOnUserRequest",
- "migration": "schemaVersioned",
- "share": "declaredWorkObjectsOnly"
- }
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/assets/icon.png b/bundles/product-apps/builtin-spark-board/1.0.0/assets/icon.png
deleted file mode 100644
index b3be67c8..00000000
Binary files a/bundles/product-apps/builtin-spark-board/1.0.0/assets/icon.png and /dev/null differ
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/assets/icon.svg b/bundles/product-apps/builtin-spark-board/1.0.0/assets/icon.svg
deleted file mode 100644
index b09559bd..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/assets/icon.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/compatibility.json b/bundles/product-apps/builtin-spark-board/1.0.0/compatibility.json
deleted file mode 100644
index 7ea934b0..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/compatibility.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "runtimeCompatibility": ">=0.1.0"
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/components/agents/builtin-spark-board-agent/component.json b/bundles/product-apps/builtin-spark-board/1.0.0/components/agents/builtin-spark-board-agent/component.json
deleted file mode 100644
index 808ab5f4..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/components/agents/builtin-spark-board-agent/component.json
+++ /dev/null
@@ -1,52 +0,0 @@
-{
- "id": "builtin-spark-board-agent",
- "kind": "agent",
- "name": "Spark Board Agent",
- "description": "Creative thinking agent for expanding, challenging, clustering, summarizing, and drafting from selected canvas notes.",
- "packageSource": "appPrivate",
- "ownerApp": {
- "appId": "builtin-spark-board",
- "appVersion": "1.0.0"
- },
- "capabilities": [
- {
- "id": "expandIdeas",
- "title": "Expand ideas",
- "description": "Generate related ideas, counterpoints, and clusters for selected notes.",
- "actions": [
- "expand",
- "challenge",
- "cluster"
- ]
- },
- {
- "id": "draftFromSelection",
- "title": "Draft from selection",
- "description": "Turn selected canvas notes into a send-ready draft.",
- "actions": [
- "summarizeSelection",
- "draft"
- ]
- }
- ],
- "permissions": [
- {
- "kind": "ai",
- "summary": "Uses the configured AI agent for canvas ideation and drafting.",
- "scopes": [
- "Runno"
- ]
- }
- ],
- "usedByApps": [
- "builtin-spark-board"
- ],
- "visibility": "appDependency",
- "dependencies": [],
- "implementationRef": "agent://Runno",
- "usesCapabilities": [
- "session.read",
- "model.invoke",
- "tool.invoke"
- ]
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/component.json b/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/component.json
deleted file mode 100644
index 3affb639..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/component.json
+++ /dev/null
@@ -1,64 +0,0 @@
-{
- "id": "builtin-spark-board-surface",
- "kind": "surface",
- "name": "Spark Board Canvas",
- "description": "The app-owned idea canvas, clustering, selection, and draft creation surface.",
- "packageSource": "appPrivate",
- "ownerApp": {
- "appId": "builtin-spark-board",
- "appVersion": "1.0.0"
- },
- "capabilities": [
- {
- "id": "interactiveSurface",
- "title": "Interactive surface",
- "description": "Hosts the Spark Board primary workspace.",
- "actions": [
- "open",
- "createCard",
- "clusterIdeas",
- "draftFromSelection"
- ]
- }
- ],
- "permissions": [
- {
- "kind": "fs",
- "summary": "Reads and writes app-owned canvas state.",
- "scopes": [
- "{appdata}"
- ]
- }
- ],
- "usedByApps": [
- "builtin-spark-board"
- ],
- "visibility": "appDependency",
- "dependencies": [
- {
- "componentId": "builtin-spark-board-agent",
- "kind": "agent",
- "source": "private",
- "role": "assistant",
- "capabilities": [
- "expandIdeas",
- "draftFromSelection"
- ]
- },
- {
- "componentId": "product-app-runtime-host",
- "kind": "runtime",
- "source": "shared",
- "role": "runtimeHost",
- "version": "^1.0.0",
- "capabilities": [
- "uiWorker"
- ]
- }
- ],
- "implementationRef": "app://builtin-spark-board@1.0.0/surfaces/builtin-spark-board-surface",
- "usesCapabilities": [
- "session.read",
- "component.run"
- ]
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/esm_dependencies.json b/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/esm_dependencies.json
deleted file mode 100644
index fe51488c..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/esm_dependencies.json
+++ /dev/null
@@ -1 +0,0 @@
-[]
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/index.html b/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/index.html
deleted file mode 100644
index 0918f91f..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/index.html
+++ /dev/null
@@ -1,177 +0,0 @@
-
-
-
-
-
-
Spark Board
-
-
-
-
-
-
-
-
-
Spark Board
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Drop in a thought.
-
AI will turn it into movable cards, links, and a send-ready output.
-
Try: write a sentence below, then press Ask AI.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
How are they related?
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
0
-
0
-
0
-
-
-
-
-
-
-
-
-
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/source_manifest.json b/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/source_manifest.json
deleted file mode 100644
index 067c84e8..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/source_manifest.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "uiEntry": "ui.js",
- "workerEntry": "worker.js",
- "styleEntries": [
- "style.css"
- ],
- "buildMode": "nativeEsm"
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/src/i18n.js b/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/src/i18n.js
deleted file mode 100644
index a6905a99..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/src/i18n.js
+++ /dev/null
@@ -1,220 +0,0 @@
-export const STRINGS = {
- 'en-US': {
- eyebrow: 'AI Thinking Canvas',
- title: 'Spark Board',
- boardLabel: 'Board',
- boardTitleLabel: 'Title',
- boardTitlePlaceholder: 'Board title',
- newBoard: 'New board',
- untitledBoard: 'Untitled board',
- defaultBoardTitle: 'First sparks',
- newBoardTitle: 'New board',
- boardCreated: 'New board created.',
- boardRenamed: 'Board renamed.',
- exportMarkdown: 'Export Markdown',
- exported: 'Board Markdown copied.',
- sendDraft: 'Send to chat',
- sentToChat: 'Draft moved to the chat input.',
- sendUnavailable: 'Chat input is unavailable. Draft copied instead.',
- copyDraft: 'Copy draft',
- reset: 'Reset',
- seedLabel: 'Ask AI',
- seedPlaceholder: 'Drop in a thought, or select cards and ask: expand this, find risks, turn it into an email...',
- composerHint: 'Press Ctrl+Enter to ask',
- sparkIdeas: 'Ask AI',
- addCard: 'Add note',
- aiTitle: 'AI collaborator',
- expand: 'Expand',
- cluster: 'Cluster',
- challenge: 'Challenge',
- draft: 'Draft',
- aiScopeNote: 'AI only sees selected cards, related connections, and the topic field.',
- ready: 'Ready.',
- sendTitle: 'Send-ready output',
- formatLabel: 'Output format',
- formatMessage: 'Message',
- formatEmail: 'Email',
- formatIssue: 'Issue',
- formatPrd: 'PRD note',
- formatPost: 'Post',
- formatMarkdown: 'Markdown',
- relationsTitle: 'Connections',
- relationLabel: 'Relationship',
- relationSupports: 'Supports',
- relationChallenges: 'Challenges',
- relationExpands: 'Expands',
- relationBecomes: 'Becomes',
- connectCards: 'Connect selected',
- needTwoCards: 'Select exactly two cards to connect.',
- connected: 'Connection added.',
- connectionRemoved: 'Connection removed.',
- removeConnection: 'Remove connection',
- draftPlaceholder: 'Select cards and choose Draft, or write here directly.',
- canvasTitle: 'Canvas',
- canvasHint: 'Select cards, then ask AI what to do next.',
- emptyTitle: 'Drop in a thought.',
- emptyBody: 'AI will turn it into movable cards, links, and a send-ready output.',
- modeFree: 'Free',
- modeThinking: 'Thinking',
- modeSend: 'Send',
- laneInput: 'Input',
- laneThink: 'Thinking',
- laneSend: 'Send',
- selected: 'selected',
- emptyInput: 'Write a topic or card first.',
- needSelection: 'Select one or more cards first.',
- thinking: 'AI is thinking...',
- copied: 'Draft copied.',
- copyEmpty: 'No draft to copy yet.',
- resetDone: 'Board reset.',
- aiFailed: 'AI request failed. A local card was added instead.',
- generated: 'AI added cards to the canvas.',
- drafted: 'Draft is ready.',
- cardIdea: 'Idea',
- cardQuestion: 'Question',
- cardInsight: 'Insight',
- cardOutput: 'Output',
- outputCardTitle: 'Send-ready output',
- cardAssumption: 'Assumption',
- cardCounterpoint: 'Counterpoint',
- cardSource: 'Source',
- remove: 'Remove card',
- openPanel: 'Open panel',
- closePanel: 'Close panel',
- paneDraft: 'Draft',
- paneSettings: 'Settings',
- cardActionExpand: 'Expand this card',
- cardActionChallenge: 'Challenge this card',
- cardActionConnect: 'Drag to connect',
- cardActionDraft: 'Draft from this card',
- relationPickerTitle: 'How are they related?',
- cancel: 'Cancel',
- deleteConnection: 'Delete connection',
- emptyHintAction: 'Try: write a sentence below, then press Ask AI.',
- drawerTitleDraft: 'Send-ready draft',
- drawerHintDraft: 'AI drafts land here. Edit, then send.',
- drawerEmptyDraft: 'No draft yet. Select cards and ask "draft" to fill this in.',
- boardSettings: 'Board settings',
- resetBoardConfirm: 'Reset board?',
- inputHint: 'Drop a thought, or ask AI what to do next',
- deleteBoard: 'Delete board',
- boardDeleted: 'Board deleted.',
- cancelAi: 'Cancel AI',
- cancelled: 'Cancelled.',
- thinkingHint: 'AI is sketching ideas…',
- tidy: 'Tidy',
- tidied: 'Board tidied.',
- zoomIn: 'Zoom in',
- zoomOut: 'Zoom out',
- zoomFit: 'Fit to content'
- },
- 'zh-CN': {
- eyebrow: 'AI \u601d\u8003\u753b\u5e03',
- title: 'Spark Board',
- boardLabel: '\u753b\u5e03',
- boardTitleLabel: '\u6807\u9898',
- boardTitlePlaceholder: '\u753b\u5e03\u6807\u9898',
- newBoard: '\u65b0\u5efa\u753b\u5e03',
- untitledBoard: '\u672a\u547d\u540d\u753b\u5e03',
- defaultBoardTitle: '\u7b2c\u4e00\u4e2a\u7075\u611f',
- newBoardTitle: '\u65b0\u753b\u5e03',
- boardCreated: '\u65b0\u753b\u5e03\u5df2\u521b\u5efa\u3002',
- boardRenamed: '\u753b\u5e03\u5df2\u91cd\u547d\u540d\u3002',
- exportMarkdown: '\u5bfc\u51fa Markdown',
- exported: 'Markdown \u5df2\u590d\u5236\u3002',
- sendDraft: '\u53d1\u9001\u5230\u5bf9\u8bdd',
- sentToChat: '\u8349\u7a3f\u5df2\u653e\u5165\u5bf9\u8bdd\u8f93\u5165\u6846\u3002',
- sendUnavailable: '\u5bf9\u8bdd\u8f93\u5165\u4e0d\u53ef\u7528\uff0c\u5df2\u6539\u4e3a\u590d\u5236\u8349\u7a3f\u3002',
- copyDraft: '\u590d\u5236\u8349\u7a3f',
- reset: '\u91cd\u7f6e',
- seedLabel: '\u8be2\u95ee AI',
- seedPlaceholder: '\u4e22\u8fdb\u4e00\u4e2a\u60f3\u6cd5\uff0c\u6216\u9009\u4e2d\u5361\u7247\u540e\u8bf4\uff1a\u6269\u5c55\u8fd9\u4e2a\u65b9\u5411\u3001\u627e\u98ce\u9669\u3001\u5199\u6210\u90ae\u4ef6...',
- composerHint: '\u6309 Ctrl+Enter \u8be2\u95ee AI',
- sparkIdeas: '\u8be2\u95ee AI',
- addCard: '\u6dfb\u52a0\u7b14\u8bb0',
- aiTitle: 'AI \u534f\u4f5c\u8005',
- expand: '\u6269\u5c55',
- cluster: '\u805a\u7c7b',
- challenge: '\u8d28\u7591',
- draft: '\u6210\u7a3f',
- aiScopeNote: 'AI \u53ea\u4f1a\u8bfb\u53d6\u9009\u4e2d\u5361\u7247\u3001\u76f8\u5173\u5173\u7cfb\u548c\u4e3b\u9898\u8f93\u5165\u3002',
- ready: '\u5c31\u7eea\u3002',
- sendTitle: '\u53ef\u53d1\u9001\u8f93\u51fa',
- formatLabel: '\u8f93\u51fa\u683c\u5f0f',
- formatMessage: '\u6d88\u606f',
- formatEmail: '\u90ae\u4ef6',
- formatIssue: 'Issue',
- formatPrd: 'PRD \u7247\u6bb5',
- formatPost: '\u5e16\u5b50',
- formatMarkdown: 'Markdown',
- relationsTitle: '\u5173\u7cfb\u7ebf',
- relationLabel: '\u5173\u7cfb',
- relationSupports: '\u652f\u6301',
- relationChallenges: '\u8d28\u7591',
- relationExpands: '\u6269\u5c55',
- relationBecomes: '\u8f6c\u4e3a',
- connectCards: '\u8fde\u63a5\u9009\u4e2d\u5361\u7247',
- needTwoCards: '\u8bf7\u53ea\u9009\u4e2d\u4e24\u5f20\u5361\u7247\u6765\u5efa\u7acb\u5173\u7cfb\u3002',
- connected: '\u5173\u7cfb\u7ebf\u5df2\u6dfb\u52a0\u3002',
- connectionRemoved: '\u5173\u7cfb\u7ebf\u5df2\u79fb\u9664\u3002',
- removeConnection: '\u79fb\u9664\u5173\u7cfb',
- draftPlaceholder: '\u9009\u4e2d\u5361\u7247\u540e\u70b9\u201c\u6210\u7a3f\u201d\uff0c\u4e5f\u53ef\u4ee5\u76f4\u63a5\u5728\u8fd9\u91cc\u5199\u3002',
- canvasTitle: '\u753b\u5e03',
- canvasHint: '\u9009\u4e2d\u5361\u7247\u540e\uff0c\u76f4\u63a5\u8ba9 AI \u63a8\u8fdb\u4e0b\u4e00\u6b65\u3002',
- emptyTitle: '\u4e22\u8fdb\u4e00\u4e2a\u60f3\u6cd5\u3002',
- emptyBody: 'AI \u4f1a\u628a\u5b83\u53d8\u6210\u53ef\u79fb\u52a8\u7684\u5361\u7247\u3001\u5173\u7cfb\u548c\u53ef\u53d1\u9001\u7684\u8f93\u51fa\u3002',
- modeFree: '\u81ea\u7531',
- modeThinking: '\u601d\u8003',
- modeSend: '\u53d1\u9001',
- laneInput: '\u8f93\u5165',
- laneThink: '\u601d\u8003',
- laneSend: '\u53d1\u9001',
- selected: '\u5df2\u9009',
- emptyInput: '\u5148\u5199\u4e00\u4e2a\u4e3b\u9898\u6216\u5361\u7247\u3002',
- needSelection: '\u5148\u9009\u4e2d\u4e00\u5f20\u6216\u591a\u5f20\u5361\u7247\u3002',
- thinking: 'AI \u6b63\u5728\u601d\u8003...',
- copied: '\u8349\u7a3f\u5df2\u590d\u5236\u3002',
- copyEmpty: '\u8fd8\u6ca1\u6709\u53ef\u590d\u5236\u7684\u8349\u7a3f\u3002',
- resetDone: '\u753b\u5e03\u5df2\u91cd\u7f6e\u3002',
- aiFailed: 'AI \u8bf7\u6c42\u5931\u8d25\uff0c\u5df2\u6dfb\u52a0\u672c\u5730\u5361\u7247\u3002',
- generated: 'AI \u5df2\u628a\u65b0\u5361\u7247\u653e\u5230\u753b\u5e03\u4e0a\u3002',
- drafted: '\u8349\u7a3f\u5df2\u751f\u6210\u3002',
- cardIdea: '\u60f3\u6cd5',
- cardQuestion: '\u95ee\u9898',
- cardInsight: '\u6d1e\u5bdf',
- cardOutput: '\u8f93\u51fa',
- outputCardTitle: '\u53ef\u53d1\u9001\u8f93\u51fa',
- cardAssumption: '\u5047\u8bbe',
- cardCounterpoint: '\u53cd\u4f8b',
- cardSource: '\u8d44\u6599',
- remove: '\u5220\u9664\u5361\u7247',
- openPanel: '\u6253\u5f00\u4fa7\u8fb9\u680f',
- closePanel: '\u6536\u8d77\u4fa7\u8fb9\u680f',
- paneDraft: '\u8349\u7a3f',
- paneSettings: '\u8bbe\u7f6e',
- cardActionExpand: '\u6269\u5c55\u8fd9\u5f20\u5361\u7247',
- cardActionChallenge: '\u8d28\u7591\u8fd9\u5f20\u5361\u7247',
- cardActionConnect: '\u62d6\u52a8\u8fde\u7ebf',
- cardActionDraft: '\u4ee5\u6b64\u4e3a\u57fa\u62df\u7a3f',
- relationPickerTitle: '\u4e24\u8005\u662f\u4ec0\u4e48\u5173\u7cfb\uff1f',
- cancel: '\u53d6\u6d88',
- deleteConnection: '\u5220\u9664\u5173\u7cfb',
- emptyHintAction: '\u8bd5\u8bd5\uff1a\u5728\u4e0b\u65b9\u5199\u4e00\u53e5\u8bdd\uff0c\u70b9 \u201c\u8be2\u95ee AI\u201d\u3002',
- drawerTitleDraft: '\u53ef\u53d1\u9001\u8349\u7a3f',
- drawerHintDraft: 'AI \u4f1a\u628a\u6210\u7a3f\u653e\u5230\u8fd9\u91cc\uff0c\u7f16\u8f91\u540e\u53ef\u53d1\u9001\u3002',
- drawerEmptyDraft: '\u8fd8\u6ca1\u6709\u8349\u7a3f\u3002\u9009\u4e2d\u5361\u7247\u540e\u70b9\u51fb\u201c\u6210\u7a3f\u201d\u3002',
- boardSettings: '\u753b\u5e03\u8bbe\u7f6e',
- resetBoardConfirm: '\u786e\u8ba4\u91cd\u7f6e\u753b\u5e03\uff1f',
- inputHint: '\u5199\u4e0b\u4e00\u4e2a\u60f3\u6cd5\uff0c\u6216\u95ee AI \u63a5\u4e0b\u6765\u5e72\u4ec0\u4e48',
- deleteBoard: '\u5220\u9664\u753b\u5e03',
- boardDeleted: '\u753b\u5e03\u5df2\u5220\u9664\u3002',
- cancelAi: '\u53d6\u6d88\u751f\u6210',
- cancelled: '\u5df2\u53d6\u6d88\u3002',
- thinkingHint: 'AI \u6b63\u5728\u52fe\u52d2\u60f3\u6cd5\u2026',
- tidy: '\u6574\u7406',
- tidied: '\u753b\u5e03\u5df2\u6574\u7406\u3002',
- zoomIn: '\u653e\u5927',
- zoomOut: '\u7f29\u5c0f',
- zoomFit: '\u9002\u5e94\u5185\u5bb9'
- }
-};
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/src/state.js b/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/src/state.js
deleted file mode 100644
index 4ffe0ef8..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/src/state.js
+++ /dev/null
@@ -1,41 +0,0 @@
-export const DEFAULT_CARDS = [
- {
- id: 'seed-1',
- kind: 'idea',
- title: 'Canvas-first collaboration',
- body: 'AI responses become movable cards, not a buried chat transcript.',
- x: 64,
- y: 96
- },
- {
- id: 'seed-2',
- kind: 'question',
- title: 'What should AI do here?',
- body: 'Expand, cluster, challenge, and turn selected thoughts into send-ready text.',
- x: 360,
- y: 168
- },
- {
- id: 'seed-3',
- kind: 'insight',
- title: 'Keep the user in control',
- body: 'Every generated note should be editable, movable, selectable, and disposable.',
- x: 672,
- y: 108
- },
- {
- id: 'seed-4',
- kind: 'output',
- title: 'Send-ready promise',
- body: 'The board is done when a messy thought becomes a message someone can act on.',
- x: 812,
- y: 328
- }
-];
-
-export const SCHEMA_VERSION = 2;
-
-export function clone(value) {
- if (typeof structuredClone === 'function') return structuredClone(value);
- return JSON.parse(JSON.stringify(value));
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/style.css b/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/style.css
deleted file mode 100644
index c1950736..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/style.css
+++ /dev/null
@@ -1,1356 +0,0 @@
-:root {
- color-scheme: light dark;
- --app-bg: var(--sparo-app-bg, Canvas);
- --app-surface: var(--sparo-app-surface, Canvas);
- --app-panel: var(--sparo-app-panel, Canvas);
- --app-card: var(--sparo-app-card, Canvas);
- --app-control: var(--sparo-app-control-bg, Field);
- --app-control-hover: var(--sparo-app-control-hover, Field);
- --app-text: var(--sparo-app-text, CanvasText);
- --app-muted: var(--sparo-app-text-secondary, CanvasText);
- --app-soft: var(--sparo-app-text-muted, GrayText);
- --app-border: var(--sparo-app-border, GrayText);
- --app-border-subtle: var(--sparo-app-border-subtle, GrayText);
- --app-accent: var(--sparo-app-accent, Highlight);
- --app-accent-hover: var(--sparo-app-accent-hover, Highlight);
- --app-accent-soft: var(--sparo-app-accent-soft, transparent);
- --app-focus-ring: var(--sparo-app-focus-ring, Highlight);
- --app-radius: var(--sparo-app-radius, 10px);
- --app-radius-sm: var(--sparo-app-radius-sm, 6px);
-
- --card-color-idea: color-mix(in srgb, var(--app-text) 36%, transparent);
- --card-color-ai: var(--app-accent);
- --card-color-insight: color-mix(in srgb, var(--app-accent) 70%, #6e58c5);
- --card-color-question: #4a8df0;
- --card-color-assumption: #d8a341;
- --card-color-counterpoint: #d96b5f;
- --card-color-output: var(--app-accent);
- --card-color-source: var(--app-soft);
-
- --drawer-width: 340px;
-
- font-family: var(--sparo-font-sans, system-ui, sans-serif);
-}
-
-* { box-sizing: border-box; }
-
-html {
- position: fixed;
- inset: 0;
- height: 100%;
- overflow: hidden;
- overscroll-behavior: none;
-}
-
-body {
- position: fixed;
- inset: 0;
- margin: 0;
- width: 100%;
- height: 100%;
- min-height: 0;
- overflow: hidden;
- overscroll-behavior: none;
- touch-action: none;
- background:
- radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--app-text) 14%, transparent) 0.8px, transparent 1.2px),
- var(--app-bg);
- background-size: 22px 22px;
- background-position: 0 0;
- color: var(--app-text);
-}
-
-button,
-textarea,
-input,
-select { font: inherit; }
-
-.spark-board {
- position: fixed;
- inset: 0;
- display: flex;
- flex-direction: column;
- width: 100%;
- height: 100vh;
- height: 100dvh;
- overflow: hidden;
- overscroll-behavior: none;
-}
-
-/* ---------- Topbar ---------- */
-
-.topbar {
- position: relative;
- z-index: 30;
- display: grid;
- grid-template-columns: auto 1fr auto;
- align-items: center;
- gap: 16px;
- min-height: 44px;
- padding: 6px 12px;
- border-bottom: 1px solid var(--app-border-subtle);
- background: color-mix(in srgb, var(--app-panel) 92%, transparent);
- backdrop-filter: blur(12px);
-}
-
-.brand {
- display: flex;
- align-items: center;
- gap: 8px;
- min-width: 0;
-}
-
-.brand-mark {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 22px;
- height: 22px;
- border-radius: 6px;
- background: color-mix(in srgb, var(--app-accent) 16%, transparent);
- color: var(--app-accent);
-}
-
-h1, h2, p { margin: 0; }
-
-h1 {
- font-size: 13px;
- font-weight: 700;
- letter-spacing: 0.01em;
- line-height: 1;
- white-space: nowrap;
-}
-
-.board-strip {
- display: flex;
- align-items: center;
- gap: 4px;
- min-width: 0;
-}
-
-.board-tabs {
- display: flex;
- align-items: center;
- gap: 2px;
- min-width: 0;
- overflow-x: auto;
- overflow-y: hidden;
- scrollbar-width: none;
- overscroll-behavior-x: contain;
-}
-
-.board-tabs::-webkit-scrollbar { display: none; }
-
-.board-tab {
- position: relative;
- display: inline-flex;
- align-items: center;
- gap: 4px;
- max-width: 200px;
- min-height: 26px;
- min-width: 0;
- flex: 0 0 auto;
- border: 1px solid transparent;
- border-radius: var(--app-radius-sm);
- background: transparent;
- color: var(--app-soft);
- padding: 0 6px 0 10px;
- cursor: pointer;
- font-size: 12px;
- font-weight: 600;
- outline: none;
-}
-
-.board-tab:focus-visible {
- border-color: var(--app-focus-ring);
-}
-
-.board-tab-label {
- display: inline-block;
- max-width: 160px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.board-tab-close {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 16px;
- height: 16px;
- border-radius: 4px;
- color: var(--app-soft);
- font-size: 14px;
- font-weight: 500;
- line-height: 1;
- cursor: pointer;
- opacity: 0;
- transition: opacity 0.12s ease, background 0.12s ease, color 0.12s ease;
-}
-
-.board-tab:hover .board-tab-close,
-.board-tab.is-active .board-tab-close,
-.board-tab:focus-within .board-tab-close {
- opacity: 0.7;
-}
-
-.board-tab-close:hover {
- opacity: 1;
- background: color-mix(in srgb, var(--card-color-counterpoint) 22%, transparent);
- color: var(--card-color-counterpoint);
-}
-
-.board-tabs[data-single="true"] .board-tab-close {
- display: none;
-}
-
-.board-tab:hover {
- background: var(--app-control-hover);
- color: var(--app-muted);
-}
-
-.board-tab.is-active {
- background: var(--app-control);
- color: var(--app-text);
-}
-
-.board-add {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- border: 0;
- border-radius: var(--app-radius-sm);
- background: transparent;
- color: var(--app-soft);
- cursor: pointer;
- font-size: 16px;
- font-weight: 500;
- line-height: 1;
-}
-
-.board-add:hover {
- background: var(--app-control-hover);
- color: var(--app-text);
-}
-
-.topbar-actions {
- display: flex;
- align-items: center;
- gap: 4px;
- justify-self: end;
-}
-
-.icon-button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- border: 1px solid transparent;
- border-radius: var(--app-radius-sm);
- background: transparent;
- color: var(--app-muted);
- cursor: pointer;
-}
-
-.icon-button:hover {
- background: var(--app-control-hover);
- color: var(--app-text);
-}
-
-.icon-button.is-active {
- background: color-mix(in srgb, var(--app-accent) 18%, transparent);
- color: var(--app-accent);
-}
-
-/* ---------- Layout ---------- */
-
-.layout {
- position: relative;
- flex: 1;
- display: flex;
- flex-direction: column;
- min-height: 0;
- overflow: hidden;
- overscroll-behavior: none;
- transition: padding-right 0.22s ease;
-}
-
-.spark-board[data-drawer="open"] .layout {
- padding-right: var(--drawer-width);
-}
-
-.canvas-shell {
- position: relative;
- display: flex;
- flex-direction: column;
- flex: 1;
- min-width: 0;
- min-height: 0;
-}
-
-.canvas {
- position: relative;
- flex: 1;
- min-height: 0;
- overflow: hidden;
- overscroll-behavior: none;
-}
-
-.canvas-stage {
- position: absolute;
- inset: 0;
- transform-origin: 0 0;
- will-change: transform;
-}
-
-.canvas-stage.is-view-animating {
- transition: transform 0.38s cubic-bezier(0.22, 0.61, 0.36, 1);
-}
-
-.canvas { cursor: grab; }
-.canvas.is-panning { cursor: grabbing; }
-.canvas.is-panning .card { pointer-events: none; }
-
-/* ---------- Empty state ---------- */
-
-.empty-prompt {
- position: absolute;
- top: 50%;
- left: 50%;
- display: grid;
- gap: 10px;
- width: min(420px, calc(100vw - 48px));
- transform: translate(-50%, -55%);
- color: var(--app-muted);
- text-align: center;
- pointer-events: none;
-}
-
-.empty-prompt[hidden] { display: none; }
-
-.empty-illustration {
- position: relative;
- width: 168px;
- height: 76px;
- margin: 0 auto 8px;
- color: var(--app-soft);
-}
-
-.ghost-card {
- position: absolute;
- width: 60px;
- height: 42px;
- border: 1.5px dashed color-mix(in srgb, var(--app-soft) 56%, transparent);
- border-radius: 8px;
- background: color-mix(in srgb, var(--app-card) 60%, transparent);
-}
-
-.ghost-card--a { top: 10px; left: 2px; }
-.ghost-card--b { top: 22px; right: 2px; }
-
-.ghost-line {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
- color: color-mix(in srgb, var(--app-accent) 64%, transparent);
-}
-
-.empty-prompt strong {
- color: var(--app-text);
- font-size: 18px;
- font-weight: 700;
-}
-
-.empty-prompt span {
- color: var(--app-soft);
- font-size: 13px;
- line-height: 1.5;
-}
-
-.empty-hint {
- margin-top: 4px;
- color: color-mix(in srgb, var(--app-soft) 80%, transparent);
- font-size: 11.5px;
- font-style: normal;
- letter-spacing: 0.01em;
-}
-
-/* ---------- Connections layer ---------- */
-
-.connection-layer {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
- z-index: 1;
- overflow: visible;
- pointer-events: none;
-}
-
-.connection-layer marker path { fill: var(--app-accent); }
-
-.connection {
- fill: none;
- stroke: var(--app-accent);
- stroke-linecap: round;
- stroke-width: 1.8;
- opacity: 0.78;
- pointer-events: stroke;
- cursor: pointer;
- transition: stroke-width 0.12s ease, opacity 0.12s ease;
-}
-
-.connection:hover,
-.connection.is-active { stroke-width: 2.8; opacity: 1; }
-
-.connection--challenges {
- stroke: var(--card-color-counterpoint);
- stroke-dasharray: 6 5;
-}
-
-.connection--expands {
- stroke: var(--card-color-question);
-}
-
-.connection--becomes {
- stroke: var(--app-accent);
- stroke-width: 2.4;
-}
-
-.connection-label {
- fill: var(--app-soft);
- font-size: 10px;
- font-weight: 700;
- paint-order: stroke;
- pointer-events: none;
- stroke: var(--app-bg);
- stroke-linejoin: round;
- stroke-width: 4px;
- text-anchor: middle;
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.connection-temp {
- fill: none;
- stroke: var(--app-accent);
- stroke-width: 1.8;
- stroke-dasharray: 5 4;
- stroke-linecap: round;
- opacity: 0.7;
- pointer-events: none;
-}
-
-/* ---------- Cards ---------- */
-
-.card {
- --card-color: var(--card-color-idea);
- position: absolute;
- width: 232px;
- min-height: 110px;
- padding: 10px 12px 12px 16px;
- border: 1px solid var(--app-border-subtle);
- border-radius: var(--app-radius);
- background: var(--app-card);
- color: var(--app-text);
- box-shadow:
- 0 1px 0 color-mix(in srgb, var(--app-text) 4%, transparent),
- 0 10px 30px color-mix(in srgb, var(--app-bg) 78%, transparent);
- cursor: grab;
- user-select: none;
- z-index: 2;
- transition: box-shadow 0.15s ease, border-color 0.15s ease;
-}
-
-.card::before {
- content: "";
- position: absolute;
- top: 10px;
- bottom: 10px;
- left: 0;
- width: 3px;
- background: var(--card-color);
- border-radius: 0 3px 3px 0;
-}
-
-.card:active { cursor: grabbing; }
-
-.card:hover {
- box-shadow:
- 0 1px 0 color-mix(in srgb, var(--app-text) 6%, transparent),
- 0 14px 40px color-mix(in srgb, var(--app-bg) 72%, transparent);
-}
-
-.card.is-selected {
- border-color: color-mix(in srgb, var(--card-color) 70%, var(--app-accent));
- box-shadow:
- 0 0 0 2px color-mix(in srgb, var(--card-color) 36%, transparent),
- 0 14px 40px color-mix(in srgb, var(--app-bg) 70%, transparent);
-}
-
-.card--idea { --card-color: var(--card-color-idea); }
-.card--ai,
-.card--insight { --card-color: var(--card-color-ai); }
-.card--question { --card-color: var(--card-color-question); }
-.card--assumption { --card-color: var(--card-color-assumption); }
-.card--counterpoint {
- --card-color: var(--card-color-counterpoint);
- border-style: dashed;
-}
-.card--output {
- --card-color: var(--card-color-output);
- background:
- linear-gradient(180deg, color-mix(in srgb, var(--app-accent) 6%, transparent), transparent 60%),
- var(--app-card);
-}
-.card--source { --card-color: var(--card-color-source); }
-
-.card-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- margin-bottom: 4px;
-}
-
-.card-kind {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- color: var(--card-color);
- font-size: 10.5px;
- font-weight: 700;
- letter-spacing: 0.06em;
- text-transform: uppercase;
-}
-
-.card-kind::before {
- content: "";
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--card-color);
-}
-
-.card-remove {
- width: 22px;
- height: 22px;
- border: 0;
- border-radius: var(--app-radius-sm);
- background: transparent;
- color: var(--app-soft);
- cursor: pointer;
- opacity: 0;
- transition: opacity 0.15s ease, background 0.15s ease;
- font-size: 14px;
- line-height: 1;
-}
-
-.card:hover .card-remove,
-.card.is-selected .card-remove { opacity: 1; }
-
-.card-remove:hover {
- background: color-mix(in srgb, var(--card-color-counterpoint) 18%, transparent);
- color: var(--card-color-counterpoint);
-}
-
-.card-title,
-.card-body {
- width: 100%;
- border: 0;
- background: transparent;
- color: var(--app-text);
- padding: 0;
- outline: none;
- resize: none;
-}
-
-.card-title {
- min-height: 20px;
- font-size: 13.5px;
- font-weight: 700;
- line-height: 1.3;
-}
-
-.card-body {
- min-height: 48px;
- margin-top: 4px;
- color: var(--app-muted);
- font-size: 12px;
- line-height: 1.5;
-}
-
-/* Card inline action bar (slides out from card bottom, always in hover zone) */
-.card-actions {
- display: flex;
- align-items: stretch;
- gap: 3px;
- max-height: 0;
- margin-top: 0;
- padding: 0;
- border-top: 1px solid transparent;
- overflow: hidden;
- transition:
- max-height 0.16s ease,
- margin-top 0.16s ease,
- padding-top 0.16s ease,
- border-top-color 0.16s ease;
-}
-
-.card:hover .card-actions,
-.card.is-selected .card-actions,
-.card:focus-within .card-actions {
- max-height: 36px;
- margin-top: 10px;
- padding-top: 8px;
- border-top-color: color-mix(in srgb, var(--app-border-subtle) 80%, transparent);
-}
-
-.card-action {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 5px;
- flex: 1;
- min-width: 0;
- height: 28px;
- padding: 0 8px;
- border: 0;
- border-radius: 6px;
- background: transparent;
- color: var(--app-muted);
- cursor: pointer;
- font-size: 11.5px;
- font-weight: 600;
- line-height: 1;
- white-space: nowrap;
- overflow: hidden;
-}
-
-.card-action:hover {
- background: color-mix(in srgb, var(--app-accent) 14%, transparent);
- color: var(--app-accent);
-}
-
-.card-action--challenge:hover {
- background: color-mix(in srgb, var(--card-color-counterpoint) 14%, transparent);
- color: var(--card-color-counterpoint);
-}
-
-.card-action--draft:hover {
- background: color-mix(in srgb, var(--card-color-output) 16%, transparent);
- color: var(--card-color-output);
-}
-
-.card-action-glyph {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 14px;
- height: 14px;
- font-size: 11px;
- font-weight: 800;
- opacity: 0.85;
-}
-
-/* Connection drag handle */
-.card-handle {
- position: absolute;
- top: 50%;
- right: -7px;
- width: 14px;
- height: 14px;
- margin-top: -7px;
- border: 2px solid var(--app-accent);
- border-radius: 50%;
- background: var(--app-card);
- cursor: crosshair;
- padding: 0;
- opacity: 0;
- transition: opacity 0.15s ease, transform 0.1s ease;
- z-index: 3;
- touch-action: none;
-}
-
-.card:hover .card-handle,
-.card.is-selected .card-handle { opacity: 1; }
-
-.card-handle:hover {
- transform: scale(1.18);
- background: var(--app-accent);
-}
-
-.card.is-drop-target {
- outline: 2px solid var(--app-accent);
- outline-offset: 2px;
-}
-
-.card.is-tidying {
- transition:
- transform 0.42s cubic-bezier(0.22, 0.61, 0.36, 1),
- box-shadow 0.15s ease,
- border-color 0.15s ease;
-}
-
-/* ---------- Stream chip (AI generating indicator) ---------- */
-
-.stream-chip {
- position: absolute;
- top: 0;
- left: 0;
- z-index: 6;
- display: inline-flex;
- align-items: center;
- gap: 8px;
- height: 26px;
- padding: 0 12px 0 10px;
- border-radius: 999px;
- border: 1px solid color-mix(in srgb, var(--app-accent) 45%, transparent);
- background: color-mix(in srgb, var(--app-card) 92%, var(--app-accent-soft));
- color: var(--app-accent);
- font-size: 11.5px;
- font-weight: 600;
- white-space: nowrap;
- pointer-events: none;
- user-select: none;
- box-shadow: 0 6px 18px color-mix(in srgb, var(--app-bg) 78%, transparent);
- animation: stream-aura 2s ease-in-out infinite;
-}
-
-.thinking-dot {
- display: inline-block;
- width: 7px;
- height: 7px;
- border-radius: 50%;
- background: var(--app-accent);
- animation: spark-thinking-dot 0.9s ease-in-out infinite;
-}
-
-.stream-chip-count {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-width: 16px;
- height: 16px;
- padding: 0 5px;
- border-radius: 999px;
- background: color-mix(in srgb, var(--app-accent) 20%, transparent);
- font-size: 10.5px;
- font-weight: 700;
- font-variant-numeric: tabular-nums;
-}
-
-.stream-chip-count[hidden] { display: none; }
-
-@keyframes stream-aura {
- 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--app-accent) 28%, transparent), 0 6px 18px color-mix(in srgb, var(--app-bg) 78%, transparent); }
- 50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--app-accent) 8%, transparent), 0 6px 18px color-mix(in srgb, var(--app-bg) 78%, transparent); }
-}
-
-@keyframes spark-thinking-dot {
- 0%, 100% { transform: scale(0.7); opacity: 0.6; }
- 50% { transform: scale(1.15); opacity: 1; }
-}
-
-/* ---------- Card glow on spawn ---------- */
-
-.card.is-glowing {
- animation: card-spawn 0.75s cubic-bezier(0.2, 0.9, 0.3, 1.1) 1 both;
- z-index: 5;
-}
-
-@keyframes card-spawn {
- 0% {
- opacity: 0;
- filter: blur(2px);
- box-shadow:
- 0 0 0 10px color-mix(in srgb, var(--card-color) 24%, transparent),
- 0 0 28px color-mix(in srgb, var(--card-color) 36%, transparent);
- }
- 55% {
- opacity: 1;
- filter: blur(0);
- box-shadow:
- 0 0 0 4px color-mix(in srgb, var(--card-color) 36%, transparent),
- 0 0 18px color-mix(in srgb, var(--card-color) 32%, transparent);
- }
- 100% {
- opacity: 1;
- filter: blur(0);
- box-shadow:
- 0 1px 0 color-mix(in srgb, var(--app-text) 4%, transparent),
- 0 10px 30px color-mix(in srgb, var(--app-bg) 78%, transparent);
- }
-}
-
-/* ---------- Canvas floating tools ---------- */
-
-.canvas-floating {
- position: absolute;
- left: 14px;
- bottom: 14px;
- z-index: 8;
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 6px 10px;
- border: 1px solid var(--app-border-subtle);
- border-radius: 999px;
- background: color-mix(in srgb, var(--app-panel) 88%, transparent);
- backdrop-filter: blur(10px);
- box-shadow: 0 6px 20px color-mix(in srgb, var(--app-bg) 80%, transparent);
- pointer-events: auto;
- max-width: calc(100% - 28px);
-}
-
-.tool-pill {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-width: 20px;
- min-height: 20px;
- padding: 0 8px;
- border-radius: 999px;
- background: color-mix(in srgb, var(--app-accent) 16%, transparent);
- color: var(--app-accent);
- font-size: 11px;
- font-weight: 700;
-}
-
-.tool-status {
- margin: 0;
- color: var(--app-soft);
- font-size: 12px;
- line-height: 1.2;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- max-width: 320px;
-}
-
-/* ---------- Canvas view controls ---------- */
-
-.canvas-controls {
- position: absolute;
- right: 14px;
- bottom: 14px;
- z-index: 8;
- display: flex;
- align-items: center;
- gap: 2px;
- padding: 4px;
- border: 1px solid var(--app-border-subtle);
- border-radius: 999px;
- background: color-mix(in srgb, var(--app-panel) 88%, transparent);
- backdrop-filter: blur(10px);
- box-shadow: 0 6px 20px color-mix(in srgb, var(--app-bg) 80%, transparent);
-}
-
-.control-button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 5px;
- min-width: 28px;
- height: 28px;
- padding: 0 6px;
- border: 0;
- border-radius: 999px;
- background: transparent;
- color: var(--app-muted);
- cursor: pointer;
- font-size: 12px;
- font-weight: 600;
- white-space: nowrap;
-}
-
-.control-button:hover {
- background: var(--app-control-hover);
- color: var(--app-text);
-}
-
-.control-button--wide { padding: 0 10px; }
-.control-button--zoom { min-width: 46px; }
-
-.zoom-label {
- font-size: 11.5px;
- font-weight: 700;
- font-variant-numeric: tabular-nums;
-}
-
-.control-divider {
- width: 1px;
- height: 18px;
- margin: 0 3px;
- background: var(--app-border-subtle);
-}
-
-@media (max-width: 620px) {
- .control-label { display: none; }
- .canvas-controls { right: 10px; bottom: 10px; }
-}
-
-/* ---------- Relation popover ---------- */
-
-.relation-popover {
- position: absolute;
- z-index: 40;
- display: grid;
- gap: 6px;
- width: 220px;
- padding: 10px;
- border: 1px solid var(--app-border);
- border-radius: 10px;
- background: var(--app-card);
- box-shadow: 0 16px 44px color-mix(in srgb, var(--app-bg) 70%, transparent);
-}
-
-.relation-popover[hidden] { display: none; }
-
-.relation-popover-title {
- font-size: 11px;
- font-weight: 700;
- color: var(--app-soft);
- text-transform: uppercase;
- letter-spacing: 0.06em;
-}
-
-.relation-popover-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 4px;
-}
-
-.relation-popover-grid button {
- display: inline-flex;
- align-items: center;
- justify-content: flex-start;
- gap: 6px;
- height: 30px;
- padding: 0 9px;
- border: 1px solid var(--app-border-subtle);
- border-radius: 6px;
- background: var(--app-control);
- color: var(--app-text);
- cursor: pointer;
- font-size: 12px;
- font-weight: 600;
-}
-
-.relation-popover-grid button::before {
- content: "";
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: currentColor;
- opacity: 0.6;
-}
-
-.relation-popover-grid button[data-relation="supports"] { color: var(--app-accent); }
-.relation-popover-grid button[data-relation="challenges"] { color: var(--card-color-counterpoint); }
-.relation-popover-grid button[data-relation="expands"] { color: var(--card-color-question); }
-.relation-popover-grid button[data-relation="becomes"] { color: var(--card-color-output); }
-
-.relation-popover-grid button:hover {
- background: var(--app-control-hover);
-}
-
-.relation-popover-cancel {
- height: 26px;
- border: 0;
- background: transparent;
- color: var(--app-soft);
- cursor: pointer;
- font-size: 11px;
- font-weight: 600;
-}
-
-.relation-popover-cancel:hover { color: var(--app-text); }
-
-/* ---------- Connection tools bubble ---------- */
-
-.connection-tools {
- position: absolute;
- z-index: 35;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 5px 7px 5px 10px;
- border: 1px solid var(--app-border-subtle);
- border-radius: 999px;
- background: var(--app-card);
- box-shadow: 0 10px 26px color-mix(in srgb, var(--app-bg) 76%, transparent);
- transform: translate(-50%, -50%);
-}
-
-.connection-tools[hidden] { display: none; }
-
-.connection-tools-label {
- color: var(--app-soft);
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- white-space: nowrap;
-}
-
-.btn-mini {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 22px;
- height: 22px;
- border: 0;
- border-radius: 50%;
- background: transparent;
- color: var(--app-muted);
- cursor: pointer;
-}
-
-.btn-mini:hover { background: var(--app-control-hover); color: var(--app-text); }
-
-.btn-mini--danger:hover {
- background: color-mix(in srgb, var(--card-color-counterpoint) 18%, transparent);
- color: var(--card-color-counterpoint);
-}
-
-/* ---------- Composer ---------- */
-
-.composer-dock {
- position: relative;
- flex: 0 0 auto;
- display: grid;
- grid-template-columns: auto 1fr auto;
- align-items: center;
- gap: 8px;
- width: min(720px, calc(100% - 28px));
- margin: 0 auto 14px;
- padding: 6px 6px 6px 12px;
- border: 1px solid var(--app-border-subtle);
- border-radius: 14px;
- background: color-mix(in srgb, var(--app-panel) 92%, transparent);
- box-shadow:
- 0 10px 30px color-mix(in srgb, var(--app-bg) 80%, transparent),
- inset 0 1px 0 color-mix(in srgb, var(--app-text) 6%, transparent);
- backdrop-filter: blur(14px);
- z-index: 9;
- transition: border-color 0.15s ease, box-shadow 0.15s ease;
-}
-
-.composer-dock:focus-within {
- border-color: color-mix(in srgb, var(--app-accent) 60%, var(--app-border));
- box-shadow:
- 0 10px 30px color-mix(in srgb, var(--app-bg) 80%, transparent),
- 0 0 0 3px color-mix(in srgb, var(--app-accent) 14%, transparent),
- inset 0 1px 0 color-mix(in srgb, var(--app-text) 6%, transparent);
-}
-
-.composer-orb {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 26px;
- height: 26px;
- border-radius: 8px;
- background: color-mix(in srgb, var(--app-accent) 16%, transparent);
- color: var(--app-accent);
-}
-
-.composer-dock #seedInput {
- min-height: 28px;
- max-height: 140px;
- border: 0;
- background: transparent;
- color: var(--app-text);
- padding: 6px 2px;
- resize: none;
- line-height: 1.4;
- font-size: 13px;
- width: 100%;
-}
-
-.composer-dock #seedInput::placeholder {
- color: color-mix(in srgb, var(--app-soft) 80%, transparent);
-}
-
-.composer-dock textarea:focus { box-shadow: none; }
-
-.composer-actions {
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-.btn-ghost {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 30px;
- height: 30px;
- border: 0;
- border-radius: 8px;
- background: transparent;
- color: var(--app-muted);
- cursor: pointer;
-}
-
-.btn-ghost:hover {
- background: var(--app-control-hover);
- color: var(--app-text);
-}
-
-/* ---------- Drawer ---------- */
-
-.drawer {
- position: absolute;
- top: 44px;
- right: 0;
- bottom: 0;
- z-index: 20;
- display: flex;
- flex-direction: column;
- width: var(--drawer-width);
- border-left: 1px solid var(--app-border-subtle);
- background: var(--app-panel);
- transform: translateX(100%);
- transition: transform 0.22s ease;
-}
-
-.spark-board[data-drawer="open"] .drawer {
- transform: translateX(0);
-}
-
-.drawer[hidden] { display: flex; }
-
-.drawer-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- padding: 8px 10px;
- border-bottom: 1px solid var(--app-border-subtle);
-}
-
-.drawer-tabs {
- display: flex;
- gap: 2px;
-}
-
-.drawer-tab {
- height: 28px;
- padding: 0 10px;
- border: 1px solid transparent;
- border-radius: 6px;
- background: transparent;
- color: var(--app-soft);
- cursor: pointer;
- font-size: 12px;
- font-weight: 600;
-}
-
-.drawer-tab:hover { color: var(--app-text); }
-
-.drawer-tab.is-active {
- background: var(--app-control);
- color: var(--app-text);
-}
-
-.drawer-body {
- flex: 1;
- display: flex;
- flex-direction: column;
- min-height: 0;
- overflow: hidden;
-}
-
-.drawer-pane {
- display: none;
- flex-direction: column;
- gap: 10px;
- padding: 12px;
- height: 100%;
- overflow: auto;
-}
-
-.drawer-pane.is-active {
- display: flex;
-}
-
-.drawer-section-head {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.drawer-section-head strong {
- font-size: 13px;
- font-weight: 700;
-}
-
-.drawer-section-hint {
- color: var(--app-soft);
- font-size: 11.5px;
-}
-
-.drawer-pane textarea#previewDraft {
- flex: 1;
- min-height: 200px;
- resize: none;
- border: 1px solid var(--app-border-subtle);
- border-radius: 8px;
- padding: 10px;
- background: var(--app-card);
- color: var(--app-text);
- font-size: 12.5px;
- line-height: 1.55;
-}
-
-.drawer-pane label {
- margin: 4px 0 -4px;
-}
-
-.button-row {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-}
-
-/* ---------- Common controls ---------- */
-
-label {
- display: block;
- margin: 0 0 4px;
- color: var(--app-muted);
- font-size: 12px;
- font-weight: 600;
-}
-
-textarea,
-input,
-select {
- width: 100%;
- border: 1px solid var(--app-border-subtle);
- border-radius: var(--app-radius-sm);
- background: var(--app-control);
- color: var(--app-text);
- outline: none;
-}
-
-textarea { padding: 8px 10px; line-height: 1.45; }
-input { min-height: 32px; padding: 0 10px; }
-select { min-height: 32px; padding: 0 8px; }
-
-textarea:focus,
-input:focus,
-select:focus { border-color: var(--app-focus-ring); }
-
-.btn-primary,
-.btn-secondary {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- border-radius: 8px;
- cursor: pointer;
- font-weight: 600;
- line-height: 1;
- white-space: nowrap;
-}
-
-.btn-base { min-height: 32px; padding: 0 12px; font-size: 12.5px; }
-
-.btn-primary {
- border: 1px solid color-mix(in srgb, var(--app-accent) 70%, var(--app-border));
- background: var(--app-accent);
- color: var(--sparo-app-accent-contrast, var(--app-bg));
- padding: 0 14px;
- height: 32px;
-}
-
-.btn-primary:hover { background: var(--app-accent-hover); }
-
-.btn-primary[data-busy="true"] {
- background: color-mix(in srgb, var(--app-accent) 65%, var(--app-bg));
- color: transparent;
- cursor: pointer;
- position: relative;
-}
-
-.btn-primary[data-busy="true"]::after {
- content: "";
- position: absolute;
- top: 50%;
- left: 50%;
- width: 13px;
- height: 13px;
- margin: -6.5px 0 0 -6.5px;
- border: 2px solid color-mix(in srgb, var(--sparo-app-accent-contrast, white) 35%, transparent);
- border-top-color: var(--sparo-app-accent-contrast, white);
- border-radius: 50%;
- animation: spark-spin 0.7s linear infinite;
-}
-
-.btn-primary[data-busy="true"]:hover {
- background: color-mix(in srgb, var(--card-color-counterpoint) 40%, var(--app-accent));
-}
-
-.btn-primary[data-busy="true"]:hover::after {
- border-color: transparent;
- border-radius: 0;
- width: 9px;
- height: 9px;
- margin: -4.5px 0 0 -4.5px;
- background:
- linear-gradient(45deg, transparent 42%, white 42%, white 58%, transparent 58%),
- linear-gradient(-45deg, transparent 42%, white 42%, white 58%, transparent 58%);
- animation: none;
-}
-
-@keyframes spark-spin { to { transform: rotate(360deg); } }
-
-.btn-secondary {
- border: 1px solid var(--app-border-subtle);
- background: var(--app-control);
- color: var(--app-text);
-}
-
-.btn-secondary:hover { background: var(--app-control-hover); }
-
-button:disabled { cursor: wait; opacity: 0.6; }
-
-.sr-only {
- position: absolute !important;
- width: 1px !important;
- height: 1px !important;
- padding: 0 !important;
- margin: -1px !important;
- overflow: hidden !important;
- clip: rect(0, 0, 0, 0) !important;
- white-space: nowrap !important;
- border: 0 !important;
-}
-
-/* ---------- Responsive ---------- */
-
-@media (max-width: 860px) {
- :root { --drawer-width: 300px; }
- .composer-dock { width: calc(100% - 20px); }
-}
-
-@media (max-width: 620px) {
- .topbar { padding: 6px 10px; gap: 10px; }
- .board-tab { max-width: 120px; padding: 0 8px; }
- .composer-dock { margin-bottom: 10px; padding-left: 10px; }
- .composer-orb { display: none; }
- .composer-dock { grid-template-columns: 1fr auto; }
- .canvas-floating { left: 10px; bottom: 10px; padding: 4px 8px; }
- .tool-status { max-width: 160px; }
- .card { width: min(232px, calc(100vw - 36px)); }
- .spark-board[data-drawer="open"] .layout { padding-right: 0; }
- .drawer { width: 100%; top: 0; box-shadow: -10px 0 30px color-mix(in srgb, var(--app-bg) 60%, transparent); }
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/ui.js b/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/ui.js
deleted file mode 100644
index 284e2939..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/ui.js
+++ /dev/null
@@ -1,1847 +0,0 @@
-import { STRINGS } from './src/i18n.js';
-import { DEFAULT_CARDS, SCHEMA_VERSION, clone } from './src/state.js';
-
-let state = {
- schemaVersion: SCHEMA_VERSION,
- boards: [],
- activeBoardId: 'board-default',
- boardTitle: 'First sparks',
- cards: clone(DEFAULT_CARDS),
- selectedIds: [],
- connections: [],
- draft: '',
- mode: 'free',
- outputFormat: 'message',
- activeConnectionId: null
-};
-
-let dragState = null;
-let connectDragState = null;
-let relationContext = null;
-let panState = null;
-let suppressCanvasClick = false;
-let busy = false;
-let saveTimer = null;
-
-const $ = (id) => document.getElementById(id);
-const runtime = () => window.app || {};
-const locale = () => runtime().locale || 'en-US';
-const t = (key) => (STRINGS[locale()] || STRINGS['en-US'])[key] || STRINGS['en-US'][key] || key;
-const uid = (prefix = 'card') => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
-const root = () => document.querySelector('.spark-board');
-
-/* ---------- Viewport (infinite pan/zoom canvas) ---------- */
-
-const MIN_ZOOM = 0.3;
-const MAX_ZOOM = 2.4;
-let view = { panX: 0, panY: 0, zoom: 1 };
-
-function canvasRect() {
- return $('canvas').getBoundingClientRect();
-}
-
-function applyView() {
- const stage = $('canvasStage');
- if (!stage) return;
- stage.style.transformOrigin = '0 0';
- stage.style.transform = `translate(${view.panX}px, ${view.panY}px) scale(${view.zoom})`;
- updateZoomLabel();
-}
-
-function animateView(next, ms = 380) {
- const stage = $('canvasStage');
- if (!stage) { Object.assign(view, next); return; }
- stage.classList.add('is-view-animating');
- Object.assign(view, next);
- applyView();
- window.clearTimeout(animateView._timer);
- animateView._timer = window.setTimeout(() => stage.classList.remove('is-view-animating'), ms + 40);
-}
-
-function screenToWorld(clientX, clientY) {
- const rect = canvasRect();
- return {
- x: (clientX - rect.left - view.panX) / view.zoom,
- y: (clientY - rect.top - view.panY) / view.zoom
- };
-}
-
-function worldToScreen(wx, wy) {
- return { x: wx * view.zoom + view.panX, y: wy * view.zoom + view.panY };
-}
-
-function viewportCenterWorld() {
- const rect = canvasRect();
- return screenToWorld(rect.left + rect.width / 2, rect.top + rect.height / 2);
-}
-
-function zoomAt(clientX, clientY, factor) {
- const rect = canvasRect();
- const sx = clientX - rect.left;
- const sy = clientY - rect.top;
- const worldX = (sx - view.panX) / view.zoom;
- const worldY = (sy - view.panY) / view.zoom;
- const nextZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, view.zoom * factor));
- view.panX = sx - worldX * nextZoom;
- view.panY = sy - worldY * nextZoom;
- view.zoom = nextZoom;
- applyView();
- scheduleSave();
-}
-
-function updateZoomLabel() {
- const label = $('zoomLabel');
- if (label) label.textContent = `${Math.round(view.zoom * 100)}%`;
-}
-
-function fitView(cards, { animate = true, maxZoom = 1.1, padding = 80 } = {}) {
- const list = (cards && cards.length ? cards : state.cards) || [];
- const rect = canvasRect();
- const vw = rect.width || 800;
- const vh = rect.height || 600;
- if (list.length === 0) {
- const next = { panX: 0, panY: 0, zoom: 1 };
- animate ? animateView(next) : (Object.assign(view, next), applyView());
- scheduleSave();
- return;
- }
- const box = computeBoundingBox(list);
- const contentW = Math.max(box.maxX - box.minX, CARD_WIDTH);
- const contentH = Math.max(box.maxY - box.minY, CARD_HEIGHT);
- const zoom = Math.max(MIN_ZOOM, Math.min(maxZoom, Math.min(
- (vw - padding * 2) / contentW,
- (vh - padding * 2) / contentH
- )));
- const cx = (box.minX + box.maxX) / 2;
- const cy = (box.minY + box.maxY) / 2;
- const next = {
- zoom,
- panX: vw / 2 - cx * zoom,
- panY: vh / 2 - cy * zoom
- };
- animate ? animateView(next) : (Object.assign(view, next), applyView());
- scheduleSave();
-}
-
-function appStorage() {
- const host = runtime();
- if (host.storage) return host.storage;
- return {
- get: async (key) => JSON.parse(localStorage.getItem(key) || 'null'),
- set: async (key, value) => localStorage.setItem(key, JSON.stringify(value))
- };
-}
-
-function applyI18n() {
- document.querySelectorAll('[data-i18n]').forEach((node) => { node.textContent = t(node.dataset.i18n); });
- document.querySelectorAll('[data-i18n-placeholder]').forEach((node) => { node.placeholder = t(node.dataset.i18nPlaceholder); });
- document.querySelectorAll('[data-i18n-aria]').forEach((node) => { node.setAttribute('aria-label', t(node.dataset.i18nAria)); });
- document.querySelectorAll('[data-i18n-title]').forEach((node) => { node.title = t(node.dataset.i18nTitle); });
-}
-
-function setStatus(message) {
- const line = $('statusLine');
- if (line) line.textContent = message;
-}
-
-function scheduleSave(delay = 180) {
- window.clearTimeout(saveTimer);
- saveTimer = window.setTimeout(() => save(), delay);
-}
-
-function setBusy(nextBusy) {
- busy = nextBusy;
- const sparkButton = $('sparkIdeas');
- if (sparkButton) {
- if (busy) sparkButton.dataset.busy = 'true';
- else delete sparkButton.dataset.busy;
- sparkButton.setAttribute('aria-label', busy ? t('cancelAi') : t('sparkIdeas'));
- sparkButton.title = busy ? t('cancelAi') : '';
- }
- updateSelectionMeta();
-}
-
-function buildBoardSnapshot(id = state.activeBoardId, title = state.boardTitle) {
- return {
- id,
- title: title || t('untitledBoard'),
- cards: clone(state.cards || []),
- connections: clone(state.connections || []),
- draft: state.draft || '',
- mode: state.mode || 'free',
- outputFormat: state.outputFormat || 'message',
- view: { ...view },
- schemaVersion: SCHEMA_VERSION,
- updatedAt: Date.now()
- };
-}
-
-function normalizeView(raw) {
- if (raw && Number.isFinite(raw.zoom) && Number.isFinite(raw.panX) && Number.isFinite(raw.panY)) {
- return { panX: raw.panX, panY: raw.panY, zoom: Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, raw.zoom)) };
- }
- return null;
-}
-
-function normalizeBoard(board) {
- return {
- id: board.id || uid('board'),
- title: board.title || t('untitledBoard'),
- cards: Array.isArray(board.cards) ? board.cards : [],
- connections: Array.isArray(board.connections) ? board.connections : [],
- draft: typeof board.draft === 'string' ? board.draft : '',
- mode: board.mode || 'free',
- outputFormat: board.outputFormat || 'message',
- view: normalizeView(board.view),
- schemaVersion: SCHEMA_VERSION,
- updatedAt: board.updatedAt || Date.now()
- };
-}
-
-function ensureBoards() {
- state.boards = (Array.isArray(state.boards) ? state.boards : []).map(normalizeBoard);
- if (!Array.isArray(state.boards) || state.boards.length === 0) {
- state.boards = [buildBoardSnapshot('board-default', state.boardTitle || t('defaultBoardTitle'))];
- state.activeBoardId = state.boards[0].id;
- }
- if (!state.boards.some((board) => board.id === state.activeBoardId)) {
- state.activeBoardId = state.boards[0].id;
- }
-}
-
-function persistCurrentBoard() {
- ensureBoards();
- const index = state.boards.findIndex((board) => board.id === state.activeBoardId);
- const snapshot = buildBoardSnapshot(state.activeBoardId, state.boardTitle);
- if (index >= 0) state.boards[index] = snapshot;
- else state.boards.unshift(snapshot);
-}
-
-function applyBoard(board) {
- state.activeBoardId = board.id;
- state.boardTitle = board.title || t('untitledBoard');
- state.cards = clone(board.cards || []);
- state.connections = clone(board.connections || []);
- state.draft = board.draft || '';
- state.mode = board.mode || 'free';
- state.outputFormat = board.outputFormat || 'message';
- state.selectedIds = [];
- state.activeConnectionId = null;
- const storedView = normalizeView(board.view);
- view = storedView || { panX: 0, panY: 0, zoom: 1 };
- state._needsFit = !storedView;
-}
-
-async function load() {
- let needsSave = false;
- try {
- const saved = await appStorage().get('sparkBoardState');
- if (saved && Array.isArray(saved.boards)) {
- state = {
- ...state,
- schemaVersion: SCHEMA_VERSION,
- boards: saved.boards,
- activeBoardId: saved.activeBoardId || saved.boards[0]?.id || 'board-default',
- selectedIds: []
- };
- ensureBoards();
- applyBoard(state.boards.find((board) => board.id === state.activeBoardId) || state.boards[0]);
- if (saved.schemaVersion !== SCHEMA_VERSION) needsSave = true;
- } else if (saved && Array.isArray(saved.cards)) {
- state = {
- ...state,
- ...saved,
- schemaVersion: SCHEMA_VERSION,
- connections: Array.isArray(saved.connections) ? saved.connections : [],
- boardTitle: saved.boardTitle || t('defaultBoardTitle'),
- selectedIds: []
- };
- state.boards = [buildBoardSnapshot('board-default', state.boardTitle)];
- state.activeBoardId = state.boards[0].id;
- needsSave = true;
- }
- } catch (error) {
- runtime().log?.warn?.('Failed to load Spark Board state', { error: String(error) });
- }
- if (needsSave) save();
-}
-
-function serializeState() {
- persistCurrentBoard();
- return {
- schemaVersion: SCHEMA_VERSION,
- boards: state.boards,
- activeBoardId: state.activeBoardId,
- boardTitle: state.boardTitle,
- cards: state.cards,
- connections: state.connections,
- draft: state.draft,
- mode: state.mode,
- outputFormat: state.outputFormat
- };
-}
-
-function save() {
- appStorage().set('sparkBoardState', serializeState())
- .catch((error) => runtime().log?.warn?.('Failed to save Spark Board state', { error: String(error) }));
-}
-
-function selectedCards() {
- return state.cards.filter((card) => state.selectedIds.includes(card.id));
-}
-
-function relatedConnectionsFor(cards) {
- const selected = new Set(cards.map((card) => card.id));
- return state.connections
- .filter((connection) => selected.has(connection.from) || selected.has(connection.to))
- .map((connection) => {
- const from = state.cards.find((card) => card.id === connection.from);
- const to = state.cards.find((card) => card.id === connection.to);
- return {
- type: connection.type,
- from: from ? { id: from.id, title: from.title, kind: from.kind } : { id: connection.from },
- to: to ? { id: to.id, title: to.title, kind: to.kind } : { id: connection.to }
- };
- });
-}
-
-function updateSelectionMeta() {
- const count = state.selectedIds.length;
- $('selectionCount').textContent = count > 0 ? `${count} ${t('selected')}` : '0';
- $('selectionBarLabel').textContent = String(count);
- $('draftMeta').textContent = String(state.draft.trim() ? state.draft.trim().split(/\s+/).length : 0);
- $('connectionCount').textContent = String(state.connections.length);
- $('emptyPrompt').hidden = state.cards.length > 0;
- if ($('previewDraft')) $('previewDraft').value = state.draft;
-}
-
-function renderBoardSwitcher() {
- const select = $('boardSelect');
- select.innerHTML = '';
- const tabs = $('boardTabs');
- tabs.innerHTML = '';
- tabs.dataset.single = state.boards.length === 1 ? 'true' : 'false';
- state.boards.forEach((board) => {
- const option = document.createElement('option');
- option.value = board.id;
- option.textContent = board.title || t('untitledBoard');
- select.appendChild(option);
-
- const tab = document.createElement('div');
- tab.className = `board-tab${board.id === state.activeBoardId ? ' is-active' : ''}`;
- tab.dataset.boardId = board.id;
- tab.setAttribute('role', 'tab');
- tab.setAttribute('tabindex', '0');
- tab.setAttribute('aria-selected', board.id === state.activeBoardId ? 'true' : 'false');
- tab.title = board.title || t('untitledBoard');
-
- const label = document.createElement('span');
- label.className = 'board-tab-label';
- label.textContent = board.title || t('untitledBoard');
- tab.appendChild(label);
-
- const close = document.createElement('span');
- close.className = 'board-tab-close';
- close.setAttribute('role', 'button');
- close.setAttribute('aria-label', t('deleteBoard'));
- close.title = t('deleteBoard');
- close.textContent = '×';
- close.addEventListener('click', (event) => {
- event.stopPropagation();
- deleteBoard(board.id);
- });
- tab.appendChild(close);
-
- tab.addEventListener('click', (event) => {
- if (event.target.closest('.board-tab-close')) return;
- switchBoard(board.id);
- });
- tab.addEventListener('keydown', (event) => {
- if (event.key === 'Enter' || event.key === ' ') {
- event.preventDefault();
- switchBoard(board.id);
- }
- });
- tabs.appendChild(tab);
- });
- select.value = state.activeBoardId;
- $('boardTitle').value = state.boardTitle || '';
-}
-
-function deleteBoard(id) {
- if (busy) cancelThinking();
- const index = state.boards.findIndex((board) => board.id === id);
- if (index < 0) return;
- state.boards.splice(index, 1);
- if (state.boards.length === 0) {
- const fresh = {
- id: uid('board'),
- title: t('defaultBoardTitle'),
- cards: [],
- connections: [],
- draft: '',
- mode: 'free',
- outputFormat: 'message',
- schemaVersion: SCHEMA_VERSION,
- updatedAt: Date.now()
- };
- state.boards.push(fresh);
- applyBoard(fresh);
- } else if (state.activeBoardId === id) {
- const next = state.boards[Math.min(index, state.boards.length - 1)];
- applyBoard(next);
- }
- save();
- render();
- setStatus(t('boardDeleted'));
-}
-
-function render() {
- const stage = $('canvasStage');
- stage.querySelectorAll('.card').forEach((node) => node.remove());
- state.cards.forEach((card) => stage.appendChild(renderCard(card)));
- measureCards();
- renderConnections();
- renderConnectionList();
- $('draftOutput').value = state.draft;
- renderBoardSwitcher();
- $('outputFormat').value = state.outputFormat;
- updateSelectionMeta();
- if (state._needsFit) {
- state._needsFit = false;
- fitView(state.cards, { animate: false });
- } else {
- applyView();
- }
-}
-
-function measureCards() {
- const stage = $('canvasStage');
- if (!stage) return;
- state.cards.forEach((card) => {
- const node = stage.querySelector(`.card[data-id="${card.id}"]`);
- if (node) card._h = node.offsetHeight;
- });
-}
-
-function cardHeight(card) {
- return (card && Number.isFinite(card._h) && card._h > 0) ? card._h : CARD_HEIGHT;
-}
-
-function renderCard(card) {
- const node = document.createElement('article');
- const classes = ['card', `card--${card.kind || 'idea'}`];
- if (state.selectedIds.includes(card.id)) classes.push('is-selected');
- if (card._glow && !card._renderedOnce) classes.push('is-glowing');
- node.className = classes.join(' ');
- node.style.transform = `translate(${card.x}px, ${card.y}px)`;
- node.dataset.id = card.id;
- node.innerHTML = `
-
-
-
-
-
-
-
-
-
- `;
-
- const kindNode = node.querySelector('.card-kind');
- kindNode.textContent = kindLabel(card.kind);
-
- const expandBtn = node.querySelector('.card-action--expand');
- const challengeBtn = node.querySelector('.card-action--challenge');
- const draftBtn = node.querySelector('.card-action--draft');
- expandBtn.querySelector('.card-action-label').textContent = t('expand');
- expandBtn.title = t('cardActionExpand');
- expandBtn.setAttribute('aria-label', t('cardActionExpand'));
- challengeBtn.querySelector('.card-action-label').textContent = t('challenge');
- challengeBtn.title = t('cardActionChallenge');
- challengeBtn.setAttribute('aria-label', t('cardActionChallenge'));
- draftBtn.querySelector('.card-action-label').textContent = t('draft');
- draftBtn.title = t('cardActionDraft');
- draftBtn.setAttribute('aria-label', t('cardActionDraft'));
-
- const removeBtn = node.querySelector('.card-remove');
- removeBtn.title = t('remove');
- removeBtn.setAttribute('aria-label', t('remove'));
-
- const handle = node.querySelector('.card-handle');
- handle.title = t('cardActionConnect');
- handle.setAttribute('aria-label', t('cardActionConnect'));
-
- const title = node.querySelector('.card-title');
- const body = node.querySelector('.card-body');
- title.value = card.title;
- body.value = card.body;
- title.addEventListener('input', (event) => updateCard(card.id, { title: event.target.value }));
- body.addEventListener('input', (event) => updateCard(card.id, { body: event.target.value }));
-
- removeBtn.addEventListener('click', (event) => {
- event.stopPropagation();
- state.cards = state.cards.filter((item) => item.id !== card.id);
- state.selectedIds = state.selectedIds.filter((id) => id !== card.id);
- state.connections = state.connections.filter((c) => c.from !== card.id && c.to !== card.id);
- save();
- render();
- });
-
- node.querySelectorAll('.card-action').forEach((btn) => {
- btn.addEventListener('click', (event) => {
- event.stopPropagation();
- if (!state.selectedIds.includes(card.id)) state.selectedIds = [card.id];
- const action = btn.dataset.action;
- runAction(action);
- });
- btn.addEventListener('pointerdown', (event) => event.stopPropagation());
- });
-
- handle.addEventListener('pointerdown', (event) => onHandlePointerDown(event, card.id));
-
- node.addEventListener('pointerdown', (event) => onCardPointerDown(event, card.id));
- node.addEventListener('click', (event) => {
- if (event.target.closest('textarea, button')) return;
- toggleSelection(card.id, event.shiftKey || event.metaKey || event.ctrlKey);
- });
- return node;
-}
-
-function kindLabel(kind) {
- if (kind === 'assumption') return t('cardAssumption');
- if (kind === 'counterpoint') return t('cardCounterpoint');
- if (kind === 'source') return t('cardSource');
- if (kind === 'question') return t('cardQuestion');
- if (kind === 'insight' || kind === 'ai') return t('cardInsight');
- if (kind === 'output') return t('cardOutput');
- return t('cardIdea');
-}
-
-function updateCard(id, patch) {
- const card = state.cards.find((item) => item.id === id);
- if (!card) return;
- Object.assign(card, patch);
- save();
-}
-
-function updateCardPosition(id, x, y) {
- const card = state.cards.find((item) => item.id === id);
- if (!card) return null;
- card.x = x;
- card.y = y;
- return card;
-}
-
-function toggleSelection(id, additive) {
- if (additive) {
- state.selectedIds = state.selectedIds.includes(id)
- ? state.selectedIds.filter((item) => item !== id)
- : [...state.selectedIds, id];
- } else {
- state.selectedIds = state.selectedIds.length === 1 && state.selectedIds[0] === id ? [] : [id];
- }
- render();
-}
-
-function onCardPointerDown(event, id) {
- if (event.target.closest('textarea, button, .card-handle')) return;
- const card = state.cards.find((item) => item.id === id);
- if (!card) return;
- dragState = {
- id,
- startX: event.clientX,
- startY: event.clientY,
- originalX: card.x,
- originalY: card.y,
- moved: false
- };
- event.currentTarget.setPointerCapture(event.pointerId);
-}
-
-function onHandlePointerDown(event, fromId) {
- event.stopPropagation();
- event.preventDefault();
- const point = screenToWorld(event.clientX, event.clientY);
- connectDragState = {
- fromId,
- currentX: point.x,
- currentY: point.y,
- targetId: null,
- pointerId: event.pointerId
- };
- event.target.setPointerCapture(event.pointerId);
- hideRelationPopover();
- hideConnectionTools();
- renderConnections();
-}
-
-window.addEventListener('pointermove', (event) => {
- if (panState) {
- view.panX = panState.panX + (event.clientX - panState.startX);
- view.panY = panState.panY + (event.clientY - panState.startY);
- if (Math.abs(event.clientX - panState.startX) + Math.abs(event.clientY - panState.startY) > 3) panState.moved = true;
- applyView();
- return;
- }
- if (connectDragState) {
- const point = screenToWorld(event.clientX, event.clientY);
- connectDragState.currentX = point.x;
- connectDragState.currentY = point.y;
- const targetEl = document.elementFromPoint(event.clientX, event.clientY);
- const targetCard = targetEl?.closest?.('.card');
- const nextTargetId = targetCard && targetCard.dataset.id !== connectDragState.fromId ? targetCard.dataset.id : null;
- if (nextTargetId !== connectDragState.targetId) {
- if (connectDragState.targetId) {
- document.querySelector(`.card[data-id="${connectDragState.targetId}"]`)?.classList.remove('is-drop-target');
- }
- if (nextTargetId) {
- document.querySelector(`.card[data-id="${nextTargetId}"]`)?.classList.add('is-drop-target');
- }
- connectDragState.targetId = nextTargetId;
- }
- renderConnections();
- return;
- }
- if (!dragState) return;
- const dx = (event.clientX - dragState.startX) / view.zoom;
- const dy = (event.clientY - dragState.startY) / view.zoom;
- if (Math.abs(dx) + Math.abs(dy) > 3) dragState.moved = true;
- const card = updateCardPosition(dragState.id, dragState.originalX + dx, dragState.originalY + dy);
- const node = document.querySelector(`[data-id="${dragState.id}"]`);
- if (node && card) node.style.transform = `translate(${card.x}px, ${card.y}px)`;
- renderConnections();
-});
-
-window.addEventListener('pointerup', (event) => {
- if (panState) {
- suppressCanvasClick = panState.moved;
- try { $('canvas').releasePointerCapture(panState.pointerId); } catch (_) { /* noop */ }
- $('canvas').classList.remove('is-panning');
- panState = null;
- if (suppressCanvasClick) save();
- return;
- }
- if (connectDragState) {
- const { fromId, targetId, currentX, currentY } = connectDragState;
- if (targetId) {
- document.querySelector(`.card[data-id="${targetId}"]`)?.classList.remove('is-drop-target');
- }
- if (targetId && targetId !== fromId) {
- showRelationPopover(currentX, currentY, fromId, targetId);
- }
- connectDragState = null;
- renderConnections();
- return;
- }
- if (!dragState) return;
- dragState = null;
- save();
-});
-
-function addCardFromInput() {
- const text = $('seedInput').value.trim();
- if (!text) {
- setStatus(t('emptyInput'));
- return;
- }
- const center = viewportCenterWorld();
- const card = {
- id: uid(),
- kind: 'idea',
- title: text.split('\n')[0].slice(0, 72),
- body: text,
- x: Math.round(center.x - CARD_WIDTH / 2),
- y: Math.round(center.y - CARD_HEIGHT / 2),
- _glow: true
- };
- state.cards.unshift(card);
- $('seedInput').value = '';
- save();
- render();
- card._renderedOnce = true;
- setTimeout(() => { delete card._glow; render(); }, 850);
- fitView([card, ...selectedCards()], { maxZoom: 1.1 });
-}
-
-function inferOutputFormat(instruction) {
- const text = String(instruction || '').toLowerCase();
- if (/\b(email|mail)\b/.test(text) || /邮件|邮箱/.test(text)) return 'email';
- if (/\b(issue|ticket|bug)\b/.test(text) || /工单|问题单/.test(text)) return 'issue';
- if (/\bprd|product requirement|requirements?\b/.test(text) || /需求|产品文档/.test(text)) return 'prd';
- if (/\bpost|tweet|thread|social\b/.test(text) || /帖子|推文|社媒/.test(text)) return 'post';
- if (/\bmarkdown|md\b/.test(text)) return 'markdown';
- return state.outputFormat || 'message';
-}
-
-function relationLabel(type) {
- if (type === 'challenges') return t('relationChallenges');
- if (type === 'expands') return t('relationExpands');
- if (type === 'becomes') return t('relationBecomes');
- return t('relationSupports');
-}
-
-function cardBox(card) {
- const w = CARD_WIDTH;
- const h = cardHeight(card);
- return { x: card.x, y: card.y, w, h, cx: card.x + w / 2, cy: card.y + h / 2 };
-}
-
-function cardHandleAnchor(card) {
- const box = cardBox(card);
- return { x: box.x + box.w, y: box.cy };
-}
-
-// Pick the nearest pair of edges based on the relative position of two cards.
-function edgeAnchors(from, to) {
- const a = cardBox(from);
- const b = cardBox(to);
- const horizontal = Math.abs(b.cx - a.cx) >= Math.abs(b.cy - a.cy);
- if (horizontal) {
- return b.cx >= a.cx
- ? { start: { x: a.x + a.w, y: a.cy }, end: { x: b.x, y: b.cy }, dir: 'h' }
- : { start: { x: a.x, y: a.cy }, end: { x: b.x + b.w, y: b.cy }, dir: 'h' };
- }
- return b.cy >= a.cy
- ? { start: { x: a.cx, y: a.y + a.h }, end: { x: b.cx, y: b.y }, dir: 'v' }
- : { start: { x: a.cx, y: a.y }, end: { x: b.cx, y: b.y + b.h }, dir: 'v' };
-}
-
-function bezierPath(start, end, dir) {
- if (dir === 'v') {
- const dy = Math.max(40, Math.abs(end.y - start.y) * 0.45);
- return `M ${start.x} ${start.y} C ${start.x} ${start.y + dy}, ${end.x} ${end.y - dy}, ${end.x} ${end.y}`;
- }
- const dx = Math.max(48, Math.abs(end.x - start.x) * 0.42);
- const sign = end.x >= start.x ? 1 : -1;
- return `M ${start.x} ${start.y} C ${start.x + dx * sign} ${start.y}, ${end.x - dx * sign} ${end.y}, ${end.x} ${end.y}`;
-}
-
-function renderConnections() {
- const layer = $('connectionLayer');
- if (!layer) return;
- layer.innerHTML = `
-
-
-
-
-
- `;
- state.connections.forEach((connection) => {
- const from = state.cards.find((card) => card.id === connection.from);
- const to = state.cards.find((card) => card.id === connection.to);
- if (!from || !to) return;
- const { start, end, dir } = edgeAnchors(from, to);
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- const isActive = state.activeConnectionId === connection.id;
- path.setAttribute('class', `connection connection--${connection.type}${isActive ? ' is-active' : ''}`);
- path.setAttribute('d', bezierPath(start, end, dir));
- path.setAttribute('marker-end', 'url(#sparkArrow)');
- path.dataset.connectionId = connection.id;
- const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
- path.addEventListener('click', (event) => {
- event.stopPropagation();
- showConnectionTools(connection.id, mid.x, mid.y - 14);
- });
- layer.appendChild(path);
-
- const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
- label.setAttribute('class', 'connection-label');
- label.setAttribute('x', String(mid.x));
- label.setAttribute('y', String(mid.y - 8));
- label.textContent = relationLabel(connection.type);
- layer.appendChild(label);
- });
-
- if (connectDragState) {
- const from = state.cards.find((card) => card.id === connectDragState.fromId);
- if (from) {
- const start = cardHandleAnchor(from);
- const end = { x: connectDragState.currentX, y: connectDragState.currentY };
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- path.setAttribute('class', 'connection-temp');
- path.setAttribute('d', bezierPath(start, end, 'h'));
- layer.appendChild(path);
- }
- }
-}
-
-function renderConnectionList() {
- const root = $('connectionList');
- root.innerHTML = '';
- if (state.connections.length === 0) return;
- state.connections.slice(-5).reverse().forEach((connection) => {
- const from = state.cards.find((card) => card.id === connection.from);
- const to = state.cards.find((card) => card.id === connection.to);
- if (!from || !to) return;
- const row = document.createElement('div');
- row.className = 'connection-row';
- row.innerHTML = '
';
- row.querySelector('span').textContent = `${from.title} -> ${relationLabel(connection.type)} -> ${to.title}`;
- const button = row.querySelector('button');
- button.textContent = '×';
- button.title = t('removeConnection');
- button.setAttribute('aria-label', t('removeConnection'));
- button.addEventListener('click', () => removeConnection(connection.id));
- root.appendChild(row);
- });
-}
-
-function removeConnection(id) {
- state.connections = state.connections.filter((item) => item.id !== id);
- state.activeConnectionId = null;
- hideConnectionTools();
- save();
- render();
- setStatus(t('connectionRemoved'));
-}
-
-function showRelationPopover(worldX, worldY, fromId, toId) {
- relationContext = { fromId, toId };
- const popover = $('relationPopover');
- const canvas = $('canvas');
- const screen = worldToScreen(worldX, worldY);
- const width = 220;
- const left = Math.max(8, Math.min(canvas.clientWidth - width - 8, screen.x - width / 2));
- const top = Math.max(8, Math.min(canvas.clientHeight - 180, screen.y + 14));
- popover.style.left = `${left}px`;
- popover.style.top = `${top}px`;
- popover.hidden = false;
-}
-
-function hideRelationPopover() {
- const popover = $('relationPopover');
- if (popover) popover.hidden = true;
- relationContext = null;
-}
-
-function showConnectionTools(connectionId, x, y) {
- state.activeConnectionId = connectionId;
- const tools = $('connectionTools');
- if (!tools) return;
- const connection = state.connections.find((c) => c.id === connectionId);
- if (!connection) return;
- $('connectionToolsLabel').textContent = relationLabel(connection.type);
- const canvas = $('canvas');
- const screen = worldToScreen(x, y);
- const left = Math.max(40, Math.min(canvas.clientWidth - 40, screen.x));
- const top = Math.max(20, Math.min(canvas.clientHeight - 20, screen.y));
- tools.style.left = `${left}px`;
- tools.style.top = `${top}px`;
- tools.hidden = false;
- renderConnections();
-}
-
-function hideConnectionTools() {
- const tools = $('connectionTools');
- if (tools) tools.hidden = true;
- if (state.activeConnectionId !== null) {
- state.activeConnectionId = null;
- renderConnections();
- }
-}
-
-function connectSelectedCards() {
- if (state.selectedIds.length !== 2) {
- setStatus(t('needTwoCards'));
- return;
- }
- const [from, to] = state.selectedIds;
- const type = $('relationType').value;
- const existing = state.connections.find((connection) => connection.from === from && connection.to === to);
- if (existing) existing.type = type;
- else state.connections.push({ id: uid('connection'), from, to, type });
- save();
- render();
- setStatus(t('connected'));
-}
-
-function fallbackSpark(text) {
- const base = text || $('seedInput').value.trim() || 'New creative direction';
- return [
- { kind: 'idea', title: 'Audience angle', body: `Who needs "${base}" badly enough to change behavior?` },
- { kind: 'question', title: 'Constraint to clarify', body: 'What must stay true even if the idea becomes smaller?' },
- { kind: 'insight', title: 'Sendable shape', body: 'Frame the output as a decision, invitation, or next action.' }
- ];
-}
-
-function extractJson(text) {
- const raw = String(text || '').trim();
- try {
- return JSON.parse(raw);
- } catch (_) {
- const match = raw.match(/\{[\s\S]*\}/);
- if (!match) return null;
- try {
- return JSON.parse(match[0]);
- } catch (error) {
- runtime().log?.warn?.('Failed to parse AI JSON', { error: String(error) });
- return null;
- }
- }
-}
-
-function buildPrompt(action, cards, topic, outputFormat) {
- const relatedConnections = relatedConnectionsFor(cards);
- const boardSummary = {
- title: state.boardTitle,
- cardCount: state.cards.length,
- connectionCount: state.connections.length,
- outputFormat,
- selectedCardIds: cards.map((card) => card.id)
- };
- return [
- 'You are Spark Board, an AI creative canvas collaborator.',
- 'Return compact JSON only with this shape: {"cards":[{"kind":"idea|question|insight|output|assumption|counterpoint|source","title":"...","body":"..."}],"draft":"optional send-ready text"}.',
- 'The user experiences this as an AI-native canvas, so prefer specific cards and inferred relationships over UI instructions.',
- `Action: ${action}.`,
- `Requested output format: ${outputFormat}.`,
- `Topic: ${topic || 'none'}.`,
- `Board summary: ${JSON.stringify(boardSummary)}.`,
- `Selected cards: ${JSON.stringify(cards.map(({ id, title, body, kind, x, y }) => ({ id, title, body, kind, x, y })))}.`,
- `Related connections: ${JSON.stringify(relatedConnections)}.`
- ].join('\n');
-}
-
-function askAi(action, cards, topic) {
- const outputFormat = inferOutputFormat(`${action}\n${topic || ''}`);
- state.outputFormat = outputFormat;
- const prompt = buildPrompt(action, cards, topic, outputFormat);
- const SYSTEM = 'Create useful, specific, concise creative thinking cards. If drafting, match the requested output format. Do not include markdown fences.';
-
- return new Promise((resolve, reject) => {
- if (!runtime().ai || typeof runtime().ai.chat !== 'function') {
- if (typeof runtime().ai?.complete === 'function') {
- let cancelled = false;
- state.activeStream = { cancel: () => { cancelled = true; reject(new Error('cancelled')); } };
- runtime().ai.complete(prompt, { systemPrompt: SYSTEM, maxTokens: 1200, temperature: 0.65 })
- .then((result) => { if (!cancelled) { state.activeStream = null; resolve(extractJson(result?.text ?? result)); } })
- .catch((err) => { if (!cancelled) { state.activeStream = null; reject(err); } });
- return;
- }
- reject(new Error('AI bridge unavailable'));
- return;
- }
-
- let accumulated = '';
- let handle = null;
- let settled = false;
- let cancelled = false;
- const finish = (cb) => { if (settled) return; settled = true; state.activeStream = null; cb(); };
-
- const exposedCancel = () => {
- if (settled) return;
- cancelled = true;
- try { handle?.cancel?.(); } catch (_) { /* noop */ }
- finish(() => reject(new Error('cancelled')));
- };
- state.activeStream = { cancel: exposedCancel };
-
- runtime().ai.chat(
- [{ role: 'user', content: prompt }],
- {
- systemPrompt: SYSTEM,
- model: 'primary',
- maxTokens: 1200,
- temperature: 0.65,
- onChunk: ({ text }) => {
- if (cancelled || !text) return;
- accumulated += text;
- updateStream(accumulated);
- },
- onDone: ({ fullText }) => {
- if (cancelled) return;
- finish(() => resolve(extractJson(fullText || accumulated)));
- },
- onError: ({ message }) => {
- if (cancelled) return;
- finish(() => reject(new Error(message || 'AI failed')));
- }
- }
- ).then((h) => {
- handle = h;
- if (cancelled && h?.cancel) { try { h.cancel(); } catch (_) { /* noop */ } }
- }).catch((error) => {
- if (cancelled) return;
- finish(() => reject(error));
- });
- });
-}
-
-// --- Streaming generation: parse cards out of JSON as it arrives, drop each on canvas with glow ---
-
-function parseStreamingCards(text) {
- const cardsKey = text.indexOf('"cards"');
- if (cardsKey < 0) return { cards: [], complete: false };
- const arrayStart = text.indexOf('[', cardsKey);
- if (arrayStart < 0) return { cards: [], complete: false };
- const cards = [];
- let depth = 0;
- let inString = false;
- let escape = false;
- let objStart = -1;
- let complete = false;
- for (let i = arrayStart + 1; i < text.length; i++) {
- const c = text[i];
- if (escape) { escape = false; continue; }
- if (inString) {
- if (c === '\\') { escape = true; continue; }
- if (c === '"') { inString = false; }
- continue;
- }
- if (c === '"') { inString = true; continue; }
- if (c === '{') {
- if (depth === 0) objStart = i;
- depth++;
- } else if (c === '}') {
- depth--;
- if (depth === 0 && objStart >= 0) {
- try {
- cards.push(JSON.parse(text.slice(objStart, i + 1)));
- } catch (_) { /* partial — skip */ }
- objStart = -1;
- }
- } else if (c === ']' && depth === 0) {
- complete = true;
- break;
- }
- }
- return { cards, complete };
-}
-
-const VALID_KINDS = ['idea', 'question', 'insight', 'output', 'ai', 'assumption', 'counterpoint', 'source'];
-
-function normalizeStreamCard(raw, fallbackKind, index) {
- return {
- kind: VALID_KINDS.includes(raw?.kind) ? raw.kind : (fallbackKind || 'ai'),
- title: String(raw?.title || `Spark ${index + 1}`).slice(0, 90),
- body: String(raw?.body || raw?.text || '').slice(0, 700)
- };
-}
-
-// Pan (keeping current zoom) so a world point sits at a chosen screen fraction.
-function focusWorldPoint(wx, wy, fracX = 0.32, fracY = 0.28, animate = true) {
- const rect = canvasRect();
- const next = {
- panX: rect.width * fracX - wx * view.zoom,
- panY: rect.height * fracY - wy * view.zoom,
- zoom: view.zoom
- };
- animate ? animateView(next) : (Object.assign(view, next), applyView());
-}
-
-function startStream(kind) {
- setBusy(true);
- setStatus(t('thinkingHint'));
- state.selectedIds = [];
- const anchor = computeAnchorPoint();
- state.stream = {
- kind: kind || 'ai',
- anchor,
- cursor: makeClusterCursor(anchor),
- placedCount: 0,
- streamedIds: [],
- prevView: { ...view }
- };
- render();
- focusWorldPoint(anchor.x, anchor.y);
- renderStreamChip();
-}
-
-// Compact, source-anchored "generating" chip — no raw JSON, single busy signal.
-function renderStreamChip() {
- const stage = $('canvasStage');
- if (!stage || !state.stream) return;
- let chip = $('streamChip');
- if (!chip) {
- chip = document.createElement('div');
- chip.id = 'streamChip';
- chip.className = 'stream-chip';
- chip.innerHTML = `
-
-
-
- `;
- stage.appendChild(chip);
- }
- chip.querySelector('.stream-chip-label').textContent = t('thinkingHint');
- const count = state.stream.placedCount;
- const countNode = chip.querySelector('.stream-chip-count');
- countNode.textContent = count > 0 ? String(count) : '';
- countNode.hidden = count === 0;
- const left = state.stream.anchor.x;
- const top = state.stream.anchor.y - 34;
- chip.style.transform = `translate(${left}px, ${top}px)`;
-}
-
-function updateStream(accumulated) {
- if (!state.stream) return;
- const parsed = parseStreamingCards(accumulated);
- if (parsed.cards.length > state.stream.placedCount) {
- placeStreamCards(parsed.cards.slice(state.stream.placedCount));
- }
-}
-
-function placeStreamCards(rawCards) {
- if (!state.stream) return;
- const stage = $('canvasStage');
- const startIdx = state.stream.placedCount;
-
- rawCards.forEach((raw, i) => {
- const normalized = normalizeStreamCard(raw, state.stream.kind, startIdx + i);
- const slot = nextClusterSlot(state.stream.cursor, normalized);
- const card = {
- id: uid('ai'),
- kind: normalized.kind,
- title: normalized.title,
- body: normalized.body,
- x: slot.x,
- y: slot.y,
- _glow: true,
- _streaming: true
- };
- state.cards.push(card);
- state.stream.streamedIds.push(card.id);
- state.stream.placedCount += 1;
- state.selectedIds = [...state.stream.streamedIds];
-
- if (stage) stage.appendChild(renderCard(card));
- card._renderedOnce = true;
- setTimeout(() => { delete card._glow; }, 850);
- });
-
- measureCards();
- renderConnections();
- renderStreamChip();
- updateSelectionMeta();
-}
-
-function finalizeStream(json, preferredKind) {
- if (!state.stream) return;
- const parsedFinal = Array.isArray(json?.cards)
- ? json.cards.slice(0, 6).map((item, i) => normalizeStreamCard(item, preferredKind, i))
- : [];
-
- // Each streamed card already carries its final content (objects emit complete),
- // so we only place the tail the streaming parser hadn't reached.
- const extras = parsedFinal.slice(state.stream.placedCount);
- if (extras.length > 0) placeStreamCards(extras);
-
- const draft = json?.draft ? String(json.draft) : '';
- if (draft) {
- state.draft = draft;
- if (preferredKind === 'output') {
- const slot = nextClusterSlot(state.stream.cursor, { body: draft });
- const outputCard = {
- id: uid('output'),
- kind: 'output',
- title: t('outputCardTitle'),
- body: draft,
- x: slot.x,
- y: slot.y,
- _glow: true,
- _streaming: true
- };
- state.cards.push(outputCard);
- state.stream.streamedIds.push(outputCard.id);
- state.selectedIds = [...state.stream.streamedIds];
- const stage = $('canvasStage');
- if (stage) stage.appendChild(renderCard(outputCard));
- outputCard._renderedOnce = true;
- openDrawer('draft');
- setTimeout(() => { delete outputCard._glow; }, 850);
- }
- }
-
- state.stream.streamedIds.forEach((id) => {
- const card = state.cards.find((c) => c.id === id);
- if (card) delete card._streaming;
- });
-
- const framed = state.stream.streamedIds
- .map((id) => state.cards.find((c) => c.id === id))
- .filter(Boolean);
- measureCards();
- setStatus(draft ? t('drafted') : t('generated'));
- stopStream();
- if (framed.length > 0) fitView(framed, { maxZoom: 1.05 });
-}
-
-function clearStreamingCards() {
- if (!state.stream) return;
- const ids = new Set(state.stream.streamedIds);
- if (ids.size === 0) return;
- state.cards = state.cards.filter((c) => !ids.has(c.id));
- state.selectedIds = state.selectedIds.filter((id) => !ids.has(id));
- state.connections = state.connections.filter((c) => !ids.has(c.from) && !ids.has(c.to));
- ids.forEach((id) => {
- const node = document.querySelector(`.card[data-id="${id}"]`);
- if (node) node.remove();
- });
- state.stream.streamedIds = [];
- state.stream.placedCount = 0;
-}
-
-function placeFallback(rawCards, draft, preferredKind) {
- const normalized = rawCards.map((item, i) => normalizeStreamCard(item, preferredKind, i));
- const placed = buildGeneratedCards(normalized, preferredKind);
- placed.forEach((c) => { c._glow = true; });
- state.cards.push(...placed);
- state.selectedIds = placed.map((c) => c.id);
- if (draft) state.draft = draft;
- render();
- placed.forEach((c) => { c._renderedOnce = true; });
- setTimeout(() => { placed.forEach((c) => { delete c._glow; }); }, 850);
- if (placed.length > 0) fitView(placed, { maxZoom: 1.05 });
-}
-
-function stopStream() {
- setBusy(false);
- state.stream = null;
- const chip = $('streamChip');
- if (chip) chip.remove();
-}
-
-function cancelThinking() {
- const handle = state.activeStream;
- const prevView = state.stream?.prevView;
- state.activeStream = null;
- try { handle?.cancel?.(); } catch (_) { /* noop */ }
- clearStreamingCards();
- stopStream();
- if (prevView) animateView(prevView);
- setStatus(t('cancelled'));
-}
-
-function isCancelError(error) {
- const msg = String(error?.message || error || '').toLowerCase();
- return msg.includes('cancel') || msg.includes('abort');
-}
-
-const CARD_WIDTH = 232;
-const CARD_HEIGHT = 116;
-const CARD_GAP_X = 36;
-const CARD_GAP_Y = 22;
-const CLUSTER_PER_COL = 4;
-
-function computeBoundingBox(cards) {
- const list = (cards || []).filter(Boolean);
- if (list.length === 0) return { minX: 0, minY: 0, maxX: CARD_WIDTH, maxY: CARD_HEIGHT };
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
- list.forEach((c) => {
- const h = cardHeight(c);
- if (c.x < minX) minX = c.x;
- if (c.y < minY) minY = c.y;
- if (c.x + CARD_WIDTH > maxX) maxX = c.x + CARD_WIDTH;
- if (c.y + h > maxY) maxY = c.y + h;
- });
- return { minX, minY, maxX, maxY };
-}
-
-// Estimate a card's height before it is rendered, so streamed slots don't shift.
-function estimateCardHeight(item) {
- const bodyLen = (item?.body || '').length;
- const titleLen = (item?.title || '').length;
- const lines = Math.ceil(bodyLen / 30) + (titleLen > 34 ? 1 : 0);
- return Math.max(110, Math.min(300, 74 + lines * 18));
-}
-
-// World-space anchor for a fresh generated cluster.
-function computeAnchorPoint() {
- const selected = selectedCards();
- if (selected.length > 0) {
- const rightmost = selected.reduce((m, c) => (c.x > m.x ? c : m), selected[0]);
- return { x: rightmost.x + CARD_WIDTH + CARD_GAP_X * 2, y: rightmost.y };
- }
- if (state.cards.length === 0) {
- const center = viewportCenterWorld();
- return { x: Math.round(center.x - CARD_WIDTH / 2), y: Math.round(center.y - CARD_HEIGHT) };
- }
- const box = computeBoundingBox(state.cards);
- return { x: box.maxX + CARD_GAP_X * 2, y: box.minY };
-}
-
-// Sequential column-packing cursor: deterministic, height-aware, never overlaps.
-function makeClusterCursor(anchor) {
- return { colX: anchor.x, colTop: anchor.y, y: anchor.y, countInCol: 0 };
-}
-
-function nextClusterSlot(cursor, item) {
- if (cursor.countInCol >= CLUSTER_PER_COL) {
- cursor.colX += CARD_WIDTH + CARD_GAP_X;
- cursor.y = cursor.colTop;
- cursor.countInCol = 0;
- }
- const slot = { x: cursor.colX, y: cursor.y };
- cursor.y += estimateCardHeight(item) + CARD_GAP_Y;
- cursor.countInCol += 1;
- return slot;
-}
-
-function buildGeneratedCards(cards, preferredKind) {
- const cursor = makeClusterCursor(computeAnchorPoint());
- return cards.map((card) => {
- const slot = nextClusterSlot(cursor, card);
- return {
- id: uid('ai'),
- kind: card.kind || preferredKind || 'ai',
- title: card.title,
- body: card.body,
- x: slot.x,
- y: slot.y
- };
- });
-}
-
-/* ---------- Tidy: semantic layered auto-layout ---------- */
-
-const TIDY_FORWARD = new Set(['expands', 'becomes', 'supports']);
-
-function computeTidyTargets() {
- const byId = new Map(state.cards.map((c) => [c.id, c]));
- const edges = state.connections.filter(
- (conn) => TIDY_FORWARD.has(conn.type) && byId.has(conn.from) && byId.has(conn.to)
- );
-
- const layer = new Map(state.cards.map((c) => [c.id, 0]));
- const connected = new Set();
- edges.forEach((conn) => { connected.add(conn.from); connected.add(conn.to); });
-
- // Longest-path layering via relaxation (cycle-safe with an iteration guard).
- let changed = true;
- let guard = 0;
- while (changed && guard++ < state.cards.length + 4) {
- changed = false;
- edges.forEach((conn) => {
- const next = layer.get(conn.from) + 1;
- if (next > layer.get(conn.to)) { layer.set(conn.to, next); changed = true; }
- });
- }
-
- const layers = [];
- state.cards.forEach((c) => {
- if (!connected.has(c.id)) return;
- const li = layer.get(c.id);
- (layers[li] ||= []).push(c);
- });
-
- const colGap = CARD_WIDTH + CARD_GAP_X * 2;
- const rowGap = CARD_GAP_Y + 8;
- const colHeights = layers.map((cards) =>
- (cards || []).reduce((sum, c) => sum + cardHeight(c) + rowGap, -rowGap)
- );
- const tallest = Math.max(0, ...colHeights);
-
- const targets = new Map();
- let graphBottom = 0;
- layers.forEach((cards, li) => {
- if (!cards) return;
- const x = li * colGap;
- let y = (tallest - colHeights[li]) / 2;
- cards.forEach((c) => {
- targets.set(c.id, { x, y });
- y += cardHeight(c) + rowGap;
- graphBottom = Math.max(graphBottom, y);
- });
- });
-
- // Cards with no semantic edges: pack into a grid block below the graph.
- const isolated = state.cards.filter((c) => !connected.has(c.id));
- if (isolated.length > 0) {
- const cols = Math.max(1, Math.min(4, Math.round(Math.sqrt(isolated.length))));
- const anchorY = targets.size > 0 ? graphBottom + CARD_GAP_Y * 3 : 0;
- const cursor = makeClusterCursor({ x: 0, y: anchorY });
- cursor.colTop = anchorY;
- // grid: fill row by row using a fixed perCol derived from cols
- let i = 0;
- const colTops = new Array(cols).fill(anchorY);
- isolated.forEach((c) => {
- const col = i % cols;
- const x = col * (CARD_WIDTH + CARD_GAP_X);
- const y = colTops[col];
- targets.set(c.id, { x, y });
- colTops[col] += cardHeight(c) + rowGap;
- i += 1;
- });
- }
-
- return targets;
-}
-
-function tidyBoard() {
- if (busy) return;
- if (state.cards.length === 0) { setStatus(t('ready')); return; }
- const targets = computeTidyTargets();
- const stage = $('canvasStage');
- state.cards.forEach((c) => {
- const tgt = targets.get(c.id);
- if (!tgt) return;
- c.x = tgt.x;
- c.y = tgt.y;
- const node = stage?.querySelector(`.card[data-id="${c.id}"]`);
- if (node) {
- node.classList.add('is-tidying');
- node.style.transform = `translate(${c.x}px, ${c.y}px)`;
- }
- });
- renderConnections();
- save();
- setStatus(t('tidied'));
- window.setTimeout(() => {
- stage?.querySelectorAll('.card.is-tidying').forEach((n) => n.classList.remove('is-tidying'));
- renderConnections();
- fitView(state.cards);
- }, 60);
-}
-
-async function runComposerIntent() {
- const instruction = $('seedInput').value.trim();
- if (!instruction) {
- setStatus(t('emptyInput'));
- return;
- }
- const cards = selectedCards();
- const wantsDraft = /draft|write|send|message|email|issue|prd|post|成稿|草稿|发送|邮件|需求|帖子/.test(instruction.toLowerCase());
- const preferredKind = wantsDraft ? 'output' : 'idea';
- startStream(preferredKind);
- try {
- const action = cards.length > 0
- ? `follow the user's instruction for the selected canvas cards: ${instruction}`
- : `start or reshape the canvas from this user intent: ${instruction}`;
- const json = await askAi(action, cards, instruction);
- finalizeStream(json, preferredKind);
- $('seedInput').value = '';
- } catch (error) {
- if (isCancelError(error)) {
- clearStreamingCards();
- stopStream();
- save();
- return;
- }
- clearStreamingCards();
- stopStream();
- runtime().log?.warn?.('Spark Board composer intent failed', { error: String(error) });
- placeFallback(fallbackSpark(instruction), '', preferredKind);
- setStatus(t('aiFailed'));
- } finally {
- save();
- }
-}
-
-async function runAction(action) {
- const cards = selectedCards();
- if (cards.length === 0) {
- setStatus(t('needSelection'));
- return;
- }
- const kind = action === 'draft' ? 'output' : 'ai';
- startStream(kind);
- try {
- const json = await askAi(action, cards, $('seedInput').value.trim());
- if (action === 'draft' && !json?.draft) {
- const fallbackDraft = cards.map((c) => `${c.title}\n${c.body}`).join('\n\n');
- json.draft = fallbackDraft;
- }
- finalizeStream(json, kind);
- } catch (error) {
- if (isCancelError(error)) {
- clearStreamingCards();
- stopStream();
- save();
- return;
- }
- clearStreamingCards();
- stopStream();
- runtime().log?.warn?.('Spark Board AI action failed', { action, error: String(error) });
- const fallback = action === 'challenge'
- ? [{ kind: 'counterpoint', title: 'What could be wrong?', body: 'Name the assumption that would break this idea if it turned out false.' }]
- : fallbackSpark(cards[0]?.title);
- placeFallback(fallback, '', kind);
- setStatus(t('aiFailed'));
- } finally {
- save();
- }
-}
-
-async function copyDraft() {
- const text = currentDraftText();
- if (!text) {
- setStatus(t('copyEmpty'));
- return;
- }
- state.draft = text;
- save();
- try {
- await copyText(text);
- setStatus(t('copied'));
- } catch (error) {
- runtime().log?.warn?.('Failed to copy Spark Board draft', { error: String(error) });
- setStatus(t('copied'));
- }
-}
-
-async function sendDraft() {
- const text = currentDraftText();
- if (!text) {
- setStatus(t('copyEmpty'));
- return;
- }
- state.draft = text;
- save();
- try {
- if (!runtime().host?.fillChatInput) throw new Error('host.fillChatInput unavailable');
- await runtime().host.fillChatInput(text);
- setStatus(t('sentToChat'));
- } catch (error) {
- runtime().log?.warn?.('Failed to send Spark Board draft to chat input', { error: String(error) });
- await copyText(text);
- setStatus(t('sendUnavailable'));
- }
-}
-
-function currentDraftText() {
- const explicit = ($('previewDraft')?.value || $('draftOutput')?.value || state.draft || '').trim();
- if (explicit) return explicit;
- const latestOutput = [...state.cards].reverse().find((card) => card.kind === 'output' && card.body);
- return latestOutput ? latestOutput.body.trim() : '';
-}
-
-async function copyText(text) {
- if (runtime().clipboard) {
- await runtime().clipboard.writeText(text);
- return;
- }
- if (navigator.clipboard?.writeText) {
- await navigator.clipboard.writeText(text);
- return;
- }
- throw new Error('Clipboard is unavailable');
-}
-
-function escapeMarkdown(text) {
- return String(text || '').replace(/\r\n/g, '\n').trim();
-}
-
-function boardToMarkdown() {
- const lines = [`# ${escapeMarkdown(state.boardTitle || t('untitledBoard'))}`, ''];
- lines.push(`_Exported from Spark Board_`, '');
-
- if (state.cards.length > 0) {
- lines.push('## Cards', '');
- state.cards.forEach((card, index) => {
- lines.push(`### ${index + 1}. ${escapeMarkdown(card.title || kindLabel(card.kind))}`);
- lines.push('');
- lines.push(`- Type: ${kindLabel(card.kind)}`);
- lines.push(`- Position: ${Math.round(card.x)}, ${Math.round(card.y)}`);
- if (card.body) {
- lines.push('');
- lines.push(escapeMarkdown(card.body));
- }
- lines.push('');
- });
- }
-
- if (state.connections.length > 0) {
- lines.push('## Connections', '');
- state.connections.forEach((connection) => {
- const from = state.cards.find((card) => card.id === connection.from);
- const to = state.cards.find((card) => card.id === connection.to);
- if (!from || !to) return;
- lines.push(`- **${escapeMarkdown(from.title)}** ${relationLabel(connection.type).toLowerCase()} **${escapeMarkdown(to.title)}**`);
- });
- lines.push('');
- }
-
- if (state.draft.trim()) {
- lines.push('## Send-ready Draft', '');
- lines.push(escapeMarkdown(state.draft));
- lines.push('');
- }
-
- return lines.join('\n').replace(/\n{3,}/g, '\n\n');
-}
-
-async function exportMarkdown() {
- const markdown = boardToMarkdown();
- try {
- await copyText(markdown);
- setStatus(t('exported'));
- } catch (error) {
- runtime().log?.warn?.('Failed to export Spark Board Markdown', { error: String(error) });
- setStatus(t('exported'));
- }
-}
-
-function resetBoard() {
- state.cards = clone(DEFAULT_CARDS);
- state.selectedIds = [];
- state.connections = [];
- state.draft = '';
- state.mode = 'free';
- state.outputFormat = 'message';
- state.activeConnectionId = null;
- view = { panX: 0, panY: 0, zoom: 1 };
- state._needsFit = true;
- $('seedInput').value = '';
- if ($('previewDraft')) $('previewDraft').value = '';
- setStatus(t('resetDone'));
- save();
- render();
-}
-
-function createBoard() {
- persistCurrentBoard();
- const id = uid('board');
- const title = `${t('newBoardTitle')} ${state.boards.length + 1}`;
- state.boards.unshift({
- id,
- title,
- cards: [],
- connections: [],
- draft: '',
- mode: 'free',
- outputFormat: 'message',
- schemaVersion: SCHEMA_VERSION,
- updatedAt: Date.now()
- });
- applyBoard(state.boards[0]);
- $('seedInput').value = '';
- save();
- render();
- setStatus(t('boardCreated'));
-}
-
-function switchBoard(id) {
- state.boardTitle = $('boardTitle').value.trim() || state.boardTitle;
- persistCurrentBoard();
- const board = state.boards.find((item) => item.id === id);
- if (!board) return;
- applyBoard(board);
- $('seedInput').value = '';
- save();
- render();
-}
-
-function renameBoard(title) {
- state.boardTitle = title.trim() || t('untitledBoard');
- save();
- renderBoardSwitcher();
-}
-
-function openDrawer(pane) {
- root().setAttribute('data-drawer', 'open');
- const drawer = $('drawer');
- drawer.hidden = false;
- $('toggleDrawer').classList.add('is-active');
- if (pane) switchDrawerPane(pane);
-}
-
-function closeDrawer() {
- root().setAttribute('data-drawer', 'closed');
- $('toggleDrawer').classList.remove('is-active');
-}
-
-function toggleDrawer() {
- if (root().getAttribute('data-drawer') === 'open') closeDrawer();
- else openDrawer();
-}
-
-function switchDrawerPane(pane) {
- document.querySelectorAll('.drawer-tab').forEach((btn) => {
- const isActive = btn.dataset.pane === pane;
- btn.classList.toggle('is-active', isActive);
- btn.setAttribute('aria-selected', isActive ? 'true' : 'false');
- });
- document.querySelectorAll('.drawer-pane').forEach((sec) => {
- const isActive = sec.dataset.pane === pane;
- sec.classList.toggle('is-active', isActive);
- sec.hidden = !isActive;
- });
-}
-
-function canScrollInDirection(node, deltaY) {
- if (!node) return false;
- if (node.classList?.contains('board-tabs')) return false;
- const style = window.getComputedStyle(node);
- const allowsScroll = /(auto|scroll)/.test(style.overflowY);
- if (!allowsScroll && !['TEXTAREA', 'SELECT'].includes(node.tagName)) return false;
- if (node.scrollHeight <= node.clientHeight + 1) return false;
- if (deltaY < 0) return node.scrollTop > 0;
- if (deltaY > 0) return node.scrollTop + node.clientHeight < node.scrollHeight - 1;
- return false;
-}
-
-function shouldAllowWheel(event) {
- const target = event.target;
- const boardTabs = target?.closest?.('.board-tabs');
- if (boardTabs && Math.abs(event.deltaX) > Math.abs(event.deltaY)) return true;
- const scrollRoot = target?.closest?.('textarea, .drawer-pane, .drawer-body');
- return canScrollInDirection(scrollRoot, event.deltaY);
-}
-
-function lockViewportScroll() {
- if (window.scrollX !== 0 || window.scrollY !== 0) window.scrollTo(0, 0);
- document.documentElement.scrollTop = 0;
- document.body.scrollTop = 0;
-}
-
-function initEvents() {
- $('addCard').addEventListener('click', addCardFromInput);
- $('composerForm').addEventListener('submit', (event) => {
- event.preventDefault();
- if (busy) { cancelThinking(); return; }
- runComposerIntent();
- });
- $('seedInput').addEventListener('keydown', (event) => {
- if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
- event.preventDefault();
- if (busy) { cancelThinking(); return; }
- runComposerIntent();
- }
- });
- $('seedInput').addEventListener('input', (event) => {
- const el = event.target;
- el.style.height = 'auto';
- el.style.height = `${Math.min(140, el.scrollHeight)}px`;
- });
- $('exportMarkdown').addEventListener('click', exportMarkdown);
- $('sendDraft').addEventListener('click', sendDraft);
- $('copyDraft').addEventListener('click', copyDraft);
- $('resetBoard').addEventListener('click', resetBoard);
- $('newBoard').addEventListener('click', createBoard);
- $('boardSelect').addEventListener('change', (event) => switchBoard(event.target.value));
- $('boardTitle').addEventListener('change', (event) => {
- renameBoard(event.target.value);
- setStatus(t('boardRenamed'));
- });
- $('connectCards').addEventListener('click', connectSelectedCards);
- $('previewDraft').addEventListener('input', (event) => {
- state.draft = event.target.value;
- save();
- updateSelectionMeta();
- });
- $('outputFormat').addEventListener('change', (event) => {
- state.outputFormat = event.target.value;
- save();
- });
- $('draftOutput').addEventListener('input', (event) => {
- state.draft = event.target.value;
- save();
- updateSelectionMeta();
- });
-
- $('toggleDrawer').addEventListener('click', toggleDrawer);
- $('closeDrawer').addEventListener('click', closeDrawer);
- document.querySelectorAll('.drawer-tab').forEach((tab) => {
- tab.addEventListener('click', () => switchDrawerPane(tab.dataset.pane));
- });
-
- document.querySelectorAll('#relationPopover button[data-relation]').forEach((btn) => {
- btn.addEventListener('click', (event) => {
- event.stopPropagation();
- const type = btn.dataset.relation;
- if (type === 'cancel' || !relationContext) {
- hideRelationPopover();
- return;
- }
- const { fromId, toId } = relationContext;
- const existing = state.connections.find((c) => c.from === fromId && c.to === toId);
- if (existing) existing.type = type;
- else state.connections.push({ id: uid('connection'), from: fromId, to: toId, type });
- hideRelationPopover();
- save();
- render();
- setStatus(t('connected'));
- });
- });
-
- $('deleteConnection').addEventListener('click', () => {
- if (state.activeConnectionId) removeConnection(state.activeConnectionId);
- });
-
- $('canvas').addEventListener('pointerdown', (event) => {
- if (event.button !== 0 && event.button !== 1) return;
- if (event.target.closest('.card, .connection, .relation-popover, .connection-tools, .canvas-floating, .canvas-controls')) return;
- panState = {
- startX: event.clientX,
- startY: event.clientY,
- panX: view.panX,
- panY: view.panY,
- moved: false,
- pointerId: event.pointerId
- };
- try { $('canvas').setPointerCapture(event.pointerId); } catch (_) { /* noop */ }
- $('canvas').classList.add('is-panning');
- });
-
- $('canvas').addEventListener('click', (event) => {
- if (suppressCanvasClick) { suppressCanvasClick = false; return; }
- if (event.target.closest('.card, .connection, .relation-popover, .connection-tools')) return;
- state.selectedIds = [];
- hideRelationPopover();
- hideConnectionTools();
- render();
- });
-
- document.addEventListener('keydown', (event) => {
- if (event.key === 'Escape') {
- hideRelationPopover();
- hideConnectionTools();
- if (busy) cancelThinking();
- }
- });
-
- if (runtime().onLocaleChange) runtime().onLocaleChange(() => {
- applyI18n();
- render();
- });
- window.addEventListener('resize', () => {
- lockViewportScroll();
- renderConnections();
- });
- window.addEventListener('wheel', (event) => {
- if (shouldAllowWheel(event)) return;
- event.preventDefault();
- if (event.target?.closest?.('.canvas')) {
- if (event.ctrlKey || event.metaKey) {
- zoomAt(event.clientX, event.clientY, Math.exp(-event.deltaY * 0.0015));
- } else {
- view.panX -= event.deltaX;
- view.panY -= event.deltaY;
- applyView();
- scheduleSave();
- }
- }
- lockViewportScroll();
- }, { capture: true, passive: false });
-
- $('zoomIn').addEventListener('click', () => {
- const rect = canvasRect();
- zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 1.2);
- });
- $('zoomOut').addEventListener('click', () => {
- const rect = canvasRect();
- zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 1 / 1.2);
- });
- $('zoomFit').addEventListener('click', () => fitView(state.cards));
- $('tidyBoard').addEventListener('click', tidyBoard);
- window.addEventListener('scroll', lockViewportScroll, { capture: true, passive: true });
-}
-
-async function init() {
- await load();
- applyI18n();
- initEvents();
- render();
-}
-
-init();
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/worker.js b/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/worker.js
deleted file mode 100644
index 39bab1bb..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/components/surfaces/builtin-spark-board-surface/source/worker.js
+++ /dev/null
@@ -1 +0,0 @@
-// Built-in Spark Board uses host AI and local storage from the UI bridge.
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/config/data-schema.json b/bundles/product-apps/builtin-spark-board/1.0.0/config/data-schema.json
deleted file mode 100644
index 1587a669..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/config/data-schema.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "version": "1.0.0"
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/config/default.json b/bundles/product-apps/builtin-spark-board/1.0.0/config/default.json
deleted file mode 100644
index 0967ef42..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/config/default.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/tests/eval.json b/bundles/product-apps/builtin-spark-board/1.0.0/tests/eval.json
deleted file mode 100644
index c6cfd579..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/tests/eval.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "version": 1,
- "cases": [
- {
- "id": "primary-behavior",
- "title": "Spark Board responds from its declared app context",
- "description": "Proves that the immutable Release can invoke its intelligent behavior.",
- "input": {
- "message": "Reply with SPARO_APP_READY, then briefly confirm you can help with Spark Board."
- },
- "expectations": [
- {
- "kind": "text-contains",
- "value": "SPARO_APP_READY"
- }
- ],
- "evidenceKind": "behavior",
- "tags": [
- "release-gate"
- ],
- "required": true
- }
- ]
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.0/tests/rehearsal.json b/bundles/product-apps/builtin-spark-board/1.0.0/tests/rehearsal.json
deleted file mode 100644
index afa27ebf..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.0/tests/rehearsal.json
+++ /dev/null
@@ -1,33 +0,0 @@
-{
- "version": 1,
- "scenarios": [
- {
- "id": "critical-user-path",
- "title": "Open Spark Board and complete its first useful action",
- "description": "A lightweight thinking canvas for capturing inspiration, organizing threads, and turning scattered ideas into drafts and next steps.",
- "kind": "user-path",
- "steps": [
- {
- "id": "open-app",
- "action": "open",
- "target": "primary",
- "expect": [
- "Spark Board opens"
- ]
- },
- {
- "id": "observe-ready-state",
- "action": "observe",
- "target": "work-outcome",
- "expect": [
- "A usable ready state is visible"
- ]
- }
- ],
- "expected": [
- "The app opens without a fatal runtime issue",
- "The primary workflow exposes an observable path"
- ]
- }
- ]
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/app.json b/bundles/product-apps/builtin-spark-board/1.0.1/app.json
deleted file mode 100644
index 01fdb476..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/app.json
+++ /dev/null
@@ -1,220 +0,0 @@
-{
- "id": "builtin-spark-board",
- "version": "1.0.1",
- "name": "Spark Board",
- "description": "A lightweight thinking canvas for capturing inspiration, organizing threads, and turning scattered ideas into drafts and next steps.",
- "authors": [
- {
- "name": "GCWing",
- "url": "https://github.com/GCWing"
- }
- ],
- "i18n": {
- "locales": {
- "en-US": {
- "name": "Spark Board",
- "description": "A lightweight thinking canvas for capturing inspiration, organizing threads, and turning scattered ideas into drafts and next steps.",
- "tags": [
- "built-in",
- "canvas",
- "ideation",
- "drafting"
- ]
- },
- "zh-CN": {
- "name": "Spark Board",
- "description": "轻量思考画布,用来捕捉灵感、梳理脉络,把零散想法沉淀成草稿和下一步。",
- "tags": [
- "内置",
- "画布",
- "灵感",
- "草稿"
- ]
- }
- }
- },
- "interactionModel": "interactiveWorkspace",
- "workMultiplicity": "singleton",
- "workObjectKinds": [
- {
- "id": "board",
- "label": "Board",
- "scope": "asset",
- "identitySchema": {
- "type": "object",
- "properties": {
- "boardId": {
- "type": "string"
- },
- "workspacePath": {
- "type": "string"
- }
- }
- },
- "contextSchema": {
- "type": "object",
- "properties": {
- "title": {
- "type": "string"
- },
- "clusters": {
- "type": "array"
- },
- "selection": {
- "type": "array"
- }
- }
- }
- },
- {
- "id": "idea-card",
- "label": "Idea Card",
- "scope": "asset",
- "identitySchema": {
- "type": "object",
- "properties": {
- "boardId": {
- "type": "string"
- },
- "cardId": {
- "type": "string"
- }
- }
- },
- "contextSchema": {
- "type": "object",
- "properties": {
- "text": {
- "type": "string"
- },
- "clusterId": {
- "type": "string"
- },
- "position": {
- "type": "object"
- }
- }
- }
- },
- {
- "id": "draft",
- "label": "Draft",
- "scope": "asset",
- "identitySchema": {
- "type": "object",
- "properties": {
- "draftId": {
- "type": "string"
- },
- "artifactId": {
- "type": "string"
- }
- }
- },
- "contextSchema": {
- "type": "object",
- "properties": {
- "sourceCards": {
- "type": "array"
- },
- "format": {
- "type": "string"
- },
- "status": {
- "type": "string"
- }
- }
- }
- }
- ],
- "truthSource": "ownedObjectState",
- "primarySurface": {
- "componentId": "builtin-spark-board-surface",
- "surfaceId": "primary"
- },
- "primarySurfaceMode": "immersivePrimary",
- "components": [
- {
- "componentId": "builtin-spark-board-surface",
- "kind": "surface",
- "source": "private",
- "role": "primarySurface",
- "capabilities": [
- "interactiveSurface"
- ],
- "usesCapabilities": [
- "session.read",
- "component.run"
- ]
- },
- {
- "componentId": "builtin-spark-board-agent",
- "kind": "agent",
- "source": "private",
- "role": "assistant",
- "capabilities": [
- "expandIdeas",
- "draftFromSelection"
- ],
- "usesCapabilities": [
- "session.read",
- "model.invoke",
- "tool.invoke"
- ]
- },
- {
- "componentId": "product-app-runtime-host",
- "kind": "runtime",
- "source": "shared",
- "role": "runtimeHost",
- "version": "^1.0.0",
- "capabilities": [
- "uiWorker"
- ],
- "usesCapabilities": [
- "component.run"
- ]
- }
- ],
- "componentLockId": "",
- "permissions": {
- "fs": true,
- "net": false,
- "shell": false,
- "gui": false,
- "secrets": false,
- "ai": true
- },
- "installScope": "system",
- "catalogVisibility": "discoverable",
- "enabled": true,
- "icon": {
- "kind": "packageAsset",
- "path": "assets/icon.png"
- },
- "category": "creative",
- "tags": [
- "built-in",
- "canvas",
- "ideation",
- "drafting"
- ],
- "launch": {
- "kind": "applicationSurface",
- "targetId": "builtin-spark-board",
- "scopeRequirement": "workspaceOptional",
- "surfaceId": "primary"
- },
- "osCapabilities": [
- "session.read",
- "component.run",
- "model.invoke",
- "tool.invoke"
- ],
- "dataLifecycle": {
- "retention": "userManaged",
- "deletion": "deleteOnUserRequest",
- "migration": "schemaVersioned",
- "share": "declaredWorkObjectsOnly"
- }
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/assets/icon.png b/bundles/product-apps/builtin-spark-board/1.0.1/assets/icon.png
deleted file mode 100644
index b3be67c8..00000000
Binary files a/bundles/product-apps/builtin-spark-board/1.0.1/assets/icon.png and /dev/null differ
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/assets/icon.svg b/bundles/product-apps/builtin-spark-board/1.0.1/assets/icon.svg
deleted file mode 100644
index b09559bd..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/assets/icon.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/compatibility.json b/bundles/product-apps/builtin-spark-board/1.0.1/compatibility.json
deleted file mode 100644
index 7ea934b0..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/compatibility.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "runtimeCompatibility": ">=0.1.0"
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/components/agents/builtin-spark-board-agent/component.json b/bundles/product-apps/builtin-spark-board/1.0.1/components/agents/builtin-spark-board-agent/component.json
deleted file mode 100644
index e5f56458..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/components/agents/builtin-spark-board-agent/component.json
+++ /dev/null
@@ -1,52 +0,0 @@
-{
- "id": "builtin-spark-board-agent",
- "kind": "agent",
- "name": "Spark Board Agent",
- "description": "Creative thinking agent for expanding, challenging, clustering, summarizing, and drafting from selected canvas notes.",
- "packageSource": "appPrivate",
- "ownerApp": {
- "appId": "builtin-spark-board",
- "appVersion": "1.0.1"
- },
- "capabilities": [
- {
- "id": "expandIdeas",
- "title": "Expand ideas",
- "description": "Generate related ideas, counterpoints, and clusters for selected notes.",
- "actions": [
- "expand",
- "challenge",
- "cluster"
- ]
- },
- {
- "id": "draftFromSelection",
- "title": "Draft from selection",
- "description": "Turn selected canvas notes into a send-ready draft.",
- "actions": [
- "summarizeSelection",
- "draft"
- ]
- }
- ],
- "permissions": [
- {
- "kind": "ai",
- "summary": "Uses the configured AI agent for canvas ideation and drafting.",
- "scopes": [
- "Runno"
- ]
- }
- ],
- "usedByApps": [
- "builtin-spark-board"
- ],
- "visibility": "appDependency",
- "dependencies": [],
- "implementationRef": "agent://Runno",
- "usesCapabilities": [
- "session.read",
- "model.invoke",
- "tool.invoke"
- ]
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/component.json b/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/component.json
deleted file mode 100644
index 33b3eb3c..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/component.json
+++ /dev/null
@@ -1,64 +0,0 @@
-{
- "id": "builtin-spark-board-surface",
- "kind": "surface",
- "name": "Spark Board Canvas",
- "description": "The app-owned idea canvas, clustering, selection, and draft creation surface.",
- "packageSource": "appPrivate",
- "ownerApp": {
- "appId": "builtin-spark-board",
- "appVersion": "1.0.1"
- },
- "capabilities": [
- {
- "id": "interactiveSurface",
- "title": "Interactive surface",
- "description": "Hosts the Spark Board primary workspace.",
- "actions": [
- "open",
- "createCard",
- "clusterIdeas",
- "draftFromSelection"
- ]
- }
- ],
- "permissions": [
- {
- "kind": "fs",
- "summary": "Reads and writes app-owned canvas state.",
- "scopes": [
- "{appdata}"
- ]
- }
- ],
- "usedByApps": [
- "builtin-spark-board"
- ],
- "visibility": "appDependency",
- "dependencies": [
- {
- "componentId": "builtin-spark-board-agent",
- "kind": "agent",
- "source": "private",
- "role": "assistant",
- "capabilities": [
- "expandIdeas",
- "draftFromSelection"
- ]
- },
- {
- "componentId": "product-app-runtime-host",
- "kind": "runtime",
- "source": "shared",
- "role": "runtimeHost",
- "version": "^1.0.0",
- "capabilities": [
- "uiWorker"
- ]
- }
- ],
- "implementationRef": "app://builtin-spark-board@1.0.1/surfaces/builtin-spark-board-surface",
- "usesCapabilities": [
- "session.read",
- "component.run"
- ]
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/esm_dependencies.json b/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/esm_dependencies.json
deleted file mode 100644
index fe51488c..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/esm_dependencies.json
+++ /dev/null
@@ -1 +0,0 @@
-[]
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/index.html b/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/index.html
deleted file mode 100644
index 0918f91f..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/index.html
+++ /dev/null
@@ -1,177 +0,0 @@
-
-
-
-
-
-
Spark Board
-
-
-
-
-
-
-
-
-
Spark Board
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Drop in a thought.
-
AI will turn it into movable cards, links, and a send-ready output.
-
Try: write a sentence below, then press Ask AI.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
How are they related?
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
0
-
0
-
0
-
-
-
-
-
-
-
-
-
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/source_manifest.json b/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/source_manifest.json
deleted file mode 100644
index 067c84e8..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/source_manifest.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "uiEntry": "ui.js",
- "workerEntry": "worker.js",
- "styleEntries": [
- "style.css"
- ],
- "buildMode": "nativeEsm"
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/src/i18n.js b/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/src/i18n.js
deleted file mode 100644
index a6905a99..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/src/i18n.js
+++ /dev/null
@@ -1,220 +0,0 @@
-export const STRINGS = {
- 'en-US': {
- eyebrow: 'AI Thinking Canvas',
- title: 'Spark Board',
- boardLabel: 'Board',
- boardTitleLabel: 'Title',
- boardTitlePlaceholder: 'Board title',
- newBoard: 'New board',
- untitledBoard: 'Untitled board',
- defaultBoardTitle: 'First sparks',
- newBoardTitle: 'New board',
- boardCreated: 'New board created.',
- boardRenamed: 'Board renamed.',
- exportMarkdown: 'Export Markdown',
- exported: 'Board Markdown copied.',
- sendDraft: 'Send to chat',
- sentToChat: 'Draft moved to the chat input.',
- sendUnavailable: 'Chat input is unavailable. Draft copied instead.',
- copyDraft: 'Copy draft',
- reset: 'Reset',
- seedLabel: 'Ask AI',
- seedPlaceholder: 'Drop in a thought, or select cards and ask: expand this, find risks, turn it into an email...',
- composerHint: 'Press Ctrl+Enter to ask',
- sparkIdeas: 'Ask AI',
- addCard: 'Add note',
- aiTitle: 'AI collaborator',
- expand: 'Expand',
- cluster: 'Cluster',
- challenge: 'Challenge',
- draft: 'Draft',
- aiScopeNote: 'AI only sees selected cards, related connections, and the topic field.',
- ready: 'Ready.',
- sendTitle: 'Send-ready output',
- formatLabel: 'Output format',
- formatMessage: 'Message',
- formatEmail: 'Email',
- formatIssue: 'Issue',
- formatPrd: 'PRD note',
- formatPost: 'Post',
- formatMarkdown: 'Markdown',
- relationsTitle: 'Connections',
- relationLabel: 'Relationship',
- relationSupports: 'Supports',
- relationChallenges: 'Challenges',
- relationExpands: 'Expands',
- relationBecomes: 'Becomes',
- connectCards: 'Connect selected',
- needTwoCards: 'Select exactly two cards to connect.',
- connected: 'Connection added.',
- connectionRemoved: 'Connection removed.',
- removeConnection: 'Remove connection',
- draftPlaceholder: 'Select cards and choose Draft, or write here directly.',
- canvasTitle: 'Canvas',
- canvasHint: 'Select cards, then ask AI what to do next.',
- emptyTitle: 'Drop in a thought.',
- emptyBody: 'AI will turn it into movable cards, links, and a send-ready output.',
- modeFree: 'Free',
- modeThinking: 'Thinking',
- modeSend: 'Send',
- laneInput: 'Input',
- laneThink: 'Thinking',
- laneSend: 'Send',
- selected: 'selected',
- emptyInput: 'Write a topic or card first.',
- needSelection: 'Select one or more cards first.',
- thinking: 'AI is thinking...',
- copied: 'Draft copied.',
- copyEmpty: 'No draft to copy yet.',
- resetDone: 'Board reset.',
- aiFailed: 'AI request failed. A local card was added instead.',
- generated: 'AI added cards to the canvas.',
- drafted: 'Draft is ready.',
- cardIdea: 'Idea',
- cardQuestion: 'Question',
- cardInsight: 'Insight',
- cardOutput: 'Output',
- outputCardTitle: 'Send-ready output',
- cardAssumption: 'Assumption',
- cardCounterpoint: 'Counterpoint',
- cardSource: 'Source',
- remove: 'Remove card',
- openPanel: 'Open panel',
- closePanel: 'Close panel',
- paneDraft: 'Draft',
- paneSettings: 'Settings',
- cardActionExpand: 'Expand this card',
- cardActionChallenge: 'Challenge this card',
- cardActionConnect: 'Drag to connect',
- cardActionDraft: 'Draft from this card',
- relationPickerTitle: 'How are they related?',
- cancel: 'Cancel',
- deleteConnection: 'Delete connection',
- emptyHintAction: 'Try: write a sentence below, then press Ask AI.',
- drawerTitleDraft: 'Send-ready draft',
- drawerHintDraft: 'AI drafts land here. Edit, then send.',
- drawerEmptyDraft: 'No draft yet. Select cards and ask "draft" to fill this in.',
- boardSettings: 'Board settings',
- resetBoardConfirm: 'Reset board?',
- inputHint: 'Drop a thought, or ask AI what to do next',
- deleteBoard: 'Delete board',
- boardDeleted: 'Board deleted.',
- cancelAi: 'Cancel AI',
- cancelled: 'Cancelled.',
- thinkingHint: 'AI is sketching ideas…',
- tidy: 'Tidy',
- tidied: 'Board tidied.',
- zoomIn: 'Zoom in',
- zoomOut: 'Zoom out',
- zoomFit: 'Fit to content'
- },
- 'zh-CN': {
- eyebrow: 'AI \u601d\u8003\u753b\u5e03',
- title: 'Spark Board',
- boardLabel: '\u753b\u5e03',
- boardTitleLabel: '\u6807\u9898',
- boardTitlePlaceholder: '\u753b\u5e03\u6807\u9898',
- newBoard: '\u65b0\u5efa\u753b\u5e03',
- untitledBoard: '\u672a\u547d\u540d\u753b\u5e03',
- defaultBoardTitle: '\u7b2c\u4e00\u4e2a\u7075\u611f',
- newBoardTitle: '\u65b0\u753b\u5e03',
- boardCreated: '\u65b0\u753b\u5e03\u5df2\u521b\u5efa\u3002',
- boardRenamed: '\u753b\u5e03\u5df2\u91cd\u547d\u540d\u3002',
- exportMarkdown: '\u5bfc\u51fa Markdown',
- exported: 'Markdown \u5df2\u590d\u5236\u3002',
- sendDraft: '\u53d1\u9001\u5230\u5bf9\u8bdd',
- sentToChat: '\u8349\u7a3f\u5df2\u653e\u5165\u5bf9\u8bdd\u8f93\u5165\u6846\u3002',
- sendUnavailable: '\u5bf9\u8bdd\u8f93\u5165\u4e0d\u53ef\u7528\uff0c\u5df2\u6539\u4e3a\u590d\u5236\u8349\u7a3f\u3002',
- copyDraft: '\u590d\u5236\u8349\u7a3f',
- reset: '\u91cd\u7f6e',
- seedLabel: '\u8be2\u95ee AI',
- seedPlaceholder: '\u4e22\u8fdb\u4e00\u4e2a\u60f3\u6cd5\uff0c\u6216\u9009\u4e2d\u5361\u7247\u540e\u8bf4\uff1a\u6269\u5c55\u8fd9\u4e2a\u65b9\u5411\u3001\u627e\u98ce\u9669\u3001\u5199\u6210\u90ae\u4ef6...',
- composerHint: '\u6309 Ctrl+Enter \u8be2\u95ee AI',
- sparkIdeas: '\u8be2\u95ee AI',
- addCard: '\u6dfb\u52a0\u7b14\u8bb0',
- aiTitle: 'AI \u534f\u4f5c\u8005',
- expand: '\u6269\u5c55',
- cluster: '\u805a\u7c7b',
- challenge: '\u8d28\u7591',
- draft: '\u6210\u7a3f',
- aiScopeNote: 'AI \u53ea\u4f1a\u8bfb\u53d6\u9009\u4e2d\u5361\u7247\u3001\u76f8\u5173\u5173\u7cfb\u548c\u4e3b\u9898\u8f93\u5165\u3002',
- ready: '\u5c31\u7eea\u3002',
- sendTitle: '\u53ef\u53d1\u9001\u8f93\u51fa',
- formatLabel: '\u8f93\u51fa\u683c\u5f0f',
- formatMessage: '\u6d88\u606f',
- formatEmail: '\u90ae\u4ef6',
- formatIssue: 'Issue',
- formatPrd: 'PRD \u7247\u6bb5',
- formatPost: '\u5e16\u5b50',
- formatMarkdown: 'Markdown',
- relationsTitle: '\u5173\u7cfb\u7ebf',
- relationLabel: '\u5173\u7cfb',
- relationSupports: '\u652f\u6301',
- relationChallenges: '\u8d28\u7591',
- relationExpands: '\u6269\u5c55',
- relationBecomes: '\u8f6c\u4e3a',
- connectCards: '\u8fde\u63a5\u9009\u4e2d\u5361\u7247',
- needTwoCards: '\u8bf7\u53ea\u9009\u4e2d\u4e24\u5f20\u5361\u7247\u6765\u5efa\u7acb\u5173\u7cfb\u3002',
- connected: '\u5173\u7cfb\u7ebf\u5df2\u6dfb\u52a0\u3002',
- connectionRemoved: '\u5173\u7cfb\u7ebf\u5df2\u79fb\u9664\u3002',
- removeConnection: '\u79fb\u9664\u5173\u7cfb',
- draftPlaceholder: '\u9009\u4e2d\u5361\u7247\u540e\u70b9\u201c\u6210\u7a3f\u201d\uff0c\u4e5f\u53ef\u4ee5\u76f4\u63a5\u5728\u8fd9\u91cc\u5199\u3002',
- canvasTitle: '\u753b\u5e03',
- canvasHint: '\u9009\u4e2d\u5361\u7247\u540e\uff0c\u76f4\u63a5\u8ba9 AI \u63a8\u8fdb\u4e0b\u4e00\u6b65\u3002',
- emptyTitle: '\u4e22\u8fdb\u4e00\u4e2a\u60f3\u6cd5\u3002',
- emptyBody: 'AI \u4f1a\u628a\u5b83\u53d8\u6210\u53ef\u79fb\u52a8\u7684\u5361\u7247\u3001\u5173\u7cfb\u548c\u53ef\u53d1\u9001\u7684\u8f93\u51fa\u3002',
- modeFree: '\u81ea\u7531',
- modeThinking: '\u601d\u8003',
- modeSend: '\u53d1\u9001',
- laneInput: '\u8f93\u5165',
- laneThink: '\u601d\u8003',
- laneSend: '\u53d1\u9001',
- selected: '\u5df2\u9009',
- emptyInput: '\u5148\u5199\u4e00\u4e2a\u4e3b\u9898\u6216\u5361\u7247\u3002',
- needSelection: '\u5148\u9009\u4e2d\u4e00\u5f20\u6216\u591a\u5f20\u5361\u7247\u3002',
- thinking: 'AI \u6b63\u5728\u601d\u8003...',
- copied: '\u8349\u7a3f\u5df2\u590d\u5236\u3002',
- copyEmpty: '\u8fd8\u6ca1\u6709\u53ef\u590d\u5236\u7684\u8349\u7a3f\u3002',
- resetDone: '\u753b\u5e03\u5df2\u91cd\u7f6e\u3002',
- aiFailed: 'AI \u8bf7\u6c42\u5931\u8d25\uff0c\u5df2\u6dfb\u52a0\u672c\u5730\u5361\u7247\u3002',
- generated: 'AI \u5df2\u628a\u65b0\u5361\u7247\u653e\u5230\u753b\u5e03\u4e0a\u3002',
- drafted: '\u8349\u7a3f\u5df2\u751f\u6210\u3002',
- cardIdea: '\u60f3\u6cd5',
- cardQuestion: '\u95ee\u9898',
- cardInsight: '\u6d1e\u5bdf',
- cardOutput: '\u8f93\u51fa',
- outputCardTitle: '\u53ef\u53d1\u9001\u8f93\u51fa',
- cardAssumption: '\u5047\u8bbe',
- cardCounterpoint: '\u53cd\u4f8b',
- cardSource: '\u8d44\u6599',
- remove: '\u5220\u9664\u5361\u7247',
- openPanel: '\u6253\u5f00\u4fa7\u8fb9\u680f',
- closePanel: '\u6536\u8d77\u4fa7\u8fb9\u680f',
- paneDraft: '\u8349\u7a3f',
- paneSettings: '\u8bbe\u7f6e',
- cardActionExpand: '\u6269\u5c55\u8fd9\u5f20\u5361\u7247',
- cardActionChallenge: '\u8d28\u7591\u8fd9\u5f20\u5361\u7247',
- cardActionConnect: '\u62d6\u52a8\u8fde\u7ebf',
- cardActionDraft: '\u4ee5\u6b64\u4e3a\u57fa\u62df\u7a3f',
- relationPickerTitle: '\u4e24\u8005\u662f\u4ec0\u4e48\u5173\u7cfb\uff1f',
- cancel: '\u53d6\u6d88',
- deleteConnection: '\u5220\u9664\u5173\u7cfb',
- emptyHintAction: '\u8bd5\u8bd5\uff1a\u5728\u4e0b\u65b9\u5199\u4e00\u53e5\u8bdd\uff0c\u70b9 \u201c\u8be2\u95ee AI\u201d\u3002',
- drawerTitleDraft: '\u53ef\u53d1\u9001\u8349\u7a3f',
- drawerHintDraft: 'AI \u4f1a\u628a\u6210\u7a3f\u653e\u5230\u8fd9\u91cc\uff0c\u7f16\u8f91\u540e\u53ef\u53d1\u9001\u3002',
- drawerEmptyDraft: '\u8fd8\u6ca1\u6709\u8349\u7a3f\u3002\u9009\u4e2d\u5361\u7247\u540e\u70b9\u51fb\u201c\u6210\u7a3f\u201d\u3002',
- boardSettings: '\u753b\u5e03\u8bbe\u7f6e',
- resetBoardConfirm: '\u786e\u8ba4\u91cd\u7f6e\u753b\u5e03\uff1f',
- inputHint: '\u5199\u4e0b\u4e00\u4e2a\u60f3\u6cd5\uff0c\u6216\u95ee AI \u63a5\u4e0b\u6765\u5e72\u4ec0\u4e48',
- deleteBoard: '\u5220\u9664\u753b\u5e03',
- boardDeleted: '\u753b\u5e03\u5df2\u5220\u9664\u3002',
- cancelAi: '\u53d6\u6d88\u751f\u6210',
- cancelled: '\u5df2\u53d6\u6d88\u3002',
- thinkingHint: 'AI \u6b63\u5728\u52fe\u52d2\u60f3\u6cd5\u2026',
- tidy: '\u6574\u7406',
- tidied: '\u753b\u5e03\u5df2\u6574\u7406\u3002',
- zoomIn: '\u653e\u5927',
- zoomOut: '\u7f29\u5c0f',
- zoomFit: '\u9002\u5e94\u5185\u5bb9'
- }
-};
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/src/state.js b/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/src/state.js
deleted file mode 100644
index 4ffe0ef8..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/src/state.js
+++ /dev/null
@@ -1,41 +0,0 @@
-export const DEFAULT_CARDS = [
- {
- id: 'seed-1',
- kind: 'idea',
- title: 'Canvas-first collaboration',
- body: 'AI responses become movable cards, not a buried chat transcript.',
- x: 64,
- y: 96
- },
- {
- id: 'seed-2',
- kind: 'question',
- title: 'What should AI do here?',
- body: 'Expand, cluster, challenge, and turn selected thoughts into send-ready text.',
- x: 360,
- y: 168
- },
- {
- id: 'seed-3',
- kind: 'insight',
- title: 'Keep the user in control',
- body: 'Every generated note should be editable, movable, selectable, and disposable.',
- x: 672,
- y: 108
- },
- {
- id: 'seed-4',
- kind: 'output',
- title: 'Send-ready promise',
- body: 'The board is done when a messy thought becomes a message someone can act on.',
- x: 812,
- y: 328
- }
-];
-
-export const SCHEMA_VERSION = 2;
-
-export function clone(value) {
- if (typeof structuredClone === 'function') return structuredClone(value);
- return JSON.parse(JSON.stringify(value));
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/style.css b/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/style.css
deleted file mode 100644
index c1950736..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/style.css
+++ /dev/null
@@ -1,1356 +0,0 @@
-:root {
- color-scheme: light dark;
- --app-bg: var(--sparo-app-bg, Canvas);
- --app-surface: var(--sparo-app-surface, Canvas);
- --app-panel: var(--sparo-app-panel, Canvas);
- --app-card: var(--sparo-app-card, Canvas);
- --app-control: var(--sparo-app-control-bg, Field);
- --app-control-hover: var(--sparo-app-control-hover, Field);
- --app-text: var(--sparo-app-text, CanvasText);
- --app-muted: var(--sparo-app-text-secondary, CanvasText);
- --app-soft: var(--sparo-app-text-muted, GrayText);
- --app-border: var(--sparo-app-border, GrayText);
- --app-border-subtle: var(--sparo-app-border-subtle, GrayText);
- --app-accent: var(--sparo-app-accent, Highlight);
- --app-accent-hover: var(--sparo-app-accent-hover, Highlight);
- --app-accent-soft: var(--sparo-app-accent-soft, transparent);
- --app-focus-ring: var(--sparo-app-focus-ring, Highlight);
- --app-radius: var(--sparo-app-radius, 10px);
- --app-radius-sm: var(--sparo-app-radius-sm, 6px);
-
- --card-color-idea: color-mix(in srgb, var(--app-text) 36%, transparent);
- --card-color-ai: var(--app-accent);
- --card-color-insight: color-mix(in srgb, var(--app-accent) 70%, #6e58c5);
- --card-color-question: #4a8df0;
- --card-color-assumption: #d8a341;
- --card-color-counterpoint: #d96b5f;
- --card-color-output: var(--app-accent);
- --card-color-source: var(--app-soft);
-
- --drawer-width: 340px;
-
- font-family: var(--sparo-font-sans, system-ui, sans-serif);
-}
-
-* { box-sizing: border-box; }
-
-html {
- position: fixed;
- inset: 0;
- height: 100%;
- overflow: hidden;
- overscroll-behavior: none;
-}
-
-body {
- position: fixed;
- inset: 0;
- margin: 0;
- width: 100%;
- height: 100%;
- min-height: 0;
- overflow: hidden;
- overscroll-behavior: none;
- touch-action: none;
- background:
- radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--app-text) 14%, transparent) 0.8px, transparent 1.2px),
- var(--app-bg);
- background-size: 22px 22px;
- background-position: 0 0;
- color: var(--app-text);
-}
-
-button,
-textarea,
-input,
-select { font: inherit; }
-
-.spark-board {
- position: fixed;
- inset: 0;
- display: flex;
- flex-direction: column;
- width: 100%;
- height: 100vh;
- height: 100dvh;
- overflow: hidden;
- overscroll-behavior: none;
-}
-
-/* ---------- Topbar ---------- */
-
-.topbar {
- position: relative;
- z-index: 30;
- display: grid;
- grid-template-columns: auto 1fr auto;
- align-items: center;
- gap: 16px;
- min-height: 44px;
- padding: 6px 12px;
- border-bottom: 1px solid var(--app-border-subtle);
- background: color-mix(in srgb, var(--app-panel) 92%, transparent);
- backdrop-filter: blur(12px);
-}
-
-.brand {
- display: flex;
- align-items: center;
- gap: 8px;
- min-width: 0;
-}
-
-.brand-mark {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 22px;
- height: 22px;
- border-radius: 6px;
- background: color-mix(in srgb, var(--app-accent) 16%, transparent);
- color: var(--app-accent);
-}
-
-h1, h2, p { margin: 0; }
-
-h1 {
- font-size: 13px;
- font-weight: 700;
- letter-spacing: 0.01em;
- line-height: 1;
- white-space: nowrap;
-}
-
-.board-strip {
- display: flex;
- align-items: center;
- gap: 4px;
- min-width: 0;
-}
-
-.board-tabs {
- display: flex;
- align-items: center;
- gap: 2px;
- min-width: 0;
- overflow-x: auto;
- overflow-y: hidden;
- scrollbar-width: none;
- overscroll-behavior-x: contain;
-}
-
-.board-tabs::-webkit-scrollbar { display: none; }
-
-.board-tab {
- position: relative;
- display: inline-flex;
- align-items: center;
- gap: 4px;
- max-width: 200px;
- min-height: 26px;
- min-width: 0;
- flex: 0 0 auto;
- border: 1px solid transparent;
- border-radius: var(--app-radius-sm);
- background: transparent;
- color: var(--app-soft);
- padding: 0 6px 0 10px;
- cursor: pointer;
- font-size: 12px;
- font-weight: 600;
- outline: none;
-}
-
-.board-tab:focus-visible {
- border-color: var(--app-focus-ring);
-}
-
-.board-tab-label {
- display: inline-block;
- max-width: 160px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.board-tab-close {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 16px;
- height: 16px;
- border-radius: 4px;
- color: var(--app-soft);
- font-size: 14px;
- font-weight: 500;
- line-height: 1;
- cursor: pointer;
- opacity: 0;
- transition: opacity 0.12s ease, background 0.12s ease, color 0.12s ease;
-}
-
-.board-tab:hover .board-tab-close,
-.board-tab.is-active .board-tab-close,
-.board-tab:focus-within .board-tab-close {
- opacity: 0.7;
-}
-
-.board-tab-close:hover {
- opacity: 1;
- background: color-mix(in srgb, var(--card-color-counterpoint) 22%, transparent);
- color: var(--card-color-counterpoint);
-}
-
-.board-tabs[data-single="true"] .board-tab-close {
- display: none;
-}
-
-.board-tab:hover {
- background: var(--app-control-hover);
- color: var(--app-muted);
-}
-
-.board-tab.is-active {
- background: var(--app-control);
- color: var(--app-text);
-}
-
-.board-add {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- border: 0;
- border-radius: var(--app-radius-sm);
- background: transparent;
- color: var(--app-soft);
- cursor: pointer;
- font-size: 16px;
- font-weight: 500;
- line-height: 1;
-}
-
-.board-add:hover {
- background: var(--app-control-hover);
- color: var(--app-text);
-}
-
-.topbar-actions {
- display: flex;
- align-items: center;
- gap: 4px;
- justify-self: end;
-}
-
-.icon-button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- border: 1px solid transparent;
- border-radius: var(--app-radius-sm);
- background: transparent;
- color: var(--app-muted);
- cursor: pointer;
-}
-
-.icon-button:hover {
- background: var(--app-control-hover);
- color: var(--app-text);
-}
-
-.icon-button.is-active {
- background: color-mix(in srgb, var(--app-accent) 18%, transparent);
- color: var(--app-accent);
-}
-
-/* ---------- Layout ---------- */
-
-.layout {
- position: relative;
- flex: 1;
- display: flex;
- flex-direction: column;
- min-height: 0;
- overflow: hidden;
- overscroll-behavior: none;
- transition: padding-right 0.22s ease;
-}
-
-.spark-board[data-drawer="open"] .layout {
- padding-right: var(--drawer-width);
-}
-
-.canvas-shell {
- position: relative;
- display: flex;
- flex-direction: column;
- flex: 1;
- min-width: 0;
- min-height: 0;
-}
-
-.canvas {
- position: relative;
- flex: 1;
- min-height: 0;
- overflow: hidden;
- overscroll-behavior: none;
-}
-
-.canvas-stage {
- position: absolute;
- inset: 0;
- transform-origin: 0 0;
- will-change: transform;
-}
-
-.canvas-stage.is-view-animating {
- transition: transform 0.38s cubic-bezier(0.22, 0.61, 0.36, 1);
-}
-
-.canvas { cursor: grab; }
-.canvas.is-panning { cursor: grabbing; }
-.canvas.is-panning .card { pointer-events: none; }
-
-/* ---------- Empty state ---------- */
-
-.empty-prompt {
- position: absolute;
- top: 50%;
- left: 50%;
- display: grid;
- gap: 10px;
- width: min(420px, calc(100vw - 48px));
- transform: translate(-50%, -55%);
- color: var(--app-muted);
- text-align: center;
- pointer-events: none;
-}
-
-.empty-prompt[hidden] { display: none; }
-
-.empty-illustration {
- position: relative;
- width: 168px;
- height: 76px;
- margin: 0 auto 8px;
- color: var(--app-soft);
-}
-
-.ghost-card {
- position: absolute;
- width: 60px;
- height: 42px;
- border: 1.5px dashed color-mix(in srgb, var(--app-soft) 56%, transparent);
- border-radius: 8px;
- background: color-mix(in srgb, var(--app-card) 60%, transparent);
-}
-
-.ghost-card--a { top: 10px; left: 2px; }
-.ghost-card--b { top: 22px; right: 2px; }
-
-.ghost-line {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
- color: color-mix(in srgb, var(--app-accent) 64%, transparent);
-}
-
-.empty-prompt strong {
- color: var(--app-text);
- font-size: 18px;
- font-weight: 700;
-}
-
-.empty-prompt span {
- color: var(--app-soft);
- font-size: 13px;
- line-height: 1.5;
-}
-
-.empty-hint {
- margin-top: 4px;
- color: color-mix(in srgb, var(--app-soft) 80%, transparent);
- font-size: 11.5px;
- font-style: normal;
- letter-spacing: 0.01em;
-}
-
-/* ---------- Connections layer ---------- */
-
-.connection-layer {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
- z-index: 1;
- overflow: visible;
- pointer-events: none;
-}
-
-.connection-layer marker path { fill: var(--app-accent); }
-
-.connection {
- fill: none;
- stroke: var(--app-accent);
- stroke-linecap: round;
- stroke-width: 1.8;
- opacity: 0.78;
- pointer-events: stroke;
- cursor: pointer;
- transition: stroke-width 0.12s ease, opacity 0.12s ease;
-}
-
-.connection:hover,
-.connection.is-active { stroke-width: 2.8; opacity: 1; }
-
-.connection--challenges {
- stroke: var(--card-color-counterpoint);
- stroke-dasharray: 6 5;
-}
-
-.connection--expands {
- stroke: var(--card-color-question);
-}
-
-.connection--becomes {
- stroke: var(--app-accent);
- stroke-width: 2.4;
-}
-
-.connection-label {
- fill: var(--app-soft);
- font-size: 10px;
- font-weight: 700;
- paint-order: stroke;
- pointer-events: none;
- stroke: var(--app-bg);
- stroke-linejoin: round;
- stroke-width: 4px;
- text-anchor: middle;
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.connection-temp {
- fill: none;
- stroke: var(--app-accent);
- stroke-width: 1.8;
- stroke-dasharray: 5 4;
- stroke-linecap: round;
- opacity: 0.7;
- pointer-events: none;
-}
-
-/* ---------- Cards ---------- */
-
-.card {
- --card-color: var(--card-color-idea);
- position: absolute;
- width: 232px;
- min-height: 110px;
- padding: 10px 12px 12px 16px;
- border: 1px solid var(--app-border-subtle);
- border-radius: var(--app-radius);
- background: var(--app-card);
- color: var(--app-text);
- box-shadow:
- 0 1px 0 color-mix(in srgb, var(--app-text) 4%, transparent),
- 0 10px 30px color-mix(in srgb, var(--app-bg) 78%, transparent);
- cursor: grab;
- user-select: none;
- z-index: 2;
- transition: box-shadow 0.15s ease, border-color 0.15s ease;
-}
-
-.card::before {
- content: "";
- position: absolute;
- top: 10px;
- bottom: 10px;
- left: 0;
- width: 3px;
- background: var(--card-color);
- border-radius: 0 3px 3px 0;
-}
-
-.card:active { cursor: grabbing; }
-
-.card:hover {
- box-shadow:
- 0 1px 0 color-mix(in srgb, var(--app-text) 6%, transparent),
- 0 14px 40px color-mix(in srgb, var(--app-bg) 72%, transparent);
-}
-
-.card.is-selected {
- border-color: color-mix(in srgb, var(--card-color) 70%, var(--app-accent));
- box-shadow:
- 0 0 0 2px color-mix(in srgb, var(--card-color) 36%, transparent),
- 0 14px 40px color-mix(in srgb, var(--app-bg) 70%, transparent);
-}
-
-.card--idea { --card-color: var(--card-color-idea); }
-.card--ai,
-.card--insight { --card-color: var(--card-color-ai); }
-.card--question { --card-color: var(--card-color-question); }
-.card--assumption { --card-color: var(--card-color-assumption); }
-.card--counterpoint {
- --card-color: var(--card-color-counterpoint);
- border-style: dashed;
-}
-.card--output {
- --card-color: var(--card-color-output);
- background:
- linear-gradient(180deg, color-mix(in srgb, var(--app-accent) 6%, transparent), transparent 60%),
- var(--app-card);
-}
-.card--source { --card-color: var(--card-color-source); }
-
-.card-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- margin-bottom: 4px;
-}
-
-.card-kind {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- color: var(--card-color);
- font-size: 10.5px;
- font-weight: 700;
- letter-spacing: 0.06em;
- text-transform: uppercase;
-}
-
-.card-kind::before {
- content: "";
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--card-color);
-}
-
-.card-remove {
- width: 22px;
- height: 22px;
- border: 0;
- border-radius: var(--app-radius-sm);
- background: transparent;
- color: var(--app-soft);
- cursor: pointer;
- opacity: 0;
- transition: opacity 0.15s ease, background 0.15s ease;
- font-size: 14px;
- line-height: 1;
-}
-
-.card:hover .card-remove,
-.card.is-selected .card-remove { opacity: 1; }
-
-.card-remove:hover {
- background: color-mix(in srgb, var(--card-color-counterpoint) 18%, transparent);
- color: var(--card-color-counterpoint);
-}
-
-.card-title,
-.card-body {
- width: 100%;
- border: 0;
- background: transparent;
- color: var(--app-text);
- padding: 0;
- outline: none;
- resize: none;
-}
-
-.card-title {
- min-height: 20px;
- font-size: 13.5px;
- font-weight: 700;
- line-height: 1.3;
-}
-
-.card-body {
- min-height: 48px;
- margin-top: 4px;
- color: var(--app-muted);
- font-size: 12px;
- line-height: 1.5;
-}
-
-/* Card inline action bar (slides out from card bottom, always in hover zone) */
-.card-actions {
- display: flex;
- align-items: stretch;
- gap: 3px;
- max-height: 0;
- margin-top: 0;
- padding: 0;
- border-top: 1px solid transparent;
- overflow: hidden;
- transition:
- max-height 0.16s ease,
- margin-top 0.16s ease,
- padding-top 0.16s ease,
- border-top-color 0.16s ease;
-}
-
-.card:hover .card-actions,
-.card.is-selected .card-actions,
-.card:focus-within .card-actions {
- max-height: 36px;
- margin-top: 10px;
- padding-top: 8px;
- border-top-color: color-mix(in srgb, var(--app-border-subtle) 80%, transparent);
-}
-
-.card-action {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 5px;
- flex: 1;
- min-width: 0;
- height: 28px;
- padding: 0 8px;
- border: 0;
- border-radius: 6px;
- background: transparent;
- color: var(--app-muted);
- cursor: pointer;
- font-size: 11.5px;
- font-weight: 600;
- line-height: 1;
- white-space: nowrap;
- overflow: hidden;
-}
-
-.card-action:hover {
- background: color-mix(in srgb, var(--app-accent) 14%, transparent);
- color: var(--app-accent);
-}
-
-.card-action--challenge:hover {
- background: color-mix(in srgb, var(--card-color-counterpoint) 14%, transparent);
- color: var(--card-color-counterpoint);
-}
-
-.card-action--draft:hover {
- background: color-mix(in srgb, var(--card-color-output) 16%, transparent);
- color: var(--card-color-output);
-}
-
-.card-action-glyph {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 14px;
- height: 14px;
- font-size: 11px;
- font-weight: 800;
- opacity: 0.85;
-}
-
-/* Connection drag handle */
-.card-handle {
- position: absolute;
- top: 50%;
- right: -7px;
- width: 14px;
- height: 14px;
- margin-top: -7px;
- border: 2px solid var(--app-accent);
- border-radius: 50%;
- background: var(--app-card);
- cursor: crosshair;
- padding: 0;
- opacity: 0;
- transition: opacity 0.15s ease, transform 0.1s ease;
- z-index: 3;
- touch-action: none;
-}
-
-.card:hover .card-handle,
-.card.is-selected .card-handle { opacity: 1; }
-
-.card-handle:hover {
- transform: scale(1.18);
- background: var(--app-accent);
-}
-
-.card.is-drop-target {
- outline: 2px solid var(--app-accent);
- outline-offset: 2px;
-}
-
-.card.is-tidying {
- transition:
- transform 0.42s cubic-bezier(0.22, 0.61, 0.36, 1),
- box-shadow 0.15s ease,
- border-color 0.15s ease;
-}
-
-/* ---------- Stream chip (AI generating indicator) ---------- */
-
-.stream-chip {
- position: absolute;
- top: 0;
- left: 0;
- z-index: 6;
- display: inline-flex;
- align-items: center;
- gap: 8px;
- height: 26px;
- padding: 0 12px 0 10px;
- border-radius: 999px;
- border: 1px solid color-mix(in srgb, var(--app-accent) 45%, transparent);
- background: color-mix(in srgb, var(--app-card) 92%, var(--app-accent-soft));
- color: var(--app-accent);
- font-size: 11.5px;
- font-weight: 600;
- white-space: nowrap;
- pointer-events: none;
- user-select: none;
- box-shadow: 0 6px 18px color-mix(in srgb, var(--app-bg) 78%, transparent);
- animation: stream-aura 2s ease-in-out infinite;
-}
-
-.thinking-dot {
- display: inline-block;
- width: 7px;
- height: 7px;
- border-radius: 50%;
- background: var(--app-accent);
- animation: spark-thinking-dot 0.9s ease-in-out infinite;
-}
-
-.stream-chip-count {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-width: 16px;
- height: 16px;
- padding: 0 5px;
- border-radius: 999px;
- background: color-mix(in srgb, var(--app-accent) 20%, transparent);
- font-size: 10.5px;
- font-weight: 700;
- font-variant-numeric: tabular-nums;
-}
-
-.stream-chip-count[hidden] { display: none; }
-
-@keyframes stream-aura {
- 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--app-accent) 28%, transparent), 0 6px 18px color-mix(in srgb, var(--app-bg) 78%, transparent); }
- 50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--app-accent) 8%, transparent), 0 6px 18px color-mix(in srgb, var(--app-bg) 78%, transparent); }
-}
-
-@keyframes spark-thinking-dot {
- 0%, 100% { transform: scale(0.7); opacity: 0.6; }
- 50% { transform: scale(1.15); opacity: 1; }
-}
-
-/* ---------- Card glow on spawn ---------- */
-
-.card.is-glowing {
- animation: card-spawn 0.75s cubic-bezier(0.2, 0.9, 0.3, 1.1) 1 both;
- z-index: 5;
-}
-
-@keyframes card-spawn {
- 0% {
- opacity: 0;
- filter: blur(2px);
- box-shadow:
- 0 0 0 10px color-mix(in srgb, var(--card-color) 24%, transparent),
- 0 0 28px color-mix(in srgb, var(--card-color) 36%, transparent);
- }
- 55% {
- opacity: 1;
- filter: blur(0);
- box-shadow:
- 0 0 0 4px color-mix(in srgb, var(--card-color) 36%, transparent),
- 0 0 18px color-mix(in srgb, var(--card-color) 32%, transparent);
- }
- 100% {
- opacity: 1;
- filter: blur(0);
- box-shadow:
- 0 1px 0 color-mix(in srgb, var(--app-text) 4%, transparent),
- 0 10px 30px color-mix(in srgb, var(--app-bg) 78%, transparent);
- }
-}
-
-/* ---------- Canvas floating tools ---------- */
-
-.canvas-floating {
- position: absolute;
- left: 14px;
- bottom: 14px;
- z-index: 8;
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 6px 10px;
- border: 1px solid var(--app-border-subtle);
- border-radius: 999px;
- background: color-mix(in srgb, var(--app-panel) 88%, transparent);
- backdrop-filter: blur(10px);
- box-shadow: 0 6px 20px color-mix(in srgb, var(--app-bg) 80%, transparent);
- pointer-events: auto;
- max-width: calc(100% - 28px);
-}
-
-.tool-pill {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-width: 20px;
- min-height: 20px;
- padding: 0 8px;
- border-radius: 999px;
- background: color-mix(in srgb, var(--app-accent) 16%, transparent);
- color: var(--app-accent);
- font-size: 11px;
- font-weight: 700;
-}
-
-.tool-status {
- margin: 0;
- color: var(--app-soft);
- font-size: 12px;
- line-height: 1.2;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- max-width: 320px;
-}
-
-/* ---------- Canvas view controls ---------- */
-
-.canvas-controls {
- position: absolute;
- right: 14px;
- bottom: 14px;
- z-index: 8;
- display: flex;
- align-items: center;
- gap: 2px;
- padding: 4px;
- border: 1px solid var(--app-border-subtle);
- border-radius: 999px;
- background: color-mix(in srgb, var(--app-panel) 88%, transparent);
- backdrop-filter: blur(10px);
- box-shadow: 0 6px 20px color-mix(in srgb, var(--app-bg) 80%, transparent);
-}
-
-.control-button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 5px;
- min-width: 28px;
- height: 28px;
- padding: 0 6px;
- border: 0;
- border-radius: 999px;
- background: transparent;
- color: var(--app-muted);
- cursor: pointer;
- font-size: 12px;
- font-weight: 600;
- white-space: nowrap;
-}
-
-.control-button:hover {
- background: var(--app-control-hover);
- color: var(--app-text);
-}
-
-.control-button--wide { padding: 0 10px; }
-.control-button--zoom { min-width: 46px; }
-
-.zoom-label {
- font-size: 11.5px;
- font-weight: 700;
- font-variant-numeric: tabular-nums;
-}
-
-.control-divider {
- width: 1px;
- height: 18px;
- margin: 0 3px;
- background: var(--app-border-subtle);
-}
-
-@media (max-width: 620px) {
- .control-label { display: none; }
- .canvas-controls { right: 10px; bottom: 10px; }
-}
-
-/* ---------- Relation popover ---------- */
-
-.relation-popover {
- position: absolute;
- z-index: 40;
- display: grid;
- gap: 6px;
- width: 220px;
- padding: 10px;
- border: 1px solid var(--app-border);
- border-radius: 10px;
- background: var(--app-card);
- box-shadow: 0 16px 44px color-mix(in srgb, var(--app-bg) 70%, transparent);
-}
-
-.relation-popover[hidden] { display: none; }
-
-.relation-popover-title {
- font-size: 11px;
- font-weight: 700;
- color: var(--app-soft);
- text-transform: uppercase;
- letter-spacing: 0.06em;
-}
-
-.relation-popover-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 4px;
-}
-
-.relation-popover-grid button {
- display: inline-flex;
- align-items: center;
- justify-content: flex-start;
- gap: 6px;
- height: 30px;
- padding: 0 9px;
- border: 1px solid var(--app-border-subtle);
- border-radius: 6px;
- background: var(--app-control);
- color: var(--app-text);
- cursor: pointer;
- font-size: 12px;
- font-weight: 600;
-}
-
-.relation-popover-grid button::before {
- content: "";
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: currentColor;
- opacity: 0.6;
-}
-
-.relation-popover-grid button[data-relation="supports"] { color: var(--app-accent); }
-.relation-popover-grid button[data-relation="challenges"] { color: var(--card-color-counterpoint); }
-.relation-popover-grid button[data-relation="expands"] { color: var(--card-color-question); }
-.relation-popover-grid button[data-relation="becomes"] { color: var(--card-color-output); }
-
-.relation-popover-grid button:hover {
- background: var(--app-control-hover);
-}
-
-.relation-popover-cancel {
- height: 26px;
- border: 0;
- background: transparent;
- color: var(--app-soft);
- cursor: pointer;
- font-size: 11px;
- font-weight: 600;
-}
-
-.relation-popover-cancel:hover { color: var(--app-text); }
-
-/* ---------- Connection tools bubble ---------- */
-
-.connection-tools {
- position: absolute;
- z-index: 35;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 5px 7px 5px 10px;
- border: 1px solid var(--app-border-subtle);
- border-radius: 999px;
- background: var(--app-card);
- box-shadow: 0 10px 26px color-mix(in srgb, var(--app-bg) 76%, transparent);
- transform: translate(-50%, -50%);
-}
-
-.connection-tools[hidden] { display: none; }
-
-.connection-tools-label {
- color: var(--app-soft);
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- white-space: nowrap;
-}
-
-.btn-mini {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 22px;
- height: 22px;
- border: 0;
- border-radius: 50%;
- background: transparent;
- color: var(--app-muted);
- cursor: pointer;
-}
-
-.btn-mini:hover { background: var(--app-control-hover); color: var(--app-text); }
-
-.btn-mini--danger:hover {
- background: color-mix(in srgb, var(--card-color-counterpoint) 18%, transparent);
- color: var(--card-color-counterpoint);
-}
-
-/* ---------- Composer ---------- */
-
-.composer-dock {
- position: relative;
- flex: 0 0 auto;
- display: grid;
- grid-template-columns: auto 1fr auto;
- align-items: center;
- gap: 8px;
- width: min(720px, calc(100% - 28px));
- margin: 0 auto 14px;
- padding: 6px 6px 6px 12px;
- border: 1px solid var(--app-border-subtle);
- border-radius: 14px;
- background: color-mix(in srgb, var(--app-panel) 92%, transparent);
- box-shadow:
- 0 10px 30px color-mix(in srgb, var(--app-bg) 80%, transparent),
- inset 0 1px 0 color-mix(in srgb, var(--app-text) 6%, transparent);
- backdrop-filter: blur(14px);
- z-index: 9;
- transition: border-color 0.15s ease, box-shadow 0.15s ease;
-}
-
-.composer-dock:focus-within {
- border-color: color-mix(in srgb, var(--app-accent) 60%, var(--app-border));
- box-shadow:
- 0 10px 30px color-mix(in srgb, var(--app-bg) 80%, transparent),
- 0 0 0 3px color-mix(in srgb, var(--app-accent) 14%, transparent),
- inset 0 1px 0 color-mix(in srgb, var(--app-text) 6%, transparent);
-}
-
-.composer-orb {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 26px;
- height: 26px;
- border-radius: 8px;
- background: color-mix(in srgb, var(--app-accent) 16%, transparent);
- color: var(--app-accent);
-}
-
-.composer-dock #seedInput {
- min-height: 28px;
- max-height: 140px;
- border: 0;
- background: transparent;
- color: var(--app-text);
- padding: 6px 2px;
- resize: none;
- line-height: 1.4;
- font-size: 13px;
- width: 100%;
-}
-
-.composer-dock #seedInput::placeholder {
- color: color-mix(in srgb, var(--app-soft) 80%, transparent);
-}
-
-.composer-dock textarea:focus { box-shadow: none; }
-
-.composer-actions {
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-.btn-ghost {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 30px;
- height: 30px;
- border: 0;
- border-radius: 8px;
- background: transparent;
- color: var(--app-muted);
- cursor: pointer;
-}
-
-.btn-ghost:hover {
- background: var(--app-control-hover);
- color: var(--app-text);
-}
-
-/* ---------- Drawer ---------- */
-
-.drawer {
- position: absolute;
- top: 44px;
- right: 0;
- bottom: 0;
- z-index: 20;
- display: flex;
- flex-direction: column;
- width: var(--drawer-width);
- border-left: 1px solid var(--app-border-subtle);
- background: var(--app-panel);
- transform: translateX(100%);
- transition: transform 0.22s ease;
-}
-
-.spark-board[data-drawer="open"] .drawer {
- transform: translateX(0);
-}
-
-.drawer[hidden] { display: flex; }
-
-.drawer-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- padding: 8px 10px;
- border-bottom: 1px solid var(--app-border-subtle);
-}
-
-.drawer-tabs {
- display: flex;
- gap: 2px;
-}
-
-.drawer-tab {
- height: 28px;
- padding: 0 10px;
- border: 1px solid transparent;
- border-radius: 6px;
- background: transparent;
- color: var(--app-soft);
- cursor: pointer;
- font-size: 12px;
- font-weight: 600;
-}
-
-.drawer-tab:hover { color: var(--app-text); }
-
-.drawer-tab.is-active {
- background: var(--app-control);
- color: var(--app-text);
-}
-
-.drawer-body {
- flex: 1;
- display: flex;
- flex-direction: column;
- min-height: 0;
- overflow: hidden;
-}
-
-.drawer-pane {
- display: none;
- flex-direction: column;
- gap: 10px;
- padding: 12px;
- height: 100%;
- overflow: auto;
-}
-
-.drawer-pane.is-active {
- display: flex;
-}
-
-.drawer-section-head {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.drawer-section-head strong {
- font-size: 13px;
- font-weight: 700;
-}
-
-.drawer-section-hint {
- color: var(--app-soft);
- font-size: 11.5px;
-}
-
-.drawer-pane textarea#previewDraft {
- flex: 1;
- min-height: 200px;
- resize: none;
- border: 1px solid var(--app-border-subtle);
- border-radius: 8px;
- padding: 10px;
- background: var(--app-card);
- color: var(--app-text);
- font-size: 12.5px;
- line-height: 1.55;
-}
-
-.drawer-pane label {
- margin: 4px 0 -4px;
-}
-
-.button-row {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-}
-
-/* ---------- Common controls ---------- */
-
-label {
- display: block;
- margin: 0 0 4px;
- color: var(--app-muted);
- font-size: 12px;
- font-weight: 600;
-}
-
-textarea,
-input,
-select {
- width: 100%;
- border: 1px solid var(--app-border-subtle);
- border-radius: var(--app-radius-sm);
- background: var(--app-control);
- color: var(--app-text);
- outline: none;
-}
-
-textarea { padding: 8px 10px; line-height: 1.45; }
-input { min-height: 32px; padding: 0 10px; }
-select { min-height: 32px; padding: 0 8px; }
-
-textarea:focus,
-input:focus,
-select:focus { border-color: var(--app-focus-ring); }
-
-.btn-primary,
-.btn-secondary {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- border-radius: 8px;
- cursor: pointer;
- font-weight: 600;
- line-height: 1;
- white-space: nowrap;
-}
-
-.btn-base { min-height: 32px; padding: 0 12px; font-size: 12.5px; }
-
-.btn-primary {
- border: 1px solid color-mix(in srgb, var(--app-accent) 70%, var(--app-border));
- background: var(--app-accent);
- color: var(--sparo-app-accent-contrast, var(--app-bg));
- padding: 0 14px;
- height: 32px;
-}
-
-.btn-primary:hover { background: var(--app-accent-hover); }
-
-.btn-primary[data-busy="true"] {
- background: color-mix(in srgb, var(--app-accent) 65%, var(--app-bg));
- color: transparent;
- cursor: pointer;
- position: relative;
-}
-
-.btn-primary[data-busy="true"]::after {
- content: "";
- position: absolute;
- top: 50%;
- left: 50%;
- width: 13px;
- height: 13px;
- margin: -6.5px 0 0 -6.5px;
- border: 2px solid color-mix(in srgb, var(--sparo-app-accent-contrast, white) 35%, transparent);
- border-top-color: var(--sparo-app-accent-contrast, white);
- border-radius: 50%;
- animation: spark-spin 0.7s linear infinite;
-}
-
-.btn-primary[data-busy="true"]:hover {
- background: color-mix(in srgb, var(--card-color-counterpoint) 40%, var(--app-accent));
-}
-
-.btn-primary[data-busy="true"]:hover::after {
- border-color: transparent;
- border-radius: 0;
- width: 9px;
- height: 9px;
- margin: -4.5px 0 0 -4.5px;
- background:
- linear-gradient(45deg, transparent 42%, white 42%, white 58%, transparent 58%),
- linear-gradient(-45deg, transparent 42%, white 42%, white 58%, transparent 58%);
- animation: none;
-}
-
-@keyframes spark-spin { to { transform: rotate(360deg); } }
-
-.btn-secondary {
- border: 1px solid var(--app-border-subtle);
- background: var(--app-control);
- color: var(--app-text);
-}
-
-.btn-secondary:hover { background: var(--app-control-hover); }
-
-button:disabled { cursor: wait; opacity: 0.6; }
-
-.sr-only {
- position: absolute !important;
- width: 1px !important;
- height: 1px !important;
- padding: 0 !important;
- margin: -1px !important;
- overflow: hidden !important;
- clip: rect(0, 0, 0, 0) !important;
- white-space: nowrap !important;
- border: 0 !important;
-}
-
-/* ---------- Responsive ---------- */
-
-@media (max-width: 860px) {
- :root { --drawer-width: 300px; }
- .composer-dock { width: calc(100% - 20px); }
-}
-
-@media (max-width: 620px) {
- .topbar { padding: 6px 10px; gap: 10px; }
- .board-tab { max-width: 120px; padding: 0 8px; }
- .composer-dock { margin-bottom: 10px; padding-left: 10px; }
- .composer-orb { display: none; }
- .composer-dock { grid-template-columns: 1fr auto; }
- .canvas-floating { left: 10px; bottom: 10px; padding: 4px 8px; }
- .tool-status { max-width: 160px; }
- .card { width: min(232px, calc(100vw - 36px)); }
- .spark-board[data-drawer="open"] .layout { padding-right: 0; }
- .drawer { width: 100%; top: 0; box-shadow: -10px 0 30px color-mix(in srgb, var(--app-bg) 60%, transparent); }
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/ui.js b/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/ui.js
deleted file mode 100644
index 284e2939..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/ui.js
+++ /dev/null
@@ -1,1847 +0,0 @@
-import { STRINGS } from './src/i18n.js';
-import { DEFAULT_CARDS, SCHEMA_VERSION, clone } from './src/state.js';
-
-let state = {
- schemaVersion: SCHEMA_VERSION,
- boards: [],
- activeBoardId: 'board-default',
- boardTitle: 'First sparks',
- cards: clone(DEFAULT_CARDS),
- selectedIds: [],
- connections: [],
- draft: '',
- mode: 'free',
- outputFormat: 'message',
- activeConnectionId: null
-};
-
-let dragState = null;
-let connectDragState = null;
-let relationContext = null;
-let panState = null;
-let suppressCanvasClick = false;
-let busy = false;
-let saveTimer = null;
-
-const $ = (id) => document.getElementById(id);
-const runtime = () => window.app || {};
-const locale = () => runtime().locale || 'en-US';
-const t = (key) => (STRINGS[locale()] || STRINGS['en-US'])[key] || STRINGS['en-US'][key] || key;
-const uid = (prefix = 'card') => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
-const root = () => document.querySelector('.spark-board');
-
-/* ---------- Viewport (infinite pan/zoom canvas) ---------- */
-
-const MIN_ZOOM = 0.3;
-const MAX_ZOOM = 2.4;
-let view = { panX: 0, panY: 0, zoom: 1 };
-
-function canvasRect() {
- return $('canvas').getBoundingClientRect();
-}
-
-function applyView() {
- const stage = $('canvasStage');
- if (!stage) return;
- stage.style.transformOrigin = '0 0';
- stage.style.transform = `translate(${view.panX}px, ${view.panY}px) scale(${view.zoom})`;
- updateZoomLabel();
-}
-
-function animateView(next, ms = 380) {
- const stage = $('canvasStage');
- if (!stage) { Object.assign(view, next); return; }
- stage.classList.add('is-view-animating');
- Object.assign(view, next);
- applyView();
- window.clearTimeout(animateView._timer);
- animateView._timer = window.setTimeout(() => stage.classList.remove('is-view-animating'), ms + 40);
-}
-
-function screenToWorld(clientX, clientY) {
- const rect = canvasRect();
- return {
- x: (clientX - rect.left - view.panX) / view.zoom,
- y: (clientY - rect.top - view.panY) / view.zoom
- };
-}
-
-function worldToScreen(wx, wy) {
- return { x: wx * view.zoom + view.panX, y: wy * view.zoom + view.panY };
-}
-
-function viewportCenterWorld() {
- const rect = canvasRect();
- return screenToWorld(rect.left + rect.width / 2, rect.top + rect.height / 2);
-}
-
-function zoomAt(clientX, clientY, factor) {
- const rect = canvasRect();
- const sx = clientX - rect.left;
- const sy = clientY - rect.top;
- const worldX = (sx - view.panX) / view.zoom;
- const worldY = (sy - view.panY) / view.zoom;
- const nextZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, view.zoom * factor));
- view.panX = sx - worldX * nextZoom;
- view.panY = sy - worldY * nextZoom;
- view.zoom = nextZoom;
- applyView();
- scheduleSave();
-}
-
-function updateZoomLabel() {
- const label = $('zoomLabel');
- if (label) label.textContent = `${Math.round(view.zoom * 100)}%`;
-}
-
-function fitView(cards, { animate = true, maxZoom = 1.1, padding = 80 } = {}) {
- const list = (cards && cards.length ? cards : state.cards) || [];
- const rect = canvasRect();
- const vw = rect.width || 800;
- const vh = rect.height || 600;
- if (list.length === 0) {
- const next = { panX: 0, panY: 0, zoom: 1 };
- animate ? animateView(next) : (Object.assign(view, next), applyView());
- scheduleSave();
- return;
- }
- const box = computeBoundingBox(list);
- const contentW = Math.max(box.maxX - box.minX, CARD_WIDTH);
- const contentH = Math.max(box.maxY - box.minY, CARD_HEIGHT);
- const zoom = Math.max(MIN_ZOOM, Math.min(maxZoom, Math.min(
- (vw - padding * 2) / contentW,
- (vh - padding * 2) / contentH
- )));
- const cx = (box.minX + box.maxX) / 2;
- const cy = (box.minY + box.maxY) / 2;
- const next = {
- zoom,
- panX: vw / 2 - cx * zoom,
- panY: vh / 2 - cy * zoom
- };
- animate ? animateView(next) : (Object.assign(view, next), applyView());
- scheduleSave();
-}
-
-function appStorage() {
- const host = runtime();
- if (host.storage) return host.storage;
- return {
- get: async (key) => JSON.parse(localStorage.getItem(key) || 'null'),
- set: async (key, value) => localStorage.setItem(key, JSON.stringify(value))
- };
-}
-
-function applyI18n() {
- document.querySelectorAll('[data-i18n]').forEach((node) => { node.textContent = t(node.dataset.i18n); });
- document.querySelectorAll('[data-i18n-placeholder]').forEach((node) => { node.placeholder = t(node.dataset.i18nPlaceholder); });
- document.querySelectorAll('[data-i18n-aria]').forEach((node) => { node.setAttribute('aria-label', t(node.dataset.i18nAria)); });
- document.querySelectorAll('[data-i18n-title]').forEach((node) => { node.title = t(node.dataset.i18nTitle); });
-}
-
-function setStatus(message) {
- const line = $('statusLine');
- if (line) line.textContent = message;
-}
-
-function scheduleSave(delay = 180) {
- window.clearTimeout(saveTimer);
- saveTimer = window.setTimeout(() => save(), delay);
-}
-
-function setBusy(nextBusy) {
- busy = nextBusy;
- const sparkButton = $('sparkIdeas');
- if (sparkButton) {
- if (busy) sparkButton.dataset.busy = 'true';
- else delete sparkButton.dataset.busy;
- sparkButton.setAttribute('aria-label', busy ? t('cancelAi') : t('sparkIdeas'));
- sparkButton.title = busy ? t('cancelAi') : '';
- }
- updateSelectionMeta();
-}
-
-function buildBoardSnapshot(id = state.activeBoardId, title = state.boardTitle) {
- return {
- id,
- title: title || t('untitledBoard'),
- cards: clone(state.cards || []),
- connections: clone(state.connections || []),
- draft: state.draft || '',
- mode: state.mode || 'free',
- outputFormat: state.outputFormat || 'message',
- view: { ...view },
- schemaVersion: SCHEMA_VERSION,
- updatedAt: Date.now()
- };
-}
-
-function normalizeView(raw) {
- if (raw && Number.isFinite(raw.zoom) && Number.isFinite(raw.panX) && Number.isFinite(raw.panY)) {
- return { panX: raw.panX, panY: raw.panY, zoom: Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, raw.zoom)) };
- }
- return null;
-}
-
-function normalizeBoard(board) {
- return {
- id: board.id || uid('board'),
- title: board.title || t('untitledBoard'),
- cards: Array.isArray(board.cards) ? board.cards : [],
- connections: Array.isArray(board.connections) ? board.connections : [],
- draft: typeof board.draft === 'string' ? board.draft : '',
- mode: board.mode || 'free',
- outputFormat: board.outputFormat || 'message',
- view: normalizeView(board.view),
- schemaVersion: SCHEMA_VERSION,
- updatedAt: board.updatedAt || Date.now()
- };
-}
-
-function ensureBoards() {
- state.boards = (Array.isArray(state.boards) ? state.boards : []).map(normalizeBoard);
- if (!Array.isArray(state.boards) || state.boards.length === 0) {
- state.boards = [buildBoardSnapshot('board-default', state.boardTitle || t('defaultBoardTitle'))];
- state.activeBoardId = state.boards[0].id;
- }
- if (!state.boards.some((board) => board.id === state.activeBoardId)) {
- state.activeBoardId = state.boards[0].id;
- }
-}
-
-function persistCurrentBoard() {
- ensureBoards();
- const index = state.boards.findIndex((board) => board.id === state.activeBoardId);
- const snapshot = buildBoardSnapshot(state.activeBoardId, state.boardTitle);
- if (index >= 0) state.boards[index] = snapshot;
- else state.boards.unshift(snapshot);
-}
-
-function applyBoard(board) {
- state.activeBoardId = board.id;
- state.boardTitle = board.title || t('untitledBoard');
- state.cards = clone(board.cards || []);
- state.connections = clone(board.connections || []);
- state.draft = board.draft || '';
- state.mode = board.mode || 'free';
- state.outputFormat = board.outputFormat || 'message';
- state.selectedIds = [];
- state.activeConnectionId = null;
- const storedView = normalizeView(board.view);
- view = storedView || { panX: 0, panY: 0, zoom: 1 };
- state._needsFit = !storedView;
-}
-
-async function load() {
- let needsSave = false;
- try {
- const saved = await appStorage().get('sparkBoardState');
- if (saved && Array.isArray(saved.boards)) {
- state = {
- ...state,
- schemaVersion: SCHEMA_VERSION,
- boards: saved.boards,
- activeBoardId: saved.activeBoardId || saved.boards[0]?.id || 'board-default',
- selectedIds: []
- };
- ensureBoards();
- applyBoard(state.boards.find((board) => board.id === state.activeBoardId) || state.boards[0]);
- if (saved.schemaVersion !== SCHEMA_VERSION) needsSave = true;
- } else if (saved && Array.isArray(saved.cards)) {
- state = {
- ...state,
- ...saved,
- schemaVersion: SCHEMA_VERSION,
- connections: Array.isArray(saved.connections) ? saved.connections : [],
- boardTitle: saved.boardTitle || t('defaultBoardTitle'),
- selectedIds: []
- };
- state.boards = [buildBoardSnapshot('board-default', state.boardTitle)];
- state.activeBoardId = state.boards[0].id;
- needsSave = true;
- }
- } catch (error) {
- runtime().log?.warn?.('Failed to load Spark Board state', { error: String(error) });
- }
- if (needsSave) save();
-}
-
-function serializeState() {
- persistCurrentBoard();
- return {
- schemaVersion: SCHEMA_VERSION,
- boards: state.boards,
- activeBoardId: state.activeBoardId,
- boardTitle: state.boardTitle,
- cards: state.cards,
- connections: state.connections,
- draft: state.draft,
- mode: state.mode,
- outputFormat: state.outputFormat
- };
-}
-
-function save() {
- appStorage().set('sparkBoardState', serializeState())
- .catch((error) => runtime().log?.warn?.('Failed to save Spark Board state', { error: String(error) }));
-}
-
-function selectedCards() {
- return state.cards.filter((card) => state.selectedIds.includes(card.id));
-}
-
-function relatedConnectionsFor(cards) {
- const selected = new Set(cards.map((card) => card.id));
- return state.connections
- .filter((connection) => selected.has(connection.from) || selected.has(connection.to))
- .map((connection) => {
- const from = state.cards.find((card) => card.id === connection.from);
- const to = state.cards.find((card) => card.id === connection.to);
- return {
- type: connection.type,
- from: from ? { id: from.id, title: from.title, kind: from.kind } : { id: connection.from },
- to: to ? { id: to.id, title: to.title, kind: to.kind } : { id: connection.to }
- };
- });
-}
-
-function updateSelectionMeta() {
- const count = state.selectedIds.length;
- $('selectionCount').textContent = count > 0 ? `${count} ${t('selected')}` : '0';
- $('selectionBarLabel').textContent = String(count);
- $('draftMeta').textContent = String(state.draft.trim() ? state.draft.trim().split(/\s+/).length : 0);
- $('connectionCount').textContent = String(state.connections.length);
- $('emptyPrompt').hidden = state.cards.length > 0;
- if ($('previewDraft')) $('previewDraft').value = state.draft;
-}
-
-function renderBoardSwitcher() {
- const select = $('boardSelect');
- select.innerHTML = '';
- const tabs = $('boardTabs');
- tabs.innerHTML = '';
- tabs.dataset.single = state.boards.length === 1 ? 'true' : 'false';
- state.boards.forEach((board) => {
- const option = document.createElement('option');
- option.value = board.id;
- option.textContent = board.title || t('untitledBoard');
- select.appendChild(option);
-
- const tab = document.createElement('div');
- tab.className = `board-tab${board.id === state.activeBoardId ? ' is-active' : ''}`;
- tab.dataset.boardId = board.id;
- tab.setAttribute('role', 'tab');
- tab.setAttribute('tabindex', '0');
- tab.setAttribute('aria-selected', board.id === state.activeBoardId ? 'true' : 'false');
- tab.title = board.title || t('untitledBoard');
-
- const label = document.createElement('span');
- label.className = 'board-tab-label';
- label.textContent = board.title || t('untitledBoard');
- tab.appendChild(label);
-
- const close = document.createElement('span');
- close.className = 'board-tab-close';
- close.setAttribute('role', 'button');
- close.setAttribute('aria-label', t('deleteBoard'));
- close.title = t('deleteBoard');
- close.textContent = '×';
- close.addEventListener('click', (event) => {
- event.stopPropagation();
- deleteBoard(board.id);
- });
- tab.appendChild(close);
-
- tab.addEventListener('click', (event) => {
- if (event.target.closest('.board-tab-close')) return;
- switchBoard(board.id);
- });
- tab.addEventListener('keydown', (event) => {
- if (event.key === 'Enter' || event.key === ' ') {
- event.preventDefault();
- switchBoard(board.id);
- }
- });
- tabs.appendChild(tab);
- });
- select.value = state.activeBoardId;
- $('boardTitle').value = state.boardTitle || '';
-}
-
-function deleteBoard(id) {
- if (busy) cancelThinking();
- const index = state.boards.findIndex((board) => board.id === id);
- if (index < 0) return;
- state.boards.splice(index, 1);
- if (state.boards.length === 0) {
- const fresh = {
- id: uid('board'),
- title: t('defaultBoardTitle'),
- cards: [],
- connections: [],
- draft: '',
- mode: 'free',
- outputFormat: 'message',
- schemaVersion: SCHEMA_VERSION,
- updatedAt: Date.now()
- };
- state.boards.push(fresh);
- applyBoard(fresh);
- } else if (state.activeBoardId === id) {
- const next = state.boards[Math.min(index, state.boards.length - 1)];
- applyBoard(next);
- }
- save();
- render();
- setStatus(t('boardDeleted'));
-}
-
-function render() {
- const stage = $('canvasStage');
- stage.querySelectorAll('.card').forEach((node) => node.remove());
- state.cards.forEach((card) => stage.appendChild(renderCard(card)));
- measureCards();
- renderConnections();
- renderConnectionList();
- $('draftOutput').value = state.draft;
- renderBoardSwitcher();
- $('outputFormat').value = state.outputFormat;
- updateSelectionMeta();
- if (state._needsFit) {
- state._needsFit = false;
- fitView(state.cards, { animate: false });
- } else {
- applyView();
- }
-}
-
-function measureCards() {
- const stage = $('canvasStage');
- if (!stage) return;
- state.cards.forEach((card) => {
- const node = stage.querySelector(`.card[data-id="${card.id}"]`);
- if (node) card._h = node.offsetHeight;
- });
-}
-
-function cardHeight(card) {
- return (card && Number.isFinite(card._h) && card._h > 0) ? card._h : CARD_HEIGHT;
-}
-
-function renderCard(card) {
- const node = document.createElement('article');
- const classes = ['card', `card--${card.kind || 'idea'}`];
- if (state.selectedIds.includes(card.id)) classes.push('is-selected');
- if (card._glow && !card._renderedOnce) classes.push('is-glowing');
- node.className = classes.join(' ');
- node.style.transform = `translate(${card.x}px, ${card.y}px)`;
- node.dataset.id = card.id;
- node.innerHTML = `
-
-
-
-
-
-
-
-
-
- `;
-
- const kindNode = node.querySelector('.card-kind');
- kindNode.textContent = kindLabel(card.kind);
-
- const expandBtn = node.querySelector('.card-action--expand');
- const challengeBtn = node.querySelector('.card-action--challenge');
- const draftBtn = node.querySelector('.card-action--draft');
- expandBtn.querySelector('.card-action-label').textContent = t('expand');
- expandBtn.title = t('cardActionExpand');
- expandBtn.setAttribute('aria-label', t('cardActionExpand'));
- challengeBtn.querySelector('.card-action-label').textContent = t('challenge');
- challengeBtn.title = t('cardActionChallenge');
- challengeBtn.setAttribute('aria-label', t('cardActionChallenge'));
- draftBtn.querySelector('.card-action-label').textContent = t('draft');
- draftBtn.title = t('cardActionDraft');
- draftBtn.setAttribute('aria-label', t('cardActionDraft'));
-
- const removeBtn = node.querySelector('.card-remove');
- removeBtn.title = t('remove');
- removeBtn.setAttribute('aria-label', t('remove'));
-
- const handle = node.querySelector('.card-handle');
- handle.title = t('cardActionConnect');
- handle.setAttribute('aria-label', t('cardActionConnect'));
-
- const title = node.querySelector('.card-title');
- const body = node.querySelector('.card-body');
- title.value = card.title;
- body.value = card.body;
- title.addEventListener('input', (event) => updateCard(card.id, { title: event.target.value }));
- body.addEventListener('input', (event) => updateCard(card.id, { body: event.target.value }));
-
- removeBtn.addEventListener('click', (event) => {
- event.stopPropagation();
- state.cards = state.cards.filter((item) => item.id !== card.id);
- state.selectedIds = state.selectedIds.filter((id) => id !== card.id);
- state.connections = state.connections.filter((c) => c.from !== card.id && c.to !== card.id);
- save();
- render();
- });
-
- node.querySelectorAll('.card-action').forEach((btn) => {
- btn.addEventListener('click', (event) => {
- event.stopPropagation();
- if (!state.selectedIds.includes(card.id)) state.selectedIds = [card.id];
- const action = btn.dataset.action;
- runAction(action);
- });
- btn.addEventListener('pointerdown', (event) => event.stopPropagation());
- });
-
- handle.addEventListener('pointerdown', (event) => onHandlePointerDown(event, card.id));
-
- node.addEventListener('pointerdown', (event) => onCardPointerDown(event, card.id));
- node.addEventListener('click', (event) => {
- if (event.target.closest('textarea, button')) return;
- toggleSelection(card.id, event.shiftKey || event.metaKey || event.ctrlKey);
- });
- return node;
-}
-
-function kindLabel(kind) {
- if (kind === 'assumption') return t('cardAssumption');
- if (kind === 'counterpoint') return t('cardCounterpoint');
- if (kind === 'source') return t('cardSource');
- if (kind === 'question') return t('cardQuestion');
- if (kind === 'insight' || kind === 'ai') return t('cardInsight');
- if (kind === 'output') return t('cardOutput');
- return t('cardIdea');
-}
-
-function updateCard(id, patch) {
- const card = state.cards.find((item) => item.id === id);
- if (!card) return;
- Object.assign(card, patch);
- save();
-}
-
-function updateCardPosition(id, x, y) {
- const card = state.cards.find((item) => item.id === id);
- if (!card) return null;
- card.x = x;
- card.y = y;
- return card;
-}
-
-function toggleSelection(id, additive) {
- if (additive) {
- state.selectedIds = state.selectedIds.includes(id)
- ? state.selectedIds.filter((item) => item !== id)
- : [...state.selectedIds, id];
- } else {
- state.selectedIds = state.selectedIds.length === 1 && state.selectedIds[0] === id ? [] : [id];
- }
- render();
-}
-
-function onCardPointerDown(event, id) {
- if (event.target.closest('textarea, button, .card-handle')) return;
- const card = state.cards.find((item) => item.id === id);
- if (!card) return;
- dragState = {
- id,
- startX: event.clientX,
- startY: event.clientY,
- originalX: card.x,
- originalY: card.y,
- moved: false
- };
- event.currentTarget.setPointerCapture(event.pointerId);
-}
-
-function onHandlePointerDown(event, fromId) {
- event.stopPropagation();
- event.preventDefault();
- const point = screenToWorld(event.clientX, event.clientY);
- connectDragState = {
- fromId,
- currentX: point.x,
- currentY: point.y,
- targetId: null,
- pointerId: event.pointerId
- };
- event.target.setPointerCapture(event.pointerId);
- hideRelationPopover();
- hideConnectionTools();
- renderConnections();
-}
-
-window.addEventListener('pointermove', (event) => {
- if (panState) {
- view.panX = panState.panX + (event.clientX - panState.startX);
- view.panY = panState.panY + (event.clientY - panState.startY);
- if (Math.abs(event.clientX - panState.startX) + Math.abs(event.clientY - panState.startY) > 3) panState.moved = true;
- applyView();
- return;
- }
- if (connectDragState) {
- const point = screenToWorld(event.clientX, event.clientY);
- connectDragState.currentX = point.x;
- connectDragState.currentY = point.y;
- const targetEl = document.elementFromPoint(event.clientX, event.clientY);
- const targetCard = targetEl?.closest?.('.card');
- const nextTargetId = targetCard && targetCard.dataset.id !== connectDragState.fromId ? targetCard.dataset.id : null;
- if (nextTargetId !== connectDragState.targetId) {
- if (connectDragState.targetId) {
- document.querySelector(`.card[data-id="${connectDragState.targetId}"]`)?.classList.remove('is-drop-target');
- }
- if (nextTargetId) {
- document.querySelector(`.card[data-id="${nextTargetId}"]`)?.classList.add('is-drop-target');
- }
- connectDragState.targetId = nextTargetId;
- }
- renderConnections();
- return;
- }
- if (!dragState) return;
- const dx = (event.clientX - dragState.startX) / view.zoom;
- const dy = (event.clientY - dragState.startY) / view.zoom;
- if (Math.abs(dx) + Math.abs(dy) > 3) dragState.moved = true;
- const card = updateCardPosition(dragState.id, dragState.originalX + dx, dragState.originalY + dy);
- const node = document.querySelector(`[data-id="${dragState.id}"]`);
- if (node && card) node.style.transform = `translate(${card.x}px, ${card.y}px)`;
- renderConnections();
-});
-
-window.addEventListener('pointerup', (event) => {
- if (panState) {
- suppressCanvasClick = panState.moved;
- try { $('canvas').releasePointerCapture(panState.pointerId); } catch (_) { /* noop */ }
- $('canvas').classList.remove('is-panning');
- panState = null;
- if (suppressCanvasClick) save();
- return;
- }
- if (connectDragState) {
- const { fromId, targetId, currentX, currentY } = connectDragState;
- if (targetId) {
- document.querySelector(`.card[data-id="${targetId}"]`)?.classList.remove('is-drop-target');
- }
- if (targetId && targetId !== fromId) {
- showRelationPopover(currentX, currentY, fromId, targetId);
- }
- connectDragState = null;
- renderConnections();
- return;
- }
- if (!dragState) return;
- dragState = null;
- save();
-});
-
-function addCardFromInput() {
- const text = $('seedInput').value.trim();
- if (!text) {
- setStatus(t('emptyInput'));
- return;
- }
- const center = viewportCenterWorld();
- const card = {
- id: uid(),
- kind: 'idea',
- title: text.split('\n')[0].slice(0, 72),
- body: text,
- x: Math.round(center.x - CARD_WIDTH / 2),
- y: Math.round(center.y - CARD_HEIGHT / 2),
- _glow: true
- };
- state.cards.unshift(card);
- $('seedInput').value = '';
- save();
- render();
- card._renderedOnce = true;
- setTimeout(() => { delete card._glow; render(); }, 850);
- fitView([card, ...selectedCards()], { maxZoom: 1.1 });
-}
-
-function inferOutputFormat(instruction) {
- const text = String(instruction || '').toLowerCase();
- if (/\b(email|mail)\b/.test(text) || /邮件|邮箱/.test(text)) return 'email';
- if (/\b(issue|ticket|bug)\b/.test(text) || /工单|问题单/.test(text)) return 'issue';
- if (/\bprd|product requirement|requirements?\b/.test(text) || /需求|产品文档/.test(text)) return 'prd';
- if (/\bpost|tweet|thread|social\b/.test(text) || /帖子|推文|社媒/.test(text)) return 'post';
- if (/\bmarkdown|md\b/.test(text)) return 'markdown';
- return state.outputFormat || 'message';
-}
-
-function relationLabel(type) {
- if (type === 'challenges') return t('relationChallenges');
- if (type === 'expands') return t('relationExpands');
- if (type === 'becomes') return t('relationBecomes');
- return t('relationSupports');
-}
-
-function cardBox(card) {
- const w = CARD_WIDTH;
- const h = cardHeight(card);
- return { x: card.x, y: card.y, w, h, cx: card.x + w / 2, cy: card.y + h / 2 };
-}
-
-function cardHandleAnchor(card) {
- const box = cardBox(card);
- return { x: box.x + box.w, y: box.cy };
-}
-
-// Pick the nearest pair of edges based on the relative position of two cards.
-function edgeAnchors(from, to) {
- const a = cardBox(from);
- const b = cardBox(to);
- const horizontal = Math.abs(b.cx - a.cx) >= Math.abs(b.cy - a.cy);
- if (horizontal) {
- return b.cx >= a.cx
- ? { start: { x: a.x + a.w, y: a.cy }, end: { x: b.x, y: b.cy }, dir: 'h' }
- : { start: { x: a.x, y: a.cy }, end: { x: b.x + b.w, y: b.cy }, dir: 'h' };
- }
- return b.cy >= a.cy
- ? { start: { x: a.cx, y: a.y + a.h }, end: { x: b.cx, y: b.y }, dir: 'v' }
- : { start: { x: a.cx, y: a.y }, end: { x: b.cx, y: b.y + b.h }, dir: 'v' };
-}
-
-function bezierPath(start, end, dir) {
- if (dir === 'v') {
- const dy = Math.max(40, Math.abs(end.y - start.y) * 0.45);
- return `M ${start.x} ${start.y} C ${start.x} ${start.y + dy}, ${end.x} ${end.y - dy}, ${end.x} ${end.y}`;
- }
- const dx = Math.max(48, Math.abs(end.x - start.x) * 0.42);
- const sign = end.x >= start.x ? 1 : -1;
- return `M ${start.x} ${start.y} C ${start.x + dx * sign} ${start.y}, ${end.x - dx * sign} ${end.y}, ${end.x} ${end.y}`;
-}
-
-function renderConnections() {
- const layer = $('connectionLayer');
- if (!layer) return;
- layer.innerHTML = `
-
-
-
-
-
- `;
- state.connections.forEach((connection) => {
- const from = state.cards.find((card) => card.id === connection.from);
- const to = state.cards.find((card) => card.id === connection.to);
- if (!from || !to) return;
- const { start, end, dir } = edgeAnchors(from, to);
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- const isActive = state.activeConnectionId === connection.id;
- path.setAttribute('class', `connection connection--${connection.type}${isActive ? ' is-active' : ''}`);
- path.setAttribute('d', bezierPath(start, end, dir));
- path.setAttribute('marker-end', 'url(#sparkArrow)');
- path.dataset.connectionId = connection.id;
- const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
- path.addEventListener('click', (event) => {
- event.stopPropagation();
- showConnectionTools(connection.id, mid.x, mid.y - 14);
- });
- layer.appendChild(path);
-
- const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
- label.setAttribute('class', 'connection-label');
- label.setAttribute('x', String(mid.x));
- label.setAttribute('y', String(mid.y - 8));
- label.textContent = relationLabel(connection.type);
- layer.appendChild(label);
- });
-
- if (connectDragState) {
- const from = state.cards.find((card) => card.id === connectDragState.fromId);
- if (from) {
- const start = cardHandleAnchor(from);
- const end = { x: connectDragState.currentX, y: connectDragState.currentY };
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- path.setAttribute('class', 'connection-temp');
- path.setAttribute('d', bezierPath(start, end, 'h'));
- layer.appendChild(path);
- }
- }
-}
-
-function renderConnectionList() {
- const root = $('connectionList');
- root.innerHTML = '';
- if (state.connections.length === 0) return;
- state.connections.slice(-5).reverse().forEach((connection) => {
- const from = state.cards.find((card) => card.id === connection.from);
- const to = state.cards.find((card) => card.id === connection.to);
- if (!from || !to) return;
- const row = document.createElement('div');
- row.className = 'connection-row';
- row.innerHTML = '
';
- row.querySelector('span').textContent = `${from.title} -> ${relationLabel(connection.type)} -> ${to.title}`;
- const button = row.querySelector('button');
- button.textContent = '×';
- button.title = t('removeConnection');
- button.setAttribute('aria-label', t('removeConnection'));
- button.addEventListener('click', () => removeConnection(connection.id));
- root.appendChild(row);
- });
-}
-
-function removeConnection(id) {
- state.connections = state.connections.filter((item) => item.id !== id);
- state.activeConnectionId = null;
- hideConnectionTools();
- save();
- render();
- setStatus(t('connectionRemoved'));
-}
-
-function showRelationPopover(worldX, worldY, fromId, toId) {
- relationContext = { fromId, toId };
- const popover = $('relationPopover');
- const canvas = $('canvas');
- const screen = worldToScreen(worldX, worldY);
- const width = 220;
- const left = Math.max(8, Math.min(canvas.clientWidth - width - 8, screen.x - width / 2));
- const top = Math.max(8, Math.min(canvas.clientHeight - 180, screen.y + 14));
- popover.style.left = `${left}px`;
- popover.style.top = `${top}px`;
- popover.hidden = false;
-}
-
-function hideRelationPopover() {
- const popover = $('relationPopover');
- if (popover) popover.hidden = true;
- relationContext = null;
-}
-
-function showConnectionTools(connectionId, x, y) {
- state.activeConnectionId = connectionId;
- const tools = $('connectionTools');
- if (!tools) return;
- const connection = state.connections.find((c) => c.id === connectionId);
- if (!connection) return;
- $('connectionToolsLabel').textContent = relationLabel(connection.type);
- const canvas = $('canvas');
- const screen = worldToScreen(x, y);
- const left = Math.max(40, Math.min(canvas.clientWidth - 40, screen.x));
- const top = Math.max(20, Math.min(canvas.clientHeight - 20, screen.y));
- tools.style.left = `${left}px`;
- tools.style.top = `${top}px`;
- tools.hidden = false;
- renderConnections();
-}
-
-function hideConnectionTools() {
- const tools = $('connectionTools');
- if (tools) tools.hidden = true;
- if (state.activeConnectionId !== null) {
- state.activeConnectionId = null;
- renderConnections();
- }
-}
-
-function connectSelectedCards() {
- if (state.selectedIds.length !== 2) {
- setStatus(t('needTwoCards'));
- return;
- }
- const [from, to] = state.selectedIds;
- const type = $('relationType').value;
- const existing = state.connections.find((connection) => connection.from === from && connection.to === to);
- if (existing) existing.type = type;
- else state.connections.push({ id: uid('connection'), from, to, type });
- save();
- render();
- setStatus(t('connected'));
-}
-
-function fallbackSpark(text) {
- const base = text || $('seedInput').value.trim() || 'New creative direction';
- return [
- { kind: 'idea', title: 'Audience angle', body: `Who needs "${base}" badly enough to change behavior?` },
- { kind: 'question', title: 'Constraint to clarify', body: 'What must stay true even if the idea becomes smaller?' },
- { kind: 'insight', title: 'Sendable shape', body: 'Frame the output as a decision, invitation, or next action.' }
- ];
-}
-
-function extractJson(text) {
- const raw = String(text || '').trim();
- try {
- return JSON.parse(raw);
- } catch (_) {
- const match = raw.match(/\{[\s\S]*\}/);
- if (!match) return null;
- try {
- return JSON.parse(match[0]);
- } catch (error) {
- runtime().log?.warn?.('Failed to parse AI JSON', { error: String(error) });
- return null;
- }
- }
-}
-
-function buildPrompt(action, cards, topic, outputFormat) {
- const relatedConnections = relatedConnectionsFor(cards);
- const boardSummary = {
- title: state.boardTitle,
- cardCount: state.cards.length,
- connectionCount: state.connections.length,
- outputFormat,
- selectedCardIds: cards.map((card) => card.id)
- };
- return [
- 'You are Spark Board, an AI creative canvas collaborator.',
- 'Return compact JSON only with this shape: {"cards":[{"kind":"idea|question|insight|output|assumption|counterpoint|source","title":"...","body":"..."}],"draft":"optional send-ready text"}.',
- 'The user experiences this as an AI-native canvas, so prefer specific cards and inferred relationships over UI instructions.',
- `Action: ${action}.`,
- `Requested output format: ${outputFormat}.`,
- `Topic: ${topic || 'none'}.`,
- `Board summary: ${JSON.stringify(boardSummary)}.`,
- `Selected cards: ${JSON.stringify(cards.map(({ id, title, body, kind, x, y }) => ({ id, title, body, kind, x, y })))}.`,
- `Related connections: ${JSON.stringify(relatedConnections)}.`
- ].join('\n');
-}
-
-function askAi(action, cards, topic) {
- const outputFormat = inferOutputFormat(`${action}\n${topic || ''}`);
- state.outputFormat = outputFormat;
- const prompt = buildPrompt(action, cards, topic, outputFormat);
- const SYSTEM = 'Create useful, specific, concise creative thinking cards. If drafting, match the requested output format. Do not include markdown fences.';
-
- return new Promise((resolve, reject) => {
- if (!runtime().ai || typeof runtime().ai.chat !== 'function') {
- if (typeof runtime().ai?.complete === 'function') {
- let cancelled = false;
- state.activeStream = { cancel: () => { cancelled = true; reject(new Error('cancelled')); } };
- runtime().ai.complete(prompt, { systemPrompt: SYSTEM, maxTokens: 1200, temperature: 0.65 })
- .then((result) => { if (!cancelled) { state.activeStream = null; resolve(extractJson(result?.text ?? result)); } })
- .catch((err) => { if (!cancelled) { state.activeStream = null; reject(err); } });
- return;
- }
- reject(new Error('AI bridge unavailable'));
- return;
- }
-
- let accumulated = '';
- let handle = null;
- let settled = false;
- let cancelled = false;
- const finish = (cb) => { if (settled) return; settled = true; state.activeStream = null; cb(); };
-
- const exposedCancel = () => {
- if (settled) return;
- cancelled = true;
- try { handle?.cancel?.(); } catch (_) { /* noop */ }
- finish(() => reject(new Error('cancelled')));
- };
- state.activeStream = { cancel: exposedCancel };
-
- runtime().ai.chat(
- [{ role: 'user', content: prompt }],
- {
- systemPrompt: SYSTEM,
- model: 'primary',
- maxTokens: 1200,
- temperature: 0.65,
- onChunk: ({ text }) => {
- if (cancelled || !text) return;
- accumulated += text;
- updateStream(accumulated);
- },
- onDone: ({ fullText }) => {
- if (cancelled) return;
- finish(() => resolve(extractJson(fullText || accumulated)));
- },
- onError: ({ message }) => {
- if (cancelled) return;
- finish(() => reject(new Error(message || 'AI failed')));
- }
- }
- ).then((h) => {
- handle = h;
- if (cancelled && h?.cancel) { try { h.cancel(); } catch (_) { /* noop */ } }
- }).catch((error) => {
- if (cancelled) return;
- finish(() => reject(error));
- });
- });
-}
-
-// --- Streaming generation: parse cards out of JSON as it arrives, drop each on canvas with glow ---
-
-function parseStreamingCards(text) {
- const cardsKey = text.indexOf('"cards"');
- if (cardsKey < 0) return { cards: [], complete: false };
- const arrayStart = text.indexOf('[', cardsKey);
- if (arrayStart < 0) return { cards: [], complete: false };
- const cards = [];
- let depth = 0;
- let inString = false;
- let escape = false;
- let objStart = -1;
- let complete = false;
- for (let i = arrayStart + 1; i < text.length; i++) {
- const c = text[i];
- if (escape) { escape = false; continue; }
- if (inString) {
- if (c === '\\') { escape = true; continue; }
- if (c === '"') { inString = false; }
- continue;
- }
- if (c === '"') { inString = true; continue; }
- if (c === '{') {
- if (depth === 0) objStart = i;
- depth++;
- } else if (c === '}') {
- depth--;
- if (depth === 0 && objStart >= 0) {
- try {
- cards.push(JSON.parse(text.slice(objStart, i + 1)));
- } catch (_) { /* partial — skip */ }
- objStart = -1;
- }
- } else if (c === ']' && depth === 0) {
- complete = true;
- break;
- }
- }
- return { cards, complete };
-}
-
-const VALID_KINDS = ['idea', 'question', 'insight', 'output', 'ai', 'assumption', 'counterpoint', 'source'];
-
-function normalizeStreamCard(raw, fallbackKind, index) {
- return {
- kind: VALID_KINDS.includes(raw?.kind) ? raw.kind : (fallbackKind || 'ai'),
- title: String(raw?.title || `Spark ${index + 1}`).slice(0, 90),
- body: String(raw?.body || raw?.text || '').slice(0, 700)
- };
-}
-
-// Pan (keeping current zoom) so a world point sits at a chosen screen fraction.
-function focusWorldPoint(wx, wy, fracX = 0.32, fracY = 0.28, animate = true) {
- const rect = canvasRect();
- const next = {
- panX: rect.width * fracX - wx * view.zoom,
- panY: rect.height * fracY - wy * view.zoom,
- zoom: view.zoom
- };
- animate ? animateView(next) : (Object.assign(view, next), applyView());
-}
-
-function startStream(kind) {
- setBusy(true);
- setStatus(t('thinkingHint'));
- state.selectedIds = [];
- const anchor = computeAnchorPoint();
- state.stream = {
- kind: kind || 'ai',
- anchor,
- cursor: makeClusterCursor(anchor),
- placedCount: 0,
- streamedIds: [],
- prevView: { ...view }
- };
- render();
- focusWorldPoint(anchor.x, anchor.y);
- renderStreamChip();
-}
-
-// Compact, source-anchored "generating" chip — no raw JSON, single busy signal.
-function renderStreamChip() {
- const stage = $('canvasStage');
- if (!stage || !state.stream) return;
- let chip = $('streamChip');
- if (!chip) {
- chip = document.createElement('div');
- chip.id = 'streamChip';
- chip.className = 'stream-chip';
- chip.innerHTML = `
-
-
-
- `;
- stage.appendChild(chip);
- }
- chip.querySelector('.stream-chip-label').textContent = t('thinkingHint');
- const count = state.stream.placedCount;
- const countNode = chip.querySelector('.stream-chip-count');
- countNode.textContent = count > 0 ? String(count) : '';
- countNode.hidden = count === 0;
- const left = state.stream.anchor.x;
- const top = state.stream.anchor.y - 34;
- chip.style.transform = `translate(${left}px, ${top}px)`;
-}
-
-function updateStream(accumulated) {
- if (!state.stream) return;
- const parsed = parseStreamingCards(accumulated);
- if (parsed.cards.length > state.stream.placedCount) {
- placeStreamCards(parsed.cards.slice(state.stream.placedCount));
- }
-}
-
-function placeStreamCards(rawCards) {
- if (!state.stream) return;
- const stage = $('canvasStage');
- const startIdx = state.stream.placedCount;
-
- rawCards.forEach((raw, i) => {
- const normalized = normalizeStreamCard(raw, state.stream.kind, startIdx + i);
- const slot = nextClusterSlot(state.stream.cursor, normalized);
- const card = {
- id: uid('ai'),
- kind: normalized.kind,
- title: normalized.title,
- body: normalized.body,
- x: slot.x,
- y: slot.y,
- _glow: true,
- _streaming: true
- };
- state.cards.push(card);
- state.stream.streamedIds.push(card.id);
- state.stream.placedCount += 1;
- state.selectedIds = [...state.stream.streamedIds];
-
- if (stage) stage.appendChild(renderCard(card));
- card._renderedOnce = true;
- setTimeout(() => { delete card._glow; }, 850);
- });
-
- measureCards();
- renderConnections();
- renderStreamChip();
- updateSelectionMeta();
-}
-
-function finalizeStream(json, preferredKind) {
- if (!state.stream) return;
- const parsedFinal = Array.isArray(json?.cards)
- ? json.cards.slice(0, 6).map((item, i) => normalizeStreamCard(item, preferredKind, i))
- : [];
-
- // Each streamed card already carries its final content (objects emit complete),
- // so we only place the tail the streaming parser hadn't reached.
- const extras = parsedFinal.slice(state.stream.placedCount);
- if (extras.length > 0) placeStreamCards(extras);
-
- const draft = json?.draft ? String(json.draft) : '';
- if (draft) {
- state.draft = draft;
- if (preferredKind === 'output') {
- const slot = nextClusterSlot(state.stream.cursor, { body: draft });
- const outputCard = {
- id: uid('output'),
- kind: 'output',
- title: t('outputCardTitle'),
- body: draft,
- x: slot.x,
- y: slot.y,
- _glow: true,
- _streaming: true
- };
- state.cards.push(outputCard);
- state.stream.streamedIds.push(outputCard.id);
- state.selectedIds = [...state.stream.streamedIds];
- const stage = $('canvasStage');
- if (stage) stage.appendChild(renderCard(outputCard));
- outputCard._renderedOnce = true;
- openDrawer('draft');
- setTimeout(() => { delete outputCard._glow; }, 850);
- }
- }
-
- state.stream.streamedIds.forEach((id) => {
- const card = state.cards.find((c) => c.id === id);
- if (card) delete card._streaming;
- });
-
- const framed = state.stream.streamedIds
- .map((id) => state.cards.find((c) => c.id === id))
- .filter(Boolean);
- measureCards();
- setStatus(draft ? t('drafted') : t('generated'));
- stopStream();
- if (framed.length > 0) fitView(framed, { maxZoom: 1.05 });
-}
-
-function clearStreamingCards() {
- if (!state.stream) return;
- const ids = new Set(state.stream.streamedIds);
- if (ids.size === 0) return;
- state.cards = state.cards.filter((c) => !ids.has(c.id));
- state.selectedIds = state.selectedIds.filter((id) => !ids.has(id));
- state.connections = state.connections.filter((c) => !ids.has(c.from) && !ids.has(c.to));
- ids.forEach((id) => {
- const node = document.querySelector(`.card[data-id="${id}"]`);
- if (node) node.remove();
- });
- state.stream.streamedIds = [];
- state.stream.placedCount = 0;
-}
-
-function placeFallback(rawCards, draft, preferredKind) {
- const normalized = rawCards.map((item, i) => normalizeStreamCard(item, preferredKind, i));
- const placed = buildGeneratedCards(normalized, preferredKind);
- placed.forEach((c) => { c._glow = true; });
- state.cards.push(...placed);
- state.selectedIds = placed.map((c) => c.id);
- if (draft) state.draft = draft;
- render();
- placed.forEach((c) => { c._renderedOnce = true; });
- setTimeout(() => { placed.forEach((c) => { delete c._glow; }); }, 850);
- if (placed.length > 0) fitView(placed, { maxZoom: 1.05 });
-}
-
-function stopStream() {
- setBusy(false);
- state.stream = null;
- const chip = $('streamChip');
- if (chip) chip.remove();
-}
-
-function cancelThinking() {
- const handle = state.activeStream;
- const prevView = state.stream?.prevView;
- state.activeStream = null;
- try { handle?.cancel?.(); } catch (_) { /* noop */ }
- clearStreamingCards();
- stopStream();
- if (prevView) animateView(prevView);
- setStatus(t('cancelled'));
-}
-
-function isCancelError(error) {
- const msg = String(error?.message || error || '').toLowerCase();
- return msg.includes('cancel') || msg.includes('abort');
-}
-
-const CARD_WIDTH = 232;
-const CARD_HEIGHT = 116;
-const CARD_GAP_X = 36;
-const CARD_GAP_Y = 22;
-const CLUSTER_PER_COL = 4;
-
-function computeBoundingBox(cards) {
- const list = (cards || []).filter(Boolean);
- if (list.length === 0) return { minX: 0, minY: 0, maxX: CARD_WIDTH, maxY: CARD_HEIGHT };
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
- list.forEach((c) => {
- const h = cardHeight(c);
- if (c.x < minX) minX = c.x;
- if (c.y < minY) minY = c.y;
- if (c.x + CARD_WIDTH > maxX) maxX = c.x + CARD_WIDTH;
- if (c.y + h > maxY) maxY = c.y + h;
- });
- return { minX, minY, maxX, maxY };
-}
-
-// Estimate a card's height before it is rendered, so streamed slots don't shift.
-function estimateCardHeight(item) {
- const bodyLen = (item?.body || '').length;
- const titleLen = (item?.title || '').length;
- const lines = Math.ceil(bodyLen / 30) + (titleLen > 34 ? 1 : 0);
- return Math.max(110, Math.min(300, 74 + lines * 18));
-}
-
-// World-space anchor for a fresh generated cluster.
-function computeAnchorPoint() {
- const selected = selectedCards();
- if (selected.length > 0) {
- const rightmost = selected.reduce((m, c) => (c.x > m.x ? c : m), selected[0]);
- return { x: rightmost.x + CARD_WIDTH + CARD_GAP_X * 2, y: rightmost.y };
- }
- if (state.cards.length === 0) {
- const center = viewportCenterWorld();
- return { x: Math.round(center.x - CARD_WIDTH / 2), y: Math.round(center.y - CARD_HEIGHT) };
- }
- const box = computeBoundingBox(state.cards);
- return { x: box.maxX + CARD_GAP_X * 2, y: box.minY };
-}
-
-// Sequential column-packing cursor: deterministic, height-aware, never overlaps.
-function makeClusterCursor(anchor) {
- return { colX: anchor.x, colTop: anchor.y, y: anchor.y, countInCol: 0 };
-}
-
-function nextClusterSlot(cursor, item) {
- if (cursor.countInCol >= CLUSTER_PER_COL) {
- cursor.colX += CARD_WIDTH + CARD_GAP_X;
- cursor.y = cursor.colTop;
- cursor.countInCol = 0;
- }
- const slot = { x: cursor.colX, y: cursor.y };
- cursor.y += estimateCardHeight(item) + CARD_GAP_Y;
- cursor.countInCol += 1;
- return slot;
-}
-
-function buildGeneratedCards(cards, preferredKind) {
- const cursor = makeClusterCursor(computeAnchorPoint());
- return cards.map((card) => {
- const slot = nextClusterSlot(cursor, card);
- return {
- id: uid('ai'),
- kind: card.kind || preferredKind || 'ai',
- title: card.title,
- body: card.body,
- x: slot.x,
- y: slot.y
- };
- });
-}
-
-/* ---------- Tidy: semantic layered auto-layout ---------- */
-
-const TIDY_FORWARD = new Set(['expands', 'becomes', 'supports']);
-
-function computeTidyTargets() {
- const byId = new Map(state.cards.map((c) => [c.id, c]));
- const edges = state.connections.filter(
- (conn) => TIDY_FORWARD.has(conn.type) && byId.has(conn.from) && byId.has(conn.to)
- );
-
- const layer = new Map(state.cards.map((c) => [c.id, 0]));
- const connected = new Set();
- edges.forEach((conn) => { connected.add(conn.from); connected.add(conn.to); });
-
- // Longest-path layering via relaxation (cycle-safe with an iteration guard).
- let changed = true;
- let guard = 0;
- while (changed && guard++ < state.cards.length + 4) {
- changed = false;
- edges.forEach((conn) => {
- const next = layer.get(conn.from) + 1;
- if (next > layer.get(conn.to)) { layer.set(conn.to, next); changed = true; }
- });
- }
-
- const layers = [];
- state.cards.forEach((c) => {
- if (!connected.has(c.id)) return;
- const li = layer.get(c.id);
- (layers[li] ||= []).push(c);
- });
-
- const colGap = CARD_WIDTH + CARD_GAP_X * 2;
- const rowGap = CARD_GAP_Y + 8;
- const colHeights = layers.map((cards) =>
- (cards || []).reduce((sum, c) => sum + cardHeight(c) + rowGap, -rowGap)
- );
- const tallest = Math.max(0, ...colHeights);
-
- const targets = new Map();
- let graphBottom = 0;
- layers.forEach((cards, li) => {
- if (!cards) return;
- const x = li * colGap;
- let y = (tallest - colHeights[li]) / 2;
- cards.forEach((c) => {
- targets.set(c.id, { x, y });
- y += cardHeight(c) + rowGap;
- graphBottom = Math.max(graphBottom, y);
- });
- });
-
- // Cards with no semantic edges: pack into a grid block below the graph.
- const isolated = state.cards.filter((c) => !connected.has(c.id));
- if (isolated.length > 0) {
- const cols = Math.max(1, Math.min(4, Math.round(Math.sqrt(isolated.length))));
- const anchorY = targets.size > 0 ? graphBottom + CARD_GAP_Y * 3 : 0;
- const cursor = makeClusterCursor({ x: 0, y: anchorY });
- cursor.colTop = anchorY;
- // grid: fill row by row using a fixed perCol derived from cols
- let i = 0;
- const colTops = new Array(cols).fill(anchorY);
- isolated.forEach((c) => {
- const col = i % cols;
- const x = col * (CARD_WIDTH + CARD_GAP_X);
- const y = colTops[col];
- targets.set(c.id, { x, y });
- colTops[col] += cardHeight(c) + rowGap;
- i += 1;
- });
- }
-
- return targets;
-}
-
-function tidyBoard() {
- if (busy) return;
- if (state.cards.length === 0) { setStatus(t('ready')); return; }
- const targets = computeTidyTargets();
- const stage = $('canvasStage');
- state.cards.forEach((c) => {
- const tgt = targets.get(c.id);
- if (!tgt) return;
- c.x = tgt.x;
- c.y = tgt.y;
- const node = stage?.querySelector(`.card[data-id="${c.id}"]`);
- if (node) {
- node.classList.add('is-tidying');
- node.style.transform = `translate(${c.x}px, ${c.y}px)`;
- }
- });
- renderConnections();
- save();
- setStatus(t('tidied'));
- window.setTimeout(() => {
- stage?.querySelectorAll('.card.is-tidying').forEach((n) => n.classList.remove('is-tidying'));
- renderConnections();
- fitView(state.cards);
- }, 60);
-}
-
-async function runComposerIntent() {
- const instruction = $('seedInput').value.trim();
- if (!instruction) {
- setStatus(t('emptyInput'));
- return;
- }
- const cards = selectedCards();
- const wantsDraft = /draft|write|send|message|email|issue|prd|post|成稿|草稿|发送|邮件|需求|帖子/.test(instruction.toLowerCase());
- const preferredKind = wantsDraft ? 'output' : 'idea';
- startStream(preferredKind);
- try {
- const action = cards.length > 0
- ? `follow the user's instruction for the selected canvas cards: ${instruction}`
- : `start or reshape the canvas from this user intent: ${instruction}`;
- const json = await askAi(action, cards, instruction);
- finalizeStream(json, preferredKind);
- $('seedInput').value = '';
- } catch (error) {
- if (isCancelError(error)) {
- clearStreamingCards();
- stopStream();
- save();
- return;
- }
- clearStreamingCards();
- stopStream();
- runtime().log?.warn?.('Spark Board composer intent failed', { error: String(error) });
- placeFallback(fallbackSpark(instruction), '', preferredKind);
- setStatus(t('aiFailed'));
- } finally {
- save();
- }
-}
-
-async function runAction(action) {
- const cards = selectedCards();
- if (cards.length === 0) {
- setStatus(t('needSelection'));
- return;
- }
- const kind = action === 'draft' ? 'output' : 'ai';
- startStream(kind);
- try {
- const json = await askAi(action, cards, $('seedInput').value.trim());
- if (action === 'draft' && !json?.draft) {
- const fallbackDraft = cards.map((c) => `${c.title}\n${c.body}`).join('\n\n');
- json.draft = fallbackDraft;
- }
- finalizeStream(json, kind);
- } catch (error) {
- if (isCancelError(error)) {
- clearStreamingCards();
- stopStream();
- save();
- return;
- }
- clearStreamingCards();
- stopStream();
- runtime().log?.warn?.('Spark Board AI action failed', { action, error: String(error) });
- const fallback = action === 'challenge'
- ? [{ kind: 'counterpoint', title: 'What could be wrong?', body: 'Name the assumption that would break this idea if it turned out false.' }]
- : fallbackSpark(cards[0]?.title);
- placeFallback(fallback, '', kind);
- setStatus(t('aiFailed'));
- } finally {
- save();
- }
-}
-
-async function copyDraft() {
- const text = currentDraftText();
- if (!text) {
- setStatus(t('copyEmpty'));
- return;
- }
- state.draft = text;
- save();
- try {
- await copyText(text);
- setStatus(t('copied'));
- } catch (error) {
- runtime().log?.warn?.('Failed to copy Spark Board draft', { error: String(error) });
- setStatus(t('copied'));
- }
-}
-
-async function sendDraft() {
- const text = currentDraftText();
- if (!text) {
- setStatus(t('copyEmpty'));
- return;
- }
- state.draft = text;
- save();
- try {
- if (!runtime().host?.fillChatInput) throw new Error('host.fillChatInput unavailable');
- await runtime().host.fillChatInput(text);
- setStatus(t('sentToChat'));
- } catch (error) {
- runtime().log?.warn?.('Failed to send Spark Board draft to chat input', { error: String(error) });
- await copyText(text);
- setStatus(t('sendUnavailable'));
- }
-}
-
-function currentDraftText() {
- const explicit = ($('previewDraft')?.value || $('draftOutput')?.value || state.draft || '').trim();
- if (explicit) return explicit;
- const latestOutput = [...state.cards].reverse().find((card) => card.kind === 'output' && card.body);
- return latestOutput ? latestOutput.body.trim() : '';
-}
-
-async function copyText(text) {
- if (runtime().clipboard) {
- await runtime().clipboard.writeText(text);
- return;
- }
- if (navigator.clipboard?.writeText) {
- await navigator.clipboard.writeText(text);
- return;
- }
- throw new Error('Clipboard is unavailable');
-}
-
-function escapeMarkdown(text) {
- return String(text || '').replace(/\r\n/g, '\n').trim();
-}
-
-function boardToMarkdown() {
- const lines = [`# ${escapeMarkdown(state.boardTitle || t('untitledBoard'))}`, ''];
- lines.push(`_Exported from Spark Board_`, '');
-
- if (state.cards.length > 0) {
- lines.push('## Cards', '');
- state.cards.forEach((card, index) => {
- lines.push(`### ${index + 1}. ${escapeMarkdown(card.title || kindLabel(card.kind))}`);
- lines.push('');
- lines.push(`- Type: ${kindLabel(card.kind)}`);
- lines.push(`- Position: ${Math.round(card.x)}, ${Math.round(card.y)}`);
- if (card.body) {
- lines.push('');
- lines.push(escapeMarkdown(card.body));
- }
- lines.push('');
- });
- }
-
- if (state.connections.length > 0) {
- lines.push('## Connections', '');
- state.connections.forEach((connection) => {
- const from = state.cards.find((card) => card.id === connection.from);
- const to = state.cards.find((card) => card.id === connection.to);
- if (!from || !to) return;
- lines.push(`- **${escapeMarkdown(from.title)}** ${relationLabel(connection.type).toLowerCase()} **${escapeMarkdown(to.title)}**`);
- });
- lines.push('');
- }
-
- if (state.draft.trim()) {
- lines.push('## Send-ready Draft', '');
- lines.push(escapeMarkdown(state.draft));
- lines.push('');
- }
-
- return lines.join('\n').replace(/\n{3,}/g, '\n\n');
-}
-
-async function exportMarkdown() {
- const markdown = boardToMarkdown();
- try {
- await copyText(markdown);
- setStatus(t('exported'));
- } catch (error) {
- runtime().log?.warn?.('Failed to export Spark Board Markdown', { error: String(error) });
- setStatus(t('exported'));
- }
-}
-
-function resetBoard() {
- state.cards = clone(DEFAULT_CARDS);
- state.selectedIds = [];
- state.connections = [];
- state.draft = '';
- state.mode = 'free';
- state.outputFormat = 'message';
- state.activeConnectionId = null;
- view = { panX: 0, panY: 0, zoom: 1 };
- state._needsFit = true;
- $('seedInput').value = '';
- if ($('previewDraft')) $('previewDraft').value = '';
- setStatus(t('resetDone'));
- save();
- render();
-}
-
-function createBoard() {
- persistCurrentBoard();
- const id = uid('board');
- const title = `${t('newBoardTitle')} ${state.boards.length + 1}`;
- state.boards.unshift({
- id,
- title,
- cards: [],
- connections: [],
- draft: '',
- mode: 'free',
- outputFormat: 'message',
- schemaVersion: SCHEMA_VERSION,
- updatedAt: Date.now()
- });
- applyBoard(state.boards[0]);
- $('seedInput').value = '';
- save();
- render();
- setStatus(t('boardCreated'));
-}
-
-function switchBoard(id) {
- state.boardTitle = $('boardTitle').value.trim() || state.boardTitle;
- persistCurrentBoard();
- const board = state.boards.find((item) => item.id === id);
- if (!board) return;
- applyBoard(board);
- $('seedInput').value = '';
- save();
- render();
-}
-
-function renameBoard(title) {
- state.boardTitle = title.trim() || t('untitledBoard');
- save();
- renderBoardSwitcher();
-}
-
-function openDrawer(pane) {
- root().setAttribute('data-drawer', 'open');
- const drawer = $('drawer');
- drawer.hidden = false;
- $('toggleDrawer').classList.add('is-active');
- if (pane) switchDrawerPane(pane);
-}
-
-function closeDrawer() {
- root().setAttribute('data-drawer', 'closed');
- $('toggleDrawer').classList.remove('is-active');
-}
-
-function toggleDrawer() {
- if (root().getAttribute('data-drawer') === 'open') closeDrawer();
- else openDrawer();
-}
-
-function switchDrawerPane(pane) {
- document.querySelectorAll('.drawer-tab').forEach((btn) => {
- const isActive = btn.dataset.pane === pane;
- btn.classList.toggle('is-active', isActive);
- btn.setAttribute('aria-selected', isActive ? 'true' : 'false');
- });
- document.querySelectorAll('.drawer-pane').forEach((sec) => {
- const isActive = sec.dataset.pane === pane;
- sec.classList.toggle('is-active', isActive);
- sec.hidden = !isActive;
- });
-}
-
-function canScrollInDirection(node, deltaY) {
- if (!node) return false;
- if (node.classList?.contains('board-tabs')) return false;
- const style = window.getComputedStyle(node);
- const allowsScroll = /(auto|scroll)/.test(style.overflowY);
- if (!allowsScroll && !['TEXTAREA', 'SELECT'].includes(node.tagName)) return false;
- if (node.scrollHeight <= node.clientHeight + 1) return false;
- if (deltaY < 0) return node.scrollTop > 0;
- if (deltaY > 0) return node.scrollTop + node.clientHeight < node.scrollHeight - 1;
- return false;
-}
-
-function shouldAllowWheel(event) {
- const target = event.target;
- const boardTabs = target?.closest?.('.board-tabs');
- if (boardTabs && Math.abs(event.deltaX) > Math.abs(event.deltaY)) return true;
- const scrollRoot = target?.closest?.('textarea, .drawer-pane, .drawer-body');
- return canScrollInDirection(scrollRoot, event.deltaY);
-}
-
-function lockViewportScroll() {
- if (window.scrollX !== 0 || window.scrollY !== 0) window.scrollTo(0, 0);
- document.documentElement.scrollTop = 0;
- document.body.scrollTop = 0;
-}
-
-function initEvents() {
- $('addCard').addEventListener('click', addCardFromInput);
- $('composerForm').addEventListener('submit', (event) => {
- event.preventDefault();
- if (busy) { cancelThinking(); return; }
- runComposerIntent();
- });
- $('seedInput').addEventListener('keydown', (event) => {
- if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
- event.preventDefault();
- if (busy) { cancelThinking(); return; }
- runComposerIntent();
- }
- });
- $('seedInput').addEventListener('input', (event) => {
- const el = event.target;
- el.style.height = 'auto';
- el.style.height = `${Math.min(140, el.scrollHeight)}px`;
- });
- $('exportMarkdown').addEventListener('click', exportMarkdown);
- $('sendDraft').addEventListener('click', sendDraft);
- $('copyDraft').addEventListener('click', copyDraft);
- $('resetBoard').addEventListener('click', resetBoard);
- $('newBoard').addEventListener('click', createBoard);
- $('boardSelect').addEventListener('change', (event) => switchBoard(event.target.value));
- $('boardTitle').addEventListener('change', (event) => {
- renameBoard(event.target.value);
- setStatus(t('boardRenamed'));
- });
- $('connectCards').addEventListener('click', connectSelectedCards);
- $('previewDraft').addEventListener('input', (event) => {
- state.draft = event.target.value;
- save();
- updateSelectionMeta();
- });
- $('outputFormat').addEventListener('change', (event) => {
- state.outputFormat = event.target.value;
- save();
- });
- $('draftOutput').addEventListener('input', (event) => {
- state.draft = event.target.value;
- save();
- updateSelectionMeta();
- });
-
- $('toggleDrawer').addEventListener('click', toggleDrawer);
- $('closeDrawer').addEventListener('click', closeDrawer);
- document.querySelectorAll('.drawer-tab').forEach((tab) => {
- tab.addEventListener('click', () => switchDrawerPane(tab.dataset.pane));
- });
-
- document.querySelectorAll('#relationPopover button[data-relation]').forEach((btn) => {
- btn.addEventListener('click', (event) => {
- event.stopPropagation();
- const type = btn.dataset.relation;
- if (type === 'cancel' || !relationContext) {
- hideRelationPopover();
- return;
- }
- const { fromId, toId } = relationContext;
- const existing = state.connections.find((c) => c.from === fromId && c.to === toId);
- if (existing) existing.type = type;
- else state.connections.push({ id: uid('connection'), from: fromId, to: toId, type });
- hideRelationPopover();
- save();
- render();
- setStatus(t('connected'));
- });
- });
-
- $('deleteConnection').addEventListener('click', () => {
- if (state.activeConnectionId) removeConnection(state.activeConnectionId);
- });
-
- $('canvas').addEventListener('pointerdown', (event) => {
- if (event.button !== 0 && event.button !== 1) return;
- if (event.target.closest('.card, .connection, .relation-popover, .connection-tools, .canvas-floating, .canvas-controls')) return;
- panState = {
- startX: event.clientX,
- startY: event.clientY,
- panX: view.panX,
- panY: view.panY,
- moved: false,
- pointerId: event.pointerId
- };
- try { $('canvas').setPointerCapture(event.pointerId); } catch (_) { /* noop */ }
- $('canvas').classList.add('is-panning');
- });
-
- $('canvas').addEventListener('click', (event) => {
- if (suppressCanvasClick) { suppressCanvasClick = false; return; }
- if (event.target.closest('.card, .connection, .relation-popover, .connection-tools')) return;
- state.selectedIds = [];
- hideRelationPopover();
- hideConnectionTools();
- render();
- });
-
- document.addEventListener('keydown', (event) => {
- if (event.key === 'Escape') {
- hideRelationPopover();
- hideConnectionTools();
- if (busy) cancelThinking();
- }
- });
-
- if (runtime().onLocaleChange) runtime().onLocaleChange(() => {
- applyI18n();
- render();
- });
- window.addEventListener('resize', () => {
- lockViewportScroll();
- renderConnections();
- });
- window.addEventListener('wheel', (event) => {
- if (shouldAllowWheel(event)) return;
- event.preventDefault();
- if (event.target?.closest?.('.canvas')) {
- if (event.ctrlKey || event.metaKey) {
- zoomAt(event.clientX, event.clientY, Math.exp(-event.deltaY * 0.0015));
- } else {
- view.panX -= event.deltaX;
- view.panY -= event.deltaY;
- applyView();
- scheduleSave();
- }
- }
- lockViewportScroll();
- }, { capture: true, passive: false });
-
- $('zoomIn').addEventListener('click', () => {
- const rect = canvasRect();
- zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 1.2);
- });
- $('zoomOut').addEventListener('click', () => {
- const rect = canvasRect();
- zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 1 / 1.2);
- });
- $('zoomFit').addEventListener('click', () => fitView(state.cards));
- $('tidyBoard').addEventListener('click', tidyBoard);
- window.addEventListener('scroll', lockViewportScroll, { capture: true, passive: true });
-}
-
-async function init() {
- await load();
- applyI18n();
- initEvents();
- render();
-}
-
-init();
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/worker.js b/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/worker.js
deleted file mode 100644
index 39bab1bb..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/components/surfaces/builtin-spark-board-surface/source/worker.js
+++ /dev/null
@@ -1 +0,0 @@
-// Built-in Spark Board uses host AI and local storage from the UI bridge.
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/config/data-schema.json b/bundles/product-apps/builtin-spark-board/1.0.1/config/data-schema.json
deleted file mode 100644
index 1587a669..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/config/data-schema.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "version": "1.0.0"
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/config/default.json b/bundles/product-apps/builtin-spark-board/1.0.1/config/default.json
deleted file mode 100644
index 0967ef42..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/config/default.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/tests/eval.json b/bundles/product-apps/builtin-spark-board/1.0.1/tests/eval.json
deleted file mode 100644
index c6cfd579..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/tests/eval.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "version": 1,
- "cases": [
- {
- "id": "primary-behavior",
- "title": "Spark Board responds from its declared app context",
- "description": "Proves that the immutable Release can invoke its intelligent behavior.",
- "input": {
- "message": "Reply with SPARO_APP_READY, then briefly confirm you can help with Spark Board."
- },
- "expectations": [
- {
- "kind": "text-contains",
- "value": "SPARO_APP_READY"
- }
- ],
- "evidenceKind": "behavior",
- "tags": [
- "release-gate"
- ],
- "required": true
- }
- ]
-}
diff --git a/bundles/product-apps/builtin-spark-board/1.0.1/tests/rehearsal.json b/bundles/product-apps/builtin-spark-board/1.0.1/tests/rehearsal.json
deleted file mode 100644
index afa27ebf..00000000
--- a/bundles/product-apps/builtin-spark-board/1.0.1/tests/rehearsal.json
+++ /dev/null
@@ -1,33 +0,0 @@
-{
- "version": 1,
- "scenarios": [
- {
- "id": "critical-user-path",
- "title": "Open Spark Board and complete its first useful action",
- "description": "A lightweight thinking canvas for capturing inspiration, organizing threads, and turning scattered ideas into drafts and next steps.",
- "kind": "user-path",
- "steps": [
- {
- "id": "open-app",
- "action": "open",
- "target": "primary",
- "expect": [
- "Spark Board opens"
- ]
- },
- {
- "id": "observe-ready-state",
- "action": "observe",
- "target": "work-outcome",
- "expect": [
- "A usable ready state is visible"
- ]
- }
- ],
- "expected": [
- "The app opens without a fatal runtime issue",
- "The primary workflow exposes an observable path"
- ]
- }
- ]
-}
diff --git a/bundles/skill-suites/product-app-development/skills/product-app-api/SKILL.md b/bundles/skill-suites/product-app-development/skills/product-app-api/SKILL.md
index 0a346dac..69e7f917 100644
--- a/bundles/skill-suites/product-app-development/skills/product-app-api/SKILL.md
+++ b/bundles/skill-suites/product-app-development/skills/product-app-api/SKILL.md
@@ -143,7 +143,6 @@ If the Product App needs durable intelligent behavior, prefer an app-private Age
## Built-In App References
-- `builtin-spark-board`: uses `runtime().storage` for canvas state persistence and `onLocaleChange` for i18n refresh; use it as a lightweight workbench reference.
- `builtin-ppt-live`: shows complex modular source, `app.backend.call('ppt.generate', ...)`, history/storage fallback, and theme/locale synchronization; use it for intelligent backend and large UI patterns.
- `builtin-harmony-dev` / `builtin-remotion-live`: listen for `productAppRuntimeRouteChange` and refresh facts by workspace route; use them for workspace-aware apps.
diff --git a/bundles/skill-suites/product-app-development/skills/product-app-ui-polish/SKILL.md b/bundles/skill-suites/product-app-development/skills/product-app-ui-polish/SKILL.md
index ec602dc1..b2d69889 100644
--- a/bundles/skill-suites/product-app-development/skills/product-app-ui-polish/SKILL.md
+++ b/bundles/skill-suites/product-app-development/skills/product-app-ui-polish/SKILL.md
@@ -72,7 +72,6 @@ Recommended app aliases at the top of `style.css`:
## Reference Choices
-- Tool-like, state-dense, frequently operated apps: prefer `builtin-spark-board`. It uses a compact topbar, clear tool buttons, restrained theme aliases, and a fixed full-screen canvas.
- Complex production tools or multi-panel flows: prefer `builtin-ppt-live`. It defines local tokens for spacing, typography, z-index, motion, and panel widths, and handles light/dark overrides explicitly.
- Workspace/devtool apps: refer to `builtin-harmony-dev` and `builtin-remotion-live`. They split actions, state, and views into modules, and organize UI around fact refresh, preview, selection, and sending context.
- Placeholder or minimal package surfaces can use `builtin-deep-research` as a basic reference, but do not treat its `--ds-*` usage as the default theme strategy for new Product Apps.
diff --git a/package.json b/package.json
index d1dceaa1..a0d2b820 100644
--- a/package.json
+++ b/package.json
@@ -21,9 +21,10 @@
"prebuild:web": "pnpm run copy-assets --silent && pnpm run generate-all --silent",
"check:i18n": "node scripts/check-i18n.mjs",
"check:brand": "node scripts/check-brand-residue.mjs",
+ "check:architecture": "node scripts/check-architecture.mjs",
"check:design-system": "node scripts/check-design-system.mjs",
"check:web:fast": "pnpm run check:brand && pnpm run check:i18n && pnpm --dir src/web-ui exec tsc --noEmit --pretty false",
- "check:web": "pnpm run check:brand && pnpm run check:i18n && pnpm run check:design-system && pnpm --dir src/web-ui run type-check",
+ "check:web": "pnpm run check:brand && pnpm run check:i18n && pnpm run check:architecture && pnpm run check:design-system && pnpm --dir src/web-ui run type-check",
"type-check:web": "pnpm run check:web",
"build": "pnpm run build:web",
"verify:monaco-assets": "node scripts/verify-monaco-assets.cjs",
@@ -88,7 +89,6 @@
},
"packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b",
"dependencies": {
- "pnpm": "^10.32.1",
"pptxgenjs": "^4.0.1"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5c4d09e0..0b018ea8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,9 +8,6 @@ importers:
.:
dependencies:
- pnpm:
- specifier: ^10.32.1
- version: 10.32.1
pptxgenjs:
specifier: ^4.0.1
version: 4.0.1
@@ -4475,11 +4472,6 @@ packages:
placeholder-loading@0.7.0:
resolution: {integrity: sha512-C9M9dra5nZF/qfFF0trA3dZTQ2j6qxxoAnqWcyF+bKWhoTQIiCYIptj9BTTuihdQjm9M/vIbbYtTEvvT+8etEg==}
- pnpm@10.32.1:
- resolution: {integrity: sha512-pwaTjw6JrBRWtlY+q07fHR+vM2jRGR/FxZeQ6W3JGORFarLmfWE94QQ9LoyB+HMD5rQNT/7KnfFe8a1Wc0jyvg==}
- engines: {node: '>=18.12'}
- hasBin: true
-
pointer-tracker@2.5.3:
resolution: {integrity: sha512-LiJUeIbzk4dXq678YeyrZ++mdY17q4n/2sBHfU9wIuvmSzdiPgMvmvWN2g8mY4J7YwYOIrqrZUWP/MfFHVwYtg==}
@@ -10373,8 +10365,6 @@ snapshots:
placeholder-loading@0.7.0: {}
- pnpm@10.32.1: {}
-
pointer-tracker@2.5.3: {}
points-on-curve@0.2.0: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index d74d6fd7..6eb7b660 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -4,7 +4,6 @@ packages:
- "src/packages/*"
- "installer"
- "tests/e2e"
- - "videos/*"
allowBuilds:
'@parcel/watcher': true
edgedriver: true
diff --git a/scripts/architecture-baseline.json b/scripts/architecture-baseline.json
new file mode 100644
index 00000000..af6d61fb
--- /dev/null
+++ b/scripts/architecture-baseline.json
@@ -0,0 +1,112 @@
+{
+ "version": 1,
+ "generatedBy": "scripts/check-architecture.mjs --update-baseline",
+ "note": "Exact pre-existing frontend reverse dependencies. New entries fail the architecture gate.",
+ "allowedViolations": [
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/ChatInput.tsx|@/app/navigation/workspaceNavigation",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/ChatInput.tsx|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/composer/actions/composerActionResolver.test.ts|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/composer/actions/composerActionResolver.ts|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/composer/actions/providers/composerActionProviderTypes.ts|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/composer/actions/providers/composerActionProviderUtils.ts|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/composer/actions/providers/profileComposerActionProvider.ts|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/composer/ComposerSpreadsheetFocusRail.test.tsx|@/app/agentic-os/excel-live/excelLiveFocusStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/composer/ComposerSpreadsheetFocusRail.tsx|@/app/agentic-os/excel-live/excelLiveFocusStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/composer/hooks/useComposerBoostActions.ts|@/app/navigation/workspaceNavigation",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/composer/hooks/useComposerBoostActions.ts|@/app/navigation/workspaceSceneTypes",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/composer/hooks/useComposerSessionTarget.ts|@/app/components/panels/content-canvas/stores",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/composer/hooks/useComposerSessionTarget.ts|@/app/navigation/workspaceSurfaceStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/composer/model/composerCommandRegistry.test.ts|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/composer/model/composerSessionProfile.test.ts|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/composer/model/composerSessionProfile.ts|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/composer/voice/useComposerVoiceInput.ts|@/app/navigation/workspaceNavigation",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/composer/voice/useComposerVoiceInput.ts|@/app/scenes/settings/settingsStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/CurrentSessionTitle.tsx|@/app/navigation/workspaceSurfaceStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/FlowChatSessionSurface.tsx|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/modern/AgenticOSFlowChatContainer.tsx|@/app/navigation/navigationController",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx|@/app/auxiliary-surface",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/modern/FlowChatSelectionAddButton.tsx|@/app/navigation/workspaceSurfaceStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/modern/FlowChatSidecarActions.tsx|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/modern/StandardFlowChatContainer.tsx|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/modern/StandardFlowChatContainer.tsx|@/app/stores/sessionModeStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts|@/app/navigation/navigationController",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/modern/useSessionSidecarActions.ts|@/app/components/panels/content-canvas/stores",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/modern/useSessionSidecarActions.ts|@/app/scenes/apps/product-app-runtime/productAppRuntimeStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/modern/useSessionSidecarActions.ts|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx|@/app/components/panels/content-canvas/stores/canvasStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/WelcomePanel.tsx|@/app/scenes/apps/app-builder/AppBuilderGlyph",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/components/WelcomePanel.tsx|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/hooks/useActiveSessionState.ts|@/app/navigation/workspaceSurfaceStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/hooks/useAgenticOsTimeline.ts|@/app/navigation/workspaceSurfaceStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/hooks/useMessageSender.ts|@/app/agentic-os/excel-live/excelLiveFocusStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/boundAgentSessionService.ts|@/app/navigation/workspaceNavigation",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/boundAgentSessionService.ts|@/app/navigation/workspaceSurfaceTypes",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/BtwThreadService.ts|@/app/navigation/workspaceSurfaceStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/childSessionPanels.ts|@/app/auxiliary-surface",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/childSessionPanels.ts|@/app/components/panels/base/types",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/childSessionPanels.ts|@/app/components/panels/content-canvas/stores",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/childSessionPanels.ts|@/app/components/panels/content-canvas/types",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/childSessionPanels.ts|@/app/navigation/navigationController",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/childSessionPanels.ts|@/app/navigation/workspaceSurfaceTypes",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/childSessionPanels.ts|@/app/services/AppManager",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts|@/app/agentic-os/work/data/workStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts|@/app/navigation/workspaceSurfaceStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts|@/app/navigation/workspaceSurfaceStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts|@/app/navigation/navigationController",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts|@/app/navigation/workspaceSurfaceStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEffectRegistry.test.ts|@/app/agentic-os/work/data/workStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEffectRegistry.ts|@/app/agentic-os/work/data/workStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts|@/app/navigation/workspaceSurfaceStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/FlowChatManager.ts|@/app/navigation/navigationController",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/FlowChatManager.ts|@/app/navigation/workspaceSurfaceStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/FlowChatManager.ts|@/app/session-profiles",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/openAgenticOsSession.ts|@/app/navigation/navigationController",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/services/storeSync.ts|@/app/navigation/workspaceSurfaceStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/store/modernFlowChatStore.ts|@/app/navigation/workspaceSurfaceStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/tool-cards/appBuilderToolScope.ts|@/app/agentic-os/work/data/workStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/tool-cards/ComponentAuthoringToolDisplay.tsx|@/app/auxiliary-surface",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/tool-cards/CreateProductAppToolDisplay.tsx|@/app/auxiliary-surface",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/tool-cards/FileOperationPlanToolCard.tsx|@/app/navigation/workspaceNavigation",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/tool-cards/SettingsChangePreviewCard.tsx|@/app/scenes/settings/settingsStore",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx|@/app/auxiliary-surface",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/tool-cards/ToolManifestSync.test.ts|@/app/scenes/apps/product-app-runtime/productAppRuntimeToolCardManifests",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/tool-cards/WorkToolCard.tsx|@/app/agentic-os/work/domain/workTypes",
+ "flow-chat-to-shell|src/web-ui/src/flow_chat/tool-cards/WorkToolCard.tsx|@/app/agentic-os/work/navigation/openWork",
+ "shared-to-feature|src/web-ui/src/shared/services/FileTabManager.ts|@/app/auxiliary-surface",
+ "shared-to-feature|src/web-ui/src/shared/services/FileTabManager.ts|@/app/components/panels/content-canvas/openCanvasItem",
+ "shared-to-feature|src/web-ui/src/shared/services/FileTabManager.ts|@/app/components/panels/content-canvas/types",
+ "shared-to-feature|src/web-ui/src/shared/services/ide-control/api.ts|@/app/auxiliary-surface",
+ "shared-to-feature|src/web-ui/src/shared/services/ide-control/api.ts|@/app/components/panels/base/types",
+ "shared-to-feature|src/web-ui/src/shared/services/ide-control/api.ts|@/app/navigation/workspaceNavigation",
+ "shared-to-feature|src/web-ui/src/shared/services/ide-control/api.ts|@/app/scenes/settings/settingsStore",
+ "shared-to-feature|src/web-ui/src/shared/services/ide-control/api.ts|@/app/services/AppManager",
+ "shared-to-feature|src/web-ui/src/shared/services/ide-control/PanelController.ts|@/app/auxiliary-surface",
+ "shared-to-feature|src/web-ui/src/shared/services/ide-control/PanelController.ts|@/app/components/panels/content-canvas/openCanvasItem",
+ "shared-to-feature|src/web-ui/src/shared/services/ide-control/PanelController.ts|@/app/components/panels/content-canvas/stores",
+ "shared-to-feature|src/web-ui/src/shared/services/ide-control/PanelController.ts|@/app/components/panels/content-canvas/types",
+ "shared-to-feature|src/web-ui/src/shared/services/ide-control/types.ts|@/app/components/panels/base/types",
+ "shared-to-feature|src/web-ui/src/shared/services/openShellSessionTarget.ts|@/app/navigation/workspaceNavigation",
+ "shared-to-feature|src/web-ui/src/shared/services/openShellSessionTarget.ts|@/app/navigation/workspaceSurfaceStore",
+ "shared-to-feature|src/web-ui/src/shared/services/openShellSessionTarget.ts|@/app/stores/terminalSceneStore",
+ "shared-to-feature|src/web-ui/src/shared/services/sceneOpenTargetResolver.test.ts|@/app/navigation/workspaceSurfaceStore",
+ "shared-to-feature|src/web-ui/src/shared/services/sceneOpenTargetResolver.test.ts|@/app/navigation/workspaceSurfaceTypes",
+ "shared-to-feature|src/web-ui/src/shared/services/sceneOpenTargetResolver.ts|@/app/navigation/workspaceNavigation",
+ "shared-to-feature|src/web-ui/src/shared/services/sceneOpenTargetResolver.ts|@/app/navigation/workspaceSceneTypes",
+ "shared-to-feature|src/web-ui/src/shared/services/sceneOpenTargetResolver.ts|@/app/navigation/workspaceSurfaceStore",
+ "shared-to-feature|src/web-ui/src/shared/types/tab.ts|@/app/components/panels/content-canvas/types",
+ "shared-to-feature|src/web-ui/src/shared/utils/tabUtils.ts|@/app/auxiliary-surface",
+ "shared-to-feature|src/web-ui/src/shared/utils/tabUtils.ts|@/app/components/panels/content-canvas/openCanvasItem",
+ "shared-to-feature|src/web-ui/src/shared/utils/tabUtils.ts|@/app/components/panels/content-canvas/types",
+ "shared-to-feature|src/web-ui/src/shared/utils/tabUtils.ts|@/app/navigation/workspaceNavigation",
+ "tool-to-feature|src/web-ui/src/tools/editor/services/MonacoInitManager.ts|@/app/auxiliary-surface",
+ "tool-to-feature|src/web-ui/src/tools/generative-widget/widgetInteraction.ts|@/app/navigation/workspaceSurfaceStore",
+ "tool-to-feature|src/web-ui/src/tools/generative-widget/widgetInteraction.ts|@/flow_chat/services/FlowChatManager",
+ "tool-to-feature|src/web-ui/src/tools/generative-widget/widgetInteraction.ts|@/flow_chat/store/FlowChatStore",
+ "tool-to-feature|src/web-ui/src/tools/markdown/components/PlanViewer.tsx|@/flow_chat/services/FlowChatManager"
+ ]
+}
diff --git a/scripts/check-architecture.mjs b/scripts/check-architecture.mjs
new file mode 100644
index 00000000..ca00d7b6
--- /dev/null
+++ b/scripts/check-architecture.mjs
@@ -0,0 +1,206 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { execFileSync } from 'node:child_process';
+
+const repoRoot = process.cwd();
+const webRoot = path.join(repoRoot, 'src', 'web-ui', 'src');
+const baselinePath = path.join(repoRoot, 'scripts', 'architecture-baseline.json');
+const updateBaseline = process.argv.includes('--update-baseline');
+const sourceExtensions = new Set(['.ts', '.tsx']);
+
+function normalize(file) {
+ return path.resolve(file).replaceAll('\\', '/');
+}
+
+function repoPath(file) {
+ return path.relative(repoRoot, file).replaceAll(path.sep, '/');
+}
+
+function walk(dir, files = [], extensions = sourceExtensions) {
+ if (!fs.existsSync(dir)) return files;
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (!['node_modules', 'dist', 'dist-preview'].includes(entry.name)) walk(fullPath, files, extensions);
+ } else if (extensions.has(path.extname(entry.name))) {
+ files.push(fullPath);
+ }
+ }
+ return files;
+}
+
+function getLineNumber(content, index) {
+ return content.slice(0, index).split(/\r?\n/).length;
+}
+
+function importSpecifiers(content) {
+ const entries = [];
+ const patterns = [
+ /(?:^|[;\n])\s*(?:import|export)\s+(?:type\s+)?[\s\S]*?\s+from\s+['"]([^'"]+)['"]/g,
+ /(?:^|[;\n])\s*import\s+['"]([^'"]+)['"]/g,
+ /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
+ ];
+ for (const pattern of patterns) {
+ for (const match of content.matchAll(pattern)) {
+ entries.push({ specifier: match[1], index: match.index ?? 0 });
+ }
+ }
+ return entries;
+}
+
+function resolveWebImport(file, specifier) {
+ if (specifier.startsWith('@/')) return path.join(webRoot, specifier.slice(2));
+ if (specifier.startsWith('.')) return path.resolve(path.dirname(file), specifier);
+ return null;
+}
+
+function webRootName(file) {
+ if (!file) return null;
+ const relative = path.relative(webRoot, file);
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
+ return relative.split(path.sep)[0];
+}
+
+function classifyWebViolation(sourceRoot, targetRoot, sourcePath) {
+ if (sourceRoot === 'shared' && ['app', 'flow_chat', 'tools'].includes(targetRoot)) {
+ return 'shared-to-feature';
+ }
+ if (sourceRoot === 'infrastructure' && ['app', 'flow_chat', 'tools'].includes(targetRoot)) {
+ return 'infrastructure-to-feature';
+ }
+ if (sourceRoot === 'flow_chat' && targetRoot === 'app') {
+ return 'flow-chat-to-shell';
+ }
+ if (sourceRoot === 'tools' && ['app', 'flow_chat'].includes(targetRoot)) {
+ return 'tool-to-feature';
+ }
+ if (
+ sourceRoot === 'design-system'
+ && !sourcePath.includes('/design-system/preview/')
+ && ['app', 'flow_chat', 'tools', 'shared', 'infrastructure'].includes(targetRoot)
+ ) {
+ return 'design-system-to-product';
+ }
+ return null;
+}
+
+function collectWebViolations() {
+ const violations = [];
+ const scanRoots = ['app', 'flow_chat', 'tools', 'shared', 'infrastructure', 'design-system'];
+ for (const root of scanRoots) {
+ for (const file of walk(path.join(webRoot, root))) {
+ const content = fs.readFileSync(file, 'utf8');
+ for (const entry of importSpecifiers(content)) {
+ const target = resolveWebImport(file, entry.specifier);
+ const targetRoot = webRootName(target);
+ const sourcePath = normalize(file);
+ const rule = classifyWebViolation(root, targetRoot, sourcePath);
+ if (!rule) continue;
+ const filePath = repoPath(file);
+ violations.push({
+ key: `${rule}|${filePath}|${entry.specifier}`,
+ rule,
+ file: filePath,
+ line: getLineNumber(content, entry.index),
+ specifier: entry.specifier,
+ });
+ }
+ }
+ }
+ return [...new Map(violations.map((entry) => [entry.key, entry])).values()]
+ .sort((a, b) => a.key.localeCompare(b.key));
+}
+
+function collectRustViolations() {
+ const raw = execFileSync('cargo', ['metadata', '--no-deps', '--format-version', '1'], {
+ cwd: repoRoot,
+ encoding: 'utf8',
+ });
+ const metadata = JSON.parse(raw);
+ const packages = new Map(metadata.packages.map((pkg) => [pkg.name, pkg]));
+ const violations = [];
+ const core = packages.get('sparo-core');
+ const events = packages.get('sparo-events');
+ const relay = packages.get('sparo-relay');
+ const toolRuntime = packages.get('tool-runtime');
+
+ if (!core || !events || !relay || !toolRuntime) {
+ throw new Error('Missing one or more required architecture packages.');
+ }
+
+ for (const dependency of core.dependencies) {
+ const manifestPath = normalize(dependency.path ?? '');
+ if (manifestPath.includes('/src/apps/')) {
+ violations.push(`sparo-core must not depend on app package ${dependency.name}`);
+ }
+ if (dependency.name === 'sparo-transport' || dependency.name === 'tauri') {
+ violations.push(`sparo-core must not depend on host adapter ${dependency.name}`);
+ }
+ }
+
+ const localEventDependencies = events.dependencies.filter((dependency) => dependency.path);
+ for (const dependency of localEventDependencies) {
+ violations.push(`sparo-events must remain independent of local crate ${dependency.name}`);
+ }
+
+ const expectedRelayManifest = normalize(path.join(repoRoot, 'src', 'crates', 'relay', 'Cargo.toml'));
+ if (normalize(relay.manifest_path) !== expectedRelayManifest) {
+ violations.push('sparo-relay must live under src/crates/relay');
+ }
+
+ const expectedToolRuntimeManifest = normalize(path.join(repoRoot, 'src', 'crates', 'tool-runtime', 'Cargo.toml'));
+ if (normalize(toolRuntime.manifest_path) !== expectedToolRuntimeManifest) {
+ violations.push('tool-runtime must live under src/crates/tool-runtime');
+ }
+
+ const coreSource = path.join(repoRoot, 'src', 'crates', 'core', 'src');
+ for (const file of walk(coreSource, [], new Set(['.rs']))) {
+ if (/\btauri::/.test(fs.readFileSync(file, 'utf8'))) {
+ violations.push(`sparo-core source must not use Tauri types: ${repoPath(file)}`);
+ }
+ }
+
+ return violations;
+}
+
+function readBaseline() {
+ if (!fs.existsSync(baselinePath)) return [];
+ const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
+ if (!Array.isArray(parsed.allowedViolations)) {
+ throw new Error('Invalid architecture baseline: expected allowedViolations array.');
+ }
+ return parsed.allowedViolations;
+}
+
+const rustViolations = collectRustViolations();
+const webViolations = collectWebViolations();
+
+if (updateBaseline) {
+ const payload = {
+ version: 1,
+ generatedBy: 'scripts/check-architecture.mjs --update-baseline',
+ note: 'Exact pre-existing frontend reverse dependencies. New entries fail the architecture gate.',
+ allowedViolations: webViolations.map((entry) => entry.key),
+ };
+ fs.writeFileSync(baselinePath, `${JSON.stringify(payload, null, 2)}\n`);
+ console.log(`Architecture baseline updated with ${webViolations.length} frontend violations.`);
+}
+
+const baseline = new Set(readBaseline());
+const current = new Set(webViolations.map((entry) => entry.key));
+const newWebViolations = webViolations.filter((entry) => !baseline.has(entry.key));
+const staleBaseline = [...baseline].filter((key) => !current.has(key));
+
+if (rustViolations.length || newWebViolations.length || staleBaseline.length) {
+ console.error('Architecture check failed.');
+ for (const violation of rustViolations) console.error(`- [rust-boundary] ${violation}`);
+ for (const violation of newWebViolations) {
+ console.error(`- [${violation.rule}] ${violation.file}:${violation.line} imports ${violation.specifier}`);
+ }
+ for (const key of staleBaseline) {
+ console.error(`- [stale-baseline] Remove resolved entry: ${key}`);
+ }
+ process.exit(1);
+}
+
+console.log(`Architecture check passed (${webViolations.length} tracked frontend violations, 0 new).`);
diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs
index 6f0138bd..69419c97 100644
--- a/src/apps/cli/src/main.rs
+++ b/src/apps/cli/src/main.rs
@@ -5212,2861 +5212,4 @@ async fn handle_tool_action(action: ToolAction) -> Result<()> {
}
#[cfg(test)]
-mod tests {
- use super::*;
- use clap::CommandFactory;
-
- fn test_config_commit(
- status: sparo_core::service::config::ConfigCommitStatus,
- ) -> sparo_core::service::config::PublishedConfigCommit {
- sparo_core::service::config::ConfigCommit {
- commit_id: "cfg-commit-test".to_string(),
- revision: 42,
- status,
- scope: sparo_events::ConfigScope::user(),
- source: sparo_events::ConfigChangeSource {
- kind: sparo_events::ConfigChangeSourceKind::Cli,
- surface: Some("cli".to_string()),
- request_id: Some("request-test".to_string()),
- },
- changes: Vec::new(),
- apply_receipts: Vec::new(),
- affected_sections: Vec::new(),
- restart_required: Vec::new(),
- undo_token: None,
- committed_at: chrono::Utc::now(),
- }
- .published()
- }
-
- #[test]
- fn config_redaction_masks_nested_secret_fields() {
- let mut value = serde_json::json!({
- "ai": {
- "models": [
- {
- "name": "demo",
- "api_key": "sk-secret",
- "custom_headers": {
- "Authorization": "Bearer token"
- }
- }
- ],
- "proxy": {
- "password": "proxy-secret"
- }
- }
- });
-
- redact_config_value(&mut value);
-
- assert_eq!(value["ai"]["models"][0]["api_key"], "
");
- assert_eq!(
- value["ai"]["models"][0]["custom_headers"]["Authorization"],
- ""
- );
- assert_eq!(value["ai"]["proxy"]["password"], "");
- assert_eq!(value["ai"]["models"][0]["name"], "demo");
- }
-
- #[test]
- fn should_emit_json_error_detects_global_json_flag() {
- assert!(should_emit_json_error([
- "tasks".to_string(),
- "show".to_string(),
- "last".to_string(),
- "--json".to_string(),
- ]));
- assert!(should_emit_json_error([
- "health".to_string(),
- "--json=true".to_string(),
- ]));
- assert!(!should_emit_json_error([
- "tasks".to_string(),
- "show".to_string(),
- "last".to_string(),
- ]));
- assert!(!should_emit_json_error([
- "exec".to_string(),
- "--".to_string(),
- "--json".to_string(),
- ]));
- assert!(should_emit_json_error([
- "config".to_string(),
- "get".to_string(),
- "ai".to_string(),
- ]));
- assert!(!should_emit_json_error([
- "config".to_string(),
- "validate".to_string(),
- ]));
- assert!(should_emit_json_error([
- "config".to_string(),
- "validate".to_string(),
- "--json".to_string(),
- ]));
- assert!(!should_emit_json_error([
- "config".to_string(),
- "health".to_string(),
- ]));
- assert!(should_emit_json_error([
- "config".to_string(),
- "health".to_string(),
- "--json".to_string(),
- ]));
- assert!(!should_emit_json_error([
- "config".to_string(),
- "import".to_string(),
- "config-export.json".to_string(),
- ]));
- assert!(should_emit_json_error([
- "config".to_string(),
- "import".to_string(),
- "config-export.json".to_string(),
- "--json".to_string(),
- ]));
- assert!(!should_emit_json_error([
- "--verbose".to_string(),
- "config".to_string(),
- "prefs".to_string(),
- "get".to_string(),
- ]));
- assert!(should_emit_json_error([
- "--verbose".to_string(),
- "config".to_string(),
- "prefs".to_string(),
- "get".to_string(),
- "--json".to_string(),
- ]));
- assert!(!should_emit_json_error([
- "tool".to_string(),
- "schema".to_string(),
- "read_file".to_string(),
- ]));
- assert!(should_emit_json_error([
- "tool".to_string(),
- "schema".to_string(),
- "read_file".to_string(),
- "--json".to_string(),
- ]));
- assert!(should_emit_json_error([
- "tool".to_string(),
- "list".to_string(),
- "--json".to_string(),
- ]));
- assert!(should_emit_json_error([
- "tool".to_string(),
- "list".to_string(),
- "--json=false".to_string(),
- ]));
- assert!(should_emit_json_error([
- "agents".to_string(),
- "list".to_string(),
- "--json".to_string(),
- ]));
- assert!(!should_emit_json_error([
- "config".to_string(),
- "prefs".to_string(),
- "get".to_string(),
- "behavior.default_agent".to_string(),
- ]));
- assert!(should_emit_json_error([
- "config".to_string(),
- "prefs".to_string(),
- "get".to_string(),
- "behavior.default_agent".to_string(),
- "--json".to_string(),
- ]));
- }
-
- #[test]
- fn cli_parse_errors_can_be_structured_when_json_was_requested() {
- let args = ["sparo", "health", "--json", "--bogus"];
- let equals_args = ["sparo", "health", "--json=true"];
-
- assert!(should_emit_json_error(
- args.iter().skip(1).map(|arg| arg.to_string())
- ));
- assert!(cli_parse_error_message(&args)
- .unwrap()
- .contains("unexpected argument '--bogus'"));
- assert!(should_emit_json_error(
- equals_args.iter().skip(1).map(|arg| arg.to_string())
- ));
- assert!(cli_parse_error_message(&equals_args)
- .unwrap()
- .contains("unexpected value 'true'"));
- assert!(cli_parse_error_message(&["sparo", "--help"]).is_none());
- }
-
- #[test]
- fn command_requests_json_tracks_structured_output_commands() {
- assert!(command_requests_json(&Some(Commands::Batch {
- tasks: None,
- example: None,
- json: true,
- timeout_secs: 600,
- continue_on_error: false,
- })));
- assert!(command_requests_json(&Some(Commands::Health {
- json: true
- })));
- assert!(command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Get {
- setting_id: "core.ai.models".to_string(),
- },
- })));
- assert!(command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Export,
- })));
- assert!(command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Show { json: true },
- })));
- assert!(command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Catalog {
- query: None,
- json: true,
- },
- })));
- assert!(command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Set {
- setting_id: "core.ai.default_models.primary".to_string(),
- value: "demo".to_string(),
- json: true,
- yes: false,
- },
- })));
- assert!(command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Reset {
- setting_id: "core.ai.default_models.primary".to_string(),
- json: true,
- yes: false,
- },
- })));
- assert!(command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Import {
- file: "config-export.json".to_string(),
- json: true,
- yes: false,
- },
- })));
- assert!(command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Validate { json: true },
- })));
- assert!(command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Health { json: true },
- })));
- assert!(!command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Prefs {
- action: PrefsAction::Get {
- path: None,
- json: false,
- },
- },
- })));
- assert!(command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Prefs {
- action: PrefsAction::Get {
- path: None,
- json: true,
- },
- },
- })));
- assert!(!command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Prefs {
- action: PrefsAction::Set {
- path: "ui.show_tips".to_string(),
- value: "false".to_string(),
- json: false,
- },
- },
- })));
- assert!(command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Prefs {
- action: PrefsAction::Set {
- path: "ui.show_tips".to_string(),
- value: "false".to_string(),
- json: true,
- },
- },
- })));
- assert!(command_requests_json(&Some(Commands::Tool {
- action: ToolAction::List { json: true },
- })));
- assert!(!command_requests_json(&Some(Commands::Tool {
- action: ToolAction::Schema {
- name: "read_file".to_string(),
- workspace: None,
- json: false,
- },
- })));
- assert!(command_requests_json(&Some(Commands::Tool {
- action: ToolAction::Schema {
- name: "read_file".to_string(),
- workspace: None,
- json: true,
- },
- })));
- assert!(command_requests_json(&Some(Commands::Tool {
- action: ToolAction::Run {
- name: "read_file".to_string(),
- params: None,
- params_file: None,
- workspace: None,
- json: true,
- },
- })));
- assert!(command_requests_json(&Some(Commands::Agents {
- action: AgentsAction::List,
- json: true,
- })));
- assert!(!command_requests_json(&Some(Commands::Chat {
- agent: None,
- workspace: None,
- })));
- assert!(!command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Show { json: false },
- })));
- assert!(!command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Set {
- setting_id: "core.ai.default_models.primary".to_string(),
- value: "demo".to_string(),
- json: false,
- yes: false,
- },
- })));
- assert!(!command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Validate { json: false },
- })));
- assert!(!command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Import {
- file: "config-export.json".to_string(),
- json: false,
- yes: false,
- },
- })));
- assert!(!command_requests_json(&Some(Commands::Config {
- action: ConfigAction::Health { json: false },
- })));
- assert!(!command_requests_json(&Some(Commands::Tool {
- action: ToolAction::List { json: false },
- })));
- assert!(!command_requests_json(&Some(Commands::Tool {
- action: ToolAction::Run {
- name: "read_file".to_string(),
- params: None,
- params_file: None,
- workspace: None,
- json: false,
- },
- })));
- assert!(!command_requests_json(&Some(Commands::Batch {
- tasks: None,
- example: None,
- json: false,
- timeout_secs: 600,
- continue_on_error: false,
- })));
- assert!(!command_requests_json(&Some(Commands::Agents {
- action: AgentsAction::List,
- json: false,
- })));
- }
-
- #[test]
- fn pre_config_dispatch_is_limited_to_readonly_discovery_commands() {
- assert!(can_dispatch_before_cli_config(&Some(
- Commands::Workspaces {
- action: WorkspacesAction::List,
- json: true,
- }
- )));
- assert!(can_dispatch_before_cli_config(&Some(Commands::Tool {
- action: ToolAction::List { json: true },
- })));
- assert!(!can_dispatch_before_cli_config(&Some(Commands::Apps {
- action: AppsAction::Show {
- id: "cursor-bridge".to_string(),
- },
- json: true,
- })));
- assert!(!can_dispatch_before_cli_config(&Some(Commands::Memory {
- action: MemoryAction::List,
- workspace: None,
- json: true,
- })));
- assert!(!can_dispatch_before_cli_config(&Some(Commands::Tasks {
- action: TasksAction::Show {
- id: "last".to_string(),
- },
- workspace: None,
- json: true,
- })));
- assert!(can_use_default_config_silently(&Some(Commands::Apps {
- action: AppsAction::Show {
- id: "cursor-bridge".to_string(),
- },
- json: false,
- })));
- assert!(can_use_default_config_silently(&Some(Commands::Sessions {
- domain: CliSessionDomain::OsAgent,
- action: SessionAction::Export {
- id: "last".to_string(),
- output: None,
- format: SessionExportFormat::Markdown,
- },
- workspace: None,
- json: false,
- })));
- assert!(can_use_default_config_silently(&Some(Commands::Tasks {
- action: TasksAction::Export {
- id: "last".to_string(),
- output: None,
- format: SessionExportFormat::Markdown,
- },
- workspace: None,
- json: false,
- })));
- assert!(can_use_default_config_silently(&Some(Commands::Agents {
- action: AgentsAction::List,
- json: false,
- })));
- assert!(!can_use_default_config_silently(&Some(
- Commands::Sessions {
- domain: CliSessionDomain::OsAgent,
- action: SessionAction::Delete {
- id: "session-1".to_string(),
- },
- workspace: None,
- json: false,
- }
- )));
- assert!(!can_dispatch_before_cli_config(&Some(
- Commands::Workspaces {
- action: WorkspacesAction::Use {
- id: "global".to_string(),
- },
- json: true,
- }
- )));
- assert!(!can_dispatch_before_cli_config(&Some(Commands::Apps {
- action: AppsAction::Open {
- id: "cursor-bridge".to_string(),
- },
- json: true,
- })));
- assert!(!can_dispatch_before_cli_config(&Some(Commands::Tasks {
- action: TasksAction::Resume {
- id: "last".to_string(),
- message: None,
- },
- workspace: None,
- json: false,
- })));
- }
-
- #[test]
- fn invalid_cli_config_is_required_for_tui_and_prefs_only() {
- assert!(requires_valid_cli_config(&None, true));
- assert!(requires_valid_cli_config(
- &Some(Commands::Chat {
- agent: None,
- workspace: None,
- }),
- true
- ));
- assert!(requires_valid_cli_config(
- &Some(Commands::Config {
- action: ConfigAction::Prefs {
- action: PrefsAction::Get {
- path: None,
- json: true,
- },
- },
- }),
- false
- ));
- assert!(!requires_valid_cli_config(
- &Some(Commands::Agents {
- action: AgentsAction::List,
- json: false,
- }),
- false
- ));
- assert!(!requires_valid_cli_config(
- &Some(Commands::Sessions {
- domain: CliSessionDomain::OsAgent,
- action: SessionAction::List,
- workspace: None,
- json: false,
- }),
- false
- ));
- }
-
- #[test]
- fn directory_health_distinguishes_missing_dirs_and_files() {
- let temp_root =
- std::env::temp_dir().join(format!("sparo-cli-health-test-{}", uuid::Uuid::new_v4()));
- std::fs::create_dir_all(&temp_root).expect("create temp health root");
- let existing_dir = temp_root.join("existing");
- std::fs::create_dir_all(&existing_dir).expect("create temp health dir");
- let existing_file = temp_root.join("workspaces");
- std::fs::write(&existing_file, "not a directory").expect("create temp health file");
-
- let dir_health = directory_health(&existing_dir);
- assert!(dir_health.exists);
- assert!(dir_health.is_dir);
- assert!(dir_health.readable);
- assert_eq!(dir_health.kind, "directory");
- assert_eq!(dir_health.status, "ok");
- assert!(dir_health.error.is_none());
- assert!(dir_health.hint.is_none());
-
- let missing_health = directory_health(&temp_root.join("missing"));
- assert!(!missing_health.exists);
- assert!(!missing_health.is_dir);
- assert!(!missing_health.readable);
- assert_eq!(missing_health.kind, "directory");
- assert_eq!(missing_health.status, "missing");
- assert!(missing_health.error.is_none());
- assert_eq!(
- missing_health.hint.as_deref(),
- Some("Directory has not been created yet; Sparo will create it when needed.")
- );
-
- let file_health = directory_health(&existing_file);
- assert!(file_health.exists);
- assert!(!file_health.is_dir);
- assert!(!file_health.readable);
- assert_eq!(file_health.kind, "directory");
- assert_eq!(file_health.status, "not_directory");
- assert_eq!(
- file_health.error.as_deref(),
- Some("Path exists but is not a directory")
- );
- assert_eq!(
- file_health.hint.as_deref(),
- Some("Move or rename the file at this path, then run the command again.")
- );
-
- std::fs::remove_file(existing_file).expect("remove temp health file");
- std::fs::remove_dir_all(temp_root).expect("remove temp health root");
- }
-
- #[test]
- fn file_health_distinguishes_files_missing_and_directories() {
- let temp_root = std::env::temp_dir().join(format!(
- "sparo-cli-file-health-test-{}",
- uuid::Uuid::new_v4()
- ));
- std::fs::create_dir_all(&temp_root).expect("create temp file health root");
- let config_file = temp_root.join("config.toml");
- std::fs::write(&config_file, "ui.theme = \"dark\"").expect("create temp config file");
- let directory_at_file_path = temp_root.join("config-dir.toml");
- std::fs::create_dir_all(&directory_at_file_path)
- .expect("create temp directory at file path");
-
- let file = file_health(&config_file);
- assert!(file.exists);
- assert!(!file.is_dir);
- assert!(file.readable);
- assert_eq!(file.kind, "file");
- assert_eq!(file.status, "ok");
- assert!(file.hint.is_none());
-
- let missing = file_health(&temp_root.join("missing.toml"));
- assert_eq!(missing.kind, "file");
- assert_eq!(missing.status, "missing");
- assert_eq!(
- missing.hint.as_deref(),
- Some("Directory has not been created yet; Sparo will create it when needed.")
- );
-
- let directory = file_health(&directory_at_file_path);
- assert_eq!(directory.kind, "file");
- assert_eq!(directory.status, "not_file");
- assert_eq!(
- directory.error.as_deref(),
- Some("Path exists but is not a file")
- );
- assert_eq!(
- directory.hint.as_deref(),
- Some("Move or rename the directory at this path, then run the command again.")
- );
-
- std::fs::remove_dir_all(temp_root).expect("remove temp file health root");
- }
-
- #[test]
- fn cli_config_file_health_validates_toml_content() {
- let temp_root = std::env::temp_dir().join(format!(
- "sparo-cli-config-health-test-{}",
- uuid::Uuid::new_v4()
- ));
- std::fs::create_dir_all(&temp_root).expect("create temp config health root");
- let valid_config = temp_root.join("valid.toml");
- std::fs::write(
- &valid_config,
- toml::to_string_pretty(&CliConfig::default()).expect("serialize default config"),
- )
- .expect("write valid config");
- let invalid_config = temp_root.join("invalid.toml");
- std::fs::write(&invalid_config, "ui.theme = [").expect("write invalid config");
- let shortcut_conflict_config = temp_root.join("shortcut-conflict.toml");
- let mut conflicting = CliConfig::default();
- conflicting.shortcuts.send_message = "Ctrl+C".to_string();
- std::fs::write(
- &shortcut_conflict_config,
- toml::to_string_pretty(&conflicting).expect("serialize conflicting config"),
- )
- .expect("write conflicting config");
- let invalid_shortcut_config = temp_root.join("invalid-shortcut.toml");
- let mut invalid_shortcut = CliConfig::default();
- invalid_shortcut.shortcuts.interrupt = "Ctrl+Delete".to_string();
- std::fs::write(
- &invalid_shortcut_config,
- toml::to_string_pretty(&invalid_shortcut).expect("serialize invalid shortcut config"),
- )
- .expect("write invalid shortcut config");
- let reserved_shortcut_config = temp_root.join("reserved-shortcut.toml");
- let mut reserved_shortcut = CliConfig::default();
- reserved_shortcut.shortcuts.send_message = "Esc".to_string();
- reserved_shortcut.shortcuts.menu = "Ctrl+B".to_string();
- std::fs::write(
- &reserved_shortcut_config,
- toml::to_string_pretty(&reserved_shortcut).expect("serialize reserved shortcut config"),
- )
- .expect("write reserved shortcut config");
- let invalid_theme_config = temp_root.join("invalid-theme.toml");
- let mut invalid_theme = CliConfig::default();
- invalid_theme.ui.theme = "neon".to_string();
- std::fs::write(
- &invalid_theme_config,
- toml::to_string_pretty(&invalid_theme).expect("serialize invalid theme config"),
- )
- .expect("write invalid theme config");
- let invalid_color_scheme_config = temp_root.join("invalid-color-scheme.toml");
- let mut invalid_color_scheme = CliConfig::default();
- invalid_color_scheme.ui.color_scheme = "infrared".to_string();
- std::fs::write(
- &invalid_color_scheme_config,
- toml::to_string_pretty(&invalid_color_scheme)
- .expect("serialize invalid color scheme config"),
- )
- .expect("write invalid color scheme config");
- let empty_default_agent_config = temp_root.join("empty-default-agent.toml");
- let mut empty_default_agent = CliConfig::default();
- empty_default_agent.behavior.default_agent = " ".to_string();
- std::fs::write(
- &empty_default_agent_config,
- toml::to_string_pretty(&empty_default_agent)
- .expect("serialize empty default agent config"),
- )
- .expect("write empty default agent config");
-
- let valid = cli_config_file_health(&valid_config);
- assert_eq!(valid.status, "ok");
- assert!(valid.error.is_none());
- assert!(valid.hint.is_none());
-
- let invalid = cli_config_file_health(&invalid_config);
- assert_eq!(invalid.status, "invalid_config");
- assert!(invalid.error.is_some());
- assert_eq!(
- invalid.hint.as_deref(),
- Some(
- "Fix the config file syntax or move the file aside so Sparo can recreate defaults."
- )
- );
-
- let shortcut_conflict = cli_config_file_health(&shortcut_conflict_config);
- assert_eq!(shortcut_conflict.status, "invalid_config");
- assert!(shortcut_conflict
- .error
- .as_deref()
- .is_some_and(|error| error.contains("shortcuts.send_message and shortcuts.interrupt")));
- assert_eq!(
- shortcut_conflict.hint.as_deref(),
- Some(
- "Fix the config file syntax or move the file aside so Sparo can recreate defaults."
- )
- );
-
- let invalid_shortcut = cli_config_file_health(&invalid_shortcut_config);
- assert_eq!(invalid_shortcut.status, "invalid_config");
- assert!(invalid_shortcut
- .error
- .as_deref()
- .is_some_and(|error| error.contains("shortcuts.interrupt has an invalid shortcut")));
-
- let reserved_shortcut = cli_config_file_health(&reserved_shortcut_config);
- assert_eq!(reserved_shortcut.status, "invalid_config");
- assert!(reserved_shortcut
- .error
- .as_deref()
- .is_some_and(|error| error.contains("shortcuts.send_message cannot use Esc")));
-
- let invalid_theme = cli_config_file_health(&invalid_theme_config);
- assert_eq!(invalid_theme.status, "invalid_config");
- assert!(invalid_theme
- .error
- .as_deref()
- .is_some_and(|error| error.contains("ui.theme must be one of")));
-
- let invalid_color_scheme = cli_config_file_health(&invalid_color_scheme_config);
- assert_eq!(invalid_color_scheme.status, "invalid_config");
- assert!(invalid_color_scheme
- .error
- .as_deref()
- .is_some_and(|error| error.contains("ui.color_scheme must be one of")));
-
- let empty_default_agent = cli_config_file_health(&empty_default_agent_config);
- assert_eq!(empty_default_agent.status, "invalid_config");
- assert!(empty_default_agent
- .error
- .as_deref()
- .is_some_and(|error| error.contains("behavior.default_agent cannot be empty")));
-
- std::fs::remove_dir_all(temp_root).expect("remove temp config health root");
- }
-
- #[test]
- fn global_config_file_health_validates_json_content() {
- let temp_root = std::env::temp_dir().join(format!(
- "sparo-global-config-health-test-{}",
- uuid::Uuid::new_v4()
- ));
- std::fs::create_dir_all(&temp_root).expect("create temp global config health root");
- let valid_config = temp_root.join("app.json");
- std::fs::write(
- &valid_config,
- serde_json::to_string_pretty(&sparo_core::service::config::GlobalConfig::default())
- .expect("serialize default global config"),
- )
- .expect("write valid global config");
- let invalid_config = temp_root.join("invalid-app.json");
- std::fs::write(&invalid_config, "{").expect("write invalid global config");
-
- let valid = global_config_file_health(&valid_config);
- assert_eq!(valid.status, "ok");
- assert!(valid.error.is_none());
- assert!(valid.hint.is_none());
-
- let invalid = global_config_file_health(&invalid_config);
- assert_eq!(invalid.status, "invalid_config");
- assert!(invalid.error.is_some());
- assert_eq!(
- invalid.hint.as_deref(),
- Some(
- "Fix the config file syntax or move the file aside so Sparo can recreate defaults."
- )
- );
-
- std::fs::remove_dir_all(temp_root).expect("remove temp global config health root");
- }
-
- #[test]
- fn help_text_mentions_task_last_and_workspace_defaults() {
- let help = Cli::command().render_long_help().to_string();
- assert!(help.contains("Manage backend-tracked agent tasks"));
-
- let mut command = Cli::command();
- let exec_help = command
- .find_subcommand_mut("exec")
- .unwrap()
- .render_long_help()
- .to_string();
- assert!(exec_help.contains("workspace.default_path"));
-
- let tasks_help = command
- .find_subcommand_mut("tasks")
- .unwrap()
- .render_long_help()
- .to_string();
- assert!(tasks_help.contains("session id, title, or \"last\""));
- assert!(tasks_help.contains("Show the most recent backend-tracked task"));
- }
-
- #[test]
- fn last_shortcuts_parse_to_recent_session_and_task_actions() {
- let cli =
- Cli::try_parse_from(["sparo", "sessions", "--domain", "os-agent", "last"]).unwrap();
- match cli.command {
- Some(Commands::Sessions {
- action: SessionAction::Last,
- ..
- }) => {}
- _ => panic!("expected sessions last shortcut"),
- }
-
- let cli = Cli::try_parse_from(["sparo", "tasks", "last"]).unwrap();
- match cli.command {
- Some(Commands::Tasks {
- action: TasksAction::Last,
- ..
- }) => {}
- _ => panic!("expected tasks last shortcut"),
- }
- }
-
- #[test]
- fn tool_run_help_mentions_loose_params() {
- let mut command = Cli::command();
- let tool_help = command
- .find_subcommand_mut("tool")
- .unwrap()
- .find_subcommand_mut("run")
- .unwrap()
- .render_long_help()
- .to_string();
- assert!(tool_help.contains("loose flat object"));
- }
-
- #[test]
- fn tool_list_help_mentions_json_output() {
- let mut command = Cli::command();
- let tool_help = command
- .find_subcommand_mut("tool")
- .unwrap()
- .find_subcommand_mut("list")
- .unwrap()
- .render_long_help()
- .to_string();
- assert!(tool_help.contains("Output raw JSON"));
- }
-
- #[test]
- fn tool_schema_help_mentions_json_output() {
- let mut command = Cli::command();
- let tool_help = command
- .find_subcommand_mut("tool")
- .unwrap()
- .find_subcommand_mut("schema")
- .unwrap()
- .render_long_help()
- .to_string();
- assert!(tool_help.contains("Output raw JSON"));
- }
-
- #[test]
- fn tool_schema_human_lines_summarize_fields_and_actions() {
- let response = sparo_core::command::tool::ToolSchemaResponse {
- name: "LS".to_string(),
- input_schema: serde_json::json!({
- "type": "object",
- "required": ["path"],
- "properties": {
- "path": {
- "type": "string",
- "description": "The absolute path to the directory to list."
- },
- "limit": {
- "type": "number",
- "description": "The maximum number of entries to return."
- }
- }
- }),
- model_input_schema: serde_json::json!({
- "type": "object",
- "required": ["path"],
- "properties": {
- "path": { "type": "string" }
- }
- }),
- };
-
- let output =
- tool_schema_human_lines(&response, Some("D:\\workspace\\my project")).join("\n");
-
- assert!(output.contains("Tool Schema: LS"));
- assert!(output.contains("Workspace context: D:\\workspace\\my project"));
- assert!(output.contains("limit (optional, number) - The maximum number"));
- assert!(output.contains("path (required, string) - The absolute path"));
- assert!(output.contains("Model-facing schema: differs from runtime schema"));
- assert!(output.contains(
- "Run tool: sparo tool run LS --workspace \"D:\\workspace\\my project\" --params-file "
- ));
- assert!(output.contains(
- "Machine output: sparo tool schema LS --workspace \"D:\\workspace\\my project\" --json"
- ));
- }
-
- #[test]
- fn tool_list_human_lines_include_next_actions() {
- let lines = tool_list_human_lines(&[sparo_core::command::tool::ToolInfo {
- name: "search_files".to_string(),
- user_facing_name: "Search Files".to_string(),
- description: "Search workspace files\nwith extra details".to_string(),
- readonly: true,
- enabled: true,
- supports_streaming: false,
- }]);
-
- assert!(lines.contains(&"Available Tools (total 1, enabled 1, readonly 1)".to_string()));
- assert!(lines.contains(&"search_files [readonly]".to_string()));
- assert!(lines.contains(&" Search workspace files".to_string()));
- assert!(
- lines.contains(&" Inspect schema: sparo tool schema search_files --json".to_string())
- );
- assert!(lines.contains(
- &" Run tool: sparo tool run search_files --params '{\"key\":\"value\"}'".to_string()
- ));
- assert!(
- lines.contains(&" Workspace scope: add --workspace to schema/run".to_string())
- );
- assert!(lines.contains(&" Machine output: sparo tool list --json".to_string()));
- }
-
- #[test]
- fn tool_list_human_lines_empty_state_still_guides_next_step() {
- let lines = tool_list_human_lines(&[]);
-
- assert!(lines.contains(&"Available Tools (total 0, enabled 0, readonly 0)".to_string()));
- assert!(lines.contains(&"No tools available.".to_string()));
- assert!(lines
- .contains(&" Inspect schema: sparo tool schema \"\" --json".to_string()));
- }
-
- #[test]
- fn tool_run_human_lines_surface_content_and_next_actions() {
- let response = sparo_core::command::tool::ExecuteToolResponse {
- tool_name: "read_file".to_string(),
- results: Vec::new(),
- display_results: vec![serde_json::json!({
- "content": "line one\nline two",
- "assistant": {
- "path": "src/main.rs",
- "truncated": false
- }
- })],
- };
-
- let output = tool_run_human_lines(&response, Some("D:\\workspace\\my project")).join("\n");
-
- assert!(output.contains("Tool: read_file"));
- assert!(output.contains("Content:\n line one\n line two"));
- assert!(output.contains("Assistant context:\n {"));
- assert!(output.contains("\"path\": \"src/main.rs\""));
- assert!(output.contains(
- "Inspect schema: sparo tool schema read_file --workspace \"D:\\workspace\\my project\" --json"
- ));
- assert!(output.contains(
- "Rerun with JSON: sparo tool run read_file --workspace \"D:\\workspace\\my project\" --params-file --json"
- ));
- }
-
- #[test]
- fn tool_run_human_lines_explain_empty_results() {
- let response = sparo_core::command::tool::ExecuteToolResponse {
- tool_name: "noop".to_string(),
- results: Vec::new(),
- display_results: Vec::new(),
- };
-
- let output = tool_run_human_lines(&response, None).join("\n");
-
- assert!(output.contains("No display results returned."));
- assert!(output.contains("Inspect schema: sparo tool schema noop --json"));
- assert!(output.contains("Rerun with JSON: sparo tool run noop --params-file --json"));
- }
-
- #[test]
- fn config_prefs_get_help_mentions_json_output() {
- let mut command = Cli::command();
- let prefs_get_help = command
- .find_subcommand_mut("config")
- .unwrap()
- .find_subcommand_mut("prefs")
- .unwrap()
- .find_subcommand_mut("get")
- .unwrap()
- .render_long_help()
- .to_string();
- assert!(prefs_get_help.contains("Output raw JSON"));
-
- let mut command = Cli::command();
- let prefs_set_help = command
- .find_subcommand_mut("config")
- .unwrap()
- .find_subcommand_mut("prefs")
- .unwrap()
- .find_subcommand_mut("set")
- .unwrap()
- .render_long_help()
- .to_string();
- assert!(prefs_set_help.contains("behavior.confirm_dangerous"));
- assert!(prefs_set_help.contains("shortcuts.send_message"));
- }
-
- #[test]
- fn structured_config_subcommands_accept_json_flag() {
- let mut command = Cli::command();
- for subcommand in [
- "get", "set", "reset", "export", "import", "validate", "reload", "health",
- ] {
- let help = command
- .find_subcommand_mut("config")
- .unwrap()
- .find_subcommand_mut(subcommand)
- .unwrap()
- .render_long_help()
- .to_string();
- assert!(
- help.contains("Output raw JSON"),
- "missing --json help for config {}",
- subcommand
- );
- }
- }
-
- #[test]
- fn health_help_mentions_json_output() {
- let mut command = Cli::command();
- let help = command
- .find_subcommand_mut("health")
- .unwrap()
- .render_long_help()
- .to_string();
- assert!(help.contains("Output in JSON format"));
- }
-
- #[test]
- fn agents_help_mentions_live_registry_and_json_output() {
- let mut command = Cli::command();
- let help = command
- .find_subcommand_mut("agents")
- .unwrap()
- .render_long_help()
- .to_string();
- assert!(help.contains("Inspect available agents"));
- assert!(help.contains("Output in JSON format"));
- assert!(help.contains("List available built-in, app-backed, and custom agents"));
- }
-
- #[test]
- fn batch_help_mentions_example_output() {
- let mut command = Cli::command();
- let help = command
- .find_subcommand_mut("batch")
- .unwrap()
- .render_long_help()
- .to_string();
- assert!(help.contains("Print an example batch task file and exit"));
- assert!(help.contains("--tasks "));
- }
-
- #[test]
- fn json_already_emitted_marker_is_suppressed() {
- let error = anyhow::anyhow!(JSON_OUTPUT_ALREADY_EMITTED);
- assert_eq!(error.to_string(), JSON_OUTPUT_ALREADY_EMITTED);
- let error = anyhow::anyhow!(CLI_OUTPUT_ALREADY_EMITTED);
- assert_eq!(error.to_string(), CLI_OUTPUT_ALREADY_EMITTED);
- }
-
- #[test]
- fn cli_health_success_reads_script_contract_flag() {
- assert!(cli_health_success(&serde_json::json!({ "success": true })));
- assert!(!cli_health_success(
- &serde_json::json!({ "success": false })
- ));
- assert!(!cli_health_success(&serde_json::json!({})));
- }
-
- #[test]
- fn command_modes_choose_blocking_confirmation_only_for_interactive_surfaces() {
- assert!(exec_skip_tool_confirmation(false));
- assert!(!exec_skip_tool_confirmation(true));
- }
-
- #[test]
- fn default_agent_pref_fills_missing_cli_agent() {
- let mut config = CliConfig::default();
- config.behavior.default_agent = "bitfun-debug".to_string();
-
- assert_eq!(effective_cli_agent(&config, None), "bitfun-debug");
- assert_eq!(effective_cli_agent(&config, Some("")), "bitfun-debug");
- assert_eq!(
- effective_cli_agent(&config, Some("bitfun-plan")),
- "bitfun-plan"
- );
- }
-
- #[test]
- fn cli_default_agent_matches_core_registry_default() {
- let config = CliConfig::default();
- let registry = sparo_core::agentic::agents::get_agent_registry();
- let registry_default = registry.default_agent_type();
-
- assert_eq!(config.behavior.default_agent, registry_default);
- assert_eq!(registry_default, "Runno");
- }
-
- #[test]
- fn cli_health_includes_app_storage_checks() {
- let health = cli_health_value().expect("build cli health value");
- let checks = health["checks"]
- .as_object()
- .expect("health checks should be an object");
-
- for key in [
- "apps",
- "components",
- "product_app_runtime_hosts",
- "agent_components",
- "bridge_components",
- ] {
- assert!(checks.contains_key(key), "missing health check: {key}");
- }
- }
-
- #[test]
- fn cli_health_includes_memory_storage_checks() {
- let health = cli_health_value().expect("build cli health value");
- let checks = health["checks"]
- .as_object()
- .expect("health checks should be an object");
-
- for key in ["agentic_os_memory", "workspace_memory"] {
- assert!(checks.contains_key(key), "missing health check: {key}");
- }
- }
-
- #[test]
- fn cli_health_includes_session_storage_checks() {
- let health = cli_health_value().expect("build cli health value");
- let checks = health["checks"]
- .as_object()
- .expect("health checks should be an object");
-
- for key in [
- "sessions",
- "os_agent_sessions",
- "global_sessions",
- "workspace_sessions",
- "works",
- "runs",
- "app_data",
- "services",
- ] {
- assert!(checks.contains_key(key), "missing health check: {key}");
- }
- }
-
- #[test]
- fn health_summary_counts_problem_checks() {
- let summary = health_summary(&serde_json::json!({
- "app_root": { "status": "ok" },
- "logs": {
- "kind": "directory",
- "status": "missing",
- "path": "C:\\sparo\\logs",
- "hint": "Created when needed"
- },
- "workspaces": {
- "kind": "directory",
- "status": "inaccessible",
- "path": "C:\\sparo\\workspaces",
- "hint": "Check permissions"
- },
- "skills": {
- "kind": "directory",
- "status": "not_directory",
- "path": "C:\\sparo\\skills"
- }
- }));
-
- assert_eq!(summary["total"], 4);
- assert_eq!(summary["ok"], 1);
- assert_eq!(summary["missing"], 1);
- assert_eq!(
- summary["missing_checks"].as_array().unwrap(),
- &vec![serde_json::Value::String("logs".to_string())]
- );
- assert_eq!(summary["missing_details"][0]["name"], "logs");
- assert_eq!(summary["missing_details"][0]["kind"], "directory");
- assert_eq!(summary["missing_details"][0]["status"], "missing");
- assert_eq!(summary["missing_details"][0]["path"], "C:\\sparo\\logs");
- assert_eq!(summary["missing_details"][0]["hint"], "Created when needed");
- assert_eq!(summary["problems"], 2);
- assert_eq!(
- summary["problem_checks"].as_array().unwrap(),
- &vec![
- serde_json::Value::String("skills".to_string()),
- serde_json::Value::String("workspaces".to_string())
- ]
- );
- assert_eq!(summary["problem_details"][0]["name"], "skills");
- assert_eq!(summary["problem_details"][0]["kind"], "directory");
- assert_eq!(summary["problem_details"][0]["status"], "not_directory");
- assert_eq!(summary["problem_details"][0]["path"], "C:\\sparo\\skills");
- assert_eq!(summary["problem_details"][1]["name"], "workspaces");
- assert_eq!(summary["problem_details"][1]["hint"], "Check permissions");
- }
-
- #[test]
- fn health_next_steps_deduplicates_problem_hints() {
- let health = serde_json::json!({
- "checks": {
- "ok": { "status": "ok", "hint": "No action" },
- "missing": { "status": "missing", "hint": "Created when needed" },
- "apps": { "status": "inaccessible", "hint": "Check permissions" },
- "sessions": { "status": "inaccessible", "hint": "Check permissions" },
- "config": { "status": "invalid_config", "hint": "Fix config syntax" }
- }
- });
-
- assert_eq!(
- health_next_steps(&health),
- vec![
- "Check permissions".to_string(),
- "Fix config syntax".to_string()
- ]
- );
- }
-
- #[test]
- fn healthy_health_next_steps_keep_success_output_actionable() {
- assert_eq!(
- healthy_health_next_steps(),
- vec![
- "Start interactive chat: sparo chat".to_string(),
- "Inspect CLI preferences: sparo config prefs get".to_string(),
- "Inspect shared config: sparo config show".to_string(),
- "Machine output: sparo health --json".to_string(),
- ]
- );
- }
-
- #[test]
- fn cli_error_hint_points_runtime_directory_failures_to_health() {
- let error = anyhow::anyhow!("initialize_global_config: Failed to create directory");
- assert_eq!(
- cli_error_hint(&error),
- Some("Run `sparo health` to diagnose Sparo CLI data directory access.")
- );
- assert_eq!(cli_error_kind(&error), "runtime_directory_error");
-
- let unrelated = anyhow::anyhow!("Tool not found: ReadFile");
- assert_eq!(
- cli_error_hint(&unrelated),
- Some("Run `sparo tool list` to see registered core tools.")
- );
- assert_eq!(cli_error_kind(&unrelated), "execution_error");
- }
-
- #[test]
- fn cli_error_hints_keep_panel_empty_states_actionable() {
- let no_tasks = anyhow::anyhow!("No backend-tracked agent tasks found");
- assert_eq!(cli_error_kind(&no_tasks), "execution_error");
- assert_eq!(
- cli_error_hint(&no_tasks),
- Some(
- "Start work with `sparo chat`, or run `sparo sessions list` to inspect saved conversations."
- )
- );
-
- let missing_memory = anyhow::anyhow!("Memory file not found: project:notes.md");
- assert_eq!(cli_error_kind(&missing_memory), "execution_error");
- assert_eq!(
- cli_error_hint(&missing_memory),
- Some("Run `sparo memory list` to see available global and project memory files.")
- );
-
- let missing_app = anyhow::anyhow!("Intelligent App not found: files");
- assert_eq!(cli_error_kind(&missing_app), "execution_error");
- assert_eq!(
- cli_error_hint(&missing_app),
- Some(
- "Run `sparo apps list` to see Intelligent Apps in the effective activation scope."
- )
- );
-
- let cli_launch = anyhow::anyhow!(
- "CLI Intelligent App launch is unavailable for 'Dashboard' (dashboard)"
- );
- assert_eq!(cli_error_kind(&cli_launch), "execution_error");
- assert_eq!(
- cli_error_hint(&cli_launch),
- Some("Open Sparo Desktop Apps Center and launch the selected Intelligent App into a Work; the CLI never falls back to opening package files.")
- );
-
- let no_sessions = anyhow::anyhow!("No history sessions");
- assert_eq!(cli_error_kind(&no_sessions), "execution_error");
- assert_eq!(
- cli_error_hint(&no_sessions),
- Some(
- "Start a session with `sparo chat`, or run `sparo exec \"\"` for a one-shot task."
- )
- );
-
- let session_resume_json =
- anyhow::anyhow!("sessions resume is interactive and does not support --json");
- assert_eq!(cli_error_kind(&session_resume_json), "execution_error");
- assert_eq!(
- cli_error_hint(&session_resume_json),
- Some(
- "Run `sparo sessions resume ` without `--json`, or use `sparo sessions show --json` for scriptable inspection."
- )
- );
-
- let task_resume_json =
- anyhow::anyhow!("tasks resume is interactive and does not support --json");
- assert_eq!(cli_error_kind(&task_resume_json), "execution_error");
- assert_eq!(
- cli_error_hint(&task_resume_json),
- Some(
- "Run `sparo tasks resume ` without `--json`, or use `sparo tasks show --json` for scriptable inspection."
- )
- );
-
- let task_without_session = anyhow::anyhow!("Task has no persisted session id: review");
- assert_eq!(cli_error_kind(&task_without_session), "execution_error");
- assert_eq!(
- cli_error_hint(&task_without_session),
- Some(
- "Use `sparo tasks resume ` to continue the task in the TUI; task export requires a persisted session transcript."
- )
- );
-
- let missing_batch = anyhow::anyhow!("Failed to read batch task file: missing.json");
- assert_eq!(cli_error_kind(&missing_batch), "execution_error");
- assert_eq!(
- cli_error_hint(&missing_batch),
- Some(
- "Run `sparo batch --example json` or `sparo batch --example toml` to generate a starter task file."
- )
- );
-
- let invalid_batch = anyhow::anyhow!("Invalid JSON batch task file: tasks.json");
- assert_eq!(cli_error_kind(&invalid_batch), "execution_error");
- assert_eq!(
- cli_error_hint(&invalid_batch),
- Some(
- "Compare the file with `sparo batch --example json` or `sparo batch --example toml`, then fix the task syntax."
- )
- );
-
- let invalid_tool_params = anyhow::anyhow!("Invalid parameters for tool Bash");
- assert_eq!(cli_error_kind(&invalid_tool_params), "execution_error");
- assert_eq!(
- cli_error_hint(&invalid_tool_params),
- Some(
- "Use `sparo tool schema --json` to inspect parameters, or pass complex input with `--params-file `."
- )
- );
-
- let misplaced_workspace = anyhow::anyhow!(
- "error: unexpected argument '--workspace' found\n\n tip: 'show --workspace' exists"
- );
- assert_eq!(cli_error_kind(&misplaced_workspace), "cli_parse_error");
- assert_eq!(
- cli_error_hint(&misplaced_workspace),
- Some(
- "Place `--workspace ` after the subcommand that accepts it, for example `sparo tool run --workspace ` or `sparo memory show --workspace `."
- )
- );
- }
-
- #[test]
- fn cli_error_kind_classifies_parse_errors() {
- let error = anyhow::anyhow!("error: unexpected argument '--json' found");
-
- assert_eq!(cli_error_kind(&error), "cli_parse_error");
- }
-
- #[test]
- fn cli_error_chain_preserves_context_and_source() {
- let source = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
- let error = anyhow::Error::new(source).context("Failed to access CLI config file");
-
- let causes = cli_error_chain(&error);
-
- assert_eq!(causes[0], "Failed to access CLI config file");
- assert_eq!(causes[1], "denied");
- }
-
- #[test]
- fn read_tool_params_rejects_multiple_sources() {
- let result = read_tool_params(Some("{}".to_string()), Some("params.json".to_string()));
- assert!(result.is_err());
- }
-
- #[test]
- fn read_tool_params_accepts_inline_json() {
- let value = read_tool_params(Some(r#"{"path":"."}"#.to_string()), None).unwrap();
- assert_eq!(value["path"], ".");
- }
-
- #[test]
- fn read_tool_params_accepts_loose_flat_object() {
- let value = read_tool_params(
- Some("{path:src/apps/cli/src,depth:1,readonly:true}".to_string()),
- None,
- )
- .unwrap();
- assert_eq!(value["path"], "src/apps/cli/src");
- assert_eq!(value["depth"], 1);
- assert_eq!(value["readonly"], true);
- }
-
- #[test]
- fn read_tool_params_accepts_utf8_bom() {
- let value = read_tool_params(Some("\u{feff}{\"path\":\".\"}".to_string()), None).unwrap();
- assert_eq!(value["path"], ".");
- }
-
- #[test]
- fn parse_batch_task_file_accepts_utf8_bom() {
- let tasks = parse_batch_task_file("\u{feff}[]", "tasks.json").unwrap();
- assert!(tasks.is_empty());
- }
-
- #[test]
- fn batch_examples_parse_as_task_files() {
- let json_tasks =
- parse_batch_task_file(render_batch_example(BatchExampleFormat::Json), "tasks.json")
- .unwrap();
- assert_eq!(json_tasks.len(), 2);
- assert_eq!(json_tasks[0].message(), "Summarize the current workspace");
- assert_eq!(json_tasks[1].agent("OSAgent"), "bitfun-coder");
-
- let toml_tasks =
- parse_batch_task_file(render_batch_example(BatchExampleFormat::Toml), "tasks.toml")
- .unwrap();
- assert_eq!(toml_tasks.len(), 2);
- assert_eq!(toml_tasks[1].workspace().as_deref(), Some("."));
- }
-
- #[test]
- fn empty_batch_hint_points_to_starter_example() {
- assert!(empty_batch_hint().contains("sparo batch --example json"));
- assert!(empty_batch_hint().contains("at least one task"));
- }
-
- #[test]
- fn empty_history_hints_point_to_health_when_storage_may_be_missing() {
- assert!(empty_task_hint().contains("sparo chat"));
- assert!(empty_task_hint().contains("sparo health"));
- assert!(empty_session_hint().contains("sparo exec"));
- assert!(empty_session_hint().contains("sparo health"));
- }
-
- #[test]
- fn write_export_file_creates_parent_directories() {
- let temp_root =
- std::env::temp_dir().join(format!("sparo-cli-export-test-{}", uuid::Uuid::new_v4()));
- let output = temp_root.join("nested").join("session.md");
-
- write_export_file(output.to_str().unwrap(), "exported").unwrap();
-
- assert_eq!(std::fs::read_to_string(&output).unwrap(), "exported");
- std::fs::remove_dir_all(temp_root).unwrap();
- }
-
- #[test]
- fn export_file_errors_include_writable_output_hint() {
- let temp_root = std::env::temp_dir().join(format!(
- "sparo-cli-export-dir-test-{}",
- uuid::Uuid::new_v4()
- ));
- std::fs::create_dir_all(&temp_root).unwrap();
-
- let error = write_export_file(temp_root.to_str().unwrap(), "exported").unwrap_err();
- assert_eq!(
- cli_error_hint(&error),
- Some("Pass a writable file path with `--output `; create or fix permissions on the parent directory if needed.")
- );
-
- std::fs::remove_dir_all(temp_root).unwrap();
- }
-
- #[test]
- fn batch_summary_serializes_script_contract() {
- let results = vec![
- BatchTaskResult {
- index: 1,
- agent: "OSAgent".to_string(),
- message: "one".to_string(),
- session_id: Some("session-1".to_string()),
- success: true,
- duration_ms: 12,
- error_kind: None,
- error: None,
- },
- BatchTaskResult {
- index: 2,
- agent: "bitfun-debug".to_string(),
- message: "two".to_string(),
- session_id: None,
- success: false,
- duration_ms: 34,
- error_kind: Some("execution_error".to_string()),
- error: Some("failed".to_string()),
- },
- ];
- let value = serde_json::to_value(batch_summary("tasks.json", &results)).unwrap();
-
- assert_eq!(value["tasks_file"], "tasks.json");
- assert_eq!(value["passed"], 1);
- assert_eq!(value["failed"], 1);
- assert_eq!(value["total"], 2);
- assert_eq!(value["results"][0]["session_id"], "session-1");
- assert_eq!(value["results"][1]["session_id"], serde_json::Value::Null);
- assert_eq!(value["results"][1]["error_kind"], "execution_error");
- assert_eq!(value["results"][1]["error"], "failed");
- }
-
- #[test]
- fn batch_human_summary_points_to_resume_command_when_session_exists() {
- let result = BatchTaskResult {
- index: 1,
- agent: "OSAgent".to_string(),
- message: "one\nmore".to_string(),
- session_id: Some("session-1".to_string()),
- success: true,
- duration_ms: 12,
- error_kind: None,
- error: None,
- };
-
- assert_eq!(
- batch_result_summary_line(&result),
- "1. ok | OSAgent | 12 ms | one"
- );
- assert_eq!(
- batch_result_session_line(result.session_id.as_deref()).as_deref(),
- Some(" session: session-1 (resume with `sparo sessions resume session-1`)")
- );
- assert!(batch_result_session_line(None).is_none());
- }
-
- #[test]
- fn batch_summary_human_lines_include_rerun_and_machine_output_actions() {
- let results = vec![BatchTaskResult {
- index: 1,
- agent: "OSAgent".to_string(),
- message: "one\nmore".to_string(),
- session_id: Some("session-1".to_string()),
- success: true,
- duration_ms: 12,
- error_kind: None,
- error: None,
- }];
- let summary = batch_summary("tasks file.json", &results);
- let output = batch_summary_human_lines(&summary).join("\n");
-
- assert!(output.contains("=== Batch Summary ==="));
- assert!(output.contains("1 passed, 0 failed, 1 total"));
- assert!(output.contains("Resume latest session: sparo sessions resume session-1"));
- assert!(output.contains("Inspect sessions: sparo sessions list"));
- assert!(output.contains("Rerun batch: sparo batch --tasks \"tasks file.json\""));
- assert!(output.contains("Machine output: sparo batch --tasks \"tasks file.json\" --json"));
- }
-
- #[test]
- fn batch_summary_human_lines_include_failure_rerun_guidance() {
- let results = vec![BatchTaskResult {
- index: 1,
- agent: "bitfun-debug".to_string(),
- message: "review".to_string(),
- session_id: None,
- success: false,
- duration_ms: 34,
- error_kind: Some("execution_error".to_string()),
- error: Some("failed".to_string()),
- }];
- let summary = batch_summary("tasks.json", &results);
- let output = batch_summary_human_lines(&summary).join("\n");
-
- assert!(output.contains("0 passed, 1 failed, 1 total"));
- assert!(output.contains("error kind: execution_error"));
- assert!(output.contains("error: failed"));
- assert!(output
- .contains("Rerun after fixes: sparo batch --tasks tasks.json --continue-on-error"));
- assert!(!output.contains("Resume latest session:"));
- }
-
- #[test]
- fn batch_summary_human_lines_keep_empty_batch_actionable() {
- let results = Vec::new();
- let summary = batch_summary("empty.json", &results);
- let output = batch_summary_human_lines(&summary).join("\n");
-
- assert!(output.contains("0 passed, 0 failed, 0 total"));
- assert!(output.contains("Generate starter file: sparo batch --example json"));
- assert!(output.contains("Machine output: sparo batch --tasks empty.json --json"));
- }
-
- #[test]
- fn empty_batch_human_lines_include_summary_and_next_actions() {
- let output = empty_batch_human_lines("empty file.json").join("\n");
-
- assert!(output.contains("No batch tasks found in empty file.json"));
- assert!(output.contains("Generate starter file: sparo batch --example json"));
- assert!(output.contains("=== Batch Summary ==="));
- assert!(output.contains("Inspect sessions: sparo sessions list"));
- assert!(output.contains("Machine output: sparo batch --tasks \"empty file.json\" --json"));
- }
-
- #[test]
- fn sessions_list_human_lines_include_next_actions_for_latest_session() {
- let session = sample_session_metadata();
- let output =
- sessions_list_human_lines(&[session], Some("D:\\workspace\\my project")).join("\n");
-
- assert!(output.contains("History sessions (total 1)"));
- assert!(output.contains("1. Review CLI sessions (ID: session-1)"));
- assert!(output.contains("Agent: bitfun-debug | Turns: 3 | Messages: 6"));
- assert!(output.contains("Next actions:"));
- assert!(output.contains(
- "Resume latest: sparo sessions --workspace \"D:\\workspace\\my project\" resume session-1"
- ));
- assert!(output.contains(
- "Show details: sparo sessions --workspace \"D:\\workspace\\my project\" show session-1"
- ));
- assert!(output.contains(
- "Export latest: sparo sessions --workspace \"D:\\workspace\\my project\" export session-1 --output session.md"
- ));
- assert!(output
- .contains("Open TUI history: sparo chat --workspace \"D:\\workspace\\my project\""));
- }
-
- #[test]
- fn sessions_list_human_lines_keep_empty_history_actionable() {
- let output = sessions_list_human_lines(&[], None).join("\n");
-
- assert!(output.contains("No history sessions"));
- assert!(output.contains("sparo chat"));
- assert!(output.contains("sparo exec"));
- }
-
- fn sample_session_metadata() -> sparo_core::service::session::SessionMetadata {
- sparo_core::service::session::SessionMetadata {
- domain: sparo_core::agentic::core::SessionDomain::Workspace {
- workspace_id: "ws_test".to_string(),
- },
- session_id: "session-1".to_string(),
- session_name: "Review CLI sessions".to_string(),
- agent_type: "bitfun-debug".to_string(),
- created_by: None,
- session_kind: sparo_core::agentic::core::SessionKind::Standard,
- model_name: "gpt-test".to_string(),
- created_at: 1_700_000_000_000,
- last_active_at: 1_700_000_100_000,
- turn_count: 3,
- message_count: 6,
- tool_call_count: 2,
- status: sparo_core::service::session::SessionStatus::Active,
- terminal_session_id: None,
- snapshot_session_id: None,
- tags: Vec::new(),
- custom_metadata: None,
- todos: None,
- workspace_path: Some("D:\\workspace\\my project".to_string()),
- workspace_hostname: None,
- }
- }
-
- #[test]
- fn session_human_detail_lines_include_next_actions() {
- let metadata = sample_session_metadata();
-
- let output = session_human_detail_lines(&metadata).join("\n");
-
- assert!(output.contains("Session Details"));
- assert!(output.contains("Title: Review CLI sessions"));
- assert!(output.contains("Statistics:"));
- assert!(output.contains(" Tool calls: 2"));
- assert!(output.contains("Next actions:"));
- assert!(output.contains(
- "Resume: sparo sessions --workspace \"D:\\workspace\\my project\" resume session-1"
- ));
- assert!(output.contains(
- "Export: sparo sessions --workspace \"D:\\workspace\\my project\" export session-1 --output session.md"
- ));
- }
-
- #[test]
- fn session_delete_human_lines_include_recovery_actions() {
- let output = session_delete_human_lines(
- "session 1",
- Some("D:\\workspace\\my project"),
- "Deleted session: session 1",
- )
- .join("\n");
-
- assert!(output.contains("Session Deleted"));
- assert!(output.contains("Status: Deleted session: session 1"));
- assert!(output.contains("Requested session: session 1"));
- assert!(output.contains("Workspace: D:\\workspace\\my project"));
- assert!(output.contains(
- "Inspect sessions: sparo sessions --workspace \"D:\\workspace\\my project\" list"
- ));
- assert!(output
- .contains("Start a new chat: sparo chat --workspace \"D:\\workspace\\my project\""));
- assert!(output.contains(
- "Machine output: sparo sessions --workspace \"D:\\workspace\\my project\" delete \"session 1\" --json"
- ));
- }
-
- #[test]
- fn session_export_human_lines_include_resume_and_machine_output() {
- let output = session_export_human_lines(
- "session 1",
- Some("D:\\workspace\\my project"),
- "exports\\session file.md",
- SessionExportFormat::Markdown,
- )
- .join("\n");
-
- assert!(output.contains("Session Exported"));
- assert!(output.contains("Session: session 1"));
- assert!(output.contains("Workspace: D:\\workspace\\my project"));
- assert!(output.contains("Output: exports\\session file.md"));
- assert!(output.contains("Format: markdown"));
- assert!(output.contains("Open file: \"exports\\session file.md\""));
- assert!(output.contains(
- "Resume session: sparo sessions --workspace \"D:\\workspace\\my project\" resume \"session 1\""
- ));
- assert!(output.contains(
- "Machine output: sparo sessions --workspace \"D:\\workspace\\my project\" export \"session 1\" --output \"exports\\session file.md\" --format markdown --json"
- ));
- }
-
- #[test]
- fn agent_summary_line_matches_cli_agents_table_contract() {
- let agent = sparo_core::agentic::agents::AgentInfo {
- id: "bitfun-debug".to_string(),
- name: "Debug".to_string(),
- description: "Diagnose failures".to_string(),
- is_readonly: false,
- tool_count: 3,
- default_tools: vec!["read_file".to_string()],
- enabled: true,
- subagent_source: None,
- path: None,
- model: None,
- app_kind: None,
- app_icon: None,
- app_category: None,
- app_path: None,
- };
-
- assert_eq!(
- agent_summary_line(&agent),
- "bitfun-debug | Debug | enabled | 3 tools | write"
- );
- }
-
- #[test]
- fn agents_human_lines_include_launch_and_default_actions() {
- let agents = vec![sparo_core::agentic::agents::AgentInfo {
- id: "debug agent".to_string(),
- name: "Debug Agent".to_string(),
- description: "Diagnose failures with detailed workspace inspection.".to_string(),
- is_readonly: false,
- tool_count: 3,
- default_tools: vec![
- "read_file".to_string(),
- "search".to_string(),
- "run_command".to_string(),
- "write_file".to_string(),
- "edit_file".to_string(),
- "diagnostics".to_string(),
- ],
- enabled: true,
- subagent_source: None,
- path: Some("D:\\agents\\debug".to_string()),
- model: None,
- app_kind: Some("Agent Component".to_string()),
- app_icon: None,
- app_category: None,
- app_path: None,
- }];
-
- let output = agents_human_lines(&agents).join("\n");
-
- assert!(output.contains("Available Agents (total 1)"));
- assert!(output.contains("debug agent | Debug Agent | enabled | 3 tools | write"));
- assert!(output
- .contains("tools: read_file, search, run_command, write_file, edit_file (+1 more)"));
- assert!(output.contains("Chat with agent: sparo chat --agent \"debug agent\""));
- assert!(output.contains("One-shot run: sparo exec --agent \"debug agent\" \"\""));
- assert!(output.contains(
- "Make default: sparo config prefs set behavior.default_agent \"debug agent\""
- ));
- assert!(output.contains("Machine output: sparo agents list --json"));
- }
-
- #[test]
- fn agents_human_lines_do_not_launch_disabled_only_registry() {
- let agents = vec![sparo_core::agentic::agents::AgentInfo {
- id: "disabled".to_string(),
- name: "Disabled".to_string(),
- description: "Currently disabled.".to_string(),
- is_readonly: true,
- tool_count: 0,
- default_tools: Vec::new(),
- enabled: false,
- subagent_source: None,
- path: None,
- model: None,
- app_kind: None,
- app_icon: None,
- app_category: None,
- app_path: None,
- }];
-
- let output = agents_human_lines(&agents).join("\n");
-
- assert!(output.contains("disabled | Disabled | disabled | 0 tools | readonly"));
- assert!(output.contains("Enable an agent in configuration before launching chat or exec."));
- assert!(!output.contains("sparo chat --agent disabled"));
- }
-
- #[test]
- fn compact_description_truncates_noisy_human_output() {
- let description = "Produces a comprehensive deep-research report on any subject using parallel sub-agent orchestration. Dispatches multiple research agents concurrently to investigate different chapters and competitors simultaneously, then synthesizes findings into a cohesive report.";
-
- let compact = compact_description(description).unwrap();
-
- assert!(compact.contains("Produces a comprehensive"));
- assert!(compact.ends_with("..."));
- assert!(!compact.contains("then synthesizes findings into a cohesive report"));
- assert_eq!(compact_description(" \n\t "), None);
- }
-
- #[test]
- fn compact_description_preserves_short_app_descriptions() {
- assert_eq!(
- compact_description("Open local Bridge Components from the CLI").as_deref(),
- Some("Open local Bridge Components from the CLI")
- );
- }
-
- #[test]
- fn batch_error_kind_classifies_timeouts() {
- assert_eq!(
- batch_error_kind("CLI exec timed out after 1 seconds"),
- "timeout"
- );
- assert_eq!(batch_error_kind("join cancelled"), "cancelled");
- assert_eq!(batch_error_kind("other failure"), "execution_error");
- }
-
- #[test]
- fn cli_prefs_get_and_set_known_paths() {
- let mut config = CliConfig::default();
-
- set_cli_pref(&mut config, "ui.theme", "light").unwrap();
- set_cli_pref(&mut config, "ui.show_tips", "off").unwrap();
- set_cli_pref(&mut config, "ui.color_scheme", "blue").unwrap();
- set_cli_pref(&mut config, "behavior.default_agent", "bitfun-debug").unwrap();
- set_cli_pref(&mut config, "behavior.confirm_dangerous", "false").unwrap();
- set_cli_pref(&mut config, "workspace.default_path", "D:\\workspace").unwrap();
- set_cli_pref(&mut config, "shortcuts.send_message", "ctrl+s").unwrap();
- set_cli_pref(&mut config, "shortcuts.interrupt", "Ctrl+X").unwrap();
- set_cli_pref(&mut config, "shortcuts.menu", "escape").unwrap();
-
- assert_eq!(
- cli_prefs_value(&config, Some("ui.theme")).unwrap(),
- serde_json::json!("light")
- );
- assert_eq!(
- cli_prefs_value(&config, Some("ui.show_tips")).unwrap(),
- serde_json::json!(false)
- );
- assert_eq!(
- cli_prefs_value(&config, Some("ui.color_scheme")).unwrap(),
- serde_json::json!("blue")
- );
- assert_eq!(
- cli_prefs_value(&config, Some("behavior.default_agent")).unwrap(),
- serde_json::json!("bitfun-debug")
- );
- assert_eq!(
- cli_prefs_value(&config, Some("behavior.confirm_dangerous")).unwrap(),
- serde_json::json!(false)
- );
- assert_eq!(
- cli_prefs_value(&config, Some("workspace.default_path")).unwrap(),
- serde_json::json!("D:\\workspace")
- );
- assert_eq!(
- cli_prefs_value(&config, Some("shortcuts.send_message")).unwrap(),
- serde_json::json!("Ctrl+S")
- );
- assert_eq!(
- cli_prefs_value(&config, Some("shortcuts.interrupt")).unwrap(),
- serde_json::json!("Ctrl+X")
- );
- assert_eq!(
- cli_prefs_value(&config, Some("shortcuts.menu")).unwrap(),
- serde_json::json!("Esc")
- );
- }
-
- #[test]
- fn cli_prefs_human_output_respects_json_flag_contract() {
- let mut config = CliConfig::default();
- config.behavior.default_agent = "bitfun-debug".to_string();
- config.workspace.default_path = "D:\\workspace".to_string();
-
- assert_eq!(
- cli_prefs_get_human_output(&config, Some("behavior.default_agent")).unwrap(),
- "behavior.default_agent = bitfun-debug"
- );
-
- let full = cli_prefs_get_human_output(&config, None).unwrap();
- assert!(full.contains("CLI Preferences"));
- assert!(full.contains("Default Agent: bitfun-debug"));
- assert!(full.contains("Default workspace: D:\\workspace"));
- assert!(full.contains("Exclude patterns: node_modules, .git, target, dist"));
- assert!(full.contains("CLI preference file: "));
- assert!(full.contains("Next actions:"));
- assert!(full.contains("Edit preferences: sparo config edit"));
- assert!(full.contains("Validate preferences: sparo health"));
- assert!(full.contains("Machine output: sparo config prefs get --json"));
-
- let set = cli_prefs_set_human_output(
- "ui.show_tips",
- &serde_json::json!(false),
- std::path::Path::new("C:\\Users\\example\\config.toml"),
- )
- .unwrap();
- assert!(set.contains("Set ui.show_tips = false"));
- assert!(set.contains("CLI preference file: C:\\Users\\example\\config.toml"));
- assert!(set.contains("Inspect CLI preferences: sparo config prefs get"));
- assert!(set.contains("Validate CLI preferences: sparo health"));
- assert!(set.contains("Machine output: sparo config prefs get ui.show_tips --json"));
- }
-
- #[test]
- fn shared_config_summary_keeps_default_config_show_scannable() {
- let value = serde_json::json!({
- "revision": 12,
- "catalogVersion": "sha256:catalog",
- "publishedSettingCount": 42,
- "models": {
- "configured": 2,
- "enabled": 1
- },
- "defaultModels": {
- "primary": "primary-model",
- "fast": "fast-model"
- },
- "agentModelMappings": 2,
- "appLanguage": "zh-CN"
- });
-
- let output = shared_config_summary_lines(&value).join("\n");
-
- assert!(output.contains("Shared Global Configuration Summary"));
- assert!(output.contains("Revision: 12"));
- assert!(output.contains("Catalog: sha256:catalog (42 settings)"));
- assert!(output.contains("Models: 2 configured, 1 enabled"));
- assert!(output.contains("Default models: primary=primary-model, fast=fast-model"));
- assert!(output.contains("Agent model mappings: 2"));
- assert!(output.contains("App language: zh-CN"));
- assert!(output.contains("sparo config export"));
- assert!(!output.contains("\"models\""));
- assert!(!output.contains("\"api_key\""));
- }
-
- #[test]
- fn config_validate_human_lines_summarize_status_and_next_actions() {
- let value = serde_json::json!({
- "valid": false,
- "errors": ["Missing primary model"],
- "warnings": ["Provider disabled"]
- });
-
- let output = config_validate_human_lines(&value).join("\n");
-
- assert!(output.contains("Shared Global Configuration Validation"));
- assert!(output.contains("Status: invalid"));
- assert!(output.contains("Errors: 1"));
- assert!(output.contains("Warnings: 1"));
- assert!(output.contains(" - Missing primary model"));
- assert!(output.contains("Edit config: sparo config edit"));
- assert!(output.contains("Inspect config: sparo config show"));
- assert!(output.contains("Run health: sparo config health"));
- assert!(output.contains("Machine output: sparo config validate --json"));
- }
-
- #[test]
- fn config_health_human_lines_summarize_health_and_next_actions() {
- let value = serde_json::json!({
- "healthy": false,
- "message": "Configuration system is unavailable",
- "config_directory": "C:\\Users\\example\\sparo_os\\config",
- "warnings": ["Failed to load configuration service"]
- });
-
- let output = config_health_human_lines(&value).join("\n");
-
- assert!(output.contains("Shared Global Configuration Health"));
- assert!(output.contains("Status: needs attention"));
- assert!(output.contains("Message: Configuration system is unavailable"));
- assert!(output.contains("Config directory: C:\\Users\\example\\sparo_os\\config"));
- assert!(output.contains(" - Failed to load configuration service"));
- assert!(output.contains("Run CLI health: sparo health"));
- assert!(output.contains("Validate config: sparo config validate"));
- assert!(output.contains("Machine output: sparo config health --json"));
- }
-
- #[test]
- fn config_edit_human_lines_include_editor_and_validation_commands() {
- let path = std::path::Path::new("C:\\Users\\example\\sparo_os\\cli.toml");
- let output = config_edit_human_lines(path).join("\n");
-
- assert!(output.contains("CLI Preference File"));
- assert!(output.contains("Path: C:\\Users\\example\\sparo_os\\cli.toml"));
- assert!(output.contains("code C:\\Users\\example\\sparo_os\\cli.toml"));
- #[cfg(target_os = "windows")]
- assert!(output.contains("notepad C:\\Users\\example\\sparo_os\\cli.toml"));
- assert!(output.contains("Validate CLI preferences: sparo health"));
- assert!(output.contains("Inspect CLI preferences: sparo config prefs get"));
- assert!(output.contains("Inspect shared config: sparo config show"));
- assert!(!output.contains("\\\"C:\\\\Users"));
- }
-
- #[test]
- fn config_set_human_lines_include_commit_and_next_actions() {
- let commit = test_config_commit(sparo_core::service::config::ConfigCommitStatus::Applying);
- let output = config_set_human_lines("core.ai.default_models.primary", &commit).join("\n");
-
- assert!(output.contains("Shared Global Configuration Updated"));
- assert!(output.contains("Status: applying"));
- assert!(output.contains("Commit ID: cfg-commit-test"));
- assert!(output.contains("Revision: 42"));
- assert!(output.contains("Setting ID: core.ai.default_models.primary"));
- assert!(!output.contains("gpt-demo"));
- assert!(!output.contains("Value:"));
- assert!(output.contains("Inspect value: sparo config get core.ai.default_models.primary"));
- assert!(output.contains("Validate config: sparo config validate"));
- assert!(output.contains("Use --json on future changes for machine-readable output"));
- }
-
- #[test]
- fn config_reset_human_lines_require_one_setting_id_and_include_commit() {
- let commit = test_config_commit(sparo_core::service::config::ConfigCommitStatus::Applied);
- let output = config_reset_human_lines("core.ai.default_models.primary", &commit).join("\n");
-
- assert!(output.contains("Shared Global Configuration Reset"));
- assert!(output.contains("Status: applied"));
- assert!(output.contains("Commit ID: cfg-commit-test"));
- assert!(output.contains("Revision: 42"));
- assert!(output.contains("Setting ID: core.ai.default_models.primary"));
- assert!(output.contains("Inspect value: sparo config get core.ai.default_models.primary"));
- assert!(output
- .contains("Machine output: sparo config reset core.ai.default_models.primary --json"));
- }
-
- #[test]
- fn config_reset_setting_id_is_required_by_clap() {
- let error = Cli::try_parse_from(["sparo", "config", "reset"])
- .err()
- .expect("reset without a setting ID must be rejected");
- assert_eq!(
- error.kind(),
- clap::error::ErrorKind::MissingRequiredArgument
- );
- }
-
- #[test]
- fn config_show_does_not_duplicate_single_setting_lookup() {
- let error = Cli::try_parse_from([
- "sparo",
- "config",
- "show",
- "--setting-id",
- "core.ai.default_models.primary",
- ])
- .err()
- .expect("single-setting lookup belongs to `config get`");
- assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument);
- }
-
- #[test]
- fn config_import_human_lines_report_only_successful_outcomes() {
- let output =
- config_import_human_lines("C:\\Users\\example\\config export.json", None).join("\n");
-
- assert!(output.contains("Shared Global Configuration Imported"));
- assert!(output.contains("Status: no changes"));
- assert!(output.contains("File: C:\\Users\\example\\config export.json"));
- assert!(output.contains("Validate config: sparo config validate"));
- assert!(output.contains(
- "Machine output: sparo config import \"C:\\Users\\example\\config export.json\" --json"
- ));
- }
-
- #[test]
- fn cli_presentation_preferences_summary_includes_operational_defaults() {
- let mut config = CliConfig::default();
- config.behavior.default_agent = "bitfun-debug".to_string();
- config.behavior.confirm_dangerous = false;
- config.workspace.default_path = "D:\\workspace\\project".to_string();
-
- let lines = cli_presentation_preference_lines(&config).join("\n");
-
- assert!(lines.contains("Default Agent: bitfun-debug"));
- assert!(lines.contains("Confirm dangerous tools: false"));
- assert!(lines.contains("Default workspace: D:\\workspace\\project"));
- assert!(lines.contains("Send shortcut: Ctrl+D"));
- }
-
- #[test]
- fn cli_config_path_line_uses_copyable_display_path() {
- let line = cli_config_path_line(std::path::Path::new(
- "C:\\Users\\example\\sparo_os\\config.toml",
- ));
-
- assert_eq!(
- line,
- " CLI preference file: C:\\Users\\example\\sparo_os\\config.toml"
- );
- assert!(!line.contains("\\\\"));
- assert!(!line.contains('"'));
- }
-
- #[test]
- fn cli_prefs_reject_unknown_paths_and_values() {
- let mut config = CliConfig::default();
-
- assert!(set_cli_pref(&mut config, "ui.theme", "blue").is_err());
- assert!(set_cli_pref(&mut config, "ui.color_scheme", "infrared").is_err());
- assert!(set_cli_pref(&mut config, "behavior.default_agent", " ").is_err());
- assert!(set_cli_pref(&mut config, "shortcuts.send_message", "Ctrl+Delete").is_err());
- assert!(set_cli_pref(&mut config, "shortcuts.interrupt", "Shift+X").is_err());
- assert!(set_cli_pref(&mut config, "shortcuts.send_message", "Esc").is_err());
- assert!(set_cli_pref(&mut config, "shortcuts.interrupt", "Enter").is_err());
- assert!(set_cli_pref(&mut config, "shortcuts.menu", "Enter").is_err());
- assert!(set_cli_pref(&mut config, "ui.unknown", "x").is_err());
- assert!(cli_prefs_value(&config, Some("ui.unknown")).is_err());
- }
-
- #[test]
- fn cli_prefs_reject_conflicting_shortcuts_without_mutating_config() {
- let mut config = CliConfig::default();
- let original_send = config.shortcuts.send_message.clone();
-
- let error = set_cli_pref(&mut config, "shortcuts.send_message", "Ctrl+C").unwrap_err();
-
- assert!(error
- .to_string()
- .contains("shortcuts.send_message and shortcuts.interrupt cannot both use Ctrl+C"));
- assert_eq!(config.shortcuts.send_message, original_send);
- assert_eq!(config.shortcuts.interrupt, "Ctrl+C");
- }
-
- #[test]
- fn effective_workspace_hint_prefers_explicit_then_cli_default() {
- let mut config = CliConfig::default();
- config.workspace.default_path = "D:\\workspace\\project".to_string();
-
- assert_eq!(
- effective_workspace_hint(&config, Some("D:\\explicit")).as_deref(),
- Some("D:\\explicit")
- );
- assert_eq!(
- effective_workspace_hint(&config, None).as_deref(),
- Some("D:\\workspace\\project")
- );
-
- config.workspace.default_path.clear();
- assert_eq!(effective_workspace_hint(&config, None), None);
-
- config.workspace.default_path = "global".to_string();
- assert!(effective_workspace_hint(&config, None).is_some());
- }
-
- #[test]
- fn effective_workspace_hint_normalizes_explicit_dot_and_global() {
- let config = CliConfig::default();
- let current = std::env::current_dir()
- .unwrap()
- .to_string_lossy()
- .to_string();
-
- assert_eq!(
- effective_workspace_hint(&config, Some(".")).as_deref(),
- Some(current.as_str())
- );
- assert_eq!(
- effective_workspace_hint(&config, Some(" . ")).as_deref(),
- Some(current.as_str())
- );
- assert!(effective_workspace_hint(&config, Some("global")).is_some());
- assert_eq!(
- effective_workspace_hint(&config, Some(" ")).as_deref(),
- Some(current.as_str())
- );
- }
-
- fn sample_intelligent_app_catalog() -> CliIntelligentAppCatalog {
- let scope = sparo_core::app_platform::AppActivationScope::System;
- let app = sparo_core::app_platform::AppRecord {
- app_id: "runno".to_string(),
- slot_id: "assistant".to_string(),
- display_name: "Runno".to_string(),
- description: Some("Built-in Intelligent App".to_string()),
- owner: sparo_core::app_platform::AppOwner::system(),
- derived_from: None,
- created_at_ms: 1,
- };
- let activation = sparo_core::app_platform::ActivationRecord {
- scope: scope.clone(),
- slot_id: app.slot_id.clone(),
- selected_app_id: app.app_id.clone(),
- active_release_id: "release_runno_1".to_string(),
- enabled: true,
- };
- CliIntelligentAppCatalog {
- slots: vec![sparo_core::app_platform::AppSlotProjection {
- slot_id: app.slot_id.clone(),
- display_name: app.display_name.clone(),
- activation: Some(activation),
- variants: vec![sparo_core::app_platform::AppVariantProjection {
- app,
- releases: Vec::new(),
- latest_release: None,
- upstream_base_release_id: None,
- upstream_latest_release_id: None,
- upstream_update_available: false,
- state: sparo_core::app_platform::AppVariantState::Active,
- }],
- }],
- drafts: Vec::new(),
- issues: Vec::new(),
- }
- }
-
- #[test]
- fn intelligent_app_details_match_app_id_or_name_case_insensitively() {
- let catalog = sample_intelligent_app_catalog();
-
- assert_eq!(
- intelligent_app_details(&catalog, "RUNNO")
- .unwrap()
- .app
- .app_id,
- "runno"
- );
- assert_eq!(
- intelligent_app_details(&catalog, "runno")
- .unwrap()
- .app
- .display_name,
- "Runno"
- );
- assert!(intelligent_app_details(&catalog, "missing").is_err());
- }
-
- #[test]
- fn app_human_detail_lines_report_activation_and_desktop_launch_boundary() {
- let catalog = sample_intelligent_app_catalog();
- let app = intelligent_app_details(&catalog, "runno").unwrap();
-
- let output = app_human_detail_lines(&app).join("\n");
-
- assert!(output.contains("Intelligent App Details"));
- assert!(output.contains("App ID: runno"));
- assert!(output.contains("Active release: release_runno_1"));
- assert!(output.contains("Inspect: sparo apps show runno"));
- assert!(output.contains("Launch: use Sparo Desktop Apps Center"));
- assert!(!output.contains("sparo apps open"));
- }
-
- #[test]
- fn apps_list_human_lines_report_slots_variants_and_activation() {
- let catalog = sample_intelligent_app_catalog();
- let output = apps_list_human_lines(&catalog).join("\n");
-
- assert!(output.contains("Intelligent Apps (1 slots, 1 variants)"));
- assert!(output.contains("Runno | slot assistant"));
- assert!(output.contains("activation: enabled | app runno | release release_runno_1"));
- assert!(output.contains("runno | active | owner system"));
- assert!(output.contains("Inspect latest: sparo apps show runno"));
- assert!(output.contains("Launch: use Sparo Desktop Apps Center"));
- }
-
- #[test]
- fn apps_list_human_lines_empty_state_guides_desktop_builder() {
- let catalog = CliIntelligentAppCatalog {
- slots: Vec::new(),
- drafts: Vec::new(),
- issues: Vec::new(),
- };
- let output = apps_list_human_lines(&catalog).join("\n");
-
- assert!(output.contains("No Intelligent Apps are available."));
- assert!(output.contains("Sparo Desktop Apps Center / App Builder"));
- assert!(output.contains("Machine output: sparo apps list --json"));
- }
-
- #[test]
- fn find_workspace_row_matches_label_path_or_global() {
- let workspaces = vec![
- sparo_core::command::agentic_os::AgenticOsWorkspaceRow {
- label: "global".to_string(),
- path: None,
- git: None,
- session_count: 0,
- },
- sparo_core::command::agentic_os::AgenticOsWorkspaceRow {
- label: "Project".to_string(),
- path: Some("D:\\workspace\\project".to_string()),
- git: Some("git main".to_string()),
- session_count: 2,
- },
- ];
-
- assert!(find_workspace_row(&workspaces, "GLOBAL")
- .unwrap()
- .path
- .is_none());
- assert_eq!(
- find_workspace_row(&workspaces, "project")
- .unwrap()
- .path
- .as_deref(),
- Some("D:\\workspace\\project")
- );
- assert_eq!(
- find_workspace_row(&workspaces, "D:/workspace/project")
- .unwrap()
- .label,
- "Project"
- );
- assert!(find_workspace_row(&workspaces, "missing").is_none());
- }
-
- #[test]
- fn resolve_workspace_row_accepts_existing_direct_path() {
- let temp_root =
- std::env::temp_dir().join(format!("sparo-cli-workspace-test-{}", uuid::Uuid::new_v4()));
- std::fs::create_dir_all(&temp_root).unwrap();
-
- let row = resolve_workspace_row(&[], temp_root.to_str().unwrap()).unwrap();
-
- assert_eq!(
- row.label,
- temp_root.file_name().unwrap().to_string_lossy().to_string()
- );
- let expected_path = display_workspace_path(&temp_root.canonicalize().unwrap());
- assert_eq!(row.path.as_deref(), Some(expected_path.as_str()));
- assert_eq!(row.session_count, 0);
-
- std::fs::remove_dir_all(temp_root).unwrap();
- }
-
- #[test]
- fn workspace_human_detail_lines_include_use_and_chat_actions() {
- let workspace = sparo_core::command::agentic_os::AgenticOsWorkspaceRow {
- label: "my project".to_string(),
- path: Some("D:\\workspace\\my project".to_string()),
- git: Some("git main".to_string()),
- session_count: 3,
- };
-
- let output = workspace_human_detail_lines(&workspace).join("\n");
-
- assert!(output.contains("Workspace Details"));
- assert!(output.contains("Path: D:\\workspace\\my project"));
- assert!(output.contains("Sessions: 3"));
- assert!(output.contains("Next actions:"));
- assert!(output.contains("Use: sparo workspaces use \"my project\""));
- assert!(output.contains("Chat: sparo chat --workspace \"D:\\workspace\\my project\""));
- }
-
- #[test]
- fn workspace_human_detail_lines_make_global_chat_explicit() {
- let workspace = sparo_core::command::agentic_os::AgenticOsWorkspaceRow {
- label: "global".to_string(),
- path: None,
- git: None,
- session_count: 1,
- };
-
- let output = workspace_human_detail_lines(&workspace).join("\n");
-
- assert!(output.contains("Path: Agentic OS global runtime"));
- assert!(output.contains("Use: sparo workspaces use global"));
- assert!(output.contains("Chat: sparo chat --workspace global"));
- }
-
- #[test]
- fn workspace_use_human_lines_include_preference_and_next_actions() {
- let workspace = sparo_core::command::agentic_os::AgenticOsWorkspaceRow {
- label: "my project".to_string(),
- path: Some("D:\\workspace\\my project".to_string()),
- git: Some("git main".to_string()),
- session_count: 3,
- };
-
- let output = workspace_use_human_lines(
- &workspace,
- "D:\\workspace\\my project",
- std::path::Path::new("C:\\Users\\example\\sparo_os\\config.toml"),
- )
- .join("\n");
-
- assert!(output.contains("CLI Default Workspace Updated"));
- assert!(output.contains("Workspace: my project"));
- assert!(output.contains("Path: D:\\workspace\\my project"));
- assert!(output.contains("CLI preference file: C:\\Users\\example\\sparo_os\\config.toml"));
- assert!(output.contains("Start chat: sparo chat"));
- assert!(output.contains("Inspect workspace: sparo workspaces show \"my project\""));
- assert!(
- output.contains("Inspect preference: sparo config prefs get workspace.default_path")
- );
- assert!(output.contains("Machine output: sparo workspaces use \"my project\" --json"));
- }
-
- #[test]
- fn workspaces_list_human_lines_include_next_actions_for_first_workspace() {
- let workspaces = vec![sparo_core::command::agentic_os::AgenticOsWorkspaceRow {
- label: "my project".to_string(),
- path: Some("D:\\workspace\\my project".to_string()),
- git: Some("git main".to_string()),
- session_count: 3,
- }];
-
- let output = workspaces_list_human_lines(&workspaces).join("\n");
-
- assert!(output.contains("Known Workspaces (total 1)"));
- assert!(output.contains("my project"));
- assert!(output.contains("path: D:\\workspace\\my project"));
- assert!(output.contains("Next actions:"));
- assert!(output.contains("Use latest: sparo workspaces use \"my project\""));
- assert!(output.contains("Show details: sparo workspaces show \"my project\""));
- assert!(output.contains("Chat here: sparo chat --workspace \"D:\\workspace\\my project\""));
- assert!(output.contains("Machine output: sparo workspaces list --json"));
- }
-
- #[test]
- fn workspaces_list_human_lines_make_global_chat_explicit() {
- let workspaces = vec![sparo_core::command::agentic_os::AgenticOsWorkspaceRow {
- label: "global".to_string(),
- path: None,
- git: None,
- session_count: 1,
- }];
-
- let output = workspaces_list_human_lines(&workspaces).join("\n");
-
- assert!(output.contains("path: Agentic OS global runtime"));
- assert!(output.contains("Use latest: sparo workspaces use global"));
- assert!(output.contains("Show details: sparo workspaces show global"));
- assert!(output.contains("Chat here: sparo chat --workspace global"));
- }
-
- #[test]
- fn workspaces_list_human_lines_empty_state_stays_scriptable() {
- let output = workspaces_list_human_lines(&[]).join("\n");
-
- assert!(output.contains("Known Workspaces (total 0)"));
- assert!(output.contains("Add a project by opening chat with --workspace ."));
- assert!(output.contains("Machine output: sparo workspaces list --json"));
- }
-
- #[test]
- fn resolve_workspace_row_rejects_missing_direct_path() {
- let missing = std::env::temp_dir().join(format!(
- "sparo-cli-workspace-missing-{}",
- uuid::Uuid::new_v4()
- ));
-
- assert!(resolve_workspace_row(&[], missing.to_str().unwrap()).is_none());
- }
-
- #[test]
- fn find_memory_row_matches_file_scope_or_path() {
- let memories = vec![sparo_core::command::agentic_os::AgenticOsMemoryRow {
- scope: "PROJECT".to_string(),
- file: "notes.md".to_string(),
- target: "D:\\workspace\\.sparo_os".to_string(),
- }];
-
- assert_eq!(
- find_memory_row(&memories, "NOTES.md").unwrap().scope,
- "PROJECT"
- );
- assert_eq!(
- find_memory_row(&memories, "project:notes.md").unwrap().file,
- "notes.md"
- );
- assert_eq!(
- find_memory_row(&memories, "D:/workspace/.sparo_os/notes.md")
- .unwrap()
- .scope,
- "PROJECT"
- );
- assert!(find_memory_row(&memories, "missing.md").is_none());
- }
-
- #[test]
- fn read_memory_content_reports_truncation_metadata() {
- let temp_root =
- std::env::temp_dir().join(format!("sparo-cli-memory-test-{}", uuid::Uuid::new_v4()));
- std::fs::create_dir_all(&temp_root).expect("create temp memory root");
- let file = temp_root.join("notes.md");
- std::fs::write(&file, "abcdef").expect("write temp memory file");
- let memory = sparo_core::command::agentic_os::AgenticOsMemoryRow {
- scope: "PROJECT".to_string(),
- file: "notes.md".to_string(),
- target: temp_root.to_string_lossy().to_string(),
- };
-
- let preview = read_memory_content(&memory, 3).expect("read truncated memory content");
-
- assert_eq!(preview.content, "abc");
- assert_eq!(preview.max_bytes, 3);
- assert_eq!(preview.bytes_read, 3);
- assert_eq!(preview.total_bytes, 6);
- assert!(preview.truncated);
-
- let value = memory_content_json(&memory, &preview);
- assert_eq!(value["content"], "abc");
- assert_eq!(value["max_bytes"], 3);
- assert_eq!(value["truncated_to_bytes"], 3);
- assert_eq!(value["bytes_read"], 3);
- assert_eq!(value["total_bytes"], 6);
- assert_eq!(value["truncated"], true);
-
- std::fs::remove_dir_all(temp_root).expect("remove temp memory root");
- }
-
- #[test]
- fn memory_human_detail_lines_include_next_actions_and_truncation() {
- let memory = sparo_core::command::agentic_os::AgenticOsMemoryRow {
- scope: "PROJECT".to_string(),
- file: "notes.md".to_string(),
- target: "D:\\workspace\\my project\\.sparo_os".to_string(),
- };
- let preview = MemoryContentPreview {
- content: "abc".to_string(),
- max_bytes: 3,
- bytes_read: 3,
- total_bytes: 6,
- truncated: true,
- };
-
- let output =
- memory_human_detail_lines(&memory, &preview, Some("D:\\workspace\\my project"))
- .join("\n");
-
- assert!(output.contains("Memory: PROJECT | notes.md"));
- assert!(output.contains("abc"));
- assert!(output.contains("[truncated: showing 3 of 6 bytes"));
- assert!(output.contains("Next actions:"));
- assert!(output.contains(
- "Show full file: sparo memory --workspace \"D:\\workspace\\my project\" show project:notes.md --max-bytes 6"
- ));
- assert!(output
- .contains("Discuss in chat: sparo chat --workspace \"D:\\workspace\\my project\""));
- }
-
- #[test]
- fn memory_human_detail_lines_omit_truncation_when_complete() {
- let memory = sparo_core::command::agentic_os::AgenticOsMemoryRow {
- scope: "GLOBAL".to_string(),
- file: "profile.md".to_string(),
- target: "D:\\sparo\\memory".to_string(),
- };
- let preview = MemoryContentPreview {
- content: "complete".to_string(),
- max_bytes: 1024,
- bytes_read: 8,
- total_bytes: 8,
- truncated: false,
- };
-
- let output = memory_human_detail_lines(&memory, &preview, None).join("\n");
-
- assert!(!output.contains("[truncated"));
- assert!(
- output.contains("Show full file: sparo memory show global:profile.md --max-bytes 8")
- );
- assert!(output.contains("Discuss in chat: sparo chat"));
- }
-
- #[test]
- fn memory_list_human_lines_include_show_and_chat_actions() {
- let memories = vec![sparo_core::command::agentic_os::AgenticOsMemoryRow {
- scope: "PROJECT".to_string(),
- file: "notes.md".to_string(),
- target: "C:\\SparoData\\workspaces\\ws_test\\memory".to_string(),
- }];
-
- let output =
- memory_list_human_lines(&memories, Some("D:\\workspace\\my project")).join("\n");
-
- assert!(output.contains("Memory Files (total 1)"));
- assert!(output.contains("PROJECT | notes.md"));
- assert!(output.contains("Next actions:"));
- assert!(output.contains(
- "Show latest: sparo memory --workspace \"D:\\workspace\\my project\" show project:notes.md"
- ));
- assert!(output
- .contains("Discuss in chat: sparo chat --workspace \"D:\\workspace\\my project\""));
- assert!(output.contains("Machine output: sparo memory list --json"));
- }
-
- #[test]
- fn memory_list_human_lines_keep_empty_snapshot_actionable() {
- let output = memory_list_human_lines(&[], None).join("\n");
-
- assert!(output.contains("No memory files are available in this snapshot."));
- assert!(!output.contains(".sparo_os/memory"));
- assert!(output.contains("Memory surface"));
- assert!(output.contains("sparo health"));
- assert!(output.contains("sparo chat"));
- }
-
- #[test]
- fn find_task_row_matches_session_id_or_title() {
- let tasks = vec![sparo_core::command::agentic_os::AgenticOsTaskRow {
- title: "Fix bug".to_string(),
- agent: "bitfun-debug".to_string(),
- status: "active".to_string(),
- detail: "2 turns".to_string(),
- session_id: Some("task-session".to_string()),
- workspace: Some("D:\\workspace".to_string()),
- }];
-
- assert_eq!(
- find_task_row(&tasks, "TASK-SESSION").unwrap().title,
- "Fix bug"
- );
- assert_eq!(
- find_task_row(&tasks, "fix bug")
- .unwrap()
- .session_id
- .as_deref(),
- Some("task-session")
- );
- assert_eq!(find_task_row(&tasks, "last").unwrap().title, "Fix bug");
- assert!(find_task_row(&tasks, "missing").is_none());
- }
-
- #[test]
- fn resolve_task_last_reports_empty_task_list() {
- let error = resolve_task_from_rows(&[], "last").unwrap_err();
- assert!(error
- .to_string()
- .contains("No backend-tracked agent tasks found"));
- }
-
- #[test]
- fn task_human_detail_lines_include_next_actions_for_persisted_tasks() {
- let task = sparo_core::command::agentic_os::AgenticOsTaskRow {
- title: "Review CLI task flow".to_string(),
- agent: "bitfun-debug".to_string(),
- status: "active".to_string(),
- detail: "Needs handoff".to_string(),
- session_id: Some("task-session".to_string()),
- workspace: Some("D:\\workspace\\my project".to_string()),
- };
-
- let output = task_human_detail_lines(&task).join("\n");
-
- assert!(output.contains("Task Details"));
- assert!(output.contains("Session: task-session"));
- assert!(output.contains("Next actions:"));
- assert!(output.contains(
- "Resume: sparo tasks --workspace \"D:\\workspace\\my project\" resume task-session"
- ));
- assert!(output.contains(
- "Export: sparo tasks --workspace \"D:\\workspace\\my project\" export task-session --output task.md"
- ));
- }
-
- #[test]
- fn task_human_detail_lines_explain_unsaved_task_export_limit() {
- let task = sparo_core::command::agentic_os::AgenticOsTaskRow {
- title: "Review CLI task flow".to_string(),
- agent: "bitfun-debug".to_string(),
- status: "active".to_string(),
- detail: "Needs handoff".to_string(),
- session_id: None,
- workspace: None,
- };
-
- let output = task_human_detail_lines(&task).join("\n");
-
- assert!(output.contains("Session: none"));
- assert!(output.contains("Resume: sparo tasks resume \"Review CLI task flow\""));
- assert!(output
- .contains("Export: unavailable until this task has a persisted session transcript."));
- }
-
- #[test]
- fn task_export_human_lines_include_resume_and_machine_output() {
- let output = task_export_human_lines(
- "task title",
- "session 1",
- "exports\\task.json",
- SessionExportFormat::Json,
- )
- .join("\n");
-
- assert!(output.contains("Task Exported"));
- assert!(output.contains("Task: task title"));
- assert!(output.contains("Session: session 1"));
- assert!(output.contains("Output: exports\\task.json"));
- assert!(output.contains("Format: json"));
- assert!(output.contains("Open file: exports\\task.json"));
- assert!(output.contains("Inspect task: sparo tasks show \"session 1\""));
- assert!(output.contains("Resume task: sparo tasks resume \"session 1\""));
- assert!(output.contains(
- "Machine output: sparo tasks export \"session 1\" --output exports\\task.json --format json --json"
- ));
- }
-
- #[test]
- fn tasks_list_human_lines_include_next_actions_for_persisted_latest_task() {
- let tasks = vec![sparo_core::command::agentic_os::AgenticOsTaskRow {
- title: "Review CLI task flow".to_string(),
- agent: "bitfun-debug".to_string(),
- status: "active".to_string(),
- detail: "Needs handoff".to_string(),
- session_id: Some("task-session".to_string()),
- workspace: Some("D:\\workspace\\task".to_string()),
- }];
-
- let output = tasks_list_human_lines(&tasks, Some("D:\\workspace\\fallback")).join("\n");
-
- assert!(output.contains("Agent Tasks (total 1)"));
- assert!(output.contains("task-session | active | Review CLI task flow"));
- assert!(output.contains("Next actions:"));
- assert!(output.contains(
- "Resume latest: sparo tasks --workspace D:\\workspace\\task resume task-session"
- ));
- assert!(output.contains(
- "Show details: sparo tasks --workspace D:\\workspace\\task show task-session"
- ));
- assert!(output.contains(
- "Export latest: sparo tasks --workspace D:\\workspace\\task export task-session --output task.md"
- ));
- assert!(output.contains("Open TUI tasks: sparo chat --workspace D:\\workspace\\task"));
- }
-
- #[test]
- fn tasks_list_human_lines_explain_no_session_latest_task() {
- let tasks = vec![sparo_core::command::agentic_os::AgenticOsTaskRow {
- title: "Review CLI task flow".to_string(),
- agent: "bitfun-debug".to_string(),
- status: "active".to_string(),
- detail: "Needs handoff".to_string(),
- session_id: None,
- workspace: None,
- }];
-
- let output = tasks_list_human_lines(&tasks, Some("D:\\workspace\\my project")).join("\n");
-
- assert!(output.contains("no-session | active | Review CLI task flow"));
- assert!(output.contains(
- "Resume latest: sparo tasks --workspace \"D:\\workspace\\my project\" resume \"Review CLI task flow\""
- ));
- assert!(output.contains(
- "Show details: sparo tasks --workspace \"D:\\workspace\\my project\" show \"Review CLI task flow\""
- ));
- assert!(output.contains(
- "Export latest: unavailable until this task has a persisted session transcript."
- ));
- }
-
- #[test]
- fn tasks_list_human_lines_keep_empty_task_list_actionable() {
- let output = tasks_list_human_lines(&[], None).join("\n");
-
- assert!(output.contains("No backend-tracked agent tasks found."));
- assert!(output.contains("sparo chat"));
- assert!(output.contains("sparo sessions list"));
- }
-
- #[test]
- fn task_tui_launch_context_prepares_unsaved_task_for_chat() {
- let task = sparo_core::command::agentic_os::AgenticOsTaskRow {
- title: "Review CLI task flow".to_string(),
- agent: "bitfun-debug".to_string(),
- status: "active".to_string(),
- detail: "Needs handoff".to_string(),
- session_id: None,
- workspace: None,
- };
-
- let launch =
- task_tui_launch_context(&task, Some("D:\\workspace\\fallback".to_string()), None);
-
- assert_eq!(launch.workspace.as_deref(), Some("D:\\workspace\\fallback"));
- assert_eq!(launch.agent, "bitfun-debug");
- assert_eq!(launch.title, "Review CLI task flow");
- assert_eq!(launch.context_messages.len(), 1);
- assert!(launch.context_messages[0].contains("Task detail"));
- assert!(launch.context_messages[0].contains("Session: none"));
- assert!(launch.context_messages[0].contains("Workspace: global"));
- let message = launch.initial_message.as_deref().unwrap();
- assert!(message.contains("Use the task detail above"));
- assert!(message.contains("Review CLI task flow"));
- assert!(message.contains("bitfun-debug"));
- }
-
- #[test]
- fn task_tui_launch_context_preserves_explicit_resume_message() {
- let task = sparo_core::command::agentic_os::AgenticOsTaskRow {
- title: "Review CLI task flow".to_string(),
- agent: "bitfun-debug".to_string(),
- status: "active".to_string(),
- detail: "Needs handoff".to_string(),
- session_id: None,
- workspace: Some("D:\\workspace\\task".to_string()),
- };
-
- let launch = task_tui_launch_context(
- &task,
- Some("D:\\workspace\\fallback".to_string()),
- Some("continue with risk review".to_string()),
- );
-
- assert_eq!(launch.workspace.as_deref(), Some("D:\\workspace\\task"));
- assert_eq!(
- launch.initial_message.as_deref(),
- Some("continue with risk review")
- );
- }
-
- #[test]
- fn task_session_resume_context_carries_task_detail_into_chat() {
- let task = sparo_core::command::agentic_os::AgenticOsTaskRow {
- title: "Review CLI task flow".to_string(),
- agent: "bitfun-debug".to_string(),
- status: "active".to_string(),
- detail: "Needs handoff".to_string(),
- session_id: Some("task-session".to_string()),
- workspace: Some("D:\\workspace\\task".to_string()),
- };
-
- let resume = task_session_resume_context(
- &task,
- Some("D:\\workspace\\fallback".to_string()),
- "task-session".to_string(),
- Some("continue from here".to_string()),
- );
-
- assert_eq!(resume.workspace.as_deref(), Some("D:\\workspace\\task"));
- assert_eq!(resume.session_id, "task-session");
- assert_eq!(
- resume.initial_message.as_deref(),
- Some("continue from here")
- );
- assert_eq!(resume.context_messages.len(), 1);
- assert!(resume.context_messages[0].contains("Task detail"));
- assert!(resume.context_messages[0].contains("Review CLI task flow"));
- assert!(resume.context_messages[0].contains("Session: task-session"));
- }
-}
+mod tests;
diff --git a/src/apps/cli/src/tests.rs b/src/apps/cli/src/tests.rs
new file mode 100644
index 00000000..95e38bd9
--- /dev/null
+++ b/src/apps/cli/src/tests.rs
@@ -0,0 +1,2839 @@
+use super::*;
+use clap::CommandFactory;
+
+fn test_config_commit(
+ status: sparo_core::service::config::ConfigCommitStatus,
+) -> sparo_core::service::config::PublishedConfigCommit {
+ sparo_core::service::config::ConfigCommit {
+ commit_id: "cfg-commit-test".to_string(),
+ revision: 42,
+ status,
+ scope: sparo_events::ConfigScope::user(),
+ source: sparo_events::ConfigChangeSource {
+ kind: sparo_events::ConfigChangeSourceKind::Cli,
+ surface: Some("cli".to_string()),
+ request_id: Some("request-test".to_string()),
+ },
+ changes: Vec::new(),
+ apply_receipts: Vec::new(),
+ affected_sections: Vec::new(),
+ restart_required: Vec::new(),
+ undo_token: None,
+ committed_at: chrono::Utc::now(),
+ }
+ .published()
+}
+
+#[test]
+fn config_redaction_masks_nested_secret_fields() {
+ let mut value = serde_json::json!({
+ "ai": {
+ "models": [
+ {
+ "name": "demo",
+ "api_key": "sk-secret",
+ "custom_headers": {
+ "Authorization": "Bearer token"
+ }
+ }
+ ],
+ "proxy": {
+ "password": "proxy-secret"
+ }
+ }
+ });
+
+ redact_config_value(&mut value);
+
+ assert_eq!(value["ai"]["models"][0]["api_key"], "");
+ assert_eq!(
+ value["ai"]["models"][0]["custom_headers"]["Authorization"],
+ ""
+ );
+ assert_eq!(value["ai"]["proxy"]["password"], "");
+ assert_eq!(value["ai"]["models"][0]["name"], "demo");
+}
+
+#[test]
+fn should_emit_json_error_detects_global_json_flag() {
+ assert!(should_emit_json_error([
+ "tasks".to_string(),
+ "show".to_string(),
+ "last".to_string(),
+ "--json".to_string(),
+ ]));
+ assert!(should_emit_json_error([
+ "health".to_string(),
+ "--json=true".to_string(),
+ ]));
+ assert!(!should_emit_json_error([
+ "tasks".to_string(),
+ "show".to_string(),
+ "last".to_string(),
+ ]));
+ assert!(!should_emit_json_error([
+ "exec".to_string(),
+ "--".to_string(),
+ "--json".to_string(),
+ ]));
+ assert!(should_emit_json_error([
+ "config".to_string(),
+ "get".to_string(),
+ "ai".to_string(),
+ ]));
+ assert!(!should_emit_json_error([
+ "config".to_string(),
+ "validate".to_string(),
+ ]));
+ assert!(should_emit_json_error([
+ "config".to_string(),
+ "validate".to_string(),
+ "--json".to_string(),
+ ]));
+ assert!(!should_emit_json_error([
+ "config".to_string(),
+ "health".to_string(),
+ ]));
+ assert!(should_emit_json_error([
+ "config".to_string(),
+ "health".to_string(),
+ "--json".to_string(),
+ ]));
+ assert!(!should_emit_json_error([
+ "config".to_string(),
+ "import".to_string(),
+ "config-export.json".to_string(),
+ ]));
+ assert!(should_emit_json_error([
+ "config".to_string(),
+ "import".to_string(),
+ "config-export.json".to_string(),
+ "--json".to_string(),
+ ]));
+ assert!(!should_emit_json_error([
+ "--verbose".to_string(),
+ "config".to_string(),
+ "prefs".to_string(),
+ "get".to_string(),
+ ]));
+ assert!(should_emit_json_error([
+ "--verbose".to_string(),
+ "config".to_string(),
+ "prefs".to_string(),
+ "get".to_string(),
+ "--json".to_string(),
+ ]));
+ assert!(!should_emit_json_error([
+ "tool".to_string(),
+ "schema".to_string(),
+ "read_file".to_string(),
+ ]));
+ assert!(should_emit_json_error([
+ "tool".to_string(),
+ "schema".to_string(),
+ "read_file".to_string(),
+ "--json".to_string(),
+ ]));
+ assert!(should_emit_json_error([
+ "tool".to_string(),
+ "list".to_string(),
+ "--json".to_string(),
+ ]));
+ assert!(should_emit_json_error([
+ "tool".to_string(),
+ "list".to_string(),
+ "--json=false".to_string(),
+ ]));
+ assert!(should_emit_json_error([
+ "agents".to_string(),
+ "list".to_string(),
+ "--json".to_string(),
+ ]));
+ assert!(!should_emit_json_error([
+ "config".to_string(),
+ "prefs".to_string(),
+ "get".to_string(),
+ "behavior.default_agent".to_string(),
+ ]));
+ assert!(should_emit_json_error([
+ "config".to_string(),
+ "prefs".to_string(),
+ "get".to_string(),
+ "behavior.default_agent".to_string(),
+ "--json".to_string(),
+ ]));
+}
+
+#[test]
+fn cli_parse_errors_can_be_structured_when_json_was_requested() {
+ let args = ["sparo", "health", "--json", "--bogus"];
+ let equals_args = ["sparo", "health", "--json=true"];
+
+ assert!(should_emit_json_error(
+ args.iter().skip(1).map(|arg| arg.to_string())
+ ));
+ assert!(cli_parse_error_message(&args)
+ .unwrap()
+ .contains("unexpected argument '--bogus'"));
+ assert!(should_emit_json_error(
+ equals_args.iter().skip(1).map(|arg| arg.to_string())
+ ));
+ assert!(cli_parse_error_message(&equals_args)
+ .unwrap()
+ .contains("unexpected value 'true'"));
+ assert!(cli_parse_error_message(&["sparo", "--help"]).is_none());
+}
+
+#[test]
+fn command_requests_json_tracks_structured_output_commands() {
+ assert!(command_requests_json(&Some(Commands::Batch {
+ tasks: None,
+ example: None,
+ json: true,
+ timeout_secs: 600,
+ continue_on_error: false,
+ })));
+ assert!(command_requests_json(&Some(Commands::Health {
+ json: true
+ })));
+ assert!(command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Get {
+ setting_id: "core.ai.models".to_string(),
+ },
+ })));
+ assert!(command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Export,
+ })));
+ assert!(command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Show { json: true },
+ })));
+ assert!(command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Catalog {
+ query: None,
+ json: true,
+ },
+ })));
+ assert!(command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Set {
+ setting_id: "core.ai.default_models.primary".to_string(),
+ value: "demo".to_string(),
+ json: true,
+ yes: false,
+ },
+ })));
+ assert!(command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Reset {
+ setting_id: "core.ai.default_models.primary".to_string(),
+ json: true,
+ yes: false,
+ },
+ })));
+ assert!(command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Import {
+ file: "config-export.json".to_string(),
+ json: true,
+ yes: false,
+ },
+ })));
+ assert!(command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Validate { json: true },
+ })));
+ assert!(command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Health { json: true },
+ })));
+ assert!(!command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Prefs {
+ action: PrefsAction::Get {
+ path: None,
+ json: false,
+ },
+ },
+ })));
+ assert!(command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Prefs {
+ action: PrefsAction::Get {
+ path: None,
+ json: true,
+ },
+ },
+ })));
+ assert!(!command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Prefs {
+ action: PrefsAction::Set {
+ path: "ui.show_tips".to_string(),
+ value: "false".to_string(),
+ json: false,
+ },
+ },
+ })));
+ assert!(command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Prefs {
+ action: PrefsAction::Set {
+ path: "ui.show_tips".to_string(),
+ value: "false".to_string(),
+ json: true,
+ },
+ },
+ })));
+ assert!(command_requests_json(&Some(Commands::Tool {
+ action: ToolAction::List { json: true },
+ })));
+ assert!(!command_requests_json(&Some(Commands::Tool {
+ action: ToolAction::Schema {
+ name: "read_file".to_string(),
+ workspace: None,
+ json: false,
+ },
+ })));
+ assert!(command_requests_json(&Some(Commands::Tool {
+ action: ToolAction::Schema {
+ name: "read_file".to_string(),
+ workspace: None,
+ json: true,
+ },
+ })));
+ assert!(command_requests_json(&Some(Commands::Tool {
+ action: ToolAction::Run {
+ name: "read_file".to_string(),
+ params: None,
+ params_file: None,
+ workspace: None,
+ json: true,
+ },
+ })));
+ assert!(command_requests_json(&Some(Commands::Agents {
+ action: AgentsAction::List,
+ json: true,
+ })));
+ assert!(!command_requests_json(&Some(Commands::Chat {
+ agent: None,
+ workspace: None,
+ })));
+ assert!(!command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Show { json: false },
+ })));
+ assert!(!command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Set {
+ setting_id: "core.ai.default_models.primary".to_string(),
+ value: "demo".to_string(),
+ json: false,
+ yes: false,
+ },
+ })));
+ assert!(!command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Validate { json: false },
+ })));
+ assert!(!command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Import {
+ file: "config-export.json".to_string(),
+ json: false,
+ yes: false,
+ },
+ })));
+ assert!(!command_requests_json(&Some(Commands::Config {
+ action: ConfigAction::Health { json: false },
+ })));
+ assert!(!command_requests_json(&Some(Commands::Tool {
+ action: ToolAction::List { json: false },
+ })));
+ assert!(!command_requests_json(&Some(Commands::Tool {
+ action: ToolAction::Run {
+ name: "read_file".to_string(),
+ params: None,
+ params_file: None,
+ workspace: None,
+ json: false,
+ },
+ })));
+ assert!(!command_requests_json(&Some(Commands::Batch {
+ tasks: None,
+ example: None,
+ json: false,
+ timeout_secs: 600,
+ continue_on_error: false,
+ })));
+ assert!(!command_requests_json(&Some(Commands::Agents {
+ action: AgentsAction::List,
+ json: false,
+ })));
+}
+
+#[test]
+fn pre_config_dispatch_is_limited_to_readonly_discovery_commands() {
+ assert!(can_dispatch_before_cli_config(&Some(
+ Commands::Workspaces {
+ action: WorkspacesAction::List,
+ json: true,
+ }
+ )));
+ assert!(can_dispatch_before_cli_config(&Some(Commands::Tool {
+ action: ToolAction::List { json: true },
+ })));
+ assert!(!can_dispatch_before_cli_config(&Some(Commands::Apps {
+ action: AppsAction::Show {
+ id: "cursor-bridge".to_string(),
+ },
+ json: true,
+ })));
+ assert!(!can_dispatch_before_cli_config(&Some(Commands::Memory {
+ action: MemoryAction::List,
+ workspace: None,
+ json: true,
+ })));
+ assert!(!can_dispatch_before_cli_config(&Some(Commands::Tasks {
+ action: TasksAction::Show {
+ id: "last".to_string(),
+ },
+ workspace: None,
+ json: true,
+ })));
+ assert!(can_use_default_config_silently(&Some(Commands::Apps {
+ action: AppsAction::Show {
+ id: "cursor-bridge".to_string(),
+ },
+ json: false,
+ })));
+ assert!(can_use_default_config_silently(&Some(Commands::Sessions {
+ domain: CliSessionDomain::OsAgent,
+ action: SessionAction::Export {
+ id: "last".to_string(),
+ output: None,
+ format: SessionExportFormat::Markdown,
+ },
+ workspace: None,
+ json: false,
+ })));
+ assert!(can_use_default_config_silently(&Some(Commands::Tasks {
+ action: TasksAction::Export {
+ id: "last".to_string(),
+ output: None,
+ format: SessionExportFormat::Markdown,
+ },
+ workspace: None,
+ json: false,
+ })));
+ assert!(can_use_default_config_silently(&Some(Commands::Agents {
+ action: AgentsAction::List,
+ json: false,
+ })));
+ assert!(!can_use_default_config_silently(&Some(
+ Commands::Sessions {
+ domain: CliSessionDomain::OsAgent,
+ action: SessionAction::Delete {
+ id: "session-1".to_string(),
+ },
+ workspace: None,
+ json: false,
+ }
+ )));
+ assert!(!can_dispatch_before_cli_config(&Some(
+ Commands::Workspaces {
+ action: WorkspacesAction::Use {
+ id: "global".to_string(),
+ },
+ json: true,
+ }
+ )));
+ assert!(!can_dispatch_before_cli_config(&Some(Commands::Apps {
+ action: AppsAction::Open {
+ id: "cursor-bridge".to_string(),
+ },
+ json: true,
+ })));
+ assert!(!can_dispatch_before_cli_config(&Some(Commands::Tasks {
+ action: TasksAction::Resume {
+ id: "last".to_string(),
+ message: None,
+ },
+ workspace: None,
+ json: false,
+ })));
+}
+
+#[test]
+fn invalid_cli_config_is_required_for_tui_and_prefs_only() {
+ assert!(requires_valid_cli_config(&None, true));
+ assert!(requires_valid_cli_config(
+ &Some(Commands::Chat {
+ agent: None,
+ workspace: None,
+ }),
+ true
+ ));
+ assert!(requires_valid_cli_config(
+ &Some(Commands::Config {
+ action: ConfigAction::Prefs {
+ action: PrefsAction::Get {
+ path: None,
+ json: true,
+ },
+ },
+ }),
+ false
+ ));
+ assert!(!requires_valid_cli_config(
+ &Some(Commands::Agents {
+ action: AgentsAction::List,
+ json: false,
+ }),
+ false
+ ));
+ assert!(!requires_valid_cli_config(
+ &Some(Commands::Sessions {
+ domain: CliSessionDomain::OsAgent,
+ action: SessionAction::List,
+ workspace: None,
+ json: false,
+ }),
+ false
+ ));
+}
+
+#[test]
+fn directory_health_distinguishes_missing_dirs_and_files() {
+ let temp_root =
+ std::env::temp_dir().join(format!("sparo-cli-health-test-{}", uuid::Uuid::new_v4()));
+ std::fs::create_dir_all(&temp_root).expect("create temp health root");
+ let existing_dir = temp_root.join("existing");
+ std::fs::create_dir_all(&existing_dir).expect("create temp health dir");
+ let existing_file = temp_root.join("workspaces");
+ std::fs::write(&existing_file, "not a directory").expect("create temp health file");
+
+ let dir_health = directory_health(&existing_dir);
+ assert!(dir_health.exists);
+ assert!(dir_health.is_dir);
+ assert!(dir_health.readable);
+ assert_eq!(dir_health.kind, "directory");
+ assert_eq!(dir_health.status, "ok");
+ assert!(dir_health.error.is_none());
+ assert!(dir_health.hint.is_none());
+
+ let missing_health = directory_health(&temp_root.join("missing"));
+ assert!(!missing_health.exists);
+ assert!(!missing_health.is_dir);
+ assert!(!missing_health.readable);
+ assert_eq!(missing_health.kind, "directory");
+ assert_eq!(missing_health.status, "missing");
+ assert!(missing_health.error.is_none());
+ assert_eq!(
+ missing_health.hint.as_deref(),
+ Some("Directory has not been created yet; Sparo will create it when needed.")
+ );
+
+ let file_health = directory_health(&existing_file);
+ assert!(file_health.exists);
+ assert!(!file_health.is_dir);
+ assert!(!file_health.readable);
+ assert_eq!(file_health.kind, "directory");
+ assert_eq!(file_health.status, "not_directory");
+ assert_eq!(
+ file_health.error.as_deref(),
+ Some("Path exists but is not a directory")
+ );
+ assert_eq!(
+ file_health.hint.as_deref(),
+ Some("Move or rename the file at this path, then run the command again.")
+ );
+
+ std::fs::remove_file(existing_file).expect("remove temp health file");
+ std::fs::remove_dir_all(temp_root).expect("remove temp health root");
+}
+
+#[test]
+fn file_health_distinguishes_files_missing_and_directories() {
+ let temp_root = std::env::temp_dir().join(format!(
+ "sparo-cli-file-health-test-{}",
+ uuid::Uuid::new_v4()
+ ));
+ std::fs::create_dir_all(&temp_root).expect("create temp file health root");
+ let config_file = temp_root.join("config.toml");
+ std::fs::write(&config_file, "ui.theme = \"dark\"").expect("create temp config file");
+ let directory_at_file_path = temp_root.join("config-dir.toml");
+ std::fs::create_dir_all(&directory_at_file_path).expect("create temp directory at file path");
+
+ let file = file_health(&config_file);
+ assert!(file.exists);
+ assert!(!file.is_dir);
+ assert!(file.readable);
+ assert_eq!(file.kind, "file");
+ assert_eq!(file.status, "ok");
+ assert!(file.hint.is_none());
+
+ let missing = file_health(&temp_root.join("missing.toml"));
+ assert_eq!(missing.kind, "file");
+ assert_eq!(missing.status, "missing");
+ assert_eq!(
+ missing.hint.as_deref(),
+ Some("Directory has not been created yet; Sparo will create it when needed.")
+ );
+
+ let directory = file_health(&directory_at_file_path);
+ assert_eq!(directory.kind, "file");
+ assert_eq!(directory.status, "not_file");
+ assert_eq!(
+ directory.error.as_deref(),
+ Some("Path exists but is not a file")
+ );
+ assert_eq!(
+ directory.hint.as_deref(),
+ Some("Move or rename the directory at this path, then run the command again.")
+ );
+
+ std::fs::remove_dir_all(temp_root).expect("remove temp file health root");
+}
+
+#[test]
+fn cli_config_file_health_validates_toml_content() {
+ let temp_root = std::env::temp_dir().join(format!(
+ "sparo-cli-config-health-test-{}",
+ uuid::Uuid::new_v4()
+ ));
+ std::fs::create_dir_all(&temp_root).expect("create temp config health root");
+ let valid_config = temp_root.join("valid.toml");
+ std::fs::write(
+ &valid_config,
+ toml::to_string_pretty(&CliConfig::default()).expect("serialize default config"),
+ )
+ .expect("write valid config");
+ let invalid_config = temp_root.join("invalid.toml");
+ std::fs::write(&invalid_config, "ui.theme = [").expect("write invalid config");
+ let shortcut_conflict_config = temp_root.join("shortcut-conflict.toml");
+ let mut conflicting = CliConfig::default();
+ conflicting.shortcuts.send_message = "Ctrl+C".to_string();
+ std::fs::write(
+ &shortcut_conflict_config,
+ toml::to_string_pretty(&conflicting).expect("serialize conflicting config"),
+ )
+ .expect("write conflicting config");
+ let invalid_shortcut_config = temp_root.join("invalid-shortcut.toml");
+ let mut invalid_shortcut = CliConfig::default();
+ invalid_shortcut.shortcuts.interrupt = "Ctrl+Delete".to_string();
+ std::fs::write(
+ &invalid_shortcut_config,
+ toml::to_string_pretty(&invalid_shortcut).expect("serialize invalid shortcut config"),
+ )
+ .expect("write invalid shortcut config");
+ let reserved_shortcut_config = temp_root.join("reserved-shortcut.toml");
+ let mut reserved_shortcut = CliConfig::default();
+ reserved_shortcut.shortcuts.send_message = "Esc".to_string();
+ reserved_shortcut.shortcuts.menu = "Ctrl+B".to_string();
+ std::fs::write(
+ &reserved_shortcut_config,
+ toml::to_string_pretty(&reserved_shortcut).expect("serialize reserved shortcut config"),
+ )
+ .expect("write reserved shortcut config");
+ let invalid_theme_config = temp_root.join("invalid-theme.toml");
+ let mut invalid_theme = CliConfig::default();
+ invalid_theme.ui.theme = "neon".to_string();
+ std::fs::write(
+ &invalid_theme_config,
+ toml::to_string_pretty(&invalid_theme).expect("serialize invalid theme config"),
+ )
+ .expect("write invalid theme config");
+ let invalid_color_scheme_config = temp_root.join("invalid-color-scheme.toml");
+ let mut invalid_color_scheme = CliConfig::default();
+ invalid_color_scheme.ui.color_scheme = "infrared".to_string();
+ std::fs::write(
+ &invalid_color_scheme_config,
+ toml::to_string_pretty(&invalid_color_scheme)
+ .expect("serialize invalid color scheme config"),
+ )
+ .expect("write invalid color scheme config");
+ let empty_default_agent_config = temp_root.join("empty-default-agent.toml");
+ let mut empty_default_agent = CliConfig::default();
+ empty_default_agent.behavior.default_agent = " ".to_string();
+ std::fs::write(
+ &empty_default_agent_config,
+ toml::to_string_pretty(&empty_default_agent).expect("serialize empty default agent config"),
+ )
+ .expect("write empty default agent config");
+
+ let valid = cli_config_file_health(&valid_config);
+ assert_eq!(valid.status, "ok");
+ assert!(valid.error.is_none());
+ assert!(valid.hint.is_none());
+
+ let invalid = cli_config_file_health(&invalid_config);
+ assert_eq!(invalid.status, "invalid_config");
+ assert!(invalid.error.is_some());
+ assert_eq!(
+ invalid.hint.as_deref(),
+ Some("Fix the config file syntax or move the file aside so Sparo can recreate defaults.")
+ );
+
+ let shortcut_conflict = cli_config_file_health(&shortcut_conflict_config);
+ assert_eq!(shortcut_conflict.status, "invalid_config");
+ assert!(shortcut_conflict
+ .error
+ .as_deref()
+ .is_some_and(|error| error.contains("shortcuts.send_message and shortcuts.interrupt")));
+ assert_eq!(
+ shortcut_conflict.hint.as_deref(),
+ Some("Fix the config file syntax or move the file aside so Sparo can recreate defaults.")
+ );
+
+ let invalid_shortcut = cli_config_file_health(&invalid_shortcut_config);
+ assert_eq!(invalid_shortcut.status, "invalid_config");
+ assert!(invalid_shortcut
+ .error
+ .as_deref()
+ .is_some_and(|error| error.contains("shortcuts.interrupt has an invalid shortcut")));
+
+ let reserved_shortcut = cli_config_file_health(&reserved_shortcut_config);
+ assert_eq!(reserved_shortcut.status, "invalid_config");
+ assert!(reserved_shortcut
+ .error
+ .as_deref()
+ .is_some_and(|error| error.contains("shortcuts.send_message cannot use Esc")));
+
+ let invalid_theme = cli_config_file_health(&invalid_theme_config);
+ assert_eq!(invalid_theme.status, "invalid_config");
+ assert!(invalid_theme
+ .error
+ .as_deref()
+ .is_some_and(|error| error.contains("ui.theme must be one of")));
+
+ let invalid_color_scheme = cli_config_file_health(&invalid_color_scheme_config);
+ assert_eq!(invalid_color_scheme.status, "invalid_config");
+ assert!(invalid_color_scheme
+ .error
+ .as_deref()
+ .is_some_and(|error| error.contains("ui.color_scheme must be one of")));
+
+ let empty_default_agent = cli_config_file_health(&empty_default_agent_config);
+ assert_eq!(empty_default_agent.status, "invalid_config");
+ assert!(empty_default_agent
+ .error
+ .as_deref()
+ .is_some_and(|error| error.contains("behavior.default_agent cannot be empty")));
+
+ std::fs::remove_dir_all(temp_root).expect("remove temp config health root");
+}
+
+#[test]
+fn global_config_file_health_validates_json_content() {
+ let temp_root = std::env::temp_dir().join(format!(
+ "sparo-global-config-health-test-{}",
+ uuid::Uuid::new_v4()
+ ));
+ std::fs::create_dir_all(&temp_root).expect("create temp global config health root");
+ let valid_config = temp_root.join("app.json");
+ std::fs::write(
+ &valid_config,
+ serde_json::to_string_pretty(&sparo_core::service::config::GlobalConfig::default())
+ .expect("serialize default global config"),
+ )
+ .expect("write valid global config");
+ let invalid_config = temp_root.join("invalid-app.json");
+ std::fs::write(&invalid_config, "{").expect("write invalid global config");
+
+ let valid = global_config_file_health(&valid_config);
+ assert_eq!(valid.status, "ok");
+ assert!(valid.error.is_none());
+ assert!(valid.hint.is_none());
+
+ let invalid = global_config_file_health(&invalid_config);
+ assert_eq!(invalid.status, "invalid_config");
+ assert!(invalid.error.is_some());
+ assert_eq!(
+ invalid.hint.as_deref(),
+ Some("Fix the config file syntax or move the file aside so Sparo can recreate defaults.")
+ );
+
+ std::fs::remove_dir_all(temp_root).expect("remove temp global config health root");
+}
+
+#[test]
+fn help_text_mentions_task_last_and_workspace_defaults() {
+ let help = Cli::command().render_long_help().to_string();
+ assert!(help.contains("Manage backend-tracked agent tasks"));
+
+ let mut command = Cli::command();
+ let exec_help = command
+ .find_subcommand_mut("exec")
+ .unwrap()
+ .render_long_help()
+ .to_string();
+ assert!(exec_help.contains("workspace.default_path"));
+
+ let tasks_help = command
+ .find_subcommand_mut("tasks")
+ .unwrap()
+ .render_long_help()
+ .to_string();
+ assert!(tasks_help.contains("session id, title, or \"last\""));
+ assert!(tasks_help.contains("Show the most recent backend-tracked task"));
+}
+
+#[test]
+fn last_shortcuts_parse_to_recent_session_and_task_actions() {
+ let cli = Cli::try_parse_from(["sparo", "sessions", "--domain", "os-agent", "last"]).unwrap();
+ match cli.command {
+ Some(Commands::Sessions {
+ action: SessionAction::Last,
+ ..
+ }) => {}
+ _ => panic!("expected sessions last shortcut"),
+ }
+
+ let cli = Cli::try_parse_from(["sparo", "tasks", "last"]).unwrap();
+ match cli.command {
+ Some(Commands::Tasks {
+ action: TasksAction::Last,
+ ..
+ }) => {}
+ _ => panic!("expected tasks last shortcut"),
+ }
+}
+
+#[test]
+fn tool_run_help_mentions_loose_params() {
+ let mut command = Cli::command();
+ let tool_help = command
+ .find_subcommand_mut("tool")
+ .unwrap()
+ .find_subcommand_mut("run")
+ .unwrap()
+ .render_long_help()
+ .to_string();
+ assert!(tool_help.contains("loose flat object"));
+}
+
+#[test]
+fn tool_list_help_mentions_json_output() {
+ let mut command = Cli::command();
+ let tool_help = command
+ .find_subcommand_mut("tool")
+ .unwrap()
+ .find_subcommand_mut("list")
+ .unwrap()
+ .render_long_help()
+ .to_string();
+ assert!(tool_help.contains("Output raw JSON"));
+}
+
+#[test]
+fn tool_schema_help_mentions_json_output() {
+ let mut command = Cli::command();
+ let tool_help = command
+ .find_subcommand_mut("tool")
+ .unwrap()
+ .find_subcommand_mut("schema")
+ .unwrap()
+ .render_long_help()
+ .to_string();
+ assert!(tool_help.contains("Output raw JSON"));
+}
+
+#[test]
+fn tool_schema_human_lines_summarize_fields_and_actions() {
+ let response = sparo_core::command::tool::ToolSchemaResponse {
+ name: "LS".to_string(),
+ input_schema: serde_json::json!({
+ "type": "object",
+ "required": ["path"],
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "The absolute path to the directory to list."
+ },
+ "limit": {
+ "type": "number",
+ "description": "The maximum number of entries to return."
+ }
+ }
+ }),
+ model_input_schema: serde_json::json!({
+ "type": "object",
+ "required": ["path"],
+ "properties": {
+ "path": { "type": "string" }
+ }
+ }),
+ };
+
+ let output = tool_schema_human_lines(&response, Some("D:\\workspace\\my project")).join("\n");
+
+ assert!(output.contains("Tool Schema: LS"));
+ assert!(output.contains("Workspace context: D:\\workspace\\my project"));
+ assert!(output.contains("limit (optional, number) - The maximum number"));
+ assert!(output.contains("path (required, string) - The absolute path"));
+ assert!(output.contains("Model-facing schema: differs from runtime schema"));
+ assert!(output.contains(
+ "Run tool: sparo tool run LS --workspace \"D:\\workspace\\my project\" --params-file "
+ ));
+ assert!(output.contains(
+ "Machine output: sparo tool schema LS --workspace \"D:\\workspace\\my project\" --json"
+ ));
+}
+
+#[test]
+fn tool_list_human_lines_include_next_actions() {
+ let lines = tool_list_human_lines(&[sparo_core::command::tool::ToolInfo {
+ name: "search_files".to_string(),
+ user_facing_name: "Search Files".to_string(),
+ description: "Search workspace files\nwith extra details".to_string(),
+ readonly: true,
+ enabled: true,
+ supports_streaming: false,
+ }]);
+
+ assert!(lines.contains(&"Available Tools (total 1, enabled 1, readonly 1)".to_string()));
+ assert!(lines.contains(&"search_files [readonly]".to_string()));
+ assert!(lines.contains(&" Search workspace files".to_string()));
+ assert!(lines.contains(&" Inspect schema: sparo tool schema search_files --json".to_string()));
+ assert!(lines.contains(
+ &" Run tool: sparo tool run search_files --params '{\"key\":\"value\"}'".to_string()
+ ));
+ assert!(lines.contains(&" Workspace scope: add --workspace to schema/run".to_string()));
+ assert!(lines.contains(&" Machine output: sparo tool list --json".to_string()));
+}
+
+#[test]
+fn tool_list_human_lines_empty_state_still_guides_next_step() {
+ let lines = tool_list_human_lines(&[]);
+
+ assert!(lines.contains(&"Available Tools (total 0, enabled 0, readonly 0)".to_string()));
+ assert!(lines.contains(&"No tools available.".to_string()));
+ assert!(
+ lines.contains(&" Inspect schema: sparo tool schema \"\" --json".to_string())
+ );
+}
+
+#[test]
+fn tool_run_human_lines_surface_content_and_next_actions() {
+ let response = sparo_core::command::tool::ExecuteToolResponse {
+ tool_name: "read_file".to_string(),
+ results: Vec::new(),
+ display_results: vec![serde_json::json!({
+ "content": "line one\nline two",
+ "assistant": {
+ "path": "src/main.rs",
+ "truncated": false
+ }
+ })],
+ };
+
+ let output = tool_run_human_lines(&response, Some("D:\\workspace\\my project")).join("\n");
+
+ assert!(output.contains("Tool: read_file"));
+ assert!(output.contains("Content:\n line one\n line two"));
+ assert!(output.contains("Assistant context:\n {"));
+ assert!(output.contains("\"path\": \"src/main.rs\""));
+ assert!(output.contains(
+ "Inspect schema: sparo tool schema read_file --workspace \"D:\\workspace\\my project\" --json"
+ ));
+ assert!(output.contains(
+ "Rerun with JSON: sparo tool run read_file --workspace \"D:\\workspace\\my project\" --params-file --json"
+ ));
+}
+
+#[test]
+fn tool_run_human_lines_explain_empty_results() {
+ let response = sparo_core::command::tool::ExecuteToolResponse {
+ tool_name: "noop".to_string(),
+ results: Vec::new(),
+ display_results: Vec::new(),
+ };
+
+ let output = tool_run_human_lines(&response, None).join("\n");
+
+ assert!(output.contains("No display results returned."));
+ assert!(output.contains("Inspect schema: sparo tool schema noop --json"));
+ assert!(output.contains("Rerun with JSON: sparo tool run noop --params-file --json"));
+}
+
+#[test]
+fn config_prefs_get_help_mentions_json_output() {
+ let mut command = Cli::command();
+ let prefs_get_help = command
+ .find_subcommand_mut("config")
+ .unwrap()
+ .find_subcommand_mut("prefs")
+ .unwrap()
+ .find_subcommand_mut("get")
+ .unwrap()
+ .render_long_help()
+ .to_string();
+ assert!(prefs_get_help.contains("Output raw JSON"));
+
+ let mut command = Cli::command();
+ let prefs_set_help = command
+ .find_subcommand_mut("config")
+ .unwrap()
+ .find_subcommand_mut("prefs")
+ .unwrap()
+ .find_subcommand_mut("set")
+ .unwrap()
+ .render_long_help()
+ .to_string();
+ assert!(prefs_set_help.contains("behavior.confirm_dangerous"));
+ assert!(prefs_set_help.contains("shortcuts.send_message"));
+}
+
+#[test]
+fn structured_config_subcommands_accept_json_flag() {
+ let mut command = Cli::command();
+ for subcommand in [
+ "get", "set", "reset", "export", "import", "validate", "reload", "health",
+ ] {
+ let help = command
+ .find_subcommand_mut("config")
+ .unwrap()
+ .find_subcommand_mut(subcommand)
+ .unwrap()
+ .render_long_help()
+ .to_string();
+ assert!(
+ help.contains("Output raw JSON"),
+ "missing --json help for config {}",
+ subcommand
+ );
+ }
+}
+
+#[test]
+fn health_help_mentions_json_output() {
+ let mut command = Cli::command();
+ let help = command
+ .find_subcommand_mut("health")
+ .unwrap()
+ .render_long_help()
+ .to_string();
+ assert!(help.contains("Output in JSON format"));
+}
+
+#[test]
+fn agents_help_mentions_live_registry_and_json_output() {
+ let mut command = Cli::command();
+ let help = command
+ .find_subcommand_mut("agents")
+ .unwrap()
+ .render_long_help()
+ .to_string();
+ assert!(help.contains("Inspect available agents"));
+ assert!(help.contains("Output in JSON format"));
+ assert!(help.contains("List available built-in, app-backed, and custom agents"));
+}
+
+#[test]
+fn batch_help_mentions_example_output() {
+ let mut command = Cli::command();
+ let help = command
+ .find_subcommand_mut("batch")
+ .unwrap()
+ .render_long_help()
+ .to_string();
+ assert!(help.contains("Print an example batch task file and exit"));
+ assert!(help.contains("--tasks "));
+}
+
+#[test]
+fn json_already_emitted_marker_is_suppressed() {
+ let error = anyhow::anyhow!(JSON_OUTPUT_ALREADY_EMITTED);
+ assert_eq!(error.to_string(), JSON_OUTPUT_ALREADY_EMITTED);
+ let error = anyhow::anyhow!(CLI_OUTPUT_ALREADY_EMITTED);
+ assert_eq!(error.to_string(), CLI_OUTPUT_ALREADY_EMITTED);
+}
+
+#[test]
+fn cli_health_success_reads_script_contract_flag() {
+ assert!(cli_health_success(&serde_json::json!({ "success": true })));
+ assert!(!cli_health_success(
+ &serde_json::json!({ "success": false })
+ ));
+ assert!(!cli_health_success(&serde_json::json!({})));
+}
+
+#[test]
+fn command_modes_choose_blocking_confirmation_only_for_interactive_surfaces() {
+ assert!(exec_skip_tool_confirmation(false));
+ assert!(!exec_skip_tool_confirmation(true));
+}
+
+#[test]
+fn default_agent_pref_fills_missing_cli_agent() {
+ let mut config = CliConfig::default();
+ config.behavior.default_agent = "bitfun-debug".to_string();
+
+ assert_eq!(effective_cli_agent(&config, None), "bitfun-debug");
+ assert_eq!(effective_cli_agent(&config, Some("")), "bitfun-debug");
+ assert_eq!(
+ effective_cli_agent(&config, Some("bitfun-plan")),
+ "bitfun-plan"
+ );
+}
+
+#[test]
+fn cli_default_agent_matches_core_registry_default() {
+ let config = CliConfig::default();
+ let registry = sparo_core::agentic::agents::get_agent_registry();
+ let registry_default = registry.default_agent_type();
+
+ assert_eq!(config.behavior.default_agent, registry_default);
+ assert_eq!(registry_default, "Runno");
+}
+
+#[test]
+fn cli_health_includes_app_storage_checks() {
+ let health = cli_health_value().expect("build cli health value");
+ let checks = health["checks"]
+ .as_object()
+ .expect("health checks should be an object");
+
+ for key in [
+ "apps",
+ "components",
+ "product_app_runtime_hosts",
+ "agent_components",
+ "bridge_components",
+ ] {
+ assert!(checks.contains_key(key), "missing health check: {key}");
+ }
+}
+
+#[test]
+fn cli_health_includes_memory_storage_checks() {
+ let health = cli_health_value().expect("build cli health value");
+ let checks = health["checks"]
+ .as_object()
+ .expect("health checks should be an object");
+
+ for key in ["agentic_os_memory", "workspace_memory"] {
+ assert!(checks.contains_key(key), "missing health check: {key}");
+ }
+}
+
+#[test]
+fn cli_health_includes_session_storage_checks() {
+ let health = cli_health_value().expect("build cli health value");
+ let checks = health["checks"]
+ .as_object()
+ .expect("health checks should be an object");
+
+ for key in [
+ "sessions",
+ "os_agent_sessions",
+ "global_sessions",
+ "workspace_sessions",
+ "works",
+ "runs",
+ "app_data",
+ "services",
+ ] {
+ assert!(checks.contains_key(key), "missing health check: {key}");
+ }
+}
+
+#[test]
+fn health_summary_counts_problem_checks() {
+ let summary = health_summary(&serde_json::json!({
+ "app_root": { "status": "ok" },
+ "logs": {
+ "kind": "directory",
+ "status": "missing",
+ "path": "C:\\sparo\\logs",
+ "hint": "Created when needed"
+ },
+ "workspaces": {
+ "kind": "directory",
+ "status": "inaccessible",
+ "path": "C:\\sparo\\workspaces",
+ "hint": "Check permissions"
+ },
+ "skills": {
+ "kind": "directory",
+ "status": "not_directory",
+ "path": "C:\\sparo\\skills"
+ }
+ }));
+
+ assert_eq!(summary["total"], 4);
+ assert_eq!(summary["ok"], 1);
+ assert_eq!(summary["missing"], 1);
+ assert_eq!(
+ summary["missing_checks"].as_array().unwrap(),
+ &vec![serde_json::Value::String("logs".to_string())]
+ );
+ assert_eq!(summary["missing_details"][0]["name"], "logs");
+ assert_eq!(summary["missing_details"][0]["kind"], "directory");
+ assert_eq!(summary["missing_details"][0]["status"], "missing");
+ assert_eq!(summary["missing_details"][0]["path"], "C:\\sparo\\logs");
+ assert_eq!(summary["missing_details"][0]["hint"], "Created when needed");
+ assert_eq!(summary["problems"], 2);
+ assert_eq!(
+ summary["problem_checks"].as_array().unwrap(),
+ &vec![
+ serde_json::Value::String("skills".to_string()),
+ serde_json::Value::String("workspaces".to_string())
+ ]
+ );
+ assert_eq!(summary["problem_details"][0]["name"], "skills");
+ assert_eq!(summary["problem_details"][0]["kind"], "directory");
+ assert_eq!(summary["problem_details"][0]["status"], "not_directory");
+ assert_eq!(summary["problem_details"][0]["path"], "C:\\sparo\\skills");
+ assert_eq!(summary["problem_details"][1]["name"], "workspaces");
+ assert_eq!(summary["problem_details"][1]["hint"], "Check permissions");
+}
+
+#[test]
+fn health_next_steps_deduplicates_problem_hints() {
+ let health = serde_json::json!({
+ "checks": {
+ "ok": { "status": "ok", "hint": "No action" },
+ "missing": { "status": "missing", "hint": "Created when needed" },
+ "apps": { "status": "inaccessible", "hint": "Check permissions" },
+ "sessions": { "status": "inaccessible", "hint": "Check permissions" },
+ "config": { "status": "invalid_config", "hint": "Fix config syntax" }
+ }
+ });
+
+ assert_eq!(
+ health_next_steps(&health),
+ vec![
+ "Check permissions".to_string(),
+ "Fix config syntax".to_string()
+ ]
+ );
+}
+
+#[test]
+fn healthy_health_next_steps_keep_success_output_actionable() {
+ assert_eq!(
+ healthy_health_next_steps(),
+ vec![
+ "Start interactive chat: sparo chat".to_string(),
+ "Inspect CLI preferences: sparo config prefs get".to_string(),
+ "Inspect shared config: sparo config show".to_string(),
+ "Machine output: sparo health --json".to_string(),
+ ]
+ );
+}
+
+#[test]
+fn cli_error_hint_points_runtime_directory_failures_to_health() {
+ let error = anyhow::anyhow!("initialize_global_config: Failed to create directory");
+ assert_eq!(
+ cli_error_hint(&error),
+ Some("Run `sparo health` to diagnose Sparo CLI data directory access.")
+ );
+ assert_eq!(cli_error_kind(&error), "runtime_directory_error");
+
+ let unrelated = anyhow::anyhow!("Tool not found: ReadFile");
+ assert_eq!(
+ cli_error_hint(&unrelated),
+ Some("Run `sparo tool list` to see registered core tools.")
+ );
+ assert_eq!(cli_error_kind(&unrelated), "execution_error");
+}
+
+#[test]
+fn cli_error_hints_keep_panel_empty_states_actionable() {
+ let no_tasks = anyhow::anyhow!("No backend-tracked agent tasks found");
+ assert_eq!(cli_error_kind(&no_tasks), "execution_error");
+ assert_eq!(
+ cli_error_hint(&no_tasks),
+ Some(
+ "Start work with `sparo chat`, or run `sparo sessions list` to inspect saved conversations."
+ )
+ );
+
+ let missing_memory = anyhow::anyhow!("Memory file not found: project:notes.md");
+ assert_eq!(cli_error_kind(&missing_memory), "execution_error");
+ assert_eq!(
+ cli_error_hint(&missing_memory),
+ Some("Run `sparo memory list` to see available global and project memory files.")
+ );
+
+ let missing_app = anyhow::anyhow!("Intelligent App not found: files");
+ assert_eq!(cli_error_kind(&missing_app), "execution_error");
+ assert_eq!(
+ cli_error_hint(&missing_app),
+ Some("Run `sparo apps list` to see Intelligent Apps in the effective activation scope.")
+ );
+
+ let cli_launch =
+ anyhow::anyhow!("CLI Intelligent App launch is unavailable for 'Dashboard' (dashboard)");
+ assert_eq!(cli_error_kind(&cli_launch), "execution_error");
+ assert_eq!(
+ cli_error_hint(&cli_launch),
+ Some("Open Sparo Desktop Apps Center and launch the selected Intelligent App into a Work; the CLI never falls back to opening package files.")
+ );
+
+ let no_sessions = anyhow::anyhow!("No history sessions");
+ assert_eq!(cli_error_kind(&no_sessions), "execution_error");
+ assert_eq!(
+ cli_error_hint(&no_sessions),
+ Some(
+ "Start a session with `sparo chat`, or run `sparo exec \"\"` for a one-shot task."
+ )
+ );
+
+ let session_resume_json =
+ anyhow::anyhow!("sessions resume is interactive and does not support --json");
+ assert_eq!(cli_error_kind(&session_resume_json), "execution_error");
+ assert_eq!(
+ cli_error_hint(&session_resume_json),
+ Some(
+ "Run `sparo sessions resume ` without `--json`, or use `sparo sessions show --json` for scriptable inspection."
+ )
+ );
+
+ let task_resume_json =
+ anyhow::anyhow!("tasks resume is interactive and does not support --json");
+ assert_eq!(cli_error_kind(&task_resume_json), "execution_error");
+ assert_eq!(
+ cli_error_hint(&task_resume_json),
+ Some(
+ "Run `sparo tasks resume ` without `--json`, or use `sparo tasks show --json` for scriptable inspection."
+ )
+ );
+
+ let task_without_session = anyhow::anyhow!("Task has no persisted session id: review");
+ assert_eq!(cli_error_kind(&task_without_session), "execution_error");
+ assert_eq!(
+ cli_error_hint(&task_without_session),
+ Some(
+ "Use `sparo tasks resume ` to continue the task in the TUI; task export requires a persisted session transcript."
+ )
+ );
+
+ let missing_batch = anyhow::anyhow!("Failed to read batch task file: missing.json");
+ assert_eq!(cli_error_kind(&missing_batch), "execution_error");
+ assert_eq!(
+ cli_error_hint(&missing_batch),
+ Some(
+ "Run `sparo batch --example json` or `sparo batch --example toml` to generate a starter task file."
+ )
+ );
+
+ let invalid_batch = anyhow::anyhow!("Invalid JSON batch task file: tasks.json");
+ assert_eq!(cli_error_kind(&invalid_batch), "execution_error");
+ assert_eq!(
+ cli_error_hint(&invalid_batch),
+ Some(
+ "Compare the file with `sparo batch --example json` or `sparo batch --example toml`, then fix the task syntax."
+ )
+ );
+
+ let invalid_tool_params = anyhow::anyhow!("Invalid parameters for tool Bash");
+ assert_eq!(cli_error_kind(&invalid_tool_params), "execution_error");
+ assert_eq!(
+ cli_error_hint(&invalid_tool_params),
+ Some(
+ "Use `sparo tool schema --json` to inspect parameters, or pass complex input with `--params-file `."
+ )
+ );
+
+ let misplaced_workspace = anyhow::anyhow!(
+ "error: unexpected argument '--workspace' found\n\n tip: 'show --workspace' exists"
+ );
+ assert_eq!(cli_error_kind(&misplaced_workspace), "cli_parse_error");
+ assert_eq!(
+ cli_error_hint(&misplaced_workspace),
+ Some(
+ "Place `--workspace ` after the subcommand that accepts it, for example `sparo tool run --workspace ` or `sparo memory show --workspace `."
+ )
+ );
+}
+
+#[test]
+fn cli_error_kind_classifies_parse_errors() {
+ let error = anyhow::anyhow!("error: unexpected argument '--json' found");
+
+ assert_eq!(cli_error_kind(&error), "cli_parse_error");
+}
+
+#[test]
+fn cli_error_chain_preserves_context_and_source() {
+ let source = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
+ let error = anyhow::Error::new(source).context("Failed to access CLI config file");
+
+ let causes = cli_error_chain(&error);
+
+ assert_eq!(causes[0], "Failed to access CLI config file");
+ assert_eq!(causes[1], "denied");
+}
+
+#[test]
+fn read_tool_params_rejects_multiple_sources() {
+ let result = read_tool_params(Some("{}".to_string()), Some("params.json".to_string()));
+ assert!(result.is_err());
+}
+
+#[test]
+fn read_tool_params_accepts_inline_json() {
+ let value = read_tool_params(Some(r#"{"path":"."}"#.to_string()), None).unwrap();
+ assert_eq!(value["path"], ".");
+}
+
+#[test]
+fn read_tool_params_accepts_loose_flat_object() {
+ let value = read_tool_params(
+ Some("{path:src/apps/cli/src,depth:1,readonly:true}".to_string()),
+ None,
+ )
+ .unwrap();
+ assert_eq!(value["path"], "src/apps/cli/src");
+ assert_eq!(value["depth"], 1);
+ assert_eq!(value["readonly"], true);
+}
+
+#[test]
+fn read_tool_params_accepts_utf8_bom() {
+ let value = read_tool_params(Some("\u{feff}{\"path\":\".\"}".to_string()), None).unwrap();
+ assert_eq!(value["path"], ".");
+}
+
+#[test]
+fn parse_batch_task_file_accepts_utf8_bom() {
+ let tasks = parse_batch_task_file("\u{feff}[]", "tasks.json").unwrap();
+ assert!(tasks.is_empty());
+}
+
+#[test]
+fn batch_examples_parse_as_task_files() {
+ let json_tasks =
+ parse_batch_task_file(render_batch_example(BatchExampleFormat::Json), "tasks.json")
+ .unwrap();
+ assert_eq!(json_tasks.len(), 2);
+ assert_eq!(json_tasks[0].message(), "Summarize the current workspace");
+ assert_eq!(json_tasks[1].agent("OSAgent"), "bitfun-coder");
+
+ let toml_tasks =
+ parse_batch_task_file(render_batch_example(BatchExampleFormat::Toml), "tasks.toml")
+ .unwrap();
+ assert_eq!(toml_tasks.len(), 2);
+ assert_eq!(toml_tasks[1].workspace().as_deref(), Some("."));
+}
+
+#[test]
+fn empty_batch_hint_points_to_starter_example() {
+ assert!(empty_batch_hint().contains("sparo batch --example json"));
+ assert!(empty_batch_hint().contains("at least one task"));
+}
+
+#[test]
+fn empty_history_hints_point_to_health_when_storage_may_be_missing() {
+ assert!(empty_task_hint().contains("sparo chat"));
+ assert!(empty_task_hint().contains("sparo health"));
+ assert!(empty_session_hint().contains("sparo exec"));
+ assert!(empty_session_hint().contains("sparo health"));
+}
+
+#[test]
+fn write_export_file_creates_parent_directories() {
+ let temp_root =
+ std::env::temp_dir().join(format!("sparo-cli-export-test-{}", uuid::Uuid::new_v4()));
+ let output = temp_root.join("nested").join("session.md");
+
+ write_export_file(output.to_str().unwrap(), "exported").unwrap();
+
+ assert_eq!(std::fs::read_to_string(&output).unwrap(), "exported");
+ std::fs::remove_dir_all(temp_root).unwrap();
+}
+
+#[test]
+fn export_file_errors_include_writable_output_hint() {
+ let temp_root = std::env::temp_dir().join(format!(
+ "sparo-cli-export-dir-test-{}",
+ uuid::Uuid::new_v4()
+ ));
+ std::fs::create_dir_all(&temp_root).unwrap();
+
+ let error = write_export_file(temp_root.to_str().unwrap(), "exported").unwrap_err();
+ assert_eq!(
+ cli_error_hint(&error),
+ Some("Pass a writable file path with `--output `; create or fix permissions on the parent directory if needed.")
+ );
+
+ std::fs::remove_dir_all(temp_root).unwrap();
+}
+
+#[test]
+fn batch_summary_serializes_script_contract() {
+ let results = vec![
+ BatchTaskResult {
+ index: 1,
+ agent: "OSAgent".to_string(),
+ message: "one".to_string(),
+ session_id: Some("session-1".to_string()),
+ success: true,
+ duration_ms: 12,
+ error_kind: None,
+ error: None,
+ },
+ BatchTaskResult {
+ index: 2,
+ agent: "bitfun-debug".to_string(),
+ message: "two".to_string(),
+ session_id: None,
+ success: false,
+ duration_ms: 34,
+ error_kind: Some("execution_error".to_string()),
+ error: Some("failed".to_string()),
+ },
+ ];
+ let value = serde_json::to_value(batch_summary("tasks.json", &results)).unwrap();
+
+ assert_eq!(value["tasks_file"], "tasks.json");
+ assert_eq!(value["passed"], 1);
+ assert_eq!(value["failed"], 1);
+ assert_eq!(value["total"], 2);
+ assert_eq!(value["results"][0]["session_id"], "session-1");
+ assert_eq!(value["results"][1]["session_id"], serde_json::Value::Null);
+ assert_eq!(value["results"][1]["error_kind"], "execution_error");
+ assert_eq!(value["results"][1]["error"], "failed");
+}
+
+#[test]
+fn batch_human_summary_points_to_resume_command_when_session_exists() {
+ let result = BatchTaskResult {
+ index: 1,
+ agent: "OSAgent".to_string(),
+ message: "one\nmore".to_string(),
+ session_id: Some("session-1".to_string()),
+ success: true,
+ duration_ms: 12,
+ error_kind: None,
+ error: None,
+ };
+
+ assert_eq!(
+ batch_result_summary_line(&result),
+ "1. ok | OSAgent | 12 ms | one"
+ );
+ assert_eq!(
+ batch_result_session_line(result.session_id.as_deref()).as_deref(),
+ Some(" session: session-1 (resume with `sparo sessions resume session-1`)")
+ );
+ assert!(batch_result_session_line(None).is_none());
+}
+
+#[test]
+fn batch_summary_human_lines_include_rerun_and_machine_output_actions() {
+ let results = vec![BatchTaskResult {
+ index: 1,
+ agent: "OSAgent".to_string(),
+ message: "one\nmore".to_string(),
+ session_id: Some("session-1".to_string()),
+ success: true,
+ duration_ms: 12,
+ error_kind: None,
+ error: None,
+ }];
+ let summary = batch_summary("tasks file.json", &results);
+ let output = batch_summary_human_lines(&summary).join("\n");
+
+ assert!(output.contains("=== Batch Summary ==="));
+ assert!(output.contains("1 passed, 0 failed, 1 total"));
+ assert!(output.contains("Resume latest session: sparo sessions resume session-1"));
+ assert!(output.contains("Inspect sessions: sparo sessions list"));
+ assert!(output.contains("Rerun batch: sparo batch --tasks \"tasks file.json\""));
+ assert!(output.contains("Machine output: sparo batch --tasks \"tasks file.json\" --json"));
+}
+
+#[test]
+fn batch_summary_human_lines_include_failure_rerun_guidance() {
+ let results = vec![BatchTaskResult {
+ index: 1,
+ agent: "bitfun-debug".to_string(),
+ message: "review".to_string(),
+ session_id: None,
+ success: false,
+ duration_ms: 34,
+ error_kind: Some("execution_error".to_string()),
+ error: Some("failed".to_string()),
+ }];
+ let summary = batch_summary("tasks.json", &results);
+ let output = batch_summary_human_lines(&summary).join("\n");
+
+ assert!(output.contains("0 passed, 1 failed, 1 total"));
+ assert!(output.contains("error kind: execution_error"));
+ assert!(output.contains("error: failed"));
+ assert!(
+ output.contains("Rerun after fixes: sparo batch --tasks tasks.json --continue-on-error")
+ );
+ assert!(!output.contains("Resume latest session:"));
+}
+
+#[test]
+fn batch_summary_human_lines_keep_empty_batch_actionable() {
+ let results = Vec::new();
+ let summary = batch_summary("empty.json", &results);
+ let output = batch_summary_human_lines(&summary).join("\n");
+
+ assert!(output.contains("0 passed, 0 failed, 0 total"));
+ assert!(output.contains("Generate starter file: sparo batch --example json"));
+ assert!(output.contains("Machine output: sparo batch --tasks empty.json --json"));
+}
+
+#[test]
+fn empty_batch_human_lines_include_summary_and_next_actions() {
+ let output = empty_batch_human_lines("empty file.json").join("\n");
+
+ assert!(output.contains("No batch tasks found in empty file.json"));
+ assert!(output.contains("Generate starter file: sparo batch --example json"));
+ assert!(output.contains("=== Batch Summary ==="));
+ assert!(output.contains("Inspect sessions: sparo sessions list"));
+ assert!(output.contains("Machine output: sparo batch --tasks \"empty file.json\" --json"));
+}
+
+#[test]
+fn sessions_list_human_lines_include_next_actions_for_latest_session() {
+ let session = sample_session_metadata();
+ let output =
+ sessions_list_human_lines(&[session], Some("D:\\workspace\\my project")).join("\n");
+
+ assert!(output.contains("History sessions (total 1)"));
+ assert!(output.contains("1. Review CLI sessions (ID: session-1)"));
+ assert!(output.contains("Agent: bitfun-debug | Turns: 3 | Messages: 6"));
+ assert!(output.contains("Next actions:"));
+ assert!(output.contains(
+ "Resume latest: sparo sessions --workspace \"D:\\workspace\\my project\" resume session-1"
+ ));
+ assert!(output.contains(
+ "Show details: sparo sessions --workspace \"D:\\workspace\\my project\" show session-1"
+ ));
+ assert!(output.contains(
+ "Export latest: sparo sessions --workspace \"D:\\workspace\\my project\" export session-1 --output session.md"
+ ));
+ assert!(
+ output.contains("Open TUI history: sparo chat --workspace \"D:\\workspace\\my project\"")
+ );
+}
+
+#[test]
+fn sessions_list_human_lines_keep_empty_history_actionable() {
+ let output = sessions_list_human_lines(&[], None).join("\n");
+
+ assert!(output.contains("No history sessions"));
+ assert!(output.contains("sparo chat"));
+ assert!(output.contains("sparo exec"));
+}
+
+fn sample_session_metadata() -> sparo_core::service::session::SessionMetadata {
+ sparo_core::service::session::SessionMetadata {
+ domain: sparo_core::agentic::core::SessionDomain::Workspace {
+ workspace_id: "ws_test".to_string(),
+ },
+ session_id: "session-1".to_string(),
+ session_name: "Review CLI sessions".to_string(),
+ agent_type: "bitfun-debug".to_string(),
+ created_by: None,
+ session_kind: sparo_core::agentic::core::SessionKind::Standard,
+ model_name: "gpt-test".to_string(),
+ created_at: 1_700_000_000_000,
+ last_active_at: 1_700_000_100_000,
+ turn_count: 3,
+ message_count: 6,
+ tool_call_count: 2,
+ status: sparo_core::service::session::SessionStatus::Active,
+ terminal_session_id: None,
+ snapshot_session_id: None,
+ tags: Vec::new(),
+ custom_metadata: None,
+ todos: None,
+ workspace_path: Some("D:\\workspace\\my project".to_string()),
+ workspace_hostname: None,
+ }
+}
+
+#[test]
+fn session_human_detail_lines_include_next_actions() {
+ let metadata = sample_session_metadata();
+
+ let output = session_human_detail_lines(&metadata).join("\n");
+
+ assert!(output.contains("Session Details"));
+ assert!(output.contains("Title: Review CLI sessions"));
+ assert!(output.contains("Statistics:"));
+ assert!(output.contains(" Tool calls: 2"));
+ assert!(output.contains("Next actions:"));
+ assert!(output.contains(
+ "Resume: sparo sessions --workspace \"D:\\workspace\\my project\" resume session-1"
+ ));
+ assert!(output.contains(
+ "Export: sparo sessions --workspace \"D:\\workspace\\my project\" export session-1 --output session.md"
+ ));
+}
+
+#[test]
+fn session_delete_human_lines_include_recovery_actions() {
+ let output = session_delete_human_lines(
+ "session 1",
+ Some("D:\\workspace\\my project"),
+ "Deleted session: session 1",
+ )
+ .join("\n");
+
+ assert!(output.contains("Session Deleted"));
+ assert!(output.contains("Status: Deleted session: session 1"));
+ assert!(output.contains("Requested session: session 1"));
+ assert!(output.contains("Workspace: D:\\workspace\\my project"));
+ assert!(output.contains(
+ "Inspect sessions: sparo sessions --workspace \"D:\\workspace\\my project\" list"
+ ));
+ assert!(
+ output.contains("Start a new chat: sparo chat --workspace \"D:\\workspace\\my project\"")
+ );
+ assert!(output.contains(
+ "Machine output: sparo sessions --workspace \"D:\\workspace\\my project\" delete \"session 1\" --json"
+ ));
+}
+
+#[test]
+fn session_export_human_lines_include_resume_and_machine_output() {
+ let output = session_export_human_lines(
+ "session 1",
+ Some("D:\\workspace\\my project"),
+ "exports\\session file.md",
+ SessionExportFormat::Markdown,
+ )
+ .join("\n");
+
+ assert!(output.contains("Session Exported"));
+ assert!(output.contains("Session: session 1"));
+ assert!(output.contains("Workspace: D:\\workspace\\my project"));
+ assert!(output.contains("Output: exports\\session file.md"));
+ assert!(output.contains("Format: markdown"));
+ assert!(output.contains("Open file: \"exports\\session file.md\""));
+ assert!(output.contains(
+ "Resume session: sparo sessions --workspace \"D:\\workspace\\my project\" resume \"session 1\""
+ ));
+ assert!(output.contains(
+ "Machine output: sparo sessions --workspace \"D:\\workspace\\my project\" export \"session 1\" --output \"exports\\session file.md\" --format markdown --json"
+ ));
+}
+
+#[test]
+fn agent_summary_line_matches_cli_agents_table_contract() {
+ let agent = sparo_core::agentic::agents::AgentInfo {
+ id: "bitfun-debug".to_string(),
+ name: "Debug".to_string(),
+ description: "Diagnose failures".to_string(),
+ is_readonly: false,
+ tool_count: 3,
+ default_tools: vec!["read_file".to_string()],
+ enabled: true,
+ subagent_source: None,
+ path: None,
+ model: None,
+ app_kind: None,
+ app_icon: None,
+ app_category: None,
+ app_path: None,
+ };
+
+ assert_eq!(
+ agent_summary_line(&agent),
+ "bitfun-debug | Debug | enabled | 3 tools | write"
+ );
+}
+
+#[test]
+fn agents_human_lines_include_launch_and_default_actions() {
+ let agents = vec![sparo_core::agentic::agents::AgentInfo {
+ id: "debug agent".to_string(),
+ name: "Debug Agent".to_string(),
+ description: "Diagnose failures with detailed workspace inspection.".to_string(),
+ is_readonly: false,
+ tool_count: 3,
+ default_tools: vec![
+ "read_file".to_string(),
+ "search".to_string(),
+ "run_command".to_string(),
+ "write_file".to_string(),
+ "edit_file".to_string(),
+ "diagnostics".to_string(),
+ ],
+ enabled: true,
+ subagent_source: None,
+ path: Some("D:\\agents\\debug".to_string()),
+ model: None,
+ app_kind: Some("Agent Component".to_string()),
+ app_icon: None,
+ app_category: None,
+ app_path: None,
+ }];
+
+ let output = agents_human_lines(&agents).join("\n");
+
+ assert!(output.contains("Available Agents (total 1)"));
+ assert!(output.contains("debug agent | Debug Agent | enabled | 3 tools | write"));
+ assert!(
+ output.contains("tools: read_file, search, run_command, write_file, edit_file (+1 more)")
+ );
+ assert!(output.contains("Chat with agent: sparo chat --agent \"debug agent\""));
+ assert!(output.contains("One-shot run: sparo exec --agent \"debug agent\" \"\""));
+ assert!(output
+ .contains("Make default: sparo config prefs set behavior.default_agent \"debug agent\""));
+ assert!(output.contains("Machine output: sparo agents list --json"));
+}
+
+#[test]
+fn agents_human_lines_do_not_launch_disabled_only_registry() {
+ let agents = vec![sparo_core::agentic::agents::AgentInfo {
+ id: "disabled".to_string(),
+ name: "Disabled".to_string(),
+ description: "Currently disabled.".to_string(),
+ is_readonly: true,
+ tool_count: 0,
+ default_tools: Vec::new(),
+ enabled: false,
+ subagent_source: None,
+ path: None,
+ model: None,
+ app_kind: None,
+ app_icon: None,
+ app_category: None,
+ app_path: None,
+ }];
+
+ let output = agents_human_lines(&agents).join("\n");
+
+ assert!(output.contains("disabled | Disabled | disabled | 0 tools | readonly"));
+ assert!(output.contains("Enable an agent in configuration before launching chat or exec."));
+ assert!(!output.contains("sparo chat --agent disabled"));
+}
+
+#[test]
+fn compact_description_truncates_noisy_human_output() {
+ let description = "Produces a comprehensive deep-research report on any subject using parallel sub-agent orchestration. Dispatches multiple research agents concurrently to investigate different chapters and competitors simultaneously, then synthesizes findings into a cohesive report.";
+
+ let compact = compact_description(description).unwrap();
+
+ assert!(compact.contains("Produces a comprehensive"));
+ assert!(compact.ends_with("..."));
+ assert!(!compact.contains("then synthesizes findings into a cohesive report"));
+ assert_eq!(compact_description(" \n\t "), None);
+}
+
+#[test]
+fn compact_description_preserves_short_app_descriptions() {
+ assert_eq!(
+ compact_description("Open local Bridge Components from the CLI").as_deref(),
+ Some("Open local Bridge Components from the CLI")
+ );
+}
+
+#[test]
+fn batch_error_kind_classifies_timeouts() {
+ assert_eq!(
+ batch_error_kind("CLI exec timed out after 1 seconds"),
+ "timeout"
+ );
+ assert_eq!(batch_error_kind("join cancelled"), "cancelled");
+ assert_eq!(batch_error_kind("other failure"), "execution_error");
+}
+
+#[test]
+fn cli_prefs_get_and_set_known_paths() {
+ let mut config = CliConfig::default();
+
+ set_cli_pref(&mut config, "ui.theme", "light").unwrap();
+ set_cli_pref(&mut config, "ui.show_tips", "off").unwrap();
+ set_cli_pref(&mut config, "ui.color_scheme", "blue").unwrap();
+ set_cli_pref(&mut config, "behavior.default_agent", "bitfun-debug").unwrap();
+ set_cli_pref(&mut config, "behavior.confirm_dangerous", "false").unwrap();
+ set_cli_pref(&mut config, "workspace.default_path", "D:\\workspace").unwrap();
+ set_cli_pref(&mut config, "shortcuts.send_message", "ctrl+s").unwrap();
+ set_cli_pref(&mut config, "shortcuts.interrupt", "Ctrl+X").unwrap();
+ set_cli_pref(&mut config, "shortcuts.menu", "escape").unwrap();
+
+ assert_eq!(
+ cli_prefs_value(&config, Some("ui.theme")).unwrap(),
+ serde_json::json!("light")
+ );
+ assert_eq!(
+ cli_prefs_value(&config, Some("ui.show_tips")).unwrap(),
+ serde_json::json!(false)
+ );
+ assert_eq!(
+ cli_prefs_value(&config, Some("ui.color_scheme")).unwrap(),
+ serde_json::json!("blue")
+ );
+ assert_eq!(
+ cli_prefs_value(&config, Some("behavior.default_agent")).unwrap(),
+ serde_json::json!("bitfun-debug")
+ );
+ assert_eq!(
+ cli_prefs_value(&config, Some("behavior.confirm_dangerous")).unwrap(),
+ serde_json::json!(false)
+ );
+ assert_eq!(
+ cli_prefs_value(&config, Some("workspace.default_path")).unwrap(),
+ serde_json::json!("D:\\workspace")
+ );
+ assert_eq!(
+ cli_prefs_value(&config, Some("shortcuts.send_message")).unwrap(),
+ serde_json::json!("Ctrl+S")
+ );
+ assert_eq!(
+ cli_prefs_value(&config, Some("shortcuts.interrupt")).unwrap(),
+ serde_json::json!("Ctrl+X")
+ );
+ assert_eq!(
+ cli_prefs_value(&config, Some("shortcuts.menu")).unwrap(),
+ serde_json::json!("Esc")
+ );
+}
+
+#[test]
+fn cli_prefs_human_output_respects_json_flag_contract() {
+ let mut config = CliConfig::default();
+ config.behavior.default_agent = "bitfun-debug".to_string();
+ config.workspace.default_path = "D:\\workspace".to_string();
+
+ assert_eq!(
+ cli_prefs_get_human_output(&config, Some("behavior.default_agent")).unwrap(),
+ "behavior.default_agent = bitfun-debug"
+ );
+
+ let full = cli_prefs_get_human_output(&config, None).unwrap();
+ assert!(full.contains("CLI Preferences"));
+ assert!(full.contains("Default Agent: bitfun-debug"));
+ assert!(full.contains("Default workspace: D:\\workspace"));
+ assert!(full.contains("Exclude patterns: node_modules, .git, target, dist"));
+ assert!(full.contains("CLI preference file: "));
+ assert!(full.contains("Next actions:"));
+ assert!(full.contains("Edit preferences: sparo config edit"));
+ assert!(full.contains("Validate preferences: sparo health"));
+ assert!(full.contains("Machine output: sparo config prefs get --json"));
+
+ let set = cli_prefs_set_human_output(
+ "ui.show_tips",
+ &serde_json::json!(false),
+ std::path::Path::new("C:\\Users\\example\\config.toml"),
+ )
+ .unwrap();
+ assert!(set.contains("Set ui.show_tips = false"));
+ assert!(set.contains("CLI preference file: C:\\Users\\example\\config.toml"));
+ assert!(set.contains("Inspect CLI preferences: sparo config prefs get"));
+ assert!(set.contains("Validate CLI preferences: sparo health"));
+ assert!(set.contains("Machine output: sparo config prefs get ui.show_tips --json"));
+}
+
+#[test]
+fn shared_config_summary_keeps_default_config_show_scannable() {
+ let value = serde_json::json!({
+ "revision": 12,
+ "catalogVersion": "sha256:catalog",
+ "publishedSettingCount": 42,
+ "models": {
+ "configured": 2,
+ "enabled": 1
+ },
+ "defaultModels": {
+ "primary": "primary-model",
+ "fast": "fast-model"
+ },
+ "agentModelMappings": 2,
+ "appLanguage": "zh-CN"
+ });
+
+ let output = shared_config_summary_lines(&value).join("\n");
+
+ assert!(output.contains("Shared Global Configuration Summary"));
+ assert!(output.contains("Revision: 12"));
+ assert!(output.contains("Catalog: sha256:catalog (42 settings)"));
+ assert!(output.contains("Models: 2 configured, 1 enabled"));
+ assert!(output.contains("Default models: primary=primary-model, fast=fast-model"));
+ assert!(output.contains("Agent model mappings: 2"));
+ assert!(output.contains("App language: zh-CN"));
+ assert!(output.contains("sparo config export"));
+ assert!(!output.contains("\"models\""));
+ assert!(!output.contains("\"api_key\""));
+}
+
+#[test]
+fn config_validate_human_lines_summarize_status_and_next_actions() {
+ let value = serde_json::json!({
+ "valid": false,
+ "errors": ["Missing primary model"],
+ "warnings": ["Provider disabled"]
+ });
+
+ let output = config_validate_human_lines(&value).join("\n");
+
+ assert!(output.contains("Shared Global Configuration Validation"));
+ assert!(output.contains("Status: invalid"));
+ assert!(output.contains("Errors: 1"));
+ assert!(output.contains("Warnings: 1"));
+ assert!(output.contains(" - Missing primary model"));
+ assert!(output.contains("Edit config: sparo config edit"));
+ assert!(output.contains("Inspect config: sparo config show"));
+ assert!(output.contains("Run health: sparo config health"));
+ assert!(output.contains("Machine output: sparo config validate --json"));
+}
+
+#[test]
+fn config_health_human_lines_summarize_health_and_next_actions() {
+ let value = serde_json::json!({
+ "healthy": false,
+ "message": "Configuration system is unavailable",
+ "config_directory": "C:\\Users\\example\\sparo_os\\config",
+ "warnings": ["Failed to load configuration service"]
+ });
+
+ let output = config_health_human_lines(&value).join("\n");
+
+ assert!(output.contains("Shared Global Configuration Health"));
+ assert!(output.contains("Status: needs attention"));
+ assert!(output.contains("Message: Configuration system is unavailable"));
+ assert!(output.contains("Config directory: C:\\Users\\example\\sparo_os\\config"));
+ assert!(output.contains(" - Failed to load configuration service"));
+ assert!(output.contains("Run CLI health: sparo health"));
+ assert!(output.contains("Validate config: sparo config validate"));
+ assert!(output.contains("Machine output: sparo config health --json"));
+}
+
+#[test]
+fn config_edit_human_lines_include_editor_and_validation_commands() {
+ let path = std::path::Path::new("C:\\Users\\example\\sparo_os\\cli.toml");
+ let output = config_edit_human_lines(path).join("\n");
+
+ assert!(output.contains("CLI Preference File"));
+ assert!(output.contains("Path: C:\\Users\\example\\sparo_os\\cli.toml"));
+ assert!(output.contains("code C:\\Users\\example\\sparo_os\\cli.toml"));
+ #[cfg(target_os = "windows")]
+ assert!(output.contains("notepad C:\\Users\\example\\sparo_os\\cli.toml"));
+ assert!(output.contains("Validate CLI preferences: sparo health"));
+ assert!(output.contains("Inspect CLI preferences: sparo config prefs get"));
+ assert!(output.contains("Inspect shared config: sparo config show"));
+ assert!(!output.contains("\\\"C:\\\\Users"));
+}
+
+#[test]
+fn config_set_human_lines_include_commit_and_next_actions() {
+ let commit = test_config_commit(sparo_core::service::config::ConfigCommitStatus::Applying);
+ let output = config_set_human_lines("core.ai.default_models.primary", &commit).join("\n");
+
+ assert!(output.contains("Shared Global Configuration Updated"));
+ assert!(output.contains("Status: applying"));
+ assert!(output.contains("Commit ID: cfg-commit-test"));
+ assert!(output.contains("Revision: 42"));
+ assert!(output.contains("Setting ID: core.ai.default_models.primary"));
+ assert!(!output.contains("gpt-demo"));
+ assert!(!output.contains("Value:"));
+ assert!(output.contains("Inspect value: sparo config get core.ai.default_models.primary"));
+ assert!(output.contains("Validate config: sparo config validate"));
+ assert!(output.contains("Use --json on future changes for machine-readable output"));
+}
+
+#[test]
+fn config_reset_human_lines_require_one_setting_id_and_include_commit() {
+ let commit = test_config_commit(sparo_core::service::config::ConfigCommitStatus::Applied);
+ let output = config_reset_human_lines("core.ai.default_models.primary", &commit).join("\n");
+
+ assert!(output.contains("Shared Global Configuration Reset"));
+ assert!(output.contains("Status: applied"));
+ assert!(output.contains("Commit ID: cfg-commit-test"));
+ assert!(output.contains("Revision: 42"));
+ assert!(output.contains("Setting ID: core.ai.default_models.primary"));
+ assert!(output.contains("Inspect value: sparo config get core.ai.default_models.primary"));
+ assert!(
+ output.contains("Machine output: sparo config reset core.ai.default_models.primary --json")
+ );
+}
+
+#[test]
+fn config_reset_setting_id_is_required_by_clap() {
+ let error = Cli::try_parse_from(["sparo", "config", "reset"])
+ .err()
+ .expect("reset without a setting ID must be rejected");
+ assert_eq!(
+ error.kind(),
+ clap::error::ErrorKind::MissingRequiredArgument
+ );
+}
+
+#[test]
+fn config_show_does_not_duplicate_single_setting_lookup() {
+ let error = Cli::try_parse_from([
+ "sparo",
+ "config",
+ "show",
+ "--setting-id",
+ "core.ai.default_models.primary",
+ ])
+ .err()
+ .expect("single-setting lookup belongs to `config get`");
+ assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument);
+}
+
+#[test]
+fn config_import_human_lines_report_only_successful_outcomes() {
+ let output =
+ config_import_human_lines("C:\\Users\\example\\config export.json", None).join("\n");
+
+ assert!(output.contains("Shared Global Configuration Imported"));
+ assert!(output.contains("Status: no changes"));
+ assert!(output.contains("File: C:\\Users\\example\\config export.json"));
+ assert!(output.contains("Validate config: sparo config validate"));
+ assert!(output.contains(
+ "Machine output: sparo config import \"C:\\Users\\example\\config export.json\" --json"
+ ));
+}
+
+#[test]
+fn cli_presentation_preferences_summary_includes_operational_defaults() {
+ let mut config = CliConfig::default();
+ config.behavior.default_agent = "bitfun-debug".to_string();
+ config.behavior.confirm_dangerous = false;
+ config.workspace.default_path = "D:\\workspace\\project".to_string();
+
+ let lines = cli_presentation_preference_lines(&config).join("\n");
+
+ assert!(lines.contains("Default Agent: bitfun-debug"));
+ assert!(lines.contains("Confirm dangerous tools: false"));
+ assert!(lines.contains("Default workspace: D:\\workspace\\project"));
+ assert!(lines.contains("Send shortcut: Ctrl+D"));
+}
+
+#[test]
+fn cli_config_path_line_uses_copyable_display_path() {
+ let line = cli_config_path_line(std::path::Path::new(
+ "C:\\Users\\example\\sparo_os\\config.toml",
+ ));
+
+ assert_eq!(
+ line,
+ " CLI preference file: C:\\Users\\example\\sparo_os\\config.toml"
+ );
+ assert!(!line.contains("\\\\"));
+ assert!(!line.contains('"'));
+}
+
+#[test]
+fn cli_prefs_reject_unknown_paths_and_values() {
+ let mut config = CliConfig::default();
+
+ assert!(set_cli_pref(&mut config, "ui.theme", "blue").is_err());
+ assert!(set_cli_pref(&mut config, "ui.color_scheme", "infrared").is_err());
+ assert!(set_cli_pref(&mut config, "behavior.default_agent", " ").is_err());
+ assert!(set_cli_pref(&mut config, "shortcuts.send_message", "Ctrl+Delete").is_err());
+ assert!(set_cli_pref(&mut config, "shortcuts.interrupt", "Shift+X").is_err());
+ assert!(set_cli_pref(&mut config, "shortcuts.send_message", "Esc").is_err());
+ assert!(set_cli_pref(&mut config, "shortcuts.interrupt", "Enter").is_err());
+ assert!(set_cli_pref(&mut config, "shortcuts.menu", "Enter").is_err());
+ assert!(set_cli_pref(&mut config, "ui.unknown", "x").is_err());
+ assert!(cli_prefs_value(&config, Some("ui.unknown")).is_err());
+}
+
+#[test]
+fn cli_prefs_reject_conflicting_shortcuts_without_mutating_config() {
+ let mut config = CliConfig::default();
+ let original_send = config.shortcuts.send_message.clone();
+
+ let error = set_cli_pref(&mut config, "shortcuts.send_message", "Ctrl+C").unwrap_err();
+
+ assert!(error
+ .to_string()
+ .contains("shortcuts.send_message and shortcuts.interrupt cannot both use Ctrl+C"));
+ assert_eq!(config.shortcuts.send_message, original_send);
+ assert_eq!(config.shortcuts.interrupt, "Ctrl+C");
+}
+
+#[test]
+fn effective_workspace_hint_prefers_explicit_then_cli_default() {
+ let mut config = CliConfig::default();
+ config.workspace.default_path = "D:\\workspace\\project".to_string();
+
+ assert_eq!(
+ effective_workspace_hint(&config, Some("D:\\explicit")).as_deref(),
+ Some("D:\\explicit")
+ );
+ assert_eq!(
+ effective_workspace_hint(&config, None).as_deref(),
+ Some("D:\\workspace\\project")
+ );
+
+ config.workspace.default_path.clear();
+ assert_eq!(effective_workspace_hint(&config, None), None);
+
+ config.workspace.default_path = "global".to_string();
+ assert!(effective_workspace_hint(&config, None).is_some());
+}
+
+#[test]
+fn effective_workspace_hint_normalizes_explicit_dot_and_global() {
+ let config = CliConfig::default();
+ let current = std::env::current_dir()
+ .unwrap()
+ .to_string_lossy()
+ .to_string();
+
+ assert_eq!(
+ effective_workspace_hint(&config, Some(".")).as_deref(),
+ Some(current.as_str())
+ );
+ assert_eq!(
+ effective_workspace_hint(&config, Some(" . ")).as_deref(),
+ Some(current.as_str())
+ );
+ assert!(effective_workspace_hint(&config, Some("global")).is_some());
+ assert_eq!(
+ effective_workspace_hint(&config, Some(" ")).as_deref(),
+ Some(current.as_str())
+ );
+}
+
+fn sample_intelligent_app_catalog() -> CliIntelligentAppCatalog {
+ let scope = sparo_core::app_platform::AppActivationScope::System;
+ let app = sparo_core::app_platform::AppRecord {
+ app_id: "runno".to_string(),
+ slot_id: "assistant".to_string(),
+ display_name: "Runno".to_string(),
+ description: Some("Built-in Intelligent App".to_string()),
+ owner: sparo_core::app_platform::AppOwner::system(),
+ derived_from: None,
+ created_at_ms: 1,
+ };
+ let activation = sparo_core::app_platform::ActivationRecord {
+ scope: scope.clone(),
+ slot_id: app.slot_id.clone(),
+ selected_app_id: app.app_id.clone(),
+ active_release_id: "release_runno_1".to_string(),
+ enabled: true,
+ };
+ CliIntelligentAppCatalog {
+ slots: vec![sparo_core::app_platform::AppSlotProjection {
+ slot_id: app.slot_id.clone(),
+ display_name: app.display_name.clone(),
+ activation: Some(activation),
+ variants: vec![sparo_core::app_platform::AppVariantProjection {
+ app,
+ releases: Vec::new(),
+ latest_release: None,
+ upstream_base_release_id: None,
+ upstream_latest_release_id: None,
+ upstream_update_available: false,
+ state: sparo_core::app_platform::AppVariantState::Active,
+ }],
+ }],
+ drafts: Vec::new(),
+ issues: Vec::new(),
+ }
+}
+
+#[test]
+fn intelligent_app_details_match_app_id_or_name_case_insensitively() {
+ let catalog = sample_intelligent_app_catalog();
+
+ assert_eq!(
+ intelligent_app_details(&catalog, "RUNNO")
+ .unwrap()
+ .app
+ .app_id,
+ "runno"
+ );
+ assert_eq!(
+ intelligent_app_details(&catalog, "runno")
+ .unwrap()
+ .app
+ .display_name,
+ "Runno"
+ );
+ assert!(intelligent_app_details(&catalog, "missing").is_err());
+}
+
+#[test]
+fn app_human_detail_lines_report_activation_and_desktop_launch_boundary() {
+ let catalog = sample_intelligent_app_catalog();
+ let app = intelligent_app_details(&catalog, "runno").unwrap();
+
+ let output = app_human_detail_lines(&app).join("\n");
+
+ assert!(output.contains("Intelligent App Details"));
+ assert!(output.contains("App ID: runno"));
+ assert!(output.contains("Active release: release_runno_1"));
+ assert!(output.contains("Inspect: sparo apps show runno"));
+ assert!(output.contains("Launch: use Sparo Desktop Apps Center"));
+ assert!(!output.contains("sparo apps open"));
+}
+
+#[test]
+fn apps_list_human_lines_report_slots_variants_and_activation() {
+ let catalog = sample_intelligent_app_catalog();
+ let output = apps_list_human_lines(&catalog).join("\n");
+
+ assert!(output.contains("Intelligent Apps (1 slots, 1 variants)"));
+ assert!(output.contains("Runno | slot assistant"));
+ assert!(output.contains("activation: enabled | app runno | release release_runno_1"));
+ assert!(output.contains("runno | active | owner system"));
+ assert!(output.contains("Inspect latest: sparo apps show runno"));
+ assert!(output.contains("Launch: use Sparo Desktop Apps Center"));
+}
+
+#[test]
+fn apps_list_human_lines_empty_state_guides_desktop_builder() {
+ let catalog = CliIntelligentAppCatalog {
+ slots: Vec::new(),
+ drafts: Vec::new(),
+ issues: Vec::new(),
+ };
+ let output = apps_list_human_lines(&catalog).join("\n");
+
+ assert!(output.contains("No Intelligent Apps are available."));
+ assert!(output.contains("Sparo Desktop Apps Center / App Builder"));
+ assert!(output.contains("Machine output: sparo apps list --json"));
+}
+
+#[test]
+fn find_workspace_row_matches_label_path_or_global() {
+ let workspaces = vec![
+ sparo_core::command::agentic_os::AgenticOsWorkspaceRow {
+ label: "global".to_string(),
+ path: None,
+ git: None,
+ session_count: 0,
+ },
+ sparo_core::command::agentic_os::AgenticOsWorkspaceRow {
+ label: "Project".to_string(),
+ path: Some("D:\\workspace\\project".to_string()),
+ git: Some("git main".to_string()),
+ session_count: 2,
+ },
+ ];
+
+ assert!(find_workspace_row(&workspaces, "GLOBAL")
+ .unwrap()
+ .path
+ .is_none());
+ assert_eq!(
+ find_workspace_row(&workspaces, "project")
+ .unwrap()
+ .path
+ .as_deref(),
+ Some("D:\\workspace\\project")
+ );
+ assert_eq!(
+ find_workspace_row(&workspaces, "D:/workspace/project")
+ .unwrap()
+ .label,
+ "Project"
+ );
+ assert!(find_workspace_row(&workspaces, "missing").is_none());
+}
+
+#[test]
+fn resolve_workspace_row_accepts_existing_direct_path() {
+ let temp_root =
+ std::env::temp_dir().join(format!("sparo-cli-workspace-test-{}", uuid::Uuid::new_v4()));
+ std::fs::create_dir_all(&temp_root).unwrap();
+
+ let row = resolve_workspace_row(&[], temp_root.to_str().unwrap()).unwrap();
+
+ assert_eq!(
+ row.label,
+ temp_root.file_name().unwrap().to_string_lossy().to_string()
+ );
+ let expected_path = display_workspace_path(&temp_root.canonicalize().unwrap());
+ assert_eq!(row.path.as_deref(), Some(expected_path.as_str()));
+ assert_eq!(row.session_count, 0);
+
+ std::fs::remove_dir_all(temp_root).unwrap();
+}
+
+#[test]
+fn workspace_human_detail_lines_include_use_and_chat_actions() {
+ let workspace = sparo_core::command::agentic_os::AgenticOsWorkspaceRow {
+ label: "my project".to_string(),
+ path: Some("D:\\workspace\\my project".to_string()),
+ git: Some("git main".to_string()),
+ session_count: 3,
+ };
+
+ let output = workspace_human_detail_lines(&workspace).join("\n");
+
+ assert!(output.contains("Workspace Details"));
+ assert!(output.contains("Path: D:\\workspace\\my project"));
+ assert!(output.contains("Sessions: 3"));
+ assert!(output.contains("Next actions:"));
+ assert!(output.contains("Use: sparo workspaces use \"my project\""));
+ assert!(output.contains("Chat: sparo chat --workspace \"D:\\workspace\\my project\""));
+}
+
+#[test]
+fn workspace_human_detail_lines_make_global_chat_explicit() {
+ let workspace = sparo_core::command::agentic_os::AgenticOsWorkspaceRow {
+ label: "global".to_string(),
+ path: None,
+ git: None,
+ session_count: 1,
+ };
+
+ let output = workspace_human_detail_lines(&workspace).join("\n");
+
+ assert!(output.contains("Path: Agentic OS global runtime"));
+ assert!(output.contains("Use: sparo workspaces use global"));
+ assert!(output.contains("Chat: sparo chat --workspace global"));
+}
+
+#[test]
+fn workspace_use_human_lines_include_preference_and_next_actions() {
+ let workspace = sparo_core::command::agentic_os::AgenticOsWorkspaceRow {
+ label: "my project".to_string(),
+ path: Some("D:\\workspace\\my project".to_string()),
+ git: Some("git main".to_string()),
+ session_count: 3,
+ };
+
+ let output = workspace_use_human_lines(
+ &workspace,
+ "D:\\workspace\\my project",
+ std::path::Path::new("C:\\Users\\example\\sparo_os\\config.toml"),
+ )
+ .join("\n");
+
+ assert!(output.contains("CLI Default Workspace Updated"));
+ assert!(output.contains("Workspace: my project"));
+ assert!(output.contains("Path: D:\\workspace\\my project"));
+ assert!(output.contains("CLI preference file: C:\\Users\\example\\sparo_os\\config.toml"));
+ assert!(output.contains("Start chat: sparo chat"));
+ assert!(output.contains("Inspect workspace: sparo workspaces show \"my project\""));
+ assert!(output.contains("Inspect preference: sparo config prefs get workspace.default_path"));
+ assert!(output.contains("Machine output: sparo workspaces use \"my project\" --json"));
+}
+
+#[test]
+fn workspaces_list_human_lines_include_next_actions_for_first_workspace() {
+ let workspaces = vec![sparo_core::command::agentic_os::AgenticOsWorkspaceRow {
+ label: "my project".to_string(),
+ path: Some("D:\\workspace\\my project".to_string()),
+ git: Some("git main".to_string()),
+ session_count: 3,
+ }];
+
+ let output = workspaces_list_human_lines(&workspaces).join("\n");
+
+ assert!(output.contains("Known Workspaces (total 1)"));
+ assert!(output.contains("my project"));
+ assert!(output.contains("path: D:\\workspace\\my project"));
+ assert!(output.contains("Next actions:"));
+ assert!(output.contains("Use latest: sparo workspaces use \"my project\""));
+ assert!(output.contains("Show details: sparo workspaces show \"my project\""));
+ assert!(output.contains("Chat here: sparo chat --workspace \"D:\\workspace\\my project\""));
+ assert!(output.contains("Machine output: sparo workspaces list --json"));
+}
+
+#[test]
+fn workspaces_list_human_lines_make_global_chat_explicit() {
+ let workspaces = vec![sparo_core::command::agentic_os::AgenticOsWorkspaceRow {
+ label: "global".to_string(),
+ path: None,
+ git: None,
+ session_count: 1,
+ }];
+
+ let output = workspaces_list_human_lines(&workspaces).join("\n");
+
+ assert!(output.contains("path: Agentic OS global runtime"));
+ assert!(output.contains("Use latest: sparo workspaces use global"));
+ assert!(output.contains("Show details: sparo workspaces show global"));
+ assert!(output.contains("Chat here: sparo chat --workspace global"));
+}
+
+#[test]
+fn workspaces_list_human_lines_empty_state_stays_scriptable() {
+ let output = workspaces_list_human_lines(&[]).join("\n");
+
+ assert!(output.contains("Known Workspaces (total 0)"));
+ assert!(output.contains("Add a project by opening chat with --workspace ."));
+ assert!(output.contains("Machine output: sparo workspaces list --json"));
+}
+
+#[test]
+fn resolve_workspace_row_rejects_missing_direct_path() {
+ let missing = std::env::temp_dir().join(format!(
+ "sparo-cli-workspace-missing-{}",
+ uuid::Uuid::new_v4()
+ ));
+
+ assert!(resolve_workspace_row(&[], missing.to_str().unwrap()).is_none());
+}
+
+#[test]
+fn find_memory_row_matches_file_scope_or_path() {
+ let memories = vec![sparo_core::command::agentic_os::AgenticOsMemoryRow {
+ scope: "PROJECT".to_string(),
+ file: "notes.md".to_string(),
+ target: "D:\\workspace\\.sparo_os".to_string(),
+ }];
+
+ assert_eq!(
+ find_memory_row(&memories, "NOTES.md").unwrap().scope,
+ "PROJECT"
+ );
+ assert_eq!(
+ find_memory_row(&memories, "project:notes.md").unwrap().file,
+ "notes.md"
+ );
+ assert_eq!(
+ find_memory_row(&memories, "D:/workspace/.sparo_os/notes.md")
+ .unwrap()
+ .scope,
+ "PROJECT"
+ );
+ assert!(find_memory_row(&memories, "missing.md").is_none());
+}
+
+#[test]
+fn read_memory_content_reports_truncation_metadata() {
+ let temp_root =
+ std::env::temp_dir().join(format!("sparo-cli-memory-test-{}", uuid::Uuid::new_v4()));
+ std::fs::create_dir_all(&temp_root).expect("create temp memory root");
+ let file = temp_root.join("notes.md");
+ std::fs::write(&file, "abcdef").expect("write temp memory file");
+ let memory = sparo_core::command::agentic_os::AgenticOsMemoryRow {
+ scope: "PROJECT".to_string(),
+ file: "notes.md".to_string(),
+ target: temp_root.to_string_lossy().to_string(),
+ };
+
+ let preview = read_memory_content(&memory, 3).expect("read truncated memory content");
+
+ assert_eq!(preview.content, "abc");
+ assert_eq!(preview.max_bytes, 3);
+ assert_eq!(preview.bytes_read, 3);
+ assert_eq!(preview.total_bytes, 6);
+ assert!(preview.truncated);
+
+ let value = memory_content_json(&memory, &preview);
+ assert_eq!(value["content"], "abc");
+ assert_eq!(value["max_bytes"], 3);
+ assert_eq!(value["truncated_to_bytes"], 3);
+ assert_eq!(value["bytes_read"], 3);
+ assert_eq!(value["total_bytes"], 6);
+ assert_eq!(value["truncated"], true);
+
+ std::fs::remove_dir_all(temp_root).expect("remove temp memory root");
+}
+
+#[test]
+fn memory_human_detail_lines_include_next_actions_and_truncation() {
+ let memory = sparo_core::command::agentic_os::AgenticOsMemoryRow {
+ scope: "PROJECT".to_string(),
+ file: "notes.md".to_string(),
+ target: "D:\\workspace\\my project\\.sparo_os".to_string(),
+ };
+ let preview = MemoryContentPreview {
+ content: "abc".to_string(),
+ max_bytes: 3,
+ bytes_read: 3,
+ total_bytes: 6,
+ truncated: true,
+ };
+
+ let output =
+ memory_human_detail_lines(&memory, &preview, Some("D:\\workspace\\my project")).join("\n");
+
+ assert!(output.contains("Memory: PROJECT | notes.md"));
+ assert!(output.contains("abc"));
+ assert!(output.contains("[truncated: showing 3 of 6 bytes"));
+ assert!(output.contains("Next actions:"));
+ assert!(output.contains(
+ "Show full file: sparo memory --workspace \"D:\\workspace\\my project\" show project:notes.md --max-bytes 6"
+ ));
+ assert!(
+ output.contains("Discuss in chat: sparo chat --workspace \"D:\\workspace\\my project\"")
+ );
+}
+
+#[test]
+fn memory_human_detail_lines_omit_truncation_when_complete() {
+ let memory = sparo_core::command::agentic_os::AgenticOsMemoryRow {
+ scope: "GLOBAL".to_string(),
+ file: "profile.md".to_string(),
+ target: "D:\\sparo\\memory".to_string(),
+ };
+ let preview = MemoryContentPreview {
+ content: "complete".to_string(),
+ max_bytes: 1024,
+ bytes_read: 8,
+ total_bytes: 8,
+ truncated: false,
+ };
+
+ let output = memory_human_detail_lines(&memory, &preview, None).join("\n");
+
+ assert!(!output.contains("[truncated"));
+ assert!(output.contains("Show full file: sparo memory show global:profile.md --max-bytes 8"));
+ assert!(output.contains("Discuss in chat: sparo chat"));
+}
+
+#[test]
+fn memory_list_human_lines_include_show_and_chat_actions() {
+ let memories = vec![sparo_core::command::agentic_os::AgenticOsMemoryRow {
+ scope: "PROJECT".to_string(),
+ file: "notes.md".to_string(),
+ target: "C:\\SparoData\\workspaces\\ws_test\\memory".to_string(),
+ }];
+
+ let output = memory_list_human_lines(&memories, Some("D:\\workspace\\my project")).join("\n");
+
+ assert!(output.contains("Memory Files (total 1)"));
+ assert!(output.contains("PROJECT | notes.md"));
+ assert!(output.contains("Next actions:"));
+ assert!(output.contains(
+ "Show latest: sparo memory --workspace \"D:\\workspace\\my project\" show project:notes.md"
+ ));
+ assert!(
+ output.contains("Discuss in chat: sparo chat --workspace \"D:\\workspace\\my project\"")
+ );
+ assert!(output.contains("Machine output: sparo memory list --json"));
+}
+
+#[test]
+fn memory_list_human_lines_keep_empty_snapshot_actionable() {
+ let output = memory_list_human_lines(&[], None).join("\n");
+
+ assert!(output.contains("No memory files are available in this snapshot."));
+ assert!(!output.contains(".sparo_os/memory"));
+ assert!(output.contains("Memory surface"));
+ assert!(output.contains("sparo health"));
+ assert!(output.contains("sparo chat"));
+}
+
+#[test]
+fn find_task_row_matches_session_id_or_title() {
+ let tasks = vec![sparo_core::command::agentic_os::AgenticOsTaskRow {
+ title: "Fix bug".to_string(),
+ agent: "bitfun-debug".to_string(),
+ status: "active".to_string(),
+ detail: "2 turns".to_string(),
+ session_id: Some("task-session".to_string()),
+ workspace: Some("D:\\workspace".to_string()),
+ }];
+
+ assert_eq!(
+ find_task_row(&tasks, "TASK-SESSION").unwrap().title,
+ "Fix bug"
+ );
+ assert_eq!(
+ find_task_row(&tasks, "fix bug")
+ .unwrap()
+ .session_id
+ .as_deref(),
+ Some("task-session")
+ );
+ assert_eq!(find_task_row(&tasks, "last").unwrap().title, "Fix bug");
+ assert!(find_task_row(&tasks, "missing").is_none());
+}
+
+#[test]
+fn resolve_task_last_reports_empty_task_list() {
+ let error = resolve_task_from_rows(&[], "last").unwrap_err();
+ assert!(error
+ .to_string()
+ .contains("No backend-tracked agent tasks found"));
+}
+
+#[test]
+fn task_human_detail_lines_include_next_actions_for_persisted_tasks() {
+ let task = sparo_core::command::agentic_os::AgenticOsTaskRow {
+ title: "Review CLI task flow".to_string(),
+ agent: "bitfun-debug".to_string(),
+ status: "active".to_string(),
+ detail: "Needs handoff".to_string(),
+ session_id: Some("task-session".to_string()),
+ workspace: Some("D:\\workspace\\my project".to_string()),
+ };
+
+ let output = task_human_detail_lines(&task).join("\n");
+
+ assert!(output.contains("Task Details"));
+ assert!(output.contains("Session: task-session"));
+ assert!(output.contains("Next actions:"));
+ assert!(output.contains(
+ "Resume: sparo tasks --workspace \"D:\\workspace\\my project\" resume task-session"
+ ));
+ assert!(output.contains(
+ "Export: sparo tasks --workspace \"D:\\workspace\\my project\" export task-session --output task.md"
+ ));
+}
+
+#[test]
+fn task_human_detail_lines_explain_unsaved_task_export_limit() {
+ let task = sparo_core::command::agentic_os::AgenticOsTaskRow {
+ title: "Review CLI task flow".to_string(),
+ agent: "bitfun-debug".to_string(),
+ status: "active".to_string(),
+ detail: "Needs handoff".to_string(),
+ session_id: None,
+ workspace: None,
+ };
+
+ let output = task_human_detail_lines(&task).join("\n");
+
+ assert!(output.contains("Session: none"));
+ assert!(output.contains("Resume: sparo tasks resume \"Review CLI task flow\""));
+ assert!(
+ output.contains("Export: unavailable until this task has a persisted session transcript.")
+ );
+}
+
+#[test]
+fn task_export_human_lines_include_resume_and_machine_output() {
+ let output = task_export_human_lines(
+ "task title",
+ "session 1",
+ "exports\\task.json",
+ SessionExportFormat::Json,
+ )
+ .join("\n");
+
+ assert!(output.contains("Task Exported"));
+ assert!(output.contains("Task: task title"));
+ assert!(output.contains("Session: session 1"));
+ assert!(output.contains("Output: exports\\task.json"));
+ assert!(output.contains("Format: json"));
+ assert!(output.contains("Open file: exports\\task.json"));
+ assert!(output.contains("Inspect task: sparo tasks show \"session 1\""));
+ assert!(output.contains("Resume task: sparo tasks resume \"session 1\""));
+ assert!(output.contains(
+ "Machine output: sparo tasks export \"session 1\" --output exports\\task.json --format json --json"
+ ));
+}
+
+#[test]
+fn tasks_list_human_lines_include_next_actions_for_persisted_latest_task() {
+ let tasks = vec![sparo_core::command::agentic_os::AgenticOsTaskRow {
+ title: "Review CLI task flow".to_string(),
+ agent: "bitfun-debug".to_string(),
+ status: "active".to_string(),
+ detail: "Needs handoff".to_string(),
+ session_id: Some("task-session".to_string()),
+ workspace: Some("D:\\workspace\\task".to_string()),
+ }];
+
+ let output = tasks_list_human_lines(&tasks, Some("D:\\workspace\\fallback")).join("\n");
+
+ assert!(output.contains("Agent Tasks (total 1)"));
+ assert!(output.contains("task-session | active | Review CLI task flow"));
+ assert!(output.contains("Next actions:"));
+ assert!(output.contains(
+ "Resume latest: sparo tasks --workspace D:\\workspace\\task resume task-session"
+ ));
+ assert!(output
+ .contains("Show details: sparo tasks --workspace D:\\workspace\\task show task-session"));
+ assert!(output.contains(
+ "Export latest: sparo tasks --workspace D:\\workspace\\task export task-session --output task.md"
+ ));
+ assert!(output.contains("Open TUI tasks: sparo chat --workspace D:\\workspace\\task"));
+}
+
+#[test]
+fn tasks_list_human_lines_explain_no_session_latest_task() {
+ let tasks = vec![sparo_core::command::agentic_os::AgenticOsTaskRow {
+ title: "Review CLI task flow".to_string(),
+ agent: "bitfun-debug".to_string(),
+ status: "active".to_string(),
+ detail: "Needs handoff".to_string(),
+ session_id: None,
+ workspace: None,
+ }];
+
+ let output = tasks_list_human_lines(&tasks, Some("D:\\workspace\\my project")).join("\n");
+
+ assert!(output.contains("no-session | active | Review CLI task flow"));
+ assert!(output.contains(
+ "Resume latest: sparo tasks --workspace \"D:\\workspace\\my project\" resume \"Review CLI task flow\""
+ ));
+ assert!(output.contains(
+ "Show details: sparo tasks --workspace \"D:\\workspace\\my project\" show \"Review CLI task flow\""
+ ));
+ assert!(output.contains(
+ "Export latest: unavailable until this task has a persisted session transcript."
+ ));
+}
+
+#[test]
+fn tasks_list_human_lines_keep_empty_task_list_actionable() {
+ let output = tasks_list_human_lines(&[], None).join("\n");
+
+ assert!(output.contains("No backend-tracked agent tasks found."));
+ assert!(output.contains("sparo chat"));
+ assert!(output.contains("sparo sessions list"));
+}
+
+#[test]
+fn task_tui_launch_context_prepares_unsaved_task_for_chat() {
+ let task = sparo_core::command::agentic_os::AgenticOsTaskRow {
+ title: "Review CLI task flow".to_string(),
+ agent: "bitfun-debug".to_string(),
+ status: "active".to_string(),
+ detail: "Needs handoff".to_string(),
+ session_id: None,
+ workspace: None,
+ };
+
+ let launch = task_tui_launch_context(&task, Some("D:\\workspace\\fallback".to_string()), None);
+
+ assert_eq!(launch.workspace.as_deref(), Some("D:\\workspace\\fallback"));
+ assert_eq!(launch.agent, "bitfun-debug");
+ assert_eq!(launch.title, "Review CLI task flow");
+ assert_eq!(launch.context_messages.len(), 1);
+ assert!(launch.context_messages[0].contains("Task detail"));
+ assert!(launch.context_messages[0].contains("Session: none"));
+ assert!(launch.context_messages[0].contains("Workspace: global"));
+ let message = launch.initial_message.as_deref().unwrap();
+ assert!(message.contains("Use the task detail above"));
+ assert!(message.contains("Review CLI task flow"));
+ assert!(message.contains("bitfun-debug"));
+}
+
+#[test]
+fn task_tui_launch_context_preserves_explicit_resume_message() {
+ let task = sparo_core::command::agentic_os::AgenticOsTaskRow {
+ title: "Review CLI task flow".to_string(),
+ agent: "bitfun-debug".to_string(),
+ status: "active".to_string(),
+ detail: "Needs handoff".to_string(),
+ session_id: None,
+ workspace: Some("D:\\workspace\\task".to_string()),
+ };
+
+ let launch = task_tui_launch_context(
+ &task,
+ Some("D:\\workspace\\fallback".to_string()),
+ Some("continue with risk review".to_string()),
+ );
+
+ assert_eq!(launch.workspace.as_deref(), Some("D:\\workspace\\task"));
+ assert_eq!(
+ launch.initial_message.as_deref(),
+ Some("continue with risk review")
+ );
+}
+
+#[test]
+fn task_session_resume_context_carries_task_detail_into_chat() {
+ let task = sparo_core::command::agentic_os::AgenticOsTaskRow {
+ title: "Review CLI task flow".to_string(),
+ agent: "bitfun-debug".to_string(),
+ status: "active".to_string(),
+ detail: "Needs handoff".to_string(),
+ session_id: Some("task-session".to_string()),
+ workspace: Some("D:\\workspace\\task".to_string()),
+ };
+
+ let resume = task_session_resume_context(
+ &task,
+ Some("D:\\workspace\\fallback".to_string()),
+ "task-session".to_string(),
+ Some("continue from here".to_string()),
+ );
+
+ assert_eq!(resume.workspace.as_deref(), Some("D:\\workspace\\task"));
+ assert_eq!(resume.session_id, "task-session");
+ assert_eq!(
+ resume.initial_message.as_deref(),
+ Some("continue from here")
+ );
+ assert_eq!(resume.context_messages.len(), 1);
+ assert!(resume.context_messages[0].contains("Task detail"));
+ assert!(resume.context_messages[0].contains("Review CLI task flow"));
+ assert!(resume.context_messages[0].contains("Session: task-session"));
+}
diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs
index 73a317a9..2fab0cb8 100644
--- a/src/apps/desktop/src/api/agentic_api.rs
+++ b/src/apps/desktop/src/api/agentic_api.rs
@@ -16,6 +16,7 @@ use sparo_core::agentic::coordination::{
use sparo_core::agentic::core::*;
use sparo_core::agentic::image_analysis::ImageContextData;
use sparo_core::agentic::tools::image_context::get_image_context;
+use sparo_core::agentic::{compile_composer_submission, ComposerSubmissionEnvelope};
fn published_settings_agent_error(error: &impl std::fmt::Display) -> String {
public_settings_agent_error(error).code().to_string()
@@ -117,6 +118,38 @@ pub struct StartDialogTurnRequest {
pub persist_agent_type: Option,
#[serde(default)]
pub image_contexts: Option>,
+ #[serde(default)]
+ pub composer_submission: Option,
+}
+
+pub(crate) fn order_composer_images(
+ expected_ids: &[String],
+ image_contexts: Option>,
+) -> Result