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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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") {
Expand Down
2 changes: 1 addition & 1 deletion src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Shell>
<Host>
Expand Down
45 changes: 16 additions & 29 deletions src/game/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 21 additions & 9 deletions tests/play.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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");
Expand Down Expand Up @@ -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 {
Expand Down
Loading