Skip to content

Commit 010c791

Browse files
committed
Merge branch 'feat/ultra-workflow-agents'
2 parents a42dbab + cede09a commit 010c791

9 files changed

Lines changed: 374 additions & 27 deletions

File tree

client/src/game/PhaserGame.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useRef } from 'react';
22
import * as Phaser from 'phaser';
33
import { gameConfig } from './config';
4+
import { applyDprSize, getRenderScale } from './dpr';
45

56
export function PhaserGame() {
67
const containerRef = useRef<HTMLDivElement>(null);
@@ -10,14 +11,46 @@ export function PhaserGame() {
1011
if (containerRef.current === null) return;
1112
if (gameRef.current !== null) return;
1213

14+
const container = containerRef.current;
15+
const renderScale = getRenderScale();
16+
1317
const game = new Phaser.Game({
1418
...gameConfig,
15-
parent: containerRef.current,
19+
parent: container,
20+
scale: {
21+
...gameConfig.scale,
22+
// Backing store at physical pixels, CSS at logical pixels (zoom).
23+
// The container is laid out by the time this effect runs, so its
24+
// client size is the real viewport.
25+
width: Math.max(1, Math.floor(container.clientWidth * renderScale)),
26+
height: Math.max(1, Math.floor(container.clientHeight * renderScale)),
27+
zoom: 1 / renderScale,
28+
},
1629
});
1730

1831
gameRef.current = game;
1932

33+
// Scale.NONE means Phaser no longer tracks the parent — resize manually.
34+
const resizeObserver = new ResizeObserver(() => applyDprSize(game, container));
35+
resizeObserver.observe(container);
36+
37+
// ResizeObserver misses DPR-only changes (window dragged between a
38+
// Retina and a non-Retina display without the CSS size changing), so
39+
// also watch the media query — same technique as text.ts.
40+
let dprQuery: MediaQueryList | null = null;
41+
const onDprChange = () => {
42+
applyDprSize(game, container);
43+
watchDpr();
44+
};
45+
const watchDpr = () => {
46+
dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
47+
dprQuery.addEventListener('change', onDprChange, { once: true });
48+
};
49+
watchDpr();
50+
2051
return () => {
52+
resizeObserver.disconnect();
53+
dprQuery?.removeEventListener('change', onDprChange);
2154
game.destroy(true);
2255
gameRef.current = null;
2356
};

client/src/game/config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@ export const gameConfig: Phaser.Types.Core.GameConfig = {
1010
// upscale. Both Tiny Swords packs are pixel art, so this is safe for
1111
// every theme we ship.
1212
pixelArt: true,
13+
// Scale.NONE because RESIZE forces the canvas backing store to CSS pixels,
14+
// which on Retina displays renders at half resolution and lets the browser
15+
// blur-upscale everything. PhaserGame sizes the canvas at physical pixels
16+
// (cssSize * devicePixelRatio) with zoom = 1/dpr instead — see dpr.ts.
1317
scale: {
14-
mode: Phaser.Scale.RESIZE,
18+
mode: Phaser.Scale.NONE,
1519
autoCenter: Phaser.Scale.CENTER_BOTH,
1620
},
1721
input: {

client/src/game/dpr.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as Phaser from 'phaser';
2+
3+
/**
4+
* Device-pixel-ratio handling for the Phaser canvas.
5+
*
6+
* Phaser 4 sizes the canvas backing store in CSS pixels and never consults
7+
* `window.devicePixelRatio` (ScaleManager has no DPR support). On a Retina
8+
* display that leaves the WebGL framebuffer at half the physical resolution
9+
* and the browser upscales it, so sprites and especially text look grainy.
10+
*
11+
* The fix: run the game in `Scale.NONE`, size the backing store at
12+
* `cssSize * renderScale` and set `scale.zoom = 1 / renderScale` so the
13+
* canvas CSS size stays at logical pixels. Input mapping is unaffected —
14+
* ScaleManager derives `displayScale` from the canvas bounding rect.
15+
*/
16+
17+
/** Backing-store multiplier: the device pixel ratio, clamped to [1, 3] to
18+
* keep VRAM bounded on high-density mobile screens. */
19+
export function getRenderScale(): number {
20+
const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
21+
return Math.min(Math.max(dpr, 1), 3);
22+
}
23+
24+
/** The render scale a live game is currently using (the inverse of the
25+
* ScaleManager zoom we set in PhaserGame). 1 when no zoom is applied. */
26+
export function sceneRenderScale(scene: Phaser.Scene): number {
27+
const zoom = scene.game.scale.zoom;
28+
return zoom > 0 ? 1 / zoom : 1;
29+
}
30+
31+
/** Resize the game so the backing store matches physical pixels for the
32+
* container's current CSS size. Safe to call repeatedly. */
33+
export function applyDprSize(game: Phaser.Game, container: HTMLElement): void {
34+
const scale = getRenderScale();
35+
const width = container.clientWidth;
36+
const height = container.clientHeight;
37+
if (width <= 0 || height <= 0) return;
38+
39+
if (game.scale.zoom !== 1 / scale) {
40+
game.scale.setZoom(1 / scale);
41+
}
42+
game.scale.resize(Math.floor(width * scale), Math.floor(height * scale));
43+
}

client/src/game/scenes/BootScene.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { BUILDING_DEFS } from '../data/building-layout';
44
import { addCrispText } from '../text';
55
import { getActiveTheme } from '../themes/registry';
66
import { groupMissingByCategory } from '../data/asset-diagnostics';
7+
import { sceneRenderScale } from '../dpr';
78

89

910

@@ -82,15 +83,24 @@ export class BootScene extends Phaser.Scene {
8283

8384
this.cameras.main.setBackgroundColor('#1a1a2e');
8485

85-
const cx = this.cameras.main.centerX;
86-
const cy = this.cameras.main.centerY;
86+
// The canvas backing store is at physical pixels (see dpr.ts). Lay the
87+
// boot UI out in logical (CSS-pixel) coordinates and zoom the camera by
88+
// the render scale, so text keeps its apparent size on Retina displays.
89+
const ui = sceneRenderScale(this);
90+
const vw = this.scale.width / ui;
91+
const vh = this.scale.height / ui;
92+
this.cameras.main.setZoom(ui);
93+
this.cameras.main.centerOn(vw / 2, vh / 2);
94+
95+
const cx = vw / 2;
96+
const cy = vh / 2;
8797
const hasMissing = this.missingAssets.length > 0;
8898

8999
// Logo is always rendered the same way — the missing-sprites state just
90100
// changes the status line underneath and adds a single action button.
91101
const logo = this.add.image(cx, cy - 40, 'logo').setOrigin(0.5);
92-
const maxW = Math.min(this.scale.width * 0.5, 520);
93-
const maxH = this.scale.height * 0.55;
102+
const maxW = Math.min(vw * 0.5, 520);
103+
const maxH = vh * 0.55;
94104
const scale = Math.min(maxW / logo.width, maxH / logo.height);
95105
logo.setScale(scale);
96106

@@ -106,7 +116,7 @@ export class BootScene extends Phaser.Scene {
106116
color: '#f0d89a',
107117
fontFamily: 'monospace',
108118
align: 'center',
109-
wordWrap: { width: Math.min(this.scale.width * 0.8, 640) },
119+
wordWrap: { width: Math.min(vw * 0.8, 640) },
110120
}).setOrigin(0.5);
111121

112122
// Per-category breakdown so the user can tell at a glance whether
@@ -136,7 +146,7 @@ export class BootScene extends Phaser.Scene {
136146
color: '#8ea0b4',
137147
fontFamily: 'monospace',
138148
align: 'left',
139-
wordWrap: { width: Math.min(this.scale.width * 0.9, 780) },
149+
wordWrap: { width: Math.min(vw * 0.9, 780) },
140150
}).setOrigin(0.5, 0);
141151

142152
const hintText =
@@ -147,7 +157,7 @@ export class BootScene extends Phaser.Scene {
147157
color: '#aabbcc',
148158
fontFamily: 'monospace',
149159
align: 'center',
150-
wordWrap: { width: Math.min(this.scale.width * 0.9, 720) },
160+
wordWrap: { width: Math.min(vw * 0.9, 720) },
151161
}).setOrigin(0.5, 0);
152162

153163
const primary = addCrispText(this, cx, hint.y + hint.displayHeight + 24, '↻ Reload page', {

client/src/game/scenes/VillageScene.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { AgentState } from '../../types/agent';
1212
import type { AssetManifest, MapConfig, BuildingPosition, NpcPlacement } from '../../editor/types/map';
1313
import { SERVER_URL as API_BASE } from '../../config';
1414
import { getActiveTheme, rebaseSavedScale } from '../themes/registry';
15+
import { sceneRenderScale } from '../dpr';
1516

1617
/** Set `cam.zoom` to `newZoom` while keeping the world point currently
1718
* under screen coordinates (sx, sy) pinned to the same screen spot.
@@ -150,7 +151,7 @@ export class VillageScene extends Phaser.Scene {
150151
// map is locked to the top-left corner while the viewport scales.
151152
this.input.on('wheel', (pointer: Phaser.Input.Pointer, _gameObjects: unknown[], _deltaX: number, deltaY: number) => {
152153
const cam = this.cameras.main;
153-
const newZoom = Phaser.Math.Clamp(cam.zoom - deltaY * 0.001, this.minZoom(), 1.5);
154+
const newZoom = Phaser.Math.Clamp(cam.zoom - deltaY * 0.001 * sceneRenderScale(this), this.minZoom(), this.maxZoom());
154155
zoomAroundPointer(cam, pointer.x, pointer.y, newZoom);
155156
});
156157

@@ -167,7 +168,7 @@ export class VillageScene extends Phaser.Scene {
167168
pinchStartZoom = this.cameras.main.zoom;
168169
} else {
169170
const scale = dist / pinchStartDist;
170-
const newZoom = Phaser.Math.Clamp(pinchStartZoom * scale, this.minZoom(), 1.5);
171+
const newZoom = Phaser.Math.Clamp(pinchStartZoom * scale, this.minZoom(), this.maxZoom());
171172
const mx = (p1.x + p2.x) / 2;
172173
const my = (p1.y + p2.y) / 2;
173174
zoomAroundPointer(this.cameras.main, mx, my, newZoom);
@@ -481,7 +482,15 @@ export class VillageScene extends Phaser.Scene {
481482
const villageH = 700;
482483
const zoomX = cam.width / villageW;
483484
const zoomY = cam.height / villageH;
484-
cam.setZoom(Phaser.Math.Clamp(Math.min(zoomX, zoomY) * 0.85, this.minZoom(), 1.5));
485+
cam.setZoom(Phaser.Math.Clamp(Math.min(zoomX, zoomY) * 0.85, this.minZoom(), this.maxZoom()));
486+
}
487+
488+
/** Upper zoom bound. 1.5 was tuned for a 1:1 (CSS pixel) framebuffer; the
489+
* canvas now renders at physical pixels (see dpr.ts), so camera zoom values
490+
* carry an extra devicePixelRatio factor and the cap scales with it to keep
491+
* the same apparent maximum magnification. */
492+
private maxZoom(): number {
493+
return 1.5 * sceneRenderScale(this);
485494
}
486495

487496
/** Lower zoom bound: the larger of the two viewport/world ratios, so the

server/src/parsers/subagent-label.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,97 @@ describe('resolveSubagentLabel', () => {
116116
expect(label!.endsWith('…')).toBe(true);
117117
});
118118

119+
test('resolves labels for workflow (ultra mode) agent paths', async () => {
120+
const workflowDir = join(projectDir, parentSessionId, 'subagents', 'workflows', 'wf_2ca7ddd8-324');
121+
await mkdir(workflowDir, { recursive: true });
122+
const workflowAgentPath = join(workflowDir, 'agent-a4f3fba033446cc4f.jsonl');
123+
await writeFile(workflowAgentPath, jsonline({
124+
type: 'user',
125+
message: { role: 'user', content: 'You are a senior engineer writing a plan. Read the spec first.' },
126+
}));
127+
128+
const label = await resolveSubagentLabel(workflowAgentPath);
129+
expect(label).toBe('You are a senior engineer writing a plan');
130+
});
131+
132+
test('workflow agents still match parent Agent invocations when present', async () => {
133+
const workflowDir = join(projectDir, parentSessionId, 'subagents', 'workflows', 'wf_deadbeef-123');
134+
await mkdir(workflowDir, { recursive: true });
135+
const workflowAgentPath = join(workflowDir, 'agent-bb11cc22dd33ee44f.jsonl');
136+
await writeFile(workflowAgentPath, jsonline({
137+
type: 'user',
138+
message: { role: 'user', content: 'Audit module Y' },
139+
}));
140+
await writeFile(parentPath, jsonline({
141+
type: 'assistant',
142+
message: {
143+
role: 'assistant',
144+
content: [{
145+
type: 'tool_use',
146+
name: 'Agent',
147+
input: { description: 'Audit Y', prompt: 'Audit module Y' },
148+
}],
149+
},
150+
}));
151+
152+
const label = await resolveSubagentLabel(workflowAgentPath);
153+
expect(label).toBe('Audit Y');
154+
});
155+
156+
test('prefers description from the sibling meta.json over everything', async () => {
157+
await writeFile(subagentPath, jsonline({
158+
type: 'user',
159+
message: { role: 'user', content: 'Run a thorough audit of X' },
160+
}));
161+
await writeFile(subagentPath.replace('.jsonl', '.meta.json'), JSON.stringify({
162+
agentType: 'Explore',
163+
description: 'Verify Claude Code lifecycle hooks',
164+
toolUseId: 'toolu_123',
165+
}));
166+
await writeFile(parentPath, jsonline({
167+
type: 'assistant',
168+
message: {
169+
role: 'assistant',
170+
content: [{
171+
type: 'tool_use',
172+
name: 'Agent',
173+
input: { description: 'Audit X for regressions', prompt: 'Run a thorough audit of X' },
174+
}],
175+
},
176+
}));
177+
178+
const label = await resolveSubagentLabel(subagentPath);
179+
expect(label).toBe('Verify Claude Code lifecycle hooks');
180+
});
181+
182+
test('combines meta agentType with the prompt first sentence for workflow agents', async () => {
183+
const workflowDir = join(projectDir, parentSessionId, 'subagents', 'workflows', 'wf_0f3513d0-3cc');
184+
await mkdir(workflowDir, { recursive: true });
185+
const workflowAgentPath = join(workflowDir, 'agent-a2282a1d2c6a1932f.jsonl');
186+
await writeFile(workflowAgentPath, jsonline({
187+
type: 'user',
188+
message: { role: 'user', content: 'Esplora il backend Laravel. Rispondi con snippet.' },
189+
}));
190+
await writeFile(workflowAgentPath.replace('.jsonl', '.meta.json'), JSON.stringify({ agentType: 'Explore' }));
191+
192+
const label = await resolveSubagentLabel(workflowAgentPath);
193+
expect(label).toBe('Explore: Esplora il backend Laravel');
194+
});
195+
196+
test('drops the generic workflow-subagent agentType from meta.json', async () => {
197+
const workflowDir = join(projectDir, parentSessionId, 'subagents', 'workflows', 'wf_2ca7ddd8-324');
198+
await mkdir(workflowDir, { recursive: true });
199+
const workflowAgentPath = join(workflowDir, 'agent-afa8678eb14a03831.jsonl');
200+
await writeFile(workflowAgentPath, jsonline({
201+
type: 'user',
202+
message: { role: 'user', content: 'Write the plan. Then stop.' },
203+
}));
204+
await writeFile(workflowAgentPath.replace('.jsonl', '.meta.json'), JSON.stringify({ agentType: 'workflow-subagent' }));
205+
206+
const label = await resolveSubagentLabel(workflowAgentPath);
207+
expect(label).toBe('Write the plan');
208+
});
209+
119210
test('ignores parent Agent invocations with non-matching prompt', async () => {
120211
await writeFile(subagentPath, jsonline({
121212
type: 'user',

0 commit comments

Comments
 (0)