Skip to content
Draft
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
21 changes: 21 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 43 additions & 0 deletions packages/app/e2e/extensions/context.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test"
import { readFile } from "node:fs/promises"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"

test("context opens independently, exports its session, and restores the closed panel", async ({ page }, info) => {
await page.setViewportSize({ width: 1440, height: 900 })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
})
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
const toggle = page.getByRole("button", { name: "View context usage", exact: true })
await toggle.click()
const context = page.getByRole("tabpanel", { name: "Context", exact: true })
await expect(context.getByText("Total Tokens", { exact: true })).toBeVisible()
await expect(context.getByText(fixture.expected.sourceTitle, { exact: true })).toBeVisible()
await expect(context.getByText("Raw messages", { exact: true })).toBeVisible()
await page.screenshot({ path: info.outputPath("context-extension.png") })
const pending = page.waitForEvent("download")
await context.getByRole("button", { name: "Export session", exact: true }).click()
const download = await pending
const exported: { info: { id: string }; messages: { id: string }[] } = JSON.parse(
await readFile((await download.path())!, "utf8"),
)
expect(exported.info.id).toBe(fixture.sourceID)
expect(exported.messages.length).toBeGreaterThan(0)
expect(new Set(exported.messages.map((message) => message.id)).size).toBe(exported.messages.length)
await toggle.click()
await expect(page.getByRole("tab", { name: "Context", exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Toggle review", exact: true })).toHaveAttribute(
"aria-expanded",
"false",
)
await toggle.click()
await expect(context.getByText(fixture.expected.sourceTitle, { exact: true })).toBeVisible()
})
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-review-desktop": "workspace:*",
"@opencode/plugin-context-desktop": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/session-ui": "workspace:*",
"@opencode/ui": "workspace:*",
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/extensions/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { terminalFontFamily, useSettings } from "@/settings/model"
import { extensionTabKey } from "./keys"
import { showToast } from "@/shell/notifications/toast"
import { ReviewDesktop } from "@opencode/plugin-review-desktop"
import { ContextDesktop } from "@opencode/plugin-context-desktop"
import type { SessionServices } from "@opencode/plugin/desktop/workspace"

