| 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]); |
| } |