mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
import { type PendingRequestId, pendingRequestDerivedKeyPrefix } from "../../../../domain/pending-requests/model";
|
|
import type { ThreadGoal } from "../../../../domain/threads/goal";
|
|
import {
|
|
type ThreadRenameActiveState,
|
|
type ThreadRenameLifecycleEvent,
|
|
type ThreadRenameLifecycleState,
|
|
threadRenameGenerationStillActive,
|
|
transitionThreadRenameLifecycleState,
|
|
} from "../../../../domain/threads/rename-lifecycle";
|
|
import type { DisclosureSetAction } from "./actions";
|
|
import { patchObject } from "./patch";
|
|
|
|
export type ChatRenameUiState = { readonly kind: "idle" } | (ThreadRenameActiveState & { readonly threadId: string });
|
|
|
|
export type ChatRenameGeneratingUiState = Extract<ChatRenameUiState, { kind: "generating" }>;
|
|
type ChatRenameUiAction = Extract<
|
|
UiAction,
|
|
{
|
|
type:
|
|
| "ui/rename-started"
|
|
| "ui/rename-draft-updated"
|
|
| "ui/rename-cancelled"
|
|
| "ui/rename-generation-started"
|
|
| "ui/rename-generation-succeeded"
|
|
| "ui/rename-generation-finished"
|
|
| "ui/rename-cleared";
|
|
}
|
|
>;
|
|
|
|
type ChatGoalEditorUiState =
|
|
| { readonly kind: "closed" }
|
|
| {
|
|
readonly kind: "editing";
|
|
readonly threadId: string | null;
|
|
readonly objectiveDraft: string;
|
|
readonly tokenBudgetDraft: number | null;
|
|
};
|
|
|
|
interface ChatThreadStreamActionMenuUiState {
|
|
readonly forkMenuItemId: string | null;
|
|
}
|
|
|
|
const CHAT_DISCLOSURE_BUCKETS = [
|
|
"details",
|
|
"activityGroups",
|
|
"textDetails",
|
|
"userDialogueExpanded",
|
|
"goalObjectiveExpanded",
|
|
"approvalDetails",
|
|
] as const;
|
|
|
|
type ChatDisclosureBucket = (typeof CHAT_DISCLOSURE_BUCKETS)[number];
|
|
|
|
type ChatDisclosureUiState = Readonly<Record<ChatDisclosureBucket, ReadonlySet<string>>>;
|
|
|
|
export interface ChatUiState {
|
|
readonly toolbarPanel: "history" | "chat-actions" | "status-panel" | null;
|
|
readonly archiveConfirmThreadId: string | null;
|
|
readonly rename: ChatRenameUiState;
|
|
readonly goalEditor: ChatGoalEditorUiState;
|
|
readonly threadStreamActionMenu: ChatThreadStreamActionMenuUiState;
|
|
readonly disclosures: ChatDisclosureUiState;
|
|
}
|
|
|
|
export type UiAction =
|
|
| {
|
|
type: "ui/panel-set";
|
|
panel: "history" | "chat-actions" | "status-panel" | null;
|
|
toggle?: boolean;
|
|
}
|
|
| { type: "ui/archive-confirm-set"; threadId: string | null }
|
|
| { 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; generationToken: number }
|
|
| { type: "ui/rename-generation-succeeded"; threadId: string; generationToken: number; draft: string }
|
|
| { type: "ui/rename-generation-finished"; threadId: string; generationToken: number }
|
|
| { type: "ui/rename-cleared" }
|
|
| { type: "ui/goal-editor-started"; threadId: string | null; objective: string; tokenBudget: number | null }
|
|
| { type: "ui/goal-editor-draft-updated"; objective: string }
|
|
| { type: "ui/goal-editor-closed" }
|
|
| { type: "ui/thread-stream-fork-menu-set"; itemId: string | null }
|
|
| DisclosureSetAction;
|
|
|
|
export function initialUiState(): ChatUiState {
|
|
return {
|
|
toolbarPanel: null,
|
|
archiveConfirmThreadId: null,
|
|
rename: initialRenameUiState(),
|
|
goalEditor: initialGoalEditorUiState(),
|
|
threadStreamActionMenu: initialThreadStreamActionMenuUiState(),
|
|
disclosures: initialDisclosureUiState(),
|
|
};
|
|
}
|
|
|
|
export function isUiAction(action: { type: string }): action is UiAction {
|
|
switch (action.type) {
|
|
case "ui/panel-set":
|
|
case "ui/archive-confirm-set":
|
|
case "ui/rename-started":
|
|
case "ui/rename-draft-updated":
|
|
case "ui/rename-cancelled":
|
|
case "ui/rename-generation-started":
|
|
case "ui/rename-generation-succeeded":
|
|
case "ui/rename-generation-finished":
|
|
case "ui/rename-cleared":
|
|
case "ui/goal-editor-started":
|
|
case "ui/goal-editor-draft-updated":
|
|
case "ui/goal-editor-closed":
|
|
case "ui/thread-stream-fork-menu-set":
|
|
case "ui/disclosure-set":
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function reduceUiSlice(state: ChatUiState, action: UiAction): ChatUiState {
|
|
switch (action.type) {
|
|
case "ui/panel-set":
|
|
return setPanelSlice(state, action.panel, action.toggle ?? false);
|
|
case "ui/archive-confirm-set":
|
|
return patchObject(state, { archiveConfirmThreadId: action.threadId });
|
|
case "ui/rename-started":
|
|
case "ui/rename-draft-updated":
|
|
case "ui/rename-cancelled":
|
|
case "ui/rename-generation-started":
|
|
case "ui/rename-generation-succeeded":
|
|
case "ui/rename-generation-finished":
|
|
case "ui/rename-cleared":
|
|
return patchObject(state, { rename: transitionChatRenameUiState(state.rename, action) });
|
|
case "ui/goal-editor-started":
|
|
return patchObject(state, {
|
|
goalEditor: {
|
|
kind: "editing",
|
|
threadId: action.threadId,
|
|
objectiveDraft: action.objective,
|
|
tokenBudgetDraft: action.tokenBudget,
|
|
},
|
|
});
|
|
case "ui/goal-editor-draft-updated":
|
|
return patchObject(state, { goalEditor: goalEditorDraftUpdated(state.goalEditor, action.objective) });
|
|
case "ui/goal-editor-closed":
|
|
return patchObject(state, { goalEditor: initialGoalEditorUiState() });
|
|
case "ui/thread-stream-fork-menu-set":
|
|
return patchObject(state, { threadStreamActionMenu: { forkMenuItemId: action.itemId } });
|
|
case "ui/disclosure-set":
|
|
return setDisclosureSlice(state, action.bucket, action.id, action.open);
|
|
}
|
|
}
|
|
|
|
export function maybeClearGoalObjectiveExpansion(
|
|
state: ChatUiState,
|
|
currentGoal: ThreadGoal | null,
|
|
nextGoal: ThreadGoal | null,
|
|
): ChatUiState {
|
|
if (goalObjectiveResetKey(currentGoal) === goalObjectiveResetKey(nextGoal)) return state;
|
|
if (state.disclosures.goalObjectiveExpanded.size === 0) return state;
|
|
return patchObject(state, {
|
|
disclosures: {
|
|
...state.disclosures,
|
|
goalObjectiveExpanded: new Set(),
|
|
},
|
|
});
|
|
}
|
|
|
|
export function renameGenerationStillActive(
|
|
state: ChatRenameUiState,
|
|
threadId: string,
|
|
generationToken: number,
|
|
): state is ChatRenameGeneratingUiState {
|
|
return state.kind === "generating" && state.threadId === threadId && threadRenameGenerationStillActive(state, generationToken);
|
|
}
|
|
|
|
export function clearAllRequestDisclosures(state: ChatUiState): ChatUiState {
|
|
if (state.disclosures.approvalDetails.size === 0) return state;
|
|
return patchObject(state, {
|
|
disclosures: {
|
|
...state.disclosures,
|
|
approvalDetails: new Set(),
|
|
},
|
|
});
|
|
}
|
|
|
|
export function clearResolvedRequestDisclosures(state: ChatUiState, requestId: PendingRequestId): ChatUiState {
|
|
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: {
|
|
...state.disclosures,
|
|
approvalDetails,
|
|
},
|
|
});
|
|
}
|
|
|
|
function setPanelSlice(state: ChatUiState, panel: "history" | "chat-actions" | "status-panel" | null, toggle: boolean): ChatUiState {
|
|
const nextPanel = toggle && state.toolbarPanel === panel ? null : panel;
|
|
return patchObject(state, { toolbarPanel: nextPanel });
|
|
}
|
|
|
|
function setDisclosureSlice(state: ChatUiState, bucket: ChatDisclosureBucket, id: string, open: boolean): ChatUiState {
|
|
const current = state.disclosures[bucket];
|
|
if (current.has(id) === open) return state;
|
|
const nextBucket = new Set(current);
|
|
if (open) {
|
|
nextBucket.add(id);
|
|
} else {
|
|
nextBucket.delete(id);
|
|
}
|
|
return patchObject(state, {
|
|
disclosures: {
|
|
...state.disclosures,
|
|
[bucket]: nextBucket,
|
|
},
|
|
});
|
|
}
|
|
|
|
function initialRenameUiState(): ChatRenameUiState {
|
|
return { kind: "idle" };
|
|
}
|
|
|
|
function initialGoalEditorUiState(): ChatGoalEditorUiState {
|
|
return { kind: "closed" };
|
|
}
|
|
|
|
function initialThreadStreamActionMenuUiState(): ChatThreadStreamActionMenuUiState {
|
|
return { forkMenuItemId: null };
|
|
}
|
|
|
|
function initialDisclosureUiState(): ChatDisclosureUiState {
|
|
return disclosureUiStateFrom(() => new Set());
|
|
}
|
|
|
|
function disclosureUiStateFrom(factory: (bucket: ChatDisclosureBucket) => ReadonlySet<string>): ChatDisclosureUiState {
|
|
const disclosures = {} as Record<ChatDisclosureBucket, ReadonlySet<string>>;
|
|
for (const bucket of CHAT_DISCLOSURE_BUCKETS) {
|
|
disclosures[bucket] = factory(bucket);
|
|
}
|
|
return disclosures;
|
|
}
|
|
|
|
function goalObjectiveResetKey(goal: ThreadGoal | null): string {
|
|
if (!goal) return "";
|
|
return [goal.threadId, goal.objective, goal.status, String(goal.tokenBudget ?? "")].join("\u0000");
|
|
}
|
|
|
|
function goalEditorDraftUpdated(state: ChatGoalEditorUiState, objective: string): ChatGoalEditorUiState {
|
|
if (state.kind !== "editing") return state;
|
|
return { ...state, objectiveDraft: objective };
|
|
}
|
|
|
|
function transitionChatRenameUiState(state: ChatRenameUiState, action: ChatRenameUiAction): ChatRenameUiState {
|
|
switch (action.type) {
|
|
case "ui/rename-started":
|
|
return { kind: "editing", threadId: action.threadId, draft: action.draft };
|
|
case "ui/rename-draft-updated":
|
|
return transitionScopedChatRenameUiState(state, action.threadId, { type: "draft-updated", draft: action.draft });
|
|
case "ui/rename-cancelled":
|
|
return transitionScopedChatRenameUiState(state, action.threadId, { type: "cancelled" });
|
|
case "ui/rename-generation-started":
|
|
return transitionScopedChatRenameUiState(state, action.threadId, {
|
|
type: "generation-started",
|
|
generationToken: action.generationToken,
|
|
});
|
|
case "ui/rename-generation-succeeded":
|
|
return transitionScopedChatRenameUiState(state, action.threadId, {
|
|
type: "generation-succeeded",
|
|
generationToken: action.generationToken,
|
|
draft: action.draft,
|
|
});
|
|
case "ui/rename-generation-finished":
|
|
return transitionScopedChatRenameUiState(state, action.threadId, {
|
|
type: "generation-finished",
|
|
generationToken: action.generationToken,
|
|
});
|
|
case "ui/rename-cleared":
|
|
return chatRenameUiStateFromThreadRenameState(
|
|
state.kind === "idle" ? null : state.threadId,
|
|
transitionThreadRenameLifecycleState(chatRenameLifecycleStateWithoutThreadId(state), { type: "cleared" }),
|
|
);
|
|
default:
|
|
return unhandledChatRenameUiAction(action);
|
|
}
|
|
}
|
|
|
|
function transitionScopedChatRenameUiState(
|
|
state: ChatRenameUiState,
|
|
threadId: string,
|
|
event: ThreadRenameLifecycleEvent,
|
|
): ChatRenameUiState {
|
|
if (state.kind === "idle" || state.threadId !== threadId) return state;
|
|
const lifecycleState = chatRenameLifecycleStateWithoutThreadId(state);
|
|
const nextLifecycleState = transitionThreadRenameLifecycleState(lifecycleState, event);
|
|
if (nextLifecycleState === lifecycleState) return state;
|
|
return chatRenameUiStateFromThreadRenameState(threadId, nextLifecycleState);
|
|
}
|
|
|
|
function chatRenameLifecycleStateWithoutThreadId(state: ChatRenameUiState): ThreadRenameLifecycleState {
|
|
if (state.kind === "idle") return state;
|
|
return chatRenameActiveStateWithoutThreadId(state);
|
|
}
|
|
|
|
function chatRenameActiveStateWithoutThreadId(state: Exclude<ChatRenameUiState, { kind: "idle" }>): ThreadRenameActiveState {
|
|
switch (state.kind) {
|
|
case "editing":
|
|
return { kind: "editing", draft: state.draft };
|
|
case "generating":
|
|
return {
|
|
kind: "generating",
|
|
draft: state.draft,
|
|
generationToken: state.generationToken,
|
|
};
|
|
}
|
|
}
|
|
|
|
function chatRenameUiStateFromThreadRenameState(threadId: string | null, state: ThreadRenameLifecycleState): ChatRenameUiState {
|
|
if (state.kind === "idle") return state;
|
|
if (threadId === null) return initialRenameUiState();
|
|
return { ...state, threadId };
|
|
}
|
|
|
|
function unhandledChatRenameUiAction(action: never): never {
|
|
throw new Error(`Unhandled chat rename UI action: ${String(action)}`);
|
|
}
|
|
|
|
function filterStringSet(values: ReadonlySet<string>, keep: (value: string) => boolean): ReadonlySet<string> {
|
|
let next: Set<string> | null = null;
|
|
for (const value of values) {
|
|
if (keep(value)) {
|
|
next?.add(value);
|
|
} else if (next === null) {
|
|
next = new Set();
|
|
for (const kept of values) {
|
|
if (kept === value) break;
|
|
next.add(kept);
|
|
}
|
|
}
|
|
}
|
|
return next ?? values;
|
|
}
|