feat: integrate pixel teacher presence and resilient classroom preview

This commit is contained in:
鲨鱼辣椒
2026-09-23 18:23:53 +08:00
parent 12d800ec51
commit 7951cca700
60 changed files with 3313 additions and 180 deletions

View File

@@ -52,6 +52,8 @@ export interface SubmitCodingPromptInput {
text?: string;
attachments?: CodingDraftAttachment[];
prepareAttachments?(): Promise<CodingDraftAttachment[]>;
/** Internal recovery uses durable conversation context, never the student's draft. */
preserveDraft?: boolean;
}
interface CodingConversationStoreDependencies {
@@ -167,18 +169,38 @@ function requestIdsInSnapshot(snapshot: ConversationSnapshot): Set<string> {
function withoutReconciledRequests(
requests: Record<string, CodingPromptRequestState> | undefined,
snapshot: ConversationSnapshot,
reconciled = requestIdsInSnapshot(snapshot),
): Record<string, CodingPromptRequestState> {
const reconciled = requestIdsInSnapshot(snapshot);
const requestUncertaintySettled = snapshot.run.status === 'idle' || snapshot.run.status === 'error';
if (!requests || (reconciled.size === 0 && !requestUncertaintySettled)) return requests ?? {};
return Object.fromEntries(
Object.entries(requests).filter(([clientRequestId, request]) => (
!reconciled.has(clientRequestId)
&& !(request.status === 'uncertain' && requestUncertaintySettled)
&& !acceptedRunHasSettled(request, snapshot)
)),
);
}
function acceptedRunHasSettled(request: CodingPromptRequestState, snapshot: ConversationSnapshot): boolean {
return request.status === 'accepted' && Boolean(request.runId)
&& request.runId === snapshot.run.runId && taskHasSettled(snapshot);
}
function withoutSettledOptimisticNodes(
reducer: ConversationReducerState,
requests: Record<string, CodingPromptRequestState> | undefined,
): ConversationReducerState {
const snapshot = reducer.snapshot;
if (!snapshot || !requests) return reducer;
const nodes = snapshot.nodes.filter((node) => {
if (node.kind !== 'message' || node.status !== 'optimistic' || !node.clientRequestId) return true;
const request = requests[node.clientRequestId];
return !request || !acceptedRunHasSettled(request, snapshot);
});
return nodes.length === snapshot.nodes.length ? reducer : replaceConversationSnapshot(reducer, { ...snapshot, nodes });
}
function withReconciledOptimisticNodeIds(
snapshot: ConversationSnapshot,
requests: Record<string, CodingPromptRequestState> | undefined,
@@ -784,7 +806,7 @@ export function createCodingConversationStore(
),
draftsByConversationId: {
...state.draftsByConversationId,
[input.conversationId]: { text: '', attachments: [], revision: clearedRevision },
...(input.preserveDraft ? {} : { [input.conversationId]: { text: '', attachments: [], revision: clearedRevision } }),
},
requestsByConversationId: {
...state.requestsByConversationId,
@@ -846,14 +868,28 @@ export function createCodingConversationStore(
const current = requests[clientRequestId];
if (!current) return state;
const nextRequests = { ...requests };
const entry = state.entriesByConversationId[input.conversationId];
let reducer = entry?.reducer;
// Queued messages are projected by the authoritative queue; only a
// prompt has an optimistic node awaiting clientRequestId reconciliation.
if (current.mode === 'prompt') {
nextRequests[clientRequestId] = { ...current, status: 'accepted' };
const accepted: CodingPromptRequestState = { ...current, status: 'accepted', runId: acceptance.runId };
if (reducer?.snapshot && acceptedRunHasSettled(accepted, reducer.snapshot)) {
delete nextRequests[clientRequestId];
reducer = withoutSettledOptimisticNodes(reducer, { [clientRequestId]: accepted });
} else {
nextRequests[clientRequestId] = accepted;
}
} else {
delete nextRequests[clientRequestId];
}
return {
...(entry && reducer !== entry.reducer ? {
entriesByConversationId: {
...state.entriesByConversationId,
[input.conversationId]: { ...entry, reducer: reducer! },
},
} : {}),
requestsByConversationId: {
...state.requestsByConversationId,
[input.conversationId]: nextRequests,
@@ -869,7 +905,7 @@ export function createCodingConversationStore(
const requests = state.requestsByConversationId[input.conversationId] ?? {};
const currentRequest = requests[clientRequestId] ?? request;
const currentDraft = state.draftsByConversationId[input.conversationId] ?? DEFAULT_DRAFT;
const canRestore = currentDraft.revision === clearedRevision
const canRestore = !input.preserveDraft && currentDraft.revision === clearedRevision
&& !currentDraft.text
&& currentDraft.attachments.length === 0;
const reducer = uncertain
@@ -937,7 +973,10 @@ export function createCodingConversationStore(
reconciledSnapshot,
);
const reducer = withUnreconciledOptimisticNodes(
replaceConversationSnapshot(current.reducer, reconciledSnapshot),
withoutSettledOptimisticNodes(
replaceConversationSnapshot(current.reducer, reconciledSnapshot),
state.requestsByConversationId[event.conversationId],
),
requests,
);
const entry: CodingConversationEntry = {
@@ -1022,6 +1061,16 @@ export function createCodingConversationStore(
}
if (reducer === current.reducer) return state;
applied = true;
const requests = state.requestsByConversationId[applicableEvent.conversationId] ?? {};
// The reduced snapshot also contains Renderer optimistic nodes. Only
// request ids delivered in this Main patch batch count as receipt evidence.
const authoritativeRequestIds = new Set(applicableEvent.items.flatMap(({ patch }) => (
patch.op === 'message.upsert' && patch.node.clientRequestId ? [patch.node.clientRequestId] : []
)));
const nextRequests = reducer.snapshot
? withoutReconciledRequests(requests, reducer.snapshot, authoritativeRequestIds)
: requests;
reducer = withoutSettledOptimisticNodes(reducer, requests);
const unread = state.selectedConversationId !== applicableEvent.conversationId
&& newlyNeedsUserAttention(current.reducer.snapshot, reducer.snapshot)
? true
@@ -1033,10 +1082,6 @@ export function createCodingConversationStore(
error: null,
unread,
};
const requests = state.requestsByConversationId[applicableEvent.conversationId] ?? {};
const nextRequests = reducer.snapshot
? withoutReconciledRequests(requests, reducer.snapshot)
: requests;
const requestsChanged = Object.keys(nextRequests).length !== Object.keys(requests).length;
return {
entriesByConversationId: {

View File

@@ -9,6 +9,7 @@ import { invokeIpc } from '@/lib/api-client';
import { hostApiFetch } from '@/lib/host-api';
import { resolveSupportedLanguage } from '../../shared/language';
import type { UserSyncPreferences } from '../../shared/user-sync';
import { CLASSROOM_SIDEBAR_DEFAULT_WIDTH, normalizeClassroomSidebarWidth } from '@/lib/classroom-layout';
type Theme = 'light' | 'dark' | 'system';
type UpdateChannel = 'stable' | 'beta' | 'dev';
@@ -35,6 +36,7 @@ interface SettingsState {
// UI State
sidebarCollapsed: boolean;
classroomSidebarWidth: number;
devModeUnlocked: boolean;
// Setup
@@ -57,6 +59,7 @@ interface SettingsState {
setAutoCheckUpdate: (value: boolean) => void;
setAutoDownloadUpdate: (value: boolean) => void;
setSidebarCollapsed: (value: boolean) => void;
setClassroomSidebarWidth: (width: number) => void;
unlockDevMode: (password: string) => Promise<boolean>;
lockDevMode: () => Promise<void>;
applySyncedPreferences: (preferences: UserSyncPreferences) => void;
@@ -80,6 +83,7 @@ const defaultSettings = {
autoCheckUpdate: true,
autoDownloadUpdate: false,
sidebarCollapsed: false,
classroomSidebarWidth: CLASSROOM_SIDEBAR_DEFAULT_WIDTH,
devModeUnlocked: false,
setupComplete: false,
};
@@ -171,6 +175,8 @@ export const useSettingsStore = create<SettingsState>()(
set({ sidebarCollapsed });
pushSyncedPreferences({ sidebarCollapsed });
},
// Local layout preference; no cloud/backend update is needed while dragging.
setClassroomSidebarWidth: (width) => set({ classroomSidebarWidth: normalizeClassroomSidebarWidth(width) }),
unlockDevMode: async (password) => {
try {
const result = await invokeIpc<{ success: boolean }>('admin:verifyPassword', password);