feat(desktop): session import view for foreign coding-agent transcripts
Browse the backend host's foreign CLI session logs, preview a bounded read-only transcript, and continue a copy in Hermes under the selected profile. Reuses the hermes_cli.foreign_sessions parsers and the portability validator/writer; imports are transactional and deduplicated on the recorded origin.
This commit is contained in:
@@ -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({
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<div className="shrink-0 px-3 py-2">
|
||||
<Button onClick={() => navigate(SESSION_IMPORT_ROUTE)} size="sm" variant="text">
|
||||
<Codicon name="cloud-download" />
|
||||
{t.sessionImport.action}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showSessionSections && (
|
||||
<div className="shrink-0 px-2 pb-1 pt-1">
|
||||
<SearchField
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
HUD_SURFACE,
|
||||
HUD_TEXT
|
||||
} from '@/app/floating-hud'
|
||||
import { SESSION_IMPORT_ROUTE } from '@/app/routes'
|
||||
import { codiconIcon } from '@/components/ui/codicon'
|
||||
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { HighlightMatches } from '@/components/ui/highlight-matches'
|
||||
@@ -898,6 +899,13 @@ function CommandPaletteBody({ onExited }: { onExited: () => 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',
|
||||
|
||||
@@ -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 }) {
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{currentView === 'session-import' && (
|
||||
<SessionImportView
|
||||
key={`${activeConnectionId}:${activeGatewayProfile}`}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onOpenSession={sessionId => {
|
||||
closeOverlayToPreviousRoute()
|
||||
openSession(sessionId, navigate, 'stack')
|
||||
}}
|
||||
owner={{ connectionId: activeConnectionId || 'local', profile: activeGatewayProfile }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{commandCenterOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<CommandCenterView
|
||||
|
||||
@@ -10,6 +10,7 @@ export const SESSION_ROUTE_PREFIX = '/'
|
||||
export const NEW_CHAT_ROUTE = '/'
|
||||
export const SETTINGS_ROUTE = '/settings'
|
||||
export const COMMAND_CENTER_ROUTE = '/command-center'
|
||||
export const SESSION_IMPORT_ROUTE = '/session-import'
|
||||
export const SKILLS_ROUTE = '/skills'
|
||||
export const MESSAGING_ROUTE = '/messaging'
|
||||
export const WEBHOOKS_ROUTE = '/webhooks'
|
||||
@@ -20,6 +21,7 @@ export const AGENTS_ROUTE = '/agents'
|
||||
export const STARMAP_ROUTE = '/starmap'
|
||||
|
||||
export type AppView =
|
||||
| 'session-import'
|
||||
| 'agents'
|
||||
| 'artifacts'
|
||||
| 'chat'
|
||||
@@ -38,6 +40,7 @@ export type AppView =
|
||||
| 'webhooks'
|
||||
|
||||
export type AppRouteId =
|
||||
| 'session-import'
|
||||
| 'agents'
|
||||
| 'artifacts'
|
||||
| 'command-center'
|
||||
@@ -57,6 +60,7 @@ export interface AppRoute {
|
||||
}
|
||||
|
||||
export const APP_ROUTES = [
|
||||
{ id: 'session-import', path: SESSION_IMPORT_ROUTE, view: 'session-import' },
|
||||
{ id: 'new', path: NEW_CHAT_ROUTE, view: 'chat' },
|
||||
{ id: 'settings', path: SETTINGS_ROUTE, view: 'settings' },
|
||||
{ id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' },
|
||||
@@ -123,6 +127,7 @@ export interface SidebarNavContribution {
|
||||
// While one is open the app's titlebar control clusters must hide so they don't
|
||||
// bleed over the overlay (they sit at a higher z-index than the overlay card).
|
||||
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
|
||||
'session-import',
|
||||
'agents',
|
||||
'command-center',
|
||||
'cron',
|
||||
|
||||
@@ -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<T>(
|
||||
owner: SessionOwnerRoute,
|
||||
method: 'list' | 'preview' | 'import',
|
||||
params: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
return requestGatewayForAgent<T>(
|
||||
owner.connectionId,
|
||||
owner.profile,
|
||||
`session.foreign.${method}`,
|
||||
params,
|
||||
60_000,
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -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 }) => <p>{text}</p>
|
||||
}))
|
||||
vi.mock('../overlays/overlay-view', () => ({
|
||||
OverlayView: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<SessionImportView onClose={vi.fn()} onOpenSession={onOpenSession} owner={owner} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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<ForeignImportResult>(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()
|
||||
})
|
||||
@@ -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<string | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const lifetime = useRef<AbortController | null>(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<ForeignPage>(
|
||||
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<ForeignPreview>(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<ForeignImportResult>(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 (
|
||||
<OverlayView
|
||||
onClose={onClose}
|
||||
rootClassName="bg-(--ui-bg-elevated)"
|
||||
titlebarActions={
|
||||
<Button
|
||||
aria-label={copy.refresh}
|
||||
disabled={sessions.isFetching}
|
||||
onClick={() => {
|
||||
void sessions.refetch()
|
||||
|
||||
if (current) {
|
||||
void preview.refetch()
|
||||
}
|
||||
}}
|
||||
size="icon-titlebar"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<section aria-label={copy.title} className="session-import flex h-full min-h-0 flex-col">
|
||||
<header className="px-8 pb-6 pt-12">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-(--ui-text-primary)">{copy.title}</h1>
|
||||
<p className="mt-2 text-sm text-(--ui-text-secondary)">{copy.subtitle}</p>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-(--ui-text-secondary)">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Codicon name="device-desktop" />
|
||||
{copy.readingFrom} {host ?? copy.connectedComputer}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Codicon name="account" />
|
||||
{copy.destination} {owner.targetProfile ?? owner.profile}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[minmax(17rem,0.85fr)_minmax(0,1.6fr)] border-t border-(--ui-stroke-tertiary) max-[760px]:grid-cols-1">
|
||||
<aside
|
||||
className={cn(
|
||||
'flex min-h-0 flex-col bg-(--ui-sidebar-surface-background)',
|
||||
selected && 'max-[760px]:hidden'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-5 px-6 pb-4 pt-6">
|
||||
<SegmentedControl
|
||||
onChange={value => {
|
||||
setSource(value)
|
||||
setSelected(null)
|
||||
setError('')
|
||||
}}
|
||||
options={[
|
||||
{ id: 'all', label: copy.all },
|
||||
{ id: 'claude', label: 'Claude Code' },
|
||||
{ id: 'codex', label: 'Codex' }
|
||||
]}
|
||||
value={source}
|
||||
/>
|
||||
{rows.length > 0 && (
|
||||
<SearchField
|
||||
aria-label={copy.search}
|
||||
containerClassName="opacity-100"
|
||||
onChange={setSearch}
|
||||
placeholder={copy.search}
|
||||
value={search}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-5">
|
||||
{sessions.isPending && <Loader className="mx-auto my-12" label={copy.scanning} />}
|
||||
{sessions.isError && (
|
||||
<ErrorState className="p-5" description={copy.scanHelp} title={copy.scanError}>
|
||||
<Button onClick={() => void sessions.refetch()} variant="secondary">
|
||||
{copy.retry}
|
||||
</Button>
|
||||
</ErrorState>
|
||||
)}
|
||||
{!sessions.isPending && !sessions.isError && visible.length === 0 && (
|
||||
<div className="p-5">
|
||||
<EmptyState
|
||||
description={search ? copy.searchHelp : copy.emptyHelp}
|
||||
title={search ? copy.noMatches : copy.empty}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{visible.map((row, index) => {
|
||||
const date = new Date(row.mtime * 1000).toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
|
||||
const previous = visible[index - 1]
|
||||
|
||||
const newDate =
|
||||
!previous ||
|
||||
new Date(previous.mtime * 1000).toDateString() !== new Date(row.mtime * 1000).toDateString()
|
||||
|
||||
return (
|
||||
<div key={row.id}>
|
||||
{newDate && <p className="px-3 pb-2 pt-5 text-xs font-medium text-(--ui-text-secondary)">{date}</p>}
|
||||
<button
|
||||
aria-pressed={selected === row.id}
|
||||
className={cn(
|
||||
'w-full cursor-pointer rounded-md px-3 py-4 text-start transition-colors hover:bg-(--chrome-action-hover) focus-visible:outline-2 focus-visible:outline-(--ui-accent)',
|
||||
selected === row.id && 'bg-(--chrome-action-hover)'
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelected(row.id)
|
||||
setError('')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="mb-2 flex items-center gap-2 text-xs text-(--ui-text-secondary)">
|
||||
<Codicon name="comment-discussion" />
|
||||
{row.label}
|
||||
<span className="ms-auto tabular-nums">
|
||||
{row.turn_count} {copy.messages}
|
||||
</span>
|
||||
</span>
|
||||
<span className="line-clamp-2 text-sm font-medium leading-5 text-(--ui-text-primary)">
|
||||
{row.title}
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-xs leading-5 text-(--ui-text-secondary)">
|
||||
{row.cwd || row.excerpt}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{unreadable > 0 && <p className="px-3 py-4 text-xs text-(--ui-text-secondary)">{copy.skipped}</p>}
|
||||
{sessions.hasNextPage && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
disabled={sessions.isFetchingNextPage}
|
||||
onClick={() => void sessions.fetchNextPage()}
|
||||
variant="text"
|
||||
>
|
||||
{copy.more}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<main className={cn('flex min-h-0 min-w-0 flex-col', !selected && 'max-[760px]:hidden')}>
|
||||
{!current ? (
|
||||
<PanelEmpty description={copy.chooseHelp} icon="comment-discussion" title={copy.choose} />
|
||||
) : (
|
||||
<>
|
||||
<div className="px-8 pb-5 pt-6">
|
||||
<div className="mb-4 hidden max-[760px]:block">
|
||||
<Button onClick={() => setSelected(null)} variant="text">
|
||||
{copy.back}
|
||||
</Button>
|
||||
</div>
|
||||
<h2 className="text-xl font-medium leading-7 tracking-tight">{current.title}</h2>
|
||||
<p className="mt-2 text-xs text-(--ui-text-secondary)">
|
||||
{current.label} · {current.turn_count} {copy.messages}
|
||||
</p>
|
||||
</div>
|
||||
<div aria-live="polite" className="min-h-0 flex-1 overflow-y-auto px-8 pb-8" key={current.id}>
|
||||
{preview.isPending && <Loader className="mx-auto my-12" label={copy.previewLoading} />}
|
||||
{preview.isError && (
|
||||
<ErrorState description={copy.previewHelp} title={copy.previewError}>
|
||||
<Button onClick={() => void preview.refetch()} variant="secondary">
|
||||
{copy.retry}
|
||||
</Button>
|
||||
</ErrorState>
|
||||
)}
|
||||
{preview.data && (
|
||||
<div className="max-w-prose space-y-7">
|
||||
{preview.data.truncated && (
|
||||
<p className="text-xs text-(--ui-text-secondary)">{copy.previewLimit}</p>
|
||||
)}
|
||||
{preview.data.messages.map((message, index) => (
|
||||
<article className="min-w-0" key={index}>
|
||||
<p className="mb-2 text-xs font-semibold text-(--ui-text-secondary)">
|
||||
{message.role === 'user' ? copy.you : current.label}
|
||||
</p>
|
||||
<div className="text-sm leading-6 wrap-anywhere">
|
||||
<MarkdownTextContent isRunning={false} previewOnly text={message.content} />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<footer className="flex flex-wrap items-center justify-between gap-4 border-t border-(--ui-stroke-tertiary) px-8 py-5">
|
||||
<p className="max-w-xs text-xs leading-5 text-(--ui-text-secondary)">
|
||||
{preview.data?.already_imported ? copy.snapshot : copy.copyNotice}
|
||||
</p>
|
||||
<Button disabled={pending || !preview.data || preview.isError} onClick={() => void continueSession()}>
|
||||
{pending ? copy.importing : preview.data?.already_imported ? copy.open : copy.continue}
|
||||
<Codicon name="arrow-right" />
|
||||
</Button>
|
||||
{error && (
|
||||
<div className="w-full text-sm text-destructive" role="alert">
|
||||
{copy.importError} {error}
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
@@ -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'>) => (
|
||||
<h4 className={cn('my-1 font-semibold', HEADING_SIZES.h4, className)} {...props} />
|
||||
),
|
||||
p: (props: ComponentProps<'p'>) => <MarkdownParagraph {...props} streaming={isStreaming} />,
|
||||
a: MarkdownLink,
|
||||
p: (props: ComponentProps<'p'>) =>
|
||||
previewOnly ? <p {...props} /> : <MarkdownParagraph {...props} streaming={isStreaming} />,
|
||||
a: previewOnly ? ({ children }: ComponentProps<'a'>) => <span>{children}</span> : 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'>) => (
|
||||
<td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} />
|
||||
),
|
||||
img: MarkdownImage,
|
||||
img: previewOnly ? ({ alt }: ComponentProps<'img'>) => <span>{alt}</span> : 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 <ArtifactCard code={props.code} detection={artifact} streaming={isStreaming} />
|
||||
@@ -646,7 +650,7 @@ function MarkdownTextSurface({
|
||||
)
|
||||
}
|
||||
}) as StreamdownTextComponents,
|
||||
[disableArtifacts, isStreaming]
|
||||
[disableArtifacts, isStreaming, previewOnly]
|
||||
)
|
||||
|
||||
if (text.length > MAX_MARKDOWN_CHARS) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '戻る',
|
||||
|
||||
@@ -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: 'Назад',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '返回',
|
||||
|
||||
@@ -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: '返回',
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user