mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
fix(web): usable past-conversation list, and search that finds past sessions
Two home-screen reports from @jordan8037310, both about history that is present but unreachable. #260 — "Resume Conversation" rendered 4 rows, then a button that appended every remaining row into a `max-height: 240px` box, so 35 conversations landed in a four-row scroll well with no ordering or filtering. Rendering now goes through `_renderHistoryList()` over a cached corpus: 10 rows to start, Show more/Show less that grows and shrinks the box (the height cap is class-driven, `.history-list.expanded`), plus a filter box (name, folder, #case label, prompts), a sort control (recent / name / folder, pinned rows still first) and a shown-of-total count. A filter implies expansion, so every match is visible, and the whole header hides as one unit while a federated search is active. The A-Z sort keys off the same string the row renders, since most rows are transcript-backed and carry no session name at all. #261 — the search box could not match a past project by folder name: `harvestSources()` built its session corpus from the live in-memory map, while past sessions come from `/api/sessions/unified` (lifecycle log + transcript scan). Folding that scan into the request path would have cost the search its no-filesystem-reads property, so the corpus arrives via a bounded snapshot instead: `session-history-index.ts` is published as a side effect of `/api/sessions/unified` (the home screen fetches it on open, which is the same screen the search box lives on) and rebuilt fire-and-forget, single-flight and TTL-guarded when a search finds it stale. A result for a closed session now resumes the conversation rather than selecting a tab that no longer exists, and is badged RESUME. The snapshot is stored unscoped with a per-row owner and re-filtered through canAccessOwned() on read, so multi-user sees exactly what /api/sessions/unified exposes: own sessions only, host-wide transcript history admin-only. Live rows are harvested first and win the dedupe. Verified end-to-end against a real instance with 60 past sessions: cold process answers its first search without history and its second with it; folder-name queries return resume targets; clicking one posts the right resumeSessionId + workingDir. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,12 +9,13 @@
|
||||
* (sessions + events) return results. Source data is injected via the mock
|
||||
* route context (sessions map, runSummaryTrackers map, attachment history).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { registerSearchRoutes } from '../../src/web/routes/search-routes.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { createMockRouteContext } from '../mocks/index.js';
|
||||
import { RunSummaryTracker } from '../../src/run-summary.js';
|
||||
import { resetHistorySessionIndex, setHistorySessionIndex } from '../../src/web/session-history-index.js';
|
||||
|
||||
type Ctx = ReturnType<typeof createMockRouteContext>;
|
||||
|
||||
@@ -206,3 +207,97 @@ describe('GET /api/search — caps & filters', () => {
|
||||
expect(types).toEqual(['event']);
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #261: with 3 live sessions and ~35 past ones, searching a past project's
|
||||
// folder name matched nothing — the corpus was the live session map alone. Past
|
||||
// sessions now arrive from the out-of-band history index snapshot.
|
||||
describe('GET /api/search — past sessions (history index)', () => {
|
||||
beforeEach(() => {
|
||||
resetHistorySessionIndex();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetHistorySessionIndex();
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
});
|
||||
|
||||
it('matches a past session by folder name with no live session at all', async () => {
|
||||
const { app } = await harness();
|
||||
setHistorySessionIndex([
|
||||
{
|
||||
sessionId: 'cod-9',
|
||||
name: 'w4-needlework',
|
||||
workingDir: '/home/u/projects/needlework',
|
||||
claudeSessionId: 'claude-uuid',
|
||||
timestamp: 1000,
|
||||
live: false,
|
||||
},
|
||||
]);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/search?q=needlework' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.totalResults).toBe(1);
|
||||
expect(body.data.groups[0].results[0].jumpTo).toMatchObject({
|
||||
kind: 'resume-session',
|
||||
sessionId: 'cod-9',
|
||||
claudeSessionId: 'claude-uuid',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not duplicate a session that is both live and in the snapshot', async () => {
|
||||
const { app } = await harness((ctx) => {
|
||||
ctx.sessions.set(
|
||||
'dup',
|
||||
fakeSession({ id: 'dup', name: 'needle live', workingDir: '/home/u/needle', lastActivityAt: 5 }) as never
|
||||
);
|
||||
});
|
||||
setHistorySessionIndex([
|
||||
{ sessionId: 'dup', name: 'needle live', workingDir: '/home/u/needle', timestamp: 5, live: true },
|
||||
]);
|
||||
const body = JSON.parse((await app.inject({ method: 'GET', url: '/api/search?q=needle' })).body);
|
||||
expect(body.data.totalResults).toBe(1);
|
||||
// The live harvest wins, so the card still switches to the open tab.
|
||||
expect(body.data.groups[0].results[0].jumpTo.kind).toBe('session');
|
||||
});
|
||||
|
||||
it('multi-user: a non-admin sees neither another user’s past session nor unowned host-wide history', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const app = Fastify({ logger: false });
|
||||
app.addHook('onRequest', async (req) => {
|
||||
(req as unknown as { authUser: unknown }).authUser = { username: 'bob', role: 'user' };
|
||||
});
|
||||
const ctx = createMockRouteContext();
|
||||
ctx.sessions.clear();
|
||||
ctx.runSummaryTrackers.clear();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
registerSearchRoutes(app, ctx as any);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
|
||||
setHistorySessionIndex([
|
||||
{
|
||||
sessionId: 'mine',
|
||||
name: 'needle-bob',
|
||||
workingDir: '/home/u/needle-bob',
|
||||
timestamp: 3,
|
||||
owner: 'bob',
|
||||
live: false,
|
||||
},
|
||||
{
|
||||
sessionId: 'hers',
|
||||
name: 'needle-alice',
|
||||
workingDir: '/home/u/needle-alice',
|
||||
timestamp: 2,
|
||||
owner: 'alice',
|
||||
live: false,
|
||||
},
|
||||
// Host-wide transcript row: no owning session, so admin-only — the same
|
||||
// rule GET /api/sessions/unified applies when it drops history for non-admins.
|
||||
{ sessionId: 'hostwide', name: 'needle-host', workingDir: '/srv/needle-host', timestamp: 1, live: false },
|
||||
]);
|
||||
|
||||
const body = JSON.parse((await app.inject({ method: 'GET', url: '/api/search?q=needle' })).body);
|
||||
expect(body.data.groups[0].results.map((r: { sessionId: string }) => r.sessionId)).toEqual(['mine']);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user