summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2026-03-12 18:26:50 +0000
committerGitHub <[email protected]>2026-03-12 18:26:50 +0000
commit184732fc2097166921dd46fbb9a8ce433a96b237 (patch)
tree6dd45cc5483f968fe4ad3c19456eb62c54b97b94 /packages/app/src/pages
parentb66222baf7a09af692e8de06179c1c3e51715269 (diff)
downloadopencode-184732fc2097166921dd46fbb9a8ce433a96b237.tar.gz
opencode-184732fc2097166921dd46fbb9a8ce433a96b237.zip
fix(app): titlebar cleanup (#17206)
Diffstat (limited to 'packages/app/src/pages')
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx202
1 files changed, 196 insertions, 6 deletions
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 50f9b452a..ad4ef8443 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -11,15 +11,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 { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
@@ -215,12 +219,14 @@ 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 rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const sessionID = createMemo(() => params.id)
@@ -298,6 +304,8 @@ export function MessageTimeline(props: {
return sync.session.get(id)
})
const titleValue = createMemo(() => info()?.title)
+ const shareUrl = createMemo(() => info()?.share?.url)
+ const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const stageCfg = { init: 1, batch: 3 }
@@ -314,9 +322,55 @@ export function MessageTimeline(props: {
saving: false,
menuOpen: false,
pendingRename: false,
+ pendingShare: false,
})
let titleRef: HTMLInputElement | undefined
+ const [share, setShare] = createStore({
+ open: false,
+ dismiss: null as "escape" | "outside" | null,
+ })
+
+ let more: HTMLButtonElement | undefined
+
+ const [req, setReq] = createStore({ share: false, unshare: false })
+
+ const shareSession = () => {
+ const id = sessionID()
+ if (!id || req.share) return
+ if (!shareEnabled()) return
+ setReq("share", true)
+ globalSDK.client.session
+ .share({ sessionID: id, directory: sdk.directory })
+ .catch((err: unknown) => {
+ console.error("Failed to share session", err)
+ })
+ .finally(() => {
+ setReq("share", false)
+ })
+ }
+
+ const unshareSession = () => {
+ const id = sessionID()
+ if (!id || req.unshare) return
+ if (!shareEnabled()) return
+ setReq("unshare", true)
+ globalSDK.client.session
+ .unshare({ sessionID: id, directory: sdk.directory })
+ .catch((err: unknown) => {
+ console.error("Failed to unshare session", err)
+ })
+ .finally(() => {
+ setReq("unshare", false)
+ })
+ }
+
+ 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
@@ -329,7 +383,15 @@ export function MessageTimeline(props: {
createEffect(
on(
sessionKey,
- () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
+ () =>
+ setTitle({
+ draft: "",
+ editing: false,
+ saving: false,
+ menuOpen: false,
+ pendingRename: false,
+ pendingShare: false,
+ }),
{ defer: true },
),
)
@@ -678,23 +740,42 @@ export function MessageTimeline(props: {
gutter={4}
placement="bottom-end"
open={title.menuOpen}
- onOpenChange={(open) => setTitle("menuOpen", open)}
+ onOpenChange={(open) => {
+ setTitle("menuOpen", open)
+ if (open) return
+ }}
>
<DropdownMenu.Trigger
as={IconButton}
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}
+ ref={(el: HTMLButtonElement) => {
+ more = el
+ }}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
- if (!title.pendingRename) return
- event.preventDefault()
- setTitle("pendingRename", false)
- openTitleEditor()
+ if (title.pendingRename) {
+ event.preventDefault()
+ setTitle("pendingRename", false)
+ openTitleEditor()
+ return
+ }
+ if (title.pendingShare) {
+ event.preventDefault()
+ requestAnimationFrame(() => {
+ setShare({ open: true, dismiss: null })
+ setTitle("pendingShare", false)
+ })
+ }
}}
>
<DropdownMenu.Item
@@ -705,6 +786,17 @@ export function MessageTimeline(props: {
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
+ <Show when={shareEnabled()}>
+ <DropdownMenu.Item
+ onSelect={() => {
+ setTitle({ pendingShare: true, menuOpen: false })
+ }}
+ >
+ <DropdownMenu.ItemLabel>
+ {language.t("session.share.action.share")}
+ </DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
@@ -717,6 +809,104 @@ 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={req.share}
+ >
+ {req.share
+ ? 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={req.unshare}
+ >
+ {req.unshare
+ ? language.t("session.share.action.unpublishing")
+ : language.t("session.share.action.unpublish")}
+ </Button>
+ <Button
+ size="large"
+ variant="primary"
+ class="w-full"
+ onClick={viewShare}
+ disabled={req.unshare}
+ >
+ {language.t("session.share.action.view")}
+ </Button>
+ </div>
+ </div>
+ </Show>
+ </div>
+ </div>
+ </KobaltePopover.Content>
+ </KobaltePopover.Portal>
+ </KobaltePopover>
</div>
)}
</Show>