Skip to content

Commit 8fb7c80

Browse files
fix(web): Body Double is for anything, and live it is the agent's screen
Reported 2026-09-28: "I should be able to run a body double session for anything I'm doing, no restrictions or list of Focus areas ... I end up with a very 'busy' screen with the agent not being a clear central focus." No Focus card. It listed the three most recent pending study topics (on the learner's machine, auto-captured questions from other sessions) with an "at capacity" chip, above the picker and above the terminal. The activity field was always free text, so it restricted nothing; it only looked as if it did, on a surface whose spec already says body doubling is not a study thread (ADR-0003). The three-topic rule is unchanged where study threads start (the Study picker's park-first check), GET /api/body-double/focus and its unit tests stay, and committed focus is managed with `studyloop focus`. The note composer offers the activity instead of study topics. A live session is the agent's screen, the way the Study view already is (absolute, inset 0 inside .content-area, so the column has a height to fill): * the view's heading and big timer step aside; the strip carries the activity, the Pomodoro (time, Start/Pause/Resume, Break) and End session; * the console fills the rest of the window; * Capture folds to one row under it while live (one $watch on sessionActive, so start, reattach and end all agree); Note and Park open it, and ending gives back the learner's own idle choice; * the floating Park-a-thought button and Pomodoro widget step aside while a Body Double session is live: they covered the terminal's last line and, once the strip moved up, the End button itself. Also: the console's status line and dot had no CSS ("Connected · kiro" was large bold text; the dot was invisible), and the idle 25:00 sat hard left under a centred settings row. Two defects found by measuring this change before calling it done, each now pinned: with Capture open at 1024x768 the console kept a 240px floor its section did not, so it spilled 90px under the note form (the floor moved to the section); and starting the Pomodoro put the floating widget on End, which only a hit test showed (the click failed after 30s with "intercepts pointer events"). Measured at 1440x900: strip 85-136px, console 148-827px (82% of the content area), Capture row 839-888px; at 1024x768 the console gets 79%. With Capture open the console keeps 270px (laptop) / 257px (tablet) and nothing overlaps. Tests retired with the card, their behaviour gone: the Focus-pane fold, the four committed-focus tests, the focus-slot note topic. Updated: the fold reload test (Capture only), the Pomodoro end test (starts it from the strip), the post-end test (opens the folded Capture first), journey phases 1-3 and 9 (no card; the rule of three read from the API; the activity typed, not picked). Body Double e2e 38/38, related web suites 129 passed, JS 167/167, mkdocs --strict clean.
1 parent 7dde69c commit 8fb7c80

7 files changed

Lines changed: 305 additions & 381 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,22 @@ experience may change before `1.0.0`.
5252

5353
### Changed
5454

55+
- Body Double is for anything you are doing, and a live session is the
56+
agent's screen. The **Focus** card is gone from Body Double: it listed the
57+
three most recent study topics with an "at capacity" chip above the picker
58+
and the terminal, which read as a limit on what could be body-doubled (the
59+
activity field was always free text). The three-topic rule is unchanged where
60+
study threads start, and committed focus is managed with `studyloop focus`;
61+
notes taken in Body Double are filed under the activity. While a session is
62+
live the view's heading and big timer step aside: the session strip carries
63+
the activity, the Pomodoro (time, Start/Pause/Resume, Break) and **End
64+
session**, the console fills the rest of the window, and Capture folds to one
65+
row beneath it — its Note and Park tabs open it, and ending the session gives
66+
back the layout you had. The floating Park-a-thought button and Pomodoro
67+
widget step aside during a live Body Double session (the `P` shortcut still
68+
parks), because they covered the terminal's last line and the End button.
69+
Measured at 1440×900 before the change, the terminal started 432px down the
70+
page and ran below the window.
5571
- The `openspec/` tree is no longer listed in `.gitignore`. Seventy-eight
5672
tracked, load-bearing files lived under an ignored path, so every new spec
5773
or archive file was invisible to `git status` and skipped by `git add -A`.

‎docs/web-ui-guide.md‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ the move goes with the old one, and a session you rejoin carries none.
4848
## Body Double
4949

5050
Use Body Double when the difficult part is starting or staying alongside the
51-
work rather than learning a new concept.
51+
work rather than learning a new concept. It is for anything you are doing —
52+
study, a report, a tax return — so there is no topic list to choose from.
5253

