diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 305684c186..65fdc366da 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -891,7 +891,7 @@ "react": 1 }, "importSpecifiers": 148, - "nonTriviaTokens": 15617 + "nonTriviaTokens": 15602 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, diff --git a/apps/desktop/src/main/__tests__/desktop-slash-command.test.ts b/apps/desktop/src/main/__tests__/desktop-slash-command.test.ts new file mode 100644 index 0000000000..403464590f --- /dev/null +++ b/apps/desktop/src/main/__tests__/desktop-slash-command.test.ts @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { slashCommandsForSurface } from '@maka/core/slash-command-catalog'; +import { desktopSlashCommandAvailability } from '../../renderer/desktop-slash-command.js'; + +const offered = (state: { hasSession: boolean; streaming: boolean }): readonly string[] => + slashCommandsForSurface('desktop') + .filter(desktopSlashCommandAvailability(state)) + .map(({ id }) => id); + +describe('desktop slash command availability', () => { + it('withholds /compact while the Turn streams, and nothing else', () => { + const idle = offered({ hasSession: true, streaming: false }); + assert.ok(idle.includes('compact'), 'an idle Session can compact its context'); + assert.deepEqual( + offered({ hasSession: true, streaming: true }), + idle.filter((id) => id !== 'compact'), + ); + }); + + it('offers only the commands that need no Session before one exists', () => { + // Spelled out rather than derived from the catalog: a new Desktop command + // reaching an empty composer is a decision, not a default. + assert.deepEqual(offered({ hasSession: false, streaming: false }), ['graph', 'swarm']); + assert.deepEqual(offered({ hasSession: false, streaming: true }), ['graph', 'swarm']); + }); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index e3ea229f2c..3e17e55038 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -65,7 +65,6 @@ import { activeInteractionFor, deriveComposerModelSwitchAvailability, deriveTitlebarProjectName, - enqueueInteraction, reconcileInteractions, } from '@maka/ui'; import type { ConnectionEvent } from '@maka/core/connections'; @@ -106,7 +105,10 @@ import { useNewTaskChoice } from './use-new-task-choice'; import { SessionCollaborationDialog } from './session-collaboration-dialog'; import * as SessionCollaboration from './features/session-collaboration'; import { NEW_TASK_PENDING_KEY } from './pending-items'; -import { parseDesktopSlashCommand } from './desktop-slash-command'; +import { + desktopSlashCommandAvailability, + parseDesktopSlashCommand, +} from './desktop-slash-command'; import { hasActiveTurnAtSubmit, mergeWorkspaceReferences, @@ -1324,11 +1326,11 @@ function AppShellContent({ : undefined; const desktopSlashCommands = useMemo( () => { - const streaming = turnActive || activeStreamingLive; const availableCommands = slashCommandsForSurface('desktop').filter( - ({ id, session }) => - (session === 'none' || Boolean(activeId)) - && !(streaming && id === 'compact'), + desktopSlashCommandAvailability({ + hasSession: Boolean(activeId), + streaming: turnActive || activeStreamingLive, + }), ); const presentation: Record< SlashCommandIdForSurface<'desktop'>, diff --git a/apps/desktop/src/renderer/desktop-slash-command.ts b/apps/desktop/src/renderer/desktop-slash-command.ts index fa7936b0cb..30c43593ba 100644 --- a/apps/desktop/src/renderer/desktop-slash-command.ts +++ b/apps/desktop/src/renderer/desktop-slash-command.ts @@ -18,6 +18,7 @@ */ import { parseGraphCommand, type ParsedGraphCommand } from '@maka/core/graph-command'; +import type { SlashCommandSpec } from '@maka/core/slash-command-catalog'; import { parseSwarmCommand, type ParsedSwarmCommand } from '@maka/core/swarm-command'; import { parseSideChatCommand, type SideChatCommand } from './side-chat-command.js'; @@ -37,3 +38,17 @@ export function parseDesktopSlashCommand(input: string): DesktopSlashCommand | n const swarm = parseSwarmCommand(input); return swarm ? { kind: 'swarm', command: swarm } : null; } + +/** + * Which catalog commands the Desktop composer offers in the state it is in. + * `/compact` rewrites the context the running Turn is still reading from, so it + * is withheld while a stream is live; every other command only needs a Session + * to act on. + */ +export function desktopSlashCommandAvailability(state: { + hasSession: boolean; + streaming: boolean; +}): (command: Pick) => boolean { + return ({ id, session }) => + (session === 'none' || state.hasSession) && !(state.streaming && id === 'compact'); +}