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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ All notable changes to this project will be documented in this file.

### Added

- **Smarter pixel agents** — office canvas agents now react to four live signals instead of just test-run state:
1. *Chat-reactive*: `agentStatuses` ('working'/'waiting') is wired to `setAgentActive` so pixel agents walk to their desk and type whenever they are responding to a chat message, and wander again when idle (`PlaywrightDashboard.tsx`).
2. *Emotional reactions*: when a test run completes, all agents briefly show a green checkmark bubble (pass) or red ✗ bubble (fail) and celebrate by staying at their desks for 2.5 s (`officeState.ts`, `PlaywrightDashboard.tsx`).
3. *Handoff bubbles*: in multi-agent chat, the finishing agent gets a waiting bubble on `turn_done` — a visual "passing the baton" moment before the next agent activates (`PlaywrightDashboard.tsx`).
4. *New bubble types* — `react_pass` (light-green bg, bright checkmark) and `react_fail` (pink bg, red ×) pixel-art speech bubbles with auto-fade timer, matching the existing sprite pipeline (`types.ts`, `spriteData.ts`, `renderer.ts`).
- **SQLite run history** — replaces per-run JSON files in `test-results/runs/` with a
single `better-sqlite3` database (`test-results/runs.db`). `archiveRun()` INSERTs into
the DB; `/api/playwright/history`, `/api/playwright/results/:runId`, and
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Type a request in the chat. **Edi M**, the Team Manager, analyses your intent an

| | Feature | Description |
| ---- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🏢 | **2D Pixel-Art Office** | 7 specialist agents at animated desks — click any desk to start a conversation |
| 🏢 | **2D Pixel-Art Office** | 7 specialist agents at animated desks — agents walk to their desk and type live while responding to chat, celebrate with checkmark bubbles on passing runs, show red ✗ bubbles on failures, and display handoff bubbles when passing work between agents in multi-turn conversations |
| 🤖 | **Edi M — Team Manager** | Orchestrator agent that analyses your request and routes it to the right specialist |
| 🤝 | **Multi-Agent Collaboration** | Tag two or more specialists (or @Edi M) and they iterate: primary drafts → critic reviews → primary refines → manager delivers the polished synthesis. Each turn streams into its own chat bubble with a role pill (primary / critic / synthesis). Chat history and image attachments are forwarded to the initial primary turn so vision prompts and follow-up context are preserved across collaborative rounds |
| ✨🦙 | **Gemini + Ollama** | Switch between cloud Gemini and fully-local Ollama models with a single click — no restart required |
Expand Down
25 changes: 23 additions & 2 deletions ui/src/office/engine/officeState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,23 @@ export class OfficeState {
}
}

/** Show a timed reaction bubble (pass ✓ or fail ✗) above an agent. Auto-dismisses. */
showReactBubble(id: number, type: 'react_pass' | 'react_fail'): void {
const ch = this.characters.get(id);
if (ch) {
ch.bubbleType = type;
ch.bubbleTimer = WAITING_BUBBLE_DURATION_SEC;
}
}

/** Show a reaction bubble on every character in the office. */
triggerReactBubblesAll(type: 'react_pass' | 'react_fail'): void {
for (const ch of this.characters.values()) {
ch.bubbleType = type;
ch.bubbleTimer = WAITING_BUBBLE_DURATION_SEC;
}
}

