Skip to content
Open
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
46 changes: 46 additions & 0 deletions packages/app/src/components/session-action-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { JSX } from "solid-js"
import { Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { useLanguage } from "@/context/language"

export function SessionActionMenu(props: {
trigger: JSX.Element
open: boolean
onOpenChange: (open: boolean) => void
onCloseAutoFocus?: () => boolean
shareEnabled: boolean
onRename: () => void
onShare: () => void
onExport: () => void
onArchive: () => void
onDelete: () => void
}) {
const language = useLanguage()
return (
<MenuV2
gutter={6}
placement="bottom-end"
open={props.open}
onOpenChange={props.onOpenChange}
>
{props.trigger}
<MenuV2.Portal>
<MenuV2.Content
style={{ width: "120px", "min-width": "120px" }}
onCloseAutoFocus={(event) => {
if (props.onCloseAutoFocus?.()) event.preventDefault()
}}
>
<MenuV2.Item onSelect={props.onRename}>{language.t("common.rename")}</MenuV2.Item>
<Show when={props.shareEnabled}>
<MenuV2.Item onSelect={props.onShare}>{language.t("session.share.action.share")}...</MenuV2.Item>
</Show>
<MenuV2.Item onSelect={props.onExport}>{language.t("common.export")}...</MenuV2.Item>
<MenuV2.Item onSelect={props.onArchive}>{language.t("common.archive")}</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={props.onDelete}>{language.t("common.delete")}...</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
)
}
32 changes: 32 additions & 0 deletions packages/app/src/components/session-delete-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"

export function SessionDeleteDialog(props: { name: string; onDelete: () => Promise<boolean> }) {
const dialog = useDialog()
const language = useLanguage()
return (
<DialogV2 fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: props.name })}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2
variant="danger"
onClick={() => {
void props.onDelete().then((ok) => { if (ok) dialog.close() })
}}
>
{language.t("session.delete.button")}
</ButtonV2>
</DialogFooter>
</DialogV2>
)
}
65 changes: 65 additions & 0 deletions packages/app/src/components/session-share-popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Popover } from "@kobalte/core/popover"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Show } from "solid-js"
import { useLanguage } from "@/context/language"

