Collapse teacher introductions after three operation replies

This commit is contained in:
鲨鱼辣椒
2026-09-29 14:29:52 +08:00
parent c82b1a43be
commit 4199cadd1e
6 changed files with 257 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { teacherApi, teacherTopicsPath, type TeacherCatalogState } from '@/lib/coding-teacher';
import { useAuthStore } from '@/stores/auth';
import { AppError } from '@/lib/error-model';
import { useTeacherIntroductionRounds } from './use-teacher-introduction-rounds';
import {
TEACHER_CHECK_IN_INTERVAL_MS,
type TeacherCheckInInput,
@@ -346,6 +347,9 @@ export function useTeacherCompanion(options: Options) {
// Opening one teacher consumes only that teacher's introduction, never their unread replies.
if (options.teacherOpen && selectedAgent?.teacher_id) dismissWelcome(selectedAgent.teacher_id);
}, [options.teacherOpen, selectedAgent?.teacher_id, dismissWelcome]);
useTeacherIntroductionRounds(introductionKey, options.projectId,
visibleState.agents.filter(agent => !introducedIds.includes(agent.teacher_id)).map(agent => agent.teacher_id),
dismissIntroductions);
const welcomes = options.projectId && !options.teacherOpen && !visibleState.loading
? visibleState.agents.flatMap((agent, index) => introducedIds.includes(agent.teacher_id) ? [] : [{
agentId: agent.teacher_id,

View File

@@ -0,0 +1,67 @@
import { useEffect } from 'react';
import { codingConversationStore, type CodingConversationStoreState } from '@/stores/coding-conversations';
interface IntroductionProgress {
since: number;
runs: string[];
}
function readProgress(key: string): Record<string, IntroductionProgress> {
try {
const value: unknown = JSON.parse(localStorage.getItem(key) ?? '{}');
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.fromEntries(Object.entries(value).filter(([, progress]) => (
progress && typeof progress.since === 'number' && Number.isFinite(progress.since)
&& Array.isArray(progress.runs) && progress.runs.length <= 3
&& progress.runs.every((run: unknown) => typeof run === 'string')
)));
} catch { return {}; }
}
/** Presentation receipts only: observe Main's settled runs, never dispatch or edit a conversation. */
export function useTeacherIntroductionRounds(
introductionKey: string | null,
projectId: string | null,
pendingAgentIds: string[],
dismiss: (ids: string[]) => void,
) {
const agentIdsKey = JSON.stringify(pendingAgentIds);
useEffect(() => {
const agentIds: string[] = JSON.parse(agentIdsKey);
if (!introductionKey || !projectId || !agentIds.length) return;
const key = `${introductionKey}:rounds`;
const saved = readProgress(key);
const progress = Object.fromEntries(agentIds.map(id => [id, saved[id] ?? { since: Date.now(), runs: [] }]));
const persist = () => {
try { localStorage.setItem(key, JSON.stringify(progress)); } catch { /* Keep counting in memory. */ }
};
persist();
const observe = (state: CodingConversationStoreState) => {
let changed = false;
for (const entry of Object.values(state.entriesByConversationId)) {
const snapshot = entry.reducer.snapshot;
if (entry.loadState !== 'live' || entry.reducer.invalidation || snapshot?.conversation.projectId !== projectId) continue;
const run = snapshot.run;
// A successful top-level prompt is one round, regardless of its tool calls or retries.
if (run.status !== 'idle' || run.terminalReason !== 'completed' || run.mode !== 'prompt'
|| !run.runId || run.startedAt === undefined) continue;
const runKey = JSON.stringify([snapshot.conversation.id, run.runId]);
for (const item of Object.values(progress)) {
// Persist the starting time so loading old history cannot consume introductions.
if (item.runs.length >= 3 || run.startedAt < item.since || item.runs.includes(runKey)) continue;
item.runs.push(runKey);
changed = true;
}
}
if (changed) persist();
const finished = agentIds.filter(id => progress[id].runs.length >= 3);
if (finished.length) dismiss(finished);
};
// Include a completion delivered during a page reload, and observe all project conversations
// so switching the selected operation chat cannot lose or double-count a completion.
observe(codingConversationStore.getState());
return codingConversationStore.subscribe((state, previous) => {
if (state.entriesByConversationId !== previous.entriesByConversationId) observe(state);
});
}, [introductionKey, projectId, agentIdsKey, dismiss]);
}