Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
39 changes: 38 additions & 1 deletion app/api/agent/sessions/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/** Agent runtime control plane for reading one owned session. */
/** Agent runtime control plane for reading and updating an owned session title. */
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';

import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { apiError } from '@/lib/server/api-response';
import { getAgentSessionStore } from '@/lib/server/agent-runtime/store';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';

Expand All @@ -23,3 +24,39 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json(meta, { headers: responseHeaders });
});
}

export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!isAgentRuntimeConfigured()) {
return new Response('Not found', { status: 404 });
}

return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { id } = await params;
const store = await getAgentSessionStore();

let body: { title?: unknown } | null;
try {
body = (await req.json()) as typeof body;
} catch {
const response = apiError('INVALID_REQUEST', 400, 'invalid JSON body');
responseHeaders.forEach((value, name) => response.headers.append(name, value));
return response;
}
if (!body || typeof body !== 'object' || !Object.hasOwn(body, 'title')) {
const response = apiError('MISSING_REQUIRED_FIELD', 400, 'title is required');
responseHeaders.forEach((value, name) => response.headers.append(name, value));
return response;
}
if (body.title !== null && typeof body.title !== 'string') {
const response = apiError('INVALID_REQUEST', 400, 'title must be a string or null');
responseHeaders.forEach((value, name) => response.headers.append(name, value));
return response;
}
const title = body.title?.trim().slice(0, 120) || null;
const meta = await store.setManualSessionTitle(id, ownerId, title);
if (!meta) {
return new Response('Not found', { status: 404, headers: responseHeaders });
}
return NextResponse.json({ title: meta.title ?? null }, { headers: responseHeaders });
});
}
112 changes: 78 additions & 34 deletions components/workbench/workspace/WorkspaceShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ import {
import type { WorkbenchCourseSummary } from '@/lib/workbench/panel-context';
import { useWorkbenchStore, type WorkbenchMaterial } from '@/lib/workbench/session-store';
import { renameWorkbenchSession } from '@/lib/workbench/session-store';
import { commitSessionRename } from '@/lib/workbench/session-title';
import { commitSessionRename, createSessionRenameQueue } from '@/lib/workbench/session-title';
import type { ElementRef } from '@/lib/workbench/element-refs';
import type { CourseRef } from '@/lib/workbench/course-refs';
import { useStageFreshnessSync, useWorkbenchStream } from '@/lib/workbench/use-workbench-session';
Expand Down Expand Up @@ -146,6 +146,16 @@ async function fetchSessions(): Promise<ProHomeSessionItem[]> {
}
}

function syncAttachedSessionTitle(
sessionId: string,
title: string | null,
forceRevision = false,
): void {
const store = useWorkbenchStore.getState();
if (store.sessionId !== sessionId || (!forceRevision && store.sessionTitle === title)) return;
store.setSessionTitle(title);
}

/**
* Route seam: capture the deep link exactly once. Native History API writes
* update Next's search-param readers, while the controller below keeps its own
Expand All @@ -166,14 +176,10 @@ function WorkspaceShellController({ initialPanes }: { readonly initialPanes: Wor
const courses = useHomeDiscovery({ mode: 'discover-only' });
const [sessions, setSessions] = useState<ProHomeSessionItem[]>(EMPTY_SESSIONS);
/**
* The latest list, readable from a callback without making that callback a
* new function on every poll (the rail would then see a changed prop every
* few seconds for no reason). Synced in an effect, never during render.
* The latest list, readable from queued callbacks without waiting for React
* to commit another render. The owner publisher updates it before setState.
*/
const sessionsRef = useRef(sessions);
useEffect(() => {
sessionsRef.current = sessions;
}, [sessions]);
const [sessionState, setSessionState] = useState<HomeDiscoveryState>('loading');
const [composerReset, setComposerReset] = useState(0);
/**
Expand All @@ -187,6 +193,7 @@ function WorkspaceShellController({ initialPanes }: { readonly initialPanes: Wor
*/
const [newConversationRequested, setNewConversationRequested] = useState(false);
const ownerSessionClient = useRef<OwnerSessionClient | null>(null);
const [sessionRenameQueue] = useState(createSessionRenameQueue);