export type Contribution = Claim<{
Expand Down Expand Up @@ -98,7 +99,7 @@ function createHost() {
const workspaceRemoved = new Set<(value: { serverID: string; directory: string }) => void>()
const attempted = new WeakSet<Plugin.Definition>()
let instanceID = 0
const builtins = () => [ReviewDesktop, ...(platform.extensionPlugins ?? [])]
const builtins = () => [ReviewDesktop, ContextDesktop, ...(platform.extensionPlugins ?? [])]
createEffect(() => {
const manager = platform.extensionManager
if (!manager) return
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
@import "@opencode/ui/styles/tokens";
@import "tw-animate-css";
@source "../../plugin-review-desktop/src";
@source "../../plugin-context-desktop/src";

@font-face {
font-family: "JetBrainsMono Nerd Font Mono";
Expand Down
75 changes: 7 additions & 68 deletions packages/app/src/session/commands/export.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,7 @@
import type { SessionInfo, SessionMessageInfo } from "@opencode/client/promise"
import type { ServerApi } from "@/runtime/server/api"
import type { Platform } from "@/runtime/platform/platform"

export type SessionExportData = {
info: SessionInfo
messages: SessionMessageInfo[]
}

export async function fetchSessionExport(input: {
sessionID: string
api: Pick<ServerApi, "session" | "message">
}): Promise<SessionExportData> {
const [info, first] = await Promise.all([
input.api.session.get({ sessionID: input.sessionID }),
input.api.message.list({ sessionID: input.sessionID, limit: 200, order: "asc" }),
])
const pages = [first]

while (pages.at(-1)?.cursor.next) {
pages.push(
await input.api.message.list({
sessionID: input.sessionID,
limit: 200,
cursor: pages.at(-1)!.cursor.next ?? undefined,
}),
)
}

return {
info,
messages: pages.flatMap((page) => page.data),
}
}

export function sessionExportFilename(session: { id: string; title?: string; slug?: string }) {
const name = session.title || session.slug || session.id
const clean = name
.toLowerCase()
.replace(/[^a-z0-9_-]+/gi, "-")
.replace(/^-+|-+$/g, "")
return `${clean || session.id}.json`
}

export function downloadSessionExport(filename: string, data: unknown) {
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}

export async function saveSessionExport(
filename: string,
data: unknown,
platform: Pick<Platform, "saveFile">,
) {
if (!platform.saveFile) {
downloadSessionExport(filename, data)
return true
}
return platform.saveFile({ defaultPath: filename }, JSON.stringify(data, null, 2))
}
export {
fetchSessionExport,
saveSessionExport,
sessionExportFilename,
downloadSessionExport,
type SessionExportData,
} from "@opencode/plugin-context-desktop/export"
20 changes: 0 additions & 20 deletions packages/app/src/session/files/session-side-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { createSessionTabs, type Sizing } from "@/session/helpers"
import { useFile } from "@/workspaces/files/model"
import type { SessionExtensions } from "@/extensions/session"
import { ExtensionPanelContent } from "@/extensions/content"
import { SessionContextTab } from "./session-context-tab"

export function SessionSidePanel(props: {
extensions: SessionExtensions
Expand Down Expand Up @@ -106,20 +105,6 @@ export function SessionSidePanel(props: {
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
{extensions.toolbar()}
</div>
<Show when={state.contextOpen()}>
<Tabs.Trigger
value="context"
onMiddleClick={() => session.tabs().close("context")}
closeButton={
<Tabs.CloseButton
onClick={() => session.tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
}
>
{language.t("session.tab.context")}
</Tabs.Trigger>
</Show>
<For each={panelTabs()}>
{(key) => (
<Show when={extensions.panels().find((panel) => panel.key === key)}>
Expand Down Expand Up @@ -177,11 +162,6 @@ export function SessionSidePanel(props: {
</Show>
</div>
</div>
<Show when={state.activeTab() === "context"}>
<Tabs.Content value="context" class="h-full min-h-0 overflow-hidden">
<SessionContextTab />
</Tabs.Content>
</Show>
<ExtensionPanelContent panels={extensions.panels()} active={state.activeTab()} />
</Tabs>
</DragDropProvider>
Expand Down
8 changes: 5 additions & 3 deletions packages/app/src/session/mobile-view-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const MobilePanelDrawer = lazy(async () => {

export function SessionMobileViewTabs(props: {
current: string
items: readonly { id: string; title: string }[]
items: readonly { id: string; title: string; menu?: boolean }[]
onSelect(value: string): void
details?: (close: () => void) => JSX.Element
onDetailsOpenChange?: (open: boolean) => void
Expand All @@ -33,7 +33,7 @@ export function SessionMobileViewTabs(props: {
<Tabs.Trigger value="session" onClick={() => props.onSelect("session")}>
{language.t("session.tab.session")}
</Tabs.Trigger>
<For each={props.items}>
<For each={props.items.filter((item) => !item.menu)}>
{(item) => (
<Tabs.Trigger value={item.id} onClick={() => props.onSelect(item.id)}>
{item.title}
Expand All @@ -55,7 +55,9 @@ export function SessionMobileViewTabs(props: {
/>
<Menu.Portal>
<Menu.Content>
<Menu.Item onSelect={() => props.onSelect("usage")}>{language.t("session.tab.usage")}</Menu.Item>
<For each={props.items.filter((item) => item.menu)}>
{(item) => <Menu.Item onSelect={() => props.onSelect(item.id)}>{item.title}</Menu.Item>}
</For>
<Show when={props.details}>
<Menu.Item onSelect={() => setStore("details", true)}>{language.t("session.summary.title")}</Menu.Item>
</Show>
Expand Down
13 changes: 7 additions & 6 deletions packages/app/src/session/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import { createSessionScreenLayout } from "./screen-layout"
import { createSessionSummary } from "./summary"
import { SessionMobileViewTabs } from "./mobile-view-tabs"
import { SessionSidePanel } from "./files/session-side-panel"
import { SessionContextTab } from "./files/session-context-tab"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { createTimelineSearchController } from "./timeline/search-controller"
import { TimelineSearchBar } from "./timeline/search-bar"
Expand Down Expand Up @@ -144,11 +143,16 @@ export function SessionScreen(props: { session: SessionModel }) {
const summary = createSessionSummary(session)
const mobileView = createMemo(() => {
if (screen.terminal.open()) return "terminal"
if (store.mobileTab === "session" || store.mobileTab === "usage") return store.mobileTab
if (store.mobileTab === "session") return store.mobileTab
const selected = extensions.panels().find((panel) => panel.key === session.tabs.activeTab())
return selected?.props.group ?? selected?.key ?? store.mobileTab
})
const mobileItems = createMemo(() => Array.from(new Map(extensions.panels().map((panel) => [panel.props.group ?? panel.key, panel])).keys()).map((id) => ({ id, title: extensions.panels().find((panel) => (panel.props.group ?? panel.key) === id)!.props.title })))
const mobileItems = createMemo(() =>
Array.from(new Set(extensions.panels().map((panel) => panel.props.group ?? panel.key))).map((id) => {
const panel = extensions.panels().find((panel) => (panel.props.group ?? panel.key) === id)!
return { id, title: panel.props.title, menu: panel.props.initial === "closed" && !panel.props.group }
}),
)
const conversationVisible = createMemo(() => isDesktop() || mobileView() === "session")
createEffect(() => {
if (!isDesktop() && screen.terminal.open()) setStore("mobileTerminalCached", true)
Expand Down Expand Up @@ -254,9 +258,6 @@ export function SessionScreen(props: { session: SessionModel }) {
<Match when={!isDesktop() && mobileView() === "terminal"}>
<></>
</Match>
<Match when={!isDesktop() && mobileView() === "usage"}>
<SessionContextTab />
</Match>
<Match when={!isDesktop() && mobileItems().some((item) => item.id === mobileView())}>
<ExtensionPanelContent panels={extensions.panels()} active={session.tabs.activeTab()} />
</Match>
Expand Down
2 changes: 0 additions & 2 deletions packages/app/src/session/timeline/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { ProjectAvatar } from "@opencode/ui/project-avatar"
import type { Project } from "@/runtime/server/types"
import { getFilename } from "@opencode/util/path"
import { Popover } from "@kobalte/core/popover"
import { SessionContextUsage } from "@/session/timeline/session-context-usage"
import { useLanguage } from "@/runtime/i18n/language"
import { useData, useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
Expand Down Expand Up @@ -795,7 +794,6 @@ function MessageTimelineView(
{(id) => (
<div class="shrink-0 flex items-center gap-2">
{props.search}
<SessionContextUsage placement="bottom" />
{props.headerActions}
<Show when={!parentID() && project()}>
{(project) => (
Expand Down
Loading
Loading