Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@
"TaskReadinessProvider",
"createComposerStagingCommands",
"createComposerSubmissionCommands",
"createComposerSurfaceOwner",
"createExecutorSessionActivator",
"resolveTaskReadinessModelTarget",
"useActiveExecutionBoundary",
Expand Down Expand Up @@ -467,6 +468,8 @@
"src/renderer/features/session-settings/index.ts": {
"appShell": [
"SessionSettingsProvider",
"createSessionModeCommands",
"deriveSessionSettingsPresentation",
"useSessionSettingIntent"
],
"composition": [
Expand Down Expand Up @@ -743,7 +746,7 @@
"react": 1
},
"importSpecifiers": 43,
"nonTriviaTokens": 7276
"nonTriviaTokens": 6843
},
"src/renderer/use-app-shell-session-list.ts": {
"importDeclarations": 3,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -537,12 +537,14 @@ describe('useSessionNavigationReads', () => {
const { root } = installReactRenderer();
const catalog = createSessionCatalogController();
catalog.commitSessions(linkedCatalog);
const opened: string[] = [];
const openSession = (sessionId: string) => { opened.push(sessionId); };
await act(async () =>
root.render(
navigationTree(
catalog,
{ activeSessionId: 'child', workHubActive: false },
createElement(ReadsProbe, { catalog, activeSessionId: 'child' }),
createElement(ReadsProbe, { catalog, activeSessionId: 'child', openSession }),
createElement(RailProbe),
),
),
Expand All @@ -555,19 +557,23 @@ describe('useSessionNavigationReads', () => {
['root', 'remote', 'environment'],
);
assert.equal(latestRail.activeId, 'root');
assert.equal(latestReads.activeParentSession?.id, 'root');
assert.equal(latestReads.titlebarParentSession?.name, linkedCatalog.find(row => row.id === 'root')?.name);
latestReads.titlebarParentSession?.onOpen();
assert.equal(opened.at(-1), 'root');

await act(async () =>
root.render(
navigationTree(
catalog,
{ activeSessionId: 'side-conversation', workHubActive: false },
createElement(ReadsProbe, { catalog, activeSessionId: 'side-conversation' }),
createElement(ReadsProbe, { catalog, activeSessionId: 'side-conversation', openSession }),
null,
),
),
);
assert.equal(latestReads.activeParentSession?.id, 'root');
assert.equal(latestReads.titlebarParentSession?.name, linkedCatalog.find(row => row.id === 'root')?.name);
latestReads.titlebarParentSession?.onOpen();
assert.equal(opened.at(-1), 'root');
});
});

Expand Down
136 changes: 19 additions & 117 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import {
type SetStateAction,
} from 'react';
import type { QuoteRef } from '@maka/core/events';
import type { OrchestrationMode } from '@maka/core/orchestration';
import type { UiLocale, UiLocalePreference } from '@maka/core/ui-locale';
import { collapseSessionRevisions } from '@maka/core/session-revisions';
import { isLinkedSubagentSession } from '@maka/core/session';
Expand Down Expand Up @@ -97,7 +96,7 @@ import * as Diagnostics from './features/diagnostics/index.js';
import { getDesktopConversationCopy } from './application/contracts/conversation-copy';
import { ErrorBoundary } from './error-boundary';
import { useShellAppearance } from './use-shell-appearance';
import { SessionSettingsProvider, useSessionSettingIntent } from './features/session-settings';
import { SessionSettingsProvider, useSessionSettingIntent, createSessionModeCommands, deriveSessionSettingsPresentation } from './features/session-settings';
import { pendingSessionView } from './pending-session-view';
import { readScrollMotionBehavior } from './scroll-motion-policy';
import { readNavigationState, selectNavigation } from './nav-selection';
Expand Down Expand Up @@ -136,12 +135,6 @@ import { useShellConnections } from './use-shell-connections';
import { useSystemUiLocale } from './use-system-ui-locale';
import { AppShell as AstryxAppShell } from '@astryxdesign/core/AppShell';

type ComposerImportOwner = {
sessionId: string | undefined;
navSection: NavSelection['section'];
newTaskDraftKey?: string;
};

export function AppShell() {
const [uiLocalePreference, setUiLocalePreference] = useState<UiLocalePreference>('auto');
const [uiLocaleOverride, setUiLocaleOverride] = useState<UiLocale | null>(null);
Expand Down Expand Up @@ -423,20 +416,8 @@ function AppShellContent({
const composerEditing = queueSurface.editing;
const rendererMountedRef = useRef(true);
const { setPermissionMode, setSessionModel, setSessionThinkingLevel, setSessionExecutor } = sessionSettingIntent.commands;
const modelConfigurationOverlay = sessionSettingIntent.overlay.modelConfiguration;
const activeSessionForModelControls = activeSession
? {
...activeSession,
...(modelConfigurationOverlay
? {
llmConnectionId: modelConfigurationOverlay.modelTarget.llmConnectionId,
llmConnectionSlug: modelConfigurationOverlay.modelTarget.llmConnectionSlug,
model: modelConfigurationOverlay.modelTarget.model,
thinkingLevel: modelConfigurationOverlay.thinkingLevel ?? undefined,
}
: {}),
}
: undefined;
const { activeSessionForModelControls, activePlanMode, activeOrchestrationMode } =
deriveSessionSettingsPresentation(activeSession, activeId, sessionSettingIntent);
// Surface a credential-lifecycle alert directly in the chat header when
// the active session's connection is in `needs_reauth` / `error` or has
// been deleted entirely with no usable default. Main resolves credential
Expand Down Expand Up @@ -517,43 +498,12 @@ function AppShellContent({
sessionSettingIntent.commands.clear(sessionId);
}

// Stable: the rail's row actions are built from it, and it only reaches
// registries and refs that are themselves stable (#4109).
function setPlanMode(active: boolean): Promise<boolean> {
const sessionId = activeIdRef.current;
if (!sessionId) {
sessionSettingIntent.commands.setNewTaskPlanMode(active);
return Promise.resolve(true);
}
if (active === activePlanMode) return Promise.resolve(true);
return sessionSettingIntent.commands.setPlanMode(sessionId, active);
}

/**
* The + menu's orchestration choice and the `/swarm` and `/graph` commands
* all land here, so every entry point spells the field the same way.
*
* `/swarm off` means "leave swarm", not "go to default": a Session already
* in Graph has nothing for it to do.
*/
function setOrchestrationMode(mode: OrchestrationMode): Promise<boolean> {
const sessionId = activeIdRef.current;
if (!sessionId) {
sessionSettingIntent.commands.setNewTaskOrchestrationMode(mode);
return Promise.resolve(true);
}
if (mode === activeOrchestrationMode) return Promise.resolve(true);
return sessionSettingIntent.commands.setOrchestrationMode(sessionId, mode);
}

function setOrchestrationModeActive(
mode: Exclude<OrchestrationMode, 'default'>,
active: boolean,
): Promise<boolean> {
if (active) return setOrchestrationMode(mode);
if (activeOrchestrationMode !== mode) return Promise.resolve(true);
return setOrchestrationMode('default');
}
const { setPlanMode, setOrchestrationMode, setOrchestrationModeActive } = createSessionModeCommands({
sessionId: () => activeIdRef.current,
planMode: () => activePlanMode,
orchestrationMode: () => activeOrchestrationMode,
commands: sessionSettingIntent.commands,
});

const openSessionInChatRef = useRef<
(sessionId: string, turnId?: string, sequence?: number) => void
Expand Down Expand Up @@ -598,18 +548,6 @@ function AppShellContent({
permissionMode: newSessionPermissionMode,
})
: undefined);
// Each control reads its own field. There is nothing to project and nothing
// to keep in sync: a Session in Plan with Swarm as its orchestration default
// says both, because it is both.
const activePlanMode = activeId
? sessionSettingIntent.overlay.planMode
?? ((activeSessionForView?.collaborationMode ?? 'agent') === 'plan')
: newTaskSettings.planMode;
const activeOrchestrationMode: OrchestrationMode = activeId
? sessionSettingIntent.overlay.orchestrationMode
?? activeSessionForView?.orchestrationMode
?? 'default'
: newTaskSettings.orchestrationMode;
const {
boundary: activeExecutionBoundary,
unreadable: activeExecutionBoundaryUnreadable,
Expand Down Expand Up @@ -809,20 +747,14 @@ function AppShellContent({
};
const {
revisionNavigation,
activeParentSession,
titlebarParentSession,
layout: { collapsed: sessionListCollapsed, width: sessionListWidth },
} = useSessionNavigationReads({
catalog: sessionCatalogController,
activeSessionId: activeId,
openSession: openSessionInChat,
});
const sessionSideNavHandleRef = sessionRailLayoutStore.collapseHandleRef;
const titlebarParentSession = useMemo(() => {
if (!activeParentSession) return undefined;
return {
name: activeParentSession.name,
onOpen: () => openSessionInChatRef.current(activeParentSession.id),
};
}, [activeParentSession]);
const archivedTasksBridge = useMemo<ArchivedTasksBridge>(
() => ({
catalog: sessionCatalogController,
Expand Down Expand Up @@ -884,44 +816,14 @@ function AppShellContent({
themePalette,
themePref,
});
function captureComposerImportOwner(): ComposerImportOwner {
return {
sessionId: activeIdRef.current,
navSection: navSelectionRef.current.section,
...(activeIdRef.current === undefined
? { newTaskDraftKey: currentNewTaskDraftKey }
: {}),
};
}

/**
* "Is this owner still the surface the user is looking at." One rule, both
* halves: an async result that lands after the user moved on must not toast,
* navigate or steal focus, and `selectNavigation` never clears `activeId`
* (nav-selection.ts) — so the session id alone answers yes long after the
* user left for 扩展 or 设置.
*
* The two below are this same question with a precondition on what KIND of
* owner the caller wants, not second opinions about the question. They were
* three independent spellings once, and the one that re-derived it from an
* id drifted: it lost the section half, which is exactly what let a failed
* send pull a user out of 技能 and into 设置 · 模型.
*/
function isShellSurfaceOwnerActive(owner: ComposerImportOwner): boolean {
return navSelectionRef.current.section === owner.navSection &&
isSessionSelected(owner.sessionId) &&
(owner.sessionId !== undefined || owner.newTaskDraftKey === currentNewTaskDraftKey);
}

/** …and the owner was captured on the chat surface. */
function isComposerImportOwnerActive(owner: ComposerImportOwner): boolean {
return owner.navSection === 'sessions' && isShellSurfaceOwnerActive(owner);
}

/** …and it was the new-chat surface, which by definition has no session. */
function isNewChatSendSurfaceActive(owner: ComposerImportOwner): boolean {
return owner.sessionId === undefined && isComposerImportOwnerActive(owner);
}
const {
captureComposerImportOwner,
isShellSurfaceOwnerActive,
isComposerImportOwnerActive,
isNewChatSendSurfaceActive,
} = Conversation.createComposerSurfaceOwner({
activeIdRef, navSelectionRef, newTaskDraftKey: currentNewTaskDraftKey, isSessionSelected,
});

async function bootstrapSessions() {
const next = await refreshSessions();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* 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 type { NavSelection } from '@maka/ui';

export interface ComposerSurfaceOwner {
sessionId: string | undefined;
navSection: NavSelection['section'];
newTaskDraftKey?: string;
}

/**
* Async composer work must still belong to the visible session or new-task draft.
* Navigation away from chat keeps the selected session, so the section matters too.
*/
export function createComposerSurfaceOwner(input: {
activeIdRef: { current: string | undefined };
navSelectionRef: { current: NavSelection };
newTaskDraftKey?: string;
isSessionSelected(sessionId: string | undefined): boolean;
}) {
const captureComposerImportOwner = (): ComposerSurfaceOwner => ({
sessionId: input.activeIdRef.current,
navSection: input.navSelectionRef.current.section,
...(input.activeIdRef.current === undefined ? { newTaskDraftKey: input.newTaskDraftKey } : {}),
});
const isShellSurfaceOwnerActive = (owner: ComposerSurfaceOwner) =>
input.navSelectionRef.current.section === owner.navSection &&
input.isSessionSelected(owner.sessionId) &&
(owner.sessionId !== undefined || owner.newTaskDraftKey === input.newTaskDraftKey);
const isComposerImportOwnerActive = (owner: ComposerSurfaceOwner) =>
owner.navSection === 'sessions' && isShellSurfaceOwnerActive(owner);
const isNewChatSendSurfaceActive = (owner: ComposerSurfaceOwner) =>
owner.sessionId === undefined && isComposerImportOwnerActive(owner);
return {
captureComposerImportOwner,
isShellSurfaceOwnerActive,
isComposerImportOwnerActive,
isNewChatSendSurfaceActive,
};
}
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/features/conversation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,5 @@ export { TaskReadinessProvider, TaskReadinessNoticeConsumer } from './ui/task-re
export { createComposerSubmissionCommands } from './controller/composer-submission-commands.js';
export { ComposerSubmissionServicesProvider, type ComposerSubmissionServices } from './submission-services.js';
export { ComposerSubmissionProvider } from './ui/composer-submission-provider.js';

export { createComposerSurfaceOwner } from './controller/composer-surface-owner.js';
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* under the License.
*/

import { useLayoutEffect } from 'react';
import { useLayoutEffect, useMemo } from 'react';
import { useMediaQuery } from '@astryxdesign/core/hooks';
import { useExternalStoreSelector } from '../../../application/contracts/session-catalog/use-external-store-selector.js';
import type { SessionCatalogState } from '../../../application/contracts/session-catalog/session-catalog-state.js';
Expand All @@ -37,8 +37,8 @@ import type { SessionNavigationSession } from '../ports.js';

export interface SessionNavigationReads {
revisionNavigation: SessionRevisionNavigation | undefined;
/** The active Session's parent row, for the titlebar breadcrumb. */
activeParentSession: SessionNavigationSession | undefined;
/** The active Session's parent breadcrumb and navigation action. */
titlebarParentSession: { name: string; onOpen(): void } | undefined;
layout: SessionRailLayoutState;
}

Expand Down Expand Up @@ -87,8 +87,9 @@ function revisionNavigationsEqual(
export function useSessionNavigationReads(input: {
catalog: SessionCatalogController;
activeSessionId: string | undefined;
openSession(sessionId: string): void;
}): SessionNavigationReads {
const { activeSessionId, catalog } = input;
const { activeSessionId, catalog, openSession } = input;
const revisionNavigation = useExternalStoreSelector(
catalog,
selectRevisionNavigation,
Expand All @@ -100,6 +101,10 @@ export function useSessionNavigationReads(input: {
selectActiveParentSession,
activeSessionId,
);
const titlebarParentSession = useMemo(() => activeParentSession ? {
name: activeParentSession.name,
onOpen: () => openSession(activeParentSession.id),
} : undefined, [activeParentSession, openSession]);
/* The one place the rail asks how wide the window is. The store is
module-scoped, so it is told rather than handed a prop; a layout effect
re-renders before paint, so a narrow launch never shows it. */
Expand All @@ -108,5 +113,5 @@ export function useSessionNavigationReads(input: {
sessionRailLayoutStore.setCompact(sidebarCompact);
}, [sidebarCompact]);
const layout = useExternalStoreSelector(sessionRailLayoutStore, selectRailLayout);
return { revisionNavigation, activeParentSession, layout };
return { revisionNavigation, titlebarParentSession, layout };
}
Loading