5354
1. Open **Body Double**.
5455
2. Name the activity in concrete terms, such as “trace one decorator call”.
@@ -58,15 +59,20 @@ work rather than learning a new concept.
5859
move names an indexed lesson, **Open the lesson** opens it in the Course
5960
Explorer panel beside the picker, so the session can start with it already
6061
open next to you.
61-
3. Choose an agent and start the Pomodoro timer if a time box would help.
62-
4. Start the body-double session. The first move stays with you: it sits on
63-
the session strip beneath the activity name for the whole session, with
64-
**Open the lesson** beside it when a lesson was named, so the blank page
65-
never arrives without it. It is a proposal on screen — the companion never
66-
says it, and nothing opens unless you press the button.
67-
5. Use **Focus** for up to three active topics and **Park a thought** for anything
68-
that can wait.
69-
6. End the session when the work block is complete.
62+
3. Choose an agent, and set the Pomodoro lengths if a time box would help.
63+
4. Start the body-double session. The page becomes the agent's screen: one
64+
strip at the top with the activity, the Pomodoro (**Start Pomodoro**,
65+
**Pause**, **Resume**) and **End session**, and the agent's console filling
66+
the rest of the window. The first move stays with you: it sits on the strip
67+
beneath the activity name for the whole session, with **Open the lesson**
68+
beside it when a lesson was named. It is a proposal on screen — the
69+
companion never says it, and nothing opens unless you press the button.
70+
5. **Capture** folds to one row under the console while the session runs.
71+
**Note** opens the note composer (notes are filed under the activity) and
72+
**Park** keeps a tangent for later without leaving the session; the `P`
73+
shortcut parks from anywhere.
74+
6. End the session when the work block is complete. The page returns to the
75+
layout you had before it started.
7076

7177
![A Body Double workspace with timer and a Kiro mentor](images/studyloop-body-double.png)
7278

‎packages/studyloop/src/studyloop/web/static/components.js‎