/** Dismiss bubble on click — permission: instant, waiting: quick fade */
dismissBubble(id: number): void {
const ch = this.characters.get(id);
Expand Down Expand Up @@ -744,8 +761,12 @@ export class OfficeState {
updateCharacter(ch, dt, this.walkableTiles, this.seats, this.tileMap, this.blockedTiles),
);

// Tick bubble timer for waiting bubbles
if (ch.bubbleType === 'waiting') {
// Tick bubble timer for timed bubbles (waiting, react_pass, react_fail)
if (
ch.bubbleType === 'waiting' ||
ch.bubbleType === 'react_pass' ||
ch.bubbleType === 'react_fail'
) {
ch.bubbleTimer -= dt;
if (ch.bubbleTimer <= 0) {
ch.bubbleType = null;
Expand Down
21 changes: 17 additions & 4 deletions ui/src/office/engine/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import { getColorizedFloorSprite, hasFloorSprites, WALL_COLOR } from '../floorTi
import { getCachedSprite, getOutlineSprite } from '../sprites/spriteCache.js';
import {
BUBBLE_PERMISSION_SPRITE,
BUBBLE_REACT_FAIL_SPRITE,
BUBBLE_REACT_PASS_SPRITE,
BUBBLE_WAITING_SPRITE,
getCharacterSprites,
} from '../sprites/spriteData.js';
Expand Down Expand Up @@ -497,11 +499,22 @@ function renderBubbles(
if (!ch.bubbleType) continue;

const sprite =
ch.bubbleType === 'permission' ? BUBBLE_PERMISSION_SPRITE : BUBBLE_WAITING_SPRITE;

// Compute opacity: permission = full, waiting = fade in last 0.5s
ch.bubbleType === 'permission'
? BUBBLE_PERMISSION_SPRITE
: ch.bubbleType === 'react_pass'
? BUBBLE_REACT_PASS_SPRITE
: ch.bubbleType === 'react_fail'
? BUBBLE_REACT_FAIL_SPRITE
: BUBBLE_WAITING_SPRITE;

// Compute opacity: permission = full, timed bubbles fade out in last 0.5s
let alpha = 1.0;
if (ch.bubbleType === 'waiting' && ch.bubbleTimer < BUBBLE_FADE_DURATION_SEC) {
if (
(ch.bubbleType === 'waiting' ||
ch.bubbleType === 'react_pass' ||
ch.bubbleType === 'react_fail') &&
ch.bubbleTimer < BUBBLE_FADE_DURATION_SEC
) {
alpha = ch.bubbleTimer / BUBBLE_FADE_DURATION_SEC;
}

Expand Down
27 changes: 27 additions & 0 deletions ui/src/office/sprites/bubble-react-fail.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "bubble-react-fail",
"description": "Reaction fail bubble: light-pink bg with red X mark, tail pointer (11x13)",
"width": 11,
"height": 13,
"palette": {
"_": "",
"B": "#555566",
"F": "#FFDDDD",
"R": "#EE3333"
},
"pixels": [
["_", "B", "B", "B", "B", "B", "B", "B", "B", "B", "_"],
["B", "F", "F", "F", "F", "F", "F", "F", "F", "F", "B"],
["B", "F", "F", "R", "F", "F", "F", "R", "F", "F", "B"],
["B", "F", "F", "F", "R", "F", "R", "F", "F", "F", "B"],
["B", "F", "F", "F", "F", "R", "F", "F", "F", "F", "B"],
["B", "F", "F", "F", "R", "F", "R", "F", "F", "F", "B"],
["B", "F", "F", "R", "F", "F", "F", "R", "F", "F", "B"],
["B", "F", "F", "F", "F", "F", "F", "F", "F", "F", "B"],
["B", "F", "F", "F", "F", "F", "F", "F", "F", "F", "B"],
["_", "B", "B", "B", "B", "B", "B", "B", "B", "B", "_"],
["_", "_", "_", "_", "B", "B", "B", "_", "_", "_", "_"],
["_", "_", "_", "_", "_", "B", "_", "_", "_", "_", "_"],
["_", "_", "_", "_", "_", "_", "_", "_", "_", "_", "_"]
]
}
27 changes: 27 additions & 0 deletions ui/src/office/sprites/bubble-react-pass.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "bubble-react-pass",
"description": "Reaction pass bubble: light-green bg with bright checkmark, tail pointer (11x13)",
"width": 11,
"height": 13,
"palette": {
"_": "",
"B": "#555566",
"F": "#CCFFCC",
"G": "#22CC55"
},
"pixels": [
["_", "B", "B", "B", "B", "B", "B", "B", "B", "B", "_"],
["B", "F", "F", "F", "F", "F", "F", "F", "F", "F", "B"],
["B", "F", "F", "F", "F", "F", "F", "F", "F", "F", "B"],
["B", "F", "F", "F", "F", "F", "F", "F", "G", "F", "B"],
["B", "F", "F", "F", "F", "F", "F", "G", "F", "F", "B"],
["B", "F", "F", "G", "F", "F", "G", "F", "F", "F", "B"],
["B", "F", "F", "F", "G", "G", "F", "F", "F", "F", "B"],
["B", "F", "F", "F", "F", "F", "F", "F", "F", "F", "B"],
["B", "F", "F", "F", "F", "F", "F", "F", "F", "F", "B"],
["_", "B", "B", "B", "B", "B", "B", "B", "B", "B", "_"],
["_", "_", "_", "_", "B", "B", "B", "_", "_", "_", "_"],
["_", "_", "_", "_", "_", "B", "_", "_", "_", "_", "_"],
["_", "_", "_", "_", "_", "_", "_", "_", "_", "_", "_"]
]
}
8 changes: 8 additions & 0 deletions ui/src/office/sprites/spriteData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { adjustSprite } from '../colorize.js';
import type { Direction, SpriteData } from '../types.js';
import { Direction as Dir } from '../types.js';
import bubblePermissionData from './bubble-permission.json';
import bubbleReactFailData from './bubble-react-fail.json';
import bubbleReactPassData from './bubble-react-pass.json';
import bubbleWaitingData from './bubble-waiting.json';

// ── Speech Bubble Sprites ───────────────────────────────────────
Expand All @@ -23,6 +25,12 @@ export const BUBBLE_PERMISSION_SPRITE: SpriteData = resolveBubbleSprite(bubblePe
/** Waiting bubble: white square with green checkmark, and a tail pointer (11x13) */
export const BUBBLE_WAITING_SPRITE: SpriteData = resolveBubbleSprite(bubbleWaitingData);

/** React-pass bubble: light-green bg with bright checkmark (11x13) */
export const BUBBLE_REACT_PASS_SPRITE: SpriteData = resolveBubbleSprite(bubbleReactPassData);

/** React-fail bubble: light-pink bg with red X mark (11x13) */
export const BUBBLE_REACT_FAIL_SPRITE: SpriteData = resolveBubbleSprite(bubbleReactFailData);

// ════════════════════════════════════════════════════════════════
// Loaded character sprites (from PNG assets)
// ════════════════════════════════════════════════════════════════
Expand Down
2 changes: 1 addition & 1 deletion ui/src/office/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export interface Character {
/** Assigned seat uid, or null if no seat */
seatId: string | null;
/** Active speech bubble type, or null if none showing */
bubbleType: 'permission' | 'waiting' | null;
bubbleType: 'permission' | 'waiting' | 'react_pass' | 'react_fail' | null;
/** Countdown timer for bubble (waiting: 2→0, permission: unused) */
bubbleTimer: number;
/** Timer to stay seated while inactive after seat reassignment (counts down to 0) */
Expand Down
46 changes: 42 additions & 4 deletions ui/src/pages/PlaywrightDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4259,6 +4259,7 @@ interface PwOfficePanelProps {
function PwOfficePanel({ running, suites, onRunCode, onRunAll }: PwOfficePanelProps) {
const {
agents,
agentStatuses,
settings,
updateSettings,
messages,
Expand All @@ -4277,6 +4278,10 @@ function PwOfficePanel({ running, suites, onRunCode, onRunAll }: PwOfficePanelPr
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(null);
/** Tracks the previous `running` value to detect run completion */
const prevRunningRef = useRef(false);
/** Epoch ms until which all agents stay briefly active (celebration) */
const celebrationEndRef = useRef(0);

const [assetsReady, setAssetsReady] = useState(false);
const [inputText, setInputText] = useState('');
Expand Down Expand Up @@ -4322,23 +4327,49 @@ function PwOfficePanel({ running, suites, onRunCode, onRunAll }: PwOfficePanelPr
// ── Derive failure flag ───────────────────────────────────────────────────
const hasFailures = suites.some((s) => s.tests.some((t) => t.status === 'failed'));

// ── Sync: drive animations from test state ────────────────────────────────
// ── Sync: drive animations from test state + chat status ─────────────────
useEffect(() => {
if (!_pwOfficeState) return;
const isCelebrating = Date.now() < celebrationEndRef.current;
agents.forEach((agent, idx) => {
const numId = pwAgentNumId(idx);
const chatActive =
agentStatuses[agent.id] === 'working' || agentStatuses[agent.id] === 'waiting';
const active =
running || (hasFailures && (agent.id === 'agent-triage' || agent.id === 'agent-healer'));
running ||
chatActive ||
isCelebrating ||
(hasFailures && (agent.id === 'agent-triage' || agent.id === 'agent-healer'));
_pwOfficeState!.setAgentActive(numId, active);
});
}, [agents, running, hasFailures]);
}, [agents, running, hasFailures, agentStatuses]);

// ── React bubbles on run completion ──────────────────────────────────────
useEffect(() => {
if (!_pwOfficeState) return;
const wasRunning = prevRunningRef.current;
prevRunningRef.current = running;
if (!wasRunning || running) return; // only fires on true→false transition
if (hasFailures) {
_pwOfficeState.triggerReactBubblesAll('react_fail');
} else {
_pwOfficeState.triggerReactBubblesAll('react_pass');
celebrationEndRef.current = Date.now() + 2500;
}
}, [running, hasFailures]);

const handleAgentClick = (charId: number) => {
const agent = agents[charId - 1];
if (!agent) return;
setSelectedAgentId(agent.id);
};

/** Map an agent's string id (e.g. "agent-triage") to its canvas numId */
const agentStringToNumId = (agentId: string): number | null => {
const idx = agents.findIndex((a) => a.id === agentId);
return idx === -1 ? null : pwAgentNumId(idx);
};

// ── @-mention autocomplete ────────────────────────────────────────────────

const mentionMatches =
Expand Down Expand Up @@ -4664,7 +4695,14 @@ function PwOfficePanel({ running, suites, onRunCode, onRunAll }: PwOfficePanelPr
continue;
}
if (payload.evt === 'turn_done') {
if (payload.agentId) setAgentStatus(payload.agentId, 'idle');
if (payload.agentId) {
setAgentStatus(payload.agentId, 'idle');
// Handoff bubble: brief waiting indicator on the finishing agent
const numId = agentStringToNumId(payload.agentId);
if (numId !== null && _pwOfficeState) {
_pwOfficeState.showWaitingBubble(numId);
}
}
continue;
}
if (payload.evt === 'turn_verdict') {
Expand Down
Loading