From 5bbefcca7a007f353ac12a41c2e6a1ac409210bc Mon Sep 17 00:00:00 2001 From: jarvis24young <749843026@qq.com> Date: Mon, 25 May 2026 11:09:46 +0800 Subject: [PATCH] feat(mobile-web): add session search, rename, and delete - Search bar filters sessions client-side by name in real-time - Long-press (500ms, 10px move tolerance) on any session card opens a bottom-sheet context menu with Rename and Delete actions - Rename opens a modal with text input pre-filled with current name; sends update_session_title command to desktop and updates store - Delete shows a confirmation dialog with Escape/Enter support; calls delete_session on desktop and removes session + cached messages from the Zustand store - Action toast with role="alert" for transient success/failure feedback - Timers cleaned up on unmount to prevent state updates on dead components --- src/mobile-web/src/i18n/messages.ts | 39 ++ src/mobile-web/src/pages/SessionListPage.tsx | 253 ++++++++++++- .../src/services/RemoteSessionManager.ts | 8 + src/mobile-web/src/services/store.ts | 9 + .../src/styles/components/sessions.scss | 336 ++++++++++++++++++ 5 files changed, 641 insertions(+), 4 deletions(-) diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index 5df952386..6b8cbca70 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -89,6 +89,19 @@ export const messages: Record = { agentClaw: 'Claw', agentDefault: 'Default', pullToRefresh: 'Pull to refresh', + searchSessions: 'Search sessions...', + renameSession: 'Rename', + deleteSession: 'Delete', + confirmDelete: 'Delete session?', + confirmDeleteDesc: 'This action cannot be undone.', + renameTitle: 'Rename Session', + sessionNamePlaceholder: 'Session name', + cancel: 'Cancel', + save: 'Save', + emptySearch: 'No sessions match your search.', + deleted: 'Session deleted', + deleteFailed: 'Delete failed', + renameFailed: 'Rename failed', }, workspace: { title: 'Workspace', @@ -243,6 +256,19 @@ export const messages: Record = { agentClaw: 'Claw', agentDefault: '默认', pullToRefresh: '下拉刷新', + searchSessions: '搜索会话...', + renameSession: '重命名', + deleteSession: '删除', + confirmDelete: '确定删除此会话?', + confirmDeleteDesc: '此操作无法撤销。', + renameTitle: '重命名会话', + sessionNamePlaceholder: '会话名称', + cancel: '取消', + save: '保存', + emptySearch: '没有匹配的会话。', + deleted: '会话已删除', + deleteFailed: '删除失败', + renameFailed: '重命名失败', }, workspace: { title: '工作区', @@ -397,6 +423,19 @@ export const messages: Record = { agentClaw: 'Claw', agentDefault: '默認', pullToRefresh: '下拉刷新', + searchSessions: '搜尋會話...', + renameSession: '重新命名', + deleteSession: '刪除', + confirmDelete: '確定刪除此會話?', + confirmDeleteDesc: '此操作無法復原。', + renameTitle: '重新命名會話', + sessionNamePlaceholder: '會話名稱', + cancel: '取消', + save: '儲存', + emptySearch: '沒有匹配的會話。', + deleted: '會話已刪除', + deleteFailed: '刪除失敗', + renameFailed: '重新命名失敗', }, workspace: { title: '工作區', diff --git a/src/mobile-web/src/pages/SessionListPage.tsx b/src/mobile-web/src/pages/SessionListPage.tsx index 1ddccd0bc..0c8446b80 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, type RecentWorkspaceEntry } from '../services/RemoteSessionManager'; +import { RemoteSessionManager, type RecentWorkspaceEntry, type SessionInfo } from '../services/RemoteSessionManager'; import { useMobileStore } from '../services/store'; import { useTheme } from '../theme'; import logoIcon from '../assets/Logo-ICON.png'; @@ -167,6 +167,100 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS const [workspaceList, setWorkspaceList] = useState>([]); const [showWorkspacePicker, setShowWorkspacePicker] = useState(false); + // Search, rename & delete state + const [searchQuery, setSearchQuery] = useState(''); + const [menuSession, setMenuSession] = useState(null); + const [renameTarget, setRenameTarget] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [deleteConfirmTarget, setDeleteConfirmTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [renaming, setRenaming] = useState(false); + const [actionToast, setActionToast] = useState(null); + + const longPressTimerRef = useRef>(); + const longPressPosRef = useRef({ x: 0, y: 0 }); + const toastTimerRef = useRef>(); + + const filteredSessions = searchQuery.trim() + ? sessions.filter((s) => (s.name || t('sessions.untitledSession')).toLowerCase().includes(searchQuery.toLowerCase())) + : sessions; + + // ── Long-press context menu ───────────────────────────────────── + const clearLongPressTimer = () => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = undefined; + } + }; + + const handleSessionTouchStart = useCallback((s: SessionInfo, e: React.TouchEvent) => { + if (deleting || renaming) return; + clearLongPressTimer(); + longPressPosRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }; + longPressTimerRef.current = setTimeout(() => { + setMenuSession(s); + longPressTimerRef.current = undefined; + }, 500); + }, [deleting, renaming]); + + const handleSessionTouchMove = useCallback((e: React.TouchEvent) => { + const dx = Math.abs(e.touches[0].clientX - longPressPosRef.current.x); + const dy = Math.abs(e.touches[0].clientY - longPressPosRef.current.y); + if (dx > 10 || dy > 10) { + clearLongPressTimer(); + } + }, []); + + const handleSessionTouchEnd = useCallback(() => { + clearLongPressTimer(); + }, []); + + // ── Session actions ───────────────────────────────────────────── + const showToast = useCallback((msg: string) => { + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + setActionToast(msg); + toastTimerRef.current = setTimeout(() => setActionToast(null), 2500); + }, []); + + // Cleanup timers on unmount + useEffect(() => { + return () => { + clearLongPressTimer(); + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + }; + }, []); + + const handleRename = useCallback(async () => { + if (!renameTarget || !renameValue.trim()) return; + setRenaming(true); + try { + await sessionMgr.renameSession(renameTarget.session_id, renameValue.trim()); + useMobileStore.getState().updateSessionName(renameTarget.session_id, renameValue.trim()); + setRenameTarget(null); + setMenuSession(null); + } catch (e: any) { + showToast(e.message || t('sessions.renameFailed')); + } finally { + setRenaming(false); + } + }, [renameTarget, renameValue, sessionMgr, showToast, t]); + + const handleDelete = useCallback(async () => { + if (!deleteConfirmTarget) return; + setDeleting(true); + try { + await sessionMgr.deleteSession(deleteConfirmTarget.session_id); + useMobileStore.getState().removeSession(deleteConfirmTarget.session_id); + setDeleteConfirmTarget(null); + setMenuSession(null); + showToast(t('sessions.deleted')); + } catch (e: any) { + showToast(e.message || t('sessions.deleteFailed')); + } finally { + setDeleting(false); + } + }, [deleteConfirmTarget, sessionMgr, showToast, t]); + const [pullDistance, setPullDistance] = useState(0); const [refreshing, setRefreshing] = useState(false); const offsetRef = useRef(0); @@ -691,7 +785,28 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS
{t('sessions.recent')}
{t('sessions.sessionHistory')}
-
{t('common.itemCount', { count: sessions.length })}
+
{t('common.itemCount', { count: filteredSessions.length })}
+ + + {/* Search */} +
+ + + + + setSearchQuery(e.target.value)} + enterKeyHint="search" + /> + {searchQuery && ( + + )}
{loading && sessions.length === 0 && ( @@ -700,13 +815,20 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS {!loading && sessions.length === 0 && (
{t('sessions.noSessions')}
)} + {!loading && sessions.length > 0 && filteredSessions.length === 0 && ( +
{t('sessions.emptySearch')}
+ )}
- {sessions.map((s) => ( + {filteredSessions.map((s) => (
onSelectSession(s.session_id, s.name)} + onTouchStart={(e) => handleSessionTouchStart(s, e)} + onTouchMove={handleSessionTouchMove} + onTouchEnd={handleSessionTouchEnd} + onContextMenu={(e) => { e.preventDefault(); setMenuSession(s); }} >
@@ -729,6 +851,129 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS )}
+ + {/* Context Menu Bottom Sheet */} + {menuSession && !renameTarget && !deleteConfirmTarget && ( +
setMenuSession(null)}> +
e.stopPropagation()}> +
+
+ {menuSession.name || t('sessions.untitledSession')} +
+
+ + +
+ +
+
+ )} + + {/* Rename Modal */} + {renameTarget && ( +
!renaming && setRenameTarget(null)}> +
e.stopPropagation()}> +

{t('sessions.renameTitle')}

+ setRenameValue(e.target.value)} + placeholder={t('sessions.sessionNamePlaceholder')} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleRename(); + if (e.key === 'Escape') setRenameTarget(null); + }} + /> +
+ + +
+
+
+ )} + + {/* Delete Confirmation */} + {deleteConfirmTarget && ( +
!deleting && setDeleteConfirmTarget(null)} + onKeyDown={(e) => { + if (e.key === 'Escape') setDeleteConfirmTarget(null); + if (e.key === 'Enter' && !deleting) handleDelete(); + }}> +
e.stopPropagation()}> +
+ + + + + +
+

