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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 92 additions & 30 deletions src/crates/core/src/service/remote_connect/remote_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,11 @@ pub enum RemoteResponse {
path: Option<String>,
project_name: Option<String>,
git_branch: Option<String>,
/// `"normal"` | `"assistant"` | `"remote"` — mirrors [`crate::service::workspace::WorkspaceKind`].
#[serde(skip_serializing_if = "Option::is_none")]
workspace_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
assistant_id: Option<String>,
},
RecentWorkspaces {
workspaces: Vec<RecentWorkspaceEntry>,
Expand Down Expand Up @@ -404,6 +409,10 @@ pub enum RemoteResponse {
project_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
git_branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
workspace_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
assistant_id: Option<String>,
sessions: Vec<SessionInfo>,
has_more_sessions: bool,
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -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<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 => {
Expand All @@ -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 {
Expand Down
2 changes: 0 additions & 2 deletions src/mobile-web/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ export const messages: Record<MobileLanguage, MessageTree> = {
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',
Expand Down Expand Up @@ -213,7 +212,6 @@ export const messages: Record<MobileLanguage, MessageTree> = {
changeMode: '切换模式',
proMode: '专业模式',
proModeDesc: '适合目标明确、一次完成的即时任务。',
proModeNeedsWorkspace: '使用专业模式请先选择一个工作区。',
assistantMode: '助理模式',
assistantModeDesc: '适合持续推进、需要延续上下文和个人偏好的任务。',
assistant: '助理',
Expand Down
25 changes: 19 additions & 6 deletions src/mobile-web/src/pages/PairingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,25 @@ const PairingPage: React.FC<PairingPageProps> = ({ 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);
Expand Down
94 changes: 74 additions & 20 deletions src/mobile-web/src/pages/SessionListPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -138,14 +148,20 @@ const SessionListPage: React.FC<SessionListPageProps> = ({ sessionMgr, onSelectS
setCurrentWorkspace,
currentAssistant,
setCurrentAssistant,
setPairedDisplayMode,
authenticatedUserId,
} = useMobileStore();
const { isDark, toggleTheme } = useTheme();
const [creating, setCreating] = useState(false);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [displayMode, setDisplayMode] = useState<DisplayMode>('pro');
const [displayMode, setDisplayMode] = useState<DisplayMode>(() => {
const hint = useMobileStore.getState().pairedDisplayMode;
if (hint === 'assistant' || hint === 'pro') return hint;
return 'pro';
});

const [assistantList, setAssistantList] = useState<Array<{ path: string; name: string; assistant_id?: string }>>([]);
const [showAssistantPicker, setShowAssistantPicker] = useState(false);
const [workspaceList, setWorkspaceList] = useState<Array<{ path: string; name: string; last_opened: string }>>([]);
Expand Down Expand Up @@ -220,6 +236,29 @@ const SessionListPage: React.FC<SessionListPageProps> = ({ sessionMgr, onSelectS
}
}, [sessionMgr, setCurrentWorkspace, setError, loadFirstPage]);

const trySelectFirstProWorkspace = useCallback(async (): Promise<boolean> => {
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);
Expand All @@ -241,11 +280,28 @@ const SessionListPage: React.FC<SessionListPageProps> = ({ 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();
Expand All @@ -257,6 +313,13 @@ const SessionListPage: React.FC<SessionListPageProps> = ({ 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);
Expand Down Expand Up @@ -344,14 +407,16 @@ const SessionListPage: React.FC<SessionListPageProps> = ({ 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 {
Expand Down Expand Up @@ -491,17 +556,6 @@ const SessionListPage: React.FC<SessionListPageProps> = ({ sessionMgr, onSelectS
</div>
</div>
)}

{!currentWorkspace && (
<div className="session-list__hint">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span>{t('sessions.proModeNeedsWorkspace')}</span>
</div>
)}
</>
)}

Expand Down
Loading
Loading