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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/app/src/context/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ export type Platform = {
/** Save file picker dialog (desktop only) */
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>

/**
* Export a session to a local JSON file (desktop only).
* Main process fetches the internal export route, opens save dialog, writes file.
*/
exportSession?(
sessionID: string,
directory: string,
defaultName?: string,
): Promise<{ ok: true; path: string } | { ok: false; error: string }>

/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage

Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,10 @@ export const dict = {
"session.share.copy.copied": "Copied",
"session.share.copy.copyLink": "Copy link",

"session.export.action.export": "Export session log",
"session.export.success": "Session exported",
"session.export.error.failed": "Export failed",

"lsp.tooltip.none": "No LSP servers",
"lsp.label.connected": "{{count}} LSP",

Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,10 @@ export const dict = {
"session.share.copy.copied": "已复制",
"session.share.copy.copyLink": "复制链接",

"session.export.action.export": "导出会话日志",
"session.export.success": "会话已导出",
"session.export.error.failed": "导出失败",

"lsp.tooltip.none": "没有 LSP 服务器",
"lsp.label.connected": "{{count}} LSP",

Expand Down
203 changes: 47 additions & 156 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,19 @@ import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Spinner } from "@opencode-ai/ui/spinner"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { TextField } from "@opencode-ai/ui/text-field"
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { isSessionRunning } from "@/pages/session/session-running-state"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useGlobalSDK } from "@/context/global-sdk"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
Expand Down Expand Up @@ -234,14 +232,18 @@ export function MessageTimeline(props: {
let touchGesture: number | undefined

const navigate = useNavigate()
const globalSDK = useGlobalSDK()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const dialog = useDialog()
const language = useLanguage()
const { params, sessionKey } = useSessionKey()
const platform = usePlatform()
const server = useServer()
// Export hits the embedded sidecar via main-process IPC. When the user has switched the
// active server to a remote HTTP/SSH target, the sidecar holds different data than the UI;
// hide the action rather than ship a misleading export.
const exportAvailable = createMemo(() => !!platform.exportSession && server.current?.type === "sidecar")

const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const sessionID = createMemo(() => params.id)
Expand Down Expand Up @@ -307,8 +309,6 @@ export function MessageTimeline(props: {
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
Expand Down Expand Up @@ -350,14 +350,9 @@ export function MessageTimeline(props: {
editing: false,
menuOpen: false,
pendingRename: false,
pendingShare: false,
})
let titleRef: HTMLInputElement | undefined

const [share, setShare] = createStore({
open: false,
dismiss: null as "escape" | "outside" | null,
})
const [bar, setBar] = createStore({
ms: pace(640),
})
Expand All @@ -373,12 +368,6 @@ export function MessageTimeline(props: {
},
)

const viewShare = () => {
const url = shareUrl()
if (!url) return
platform.openLink(url)
}

const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
Expand All @@ -388,20 +377,6 @@ export function MessageTimeline(props: {
return language.t("common.requestFailed")
}

const shareMutation = useMutation(() => ({
mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
onError: (err) => {
console.error("Failed to share session", err)
},
}))

const unshareMutation = useMutation(() => ({
mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
onError: (err) => {
console.error("Failed to unshare session", err)
},
}))

const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) =>
sdk.client.session.update({ sessionID: input.id, title: input.title }),
Expand All @@ -422,18 +397,43 @@ export function MessageTimeline(props: {
},
}))

const shareSession = () => {
const onExport = async () => {
const id = sessionID()
if (!id || shareMutation.isPending) return
if (!shareEnabled()) return
shareMutation.mutate(id)
}

const unshareSession = () => {
const id = sessionID()
if (!id || unshareMutation.isPending) return
if (!shareEnabled()) return
unshareMutation.mutate(id)
if (!id || !platform.exportSession) return

// Build a slug-based default filename. Falls back to id suffix if slug is missing.
const slugSource = info()?.slug ?? id
// Allow Unicode letters/numbers (CJK titles work) but strip filesystem-hostile chars.
// If sanitization produces an empty/dash-only string, fall back to the id suffix.
const sanitized = slugSource.replace(/[\\/:*?"<>|]/g, "-").slice(0, 32)
const slug = /[\p{L}\p{N}]/u.test(sanitized) ? sanitized : id.slice(-8)
const stamp = new Date().toISOString().replace(/[:T]/g, "-").replace(/\..+$/, "")
const defaultName = `pawwork-session-${slug}-${stamp}.json`

let result: { ok: true; path: string } | { ok: false; error: string }
try {
result = await platform.exportSession(id, sdk.directory, defaultName)
} catch (err) {
showToast({
title: language.t("session.export.error.failed"),
description: errorMessage(err),
variant: "error",
})
return
}
if (!result.ok) {
if (result.error === "cancelled") return
showToast({
title: language.t("session.export.error.failed"),
description: result.error,
variant: "error",
})
return
}
showToast({
title: language.t("session.export.success"),
description: result.path,
})
}

createEffect(
Expand All @@ -445,7 +445,6 @@ export function MessageTimeline(props: {
editing: false,
menuOpen: false,
pendingRename: false,
pendingShare: false,
}),
{ defer: true },
),
Expand Down Expand Up @@ -835,11 +834,8 @@ export function MessageTimeline(props: {
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"bg-surface-base-active": share.open || title.pendingShare,
}}
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
aria-expanded={title.menuOpen}
ref={(el: HTMLButtonElement) => {
more = el
}}
Expand All @@ -852,14 +848,6 @@ export function MessageTimeline(props: {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}}
>
Expand All @@ -871,14 +859,15 @@ export function MessageTimeline(props: {
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={shareEnabled()}>
<Show when={exportAvailable()}>
<DropdownMenu.Item
onSelect={() => {
setTitle({ pendingShare: true, menuOpen: false })
setTitle("menuOpen", false)
void onExport()
}}
>
<DropdownMenu.ItemLabel>
{language.t("session.share.action.share")}
{language.t("session.export.action.export")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
Expand All @@ -894,104 +883,6 @@ export function MessageTimeline(props: {
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>

<KobaltePopover
open={share.open}
anchorRef={() => more}
placement="bottom-end"
gutter={4}
modal={false}
onOpenChange={(open) => {
if (open) setShare("dismiss", null)
setShare("open", open)
}}
>
<KobaltePopover.Portal>
<KobaltePopover.Content
data-component="popover-content"
style={{ "min-width": "320px" }}
onEscapeKeyDown={(event) => {
setShare({ dismiss: "escape", open: false })
event.preventDefault()
event.stopPropagation()
}}
onPointerDownOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onFocusOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onCloseAutoFocus={(event) => {
if (share.dismiss === "outside") event.preventDefault()
setShare("dismiss", null)
}}
>
<div class="flex flex-col p-3">
<div class="flex flex-col gap-1">
<div class="text-13-medium text-text-strong">
{language.t("session.share.popover.title")}
</div>
<div class="text-12-regular text-text-weak">
{shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")}
</div>
</div>
<div class="mt-3 flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<Button
size="large"
variant="primary"
class="w-full"
onClick={shareSession}
disabled={shareMutation.isPending}
>
{shareMutation.isPending
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
}
>
<div class="flex flex-col gap-2">
<TextField
value={shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
tabIndex={-1}
class="w-full"
/>
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={unshareMutation.isPending}
>
{unshareMutation.isPending
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={unshareMutation.isPending}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div>
</Show>
</div>
</div>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
</Show>
</div>
)}
Expand Down
1 change: 1 addition & 0 deletions packages/desktop-electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ registerIpcHandlers({
initEmitter.off("step", listener)
}
},
getServerReadyData: () => serverReady.promise,
getDefaultServerUrl: () => getDefaultServerUrl(),
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
getWslConfig: () => Promise.resolve(getWslConfig()),
Expand Down
Loading
Loading