diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 091f00702f3..7acb766f808 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -22,7 +22,7 @@ export function DialogEditProject(props: { project: LocalProject }) { const [store, setStore] = createStore({ name: defaultName(), color: props.project.icon?.color || "pink", - iconUrl: props.project.icon?.url || "", + iconUrl: props.project.icon?.override || "", saving: false, }) @@ -74,7 +74,7 @@ export function DialogEditProject(props: { project: LocalProject }) { await globalSDK.client.project.update({ projectID: props.project.id, name, - icon: { color: store.color, url: store.iconUrl }, + icon: { color: store.color, override: store.iconUrl }, }) setStore("saving", false) dialog.close() diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index a8da156092b..d7d09aa3999 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -33,8 +33,6 @@ type SessionTabs = { type SessionView = { scroll: Record reviewOpen?: string[] - terminalOpened?: boolean - reviewPanelOpened?: boolean } export type LocalProject = Partial & { worktree: string; expanded: boolean } @@ -78,9 +76,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, terminal: { height: 280, + opened: false, }, review: { diffStyle: "split" as ReviewDiffStyle, + panelOpened: true, }, session: { width: 600, @@ -172,7 +172,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const current = store.sessionView[sessionKey] const keep = meta.active ?? sessionKey if (!current) { - setStore("sessionView", sessionKey, { scroll: next, terminalOpened: false, reviewPanelOpened: true }) + setStore("sessionView", sessionKey, { scroll: next }) prune(keep) return } @@ -208,10 +208,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }) }) - const usedColors = new Set() + const [colors, setColors] = createStore>({}) - function pickAvailableColor(): AvatarColorKey { - const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c)) + function pickAvailableColor(used: Set): AvatarColorKey { + const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c)) if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)] return available[Math.floor(Math.random() * available.length)] } @@ -222,24 +222,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const metadata = projectID ? globalSync.data.project.find((x) => x.id === projectID) : globalSync.data.project.find((x) => x.worktree === project.worktree) - return [ - { - ...(metadata ?? {}), - ...project, - icon: { url: metadata?.icon?.url, color: metadata?.icon?.color }, + return { + ...(metadata ?? {}), + ...project, + icon: { + url: metadata?.icon?.url, + override: metadata?.icon?.override, + color: metadata?.icon?.color, }, - ] - } - - function colorize(project: LocalProject) { - if (project.icon?.color) return project - const color = pickAvailableColor() - usedColors.add(color) - project.icon = { ...project.icon, color } - if (project.id) { - globalSdk.client.project.update({ projectID: project.id, icon: { color } }) } - return project } const roots = createMemo(() => { @@ -277,8 +268,37 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }) }) - const enriched = createMemo(() => server.projects.list().flatMap(enrich)) - const list = createMemo(() => enriched().flatMap(colorize)) + const enriched = createMemo(() => server.projects.list().map(enrich)) + const list = createMemo(() => { + const projects = enriched() + return projects.map((project) => { + const color = project.icon?.color ?? colors[project.worktree] + if (!color) return project + const icon = project.icon ? { ...project.icon, color } : { color } + return { ...project, icon } + }) + }) + + createEffect(() => { + const projects = enriched() + if (projects.length === 0) return + + const used = new Set() + for (const project of projects) { + const color = project.icon?.color ?? colors[project.worktree] + if (color) used.add(color) + } + + for (const project of projects) { + if (project.icon?.color) continue + if (colors[project.worktree]) continue + const color = pickAvailableColor(used) + used.add(color) + setColors(project.worktree, color) + if (!project.id) continue + void globalSdk.client.project.update({ projectID: project.id, icon: { color } }) + } + }) onMount(() => { Promise.all( @@ -379,31 +399,31 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( touch(sessionKey) scroll.seed(sessionKey) const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} }) - const terminalOpened = createMemo(() => s().terminalOpened ?? false) - const reviewPanelOpened = createMemo(() => s().reviewPanelOpened ?? true) + const terminalOpened = createMemo(() => store.terminal?.opened ?? false) + const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true) function setTerminalOpened(next: boolean) { - const current = store.sessionView[sessionKey] + const current = store.terminal if (!current) { - setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: next, reviewPanelOpened: true }) + setStore("terminal", { height: 280, opened: next }) return } - const value = current.terminalOpened ?? false + const value = current.opened ?? false if (value === next) return - setStore("sessionView", sessionKey, "terminalOpened", next) + setStore("terminal", "opened", next) } function setReviewPanelOpened(next: boolean) { - const current = store.sessionView[sessionKey] + const current = store.review if (!current) { - setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: false, reviewPanelOpened: next }) + setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next }) return } - const value = current.reviewPanelOpened ?? true + const value = current.panelOpened ?? true if (value === next) return - setStore("sessionView", sessionKey, "reviewPanelOpened", next) + setStore("review", "panelOpened", next) } return { @@ -444,8 +464,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( if (!current) { setStore("sessionView", sessionKey, { scroll: {}, - terminalOpened: false, - reviewPanelOpened: true, reviewOpen: open, }) return diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 2f3b39d8628..9daac949e43 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1284,7 +1284,7 @@ export default function Layout(props: ParentProps) {
{ if (!isActive()) { - navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`) + sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`) + navigate(`${props.slug}/session/${props.session.id}`) return } - window.location.hash = `message-${message.id}` + window.history.replaceState(null, "", `#message-${message.id}`) window.dispatchEvent(new HashChangeEvent("hashchange")) }} size="normal" diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 31f9eac9c27..3b405ef0773 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js" +import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { createResizeObserver } from "@solid-primitives/resize-observer" import { Dynamic } from "solid-js/web" @@ -167,6 +167,7 @@ export default function Page() { const sdk = useSDK() const prompt = usePrompt() const permission = usePermission() + const [pendingMessage, setPendingMessage] = createSignal(undefined) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) const view = createMemo(() => layout.view(sessionKey())) @@ -943,17 +944,30 @@ export default function Page() { window.history.replaceState(null, "", `#${anchor(id)}`) } + createEffect(() => { + const sessionID = params.id + if (!sessionID) return + const raw = sessionStorage.getItem("opencode.pendingMessage") + if (!raw) return + const parts = raw.split("|") + const pendingSessionID = parts[0] + const messageID = parts[1] + if (!pendingSessionID || !messageID) return + if (pendingSessionID !== sessionID) return + + sessionStorage.removeItem("opencode.pendingMessage") + setPendingMessage(messageID) + }) + const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { const root = scroller - if (!root) { - el.scrollIntoView({ behavior, block: "start" }) - return - } + if (!root) return false const a = el.getBoundingClientRect() const b = root.getBoundingClientRect() const top = a.top - b.top + root.scrollTop root.scrollTo({ top, behavior }) + return true } const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { @@ -967,7 +981,15 @@ export default function Page() { requestAnimationFrame(() => { const el = document.getElementById(anchor(message.id)) - if (el) scrollToElement(el, behavior) + if (!el) { + requestAnimationFrame(() => { + const next = document.getElementById(anchor(message.id)) + if (!next) return + scrollToElement(next, behavior) + }) + return + } + scrollToElement(el, behavior) }) updateHash(message.id) @@ -975,10 +997,57 @@ export default function Page() { } const el = document.getElementById(anchor(message.id)) - if (el) scrollToElement(el, behavior) + if (!el) { + updateHash(message.id) + requestAnimationFrame(() => { + const next = document.getElementById(anchor(message.id)) + if (!next) return + if (!scrollToElement(next, behavior)) return + }) + return + } + if (scrollToElement(el, behavior)) { + updateHash(message.id) + return + } + + requestAnimationFrame(() => { + const next = document.getElementById(anchor(message.id)) + if (!next) return + if (!scrollToElement(next, behavior)) return + }) updateHash(message.id) } + const applyHash = (behavior: ScrollBehavior) => { + const hash = window.location.hash.slice(1) + if (!hash) { + autoScroll.forceScrollToBottom() + return + } + + const match = hash.match(/^message-(.+)$/) + if (match) { + const msg = visibleUserMessages().find((m) => m.id === match[1]) + if (msg) { + scrollToMessage(msg, behavior) + return + } + + // If we have a message hash but the message isn't loaded/rendered yet, + // don't fall back to "bottom". We'll retry once messages arrive. + return + } + + const target = document.getElementById(hash) + if (target) { + scrollToElement(target, behavior) + return + } + + autoScroll.forceScrollToBottom() + } + const getActiveMessageId = (container: HTMLDivElement) => { const cutoff = container.scrollTop + 100 const nodes = container.querySelectorAll("[data-message-id]") @@ -1019,29 +1088,45 @@ export default function Page() { if (!sessionID || !ready) return requestAnimationFrame(() => { - const hash = window.location.hash.slice(1) - if (!hash) { - autoScroll.forceScrollToBottom() - return - } + applyHash("auto") + }) + }) - const hashTarget = document.getElementById(hash) - if (hashTarget) { - scrollToElement(hashTarget, "auto") - return - } + // Retry message navigation once the target message is actually loaded. + createEffect(() => { + const sessionID = params.id + const ready = messagesReady() + if (!sessionID || !ready) return - const match = hash.match(/^message-(.+)$/) - if (match) { - const msg = visibleUserMessages().find((m) => m.id === match[1]) - if (msg) { - scrollToMessage(msg, "auto") - return - } - } + // dependencies + visibleUserMessages().length + store.turnStart + + const targetId = + pendingMessage() ?? + (() => { + const hash = window.location.hash.slice(1) + const match = hash.match(/^message-(.+)$/) + if (!match) return undefined + return match[1] + })() + if (!targetId) return + if (store.messageId === targetId) return + + const msg = visibleUserMessages().find((m) => m.id === targetId) + if (!msg) return + if (pendingMessage() === targetId) setPendingMessage(undefined) + requestAnimationFrame(() => scrollToMessage(msg, "auto")) + }) - autoScroll.forceScrollToBottom() - }) + createEffect(() => { + const sessionID = params.id + const ready = messagesReady() + if (!sessionID || !ready) return + + const handler = () => requestAnimationFrame(() => applyHash("auto")) + window.addEventListener("hashchange", handler) + onCleanup(() => window.removeEventListener("hashchange", handler)) }) createEffect(() => { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index afcb2c6118d..7ed0b91835c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -128,6 +128,15 @@ export function Header() { v{Installation.VERSION} + + + + + {session().share!.url} + + + + diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 2cec78623d1..40ebb21ea5a 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -25,6 +25,7 @@ export namespace Project { icon: z .object({ url: z.string().optional(), + override: z.string().optional(), color: z.string().optional(), }) .optional(), @@ -190,6 +191,7 @@ export namespace Project { if (!existing.sandboxes) existing.sandboxes = [] if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing) + const result: Info = { ...existing, worktree, @@ -213,6 +215,7 @@ export namespace Project { export async function discover(input: Info) { if (input.vcs !== "git") return + if (input.icon?.override) return if (input.icon?.url) return const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}") const matches = await Array.fromAsync( @@ -293,6 +296,7 @@ export namespace Project { ...draft.icon, } if (input.icon.url !== undefined) draft.icon.url = input.icon.url + if (input.icon.override !== undefined) draft.icon.override = input.icon.override || undefined if (input.icon.color !== undefined) draft.icon.color = input.icon.color } draft.time.updated = Date.now() diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 59b7f06963b..706d0f9c227 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -302,6 +302,7 @@ export class Project extends HeyApiClient { name?: string icon?: { url?: string + override?: string color?: string } }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 75540f90724..b7e72fbad8f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -25,6 +25,7 @@ export type Project = { name?: string icon?: { url?: string + override?: string color?: string } time: { @@ -2229,6 +2230,7 @@ export type ProjectUpdateData = { name?: string icon?: { url?: string + override?: string color?: string } } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 08dd98fd9bc..c1be820f262 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -231,6 +231,9 @@ "url": { "type": "string" }, + "override": { + "type": "string" + }, "color": { "type": "string" } @@ -5796,6 +5799,9 @@ "url": { "type": "string" }, + "override": { + "type": "string" + }, "color": { "type": "string" } diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 5f8c0a16f6a..034d3024707 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -75,6 +75,17 @@ background-color: var(--background-stronger); z-index: -1; } + + &::after { + content: ""; + position: absolute; + top: 100%; + left: 0; + right: 0; + height: 32px; + background: linear-gradient(to bottom, var(--background-stronger), transparent); + pointer-events: none; + } } [data-slot="session-turn-response-trigger"] {