From 21f562fdae2a59ea395929ab9228756da10f217b Mon Sep 17 00:00:00 2001 From: David Hill Date: Tue, 10 Mar 2026 13:59:48 +0000 Subject: [PATCH 01/23] tui: make context group header non-clickable except for arrow icon Users now see that only the arrow icon expands/collapses context groups, not the entire row, making the UI behavior more predictable. --- packages/ui/src/components/message-part.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 7ee5378848..1052a591c0 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -614,7 +614,7 @@ align-items: center; justify-content: flex-start; gap: 0px; - cursor: pointer; + cursor: default; [data-slot="context-tool-group-title"] { flex-shrink: 1; @@ -623,6 +623,7 @@ [data-slot="collapsible-arrow"] { color: var(--icon-weaker); + cursor: pointer; } } From 5ca2dc87b7d07bcadde88bf60762406ec10b39f6 Mon Sep 17 00:00:00 2001 From: David Hill Date: Wed, 11 Mar 2026 15:39:59 +0000 Subject: [PATCH 02/23] fix(app): move session share into options menu Remove the title bar share control and surface sharing from the session options dropdown, opening the share popover in-place after selection to avoid flicker and keep consistent dismissal behavior. --- .../src/components/session/session-header.tsx | 211 +----------------- .../src/pages/session/message-timeline.tsx | 196 +++++++++++++++- 2 files changed, 191 insertions(+), 216 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 97f0530e98..c20161b987 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -4,23 +4,19 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Keybind } from "@opencode-ai/ui/keybind" -import { Popover } from "@opencode-ai/ui/popover" import { Spinner } from "@opencode-ai/ui/spinner" -import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/util/path" import { useParams } from "@solidjs/router" import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useCommand } from "@/context/command" -import { useGlobalSDK } from "@/context/global-sdk" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useServer } from "@/context/server" -import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { focusTerminalById } from "@/pages/session/helpers" import { decode64 } from "@/utils/base64" @@ -136,99 +132,11 @@ const showRequestError = (language: ReturnType, err: unknown }) } -function useSessionShare(args: { - globalSDK: ReturnType - currentSession: () => - | { - share?: { - url?: string - } - } - | undefined - sessionID: () => string | undefined - projectDirectory: () => string - platform: ReturnType -}) { - const [state, setState] = createStore({ - share: false, - unshare: false, - copied: false, - timer: undefined as number | undefined, - }) - const shareUrl = createMemo(() => args.currentSession()?.share?.url) - - createEffect(() => { - const url = shareUrl() - if (url) return - if (state.timer) window.clearTimeout(state.timer) - setState({ copied: false, timer: undefined }) - }) - - onCleanup(() => { - if (state.timer) window.clearTimeout(state.timer) - }) - - const shareSession = () => { - const sessionID = args.sessionID() - if (!sessionID || state.share) return - setState("share", true) - args.globalSDK.client.session - .share({ sessionID, directory: args.projectDirectory() }) - .catch((error) => { - console.error("Failed to share session", error) - }) - .finally(() => { - setState("share", false) - }) - } - - const unshareSession = () => { - const sessionID = args.sessionID() - if (!sessionID || state.unshare) return - setState("unshare", true) - args.globalSDK.client.session - .unshare({ sessionID, directory: args.projectDirectory() }) - .catch((error) => { - console.error("Failed to unshare session", error) - }) - .finally(() => { - setState("unshare", false) - }) - } - - const copyLink = (onError: (error: unknown) => void) => { - const url = shareUrl() - if (!url) return - navigator.clipboard - .writeText(url) - .then(() => { - if (state.timer) window.clearTimeout(state.timer) - setState("copied", true) - const timer = window.setTimeout(() => { - setState("copied", false) - setState("timer", undefined) - }, 3000) - setState("timer", timer) - }) - .catch(onError) - } - - const viewShare = () => { - const url = shareUrl() - if (!url) return - args.platform.openLink(url) - } - - return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare } -} - export function SessionHeader() { - const globalSDK = useGlobalSDK() const layout = useLayout() const params = useParams() const command = useCommand() const server = useServer() - const sync = useSync() const platform = usePlatform() const language = useLanguage() const terminal = useTerminal() @@ -246,9 +154,6 @@ export function SessionHeader() { }) const hotkey = createMemo(() => command.keybind("file.open")) - const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") - const showShare = createMemo(() => shareEnabled() && !!params.id) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const view = createMemo(() => layout.view(sessionKey)) const os = createMemo(() => detectOS(platform)) @@ -361,14 +266,6 @@ export function SessionHeader() { .catch((err: unknown) => showRequestError(language, err)) } - const share = useSessionShare({ - globalSDK, - currentSession, - sessionID: () => params.id, - projectDirectory, - platform, - }) - const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) @@ -523,112 +420,6 @@ export function SessionHeader() { - -
- {language.t("session.share.action.share")}} - > -
- - -
- } - > -
- -
- - -
-
- -
- - - -
props.renderedUserMessages.map((message) => message.id)) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) @@ -292,6 +298,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 } @@ -308,9 +316,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((error) => { + console.error("Failed to share session", error) + }) + .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((error) => { + console.error("Failed to unshare session", error) + }) + .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 @@ -323,7 +377,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 }, ), ) @@ -672,7 +734,10 @@ 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 + }} > { + more = el + }} /> { - 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() + setTitle("pendingShare", false) + requestAnimationFrame(() => setShare({ open: true, dismiss: null })) + } }} > {language.t("common.rename")} + + { + setTitle({ pendingShare: true, menuOpen: false }) + }} + > + + {language.t("session.share.action.share")} + + + void archiveSession(id())}> {language.t("common.archive")} @@ -711,6 +797,104 @@ export function MessageTimeline(props: { + + more} + placement="bottom-end" + gutter={4} + modal={false} + onOpenChange={(open) => { + if (open) setShare("dismiss", null) + setShare("open", open) + }} + > + + { + 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) + }} + > +
+
+
+ {language.t("session.share.popover.title")} +
+
+ {shareUrl() + ? language.t("session.share.popover.description.shared") + : language.t("session.share.popover.description.unshared")} +
+
+
+ + {req.share + ? language.t("session.share.action.publishing") + : language.t("session.share.action.publish")} + + } + > +
+ +
+ + +
+
+
+
+
+
+
+
)} From d2747d70d6d84a332eae49f694cef8dd83ce449c Mon Sep 17 00:00:00 2001 From: David Hill Date: Wed, 11 Mar 2026 15:45:06 +0000 Subject: [PATCH 03/23] fix(app): keep session options button active while sharing Avoid a one-frame active-state flicker when transitioning from the options dropdown to the share popover by treating the pending-share state as active until the popover opens. --- packages/app/src/pages/session/message-timeline.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index ad3b30ad67..aa540e6a93 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -744,7 +744,11 @@ 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} ref={(el: HTMLButtonElement) => { more = el }} @@ -761,8 +765,10 @@ export function MessageTimeline(props: { } if (title.pendingShare) { event.preventDefault() - setTitle("pendingShare", false) - requestAnimationFrame(() => setShare({ open: true, dismiss: null })) + requestAnimationFrame(() => { + setShare({ open: true, dismiss: null }) + setTitle("pendingShare", false) + }) } }} > From fe4d4a7a3aaf5b4b7f5100869241e9816a1f08a1 Mon Sep 17 00:00:00 2001 From: David Hill Date: Wed, 11 Mar 2026 16:06:00 +0000 Subject: [PATCH 04/23] feat(app): add shared link button in session header When a session is published, show a link icon next to context that copies the share URL with a transient check + tooltip, and animate the header controls to make room for it. --- .../src/pages/session/message-timeline.tsx | 84 ++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index aa540e6a93..41d99da96d 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -17,6 +17,7 @@ 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 { Tooltip } from "@opencode-ai/ui/tooltip" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -300,6 +301,7 @@ export function MessageTimeline(props: { const titleValue = createMemo(() => info()?.title) const shareUrl = createMemo(() => info()?.share?.url) const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") + const shared = createMemo(() => shareEnabled() && !!shareUrl()) const parentID = createMemo(() => info()?.parentID) const showHeader = createMemo(() => !!(titleValue() || parentID())) const stageCfg = { init: 1, batch: 3 } @@ -325,6 +327,11 @@ export function MessageTimeline(props: { dismiss: null as "escape" | "outside" | null, }) + const [clip, setClip] = createStore({ + ok: false, + timer: undefined as number | undefined, + }) + let more: HTMLButtonElement | undefined const [req, setReq] = createStore({ share: false, unshare: false }) @@ -365,6 +372,42 @@ export function MessageTimeline(props: { platform.openLink(url) } + createEffect(() => { + const url = shareUrl() + if (url) return + if (clip.timer) window.clearTimeout(clip.timer) + setClip({ ok: false, timer: undefined }) + }) + + onCleanup(() => { + if (!clip.timer) return + window.clearTimeout(clip.timer) + }) + + const copy = () => { + const url = shareUrl() + if (!url) return + navigator.clipboard + .writeText(url) + .then(() => { + if (clip.timer) window.clearTimeout(clip.timer) + setClip("ok", true) + setClip( + "timer", + window.setTimeout(() => { + setClip({ ok: false, timer: undefined }) + }, 3000), + ) + }) + .catch((err: unknown) => + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }), + ) + } + const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { const data = (err as { data?: { message?: string } }).data @@ -728,8 +771,45 @@ export function MessageTimeline(props: { {(id) => ( -
- +
+
+ +
+
+
+ + + +
+
Date: Wed, 11 Mar 2026 16:46:25 +0000 Subject: [PATCH 05/23] revert(app): remove shared link button in session header Drop the inline share-url copy icon next to session context, returning the session header controls to context + options only. --- .../src/pages/session/message-timeline.tsx | 84 +------------------ 1 file changed, 2 insertions(+), 82 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 41d99da96d..aa540e6a93 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -17,7 +17,6 @@ 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 { Tooltip } from "@opencode-ai/ui/tooltip" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -301,7 +300,6 @@ export function MessageTimeline(props: { const titleValue = createMemo(() => info()?.title) const shareUrl = createMemo(() => info()?.share?.url) const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") - const shared = createMemo(() => shareEnabled() && !!shareUrl()) const parentID = createMemo(() => info()?.parentID) const showHeader = createMemo(() => !!(titleValue() || parentID())) const stageCfg = { init: 1, batch: 3 } @@ -327,11 +325,6 @@ export function MessageTimeline(props: { dismiss: null as "escape" | "outside" | null, }) - const [clip, setClip] = createStore({ - ok: false, - timer: undefined as number | undefined, - }) - let more: HTMLButtonElement | undefined const [req, setReq] = createStore({ share: false, unshare: false }) @@ -372,42 +365,6 @@ export function MessageTimeline(props: { platform.openLink(url) } - createEffect(() => { - const url = shareUrl() - if (url) return - if (clip.timer) window.clearTimeout(clip.timer) - setClip({ ok: false, timer: undefined }) - }) - - onCleanup(() => { - if (!clip.timer) return - window.clearTimeout(clip.timer) - }) - - const copy = () => { - const url = shareUrl() - if (!url) return - navigator.clipboard - .writeText(url) - .then(() => { - if (clip.timer) window.clearTimeout(clip.timer) - setClip("ok", true) - setClip( - "timer", - window.setTimeout(() => { - setClip({ ok: false, timer: undefined }) - }, 3000), - ) - }) - .catch((err: unknown) => - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }), - ) - } - const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { const data = (err as { data?: { message?: string } }).data @@ -771,45 +728,8 @@ export function MessageTimeline(props: {
{(id) => ( -
-
- -
-
-
- - - -
-
+
+ Date: Thu, 12 Mar 2026 11:07:31 +0000 Subject: [PATCH 06/23] feat(app): refresh titlebar icons Swap titlebar controls to the new terminal/review/file tree/sidebar/new session icons with explicit active variants, and style navigation-active titlebar buttons via aria-current. --- .../src/components/session/session-header.tsx | 36 ++----------------- packages/app/src/components/titlebar.tsx | 23 +++++------- packages/ui/src/components/button.css | 12 +++++++ packages/ui/src/components/icon.tsx | 18 ++++++++-- 4 files changed, 38 insertions(+), 51 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index c20161b987..d752901332 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -433,23 +433,7 @@ export function SessionHeader() { aria-expanded={view().terminal.opened()} aria-controls="terminal-panel" > -
- -
+ @@ -466,23 +450,7 @@ export function SessionHeader() { aria-expanded={view().reviewPanel.opened()} aria-controls="review-panel" > -
- -
+ diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index b45f811501..01fa935eaa 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -58,6 +58,12 @@ export function Titlebar() { }) const path = () => `${location.pathname}${location.search}${location.hash}` + const creating = createMemo(() => { + if (!params.dir) return false + if (params.id) return false + const parts = location.pathname.replace(/\/+$/, "").split("/") + return parts.at(-1) === "session" + }) createEffect(() => { const current = path() @@ -206,19 +212,7 @@ export function Titlebar() { aria-label={language.t("command.sidebar.toggle")} aria-expanded={layout.sidebar.opened()} > -
- -
+