Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/skills/nexus-testing/refinement-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,7 @@ The plan asked for an in-app `obsidian eval` check of slider markup. It was sett
### 2026-10-14 — `plugin:reload … vault=<name>` reloaded the focused vault

Linux, four vaults open. `obsidian plugin:reload id=nexus vault="Nexus Redesign Test"` did not reload the named vault: its plugin instance's `lifecycleManager.getInitializationStatus().startTime` did not move, while the focused Synaptic-Labs vault's did. The first call printed `Reloaded: nexus`, later ones printed nothing; neither output says which vault. What worked and is provably targeted: one guarded `eval` in the named vault that asserts `app.vault.getName()`, then runs `app.plugins.disablePlugin('nexus').then(() => app.plugins.enablePlugin('nexus'))` into a `window` status object, polled separately; confirm the new instance by its startTime. Here `vault=` came after the command, the shape step 3 of `live-loop.md` shows; the 2026-10-13 entry above found that putting it before the command targeted correctly, which was not retried. Never verify a reload by its command output. Prefer that in-band reload whenever more than one vault is open.

### 2026-10-18 — Continuous status carousel

The status-line regression fails against the previous implementation. Geometry fixtures in Jest verify sequencing and duplication; a Chromium run using the bundled production component and stylesheet verifies constant motion, continuity across the lap boundary, resize and tense stability, new-status replacement, and reduced motion. Obsidian CLI was unavailable, so this is browser component evidence rather than in-app verification. No protocol change.
4 changes: 4 additions & 0 deletions .cline/skills/nexus-testing/refinement-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,7 @@ The plan asked for an in-app `obsidian eval` check of slider markup. It was sett
### 2026-10-14 — `plugin:reload … vault=<name>` reloaded the focused vault

Linux, four vaults open. `obsidian plugin:reload id=nexus vault="Nexus Redesign Test"` did not reload the named vault: its plugin instance's `lifecycleManager.getInitializationStatus().startTime` did not move, while the focused Synaptic-Labs vault's did. The first call printed `Reloaded: nexus`, later ones printed nothing; neither output says which vault. What worked and is provably targeted: one guarded `eval` in the named vault that asserts `app.vault.getName()`, then runs `app.plugins.disablePlugin('nexus').then(() => app.plugins.enablePlugin('nexus'))` into a `window` status object, polled separately; confirm the new instance by its startTime. Here `vault=` came after the command, the shape step 3 of `live-loop.md` shows; the 2026-10-13 entry above found that putting it before the command targeted correctly, which was not retried. Never verify a reload by its command output. Prefer that in-band reload whenever more than one vault is open.

### 2026-10-18 — Continuous status carousel

The status-line regression fails against the previous implementation. Geometry fixtures in Jest verify sequencing and duplication; a Chromium run using the bundled production component and stylesheet verifies constant motion, continuity across the lap boundary, resize and tense stability, new-status replacement, and reduced motion. Obsidian CLI was unavailable, so this is browser component evidence rather than in-app verification. No protocol change.
4 changes: 4 additions & 0 deletions .codex/skills/nexus-testing/refinement-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,7 @@ The plan asked for an in-app `obsidian eval` check of slider markup. It was sett
### 2026-10-14 — `plugin:reload … vault=<name>` reloaded the focused vault

Linux, four vaults open. `obsidian plugin:reload id=nexus vault="Nexus Redesign Test"` did not reload the named vault: its plugin instance's `lifecycleManager.getInitializationStatus().startTime` did not move, while the focused Synaptic-Labs vault's did. The first call printed `Reloaded: nexus`, later ones printed nothing; neither output says which vault. What worked and is provably targeted: one guarded `eval` in the named vault that asserts `app.vault.getName()`, then runs `app.plugins.disablePlugin('nexus').then(() => app.plugins.enablePlugin('nexus'))` into a `window` status object, polled separately; confirm the new instance by its startTime. Here `vault=` came after the command, the shape step 3 of `live-loop.md` shows; the 2026-10-13 entry above found that putting it before the command targeted correctly, which was not retried. Never verify a reload by its command output. Prefer that in-band reload whenever more than one vault is open.

### 2026-10-18 — Continuous status carousel

The status-line regression fails against the previous implementation. Geometry fixtures in Jest verify sequencing and duplication; a Chromium run using the bundled production component and stylesheet verifies constant motion, continuity across the lap boundary, resize and tense stability, new-status replacement, and reduced motion. Obsidian CLI was unavailable, so this is browser component evidence rather than in-app verification. No protocol change.
4 changes: 4 additions & 0 deletions .skills/nexus-testing/refinement-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,7 @@ The plan asked for an in-app `obsidian eval` check of slider markup. It was sett
### 2026-10-14 — `plugin:reload … vault=<name>` reloaded the focused vault

