blob: 48a3b70acd251e92928449feaaacd3c4aadaa620 [file]
import { useMemo, useRef } from 'react';
import type {
DailyReviewSummary,
LlmConnection,
PermissionMode,
SessionStartMode,
SessionSummary,
SettingsSection,
StoredMessage,
ThemePreference,
UiLocale,
} from '@maka/core';
import { formatDailyReviewMarkdown } from '@maka/ui';
import type { NavSelection } from '@maka/ui';
import { buildCommandList, buildSessionCommands } from './command-palette-commands.js';
import type { Command } from './command-palette-types.js';
import { renderConversationMarkdown } from './conversation-markdown.js';
import { dailyReviewActionErrorMessage } from './daily-review-actions.js';
import { commandPaletteActionErrorMessage, commandPaletteConnectionTestFailureMessage } from './app-shell-copy.js';
import { getShellCopy } from './locales/shell-copy.js';
type ToastApi = {
success(title: string, description?: string): void;
info(title: string, description?: string): void;
error(title: string, description?: string): void;
};
type ComposerImportOwner = {
sessionId: string | undefined;
navSection: NavSelection['section'];
};
type RefBox<T> = { current: T };
type ComposerAppendHandle = {
appendText(text: string): void;
};
type DailyReviewBridge = {
fetchDay(offsetDays: number, daySpan?: number): Promise<DailyReviewSummary>;
};
export interface AppShellCommandListOptions {
uiLocale: UiLocale;
activeId: string | undefined;
activePermissionMode: PermissionMode | undefined;
connections: LlmConnection[];
defaultConnection: string | null;
dailyReviewBridge: DailyReviewBridge;
messages: StoredMessage[];
sessions: SessionSummary[];
themePref: ThemePreference;
visibleSessions: SessionSummary[];
captureComposerImportOwner: () => ComposerImportOwner;
closePalette: () => void;
composerRef: RefBox<ComposerAppendHandle | null>;
createSession: () => void;
startModeSession: (mode: SessionStartMode) => Promise<boolean>;
isComposerImportOwnerActive: (owner: ComposerImportOwner) => boolean;
openHelp: () => void;
openPlanReminderForm: () => void;
openProjectFolder: () => Promise<void>;
openSessionInChat: (sessionId: string) => void;
openSettings: () => void;
openSettingsSection: (section: SettingsSection) => void;
openSkillsFolder: () => Promise<void>;
openWorkspaceFolder: () => Promise<void>;
refreshConnections: () => Promise<void>;
saveDailyReviewMarkdown: (input: { markdown: string; label: string; summary: DailyReviewSummary }) => Promise<void>;
setNavSelection: (selection: NavSelection) => void;
setPermissionMode: (mode: PermissionMode) => Promise<void>;
setThemePref: (themePref: ThemePreference) => void;
toastApi: ToastApi;
}
export function buildAppShellCommandList(
optionsRef: RefBox<AppShellCommandListOptions>,
): ReturnType<typeof buildCommandList> {
// #1045: useAppShellCommands freezes this list per palette open/close
// transition. List-SHAPING fields (which rows exist, labels, hints) come
// from the build-time snapshot below; every value a command touches at RUN
// time is dereferenced from the ref inside the callback, so the frozen list
// still acts on current data (same stable-ref pattern as
// openSessionInChatRef in app-shell.tsx).
const options = optionsRef.current;
const copy = getShellCopy(options.uiLocale).commandActions;
return buildCommandList({
locale: options.uiLocale,
activeSessionId: options.activeId,
themePref: options.themePref,
connections: options.connections,
defaultSlug: options.defaultConnection,
onNewChat: () => optionsRef.current.createSession(),
onStartDeepResearch: async () => {
const { startModeSession } = optionsRef.current;
await startModeSession('deep_research');
},
onStartPlanReminder: () => optionsRef.current.openPlanReminderForm(),
onOpenSettings: () => optionsRef.current.openSettings(),
onOpenSettingsSection: (section) => optionsRef.current.openSettingsSection(section),
// PR-UX-POLISH-1 commit 4 (WAWQAQ `e0dbad11` + kenji `2844f64f`):
// use the openHelp callback returned by useKeyboardHelp directly,
// instead of dispatching a synthetic KeyboardEvent. Same effect,
// clearer intent, and avoids the foot-gun where a typed `?` in a
// text input would be swallowed by the global keydown listener.
onOpenShortcuts: () => optionsRef.current.openHelp(),
onSetTheme: (next) => optionsRef.current.setThemePref(next),
onTestConnection: async (slug) => {
const { connections, refreshConnections, toastApi } = optionsRef.current;
try {
const result = await window.maka.connections.test(slug);
const conn = connections.find((c) => c.slug === slug);
const name = conn?.name ?? slug;
if (result.ok) {
toastApi.success(
copy.connectionVerified(name),
copy.connectionLatency(result.latencyMs ?? '?', result.modelTested),
);
} else {
toastApi.error(
copy.connectionTestFailed(name),
commandPaletteConnectionTestFailureMessage(result, options.uiLocale),
);
}
await refreshConnections();
} catch (err) {
toastApi.error(
copy.testErrorTitle,
commandPaletteActionErrorMessage(err, copy.connectionUnavailable, options.uiLocale),
);
}
},
onSetDefaultConnection: async (slug) => {
const { connections, refreshConnections, toastApi } = optionsRef.current;
try {
await window.maka.connections.setDefault(slug);
await refreshConnections();
const conn = connections.find((c) => c.slug === slug);
toastApi.success(copy.setDefaultSuccess(conn?.name ?? slug));
} catch (err) {
toastApi.error(
copy.setDefaultFailedTitle,
commandPaletteActionErrorMessage(err, copy.setDefaultFallback, options.uiLocale),
);
}
},
onOpenWorkspace: async () => {
await optionsRef.current.openWorkspaceFolder();
},
onOpenProjectFolder: () => optionsRef.current.openProjectFolder(),
onOpenSkillsFolder: () => optionsRef.current.openSkillsFolder(),
onSelectModule: (selection) => {
const { closePalette, setNavSelection } = optionsRef.current;
setNavSelection(selection);
closePalette();
},
onExportActiveConversation: async () => {
const { activeId, messages, sessions, toastApi } = optionsRef.current;
if (!activeId) return;
const session = sessions.find((s) => s.id === activeId);
const markdown = renderConversationMarkdown(session?.name ?? copy.newConversation, messages, options.uiLocale);
try {
await navigator.clipboard.writeText(markdown);
toastApi.success(copy.conversationCopiedTitle, copy.lineCount(markdown.split('\n').length));
} catch {
toastApi.error(copy.copyFailedTitle, copy.clipboardUnavailable);
}
},
onSaveActiveConversationToFile: async () => {
const { activeId, messages, sessions, toastApi } = optionsRef.current;
if (!activeId) return;
const session = sessions.find((s) => s.id === activeId);
const sessionName = session?.name ?? copy.newConversation;
const markdown = renderConversationMarkdown(sessionName, messages, options.uiLocale);
const now = new Date();
const yyyy = now.getFullYear();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
// Make the filename mostly portable: collapse whitespace
// and quote chars that some file pickers don't like.
const sanitizedSession = sessionName
.replace(/[\s ]+/g, '-')
.replace(/["<>:|?*]/g, '')
.slice(0, 80);
const defaultName = `maka-${sanitizedSession}-${yyyy}-${mm}-${dd}.md`;
try {
const result = await window.maka.sessions.saveConversationToFile({
markdown,
defaultName,
});
if (result.ok) {
toastApi.success(copy.conversationSavedTitle, copy.saveSummary(markdown.split('\n').length, defaultName));
} else if (result.reason === 'canceled') {
// User dismissed the dialog — no toast.
} else if (result.reason === 'invalid_input') {
toastApi.error(copy.saveFailedTitle, copy.invalidExport);
} else {
toastApi.error(copy.saveFailedTitle, copy.writeFailed);
}
} catch (err) {
toastApi.error(
copy.saveFailedTitle,
commandPaletteActionErrorMessage(err, copy.exportFallback, options.uiLocale),
);
}
},
onOpenLocalMemoryFile: async () => {
const { toastApi } = optionsRef.current;
try {
const result = await window.maka.memory.openFile();
if (!result.ok) {
toastApi.error(copy.memoryOpenFailedTitle, result.message);
}
} catch (err) {
toastApi.error(
copy.openFailedTitle,
commandPaletteActionErrorMessage(err, copy.memoryOpenFallback, options.uiLocale),
);
}
},
onOpenWorkspaceInstructionsFile: async () => {
const { toastApi } = optionsRef.current;
try {
// PR-CMD-PALETTE-OPEN-WORKSPACE-INSTRUCTIONS-0: open the
// first available workspace instruction file. If none are
// available, surface a hint so the user knows where to
// create one rather than getting a silent no-op.
const state = await window.maka.workspaceInstructions.getState();
const available = state.files.find((f) => f.status === 'available');
if (!available) {
toastApi.info(copy.instructionsMissingTitle, copy.instructionsMissingDescription);
return;
}
const result = await window.maka.workspaceInstructions.openFile(available.file);
if (!result.ok) {
toastApi.error(copy.fileOpenFailed(available.file), result.message);
}
} catch (err) {
toastApi.error(
copy.openFailedTitle,
commandPaletteActionErrorMessage(err, copy.instructionsOpenFallback, options.uiLocale),
);
}
},
onSetPermissionMode: (mode) => optionsRef.current.setPermissionMode(mode),
activePermissionMode: options.activePermissionMode,
onCopyTodayDailyReview: async () => {
const { dailyReviewBridge, toastApi } = optionsRef.current;
try {
const summary = await dailyReviewBridge.fetchDay(0, 1);
const markdown = formatDailyReviewMarkdown(summary, copy.today, options.uiLocale);
await navigator.clipboard.writeText(markdown);
toastApi.success(
copy.reviewCopiedTitle,
copy.reviewSummary(summary.totals.sessionCount, summary.totals.requestCount),
);
} catch (err) {
toastApi.error(
copy.copyFailedTitle,
dailyReviewActionErrorMessage(err, copy.reviewCopyFallback, options.uiLocale),
);
}
},
onPasteTodayDailyReviewIntoComposer: async () => {
const { captureComposerImportOwner, composerRef, dailyReviewBridge, isComposerImportOwnerActive, toastApi } =
optionsRef.current;
const owner = captureComposerImportOwner();
if (!owner.sessionId) return;
try {
const summary = await dailyReviewBridge.fetchDay(0, 1);
const markdown = formatDailyReviewMarkdown(summary, copy.today, options.uiLocale);
if (!isComposerImportOwnerActive(owner)) return;
composerRef.current?.appendText(markdown);
toastApi.success(
copy.reviewPastedTitle,
copy.reviewSummary(summary.totals.sessionCount, summary.totals.requestCount),
);
} catch (err) {
if (isComposerImportOwnerActive(owner)) {
toastApi.error(
copy.pasteFailedTitle,
dailyReviewActionErrorMessage(err, copy.reviewUnavailable, options.uiLocale),
);
}
}
},
onSaveTodayDailyReviewToFile: async () => {
const { dailyReviewBridge, saveDailyReviewMarkdown, toastApi } = optionsRef.current;
try {
const summary = await dailyReviewBridge.fetchDay(0, 1);
const markdown = formatDailyReviewMarkdown(summary, copy.today, options.uiLocale);
await saveDailyReviewMarkdown({ markdown, label: copy.today, summary });
} catch (err) {
toastApi.error(
copy.saveFailedTitle,
dailyReviewActionErrorMessage(err, copy.reviewUnavailable, options.uiLocale),
);
}
},
onCopyEnvSummary: async () => {
const { toastApi } = optionsRef.current;
try {
const info = await window.maka.app.info();
const platformPretty =
info.platform === 'darwin'
? 'macOS'
: info.platform === 'win32'
? 'Windows'
: info.platform === 'linux'
? 'Linux'
: info.platform;
const buildLine =
info.buildMode === 'dev'
? `- Build: dev${info.buildCommit ? ` @ ${info.buildCommit}` : ''}`
: '- Build: packaged';
const summary = [
`**Maka** v${info.appVersion}`,
``,
`- Electron: ${info.electronVersion}`,
`- Node: ${info.nodeVersion}`,
`- Chrome: ${info.chromeVersion}`,
`- Platform: ${platformPretty} ${info.osRelease}`,
`- Arch: ${info.arch}`,
buildLine,
].join('\n');
await navigator.clipboard.writeText(summary);
toastApi.success(copy.environmentCopiedTitle, `Maka v${info.appVersion} · ${platformPretty} · ${info.arch}`);
} catch (err) {
toastApi.error(
copy.copyFailedTitle,
commandPaletteActionErrorMessage(err, copy.clipboardDenied, options.uiLocale),
);
}
},
onTestNetworkProxy: async () => {
const { toastApi } = optionsRef.current;
try {
// PR-CMD-PALETTE-NETWORK-PROXY-TEST-0: surface the
// proxy test result via toast so a user debugging a
// connection issue does not need to open Settings →
// 网络. `testNetworkProxy(undefined)` uses the
// current persisted proxy config.
const result = await window.maka.settings.testNetworkProxy(undefined);
if (result.ok) {
const latency = result.latencyMs ? ` · ${result.latencyMs}ms` : '';
toastApi.success(copy.networkPassedTitle, `${result.message}${latency}`);
} else {
toastApi.error(copy.networkFailedTitle, result.message);
}
} catch (err) {
toastApi.error(
copy.genericTestFailedTitle,
commandPaletteActionErrorMessage(err, copy.networkTestFallback, options.uiLocale),
);
}
},
});
}
export function buildAppShellSessionCommands(
optionsRef: RefBox<AppShellCommandListOptions>,
): ReturnType<typeof buildSessionCommands> {
const options = optionsRef.current;
return buildSessionCommands({
locale: options.uiLocale,
sessions: options.visibleSessions,
activeSessionId: options.activeId,
onSelectSession: (sessionId) => {
optionsRef.current.openSessionInChat(sessionId);
},
});
}
/**
* #1045: the palette's command list keeps a stable identity while it is open.
* app-shell rebuilds commandOptions on every render (streaming ticks
* included), so the base commands are built once per open/close transition —
* their run() closures dereference the latest options through the ref, so the
* frozen list still acts on current data. Session rows are derived separately,
* memoized on the visible session catalog + active session only: background
* session creates/renames stay live while the palette is open, without
* reintroducing per-tick rebuilds (visibleSessions is itself memoized in
* app-shell, so rows rebuild only on real catalog changes).
*/
export function useAppShellCommands(paletteOpen: boolean, commandOptions: AppShellCommandListOptions): Command[] {
const optionsRef = useRef(commandOptions);
optionsRef.current = commandOptions;
const { activeId, uiLocale, visibleSessions } = commandOptions;
const baseCommands = useMemo(() => buildAppShellCommandList(optionsRef), [paletteOpen, uiLocale]);
const sessionCommands = useMemo(
() => buildAppShellSessionCommands(optionsRef),
[paletteOpen, visibleSessions, activeId, uiLocale],
);
return useMemo(() => [...baseCommands, ...sessionCommands], [baseCommands, sessionCommands]);
}