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
64 changes: 64 additions & 0 deletions packages/client/workbench/src/mock/data/long-thread.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { AgentEvent, MessageId } from '@linkcode/schema';
import { textBlock } from '@linkcode/schema';

/** Enough turns to overflow the conversation minimap's rail, which only scrolls past ~38 of them. */
export const LONG_THREAD_TURNS = 48;

const SUBJECTS = [
'the reconnect backoff',
'the wire envelope',
'the fleet table query',
'the PTY sidecar handshake',
'the artifact viewer',
'the approval policy',
'the compaction marker',
'the terminal credit window',
];

const ASKS = [
'Walk me through',
'What breaks in',
'Summarize',
'Find the regression in',
'Explain the invariant behind',
];

/**
* A long settled transcript, so surfaces that only misbehave at length — the minimap rail, the
* virtualizer's windowing — are reachable in dev without a real agent. Turn bodies vary in size on
* purpose: a rail whose ticks all look alike hides nothing.
*/
export function createLongThreadScript(messageId: (slug: string) => MessageId): AgentEvent[] {
const script: AgentEvent[] = [{ type: 'status', status: 'running' }];
for (let turn = 0; turn < LONG_THREAD_TURNS; turn++) {
const subject = SUBJECTS[turn % SUBJECTS.length];
const ask = ASKS[turn % ASKS.length];
script.push({
type: 'user-message',
messageId: messageId(`mock-long-user-${turn}`),
content: [textBlock(`${ask} ${subject}.`)],
});
script.push({
type: 'agent-message-chunk',
messageId: messageId(`mock-long-reply-${turn}`),
content: textBlock(replyBody(turn, subject)),
});
}
script.push({ type: 'stop', stopReason: 'end_turn' });
script.push({ type: 'status', status: 'idle' });
return script;
}

