diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 75f0c6b44465..0a1611d99e4b 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -170,13 +170,13 @@ declare global { __OPENCODE__?: { deepLinks?: string[] } - api?: { - setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise - exportDebugLogs?: () => Promise - } } } +type TitlebarAPI = { + setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise +} + function QueryProvider(props: ParentProps) { const client = new QueryClient({ defaultOptions: { @@ -266,7 +266,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { { - void window.api?.setTitlebar?.({ mode }) + void (window as Window & { api?: TitlebarAPI }).api?.setTitlebar?.({ mode }) }} > diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index e813f9f10b08..5f403ee55c77 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -268,6 +268,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const tabs = useTabs() const tabsStore = tabs.store const tabsStoreActions = tabs + const sessionTabCount = () => + tabsStore.filter((tab) => tab.type === "session" && tab.server === server.key).length + const panelToggleLabel = () => + tabs.panels.tiled() ? language.t("command.tabs.viewAsTabs") : language.t("command.tabs.viewAsPanels") const navigateTab = (tab: Tab) => { const href = tabHref(tab) if (tab.server === server.key) { @@ -337,6 +341,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const current = currentTab() return [ + { + id: "tabs.panel.toggle", + category: language.t("command.category.view"), + title: panelToggleLabel(), + disabled: sessionTabCount() < 2, + hidden: true, + onSelect: () => tabs.panels.toggle(), + }, { id: "tab.new", category: "tab", @@ -551,6 +563,21 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { aria-label={language.t("command.session.new")} /> + 1}> + + tabs.panels.toggle()} + aria-label={panelToggleLabel()} + aria-pressed={tabs.panels.tiled()} + icon={} + /> + +
diff --git a/packages/app/src/context/tabs-order.test.ts b/packages/app/src/context/tabs-order.test.ts new file mode 100644 index 000000000000..d8d85585240a --- /dev/null +++ b/packages/app/src/context/tabs-order.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import { swapTabOrder, type TabOrderItem } from "./tabs-order" + +const sessionTab = (sessionId: string): TabOrderItem => ({ + type: "session", + server: "sidecar", + dirBase64: "L3JlcG8=", + sessionId, +}) + +describe("swapTabOrder", () => { + test("swaps matching tabs by tab identity", () => { + const first = sessionTab("first") + const second = sessionTab("second") + const third = sessionTab("third") + + expect(swapTabOrder([first, second, third], first, third)).toEqual([third, second, first]) + }) + + test("keeps order when either tab is missing", () => { + const first = sessionTab("first") + const second = sessionTab("second") + + expect(swapTabOrder([first], first, second)).toEqual([first]) + }) +}) diff --git a/packages/app/src/context/tabs-order.ts b/packages/app/src/context/tabs-order.ts new file mode 100644 index 000000000000..7e4f0af9711c --- /dev/null +++ b/packages/app/src/context/tabs-order.ts @@ -0,0 +1,29 @@ +export type TabOrderItem = + | { + type: "session" + server: string + dirBase64: string + sessionId: string + } + | { + type: "draft" + draftID: string + } + +export const tabOrderKey = (tab: TabOrderItem) => + tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n/${tab.dirBase64}/session/${tab.sessionId}` + +export function swapTabOrder(tabs: readonly T[], first: TabOrderItem, second: TabOrderItem) { + const firstKey = tabOrderKey(first) + const secondKey = tabOrderKey(second) + if (firstKey === secondKey) return tabs.slice() + + const firstIndex = tabs.findIndex((tab) => tabOrderKey(tab) === firstKey) + const secondIndex = tabs.findIndex((tab) => tabOrderKey(tab) === secondKey) + if (firstIndex === -1 || secondIndex === -1) return tabs.slice() + + const next = tabs.slice() + next[firstIndex] = tabs[secondIndex]! + next[secondIndex] = tabs[firstIndex]! + return next +} diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 7f6274cf9922..2d083ccd035b 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -9,6 +9,7 @@ import { useLocation, useNavigate, useParams } from "@solidjs/router" import { usePlatform } from "./platform" import { uuid } from "@/utils/uuid" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" +import { swapTabOrder, tabOrderKey } from "./tabs-order" export type SessionTab = { type: "session" @@ -32,7 +33,7 @@ export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIC export const tabHref = (tab: Tab) => tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}` -export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`) +export const tabKey = tabOrderKey export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) { const dirBase64 = base64Encode(session.directory) @@ -62,6 +63,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }, createStore([]), ) + const [panelStore, setPanelStore] = persisted(Persist.global("tabs.panels"), createStore({ tiled: false })) const params = useParams() const navigate = useNavigate() @@ -158,6 +160,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }).finally(() => closing.delete(key)) if (draftID) removeDraftPersisted(draftID) }, + swapTabs: (first: Tab, second: Tab) => { + setStore((tabs) => swapTabOrder(tabs, first, second)) + }, removeServer(key: ServerConnection.Key) { const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : [])) setStore((tabs) => tabs.filter((tab) => tab.server !== key)) @@ -211,6 +216,16 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }, } - return { ...actions, store, ready } + const panels = { + tiled: () => panelStore.tiled, + setTiled(value: boolean) { + setPanelStore("tiled", value) + }, + toggle() { + setPanelStore("tiled", (value) => !value) + }, + } + + return { ...actions, panels, store, ready } }, }) diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts index e4ea0d6cebda..49ec4449fa20 120000 --- a/packages/app/src/custom-elements.d.ts +++ b/packages/app/src/custom-elements.d.ts @@ -1 +1,17 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file +import { DIFFS_TAG_NAME } from "@pierre/diffs" + +/** + * TypeScript declaration for the custom element. + * This tells TypeScript that is a valid JSX element in SolidJS. + * Required for using the @pierre/diffs web component in .tsx files. + */ + +declare module "solid-js" { + namespace JSX { + interface IntrinsicElements { + [DIFFS_TAG_NAME]: HTMLAttributes + } + } +} + +export {} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 403addcb241a..619670d6cc33 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -48,6 +48,8 @@ export const dict = { "command.session.new": "New session", "command.file.open": "Open file", "command.tab.close": "Close tab", + "command.tabs.viewAsPanels": "View tabs as panels", + "command.tabs.viewAsTabs": "View as tabs", "command.context.addSelection": "Add selection to context", "command.context.addSelection.description": "Add selected lines from the current file", "command.input.focus": "Focus input", diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 88f28c38727c..d577dcdf33e5 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -126,6 +126,21 @@ width: 100%; } + [data-session-panel-grid] [data-session-panel], + [data-session-panel-grid] [data-session-panel] .scroll-view__viewport, + [data-session-panel-grid] [data-session-panel] [data-slot="session-turn-message-container"], + [data-session-panel-grid] [data-session-panel] [data-slot="session-turn-message-content"], + [data-session-panel-grid] [data-session-panel] [data-slot="session-turn-assistant-content"] { + min-width: 0; + max-width: 100%; + overflow-x: hidden; + } + + [data-session-panel-grid] [data-session-panel] [data-slot="session-turn-message-content"], + [data-session-panel-grid] [data-session-panel] [data-slot="session-turn-assistant-content"] { + overflow-wrap: anywhere; + } + @container getting-started (min-width: 17rem) { [data-component="getting-started-actions"] { flex-direction: row; diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 5f74650feed4..44e106f3cc7f 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -14,6 +14,7 @@ import { onMount, untrack, createResource, + For, } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import { createMediaQuery } from "@solid-primitives/media" @@ -30,7 +31,7 @@ import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { Button } from "@opencode-ai/ui/button" import { showToast } from "@/utils/toast" import { checksum } from "@opencode-ai/core/util/encode" -import { useLocation, useSearchParams } from "@solidjs/router" +import { useLocation, useNavigate, useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" import { useComments } from "@/context/comments" import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" @@ -63,6 +64,19 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel" import { TerminalPanel } from "@/pages/session/terminal-panel" import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" +import { + normalizePanelState, + normalizePanelWeights, + panelAvailableSize, + panelBoundary, + panelHandleOffset, + panelItemStyle, + panelMinHeight, + panelMinWidth, + panelPixels, + panelTrackTemplate, + resizePanelWeights, +} from "@/pages/session/panel-layout" import { Identifier } from "@/utils/id" import { diffs as list } from "@/utils/diffs" import { Persist, persisted } from "@/utils/persist" @@ -70,6 +84,8 @@ import { extractPromptFromParts } from "@/utils/prompt" import { same } from "@/utils/same" import { formatServerError } from "@/utils/server-errors" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" +import { tabHref, tabKey, useTabs as useOpenTabs, type SessionTab } from "@/context/tabs" +import { SessionTextPane } from "@/pages/session/session-text-pane" const emptyUserMessages: UserMessage[] = [] type FollowupItem = FollowupDraft & { id: string } @@ -200,10 +216,36 @@ export default function Page() { const comments = useComments() const terminal = useTerminal() const server = useServer() + const navigate = useNavigate() + const openTabs = useOpenTabs() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const location = useLocation() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) + const panelTabs = createMemo(() => + openTabs.store.filter((tab): tab is SessionTab => tab.type === "session" && tab.server === server.key), + ) + const focusedPanel = (tab: SessionTab) => tab.dirBase64 === params.dir && tab.sessionId === params.id + const panelMode = createMemo(() => newSessionDesign() && openTabs.panels.tiled() && !!params.id && panelTabs().length > 1) + const focusPanel = (tab: SessionTab) => { + if (focusedPanel(tab)) return + navigate(tabHref(tab)) + } + const panelKey = (tab: SessionTab) => tabKey(tab) + const clearPanelDrag = () => { + setStore("panelDragKey", undefined) + setStore("panelDropKey", undefined) + } + const startPanelDrag = (event: PointerEvent, tab: SessionTab) => { + if (event.button !== 0 || !event.isPrimary) return + event.preventDefault() + event.stopPropagation() + setStore("panelDragKey", panelKey(tab)) + setStore("panelDropKey", undefined) + } + const panelKeyAt = (event: PointerEvent) => + document.elementFromPoint(event.clientX, event.clientY)?.closest("[data-session-panel-key]")?.dataset + .sessionPanelKey createEffect(() => { if (!prompt.ready()) return @@ -394,8 +436,107 @@ export default function Page() { changes: "git" as ChangeMode, newSessionWorktree: "main", deferRender: false, + panel: { + width: 0, + height: 0, + columns: [] as number[], + rows: [] as number[], + }, + panelDragKey: undefined as string | undefined, + panelDropKey: undefined as string | undefined, + }) + + makeEventListener(window, "pointermove", (event) => { + if (!store.panelDragKey) return + const key = panelKeyAt(event) + const next = key && key !== store.panelDragKey ? key : undefined + if (store.panelDropKey !== next) setStore("panelDropKey", next) + }) + + makeEventListener(window, "pointerup", (event) => { + const sourceKey = store.panelDragKey + if (!sourceKey) return + const targetKey = panelKeyAt(event) + clearPanelDrag() + if (!targetKey || sourceKey === targetKey) return + + const source = panelTabs().find((tab) => panelKey(tab) === sourceKey) + const target = panelTabs().find((tab) => panelKey(tab) === targetKey) + if (!source || !target) return + openTabs.swapTabs(source, target) + }) + + const panelState = () => normalizePanelState(store.panel) + const panelColumnCount = createMemo(() => Math.max(1, isDesktop() ? Math.min(2, panelTabs().length) : 1)) + const panelRowCount = createMemo(() => Math.max(1, Math.ceil(panelTabs().length / panelColumnCount()))) + const panelColumnWeights = createMemo(() => normalizePanelWeights(panelState().columns, panelColumnCount())) + const panelRowWeights = createMemo(() => normalizePanelWeights(panelState().rows, panelRowCount())) + const panelAvailableWidth = createMemo(() => panelAvailableSize(panelState().width, panelColumnCount())) + const panelAvailableHeight = createMemo(() => panelAvailableSize(panelState().height, panelRowCount())) + const panelColumnPixels = createMemo(() => panelPixels(panelColumnWeights(), panelAvailableWidth())) + const panelRowPixels = createMemo(() => panelPixels(panelRowWeights(), panelAvailableHeight())) + const panelColumnHandles = createMemo(() => + Array.from({ length: Math.max(0, panelColumnCount() - 1) }, (_, index) => index), + ) + const panelRowHandles = createMemo(() => + Array.from({ length: Math.max(0, panelRowCount() - 1) }, (_, index) => index), + ) + const panelGridStyle = createMemo(() => ({ + "grid-template-columns": panelTrackTemplate(panelColumnWeights()), + "grid-template-rows": panelTrackTemplate(panelRowWeights()), + })) + const panelRowHandleStyle = (index: number) => { + const top = `${panelHandleOffset(panelRowPixels(), index)}px` + if (panelTabs().length === 3 && panelColumnCount() === 2) { + return { top, right: "auto", width: `${panelColumnPixels()[0]}px` } + } + return { top } + } + + let panelGrid: HTMLDivElement | undefined + + const setPanelSize = (width: number, height: number) => { + const nextWidth = Math.round(width) + const nextHeight = Math.round(height) + + if (nextWidth <= 0 || nextHeight <= 0) return + if (panelState().width === nextWidth && panelState().height === nextHeight) return + setStore("panel", { ...panelState(), width: nextWidth, height: nextHeight }) + } + + const measurePanelGrid = (el = panelGrid) => { + const rect = el?.getBoundingClientRect() + if (!rect) return + setPanelSize(rect.width, rect.height) + } + + createEffect(() => { + if (!panelMode()) return + panelTabs().length + requestAnimationFrame(() => measurePanelGrid()) }) + createResizeObserver( + () => panelGrid, + ({ width, height }) => { + setPanelSize(width, height) + }, + ) + + function resizePanelColumn(index: number, boundary: number) { + const available = panelAvailableWidth() + if (available <= 0) return + const min = Math.min(panelMinWidth, available / panelColumnCount()) + setStore("panel", { ...panelState(), columns: resizePanelWeights(panelColumnPixels(), index, boundary, min) }) + } + + function resizePanelRow(index: number, boundary: number) { + const available = panelAvailableHeight() + if (available <= 0) return + const min = Math.min(panelMinHeight, available / panelRowCount()) + setStore("panel", { ...panelState(), rows: resizePanelWeights(panelRowPixels(), index, boundary, min) }) + } + const [followup, setFollowup] = persisted( Persist.serverWorkspace(serverSDK().scope, sdk().directory, "followup", ["followup.v1"]), createStore<{ @@ -1722,7 +1863,165 @@ export default function Page() { /> ) - return ( + const sessionContent = () => ( + + +
+ {reviewContent({ + diffStyle: "unified", + classes: { + root: "pb-8", + header: "px-4", + container: "px-4", + }, + loadingClass: "px-4 py-4 text-text-weak", + emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6", + })} +
+
+ + + + !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled() + } + centered={centered()} + fullWidthHeader={panelMode()} + setContentRef={(el) => { + content = el + autoScroll.contentRef(el) + + const root = scroller + if (root) scheduleScrollState(root) + }} + historyShift={historyLoader.shift()} + userMessages={historyLoader.userMessages()} + anchor={anchor} + setRevealMessage={(fn) => { + revealMessage = fn + }} + /> + + + + + +
+ ) + + const panelView = () => ( +
+ {sessionSync() ?? ""} +
+
{ + panelGrid = el + requestAnimationFrame(() => measurePanelGrid(el)) + }} + > +
+ + {(tab, index) => { + const active = () => focusedPanel(tab) + const style = () => panelItemStyle(index(), panelTabs().length, panelColumnCount()) + return ( +
{ + if (!active()) focusPanel(tab) + }} + onKeyDown={(event) => { + if (active()) return + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + focusPanel(tab) + }} + > +
{ + event.preventDefault() + event.stopPropagation() + if (!active()) focusPanel(tab) + }} + onMouseDown={(event) => event.stopPropagation()} + onPointerDown={(event) => startPanelDrag(event, tab)} + /> + }> +
{sessionContent()}
+
+
+ ) + }} +
+
+ + 0 && panelState().height > 0}> + + {(index) => { + const min = () => Math.min(panelMinWidth, panelAvailableWidth() / panelColumnCount()) + return ( + resizePanelColumn(index, size)} + class="absolute top-0 bottom-0 z-50 w-3 -translate-x-1/2 cursor-col-resize [app-region:no-drag] after:absolute after:inset-y-4 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-v2-border-border-focus after:opacity-0 after:transition-opacity hover:after:opacity-100" + style={{ left: `${panelHandleOffset(panelColumnPixels(), index)}px` }} + /> + ) + }} + + + {(index) => { + const min = () => Math.min(panelMinHeight, panelAvailableHeight() / panelRowCount()) + return ( + resizePanelRow(index, size)} + class="absolute left-0 right-0 z-50 h-3 -translate-y-1/2 cursor-row-resize [app-region:no-drag] after:absolute after:top-1/2 after:inset-x-4 after:h-px after:-translate-y-1/2 after:bg-v2-border-border-focus after:opacity-0 after:transition-opacity hover:after:opacity-100" + style={panelRowHandleStyle(index)} + /> + ) + }} + + +
+
+ {composerRegion("dock")} +
+ ) + + const standardView = () => (
{sessionSync() ?? ""} @@ -1774,61 +2073,7 @@ export default function Page() { "shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id, }} > -
- - -
- {reviewContent({ - diffStyle: "unified", - classes: { - root: "pb-8", - header: "px-4", - container: "px-4", - }, - loadingClass: "px-4 py-4 text-text-weak", - emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6", - })} -
-
- - - - !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled() - } - centered={centered()} - setContentRef={(el) => { - content = el - autoScroll.contentRef(el) - - const root = scroller - if (root) scheduleScrollState(root) - }} - historyShift={historyLoader.shift()} - userMessages={historyLoader.userMessages()} - anchor={anchor} - setRevealMessage={(fn) => { - revealMessage = fn - }} - /> - - - - - -
-
+
{sessionContent()}
{composerRegion("dock")}
@@ -1870,4 +2115,13 @@ export default function Page() {
) + + return ( + + {panelView()} + + ) } diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index d025043c6d3f..c8c681383914 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -169,7 +169,7 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu ) } -function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) { +export function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) { const language = useLanguage() const maxFiles = 10 const [state, setState] = createStore({ @@ -277,6 +277,7 @@ export function MessageTimeline(props: { onAutoScrollInteraction: (event: MouseEvent) => void shouldAnchorBottom: () => boolean centered: boolean + fullWidthHeader?: boolean setContentRef: (el: HTMLDivElement) => void historyShift: boolean userMessages: UserMessage[] @@ -1305,8 +1306,8 @@ export function MessageTimeline(props: { "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, "w-full": true, "pb-4": true, - "pl-2 pr-3 md:pl-4 md:pr-3": true, - "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, + "pl-2 pr-3 md:pl-4 md:pr-3": !props.fullWidthHeader, + "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !props.fullWidthHeader, }} > @@ -1320,7 +1321,13 @@ export function MessageTimeline(props: { /> -
+
diff --git a/packages/app/src/pages/session/panel-layout.test.ts b/packages/app/src/pages/session/panel-layout.test.ts new file mode 100644 index 000000000000..327e3d19a332 --- /dev/null +++ b/packages/app/src/pages/session/panel-layout.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test" +import { + normalizePanelState, + normalizePanelWeights, + panelAvailableSize, + panelBoundary, + panelGap, + panelHandleOffset, + panelItemStyle, + panelPixels, + panelTrackTemplate, + resizePanelWeights, +} from "./panel-layout" + +describe("panel layout", () => { + test("normalizes stale or partially migrated panel state", () => { + expect(normalizePanelState(undefined)).toEqual({ + width: 0, + height: 0, + columns: [], + rows: [], + }) + + expect( + normalizePanelState({ + width: 900, + height: Number.POSITIVE_INFINITY, + columns: [2, 1], + }), + ).toEqual({ + width: 900, + height: 0, + columns: [2, 1], + rows: [], + }) + }) + + test("fills missing and invalid weights with equal tracks", () => { + expect(normalizePanelWeights([], 2)).toEqual([0.5, 0.5]) + expect(normalizePanelWeights([3, -1, Number.NaN], 3)).toEqual([0.6, 0.2, 0.2]) + }) + + test("builds minmax tracks so panels can shrink without horizontal overflow", () => { + expect(panelTrackTemplate([0.25, 0.75])).toBe("minmax(0, 0.25fr) minmax(0, 0.75fr)") + }) + + test("spans the right panel in three-panel desktop layouts", () => { + expect(panelItemStyle(0, 3, 2)).toEqual({ "grid-column": "1", "grid-row": "1" }) + expect(panelItemStyle(1, 3, 2)).toEqual({ "grid-column": "2", "grid-row": "1 / span 2" }) + expect(panelItemStyle(2, 3, 2)).toEqual({ "grid-column": "1", "grid-row": "2" }) + }) + + test("keeps normal grid flow outside the three-panel desktop layout", () => { + expect(panelItemStyle(0, 2, 2)).toEqual({}) + expect(panelItemStyle(2, 3, 1)).toEqual({}) + expect(panelItemStyle(3, 4, 2)).toEqual({}) + }) + + test("subtracts only visible gaps from available space", () => { + expect(panelAvailableSize(1000, 1)).toBe(1000) + expect(panelAvailableSize(1000, 3)).toBe(1000 - panelGap * 2) + }) + + test("computes handle offsets at track boundaries plus half the gap", () => { + expect(panelHandleOffset([300, 200], 0)).toBe(304) + expect(panelHandleOffset([300, 200, 100], 1)).toBe(512) + }) + + test("resizes adjacent tracks while preserving total panel width", () => { + const weights = resizePanelWeights([300, 300], 0, 420, 180) + const pixels = panelPixels(weights, 600) + + expect(pixels.map(Math.round)).toEqual([420, 180]) + expect(Math.round(pixels.reduce((sum, value) => sum + value, 0))).toBe(600) + }) + + test("clamps resize at minimum track size", () => { + const weights = resizePanelWeights([300, 300], 0, 80, 180) + + expect(panelPixels(weights, 600).map(Math.round)).toEqual([180, 420]) + }) + + test("relaxes the minimum when adjacent tracks are already too small", () => { + const weights = resizePanelWeights([120, 120, 760], 0, 220, 180) + + expect(panelPixels(weights, 1000).map(Math.round)).toEqual([120, 120, 760]) + }) + + test("only changes the adjacent pair in multi-track layouts", () => { + const weights = resizePanelWeights([200, 300, 500], 1, 620, 180) + const pixels = panelPixels(weights, 1000) + + expect(pixels.map(Math.round)).toEqual([200, 420, 380]) + expect(panelBoundary(pixels, 1)).toBe(620) + }) +}) diff --git a/packages/app/src/pages/session/panel-layout.ts b/packages/app/src/pages/session/panel-layout.ts new file mode 100644 index 000000000000..57d60bc52c65 --- /dev/null +++ b/packages/app/src/pages/session/panel-layout.ts @@ -0,0 +1,69 @@ +export const panelGap = 8 +export const panelMinWidth = 280 +export const panelMinHeight = 180 + +export type PanelState = { + width: number + height: number + columns: number[] + rows: number[] +} + +export function normalizePanelState(input: Partial | undefined): PanelState { + return { + width: typeof input?.width === "number" && Number.isFinite(input.width) ? input.width : 0, + height: typeof input?.height === "number" && Number.isFinite(input.height) ? input.height : 0, + columns: Array.isArray(input?.columns) ? input.columns : [], + rows: Array.isArray(input?.rows) ? input.rows : [], + } +} + +export function normalizePanelWeights(weights: readonly number[], count: number) { + const values = Array.from({ length: count }, (_, index) => { + const value = weights[index] + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 1 + }) + const total = values.reduce((sum, value) => sum + value, 0) + return values.map((value) => value / total) +} + +export function panelAvailableSize(size: number, count: number, gap = panelGap) { + return Math.max(0, size - gap * Math.max(0, count - 1)) +} + +export function panelTrackTemplate(weights: readonly number[]) { + return weights.map((weight) => `minmax(0, ${weight}fr)`).join(" ") +} + +export function panelItemStyle(index: number, count: number, columns: number) { + if (count !== 3 || columns !== 2) return {} + if (index === 1) return { "grid-column": "2", "grid-row": "1 / span 2" } + return { "grid-column": "1", "grid-row": index === 2 ? "2" : "1" } +} + +export function panelPixels(weights: readonly number[], available: number) { + return weights.map((weight) => weight * available) +} + +export function panelBoundary(pixels: readonly number[], index: number) { + return pixels.slice(0, index + 1).reduce((sum, value) => sum + value, 0) +} + +export function panelHandleOffset(pixels: readonly number[], index: number, gap = panelGap) { + return panelBoundary(pixels, index) + gap * index + gap / 2 +} + +export function resizePanelWeights(pixels: readonly number[], index: number, boundary: number, min: number) { + const before = pixels.slice(0, index).reduce((sum, value) => sum + value, 0) + const pair = (pixels[index] ?? 0) + (pixels[index + 1] ?? 0) + const effectiveMin = Math.min(Number.isFinite(min) ? Math.max(0, min) : 0, pair / 2) + const first = Math.min(pair - effectiveMin, Math.max(effectiveMin, boundary - before)) + const next = pixels.map((value, pixelIndex) => { + if (pixelIndex === index) return first + if (pixelIndex === index + 1) return pair - first + return value + }) + const total = next.reduce((sum, value) => sum + value, 0) + if (!Number.isFinite(total) || total <= 0) return normalizePanelWeights(next, next.length) + return next.map((value) => value / total) +} diff --git a/packages/app/src/pages/session/session-text-pane.tsx b/packages/app/src/pages/session/session-text-pane.tsx new file mode 100644 index 000000000000..223b8119222e --- /dev/null +++ b/packages/app/src/pages/session/session-text-pane.tsx @@ -0,0 +1,381 @@ +import { + createEffect, + createMemo, + For, + Index, + Show, + type Accessor, + type JSX, +} from "solid-js" +import { createStore } from "solid-js/store" +import type { + AssistantMessage, + Message as MessageType, + Part as PartType, + ToolPart, + UserMessage, +} from "@opencode-ai/sdk/v2/client" +import { Card } from "@opencode-ai/ui/card" +import { createAutoScroll } from "@opencode-ai/ui/hooks" +import { + ContextToolGroup, + Message, + MessageDivider, + Part as MessagePart, + partDefaultOpen, +} from "@opencode-ai/ui/message-part" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { useLanguage } from "@/context/language" +import { useServerSync } from "@/context/server-sync" +import { useSettings } from "@/context/settings" +import type { SessionTab } from "@/context/tabs" +import { decode64 } from "@/utils/base64" +import { same } from "@/utils/same" +import { sessionTitle } from "@/utils/session-title" +import { getFilename } from "@opencode-ai/core/util/path" +import { MessageComment, Timeline, TimelineRow, type TimelineRowMap } from "./message-timeline.data" +import { TimelineDiffSummaryRow } from "./message-timeline" + +const emptyMessages: MessageType[] = [] +const emptyParts: PartType[] = [] +const emptyTools: ToolPart[] = [] +const emptyAssistantMessages: AssistantMessage[] = [] +const idle = { type: "idle" as const } + +type FramedTimelineRow = Exclude +type TimelineRowByTag = Extract + +export function SessionTextPane(props: { tab: SessionTab; centered?: boolean }) { + const serverSync = useServerSync() + const language = useLanguage() + const settings = useSettings() + const [toolOpen, setToolOpen] = createStore>({}) + const directory = createMemo(() => decode64(props.tab.dirBase64)) + const sync = createMemo(() => { + const dir = directory() + if (!dir) return + return serverSync().createDirSyncContext(dir) + }) + const messages = createMemo( + () => sync()?.data.message[props.tab.sessionId] ?? emptyMessages, + emptyMessages, + { equals: same }, + ) + const loaded = createMemo(() => sync()?.data.message[props.tab.sessionId] !== undefined) + const title = createMemo(() => sessionTitle(sync()?.session.get(props.tab.sessionId)?.title)) + const sessionStatus = createMemo(() => sync()?.data.session_status[props.tab.sessionId] ?? idle) + const userMessages = createMemo( + () => messages().filter((message): message is UserMessage => message.role === "user"), + [] as UserMessage[], + { equals: same }, + ) + const messageByID = createMemo(() => new Map(messages().map((message) => [message.id, message] as const))) + const assistantMessagesByParent = createMemo(() => + messages().reduce((result, message) => { + if (message.role !== "assistant" || !message.parentID) return result + result.set(message.parentID, [...(result.get(message.parentID) ?? emptyAssistantMessages), message]) + return result + }, new Map()), + ) + const getMsgParts = (messageID: string) => sync()?.data.part[messageID] ?? emptyParts + const getMsgPart = (messageID: string, partID: string) => + getMsgParts(messageID).find((part) => part.id === partID) + const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => { + const next = [ + ...userMessages().flatMap((message, index) => + Timeline.constructMessageRows( + message, + getMsgParts, + assistantMessagesByParent().get(message.id) ?? emptyAssistantMessages, + index, + settings.general.showReasoningSummaries(), + sessionStatus().type, + false, + ), + ), + new TimelineRow.BottomSpacer(), + ] + if (!previous || previous.length !== next.length) return next + if (!previous.every((item, index) => TimelineRow.equals(item, next[index]!))) return next + return previous + }) + const autoScroll = createAutoScroll({ + working: loaded, + bottomThreshold: 8, + }) + + createEffect(() => { + const ctx = sync() + if (!ctx) return + void ctx.session.sync(props.tab.sessionId).catch(() => {}) + }) + + const turnDurationMs = (userMessageID: string) => { + const message = messageByID().get(userMessageID) + if (!message || message.role !== "user") return + const end = (assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages).reduce( + (max, item) => { + const completed = item.time.completed + if (typeof completed !== "number") return max + if (max === undefined) return completed + return Math.max(max, completed) + }, + undefined, + ) + if (typeof end !== "number" || end < message.time.created) return + return end - message.time.created + } + + const assistantCopyPartID = (userMessageID: string) => + (assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages) + .slice() + .reverse() + .flatMap((message) => getMsgParts(message.id).slice().reverse()) + .find((part) => part.type === "text" && !!part.text?.trim())?.id ?? null + + const renderAssistantPartGroup = (row: Accessor) => { + if (row().group.type === "context") { + const parts = createMemo(() => { + const group = row().group + if (group.type !== "context") return emptyTools + return group.refs + .map((ref) => getMsgPart(ref.messageID, ref.partID)) + .filter((part): part is ToolPart => part?.type === "tool") + }) + + return + } + + const message = createMemo(() => { + const group = row().group + if (group.type !== "part") return + return messageByID().get(group.ref.messageID) + }) + const part = createMemo(() => { + const group = row().group + if (group.type !== "part") return + return getMsgPart(group.ref.messageID, group.ref.partID) + }) + const defaultOpen = createMemo(() => { + const item = part() + if (!item) return + return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded()) + }) + + return ( + + {(message) => ( + + {(part) => ( + setToolOpen(part().id, open)} + deferToolContent={false} + virtualizeDiff={false} + /> + )} + + )} + + ) + } + + function TimelineRowFrame(input: { row: Accessor; children: JSX.Element }) { + const anchor = () => { + const row = input.row() + return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor) + } + const previousUserMessage = () => { + const row = input.row() + return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage + } + const previousAssistantPart = () => { + const row = input.row() + return row._tag === "AssistantPart" && row.previousAssistantPart + } + + return ( +
+
+ {input.children} +
+
+ ) + } + + const renderTimelineRow = (row: Accessor) => { + switch (row()._tag) { + case "CommentStrip": { + const commentStripRow = row as Accessor> + const comments = createMemo(() => + getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []), + ) + return ( + +
+
+
+ + {(comment) => ( +
+
+ + {getFilename(comment().path)} + + {(selection) => ( + + {selection().startLine === selection().endLine + ? `:${selection().startLine}` + : `:${selection().startLine}-${selection().endLine}`} + + )} + +
+
+ {comment().comment} +
+
+ )} +
+
+
+
+
+ ) + } + case "UserMessage": { + const userMessageRow = row as Accessor> + const message = createMemo(() => { + const item = messageByID().get(userMessageRow().userMessageID) + if (item?.role === "user") return item + }) + return ( + + + {(message) => ( +
+
+ +
+
+ )} +
+
+ ) + } + case "TurnDivider": { + const turnDividerRow = row as Accessor> + return ( + +
+
+ +
+
+
+ ) + } + case "AssistantPart": { + const assistantPartRow = row as Accessor> + return ( + +
+
{renderAssistantPartGroup(assistantPartRow)}
+
+
+ ) + } + case "DiffSummary": { + const diffSummaryRow = row as Accessor> + return ( + +
+ +
+
+ ) + } + case "Error": { + const errorRow = row as Accessor> + return ( + +
+ + {errorRow().text} + +
+
+ ) + } + case "Thinking": + case "Retry": + return null + case "BottomSpacer": + return + ) +} diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index 5ea46b51656a..40166d7d102c 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -1,9 +1,55 @@ import { sentryVitePlugin } from "@sentry/vite-plugin" import { defineConfig } from "electron-vite" -import appPlugin from "@opencode-ai/app/vite" +import appPlugin from "../app/vite.js" import * as fs from "node:fs/promises" +import { realpathSync } from "node:fs" +import { fileURLToPath } from "node:url" const OPENCODE_SERVER_DIST = "../opencode/dist/node" +const repoRoot = fileURLToPath(new URL("../..", import.meta.url)).replaceAll("\\", "/") +const workspacePath = (target: string) => fileURLToPath(new URL(`../${target}`, import.meta.url)).replaceAll("\\", "/") +const repoPath = (target: string) => fileURLToPath(new URL(`../../${target}`, import.meta.url)).replaceAll("\\", "/") +const copiedWorkspacePackages = (() => { + try { + return realpathSync(repoPath("node_modules/@opencode-ai/app")).replaceAll("\\", "/") !== workspacePath("app") + } catch { + return false + } +})() +const rendererWorkspaceAliases = [ + { find: /^@opencode-ai\/app$/, replacement: workspacePath("app/src/index.ts") }, + { find: /^@opencode-ai\/app\/desktop-menu$/, replacement: workspacePath("app/src/desktop-menu.ts") }, + { find: /^@opencode-ai\/app\/updater$/, replacement: workspacePath("app/src/updater.ts") }, + { find: /^@opencode-ai\/app\/wsl\/types$/, replacement: workspacePath("app/src/wsl/types.ts") }, + { find: /^@opencode-ai\/core\/(.+)$/, replacement: `${workspacePath("core/src")}/$1` }, + { find: /^@opencode-ai\/sdk$/, replacement: workspacePath("sdk/js/src/index.ts") }, + { find: /^@opencode-ai\/sdk\/client$/, replacement: workspacePath("sdk/js/src/client.ts") }, + { find: /^@opencode-ai\/sdk\/server$/, replacement: workspacePath("sdk/js/src/server.ts") }, + { find: /^@opencode-ai\/sdk\/v2$/, replacement: workspacePath("sdk/js/src/v2/index.ts") }, + { find: /^@opencode-ai\/sdk\/v2\/client$/, replacement: workspacePath("sdk/js/src/v2/client.ts") }, + { find: /^@opencode-ai\/sdk\/v2\/gen\/client$/, replacement: workspacePath("sdk/js/src/v2/gen/client/index.ts") }, + { find: /^@opencode-ai\/sdk\/v2\/server$/, replacement: workspacePath("sdk/js/src/v2/server.ts") }, + { find: /^@opencode-ai\/ui\/i18n\/(.+)$/, replacement: `${workspacePath("ui/src/i18n")}/$1.ts` }, + { find: /^@opencode-ai\/ui\/pierre$/, replacement: workspacePath("ui/src/pierre/index.ts") }, + { find: /^@opencode-ai\/ui\/pierre\/(.+)$/, replacement: `${workspacePath("ui/src/pierre")}/$1.ts` }, + { find: /^@opencode-ai\/ui\/hooks$/, replacement: workspacePath("ui/src/hooks/index.ts") }, + { find: /^@opencode-ai\/ui\/context$/, replacement: workspacePath("ui/src/context/index.ts") }, + { find: /^@opencode-ai\/ui\/context\/(.+)$/, replacement: `${workspacePath("ui/src/context")}/$1.tsx` }, + { find: /^@opencode-ai\/ui\/styles$/, replacement: workspacePath("ui/src/styles/index.css") }, + { find: /^@opencode-ai\/ui\/styles\/tailwind$/, replacement: workspacePath("ui/src/styles/tailwind/index.css") }, + { find: /^@opencode-ai\/ui\/theme$/, replacement: workspacePath("ui/src/theme/index.ts") }, + { find: /^@opencode-ai\/ui\/theme\/context$/, replacement: workspacePath("ui/src/theme/context.tsx") }, + { find: /^@opencode-ai\/ui\/theme\/(.+)$/, replacement: `${workspacePath("ui/src/theme")}/$1.ts` }, + { find: /^@opencode-ai\/ui\/icons\/provider$/, replacement: workspacePath("ui/src/components/provider-icons/types.ts") }, + { find: /^@opencode-ai\/ui\/icons\/file-type$/, replacement: workspacePath("ui/src/components/file-icons/types.ts") }, + { find: /^@opencode-ai\/ui\/icons\/app$/, replacement: workspacePath("ui/src/components/app-icons/types.ts") }, + { find: /^@opencode-ai\/ui\/fonts\/(.+)$/, replacement: `${workspacePath("ui/src/assets/fonts")}/$1` }, + { find: /^@opencode-ai\/ui\/audio\/(.+)$/, replacement: `${workspacePath("ui/src/assets/audio")}/$1` }, + { find: /^@opencode-ai\/ui\/v2\/styles\/(.+)$/, replacement: `${workspacePath("ui/src/v2/styles")}/$1` }, + { find: /^@opencode-ai\/ui\/v2\/(.+)\.css$/, replacement: `${workspacePath("ui/src/v2/components")}/$1.css` }, + { find: /^@opencode-ai\/ui\/v2\/(.+)$/, replacement: `${workspacePath("ui/src/v2/components")}/$1.tsx` }, + { find: /^@opencode-ai\/ui\/(.+)$/, replacement: `${workspacePath("ui/src/components")}/$1` }, +] const channel = (() => { const raw = process.env.OPENCODE_CHANNEL @@ -83,6 +129,21 @@ export default defineConfig({ plugins: [appPlugin, sentry], publicDir: "../../../app/public", root: "src/renderer", + ...(copiedWorkspacePackages + ? { + resolve: { + alias: rendererWorkspaceAliases, + }, + optimizeDeps: { + exclude: ["@opencode-ai/app", "@opencode-ai/core", "@opencode-ai/sdk", "@opencode-ai/ui"], + }, + server: { + fs: { + allow: [repoRoot], + }, + }, + } + : {}), build: { sourcemap: true, rollupOptions: { diff --git a/packages/desktop/tsconfig.json b/packages/desktop/tsconfig.json index 9637fe03ddc1..fb9432681a7a 100644 --- a/packages/desktop/tsconfig.json +++ b/packages/desktop/tsconfig.json @@ -15,7 +15,17 @@ "noEmit": true, "emitDeclarationOnly": false, "outDir": "node_modules/.ts-dist", - "types": ["vite/client", "node", "electron"] + "types": ["vite/client", "node", "electron"], + "paths": { + "@/*": ["../app/src/*"], + "@opencode-ai/app": ["../app/src/index.ts"], + "@opencode-ai/app/desktop-menu": ["../app/src/desktop-menu.ts"], + "@opencode-ai/app/updater": ["../app/src/updater.ts"], + "@opencode-ai/app/wsl/types": ["../app/src/wsl/types.ts"], + "@opencode-ai/app/*": ["../app/src/*"], + "@opencode-ai/ui": ["../ui/src/index.ts"], + "@opencode-ai/ui/*": ["../ui/src/*"] + } }, "references": [{ "path": "../app" }], "include": ["src", "package.json"],