feat(opencode): import local session history (#1677)

* feat(cli): list local OpenCode sessions over machine RPC

* feat(hub): import OpenCode sessions into HAPI

* feat(web): import OpenCode history from new-session picker

* fix(cli): derive OpenCode imported message ids from part ids

Random ids made every scan differ, so delta classification flagged
unchanged transcripts as diverged on re-import.

* fix(cli): surface unreadable OpenCode db as rpc error

A locked or unopenable opencode.db now fails the listing instead of
reporting an empty success; summaries normalize timestamps and the web
import toast counts only created or actually-appended sessions.

* fix(hub): fail closed on opencode re-import past live HAPI continuation

Imported entries must remain a strict prefix of the native transcript.
Insertions ahead of the imported boundary or new native messages after
the session continued in HAPI now fail closed instead of appending
duplicated or reordered history.

* fix(web,hub): apply selected launch config when importing OpenCode history

Pass the model, reasoning effort, and permission mode chosen in the
new-session picker through the import request and persist them on the
imported HAPI session via applySessionConfig, so reopen launches with
the visible selection instead of stale or default values.

* fix(hub,cli): preserve explicit launch resets and skip streaming parts on import

- Launch config keys are applied by presence, so explicit null/default
  selections from the picker must reach applySessionConfig to clear
  stale model, effort, or permission values on re-import. Requests that
  omit the fields entirely still leave persisted config untouched.
- Assistant text parts are mutable while a response streams. Mirror the
  live scanner gate and import only finished (time.end set), synthetic,
  or user text so an in-progress row can never be stored and later
  finalized as transcript_diverged.

* test(hub): fix launch-config reset assertions to satisfy strict types

* fix(cli): fail opencode transcript reads on query errors instead of partial snapshots

Swallowed SQLite errors could return a truncated message list that the
hub later rejects as transcript_diverged with no repair path. Full
transcript reads now propagate query failures; summary lastUserMessage
extraction stays resilient per message.

* test(cli): cover unreadable-part-table transcript read stays non-partial

* fix(hub): scope launch-config failures to the per-session import result

A thrown applySessionConfig call used to escape the bulk handler as an
unstructured 500 even though transcripts were already persisted. Catch
it per session and report it as a config_failed result so remaining
sessions in a bulk request still import.

Also cover the mid-transcript part-query failure with an injected
opener that rejects instead of returning a holey transcript.

* fix(cli): read opencode transcripts inside a deferred snapshot transaction

Messages and their parts were read in separate statements, so a
concurrent OpenCode write could leave a hole in the imported sequence
that positional re-import checks then reject forever. Wrap each full
transcript read in BEGIN DEFERRED/COMMIT with rollback on failure.

* fix(cli): follow machine path policy for OpenCode imports

* fix(web): use unified permission mode for OpenCode imports

* fix(opencode): harden session import consistency

* fix(hub): preserve metadata on rejected OpenCode imports

* fix(hub): refresh OpenCode import state under lock

* fix(hub): resolve OpenCode imports after locking

* fix(opencode): bind imported resume to source machine
This commit is contained in:
Junmo Kim
2026-10-28 12:14:18 +08:00
committed by GitHub
parent 3f4bd9afea
commit ae147e85db
21 changed files with 2229 additions and 11 deletions
+17
View File
@@ -14,6 +14,8 @@ import type {
FileSearchResponse,
MachinesResponse,
MessagesResponse,
OpencodeImportSessionsResponse,
OpencodeLocalSessionsResponse,
PermissionMode,
PiImportSessionsResponse,
PiLocalSessionsResponse,
@@ -316,6 +318,21 @@ export class ApiClient {
})
}
async getOpencodeSessions(cwd?: string | null, machineId?: string | null): Promise<OpencodeLocalSessionsResponse> {
const params = new URLSearchParams()
if (cwd?.trim()) params.set('cwd', cwd.trim())
if (machineId?.trim()) params.set('machineId', machineId.trim())
const query = params.size ? `?${params.toString()}` : ''
return await this.request<OpencodeLocalSessionsResponse>(`/api/opencode/sessions${query}`)
}
async importOpencodeSessions(payload: { sessionIds: string[]; cwd?: string | null; machineId?: string | null; model?: string | null; modelReasoningEffort?: string | null; permissionMode?: PermissionMode }): Promise<OpencodeImportSessionsResponse> {
return await this.request<OpencodeImportSessionsResponse>('/api/opencode/import-sessions', {
method: 'POST',
body: JSON.stringify(payload)
})
}
async archiveCodexSession(sessionId: string, machineId?: string | null): Promise<CodexArchiveSessionResponse> {
return await this.request<CodexArchiveSessionResponse>('/api/codex/archive-session', {
method: 'POST',
@@ -0,0 +1,26 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { OpencodeImportActions } from './OpencodeImportActions'
vi.mock('@/lib/use-translation', () => ({
useTranslation: () => ({ t: (key: string) => key })
}))
describe('OpencodeImportActions', () => {
it('opens the local OpenCode history picker', () => {
const onChooseHistory = vi.fn()
render(
<OpencodeImportActions
selectedSession={null}
isLoading={false}
isDisabled={false}
error={null}
onChooseHistory={onChooseHistory}
onClear={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'opencodeSync.inline.choose' }))
expect(onChooseHistory).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,41 @@
import type { OpencodeLocalSessionSummary } from '@/types/api'
import { useTranslation } from '@/lib/use-translation'
export function OpencodeImportActions(props: {
selectedSession: OpencodeLocalSessionSummary | null
isLoading: boolean
isDisabled: boolean
error: string | null
onChooseHistory: () => void
onClear: () => void
}) {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-2 px-3 py-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-xs font-medium text-[var(--app-hint)]">{t('opencodeSync.inline.title')}</div>
<div className="truncate text-[11px] text-[var(--app-hint)]">
{props.selectedSession ? props.selectedSession.title : t('opencodeSync.inline.description')}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{props.selectedSession ? (
<button type="button" className="text-xs text-[var(--app-link)]" onClick={props.onClear} disabled={props.isDisabled}>
{t('opencodeSync.inline.clear')}
</button>
) : null}
<button
type="button"
className="rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] px-2 py-1.5 text-xs text-[var(--app-fg)] hover:bg-[var(--app-secondary-bg)] disabled:opacity-50"
onClick={props.onChooseHistory}
disabled={props.isDisabled || props.isLoading}
>
{props.isLoading ? t('opencodeSync.loading') : t('opencodeSync.inline.choose')}
</button>
</div>
</div>
{props.error ? <div className="text-xs text-red-600">{props.error}</div> : null}
</div>
)
}
+187 -2
View File
@@ -1,6 +1,6 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import type { ApiClient } from '@/api/client'
import type { CodexDuplicateSessionGroup, CodexLocalSessionSummary, Machine, PiLocalSessionSummary } from '@/types/api'
import type { CodexDuplicateSessionGroup, CodexLocalSessionSummary, Machine, OpencodeLocalSessionSummary, PiLocalSessionSummary } from '@/types/api'
import type { CodexCollaborationMode, GrokPermissionMode, PermissionMode, CopilotAgentMode } from '@hapi/protocol'
import { getClaudeEffortLevelsForModel, resolveClaudeEffortForModel } from '@hapi/protocol'
import { codexModelAdvertisesFastTier } from '@/components/AssistantChat/codexFastMode'
@@ -52,6 +52,7 @@ import { AgentSelector } from './AgentSelector'
import { CollaborationModeSelector } from './CollaborationModeSelector'
import { CodexImportActions } from './CodexImportActions'
import { PiImportActions } from './PiImportActions'
import { OpencodeImportActions } from './OpencodeImportActions'
import { clearBatchImportedCodexSelection, resolveCodexImportRedirectSessionId } from './codexImportMerge'
import { AgyModelSelector } from './AgyModelSelector'
import { DirectorySection } from './DirectorySection'
@@ -80,6 +81,7 @@ import { PermissionField } from './PermissionField'
import { usesNativePermissionSelect, usesSharedPermissionModeState } from '@/lib/codexFamilyPermissionAgents'
import { CodexSessionSyncDialog } from '@/components/CodexSessionSyncDialog'
import { PiSessionImportDialog } from '@/components/PiSessionImportDialog'
import { OpencodeSessionImportDialog } from '@/components/OpencodeSessionImportDialog'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { formatRunnerSpawnError } from '../../utils/formatRunnerSpawnError'
import { markCodexSessionsImported } from '@/lib/codexImportedSessions'
@@ -150,6 +152,15 @@ export function NewSession(props: {
const [isBulkImportingPiSessions, setIsBulkImportingPiSessions] = useState(false)
const [isPiImportDialogOpen, setIsPiImportDialogOpen] = useState(false)
const piLoadGenerationRef = useRef(0)
const [opencodeImportSessions, setOpencodeImportSessions] = useState<OpencodeLocalSessionSummary[]>([])
const [selectedOpencodeImportSessionId, setSelectedOpencodeImportSessionId] = useState<string | null>(null)
const [opencodeImportMachineId, setOpencodeImportMachineId] = useState<string | null>(null)
const [isLoadingOpencodeImportSessions, setIsLoadingOpencodeImportSessions] = useState(false)
const [opencodeImportError, setOpencodeImportError] = useState<string | null>(null)
const [isImportingOpencodeSession, setIsImportingOpencodeSession] = useState(false)
const [isBulkImportingOpencodeSessions, setIsBulkImportingOpencodeSessions] = useState(false)
const [isOpencodeImportDialogOpen, setIsOpencodeImportDialogOpen] = useState(false)
const opencodeLoadGenerationRef = useRef(0)
const [isCreating, setIsCreating] = useState(false)
const createInFlightRef = useRef(false)
const [isBulkImportingCodexSessions, setIsBulkImportingCodexSessions] = useState(false)
@@ -167,6 +178,8 @@ export function NewSession(props: {
|| isBulkImportingCodexSessions
|| isImportingPiSession
|| isBulkImportingPiSessions
|| isImportingOpencodeSession
|| isBulkImportingOpencodeSessions
)
const worktreeInputRef = useRef<HTMLInputElement>(null)
const preserveRestoredDraftRef = useRef(false)
@@ -1165,6 +1178,44 @@ export function NewSession(props: {
return message?.trim() || t('piImport.failed.body')
}, [t])
useEffect(() => {
opencodeLoadGenerationRef.current += 1
setIsLoadingOpencodeImportSessions(false)
}, [agent, machineId, trimmedDirectory])
useEffect(() => () => {
opencodeLoadGenerationRef.current += 1
}, [])
const loadOpencodeImportSessions = useCallback(async () => {
if (agent !== 'opencode' || !machineId) return
const generation = ++opencodeLoadGenerationRef.current
setIsLoadingOpencodeImportSessions(true)
setOpencodeImportError(null)
try {
const result = await props.api.getOpencodeSessions(trimmedDirectory || null, machineId)
if (generation !== opencodeLoadGenerationRef.current) return
if (!result.success) throw new Error(result.error)
setOpencodeImportSessions(result.sessions)
setOpencodeImportMachineId(result.machineId ?? machineId)
setSelectedOpencodeImportSessionId((current) => current && result.sessions.some((session) => session.id === current) ? current : null)
} catch (loadError) {
if (generation !== opencodeLoadGenerationRef.current) return
setOpencodeImportSessions([])
setOpencodeImportMachineId(null)
setSelectedOpencodeImportSessionId(null)
setOpencodeImportError(loadError instanceof Error ? loadError.message : t('opencodeSync.failed.body'))
} finally {
if (generation === opencodeLoadGenerationRef.current) setIsLoadingOpencodeImportSessions(false)
}
}, [agent, machineId, props.api, trimmedDirectory, t])
const formatOpencodeImportError = useCallback((code?: string, message?: string): string => {
if (code === 'transcript_diverged') return t('opencodeSync.error.diverged')
if (code === 'session_active') return t('opencodeSync.error.active')
return message?.trim() || t('opencodeSync.failed.body')
}, [t])
const normalizeCodexScriptError = useCallback((message: string | null | undefined, fallback: string): string => {
const raw = (message ?? '').trim()
if (!raw) return fallback
@@ -1419,6 +1470,66 @@ export function NewSession(props: {
trimmedDirectory
])
const handleBulkImportOpencodeSessions = useCallback(async (sessionIds: string[]) => {
if (isBulkImportingOpencodeSessions || isLoadingOpencodeImportSessions) return
setIsBulkImportingOpencodeSessions(true)
try {
const result = await props.api.importOpencodeSessions({
sessionIds,
cwd: trimmedDirectory || null,
machineId: opencodeImportMachineId ?? machineId
})
const importedCount = result.results.filter((item) => item.hapiSessionId && !item.error && (item.action === 'created' || (item.appended ?? 0) > 0)).length
const failed = result.results.filter((item) => item.error)
if (importedCount > 0) {
addToast({
title: t('opencodeSync.success.title'),
body: t('opencodeSync.success.body', { n: importedCount }),
sessionId: '',
url: ''
})
await refetchSessions()
await loadOpencodeImportSessions()
}
if (failed.length > 0) {
const first = failed[0]!.error!
addToast({
title: t('opencodeSync.failed.title'),
body: t('opencodeSync.failed.partial', {
failed: failed.length,
reason: formatOpencodeImportError(first.code, first.message)
}),
sessionId: '',
url: ''
})
return
}
setIsOpencodeImportDialogOpen(false)
setSelectedOpencodeImportSessionId(null)
} catch (importError) {
addToast({
title: t('opencodeSync.failed.title'),
body: importError instanceof Error ? importError.message : t('opencodeSync.failed.body'),
sessionId: '',
url: ''
})
} finally {
setIsBulkImportingOpencodeSessions(false)
}
}, [
addToast,
formatOpencodeImportError,
isBulkImportingOpencodeSessions,
isLoadingOpencodeImportSessions,
loadOpencodeImportSessions,
machineId,
opencodeImportMachineId,
props.api,
refetchSessions,
t,
trimmedDirectory
])
const selectedCodexImportSession = useMemo(
() => codexImportSessions.find((session) => session.id === selectedCodexImportSessionId) ?? null,
[codexImportSessions, selectedCodexImportSessionId]
@@ -1427,6 +1538,10 @@ export function NewSession(props: {
() => piImportSessions.find((session) => session.id === selectedPiImportSessionId) ?? null,
[piImportSessions, selectedPiImportSessionId]
)
const selectedOpencodeImportSession = useMemo(
() => opencodeImportSessions.find((session) => session.id === selectedOpencodeImportSessionId) ?? null,
[opencodeImportSessions, selectedOpencodeImportSessionId]
)
// Pi history import reopens the native session as-is; the launch-only
// model/effort controls would silently not apply, so hide them.
const showPiLaunchConfig = agent !== 'pi' || !selectedPiImportSession
@@ -1447,6 +1562,9 @@ export function NewSession(props: {
setSelectedPiImportSessionId(null)
setPiImportSessions([])
setPiImportMachineId(null)
setSelectedOpencodeImportSessionId(null)
setOpencodeImportSessions([])
setOpencodeImportMachineId(null)
const paths = getRecentPaths(newMachineId)
if (paths[0]) {
setDirectory(paths[0])
@@ -1546,6 +1664,11 @@ export function NewSession(props: {
if (session.cwd?.trim()) setDirectory(session.cwd.trim())
}, [])
const handleSelectOpencodeImportSession = useCallback((session: OpencodeLocalSessionSummary) => {
setSelectedOpencodeImportSessionId(session.id)
if (session.cwd?.trim()) setDirectory(session.cwd.trim())
}, [])
const handlePathClick = useCallback((path: string) => {
setDirectory(path)
}, [])
@@ -1741,6 +1864,34 @@ export function NewSession(props: {
return
}
if (agent === 'opencode' && selectedOpencodeImportSession) {
setIsImportingOpencodeSession(true)
const result = await props.api.importOpencodeSessions({
sessionIds: [selectedOpencodeImportSession.id],
cwd: selectedOpencodeImportSession.cwd ?? trimmedDirectory,
machineId: opencodeImportMachineId ?? machineId,
model: resolvedModel ?? null,
modelReasoningEffort: resolvedModelReasoningEffort ?? null,
permissionMode: nativePermissionMode
})
const imported = result.results.find((item) => item.opencodeSessionId === selectedOpencodeImportSession.id)
if (imported?.error) {
setIsImportingOpencodeSession(false)
haptic.notification('error')
setError(formatOpencodeImportError(imported.error.code, imported.error.message))
return
}
if (!imported?.hapiSessionId) throw new Error(result.error || t('opencodeSync.failed.body'))
const reopened = await props.api.reopenSession(imported.hapiSessionId)
haptic.notification('success')
savePreferredLaunchSettings(machineId, agent, preferredLaunchSettings)
clearNewSessionFormDraft()
setLastUsedMachineId(machineId)
addRecentPath(machineId, trimmedDirectory)
props.onSuccess(reopened.sessionId)
return
}
const result = await spawnSession({
machineId,
directory: trimmedDirectory,
@@ -1777,6 +1928,7 @@ export function NewSession(props: {
} catch (e) {
setIsImportingCodexSession(false)
setIsImportingPiSession(false)
setIsImportingOpencodeSession(false)
haptic.notification('error')
setError(e instanceof Error ? e.message : 'Failed to create session')
} finally {
@@ -1935,6 +2087,19 @@ export function NewSession(props: {
onClear={() => setSelectedPiImportSessionId(null)}
/>
) : null}
{agent === 'opencode' ? (
<OpencodeImportActions
selectedSession={selectedOpencodeImportSession}
isLoading={isLoadingOpencodeImportSessions}
isDisabled={isFormDisabled}
error={opencodeImportError}
onChooseHistory={() => {
setIsOpencodeImportDialogOpen(true)
void loadOpencodeImportSessions()
}}
onClear={() => setSelectedOpencodeImportSessionId(null)}
/>
) : null}
{agent === 'dsh' ? (
<ModelSelector
agent={agent}
@@ -2123,7 +2288,7 @@ export function NewSession(props: {
) : null}
<ActionButtons
isPending={isCreating || isPending || isImportingCodexSession || isImportingPiSession}
isPending={isCreating || isPending || isImportingCodexSession || isImportingPiSession || isImportingOpencodeSession}
canCreate={canCreate}
isDisabled={isFormDisabled}
createLabel={createLabel}
@@ -2174,6 +2339,26 @@ export function NewSession(props: {
isPending={isBulkImportingPiSessions}
isLoading={isLoadingPiImportSessions}
/>
<OpencodeSessionImportDialog
isOpen={isOpencodeImportDialogOpen}
onClose={() => setIsOpencodeImportDialogOpen(false)}
sessions={opencodeImportSessions}
currentSessionId={selectedOpencodeImportSessionId}
currentWorkDirectory={trimmedDirectory}
onConfirm={async (sessionIds) => {
if (sessionIds.length === 1) {
const session = opencodeImportSessions.find((candidate) => candidate.id === sessionIds[0])
if (session) {
handleSelectOpencodeImportSession(session)
setIsOpencodeImportDialogOpen(false)
}
return
}
await handleBulkImportOpencodeSessions(sessionIds)
}}
isPending={isBulkImportingOpencodeSessions}
isLoading={isLoadingOpencodeImportSessions}
/>
<ConfirmDialog
isOpen={isDuplicateMergeConfirmOpen && duplicateSessionGroups.length > 0}
onClose={closeDuplicateMergeDialog}
@@ -0,0 +1,126 @@
import { useEffect, useMemo, useState } from 'react'
import type { OpencodeLocalSessionSummary } from '@/types/api'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { SelectControl } from '@/components/ui/select-control'
import { useTranslation } from '@/lib/use-translation'
const ALL_DIRECTORIES = '__all__'
export function OpencodeSessionImportDialog(props: {
isOpen: boolean
onClose: () => void
sessions: OpencodeLocalSessionSummary[]
currentSessionId: string | null
currentWorkDirectory?: string | null
onConfirm: (sessionIds: string[]) => Promise<void>
isPending: boolean
isLoading: boolean
}) {
const { t } = useTranslation()
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [directory, setDirectory] = useState(ALL_DIRECTORIES)
const [query, setQuery] = useState('')
const directories = useMemo(() => Array.from(new Set(props.sessions
.map((session) => session.cwd?.trim())
.filter((value): value is string => Boolean(value)))).sort(), [props.sessions])
const filtered = useMemo(() => {
const normalized = query.trim().toLowerCase()
return props.sessions.filter((session) => {
if (directory !== ALL_DIRECTORIES && session.cwd !== directory) return false
if (!normalized) return true
return [session.title, session.lastUserMessage, session.cwd, session.id]
.filter((value): value is string => typeof value === 'string')
.some((value) => value.toLowerCase().includes(normalized))
})
}, [directory, props.sessions, query])
useEffect(() => {
if (!props.isOpen) return
setSelectedIds(props.currentSessionId ? [props.currentSessionId] : [])
const cwd = props.currentWorkDirectory?.trim()
setDirectory(cwd && directories.includes(cwd) ? cwd : ALL_DIRECTORIES)
setQuery('')
}, [directories, props.currentSessionId, props.currentWorkDirectory, props.isOpen])
const toggle = (id: string) => {
if (props.isPending || props.isLoading) return
setSelectedIds((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id])
}
return (
<Dialog open={props.isOpen} onOpenChange={(open) => !open && props.onClose()}>
<DialogContent className="max-w-xl">
<DialogHeader className="text-left">
<DialogTitle>{t('opencodeSync.dialog.title')}</DialogTitle>
<DialogDescription>{t('opencodeSync.dialog.description')}</DialogDescription>
</DialogHeader>
<div className="mt-3 space-y-3">
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
{t('opencodeSync.dialog.concurrentWarning')}
</div>
<div className="flex items-center justify-between text-xs text-[var(--app-hint)]">
<span>{t('opencodeSync.selectedCount', { n: selectedIds.length })}</span>
<div className="flex gap-2">
<Button type="button" variant="secondary" size="sm" disabled={selectedIds.length === 0 || props.isPending} onClick={() => setSelectedIds([])}>
{t('opencodeSync.clearAll')}
</Button>
<Button type="button" variant="secondary" size="sm" disabled={filtered.length === 0 || props.isPending} onClick={() => setSelectedIds(filtered.map((session) => session.id))}>
{t('opencodeSync.selectAll')}
</Button>
</div>
</div>
{props.sessions.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2">
<SelectControl value={directory} disabled={props.isPending} onChange={(event) => setDirectory(event.target.value)}>
<option value={ALL_DIRECTORIES}>{t('opencodeSync.allDirectories')}</option>
{directories.map((value) => <option key={value} value={value}>{value}</option>)}
</SelectControl>
<input
type="search"
className="h-9 rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-2 text-sm text-[var(--app-fg)]"
value={query}
disabled={props.isPending}
placeholder={t('opencodeSync.search')}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
) : null}
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-[var(--app-border)]">
{props.isLoading ? (
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">{t('opencodeSync.loading')}</div>
) : filtered.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">{t('opencodeSync.empty')}</div>
) : (
<div className="divide-y divide-[var(--app-border)]">
{filtered.map((session) => {
const checked = selectedIds.includes(session.id)
return (
<label key={session.id} className="flex cursor-pointer items-start gap-3 px-3 py-2 hover:bg-[var(--app-subtle-bg)]">
<input type="checkbox" className="mt-1 h-4 w-4 accent-[var(--app-link)]" checked={checked} onChange={() => toggle(session.id)} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-[var(--app-fg)]">
<span className="truncate">{session.title}</span>
{session.hapiSessionId ? <span className="rounded bg-[var(--app-subtle-bg)] px-1.5 py-0.5 text-[10px] text-[var(--app-hint)]">{t('opencodeSync.imported')}</span> : null}
</span>
<span className="mt-0.5 block truncate text-xs text-[var(--app-hint)]">{session.lastUserMessage ?? session.cwd ?? session.id}</span>
<span className="mt-0.5 block text-[10px] text-[var(--app-hint)]">{new Date(session.modifiedAt).toLocaleString()}</span>
</span>
</label>
)
})}
</div>
)}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={props.onClose} disabled={props.isPending}>{t('button.cancel')}</Button>
<Button type="button" onClick={() => void props.onConfirm(selectedIds)} disabled={selectedIds.length === 0 || props.isPending || props.isLoading}>
{props.isPending ? t('opencodeSync.importing') : t('opencodeSync.confirm')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
+24
View File
@@ -134,6 +134,30 @@ export default {
'piImport.manual.success.body': 'Synced {n} new Pi history items.',
'piImport.manual.success.noNewMessages': 'Pi history is already up to date.',
'piImport.manual.failed.title': 'Pi history sync failed',
'opencodeSync.inline.title': 'Import OpenCode history',
'opencodeSync.inline.description': 'Optional: choose an existing local OpenCode session to import and continue.',
'opencodeSync.inline.clear': 'Clear',
'opencodeSync.inline.choose': 'Choose history',
'opencodeSync.dialog.title': 'Import OpenCode sessions',
'opencodeSync.dialog.description': 'Select local OpenCode histories. One selection is continued now; multiple selections are imported in the background.',
'opencodeSync.dialog.concurrentWarning': 'Multiple processes can open the same OpenCode session, but avoid writing from stale copies. Before continuing in HAPI, reload or close any other OpenCode process to avoid an unintended branch.',
'opencodeSync.loading': 'Scanning OpenCode sessions…',
'opencodeSync.empty': 'No importable OpenCode sessions found.',
'opencodeSync.selectedCount': '{n} selected',
'opencodeSync.clearAll': 'Clear all',
'opencodeSync.selectAll': 'Select all',
'opencodeSync.allDirectories': 'All directories',
'opencodeSync.search': 'Search title, prompt, path, or ID',
'opencodeSync.imported': 'Imported',
'opencodeSync.importing': 'Importing…',
'opencodeSync.confirm': 'Import',
'opencodeSync.success.title': 'OpenCode sessions imported',
'opencodeSync.success.body': 'Imported {n} OpenCode sessions.',
'opencodeSync.failed.title': 'OpenCode import failed',
'opencodeSync.failed.body': 'Failed to import OpenCode history.',
'opencodeSync.failed.partial': '{failed} sessions failed: {reason}',
'opencodeSync.error.diverged': 'The local OpenCode branch changed after the previous import. Review the session before continuing.',
'opencodeSync.error.active': 'Stop the active Hapi OpenCode session before importing newer native history.',
// 中文注释:以下文案支撑导入弹窗的多选列表、加载态,以及右上角重启 Codex 客户端按钮提示。
'codexSync.confirm.selectAll': 'Select all',
'codexSync.confirm.clearAll': 'Clear all',
+24
View File
@@ -134,6 +134,30 @@ export default {
'piImport.manual.success.body': '已同步 {n} 条新的 Pi 历史记录。',
'piImport.manual.success.noNewMessages': 'Pi 历史已经是最新状态。',
'piImport.manual.failed.title': 'Pi 历史同步失败',
'opencodeSync.inline.title': '导入 OpenCode 历史',
'opencodeSync.inline.description': '可选:选择本机已有的 OpenCode session,导入历史后继续对话。',
'opencodeSync.inline.clear': '清除',
'opencodeSync.inline.choose': '选择历史',
'opencodeSync.dialog.title': '导入 OpenCode sessions',
'opencodeSync.dialog.description': '选择本机 OpenCode 历史。单选会立即继续,多选会批量导入到会话列表。',
'opencodeSync.dialog.concurrentWarning': '多个进程可以同时打开同一个 OpenCode session,但不要从未刷新的副本交替写入。继续在 HAPI 中操作前,请重新加载或关闭其他 OpenCode 进程,避免产生意外分支。',
'opencodeSync.loading': '正在扫描 OpenCode sessions…',
'opencodeSync.empty': '没有找到可导入的 OpenCode session。',
'opencodeSync.selectedCount': '已选择 {n} 个',
'opencodeSync.clearAll': '全部清除',
'opencodeSync.selectAll': '全部选择',
'opencodeSync.allDirectories': '全部目录',
'opencodeSync.search': '搜索标题、消息、路径或 ID',
'opencodeSync.imported': '已导入',
'opencodeSync.importing': '正在导入…',
'opencodeSync.confirm': '导入',
'opencodeSync.success.title': 'OpenCode sessions 已导入',
'opencodeSync.success.body': '已导入 {n} 个 OpenCode sessions。',
'opencodeSync.failed.title': 'OpenCode 导入失败',
'opencodeSync.failed.body': '导入 OpenCode 历史失败。',
'opencodeSync.failed.partial': '{failed} 个 session 导入失败:{reason}',
'opencodeSync.error.diverged': '本机 OpenCode 分支在上次导入后发生变化,请先检查会话再继续。',
'opencodeSync.error.active': '请先停止正在运行的 Hapi OpenCode session,再导入新增的原生历史。',
// 中文注释:以下文案支撑导入弹窗的多选列表、加载态,以及右上角重启 Codex 客户端按钮提示。
'codexSync.confirm.selectAll': '全选',
'codexSync.confirm.clearAll': '全取消',
+36
View File
@@ -290,6 +290,42 @@ export type PiImportSessionsResponse = {
error?: string
}
export type OpencodeLocalSessionSummary = {
id: string
title: string
lastUserMessage?: string | null
cwd?: string | null
file: string
modifiedAt: number
hapiSessionId?: string
}
export type OpencodeLocalSessionsResponse = {
success: true
sessions: OpencodeLocalSessionSummary[]
machineId: string
} | {
success: false
error: string
sessions: []
machineId?: string
}
export type OpencodeImportResult = {
opencodeSessionId: string
hapiSessionId?: string
action?: 'created' | 'updated' | 'unchanged'
appended?: number
error?: { code: string; message: string }
}
export type OpencodeImportSessionsResponse = {
success: boolean
results: OpencodeImportResult[]
machineId?: string
error?: string
}
export type CodexArchiveSessionResponse = {
success: true