{t('sessions.confirmDelete')}

+

+ "{deleteConfirmTarget.name || t('sessions.untitledSession')}" +
+ {t('sessions.confirmDeleteDesc')} +

+
+ + +
+
+
+ )} + + {/* Action Toast */} + {actionToast && ( +
{actionToast}
+ )}
); }; diff --git a/src/mobile-web/src/services/RemoteSessionManager.ts b/src/mobile-web/src/services/RemoteSessionManager.ts index 47d8d928e..1a624f41c 100644 --- a/src/mobile-web/src/services/RemoteSessionManager.ts +++ b/src/mobile-web/src/services/RemoteSessionManager.ts @@ -331,6 +331,14 @@ export class RemoteSessionManager { await this.request({ cmd: 'delete_session', session_id: sessionId }); } + async renameSession(sessionId: string, title: string): Promise { + await this.request({ + cmd: 'update_session_title', + session_id: sessionId, + title, + }); + } + async answerQuestion(toolId: string, answers: any): Promise { await this.request({ cmd: 'answer_question', tool_id: toolId, answers }); } diff --git a/src/mobile-web/src/services/store.ts b/src/mobile-web/src/services/store.ts index 0c9def4b5..401dbc329 100644 --- a/src/mobile-web/src/services/store.ts +++ b/src/mobile-web/src/services/store.ts @@ -30,6 +30,7 @@ interface MobileStore { setSessions: (s: SessionInfo[]) => void; appendSessions: (s: SessionInfo[]) => void; updateSessionName: (sessionId: string, name: string) => void; + removeSession: (sessionId: string) => void; activeSessionId: string | null; setActiveSessionId: (id: string | null) => void; @@ -72,6 +73,14 @@ export const useMobileStore = create((set, get) => ({ s.session_id === sessionId ? { ...s, name } : s, ), })), + removeSession: (sessionId) => + set((state) => { + const { [sessionId]: _, ...rest } = state.messagesBySession; + return { + sessions: state.sessions.filter((s) => s.session_id !== sessionId), + messagesBySession: rest, + }; + }), activeSessionId: null, setActiveSessionId: (activeSessionId) => set({ activeSessionId }), diff --git a/src/mobile-web/src/styles/components/sessions.scss b/src/mobile-web/src/styles/components/sessions.scss index 339b7e152..82a2b61c0 100644 --- a/src/mobile-web/src/styles/components/sessions.scss +++ b/src/mobile-web/src/styles/components/sessions.scss @@ -804,6 +804,342 @@ } } +// ── Search bar ───────────────────────────────────────────────────── +.session-list__search { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: 1px solid var(--border-subtle); + @include squircle(12px); + background: var(--color-bg-secondary); + margin-bottom: var(--size-gap-3); +} + +.session-list__search-icon { + flex-shrink: 0; + color: var(--color-text-muted); +} + +.session-list__search-input { + flex: 1; + border: none; + background: none; + font-size: var(--font-size-sm); + color: var(--color-text-primary); + outline: none; + padding: 0; + min-width: 0; + + &::placeholder { + color: var(--color-text-muted); + } + + &::-webkit-search-decoration, + &::-webkit-search-cancel-button { + display: none; + } +} + +.session-list__search-clear { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: none; + color: var(--color-text-muted); + cursor: pointer; + border-radius: 50%; + flex-shrink: 0; + + &:active { + background: var(--element-bg-subtle); + color: var(--color-text-primary); + } +} + +// ── Context menu (bottom sheet) ──────────────────────────────────── +.session-list__menu-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: flex-end; + justify-content: center; + z-index: 1000; + animation: fadeIn var(--motion-fast) var(--easing-standard); +} + +.session-list__menu-sheet { + width: 100%; + max-width: 440px; + background: var(--color-bg-elevated); + border-radius: 20px 20px 0 0; + padding: var(--size-gap-2) var(--size-gap-4) var(--size-gap-6); + display: flex; + flex-direction: column; + gap: var(--size-gap-3); + animation: slideUp var(--motion-base) var(--easing-decelerate); +} + +.session-list__menu-handle { + width: 36px; + height: 4px; + border-radius: 999px; + background: var(--border-subtle); + margin: 0 auto; +} + +.session-list__menu-title { + text-align: center; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + padding: var(--size-gap-1) 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-list__menu-actions { + display: flex; + flex-direction: column; + gap: 4px; +} + +.session-list__menu-btn { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 14px 16px; + border: none; + @include squircle(16px); + background: var(--color-bg-secondary); + color: var(--color-text-primary); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + cursor: pointer; + text-align: left; + + svg { + flex-shrink: 0; + } + + &:active { + background: var(--element-bg-subtle); + transform: scale(0.99); + } + + &--danger { + color: var(--color-error-500, #dc3545); + background: var(--color-error-50, #fef2f2); + + &:active { + background: var(--color-error-100, #fee2e2); + } + } +} + +.session-list__menu-cancel { + width: 100%; + padding: 14px; + border: none; + @include squircle(14px); + background: var(--color-bg-secondary); + color: var(--color-text-secondary); + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + cursor: pointer; + + &:active { + background: var(--element-bg-subtle); + } +} + +.session-list__item--active { + border-color: var(--color-accent-500) !important; + background: var(--element-bg-subtle) !important; +} + +// ── Rename modal ─────────────────────────────────────────────────── +.session-list__rename-modal { + width: calc(100% - 32px); + max-width: 400px; + background: var(--color-bg-elevated); + border-radius: 20px; + padding: var(--size-gap-5); + display: flex; + flex-direction: column; + gap: var(--size-gap-4); + animation: slideUp var(--motion-base) var(--easing-decelerate); +} + +.session-list__rename-title { + margin: 0; + font-size: 18px; + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + text-align: center; +} + +.session-list__rename-input { + padding: 12px 14px; + border: 1px solid var(--border-subtle); + @include squircle(12px); + background: var(--color-bg-secondary); + color: var(--color-text-primary); + font-size: var(--font-size-base); + outline: none; + + &:focus { + border-color: var(--color-accent-500); + box-shadow: 0 0 0 3px var(--color-accent-100); + } +} + +.session-list__rename-actions { + display: flex; + gap: 10px; +} + +.session-list__rename-btn { + flex: 1; + padding: 12px; + border: none; + @include squircle(14px); + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + cursor: pointer; + transition: all var(--motion-fast) var(--easing-standard); + + &:disabled { + opacity: var(--opacity-disabled); + cursor: not-allowed; + } + + &--cancel { + background: var(--color-bg-secondary); + color: var(--color-text-secondary); + + &:active:not(:disabled) { + background: var(--element-bg-subtle); + } + } + + &--save { + background: var(--color-accent-500); + color: #fff; + + &:active:not(:disabled) { + opacity: 0.85; + } + } +} + +// ── Delete confirmation ──────────────────────────────────────────── +.session-list__confirm-modal { + width: calc(100% - 32px); + max-width: 340px; + background: var(--color-bg-elevated); + border-radius: 20px; + padding: var(--size-gap-6) var(--size-gap-5) var(--size-gap-5); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--size-gap-3); + text-align: center; + animation: slideUp var(--motion-base) var(--easing-decelerate); +} + +.session-list__confirm-icon { + color: var(--color-error-500, #dc3545); + margin-bottom: var(--size-gap-1); +} + +.session-list__confirm-title { + margin: 0; + font-size: 17px; + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); +} + +.session-list__confirm-desc { + margin: 0; + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + line-height: 1.5; +} + +.session-list__confirm-actions { + display: flex; + gap: 10px; + width: 100%; + margin-top: var(--size-gap-2); +} + +.session-list__confirm-btn { + flex: 1; + padding: 12px; + border: none; + @include squircle(14px); + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + cursor: pointer; + transition: all var(--motion-fast) var(--easing-standard); + + &:disabled { + opacity: var(--opacity-disabled); + cursor: not-allowed; + } + + &--cancel { + background: var(--color-bg-secondary); + color: var(--color-text-secondary); + + &:active:not(:disabled) { + background: var(--element-bg-subtle); + } + } + + &--danger { + background: var(--color-error-500, #dc3545); + color: #fff; + + &:active:not(:disabled) { + opacity: 0.85; + } + } +} + +// ── Action toast ─────────────────────────────────────────────────── +.session-list__toast { + position: fixed; + bottom: 40px; + left: 50%; + transform: translateX(-50%); + padding: 10px 20px; + border-radius: 999px; + background: var(--color-text-primary); + color: var(--color-bg-primary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + z-index: 9999; + box-shadow: var(--shadow-md); + pointer-events: none; + animation: toastIn 0.25s var(--easing-standard); +} + +@keyframes toastIn { + from { opacity: 0; transform: translateX(-50%) translateY(10px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + @media (prefers-reduced-motion: reduce) { .session-list *, .session-list *::before,