mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Centralize local IDs and clarify async tokens
This commit is contained in:
parent
d7889c5834
commit
d3f62d8228
26 changed files with 315 additions and 276 deletions
|
|
@ -2,6 +2,7 @@ import type { RequestId, ServerNotification, ServerRequest } from "../../../../a
|
|||
import type { McpServerStartupStatus } from "../../../../domain/server/diagnostics";
|
||||
import type { Thread } from "../../../../domain/threads/model";
|
||||
import type { ThreadConversationSummary } from "../../../../domain/threads/transcript";
|
||||
import { createLocalIdSource, type LocalIdSource } from "../../../../shared/id/local-id";
|
||||
import { classifyAppServerLog } from "./app-server-logs";
|
||||
import { activeTurnId, type ChatAction, type ChatState } from "../../application/state/root-reducer";
|
||||
import type { ChatStateStore } from "../../application/state/store";
|
||||
|
|
@ -11,7 +12,6 @@ import type { ApprovalAction, PendingApproval, PendingUserInput } from "../../do
|
|||
import { approvalResponse } from "../requests/approval";
|
||||
import { userInputResponse } from "../requests/user-input";
|
||||
import { createApprovalResultItem, createUserInputResultItem } from "../../domain/pending-requests/result-items";
|
||||
import { createLocalChatItemIdFactory, type LocalChatItemIdFactory } from "../../domain/local-id";
|
||||
import { planChatNotification, type ChatNotificationEffect } from "./notification-plan";
|
||||
import { routeServerRequest } from "./routing";
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ export interface ChatInboundControllerActions {
|
|||
}
|
||||
|
||||
export class ChatInboundController {
|
||||
private readonly localItemIds: LocalChatItemIdFactory = createLocalChatItemIdFactory();
|
||||
private readonly localItemIds: LocalIdSource = createLocalIdSource();
|
||||
|
||||
constructor(
|
||||
private readonly store: ChatStateStore,
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export interface ChatNotificationPlan {
|
|||
effects: readonly ChatNotificationEffect[];
|
||||
}
|
||||
|
||||
export type LocalItemIdFactory = (prefix: string) => string;
|
||||
export type LocalItemIdProvider = (prefix: string) => string;
|
||||
|
||||
const EMPTY_PLAN: ChatNotificationPlan = { actions: [], effects: [] };
|
||||
const MESSAGE_CONTEXT_COMPACTED = "Context compacted.";
|
||||
|
|
@ -85,7 +85,7 @@ type ServerNotificationPlanner<M extends ServerNotification["method"]> = (
|
|||
type ServerNotificationPlannerMap<M extends ServerNotification["method"]> = { [Method in M]: ServerNotificationPlanner<Method> };
|
||||
type ServerNotificationLocalPlanner<M extends ServerNotification["method"]> = (
|
||||
notification: Extract<ServerNotification, { method: M }>,
|
||||
localItemId: LocalItemIdFactory,
|
||||
localItemId: LocalItemIdProvider,
|
||||
) => ChatNotificationPlan;
|
||||
type ServerNotificationLocalPlannerMap<M extends ServerNotification["method"]> = {
|
||||
[Method in M]: ServerNotificationLocalPlanner<Method>;
|
||||
|
|
@ -93,7 +93,7 @@ type ServerNotificationLocalPlannerMap<M extends ServerNotification["method"]> =
|
|||
type ServerNotificationStatePlanner<M extends ServerNotification["method"]> = (
|
||||
state: ChatState,
|
||||
notification: Extract<ServerNotification, { method: M }>,
|
||||
localItemId: LocalItemIdFactory,
|
||||
localItemId: LocalItemIdProvider,
|
||||
) => ChatNotificationPlan;
|
||||
type ServerNotificationStatePlannerMap<M extends ServerNotification["method"]> = {
|
||||
[Method in M]: ServerNotificationStatePlanner<Method>;
|
||||
|
|
@ -316,7 +316,7 @@ export const PLANNED_SERVER_NOTIFICATION_METHODS_BY_ROUTE_KIND = {
|
|||
export function planChatNotification(
|
||||
state: ChatState,
|
||||
notification: ServerNotification,
|
||||
localItemId: LocalItemIdFactory,
|
||||
localItemId: LocalItemIdProvider,
|
||||
): ChatNotificationPlan {
|
||||
const route = routeServerNotification(notification, {
|
||||
activeThreadId: state.activeThread.id,
|
||||
|
|
@ -344,14 +344,18 @@ export function planChatNotification(
|
|||
}
|
||||
}
|
||||
|
||||
function planStreamUpdate(state: ChatState, notification: StreamUpdateNotification, localItemId: LocalItemIdFactory): ChatNotificationPlan {
|
||||
function planStreamUpdate(
|
||||
state: ChatState,
|
||||
notification: StreamUpdateNotification,
|
||||
localItemId: LocalItemIdProvider,
|
||||
): ChatNotificationPlan {
|
||||
return planNotificationWithStateByMethod(state, notification, STREAM_UPDATE_PLANNERS, localItemId);
|
||||
}
|
||||
|
||||
function planTurnLifecycle(
|
||||
state: ChatState,
|
||||
notification: TurnLifecycleNotification,
|
||||
localItemId: LocalItemIdFactory,
|
||||
localItemId: LocalItemIdProvider,
|
||||
): ChatNotificationPlan {
|
||||
return planNotificationWithStateByMethod(state, notification, TURN_LIFECYCLE_PLANNERS, localItemId);
|
||||
}
|
||||
|
|
@ -359,7 +363,7 @@ function planTurnLifecycle(
|
|||
function planThreadLifecycle(
|
||||
state: ChatState,
|
||||
notification: ThreadLifecycleNotification,
|
||||
localItemId: LocalItemIdFactory,
|
||||
localItemId: LocalItemIdProvider,
|
||||
): ChatNotificationPlan {
|
||||
return planNotificationWithStateByMethod(state, notification, THREAD_LIFECYCLE_PLANNERS, localItemId);
|
||||
}
|
||||
|
|
@ -368,7 +372,7 @@ function planDiagnosticStatus(notification: DiagnosticStatusNotification): ChatN
|
|||
return planNotificationByMethod(notification, DIAGNOSTIC_STATUS_PLANNERS);
|
||||
}
|
||||
|
||||
function planUserVisibleNotice(notification: UserVisibleNoticeNotification, localItemId: LocalItemIdFactory): ChatNotificationPlan {
|
||||
function planUserVisibleNotice(notification: UserVisibleNoticeNotification, localItemId: LocalItemIdProvider): ChatNotificationPlan {
|
||||
return planNotificationWithLocalItemIdByMethod(notification, USER_VISIBLE_NOTICE_PLANNERS, localItemId);
|
||||
}
|
||||
|
||||
|
|
@ -383,7 +387,7 @@ function planNotificationByMethod<M extends ServerNotification["method"]>(
|
|||
function planNotificationWithLocalItemIdByMethod<M extends ServerNotification["method"]>(
|
||||
notification: Extract<ServerNotification, { method: M }>,
|
||||
planners: ServerNotificationLocalPlannerMap<M>,
|
||||
localItemId: LocalItemIdFactory,
|
||||
localItemId: LocalItemIdProvider,
|
||||
): ChatNotificationPlan {
|
||||
const planner = planners[notification.method];
|
||||
return planner(notification, localItemId);
|
||||
|
|
@ -393,7 +397,7 @@ function planNotificationWithStateByMethod<M extends ServerNotification["method"
|
|||
state: ChatState,
|
||||
notification: Extract<ServerNotification, { method: M }>,
|
||||
planners: ServerNotificationStatePlannerMap<M>,
|
||||
localItemId: LocalItemIdFactory,
|
||||
localItemId: LocalItemIdProvider,
|
||||
): ChatNotificationPlan {
|
||||
const planner = planners[notification.method];
|
||||
return planner(state, notification, localItemId);
|
||||
|
|
@ -405,7 +409,7 @@ function plannerMethods<M extends ServerNotification["method"]>(planners: Record
|
|||
|
||||
function jsonNoticePlan(
|
||||
notification: Extract<ServerNotification, { method: Exclude<UserVisibleNoticeNotificationMethod, "thread/compacted"> }>,
|
||||
localItemId: LocalItemIdFactory,
|
||||
localItemId: LocalItemIdProvider,
|
||||
): ChatNotificationPlan {
|
||||
return systemMessagePlan({ id: localItemId("system"), text: `${notification.method}: ${jsonPreview(notification.params)}` });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { MessageStreamItemProvenance } from "../../domain/message-stream/pr
|
|||
import { fileMentionsFromInput } from "../../domain/message-stream/format/file-mentions";
|
||||
import { userMessageDisplayText } from "../../domain/message-stream/format/user-message-text";
|
||||
import { attachHookRunsToTurn } from "../../domain/message-stream/updates";
|
||||
import { isLocalSteerMessageClientId } from "../../domain/local-id";
|
||||
import { isLocalSteerMessageClientId } from "../../domain/local-message-ids";
|
||||
import type { CodexInput } from "../../../../domain/chat/input";
|
||||
|
||||
interface LocalUserMessageParams {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import type { AppServerClient } from "../../../../app-server/connection/client";
|
||||
import type { CodexInput } from "../../../../domain/chat/input";
|
||||
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
|
||||
import { createLocalIdSource, type LocalIdSource } from "../../../../shared/id/local-id";
|
||||
import { submissionStateSnapshot } from "../state/selectors";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { createLocalChatItemIdFactory } from "../../domain/local-id";
|
||||
import {
|
||||
acknowledgeOptimisticTurnStart,
|
||||
cleanupFailedTurnStart,
|
||||
|
|
@ -39,7 +39,7 @@ export interface TurnSubmissionActions {
|
|||
}
|
||||
|
||||
export function createTurnSubmissionActions(host: TurnSubmissionActionsHost): TurnSubmissionActions {
|
||||
const localItemIds = createLocalChatItemIdFactory();
|
||||
const localItemIds = createLocalIdSource();
|
||||
|
||||
return {
|
||||
sendTurnText: (text, codexInputOverride, referencedThread) =>
|
||||
|
|
@ -49,7 +49,7 @@ export function createTurnSubmissionActions(host: TurnSubmissionActionsHost): Tu
|
|||
|
||||
async function sendTurnText(
|
||||
host: TurnSubmissionActionsHost,
|
||||
localItemIds: ReturnType<typeof createLocalChatItemIdFactory>,
|
||||
localItemIds: LocalIdSource,
|
||||
text: string,
|
||||
codexInputOverride?: CodexInput,
|
||||
referencedThread?: ReferencedThreadMetadata,
|
||||
|
|
@ -135,7 +135,7 @@ async function sendTurnText(
|
|||
|
||||
async function steerCurrentTurn(
|
||||
host: TurnSubmissionActionsHost,
|
||||
localItemIds: ReturnType<typeof createLocalChatItemIdFactory>,
|
||||
localItemIds: LocalIdSource,
|
||||
client: AppServerClient,
|
||||
text: string,
|
||||
codexInputOverride?: CodexInput,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ChatStateStore } from "../state/store";
|
||||
import { pendingRequestFocusSignature } from "../../domain/pending-requests/signatures";
|
||||
import {
|
||||
approvalDetailsDisclosureId,
|
||||
answersForPendingUserInput,
|
||||
type ApprovalAction,
|
||||
type PendingApproval,
|
||||
|
|
@ -72,7 +73,7 @@ export function createPendingRequestActions(host: PendingRequestActionsHost): Pe
|
|||
host.stateStore.dispatch({
|
||||
type: "ui/disclosure-set",
|
||||
bucket: "approvalDetails",
|
||||
id: `${String(requestId)}:details`,
|
||||
id: approvalDetailsDisclosureId(requestId),
|
||||
open: expanded,
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ThreadGoal } from "../../../../domain/threads/goal";
|
||||
import type { PendingRequestId } from "../../domain/pending-requests/model";
|
||||
import { pendingRequestDerivedKeyPrefix, type PendingRequestId } from "../../domain/pending-requests/model";
|
||||
import type { DisclosureSetAction } from "./actions";
|
||||
|
||||
export type ChatRenameUiState =
|
||||
|
|
@ -10,7 +10,7 @@ export type ChatRenameUiState =
|
|||
readonly threadId: string;
|
||||
readonly draft: string;
|
||||
readonly originalDraft: string;
|
||||
readonly generationId: number;
|
||||
readonly generationToken: number;
|
||||
};
|
||||
|
||||
export type ChatRenameGeneratingUiState = Extract<ChatRenameUiState, { kind: "generating" }>;
|
||||
|
|
@ -60,7 +60,7 @@ export type UiAction =
|
|||
| { type: "ui/rename-started"; threadId: string; draft: string }
|
||||
| { type: "ui/rename-draft-updated"; threadId: string; draft: string }
|
||||
| { type: "ui/rename-cancelled"; threadId: string }
|
||||
| { type: "ui/rename-generation-started"; threadId: string; originalDraft: string; generationId: number }
|
||||
| { type: "ui/rename-generation-started"; threadId: string; originalDraft: string; generationToken: number }
|
||||
| { type: "ui/rename-generation-succeeded"; generatingState: ChatRenameGeneratingUiState; draft: string }
|
||||
| { type: "ui/rename-generation-finished"; threadId: string; generatingState: ChatRenameGeneratingUiState }
|
||||
| { type: "ui/rename-cleared" }
|
||||
|
|
@ -117,7 +117,7 @@ export function reduceUiSlice(state: ChatUiState, action: UiAction): ChatUiState
|
|||
return patchObject(state, { rename: renameUiStateCancelled(state.rename, action.threadId) });
|
||||
case "ui/rename-generation-started":
|
||||
return patchObject(state, {
|
||||
rename: renameUiGenerationStarted(state.rename, action.threadId, action.originalDraft, action.generationId),
|
||||
rename: renameUiGenerationStarted(state.rename, action.threadId, action.originalDraft, action.generationToken),
|
||||
});
|
||||
case "ui/rename-generation-succeeded":
|
||||
return patchObject(state, { rename: renameUiGenerationSucceeded(state.rename, action.generatingState, action.draft) });
|
||||
|
|
@ -172,7 +172,7 @@ export function renameGenerationStillActive(
|
|||
state.kind === "generating" &&
|
||||
state.threadId === generatingState.threadId &&
|
||||
state.originalDraft === generatingState.originalDraft &&
|
||||
state.generationId === generatingState.generationId
|
||||
state.generationToken === generatingState.generationToken
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -187,8 +187,8 @@ export function clearAllRequestDisclosures(state: ChatUiState): ChatUiState {
|
|||
}
|
||||
|
||||
export function clearResolvedRequestDisclosures(state: ChatUiState, requestId: PendingRequestId): ChatUiState {
|
||||
const id = String(requestId);
|
||||
const approvalDetails = filterStringSet(state.disclosures.approvalDetails, (key) => !key.startsWith(`${id}:`));
|
||||
const keyPrefix = pendingRequestDerivedKeyPrefix(requestId);
|
||||
const approvalDetails = filterStringSet(state.disclosures.approvalDetails, (key) => !key.startsWith(keyPrefix));
|
||||
if (approvalDetails === state.disclosures.approvalDetails) return state;
|
||||
return patchObject(state, {
|
||||
disclosures: {
|
||||
|
|
@ -268,7 +268,7 @@ function renameUiGenerationStarted(
|
|||
state: ChatRenameUiState,
|
||||
threadId: string,
|
||||
originalDraft: string,
|
||||
generationId: number,
|
||||
generationToken: number,
|
||||
): ChatRenameUiState {
|
||||
if (state.kind !== "editing" || state.threadId !== threadId) return state;
|
||||
return {
|
||||
|
|
@ -276,7 +276,7 @@ function renameUiGenerationStarted(
|
|||
threadId,
|
||||
draft: state.draft,
|
||||
originalDraft,
|
||||
generationId,
|
||||
generationToken,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import type { AppServerClient } from "../../../../app-server/connection/client";
|
||||
import { readThreadGoal, recordThreadGoalUserMessage, setThreadGoal } from "../../../../app-server/threads";
|
||||
import type { ThreadGoal, ThreadGoalStatus, ThreadGoalUpdate } from "../../../../domain/threads/goal";
|
||||
import { createLocalIdSource, type LocalIdSource } from "../../../../shared/id/local-id";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import type { GoalMessageStreamItem } from "../../domain/message-stream/items";
|
||||
import { goalChangeItem } from "../../domain/message-stream/factories/goal-items";
|
||||
import { createLocalChatItemIdFactory } from "../../domain/local-id";
|
||||
|
||||
export interface ThreadGoalSyncHost {
|
||||
stateStore: ChatStateStore;
|
||||
|
|
@ -34,14 +34,14 @@ function emptyGoalObjectiveMessage(): string {
|
|||
}
|
||||
|
||||
export function createThreadGoalSyncActions(host: ThreadGoalSyncHost): ThreadGoalSyncActions {
|
||||
const localItemIds = createLocalChatItemIdFactory();
|
||||
const localItemIds = createLocalIdSource();
|
||||
return {
|
||||
syncThreadGoal: (threadId) => syncThreadGoal(host, localItemIds, threadId),
|
||||
};
|
||||
}
|
||||
|
||||
export function createGoalActions(host: GoalActionsHost): GoalActions {
|
||||
const localItemIds = createLocalChatItemIdFactory();
|
||||
const localItemIds = createLocalIdSource();
|
||||
return {
|
||||
activeGoal: () => host.stateStore.getState().activeThread.goal,
|
||||
syncThreadGoal: (threadId) => syncThreadGoal(host, localItemIds, threadId),
|
||||
|
|
@ -51,11 +51,7 @@ export function createGoalActions(host: GoalActionsHost): GoalActions {
|
|||
};
|
||||
}
|
||||
|
||||
async function syncThreadGoal(
|
||||
host: ThreadGoalSyncHost,
|
||||
localItemIds: ReturnType<typeof createLocalChatItemIdFactory>,
|
||||
threadId: string,
|
||||
): Promise<void> {
|
||||
async function syncThreadGoal(host: ThreadGoalSyncHost, localItemIds: LocalIdSource, threadId: string): Promise<void> {
|
||||
const client = host.currentClient();
|
||||
if (!client) return;
|
||||
try {
|
||||
|
|
@ -67,7 +63,7 @@ async function syncThreadGoal(
|
|||
|
||||
async function setObjective(
|
||||
host: GoalActionsHost,
|
||||
localItemIds: ReturnType<typeof createLocalChatItemIdFactory>,
|
||||
localItemIds: LocalIdSource,
|
||||
threadId: string,
|
||||
objective: string,
|
||||
tokenBudget: number | null,
|
||||
|
|
@ -90,20 +86,11 @@ async function setObjective(
|
|||
return applied;
|
||||
}
|
||||
|
||||
function setGoalStatus(
|
||||
host: GoalActionsHost,
|
||||
localItemIds: ReturnType<typeof createLocalChatItemIdFactory>,
|
||||
threadId: string,
|
||||
status: ThreadGoalStatus,
|
||||
): Promise<boolean> {
|
||||
function setGoalStatus(host: GoalActionsHost, localItemIds: LocalIdSource, threadId: string, status: ThreadGoalStatus): Promise<boolean> {
|
||||
return setGoal(host, localItemIds, threadId, { status });
|
||||
}
|
||||
|
||||
async function clearGoal(
|
||||
host: GoalActionsHost,
|
||||
localItemIds: ReturnType<typeof createLocalChatItemIdFactory>,
|
||||
threadId: string,
|
||||
): Promise<boolean> {
|
||||
async function clearGoal(host: GoalActionsHost, localItemIds: LocalIdSource, threadId: string): Promise<boolean> {
|
||||
await host.ensureConnected();
|
||||
const client = host.currentClient();
|
||||
if (!client) return false;
|
||||
|
|
@ -117,12 +104,7 @@ async function clearGoal(
|
|||
}
|
||||
}
|
||||
|
||||
async function setGoal(
|
||||
host: GoalActionsHost,
|
||||
localItemIds: ReturnType<typeof createLocalChatItemIdFactory>,
|
||||
threadId: string,
|
||||
params: ThreadGoalUpdate,
|
||||
): Promise<boolean> {
|
||||
async function setGoal(host: GoalActionsHost, localItemIds: LocalIdSource, threadId: string, params: ThreadGoalUpdate): Promise<boolean> {
|
||||
await host.ensureConnected();
|
||||
const client = host.currentClient();
|
||||
if (!client) return false;
|
||||
|
|
@ -136,7 +118,7 @@ async function setGoal(
|
|||
|
||||
function applyGoalIfActive(
|
||||
host: ThreadGoalSyncHost,
|
||||
localItemIds: ReturnType<typeof createLocalChatItemIdFactory>,
|
||||
localItemIds: LocalIdSource,
|
||||
threadId: string,
|
||||
goal: ThreadGoal | null,
|
||||
options: { reportChange: boolean },
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export interface ThreadRenameEditorActions {
|
|||
}
|
||||
|
||||
export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsHost): ThreadRenameEditorActions {
|
||||
let nextRenameGenerationId = 1;
|
||||
let nextRenameGenerationToken = 1;
|
||||
|
||||
const action = {
|
||||
editState(threadId: string): RenameEditState | null {
|
||||
|
|
@ -99,11 +99,11 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
|
|||
type: "ui/rename-generation-started",
|
||||
threadId,
|
||||
originalDraft: editingState.draft,
|
||||
generationId: nextRenameGenerationId,
|
||||
generationToken: nextRenameGenerationToken,
|
||||
});
|
||||
const generatingState: ChatRenameUiState = host.stateStore.getState().ui.rename;
|
||||
if (generatingState.kind !== "generating") return;
|
||||
nextRenameGenerationId += 1;
|
||||
nextRenameGenerationToken += 1;
|
||||
|
||||
try {
|
||||
const title = await host.generateThreadTitle(threadId);
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
export interface LocalChatItemIdFactory {
|
||||
next(prefix: string): string;
|
||||
}
|
||||
|
||||
export interface LocalChatItemIdFactoryOptions {
|
||||
nowMs?: () => number;
|
||||
seed?: string;
|
||||
}
|
||||
|
||||
let factorySequence = 0;
|
||||
|
||||
export function createLocalChatItemIdFactory(options: LocalChatItemIdFactoryOptions = {}): LocalChatItemIdFactory {
|
||||
const nowMs = options.nowMs ?? Date.now;
|
||||
const seed = sanitizeIdPart(options.seed ?? defaultIdSeed());
|
||||
factorySequence += 1;
|
||||
const factoryId = `${seed}-${factorySequence.toString(36)}`;
|
||||
let itemSequence = 0;
|
||||
return {
|
||||
next(prefix: string): string {
|
||||
itemSequence += 1;
|
||||
return `${prefix}-${String(nowMs())}-${factoryId}-${itemSequence.toString(36)}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isLocalUserMessageId(id: string): boolean {
|
||||
return id.startsWith("local-user-") || id.startsWith("local-steer-");
|
||||
}
|
||||
|
||||
export function isLocalSteerMessageClientId(id: string | null | undefined): boolean {
|
||||
return id?.startsWith("local-steer-") ?? false;
|
||||
}
|
||||
|
||||
function defaultIdSeed(): string {
|
||||
return Date.now().toString(36);
|
||||
}
|
||||
|
||||
function sanitizeIdPart(value: string): string {
|
||||
return value.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 24) || "local";
|
||||
}
|
||||
7
src/features/chat/domain/local-message-ids.ts
Normal file
7
src/features/chat/domain/local-message-ids.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function isLocalUserMessageId(id: string): boolean {
|
||||
return id.startsWith("local-user-") || id.startsWith("local-steer-");
|
||||
}
|
||||
|
||||
export function isLocalSteerMessageClientId(id: string | null | undefined): boolean {
|
||||
return id?.startsWith("local-steer-") ?? false;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { isLocalUserMessageId } from "../local-id";
|
||||
import { isLocalUserMessageId } from "../local-message-ids";
|
||||
import type { MessageStreamItem, MessageStreamMessageItem } from "./items";
|
||||
import { upsertMessageStreamItemById } from "./updates";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { MessageStreamItem } from "../items";
|
||||
import { isLocalSteerMessageClientId } from "../../local-id";
|
||||
import { isLocalSteerMessageClientId } from "../../local-message-ids";
|
||||
import type {
|
||||
MessageStreamLifecycle,
|
||||
MessageStreamMeaning,
|
||||
|
|
|
|||
|
|
@ -129,11 +129,23 @@ export function questionDefaultAnswer(question: PendingUserInputQuestion): strin
|
|||
}
|
||||
|
||||
export function userInputDraftKey(requestId: PendingRequestId, questionId: string): string {
|
||||
return `${String(requestId)}:${questionId}`;
|
||||
return pendingRequestDerivedKey(requestId, questionId);
|
||||
}
|
||||
|
||||
export function userInputOtherDraftKey(requestId: PendingRequestId, questionId: string): string {
|
||||
return `${String(requestId)}:${questionId}:other`;
|
||||
return pendingRequestDerivedKey(requestId, `${questionId}:other`);
|
||||
}
|
||||
|
||||
export function approvalDetailsDisclosureId(requestId: PendingRequestId): string {
|
||||
return pendingRequestDerivedKey(requestId, "details");
|
||||
}
|
||||
|
||||
export function pendingRequestDerivedKeyPrefix(requestId: PendingRequestId): string {
|
||||
return `${String(requestId)}:`;
|
||||
}
|
||||
|
||||
function pendingRequestDerivedKey(requestId: PendingRequestId, suffix: string): string {
|
||||
return `${pendingRequestDerivedKeyPrefix(requestId)}${suffix}`;
|
||||
}
|
||||
|
||||
export function answersForPendingUserInput(input: PendingUserInput, drafts: ReadonlyMap<string, string>): Record<string, string> {
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ import {
|
|||
} from "../presentation/runtime/status";
|
||||
import { connectionDiagnosticsModel } from "../application/connection/diagnostics-display";
|
||||
import { createStructuredSystemItem, createSystemItem } from "../domain/message-stream/factories/system-items";
|
||||
import { createLocalChatItemIdFactory, type LocalChatItemIdFactory } from "../domain/local-id";
|
||||
import { createLocalIdSource, type LocalIdSource } from "../../../shared/id/local-id";
|
||||
import type { RuntimeSnapshot } from "../application/runtime/snapshot";
|
||||
import type { ChatPanelEnvironment } from "./runtime";
|
||||
import { createConnectionBundle, type ChatPanelConnectionBundle, type CurrentAppServerClient } from "./connection-bundle";
|
||||
|
|
@ -166,7 +166,7 @@ interface ChatPanelSurfacePresenterParts {
|
|||
|
||||
export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): ChatPanelSessionGraph {
|
||||
const { environment, stateStore } = host;
|
||||
const localItemIds = createLocalChatItemIdFactory();
|
||||
const localItemIds = createLocalIdSource();
|
||||
const connection = createConnectionManager(environment);
|
||||
const currentClient = () => connection.currentClient();
|
||||
const status = createSessionStatus(stateStore, localItemIds);
|
||||
|
|
@ -985,7 +985,7 @@ function collaborationModeLabel(stateStore: ChatStateStore): string {
|
|||
return formatCollaborationModeLabel(stateStore.getState().runtime.selectedCollaborationMode);
|
||||
}
|
||||
|
||||
function createSessionStatus(stateStore: ChatStateStore, localItemIds: LocalChatItemIdFactory): ChatPanelSessionStatus {
|
||||
function createSessionStatus(stateStore: ChatStateStore, localItemIds: LocalIdSource): ChatPanelSessionStatus {
|
||||
return {
|
||||
set: (statusText, phase) => {
|
||||
dispatch(stateStore, { type: "connection/status-set", statusText, ...(phase ? { phase } : {}) });
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { ItemView, type ViewStateResult, type WorkspaceLeaf } from "obsidian";
|
||||
|
||||
import { VIEW_TYPE_CODEX_PANEL } from "../../../constants";
|
||||
import { createLocalIdSource } from "../../../shared/id/local-id";
|
||||
import type { CodexChatHost } from "./runtime";
|
||||
import { ChatPanelSession } from "./session";
|
||||
import type { ChatSurfaceHandle } from "./surface-handle";
|
||||
|
||||
export class CodexChatView extends ItemView {
|
||||
readonly surface: ChatSurfaceHandle;
|
||||
private readonly viewId = `codex-panel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
private readonly viewId = createLocalIdSource().next("codex-panel");
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: CodexChatHost) {
|
||||
super(leaf);
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ function goalDetailView(item: GoalMessageStreamItem): DetailView {
|
|||
item,
|
||||
"codex-panel__detail-item codex-panel__detail-item--goal",
|
||||
"goal",
|
||||
`${item.id}:goal-details`,
|
||||
messageDetailKey(item.id, "goal-details"),
|
||||
goalDetails(item),
|
||||
);
|
||||
}
|
||||
|
|
@ -147,7 +147,7 @@ function agentDetailView(item: AgentMessageStreamItem): DetailView {
|
|||
item,
|
||||
"codex-panel__detail-item codex-panel__agent-activity",
|
||||
"agent",
|
||||
`${item.id}:agent-details`,
|
||||
messageDetailKey(item.id, "agent-details"),
|
||||
agentDetailSections(item),
|
||||
agentSummaryText(item),
|
||||
);
|
||||
|
|
@ -158,7 +158,7 @@ function genericToolDetailView(item: ToolCallMessageStreamItem | HookMessageStre
|
|||
item,
|
||||
`codex-panel__detail-item codex-panel__detail-item--${item.kind}`,
|
||||
item.toolName ?? item.kind,
|
||||
`${item.id}:details`,
|
||||
messageDetailKey(item.id, "details"),
|
||||
[...genericToolDetails(item), ...outputSection(item.kind === "hook" ? "Hook output" : "Output", item.output)],
|
||||
genericToolSummary(item, workspaceRoot),
|
||||
);
|
||||
|
|
@ -168,7 +168,7 @@ function reviewDetailView(item: ReviewResultMessageStreamItem): DetailView {
|
|||
return resultDetailView(
|
||||
item,
|
||||
"auto-review",
|
||||
`${item.id}:review-details`,
|
||||
messageDetailKey(item.id, "review-details"),
|
||||
"codex-panel__message--review-result codex-panel__detail-item--review",
|
||||
);
|
||||
}
|
||||
|
|
@ -177,7 +177,7 @@ function approvalDetailView(item: ApprovalResultMessageStreamItem): DetailView {
|
|||
return resultDetailView(
|
||||
item,
|
||||
"approval",
|
||||
`${item.id}:approval-details`,
|
||||
messageDetailKey(item.id, "approval-details"),
|
||||
"codex-panel__message--approval-result codex-panel__detail-item--approval",
|
||||
);
|
||||
}
|
||||
|
|
@ -187,12 +187,16 @@ function genericDetailView(item: MessageStreamItem, workspaceRoot?: string | nul
|
|||
item,
|
||||
`codex-panel__detail-item codex-panel__detail-item--${item.kind}`,
|
||||
detailLabel(item),
|
||||
`${item.id}:details`,
|
||||
messageDetailKey(item.id, "details"),
|
||||
genericDetailSections(item, workspaceRoot),
|
||||
genericDetailSummary(item, workspaceRoot),
|
||||
);
|
||||
}
|
||||
|
||||
function messageDetailKey(itemId: string, suffix: string): string {
|
||||
return `${itemId}:${suffix}`;
|
||||
}
|
||||
|
||||
function resultDetailView(
|
||||
item: ApprovalResultMessageStreamItem | ReviewResultMessageStreamItem,
|
||||
label: string,
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export function messageStreamLayoutBlocks(
|
|||
const groupItems = groupedActivities.get(turnId) ?? [];
|
||||
blocks.push({
|
||||
type: "activityGroup",
|
||||
id: `turn-${turnId}-activity`,
|
||||
id: turnActivityGroupId(turnId),
|
||||
turnId,
|
||||
summary: "Work details",
|
||||
items: groupItems,
|
||||
|
|
@ -121,13 +121,21 @@ function isEmptyCompletedReasoningItem(item: MessageStreamItem): boolean {
|
|||
function steeringActivityGroupItem(classification: MessageStreamSemanticClassification): MessageStreamActivityGroupItem {
|
||||
return {
|
||||
type: "steering",
|
||||
id: `steer-activity-${classification.item.id}`,
|
||||
id: steerActivityGroupId(classification.item.id),
|
||||
label: STEERING_ACTIVITY_LABEL,
|
||||
text: textForMessageStreamItem(classification.item),
|
||||
sourceItemId: classification.item.sourceItemId ?? classification.item.id,
|
||||
};
|
||||
}
|
||||
|
||||
function turnActivityGroupId(turnId: string): string {
|
||||
return `turn-${turnId}-activity`;
|
||||
}
|
||||
|
||||
function steerActivityGroupId(itemId: string): string {
|
||||
return `steer-activity-${itemId}`;
|
||||
}
|
||||
|
||||
function isCompletedTurnDetailItem(classification: MessageStreamSemanticClassification, turnOutcomeIdByTurn: Map<string, string>): boolean {
|
||||
const turnId = classification.item.turnId;
|
||||
if (!turnId || messageStreamIsTurnInitiator(classification) || messageStreamIsTurnSteer(classification)) return false;
|
||||
|
|
|
|||
|
|
@ -123,8 +123,7 @@ export function ComposerShell({
|
|||
});
|
||||
const sendMode = composerSendMode(busy, canInterrupt, draft);
|
||||
const normalizedSelectedSuggestionIndex = suggestions.length === 0 ? 0 : Math.min(selectedSuggestionIndex, suggestions.length - 1);
|
||||
const selectedSuggestionId =
|
||||
suggestions.length > 0 ? `${viewId}-composer-suggestion-${String(normalizedSelectedSuggestionIndex)}` : undefined;
|
||||
const selectedSuggestionId = suggestions.length > 0 ? composerSuggestionOptionId(viewId, normalizedSelectedSuggestionIndex) : undefined;
|
||||
|
||||
return (
|
||||
<div className="codex-panel__composer">
|
||||
|
|
@ -136,7 +135,7 @@ export function ComposerShell({
|
|||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={suggestions.length > 0 ? "true" : "false"}
|
||||
aria-controls={`${viewId}-composer-suggestions`}
|
||||
aria-controls={composerSuggestionsListId(viewId)}
|
||||
aria-activedescendant={selectedSuggestionId}
|
||||
value={draft}
|
||||
onInput={(event) => {
|
||||
|
|
@ -527,13 +526,13 @@ function ComposerSuggestions({
|
|||
<div
|
||||
ref={containerRef}
|
||||
className="codex-panel__composer-suggestions"
|
||||
id={`${viewId}-composer-suggestions`}
|
||||
id={composerSuggestionsListId(viewId)}
|
||||
role="listbox"
|
||||
hidden={suggestions.length === 0}
|
||||
>
|
||||
{suggestions.map((suggestion, index) => {
|
||||
const selected = index === selectedIndex;
|
||||
const optionId = `${viewId}-composer-suggestion-${String(index)}`;
|
||||
const optionId = composerSuggestionOptionId(viewId, index);
|
||||
return (
|
||||
<div
|
||||
key={optionId}
|
||||
|
|
@ -559,3 +558,11 @@ function ComposerSuggestions({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function composerSuggestionsListId(viewId: string): string {
|
||||
return `${viewId}-composer-suggestions`;
|
||||
}
|
||||
|
||||
function composerSuggestionOptionId(viewId: string, index: number): string {
|
||||
return `${viewId}-composer-suggestion-${String(index)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ComponentChild as UiNode } from "preact";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import { approvalDetailsDisclosureId } from "../../domain/pending-requests/model";
|
||||
import {
|
||||
type PendingApprovalViewModel,
|
||||
type PendingUserInputQuestionViewModel,
|
||||
|
|
@ -130,7 +131,7 @@ function ApprovalDetails({
|
|||
approvalDetails: ReadonlySet<string>;
|
||||
actions: PendingRequestBlockActions;
|
||||
}): UiNode {
|
||||
const detailId = `${String(approval.requestId)}:details`;
|
||||
const detailId = approvalDetailsDisclosureId(approval.requestId);
|
||||
return (
|
||||
<details
|
||||
className="codex-panel__approval-details"
|
||||
|
|
@ -210,7 +211,7 @@ function UserInputQuestions({
|
|||
{question.options && question.options.length > 0 ? (
|
||||
<>
|
||||
{question.options.map((option) => {
|
||||
const groupName = `codex-panel-${String(input.requestId)}-${question.id}`;
|
||||
const groupName = userInputRadioGroupName(input.requestId, question.id);
|
||||
return (
|
||||
<label key={option.label} className="codex-panel__user-input-option">
|
||||
<input
|
||||
|
|
@ -232,7 +233,7 @@ function UserInputQuestions({
|
|||
})}
|
||||
{question.isOther ? (
|
||||
<OtherUserInputOption
|
||||
groupName={`codex-panel-${String(input.requestId)}-${question.id}`}
|
||||
groupName={userInputRadioGroupName(input.requestId, question.id)}
|
||||
current={current}
|
||||
optionLabels={new Set(question.options.map((option) => option.label))}
|
||||
question={question}
|
||||
|
|
@ -252,6 +253,10 @@ function UserInputQuestions({
|
|||
);
|
||||
}
|
||||
|
||||
function userInputRadioGroupName(requestId: PendingUserInputViewModel["requestId"], questionId: string): string {
|
||||
return `codex-panel-${String(requestId)}-${questionId}`;
|
||||
}
|
||||
|
||||
function OtherUserInputOption({
|
||||
groupName,
|
||||
current,
|
||||
|
|
|
|||
|
|
@ -40,10 +40,10 @@ export interface SettingsDynamicDataSnapshot {
|
|||
|
||||
export class SettingsDynamicDataController {
|
||||
private settingsDataAutoLoadStarted = false;
|
||||
private settingsRefreshOperationId = 0;
|
||||
private modelsOperationId = 0;
|
||||
private hooksOperationId = 0;
|
||||
private archivedThreadsOperationId = 0;
|
||||
private settingsRefreshOperationToken = 0;
|
||||
private modelsOperationToken = 0;
|
||||
private hooksOperationToken = 0;
|
||||
private archivedThreadsOperationToken = 0;
|
||||
private settingsDataRefreshLifecycle: SettingsDataRefreshLifecycleState = { kind: "idle" };
|
||||
|
||||
private archivedThreads: Thread[] = [];
|
||||
|
|
@ -80,10 +80,10 @@ export class SettingsDynamicDataController {
|
|||
|
||||
resetSettingsDataContext(): void {
|
||||
this.settingsDataAutoLoadStarted = false;
|
||||
this.settingsRefreshOperationId += 1;
|
||||
this.modelsOperationId += 1;
|
||||
this.hooksOperationId += 1;
|
||||
this.archivedThreadsOperationId += 1;
|
||||
this.settingsRefreshOperationToken += 1;
|
||||
this.modelsOperationToken += 1;
|
||||
this.hooksOperationToken += 1;
|
||||
this.archivedThreadsOperationToken += 1;
|
||||
this.settingsDataRefreshLifecycle = { kind: "idle" };
|
||||
this.models = [...(this.host.appServerData.modelsSnapshot() ?? [])];
|
||||
this.modelsLifecycle = createSettingsDynamicSectionLifecycle();
|
||||
|
|
@ -108,28 +108,28 @@ export class SettingsDynamicDataController {
|
|||
|
||||
async refreshSettingsData(options: { forceModels?: boolean } = {}): Promise<void> {
|
||||
this.settingsDataAutoLoadStarted = true;
|
||||
const operationId = this.nextSettingsRefreshOperationId();
|
||||
const modelsOperationId = this.nextModelsOperationId();
|
||||
const hooksOperationId = this.nextHooksOperationId();
|
||||
const archivedThreadsOperationId = this.nextArchivedThreadsOperationId();
|
||||
const operationToken = this.nextSettingsRefreshOperationToken();
|
||||
const modelsOperationToken = this.nextModelsOperationToken();
|
||||
const hooksOperationToken = this.nextHooksOperationToken();
|
||||
const archivedThreadsOperationToken = this.nextArchivedThreadsOperationToken();
|
||||
this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, {
|
||||
type: "started",
|
||||
operationId,
|
||||
operationToken,
|
||||
});
|
||||
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
|
||||
type: "started",
|
||||
status: "Loading models...",
|
||||
operationId: modelsOperationId,
|
||||
operationToken: modelsOperationToken,
|
||||
});
|
||||
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
|
||||
type: "started",
|
||||
status: "Loading archived threads...",
|
||||
operationId: archivedThreadsOperationId,
|
||||
operationToken: archivedThreadsOperationToken,
|
||||
});
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "started",
|
||||
status: "Loading hooks...",
|
||||
operationId: hooksOperationId,
|
||||
operationToken: hooksOperationToken,
|
||||
});
|
||||
this.callbacks.display();
|
||||
|
||||
|
|
@ -139,16 +139,16 @@ export class SettingsDynamicDataController {
|
|||
options.forceModels === false ? this.host.appServerData.fetchModels() : this.host.appServerData.refreshModels(),
|
||||
this.withSettingsConnection((client) => loadSettingsCompanionData(client, this.host.vaultPath)),
|
||||
] as const);
|
||||
if (this.isStaleSettingsRefreshOperation(operationId)) return;
|
||||
if (this.isStaleSettingsRefreshOperation(operationToken)) return;
|
||||
|
||||
if (this.isStaleModelsOperation(modelsOperationId)) {
|
||||
if (this.isStaleModelsOperation(modelsOperationToken)) {
|
||||
// A newer models operation owns this section.
|
||||
} else if (modelsResult.status === "fulfilled") {
|
||||
this.models = [...modelsResult.value];
|
||||
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
|
||||
type: "loaded",
|
||||
status: `Loaded ${String(modelsResult.value.length)} model${modelsResult.value.length === 1 ? "" : "s"}.`,
|
||||
operationId: modelsOperationId,
|
||||
operationToken: modelsOperationToken,
|
||||
});
|
||||
} else if (isStaleAppServerSharedQueryContextError(modelsResult.reason)) {
|
||||
return;
|
||||
|
|
@ -157,7 +157,7 @@ export class SettingsDynamicDataController {
|
|||
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
|
||||
type: "failed",
|
||||
status: `Could not load models: ${errorMessage(modelsResult.reason)}`,
|
||||
operationId: modelsOperationId,
|
||||
operationToken: modelsOperationToken,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +169,7 @@ export class SettingsDynamicDataController {
|
|||
archivedThreads: { ok: false as const, status: `Could not load archived threads: ${errorMessage(companionResult.reason)}` },
|
||||
};
|
||||
|
||||
if (this.isStaleHooksOperation(hooksOperationId)) {
|
||||
if (this.isStaleHooksOperation(hooksOperationToken)) {
|
||||
// A newer hooks operation owns this section.
|
||||
} else if (companion.hooks.ok) {
|
||||
this.hooks = companion.hooks.data.hooks;
|
||||
|
|
@ -178,67 +178,67 @@ export class SettingsDynamicDataController {
|
|||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "loaded",
|
||||
status: companion.hooks.status,
|
||||
operationId: hooksOperationId,
|
||||
operationToken: hooksOperationToken,
|
||||
});
|
||||
} else {
|
||||
failedCount += 1;
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "failed",
|
||||
status: companion.hooks.status,
|
||||
operationId: hooksOperationId,
|
||||
operationToken: hooksOperationToken,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.isStaleArchivedThreadsOperation(archivedThreadsOperationId)) {
|
||||
if (this.isStaleArchivedThreadsOperation(archivedThreadsOperationToken)) {
|
||||
// A newer archived threads operation owns this section.
|
||||
} else if (companion.archivedThreads.ok) {
|
||||
this.archivedThreads = companion.archivedThreads.data;
|
||||
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
|
||||
type: "loaded",
|
||||
status: companion.archivedThreads.status,
|
||||
operationId: archivedThreadsOperationId,
|
||||
operationToken: archivedThreadsOperationToken,
|
||||
});
|
||||
} else {
|
||||
failedCount += 1;
|
||||
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
|
||||
type: "failed",
|
||||
status: companion.archivedThreads.status,
|
||||
operationId: archivedThreadsOperationId,
|
||||
operationToken: archivedThreadsOperationToken,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.isStaleSettingsRefreshOperation(operationId)) return;
|
||||
if (this.isStaleSettingsRefreshOperation(operationToken)) return;
|
||||
failedCount = 3;
|
||||
const message = errorMessage(error);
|
||||
if (!this.isStaleModelsOperation(modelsOperationId)) {
|
||||
if (!this.isStaleModelsOperation(modelsOperationToken)) {
|
||||
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
|
||||
type: "failed",
|
||||
status: `Could not load models: ${message}`,
|
||||
operationId: modelsOperationId,
|
||||
operationToken: modelsOperationToken,
|
||||
});
|
||||
}
|
||||
if (!this.isStaleHooksOperation(hooksOperationId)) {
|
||||
if (!this.isStaleHooksOperation(hooksOperationToken)) {
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "failed",
|
||||
status: `Could not load hooks: ${message}`,
|
||||
operationId: hooksOperationId,
|
||||
operationToken: hooksOperationToken,
|
||||
});
|
||||
}
|
||||
if (!this.isStaleArchivedThreadsOperation(archivedThreadsOperationId)) {
|
||||
if (!this.isStaleArchivedThreadsOperation(archivedThreadsOperationToken)) {
|
||||
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
|
||||
type: "failed",
|
||||
status: `Could not load archived threads: ${message}`,
|
||||
operationId: archivedThreadsOperationId,
|
||||
operationToken: archivedThreadsOperationToken,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
const staleOperation = this.isStaleSettingsRefreshOperation(operationId);
|
||||
const staleRefresh = this.isStaleSettingsRefreshOperation(operationToken);
|
||||
this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, {
|
||||
type: "completed",
|
||||
failedCount,
|
||||
operationId,
|
||||
operationToken,
|
||||
});
|
||||
if (!staleOperation) {
|
||||
if (!staleRefresh) {
|
||||
if (failedCount > 0) {
|
||||
this.callbacks.notify("Could not refresh all Codex data.");
|
||||
}
|
||||
|
|
@ -270,13 +270,13 @@ export class SettingsDynamicDataController {
|
|||
loadingStatus: "Loading hooks...",
|
||||
failureStatus: (error) => `Could not trust hook: ${errorMessage(error)}`,
|
||||
failureNotice: "Could not trust Codex hook.",
|
||||
operation: async (operationId) => {
|
||||
operation: async (operationToken) => {
|
||||
await this.withSettingsConnection((client) => trustHookItem(client, hook));
|
||||
if (this.isStaleHooksOperation(operationId)) return;
|
||||
if (this.isStaleHooksOperation(operationToken)) return;
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "loaded",
|
||||
status: "Trusted hook definition.",
|
||||
operationId,
|
||||
operationToken,
|
||||
});
|
||||
await this.loadHooks();
|
||||
},
|
||||
|
|
@ -289,13 +289,13 @@ export class SettingsDynamicDataController {
|
|||
loadingStatus: "Loading hooks...",
|
||||
failureStatus: (error) => `Could not update hook: ${errorMessage(error)}`,
|
||||
failureNotice: "Could not update Codex hook.",
|
||||
operation: async (operationId) => {
|
||||
operation: async (operationToken) => {
|
||||
await this.withSettingsConnection((client) => setHookItemEnabled(client, hook, enabled));
|
||||
if (this.isStaleHooksOperation(operationId)) return;
|
||||
if (this.isStaleHooksOperation(operationToken)) return;
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "loaded",
|
||||
status: enabled ? "Enabled hook." : "Disabled hook.",
|
||||
operationId,
|
||||
operationToken,
|
||||
});
|
||||
await this.loadHooks();
|
||||
},
|
||||
|
|
@ -308,14 +308,14 @@ export class SettingsDynamicDataController {
|
|||
loadingStatus: "Loading archived threads...",
|
||||
failureStatus: (error) => `Could not restore archived thread: ${errorMessage(error)}`,
|
||||
failureNotice: "Could not restore archived Codex thread.",
|
||||
operation: async (operationId) => {
|
||||
operation: async (operationToken) => {
|
||||
const restoredThread = await this.withSettingsConnection((client) => restoreArchivedThreadOnAppServer(client, threadId));
|
||||
if (this.isStaleArchivedThreadsOperation(operationId)) return;
|
||||
if (this.isStaleArchivedThreadsOperation(operationToken)) return;
|
||||
this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId);
|
||||
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
|
||||
type: "loaded",
|
||||
status: `Restored "${archivedThreadDisplayTitle(restoredThread)}".`,
|
||||
operationId,
|
||||
operationToken,
|
||||
});
|
||||
this.host.threadCatalog.recordThreadRestored(restoredThread);
|
||||
},
|
||||
|
|
@ -332,14 +332,14 @@ export class SettingsDynamicDataController {
|
|||
loadingStatus: "Loading archived threads...",
|
||||
failureStatus: (error) => `Could not delete archived thread: ${errorMessage(error)}`,
|
||||
failureNotice: "Could not delete archived Codex thread.",
|
||||
operation: async (operationId) => {
|
||||
operation: async (operationToken) => {
|
||||
await this.withSettingsConnection((client) => client.deleteThread(threadId));
|
||||
if (this.isStaleArchivedThreadsOperation(operationId)) return;
|
||||
if (this.isStaleArchivedThreadsOperation(operationToken)) return;
|
||||
this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId);
|
||||
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
|
||||
type: "loaded",
|
||||
status: `Deleted "${title}".`,
|
||||
operationId,
|
||||
operationToken,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -368,16 +368,16 @@ export class SettingsDynamicDataController {
|
|||
loadingStatus: "Loading hooks...",
|
||||
failureStatus: (error) => `Could not load hooks: ${errorMessage(error)}`,
|
||||
failureNotice: "Could not load Codex hooks.",
|
||||
operation: async (operationId) => {
|
||||
operation: async (operationToken) => {
|
||||
const hooks = await this.withSettingsConnection((client) => loadHookData(client, this.host.vaultPath));
|
||||
if (this.isStaleHooksOperation(operationId)) return;
|
||||
if (this.isStaleHooksOperation(operationToken)) return;
|
||||
this.hooks = hooks.hooks;
|
||||
this.hookWarnings = hooks.warnings;
|
||||
this.hookErrors = hooks.errors;
|
||||
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
|
||||
type: "loaded",
|
||||
status: hooks.status,
|
||||
operationId,
|
||||
operationToken,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -388,11 +388,11 @@ export class SettingsDynamicDataController {
|
|||
loadingStatus: string;
|
||||
failureStatus: (error: unknown) => string;
|
||||
failureNotice: string;
|
||||
operation: (operationId: number) => Promise<void>;
|
||||
operation: (operationToken: number) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const operationId = options.section === "hooks" ? this.nextHooksOperationId() : this.nextArchivedThreadsOperationId();
|
||||
const operationToken = options.section === "hooks" ? this.nextHooksOperationToken() : this.nextArchivedThreadsOperationToken();
|
||||
const stale = (): boolean =>
|
||||
options.section === "hooks" ? this.isStaleHooksOperation(operationId) : this.isStaleArchivedThreadsOperation(operationId);
|
||||
options.section === "hooks" ? this.isStaleHooksOperation(operationToken) : this.isStaleArchivedThreadsOperation(operationToken);
|
||||
const lifecycle = (): SettingsDynamicSectionLifecycleState =>
|
||||
options.section === "hooks" ? this.hooksLifecycle : this.archivedThreadsLifecycle;
|
||||
const setLifecycle = (state: SettingsDynamicSectionLifecycleState): void => {
|
||||
|
|
@ -407,19 +407,19 @@ export class SettingsDynamicDataController {
|
|||
transitionSettingsDynamicSectionLifecycle(lifecycle(), {
|
||||
type: "started",
|
||||
status: options.loadingStatus,
|
||||
operationId,
|
||||
operationToken,
|
||||
}),
|
||||
);
|
||||
this.callbacks.display();
|
||||
try {
|
||||
await options.operation(operationId);
|
||||
await options.operation(operationToken);
|
||||
} catch (error) {
|
||||
if (stale()) return;
|
||||
setLifecycle(
|
||||
transitionSettingsDynamicSectionLifecycle(lifecycle(), {
|
||||
type: "failed",
|
||||
status: options.failureStatus(error),
|
||||
operationId,
|
||||
operationToken,
|
||||
}),
|
||||
);
|
||||
this.callbacks.notify(options.failureNotice);
|
||||
|
|
@ -434,39 +434,39 @@ export class SettingsDynamicDataController {
|
|||
});
|
||||
}
|
||||
|
||||
private nextSettingsRefreshOperationId(): number {
|
||||
this.settingsRefreshOperationId += 1;
|
||||
return this.settingsRefreshOperationId;
|
||||
private nextSettingsRefreshOperationToken(): number {
|
||||
this.settingsRefreshOperationToken += 1;
|
||||
return this.settingsRefreshOperationToken;
|
||||
}
|
||||
|
||||
private nextModelsOperationId(): number {
|
||||
this.modelsOperationId += 1;
|
||||
return this.modelsOperationId;
|
||||
private nextModelsOperationToken(): number {
|
||||
this.modelsOperationToken += 1;
|
||||
return this.modelsOperationToken;
|
||||
}
|
||||
|
||||
private nextHooksOperationId(): number {
|
||||
this.hooksOperationId += 1;
|
||||
return this.hooksOperationId;
|
||||
private nextHooksOperationToken(): number {
|
||||
this.hooksOperationToken += 1;
|
||||
return this.hooksOperationToken;
|
||||
}
|
||||
|
||||
private nextArchivedThreadsOperationId(): number {
|
||||
this.archivedThreadsOperationId += 1;
|
||||
return this.archivedThreadsOperationId;
|
||||
private nextArchivedThreadsOperationToken(): number {
|
||||
this.archivedThreadsOperationToken += 1;
|
||||
return this.archivedThreadsOperationToken;
|
||||
}
|
||||
|
||||
private isStaleSettingsRefreshOperation(operationId: number): boolean {
|
||||
return operationId !== this.settingsRefreshOperationId;
|
||||
private isStaleSettingsRefreshOperation(operationToken: number): boolean {
|
||||
return operationToken !== this.settingsRefreshOperationToken;
|
||||
}
|
||||
|
||||
private isStaleModelsOperation(operationId: number): boolean {
|
||||
return operationId !== this.modelsOperationId;
|
||||
private isStaleModelsOperation(operationToken: number): boolean {
|
||||
return operationToken !== this.modelsOperationToken;
|
||||
}
|
||||
|
||||
private isStaleHooksOperation(operationId: number): boolean {
|
||||
return operationId !== this.hooksOperationId;
|
||||
private isStaleHooksOperation(operationToken: number): boolean {
|
||||
return operationToken !== this.hooksOperationToken;
|
||||
}
|
||||
|
||||
private isStaleArchivedThreadsOperation(operationId: number): boolean {
|
||||
return operationId !== this.archivedThreadsOperationId;
|
||||
private isStaleArchivedThreadsOperation(operationToken: number): boolean {
|
||||
return operationToken !== this.archivedThreadsOperationToken;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
export type SettingsDataRefreshLifecycleState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading"; operationId: number }
|
||||
| { kind: "completed"; failedCount: number; operationId: number };
|
||||
| { kind: "loading"; operationToken: number }
|
||||
| { kind: "completed"; failedCount: number; operationToken: number };
|
||||
|
||||
export type SettingsDataRefreshLifecycleEvent =
|
||||
| { type: "started"; operationId: number }
|
||||
| { type: "completed"; failedCount: number; operationId: number };
|
||||
| { type: "started"; operationToken: number }
|
||||
| { type: "completed"; failedCount: number; operationToken: number };
|
||||
|
||||
export type SettingsDynamicSectionLifecycleState =
|
||||
| { kind: "idle"; status: "" }
|
||||
| { kind: "loading"; status: string; operationId: number }
|
||||
| { kind: "loaded"; status: string; operationId: number }
|
||||
| { kind: "failed"; status: string; operationId: number };
|
||||
| { kind: "loading"; status: string; operationToken: number }
|
||||
| { kind: "loaded"; status: string; operationToken: number }
|
||||
| { kind: "failed"; status: string; operationToken: number };
|
||||
|
||||
export type SettingsDynamicSectionLifecycleEvent =
|
||||
| { type: "started"; status: string; operationId: number }
|
||||
| { type: "loaded"; status: string; operationId: number }
|
||||
| { type: "failed"; status: string; operationId: number }
|
||||
| { type: "started"; status: string; operationToken: number }
|
||||
| { type: "loaded"; status: string; operationToken: number }
|
||||
| { type: "failed"; status: string; operationToken: number }
|
||||
| { type: "reset" };
|
||||
|
||||
export function transitionSettingsDataRefreshLifecycle(
|
||||
|
|
@ -25,11 +25,11 @@ export function transitionSettingsDataRefreshLifecycle(
|
|||
): SettingsDataRefreshLifecycleState {
|
||||
switch (event.type) {
|
||||
case "started":
|
||||
if (isStaleSettingsDataRefreshEvent(state, event.operationId)) return state;
|
||||
return { kind: "loading", operationId: event.operationId };
|
||||
if (isStaleSettingsDataRefreshEvent(state, event.operationToken)) return state;
|
||||
return { kind: "loading", operationToken: event.operationToken };
|
||||
case "completed":
|
||||
if (isStaleSettingsDataRefreshEvent(state, event.operationId)) return state;
|
||||
return { kind: "completed", failedCount: event.failedCount, operationId: event.operationId };
|
||||
if (isStaleSettingsDataRefreshEvent(state, event.operationToken)) return state;
|
||||
return { kind: "completed", failedCount: event.failedCount, operationToken: event.operationToken };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,23 +43,23 @@ export function transitionSettingsDynamicSectionLifecycle(
|
|||
): SettingsDynamicSectionLifecycleState {
|
||||
switch (event.type) {
|
||||
case "started":
|
||||
if (isStaleSettingsDynamicSectionEvent(state, event.operationId)) return state;
|
||||
return { kind: "loading", status: event.status, operationId: event.operationId };
|
||||
if (isStaleSettingsDynamicSectionEvent(state, event.operationToken)) return state;
|
||||
return { kind: "loading", status: event.status, operationToken: event.operationToken };
|
||||
case "loaded":
|
||||
if (isStaleSettingsDynamicSectionEvent(state, event.operationId)) return state;
|
||||
return { kind: "loaded", status: event.status, operationId: event.operationId };
|
||||
if (isStaleSettingsDynamicSectionEvent(state, event.operationToken)) return state;
|
||||
return { kind: "loaded", status: event.status, operationToken: event.operationToken };
|
||||
case "failed":
|
||||
if (isStaleSettingsDynamicSectionEvent(state, event.operationId)) return state;
|
||||
return { kind: "failed", status: event.status, operationId: event.operationId };
|
||||
if (isStaleSettingsDynamicSectionEvent(state, event.operationToken)) return state;
|
||||
return { kind: "failed", status: event.status, operationToken: event.operationToken };
|
||||
case "reset":
|
||||
return createSettingsDynamicSectionLifecycle();
|
||||
}
|
||||
}
|
||||
|
||||
function isStaleSettingsDynamicSectionEvent(state: SettingsDynamicSectionLifecycleState, operationId: number): boolean {
|
||||
return "operationId" in state && state.operationId > operationId;
|
||||
function isStaleSettingsDynamicSectionEvent(state: SettingsDynamicSectionLifecycleState, operationToken: number): boolean {
|
||||
return "operationToken" in state && state.operationToken > operationToken;
|
||||
}
|
||||
|
||||
function isStaleSettingsDataRefreshEvent(state: SettingsDataRefreshLifecycleState, operationId: number): boolean {
|
||||
return "operationId" in state && state.operationId > operationId;
|
||||
function isStaleSettingsDataRefreshEvent(state: SettingsDataRefreshLifecycleState, operationToken: number): boolean {
|
||||
return "operationToken" in state && state.operationToken > operationToken;
|
||||
}
|
||||
|
|
|
|||
33
src/shared/id/local-id.ts
Normal file
33
src/shared/id/local-id.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export interface LocalIdSource {
|
||||
next(prefix: string): string;
|
||||
}
|
||||
|
||||
export interface LocalIdSourceOptions {
|
||||
nowMs?: () => number;
|
||||
seed?: string;
|
||||
}
|
||||
|
||||
let sourceSequence = 0;
|
||||
|
||||
export function createLocalIdSource(options: LocalIdSourceOptions = {}): LocalIdSource {
|
||||
const nowMs = options.nowMs ?? Date.now;
|
||||
const seed = sanitizeIdPart(options.seed ?? defaultIdSeed());
|
||||
sourceSequence += 1;
|
||||
const sourceId = `${seed}-${sourceSequence.toString(36)}`;
|
||||
let itemSequence = 0;
|
||||
return {
|
||||
next(prefix: string): string {
|
||||
const idPrefix = sanitizeIdPart(prefix);
|
||||
itemSequence += 1;
|
||||
return `${idPrefix}-${String(nowMs())}-${sourceId}-${itemSequence.toString(36)}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defaultIdSeed(): string {
|
||||
return Date.now().toString(36);
|
||||
}
|
||||
|
||||
function sanitizeIdPart(value: string): string {
|
||||
return value.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 24) || "local";
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createLocalChatItemIdFactory,
|
||||
isLocalSteerMessageClientId,
|
||||
isLocalUserMessageId,
|
||||
} from "../../../src/features/chat/domain/local-id";
|
||||
|
||||
describe("local chat item ids", () => {
|
||||
it("generates monotonic IDs per factory with a stable prefix and timestamp", () => {
|
||||
const ids = createLocalChatItemIdFactory({ nowMs: () => 1234, seed: "test" });
|
||||
const first = ids.next("local-user");
|
||||
const second = ids.next("local-user");
|
||||
const third = ids.next("system");
|
||||
|
||||
expect(first).toMatch(/^local-user-1234-test-[a-z0-9]+-1$/);
|
||||
expect(second).toMatch(/^local-user-1234-test-[a-z0-9]+-2$/);
|
||||
expect(third).toMatch(/^system-1234-test-[a-z0-9]+-3$/);
|
||||
});
|
||||
|
||||
it("keeps factories distinct when they share a timestamp and seed", () => {
|
||||
const first = createLocalChatItemIdFactory({ nowMs: () => 1234, seed: "test" });
|
||||
const second = createLocalChatItemIdFactory({ nowMs: () => 1234, seed: "test" });
|
||||
|
||||
expect(first.next("local-user")).not.toBe(second.next("local-user"));
|
||||
});
|
||||
|
||||
it("classifies local prompt and steer message identifiers", () => {
|
||||
expect(isLocalUserMessageId("local-user-1234-test-1-1")).toBe(true);
|
||||
expect(isLocalUserMessageId("local-steer-1234-test-1-1")).toBe(true);
|
||||
expect(isLocalUserMessageId("user-1")).toBe(false);
|
||||
expect(isLocalSteerMessageClientId("local-steer-1234-test-1-1")).toBe(true);
|
||||
expect(isLocalSteerMessageClientId("local-user-1234-test-1-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
13
tests/features/chat/local-message-ids.test.ts
Normal file
13
tests/features/chat/local-message-ids.test.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isLocalSteerMessageClientId, isLocalUserMessageId } from "../../../src/features/chat/domain/local-message-ids";
|
||||
|
||||
describe("local chat message IDs", () => {
|
||||
it("classifies local prompt and steer message identifiers", () => {
|
||||
expect(isLocalUserMessageId("local-user-1234-test-1-1")).toBe(true);
|
||||
expect(isLocalUserMessageId("local-steer-1234-test-1-1")).toBe(true);
|
||||
expect(isLocalUserMessageId("user-1")).toBe(false);
|
||||
expect(isLocalSteerMessageClientId("local-steer-1234-test-1-1")).toBe(true);
|
||||
expect(isLocalSteerMessageClientId("local-user-1234-test-1-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -12,38 +12,46 @@ describe("settings lifecycle", () => {
|
|||
it("tracks settings data refresh lifecycle", () => {
|
||||
const idle = { kind: "idle" } as const;
|
||||
|
||||
const loading = transitionSettingsDataRefreshLifecycle(idle, { type: "started", operationId: 1 });
|
||||
expect(loading).toEqual({ kind: "loading", operationId: 1 });
|
||||
expect(transitionSettingsDataRefreshLifecycle(loading, { type: "started", operationId: 0 })).toBe(loading);
|
||||
const loading = transitionSettingsDataRefreshLifecycle(idle, { type: "started", operationToken: 1 });
|
||||
expect(loading).toEqual({ kind: "loading", operationToken: 1 });
|
||||
expect(transitionSettingsDataRefreshLifecycle(loading, { type: "started", operationToken: 0 })).toBe(loading);
|
||||
|
||||
const newerLoading = transitionSettingsDataRefreshLifecycle(loading, { type: "started", operationId: 2 });
|
||||
expect(newerLoading).toEqual({ kind: "loading", operationId: 2 });
|
||||
expect(transitionSettingsDataRefreshLifecycle(newerLoading, { type: "completed", failedCount: 2, operationId: 1 })).toBe(newerLoading);
|
||||
const newerLoading = transitionSettingsDataRefreshLifecycle(loading, { type: "started", operationToken: 2 });
|
||||
expect(newerLoading).toEqual({ kind: "loading", operationToken: 2 });
|
||||
expect(transitionSettingsDataRefreshLifecycle(newerLoading, { type: "completed", failedCount: 2, operationToken: 1 })).toBe(
|
||||
newerLoading,
|
||||
);
|
||||
|
||||
const completed = transitionSettingsDataRefreshLifecycle(newerLoading, { type: "completed", failedCount: 2, operationId: 2 });
|
||||
expect(completed).toEqual({ kind: "completed", failedCount: 2, operationId: 2 });
|
||||
const completed = transitionSettingsDataRefreshLifecycle(newerLoading, { type: "completed", failedCount: 2, operationToken: 2 });
|
||||
expect(completed).toEqual({ kind: "completed", failedCount: 2, operationToken: 2 });
|
||||
});
|
||||
|
||||
it("tracks dynamic section lifecycle", () => {
|
||||
const idle = createSettingsDynamicSectionLifecycle();
|
||||
expect(idle).toEqual({ kind: "idle", status: "" });
|
||||
|
||||
const loading = transitionSettingsDynamicSectionLifecycle(idle, { type: "started", status: "Loading hooks...", operationId: 1 });
|
||||
expect(loading).toEqual({ kind: "loading", status: "Loading hooks...", operationId: 1 });
|
||||
const loading = transitionSettingsDynamicSectionLifecycle(idle, { type: "started", status: "Loading hooks...", operationToken: 1 });
|
||||
expect(loading).toEqual({ kind: "loading", status: "Loading hooks...", operationToken: 1 });
|
||||
|
||||
expect(transitionSettingsDynamicSectionLifecycle(loading, { type: "loaded", status: "Stale result.", operationId: 0 })).toBe(loading);
|
||||
expect(transitionSettingsDynamicSectionLifecycle(loading, { type: "loaded", status: "Stale result.", operationToken: 0 })).toBe(
|
||||
loading,
|
||||
);
|
||||
|
||||
const loaded = transitionSettingsDynamicSectionLifecycle(loading, { type: "loaded", status: "Loaded 1 hook.", operationId: 1 });
|
||||
expect(loaded).toEqual({ kind: "loaded", status: "Loaded 1 hook.", operationId: 1 });
|
||||
const loaded = transitionSettingsDynamicSectionLifecycle(loading, { type: "loaded", status: "Loaded 1 hook.", operationToken: 1 });
|
||||
expect(loaded).toEqual({ kind: "loaded", status: "Loaded 1 hook.", operationToken: 1 });
|
||||
|
||||
const failed = transitionSettingsDynamicSectionLifecycle(loaded, { type: "failed", status: "Could not load hooks.", operationId: 1 });
|
||||
expect(failed).toEqual({ kind: "failed", status: "Could not load hooks.", operationId: 1 });
|
||||
const failed = transitionSettingsDynamicSectionLifecycle(loaded, {
|
||||
type: "failed",
|
||||
status: "Could not load hooks.",
|
||||
operationToken: 1,
|
||||
});
|
||||
expect(failed).toEqual({ kind: "failed", status: "Could not load hooks.", operationToken: 1 });
|
||||
|
||||
const laterLoaded = transitionSettingsDynamicSectionLifecycle(failed, { type: "loaded", status: "Loaded 2 hooks.", operationId: 2 });
|
||||
const laterLoaded = transitionSettingsDynamicSectionLifecycle(failed, { type: "loaded", status: "Loaded 2 hooks.", operationToken: 2 });
|
||||
expect(
|
||||
transitionSettingsDynamicSectionLifecycle(laterLoaded, { type: "started", status: "Loading old hooks...", operationId: 1 }),
|
||||
transitionSettingsDynamicSectionLifecycle(laterLoaded, { type: "started", status: "Loading old hooks...", operationToken: 1 }),
|
||||
).toBe(laterLoaded);
|
||||
expect(transitionSettingsDynamicSectionLifecycle(laterLoaded, { type: "failed", status: "Late old failure.", operationId: 1 })).toBe(
|
||||
expect(transitionSettingsDynamicSectionLifecycle(laterLoaded, { type: "failed", status: "Late old failure.", operationToken: 1 })).toBe(
|
||||
laterLoaded,
|
||||
);
|
||||
|
||||
|
|
|
|||
29
tests/shared/local-id.test.ts
Normal file
29
tests/shared/local-id.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createLocalIdSource } from "../../src/shared/id/local-id";
|
||||
|
||||
describe("local ID source", () => {
|
||||
it("generates monotonic IDs per source with a stable prefix and timestamp", () => {
|
||||
const ids = createLocalIdSource({ nowMs: () => 1234, seed: "test" });
|
||||
const first = ids.next("local-user");
|
||||
const second = ids.next("local-user");
|
||||
const third = ids.next("system");
|
||||
|
||||
expect(first).toMatch(/^local-user-1234-test-[a-z0-9]+-1$/);
|
||||
expect(second).toMatch(/^local-user-1234-test-[a-z0-9]+-2$/);
|
||||
expect(third).toMatch(/^system-1234-test-[a-z0-9]+-3$/);
|
||||
});
|
||||
|
||||
it("keeps sources distinct when they share a timestamp and seed", () => {
|
||||
const first = createLocalIdSource({ nowMs: () => 1234, seed: "test" });
|
||||
const second = createLocalIdSource({ nowMs: () => 1234, seed: "test" });
|
||||
|
||||
expect(first.next("local-user")).not.toBe(second.next("local-user"));
|
||||
});
|
||||
|
||||
it("sanitizes dynamic ID parts", () => {
|
||||
const ids = createLocalIdSource({ nowMs: () => 1234, seed: "test seed!" });
|
||||
|
||||
expect(ids.next("bad prefix!")).toMatch(/^badprefix-1234-testseed-[a-z0-9]+-1$/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue