feat: integrate pixel teacher presence and resilient classroom preview
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user