Something went wrong. Try again.
Where attention appears to go across agent threads: collector, web explorer, and native iOS client
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298// @ts-checkimport { duration, plural, shiftDay } from "./dates.mjs";import { estimate, summarize } from "./model.mjs";import { repositoryReference } from "./repos.mjs";/** @typedef {import("./contract.mjs").Attention} Attention *//** @typedef {import("./contract.mjs").AttentionEvent} AttentionEvent *//** @typedef {import("./contract.mjs").Thread} Thread *//** @typedef {import("./dates.mjs").Calendar} Calendar *//** @typedef {import("./model.mjs").GroupBy} GroupBy *//** @typedef {import("./model.mjs").Span} Span *//** @typedef {1 | 7 | 30} Period *//** * @typedef {{day: string, period: Period, project: string, search: string, * threadFilter: string[] | null, gap: number, group: GroupBy}} TimelineState */
export const PERIODS = /** @type {const} */ ([ [1, "Selected day"], [7, "7 days ending here"], [30, "30 days ending here"],]);export const GROUPS = /** @type {const} */ ([ ["project", "Project"], ["repo", "Repository"], ["thread", "Thread"],]);export const DEFAULT_GAP = 5;export const TRANSITION_LIMIT = 12;
/** @param {unknown} value @returns {Period | null} */export const parsePeriod = (value) => value === "1" || value === 1 ? 1 : value === "7" || value === 7 ? 7 : value === "30" || value === 30 ? 30 : null;
/** @param {Period} period */export const periodLabel = (period) => PERIODS.find(([p]) => p === period)?.[1] || "";
/** @param {Thread} thread */export const projectKey = (thread) => thread.repo || thread.project_key;
/** @param {Attention} data @param {Calendar} cal */export function countsByDay(data, cal) { /** @type {Map<string, Set<string>>} */ const counts = new Map(); for (const event of data.events) { const day = cal.dateKey(event.at); let set = counts.get(day); if (!set) counts.set(day, (set = new Set())); set.add(event.thread); } return counts;}
/** Interactions per day, unfiltered. @param {Attention} data @param {Calendar} cal */export function activityByDay(data, cal) { /** @type {Map<string, number>} */ const counts = new Map(); for (const event of data.events) { const day = cal.dateKey(event.at); counts.set(day, (counts.get(day) || 0) + 1); } return counts;}
/** Sorted project filter choices. @param {Attention} data @returns {[string, string][]} */export function projectOptions(data) { const projects = new Map( Object.values(data.threads).map((t) => [ projectKey(t), repositoryReference(t.repo, t.project)?.label || `${t.project} (workspace)`, ]), ); return [...projects].sort((a, b) => a[1].localeCompare(b[1]));}
/** Estimates keyed by gap minutes, computed over all events. */export function estimator() { /** @type {Map<number, ReturnType<typeof estimate>>} */ const cache = new Map(); /** @type {Attention | null} */ let source = null; /** @param {Attention} data @param {number} minutes */ return (data, minutes) => { if (source !== data) { cache.clear(); source = data; } let result = cache.get(minutes); if (!result) cache.set(minutes, (result = estimate(data.events, minutes * 60))); return result; };}
/** * @param {Attention} data * @param {Pick<TimelineState, "project" | "search" | "threadFilter">} state */function matcher(data, state) { const query = state.search.toLowerCase(); /** @param {{thread: string}} event */ return (event) => { const thread = data.threads[event.thread]; if (!thread) return false; return ( (!state.threadFilter || state.threadFilter.includes(event.thread)) && (!state.project || projectKey(thread) === state.project) && `${thread.title} ${thread.project} ${thread.repo}`.toLowerCase().includes(query) ); };}
/** * The whole timeline view, rendered by both clients. * @param {Attention} data * @param {TimelineState} state * @param {Calendar} cal * @param {ReturnType<typeof estimator>} estimates */export function computeTimeline(data, state, cal, estimates, now = Date.now() / 1000) { const matches = matcher(data, state); const [start, end] = cal.bounds(state.day, state.period); const global = estimates(data, state.gap); const visibleEvents = data.events.filter((e) => e.at >= start && e.at < end && matches(e)); const visibleSpans = global.spans .filter((s) => s.end > start && s.start < end && matches(s)) .map((s) => ({ ...s, start: Math.max(s.start, start), end: Math.min(s.end, end) })); const switches = global.switches.filter( (s) => s.at >= start && s.at < end && (matches({ thread: s.from }) || matches({ thread: s.to })), ); const stats = [ { value: String(visibleEvents.length), label: "recorded interactions", short: "interactions" }, { value: String(new Set(visibleEvents.map((e) => e.thread)).size), label: "conversations", short: "conversations" }, { value: duration(visibleSpans.reduce((n, s) => n + s.end - s.start, 0)), label: "estimated spans", short: "est. spans" }, { value: String(switches.length), label: "nearby thread transitions", short: "transitions" }, ];
/** @type {Map<string, number>} */ const perDay = new Map(); for (const event of data.events.filter(matches)) { const key = cal.dateKey(event.at); perDay.set(key, (perDay.get(key) || 0) + 1); } const busiest = Math.max(1, ...perDay.values()); const last = cal.dateKey(data.at); const bars = Array.from({ length: 30 }, (_, i) => { const day = shiftDay(last, i - 29); const count = perDay.get(day) || 0; return { day, count, height: count ? Math.max(2 / 56, count / busiest) : 0, label: `${day}: ${plural(count, "signal")}`, number: String(Number(day.slice(-2))) }; });
const [dayStart, dayEnd] = cal.bounds(state.day); const dayEvents = data.events.filter((e) => e.at >= dayStart && e.at < dayEnd && matches(e)); /** @type {Map<string, AttentionEvent[]>} */ const byThread = new Map(); for (const event of dayEvents) { let list = byThread.get(event.thread); if (!list) byThread.set(event.thread, (list = [])); list.push(event); } const daySpans = global.spans.filter((s) => s.end > dayStart && s.start < dayEnd && matches(s)); /** @type {null | {low: number, high: number, now: number | null, axis: string[], rows: {id: string, thread: Thread, label: string, sublabel: string, events: AttentionEvent[], spans: Span[]}[]}} */ let track = null; if (dayEvents.length) { // on the current day the axis reaches the present, so the now line has a place const today = now >= dayStart && now < dayEnd; const latest = Math.max(...dayEvents.map((e) => e.at), today ? now : 0); const low = Math.max(dayStart, Math.floor(Math.min(...dayEvents.map((e) => e.at)) / 3600) * 3600); const high = Math.min(dayEnd, Math.ceil((latest + 1) / 3600) * 3600); track = { low, high, now: today ? now : null, axis: Array.from({ length: 5 }, (_, n) => cal.time(low + ((high - low) * n) / 4)), rows: [...byThread].map(([id, events]) => { const thread = data.threads[id]; return { id, thread, label: thread.display_title || thread.title, sublabel: repositoryReference(thread.repo, thread.project)?.label || thread.project, events, spans: daySpans .filter((s) => s.thread === id) .map((s) => ({ ...s, start: Math.max(low, s.start), end: Math.min(high, s.end) })), }; }), }; }
const groups = summarize(visibleEvents, visibleSpans, data.threads, state.group); const highest = Math.max(1, ...groups.map((g) => g.seconds)); const breakdown = groups.map((g) => ({ key: g.key, label: g.label, duration: duration(g.seconds), detail: `${plural(g.count, "signal")} · ${plural(g.threads.size, "thread")}`, share: g.seconds / highest, firstThread: [...g.threads][0], }));
const transitions = switches .slice(-TRANSITION_LIMIT) .reverse() .map((t) => ({ key: `${t.from}>${t.to}@${t.at}`, to: t.to, when: `${cal.dateKey(t.at)} · ${cal.time(t.at)} · ${duration(t.gap)} between signals`, label: `${data.threads[t.from].title} → ${data.threads[t.to].title}`, }));
const projectLabel = state.project ? projectOptions(data).find(([key]) => key === state.project)?.[1] || "All projects" : "All projects"; return { filterContext: state.threadFilter ? `Conversation: ${state.threadFilter.map((id) => data.threads[id]?.title || "Not in the recorded history").join(" / ")}` : "", filterLabel: `${periodLabel(state.period)} · ${projectLabel}${state.search ? " · Search active" : ""}`, coverageNote: data.since && start < data.since ? `Only records since ${cal.dateKey(data.since)} are available in this window.` : "", stats, bars, title: cal.title(state.day), track, breakdown, transitions, transitionTotal: switches.length, transitionOverflow: switches.length > TRANSITION_LIMIT ? `Showing the latest ${TRANSITION_LIMIT} of ${switches.length} transitions.` : "", };}
/** Position of a timestamp on a 0..1000 track, matching the web SVG. @param {number} at @param {number} low @param {number} high */export const trackX = (at, low, high) => 8 + ((at - low) / (high - low)) * 984;
/** * @param {Attention} data * @param {string} id * @param {Pick<TimelineState, "day" | "period">} state * @param {Calendar} cal */export function inspectThread(data, id, state, cal) { const thread = data.threads[id]; if (!thread) return null; const [start, end] = cal.bounds(state.day, state.period); const events = data.events.filter((e) => e.thread === id && e.at >= start && e.at < end); return { id, thread, day: state.day, period: state.period, events, dateLabel: state.period > 1 ? `${cal.longDate(shiftDay(state.day, 1 - state.period))} – ${cal.longDate(state.day)}` : cal.fullLabel(state.day), meta: `${plural(events.length, "interaction")} in the selected period`, recapDay: events.length ? cal.dateKey(/** @type {AttentionEvent} */ (events.at(-1)).at) : state.day, related: Object.entries(data.threads).filter(([key, t]) => key !== id && thread.repo && t.repo === thread.repo), entries: [...events].reverse().map((e) => ({ key: `${e.at}:${e.kind}`, text: `${cal.longDate(cal.dateKey(e.at))}, ${cal.time(e.at)} · ${e.kind === "voice" ? "Spoken turn" : "Message"}`, })), };}
export const timelineCopy = { loadFailed: "Could not load the timeline. Try Refresh.", refreshFailed: "Could not refresh interaction history. Your previous view is preserved. Try Refresh.", legend: "● Message ◆ Voice", hint: "Each row is a thread. Marks are recorded interactions; connecting lines are estimates. Select a row to inspect it.", empty: "No recorded interactions for this day and these filters. Select another day or clear the filters.", acrossDays: "Across days", acrossDaysHint: "Interaction signals · tap a day", breakdown: "Breakdown", breakdownHint: "Estimated spans in the selected period. Projects with a recorded repository are grouped across worktrees.", gapTitle: "How gaps are estimated", /** @param {number} minutes */ gapLabel: (minutes) => `Connect same-thread signals up to ${minutes} minute${minutes === 1 ? "" : "s"} apart`, gapText: "Only the gap between consecutive signals in the same thread is estimated. Switching threads breaks the span. Isolated signals add no time. These estimates are not measured attention.", breakdownEmpty: "No signals in the selected period.", transitions: "Moving between threads", transitionsHint: "Consecutive signals in different threads within your gap threshold. These are transitions, not proof of related work.", transitionsEmpty: "No nearby thread transitions at this threshold.", coverage: "Coverage and limits", /** @param {string} zone */ limits: (zone) => `Estimates are calculated before filtering, so filters never bridge hidden interactions. Isolated signals add no time. Repository links use recorded remotes; shared repositories and transitions do not prove shared intent. Dates use ${zone}.`, filters: "Filters", searchPlaceholder: "Title or repository", clear: "Clear filters",};