fix(history): preserve empty Claude page cursors

Keep empty CLI and stdin cursors as the latest-page sentinel so session reloads do not return an empty first page. Cover positional and JSON bridge requests while preserving explicit turn zero pagination.
This commit is contained in:
Gadfly
2026-09-20 06:16:18 +08:00
parent a15653f6d4
commit 2dc9ecd5d1
2 changed files with 88 additions and 1 deletions
@@ -1,8 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { getClaudeProjectKey } from './utils/path-utils.js';
const bridgeDir = dirname(fileURLToPath(import.meta.url));
const channelManager = join(bridgeDir, 'channel-manager.js');
@@ -21,3 +24,83 @@ test('system command keeps stdout reserved for its JSON response', () => {
assert.equal(typeof response.success, 'boolean');
assert.equal(typeof response.available, 'boolean');
});
test('Claude session-page command treats empty cursors as the latest page', () => {
const homeDir = fs.mkdtempSync(join(os.tmpdir(), 'cc-gui-page-cursor-'));
const cwd = join(homeDir, 'project');
const sessionId = 'page-cursor-regression';
const sessionDir = join(homeDir, '.claude', 'projects', getClaudeProjectKey(cwd));
const sessionFile = join(sessionDir, `${sessionId}.jsonl`);
try {
fs.mkdirSync(cwd, { recursive: true });
fs.mkdirSync(sessionDir, { recursive: true });
fs.writeFileSync(sessionFile, [
JSON.stringify({ type: 'user', uuid: 'u1', message: { role: 'user', content: 'first' } }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: 'first reply' } }),
JSON.stringify({ type: 'user', uuid: 'u2', message: { role: 'user', content: 'second' } }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: 'second reply' } }),
].join('\n') + '\n');
const result = spawnSync(
process.execPath,
[channelManager, 'claude', 'getSessionPage', sessionId, cwd, '', '30'],
{
cwd: bridgeDir,
env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir },
encoding: 'utf8',
timeout: 10_000,
},
);
assert.equal(result.status, 0, result.stderr);
const response = JSON.parse(result.stdout.trim());
assert.equal(response.success, true);
assert.equal(response.fromTurn, 0);
assert.equal(response.toTurn, 2);
assert.equal(response.totalTurns, 2);
assert.equal(response.messages.length, 4);
const stdinCursors = ['', ' ', false, []];
for (const beforeTurn of stdinCursors) {
const stdinResult = spawnSync(
process.execPath,
[channelManager, 'claude', 'getSessionPage'],
{
cwd: bridgeDir,
env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir, CLAUDE_USE_STDIN: 'true' },
input: JSON.stringify({ sessionId, cwd, beforeTurn, limit: 30 }),
encoding: 'utf8',
timeout: 10_000,
},
);
assert.equal(stdinResult.status, 0, stdinResult.stderr);
const stdinResponse = JSON.parse(stdinResult.stdout.trim());
assert.equal(stdinResponse.success, true);
assert.equal(stdinResponse.fromTurn, 0);
assert.equal(stdinResponse.toTurn, 2);
assert.equal(stdinResponse.totalTurns, 2);
assert.equal(stdinResponse.messages.length, 4);
}
const firstPageResult = spawnSync(
process.execPath,
[channelManager, 'claude', 'getSessionPage', sessionId, cwd, '0', '30'],
{
cwd: bridgeDir,
env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir },
encoding: 'utf8',
timeout: 10_000,
},
);
assert.equal(firstPageResult.status, 0, firstPageResult.stderr);
const firstPage = JSON.parse(firstPageResult.stdout.trim());
assert.equal(firstPage.success, true);
assert.equal(firstPage.toTurn, 0);
assert.equal(firstPage.messages.length, 0);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
+5 -1
View File
@@ -77,7 +77,11 @@ export async function handleClaudeCommand(command, args, stdinData) {
const sessionId = stdinData?.sessionId || args[0];
const cwd = stdinData?.cwd || args[1] || null;
const beforeTurnRaw = stdinData?.beforeTurn ?? (args[2] !== '' && args[2] !== undefined ? args[2] : null);
const parsedBeforeTurn = Number(beforeTurnRaw);
const parsedBeforeTurn = typeof beforeTurnRaw === 'number'
? beforeTurnRaw
: typeof beforeTurnRaw === 'string' && beforeTurnRaw.trim()
? Number(beforeTurnRaw)
: null;
const beforeTurn = Number.isInteger(parsedBeforeTurn) && parsedBeforeTurn >= 0 ? parsedBeforeTurn : null;
const parsedLimit = Number(stdinData?.limit ?? args[3]);
const limit = Number.isInteger(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 200) : 30;