= {
OSAgent: 'os-agent',
- Runno: 'runno',
- AppBuilder: 'app-builder',
+};
+const AGENT_TYPE_BY_SLOT: Record
= {
+ runno: 'Runno',
+ 'app-builder': 'AppBuilder',
};
type NewWorkStartMode = 'manual' | 'agentic-os';
@@ -58,55 +58,45 @@ export type NewWorkClassifyKind = Extract<
export type NewWorkAgentChoice =
| 'OSAgent'
- | 'Runno'
- | 'AppBuilder'
+ | `app-slot:${string}`
| (string & {});
export interface NewWorkDialogProps {
open: boolean;
onClose: () => void;
initialAgentChoice?: NewWorkAgentChoice;
- initialScopeRequirement?: ProductAppLaunchScopeRequirement;
}
-export function productAppWorkChoice(appId: string): NewWorkAgentChoice {
- return `${PRODUCT_APP_CHOICE_PREFIX}${appId}` as NewWorkAgentChoice;
+function appSlotWorkChoice(slotId: string): NewWorkAgentChoice {
+ return `${INTELLIGENT_APP_CHOICE_PREFIX}${slotId}`;
+}
+
+// App Center keeps its original dialog/launcher interaction while the value now
+// identifies a lifecycle slot instead of a mutable package installation.
+export function productAppWorkChoice(slotId: string): NewWorkAgentChoice {
+ return appSlotWorkChoice(slotId);
}
-function parseProductAppWorkChoice(agentChoice: NewWorkAgentChoice): string | null {
+function parseAppSlotWorkChoice(agentChoice: NewWorkAgentChoice): string | null {
const raw = String(agentChoice);
- if (!raw.startsWith(PRODUCT_APP_CHOICE_PREFIX)) return null;
- const appId = raw.slice(PRODUCT_APP_CHOICE_PREFIX.length).trim();
- return appId || null;
+ if (!raw.startsWith(INTELLIGENT_APP_CHOICE_PREFIX)) return null;
+ return raw.slice(INTELLIGENT_APP_CHOICE_PREFIX.length).trim() || null;
}
function normalizeChoiceForAvailableApps(
agentChoice: NewWorkAgentChoice | null | undefined,
- apps: ProductAppCatalogEntry[],
- knownBuiltinChoices: Set,
+ availableChoices: Set,
): NewWorkAgentChoice | null {
if (!agentChoice) return null;
- const productAppId = parseProductAppWorkChoice(agentChoice);
- if (productAppId) {
- return apps.some((app) => app.id === productAppId) ? agentChoice : null;
- }
-
- return knownBuiltinChoices.has(String(agentChoice)) ? agentChoice : null;
+ return availableChoices.has(String(agentChoice)) ? agentChoice : null;
}
function labelForChoice(agentChoice: NewWorkAgentChoice): string {
- const productAppId = parseProductAppWorkChoice(agentChoice);
- if (productAppId) {
- return `${productAppId} Work`;
- }
-
+ const slotId = parseAppSlotWorkChoice(agentChoice);
+ if (slotId) return `${slotId} Work`;
switch (agentChoice) {
case 'OSAgent':
return 'OS Work';
- case 'Runno':
- return 'Runno Work';
- case 'AppBuilder':
- return 'App Builder Work';
default:
return `${agentChoice} Work`;
}
@@ -167,9 +157,9 @@ export async function launchWorkForChoice(params: {
title?: string;
objective?: string;
titleState?: WorkTitleState | null;
- appRef?: WorkAppRef;
- workResolutionMode?: ProductAppWorkResolutionMode;
classifyKind?: NewWorkClassifyKind;
+ appRef?: WorkAppRef;
+ workResolutionMode?: string;
}): Promise {
const {
agentChoice,
@@ -178,92 +168,16 @@ export async function launchWorkForChoice(params: {
title,
objective,
titleState,
- appRef,
- workResolutionMode,
classifyKind = 'multi_step',
} = params;
- const productAppId = parseProductAppWorkChoice(agentChoice);
- let resolvedAgentChoice = agentChoice;
- let resolvedAppRef = appRef;
- let resolvedTitle = title?.trim();
- let resolvedObjective = objective?.trim();
- let resolvedWorkScope = workspace
+ const resolvedAgentChoice = agentChoice;
+ const nativeAppId = NATIVE_AGENT_APP_IDS[String(agentChoice)];
+ const resolvedAppRef = params.appRef ?? (nativeAppId ? nativeAppWorkRef(nativeAppId) : undefined);
+ const resolvedTitle = title?.trim();
+ const resolvedObjective = objective?.trim();
+ const resolvedWorkScope = workspace
? { kind: 'workspace' as const, workspacePath: workspace.rootPath }
: { kind: 'system' as const };
- let resolvedAppWorkMode: ProductAppWorkResolutionMode = workResolutionMode ?? 'createNewWork';
- const nativeAppId = NATIVE_AGENT_APP_IDS[String(agentChoice)];
- if (!productAppId && nativeAppId) {
- resolvedAppRef = nativeAppWorkRef(nativeAppId);
- }
-
- if (productAppId) {
- const productApp = await appCatalogAPI.getProductApp(productAppId);
- const productAppRef = productAppWorkRef(productApp);
- const launch = productApp.launch;
- resolvedAppRef = productAppRef;
- resolvedTitle = resolvedTitle || productApp.name;
- resolvedObjective = resolvedObjective || productApp.description || productApp.name;
-
- if (!launch) {
- throw new Error(`Product App ${productApp.name} has no launch target.`);
- }
- resolvedWorkScope = resolveProductAppWorkScope(productApp, workspace);
- const launchBehavior = getProductAppLaunchBehavior(productApp);
- resolvedAppWorkMode = launchBehavior.workResolutionMode;
-
- if (launch.kind === 'applicationSurface') {
- const targetProductAppId = productApp.id;
- const appRefs = [
- { app: productAppRef, role: 'executor' as const },
- ];
- const assignment = {
- kind: 'application' as const,
- applicationId: targetProductAppId,
- };
- const workStore = useWorkStore.getState();
-
- const work = launchBehavior.workResolutionMode === 'createNewWork'
- ? await workStore.createWork({
- kind: 'app_workflow',
- title: resolvedTitle,
- objective: resolvedObjective,
- subject: {
- kind: 'app',
- app: productAppRef,
- intent: 'run',
- },
- appRefs,
- scope: resolvedWorkScope,
- visibility: 'primary',
- primarySurfacePolicy: 'application_surface',
- titleState: titleState ?? { source: 'application_surface', locked: false, subjectRef: targetProductAppId },
- assignment,
- })
- : (await workStore.resolveAppWork({
- app: productAppRef,
- intent: 'run',
- title: resolvedTitle,
- objective: resolvedObjective,
- appRefs,
- scope: resolvedWorkScope,
- visibility: 'primary',
- primarySurfacePolicy: 'application_surface',
- assignment,
- })).work;
-
- if (workspace) {
- await rememberWorkspace(workspace.id);
- }
- await openWork(work);
- return work;
- }
-
- if (launch.kind !== 'agentSession' && launch.kind !== 'appBuilder') {
- throw new Error(`Product App ${productApp.name} cannot start a work session.`);
- }
-
- resolvedAgentChoice = (launch.agentType || launch.targetId || 'Runno') as NewWorkAgentChoice;
- }
const descriptor = resolveDescriptorFromChoice(resolvedAgentChoice);
const backendAgentType = getBackendAgentType(descriptor);
@@ -274,31 +188,8 @@ export async function launchWorkForChoice(params: {
syncSessionModeStore(descriptor);
- if (resolvedAppRef && resolvedAppWorkMode === 'resolveSingletonWork') {
- const work = (await useWorkStore.getState().resolveAppWork({
- app: resolvedAppRef,
- intent: 'run',
- title: workTitle,
- objective: workObjective,
- appRefs,
- scope: resolvedWorkScope,
- visibility: 'primary',
- primarySurfacePolicy: 'work_session',
- assignment: {
- kind: 'agent',
- agentType: backendAgentType,
- },
- })).work;
-
- if (workspace) {
- await rememberWorkspace(workspace.id);
- }
- await openWork(work);
- return work;
- }
-
const work = await useWorkStore.getState().createWork({
- kind: productAppId ? 'app_workflow' : classifyKind,
+ kind: classifyKind,
title: workTitle,
objective: workObjective,
subject: resolvedAppRef
@@ -326,9 +217,8 @@ export const NewWorkDialog: React.FC = ({
open: isOpen,
onClose,
initialAgentChoice,
- initialScopeRequirement,
}) => {
- const { t, currentLanguage } = useI18n('common');
+ const { t } = useI18n('common');
const {
openedWorkspacesList,
recentWorkspaces,
@@ -337,62 +227,62 @@ export const NewWorkDialog: React.FC = ({
openWorkspace,
} = useWorkspaceContext();
- const [agentChoice, setAgentChoice] = useState('Runno');
+ const [agentChoice, setAgentChoice] = useState(appSlotWorkChoice('runno'));
const [startMode, setStartMode] = useState('manual');
const [classifyKind, setClassifyKind] = useState('multi_step');
const [workspaceId, setWorkspaceId] = useState(null);
const [browsedWorkspacePath, setBrowsedWorkspacePath] = useState(null);
const [objective, setObjective] = useState('');
const [submitting, setSubmitting] = useState(false);
- const [nativeApps, setNativeApps] = useState([]);
- const [productApps, setProductApps] = useState([]);
- const localizedNativeApps = useMemo(
- () => localizeCatalogApps(nativeApps, currentLanguage),
- [currentLanguage, nativeApps],
- );
- const localizedProductApps = useMemo(
- () => localizeCatalogApps(productApps, currentLanguage),
- [currentLanguage, productApps],
- );
+ const [appSlots, setAppSlots] = useState([]);
const knownBuiltinChoices = useMemo>(
- () => new Set(['OSAgent', 'Runno', 'AppBuilder']),
+ () => new Set(['OSAgent']),
[]
);
- const selectedProductAppId = parseProductAppWorkChoice(agentChoice);
- const selectedProductApp = useMemo(
- () => selectedProductAppId
- ? productApps.find((app) => app.id === selectedProductAppId) ?? null
- : null,
- [productApps, selectedProductAppId]
+ const workAppScope = useMemo(() => {
+ const workspacePath = workspaceId === BROWSED_WORKSPACE_VALUE
+ ? browsedWorkspacePath
+ : openedWorkspacesList.find((workspace) => workspace.id === workspaceId)?.rootPath;
+ return appScopeFromWorkspacePath(workspacePath) ?? systemAppScope();
+ }, [browsedWorkspacePath, openedWorkspacesList, workspaceId]);
+
+ const intelligentExecutors = useMemo(() => appSlots.flatMap((slot) => {
+ const defaultAgentType = AGENT_TYPE_BY_SLOT[slot.slotId];
+ const activeApp = defaultAgentType ? intelligentAppAPI.activeRef(slot) : null;
+ if (!defaultAgentType || !activeApp) return [];
+ const launch = activeApp.runtime.launch;
+ const agentType = launch?.kind === 'appBuilder'
+ ? 'AppBuilder'
+ : launch?.agentType || defaultAgentType;
+ const variant = slot.variants.find(({ app }) => app.appId === activeApp.appId);
+ return [{
+ choice: appSlotWorkChoice(slot.slotId),
+ slot,
+ activeApp,
+ agentType,
+ description: variant?.app.description ?? '',
+ }];
+ }), [appSlots]);
+ const executorByChoice = useMemo(
+ () => new Map(intelligentExecutors.map((executor) => [executor.choice, executor])),
+ [intelligentExecutors],
);
- const initialChoiceScopeRequirement = initialAgentChoice === agentChoice
- ? initialScopeRequirement
- : undefined;
- const effectiveScopeRequirement = selectedProductApp
- ? getProductAppLaunchScopeRequirement(selectedProductApp)
- : initialChoiceScopeRequirement ?? getProductAppLaunchScopeRequirement(null);
- const workspaceRequired = startMode === 'manual' && effectiveScopeRequirement === 'workspaceRequired';
-
- const resetDefaults = useCallback((loadedProductApps?: ProductAppCatalogEntry[]) => {
- const apps = loadedProductApps ?? productApps;
- const normalizedInitialChoice = normalizeChoiceForAvailableApps(
- initialAgentChoice,
- apps,
- knownBuiltinChoices,
- );
+ const selectedExecutor = executorByChoice.get(agentChoice);
+
+ const resetDefaults = useCallback(() => {
let storedAgent: NewWorkAgentChoice | null = null;
let storedWorkspaceId: string | null = null;
try {
const rawAgent = localStorage.getItem(LS_AGENT) as NewWorkAgentChoice | null;
- storedAgent = normalizeChoiceForAvailableApps(rawAgent, apps, knownBuiltinChoices);
+ storedAgent = rawAgent;
storedWorkspaceId = localStorage.getItem(LS_WORKSPACE);
} catch {
/* ignore */
}
- setAgentChoice(normalizedInitialChoice ?? storedAgent ?? 'Runno');
+ setAgentChoice(initialAgentChoice ?? storedAgent ?? appSlotWorkChoice('runno'));
setStartMode('manual');
setClassifyKind('multi_step');
setBrowsedWorkspacePath(null);
@@ -402,54 +292,42 @@ export const NewWorkDialog: React.FC = ({
);
}, [
initialAgentChoice,
- knownBuiltinChoices,
lastUsedWorkspace,
openedWorkspacesList,
- productApps,
recentWorkspaces,
]);
+ useEffect(() => {
+ if (!isOpen) return;
+ resetDefaults();
+ }, [isOpen, resetDefaults]);
+
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
- appCatalogAPI.listAppCatalog().then((catalog) => {
+ setAppSlots([]);
+ intelligentAppAPI.listCatalog().then((catalog) => {
if (cancelled) return;
- const nativeLaunchableApps = catalog.native.filter((app) => (
- app.launch?.kind === 'agentSession'
- ));
- const productLaunchableApps = catalog.productApps.installed.filter((app) => (
- (app.catalogIssues?.length ?? 0) === 0 &&
- app.enabled && (
- app.launch?.kind === 'agentSession' ||
- app.launch?.kind === 'applicationSurface'
- )
- ));
- setNativeApps(nativeLaunchableApps);
- setProductApps(productLaunchableApps);
- resetDefaults(productLaunchableApps);
+ setAppSlots(catalog.slots);
+ const available = new Set(['OSAgent']);
+ for (const slot of catalog.slots) {
+ if (AGENT_TYPE_BY_SLOT[slot.slotId] && intelligentAppAPI.activeRef(slot)) {
+ available.add(appSlotWorkChoice(slot.slotId));
+ }
+ }
+ setAgentChoice((current) => normalizeChoiceForAvailableApps(current, available)
+ ?? (available.has(appSlotWorkChoice('runno')) ? appSlotWorkChoice('runno') : 'OSAgent'));
}).catch((error) => {
if (cancelled) return;
- log.error('Failed to load work executors', { error });
- setNativeApps([]);
- setProductApps([]);
- resetDefaults([]);
+ log.error('Failed to load active Intelligent App executors', { error });
+ setAppSlots([]);
+ setAgentChoice('OSAgent');
});
return () => {
cancelled = true;
};
- // resetDefaults intentionally omitted to avoid resetting while the dialog is open.
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
- useEffect(() => {
- if (!isOpen || !initialAgentChoice) return;
- setAgentChoice(
- normalizeChoiceForAvailableApps(initialAgentChoice, productApps, knownBuiltinChoices) ??
- initialAgentChoice,
- );
- setStartMode('manual');
- }, [initialAgentChoice, isOpen, knownBuiltinChoices, productApps]);
-
const workspaceOptions = useMemo(() => {
const recentOrder = new Map(recentWorkspaces.map((workspace, index) => [workspace.id, index]));
const sorted = [...openedWorkspacesList].sort((left, right) => {
@@ -463,72 +341,41 @@ export const NewWorkDialog: React.FC = ({
value: workspace.id,
description: workspace.rootPath,
}));
- const options: SelectOption[] = workspaceRequired
- ? workspaceItems
- : [
- {
- label: t('nav.workDock.globalScopeLabel'),
- value: SYSTEM_WORKSPACE_VALUE,
- description: t('nav.workDock.globalScopeDescription'),
- },
- ...workspaceItems,
- ];
+ const options: SelectOption[] = [
+ {
+ label: t('nav.workDock.globalScopeLabel'),
+ value: SYSTEM_WORKSPACE_VALUE,
+ description: t('nav.workDock.globalScopeDescription'),
+ },
+ ...workspaceItems,
+ ];
if (browsedWorkspacePath) {
- options.splice(workspaceRequired ? 0 : 1, 0, {
+ options.splice(1, 0, {
label: getBrowsedWorkspaceName(browsedWorkspacePath),
value: BROWSED_WORKSPACE_VALUE,
description: browsedWorkspacePath,
});
}
return options;
- }, [browsedWorkspacePath, openedWorkspacesList, recentWorkspaces, t, workspaceRequired]);
-
- useEffect(() => {
- if (!isOpen || !workspaceRequired) return;
- if (workspaceId && workspaceId !== SYSTEM_WORKSPACE_VALUE) return;
-
- const defaultWorkspaceId = pickDefaultWorkspaceId(
- openedWorkspacesList,
- recentWorkspaces,
- lastUsedWorkspace,
- null
- );
- setWorkspaceId(defaultWorkspaceId === SYSTEM_WORKSPACE_VALUE ? null : defaultWorkspaceId);
- }, [
- isOpen,
- lastUsedWorkspace,
- openedWorkspacesList,
- recentWorkspaces,
- workspaceId,
- workspaceRequired,
- ]);
+ }, [browsedWorkspacePath, openedWorkspacesList, recentWorkspaces, t]);
const agentOptions = useMemo(
() => {
- const nativeById = new Map(localizedNativeApps.map((app) => [app.id, app]));
- const systemOptions = [
- {
- value: 'Runno',
- label: nativeById.get('runno')?.name ?? 'Runno',
- description: nativeById.get('runno')?.description ?? 'Sparo OS general execution unit.',
- group: t('nav.workDock.executor.systemGroup'),
- },
- {
- value: 'OSAgent',
- label: nativeById.get('os-agent')?.name ?? 'OSAgent',
- description: nativeById.get('os-agent')?.description ?? 'Coordinate Sparo OS work, sessions, and memory.',
- group: t('nav.workDock.executor.systemGroup'),
- },
- ];
- const productOptions = localizedProductApps.map((app) => ({
- value: productAppWorkChoice(app.id),
- label: app.name,
- description: app.description,
- group: t('nav.workDock.executor.productAppGroup'),
+ const systemOption = {
+ value: 'OSAgent',
+ label: 'OSAgent',
+ description: 'Coordinate Sparo OS work, sessions, and memory.',
+ group: t('nav.workDock.executor.systemGroup'),
+ };
+ const appOptions = intelligentExecutors.map((executor) => ({
+ value: executor.choice,
+ label: executor.slot.displayName,
+ description: executor.description,
+ group: t('nav.workDock.executor.intelligentAppGroup'),
}));
- return [...systemOptions, ...productOptions];
+ return [systemOption, ...appOptions];
},
- [localizedNativeApps, localizedProductApps, t]
+ [intelligentExecutors, t]
);
const startModeOptions = useMemo = ({
{ value: 'tracking', label: t('newWork.classify.tracking') },
{ value: 'recurring', label: t('newWork.classify.recurring') },
], [t]);
- const showClassifyControls = startMode === 'manual' && !selectedProductAppId;
+ const showClassifyControls = startMode === 'manual';
const modeLede = startMode === 'manual'
? t('nav.workDock.modeManualLede')
: t('nav.workDock.modeAgenticOsLede');
@@ -646,14 +493,10 @@ export const NewWorkDialog: React.FC = ({
notificationService.error(t('nav.workDock.objectiveRequired'), { duration: 3000 });
return;
}
- const workspaceSelectionReady = workspaceId === BROWSED_WORKSPACE_VALUE
- ? Boolean(browsedWorkspacePath)
- : Boolean(workspaceId && workspaceId !== SYSTEM_WORKSPACE_VALUE);
- if (startMode === 'manual' && workspaceRequired && !workspaceSelectionReady) {
- notificationService.error(t('nav.workDock.workspaceRequired'), { duration: 3000 });
+ if (startMode === 'manual' && parseAppSlotWorkChoice(agentChoice) && !selectedExecutor) {
+ notificationService.error(t('nav.workDock.executor.releaseUnavailable'), { duration: 3000 });
return;
}
-
setSubmitting(true);
try {
if (startMode === 'agentic-os') {
@@ -690,16 +533,21 @@ export const NewWorkDialog: React.FC = ({
if (!workspace && shouldOpenBrowsedWorkspace && browsedWorkspacePath) {
workspace = await openWorkspace(browsedWorkspacePath);
}
- if (workspaceRequired && !workspace) {
- notificationService.error(t('nav.workDock.workspaceRequired'), { duration: 3000 });
- return;
+ if (selectedExecutor) {
+ await launchActiveIntelligentApp(selectedExecutor.activeApp, {
+ scope: workAppScope,
+ title: selectedExecutor.slot.displayName,
+ objective: selectedExecutor.description || selectedExecutor.slot.displayName,
+ });
+ if (workspace) await rememberWorkspace(workspace.id);
+ } else {
+ await launchWorkForChoice({
+ agentChoice,
+ workspace,
+ rememberWorkspace,
+ classifyKind,
+ });
}
- await launchWorkForChoice({
- agentChoice,
- workspace,
- rememberWorkspace,
- classifyKind: selectedProductAppId ? undefined : classifyKind,
- });
try {
localStorage.setItem(LS_AGENT, agentChoice);
@@ -729,25 +577,22 @@ export const NewWorkDialog: React.FC = ({
}, [
agentChoice,
browsedWorkspacePath,
+ workAppScope,
classifyKind,
objective,
onClose,
openWorkspace,
openedWorkspacesList,
rememberWorkspace,
- selectedProductAppId,
+ selectedExecutor,
startMode,
t,
workspaceId,
- workspaceRequired,
]);
const selectedWorkspaceOption = workspaceOptions.find((option) => option.value === (workspaceId ?? SYSTEM_WORKSPACE_VALUE));
- const workspaceSelectionReady = workspaceId === BROWSED_WORKSPACE_VALUE
- ? Boolean(browsedWorkspacePath)
- : Boolean(workspaceId && workspaceId !== SYSTEM_WORKSPACE_VALUE);
const canSubmit = (startMode === 'manual' || objective.trim().length > 0)
- && (!workspaceRequired || workspaceSelectionReady)
+ && (!parseAppSlotWorkChoice(agentChoice) || Boolean(selectedExecutor))
&& !submitting;
return (
@@ -893,11 +738,9 @@ export const NewWorkDialog: React.FC = ({
- {workspaceRequired && !selectedWorkspaceOption
- ? t('nav.workDock.workspaceRequiredHint')
- : t('nav.workDock.scopePreview', {
- scope: selectedWorkspaceOption?.label ?? t('nav.workDock.globalScopeLabel'),
- })}
+ {t('nav.workDock.scopePreview', {
+ scope: selectedWorkspaceOption?.label ?? t('nav.workDock.globalScopeLabel'),
+ })}
diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceFooterActions.scss b/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceFooterActions.scss
index 65bc89ff..29ba980b 100644
--- a/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceFooterActions.scss
+++ b/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceFooterActions.scss
@@ -1,346 +1,869 @@
@use '@/design-system/foundation/tokens/tokens.scss' as *;
-@keyframes sparo-workspace-footer-panel-in {
+@keyframes sparo-workspace-hub-bloom-in {
+ 0% {
+ opacity: 0.72;
+ clip-path: circle(23px at 20px calc(100% - 34px));
+ transform: translateY(2px) scale(0.985);
+ }
+
+ 62% {
+ opacity: 1;
+ }
+
+ 100% {
+ opacity: 1;
+ clip-path: circle(920px at 20px calc(100% - 34px));
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes sparo-workspace-hub-bloom-out {
+ 0% {
+ opacity: 1;
+ clip-path: circle(920px at 20px calc(100% - 34px));
+ transform: translateY(0) scale(1);
+ }
+
+ 100% {
+ opacity: 0;
+ clip-path: circle(22px at 20px calc(100% - 34px));
+ transform: translateY(2px) scale(0.985);
+ }
+}
+
+@keyframes sparo-workspace-hub-nav-in {
from {
opacity: 0;
- transform: translateY(6px) scale(0.96);
+ transform: translateX(-7px);
}
to {
opacity: 1;
- transform: translateY(0) scale(1);
+ transform: translateX(0);
+ }
+}
+
+@keyframes sparo-workspace-hub-stage-in {
+ from {
+ opacity: 0;
+ transform: translateX(10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(0);
}
}
-@keyframes sparo-workspace-footer-panel-out {
+@keyframes sparo-workspace-hub-utility-in {
from {
+ opacity: 0;
+ clip-path: inset(0 100% 0 0 round var(--ds-radius-lg));
+ transform: translate(-7px, 5px);
+ }
+
+ to {
opacity: 1;
- transform: translateY(0) scale(1);
+ clip-path: inset(0 0 0 0 round var(--ds-radius-lg));
+ transform: translate(0, 0);
+ }
+}
+
+@keyframes sparo-workspace-hub-utility-out {
+ from {
+ opacity: 1;
+ clip-path: inset(0 0 0 0 round var(--ds-radius-lg));
+ transform: translate(0, 0);
}
to {
opacity: 0;
- transform: translateY(6px) scale(0.96);
+ clip-path: inset(0 100% 0 0 round var(--ds-radius-lg));
+ transform: translate(-5px, 3px);
}
}
-.sparo-workspace-footer {
- position: relative;
- isolation: isolate;
- display: flex;
- align-items: center;
- flex-shrink: 0;
- gap: $size-gap-1;
- padding: $size-gap-1 0 0;
+@keyframes sparo-workspace-hub-choice-pop {
+ 0% { transform: scale(0.86) rotate(-8deg); }
+ 65% { transform: scale(1.08) rotate(2deg); }
+ 100% { transform: scale(1) rotate(0); }
+}
+
+@keyframes sparo-workspace-hub-letter-arrival {
+ 0%, 100% { box-shadow: 0 0 0 0 var(--ds-color-accent-300); }
+ 50% { box-shadow: 0 0 0 4px transparent; }
+}
- &::before {
- content: '';
- position: absolute;
- inset: -6px -10px -6px -6px;
- z-index: var(--ds-z-base);
- pointer-events: none;
- background: var(--sparo-workspace-footer-mask-bg, var(--ds-color-bg-scene));
- mask-image: radial-gradient(ellipse 68% 72% at 20px 50%, black 0 48%, transparent 76%);
- -webkit-mask-image: radial-gradient(ellipse 68% 72% at 20px 50%, black 0 48%, transparent 76%);
+@keyframes sparo-workspace-hub-utility-mode-in {
+ from {
+ opacity: 0;
+ transform: translateX(7px) scale(0.985);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(0) scale(1);
}
}
-.sparo-workspace-footer__left {
+.sparo-workspace-footer {
position: relative;
- z-index: var(--ds-z-local);
display: flex;
align-items: center;
- gap: $size-gap-1;
+ flex-shrink: 0;
+ min-width: 0;
+ padding-top: var(--ds-space-1);
}
-.sparo-workspace-footer__more {
+.sparo-workspace-hub {
position: relative;
+ min-width: 40px;
+ min-height: 40px;
}
-.sparo-workspace-footer__trigger {
- width: 28px;
- height: 28px;
- color: var(--ds-color-text-muted);
+.sparo-workspace-hub__trigger {
+ position: relative;
+ z-index: var(--ds-z-local);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ min-width: 40px;
+ height: 40px;
+ padding: 0;
+ color: var(--ds-color-text-inverse);
+ background: var(--ds-color-accent-500);
+ border: 1px solid transparent;
+ border-radius: var(--ds-radius-full);
+ box-shadow: var(--ds-shadow-sm);
transform: none;
+ transition:
+ background var(--ds-motion-fast) var(--ds-easing-standard),
+ box-shadow var(--ds-motion-base) var(--ds-easing-standard),
+ transform var(--ds-motion-fast) var(--ds-easing-standard);
+
+ > svg {
+ display: block;
+ flex: 0 0 auto;
+ }
&:hover:not(:disabled),
- &:focus-visible,
- &.is-active {
- color: var(--ds-color-text-primary);
- background: var(--ds-color-surface-hover);
+ &:focus-visible {
+ color: var(--ds-color-text-inverse);
+ background: var(--ds-color-accent-600);
+ border-color: transparent;
+ transform: scale(1.035);
+ }
+
+ &.is-open {
+ z-index: var(--ds-z-tooltip);
+ background: var(--ds-color-accent-500);
+ box-shadow: none;
transform: none;
}
- &.is-active {
- background: var(--ds-color-surface-active);
+ &.has-unread-letter:not(.is-open) {
+ animation: sparo-workspace-hub-letter-arrival 2.4s var(--ds-easing-standard) infinite;
}
}
-.sparo-workspace-footer__trigger-icon-swap {
- position: relative;
+.sparo-workspace-hub__letter-badge {
+ position: absolute;
+ top: -3px;
+ right: -5px;
display: inline-flex;
align-items: center;
justify-content: center;
- width: 15px;
- height: 15px;
- flex-shrink: 0;
+ width: 18px;
+ height: 18px;
+ color: var(--ds-color-text-inverse);
+ background: var(--ds-color-danger);
+ border: 2px solid var(--ds-color-bg-panel);
+ border-radius: var(--ds-radius-full);
+ box-shadow: var(--ds-shadow-sm);
+}
- > svg {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- transition: opacity $motion-fast $easing-standard;
+.sparo-workspace-hub__backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: var(--ds-z-overlay);
+ background: transparent;
+}
+
+.sparo-workspace-hub__bloom {
+ position: absolute;
+ bottom: -14px;
+ left: 0;
+ z-index: var(--ds-z-popover);
+ width: min(684px, calc(100vw - 8px));
+ height: min(500px, calc(100dvh - 18px));
+ pointer-events: none;
+ transform-origin: 20px calc(100% - 34px);
+
+ &.is-open {
+ animation: sparo-workspace-hub-bloom-in 360ms cubic-bezier(0.16, 1, 0.3, 1) both;
+
+ .sparo-workspace-hub__navigation {
+ animation: sparo-workspace-hub-nav-in 240ms 90ms cubic-bezier(0.16, 1, 0.3, 1) both;
+ }
+
+ .sparo-workspace-hub__utility-rail {
+ animation: sparo-workspace-hub-utility-in 220ms 118ms cubic-bezier(0.16, 1, 0.3, 1) both;
+ }
+ }
+
+ &.is-closing {
+ animation: sparo-workspace-hub-bloom-out 220ms var(--ds-easing-standard) both;
+
+ .sparo-workspace-hub__panel {
+ pointer-events: none;
+ }
+
+ .sparo-workspace-hub__utility-rail {
+ animation: sparo-workspace-hub-utility-out 105ms var(--ds-easing-standard) both;
+ }
}
}
-.sparo-workspace-footer__trigger-icon-default {
- opacity: 1;
+.sparo-workspace-hub__surface-stack {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ filter:
+ drop-shadow(0 0 0.75px color-mix(in srgb, var(--ds-color-border-base) 82%, transparent))
+ drop-shadow(0 18px 30px color-mix(in srgb, var(--ds-color-text-primary) 14%, transparent));
}
-.sparo-workspace-footer__trigger-icon-hover {
- opacity: 0;
+.sparo-workspace-hub__seed-socket {
+ position: absolute;
+ bottom: 0;
+ left: -12px;
+ width: 150px;
+ height: 110px;
+ background: var(--ds-color-bg-elevated);
+ border: 0;
+ clip-path: path('M 32 30 H 150 V 60 H 100 C 76 60 64.5 83.1 51.8 95.8 C 40.9 106.7 23.1 106.7 12.2 95.8 C 1.3 84.9 1.3 67.1 12.2 56.2 C 20.7 47.7 32 42 32 30 Z');
}
-.sparo-workspace-footer__trigger:hover .sparo-workspace-footer__trigger-icon-default,
-.sparo-workspace-footer__trigger:focus-visible .sparo-workspace-footer__trigger-icon-default {
- opacity: 0;
+.sparo-workspace-hub__panel {
+ position: absolute;
+ bottom: 50px;
+ left: 20px;
+ z-index: var(--ds-z-local);
+ width: calc(100% - 20px);
+ height: min(420px, calc(100dvh - 80px));
+ min-height: min(360px, calc(100dvh - 80px));
+ overflow: hidden;
+ color: var(--ds-color-text-primary);
+ pointer-events: auto;
+ background: var(--ds-color-bg-elevated);
+ border: 0;
+ border-radius: calc(var(--ds-radius-xl) + 8px);
+ box-shadow: none;
}
-.sparo-workspace-footer__trigger:hover .sparo-workspace-footer__trigger-icon-hover,
-.sparo-workspace-footer__trigger:focus-visible .sparo-workspace-footer__trigger-icon-hover {
- opacity: 1;
+.sparo-workspace-hub__panel-body {
+ height: 100%;
+ min-height: 0;
+ padding: 0;
}
-.sparo-workspace-footer__backdrop {
- position: fixed;
- inset: 0;
- z-index: var(--ds-z-overlay);
+.sparo-workspace-hub__layout {
+ display: grid;
+ grid-template-columns: 286px minmax(0, 1fr);
+ height: 100%;
+ min-height: 0;
}
-.sparo-workspace-footer__panel {
- position: absolute;
- bottom: calc(100% + 6px);
- left: 0;
- width: 216px;
+.sparo-workspace-hub__navigation {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ padding: 16px 16px 10px;
overflow: hidden;
- z-index: var(--ds-z-popover);
- transform-origin: bottom left;
- animation: sparo-workspace-footer-panel-in $motion-fast $easing-decelerate forwards;
- background: var(--color-bg-elevated, var(--ds-color-bg-elevated));
- border: 1px solid var(--border-subtle, var(--ds-color-border-subtle));
- border-radius: $size-radius-base;
- box-shadow: 0 4px 12px var(--ds-shadow-color-popover);
+ background: var(--ds-color-bg-panel);
+ border-right: 1px solid var(--ds-color-border-subtle);
+}
- &.is-closing {
- animation: sparo-workspace-footer-panel-out $motion-fast $easing-accelerate forwards;
+.sparo-workspace-hub__navigation-scroll {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ min-height: 0;
+ flex: 1 1 auto;
+ overflow: hidden;
+}
+
+.sparo-workspace-hub__nav-group {
+ padding: 2px 7px 0;
+ background: transparent;
+ border-radius: 0;
+ box-shadow: none;
+
+ &.is-direct {
+ display: flex;
+ flex: 1 1 0;
+ flex-direction: column;
+ min-height: 0;
+ padding-bottom: 0;
}
}
-.sparo-workspace-footer__panel-body {
- position: relative;
+.sparo-workspace-hub__nav-group-title {
+ margin: 0 0 9px 3px;
+ color: var(--ds-color-text-muted);
+ font-size: calc(var(--ds-font-size-xs) - 2px);
+ font-weight: var(--ds-font-weight-normal);
+ line-height: 1.2;
+ opacity: 0.58;
+}
+
+.sparo-workspace-hub__preview-tracks {
display: flex;
flex-direction: column;
- min-height: 0;
- padding: $size-gap-1;
+ gap: 7px;
+}
+
+.sparo-workspace-hub__preview-track {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 5px;
+ min-width: 0;
+ padding: 3px;
+ background: var(--ds-color-element-subtle);
+ border-radius: var(--ds-radius-lg);
+}
+
+.sparo-workspace-hub__preview-item,
+.sparo-workspace-hub__direct-item {
+ color: var(--ds-color-text-secondary);
+ border-color: transparent;
+ box-shadow: none;
+ transform: none;
+ transition:
+ color var(--ds-motion-fast) var(--ds-easing-standard),
+ background var(--ds-motion-fast) var(--ds-easing-standard),
+ box-shadow var(--ds-motion-fast) var(--ds-easing-standard);
+
+ &:focus-visible {
+ @include ds-focus-ring;
+ border-color: transparent;
+ transform: none;
+ }
+}
+
+.sparo-workspace-hub__preview-item-shell {
+ position: relative;
+ min-width: 0;
+ border-radius: calc(var(--ds-radius-lg) - 2px);
+ transition: background var(--ds-motion-fast) var(--ds-easing-standard);
+
+ &:hover,
+ &:focus-within {
+ background: var(--ds-color-surface-hover);
+ }
+
+ &.is-selected,
+ &.is-selected:hover,
+ &.is-selected:focus-within {
+ background: color-mix(
+ in srgb,
+ var(--ds-color-accent-500) 8%,
+ var(--ds-color-bg-elevated)
+ );
+ }
+
+ &:hover .sparo-workspace-hub__preview-item-open,
+ &:focus-within .sparo-workspace-hub__preview-item-open {
+ opacity: 1;
+ pointer-events: auto;
+ }
+}
+
+.sparo-workspace-hub__preview-item {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 5px;
+ width: 100%;
+ min-width: 0;
+ min-height: 41px;
+ padding: 6px 26px 6px 5px;
+ background: transparent;
+ border-radius: calc(var(--ds-radius-lg) - 2px);
+ text-align: left;
+
+ &:hover:not(:disabled),
+ &:focus-visible {
+ color: var(--ds-color-text-primary);
+ background: transparent;
+ border-color: transparent;
+ transform: none;
+ }
}
-.sparo-workspace-footer__moving-hover {
+.sparo-workspace-hub__preview-item-open {
position: absolute;
- top: 0;
- left: 0;
- z-index: var(--ds-z-base);
- border-radius: var(--ds-list-row-radius, var(--ds-radius-full));
- background: var(--sparo-moving-hover-bg, var(--ds-color-surface-hover));
+ top: 50%;
+ right: 3px;
+ width: 22px;
+ min-width: 22px;
+ height: 22px;
+ padding: 0;
+ color: var(--ds-color-text-inverse);
pointer-events: none;
+ background: var(--ds-color-accent-500);
+ border-color: transparent;
+ border-radius: var(--ds-radius-full);
+ box-shadow: var(--ds-shadow-sm);
opacity: 0;
- width: var(--sparo-footer-hover-width, 0);
- height: var(--sparo-footer-hover-height, 0);
- transform:
- translate3d(var(--sparo-footer-hover-left, 0), var(--sparo-footer-hover-top, 0), 0)
- scale(var(--sparo-footer-hover-stretch-x, 1), var(--sparo-footer-hover-stretch-y, 1));
- transform-origin: center;
+ transform: translateY(-50%);
transition:
- opacity $motion-fast $easing-standard,
- transform 0.13s cubic-bezier(0.18, 0.86, 0.24, 1),
- width 0.13s cubic-bezier(0.18, 0.86, 0.24, 1),
- height 0.13s cubic-bezier(0.18, 0.86, 0.24, 1);
-}
+ color var(--ds-motion-fast) var(--ds-easing-standard),
+ background var(--ds-motion-fast) var(--ds-easing-standard),
+ opacity var(--ds-motion-fast) var(--ds-easing-standard);
-.sparo-workspace-footer__moving-hover--visible {
- opacity: 1;
+ &:hover:not(:disabled),
+ &:focus-visible {
+ color: var(--ds-color-text-inverse);
+ background: var(--ds-color-accent-600);
+ border-color: transparent;
+ transform: translateY(-50%);
+ }
}
-.sparo-workspace-footer__menu-greeting {
+.sparo-workspace-hub__item-icon {
position: relative;
- z-index: var(--ds-z-local);
- margin: 0;
- padding: 6px $size-gap-2 8px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 17px;
+ height: 17px;
+ flex: 0 0 auto;
+}
+
+.sparo-workspace-hub__item-copy {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 1px;
+ min-width: 0;
+ flex: 1 1 auto;
+}
+
+.sparo-workspace-hub__item-label {
+ display: block;
+ min-width: 0;
+ overflow: hidden;
+ font-size: var(--ds-font-size-xs);
+ font-weight: var(--ds-font-weight-normal);
+ line-height: 1.2;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sparo-workspace-hub__item-meta {
+ display: block;
+ min-width: 0;
+ overflow: hidden;
color: var(--ds-color-text-muted);
font-size: var(--ds-font-size-2xs);
- line-height: 1.4;
+ font-weight: var(--ds-font-weight-normal);
+ line-height: 1.1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.sparo-workspace-footer__menu {
- position: relative;
- z-index: var(--ds-z-local);
+.sparo-workspace-hub__direct-list {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-template-rows: 48px minmax(38px, 1fr);
+ gap: 7px;
+ min-height: 0;
+ flex: 1 1 auto;
+}
+
+.sparo-workspace-hub__direct-item {
display: flex;
flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 3px;
+ width: 100%;
min-width: 0;
+ min-height: 48px;
+ padding: 6px 4px;
+ color: var(--ds-color-text-secondary);
+ background: var(--ds-color-element-subtle);
+ border-radius: var(--ds-radius-lg);
+ text-align: center;
+
+ > .sparo-workspace-hub__item-label {
+ width: 100%;
+ flex: 0 1 auto;
+ font-size: var(--ds-font-size-2xs);
+ }
+
+ &.is-wide {
+ grid-column: 1 / -1;
+ flex-direction: row;
+ height: 100%;
+ min-height: 0;
+ padding-inline: 10px;
+
+ > .sparo-workspace-hub__item-label {
+ width: auto;
+ flex: 0 1 auto;
+ }
+ }
+
+ &:hover:not(:disabled),
+ &.is-current {
+ color: var(--ds-color-text-primary);
+ background: var(--ds-color-element-soft);
+ border-color: transparent;
+ transform: none;
+ }
+
+}
+
+.sparo-workspace-hub__unread-dot {
+ position: absolute;
+ top: -3px;
+ right: -4px;
+ z-index: var(--ds-z-local);
+ width: 6px;
+ height: 6px;
+ background: var(--ds-color-danger);
+ border: 1px solid var(--ds-color-bg-panel);
+ border-radius: var(--ds-radius-full);
}
-.sparo-workspace-footer__agentic-os {
+.sparo-workspace-hub__utility-rail {
position: relative;
- display: flex;
+ flex: 0 0 auto;
+ margin: 9px 8px 0 42px;
+ padding: 3px;
+ background: transparent;
+ border: 0;
+ border-radius: var(--ds-radius-full);
+ transition: background var(--ds-motion-fast) var(--ds-easing-standard);
+
+ &.is-expanded {
+ background: var(--ds-color-element-soft);
+ }
+}
+
+.sparo-workspace-hub__utility-home,
+.sparo-workspace-hub__utility-editor {
+ display: grid;
align-items: center;
- gap: 2px;
- border-radius: var(--ds-list-row-radius, var(--ds-radius-full));
- isolation: isolate;
+ gap: 3px;
+ height: 32px;
+ animation: sparo-workspace-hub-utility-mode-in 190ms cubic-bezier(0.16, 1, 0.3, 1) both;
+}
- &:hover {
- .sparo-workspace-footer__agentic-os-primary {
- background: transparent;
- }
+.sparo-workspace-hub__utility-home {
+ grid-template-columns: repeat(3, 32px);
+ justify-content: end;
+}
- .sparo-workspace-footer__agentic-os-new {
- opacity: 1;
- }
+.sparo-workspace-hub__utility-editor {
+ grid-template-columns: 62px minmax(0, 1fr);
+
+ &.is-theme {
+ grid-template-columns: 62px minmax(0, 1fr);
}
}
-.sparo-workspace-footer__agentic-os-primary {
- flex: 1;
+.sparo-workspace-hub__utility-status,
+.sparo-workspace-hub__utility-editor-label {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
min-width: 0;
- font-weight: var(--ds-font-weight-medium);
+ height: 32px;
+ padding: 0 6px;
+ color: var(--ds-color-text-muted);
+ background: transparent;
+ border-color: transparent;
+ border-radius: var(--ds-radius-full);
+ transform: none;
+
+ > span:last-child {
+ min-width: 0;
+ overflow: hidden;
+ font-size: var(--ds-font-size-2xs);
+ line-height: 1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ &:hover:not(:disabled),
+ &:focus-visible,
&.is-active {
color: var(--ds-color-text-primary);
- background: var(--ds-color-surface-active);
+ background: var(--ds-color-surface-hover);
+ border-color: transparent;
+ transform: none;
}
- &:hover {
- background: transparent;
+ &.is-active {
+ color: var(--ds-color-accent-500);
+ background: color-mix(in srgb, var(--ds-color-accent-500) 9%, transparent);
}
}
-.sparo-workspace-footer__agentic-os-new {
- position: relative;
- z-index: var(--ds-z-local);
- margin-right: 2px;
+.sparo-workspace-hub__utility-status {
+ width: 32px;
+ padding: 0;
+
+ &:hover:not(:disabled),
+ &:focus-visible {
+ color: var(--ds-color-accent-500);
+ background: color-mix(in srgb, var(--ds-color-accent-500) 9%, transparent);
+ }
+}
+
+.sparo-workspace-hub__utility-more {
+ width: 32px;
+ min-width: 32px;
+ height: 32px;
+ padding: 0;
color: var(--ds-color-text-muted);
- opacity: 0.55;
+ background: transparent;
+ border-color: transparent;
+ border-radius: var(--ds-radius-full);
transform: none;
&:hover:not(:disabled),
&:focus-visible {
- color: var(--ds-color-text-primary);
- background: var(--ds-color-surface-active);
- opacity: 1;
+ color: var(--ds-color-accent-500);
+ background: color-mix(in srgb, var(--ds-color-accent-500) 9%, transparent);
+ border-color: transparent;
transform: none;
}
}
-.sparo-workspace-footer__action {
+.sparo-workspace-hub__utility-status-icon {
+ display: inline-flex;
+ flex: 0 0 auto;
+}
+
+.sparo-workspace-hub__theme-preview {
+ display: block;
+ width: 15px;
+ height: 15px;
+ background: linear-gradient(
+ 90deg,
+ var(--hub-theme-preview-left, var(--ds-color-bg-panel)) 0 50%,
+ var(--hub-theme-preview-right, var(--ds-color-accent-500)) 50% 100%
+ );
+ border: 1px solid var(--ds-color-border-base);
+ border-radius: var(--ds-radius-full);
+ box-shadow: var(--ds-shadow-xs);
+}
+
+.sparo-workspace-hub__utility-editor-label {
+ color: var(--ds-color-text-secondary);
+
+ &:hover:not(:disabled),
+ &:focus-visible {
+ color: var(--ds-color-text-primary);
+ background: transparent;
+ }
+}
+
+.sparo-workspace-hub__utility-choice {
+ position: relative;
+ min-width: 0;
+ height: 32px;
+}
+
+.sparo-workspace-hub__utility-indicator {
+ position: absolute;
+ top: 2px;
+ bottom: 2px;
+ left: 2px;
+ z-index: var(--ds-z-base);
+ width: calc((100% - 4px) / var(--hub-choice-count));
+ background: var(--ds-color-bg-elevated);
+ border-radius: calc(var(--ds-radius-lg) - 3px);
+ box-shadow: var(--ds-shadow-xs);
+ transform: translateX(calc(var(--hub-choice-index) * 100%));
+ transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.sparo-workspace-hub__utility-choice.is-theme .sparo-workspace-hub__utility-indicator {
+ display: none;
+}
+
+.sparo-workspace-hub__utility-segmented.ds-segmented-control {
position: relative;
z-index: var(--ds-z-local);
- justify-content: flex-start;
width: 100%;
- height: 30px;
- padding: 0 $size-gap-2;
- border-radius: var(--ds-list-row-radius, var(--ds-radius-full));
- color: var(--ds-color-text-secondary);
- font-weight: var(--ds-font-weight-regular);
- text-align: left;
+ height: 32px;
+ padding: 2px;
+ gap: 0;
+ background: transparent;
+ border: 0;
+ border-radius: calc(var(--ds-radius-lg) - 2px);
+
+ .ds-segmented-control__item {
+ height: 28px;
+ min-width: 0;
+ padding: 0 4px;
+ color: var(--ds-color-text-muted);
+ background: transparent;
+ border-radius: calc(var(--ds-radius-lg) - 3px);
+ box-shadow: none;
+ }
- svg {
- flex-shrink: 0;
- opacity: 0.7;
- transition: opacity $motion-fast $easing-standard;
+ .ds-segmented-control__item:hover:not(:disabled) {
+ color: var(--ds-color-text-primary);
+ background: transparent;
}
- &:hover,
- &.is-active {
+ .ds-segmented-control__item--selected {
color: var(--ds-color-text-primary);
+ background: transparent;
+ box-shadow: none;
- svg {
- opacity: 1;
+ .ds-segmented-control__icon,
+ .ds-segmented-control__label > span {
+ animation: sparo-workspace-hub-choice-pop 240ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
}
- &.is-active {
- background: var(--ds-color-surface-active);
+ .ds-segmented-control__icon {
+ display: inline-flex;
}
-}
-.sparo-workspace-footer__icon-with-dot {
- position: relative;
- display: inline-flex;
- flex-shrink: 0;
+ .ds-segmented-control__label {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
}
-.sparo-workspace-footer__unread-dot {
- position: absolute;
- top: -2px;
- right: -3px;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--ds-color-accent);
- box-shadow: 0 0 0 1.5px var(--ds-color-bg-elevated);
+.sparo-workspace-hub__utility-segmented.is-theme {
+ .ds-segmented-control__icon {
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: var(--ds-radius-full);
+ transition:
+ color var(--ds-motion-fast) var(--ds-easing-standard),
+ background var(--ds-motion-fast) var(--ds-easing-standard);
+ }
+
+ .ds-segmented-control__item:hover:not(:disabled) .ds-segmented-control__icon,
+ .ds-segmented-control__item--selected .ds-segmented-control__icon {
+ color: var(--ds-color-accent-500);
+ background: color-mix(in srgb, var(--ds-color-accent-500) 10%, transparent);
+ }
}
-.sparo-workspace-footer__action-label {
+.sparo-workspace-hub__detail {
+ display: flex;
+ flex-direction: column;
min-width: 0;
+ min-height: 0;
+ padding: 30px 30px 20px;
+ overflow: auto;
+ scrollbar-width: thin;
+}
+
+.sparo-workspace-hub__sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
overflow: hidden;
- text-overflow: ellipsis;
+ clip: rect(0, 0, 0, 0);
white-space: nowrap;
+ border: 0;
}
-.sparo-workspace-footer__action--expandable {
- .sparo-workspace-footer__action-chevron {
- flex-shrink: 0;
- margin-left: auto;
- opacity: 0.55;
- transition:
- opacity $motion-fast $easing-standard,
- transform $motion-fast $easing-standard;
+@media (max-width: 700px) {
+ .sparo-workspace-hub__bloom {
+ width: calc(100vw - 8px);
}
- &.is-open .sparo-workspace-footer__action-chevron {
- opacity: 0.85;
- transform: rotate(90deg);
+ .sparo-workspace-hub__layout {
+ grid-template-columns: minmax(230px, 44%) minmax(0, 1fr);
+ }
+
+ .sparo-workspace-hub__navigation {
+ padding-inline: 10px;
+ }
+
+ .sparo-workspace-hub__detail {
+ padding-inline: 18px;
}
}
-.sparo-workspace-footer__subactions {
- display: grid;
- grid-template-rows: 0fr;
- overflow: hidden;
- transition: grid-template-rows 0.2s $easing-standard;
+@media (max-width: 540px) {
+ .sparo-workspace-hub__layout {
+ grid-template-columns: minmax(172px, 42%) minmax(0, 1fr);
+ }
- &.is-open {
- grid-template-rows: 1fr;
+ .sparo-workspace-hub__nav-group {
+ padding-inline: 6px;
}
- > div {
- display: flex;
- flex-direction: column;
- min-height: 0;
- overflow: hidden;
+ .sparo-workspace-hub__preview-item {
+ gap: 4px;
+ padding-inline: 5px;
}
}
-.sparo-workspace-footer__action--sub {
- height: 26px;
- padding-left: 24px;
- color: var(--ds-color-text-muted);
- font-size: var(--ds-font-size-xs);
+@media (max-height: 500px) {
+ .sparo-workspace-hub__bloom {
+ height: calc(100dvh - 8px);
+ }
+
+ .sparo-workspace-hub__panel {
+ height: calc(100dvh - 74px);
+ min-height: 0;
+ }
}
-.sparo-workspace-footer__separator {
- position: relative;
- z-index: var(--ds-z-local);
- height: 1px;
- margin: $size-gap-1 $size-gap-2;
- background: var(--ds-color-border-subtle);
+@media (prefers-reduced-motion: reduce) {
+ .sparo-workspace-hub__trigger.has-unread-letter {
+ animation: none;
+ }
+
+ .sparo-workspace-hub__bloom,
+ .sparo-workspace-hub__navigation,
+ .sparo-workspace-hub__utility-rail,
+ .sparo-workspace-hub__trigger.is-open {
+ animation: none;
+ }
+
+ .sparo-workspace-hub__utility-indicator,
+ .sparo-workspace-hub__utility-segmented .ds-segmented-control__icon,
+ .sparo-workspace-hub__utility-segmented .ds-segmented-control__label > span {
+ animation: none;
+ transition: none;
+ }
+
+ .sparo-workspace-hub__utility-home,
+ .sparo-workspace-hub__utility-editor {
+ animation: none;
+ }
+
+ .sparo-workspace-hub__bloom.is-open {
+ opacity: 1;
+ clip-path: none;
+ transform: none;
+ }
+
+ .sparo-workspace-hub__bloom.is-closing {
+ opacity: 0;
+ }
}
diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceFooterActions.tsx b/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceFooterActions.tsx
index 753520fd..60f471f6 100644
--- a/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceFooterActions.tsx
+++ b/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceFooterActions.tsx
@@ -1,466 +1,465 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Mail, Maximize2 } from 'lucide-react';
import {
- SquareTerminal,
- BookOpen,
- ChevronUp,
- ChevronRight,
- FolderTree,
- Orbit,
- RotateCcw,
- Brain,
- AppWindow,
- LayoutDashboard,
- MailOpen,
- Settings,
- Code2,
- Wrench,
-} from 'lucide-react';
-import { Button, IconButton, Panel, PanelBody, SparoAgentIcon, SparoSubagentIcon } from '@/design-system';
+ Button,
+ IconButton,
+ Panel,
+ PanelBody,
+ SparoAgentIcon,
+ useDialogFocusTrap,
+} from '@/design-system';
import { useI18n } from '@/infrastructure/i18n/hooks/useI18n';
-import { flowChatManager } from '@/flow_chat/services/FlowChatManager';
-import { openAgenticOsSession } from '@/flow_chat/services/openAgenticOsSession';
-import { openWorkspaceScene, openWorkspaceSession } from '../../navigation/workspaceNavigation';
-import { useWorkspaceSurfaceStore } from '../../navigation/workspaceSurfaceStore';
+import { systemRuntimeScope } from '@/shared/types/runtime-scope';
+import { useDailyLetterArrivalStore } from '@/app/daily-letter-arrival/store/dailyLetterArrivalStore';
+import { useWorkStore } from '@/app/agentic-os/work/data/workStore';
+import { selectWorkProjections } from '@/app/agentic-os/work/data/workSelectors';
+import {
+ isDockEligibleWork,
+ isWorkAttentionStatus,
+ isWorkRunningStatus,
+} from '@/app/agentic-os/work/domain/workClassification';
import { openWorkCenterHome } from '@/app/agentic-os/work/navigation/openWork';
-import { createLogger } from '@/shared/utils/logger';
-import { getAgenticOsSessionDescriptor } from '@/flow_chat/domain/sessionDescriptor';
-import { useMovingHoverHighlight } from '@/shared/hooks/useMovingHoverHighlight';
-import { useLastUsedWorkspace } from '@/infrastructure/contexts/WorkspaceContext';
+import type { WorkspaceSceneId } from '@/app/navigation/workspaceSceneTypes';
+import { NewWorkDialog } from '../WorkDock/NewWorkDialog';
+import { openWorkspaceScene } from '../../navigation/workspaceNavigation';
+import { useWorkspaceSurfaceStore } from '../../navigation/workspaceSurfaceStore';
+import { WorkspaceHubPreview } from './previews/WorkspaceHubPreviewRegistry';
+import { WorkspaceHubUtilityRail } from './WorkspaceHubUtilityRail';
import {
- projectRuntimeScopeFromWorkspace,
- systemRuntimeScope,
-} from '@/shared/types/runtime-scope';
-import { useDailyLetterArrivalStore } from '@/app/daily-letter-arrival/store/dailyLetterArrivalStore';
+ WORKSPACE_HUB_ITEM_DEFINITIONS,
+ getWorkspaceHubItem,
+ renderWorkspaceHubItemIcon,
+ resolveWorkspaceHubItem,
+ type WorkspaceHubItemId,
+ type WorkspaceHubPreviewItemId,
+} from './workspaceHubItems';
import './WorkspaceFooterActions.scss';
-const log = createLogger('WorkspaceFooterActions');
+const HUB_PREVIEW_ITEM_ROWS = [
+ ['work-center', 'apps'],
+ ['daily-letter', 'memory'],
+ ['files', 'shell'],
+] as const satisfies readonly (readonly WorkspaceHubPreviewItemId[])[];
-const GREETING_KEYS = ['greetingMorning', 'greetingAfternoon', 'greetingEvening', 'greetingNight'] as const;
+const HUB_PREVIEW_ITEM_IDS = HUB_PREVIEW_ITEM_ROWS.flat();
+const HUB_PREVIEW_ITEM_ID_SET = new Set
(HUB_PREVIEW_ITEM_IDS);
+const HUB_DIRECT_ITEM_IDS = ['skills', 'tools', 'subagents', 'settings'] as const satisfies readonly WorkspaceHubItemId[];
-interface FooterActionProps {
- active?: boolean;
- children: React.ReactNode;
- className?: string;
- icon: React.ReactNode;
- movingHoverHandlers?: React.HTMLAttributes;
- onClick: () => void;
- testId?: string;
- title?: string;
-}
+type WorkspaceHubDirectItemId = (typeof HUB_DIRECT_ITEM_IDS)[number];
-const FooterAction: React.FC = ({
- active = false,
- children,
- className = '',
- icon,
- movingHoverHandlers,
- onClick,
- testId,
- title,
-}) => (
-
-);
+function isHubPreviewNavigationItem(
+ itemId: WorkspaceHubItemId,
+): itemId is (typeof HUB_PREVIEW_ITEM_IDS)[number] {
+ return HUB_PREVIEW_ITEM_ID_SET.has(itemId);
+}
const WorkspaceFooterActions: React.FC = () => {
const { t } = useI18n('common');
- const activeSurface = useWorkspaceSurfaceStore(s => s.activeSurface);
- const { workspace: defaultWorkspace } = useLastUsedWorkspace();
- const activeSceneId = activeSurface.kind === 'scene' ? activeSurface.sceneId : null;
- const isAgenticOsActive = activeSurface.kind === 'agentic-os-home';
-
- const greeting = useMemo(() => {
- const hour = new Date().getHours();
- const key = hour >= 5 && hour < 12
- ? GREETING_KEYS[0]
- : hour >= 12 && hour < 18
- ? GREETING_KEYS[1]
- : hour >= 18 && hour < 22
- ? GREETING_KEYS[2]
- : GREETING_KEYS[3];
- return t(`welcome.${key}`);
- }, [t]);
-
- const hasUnreadDailyLetter = useDailyLetterArrivalStore((s) => s.hasUnread);
-
- const isMemoryActive = activeSceneId === 'memory';
- const isWorkCenterActive = activeSceneId === 'work-center';
- const isDailyLetterActive = activeSceneId === 'daily-letter';
- const isAppsActive = activeSceneId === 'apps'
- || (typeof activeSceneId === 'string' && activeSceneId.startsWith('app-surface:'));
- const isSkillsActive = activeSceneId === 'skills';
- const isToolsActive = activeSceneId === 'tools';
- const isSubagentsActive = activeSceneId === 'subagents';
- const isSettingsActive = activeSceneId === 'settings';
- const isShellActive = activeSceneId === 'shell';
- const isFileViewerActive = activeSceneId === 'file-viewer';
- const isDevKitChildActive = isSkillsActive || isToolsActive || isSubagentsActive;
- const defaultProjectScope = useMemo(
- () => projectRuntimeScopeFromWorkspace(defaultWorkspace) ?? systemRuntimeScope(),
- [defaultWorkspace],
+ const activeSurface = useWorkspaceSurfaceStore((state) => state.activeSurface);
+ const works = useWorkStore((state) => state.works);
+ const hasUnreadDailyLetter = useDailyLetterArrivalStore((state) => state.hasUnread);
+
+ const activeItemId = useMemo(() => resolveWorkspaceHubItem(activeSurface), [activeSurface]);
+ const projections = useMemo(() => selectWorkProjections(works), [works]);
+ const runningWorkCount = useMemo(
+ () => projections.filter((work) => (
+ isDockEligibleWork(work) && isWorkRunningStatus(work.status)
+ )).length,
+ [projections],
+ );
+ const attentionWorkCount = useMemo(
+ () => projections.filter((work) => (
+ isDockEligibleWork(work) && isWorkAttentionStatus(work.status)
+ )).length,
+ [projections],
);
- const [menuOpen, setMenuOpen] = useState(false);
- const [menuClosing, setMenuClosing] = useState(false);
- const [isDevKitSubmenuOpen, setIsDevKitSubmenuOpen] = useState(isDevKitChildActive);
- const closeTimerRef = useRef(null);
- const menuHover = useMovingHoverHighlight();
-
- const clearCloseTimer = useCallback(() => {
- if (closeTimerRef.current === null) return;
- window.clearTimeout(closeTimerRef.current);
- closeTimerRef.current = null;
+ const [hubOpen, setHubOpen] = useState(false);
+ const [hubClosing, setHubClosing] = useState(false);
+ const [selectedItemId, setSelectedItemId] = useState(
+ isHubPreviewNavigationItem(activeItemId) ? activeItemId : 'work-center',
+ );
+ const [newWorkDialogOpen, setNewWorkDialogOpen] = useState(false);
+ const triggerRef = useRef(null);
+ const panelRef = useRef(null);
+ const selectedItemRef = useRef(null);
+ const detailPrimaryActionRef = useRef(null);
+ const itemRefs = useRef(new Map());
+ const closeAnimationTimerRef = useRef(null);
+
+ const closeHub = useCallback(() => {
+ if (closeAnimationTimerRef.current !== null) {
+ window.clearTimeout(closeAnimationTimerRef.current);
+ }
+ setHubOpen(false);
+ setHubClosing(true);
+ closeAnimationTimerRef.current = window.setTimeout(() => {
+ setHubClosing(false);
+ closeAnimationTimerRef.current = null;
+ }, 240);
}, []);
-
- const closeMenu = useCallback(() => {
- clearCloseTimer();
- setMenuClosing(true);
- setIsDevKitSubmenuOpen(false);
- setTimeout(() => {
- setMenuOpen(false);
- setMenuClosing(false);
- }, 150);
- }, [clearCloseTimer]);
-
- const openMenu = useCallback(() => {
- clearCloseTimer();
- setIsDevKitSubmenuOpen(isDevKitChildActive);
- setMenuOpen(true);
- }, [clearCloseTimer, isDevKitChildActive]);
-
- const scheduleCloseMenu = useCallback(() => {
- if (!menuOpen) return;
- clearCloseTimer();
- closeTimerRef.current = window.setTimeout(() => {
- closeMenu();
- }, 320);
- }, [clearCloseTimer, closeMenu, menuOpen]);
-
- useEffect(() => clearCloseTimer, [clearCloseTimer]);
-
- const toggleMenu = useCallback(() => {
- if (menuOpen) {
- closeMenu();
+ const openHub = useCallback(() => {
+ if (closeAnimationTimerRef.current !== null) {
+ window.clearTimeout(closeAnimationTimerRef.current);
+ closeAnimationTimerRef.current = null;
+ }
+ setHubClosing(false);
+ setSelectedItemId(isHubPreviewNavigationItem(activeItemId) ? activeItemId : 'work-center');
+ setHubOpen(true);
+ }, [activeItemId]);
+
+ useEffect(() => () => {
+ if (closeAnimationTimerRef.current !== null) {
+ window.clearTimeout(closeAnimationTimerRef.current);
+ }
+ }, []);
+ const toggleHub = useCallback(() => {
+ if (hubOpen) {
+ closeHub();
return;
}
- openMenu();
- }, [closeMenu, menuOpen, openMenu]);
-
- const handleOpenShell = useCallback(() => {
- closeMenu();
- openWorkspaceScene('shell', { scope: defaultProjectScope });
- }, [closeMenu, defaultProjectScope]);
-
- const handleOpenFiles = useCallback(() => {
- closeMenu();
- openWorkspaceScene('file-viewer', { scope: defaultProjectScope });
- }, [closeMenu, defaultProjectScope]);
-
- const handleOpenAgenticOs = useCallback(async () => {
- closeMenu();
- try {
- await openAgenticOsSession();
- } catch (error) {
- log.error('Failed to open Agentic OS', error);
+ openHub();
+ }, [closeHub, hubOpen, openHub]);
+
+ useDialogFocusTrap({
+ enabled: hubOpen,
+ containerRef: panelRef,
+ initialFocusRef: selectedItemRef,
+ restoreFocus: true,
+ });
+
+ useEffect(() => {
+ const panel = panelRef.current;
+ if (!panel) return;
+ if (hubOpen) panel.removeAttribute('inert');
+ else panel.setAttribute('inert', '');
+ }, [hubOpen, hubClosing]);
+
+ const labels = useMemo>(() => Object.fromEntries(
+ WORKSPACE_HUB_ITEM_DEFINITIONS.map((item) => [item.id, t(item.labelKey)]),
+ ) as Record, [t]);
+
+ const capsuleLabel = useMemo(() => {
+ if (attentionWorkCount > 0) {
+ return t('nav.menuPanel.hub.capsule.attention', { count: attentionWorkCount });
}
- }, [closeMenu]);
-
- const handleCreateAgenticOsSession = useCallback(async (event: React.MouseEvent) => {
- event.stopPropagation();
- try {
- const sessionId = await flowChatManager.createChatSession(
- { storageScope: 'agentic_os' },
- getAgenticOsSessionDescriptor()
- );
- await openWorkspaceSession(sessionId);
- closeMenu();
- } catch (error) {
- log.error('Failed to create new Agentic OS session', error);
+ if (runningWorkCount > 0) {
+ return t('nav.menuPanel.hub.capsule.running', { count: runningWorkCount });
}
- }, [closeMenu]);
-
- const handleOpenMemory = useCallback(() => {
- closeMenu();
- openWorkspaceScene('memory');
- }, [closeMenu]);
-
- const handleOpenWorkCenter = useCallback(() => {
- closeMenu();
- openWorkCenterHome();
- }, [closeMenu]);
-
- const handleOpenDailyLetter = useCallback(() => {
- closeMenu();
- openWorkspaceScene('daily-letter');
- }, [closeMenu]);
-
- const handleOpenApps = useCallback(() => {
- closeMenu();
- openWorkspaceScene('apps');
- }, [closeMenu]);
+ if (hasUnreadDailyLetter) {
+ return t('nav.menuPanel.hub.capsule.dailyLetter');
+ }
+ return t('nav.menuPanel.hub.capsule.default');
+ }, [attentionWorkCount, hasUnreadDailyLetter, runningWorkCount, t]);
+
+ const openItem = useCallback(async (itemId: WorkspaceHubItemId) => {
+ closeHub();
+ const { openTarget } = getWorkspaceHubItem(itemId);
+ switch (openTarget.kind) {
+ case 'work-center':
+ openWorkCenterHome();
+ return;
+ case 'preview-only':
+ return;
+ case 'scene':
+ openWorkspaceScene(
+ openTarget.sceneId,
+ 'systemScope' in openTarget && openTarget.systemScope
+ ? { scope: systemRuntimeScope() }
+ : {},
+ );
+ }
+ }, [closeHub]);
- const handleOpenSkills = useCallback(() => {
- closeMenu();
- openWorkspaceScene('skills');
- }, [closeMenu]);
+ const openScene = useCallback((sceneId: WorkspaceSceneId) => {
+ closeHub();
+ openWorkspaceScene(sceneId);
+ }, [closeHub]);
- const handleOpenTools = useCallback(() => {
- closeMenu();
- openWorkspaceScene('tools');
- }, [closeMenu]);
+ const startNewWork = useCallback(() => {
+ closeHub();
+ setNewWorkDialogOpen(true);
+ }, [closeHub]);
- const handleOpenSubagents = useCallback(() => {
- closeMenu();
- openWorkspaceScene('subagents');
- }, [closeMenu]);
+ const openAbout = useCallback(() => {
+ closeHub();
+ window.dispatchEvent(new CustomEvent('nav:show-about'));
+ }, [closeHub]);
- const handleOpenSettings = useCallback(() => {
- closeMenu();
- openWorkspaceScene('settings');
- }, [closeMenu]);
+ const focusItem = useCallback((itemId: WorkspaceHubItemId) => {
+ window.requestAnimationFrame(() => itemRefs.current.get(itemId)?.focus());
+ }, []);
- const agenticOsTitle = `${t('nav.sessions.agenticOsShort')} — ${t('nav.menuPanel.agenticOSDesc')}`;
+ const handleGroupKeyDown = useCallback((
+ event: React.KeyboardEvent,
+ ids: readonly WorkspaceHubItemId[],
+ index: number,
+ columns: number,
+ moveToDetailOnRight = false,
+ ) => {
+ let nextIndex: number | null = null;
+ if (event.key === 'Home') nextIndex = 0;
+ if (event.key === 'End') nextIndex = ids.length - 1;
+ if (event.key === 'ArrowRight') {
+ if (columns === 1 && moveToDetailOnRight) {
+ event.preventDefault();
+ detailPrimaryActionRef.current?.focus();
+ return;
+ }
+ nextIndex = Math.min(ids.length - 1, index + 1);
+ }
+ if (event.key === 'ArrowLeft' && columns > 1) nextIndex = Math.max(0, index - 1);
+ if (event.key === 'ArrowDown') nextIndex = Math.min(ids.length - 1, index + columns);
+ if (event.key === 'ArrowUp') nextIndex = Math.max(0, index - columns);
+ if (nextIndex === null || nextIndex === index) return;
+ event.preventDefault();
+ focusItem(ids[nextIndex]);
+ }, [focusItem]);
+
+ const registerItemRef = useCallback((itemId: WorkspaceHubItemId, element: HTMLButtonElement | null) => {
+ if (element) itemRefs.current.set(itemId, element);
+ else itemRefs.current.delete(itemId);
+ }, []);
- return (
-
-
-
{
+ const selected = selectedItemId === itemId;
+ const current = activeItemId === itemId;
+ const unread = itemId === 'daily-letter' && hasUnreadDailyLetter;
+ const workMeta = itemId === 'work-center' && runningWorkCount > 0
+ ? t('nav.menuPanel.hub.states.running', { count: runningWorkCount })
+ : null;
+ const canOpenDirectly = getWorkspaceHubItem(itemId).openTarget.kind !== 'preview-only';
+
+ return (
+
+
+ {canOpenDirectly && (
{ void openItem(itemId); }}
>
- {menuOpen ? (
-
- ) : (
-
-
-
-
- )}
+
+ )}
+
+ );
+ };
+
+ const renderDirectItem = (itemId: WorkspaceHubDirectItemId, index: number) => {
+ const current = activeItemId === itemId;
+
+ return (
+
+ );
+ };
- {menuOpen && (
- <>
+ return (
+
+
+
+
+ {(hubOpen || hubClosing) && (
+ <>
+ {hubOpen && (
{
+ if (event.target === event.currentTarget) closeHub();
+ }}
/>
-
- {
+ if (event.target !== event.currentTarget || hubOpen) return;
+ if (closeAnimationTimerRef.current !== null) {
+ window.clearTimeout(closeAnimationTimerRef.current);
+ closeAnimationTimerRef.current = null;
+ }
+ setHubClosing(false);
+ }}
+ >
+
+
+
{
+ if (event.defaultPrevented || event.key !== 'Escape') return;
+ event.preventDefault();
+ event.stopPropagation();
+ closeHub();
+ }}
>
-
- {greeting}
+
+
+
+
+
+
+ {t('nav.menuPanel.hub.sections.preview')}
+
+
+
+
+
+
+ {t('nav.menuPanel.hub.sections.direct')}
+
+
+
+
+
+
+
-
-
-
- >
- )}
-
+
+
+
+
+ >
+ )}
+
+ {newWorkDialogOpen && (
+
setNewWorkDialogOpen(false)}
+ />
+ )}
);
};
diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceHubUtilityRail.tsx b/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceHubUtilityRail.tsx
new file mode 100644
index 00000000..ef32cab4
--- /dev/null
+++ b/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceHubUtilityRail.tsx
@@ -0,0 +1,291 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { ChevronLeft, Info, Languages } from 'lucide-react';
+import { Button, SegmentedControl } from '@/design-system';
+import { useLanguageSelector } from '@/infrastructure/i18n';
+import type { LocaleId, LocaleMetadata } from '@/infrastructure/i18n/types';
+import { useI18n } from '@/infrastructure/i18n/hooks/useI18n';
+import { builtinThemes, SYSTEM_THEME_ID, themeService, useTheme } from '@/infrastructure/theme';
+import type { ThemeSelectionId } from '@/infrastructure/theme';
+import { createLogger } from '@/shared/utils/logger';
+
+const log = createLogger('WorkspaceHubUtilityRail');
+const COLLAPSE_AFTER_SELECTION_MS = 280;
+
+interface WorkspaceHubUtilityRailProps {
+ onOpenAbout: () => void;
+}
+
+type ExpandedControl = 'theme' | 'language' | null;
+
+type ChoiceStyle = React.CSSProperties & {
+ '--hub-choice-count': number;
+ '--hub-choice-index': number;
+};
+
+type ThemePreviewStyle = React.CSSProperties & {
+ '--hub-theme-preview-left'?: string;
+ '--hub-theme-preview-right'?: string;
+};
+
+function choiceStyle(index: number, count: number): ChoiceStyle {
+ return {
+ '--hub-choice-count': Math.max(count, 1),
+ '--hub-choice-index': Math.max(index, 0),
+ };
+}
+
+function compactLocaleLabel(locale: LocaleMetadata): string {
+ if (locale.id.toLowerCase().startsWith('zh')) return '中文';
+ return locale.id.split('-')[0]?.toUpperCase() || locale.nativeName.slice(0, 2);
+}
+
+function ThemeColorPreview({ left, right }: { left?: string; right?: string }) {
+ const style: ThemePreviewStyle = {
+ '--hub-theme-preview-left': left,
+ '--hub-theme-preview-right': right,
+ };
+
+ return
;
+}
+
+export const WorkspaceHubUtilityRail: React.FC
= ({
+ onOpenAbout,
+}) => {
+ const { t } = useI18n('common');
+ const { currentLanguage, supportedLocales, selectLanguage, isChanging } = useLanguageSelector();
+ const { theme, themeId, themeType, themes, setTheme, loading: themeLoading } = useTheme();
+ const [expandedControl, setExpandedControl] = useState(null);
+ const collapseTimerRef = useRef(null);
+ const themeTriggerRef = useRef(null);
+ const languageTriggerRef = useRef(null);
+ const themeControlRef = useRef(null);
+ const languageControlRef = useRef(null);
+
+ const activeThemeChoice = themeId === SYSTEM_THEME_ID ? SYSTEM_THEME_ID : (themeId || themeType);
+ const themeOptions = useMemo(() => {
+ const lightTheme = builtinThemes.find((candidate) => candidate.id === 'light');
+ const darkTheme = builtinThemes.find((candidate) => candidate.id === 'dark');
+ const previewFor = (id: string) => {
+ if (id === SYSTEM_THEME_ID) {
+ return {
+ left: lightTheme?.colors.background.primary,
+ right: darkTheme?.colors.background.primary,
+ };
+ }
+ const candidate = themeService.getTheme(id) ?? builtinThemes.find((item) => item.id === id);
+ return {
+ left: candidate?.colors.background.primary,
+ right: candidate?.colors.accent[500],
+ };
+ };
+
+ return [
+ {
+ value: SYSTEM_THEME_ID,
+ label: {t('nav.menuPanel.hub.controls.systemTheme')},
+ icon: ,
+ },
+ ...themes.map((candidate) => ({
+ value: candidate.id,
+ label: {candidate.name},
+ icon: ,
+ })),
+ ];
+ }, [t, themes]);
+ const languageOptions = useMemo(() => supportedLocales.map((locale) => ({
+ value: locale.id,
+ label: {compactLocaleLabel(locale)},
+ })), [supportedLocales]);
+
+ const themeIndex = themeOptions.findIndex((option) => option.value === activeThemeChoice);
+ const languageIndex = languageOptions.findIndex((option) => option.value === currentLanguage);
+ const currentLocale = supportedLocales.find((locale) => locale.id === currentLanguage);
+
+ const currentThemeLabel = activeThemeChoice === SYSTEM_THEME_ID
+ ? t('nav.menuPanel.hub.controls.systemThemeShort')
+ : activeThemeChoice === 'light'
+ ? t('nav.menuPanel.hub.controls.lightTheme')
+ : activeThemeChoice === 'dark'
+ ? t('nav.menuPanel.hub.controls.darkTheme')
+ : theme?.name || activeThemeChoice;
+ const systemLightTheme = builtinThemes.find((candidate) => candidate.id === 'light');
+ const systemDarkTheme = builtinThemes.find((candidate) => candidate.id === 'dark');
+ const currentThemeIcon = activeThemeChoice === SYSTEM_THEME_ID
+ ? (
+
+ )
+ : (
+
+ );
+ const currentLanguageLabel = currentLocale ? compactLocaleLabel(currentLocale) : currentLanguage;
+
+ const clearCollapseTimer = useCallback(() => {
+ if (collapseTimerRef.current === null) return;
+ window.clearTimeout(collapseTimerRef.current);
+ collapseTimerRef.current = null;
+ }, []);
+
+ useEffect(() => () => clearCollapseTimer(), [clearCollapseTimer]);
+
+ const returnFocusToTrigger = useCallback((control: Exclude) => {
+ window.requestAnimationFrame(() => {
+ (control === 'theme' ? themeTriggerRef : languageTriggerRef).current?.focus();
+ });
+ }, []);
+
+ const collapseEditor = useCallback((control: Exclude) => {
+ clearCollapseTimer();
+ setExpandedControl(null);
+ returnFocusToTrigger(control);
+ }, [clearCollapseTimer, returnFocusToTrigger]);
+
+ const scheduleCollapse = useCallback((control: Exclude) => {
+ clearCollapseTimer();
+ collapseTimerRef.current = window.setTimeout(() => {
+ collapseTimerRef.current = null;
+ setExpandedControl(null);
+ returnFocusToTrigger(control);
+ }, COLLAPSE_AFTER_SELECTION_MS);
+ }, [clearCollapseTimer, returnFocusToTrigger]);
+
+ const openEditor = useCallback((control: Exclude) => {
+ clearCollapseTimer();
+ setExpandedControl(control);
+ window.requestAnimationFrame(() => {
+ const target = control === 'theme' ? themeControlRef.current : languageControlRef.current;
+ target?.querySelector('[role="radio"][aria-checked="true"]')?.focus();
+ });
+ }, [clearCollapseTimer]);
+
+ const handleThemeChange = useCallback((nextTheme: string) => {
+ void setTheme(nextTheme as ThemeSelectionId)
+ .catch((error: unknown) => {
+ log.error('Failed to change theme from Hub', { themeId: nextTheme, error });
+ })
+ .finally(() => scheduleCollapse('theme'));
+ }, [scheduleCollapse, setTheme]);
+
+ const handleLanguageChange = useCallback((nextLocale: string) => {
+ void selectLanguage(nextLocale as LocaleId)
+ .catch((error: unknown) => {
+ log.error('Failed to change language from Hub', { locale: nextLocale, error });
+ })
+ .finally(() => scheduleCollapse('language'));
+ }, [scheduleCollapse, selectLanguage]);
+
+ return (
+ {
+ if (event.key !== 'Escape' || !expandedControl) return;
+ event.preventDefault();
+ event.stopPropagation();
+ collapseEditor(expandedControl);
+ }}
+ >
+ {expandedControl === null ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+ {expandedControl === 'theme' ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ );
+};
diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/previews/AppsPreview.scss b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/AppsPreview.scss
new file mode 100644
index 00000000..70d912f6
--- /dev/null
+++ b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/AppsPreview.scss
@@ -0,0 +1,137 @@
+.sparo-workspace-hub-apps-preview {
+ .sparo-workspace-hub-preview__content {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: var(--ds-space-4);
+ margin-top: var(--ds-space-4);
+ }
+
+ .sparo-workspace-hub-preview__title {
+ font-size: var(--ds-font-size-xl);
+ }
+}
+
+.sparo-workspace-hub-apps-preview__header-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--ds-space-1);
+}
+
+.sparo-workspace-hub-apps-preview__running-section {
+ .sparo-workspace-hub-preview__empty {
+ min-height: 54px;
+ padding: var(--ds-space-3);
+
+ strong {
+ color: var(--ds-color-text-muted);
+ font-weight: var(--ds-font-weight-normal);
+ }
+ }
+}
+
+.sparo-workspace-hub-apps-preview__running-dock {
+ display: flex;
+ width: 100%;
+ min-height: 50px;
+ align-items: center;
+ gap: var(--ds-space-1);
+ padding: var(--ds-space-1);
+ border: 1px solid var(--ds-color-border-subtle);
+ border-radius: var(--ds-radius-xl);
+ background: color-mix(in srgb, var(--ds-color-bg-muted) 28%, var(--ds-color-bg-panel));
+ box-shadow: none;
+}
+
+.sparo-workspace-hub-apps-preview__running-item {
+ position: relative;
+ display: inline-flex;
+}
+
+.sparo-workspace-hub-apps-preview__running-indicator {
+ position: absolute;
+ right: 1px;
+ bottom: 1px;
+ width: 8px;
+ height: 8px;
+ border: 2px solid var(--ds-color-bg-panel);
+ border-radius: var(--ds-radius-full);
+ background: var(--ds-color-success);
+ pointer-events: none;
+}
+
+.sparo-workspace-hub-apps-preview__apps-grid-section {
+ margin-top: auto;
+}
+
+.sparo-workspace-hub-apps-preview__apps-section {
+ min-width: 0;
+
+ .sparo-workspace-hub-preview__section-header {
+ min-height: 22px;
+ padding-inline: var(--ds-space-1);
+ padding-bottom: var(--ds-space-2);
+ border-bottom: 0;
+
+ h3,
+ > span {
+ font-size: var(--ds-font-size-2xs);
+ }
+ }
+
+ .sparo-workspace-hub-preview__section-body {
+ padding-top: 0;
+ }
+}
+
+.sparo-workspace-hub-apps-preview__quick-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: var(--ds-space-2);
+}
+
+.sparo-workspace-hub-apps-preview .sparo-workspace-hub-apps-preview__quick-card {
+ width: 100%;
+ height: 92px;
+ min-width: 0;
+ flex-direction: column;
+ justify-content: center;
+ gap: var(--ds-space-2);
+ padding: var(--ds-space-2);
+ border-radius: var(--ds-radius-lg);
+ border: 1px solid var(--ds-color-border-subtle);
+ background: var(--ds-color-bg-muted);
+ transition:
+ border-color var(--ds-motion-fast) var(--ds-easing-standard),
+ background-color var(--ds-motion-fast) var(--ds-easing-standard),
+ transform var(--ds-motion-fast) var(--ds-easing-standard);
+
+ > span:last-child {
+ width: 100%;
+ overflow: hidden;
+ color: var(--ds-color-text-secondary);
+ font-size: var(--ds-font-size-xs);
+ font-weight: var(--ds-font-weight-medium);
+ line-height: 1.2;
+ text-align: center;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &:hover,
+ &:focus-visible {
+ background: var(--ds-color-bg-hover);
+ border-color: var(--ds-color-danger);
+ transform: scale(1.04);
+ }
+}
+
+@media (max-width: 560px) {
+ .sparo-workspace-hub-apps-preview__quick-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .sparo-workspace-hub-apps-preview .sparo-workspace-hub-apps-preview__quick-card {
+ height: 82px;
+ }
+}
diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/previews/AppsPreview.tsx b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/AppsPreview.tsx
new file mode 100644
index 00000000..4329a72c
--- /dev/null
+++ b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/AppsPreview.tsx
@@ -0,0 +1,356 @@
+import React, { useCallback, useEffect, useMemo } from 'react';
+import { LayoutGrid, Plus, Workflow } from 'lucide-react';
+import { Button, IconButton } from '@/design-system';
+import { useWorkStore } from '@/app/agentic-os/work/data/workStore';
+import type { WorkAppRef, WorkRecord, WorkStatus } from '@/app/agentic-os/work/domain/workTypes';
+import { catalogAppRequiresWorkspace } from '@/app/agentic-os/work/domain/productAppLaunchPolicy';
+import { openWork } from '@/app/agentic-os/work/navigation/openWork';
+import { AppIcon } from '@/app/scenes/apps/AppIcon';
+import { useAppsStore } from '@/app/scenes/apps/appsStore';
+import { createAndOpenAppBuilder } from '@/app/scenes/apps/app-builder/openAppBuilderSession';
+import { launchActiveIntelligentApp } from '@/app/scenes/apps/intelligentAppLaunchService';
+import { useProductAppRuntimeStore } from '@/app/scenes/apps/product-app-runtime/productAppRuntimeStore';
+import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext';
+import {
+ appCatalogAPI,
+ localizeCatalogApps,
+ type ProductAppCatalogEntry,
+ type ProductAppLibrary,
+} from '@/infrastructure/api/service-api/AppCatalogAPI';
+import { useI18n } from '@/infrastructure/i18n/hooks/useI18n';
+import { appScopeFromWorkspace, systemAppScope } from '@/shared/types/app-scope';
+import { createLogger } from '@/shared/utils/logger';
+import {
+ WorkspaceHubPreviewEmpty,
+ WorkspaceHubPreviewError,
+ WorkspaceHubPreviewFrame,
+ WorkspaceHubPreviewLoading,
+ WorkspaceHubPreviewSection,
+} from './WorkspaceHubPreviewFrame';
+import type { WorkspaceHubPreviewProps } from './workspaceHubPreviewTypes';
+import { useHubPreviewResource } from './useHubPreviewResource';
+import './AppsPreview.scss';
+
+const log = createLogger('WorkspaceHubAppsPreview');
+
+const CONTINUABLE_STATUSES = new Set([
+ 'active',
+ 'running',
+ 'waiting_user',
+ 'blocked',
+ 'paused',
+ 'interrupted',
+]);
+
+type AppActivityItem =
+ | { kind: 'work'; work: WorkRecord; appRef: WorkAppRef }
+ | { kind: 'app'; app: ProductAppCatalogEntry };
+
+function appRefFromWork(work: WorkRecord): WorkAppRef | null {
+ if (work.subject.kind === 'app') return work.subject.app;
+ return work.appRefs.find(({ role }) => role === 'subject')?.app
+ ?? work.appRefs.find(({ role }) => role === 'executor')?.app
+ ?? work.appRefs[0]?.app
+ ?? null;
+}
+
+const AppsPreview: React.FC = ({
+ label,
+ primaryActionRef,
+ onOpenItem,
+ onClose,
+}) => {
+ const { t, currentLanguage } = useI18n('common');
+ const { lastUsedWorkspace } = useWorkspaceContext();
+ const works = useWorkStore((state) => state.works);
+ const worksLoaded = useWorkStore((state) => state.loaded);
+ const worksLoading = useWorkStore((state) => state.loading);
+ const worksError = useWorkStore((state) => state.error);
+ const refreshWorks = useWorkStore((state) => state.refreshWorks);
+ const runningRuntimeAppIds = useProductAppRuntimeStore((state) => state.runningWorkerIds);
+ const recentRuntimeAppIds = useProductAppRuntimeStore((state) => state.recentAppIds);
+ const pinnedAppIds = useAppsStore((state) => state.pinnedAppIds);
+
+ const catalog = useHubPreviewResource(
+ 'workspace-hub:apps:system-catalog',
+ () => appCatalogAPI.listProductAppLibrary(),
+ { ttlMs: 30_000 },
+ );
+
+ useEffect(() => {
+ if (!worksLoaded && !worksLoading) void refreshWorks();
+ }, [refreshWorks, worksLoaded, worksLoading]);
+
+ const continuableWorks = useMemo(() => works
+ .map((work) => ({ work, appRef: appRefFromWork(work) }))
+ .filter((item): item is { work: WorkRecord; appRef: WorkAppRef } => (
+ Boolean(item.appRef) && CONTINUABLE_STATUSES.has(item.work.status)
+ ))
+ .sort((left, right) => right.work.updatedAt - left.work.updatedAt), [works]);
+
+ const installedApps = useMemo(
+ () => localizeCatalogApps(catalog.data?.installed ?? [], currentLanguage)
+ .filter((app) => app.enabled && app.catalogVisibility !== 'hidden'),
+ [catalog.data?.installed, currentLanguage],
+ );
+ const discoverApps = useMemo(
+ () => localizeCatalogApps(catalog.data?.discoverable ?? [], currentLanguage)
+ .filter((app) => app.installed !== true && app.catalogVisibility !== 'hidden'),
+ [catalog.data?.discoverable, currentLanguage],
+ );
+ const appById = useMemo(
+ () => new Map(installedApps.map((app) => [app.appId, app])),
+ [installedApps],
+ );
+ const installedAppBySurfaceId = useMemo(
+ () => new Map(installedApps.map((app) => [app.id, app])),
+ [installedApps],
+ );
+ const runningRuntimeIdSet = useMemo(
+ () => new Set(runningRuntimeAppIds),
+ [runningRuntimeAppIds],
+ );
+ const workAppIds = useMemo(
+ () => new Set(continuableWorks.map(({ appRef }) => appRef.appId)),
+ [continuableWorks],
+ );
+ const runningAppsWithoutWork = useMemo(
+ () => installedApps.filter((app) => (
+ runningRuntimeIdSet.has(app.id) && !workAppIds.has(app.appId)
+ )),
+ [installedApps, runningRuntimeIdSet, workAppIds],
+ );
+ const activityItems = useMemo(() => [
+ ...continuableWorks.map((item) => ({ kind: 'work' as const, ...item })),
+ ...runningAppsWithoutWork.map((app) => ({ kind: 'app' as const, app })),
+ ], [continuableWorks, runningAppsWithoutWork]);
+ const runningDockItems = useMemo(() => {
+ const seenAppIds = new Set();
+ return activityItems.filter((item) => {
+ const running = item.kind === 'app' || item.work.status === 'running';
+ const appId = item.kind === 'app' ? item.app.appId : item.appRef.appId;
+ if (!running || seenAppIds.has(appId)) return false;
+ seenAppIds.add(appId);
+ return true;
+ }).slice(0, 4);
+ }, [activityItems]);
+ const recentApps = useMemo(
+ () => recentRuntimeAppIds
+ .map((id) => installedAppBySurfaceId.get(id))
+ .filter((app): app is ProductAppCatalogEntry => Boolean(app)),
+ [installedAppBySurfaceId, recentRuntimeAppIds],
+ );
+ const pinnedApps = useMemo(
+ () => pinnedAppIds
+ .map((id) => installedApps.find((app) => app.appId === id || app.id === id))
+ .filter((app): app is ProductAppCatalogEntry => Boolean(app)),
+ [installedApps, pinnedAppIds],
+ );
+ const visibleApps = useMemo(() => {
+ const candidates = [...pinnedApps, ...recentApps, ...installedApps, ...discoverApps];
+ const selected: ProductAppCatalogEntry[] = [];
+ const selectedAppIds = new Set();
+
+ for (const app of candidates) {
+ if (selected.length >= 8) break;
+ if (selectedAppIds.has(app.appId)) continue;
+ selected.push(app);
+ selectedAppIds.add(app.appId);
+ }
+
+ return selected;
+ }, [discoverApps, installedApps, pinnedApps, recentApps]);
+
+ const openAppCenter = useCallback(() => {
+ useAppsStore.getState().openHome();
+ onOpenItem('apps');
+ }, [onOpenItem]);
+
+ const openAppDetails = useCallback((appId: string) => {
+ useAppsStore.getState().openAppDetail(appId);
+ onOpenItem('apps');
+ }, [onOpenItem]);
+
+ const handleOpenWork = useCallback((work: WorkRecord) => {
+ onClose();
+ void openWork(work).catch((error) => {
+ log.error('Failed to open app work from Workspace Hub', { workId: work.id, error });
+ });
+ }, [onClose]);
+
+ const handleOpenApp = useCallback((app: ProductAppCatalogEntry) => {
+ if (!app.activeRef || catalogAppRequiresWorkspace(app)) {
+ openAppDetails(app.id);
+ return;
+ }
+
+ onClose();
+ void launchActiveIntelligentApp(app.activeRef, {
+ scope: appScopeFromWorkspace(lastUsedWorkspace) ?? systemAppScope(),
+ title: app.name,
+ objective: app.description || app.name,
+ }).catch((error) => {
+ log.error('Failed to launch app from Workspace Hub', { appId: app.id, error });
+ useAppsStore.getState().openAppDetail(app.id);
+ onOpenItem('apps');
+ });
+ }, [lastUsedWorkspace, onClose, onOpenItem, openAppDetails]);
+
+ const handleCreateApp = useCallback(() => {
+ onClose();
+ void createAndOpenAppBuilder({
+ scope: appScopeFromWorkspace(lastUsedWorkspace) ?? systemAppScope(),
+ }).catch((error) => {
+ log.error('Failed to create app from Workspace Hub', { error });
+ });
+ }, [lastUsedWorkspace, onClose]);
+
+ const handleOpenActivity = useCallback((item: AppActivityItem) => {
+ if (item.kind === 'app') {
+ handleOpenApp(item.app);
+ return;
+ }
+ handleOpenWork(item.work);
+ }, [handleOpenApp, handleOpenWork]);
+
+ const activityTitle = useCallback((item: AppActivityItem) => (
+ item.kind === 'app' ? item.app.name : item.work.title
+ ), []);
+
+ const renderActivityIcon = useCallback((item: AppActivityItem, size: number) => {
+ if (item.kind === 'app') return ;
+ const app = appById.get(item.appRef.appId);
+ return app ? : ;
+ }, [appById]);
+
+ const initialLoading = (!worksLoaded && !worksError) || (catalog.loading && !catalog.data);
+
+ return (
+
+
+
+
+
+
+
+
+ )}
+ >
+
+ {initialLoading ? (
+
+ ) : worksError && !runningDockItems.length ? (
+
{ void refreshWorks(); }}
+ />
+ ) : runningDockItems.length ? (
+
+ {runningDockItems.map((item) => {
+ const title = activityTitle(item);
+ const key = item.kind === 'app' ? `app:${item.app.id}` : `work:${item.work.id}`;
+ return (
+
+ handleOpenActivity(item)}
+ >
+ {renderActivityIcon(item, 26)}
+
+
+
+ );
+ })}
+
+ ) : (
+
+ )}
+
+
+
+
+ {catalog.loading && !catalog.data ? (
+
+ ) : catalog.error && !catalog.data ? (
+
+ ) : visibleApps.length ? (
+
+ {visibleApps.map((app) => {
+ const installed = app.installed === true;
+ return (
+
+ );
+ })}
+
+ ) : (
+
+ )}
+
+
+
+ );
+};
+
+export default AppsPreview;
diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/previews/CapabilitiesPreview.scss b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/CapabilitiesPreview.scss
new file mode 100644
index 00000000..adba1167
--- /dev/null
+++ b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/CapabilitiesPreview.scss
@@ -0,0 +1,201 @@
+@use '@/design-system/foundation/tokens/tokens.scss' as *;
+
+.sparo-workspace-hub-capabilities-preview {
+ .sparo-workspace-hub-preview__title {
+ font-size: var(--ds-font-size-xl);
+ }
+
+ .sparo-workspace-hub-preview__content {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: 0;
+ margin-top: 18px;
+ }
+}
+
+.sparo-workspace-hub-capabilities-preview__header-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--ds-space-1);
+}
+
+.sparo-workspace-hub-capabilities-preview__body {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.sparo-workspace-hub-capabilities-preview__map {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr) 24px minmax(0, 1fr);
+ align-items: center;
+}
+
+.sparo-workspace-hub-capabilities-preview__node {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+ min-height: 116px;
+ padding: var(--ds-space-2);
+ color: var(--ds-color-text-muted);
+ background: transparent;
+ border: 0;
+ transform: none;
+
+ &:hover:not(:disabled),
+ &:focus-visible {
+ color: var(--ds-color-text-primary);
+ background: var(--ds-color-surface-hover);
+ transform: none;
+ }
+
+ > strong {
+ color: var(--ds-color-text-primary);
+ font-size: var(--ds-font-size-xl);
+ font-weight: var(--ds-font-weight-semibold);
+ line-height: 1;
+ }
+
+ > span:not(.sparo-workspace-hub-capabilities-preview__node-icon),
+ > small {
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ > span:not(.sparo-workspace-hub-capabilities-preview__node-icon) {
+ color: var(--ds-color-text-secondary);
+ font-size: var(--ds-font-size-2xs);
+ }
+
+ > small {
+ color: var(--ds-color-text-muted);
+ font-size: calc(var(--ds-font-size-2xs) - 1px);
+ }
+
+ &.is-positive .sparo-workspace-hub-capabilities-preview__node-icon {
+ color: var(--ds-color-success);
+ background: color-mix(in srgb, var(--ds-color-success) 8%, transparent);
+ }
+
+ &.is-danger .sparo-workspace-hub-capabilities-preview__node-icon {
+ color: var(--ds-color-danger);
+ background: color-mix(in srgb, var(--ds-color-danger) 8%, transparent);
+ }
+
+ &.is-accent .sparo-workspace-hub-capabilities-preview__node-icon {
+ color: var(--ds-color-accent-500);
+ background: color-mix(in srgb, var(--ds-color-accent-500) 8%, transparent);
+ }
+}
+
+.sparo-workspace-hub-capabilities-preview__node-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 38px;
+ height: 38px;
+ margin-bottom: 2px;
+ color: var(--ds-color-text-secondary);
+ background: var(--ds-color-surface-hover);
+ border-radius: var(--ds-radius-full);
+}
+
+.sparo-workspace-hub-capabilities-preview__connector {
+ height: 1px;
+ background: linear-gradient(
+ 90deg,
+ var(--ds-color-border-subtle),
+ var(--ds-color-border-base),
+ var(--ds-color-border-subtle)
+ );
+}
+
+.sparo-workspace-hub-capabilities-preview__specialists {
+ padding-top: var(--ds-space-3);
+ border-top: 1px solid var(--ds-color-border-subtle);
+
+ > h3 {
+ margin: 0 0 var(--ds-space-2);
+ color: var(--ds-color-text-muted);
+ font-size: var(--ds-font-size-2xs);
+ font-weight: var(--ds-font-weight-medium);
+ }
+}
+
+.sparo-workspace-hub-capabilities-preview__specialist-strip {
+ display: flex;
+ align-items: center;
+ gap: var(--ds-space-1);
+ overflow: hidden;
+
+ > button {
+ display: inline-flex;
+ min-width: 0;
+ gap: 5px;
+
+ > span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+}
+
+.sparo-workspace-hub-capabilities-preview__specialist-empty {
+ color: var(--ds-color-text-muted);
+ font-size: var(--ds-font-size-xs);
+}
+
+.sparo-workspace-hub-capabilities-preview__pulse {
+ display: flex;
+ align-items: center;
+ gap: var(--ds-space-3);
+ margin-top: auto;
+ padding-top: var(--ds-space-3);
+ color: var(--ds-color-text-muted);
+ font-size: var(--ds-font-size-2xs);
+ border-top: 1px solid var(--ds-color-border-subtle);
+
+ > span {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ white-space: nowrap;
+
+ > span {
+ width: 6px;
+ height: 6px;
+ background: var(--ds-color-text-muted);
+ border-radius: var(--ds-radius-full);
+ }
+
+ &.is-positive > span { background: var(--ds-color-success); }
+ &.is-warning > span { background: var(--ds-color-warning); }
+ &.is-danger > span { background: var(--ds-color-danger); }
+ }
+
+ > button {
+ display: inline-flex;
+ gap: 5px;
+ margin-left: auto;
+ }
+}
+
+@media (max-width: 540px) {
+ .sparo-workspace-hub-capabilities-preview__map {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: var(--ds-space-1);
+ }
+
+ .sparo-workspace-hub-capabilities-preview__connector,
+ .sparo-workspace-hub-capabilities-preview__pulse > span:nth-child(2) {
+ display: none;
+ }
+}
diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/previews/CapabilitiesPreview.tsx b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/CapabilitiesPreview.tsx
new file mode 100644
index 00000000..1fe44101
--- /dev/null
+++ b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/CapabilitiesPreview.tsx
@@ -0,0 +1,256 @@
+import React, { useCallback, useMemo } from 'react';
+import { Blocks, Plug, RotateCcw, Server, ShieldAlert } from 'lucide-react';
+import { Button, IconButton, SparoSubagentIcon } from '@/design-system';
+import { BUILTIN_TOOLS } from '@/app/scenes/tools/data/builtinTools';
+import { useLastUsedWorkspace } from '@/infrastructure/contexts/WorkspaceContext';
+import { useI18n } from '@/infrastructure/i18n/hooks/useI18n';
+import MCPAPI, { type MCPServerInfo } from '@/infrastructure/api/service-api/MCPAPI';
+import { toolAPI } from '@/infrastructure/api/service-api/ToolAPI';
+import { SubagentAPI, type SubagentInfo } from '@/infrastructure/api/service-api/SubagentAPI';
+import {
+ WorkspaceHubPreviewFrame,
+ WorkspaceHubPreviewLoading,
+ type WorkspaceHubPreviewTone,
+} from './WorkspaceHubPreviewFrame';
+import type { WorkspaceHubPreviewProps } from './workspaceHubPreviewTypes';
+import { useHubPreviewResource } from './useHubPreviewResource';
+import './CapabilitiesPreview.scss';
+
+interface RegisteredTool {
+ name: string;
+}
+
+function isConnected(server: MCPServerInfo): boolean {
+ return /^(Connected|Healthy)$/i.test(server.status);
+}
+
+function needsAttention(server: MCPServerInfo): boolean {
+ return /^(NeedsAuth|Failed)$/i.test(server.status);
+}
+
+function sourcePriority(item: SubagentInfo): number {
+ if (item.subagentSource === 'project') return 0;
+ if (item.subagentSource === 'user') return 1;
+ return 2;
+}
+
+const CapabilitiesPreview: React.FC
= ({
+ label,
+ primaryActionRef,
+ onOpenScene,
+}) => {
+ const { t } = useI18n('common');
+ const { workspacePath } = useLastUsedWorkspace();
+
+ const serversResource = useHubPreviewResource(
+ 'workspace-hub:capabilities:mcp-servers',
+ () => MCPAPI.getServers(),
+ );
+ const toolsResource = useHubPreviewResource(
+ 'workspace-hub:capabilities:registered-tools',
+ async () => toolAPI.getAllToolsInfo() as Promise,
+ );
+ const subagentsResource = useHubPreviewResource(
+ `workspace-hub:capabilities:subagents:${workspacePath || 'global'}`,
+ () => SubagentAPI.listSubagents({ workspacePath: workspacePath || undefined }),
+ );
+
+ const servers = serversResource.data ?? [];
+ const connectedCount = servers.filter(isConnected).length;
+ const attentionCount = servers.filter(needsAttention).length;
+ const mcpToolCount = toolsResource.data
+ ?.filter((tool) => tool.name.startsWith('mcp__')).length ?? null;
+ const readySubagents = useMemo(
+ () => [...(subagentsResource.data ?? [])]
+ .filter((item) => item.enabled)
+ .sort((left, right) => (
+ sourcePriority(left) - sourcePriority(right) || left.name.localeCompare(right.name)
+ ))
+ .slice(0, 3),
+ [subagentsResource.data],
+ );
+
+ const executionLoading = !serversResource.data
+ && !toolsResource.data
+ && (serversResource.loading || toolsResource.loading);
+ const anyUnavailable = Boolean(
+ serversResource.error || toolsResource.error || subagentsResource.error,
+ );
+
+ let statusKey = 'nav.menuPanel.hub.preview.capabilities.status.ready';
+ let statusTone: WorkspaceHubPreviewTone = 'positive';
+ if (executionLoading && subagentsResource.loading && !subagentsResource.data) {
+ statusKey = 'nav.menuPanel.hub.preview.common.loading';
+ statusTone = 'neutral';
+ } else if (attentionCount > 0) {
+ statusKey = 'nav.menuPanel.hub.preview.capabilities.status.attention';
+ statusTone = 'danger';
+ } else if (anyUnavailable) {
+ statusKey = 'nav.menuPanel.hub.preview.capabilities.status.partial';
+ statusTone = 'warning';
+ } else if (!connectedCount && !readySubagents.length) {
+ statusKey = 'nav.menuPanel.hub.preview.capabilities.status.idle';
+ statusTone = 'neutral';
+ }
+
+ const openTools = useCallback(() => onOpenScene('tools'), [onOpenScene]);
+ const openSubagents = useCallback(() => onOpenScene('subagents'), [onOpenScene]);
+ const refreshServers = serversResource.refresh;
+ const refreshTools = toolsResource.refresh;
+ const refreshSubagents = subagentsResource.refresh;
+ const refreshAll = useCallback(() => {
+ refreshServers();
+ refreshTools();
+ refreshSubagents();
+ }, [refreshServers, refreshSubagents, refreshTools]);
+
+ const initialLoading = executionLoading
+ && subagentsResource.loading
+ && !subagentsResource.data;
+
+ return (
+
+
+
+
+
+
+
+
+ )}
+ >
+ {initialLoading ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('nav.menuPanel.hub.preview.capabilities.sections.specialists')}
+ {readySubagents.length > 0 ? (
+
+ {readySubagents.map((subagent) => (
+
+ ))}
+
+ ) : (
+
+ {subagentsResource.data?.length
+ ? t('nav.menuPanel.hub.preview.capabilities.empty.noneEnabledTitle')
+ : t('nav.menuPanel.hub.preview.capabilities.empty.noneTitle')}
+
+ )}
+
+
+
+
+
+ {t(statusKey)}
+
+ {mcpToolCount !== null && (
+
+
+ {mcpToolCount} {t('nav.menuPanel.hub.preview.capabilities.execution.mcpTools')}
+
+ )}
+ {anyUnavailable && (
+
+ )}
+
+
+ )}
+
+ );
+};
+
+export default CapabilitiesPreview;
diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/previews/DailyLetterPreview.scss b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/DailyLetterPreview.scss
new file mode 100644
index 00000000..cf52615a
--- /dev/null
+++ b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/DailyLetterPreview.scss
@@ -0,0 +1,115 @@
+@use '@/design-system/foundation/tokens/tokens.scss' as *;
+
+$daily-letter-serif: georgia, 'Iowan Old Style', 'Times New Roman', 'Songti SC', simsun, 'Noto Serif CJK SC', serif;
+
+.sparo-workspace-hub-daily-letter-preview {
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+ flex-direction: column;
+ animation: sparo-workspace-hub-stage-in 220ms cubic-bezier(0.16, 1, 0.3, 1) both;
+
+ .sparo-workspace-hub-preview__loading,
+ .sparo-workspace-hub-preview__error,
+ .sparo-workspace-hub-preview__empty {
+ width: 100%;
+ }
+
+ .sparo-workspace-hub-preview__empty {
+ flex: 1 1 auto;
+ min-height: 0;
+ padding: 0;
+ }
+}
+
+.sparo-workspace-hub-daily-letter-preview__header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--ds-space-3);
+ min-width: 0;
+
+ h2 {
+ margin: 0;
+ color: var(--ds-color-text-primary);
+ font-size: calc(var(--ds-font-size-2xl) + 2px);
+ font-weight: var(--ds-font-weight-semibold);
+ line-height: 1.15;
+ }
+
+}
+
+.sparo-workspace-hub-daily-letter-preview__content {
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+ flex-direction: column;
+ margin-top: 18px;
+}
+
+.sparo-workspace-hub-daily-letter-preview__letter {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ padding: 24px 26px 14px;
+ border: 1px solid color-mix(in srgb, var(--ds-color-border-subtle) 72%, transparent);
+ border-radius: var(--ds-radius-xl);
+ background: color-mix(in srgb, var(--ds-color-bg-panel) 82%, transparent);
+ box-shadow:
+ 0 1px 2px var(--ds-shadow-color-soft),
+ 0 10px 24px color-mix(in srgb, var(--ds-shadow-color-modal) 12%, transparent);
+
+ > .sparo-workspace-hub-preview__actions {
+ justify-content: flex-end;
+ margin-top: auto;
+ padding-top: 12px;
+ border-top: 0;
+ }
+}
+
+.sparo-workspace-hub-daily-letter-preview__letter-body {
+ display: flex;
+ min-height: 0;
+ flex: 1 1 auto;
+ overflow: hidden;
+}
+
+.sparo-workspace-hub-daily-letter-preview__copy {
+ min-width: 0;
+ max-height: 100%;
+ overflow: hidden;
+
+ p {
+ display: -webkit-box;
+ margin: 0;
+ overflow: hidden;
+ color: var(--ds-color-text-secondary);
+ font-family: $daily-letter-serif;
+ font-size: var(--ds-font-size-sm);
+ line-height: 1.72;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+
+ + p {
+ margin-top: 13px;
+ }
+ }
+}
+
+@media (max-width: 540px) {
+ .sparo-workspace-hub-daily-letter-preview__header h2 {
+ font-size: var(--ds-font-size-2xl);
+ }
+
+ .sparo-workspace-hub-daily-letter-preview__letter {
+ padding: 18px 18px 12px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .sparo-workspace-hub-daily-letter-preview {
+ animation: none;
+ }
+}
diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/previews/DailyLetterPreview.tsx b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/DailyLetterPreview.tsx
new file mode 100644
index 00000000..4afeb42f
--- /dev/null
+++ b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/DailyLetterPreview.tsx
@@ -0,0 +1,233 @@
+import React, { useMemo, useState } from 'react';
+import { MailOpen, ScrollText } from 'lucide-react';
+import { IconButton } from '@/design-system';
+import { useDailyLetterArrivalStore } from '@/app/daily-letter-arrival/store/dailyLetterArrivalStore';
+import { dailyLetterApi } from '@/app/scenes/daily-letter/dailyLetterApi';
+import { todayKey } from '@/app/scenes/daily-letter/dailyLetterDateUtils';
+import type {
+ DailyLetterRecord,
+ DailyLetterState,
+} from '@/app/scenes/daily-letter/dailyLetterTypes';
+import { useLastUsedWorkspace } from '@/infrastructure/contexts/WorkspaceContext';
+import { useI18n } from '@/infrastructure/i18n/hooks/useI18n';
+import {
+ WorkspaceHubPreviewAction,
+ WorkspaceHubPreviewEmpty,
+ WorkspaceHubPreviewError,
+ WorkspaceHubPreviewLoading,
+} from './WorkspaceHubPreviewFrame';
+import type { WorkspaceHubPreviewProps } from './workspaceHubPreviewTypes';
+import { useHubPreviewResource } from './useHubPreviewResource';
+import './DailyLetterPreview.scss';
+
+interface DailyLetterPreviewData {
+ letters: DailyLetterRecord[] | null;
+ state: DailyLetterState | null;
+ lettersPartial: boolean;
+ stateFailed: boolean;
+}
+
+function sortLetters(letters: DailyLetterRecord[]): DailyLetterRecord[] {
+ return [...letters].sort((left, right) => (
+ right.date.localeCompare(left.date) || right.updatedAtMs - left.updatedAtMs
+ ));
+}
+
+function letterPreviewParagraphs(markdown: string, fallback: string): string[] {
+ const paragraphs = markdown
+ .replace(/```[\s\S]*?```/g, ' ')
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
+ .split(/\n\s*\n/)
+ .map((paragraph) => paragraph
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
+ .replace(/^#{1,6}\s+/gm, '')
+ .replace(/^\s*[-+]\s+/gm, '')
+ .replace(/[*_~`>|]/g, '')
+ .replace(/\s+/g, ' ')
+ .trim())
+ .filter(Boolean);
+
+ return paragraphs.length > 0 ? paragraphs.slice(0, 3) : [fallback];
+}
+
+const DailyLetterPreview: React.FC
= ({
+ label,
+ primaryActionRef,
+ onOpenItem,
+ onClose,
+}) => {
+ const { t } = useI18n('common');
+ const { workspacePath } = useLastUsedWorkspace();
+ const openRecord = useDailyLetterArrivalStore((state) => state.openRecord);
+ const [generating, setGenerating] = useState(false);
+ const [generateFailed, setGenerateFailed] = useState(false);
+ const key = `workspace-hub:daily-letter:${workspacePath || 'global'}`;
+
+ const resource = useHubPreviewResource(key, async () => {
+ const requests = [dailyLetterApi.list({ scope: 'agentic_os', limit: 4 })];
+ if (workspacePath) {
+ requests.push(dailyLetterApi.list({ scope: 'workspace', workspacePath, limit: 4 }));
+ }
+
+ const [letterResults, stateResult] = await Promise.all([
+ Promise.allSettled(requests),
+ Promise.resolve(dailyLetterApi.state()).then(
+ (value) => ({ ok: true as const, value }),
+ () => ({ ok: false as const, value: null }),
+ ),
+ ]);
+ const fulfilledLetterLists = letterResults
+ .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled')
+ .flatMap((result) => result.value);
+ const allLetterRequestsFailed = letterResults.every((result) => result.status === 'rejected');
+
+ if (allLetterRequestsFailed && !stateResult.ok) {
+ const firstFailure = letterResults.find((result) => result.status === 'rejected');
+ throw firstFailure?.status === 'rejected' ? firstFailure.reason : new Error('daily-letter-preview');
+ }
+
+ const merged = new Map();
+ fulfilledLetterLists.forEach((letter) => merged.set(letter.id, letter));
+ return {
+ letters: allLetterRequestsFailed ? null : sortLetters(Array.from(merged.values())),
+ state: stateResult.value,
+ lettersPartial: letterResults.some((result) => result.status === 'rejected'),
+ stateFailed: !stateResult.ok,
+ };
+ });
+
+ const today = todayKey();
+ const todayLetter = useMemo(() => {
+ const candidates = resource.data?.letters?.filter((letter) => letter.date === today) ?? [];
+ return candidates.find((letter) => letter.scope === 'workspace') ?? candidates[0] ?? null;
+ }, [resource.data?.letters, today]);
+ const writing = resource.data?.state?.lastAttemptStatus === 'running'
+ && resource.data.state.activeDate === today;
+ const canGenerate = Boolean(
+ resource.data
+ && !resource.data.lettersPartial
+ && !resource.loading
+ && !resource.error
+ && !todayLetter
+ && !writing,
+ );
+ const previewParagraphs = useMemo(
+ () => letterPreviewParagraphs(
+ todayLetter?.bodyMarkdown ?? '',
+ todayLetter?.preview.oneLine ?? '',
+ ),
+ [todayLetter?.bodyMarkdown, todayLetter?.preview.oneLine],
+ );
+
+ const handleGenerate = async () => {
+ setGenerating(true);
+ setGenerateFailed(false);
+ try {
+ const scope = workspacePath ? 'workspace' : 'agentic_os';
+ await dailyLetterApi.generate({
+ date: today,
+ scope,
+ workspacePath: scope === 'workspace' ? workspacePath : null,
+ force: false,
+ });
+ resource.refresh();
+ } catch {
+ setGenerateFailed(true);
+ } finally {
+ setGenerating(false);
+ }
+ };
+
+ const handleExpandReading = () => {
+ if (!todayLetter) return;
+ onClose();
+ openRecord(todayLetter);
+ };
+
+ const actions = todayLetter ? (
+
+ {t('nav.menuPanel.hub.preview.dailyLetter.actions.expandReading')}
+
+ ) : canGenerate ? (
+ { void handleGenerate(); }}
+ >
+
+ {t('nav.menuPanel.hub.preview.dailyLetter.actions.generate')}
+
+ ) : (
+ onOpenItem('daily-letter')}
+ >
+ {t('nav.menuPanel.hub.preview.dailyLetter.actions.open')}
+
+ );
+
+ return (
+
+
+ {label}
+ onOpenItem('daily-letter')}
+ >
+
+
+
+
+
+
+
+ {resource.loading && !resource.data ? (
+
+ ) : resource.error || !resource.data ? (
+
+ ) : generateFailed ? (
+
{ void handleGenerate(); }}
+ />
+ ) : todayLetter ? (
+
+ {previewParagraphs.map((paragraph, index) => (
+
{paragraph}
+ ))}
+
+ ) : writing ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default DailyLetterPreview;
diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/previews/FilesPreview.scss b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/FilesPreview.scss
new file mode 100644
index 00000000..ab0a97f1
--- /dev/null
+++ b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/FilesPreview.scss
@@ -0,0 +1,298 @@
+@use '@/design-system/foundation/tokens/tokens.scss' as *;
+
+.sparo-workspace-hub-files-preview {
+ .sparo-workspace-hub-preview__title {
+ font-size: var(--ds-font-size-xl);
+ }
+
+ .sparo-workspace-hub-preview__summary {
+ margin-top: var(--ds-space-4);
+ }
+
+ .sparo-workspace-hub-preview__content {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: var(--ds-space-3);
+ margin-top: var(--ds-space-4);
+ overflow: hidden;
+ }
+
+ .sparo-workspace-hub-preview__section-header {
+ min-height: 18px;
+ padding: 0 var(--ds-space-1) 6px;
+ border-bottom: 0;
+
+ h3 {
+ color: var(--ds-color-text-muted);
+ font-size: var(--ds-font-size-2xs);
+ font-weight: var(--ds-font-weight-normal);
+ }
+ }
+
+ .sparo-workspace-hub-preview__section-body {
+ overflow: hidden;
+ }
+
+ .sparo-workspace-hub-preview__row {
+ min-height: 42px;
+ padding: 5px var(--ds-space-2);
+
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ &.is-active {
+ color: var(--ds-color-text-primary);
+ background: var(--ds-color-element-medium, var(--element-bg-medium));
+ }
+ }
+
+ .sparo-workspace-hub-preview__row-icon {
+ width: 26px;
+ height: 26px;
+ color: var(--ds-color-text-secondary);
+ background: transparent;
+ }
+
+ .sparo-workspace-hub-preview__row-copy {
+ gap: 3px;
+
+ strong {
+ font-size: var(--ds-font-size-xs);
+ }
+ }
+
+ .sparo-workspace-hub-preview__row-chevron {
+ color: var(--ds-color-text-secondary);
+ }
+
+ .sparo-workspace-hub-preview__empty {
+ min-height: 84px;
+ padding: var(--ds-space-3) 0;
+ }
+}
+
+.sparo-workspace-hub-files-preview__header-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--ds-space-2);
+}
+
+.sparo-workspace-hub-files-preview__search {
+ .ds-input-container {
+ min-height: 40px;
+ padding-inline: 11px;
+ border-color: var(--ds-color-border-base);
+ border-radius: var(--ds-radius-lg);
+ box-shadow: none;
+ }
+
+ .ds-input-prefix,
+ .ds-input-suffix {
+ color: var(--ds-color-text-muted);
+ }
+
+ .ds-input {
+ font-size: var(--ds-font-size-sm);
+ }
+}
+
+.sparo-workspace-hub-files-preview__clear {
+ flex: 0 0 auto;
+}
+
+.sparo-workspace-hub-files-preview__context {
+ overflow: hidden;
+ border-radius: var(--ds-radius-xl);
+ background: transparent;
+}
+
+.sparo-workspace-hub-files-preview .sparo-workspace-hub-files-preview__context-heading {
+ display: grid;
+ grid-template-columns: 30px minmax(0, 1fr) 14px;
+ gap: var(--ds-space-2);
+ width: 100%;
+ min-height: 56px;
+ padding: 8px var(--ds-space-3);
+ border: 0;
+ border-radius: var(--ds-radius-lg);
+ background: var(--ds-color-element-soft, var(--element-bg-soft));
+ backdrop-filter: blur(10px) saturate(1.08);
+ text-align: left;
+ transition:
+ color var(--ds-motion-fast) var(--ds-easing-standard),
+ background-color var(--ds-motion-fast) var(--ds-easing-standard);
+
+ &:hover:not(:disabled),
+ &:focus-visible {
+ color: var(--ds-color-text-primary);
+ background: color-mix(
+ in srgb,
+ var(--ds-color-element-medium, var(--element-bg-medium)) 92%,
+ var(--ds-color-accent-500) 8%
+ );
+ }
+
+ > svg {
+ color: var(--ds-color-text-muted);
+ }
+}
+
+.sparo-workspace-hub-files-preview__context-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ color: var(--ds-color-warning);
+ background: transparent;
+}
+
+.sparo-workspace-hub-files-preview__context-copy {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 0;
+
+ strong {
+ overflow: hidden;
+ color: var(--ds-color-text-primary);
+ font-size: var(--ds-font-size-sm);
+ font-weight: var(--ds-font-weight-semibold);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+.sparo-workspace-hub-files-preview__contents {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-top: 5px;
+
+ .sparo-workspace-hub-preview__loading,
+ .sparo-workspace-hub-preview__error,
+ .sparo-workspace-hub-preview__empty {
+ margin: var(--ds-space-3);
+ }
+}
+
+.sparo-workspace-hub-files-preview .sparo-workspace-hub-files-preview__content-row {
+ display: grid;
+ grid-template-columns: 24px minmax(0, 1fr) auto;
+ gap: var(--ds-space-2);
+ width: 100%;
+ min-height: 40px;
+ padding: 4px var(--ds-space-3);
+ border: 0;
+ border-radius: var(--ds-radius-md);
+ background: var(--ds-color-element-soft, var(--element-bg-soft));
+ backdrop-filter: blur(8px) saturate(1.04);
+ text-align: left;
+ transition:
+ color var(--ds-motion-fast) var(--ds-easing-standard),
+ background-color var(--ds-motion-fast) var(--ds-easing-standard),
+ transform var(--ds-motion-fast) var(--ds-easing-standard);
+
+ &:hover:not(:disabled),
+ &:focus-visible {
+ color: var(--ds-color-text-primary);
+ background: color-mix(
+ in srgb,
+ var(--ds-color-element-medium, var(--element-bg-medium)) 93%,
+ var(--ds-color-accent-500) 7%
+ );
+ transform: translateX(2px);
+ }
+
+ strong {
+ overflow: hidden;
+ min-width: 0;
+ color: var(--ds-color-text-primary);
+ font-size: var(--ds-font-size-xs);
+ font-weight: var(--ds-font-weight-medium);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ > span:last-child {
+ overflow: hidden;
+ max-width: 126px;
+ color: var(--ds-color-text-muted);
+ font-size: var(--ds-font-size-xs);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+.sparo-workspace-hub-files-preview__content-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ color: var(--ds-color-text-secondary);
+
+ &.is-dir {
+ color: var(--ds-color-warning);
+ }
+}
+
+.sparo-workspace-hub-files-preview__common {
+ margin-top: auto;
+
+ .sparo-workspace-hub-preview__section-body {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--ds-space-2);
+ }
+
+ .sparo-workspace-hub-preview__row {
+ grid-template-columns: 26px minmax(0, 1fr) 12px;
+ border: 0;
+ border-radius: var(--ds-radius-lg);
+ background: var(--ds-color-element-soft, var(--element-bg-soft));
+ backdrop-filter: blur(8px) saturate(1.05);
+ transition:
+ color var(--ds-motion-fast) var(--ds-easing-standard),
+ background-color var(--ds-motion-fast) var(--ds-easing-standard);
+
+ &:hover:not(:disabled),
+ &:focus-visible {
+ color: var(--ds-color-text-primary);
+ background: color-mix(
+ in srgb,
+ var(--ds-color-element-medium, var(--element-bg-medium)) 93%,
+ var(--ds-color-accent-500) 7%
+ );
+ }
+ }
+}
+
+.sparo-workspace-hub-files-preview__results .sparo-workspace-hub-preview__section-body {
+ border: 0;
+ border-radius: 0;
+}
+
+.sparo-workspace-hub-files-preview__listbox {
+ overflow: hidden;
+}
+
+@media (max-width: 540px) {
+ .sparo-workspace-hub-files-preview .sparo-workspace-hub-files-preview__content-row {
+ grid-template-columns: 24px minmax(0, 1fr);
+
+ > span:last-child {
+ display: none;
+ }
+ }
+
+ .sparo-workspace-hub-files-preview__common .sparo-workspace-hub-preview__section-body {
+ grid-template-columns: 1fr;
+
+ .sparo-workspace-hub-preview__row:first-child {
+ border: 0;
+ }
+ }
+}
diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/previews/FilesPreview.tsx b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/FilesPreview.tsx
new file mode 100644
index 00000000..e22f53f4
--- /dev/null
+++ b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/FilesPreview.tsx
@@ -0,0 +1,482 @@
+import React, {
+ useCallback,
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import {
+ Archive,
+ ChevronRight,
+ File,
+ FileCode2,
+ FileImage,
+ FileText,
+ Folder,
+ FolderClock,
+ FolderInput,
+ FolderOpen,
+ Search as SearchIcon,
+ Star,
+ X,
+} from 'lucide-react';
+import { Button, IconButton, Input, Tooltip } from '@/design-system';
+import {
+ pinnedAPI,
+ systemFsAPI,
+ type FsEntry,
+ type PinnedPath,
+ type QuickFolder,
+} from '@/infrastructure/api';
+import { useI18n } from '@/infrastructure/i18n/hooks/useI18n';
+import { openWorkspaceScene } from '@/app/navigation/workspaceNavigation';
+import { loadRecentFileLocations } from '@/app/scenes/file-viewer/services/recentFileLocations';
+import { externalRuntimeScope } from '@/shared/types/runtime-scope';
+import {
+ WorkspaceHubPreviewEmpty,
+ WorkspaceHubPreviewError,
+ WorkspaceHubPreviewFrame,
+ WorkspaceHubPreviewLoading,
+ WorkspaceHubPreviewRow,
+ WorkspaceHubPreviewSection,
+} from './WorkspaceHubPreviewFrame';
+import type { WorkspaceHubPreviewProps } from './workspaceHubPreviewTypes';
+import { useHubPreviewResource } from './useHubPreviewResource';
+import './FilesPreview.scss';
+
+const COMMON_LOCATION_LIMIT = 2;
+const SEARCH_RESULT_LIMIT = 4;
+const RECENT_CONTENT_LIMIT = 3;
+
+type FileLocationKind = 'recent' | 'pinned' | 'quick' | 'typed';
+
+interface FileLocation {
+ id: string;
+ name: string;
+ path: string;
+ kind: FileLocationKind;
+}
+
+function basename(path: string): string {
+ const normalized = path.replace(/[\\/]+$/, '');
+ return normalized.split(/[\\/]+/).filter(Boolean).pop() || path;
+}
+
+function parentDirectory(path: string): string {
+ const normalized = path.replace(/[\\/]+$/, '');
+ const index = Math.max(normalized.lastIndexOf('\\'), normalized.lastIndexOf('/'));
+ if (index < 0) return path;
+ if (index === 2 && /^[A-Za-z]:/.test(normalized)) return `${normalized.slice(0, 2)}\\`;
+ return normalized.slice(0, index) || path;
+}
+
+function pinnedTarget(item: PinnedPath): string {
+ return item.kind === 'dir' ? item.path : parentDirectory(item.path);
+}
+
+function normalizePath(path: string): string {
+ return path.trim().replace(/\\/g, '/').replace(/\/+$/, '').toLocaleLowerCase();
+}
+
+function isLikelyPath(value: string): boolean {
+ const trimmed = value.trim();
+ return /^[A-Za-z]:[\\/]/.test(trimmed)
+ || /^\\\\/.test(trimmed)
+ || /^\//.test(trimmed)
+ || /^~[\\/]/.test(trimmed);
+}
+
+function dedupeLocations(locations: readonly FileLocation[]): FileLocation[] {
+ const seen = new Set();
+ return locations.filter((location) => {
+ const key = normalizePath(location.path);
+ if (!key || seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+}
+
+function locationFromPinned(item: PinnedPath): FileLocation {
+ const path = pinnedTarget(item);
+ return {
+ id: `pinned:${item.id}`,
+ name: item.label || basename(path),
+ path,
+ kind: 'pinned',
+ };
+}
+
+function locationFromQuickFolder(folder: QuickFolder): FileLocation {
+ return {
+ id: `quick:${folder.id}`,
+ name: folder.name,
+ path: folder.path,
+ kind: 'quick',
+ };
+}
+
+function fileExtension(name: string): string {
+ const index = name.lastIndexOf('.');
+ return index > 0 ? name.slice(index + 1).toLocaleLowerCase() : '';
+}
+
+function entryIcon(entry: FsEntry): React.ReactNode {
+ if (entry.kind === 'dir') return ;
+ const extension = fileExtension(entry.name);
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico'].includes(extension)) {
+ return ;
+ }
+ if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) return ;
+ if (['ts', 'tsx', 'js', 'jsx', 'rs', 'py', 'go', 'java', 'json', 'html', 'css', 'scss'].includes(extension)) {
+ return ;
+ }
+ if (['md', 'txt', 'doc', 'docx', 'pdf', 'rtf'].includes(extension)) return ;
+ return ;
+}
+
+const FilesPreview: React.FC = ({
+ label,
+ primaryActionRef,
+ onOpenItem,
+ onClose,
+}) => {
+ const { t, formatRelativeTime } = useI18n('common');
+ const listboxId = useId();
+ const searchInputRef = useRef(null);
+ const [query, setQuery] = useState('');
+ const [searchExpanded, setSearchExpanded] = useState(false);
+ const [activeResultIndex, setActiveResultIndex] = useState(0);
+ const recentLocations = useMemo(loadRecentFileLocations, []);
+ const pinned = useHubPreviewResource(
+ 'workspace-hub:files:pinned',
+ () => pinnedAPI.list(),
+ { ttlMs: 15_000 },
+ );
+ const quickFolders = useHubPreviewResource(
+ 'workspace-hub:files:quick-folders',
+ () => systemFsAPI.listQuickFolders(),
+ { ttlMs: 60_000 },
+ );
+
+ const pinnedLocations = useMemo(
+ () => (pinned.data?.paths ?? []).map(locationFromPinned),
+ [pinned.data?.paths],
+ );
+ const quickLocations = useMemo(
+ () => (quickFolders.data ?? []).map(locationFromQuickFolder),
+ [quickFolders.data],
+ );
+ const recentLocationItems = useMemo(() => recentLocations.map((path, index) => ({
+ id: `recent:${index}:${path}`,
+ name: basename(path),
+ path,
+ kind: 'recent' as const,
+ })), [recentLocations]);
+ const continueLocation = recentLocationItems[0];
+ const recentContents = useHubPreviewResource(
+ `workspace-hub:files:contents:${normalizePath(continueLocation?.path ?? '') || 'empty'}`,
+ () => continueLocation ? systemFsAPI.listDir(continueLocation.path) : Promise.resolve([]),
+ { ttlMs: 15_000 },
+ );
+ const visibleRecentContents = useMemo(() => (
+ (recentContents.data ?? [])
+ .filter((entry) => !entry.hidden)
+ .sort((left, right) => {
+ if (left.kind === 'dir' && right.kind !== 'dir') return -1;
+ if (left.kind !== 'dir' && right.kind === 'dir') return 1;
+ return left.name.localeCompare(right.name);
+ })
+ .slice(0, RECENT_CONTENT_LIMIT)
+ ), [recentContents.data]);
+ const commonLocations = useMemo(() => {
+ const continuePath = continueLocation ? normalizePath(continueLocation.path) : '';
+ return dedupeLocations([...pinnedLocations, ...quickLocations])
+ .filter((location) => normalizePath(location.path) !== continuePath)
+ .slice(0, COMMON_LOCATION_LIMIT);
+ }, [continueLocation, pinnedLocations, quickLocations]);
+ const knownLocations = useMemo(
+ () => dedupeLocations([...recentLocationItems, ...pinnedLocations, ...quickLocations]),
+ [pinnedLocations, quickLocations, recentLocationItems],
+ );
+ const normalizedQuery = query.trim().toLocaleLowerCase();
+ const matchingLocations = useMemo(() => {
+ if (!normalizedQuery) return [];
+ return knownLocations.filter((location) => (
+ location.name.toLocaleLowerCase().includes(normalizedQuery)
+ || location.path.toLocaleLowerCase().includes(normalizedQuery)
+ )).slice(0, SEARCH_RESULT_LIMIT);
+ }, [knownLocations, normalizedQuery]);
+ const searchResults = useMemo(() => {
+ if (matchingLocations.length > 0) return matchingLocations;
+ if (!isLikelyPath(query)) return [];
+ const path = query.trim();
+ return [{
+ id: `typed:${path}`,
+ name: basename(path),
+ path,
+ kind: 'typed' as const,
+ }];
+ }, [matchingLocations, query]);
+ const commonLoading = pinned.loading && !pinned.data
+ && quickFolders.loading && !quickFolders.data;
+
+ useEffect(() => {
+ setActiveResultIndex(0);
+ }, [normalizedQuery, searchResults.length]);
+
+ useEffect(() => {
+ if (!searchExpanded) return undefined;
+ const frameId = window.requestAnimationFrame(() => searchInputRef.current?.focus());
+ return () => window.cancelAnimationFrame(frameId);
+ }, [searchExpanded]);
+
+ const openLocation = useCallback((path: string) => {
+ const normalizedPath = path.trim();
+ const scope = externalRuntimeScope(normalizedPath, basename(normalizedPath));
+ if (!scope) return;
+ onClose();
+ openWorkspaceScene('file-viewer', { scope });
+ }, [onClose]);
+
+ const openEntry = useCallback((entry: FsEntry) => {
+ if (entry.kind === 'dir') {
+ openLocation(entry.path);
+ return;
+ }
+ onClose();
+ void systemFsAPI.openWithDefault(entry.path);
+ }, [onClose, openLocation]);
+
+ const openActiveSearchResult = useCallback(() => {
+ const result = searchResults[activeResultIndex] ?? searchResults[0];
+ if (result) openLocation(result.path);
+ }, [activeResultIndex, openLocation, searchResults]);
+
+ const collapseSearch = useCallback(() => {
+ setQuery('');
+ setSearchExpanded(false);
+ }, []);
+
+ const handleSearchKeyDown = useCallback((event: React.KeyboardEvent) => {
+ if (event.key === 'ArrowDown' && searchResults.length > 0) {
+ event.preventDefault();
+ setActiveResultIndex((index) => (index + 1) % searchResults.length);
+ return;
+ }
+ if (event.key === 'ArrowUp' && searchResults.length > 0) {
+ event.preventDefault();
+ setActiveResultIndex((index) => (index - 1 + searchResults.length) % searchResults.length);
+ return;
+ }
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ openActiveSearchResult();
+ return;
+ }
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ collapseSearch();
+ }
+ }, [collapseSearch, openActiveSearchResult, searchResults.length]);
+
+ const locationIcon = (location: FileLocation) => {
+ if (location.kind === 'recent') return ;
+ if (location.kind === 'pinned') return ;
+ if (location.kind === 'typed') return ;
+ return ;
+ };
+
+ const renderLocationRow = (location: FileLocation, options?: {
+ active?: boolean;
+ id?: string;
+ }) => (
+ openLocation(location.path)}
+ ariaLabel={t('nav.menuPanel.hub.preview.files.aria.openLocation', { name: location.name })}
+ tooltip={location.path}
+ />
+ );
+
+ const entryMeta = (entry: FsEntry) => {
+ const kindLabel = entry.kind === 'dir'
+ ? t('nav.menuPanel.hub.preview.files.types.folder')
+ : fileExtension(entry.name).toLocaleUpperCase()
+ || t('nav.menuPanel.hub.preview.files.types.file');
+ if (!entry.modified) return kindLabel;
+ const modifiedAt = new Date(entry.modified);
+ if (Number.isNaN(modifiedAt.getTime())) return kindLabel;
+ return `${kindLabel} · ${formatRelativeTime(modifiedAt)}`;
+ };
+
+ const activeDescendant = normalizedQuery && searchResults.length > 0
+ ? `${listboxId}-${activeResultIndex}`
+ : undefined;
+
+ return (
+
+ setSearchExpanded((expanded) => !expanded)}
+ >
+
+
+ onOpenItem('files')}
+ >
+
+
+
+ )}
+ summary={searchExpanded ? (
+ setQuery(event.target.value)}
+ onKeyDown={handleSearchKeyDown}
+ placeholder={t('nav.menuPanel.hub.preview.files.search.placeholder')}
+ aria-label={t('nav.menuPanel.hub.preview.files.search.ariaLabel')}
+ role="combobox"
+ aria-autocomplete="list"
+ aria-expanded={Boolean(normalizedQuery)}
+ aria-controls={normalizedQuery ? listboxId : undefined}
+ aria-activedescendant={activeDescendant}
+ autoComplete="off"
+ inputSize="large"
+ prefix={}
+ suffix={(
+
+
+
+ )}
+ className="sparo-workspace-hub-files-preview__search"
+ />
+ ) : undefined}
+ >
+ {normalizedQuery ? (
+
+
+ {searchResults.length > 0 ? searchResults.map((location, index) => renderLocationRow(location, {
+ active: activeResultIndex === index,
+ id: `${listboxId}-${index}`,
+ })) : (
+
+ )}
+
+
+ ) : (
+ <>
+ {continueLocation ? (
+
+
+
+
+
+
+
+ {recentContents.loading && !recentContents.data ? (
+
+ ) : recentContents.error && !recentContents.data ? (
+
+ ) : visibleRecentContents.length > 0 ? visibleRecentContents.map((entry) => (
+
+ )) : (
+
+ )}
+
+
+
+ ) : (
+
+ )}
+
+ {(commonLoading || commonLocations.length > 0) && (
+
+ {commonLoading ? (
+
+ ) : commonLocations.map((location) => renderLocationRow(location))}
+
+ )}
+ >
+ )}
+
+ );
+};
+
+export default FilesPreview;
diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/previews/MemoryPreview.scss b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/MemoryPreview.scss
new file mode 100644
index 00000000..a973fe67
--- /dev/null
+++ b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/MemoryPreview.scss
@@ -0,0 +1,184 @@
+@use '@/design-system/foundation/tokens/tokens.scss' as *;
+
+.sparo-workspace-hub-memory-preview {
+ .sparo-workspace-hub-preview__title {
+ font-size: var(--ds-font-size-xl);
+ }
+
+ .sparo-workspace-hub-preview__content {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: 0;
+ margin-top: 20px;
+ }
+}
+
+.sparo-workspace-hub-memory-preview__body {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.sparo-workspace-hub-memory-preview__auto-status {
+ display: inline-flex;
+ width: fit-content;
+ align-items: center;
+ gap: 6px;
+ color: var(--ds-color-text-muted);
+ font-size: var(--ds-font-size-2xs);
+
+ > span {
+ width: 6px;
+ height: 6px;
+ background: var(--ds-color-text-muted);
+ border-radius: var(--ds-radius-full);
+ }
+
+ > small {
+ padding-left: 6px;
+ color: var(--ds-color-warning);
+ border-left: 1px solid var(--ds-color-border-subtle);
+ }
+
+ &.is-positive > span { background: var(--ds-color-success); }
+ &.is-warning > span { background: var(--ds-color-warning); }
+ &.is-danger > span { background: var(--ds-color-danger); }
+}
+
+.sparo-workspace-hub-memory-preview__constellation {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ align-items: center;
+ gap: var(--ds-space-3);
+ margin-top: var(--ds-space-5);
+
+ &::before {
+ position: absolute;
+ top: 21px;
+ right: 15%;
+ left: 15%;
+ height: 1px;
+ background: var(--ds-color-border-subtle);
+ content: '';
+ }
+}
+
+.sparo-workspace-hub-memory-preview__memory-node {
+ position: relative;
+ z-index: var(--ds-z-local);
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ min-width: 0;
+ gap: 5px;
+ text-align: center;
+
+ > strong {
+ color: var(--ds-color-text-primary);
+ font-size: var(--ds-font-size-xs);
+ font-weight: var(--ds-font-weight-medium);
+ line-height: 1.2;
+ }
+
+ > span:last-child {
+ max-width: 100%;
+ overflow: hidden;
+ color: var(--ds-color-text-muted);
+ font-size: var(--ds-font-size-2xs);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+.sparo-workspace-hub-memory-preview__memory-node-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 42px;
+ height: 42px;
+ margin-bottom: 3px;
+ color: var(--ds-color-text-secondary);
+ background: var(--ds-color-bg-elevated);
+ border: 1px solid var(--ds-color-border-subtle);
+ border-radius: var(--ds-radius-full);
+
+ .is-accent & {
+ color: var(--ds-color-accent-500);
+ background: color-mix(in srgb, var(--ds-color-accent-500) 7%, var(--ds-color-bg-elevated));
+ }
+}
+
+.sparo-workspace-hub-memory-preview__consolidation {
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--ds-space-2);
+ min-width: 0;
+ margin-top: auto;
+ padding-top: var(--ds-space-3);
+ border-top: 1px solid var(--ds-color-border-subtle);
+
+ &.is-failed .sparo-workspace-hub-memory-preview__consolidation-icon {
+ color: var(--ds-color-danger);
+ background: color-mix(in srgb, var(--ds-color-danger) 8%, transparent);
+ }
+
+ &.is-running .sparo-workspace-hub-memory-preview__consolidation-icon {
+ color: var(--ds-color-accent-500);
+ background: color-mix(in srgb, var(--ds-color-accent-500) 8%, transparent);
+ }
+
+ > button {
+ display: inline-flex;
+ gap: 6px;
+ }
+}
+
+.sparo-workspace-hub-memory-preview__consolidation-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ color: var(--ds-color-text-secondary);
+ background: var(--ds-color-surface-hover);
+ border-radius: var(--ds-radius-full);
+}
+
+.sparo-workspace-hub-memory-preview__consolidation-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+
+ > strong {
+ color: var(--ds-color-text-primary);
+ font-size: var(--ds-font-size-xs);
+ font-weight: var(--ds-font-weight-medium);
+ }
+
+ > span {
+ overflow: hidden;
+ color: var(--ds-color-text-muted);
+ font-size: var(--ds-font-size-2xs);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+@media (max-width: 540px) {
+ .sparo-workspace-hub-memory-preview__constellation {
+ gap: var(--ds-space-1);
+ }
+
+ .sparo-workspace-hub-memory-preview__consolidation {
+ grid-template-columns: 32px minmax(0, 1fr);
+
+ > button {
+ display: none;
+ }
+ }
+}
diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/previews/MemoryPreview.tsx b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/MemoryPreview.tsx
new file mode 100644
index 00000000..ebedc586
--- /dev/null
+++ b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/MemoryPreview.tsx
@@ -0,0 +1,406 @@
+import React, { useMemo, useState } from 'react';
+import { BookOpen, Brain, Folder, Sparkles, UserRound } from 'lucide-react';
+import { Button, IconButton } from '@/design-system';
+import {
+ backgroundProcessApi,
+} from '@/app/agentic-os/background-process/data/backgroundProcessApi';
+import type {
+ BackgroundProcess,
+ BackgroundProcessStatus,
+} from '@/app/agentic-os/background-process/domain/backgroundProcessTypes';
+import {
+ memoryLibraryAPI,
+ type AutoMemoryStatus,
+ type MemoryRecord,
+ type MemorySpace,
+} from '@/app/scenes/memory/MemoryLibraryAPI';
+import { useLastUsedWorkspace } from '@/infrastructure/contexts/WorkspaceContext';
+import { useI18n } from '@/infrastructure/i18n/hooks/useI18n';
+import { notificationService } from '@/shared/notification-system';
+import {
+ WorkspaceHubPreviewError,
+ WorkspaceHubPreviewFrame,
+ WorkspaceHubPreviewLoading,
+ type WorkspaceHubPreviewTone,
+} from './WorkspaceHubPreviewFrame';
+import type { WorkspaceHubPreviewProps } from './workspaceHubPreviewTypes';
+import { useHubPreviewResource } from './useHubPreviewResource';
+import './MemoryPreview.scss';
+
+interface MemoryPreviewData {
+ records: MemoryRecord[] | null;
+ autoStatus: AutoMemoryStatus | null;
+ consolidationProcess: BackgroundProcess | null;
+ recordsPartial: boolean;
+ autoStatusFailed: boolean;
+ consolidationStatusFailed: boolean;
+}
+
+interface MemoryPreviewItem {
+ id: 'user' | 'long-term' | 'workspace';
+ icon: React.ReactNode;
+ title: string;
+ meta: string;
+ updatedAt?: number;
+ unavailableLabel?: string;
+ tone: WorkspaceHubPreviewTone;
+}
+
+const ACTIVE_PROCESS_STATUSES = new Set(['queued', 'running']);
+
+function memoryConsolidationSourceCount(process: BackgroundProcess | null): number | null {
+ if (!process || process.kind !== 'memory_consolidation') return null;
+ const message = process.lastResult?.message?.trim() ?? '';
+ const match = /^(\d+)\s+source\(s\)\s+tracked$/i.exec(message);
+ return match ? Number(match[1]) : null;
+}
+
+function memoryConsolidationFinishedAt(process: BackgroundProcess | null): number | null {
+ return process?.lastResult?.finishedAt ?? process?.finishedAt ?? null;
+}
+
+const MemoryPreview: React.FC = ({
+ label,
+ primaryActionRef,
+ onOpenItem,
+}) => {
+ const { t } = useI18n('common');
+ const { workspacePath, workspaceName, hasWorkspace } = useLastUsedWorkspace();
+ const [isConsolidating, setIsConsolidating] = useState(false);
+ const key = `workspace-hub:memory:${workspacePath || 'global'}`;
+
+ const resource = useHubPreviewResource(key, async () => {
+ const [storageResult, workspaceResult, autoStatusResult, processResult] = await Promise.all([
+ Promise.resolve(memoryLibraryAPI.getStoragePaths()).then(
+ (value) => ({ ok: true as const, value }),
+ (reason) => ({ ok: false as const, value: null, reason }),
+ ),
+ hasWorkspace && workspacePath
+ ? Promise.resolve(memoryLibraryAPI.getWorkspaceStoragePaths(workspacePath)).then(
+ (value) => ({ ok: true as const, value }),
+ () => ({ ok: false as const, value: null }),
+ )
+ : Promise.resolve({ ok: true as const, value: null }),
+ Promise.resolve(memoryLibraryAPI.getAutoMemoryStatus()).then(
+ (value) => ({ ok: true as const, value }),
+ () => ({ ok: false as const, value: null }),
+ ),
+ Promise.resolve(backgroundProcessApi.listProcesses()).then(
+ (value) => ({ ok: true as const, value }),
+ () => ({ ok: false as const, value: null }),
+ ),
+ ]);
+
+ if (!storageResult.ok && !autoStatusResult.ok && !processResult.ok) {
+ throw storageResult.reason;
+ }
+
+ let records: MemoryRecord[] | null = null;
+ let recordsPartial = false;
+ if (storageResult.ok) {
+ const spaces: MemorySpace[] = [
+ {
+ scope: 'global',
+ label: 'global',
+ memoryDir: storageResult.value.agenticOsMemoryDir,
+ available: true,
+ },
+ {
+ scope: 'global',
+ label: 'global',
+ memoryDir: storageResult.value.agenticOsHostDir,
+ available: true,
+ },
+ {
+ scope: 'global',
+ label: 'global',
+ memoryDir: storageResult.value.agenticOsWorkspacesOverviewDir,
+ available: true,
+ },
+ ];
+ if (workspaceResult.ok && workspaceResult.value) {
+ spaces.push({
+ scope: 'workspace',
+ label: workspaceName || 'workspace',
+ memoryDir: workspaceResult.value.memoryDir,
+ available: true,
+ });
+ } else if (hasWorkspace) {
+ recordsPartial = true;
+ }
+
+ const recordResults = await Promise.allSettled(
+ spaces.map((space) => memoryLibraryAPI.listExistingMemoryRecords(space)),
+ );
+ const fulfilled = recordResults
+ .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled')
+ .flatMap((result) => result.value);
+ recordsPartial ||= recordResults.some((result) => result.status === 'rejected');
+ records = recordResults.every((result) => result.status === 'rejected') ? null : fulfilled;
+ }
+
+ return {
+ records,
+ autoStatus: autoStatusResult.value,
+ consolidationProcess: processResult.value?.processes.find(
+ (process) => process.kind === 'memory_consolidation',
+ ) ?? null,
+ recordsPartial,
+ autoStatusFailed: !autoStatusResult.ok,
+ consolidationStatusFailed: !processResult.ok,
+ };
+ });
+
+ const items = useMemo(() => {
+ const records = resource.data?.records ?? [];
+ const userRecord = records.find(
+ (record) => record.scope === 'global' && record.type === 'user',
+ );
+ const longTermRecord = records.find(
+ (record) => record.scope === 'global' && record.type === 'memory' && !record.isWorkspaceOverview,
+ );
+ const workspaceRecord = records.find(
+ (record) => record.scope === 'workspace' && record.type === 'memory',
+ );
+
+ return [
+ {
+ id: 'user',
+ icon: ,
+ title: t('nav.menuPanel.hub.preview.memory.items.user.title'),
+ meta: t('nav.menuPanel.hub.preview.memory.items.user.meta'),
+ updatedAt: userRecord?.updatedAt,
+ tone: 'accent',
+ },
+ {
+ id: 'long-term',
+ icon: ,
+ title: t('nav.menuPanel.hub.preview.memory.items.longTerm.title'),
+ meta: t('nav.menuPanel.hub.preview.memory.items.longTerm.meta'),
+ updatedAt: longTermRecord?.updatedAt,
+ tone: 'accent',
+ },
+ {
+ id: 'workspace',
+ icon: ,
+ title: t('nav.menuPanel.hub.preview.memory.items.workspace.title'),
+ meta: t('nav.menuPanel.hub.preview.memory.items.workspace.meta'),
+ updatedAt: workspaceRecord?.updatedAt,
+ unavailableLabel: hasWorkspace
+ ? undefined
+ : t('nav.menuPanel.hub.preview.memory.updated.noWorkspace'),
+ tone: 'neutral',
+ },
+ ];
+ }, [hasWorkspace, resource.data?.records, t]);
+
+ const formatUpdatedAt = (item: MemoryPreviewItem): string => {
+ if (item.unavailableLabel) return item.unavailableLabel;
+ if (!item.updatedAt) return t('nav.menuPanel.hub.preview.memory.updated.unavailable');
+
+ const updated = new Date(item.updatedAt);
+ const now = new Date();
+ const elapsedHours = (now.getTime() - updated.getTime()) / 3_600_000;
+ if (elapsedHours >= 0 && elapsedHours < 1) {
+ return t('nav.menuPanel.hub.preview.memory.updated.today');
+ }
+
+ const hoursAgo = Math.max(1, Math.floor(elapsedHours));
+ if (hoursAgo < 24) {
+ return t('nav.menuPanel.hub.preview.memory.updated.hoursAgo', { count: hoursAgo });
+ }
+
+ return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(updated);
+ };
+
+ const autoStatus = resource.data?.autoStatus;
+ let statusKey = 'nav.menuPanel.hub.preview.memory.status.ready';
+ let statusTone: WorkspaceHubPreviewTone = 'positive';
+ if (resource.loading && !resource.data) {
+ statusKey = 'nav.menuPanel.hub.preview.common.loading';
+ statusTone = 'neutral';
+ } else if (resource.error && !resource.data) {
+ statusKey = 'nav.menuPanel.hub.preview.common.statusUnavailable';
+ statusTone = 'danger';
+ } else if (!autoStatus && resource.data?.autoStatusFailed) {
+ statusKey = 'nav.menuPanel.hub.preview.common.statusUnavailable';
+ statusTone = 'danger';
+ } else if (autoStatus && !autoStatus.globalEnabled && !autoStatus.workspaceEnabled) {
+ statusKey = 'nav.menuPanel.hub.preview.memory.status.paused';
+ statusTone = 'neutral';
+ } else if (autoStatus && (!autoStatus.globalEnabled || !autoStatus.workspaceEnabled)) {
+ statusKey = 'nav.menuPanel.hub.preview.memory.status.partial';
+ statusTone = 'warning';
+ }
+
+ const consolidationProcess = resource.data?.consolidationProcess ?? null;
+ const processActive = Boolean(
+ consolidationProcess && ACTIVE_PROCESS_STATUSES.has(consolidationProcess.status),
+ );
+ const consolidationActive = isConsolidating || processActive;
+ const consolidationFinishedAt = memoryConsolidationFinishedAt(consolidationProcess);
+ const sourceCount = memoryConsolidationSourceCount(consolidationProcess);
+
+ let consolidationStateKey = 'nav.menuPanel.hub.preview.memory.consolidation.state.ready';
+ let consolidationStateClass = 'is-ready';
+ if (consolidationActive) {
+ consolidationStateKey = 'nav.menuPanel.hub.preview.memory.consolidation.state.running';
+ consolidationStateClass = 'is-running';
+ } else if (consolidationProcess?.status === 'failed') {
+ consolidationStateKey = 'nav.menuPanel.hub.preview.memory.consolidation.state.failed';
+ consolidationStateClass = 'is-failed';
+ } else if (consolidationProcess?.status === 'disabled') {
+ consolidationStateKey = 'nav.menuPanel.hub.preview.memory.consolidation.state.disabled';
+ consolidationStateClass = 'is-disabled';
+ } else if (!consolidationFinishedAt) {
+ consolidationStateKey = 'nav.menuPanel.hub.preview.memory.consolidation.state.notRun';
+ consolidationStateClass = 'is-idle';
+ }
+
+ const consolidationMeta = (() => {
+ if (resource.data?.consolidationStatusFailed) {
+ return t('nav.menuPanel.hub.preview.memory.consolidation.statusUnavailable');
+ }
+ if (consolidationActive) {
+ return t('nav.menuPanel.hub.preview.memory.consolidation.runningMeta');
+ }
+ if (!consolidationFinishedAt) {
+ return t('nav.menuPanel.hub.preview.memory.consolidation.notRunMeta');
+ }
+
+ const time = new Intl.DateTimeFormat(undefined, {
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(consolidationFinishedAt);
+ return sourceCount === null
+ ? t('nav.menuPanel.hub.preview.memory.consolidation.lastRun', { time })
+ : t('nav.menuPanel.hub.preview.memory.consolidation.summary', { count: sourceCount, time });
+ })();
+
+ const runConsolidation = async () => {
+ setIsConsolidating(true);
+ try {
+ const response = await backgroundProcessApi.runProcess('memory_consolidation');
+ if (response.started) {
+ notificationService.success(
+ t('nav.menuPanel.hub.preview.memory.consolidation.messages.started'),
+ { duration: 2500 },
+ );
+ } else {
+ notificationService.info(
+ t('nav.menuPanel.hub.preview.memory.consolidation.messages.notNeeded'),
+ { duration: 2500 },
+ );
+ }
+ await resource.refresh();
+ } catch {
+ notificationService.error(
+ t('nav.menuPanel.hub.preview.memory.consolidation.messages.failed'),
+ );
+ } finally {
+ setIsConsolidating(false);
+ }
+ };
+
+ return (
+ onOpenItem('memory')}
+ >
+
+
+ )}
+ >
+ {resource.loading && !resource.data ? (
+
+
+
+ ) : resource.error || !resource.data ? (
+
+
+
+ ) : (
+
+
+
+ {t(statusKey)}
+ {resource.data.recordsPartial && (
+ {t('nav.menuPanel.hub.preview.common.partialData')}
+ )}
+
+
+ {resource.data.records === null ? (
+
+ ) : (
+
+ {items.map((item) => (
+
+
+ {item.icon}
+
+ {item.title}
+ {formatUpdatedAt(item)}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ {t(consolidationStateKey)}
+ {consolidationMeta}
+
+
+
+
+ )}
+
+ );
+};
+
+export default MemoryPreview;
diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/previews/ShellPreview.scss b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/ShellPreview.scss
new file mode 100644
index 00000000..a0447462
--- /dev/null
+++ b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/ShellPreview.scss
@@ -0,0 +1,82 @@
+@use '@/design-system/foundation/tokens/tokens.scss' as *;
+
+.sparo-workspace-hub-shell-preview {
+ .sparo-workspace-hub-preview__title {
+ font-size: var(--ds-font-size-xl);
+ }
+
+ .sparo-workspace-hub-preview__content {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: 0;
+ margin-top: 20px;
+ }
+
+ .sparo-workspace-hub-preview__section-header {
+ min-height: 20px;
+ padding: 0 0 var(--ds-space-2);
+ border-bottom: 0;
+
+ h3 {
+ color: var(--ds-color-text-muted);
+ }
+ }
+
+ .sparo-workspace-hub-preview__row {
+ min-height: 44px;
+ padding: 6px var(--ds-space-2);
+ border-radius: var(--ds-radius-lg);
+
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ &.is-interactive:hover:not(:disabled),
+ &.is-interactive:focus-visible {
+ background: var(--element-bg-soft);
+ }
+ }
+
+ .sparo-workspace-hub-preview__row-icon {
+ width: 32px;
+ height: 32px;
+ }
+}
+
+.sparo-workspace-hub-shell-preview__header-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--ds-space-1);
+}
+
+.sparo-workspace-hub-shell-preview__body {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.sparo-workspace-hub-shell-preview__running,
+.sparo-workspace-hub-shell-preview__saved {
+ padding-bottom: var(--ds-space-4);
+}
+
+.sparo-workspace-hub-shell-preview__empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1 1 auto;
+
+ .sparo-workspace-hub-preview__empty {
+ align-items: center;
+ min-height: 0;
+ padding: 0;
+ text-align: center;
+
+ strong {
+ color: var(--ds-color-text-muted);
+ font-weight: var(--ds-font-weight-regular);
+ }
+ }
+}
diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/previews/ShellPreview.tsx b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/ShellPreview.tsx
new file mode 100644
index 00000000..2416da03
--- /dev/null
+++ b/src/web-ui/src/app/components/WorkspaceFooterActions/previews/ShellPreview.tsx
@@ -0,0 +1,236 @@
+import React, { useCallback, useEffect, useMemo } from 'react';
+import { Bot, Play, Plus, SquareTerminal } from 'lucide-react';
+import { IconButton } from '@/design-system';
+import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext';
+import { configManager } from '@/infrastructure/config/services/ConfigManager';
+import type { TerminalConfig } from '@/infrastructure/config/types';
+import { useI18n } from '@/infrastructure/i18n/hooks/useI18n';
+import { openShellSessionTarget } from '@/shared/services/openShellSessionTarget';
+import { createLogger } from '@/shared/utils/logger';
+import { getTerminalService } from '@/tools/terminal';
+import { listManualTerminalProfiles, type ManualTerminalProfile } from '@/tools/terminal/services/manualTerminalProfileService';
+import type { SessionResponse } from '@/tools/terminal/types/session';
+import { isSessionRunning } from '@/app/scenes/shell/hooks/shellEntryTypes';
+import {
+ WorkspaceHubPreviewEmpty,
+ WorkspaceHubPreviewError,
+ WorkspaceHubPreviewFrame,
+ WorkspaceHubPreviewLoading,
+ WorkspaceHubPreviewRow,
+ WorkspaceHubPreviewSection,
+} from './WorkspaceHubPreviewFrame';
+import type { WorkspaceHubPreviewProps } from './workspaceHubPreviewTypes';
+import { useHubPreviewResource } from './useHubPreviewResource';
+import './ShellPreview.scss';
+
+const log = createLogger('WorkspaceHubShellPreview');
+
+async function loadTerminalSessions(): Promise {
+ const service = getTerminalService();
+ await service.connect();
+ const sessions = await service.listSessions();
+ return sessions.filter((session) => session.shellType !== 'Remote');
+}
+
+async function defaultShellType(): Promise {
+ try {
+ const config = await configManager.getConfig('terminal');
+ return config?.default_shell || undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+const ShellPreview: React.FC = ({
+ label,
+ primaryActionRef,
+ onOpenItem,
+ onClose,
+}) => {
+ const { t } = useI18n('common');
+ const { lastUsedWorkspace } = useWorkspaceContext();
+ const sessions = useHubPreviewResource(
+ 'workspace-hub:shell:sessions',
+ loadTerminalSessions,
+ { ttlMs: 5_000 },
+ );
+ const savedProfiles = useMemo(
+ () => lastUsedWorkspace?.rootPath
+ ? listManualTerminalProfiles(lastUsedWorkspace.rootPath)
+ : [],
+ [lastUsedWorkspace?.rootPath],
+ );
+ const runningSessions = useMemo(
+ () => (sessions.data ?? [])
+ .filter(isSessionRunning)
+ .sort((left, right) => left.name.localeCompare(right.name)),
+ [sessions.data],
+ );
+ const initialLoading = sessions.loading && !sessions.data;
+ const refreshSessions = sessions.refresh;
+
+ useEffect(() => {
+ const service = getTerminalService();
+ return service.onEvent((event) => {
+ if (event.type === 'ready' || event.type === 'exit') refreshSessions();
+ });
+ }, [refreshSessions]);
+
+ const handleOpenSession = useCallback((session: SessionResponse) => {
+ onClose();
+ openShellSessionTarget({ sessionId: session.id, sessionName: session.name });
+ }, [onClose]);
+
+ const handleOpenProfile = useCallback((profile: ManualTerminalProfile) => {
+ onClose();
+ void (async () => {
+ const service = getTerminalService();
+ await service.connect();
+ const current = (await service.listSessions()).find((session) => session.id === profile.sessionId);
+ if (current && !isSessionRunning(current)) await service.closeSession(current.id);
+ if (!current || !isSessionRunning(current)) {
+ await service.createSession({
+ sessionId: profile.sessionId,
+ workingDirectory: profile.workingDirectory ?? lastUsedWorkspace?.rootPath,
+ name: profile.name,
+ shellType: profile.shellType ?? await defaultShellType(),
+ source: 'manual',
+ });
+ if (profile.startupCommand?.trim()) {
+ await new Promise((resolve) => window.setTimeout(resolve, 800));
+ try {
+ await service.sendCommand(profile.sessionId, profile.startupCommand);
+ } catch (error) {
+ log.warn('Failed to run saved terminal startup command', {
+ sessionId: profile.sessionId,
+ error,
+ });
+ }
+ }
+ }
+ openShellSessionTarget({ sessionId: profile.sessionId, sessionName: profile.name });
+ })().catch((error) => {
+ log.error('Failed to open saved terminal from Workspace Hub', {
+ sessionId: profile.sessionId,
+ error,
+ });
+ });
+ }, [lastUsedWorkspace?.rootPath, onClose]);
+
+ const handleCreateTerminal = useCallback(() => {
+ onClose();
+ void (async () => {
+ const service = getTerminalService();
+ await service.connect();
+ const current = await service.listSessions();
+ const nextIndex = current.filter((session) => session.source === 'manual').length + 1;
+ const session = await service.createSession({
+ workingDirectory: lastUsedWorkspace?.rootPath,
+ name: t('nav.menuPanel.hub.preview.shell.newSessionName', { count: nextIndex }),
+ shellType: await defaultShellType(),
+ source: 'manual',
+ });
+ openShellSessionTarget({ sessionId: session.id, sessionName: session.name });
+ })().catch((error) => {
+ log.error('Failed to create terminal from Workspace Hub', { error });
+ });
+ }, [lastUsedWorkspace?.rootPath, onClose, t]);
+
+ return (
+
+
+
+
+ onOpenItem('shell')}
+ >
+
+
+
+ )}
+ >
+ {initialLoading ? (
+