const rootRef = useRef<HTMLDivElement>(null);
const railWidth = useRailWidth(rootRef);
Expand Down Expand Up @@ -331,11 +338,30 @@ function WorkspaceShellController({ initialPanes }: { readonly initialPanes: Wor
ownerSessionClient.current?.requestFullFetch(showLoading);
}, []);

const publishOwnerSessions = useCallback(
(next: readonly ProHomeSessionItem[], source?: 'incremental' | 'snapshot') => {
const snapshot = [...next];
sessionsRef.current = snapshot;
setSessions(snapshot);
// Sparse owner events carry only their own field. A status publication
// therefore has no authority over the title it happens to copy from the
// client's current row; only a complete list snapshot may repair the
// attached header here. Local renames and title events use their explicit
// paths below.
if (source !== 'snapshot') return;
const attachedId = useWorkbenchStore.getState().sessionId;
const attached = snapshot.find((session) => session.id === attachedId);
if (attached) syncAttachedSessionTitle(attached.id, attached.title ?? null, true);
},
[],
);

useEffect(() => {
const client = new OwnerSessionClient({
fetchSessions,
createEventSource: (url) => new EventSource(url),
onSessions: (next) => setSessions([...next]),
onSessions: publishOwnerSessions,
onSessionTitle: (sessionId, title) => syncAttachedSessionTitle(sessionId, title, true),
onState: setSessionState,
});
ownerSessionClient.current = client;
Expand All @@ -344,7 +370,7 @@ function WorkspaceShellController({ initialPanes }: { readonly initialPanes: Wor
ownerSessionClient.current = null;
client.stop();
};
}, []);
}, [publishOwnerSessions]);