Lines changed: 22 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3160,9 +3160,7 @@ function notesPanel() {
31603160
* ==================================================================== */
31613161
function bodyDoubleSession() {
31623162
return {
3163-
slots: [], slotsUsed: 0, maxActive: 3, atCapacity: false, parkingLotCount: 0,
3164-
focus: { topics: [], is_set: false, is_stale: false },
3165-
focusCollapsed: false, captureCollapsed: false, captureTab: 'note',
3163+
captureCollapsed: false, captureTab: 'note',
31663164
activity: '', firstMove: '', firstMoveLessonId: '', firstMoveLessonTitle: '',
31673165
agent: '', transport: 'pty', energy: 5, agents: [],
31683166
sessionActive: false, liveActivity: '', confirmingEnd: false,
@@ -3186,7 +3184,7 @@ function bodyDoubleSession() {
31863184
comparison would then work only by accident. */
31873185
_conflictEpoch: 0,
31883186
/* True once init() has finished ALL its async work. The only honest ready
3189-
signal for tests: the conflict probe settles after the focus and options
3187+
signal for tests: the conflict probe settles after the options and notes
31903188
loads, so nothing else marks the end of init(). */
31913189
_initDone: false,
31923190
noteKind: 'note', noteTopic: '', noteTitle: '', noteBody: '',
@@ -3251,9 +3249,18 @@ function bodyDoubleSession() {
32513249
this.firstMoveLessonTitle = detail.firstMoveLessonTitle
32523250
? String(detail.firstMoveLessonTitle) : '';
32533251
});
3254-
this.focusCollapsed = localStorage.getItem('bd.focus.collapsed') === 'true';
32553252
this.captureCollapsed = localStorage.getItem('bd.capture.collapsed') === 'true';
3256-
await this.refreshFocus();
3253+
/* A live session is the agent's screen, so Capture folds when one starts
3254+
and the learner's own idle choice comes back when it ends. One watcher
3255+
rather than a line in each path, because a session goes live three
3256+
ways (start, reattach, adopt) and ends several more; a fold wired into
3257+
only some of them would leave the note form over the console in the
3258+
rest. Not persisted: this is the session's layout, not a preference. */
3259+
this.$watch('sessionActive', (live) => {
3260+
this.captureCollapsed = live
3261+
? true
3262+
: localStorage.getItem('bd.capture.collapsed') === 'true';
3263+
});
32573264
try {
32583265
const res = await fetch('/api/session/options');
32593266
if (res.ok) {
@@ -3310,54 +3317,20 @@ function bodyDoubleSession() {
33103317
this.clearFirstMove();
33113318
},
33123319

3313-
async refreshFocus() {
3314-
try {
3315-
const res = await fetch('/api/body-double/focus');
3316-
if (!res.ok) return;
3317-
const d = await res.json();
3318-
this.slots = d.slots || [];
3319-
this.slotsUsed = d.slots_used ?? this.slots.length;
3320-
this.maxActive = d.max_active ?? 3;
3321-
this.atCapacity = !!d.at_capacity;
3322-
this.parkingLotCount = d.parking_lot_count || 0;
3323-
this.focus = d.focus || { topics: [], is_set: false, is_stale: false };
3324-
/* Default the note topic to what the learner is actually doing. A note
3325-
filed against the wrong topic is worse than an untagged one. */
3326-
if (!this.noteTopic) {
3327-
this.noteTopic = this.liveActivity || (this.slots[0] && this.slots[0].topic) || '';
3328-
}
3329-
} catch {
3330-
Alpine.store('toast').show('Could not load focus — offline?');
3331-
}
3320+
/* The note composer's topic choices: what the learner is working on. While
3321+
a session is live that is its activity; afterwards, the topic the last
3322+
note was filed under, so an unsaved draft keeps its topic. Never a list
3323+
of study topics — this surface is for anything (2026-09-28). */
3324+
get noteTopicOptions() {
3325+
const topic = (this.liveActivity || this.noteTopic || '').trim();
3326+
return topic ? [topic] : [];
33323327
},
33333328

3334-
toggleFocus() {
3335-
this.focusCollapsed = !this.focusCollapsed;
3336-
localStorage.setItem('bd.focus.collapsed', String(this.focusCollapsed));
3337-
},
33383329
toggleCapture() {
33393330
this.captureCollapsed = !this.captureCollapsed;
33403331
localStorage.setItem('bd.capture.collapsed', String(this.captureCollapsed));
33413332
},
33423333

3343-
pickTopic(slot) { this.activity = slot.topic; },
3344-
3345-
async dropTopic(topic) { await this._setFocus(this.focus.topics.filter((t) => t !== topic)); },
3346-
async clearFocus() { await this._setFocus([]); },
3347-
async _setFocus(topics) {
3348-
try {
3349-
const res = await fetch('/api/body-double/focus', {
3350-
method: 'POST',
3351-
headers: { 'Content-Type': 'application/json' },
3352-
body: JSON.stringify({ topics }),
3353-
});
3354-
if (res.ok) await this.refreshFocus();
3355-
else Alpine.store('toast').show('Could not update focus — try again');
3356-
} catch {
3357-
Alpine.store('toast').show('Could not update focus — offline?');
3358-
}
3359-
},
3360-
33613334
applyTemplate() { this.noteBody = this.templates[this.noteKind] || ''; },
33623335

33633336
insertDiagram() {
@@ -3439,7 +3412,6 @@ function bodyDoubleSession() {
34393412
// discard.
34403413
if (this.parkQuestion.trim() === question) this.parkQuestion = '';
34413414
if (this.parkNotes === notes) this.parkNotes = '';
3442-
await this.refreshFocus();
34433415
window.dispatchEvent(new CustomEvent('parking:changed'));
34443416
} catch {
34453417
Alpine.store('toast').show('Could not park — offline?');
@@ -3478,10 +3450,8 @@ function bodyDoubleSession() {
34783450
/* A start that succeeded proves nothing is blocking us any more. */
34793451
this.conflictSession = null;
34803452
this.liveActivity = topic;
3481-
/* Re-point the note composer at the live activity. refreshFocus() ran at
3482-
init(), before any session existed, so its default fell back to the
3483-
first focus slot - filing notes against the wrong topic for the whole
3484-
session. A misfiled note is worse than an untagged one. */
3453+
/* File notes under the live activity. A note filed against the wrong
3454+
topic is worse than an untagged one. */
34853455
this.noteTopic = topic;
34863456
window.dispatchEvent(new CustomEvent('study-session-start', {
34873457
detail: {

‎packages/studyloop/src/studyloop/web/static/index.html‎

Lines changed: 36 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1671,8 +1671,14 @@ <h2>Session Complete</h2>
16711671
components.js, so the old string still FUNCTIONS in a browser —
16721672
which is exactly why a stale attribute here would look fine and
16731673
still fail the suite. -->
1674-
<div class="body-double-view session-dashboard" x-data="bodyDoubleSession()" x-init="init()">
1675-
<header class="body-double-header">
1674+
<div class="body-double-view session-dashboard" x-data="bodyDoubleSession()" x-init="init()"
1675+
:class="{ 'bd-live': sessionActive }">
1676+
<!-- While a session is live the view is the agent's screen: this
1677+
heading and the big timer block step aside (the timer moves onto
1678+
the session strip), so the console is the first thing under the
1679+
strip. Reported 2026-09-28: "a very 'busy' screen with the agent
1680+
not being a clear central focus". -->
1681+
<header class="body-double-header" x-show="!sessionActive">
16761682
<h2>Body Double</h2>
16771683
<p class="text-muted">A focused workspace with timer and terminal. Just be here.</p>
16781684
</header>
@@ -1681,7 +1687,7 @@ <h2>Body Double</h2>
16811687
reason this surface exists, and test_remaining_surface asserts
16821688
#bd-timer-display matches the SERVED defaults, so it binds to the
16831689
store rather than hardcoding 00:00 as the old markup did. -->
1684-
<div class="body-double-timer">
1690+
<div class="body-double-timer" x-show="!sessionActive">
16851691
<div class="timer-display large-timer">
16861692
<span class="timer-time" id="bd-timer-display" x-text="$store.pomodoro.display">00:00</span>
16871693
</div>
@@ -1698,39 +1704,12 @@ <h2>Body Double</h2>
16981704
</div>
16991705
</div>
17001706

1701-
<!-- FOCUS: at most three live topics. The cap is the point — an
1702-
unbounded list is the thing this whole feature exists to prevent. -->
1703-
<section id="bd-focus" class="bd-card">
1704-
<div class="bd-card-head">
1705-
<button id="bd-focus-toggle" type="button" class="bd-collapse-btn"
1706-
:aria-expanded="String(!focusCollapsed)" @click="toggleFocus()">
1707-
<span x-text="focusCollapsed ? '▸' : '▾'"></span> Focus
1708-
</button>
1709-
<span id="bd-focus-count" x-text="slotsUsed + ' of ' + maxActive + ' topics'"></span>
1710-
<button id="bd-focus-refresh" type="button" class="bulk-btn" @click="refreshFocus()">Refresh</button>
1711-
<button id="bd-focus-clear" type="button" class="bulk-btn" x-show="focus.is_set"
1712-
@click="clearFocus()">Clear focus</button>
1713-
<span id="bd-focus-stale" class="bd-chip warn" x-show="focus.is_stale"
1714-
title="This focus was set more than 30 days ago — still current?">stale</span>
1715-
</div>
1716-
<div id="bd-focus-body" x-show="!focusCollapsed">
1717-
<div id="bd-focus-empty" class="text-muted" x-show="slots.length === 0">
1718-
Nothing in focus yet. Park a tangent or set a focus to fill these slots.
1719-
</div>
1720-
<template x-for="slot in slots" :key="slot.topic">
1721-
<div class="bd-focus-slot">
1722-
<button type="button" class="bd-focus-slot-topic" x-text="slot.topic"
1723-
@click="pickTopic(slot)"></button>
1724-
<button type="button" class="bd-focus-drop" x-show="slot.source === 'focus'"
1725-
:data-topic="slot.topic" @click="dropTopic(slot.topic)"
1726-
aria-label="Drop this topic">&times;</button>
1727-
</div>
1728-
</template>
1729-
<span id="bd-focus-at-capacity" class="bd-chip" x-show="atCapacity">at capacity</span>
1730-
<span id="bd-focus-parked" class="bd-chip" x-show="parkingLotCount > 0"
1731-
x-text="parkingLotCount + ' parked'"></span>
1732-
</div>
1733-
</section>
1707+
<!-- No Focus card here (removed 2026-09-28). Body doubling is for
1708+
anything the learner is doing, not a study thread (ADR-0003), and
1709+
a list of three study topics with an "at capacity" chip above the
1710+
picker read as a restriction on what could be body-doubled. The
1711+
three-topic rule still holds where study threads start (the Study
1712+
picker's park-first check) and in `studyloop focus`. -->
17341713

17351714
<!-- PICKER (idle). .bd-start-picker is the surface-scoping hook: this
17361715
view is declared BEFORE the Study view in the document, so an
@@ -1834,9 +1813,19 @@ <h2>Body Double</h2>
18341813

18351814
<!-- LIVE (session active). The strip is sticky so End stays reachable
18361815
without scrolling back up mid-session. -->
1837-
<section x-show="sessionActive">
1816+
<section class="bd-live-section" x-show="sessionActive">
18381817
<div class="bd-live-strip">
18391818
<span id="bd-live-activity" x-text="liveActivity"></span>
1819+
<!-- The Pomodoro, on the strip while a session is live: the big
1820+
timer block steps aside so the console sits right under this
1821+
strip. Same store, so starting it here is starting it. -->
1822+
<span class="bd-live-timer-phase" x-show="$store.pomodoro.running && $store.pomodoro.isBreak">Break</span>
1823+
<span id="bd-live-timer" class="bd-live-timer"
1824+
:class="{ running: $store.pomodoro.running && !$store.pomodoro.paused }"
1825+
x-text="$store.pomodoro.display" aria-label="Pomodoro time"></span>
1826+
<button id="bd-live-pomodoro" type="button" class="bulk-btn"
1827+
@click="$store.pomodoro.running ? $store.pomodoro.togglePause() : $store.pomodoro.start()"
1828+
x-text="!$store.pomodoro.running ? 'Start Pomodoro' : ($store.pomodoro.paused ? 'Resume' : 'Pause')"></button>
18401829
<button id="bd-end-session" type="button" class="toggle-btn"
18411830
aria-label="End body double session" @click="endSession()">End session</button>
18421831
<div id="bd-end-confirm" class="bd-end-confirm" x-show="confirmingEnd">
@@ -1878,7 +1867,10 @@ <h2>Body Double</h2>
18781867
<div class="bd-console-panel">
18791868
<div class="session-terminal-area agent-console" x-data="liveAgentConsole('body-double')">
18801869
<div class="agent-console-status">
1881-
<span class="status-dot" :class="statusDot"></span>
1870+
<!-- The Study console's dot classes: .status-dot had no rule,
1871+
so the dot was invisible and "Connected · kiro" rendered
1872+
as large bold text over the terminal. -->
1873+
<span class="agent-status-dot" :class="'agent-status-dot-' + statusDot"></span>
18821874
<span x-text="status"></span>
18831875
</div>
18841876
<!-- The .xterm-mount class is applied ONLY while a Body Double
@@ -1898,7 +1890,7 @@ <h2>Body Double</h2>
18981890
</section>
18991891

19001892
<!-- CAPTURE: note or park, without leaving the session. -->
1901-
<section id="bd-capture" class="bd-card">
1893+
<section id="bd-capture" class="bd-card" :class="{ collapsed: captureCollapsed }">
19021894
<div class="bd-card-head">
19031895
<button id="bd-capture-toggle" type="button" class="bd-collapse-btn"
19041896
:aria-expanded="String(!captureCollapsed)" @click="toggleCapture()">
@@ -1932,10 +1924,13 @@ <h2>Body Double</h2>
19321924
<option value="struggle">Struggle</option>
19331925
</select>
19341926
<button id="bd-note-template" type="button" class="bulk-btn" @click="applyTemplate()">Template</button>
1927+
<!-- What you are working on, not a list of study topics: a note
1928+
taken while body-doubling "the March invoices" belongs to
1929+
the March invoices. -->
19351930
<select id="bd-note-topic" class="picker-select" x-model="noteTopic">
19361931
<option value="">No topic</option>
1937-
<template x-for="slot in slots" :key="'nt-' + slot.topic">
1938-
<option :value="slot.topic" x-text="slot.topic"></option>
1932+
<template x-for="topic in noteTopicOptions" :key="'nt-' + topic">
1933+
<option :value="topic" x-text="topic"></option>
19391934
</template>
19401935
</select>
19411936
<input id="bd-note-title" type="text" x-model="noteTitle" placeholder="Title">

0 commit comments

Comments
 (0)