function replyBody(turn: number, subject: string): string {
const lines = [`Turn ${turn + 1} — notes on ${subject}.`, ''];
// Every third turn is a long one, so the rail shows an uneven, realistic rhythm.
const paragraphs = turn % 3 === 0 ? 4 : 1;
for (let i = 0; i < paragraphs; i++) {
lines.push(
`${subject} settles once the caller owns the retry budget; paragraph ${i + 1} of turn ${turn + 1} exists to give this reply real height.`,
'',
);
}
if (turn % 4 === 0) lines.push('- one', '- two', '- three', '');
return lines.join('\n');
}
10 changes: 10 additions & 0 deletions packages/client/workbench/src/mock/data/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface SeedSession {
status: SessionStatus;
ageMs: number;
showcase?: boolean;
/** Seeds a long settled transcript instead of the scripted showcase (see `long-thread.ts`). */
longThread?: boolean;
terminalId?: string;
resources?: SeedSessionResource[];
}
Expand Down Expand Up @@ -86,6 +88,14 @@ export const SEED_SESSIONS: SeedSession[] = [
},
],
},
{
kind: 'claude-code',
cwd: '/mock/linkcode',
title: 'Long thread · navigation testbed',
status: 'idle',
ageMs: 8 * 60000,
longThread: true,
},
{
kind: 'claude-code',
cwd: '/mock/linkcode',
Expand Down
15 changes: 15 additions & 0 deletions packages/client/workbench/src/mock/dev-mock-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { MOCK_COMMAND_CATALOG, mockCommandFixture } from './data/commands';
import { MOCK_WORKSPACE_FILES, mockFileFixture } from './data/files';
import { gitFixtureFor } from './data/git';
import { SEED_HISTORY } from './data/history';
import { createLongThreadScript } from './data/long-thread';
import { SEED_MODEL_CATALOGS } from './data/models';
import {
CHUNK_LATENCY_MS,
Expand Down Expand Up @@ -120,6 +121,8 @@ interface MockSession extends SessionInfo {
epoch: number;
showcase?: boolean;
showcaseSeeded?: boolean;
longThread?: boolean;
longThreadSeeded?: boolean;
terminalId?: string;
}

Expand Down Expand Up @@ -310,6 +313,7 @@ export class DevMockHost {
});
// Start after the list reply so the UI can subscribe before scripted frames arrive.
this.startShowcase();
this.seedLongThreads();
break;
case 'session.attach':
this.attachSession(p.sessionId);
Expand Down Expand Up @@ -1158,6 +1162,17 @@ export class DevMockHost {
this.sendSuccess(replyTo);
}

/** Emitted in one burst, not streamed: this transcript exists to be long, not to look live. */
private seedLongThreads(): void {
for (const session of this.sessions.values()) {
if (!session.longThread || session.longThreadSeeded) continue;
session.longThreadSeeded = true;
for (const event of createLongThreadScript((slug) => this.nextMessageId(slug))) {
this.emit(session.sessionId, event);
}
}
}

private startShowcase(): void {
for (const session of this.sessions.values()) {
if (!session.showcase) continue;
Expand Down
4 changes: 4 additions & 0 deletions packages/presentation/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ export const en = {
compacting: 'Compacting context…',
compacted: 'Context compacted',
compactedTokens: '{pre} → {post} tokens',
minimap: {
label: 'Conversation navigation',
turn: 'Turn {index}',
},
},
tool: {
input: 'Input',
Expand Down
4 changes: 4 additions & 0 deletions packages/presentation/i18n/src/locales/zh-cn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ export const zhCN = {
compacting: '正在压缩上下文…',
compacted: '上下文已压缩',
compactedTokens: '{pre} → {post} tokens',
minimap: {
label: '对话导航',
turn: '第 {index} 轮',
},
},
tool: {
input: '输入',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { createFixedArray } from 'foxts/create-fixed-array';
import { describe, expect, it } from 'vitest';
import {
fisheyeFactor,
fisheyeScaleX,
fisheyeWidth,
MINIMAP_FISHEYE_SPREAD,
MINIMAP_ROW_HEIGHT,
MINIMAP_TICK_BASE_WIDTH,
MINIMAP_TICK_PEAK_WIDTH,
railScrollTopFor,
} from '../minimap-geometry';

describe('fisheyeFactor', () => {
it('peaks under the pointer and dies at the spread', () => {
expect(fisheyeFactor(0)).toBe(1);
expect(fisheyeFactor(MINIMAP_FISHEYE_SPREAD)).toBe(0);
expect(fisheyeFactor(MINIMAP_FISHEYE_SPREAD * 4)).toBe(0);
});

it('is symmetric, so ticks above and below the pointer grow alike', () => {
expect(fisheyeFactor(-20)).toBe(fisheyeFactor(20));
});

it('decreases monotonically across the falloff', () => {
const step = MINIMAP_FISHEYE_SPREAD / 6;
const samples = createFixedArray(7).map((_, i) => fisheyeFactor(i * step));
for (let i = 1; i < samples.length; i++) expect(samples[i]).toBeLessThan(samples[i - 1]);
});

it('eases out — the near half of the falloff keeps most of the magnification', () => {
expect(fisheyeFactor(MINIMAP_FISHEYE_SPREAD / 2)).toBeGreaterThan(0.5);
});
});

describe('fisheyeWidth', () => {
it('spans exactly base to peak', () => {
expect(fisheyeWidth(0)).toBe(MINIMAP_TICK_BASE_WIDTH);
expect(fisheyeWidth(1)).toBe(MINIMAP_TICK_PEAK_WIDTH);
});

it('interpolates linearly between them', () => {
expect(fisheyeWidth(0.5)).toBe((MINIMAP_TICK_BASE_WIDTH + MINIMAP_TICK_PEAK_WIDTH) / 2);
});
});

describe('fisheyeScaleX', () => {
it('leaves a resting tick untransformed', () => {
expect(fisheyeScaleX(0)).toBe(1);
});

it('reaches the peak width without the box changing size', () => {
expect(fisheyeScaleX(1) * MINIMAP_TICK_BASE_WIDTH).toBe(MINIMAP_TICK_PEAK_WIDTH);
});
});

describe('railScrollTopFor', () => {
const railHeight = MINIMAP_ROW_HEIGHT * 10;

it('does not scroll while every turn fits', () => {
expect(railScrollTopFor({ start: 0, end: 4 }, 8, railHeight)).toBe(0);
});

it('centers the visible range once the rail overflows', () => {
// Rows 20..29 → center at row 25 → 25 pitches, less half a rail.
expect(railScrollTopFor({ start: 20, end: 29 }, 100, railHeight)).toBe(
25 * MINIMAP_ROW_HEIGHT - railHeight / 2,
);
});

it('clamps at both ends instead of overscrolling', () => {
expect(railScrollTopFor({ start: 0, end: 3 }, 100, railHeight)).toBe(0);
expect(railScrollTopFor({ start: 96, end: 99 }, 100, railHeight)).toBe(
100 * MINIMAP_ROW_HEIGHT - railHeight,
);
});
});
Loading