export function SessionSharePopover(props: {
open: boolean
anchor: () => HTMLElement | undefined
url?: string
publishing?: boolean
unpublishing?: boolean
onOpenChange: (open: boolean) => void
onPublish: () => void
onUnpublish: () => void
onCopy: () => void
onView: () => void
}) {
const language = useLanguage()
return (
<Popover open={props.open} anchorRef={props.anchor} placement="bottom-end" gutter={6} modal={false} onOpenChange={props.onOpenChange}>
<Popover.Portal>
<Popover.Content class="flex w-80 max-w-none flex-col items-start gap-3 rounded-[10px] border-0 bg-v2-background-bg-layer-01 p-3 shadow-[var(--v2-elevation-floating)]">
<div class="flex w-full flex-col gap-1.5 px-0.5 pt-0.5">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base [font-variation-settings:'slnt'_0]">
{language.t("session.share.popover.title")}
</div>
<div class="select-none text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-variation-settings:'slnt'_0]">
{props.url
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")}
</div>
</div>
<div class="flex w-full flex-col gap-2">
<Show
when={props.url}
fallback={
<ButtonV2 variant="contrast" class="w-full" onClick={props.onPublish} disabled={props.publishing}>
{props.publishing ? language.t("session.share.action.publishing") : language.t("session.share.action.publish")}
</ButtonV2>
}
>
<div class="flex flex-col gap-2">
<div
class="flex h-8 w-full items-center gap-1.5 rounded-[6px] py-1 pl-2.5 pr-1.5 shadow-[var(--v2-elevation-button-neutral)]"
style={{ background: "linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-button-neutral)" }}
>
<div class="min-w-0 flex-1 truncate select-text cursor-text text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-variation-settings:'slnt'_0]">
{props.url}
</div>
<IconButtonV2 type="button" size="small" variant="ghost-muted" icon={<IconV2 name="outline-copy" />} aria-label={language.t("session.share.copy.copyLink")} onClick={props.onCopy} />
<IconButtonV2 type="button" size="small" variant="ghost-muted" icon={<IconV2 name="outline-square-arrow" />} aria-label={language.t("session.share.action.view")} onClick={props.onView} disabled={props.unpublishing} />
</div>
<ButtonV2 variant="outline" class="w-full" onClick={props.onUnpublish} disabled={props.unpublishing}>
{props.unpublishing ? language.t("session.share.action.unpublishing") : language.t("session.share.action.unpublish")}
</ButtonV2>
</div>
</Show>
</div>
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
7 changes: 2 additions & 5 deletions packages/app/src/components/titlebar-tab-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { showToast } from "@/utils/toast"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
import type { Session } from "@opencode-ai/sdk/v2"
import { createSessionMutation } from "@/utils/session-mutation"

function SessionTabSlot(props: {
tab: SessionTab
Expand Down Expand Up @@ -103,13 +104,9 @@ function SessionTabEntry(props: {
const ctx = props.serverCtx()
if (!value || !ctx) return

ctx.sync.session.remember({ ...value, title })
try {
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
await createSessionMutation({ client: ctx.sdk.client, serverSync: ctx.sync }).rename(value, title)
} catch (err) {
const current = session()
const currentCtx = props.serverCtx()
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
showToast({
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ describe("Home V2 session index", () => {
cache.remove("a")

expect(queryClient.getQueryData(cache.indexKey)).toBeUndefined()
expect(cache.sessions({ sessions, eventSequence: 0 }, undefined).map((item) => item.id)).toEqual(["b"])
expect(cache.sessions({ sessions, eventSequence: 0 }, queryClient.getQueryData(cache.eventsKey)).map((item) => item.id)).toEqual([
"b",
])
})
})
49 changes: 29 additions & 20 deletions packages/app/src/context/global-sync/home-session-index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
import type { QueryClient } from "@tanstack/solid-query"
import { createStore, reconcile } from "solid-js/store"
import { trimSessions } from "./session-trim"
import { pathKey } from "@/utils/path-key"

export const HOME_V2_SESSION_PAGE_LIMIT = 5_000

export type HomeSessionEvent = {
type: "session.created" | "session.updated" | "session.deleted"
properties: { sessionID: string; info: Session }
properties: { sessionID: string; info?: Session }
}
export type HomeSessionEvents = {
sequence: number
Expand Down Expand Up @@ -85,11 +86,18 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st
const indexKey = homeSessionIndexKey(server)
const eventsKey = homeSessionEventsKey(server)
let connected = false
const removed = new Set<string>()

const [state, setState] = createStore({
sessions: [] as Session[],
ready: false,
})

return {
indexKey,
eventsKey,
get state() {
return state
},
eventSequence() {
return queryClient.getQueryData<HomeSessionEvents>(eventsKey)?.sequence ?? 0
},
Expand All @@ -98,35 +106,31 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st
queryClient.setQueryData<HomeSessionEvents>(eventsKey, (current) => trimHomeSessionEvents(current, sequence))
},
sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
const sessions = homeSessionIndexSessions(index, events)
return removed.size === 0 ? sessions : sessions.filter((session) => !removed.has(session.id))
return state.ready ? state.sessions : homeSessionIndexSessions(index, events)
},
setInitial(sessions: Session[]) {
const currentEvents = queryClient.getQueryData<HomeSessionEvents>(eventsKey)
const merged = homeSessionIndexSessions({ sessions, eventSequence: 0 }, currentEvents)
setState({ sessions: merged, ready: true })
queryClient.setQueryData<HomeSessionIndex>(indexKey, { sessions: merged, eventSequence: currentEvents?.sequence ?? 0 })
},
apply(event: HomeSessionEvent) {
if (!queryClient.getQueryState(indexKey)) return
const next = appendHomeSessionEvent(queryClient.getQueryData<HomeSessionEvents>(eventsKey), event)
if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) {
queryClient.setQueryData(eventsKey, next)
return
queryClient.setQueryData(eventsKey, next)
if (state.ready) {
const updated = applyHomeSessionEvent(state.sessions, event)
setState("sessions", reconcile(updated, { key: "id" }))
}

const index = queryClient.getQueryData<HomeSessionIndex>(indexKey)
if (index) {
queryClient.setQueryData<HomeSessionIndex>(indexKey, {
sessions: homeSessionIndexSessions(index, next),
eventSequence: next.sequence,
})
}
queryClient.setQueryData<HomeSessionEvents>(eventsKey, { sequence: next.sequence, entries: [] })
},
remove(sessionID: string) {
removed.add(sessionID)
if (!queryClient.getQueryState(indexKey)) return
queryClient.setQueryData<HomeSessionIndex>(indexKey, (index) => {
if (!index) return index
const at = index.sessions.findIndex((session) => session.id === sessionID)
if (at === -1) return index
return { ...index, sessions: index.sessions.toSpliced(at, 1) }
})
this.apply({ type: "session.deleted", properties: { sessionID } })
},
refresh(event: Event["type"]) {
const result = homeSessionIndexRefresh(event, connected)
Expand Down Expand Up @@ -156,8 +160,13 @@ export function retainHomeSessions(sessions: Session[], limit: number, now: numb

export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
const info = event.properties.info
const index = sessions.findIndex((session) => session.id === info.id)
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
const sessionID = info?.id ?? event.properties.sessionID
const index = sessions.findIndex((session) => session.id === sessionID)
if (event.type === "session.deleted") {
if (index === -1) return sessions
return sessions.toSpliced(index, 1)
}
if (!info || info.parentID || typeof info.time.archived === "number") {
if (index === -1) return sessions
return sessions.toSpliced(index, 1)
}
Expand Down
23 changes: 13 additions & 10 deletions packages/app/src/context/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,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 { sessionHref } from "@/utils/session-route"
import { requireServerKey, sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
Expand Down Expand Up @@ -179,12 +179,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const actions = {
addSessionTab: (tab: Omit<SessionTab, "type">) => {
const next = { type: "session" as const, ...tab }
const existing = store.find((item) => tabKey(item) === tabKey(next))
const key = tabKey(next)
const existing = store.find((item) => tabKey(item) === key)
if (existing) return existing
if (closing.has(key)) return next
void startTransition(() => {
setStore(
produce((tabs) => {
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
if (closing.has(key)) return
if (tabs.some((item) => tabKey(item) === key)) return
tabs.push(next)
}),
)
Expand Down Expand Up @@ -298,17 +301,17 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
removeSessions: (input: SessionTabsRemovedDetail) => {
const targetServer = input.server ?? server.key
updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))
const removed = store
.filter(
(tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId),
)
.map(tabKey)
const removed = input.sessionIDs.map((sessionId) =>
tabKey({ type: "session", server: targetServer, sessionId }),
)
removed.forEach((key) => closing.add(key))
void startTransition(() => {
setStore(
produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs)
const routeServer = params.serverKey ? requireServerKey(params.serverKey) : server.key
const currentHref =
targetServer === server.key && params.dir && params.id
params.id && routeServer === targetServer
? tabHref({
type: "session",
server: targetServer,
Expand Down Expand Up @@ -343,7 +346,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}),
)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
})
}).finally(() => removed.forEach((key) => closing.delete(key)))
for (const key of removed) memory.remove(key)
for (const key of removed) removeInfo(key)
},
Expand Down
Loading
Loading