useEffect(() => {
if (
Expand Down Expand Up @@ -529,12 +555,10 @@ function WorkspaceShellController({ initialPanes }: { readonly initialPanes: Wor
* which reads its own copy out of the session store.
*/
const applySessionTitle = useCallback((sessionId: string, title: string | null) => {
setSessions((current) =>
current.map((session) => (session.id === sessionId ? { ...session, title } : session)),
);
if (useWorkbenchStore.getState().sessionId === sessionId) {
useWorkbenchStore.getState().setSessionTitle(title);
}
syncAttachedSessionTitle(sessionId, title, true);
const client = ownerSessionClient.current;
const revision = client?.updateSessionTitle(sessionId, title) ?? null;
return { client, revision };
}, []);

/**
Expand All @@ -550,25 +574,43 @@ function WorkspaceShellController({ initialPanes }: { readonly initialPanes: Wor
* renames use.
*/
const renameSession = useCallback(
async (sessionId: string, raw: string): Promise<string | null> => {
const row = sessionsRef.current.find((session) => session.id === sessionId);
const store = useWorkbenchStore.getState();
const attached = store.sessionId === sessionId;
const outcome = await commitSessionRename({
current: {
title: row?.title ?? (attached ? store.sessionTitle : null),
prompt: row?.prompt ?? (attached ? store.sessionPrompt : null),
},
raw,
apply: (title) => applySessionTitle(sessionId, title),
save: (title) => renameWorkbenchSession(sessionId, title),
});
if (outcome !== 'failed') return null;
const message = t('workspace.renameSessionFailed');
toast.error(message);
return message;
},
[applySessionTitle, t],
(sessionId: string, raw: string): Promise<string | null> =>
sessionRenameQueue.run(sessionId, async (queued) => {
const row = sessionsRef.current.find((session) => session.id === sessionId);
const store = useWorkbenchStore.getState();
const attached = store.sessionId === sessionId;
let decision: ReturnType<typeof applySessionTitle> | null = null;
const outcome = await commitSessionRename({
current: {
title: row?.title ?? (attached ? store.sessionTitle : null),
prompt: row?.prompt ?? (attached ? store.sessionPrompt : null),
},
raw,
apply: (title) => {
decision = applySessionTitle(sessionId, title);
},
save: (title) => renameWorkbenchSession(sessionId, title),
isCurrent: () =>
decision === null ||
decision.revision === null ||
decision.client === null ||
(ownerSessionClient.current === decision.client &&
decision.client.isSessionTitleRevisionCurrent(sessionId, decision.revision)),
// A queued value that looks unchanged may be the user's explicit
// attempt to undo the still-ambiguous write ahead of it.
forceSave: queued,
});
// A failed write is still ambiguous at the transport boundary: the
// database may have committed before the response was lost, and an
// optimistic mutation may have hidden a newer snapshot while it was in
// flight. Reconcile every attempted change; unchanged input did no IO.
if (outcome !== 'unchanged') ownerSessionClient.current?.requestFullFetch();
if (outcome !== 'failed') return null;
const message = t('workspace.renameSessionFailed');
toast.error(message);
return message;
}),
[applySessionTitle, sessionRenameQueue, t],
);

/**
Expand Down Expand Up @@ -716,6 +758,8 @@ function WorkspaceShellController({ initialPanes }: { readonly initialPanes: Wor
// the meta fetch otherwise — a `?session=` deep link has neither yet, and
// the chat needs neither.
attach(panes.sessionId, paneSessionStageId);
const attached = sessionsRef.current.find((session) => session.id === panes.sessionId);
if (attached) syncAttachedSessionTitle(attached.id, attached.title ?? null);
}, [panes.sessionId, paneSessionStageId, attach, detach]);

// Attachment is workspace-scoped view state. Clear it when the shell leaves
Expand Down
89 changes: 77 additions & 12 deletions lib/workbench/owner-session-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const OWNER_SESSION_EVENT_TYPES = [
'session_deleted',
'session_active_stage',
'session_cancel_requested',
'session_title',
] as const;

export const SESSION_RECONCILE_MIN_MS = 60_000;
Expand Down Expand Up @@ -51,7 +52,11 @@ export interface OwnerEventSourceInit {
interface OwnerSessionClientOptions {
readonly fetchSessions: () => Promise<ProHomeSessionItem[]>;
readonly createEventSource: (url: string, init?: OwnerEventSourceInit) => OwnerEventSource;
readonly onSessions: (sessions: readonly ProHomeSessionItem[]) => void;
readonly onSessions: (
sessions: readonly ProHomeSessionItem[],
source?: 'incremental' | 'snapshot',
) => void;
readonly onSessionTitle?: (sessionId: string, title: string | null) => void;
readonly onState: (state: SessionListState) => void;
readonly onInitialized?: () => void;
/**
Expand Down Expand Up @@ -93,14 +98,15 @@ function parseData(event: Event): unknown {

function isOwnerSessionEvent(value: unknown): value is OwnerSessionEvent {
if (!value || typeof value !== 'object') return false;
const event = value as Partial<OwnerSessionEvent>;
return (
const event = value as Record<string, unknown>;
const validBase =
typeof event.type === 'string' &&
(OWNER_SESSION_EVENT_TYPES as readonly string[]).includes(event.type) &&
isDecimalCursor(event.id) &&
typeof event.sessionId === 'string' &&
typeof event.ts === 'number'
);
typeof event.ts === 'number';
if (!validBase) return false;
return event.type !== 'session_title' || event.title === null || typeof event.title === 'string';
}

/**
Expand All @@ -126,6 +132,12 @@ export class OwnerSessionClient {
private malformedEventCount = 0;
private connectingSamples = 0;
private streamDegraded = false;
private titleMutationRevision = 0;
private titleDecisionRevisions = new Map<string, number>();
private titleMutations = new Map<
string,
{ readonly revision: number; readonly title: string | null }
>();
private reconcileTimer: ReturnType<typeof setTimeout> | null = null;
private streamHealthTimer: ReturnType<typeof setInterval> | null = null;

Expand Down Expand Up @@ -155,6 +167,9 @@ export class OwnerSessionClient {
this.malformedEventCount = 0;
this.connectingSamples = 0;
this.streamDegraded = false;
this.titleMutationRevision = 0;
this.titleDecisionRevisions.clear();
this.titleMutations.clear();
}

requestFullFetch(showLoading = false): void {
Expand All @@ -176,6 +191,24 @@ export class OwnerSessionClient {
this.options.onSessions(next);
}

/** A local title decision that snapshots already in flight may not undo. */
updateSessionTitle(sessionId: string, title: string | null): number {
const revision = (this.titleMutationRevision += 1);
this.titleDecisionRevisions.set(sessionId, revision);
this.titleMutations.set(sessionId, {
revision,
title,
});
this.updateSessions((sessions) =>
sessions.map((session) => (session.id === sessionId ? { ...session, title } : session)),
);
return revision;
}

isSessionTitleRevisionCurrent(sessionId: string, revision: number): boolean {
return this.titleDecisionRevisions.get(sessionId) === revision;
}

private openStream(): void {
const epoch = this.epoch;
const source = this.options.createEventSource('/api/agent/owner-events', { headers: {} });
Expand All @@ -200,12 +233,27 @@ export class OwnerSessionClient {
}
this.malformedEventCount = 0;
this.cursor = event.id;
this.journal.push(event);
if (this.journal.length > OWNER_SESSION_JOURNAL_LIMIT) {
this.journal.splice(0, this.journal.length - OWNER_SESSION_JOURNAL_LIMIT);
this.requestFullFetch();
}
const reduced = reduceOwnerSessionEvent(this.sessions, event);
const timestampStaleTitle =
event.type === 'session_title' &&
!reduced.needsFullFetch &&
reduced.sessions === this.sessions;
// `updatedAt` also contains app-clock lifecycle writes, so a small clock
// skew can make a committed DB-clock title look old. A fresh list read
// distinguishes that case from a genuinely stale projection.
if (timestampStaleTitle) this.requestFullFetch();
if (!timestampStaleTitle) {
this.journal.push(event);
if (this.journal.length > OWNER_SESSION_JOURNAL_LIMIT) {
this.journal.splice(0, this.journal.length - OWNER_SESSION_JOURNAL_LIMIT);
this.requestFullFetch();
}
if (event.type === 'session_title') {
this.titleDecisionRevisions.set(event.sessionId, (this.titleMutationRevision += 1));
this.titleMutations.delete(event.sessionId);
this.options.onSessionTitle?.(event.sessionId, event.title);
}
}
if (reduced.sessions !== this.sessions) {
this.sessions = reduced.sessions;
this.options.onSessions(this.sessions);
Expand Down Expand Up @@ -296,11 +344,28 @@ export class OwnerSessionClient {
this.requestInFlight = true;
const epoch = this.epoch;
const snapshotCursor = this.cursor;
const titleRevisions = new Map(
[...this.titleMutations].map(([sessionId, mutation]) => [sessionId, mutation.revision]),
);
void this.options
.fetchSessions()
.then((snapshot) => {
if (this.stopped || epoch !== this.epoch) return;
let merged: readonly ProHomeSessionItem[] = newestFirst(snapshot);
let merged: readonly ProHomeSessionItem[] = newestFirst(snapshot).map((session) => {
const mutation = this.titleMutations.get(session.id);
if (!mutation) return session;
if (mutation.revision !== titleRevisions.get(session.id)) {
// This decision happened after the request began, so the response
// cannot contain it yet.
return { ...session, title: mutation.title };
}
// This request began after the local decision and its row is now the
Comment thread
YizukiAme marked this conversation as resolved.
Outdated
// authoritative answer. Fence the still-pending PATCH settlement so
// a later network failure cannot roll this snapshot back.
this.titleDecisionRevisions.set(session.id, (this.titleMutationRevision += 1));
this.titleMutations.delete(session.id);
return session;
});
let needsFullFetch = false;
for (const event of this.journal) {
if (compareDecimalCursor(event.id, snapshotCursor) <= 0) continue;
Expand All @@ -311,7 +376,7 @@ export class OwnerSessionClient {
this.sessions = merged;
this.journal = [];
this.lastFullFetchFailed = false;
this.options.onSessions(merged);
this.options.onSessions(merged, 'snapshot');
// Unconditionally authoritative: this snapshot is a full read of the
// list, so it is correct even while the push channel is down. Gating
// 'ready' on stream health left a dead stream + healthy REST stuck in
Expand Down
Loading
Loading