diff --git a/src/crates/core/src/service/remote_connect/remote_server.rs b/src/crates/core/src/service/remote_connect/remote_server.rs index 6823e9af..bb9e0958 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -347,6 +347,11 @@ pub enum RemoteResponse { path: Option, project_name: Option, git_branch: Option, + /// `"normal"` | `"assistant"` | `"remote"` — mirrors [`crate::service::workspace::WorkspaceKind`]. + #[serde(skip_serializing_if = "Option::is_none")] + workspace_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + assistant_id: Option, }, RecentWorkspaces { workspaces: Vec, @@ -404,6 +409,10 @@ pub enum RemoteResponse { project_name: Option, #[serde(skip_serializing_if = "Option::is_none")] git_branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + workspace_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + assistant_id: Option, sessions: Vec, has_more_sessions: bool, #[serde(skip_serializing_if = "Option::is_none")] @@ -514,6 +523,9 @@ pub struct RecentWorkspaceEntry { pub path: String, pub name: String, pub last_opened: String, + /// `"normal"` | `"assistant"` | `"remote"` + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_kind: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1896,18 +1908,43 @@ impl RemoteServer { ) -> RemoteResponse { use crate::agentic::persistence::PersistenceManager; use crate::infrastructure::PathManager; + use crate::service::workspace::{get_global_workspace_service, WorkspaceKind}; - let ws_path = current_workspace_path(); - let (has_workspace, path_str, project_name, git_branch) = if let Some(ref p) = ws_path { - let name = p.file_name().map(|n| n.to_string_lossy().to_string()); - let branch = git2::Repository::open(p).ok().and_then(|repo| { - repo.head() - .ok() - .and_then(|h| h.shorthand().map(String::from)) - }); - (true, Some(p.to_string_lossy().to_string()), name, branch) + let ( + ws_path, + has_workspace, + path_str, + project_name, + git_branch, + workspace_kind, + assistant_id, + ) = if let Some(ws_service) = get_global_workspace_service() { + if let Some(ws) = ws_service.get_current_workspace().await { + let p = ws.root_path.clone(); + let branch = git2::Repository::open(&p).ok().and_then(|repo| { + repo.head() + .ok() + .and_then(|h| h.shorthand().map(String::from)) + }); + let kind_str = match ws.workspace_kind { + WorkspaceKind::Normal => "normal", + WorkspaceKind::Assistant => "assistant", + WorkspaceKind::Remote => "remote", + }; + ( + Some(p.clone()), + true, + Some(p.to_string_lossy().to_string()), + Some(ws.name.clone()), + branch, + Some(kind_str.to_string()), + ws.assistant_id.clone(), + ) + } else { + (None, false, None, None, None, None, None) + } } else { - (false, None, None, None) + (None, false, None, None, None, None, None) }; let (sessions, has_more) = if let Some(ref wp) = ws_path { @@ -1953,6 +1990,8 @@ impl RemoteServer { path: path_str, project_name, git_branch, + workspace_kind, + assistant_id, sessions, has_more_sessions: has_more, authenticated_user_id, @@ -2246,23 +2285,38 @@ impl RemoteServer { match cmd { RemoteCommand::GetWorkspaceInfo => { - let ws_path = current_workspace_path(); - let (project_name, git_branch) = if let Some(ref p) = ws_path { - let name = p.file_name().map(|n| n.to_string_lossy().to_string()); - let branch = git2::Repository::open(p).ok().and_then(|repo| { - repo.head() - .ok() - .and_then(|h| h.shorthand().map(String::from)) - }); - (name, branch) - } else { - (None, None) - }; + use crate::service::workspace::{get_global_workspace_service, WorkspaceKind}; + + if let Some(ws_service) = get_global_workspace_service() { + if let Some(ws) = ws_service.get_current_workspace().await { + let p = ws.root_path.clone(); + let branch = git2::Repository::open(&p).ok().and_then(|repo| { + repo.head() + .ok() + .and_then(|h| h.shorthand().map(String::from)) + }); + let kind_str = match ws.workspace_kind { + WorkspaceKind::Normal => "normal", + WorkspaceKind::Assistant => "assistant", + WorkspaceKind::Remote => "remote", + }; + return RemoteResponse::WorkspaceInfo { + has_workspace: true, + path: Some(p.to_string_lossy().to_string()), + project_name: Some(ws.name.clone()), + git_branch: branch, + workspace_kind: Some(kind_str.to_string()), + assistant_id: ws.assistant_id.clone(), + }; + } + } RemoteResponse::WorkspaceInfo { - has_workspace: ws_path.is_some(), - path: ws_path.map(|p| p.to_string_lossy().to_string()), - project_name, - git_branch, + has_workspace: false, + path: None, + project_name: None, + git_branch: None, + workspace_kind: None, + assistant_id: None, } } RemoteCommand::ListRecentWorkspaces => { @@ -2275,10 +2329,18 @@ impl RemoteServer { let recent = ws_service.get_recent_workspaces().await; let entries = recent .into_iter() - .map(|w| RecentWorkspaceEntry { - path: w.root_path.to_string_lossy().to_string(), - name: w.name.clone(), - last_opened: w.last_accessed.to_rfc3339(), + .map(|w| { + let kind_str = match w.workspace_kind { + crate::service::workspace::WorkspaceKind::Normal => "normal", + crate::service::workspace::WorkspaceKind::Assistant => "assistant", + crate::service::workspace::WorkspaceKind::Remote => "remote", + }; + RecentWorkspaceEntry { + path: w.root_path.to_string_lossy().to_string(), + name: w.name.clone(), + last_opened: w.last_accessed.to_rfc3339(), + workspace_kind: Some(kind_str.to_string()), + } }) .collect(); RemoteResponse::RecentWorkspaces { diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index 1adb816b..8449ee1c 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -61,7 +61,6 @@ export const messages: Record = { changeMode: 'Change Mode', proMode: 'Expert Mode', proModeDesc: 'Best for focused, one-shot tasks with a clear goal.', - proModeNeedsWorkspace: 'Please select a workspace first to use Expert Mode.', assistantMode: 'Assistant Mode', assistantModeDesc: 'Best for ongoing work with context and personal preferences.', assistant: 'Assistant', @@ -213,7 +212,6 @@ export const messages: Record = { changeMode: '切换模式', proMode: '专业模式', proModeDesc: '适合目标明确、一次完成的即时任务。', - proModeNeedsWorkspace: '使用专业模式请先选择一个工作区。', assistantMode: '助理模式', assistantModeDesc: '适合持续推进、需要延续上下文和个人偏好的任务。', assistant: '助理', diff --git a/src/mobile-web/src/pages/PairingPage.tsx b/src/mobile-web/src/pages/PairingPage.tsx index 15c40e7f..159c4456 100644 --- a/src/mobile-web/src/pages/PairingPage.tsx +++ b/src/mobile-web/src/pages/PairingPage.tsx @@ -155,12 +155,25 @@ const PairingPage: React.FC = ({ onPaired }) => { const sessionMgr = new RemoteSessionManager(client); const store = useMobileStore.getState(); if (initialSync.has_workspace) { - store.setCurrentWorkspace({ - has_workspace: true, - path: initialSync.path, - project_name: initialSync.project_name, - git_branch: initialSync.git_branch, - }); + if (initialSync.workspace_kind === 'assistant' && initialSync.path) { + store.setPairedDisplayMode('assistant'); + store.setCurrentAssistant({ + path: initialSync.path, + name: initialSync.project_name ?? 'Claw', + assistant_id: initialSync.assistant_id, + }); + store.setCurrentWorkspace(null); + } else { + store.setPairedDisplayMode('pro'); + store.setCurrentWorkspace({ + has_workspace: true, + path: initialSync.path, + project_name: initialSync.project_name, + git_branch: initialSync.git_branch, + workspace_kind: initialSync.workspace_kind, + assistant_id: initialSync.assistant_id, + }); + } } if (initialSync.sessions) { store.setSessions(initialSync.sessions); diff --git a/src/mobile-web/src/pages/SessionListPage.tsx b/src/mobile-web/src/pages/SessionListPage.tsx index da400a98..1ddccd0b 100644 --- a/src/mobile-web/src/pages/SessionListPage.tsx +++ b/src/mobile-web/src/pages/SessionListPage.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useCallback, useState } from 'react'; import LanguageToggleButton from '../components/LanguageToggleButton'; import { useI18n } from '../i18n'; -import { RemoteSessionManager } from '../services/RemoteSessionManager'; +import { RemoteSessionManager, type RecentWorkspaceEntry } from '../services/RemoteSessionManager'; import { useMobileStore } from '../services/store'; import { useTheme } from '../theme'; import logoIcon from '../assets/Logo-ICON.png'; @@ -56,6 +56,16 @@ function isClawAgent(agentType: string): boolean { return agentType === 'claw' || agentType === 'Claw'; } +/** Pick first workspace suitable for Expert mode (exclude Claw assistant roots when kind is known). */ +function pickFirstProWorkspace(list: RecentWorkspaceEntry[]): RecentWorkspaceEntry | undefined { + if (list.length === 0) return undefined; + const anyKind = list.some((w) => w.workspace_kind != null); + if (anyKind) { + return list.find((w) => w.workspace_kind !== 'assistant'); + } + return list[0]; +} + function truncateMiddle(str: string, maxLen: number): string { if (!str || str.length <= maxLen) return str; const keep = maxLen - 3; @@ -138,6 +148,7 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS setCurrentWorkspace, currentAssistant, setCurrentAssistant, + setPairedDisplayMode, authenticatedUserId, } = useMobileStore(); const { isDark, toggleTheme } = useTheme(); @@ -145,7 +156,12 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS const [loading, setLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(false); - const [displayMode, setDisplayMode] = useState('pro'); + const [displayMode, setDisplayMode] = useState(() => { + const hint = useMobileStore.getState().pairedDisplayMode; + if (hint === 'assistant' || hint === 'pro') return hint; + return 'pro'; + }); + const [assistantList, setAssistantList] = useState>([]); const [showAssistantPicker, setShowAssistantPicker] = useState(false); const [workspaceList, setWorkspaceList] = useState>([]); @@ -220,6 +236,29 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS } }, [sessionMgr, setCurrentWorkspace, setError, loadFirstPage]); + const trySelectFirstProWorkspace = useCallback(async (): Promise => { + try { + const list = await sessionMgr.listRecentWorkspaces(); + const candidate = pickFirstProWorkspace(list); + if (!candidate) return false; + const result = await sessionMgr.setWorkspace(candidate.path); + if (result.success) { + setCurrentWorkspace({ + has_workspace: true, + path: result.path || candidate.path, + project_name: result.project_name || candidate.name, + }); + await loadFirstPage(result.path || candidate.path); + return true; + } + setError(result.error || t('workspace.failedToSetWorkspace')); + return false; + } catch (e: any) { + setError(e.message); + return false; + } + }, [sessionMgr, setCurrentWorkspace, setError, loadFirstPage, t]); + const loadNextPage = useCallback(async (workspacePath: string | undefined) => { if (loadingMore || !hasMore) return; setLoadingMore(true); @@ -241,11 +280,28 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS try { const info = await sessionMgr.getWorkspaceInfo(); if (cancelled) return; - const ws = info.has_workspace ? info : null; - setCurrentWorkspace(ws); - await loadFirstPage(ws?.path); + if (info.workspace_kind === 'assistant' && info.path) { + setCurrentAssistant({ + path: info.path, + name: info.project_name ?? 'Claw', + assistant_id: info.assistant_id, + }); + setCurrentWorkspace(null); + setDisplayMode('assistant'); + await loadFirstPage(info.path); + } else { + const ws = info.has_workspace ? info : null; + setCurrentWorkspace(ws); + if (ws?.path) { + await loadFirstPage(ws.path); + } else { + await trySelectFirstProWorkspace(); + } + } } catch (e: any) { if (!cancelled) setError(e.message); + } finally { + if (!cancelled) setPairedDisplayMode(null); } }; init(); @@ -257,6 +313,13 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS try { if (displayMode === 'pro') { const info = await sessionMgr.getWorkspaceInfo(); + if (info.workspace_kind === 'assistant') { + setCurrentWorkspace(null); + setSessions([]); + setHasMore(false); + offsetRef.current = 0; + return; + } const ws = info.has_workspace ? info : null; setCurrentWorkspace(ws); const resp = await sessionMgr.listSessions(ws?.path, PAGE_SIZE, 0); @@ -344,14 +407,16 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS setDisplayMode(mode); setShowAssistantPicker(false); if (mode === 'assistant') { - // Load assistant list and set default const assistantPath = await loadAssistantList(); loadFirstPage(assistantPath); } else { - // Pro mode needs workspace - loadFirstPage(currentWorkspace?.path); + if (currentWorkspace?.path) { + await loadFirstPage(currentWorkspace.path); + } else { + await trySelectFirstProWorkspace(); + } } - }, [currentWorkspace?.path, loadFirstPage, loadAssistantList]); + }, [currentWorkspace?.path, loadFirstPage, loadAssistantList, trySelectFirstProWorkspace]); const handleSelectAssistant = useCallback(async (assistant: { path: string; name: string; assistant_id?: string }) => { try { @@ -491,17 +556,6 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS )} - - {!currentWorkspace && ( -
- - - - - - {t('sessions.proModeNeedsWorkspace')} -
- )} )} diff --git a/src/mobile-web/src/services/RemoteSessionManager.ts b/src/mobile-web/src/services/RemoteSessionManager.ts index c29a2a0f..a5eb4fc0 100644 --- a/src/mobile-web/src/services/RemoteSessionManager.ts +++ b/src/mobile-web/src/services/RemoteSessionManager.ts @@ -15,12 +15,16 @@ export interface WorkspaceInfo { path?: string; project_name?: string; git_branch?: string; + /** Mirrors desktop `WorkspaceKind`: normal project, Claw assistant workspace, or remote SSH. */ + workspace_kind?: 'normal' | 'assistant' | 'remote'; + assistant_id?: string; } export interface RecentWorkspaceEntry { path: string; name: string; last_opened: string; + workspace_kind?: 'normal' | 'assistant' | 'remote'; } export interface AssistantEntry { @@ -127,6 +131,8 @@ export interface InitialSyncData { path?: string; project_name?: string; git_branch?: string; + workspace_kind?: 'normal' | 'assistant' | 'remote'; + assistant_id?: string; sessions: SessionInfo[]; has_more_sessions: boolean; authenticated_user_id?: string; @@ -159,6 +165,8 @@ export class RemoteSessionManager { path: resp.path, project_name: resp.project_name, git_branch: resp.git_branch, + workspace_kind: resp.workspace_kind, + assistant_id: resp.assistant_id, }; } diff --git a/src/mobile-web/src/services/store.ts b/src/mobile-web/src/services/store.ts index 7ab578d9..0c9def4b 100644 --- a/src/mobile-web/src/services/store.ts +++ b/src/mobile-web/src/services/store.ts @@ -19,6 +19,10 @@ interface MobileStore { currentAssistant: AssistantEntry | null; setCurrentAssistant: (a: AssistantEntry | null) => void; + /** One-shot hint after pairing so SessionList matches desktop assistant vs project workspace. */ + pairedDisplayMode: 'pro' | 'assistant' | null; + setPairedDisplayMode: (m: 'pro' | 'assistant' | null) => void; + authenticatedUserId: string | null; setAuthenticatedUserId: (userId: string | null) => void; @@ -52,6 +56,9 @@ export const useMobileStore = create((set, get) => ({ currentAssistant: null, setCurrentAssistant: (currentAssistant) => set({ currentAssistant }), + pairedDisplayMode: null, + setPairedDisplayMode: (pairedDisplayMode) => set({ pairedDisplayMode }), + authenticatedUserId: null, setAuthenticatedUserId: (authenticatedUserId) => set({ authenticatedUserId }),