mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
Merge pull request #303 from Ark0N/feat/overview-activity-order
Sort the home-screen session lists by activity, not tab order
This commit is contained in:
@@ -437,6 +437,94 @@ function computeSseStale(input) {
|
||||
return now - lastMessageAt >= timeoutMs;
|
||||
}
|
||||
|
||||
// Home-screen session order: one comparator for both overviews.
|
||||
//
|
||||
// The phone overview (mobile-overview.js) and the desktop tab rail
|
||||
// (home-sessions.js) list the same sessions, so they answer the same question
|
||||
// and must answer it the same way: "which of these wants me next?".
|
||||
//
|
||||
// 1. Anything blocked on a human first (red question, then error, then a
|
||||
// yellow idle prompt), longest-blocked at the top: a session that has been
|
||||
// sitting on a permission dialog for 20 minutes is starving, one that
|
||||
// raised it 5 seconds ago is not.
|
||||
// 2. Then whatever is running, LONGEST-RUNNING first, since that is the turn most
|
||||
// likely to be finished, or stuck, by the time you look.
|
||||
// 3. Then everything quiet, MOST RECENTLY quiet first: when nothing is
|
||||
// running, the session that just finished is the one you came back for,
|
||||
// and the one you abandoned yesterday sinks.
|
||||
//
|
||||
// So the tiebreak flips direction halfway down the list, and that is the point:
|
||||
// for a state something is still doing, longer = more urgent; for a state
|
||||
// something has stopped in, more recent = more relevant.
|
||||
//
|
||||
// Pure: no DOM, no clock (every input is an epoch-ms stamp already on the
|
||||
// session payload), no `this`. Unit-tested in test/session-overview-order.test.ts.
|
||||
const SESSION_ACTIVITY_RANK = {
|
||||
needs: 0,
|
||||
error: 1,
|
||||
waiting: 2,
|
||||
working: 3,
|
||||
idle: 4,
|
||||
done: 5,
|
||||
};
|
||||
|
||||
/** States still in progress, where the OLDEST stamp sorts first. */
|
||||
const SESSION_ACTIVITY_OLDEST_FIRST = ['needs', 'error', 'waiting', 'working'];
|
||||
|
||||
/**
|
||||
* When the row entered the state it is in.
|
||||
*
|
||||
* For everything quiet that is `lastActivityAt`, the last byte the pane printed:
|
||||
* a Claude pane sitting at its composer prints nothing, so the end of the last
|
||||
* turn is exactly when it went quiet.
|
||||
*
|
||||
* A WORKING pane is the opposite: it repaints about once a second, so its
|
||||
* last-activity stamp is always "now" and would rank every running turn as
|
||||
* freshly started. Its real start is the pane's last Enter (`lastSubmitAt`),
|
||||
* persisted server-side and therefore stable across a Codeman restart. A
|
||||
* working pane that has never submitted (spawned with its prompt on the command
|
||||
* line, or an external CLI) falls back to last activity, which puts it at the
|
||||
* short end of the running group rather than falsely at the head of it.
|
||||
*/
|
||||
function sessionActivityAnchor(row) {
|
||||
const activeAt = Number(row && row.lastActivityAt) || 0;
|
||||
if (row && row.state === 'working') return Number(row.lastSubmitAt) || activeAt;
|
||||
return activeAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort comparator for one overview row against another.
|
||||
* @param {{state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}} a
|
||||
* @param {{state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}} b
|
||||
*/
|
||||
function compareSessionActivity(a, b) {
|
||||
const rankA = SESSION_ACTIVITY_RANK[a.state];
|
||||
const rankB = SESSION_ACTIVITY_RANK[b.state];
|
||||
const rank = (rankA === undefined ? 99 : rankA) - (rankB === undefined ? 99 : rankB);
|
||||
if (rank !== 0) return rank;
|
||||
|
||||
const atA = sessionActivityAnchor(a);
|
||||
const atB = sessionActivityAnchor(b);
|
||||
if (atA !== atB) {
|
||||
// A row with no stamp at all gets no opinion: it sorts last either way
|
||||
// rather than claiming to be the oldest (0) thing on the screen.
|
||||
if (!atA) return 1;
|
||||
if (!atB) return -1;
|
||||
return SESSION_ACTIVITY_OLDEST_FIRST.includes(a.state) ? atA - atB : atB - atA;
|
||||
}
|
||||
|
||||
// Equal stamps (or two unstamped rows): fall back to the user's tab order so
|
||||
// the list is deterministic and cannot shuffle between renders.
|
||||
const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER;
|
||||
const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
}
|
||||
|
||||
/** Copy of `rows`, in overview order. Never sorts in place, so callers keep their array. */
|
||||
function sortSessionsByActivity(rows) {
|
||||
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||
@@ -464,6 +552,12 @@ if (typeof window !== 'undefined') {
|
||||
compute: computeSseStale,
|
||||
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
|
||||
};
|
||||
window.CodemanSessionOrder = {
|
||||
RANK: SESSION_ACTIVITY_RANK,
|
||||
anchor: sessionActivityAnchor,
|
||||
compare: compareSessionActivity,
|
||||
sort: sortSessionsByActivity,
|
||||
};
|
||||
}
|
||||
|
||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
* The welcome screen centers ~560px of content in a window that is usually
|
||||
* 1400px+, so the two gutters are dead space. The left one now carries the same
|
||||
* list a phone gets on its home screen (mobile-overview.js), turned vertical:
|
||||
* one row per live tab, in TAB ORDER (not sorted by state) so it reads as the
|
||||
* tab strip rotated, and so Alt+1..9 still matches what you see.
|
||||
* one row per live tab.
|
||||
*
|
||||
* Rows are ordered by `CodemanSessionOrder` (constants.js), the same comparator
|
||||
* the phone overview uses: blocked on you first, then running longest-first,
|
||||
* then quiet most-recently-quiet first. The number badge stays the tab-strip
|
||||
* index (Alt+1..9), so it is deliberately NOT sequential down a sorted rail:
|
||||
* it names a shortcut, not a row position.
|
||||
*
|
||||
* DESKTOP ONLY, and only in a wide enough window: the rail is absolutely
|
||||
* positioned so the centered welcome content never moves, which means it can
|
||||
@@ -16,11 +21,13 @@
|
||||
* both scale with the viewport (see the `.home-sessions` block in styles.css) —
|
||||
* a fixed 256px card looks abandoned on a 2560px display.
|
||||
*
|
||||
* Each row carries when the session was FIRST CREATED and when it was LAST
|
||||
* ACTIVE, both relative. Those two stamps go stale on their own (a sitting
|
||||
* session emits no event), so a slow clock refreshes them IN PLACE from the
|
||||
* epoch-ms values parked on the elements, rather than re-rendering: a re-render
|
||||
* would restart every row's blink animation and its working ring.
|
||||
* Each row carries when the session was FIRST CREATED and how long it has been
|
||||
* in the state it is in ("created 3d ago · working 12m"), and that second stamp is
|
||||
* the value the order above is computed from, so the rail explains itself
|
||||
* rather than looking arbitrarily shuffled. Both stamps go stale on their own
|
||||
* (a sitting session emits no event), so a slow clock refreshes them IN PLACE
|
||||
* from the epoch-ms values parked on the elements, rather than re-rendering: a
|
||||
* re-render would restart every row's blink animation and its working ring.
|
||||
*
|
||||
* The working state is deliberately identical to the phone's: a pulsing green
|
||||
* dot ringed by the spinner a tab shows while it loads (`tab-load-spin`, reused
|
||||
@@ -34,6 +41,7 @@
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession)
|
||||
* @dependency constants.js (CodemanSessionOrder, the shared row comparator)
|
||||
* @dependency mobile-overview.js (_mobileOverviewState, _mobileOverviewCaseFor, shouldUseMobileOverview)
|
||||
* @dependency ralph-panel.js (formatRelativeTime — the app's one relative-time formatter)
|
||||
* @dependency webview-tabs.js (this.webviews, this.webviewOrder, openWebview)
|
||||
@@ -162,11 +170,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* One row per live session, in the user's tab order. State classification is
|
||||
* `_mobileOverviewState()` (mobile-overview.js) so both home screens agree on
|
||||
* what counts as needing you; the ORDER differs on purpose — the phone sorts
|
||||
* by urgency because it shows one screenful at a time, this column mirrors the
|
||||
* tab strip so the number badges line up with Alt+1..9.
|
||||
* One row per live session, in overview order: whatever is blocked on you
|
||||
* first, then whatever is running (longest turn first), then the quiet ones
|
||||
* most-recently-quiet first. The comparator is `CodemanSessionOrder`
|
||||
* (constants.js), shared with the phone overview, and state classification is
|
||||
* `_mobileOverviewState()` (mobile-overview.js), so the two home screens can
|
||||
* neither disagree about what "working" means nor about what sorts first.
|
||||
*
|
||||
* `orderIndex` stays the position in the TAB STRIP, because that is what the
|
||||
* number badge means (Alt+1..9). Once the rows are sorted those badges no
|
||||
* longer run 1,2,3 down the rail: the badge answers "which key selects this",
|
||||
* not "how far down the list is it".
|
||||
*
|
||||
* @returns {Array<object>} row descriptors, ready to render
|
||||
*/
|
||||
buildHomeSessionRows() {
|
||||
@@ -177,14 +192,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// invisible here while its tab already exists.
|
||||
for (const id of this.sessions?.keys() || []) if (!ids.includes(id)) ids.push(id);
|
||||
|
||||
return ids.map((id, index) => {
|
||||
const rows = ids.map((id, orderIndex) => {
|
||||
const session = this.sessions.get(id);
|
||||
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
||||
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
|
||||
const mode = session.mode || 'claude';
|
||||
return {
|
||||
id,
|
||||
index,
|
||||
orderIndex,
|
||||
name: this.getSessionName ? this.getSessionName(session) : session.name || id.slice(0, 8),
|
||||
mode,
|
||||
modeBadge: HOME_SESSIONS_MODE_BADGE[mode] || '',
|
||||
@@ -196,8 +211,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
// render time so the clock below can redo it without a re-render.
|
||||
createdAt: Number(session.createdAt) || 0,
|
||||
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||
// The running group is ordered by the pane's last Enter, since a
|
||||
// working pane's last-activity stamp is always "now".
|
||||
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||
// "how long has it been like this", resolved by the phone overview's
|
||||
// helper so both home screens label the same stamp with the same word.
|
||||
since: this._mobileOverviewSince(state, session),
|
||||
};
|
||||
});
|
||||
|
||||
// Guarded like every other constants.js consumer: a stale cached
|
||||
// constants.js (iOS Safari serves old JS after a deploy) must degrade to
|
||||
// tab order, not TypeError the whole home screen away.
|
||||
return window.CodemanSessionOrder ? window.CodemanSessionOrder.sort(rows) : rows;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -238,32 +264,40 @@ Object.assign(CodemanApp.prototype, {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* The "created 2h ago · active 3m ago" footer line. Both stamps keep their raw
|
||||
* The "created 2h ago · working 12m" footer line. Both stamps keep their raw
|
||||
* epoch-ms on the element (`data-hs-ts`) so `_tickHomeSessionsTimes()` can
|
||||
* rewrite the text without rebuilding the row.
|
||||
*
|
||||
* The second stamp is the row's state duration, NOT a plain last-active
|
||||
* stamp: it is the number the rail is sorted by, and a working row that reads
|
||||
* "active just now" (every working pane repaints about once a second) hides
|
||||
* exactly the value that decided its position. `_mobileOverviewSince()` owns
|
||||
* both the word and the anchor, so the phone says the same thing.
|
||||
*/
|
||||
_buildHomeSessionsMeta(row) {
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'home-sessions-row-meta';
|
||||
// Relative times are generated text, and "created"/"active" here are the
|
||||
// Relative times are generated text, and "created"/"idle" here are the
|
||||
// same generic words that mean something else on other surfaces.
|
||||
meta.setAttribute('data-i18n-skip', '');
|
||||
|
||||
meta.appendChild(this._buildHomeSessionsStamp('created', row.createdAt, 'home-sessions-meta-created'));
|
||||
meta.appendChild(this._buildHomeSessionsStamp('created', row.createdAt, 'ago', 'home-sessions-meta-created'));
|
||||
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'home-sessions-meta-sep';
|
||||
sep.setAttribute('aria-hidden', 'true');
|
||||
sep.textContent = '·';
|
||||
meta.appendChild(sep);
|
||||
if (row.since) {
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'home-sessions-meta-sep';
|
||||
sep.setAttribute('aria-hidden', 'true');
|
||||
sep.textContent = '·';
|
||||
meta.appendChild(sep);
|
||||
|
||||
meta.appendChild(this._buildHomeSessionsStamp('active', row.lastActivityAt, 'home-sessions-meta-active'));
|
||||
meta.appendChild(this._buildHomeSessionsStamp(row.since.key, row.since.at, 'for', 'home-sessions-meta-since'));
|
||||
}
|
||||
|
||||
return meta;
|
||||
},
|
||||
|
||||
/** One labelled stamp: a dim key, the relative value, full date in the title. */
|
||||
_buildHomeSessionsStamp(key, timestamp, className) {
|
||||
/** One labelled stamp: a dim key, the value, full date in the title. */
|
||||
_buildHomeSessionsStamp(key, timestamp, format, className) {
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = `home-sessions-meta-item ${className}`;
|
||||
|
||||
@@ -274,18 +308,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const value = document.createElement('span');
|
||||
value.dataset.hsTs = String(timestamp || 0);
|
||||
value.textContent = this._homeSessionsAgo(timestamp);
|
||||
value.dataset.hsFmt = format;
|
||||
value.textContent = this._homeSessionsStampText(timestamp, format);
|
||||
wrap.appendChild(value);
|
||||
|
||||
if (timestamp)
|
||||
wrap.title = `${key === 'created' ? 'First created' : 'Last active'}: ${new Date(timestamp).toLocaleString()}`;
|
||||
if (timestamp) wrap.title = `${key === 'created' ? 'First created' : key}: ${new Date(timestamp).toLocaleString()}`;
|
||||
return wrap;
|
||||
},
|
||||
|
||||
/** Relative label for a stamp. `formatRelativeTime` is the app's one formatter. */
|
||||
_homeSessionsAgo(timestamp) {
|
||||
if (!timestamp) return '—';
|
||||
return this.formatRelativeTime(timestamp) || '—';
|
||||
/**
|
||||
* 'ago' points at a moment ("3d ago"), 'for' measures a span to now ("12m").
|
||||
* Both come from the phone overview's formatter, so a duration is written the
|
||||
* same way on both home screens.
|
||||
*/
|
||||
_homeSessionsStampText(timestamp, format) {
|
||||
return this._mobileOverviewStampText(timestamp, format);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -315,7 +352,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!el) return;
|
||||
for (const node of el.querySelectorAll('[data-hs-ts]')) {
|
||||
const ts = Number(node.dataset.hsTs) || 0;
|
||||
const text = this._homeSessionsAgo(ts);
|
||||
const text = this._homeSessionsStampText(ts, node.dataset.hsFmt);
|
||||
if (node.textContent !== text) node.textContent = text;
|
||||
}
|
||||
},
|
||||
@@ -352,11 +389,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
item.dataset.hsSession = row.id;
|
||||
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
|
||||
|
||||
if (row.index < 9) {
|
||||
// The badge is the Alt+N key for this tab, so it keeps the tab-strip index
|
||||
// even though the rows are sorted by activity: it will not read 1,2,3 down
|
||||
// the rail, and must not, or the shortcut it names would be wrong.
|
||||
if (row.orderIndex < 9) {
|
||||
const number = document.createElement('span');
|
||||
number.className = 'home-sessions-number';
|
||||
number.setAttribute('data-i18n-skip', '');
|
||||
number.textContent = String(row.index + 1);
|
||||
number.textContent = String(row.orderIndex + 1);
|
||||
item.appendChild(number);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
* errored sessions), then SPACES (cases, expandable to their sessions), then
|
||||
* WORKING and IDLE / DONE.
|
||||
*
|
||||
* Rows inside a section are ordered by `CodemanSessionOrder` (constants.js),
|
||||
* the SAME comparator the desktop rail uses: blocked longest-first, then
|
||||
* running longest-first, then quiet most-recently-quiet first.
|
||||
*
|
||||
* PHONE ONLY. The gate is `shouldUseMobileOverview()` (viewport < 430px, not a
|
||||
* popped-out solo window, per-device setting on). Tablet and desktop keep the
|
||||
* welcome overlay untouched. The container ships with the `hidden` attribute and
|
||||
@@ -25,6 +29,7 @@
|
||||
* `buildMobileOverviewModel()` is pure and unit-tested (test/mobile-overview.test.ts).
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency constants.js (CodemanSessionOrder, the shared row comparator)
|
||||
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession, run)
|
||||
* @dependency ralph-panel.js (formatRelativeTime, the app's one relative-time formatter)
|
||||
* @dependency mobile-handlers.js (MobileDetection)
|
||||
@@ -35,16 +40,6 @@
|
||||
/** Viewport width that counts as a phone. Matches the mobile.css phone block. */
|
||||
const MOBILE_OVERVIEW_PHONE_QUERY = '(max-width: 430px)';
|
||||
|
||||
/** Sort rank per state: the most demanding thing sorts first inside a section. */
|
||||
const MOBILE_OVERVIEW_STATE_RANK = {
|
||||
needs: 0,
|
||||
error: 1,
|
||||
waiting: 2,
|
||||
working: 3,
|
||||
idle: 4,
|
||||
done: 5,
|
||||
};
|
||||
|
||||
/** How many past conversations show before the "Show all" toggle. */
|
||||
const MOBILE_OVERVIEW_PAST_LIMIT = 8;
|
||||
|
||||
@@ -188,18 +183,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Epoch ms, straight off the session payload; formatting happens at
|
||||
// render time so the clock can redo it without a re-render.
|
||||
createdAt: Number(session.createdAt) || 0,
|
||||
// Raw stamps for the shared order comparator; `since` above is the same
|
||||
// pair resolved for DISPLAY, and the two must not drift apart.
|
||||
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||
since: this._mobileOverviewSince(state, session),
|
||||
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
|
||||
};
|
||||
});
|
||||
|
||||
const bySeverityThenOrder = (a, b) => {
|
||||
const rank = MOBILE_OVERVIEW_STATE_RANK[a.state] - MOBILE_OVERVIEW_STATE_RANK[b.state];
|
||||
return rank !== 0 ? rank : a.orderIndex - b.orderIndex;
|
||||
// Order is `CodemanSessionOrder` (constants.js), shared with the desktop
|
||||
// rail: blocked first (longest-blocked at the top), then running
|
||||
// longest-first, then quiet most-recent-first.
|
||||
// Guarded: a stale cached constants.js (iOS Safari after a deploy) must
|
||||
// degrade to tab order, not TypeError the overview away.
|
||||
const inSection = (states) => {
|
||||
const filtered = rows.filter((r) => states.includes(r.state));
|
||||
return window.CodemanSessionOrder ? window.CodemanSessionOrder.sort(filtered) : filtered;
|
||||
};
|
||||
|
||||
const inSection = (states) => rows.filter((r) => states.includes(r.state)).sort(bySeverityThenOrder);
|
||||
|
||||
// Past = conversations from the unified list that are not currently live.
|
||||
// The endpoint already folds a transcript into its owning session (via the
|
||||
// claudeSessionId alias map), so a plain id check is enough to avoid listing
|
||||
|
||||
@@ -14908,8 +14908,9 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
}
|
||||
|
||||
/* The freshest signal on the row: while a session is actually doing something,
|
||||
its "active" stamp is the one the eye should land on. */
|
||||
.home-sessions-row--working .home-sessions-meta-active {
|
||||
how long it has been doing it is what the eye should land on (and it is what
|
||||
the rail is sorted by). */
|
||||
.home-sessions-row--working .home-sessions-meta-since {
|
||||
color: var(--green);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
@@ -224,6 +224,11 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
// re-capture instead).
|
||||
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
||||
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
||||
// Full state ride-along: the home screens sort the running group on
|
||||
// lastSubmitAt, and without this the browser keeps the stamp it loaded
|
||||
// with (a turn started after page load ranks by the PREVIOUS turn's
|
||||
// Enter). Debounced, so working-signal flaps cost one broadcast.
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordWorking();
|
||||
|
||||
Reference in New Issue
Block a user