diff --git a/README.md b/README.md index 23d6abe..72a5bd4 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ every push and pull request. like T3 Code's thread.next and thread.previous. - Every prompt is its own WPM test, timed from its first key to its last. Time spent in other threads does not count. Each sent message shows its WPM and accuracy. -- A session ends when every thread is done. +- When a thread's last reply finishes, read it, then press Enter to settle the thread and move on. +- A session ends when you settle the last thread. ## Content diff --git a/src/App.tsx b/src/App.tsx index b18f8fe..bf2011a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,11 +4,14 @@ import { ChatView } from "./components/ChatView"; import { ThreadSidebar } from "./components/Sidebar"; import { backspace, + activeThread, cycleThread, getState, + isComplete, nextWaitingThread, restart, send, + settleActive, typeChar, useGame, } from "./game/store"; @@ -38,7 +41,9 @@ function onKeyDown(event: KeyboardEvent) { } if (event.key === "Enter") { event.preventDefault(); - if (!event.shiftKey) send(); + if (event.shiftKey) return; + if (isComplete(activeThread())) settleActive(); + else send(); return; } if (event.key === "Backspace") { diff --git a/src/components/chat/ChatComposer.tsx b/src/components/chat/ChatComposer.tsx index 1cd28e1..5ef2386 100644 --- a/src/components/chat/ChatComposer.tsx +++ b/src/components/chat/ChatComposer.tsx @@ -268,7 +268,7 @@ export function ChatComposer({ game, thread }: { game: GameState; thread: Thread ? "The agent is working. Press Tab to switch to a thread that needs you." : prompt ? prompt.text - : "Thread finished. Settling it and moving to the next one."; + : "Thread finished. Press Enter to settle it and move on."; return ( diff --git a/src/game/store.ts b/src/game/store.ts index bb5c339..19629eb 100644 --- a/src/game/store.ts +++ b/src/game/store.ts @@ -55,7 +55,6 @@ export interface GameState { const params = new URLSearchParams(typeof location === "undefined" ? "" : location.search); const SPEED = Math.max(0.1, Number(params.get("speed") ?? 1) || 1); -const SETTLE_DELAY_MS = 1800; const seedParam = params.get("seed"); let sessionSeed = seedParam !== null && seedParam !== "" ? Number(seedParam) : Math.floor(Math.random() * 1e9); @@ -220,34 +219,7 @@ function advance(s: GameState, now: GameMs): GameState { updatedAt: finishedAt, }; }); - let next: GameState = { ...s, threads: changed ? threads : s.threads, now }; - next = autoSettle(next, now); - if (s.session.status === "playing" && next.threads.every(isComplete)) { - next = endSession(next, "done", now); - } - return next; -} - -function autoSettle(s: GameState, now: GameMs): GameState { - const delay = SETTLE_DELAY_MS * SPEED; - const ready = s.threads.filter((thread) => { - if (isSettled(thread) || !isComplete(thread)) return false; - const last = thread.turns.at(-1); - return last !== undefined && now >= last.startedAt + last.durationMs + delay; - }); - if (ready.length === 0) return s; - const readyIds = new Set(ready.map((thread) => thread.def.id)); - let next: GameState = { - ...s, - threads: s.threads.map((thread) => - readyIds.has(thread.def.id) ? { ...thread, settledAt: now, unread: false } : thread, - ), - }; - if (readyIds.has(s.activeId)) { - const target = nextThreadNeedingPlayer(next); - if (target) next = withSelected(next, target); - } - return next; + return { ...s, threads: changed ? threads : s.threads, now }; } function endSession(s: GameState, reason: "done", at: GameMs): GameState { @@ -384,10 +356,25 @@ function nextThreadNeedingPlayer(s: GameState): string | null { waiting.find((id) => byId.get(id)!.unread) ?? waiting[0] ?? rotated.find((id) => isWorking(byId.get(id)!)) ?? + rotated.find((id) => !isSettled(byId.get(id)!)) ?? null ); } +export function settleActive() { + const thread = activeThread(); + if (state.session.status === "ended" || isSettled(thread) || !isComplete(thread)) return; + const now = clock(); + let s = advance(state, now); + s = updateThread(s, thread.def.id, (t) => ({ ...t, settledAt: now, unread: false })); + if (s.threads.every(isSettled)) { + set(endSession(s, "done", now)); + return; + } + const target = nextThreadNeedingPlayer(s); + set(target ? withSelected(s, target) : s); +} + export function cycleThread(direction: 1 | -1) { const ids = unsettledIdsInSidebarOrder(); const index = ids.indexOf(state.activeId); diff --git a/tests/play.mjs b/tests/play.mjs index 54a735b..3e143d9 100644 --- a/tests/play.mjs +++ b/tests/play.mjs @@ -103,6 +103,10 @@ async function playFullSessionInDark(browser) { if (switched.activeId !== thread.def.id && !previous.turns.at(-1).done) switchesWhileRunning += 1; continue; } + if (!busy && !prompt && thread.settledAt === null) { + await page.keyboard.press("Enter"); + continue; + } await page.keyboard.press("Tab"); const next = await state(page); if (next.activeId === s.activeId) await page.waitForTimeout(150); @@ -128,23 +132,31 @@ async function playFullSessionInDark(browser) { await page.close(); } -async function checkAutoSettle(browser) { +async function checkEnterSettles(browser) { const { page, errors } = await openPage(browser, "dark", "speed=4"); const s0 = await state(page); const first = activeOf(s0); + const lastDone = (id) => { + const t = window.__game.state().threads.find((x) => x.def.id === id); + return t.turns.length === 0 || t.turns.at(-1).done; + }; for (const prompt of first.def.prompts) { - await page.waitForFunction((id) => { - const t = window.__game.state().threads.find((x) => x.def.id === id); - return t.turns.length === 0 || t.turns.at(-1).done; - }, first.def.id); + await page.waitForFunction(lastDone, first.def.id); await page.keyboard.type(prompt.text); await page.keyboard.press("Enter"); } - await page.waitForFunction((id) => window.__game.state().activeId !== id, first.def.id, { timeout: 15_000 }); + await page.waitForFunction(lastDone, first.def.id, { timeout: 20_000 }); + await page.waitForTimeout(3000); + const waited = await state(page); + const finished = waited.threads.find((t) => t.def.id === first.def.id); + check(waited.activeId === first.def.id, "a finished thread stays open until Enter"); + check(finished.settledAt === null, "a finished thread does not settle on its own"); + check((await page.locator("[data-assistant-message]").count()) > 0, "the final reply stays on screen"); + await page.keyboard.press("Enter"); const s1 = await state(page); const settled = s1.threads.find((t) => t.def.id === first.def.id); - check(settled.settledAt !== null, "finished thread auto-settles"); - check(s1.activeId !== first.def.id, `navigation moved forward on its own (to ${s1.activeId})`); + check(settled.settledAt !== null, "Enter settles a finished thread"); + check(s1.activeId !== first.def.id, `Enter moves to the next thread (to ${s1.activeId})`); const header = await page.locator('[data-testid="sidebar-settled-header"]').textContent(); check(/1/.test(header ?? ""), `Settled shelf shows 1 (got "${header?.trim()}")`); await page.keyboard.press("Tab"); @@ -305,7 +317,7 @@ try { await checkWrongKeysShowExpectedText(browser); await checkWordsStayPutWhileTyping(browser); await playFullSessionInDark(browser); - await checkAutoSettle(browser); + await checkEnterSettles(browser); await checkPerPromptWpmInLight(browser); await checkComposerFontSize(browser); } finally {