Linux, four vaults open. `obsidian plugin:reload id=nexus vault="Nexus Redesign Test"` did not reload the named vault: its plugin instance's `lifecycleManager.getInitializationStatus().startTime` did not move, while the focused Synaptic-Labs vault's did. The first call printed `Reloaded: nexus`, later ones printed nothing; neither output says which vault. What worked and is provably targeted: one guarded `eval` in the named vault that asserts `app.vault.getName()`, then runs `app.plugins.disablePlugin('nexus').then(() => app.plugins.enablePlugin('nexus'))` into a `window` status object, polled separately; confirm the new instance by its startTime. Here `vault=` came after the command, the shape step 3 of `live-loop.md` shows; the 2026-10-13 entry above found that putting it before the command targeted correctly, which was not retried. Never verify a reload by its command output. Prefer that in-band reload whenever more than one vault is open.

### 2026-10-18 — Continuous status carousel

The status-line regression fails against the previous implementation. Geometry fixtures in Jest verify sequencing and duplication; a Chromium run using the bundled production component and stylesheet verifies constant motion, continuity across the lap boundary, resize and tense stability, new-status replacement, and reduced motion. Obsidian CLI was unavailable, so this is browser component evidence rather than in-app verification. No protocol change.
86 changes: 49 additions & 37 deletions src/ui/chat/components/toolStatusLine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ export type { ToolStatusEntry };
* always looking at the words being "typed".
*
* Once the reveal finishes, a line that does not fit the row does not park on
* its tail: it rewinds to the head and carousels — hold, travel left at a
* reading pace, hold, snap back — looping until the next entry replaces it, so
* an uncapped goal sentence stays readable end to end. The loop is a pure CSS
* animation (no per-frame timers); JS only measures the overflow and hands the
* distance and duration to the stylesheet as custom properties. The row is
* its tail: two copies travel left at a reading pace in a seamless loop
* until the next entry replaces it, so an uncapped goal sentence stays
* readable end to end. The loop is a pure CSS
* animation (no per-frame timers); JS measures the repeated track and hands
* its duration to the stylesheet as a custom property. The row is
* watched for resizes and the lap re-measured against the new width, so the
* carousel tracks the pane rather than the width it happened to start at.
*
Expand All @@ -41,12 +41,7 @@ export class ToolStatusLine {
// Carousel travel speed. Slow enough to read, brisk enough that a long
// goal completes a lap while the tools it describes are still running.
private static readonly MARQUEE_SPEED_PX_PER_S = 45;
// Fraction of the cycle spent travelling — the remainder is the hold at
// each end. Must match the keyframe stops of `tool-status-marquee`
// (0%–15% head hold, 15%–85% travel, 85%–100% tail hold).
private static readonly MARQUEE_TRAVEL_FRACTION = 0.7;
// Floor on a full lap so a barely-overflowing line drifts instead of
// twitching back and forth.
// Keep even a short overflowing line slow enough to read.
private static readonly MARQUEE_MIN_CYCLE_MS = 3000;
// Sub-pixel overflow is a rounding artifact, not text worth scrolling to.
private static readonly MARQUEE_MIN_OVERFLOW_PX = 4;
Expand All @@ -55,13 +50,15 @@ export class ToolStatusLine {
private static readonly RESIZE_SETTLE_MS = 150;

private currentSlot: HTMLElement | null = null;
private currentTrack: HTMLElement | null = null;
private currentText: HTMLElement | null = null;
private repeatedText: HTMLElement | null = null;
private currentEntry: ToolStatusEntry | null = null;
private queuedEntry: ToolStatusEntry | null = null;
private animating = false;
// Lap distance currently handed to the stylesheet, so a resize that leaves
// the endpoint where it was writes nothing.
private appliedShiftPx: number | null = null;
private appliedDistancePx: number | null = null;
private resizePending = false;
private timeouts: ManagedTimeoutTracker;

Expand Down Expand Up @@ -127,9 +124,11 @@ export class ToolStatusLine {
this.queuedEntry = null;
this.currentEntry = null;
this.currentSlot = null;
this.currentTrack = null;
this.currentText = null;
this.repeatedText = null;
this.animating = false;
this.appliedShiftPx = null;
this.appliedDistancePx = null;
// Remove every slot, including any mid-exit one whose scheduled removal
// was just cancelled by timeouts.clear().
const host = this.slot as HTMLElement & { empty?: () => void };
Expand Down Expand Up @@ -161,14 +160,16 @@ export class ToolStatusLine {
const nextSlot = this.slot.createDiv({
cls: `tool-status-text-${entry.state} entering`,
});
// The text lives in an inner element so the carousel can own `transform`
// without fighting the slot's enter/exit translateY, and so the full text
// stays available on hover for readers who get no carousel at all.
const nextText = nextSlot.createSpan({ cls: 'tool-status-text-inner' });
// A separate track owns the carousel's transform without fighting the
// slot's enter/exit translateY. The title exposes the full text on hover.
const nextTrack = nextSlot.createSpan({ cls: 'tool-status-track' });
const nextText = nextTrack.createSpan({ cls: 'tool-status-text-inner' });
nextSlot.setAttribute('title', entry.text);
this.currentSlot = nextSlot;
this.currentTrack = nextTrack;
this.currentText = nextText;
this.appliedShiftPx = null;
this.repeatedText = null;
this.appliedDistancePx = null;
this.timeouts.schedule(() => {
nextSlot.removeClass('entering');
nextSlot.addClass('active');
Expand Down Expand Up @@ -228,15 +229,18 @@ export class ToolStatusLine {
*
* A no-op when the row has no width yet (status bar still collapsed) or
* under reduced motion — the `title` attribute carries the full text in
* those cases. Only the custom properties change on a re-measure, never the
* `animation` shorthand, so a running lap keeps its position instead of
* snapping back to the head mid-resize.
* those cases. A width change that still needs a loop leaves its distance
* and duration alone, preserving the running lap.
*/
private syncMarquee(): void {
const outer = this.currentSlot;
const track = this.currentTrack;
const inner = this.currentText;
if (!outer || !inner) return;
if (this.prefersReducedMotion()) return;
if (!outer || !track || !inner) return;
if (this.prefersReducedMotion()) {
this.stopMarquee();
return;
}

const rowWidth = outer.clientWidth;
const textWidth = inner.scrollWidth;
Expand All @@ -248,33 +252,41 @@ export class ToolStatusLine {
return;
}

const shiftPx = -Math.round(overflow);
if (this.appliedShiftPx === null) {
if (!this.repeatedText) {
this.repeatedText = track.createSpan({
cls: 'tool-status-text-inner',
text: this.currentEntry?.text ?? '',
attr: { 'aria-hidden': 'true' },
});
}
track.addClass('tool-status-marquee');
// Two equal copies, each followed by the same CSS spacing. Moving half
// the track puts the second copy exactly where the first one started.
const distancePx = track.getBoundingClientRect().width / 2;
if (this.appliedDistancePx === null) {
// First lap for this line: the reveal left the row scrolled to the tail
// and the loop drives the text with `transform`, so hand it back a row
// scrolled to the head. A re-measure of a running lap leaves it alone.
outer.scrollLeft = 0;
}
if (this.appliedShiftPx !== shiftPx) {
const travelMs = (overflow / ToolStatusLine.MARQUEE_SPEED_PX_PER_S) * 1000;
if (this.appliedDistancePx !== distancePx) {
const travelMs = (distancePx / ToolStatusLine.MARQUEE_SPEED_PX_PER_S) * 1000;
const cycleMs = Math.max(
ToolStatusLine.MARQUEE_MIN_CYCLE_MS,
travelMs / ToolStatusLine.MARQUEE_TRAVEL_FRACTION
travelMs
);
inner.style.setProperty('--tool-status-marquee-shift', `${shiftPx}px`);
inner.style.setProperty('--tool-status-marquee-duration', `${Math.round(cycleMs)}ms`);
this.appliedShiftPx = shiftPx;
track.style.setProperty('--tool-status-marquee-duration', `${Math.round(cycleMs)}ms`);
this.appliedDistancePx = distancePx;
}
inner.addClass('tool-status-marquee');
}

/** The text fits the row now — park it at the head, unlooped. */
private stopMarquee(): void {
if (this.appliedShiftPx === null) return;
this.appliedShiftPx = null;
this.currentText?.removeClass('tool-status-marquee');
this.currentText?.style.removeProperty('--tool-status-marquee-shift');
this.currentText?.style.removeProperty('--tool-status-marquee-duration');
this.appliedDistancePx = null;
this.currentTrack?.removeClass('tool-status-marquee');
this.currentTrack?.style.removeProperty('--tool-status-marquee-duration');
this.repeatedText?.remove();
this.repeatedText = null;
}

private prefersReducedMotion(): boolean {
Expand Down
54 changes: 32 additions & 22 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -8264,17 +8264,23 @@ body.is-mobile .nexus-task-board-shell {
No ellipsis: ToolStatusLine streams the text word-by-word and follows the
newest word via scrollLeft when the line outgrows the row, so overflow
stays hidden but nothing is truncated. Once the reveal ends, a line that
does not fit carousels via the inner element's transform. */
does not fit carousels via the track's transform. */
line-height: 1.46em;
white-space: nowrap;
overflow: hidden;
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
}

/* The text itself. Owns `transform` for the carousel, so the slot above is
free to keep using `transform` for its enter/exit slide. */
/* The track owns the horizontal loop; the slot keeps its enter/exit slide. */
.tool-status-track {
display: inline-flex;
width: max-content;
vertical-align: top;
}

.tool-status-text-inner {
display: inline-block;
flex: none;
/* Top-aligned, not baseline: an inline-block on the baseline can grow the
line box past the row's fixed height and push the text into the clip. */
vertical-align: top;
Expand Down Expand Up @@ -8306,7 +8312,7 @@ body.is-mobile .nexus-task-board-shell {
/* Shimmer animation for active/present status. Painted on the inner element
so the gradient travels with the text when the carousel is running — a
background clipped to the slot would stay put while the words slid past. */
.tool-status-text-present > .tool-status-text-inner {
.tool-status-text-present .tool-status-text-inner {
background: linear-gradient(
90deg,
var(--text-muted) 0%,
Expand All @@ -8322,25 +8328,20 @@ body.is-mobile .nexus-task-board-shell {
animation: shimmer-sweep 2s linear infinite;
}

/* Carousel: hold at the head, travel left at a reading pace, hold at the
tail, snap back, repeat — until the next status entry replaces the line.
ToolStatusLine measures the overflow and sets the two custom properties;
the stops here must match its MARQUEE_TRAVEL_FRACTION (15% + 15% held).
/* Two equal copies, each with trailing space: the second reaches the first's
starting position exactly at the lap boundary. No holds or visible rewind.
Shimmer stays on the text, so a tense change leaves this loop untouched. */
.tool-status-track.tool-status-marquee {
animation: tool-status-marquee var(--tool-status-marquee-duration, 6s) linear infinite;
}

Declared after the shimmer rule (equal specificity, later wins) and with
the same two-animation list in both tenses, so the present→past flip on a
looping line does not restart the shorthand and yank the text back to its
head mid-lap. Past/failed lines have no gradient behind them, so their
shimmer half animates nothing. */
.tool-status-text-inner.tool-status-marquee {
animation:
shimmer-sweep 2s linear infinite,
tool-status-marquee var(--tool-status-marquee-duration, 6s) linear infinite;
.tool-status-marquee > .tool-status-text-inner {
margin-inline-end: var(--space-xl);
}

@keyframes tool-status-marquee {
0%, 15% { transform: translateX(0); }
85%, 100% { transform: translateX(var(--tool-status-marquee-shift, 0px)); }
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}

@keyframes shimmer-sweep {
Expand Down Expand Up @@ -8387,7 +8388,7 @@ body.is-mobile .nexus-task-board-shell {
}

@media (prefers-reduced-motion: reduce) {
.tool-status-text-present > .tool-status-text-inner {
.tool-status-text-present .tool-status-text-inner {
animation: none;
-webkit-text-fill-color: initial;
background: none;
Expand All @@ -8396,11 +8397,19 @@ body.is-mobile .nexus-task-board-shell {

/* No carousel for motion-sensitive readers — ToolStatusLine skips it too;
the slot's `title` attribute carries the full text instead. */
.tool-status-text-inner.tool-status-marquee {
.tool-status-track.tool-status-marquee {
animation: none;
transform: none;
}

.tool-status-marquee > .tool-status-text-inner {
margin-inline-end: 0;
}

.tool-status-marquee > [aria-hidden="true"] {
display: none;
}

.tool-status-text-present.entering,
.tool-status-text-past.entering,
.tool-status-text-failed.entering,
Expand Down Expand Up @@ -9514,7 +9523,8 @@ body.is-mobile .nexus-workflow-attachment button { min-height: 44px; }
}
.reasoning-catch-up {
position: absolute;
right: var(--space-m);
left: 50%;
transform: translateX(-50%);
bottom: var(--space-m);
display: grid;
place-items: center;
Expand Down
Loading
Loading