diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 3f53fafa7f..0aefa90e30 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -3,9 +3,10 @@ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable' import { useStore } from '@nanostores/react' import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useLocation } from 'react-router' +import { useLocation, useNavigate } from 'react-router' import { PlatformAvatar } from '@/app/messaging/platform-icon' +import { SESSION_IMPORT_ROUTE } from '@/app/routes' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu' @@ -333,6 +334,7 @@ export function ChatSidebar({ const { t } = useI18n() const s = t.sidebar const { pathname } = useLocation() + const navigate = useNavigate() // Contributed nav rows (plugins pairing a page with a sidebar entry) render // below the built-ins with the same chrome; active = at their route. const navContributions = useContributions(SIDEBAR_NAV_AREA) @@ -1617,6 +1619,13 @@ export function ChatSidebar({ +
+ +
+ {showSessionSections && (
void }) { label: cc.sections.sessions, run: go(`${COMMAND_CENTER_ROUTE}?section=sessions`) }, + { + icon: Download, + id: 'session-import', + keywords: ['import', 'claude', 'codex', 'conversation'], + label: t.sessionImport.action, + run: go(SESSION_IMPORT_ROUTE) + }, { icon: Activity, id: 'cc-system', diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 0783e243ac..2e7881845e 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -113,6 +113,7 @@ import { SETTINGS_ROUTE, syncWorkspaceRoute } from '../routes' +import { SessionImportView } from '../session-import' import { SessionPickerOverlay } from '../session-picker-overlay' import { SessionSwitcher } from '../session-switcher' import { useBackgroundQueueDrain } from '../session/hooks/use-background-queue-drain' @@ -1246,6 +1247,18 @@ export function ContribWiring({ children }: { children: ReactNode }) { )} + {currentView === 'session-import' && ( + { + closeOverlayToPreviousRoute() + openSession(sessionId, navigate, 'stack') + }} + owner={{ connectionId: activeConnectionId || 'local', profile: activeGatewayProfile }} + /> + )} + {commandCenterOpen && ( = new Set([ + 'session-import', 'agents', 'command-center', 'cron', diff --git a/apps/desktop/src/app/session-import/api.ts b/apps/desktop/src/app/session-import/api.ts new file mode 100644 index 0000000000..13fe71464b --- /dev/null +++ b/apps/desktop/src/app/session-import/api.ts @@ -0,0 +1,49 @@ +import { requestGatewayForAgent } from '@/store/gateway' +import type { SessionOwnerRoute } from '@/store/session-request-router' + +export interface ForeignSession { + id: string + source: 'claude' | 'codex' + label: string + title: string + cwd: string | null + mtime: number + turn_count: number + excerpt: string +} + +export interface ForeignPage { + sessions: ForeignSession[] + next_offset: number | null + host: string + unreadable: number +} + +export interface ForeignPreview { + truncated: boolean + messages: { role: string; content: string }[] + total: number + already_imported: string | null + cwd: string | null +} + +export interface ForeignImportResult { + session_id: string + already_imported: boolean +} + +export function foreignRequest( + owner: SessionOwnerRoute, + method: 'list' | 'preview' | 'import', + params: Record, + signal?: AbortSignal +) { + return requestGatewayForAgent( + owner.connectionId, + owner.profile, + `session.foreign.${method}`, + params, + 60_000, + signal + ) +} diff --git a/apps/desktop/src/app/session-import/index.test.tsx b/apps/desktop/src/app/session-import/index.test.tsx new file mode 100644 index 0000000000..2656245bd5 --- /dev/null +++ b/apps/desktop/src/app/session-import/index.test.tsx @@ -0,0 +1,102 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' + +import { setSessionOwnerHint } from '@/store/session' + +import { type ForeignImportResult, foreignRequest } from './api' + +import { SessionImportView } from './index' + +vi.mock('./api', () => ({ foreignRequest: vi.fn() })) +vi.mock('@/store/session', () => ({ setSessionOwnerHint: vi.fn() })) +vi.mock('@/components/assistant-ui/markdown-text', () => ({ + MarkdownTextContent: ({ text }: { text: string }) =>

{text}

+})) +vi.mock('../overlays/overlay-view', () => ({ + OverlayView: ({ children }: { children: React.ReactNode }) =>
{children}
+})) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +const owner = { connectionId: 'workstation', profile: 'research' } + +const session = { + id: 'foreign-one', + source: 'claude', + label: 'Claude Code', + title: 'Repair imports', + cwd: '/work/project', + mtime: 1000, + turn_count: 2, + excerpt: 'Help with imports' +} + +function mount(onOpenSession = vi.fn()) { + return { + onOpenSession, + ...render( + + + + ) + } +} + +it('browses without importing, then retries a failed import on the captured owner before opening', async () => { + let attempts = 0 + vi.mocked(foreignRequest).mockImplementation(async (_owner, method) => { + if (method === 'list') { + return { sessions: [session], next_offset: null, host: 'studio', unreadable: 0 } + } + + if (method === 'preview') { + return { messages: [{ role: 'user', content: 'Please repair this' }], total: 1, already_imported: null } + } + + if (++attempts === 1) { + throw new Error('Connection interrupted') + } + + return { session_id: 'durable-one', already_imported: false } + }) + const { onOpenSession } = mount() + fireEvent.click(await screen.findByRole('button', { name: /Repair imports/ })) + await screen.findByText('Please repair this') + expect(attempts).toBe(0) + fireEvent.click(screen.getByRole('button', { name: 'Continue in Hermes' })) + await screen.findByRole('alert') + expect(onOpenSession).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Continue in Hermes' })) + await waitFor(() => expect(onOpenSession).toHaveBeenCalledWith('durable-one')) + expect(setSessionOwnerHint).toHaveBeenCalledWith('durable-one', owner) + expect(foreignRequest).toHaveBeenCalledWith(owner, 'import', { id: 'foreign-one' }, expect.any(AbortSignal)) +}) + +it('does not navigate when an import finishes after the view has closed', async () => { + let finish!: (result: ForeignImportResult) => void + vi.mocked(foreignRequest).mockImplementation(async (_owner, method) => { + if (method === 'list') { + return { sessions: [session], next_offset: null, host: 'studio', unreadable: 0 } + } + + if (method === 'preview') { + return { messages: [], total: 0, already_imported: 'existing' } + } + + return new Promise(resolve => { + finish = resolve + }) + }) + const { onOpenSession, unmount } = mount() + fireEvent.click(await screen.findByRole('button', { name: /Repair imports/ })) + fireEvent.click(await screen.findByRole('button', { name: 'Open in Hermes' })) + await waitFor(() => expect(finish).toBeDefined()) + unmount() + finish({ session_id: 'existing', already_imported: true }) + await waitFor(() => expect(setSessionOwnerHint).toHaveBeenCalledWith('existing', owner)) + expect(onOpenSession).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/src/app/session-import/index.tsx b/apps/desktop/src/app/session-import/index.tsx new file mode 100644 index 0000000000..47eef212b4 --- /dev/null +++ b/apps/desktop/src/app/session-import/index.tsx @@ -0,0 +1,320 @@ +import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect, useRef, useState } from 'react' + +import { MarkdownTextContent } from '@/components/assistant-ui/markdown-text' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { EmptyState } from '@/components/ui/empty-state' +import { ErrorState } from '@/components/ui/error-state' +import { Loader } from '@/components/ui/loader' +import { SearchField } from '@/components/ui/search-field' +import { SegmentedControl } from '@/components/ui/segmented-control' +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' +import { setSessionOwnerHint } from '@/store/session' +import type { SessionOwnerRoute } from '@/store/session-request-router' + +import { OverlayView } from '../overlays/overlay-view' +import { PanelEmpty } from '../overlays/panel' + +import { type ForeignImportResult, type ForeignPage, type ForeignPreview, foreignRequest } from './api' + +interface SessionImportViewProps { + owner: SessionOwnerRoute + onClose: () => void + onOpenSession: (id: string) => void +} + +export function SessionImportView({ owner, onClose, onOpenSession }: SessionImportViewProps) { + const { t, locale } = useI18n() + const copy = t.sessionImport + const queryClient = useQueryClient() + const [source, setSource] = useState<'all' | 'claude' | 'codex'>('all') + const [search, setSearch] = useState('') + const [selected, setSelected] = useState(null) + const [pending, setPending] = useState(false) + const [error, setError] = useState('') + const lifetime = useRef(null) + // eslint-disable-next-line no-restricted-syntax -- lifetime cancellation, not a mirrored reactive value + useEffect(() => { + const controller = new AbortController() + lifetime.current = controller + + return () => controller.abort() + }, []) + const scope = [owner.connectionId, owner.profile] + + const sessions = useInfiniteQuery({ + queryKey: ['foreign-sessions', ...scope, source], + initialPageParam: 0, + queryFn: ({ pageParam, signal }) => + foreignRequest( + owner, + 'list', + { + source: source === 'all' ? null : source, + offset: pageParam + }, + signal + ), + getNextPageParam: page => page.next_offset, + retry: false + }) + + const rows = [ + ...new Map((sessions.data?.pages.flatMap(page => page.sessions) ?? []).map(row => [row.id, row])).values() + ] + + const visible = rows.filter(row => + `${row.title} ${row.cwd ?? ''} ${row.excerpt}`.toLocaleLowerCase().includes(search.toLocaleLowerCase()) + ) + + const current = rows.find(row => row.id === selected) + + const preview = useQuery({ + queryKey: ['foreign-preview', ...scope, selected], + queryFn: ({ signal }) => foreignRequest(owner, 'preview', { id: selected }, signal), + enabled: Boolean(current), + retry: false + }) + + const host = sessions.data?.pages[0]?.host + const unreadable = sessions.data?.pages.reduce((sum, page) => sum + page.unreadable, 0) ?? 0 + + async function continueSession() { + if (!current || pending) { + return + } + + const signal = lifetime.current?.signal + setPending(true) + setError('') + + try { + const result = await foreignRequest(owner, 'import', { id: current.id }, signal) + setSessionOwnerHint(result.session_id, owner) + void queryClient.invalidateQueries({ queryKey: ['foreign-preview', ...scope] }) + void queryClient.invalidateQueries({ queryKey: ['sessions'] }) + + if (!signal?.aborted) { + onOpenSession(result.session_id) + } + } catch (cause) { + if (!signal?.aborted) { + setError(cause instanceof Error ? cause.message : copy.importError) + } + } finally { + if (!signal?.aborted) { + setPending(false) + } + } + } + + return ( + { + void sessions.refetch() + + if (current) { + void preview.refetch() + } + }} + size="icon-titlebar" + variant="ghost" + > + + + } + > +
+
+

{copy.title}

+

{copy.subtitle}

+
+ + + {copy.readingFrom} {host ?? copy.connectedComputer} + + + + {copy.destination} {owner.targetProfile ?? owner.profile} + +
+
+
+ +
+ {!current ? ( + + ) : ( + <> +
+
+ +
+

{current.title}

+

+ {current.label} · {current.turn_count} {copy.messages} +

+
+
+ {preview.isPending && } + {preview.isError && ( + + + + )} + {preview.data && ( +
+ {preview.data.truncated && ( +

{copy.previewLimit}

+ )} + {preview.data.messages.map((message, index) => ( +
+

+ {message.role === 'user' ? copy.you : current.label} +

+
+ +
+
+ ))} +
+ )} +
+
+

+ {preview.data?.already_imported ? copy.snapshot : copy.copyNotice} +

+ + {error && ( +
+ {copy.importError} {error} +
+ )} +
+ + )} +
+
+
+
+ ) +} diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index f0671fb1a8..23031e6514 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -449,6 +449,8 @@ interface MarkdownTextSurfaceProps { /** Disable artifact-card promotion for fenced blocks (reasoning text — a * model's scratchpad draft must not register artifact versions). */ disableArtifacts?: boolean + /** Foreign history must not load images or mount live transcript directives. */ + previewOnly?: boolean } // Headings shrink to chat scale rather than the prose default (h1≈xl). Kept @@ -537,7 +539,8 @@ function MarkdownTextSurface({ containerClassName, containerProps, defer, - disableArtifacts + disableArtifacts, + previewOnly }: MarkdownTextSurfaceProps) { const { status, text } = useMessagePartText() const isStreaming = status.type === 'running' @@ -564,8 +567,9 @@ function MarkdownTextSurface({ h4: ({ className, ...props }: ComponentProps<'h4'>) => (

), - p: (props: ComponentProps<'p'>) => , - a: MarkdownLink, + p: (props: ComponentProps<'p'>) => + previewOnly ?

: , + a: previewOnly ? ({ children }: ComponentProps<'a'>) => {children} : MarkdownLink, // Inline code must not vote when an ancestor resolves `dir="auto"` // (HTML's algorithm skips descendants that carry their own dir), // mirroring the CSS isolate that already keeps it out of the @@ -624,13 +628,13 @@ function MarkdownTextSurface({ td: ({ className, ...props }: ComponentProps<'td'>) => ( ), - img: MarkdownImage, + img: previewOnly ? ({ alt }: ComponentProps<'img'>) => {alt} : MarkdownImage, // ```mermaid / ```svg fences route to their lazy renderers; substantial // html/svg/code fences promote to an artifact card that opens in the // right rail; every other language falls back to the Shiki-highlighted // code block. SyntaxHighlighter: (props: SyntaxHighlighterProps) => { - const artifact = disableArtifacts ? null : detectArtifact(props.language, props.code) + const artifact = disableArtifacts || previewOnly ? null : detectArtifact(props.language, props.code) if (artifact) { return @@ -646,7 +650,7 @@ function MarkdownTextSurface({ ) } }) as StreamdownTextComponents, - [disableArtifacts, isStreaming] + [disableArtifacts, isStreaming, previewOnly] ) if (text.length > MAX_MARKDOWN_CHARS) { diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index c99c0fbdce..007e631917 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -1,6 +1,42 @@ import { defineLocale } from './define-locale' export const ar = defineLocale({ + sessionImport: { + refresh: 'تحديث الجلسات', + title: 'المتابعة من تطبيق آخر', + subtitle: 'انقل محادثة إلى Hermes وتابع من حيث توقفت.', + action: 'استيراد جلسة', + readingFrom: 'القراءة من', + connectedComputer: 'الكمبيوتر المتصل', + destination: 'الاستيراد إلى', + all: 'الكل', + search: 'البحث في الجلسات المحملة', + scanning: 'جارٍ البحث عن المحادثات', + retry: 'حاول مرة أخرى', + scanError: 'تعذر العثور على الجلسات', + scanHelp: 'تحقق من اتصال الخادم ثم أعد المحاولة. قد تحتاج الخوادم القديمة إلى تحديث.', + empty: 'لا توجد محادثات', + emptyHelp: 'ستظهر هنا جلسات Claude Code وCodex الموجودة على هذا الخادم.', + noMatches: 'لا توجد محادثات مطابقة', + searchHelp: 'جرّب عنوانًا أو مجلدًا آخر، أو حمّل المزيد من الجلسات.', + skipped: 'تم تجاوز بعض السجلات الفارغة أو غير المقروءة أو الكبيرة جدًا.', + more: 'تحميل المزيد من الجلسات', + messages: 'رسائل', + choose: 'محادثة تستحق المتابعة', + chooseHelp: 'اختر جلسة لقراءة سجلها قبل نقلها إلى Hermes.', + back: 'العودة إلى الجلسات', + previewLoading: 'جارٍ فتح المعاينة', + previewError: 'المعاينة غير متاحة', + previewHelp: 'ربما تم نقل الملف الأصلي أو تغييره. حدّث القائمة وحاول مرة أخرى.', + previewLimit: 'تم اختصار المعاينة لتسهيل القراءة. يتم استيراد المحادثة كاملة.', + you: 'أنت', + snapshot: 'هذه المحادثة موجودة بالفعل في Hermes. افتح نسختك الحالية للمتابعة.', + copyNotice: 'ينسخ نص المحادثة دون تغيير الملفات الأصلية. لا يشمل مخرجات الأدوات أو الاستدلال.', + importing: 'جارٍ الاستيراد…', + open: 'فتح في Hermes', + continue: 'المتابعة في Hermes', + importError: 'تعذر استيراد هذه المحادثة.' + }, sendDiagnostics: { title: 'إرسال التشخيصات إلى Nous', privacyNotice: diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 6275a42ff7..7a1eef6084 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -3,6 +3,43 @@ import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants' import type { Translations } from './types' export const en: Translations = { + sessionImport: { + refresh: 'Refresh sessions', + title: 'Continue from another app', + subtitle: 'Bring a conversation into Hermes and pick up where you left off.', + action: 'Import session', + readingFrom: 'Reading from', + connectedComputer: 'the connected computer', + destination: 'Import into', + all: 'All', + search: 'Search loaded sessions', + scanning: 'Finding conversations', + retry: 'Try again', + scanError: 'Could not find sessions', + scanHelp: 'Check your backend connection, then try again. Older backends may need an update.', + empty: 'No conversations found', + emptyHelp: 'Claude Code and Codex sessions on this backend will appear here.', + noMatches: 'No matching conversations', + searchHelp: 'Try another title or folder, or load more sessions.', + skipped: 'Some logs were empty, unreadable, or too large to preview.', + more: 'Load more sessions', + messages: 'messages', + choose: 'A conversation worth continuing', + chooseHelp: 'Choose a session to read its history before bringing it into Hermes.', + back: 'Back to sessions', + previewLoading: 'Opening preview', + previewError: 'Preview unavailable', + previewHelp: 'The source may have moved or changed. Refresh the list and try again.', + previewLimit: 'Preview shortened for readability. The complete conversation is imported.', + you: 'You', + snapshot: 'This conversation is already in Hermes. Open your existing copy to continue.', + copyNotice: + 'Copies conversation text. Source files stay unchanged. Tool output and reasoning are not carried over.', + importing: 'Importing…', + open: 'Open in Hermes', + continue: 'Continue in Hermes', + importError: 'Could not import this conversation.' + }, common: { apply: 'Apply', back: 'Back', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 36d3408d9c..e7f5c7cbf4 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -3,6 +3,42 @@ import { defineFieldCopy } from '@/app/settings/field-copy' import { defineLocale } from './define-locale' export const ja = defineLocale({ + sessionImport: { + refresh: '一覧を更新', + title: '別のアプリから続ける', + subtitle: '会話をHermesに取り込み、続きを始めましょう。', + action: 'セッションを取り込む', + readingFrom: '読み込み元', + connectedComputer: '接続先のコンピューター', + destination: '取り込み先', + all: 'すべて', + search: '読み込み済みのセッションを検索', + scanning: '会話を検索中', + retry: '再試行', + scanError: 'セッションを取得できません', + scanHelp: 'バックエンドの接続を確認して再試行してください。古いバックエンドは更新が必要な場合があります。', + empty: '会話が見つかりません', + emptyHelp: 'このバックエンドのClaude CodeとCodexのセッションがここに表示されます。', + noMatches: '一致する会話がありません', + searchHelp: '別のタイトルやフォルダーを検索するか、セッションを追加で読み込んでください。', + skipped: '空、読み込み不可、または大きすぎるログをスキップしました。', + more: 'さらに読み込む', + messages: 'メッセージ', + choose: '会話の続きを始めましょう', + chooseHelp: 'セッションを選び、取り込む前に履歴を確認できます。', + back: 'セッションに戻る', + previewLoading: 'プレビューを開いています', + previewError: 'プレビューできません', + previewHelp: '元のファイルが移動または変更された可能性があります。一覧を更新してください。', + previewLimit: '読みやすいようにプレビューを省略しています。取り込み時は会話全体をコピーします。', + you: 'あなた', + snapshot: 'この会話は取り込み済みです。既存のコピーを開いて続けられます。', + copyNotice: '会話のテキストをコピーします。元のファイルは変更されません。ツール出力と推論は含まれません。', + importing: '取り込み中…', + open: 'Hermesで開く', + continue: 'Hermesで続ける', + importError: '会話を取り込めませんでした。' + }, common: { apply: '適用', back: '戻る', diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index fbeb912a24..a82750482a 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -24,6 +24,43 @@ const RU_NOUN = (count: number | string, one: string, few: string, many: string) } export const ru = defineLocale({ + sessionImport: { + refresh: 'Обновить сессии', + title: 'Продолжить из другого приложения', + subtitle: 'Перенесите разговор в Hermes и продолжите с того места, где остановились.', + action: 'Импортировать сессию', + readingFrom: 'Читаем с', + connectedComputer: 'подключённого компьютера', + destination: 'Импорт в', + all: 'Все', + search: 'Поиск по загруженным сессиям', + scanning: 'Поиск разговоров', + retry: 'Повторить', + scanError: 'Не удалось найти сессии', + scanHelp: 'Проверьте подключение к серверу и повторите попытку. Старому серверу может требоваться обновление.', + empty: 'Разговоров пока нет', + emptyHelp: 'Здесь появятся сессии Claude Code и Codex с этого сервера.', + noMatches: 'Совпадений нет', + searchHelp: 'Попробуйте другой заголовок или папку либо загрузите ещё сессии.', + skipped: 'Некоторые журналы пусты, недоступны или слишком велики для просмотра.', + more: 'Загрузить ещё сессии', + messages: 'сообщений', + choose: 'Разговор, который стоит продолжить', + chooseHelp: 'Выберите сессию, чтобы прочитать историю перед импортом в Hermes.', + back: 'Назад к сессиям', + previewLoading: 'Открываем просмотр', + previewError: 'Просмотр недоступен', + previewHelp: 'Исходный файл мог переместиться или измениться. Обновите список и повторите попытку.', + previewLimit: 'Просмотр сокращён для удобства чтения. Импортируется весь разговор.', + you: 'Вы', + snapshot: 'Этот разговор уже есть в Hermes. Откройте существующую копию, чтобы продолжить.', + copyNotice: + 'Копируется текст разговора. Исходные файлы не меняются. Вывод инструментов и рассуждения не переносятся.', + importing: 'Импорт…', + open: 'Открыть в Hermes', + continue: 'Продолжить в Hermes', + importError: 'Не удалось импортировать разговор.' + }, common: { apply: 'Применить', back: 'Назад', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index bacb24a5c7..b8dcc14adf 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -51,6 +51,42 @@ interface AuxTaskCopy { } export interface Translations { + sessionImport: { + refresh: string + title: string + subtitle: string + action: string + readingFrom: string + connectedComputer: string + destination: string + all: string + search: string + scanning: string + retry: string + scanError: string + scanHelp: string + empty: string + emptyHelp: string + noMatches: string + searchHelp: string + skipped: string + more: string + messages: string + choose: string + chooseHelp: string + back: string + previewLoading: string + previewError: string + previewHelp: string + previewLimit: string + you: string + snapshot: string + copyNotice: string + importing: string + open: string + continue: string + importError: string + } common: { apply: string back: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 9d6c61ba71..50a5a8b689 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -3,6 +3,42 @@ import { defineFieldCopy } from '@/app/settings/field-copy' import { defineLocale } from './define-locale' export const zhHant = defineLocale({ + sessionImport: { + refresh: '重新整理工作階段', + title: '從其他應用程式繼續', + subtitle: '將對話匯入 Hermes,接著上次的進度繼續。', + action: '匯入工作階段', + readingFrom: '讀取自', + connectedComputer: '已連線的電腦', + destination: '匯入至', + all: '全部', + search: '搜尋已載入的工作階段', + scanning: '正在尋找對話', + retry: '重試', + scanError: '無法尋找工作階段', + scanHelp: '請檢查後端連線並重試。舊版後端可能需要更新。', + empty: '找不到對話', + emptyHelp: '此後端上的 Claude Code 和 Codex 工作階段將顯示在這裡。', + noMatches: '沒有符合的對話', + searchHelp: '嘗試其他標題或資料夾,或載入更多工作階段。', + skipped: '部分記錄為空白、無法讀取或過大,已略過。', + more: '載入更多工作階段', + messages: '則訊息', + choose: '繼續一段對話', + chooseHelp: '選擇工作階段,在匯入 Hermes 前查看歷程記錄。', + back: '返回工作階段', + previewLoading: '正在開啟預覽', + previewError: '無法預覽', + previewHelp: '來源檔案可能已移動或變更。請重新整理清單後重試。', + previewLimit: '預覽已縮短,方便閱讀。匯入時會複製完整對話。', + you: '你', + snapshot: '此對話已匯入 Hermes。開啟現有副本即可繼續。', + copyNotice: '複製對話文字,不變更來源檔案。不包含工具輸出和推理內容。', + importing: '正在匯入…', + open: '在 Hermes 中開啟', + continue: '在 Hermes 中繼續', + importError: '無法匯入此對話。' + }, common: { apply: '套用', back: '返回', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 1c94cd2019..9ca3534e73 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -3,6 +3,42 @@ import { defineFieldCopy } from '@/app/settings/field-copy' import type { Translations } from './types' export const zh: Translations = { + sessionImport: { + refresh: '刷新会话', + title: '从其他应用继续', + subtitle: '将对话导入 Hermes,接着上次的进度继续。', + action: '导入会话', + readingFrom: '读取自', + connectedComputer: '已连接的计算机', + destination: '导入到', + all: '全部', + search: '搜索已加载的会话', + scanning: '正在查找对话', + retry: '重试', + scanError: '无法查找会话', + scanHelp: '请检查后端连接并重试。旧版后端可能需要更新。', + empty: '未找到对话', + emptyHelp: '此后端上的 Claude Code 和 Codex 会话将显示在这里。', + noMatches: '没有匹配的对话', + searchHelp: '尝试其他标题或文件夹,或加载更多会话。', + skipped: '部分日志为空、无法读取或过大,已跳过。', + more: '加载更多会话', + messages: '条消息', + choose: '继续一段对话', + chooseHelp: '选择会话,在导入 Hermes 前查看历史记录。', + back: '返回会话列表', + previewLoading: '正在打开预览', + previewError: '预览不可用', + previewHelp: '源文件可能已移动或更改。请刷新列表后重试。', + previewLimit: '预览已缩短,便于阅读。导入时会复制完整对话。', + you: '你', + snapshot: '此对话已导入 Hermes。打开现有副本即可继续。', + copyNotice: '复制对话文本,不更改源文件。不包含工具输出和推理内容。', + importing: '正在导入…', + open: '在 Hermes 中打开', + continue: '在 Hermes 中继续', + importError: '无法导入此对话。' + }, common: { apply: '应用', back: '返回', diff --git a/hermes_cli/foreign_session_browser.py b/hermes_cli/foreign_session_browser.py new file mode 100644 index 0000000000..cc194c6098 --- /dev/null +++ b/hermes_cli/foreign_session_browser.py @@ -0,0 +1,111 @@ +"""Backend-local discovery and previews for the desktop session importer.""" + +import hashlib +import re +import socket +from pathlib import Path + +from hermes_cli.foreign_sessions import _SOURCES, _SOURCE_LABELS, _SOURCE_DB_NAMES + +MAX_LOG_BYTES = 32 * 1024 * 1024 + + +def _display_title(parsed, source): + # Codex attachment envelopes put the actual request after a file listing. + # Keep the history intact; only the browser's display title skips that header. + first_user = next((turn["content"] for turn in parsed["turns"] if turn["role"] == "user"), "") + request = re.split(r"(?im)^#{1,6}\s*My request:\s*$", first_user, maxsplit=1) + title = request[-1].strip().splitlines()[0] if len(request) > 1 and request[-1].strip() else parsed["title_guess"] + return (title or _SOURCE_LABELS[source]).lstrip("# ")[:180] + + +def _candidates(source=None): + if source is not None and source not in _SOURCES: + raise ValueError("Unknown session source") + rows = [] + for name, (parts, pattern, recursive, _) in _SOURCES.items(): + if source and source != name: + continue + root = Path.home().joinpath(*parts).resolve() + if not root.is_dir(): + continue + for path in root.rglob(pattern) if recursive else root.glob(pattern): + resolved = path.resolve() + if not resolved.is_relative_to(root): + continue + try: + stat = resolved.stat() + except FileNotFoundError: + continue # A running source may rotate a log during discovery. + if not resolved.is_file(): + continue + handle = hashlib.sha256(f"{name}:{resolved}".encode()).hexdigest() + rows.append((stat.st_mtime, handle, name, resolved, stat.st_size)) + return sorted(rows, key=lambda row: (row[0], row[1]), reverse=True) + + +def _parse(candidate): + _, _, source, path, size = candidate + if size > MAX_LOG_BYTES: + raise ValueError("This log exceeds the 32 MB preview and import limit") + parsed = _SOURCES[source][3](path) + if not parsed["turns"]: + raise ValueError("This session has no readable conversation messages") + return parsed + + +def resolve_foreign_session(handle): + if not isinstance(handle, str) or len(handle) != 64: + raise ValueError("Unknown session. Refresh the list and try again") + for candidate in _candidates(): + if candidate[1] == handle: + return candidate, _parse(candidate) + raise ValueError("Session no longer available. Refresh the list and try again") + + +def list_foreign_sessions(source=None, offset=0, limit=25): + if not isinstance(offset, int) or offset < 0 or not isinstance(limit, int) or not 1 <= limit <= 50: + raise ValueError("Invalid session page") + candidates = _candidates(source) + rows, unreadable = [], 0 + # Page candidates before parsing. Empty or oversized logs cannot turn a + # request for 25 rows into an unbounded transcript scan. + for candidate in candidates[offset:offset + limit]: + mtime, handle, name, _, _ = candidate + try: + parsed = _parse(candidate) + except (ValueError, OSError): + unreadable += 1 + continue + rows.append({"id": handle, "source": name, "label": _SOURCE_LABELS[name], + "title": _display_title(parsed, name), + "cwd": parsed["cwd"], "mtime": mtime, "turn_count": len(parsed["turns"]), + "excerpt": parsed["turns"][0]["content"][:200]}) + next_offset = offset + limit + return {"sessions": rows, "next_offset": next_offset if next_offset < len(candidates) else None, + "host": socket.gethostname(), "unreadable": unreadable} + + +def foreign_origin(candidate, parsed): + return {"tool": _SOURCE_DB_NAMES[candidate[2]], "path": str(candidate[3]), + "foreign_session_id": parsed["session_id"]} + + +def preview_foreign_session(handle, db): + candidate, parsed = resolve_foreign_session(handle) + origin = foreign_origin(candidate, parsed) + existing = db.find_foreign_import(origin) + # A bounded preview avoids mounting thousands of messages in the renderer. + messages = [{**turn, "content": turn["content"][:8000]} for turn in parsed["turns"][-40:]] + return {"messages": messages, "total": len(parsed["turns"]), + "truncated": len(parsed["turns"]) > 40 or any(len(turn["content"]) > 8000 for turn in parsed["turns"][-40:]), + "already_imported": existing, "cwd": parsed["cwd"]} + + +def import_browser_session(handle, db, profile): + candidate, parsed = resolve_foreign_session(handle) + origin = foreign_origin(candidate, parsed) + cwd = parsed["cwd"] + return db.import_foreign_history(origin, parsed["turns"], + title=_display_title(parsed, candidate[2]), + cwd=cwd if cwd and Path(cwd).is_dir() else None, profile=profile) diff --git a/hermes_state_portability.py b/hermes_state_portability.py index d2da283c50..3858ccd9f5 100644 --- a/hermes_state_portability.py +++ b/hermes_state_portability.py @@ -80,6 +80,55 @@ _PROMPT_RESOLVED_SQL = "COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_r class SessionPortabilityMixin: """See module docstring — mixin for SessionDB (Port cluster).""" + @staticmethod + def _find_foreign_import_on_conn(conn, origin): + rows = conn.execute("SELECT id, origin_json FROM sessions WHERE source = ? AND origin_json IS NOT NULL", + (origin["tool"],)).fetchall() + for row in rows: + imported = (safe_json_loads(row["origin_json"], default={}) or {}).get("imported_from", {}) + if imported.get("tool") != origin["tool"]: + continue + foreign_id = origin.get("foreign_session_id") + if ((foreign_id and foreign_id == imported.get("foreign_session_id")) + or (not foreign_id and imported.get("path") == origin["path"])): + return row["id"] + return None + + def find_foreign_import(self, origin): + with self._read_ctx() as conn: + return self._find_foreign_import_on_conn(conn, origin) + + def import_foreign_history(self, origin, messages, *, title, cwd, profile): + """Adopt or mint a foreign snapshot in one transaction, including its provenance. + + BEGIN IMMEDIATE serializes duplicate clicks across connections/processes. + Reuse the portability validator and message writer so counters and FTS + obey the same contract as ordinary transcript imports. + """ + import uuid + session_id = f"{time.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:12]}" + normalized, errors = self._validate_import_payload([ + {"id": session_id, "source": origin["tool"], "title": title, + "cwd": cwd, "messages": messages}]) + if errors: + raise ValueError(errors[0]["error"]) + + def _do(conn): + existing = self._find_foreign_import_on_conn(conn, origin) + if existing: + return {"session_id": existing, "already_imported": True} + # Titles are globally unique within a profile. Preserve a readable + # title while giving unrelated conversations with the same text room. + item = normalized[0] + if conn.execute("SELECT 1 FROM sessions WHERE title = ?", (title,)).fetchone(): + item["session"]["title"] = f"{title} ({session_id[-12:]})" + self._import_session_row(conn, item["session"], item["messages"], session_id) + conn.execute("UPDATE sessions SET origin_json = ?, profile_name = ? WHERE id = ?", + (json.dumps({"imported_from": origin}), profile, session_id)) + return {"session_id": session_id, "already_imported": False} + + return self._execute_write(_do) + @classmethod def _compact_session_cols(cls) -> str: """``s.``-prefixed SELECT list of every SCHEMA_SQL ``sessions`` column except diff --git a/tests/tui_gateway/test_session_foreign.py b/tests/tui_gateway/test_session_foreign.py new file mode 100644 index 0000000000..fa8b4b93df --- /dev/null +++ b/tests/tui_gateway/test_session_foreign.py @@ -0,0 +1,97 @@ +"""Real foreign logs through the registered desktop RPCs and profile stores.""" + +import json +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest + + +def test_foreign_rpc_preview_import_and_profile_isolation(tmp_path, monkeypatch): + from hermes_state import SessionDB + from tui_gateway import server + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + folder = tmp_path / ".claude" / "projects" / "project" + folder.mkdir(parents=True) + log = folder / "session.jsonl" + lines = [{"type": role, "sessionId": "foreign-one", "cwd": str(tmp_path), + "message": {"role": role, "content": content}} + for role, content in [("user", "# Files mentioned by the user:\nplan.md\n\n## My request:\nFix the import"), ("assistant", "Here is the fix."), + ("assistant", "And its test."), ("user", "Continue")]] + log.write_text("\n".join(map(json.dumps, lines)), encoding="utf-8") + original = log.read_bytes() + db = SessionDB(tmp_path / ".hermes" / "state.db") + monkeypatch.setattr(server, "_get_db", lambda: db) + monkeypatch.setattr(server, "_profile_home", lambda profile: None) + + def rpc(method, **params): + result = server._methods[f"session.foreign.{method}"](1, params) + assert "error" not in result, result + return result["result"] + + try: + page = rpc("list", limit=1) + handle = page["sessions"][0]["id"] + assert page["sessions"][0]["title"] == "Fix the import" + assert "path" not in page["sessions"][0] + preview = rpc("preview", id=handle) + assert preview["already_imported"] is None + assert db.session_count() == 0 + with ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(lambda _: rpc("import", id=handle), range(2))) + assert results[0]["session_id"] == results[1]["session_id"] + sid = results[0]["session_id"] + stored = db.get_session(sid) + assert json.loads(stored["origin_json"])["imported_from"]["foreign_session_id"] == "foreign-one" + history = db.get_messages(sid) + assert [message["role"] for message in history] == ["user", "assistant", "user"] + assert len(history) == preview["total"] == stored["message_count"] + assert rpc("preview", id=handle)["already_imported"] == sid + assert log.read_bytes() == original + with SessionDB(tmp_path / "other" / "state.db") as other: + monkeypatch.setattr(server, "_get_db", lambda: other) + assert rpc("preview", id=handle)["already_imported"] is None + assert rpc("import", id=handle)["session_id"] != sid + finally: + db.close() + + +def test_foreign_pages_confine_handles_and_failed_import_rolls_back(tmp_path, monkeypatch): + from hermes_cli import foreign_session_browser as browser + from hermes_state import SessionDB + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + folder = tmp_path / ".codex" / "sessions" + folder.mkdir(parents=True) + for index in range(3): + (folder / f"rollout-{index}.jsonl").write_text(json.dumps({ + "type": "response_item", "payload": {"type": "message", "role": "user", + "content": [{"type": "input_text", "text": f"Question {index}"}]}}), encoding="utf-8") + calls = [] + parse = browser._parse + monkeypatch.setattr(browser, "_parse", lambda row: (calls.append(row[1]), parse(row))[1]) + page = browser.list_foreign_sessions(limit=1) + assert len(calls) == 1 + next_page = browser.list_foreign_sessions(offset=page["next_offset"], limit=1) + assert page["sessions"][0]["id"] != next_page["sessions"][0]["id"] + with pytest.raises(ValueError): + browser.resolve_foreign_session(str(tmp_path / "secret.jsonl")) + # A fabricated well-shaped handle cannot become a file-read request. + with pytest.raises(ValueError): + browser.resolve_foreign_session("0" * 64) + db = SessionDB(tmp_path / ".hermes" / "state.db") + insert = db._insert_message_rows + def failing_insert(*args): + insert(*args) + raise RuntimeError("interrupted write") + monkeypatch.setattr(db, "_insert_message_rows", failing_insert) + try: + with pytest.raises(RuntimeError, match="interrupted write"): + browser.import_browser_session(page["sessions"][0]["id"], db, "default") + assert db.session_count() == 0 + finally: + db.close() diff --git a/tui_gateway/methods_session_foreign.py b/tui_gateway/methods_session_foreign.py new file mode 100644 index 0000000000..82fc7edf15 --- /dev/null +++ b/tui_gateway/methods_session_foreign.py @@ -0,0 +1,46 @@ +"""Desktop foreign-history browsing, scoped to the serving backend and profile.""" + +from .method_ctx import HandlerRegistry, bind_module + +_registry = HandlerRegistry() +method = _registry.method + + +@method("session.foreign.list") +def _foreign_list(rid, params): + from hermes_cli.foreign_session_browser import list_foreign_sessions + try: + return _ok(rid, list_foreign_sessions(params.get("source"), params.get("offset", 0), params.get("limit", 25))) + except ValueError as exc: + return _err(rid, -32602, str(exc)) + except OSError: + return _err(rid, -32000, "Could not read session folders on this backend") + + +def _foreign_history_request(rid, params, importing): + from hermes_cli.foreign_session_browser import import_browser_session, preview_foreign_session + try: + with _profile_db(params) as db: + if db is None: + return _db_unavailable_error(rid, code=-32000) + result = (import_browser_session(params.get("id"), db, _response_profile_name(params.get("profile"))) + if importing else preview_foreign_session(params.get("id"), db)) + return _ok(rid, result) + except ValueError as exc: + return _err(rid, -32602, str(exc)) + except OSError: + return _err(rid, -32000, "Could not read this session on the backend") + + +@method("session.foreign.preview") +def _foreign_preview(rid, params): + return _foreign_history_request(rid, params, False) + + +@method("session.foreign.import") +def _foreign_import(rid, params): + return _foreign_history_request(rid, params, True) + + +def register(server): + bind_module(globals(), server) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 671def454a..33ebf7ba25 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -158,6 +158,7 @@ _DETAIL_MODES = frozenset({"hidden", "collapsed", "expanded"}) # interrupts); voice.*/wake.* = SYNCHRONOUS faster-whisper install (300s); session.workspace.move = # git subprocess probes on an arbitrary (maybe slow) mount. _LONG_HANDLERS = frozenset({ + "session.foreign.list", "session.foreign.preview", "session.foreign.import", "billing.state", "subscription.state", "subscription.preview", "subscription.change", "subscription.resume", "subscription.upgrade", "usage.bars", "session.usage", "billing.step_up", "browser.manage", "cli.exec", "complete.path", "complete.slash", "llm.oneshot", "model.options", @@ -3206,7 +3207,7 @@ from . import ( # noqa: E402 methods_config_set as _methods_config_set, methods_images as _methods_images, methods_profiles as _methods_profiles, methods_prompt as _methods_prompt, methods_session as _methods_session, methods_tools as _methods_tools, prompt_turn as _prompt_turn, billing_view as _billing_view, - methods_projects as _methods_projects) + methods_projects as _methods_projects, methods_session_foreign as _methods_session_foreign) for _m in ( _session_reaper, _session_lifecycle, _session_workdir, _compute_host_bridge, _model_switch, @@ -3215,6 +3216,6 @@ for _m in ( _methods_complete_helpers, _methods_slash, _methods_voice, _methods_browser, _methods_browser_control, _methods_session, _methods_prompt, _methods_config, _methods_config_set, _methods_complete, _methods_tools, _methods_profiles, _methods_images, - _methods_bot_relay, _prompt_turn, _billing_view, _methods_projects): + _methods_bot_relay, _prompt_turn, _billing_view, _methods_projects, _methods_session_foreign): _m.register(sys.modules[__name__]) del _m